Skip to content

Commit 96b5d13

Browse files
committed
Merge remote-tracking branch 'origin/main' into inflection
2 parents fb2a0ff + 3b535b1 commit 96b5d13

19 files changed

Lines changed: 1321 additions & 28 deletions

.gitignore

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,11 @@ mise.toml
4343
!/data/inflection/test/
4444
!/priv/localize/locales/und.etf
4545

46+
# Inflection data (sources and generated ETFs) — belongs to the
47+
# inflection feature branch, not main
48+
/data/inflection/
49+
/priv/localize/inflection/
50+
4651
# Experiment files
4752
bench_unit_parser.sh
4853

CHANGELOG.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,18 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
1414

1515
* The BCP 47 validity data generator no longer crashes on deprecated timezone codes (those with a preferred replacement, such as `camtr``cator`); the generated validity data is unchanged, but it can once again be regenerated from source.
1616

17+
* `Localize.Unit.parse/2` resolves unit symbols that map to compound identifiers — `"1 m/s"``meter-per-second`, `"3 kWh"``kilowatt-hour` — instead of failing on the internal underscore key form ([#42](https://github.com/elixir-localize/localize/issues/42)).
18+
19+
* Times-compound units with no precomposed CLDR pattern (`tonne-kilometer`) now format as their localized name (`"5 metric ton-kilometers"`, `"5 t⋅km"`, `"5 tonnes-kilomètres"`) instead of the raw identifier, composed per the CLDR grammatical derivation loaded from `grammaticalFeatures.xml` ([#43](https://github.com/elixir-localize/localize/issues/43)).
20+
21+
* Per-compound units whose denominator has no per-unit pattern now localize through the locale's `compound.per` pattern (Polish `"5 jardów na milimetr"`) instead of falling back to an English `"per"`.
22+
23+
* `Localize.Unit.define_unit/2` accepts a derived unit as `base_unit` (e.g. `"day"`) and folds it to its fundamental base at registration, so the custom unit is convertible and `compatible?/2` returns `true` ([#44](https://github.com/elixir-localize/localize/issues/44)).
24+
25+
* Person-duration units (`year-person`, `month-person`, `week-person`, `day-person`) format as their base unit (`"5 years"`), matching ICU, instead of the raw identifier.
26+
27+
* SI-prefixed units with no precomposed CLDR pattern (`megajoule`, `gigajoule`, `nanogram`) now compose their display from the prefix and base unit (`"5 MJ"`, `"5 megajoules"`, with CLDR `combineLowercasing` for capitalising locales) instead of the raw identifier ([#46](https://github.com/elixir-localize/localize/issues/46)).
28+
1729
### Changed
1830

1931
* Generated locale ETF files and the download-integrity hash manifest are now serialized with the `term_to_binary` `:deterministic` option, making the bytes reproducible across operating systems, architectures, and OTP versions. This lets the manifest generated during development match the data generated in CI and served from the CDN, so runtime download verification succeeds regardless of where each was produced. All bundled supplemental, validity, and collation ETF data is regenerated with the same option.

data/data.ex

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,7 @@ defmodule Localize.Data do
6868
{"common/supplemental/pluralRanges.xml", "pluralRanges.xml"},
6969
{"common/supplemental/subdivisions.xml", "subdivisions.xml"},
7070
{"common/supplemental/units.xml", "units.xml"},
71+
{"common/supplemental/grammaticalFeatures.xml", "grammaticalFeatures.xml"},
7172
{"common/bcp47/timezone.xml", "bcp47_timezone.xml"}
7273
]
7374

@@ -123,6 +124,8 @@ defmodule Localize.Data do
123124
{"territory_subdivision_containment.etf",
124125
&Localize.Data.XmlExtractors.generate_territory_subdivision_containment/0},
125126
{"unit_data.etf", &Localize.Data.XmlExtractors.generate_unit_data/0},
127+
{"unit_grammatical_derivations.etf",
128+
&Localize.Data.XmlExtractors.generate_unit_grammatical_derivations/0},
126129
{"collation_tailoring.etf", &Localize.Data.Collation.generate_collation_tailoring/0},
127130
{"coverage_levels.etf", &Localize.Data.Supplemental.generate_coverage_levels/0},
128131
{"measurement_systems.etf", &Localize.Data.XmlExtractors.generate_measurement_systems/0},

data/xml_extractors.ex

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -319,6 +319,103 @@ defmodule Localize.Data.XmlExtractors do
319319
}
320320
end
321321

322+
@doc """
323+
Generates the compound-unit grammatical-derivation table from
324+
`grammaticalFeatures.xml`.
325+
326+
CLDR derives the plural category, grammatical case, and gender of a
327+
compound unit's components from the compound as a whole (TR35
328+
"Compound Units"). For example the default "times" derivation makes
329+
the leading component singular and lets the trailing component carry
330+
the count (`newton-meters`), while French pluralizes every component
331+
(`tonnes-kilomètres`). The unit formatter reads this table to compose
332+
the localized name of a compound unit that has no precomposed pattern.
333+
334+
Returns a map keyed by the base language subtag string (plus `"root"`
335+
for the default), each value a map of the form:
336+
337+
%{
338+
plural: %{times: {:one, :compound}, per: {:compound, :one}, ...},
339+
case: %{times: {:nominative, :compound}, ...},
340+
gender: %{times: 1, per: 0, ...}
341+
}
342+
343+
For `deriveComponent` features (`:plural`, `:case`) the value is a
344+
`{value0, value1}` tuple where `:compound` means "use the compound's
345+
own category" and any other atom is a fixed category. For the
346+
`deriveCompound` `:gender` feature the value is the `0`/`1` index of
347+
the component whose gender the compound inherits. Each locale's block
348+
is merged over `"root"` so per-feature fallbacks are already resolved.
349+
350+
"""
351+
def generate_unit_grammatical_derivations do
352+
raw =
353+
"grammatical_features.xml"
354+
|> read_xml()
355+
|> SweetXml.parse()
356+
|> xpath(~x"//grammaticalDerivations"l,
357+
locales: ~x"./@locales"s,
358+
components: [
359+
~x"./deriveComponent"l,
360+
feature: ~x"./@feature"s,
361+
structure: ~x"./@structure"s,
362+
value0: ~x"./@value0"s,
363+
value1: ~x"./@value1"s
364+
],
365+
compounds: [
366+
~x"./deriveCompound"l,
367+
feature: ~x"./@feature"s,
368+
structure: ~x"./@structure"s,
369+
value: ~x"./@value"s
370+
]
371+
)
372+
|> Map.new(fn %{locales: locales, components: components, compounds: compounds} ->
373+
{locales, build_derivation_map(components, compounds)}
374+
end)
375+
376+
root = Map.get(raw, "root", %{})
377+
378+
raw
379+
|> Enum.flat_map(fn {locales, derivations} ->
380+
merged = deep_merge_derivations(root, derivations)
381+
for locale <- String.split(locales), do: {locale, merged}
382+
end)
383+
|> Map.new()
384+
end
385+
386+
# Folds a block's `deriveComponent`/`deriveCompound` rows into the
387+
# nested `%{feature => %{structure => value}}` shape.
388+
defp build_derivation_map(components, compounds) do
389+
from_components =
390+
Enum.reduce(components, %{}, fn %{feature: feature, structure: structure} = row, acc ->
391+
value = {derivation_atom(row.value0), derivation_atom(row.value1)}
392+
put_derivation(acc, feature, structure, value)
393+
end)
394+
395+
Enum.reduce(compounds, from_components, fn %{feature: feature, structure: structure} = row,
396+
acc ->
397+
put_derivation(acc, feature, structure, String.to_integer(row.value))
398+
end)
399+
end
400+
401+
defp put_derivation(acc, feature, structure, value) do
402+
feature = String.to_atom(feature)
403+
structure = String.to_atom(structure)
404+
Map.update(acc, feature, %{structure => value}, &Map.put(&1, structure, value))
405+
end
406+
407+
defp derivation_atom(""), do: nil
408+
defp derivation_atom(value), do: String.to_atom(value)
409+
410+
# Merges a locale block over `root` at the feature level, so a locale
411+
# that overrides only some features (French overrides plural but not
412+
# case) inherits the rest from root.
413+
defp deep_merge_derivations(root, override) do
414+
Map.merge(root, override, fn _feature, root_structures, override_structures ->
415+
Map.merge(root_structures, override_structures)
416+
end)
417+
end
418+
322419
@doc """
323420
Generates measurement system data from `bcp47/measure.xml`.
324421
@@ -453,6 +550,7 @@ defmodule Localize.Data.XmlExtractors do
453550
"bcp47/timezone.xml" => {:bcp47, "timezone.xml"},
454551
"bcp47/measure.xml" => {:bcp47, "measure.xml"},
455552
"units.xml" => {:supplemental, "units.xml"},
553+
"grammatical_features.xml" => {:supplemental, "grammaticalFeatures.xml"},
456554
"validity/unit.xml" => {:validity, "unit.xml"}
457555
}
458556

lib/localize/supplemental_data.ex

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,16 @@ defmodule Localize.SupplementalData do
3939
load_supplemental("likely_subtags.etf")
4040
end
4141

42+
@doc false
43+
# The CLDR compound-unit grammatical-derivation table, keyed by base
44+
# language subtag string (with `"root"` as the default). Read by the
45+
# unit formatter to derive each component's plural/case when composing
46+
# a compound unit that has no precomposed pattern.
47+
@spec unit_grammatical_derivations() :: %{String.t() => map()}
48+
def unit_grammatical_derivations do
49+
load_supplemental("unit_grammatical_derivations.etf")
50+
end
51+
4252
@doc false
4353
@spec aliases() :: map()
4454
def aliases do

lib/localize/unit.ex

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -490,6 +490,16 @@ defmodule Localize.Unit do
490490
* `{:error, reason}` if the unit has no value, the target cannot
491491
be parsed, or the units are not convertible.
492492
493+
### Value type
494+
495+
The converted value is a `Decimal` when the source value is a `Decimal`
496+
(results stay exact) and a float otherwise. Integer and float values
497+
both yield a float, because unit conversion is real-valued — an integer
498+
input cannot represent a converted quantity such as `1` mile in
499+
kilometers, so promoting to float is preferred over integer arithmetic,
500+
which would silently lose precision. Pass a `Decimal` value when you
501+
need an exact result.
502+
493503
### Examples
494504
495505
iex> {:ok, meters} = Localize.Unit.new(1, "kilometer")
@@ -499,6 +509,11 @@ defmodule Localize.Unit do
499509
iex> result.name
500510
"meter"
501511
512+
iex> {:ok, tonnes} = Localize.Unit.new(Decimal.new(1), "tonne")
513+
iex> {:ok, result} = Localize.Unit.convert(tonnes, "kilogram")
514+
iex> match?(%Decimal{}, result.value)
515+
true
516+
502517
"""
503518
@spec convert(t(), String.t()) :: {:ok, t()} | {:error, Exception.t()}
504519

lib/localize/unit/custom_registry.ex

Lines changed: 73 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,12 @@ defmodule Localize.Unit.CustomRegistry do
1111
1212
Each definition is a map with the following keys:
1313
14-
* `:base_unit` (required) — the CLDR base unit this custom unit converts to
15-
(e.g., `"meter"`, `"kilogram"`, `"second"`).
14+
* `:base_unit` (required) — the CLDR unit this custom unit converts to
15+
(e.g., `"meter"`, `"kilogram"`, `"second"`). It need not be a
16+
fundamental base unit: a derived unit such as `"day"` is accepted and
17+
folded down to its fundamental base (`"second"`) at registration, with
18+
the factor and offset adjusted accordingly, so the custom unit stays
19+
convertible against every CLDR unit in the same category.
1620
1721
* `:factor` (required) — the conversion factor:
1822
`1 custom_unit = factor * base_unit`.
@@ -136,9 +140,10 @@ defmodule Localize.Unit.CustomRegistry do
136140
def register(name, definition) do
137141
with :ok <- validate_name(name),
138142
:ok <- validate_definition(definition),
139-
:ok <- validate_no_collision(name) do
143+
:ok <- validate_no_collision(name),
144+
{:ok, normalized} <- normalize_base(definition) do
140145
current = all()
141-
:persistent_term.put(@persistent_term_key, Map.put(current, name, definition))
146+
:persistent_term.put(@persistent_term_key, Map.put(current, name, normalized))
142147
:ok
143148
end
144149
end
@@ -180,8 +185,9 @@ defmodule Localize.Unit.CustomRegistry do
180185
Enum.reduce(definitions, %{}, fn {name, definition}, acc ->
181186
with :ok <- validate_name(name),
182187
:ok <- validate_definition(definition),
183-
:ok <- validate_no_collision(name) do
184-
Map.put(acc, name, definition)
188+
:ok <- validate_no_collision(name),
189+
{:ok, normalized} <- normalize_base(definition) do
190+
Map.put(acc, name, normalized)
185191
else
186192
{:error, _reason} -> acc
187193
end
@@ -329,6 +335,67 @@ defmodule Localize.Unit.CustomRegistry do
329335
:ok
330336
end
331337

338+
# ── Base-unit normalization ──
339+
340+
# A custom unit's `:base_unit` must be a fundamental CLDR base unit,
341+
# because compatibility and conversion compare fully-reduced base
342+
# units: `compatible?/2` succeeds only when both units reduce to the
343+
# same base. When a definition is expressed in terms of a *derived*
344+
# unit (e.g. `%{base_unit: "day", factor: 1}`), storing "day"
345+
# verbatim leaves the custom unit reducing to "day" while every CLDR
346+
# unit reduces "day" to "second" — so the two never match.
347+
#
348+
# Fold the derived unit's own conversion into the custom factor and
349+
# offset so the stored base becomes the fundamental base with an
350+
# equivalent factor: `guestnight` given as `base_unit: "day",
351+
# factor: 1` is stored as `base_unit: "second", factor: 86400.0`.
352+
# A definition already expressed against a fundamental base is stored
353+
# unchanged, and `:special` (function-based) definitions — which do
354+
# not convert via a linear factor — are left untouched.
355+
defp normalize_base(%{factor: factor} = definition) when is_number(factor) do
356+
base = definition.base_unit
357+
358+
case Localize.Unit.BaseUnit.base_unit(base) do
359+
{:ok, ^base} ->
360+
{:ok, definition}
361+
362+
{:ok, true_base} ->
363+
with {:ok, base_factor, base_offset} <- base_conversion(base, true_base) do
364+
offset = Map.get(definition, :offset, 0.0)
365+
366+
{:ok,
367+
definition
368+
|> Map.put(:base_unit, true_base)
369+
|> Map.put(:factor, factor * base_factor)
370+
|> Map.put(:offset, offset * base_factor + base_offset)}
371+
end
372+
373+
{:error, _reason} ->
374+
{:error, "unknown base unit: #{inspect(base)}"}
375+
end
376+
end
377+
378+
defp normalize_base(definition), do: {:ok, definition}
379+
380+
# The linear parameters `{factor, offset}` that convert one unit of
381+
# `base` into `true_base`, such that
382+
# `value_in_true_base = value_in_base * factor + offset`. For a simple
383+
# derived unit CLDR stores these directly; for a compound derived unit
384+
# they are recovered from two reference conversions (the offset is the
385+
# image of zero, the factor the difference across one unit).
386+
defp base_conversion(base, true_base) do
387+
case Localize.Unit.Data.conversion_factor_raw(base) do
388+
%{factor: factor, offset: offset} when is_number(factor) ->
389+
{:ok, factor, offset}
390+
391+
_other ->
392+
with {:ok, at_zero} <- Localize.Unit.Conversion.convert(0, base, true_base),
393+
{:ok, at_one} <- Localize.Unit.Conversion.convert(1, base, true_base) do
394+
{:ok, at_one - at_zero, at_zero}
395+
end
396+
end
397+
end
398+
332399
# ── Validation ──
333400

334401
@name_pattern ~r/^[a-z][a-z0-9_-]*$/

0 commit comments

Comments
 (0)