Skip to content

Commit 3caa8e4

Browse files
committed
Add humanize: true option to Localize.Unit.to_string/2, covering bit, byte, hertz and watt bases
The option renders any unit in the unit people expect for its magnitude: bit-, byte-, hertz- and watt-based units scale through the humanize/2 prefix ladder (system: :si or :iec, IEC restricted to bits and bytes), and all other units render through the usage-based preference pipeline with the struct's usage or :default. An explicit :usage option keeps precedence. humanize/2 and humanize!/2 gain the hertz and watt bases as well.
1 parent f309cd1 commit 3caa8e4

6 files changed

Lines changed: 251 additions & 44 deletions

File tree

CHANGELOG.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,14 @@ All notable changes to this project will be documented in this file.
44

55
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
66

7+
## [Unreleased]
8+
9+
### Added
10+
11+
* `Localize.Unit.to_string/2` accepts `humanize: true` to render any unit in the unit people expect for its magnitude: bit-, byte-, hertz- and watt-based units scale through the `humanize/2` prefix ladder (honouring `system: :si | :iec`) and all other units render through usage-based preferences. An explicit `:usage` option keeps precedence for preference-scaled units.
12+
13+
* `Localize.Unit.humanize/2` and `humanize!/2` also scale hertz- and watt-based units through the SI prefix ladder ("2.5 gigahertz", "3.2 gigawatts") — like bits and bytes, these are locale-invariant quantities that CLDR's unit preferences do not cover. IEC prefixes remain restricted to bit- and byte-based units.
14+
715
## [0.47.0] — July 7th, 2026
816

917
### Added

cheatsheets/unit_formatting.md

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -123,22 +123,32 @@ result.name #=> "mile"
123123
| `:us` | United States | mile, pound, fahrenheit |
124124
| `:uk` | United Kingdom | mile, stone, celsius |
125125

126-
### Humanizing digital units (file sizes)
126+
### Humanizing (auto-scaling to the expected unit)
127127

128128
```elixir
129+
# One call for any unit kind
129130
Localize.Unit.new!(1_500_000, "byte")
130-
|> Localize.Unit.humanize!()
131-
|> Localize.Unit.to_string!(format: :narrow)
131+
|> Localize.Unit.to_string!(humanize: true, format: :narrow)
132132
#=> "1.5MB"
133133

134+
Localize.Unit.new!(2_500_000, "hertz")
135+
|> Localize.Unit.to_string!(humanize: true, format: :narrow)
136+
#=> "2.5MHz"
137+
138+
Localize.Unit.new!(1_500, "meter")
139+
|> Localize.Unit.to_string!(humanize: true, locale: :de)
140+
#=> "1,5 Kilometer"
141+
142+
# Struct-level scaling for bit/byte/hertz/watt units
134143
{:ok, unit} = Localize.Unit.new(1_048_576, "byte")
135144
{:ok, humanized} = Localize.Unit.humanize(unit, system: :iec)
136145
{humanized.name, humanized.value} #=> {"mebibyte", 1.0}
137146
```
138147

139148
| Option | Values | Notes |
140149
|---|---|---|
141-
| `:system` | `:si` (default), `:iec` | `:si` scales by 1000 (kilobyte, megabyte, ...); `:iec` by 1024 (kibibyte, mebibyte, ...) |
150+
| `:humanize` | `true`, `false` (default) | On `to_string/2`: bit/byte/hertz/watt units scale through the prefix ladder, all others use usage-based preferences |
151+
| `:system` | `:si` (default), `:iec` | `:si` scales by 1000 (kilobyte, megahertz, ...); `:iec` by 1024 (kibibyte, mebibyte, ...) — bits and bytes only |
142152

143153
## Arithmetic
144154

guides/unit_formatting.md

Lines changed: 33 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -229,15 +229,20 @@ iex> {:ok, m} = Localize.Unit.new(1, "meter")
229229
iex> {:error, _} = Localize.Unit.convert(m, "kilogram")
230230
```
231231

232-
## Humanizing digital units
232+
## Humanizing prefix-scaled units
233233

234-
`Localize.Unit.humanize/2` converts a byte- or bit-based unit to the prefixed unit that best fits its magnitude — the conventional way to display file sizes. It selects the largest prefix such that the converted value is at least 1:
234+
`Localize.Unit.humanize/2` converts a bit-, byte-, hertz- or watt-based unit to the prefixed unit that best fits its magnitude — the conventional way to display file sizes, frequencies and power figures. It selects the largest prefix such that the converted value is at least 1:
235235

236236
```elixir
237237
iex> {:ok, unit} = Localize.Unit.new(1_500_000, "byte")
238238
iex> {:ok, humanized} = Localize.Unit.humanize(unit)
239239
iex> {humanized.name, humanized.value}
240240
{"megabyte", 1.5}
241+
242+
iex> {:ok, unit} = Localize.Unit.new(2_500_000_000, "hertz")
243+
iex> {:ok, humanized} = Localize.Unit.humanize(unit)
244+
iex> {humanized.name, humanized.value}
245+
{"gigahertz", 2.5}
241246
```
242247

243248
Combined with the `:narrow` format width this produces compact file sizes:
@@ -254,7 +259,7 @@ iex> Localize.Unit.new!(2_750_000_000, "byte")
254259
"2.8GB"
255260
```
256261

257-
The `:system` option selects the prefix ladder: `:si` (powers of 1000 — kilobyte, megabyte, gigabyte, the default) or `:iec` (powers of 1024 — kibibyte, mebibyte, gibibyte):
262+
The `:system` option selects the prefix ladder: `:si` (powers of 1000 — kilobyte, megabyte, gigabyte, the default) or `:iec` (powers of 1024 — kibibyte, mebibyte, gibibyte). IEC prefixes apply only to bit- and byte-based units:
258263

259264
```elixir
260265
iex> {:ok, unit} = Localize.Unit.new(1_048_576, "byte")
@@ -263,13 +268,37 @@ iex> {humanized.name, humanized.value}
263268
{"mebibyte", 1.0}
264269
```
265270

266-
Note that CLDR provides display patterns (like `"MB"` in the narrow width) only for SI-prefixed digital units; IEC units format with their full names in all widths. Values below one kilobyte (or kibibyte) are returned unchanged, and already-prefixed units are rescaled from their base value. Non-digital units return an error:
271+
Note that CLDR provides display patterns (like `"MB"` in the narrow width) only for SI-prefixed units; IEC units format with their full names in all widths. Values below one kilo-unit (or kibi-unit) are returned unchanged, and already-prefixed units are rescaled from their base value. Units with any other base return an error — physical quantities are covered by usage-based preferences instead (see below):
267272

268273
```elixir
269274
iex> {:ok, meters} = Localize.Unit.new(5, "meter")
270275
iex> {:error, _} = Localize.Unit.humanize(meters)
271276
```
272277

278+
### Humanizing any unit: `humanize: true`
279+
280+
The simplest way to render *any* unit in the unit people expect for its magnitude is the `:humanize` option on `to_string/2`:
281+
282+
```elixir
283+
iex> Localize.Unit.new!(1_500_000, "byte") |> Localize.Unit.to_string(humanize: true, format: :narrow)
284+
{:ok, "1.5MB"}
285+
286+
iex> Localize.Unit.new!(1_500, "meter") |> Localize.Unit.to_string(humanize: true, locale: :de)
287+
{:ok, "1,5 Kilometer"}
288+
289+
iex> Localize.Unit.new!(1_500_000, "gram") |> Localize.Unit.to_string(humanize: true, locale: :en)
290+
{:ok, "1.653 tons"}
291+
```
292+
293+
Underneath, the option dispatches by unit kind. Bit-, byte-, hertz- and watt-based units scale through the `humanize/2` prefix ladder above — the locale-invariant quantities that CLDR's unit-preference data does not cover ("kB", "MHz" and "GW" mean the same thing in every locale). Every other unit is rendered through the usage-based preference pipeline (see [Usage preferences](#usage-preferences) below) with the struct's usage or `:default`. CLDR unit preferences pick the display unit by territory *and* magnitude, which does more than prefix scaling could: grams scale to kilograms and then to tonnes (not to the technically-correct-but-unidiomatic megagram), and a US-locale reader sees miles, tons, feet and inches rather than SI units at all:
294+
295+
```elixir
296+
iex> Localize.Unit.new!(1_500, "meter") |> Localize.Unit.to_string(humanize: true, usage: :road, locale: "en-US")
297+
{:ok, "0.932 miles"}
298+
```
299+
300+
An explicit `:usage` option (as above) takes precedence for preference-scaled units, and is the ICU-compatible precise control — ICU triggers the same behaviour with the `usage()` setting on `NumberFormatter`. Use `humanize/2` directly when you want the scaled `%Localize.Unit{}` itself rather than a formatted string.
301+
273302
## Measurement system preferences
274303

275304
`Localize.Unit.convert_measurement_system/2` converts a unit to the preferred unit for a measurement system. CLDR defines preferences for three systems:

lib/localize/unit.ex

Lines changed: 107 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -591,6 +591,13 @@ defmodule Localize.Unit do
591591
end
592592
end
593593

594+
# Base units humanize/2 scales through the prefix ladder: the
595+
# locale-invariant SI-prefixed quantities that CLDR's unit
596+
# preference data does not cover. IEC binary prefixes are
597+
# meaningful only for the digital bases.
598+
@si_humanize_bases ~w(bit byte hertz watt)
599+
@iec_humanize_bases ~w(bit byte)
600+
594601
# Prefix ladders for humanize/2, largest factor first so the first
595602
# match is the prefix that scales the value into [1, 1000) or [1, 1024).
596603
@si_humanize_prefixes ~w(kilo mega giga tera peta exa zetta yotta)
@@ -607,37 +614,46 @@ defmodule Localize.Unit do
607614
|> Enum.reverse()
608615

609616
@doc """
610-
Converts a digital unit (bytes or bits) to the prefixed unit that
611-
best fits its magnitude, suitable for human-readable file sizes.
617+
Converts a bit-, byte-, hertz- or watt-based unit to the prefixed
618+
unit that best fits its magnitude — human-readable file sizes,
619+
frequencies and power figures.
620+
621+
Selects the largest SI prefix (kilobyte, megahertz, gigawatt, ...)
622+
or, for bits and bytes, IEC binary prefix (kibibyte, mebibyte, ...)
623+
such that the converted value is at least `1`. Values smaller than
624+
one kilo-unit (or kibi-unit) are returned unchanged.
612625
613-
Selects the largest SI prefix (kilobyte, megabyte, gigabyte, ...)
614-
or IEC binary prefix (kibibyte, mebibyte, gibibyte, ...) such that
615-
the converted value is at least `1`. Values smaller than one
616-
kilobyte (or kibibyte) are returned unchanged.
626+
These bases are the locale-invariant SI-prefixed quantities that
627+
CLDR's unit preference data does not cover. For physical
628+
quantities such as length or mass, use the `:usage` option on
629+
`to_string/2` instead — CLDR preferences pick the display unit by
630+
territory and magnitude.
617631
618632
### Arguments
619633
620634
* `unit` is a `%Localize.Unit{}` struct with a value, based on
621-
`"byte"` or `"bit"` (a bare or already-prefixed unit such as
622-
`"byte"`, `"kilobyte"` or `"megabit"`).
635+
`"bit"`, `"byte"`, `"hertz"` or `"watt"` (a bare or
636+
already-prefixed unit such as `"byte"`, `"kilobyte"` or
637+
`"megahertz"`).
623638
624639
* `options` is a keyword list of options.
625640
626641
### Options
627642
628643
* `:system` is the prefix system to scale with: `:si` (powers of
629-
1000, the default) or `:iec` (powers of 1024). Note that CLDR
630-
provides compact display patterns (like `"MB"`) only for
631-
SI-prefixed digital units; IEC units format with their full
632-
names in all format widths.
644+
1000, the default) or `:iec` (powers of 1024, bit- and
645+
byte-based units only). Note that CLDR provides compact display
646+
patterns (like `"MB"`) only for SI-prefixed units; IEC units
647+
format with their full names in all format widths.
633648
634649
### Returns
635650
636651
* `{:ok, unit}` where `unit` is a new `%Localize.Unit{}` with the
637652
scaled value and prefixed unit name, or
638653
639-
* `{:error, reason}` if the unit has no value, is not a digital
640-
(bit- or byte-based) unit, or the prefix system is invalid.
654+
* `{:error, reason}` if the unit has no value, has an unsupported
655+
base unit, or the prefix system is invalid or does not apply to
656+
the base unit.
641657
642658
### Examples
643659
@@ -648,6 +664,11 @@ defmodule Localize.Unit do
648664
iex> Localize.Unit.to_string(humanized, format: :narrow, locale: :en)
649665
{:ok, "1.5MB"}
650666
667+
iex> {:ok, unit} = Localize.Unit.new(2_500_000_000, "hertz")
668+
iex> {:ok, humanized} = Localize.Unit.humanize(unit)
669+
iex> {humanized.name, humanized.value}
670+
{"gigahertz", 2.5}
671+
651672
iex> {:ok, unit} = Localize.Unit.new(1_048_576, "byte")
652673
iex> {:ok, humanized} = Localize.Unit.humanize(unit, system: :iec)
653674
iex> {humanized.name, humanized.value}
@@ -665,23 +686,24 @@ defmodule Localize.Unit do
665686
system = Keyword.get(options, :system, :si)
666687

667688
with :ok <- validate_prefix_system(system),
668-
{:ok, base_name} <- digital_base_unit(unit),
689+
{:ok, base_name} <- humanizable_base_unit(unit),
690+
:ok <- validate_system_for_base(system, base_name),
669691
{:ok, base_unit} <- convert_unless_same(unit, base_name) do
670692
target_name = humanized_unit_name(base_unit.value, system, base_name)
671693
convert_unless_same(base_unit, target_name)
672694
end
673695
end
674696

675697
@doc """
676-
Converts a digital unit (bytes or bits) to the prefixed unit that
677-
best fits its magnitude, raising on error.
698+
Converts a bit-, byte-, hertz- or watt-based unit to the prefixed
699+
unit that best fits its magnitude, raising on error.
678700
679701
See `humanize/2` for details.
680702
681703
### Arguments
682704
683705
* `unit` is a `%Localize.Unit{}` struct with a value, based on
684-
`"byte"` or `"bit"`.
706+
`"bit"`, `"byte"`, `"hertz"` or `"watt"`.
685707
686708
* `options` is a keyword list of options. See `humanize/2`.
687709
@@ -690,8 +712,8 @@ defmodule Localize.Unit do
690712
* A new `%Localize.Unit{}` with the scaled value and prefixed
691713
unit name, or
692714
693-
* raises an exception if the unit has no value, is not a digital
694-
unit, or the prefix system is invalid.
715+
* raises an exception if the unit has no value, has an unsupported
716+
base unit, or the prefix system is invalid.
695717
696718
### Examples
697719
@@ -720,24 +742,34 @@ defmodule Localize.Unit do
720742
)}
721743
end
722744

723-
defp digital_base_unit(%__MODULE__{parsed: {:unit, parts}} = unit) do
745+
defp validate_system_for_base(:iec, base_name) when base_name not in @iec_humanize_bases do
746+
{:error,
747+
Localize.InvalidValueError.exception(
748+
value: base_name,
749+
expected: "a bit- or byte-based unit when system: :iec"
750+
)}
751+
end
752+
753+
defp validate_system_for_base(_system, _base_name), do: :ok
754+
755+
defp humanizable_base_unit(%__MODULE__{parsed: {:unit, parts}} = unit) do
724756
with [single_unit: single_unit] <- Keyword.get(parts, :numerator),
725757
[] <- Keyword.get(parts, :denominator),
726-
base when base in ["bit", "byte"] <- Keyword.get(single_unit, :base),
758+
base when base in @si_humanize_bases <- Keyword.get(single_unit, :base),
727759
power when power in [nil, 1] <- Keyword.get(single_unit, :power) do
728760
{:ok, base}
729761
else
730-
_other -> not_digital_error(unit)
762+
_other -> not_humanizable_error(unit)
731763
end
732764
end
733765

734-
defp digital_base_unit(%__MODULE__{} = unit), do: not_digital_error(unit)
766+
defp humanizable_base_unit(%__MODULE__{} = unit), do: not_humanizable_error(unit)
735767

736-
defp not_digital_error(unit) do
768+
defp not_humanizable_error(unit) do
737769
{:error,
738770
Localize.InvalidValueError.exception(
739771
value: unit.name,
740-
expected: "a bit- or byte-based unit",
772+
expected: "a bit-, byte-, hertz- or watt-based unit",
741773
context: "humanize"
742774
)}
743775
end
@@ -1008,6 +1040,15 @@ defmodule Localize.Unit do
10081040
* `:format` is `:long`, `:short`, or `:narrow`.
10091041
The default is `:long`.
10101042
1043+
* `:humanize` (single unit only) when `true` renders the unit in
1044+
the unit people expect for its magnitude: bit-, byte-, hertz-
1045+
and watt-based units are scaled through `humanize/2` (honouring
1046+
the `:system` option for `:si` or `:iec` prefixes), and all
1047+
other units are rendered through the usage-based preference
1048+
pipeline using the struct's `:usage` field or `:default`. An
1049+
explicit `:usage` option takes precedence for preference-scaled
1050+
units. The default is `false`.
1051+
10111052
* `:usage` (single unit only) is the unit's intended usage. When
10121053
given, triggers `localize/2` before formatting. Accepts an atom
10131054
(`:person_height`) or a CLDR-style string (`"person-height"`). If
@@ -1060,6 +1101,14 @@ defmodule Localize.Unit do
10601101
iex> Localize.Unit.to_string(unit, usage: :road, locale: "de-DE")
10611102
{:ok, "2 Kilometer"}
10621103
1104+
iex> Localize.Unit.new!(1_500_000, "byte")
1105+
...> |> Localize.Unit.to_string(humanize: true, format: :narrow)
1106+
{:ok, "1.5MB"}
1107+
1108+
iex> Localize.Unit.new!(1_500, "meter")
1109+
...> |> Localize.Unit.to_string(humanize: true, locale: :de)
1110+
{:ok, "1,5 Kilometer"}
1111+
10631112
"""
10641113
@spec to_string(t() | [t(), ...], Keyword.t()) :: {:ok, String.t()} | {:error, Exception.t()}
10651114
def to_string(unit_or_units, options \\ [])
@@ -1069,15 +1118,40 @@ defmodule Localize.Unit do
10691118
end
10701119

10711120
def to_string(%__MODULE__{} = unit, options) do
1072-
if Keyword.has_key?(options, :usage) or not is_nil(unit.usage) do
1073-
preference_options = Keyword.take(options, [:usage, :locale])
1074-
list_options = Keyword.drop(options, [:usage])
1121+
with {:ok, unit, options} <- maybe_humanize(unit, options) do
1122+
if Keyword.has_key?(options, :usage) or not is_nil(unit.usage) do
1123+
preference_options = Keyword.take(options, [:usage, :locale])
1124+
list_options = Keyword.drop(options, [:usage])
10751125

1076-
with {:ok, parts} <- localize(unit, preference_options) do
1077-
format_unit_list(parts, list_options)
1126+
with {:ok, parts} <- localize(unit, preference_options) do
1127+
format_unit_list(parts, list_options)
1128+
end
1129+
else
1130+
Localize.Unit.Formatter.to_string(unit, merge_struct_format_options(unit, options))
10781131
end
1079-
else
1080-
Localize.Unit.Formatter.to_string(unit, merge_struct_format_options(unit, options))
1132+
end
1133+
end
1134+
1135+
# `humanize: true` renders the unit the way people expect for its
1136+
# kind: bit-, byte-, hertz- and watt-based units scale through the
1137+
# `humanize/2` prefix ladder, every other unit goes through the
1138+
# usage-based preference pipeline with the struct's usage or
1139+
# `:default`. An explicit `:usage` option still wins for
1140+
# preference-scaled units.
1141+
defp maybe_humanize(unit, options) do
1142+
{humanize?, options} = Keyword.pop(options, :humanize, false)
1143+
1144+
cond do
1145+
humanize? != true ->
1146+
{:ok, unit, options}
1147+
1148+
match?({:ok, _base}, humanizable_base_unit(unit)) ->
1149+
with {:ok, humanized} <- humanize(unit, Keyword.take(options, [:system])) do
1150+
{:ok, humanized, options}
1151+
end
1152+
1153+
true ->
1154+
{:ok, unit, Keyword.put_new(options, :usage, unit.usage || :default)}
10811155
end
10821156
end
10831157

skills/localize/references/units.md

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -76,13 +76,24 @@ Localize.Unit.new!(1, "meter") |> Localize.Unit.convert("kilogram")
7676

7777
`convert_measurement_system/2` targets `:metric`, `:us`, or `:uk` preferred units (1000 meters → mile for `:us`).
7878

79-
## Humanizing digital units (file sizes)
79+
## Humanizing (auto-scaling to the expected unit)
8080

81-
`humanize/2` and `humanize!/2` convert a byte- or bit-based unit to the prefix that best fits its magnitude — the idiomatic way to render file sizes. `system: :si` (powers of 1000, default) or `system: :iec` (powers of 1024); CLDR has compact narrow patterns (`"MB"`) only for SI prefixes, IEC units render with full names. Non-digital units return `InvalidValueError`.
81+
`to_string(unit, humanize: true)` renders any unit in the unit people expect for its magnitude: bit-, byte-, hertz- and watt-based units scale through the SI prefix ladder (or IEC with `system: :iec`, bits/bytes only), all other units go through usage-based preferences (struct usage or `:default`; an explicit `usage:` wins).
8282

8383
```elixir
84-
Localize.Unit.new!(1_500_000, "byte") |> Localize.Unit.humanize!() |> Localize.Unit.to_string(format: :narrow)
84+
Localize.Unit.new!(1_500_000, "byte") |> Localize.Unit.to_string(humanize: true, format: :narrow)
8585
#=> {:ok, "1.5MB"}
86+
Localize.Unit.new!(2_500_000, "hertz") |> Localize.Unit.to_string(humanize: true, format: :narrow)
87+
#=> {:ok, "2.5MHz"}
88+
Localize.Unit.new!(1_500, "meter") |> Localize.Unit.to_string(humanize: true, locale: :de)
89+
#=> {:ok, "1,5 Kilometer"}
90+
Localize.Unit.new!(1_500_000, "gram") |> Localize.Unit.to_string(humanize: true, locale: :en)
91+
#=> {:ok, "1.653 tons"}
92+
```
93+
94+
`humanize/2` and `humanize!/2` are the struct-level API for the prefix-scaled bases (bit, byte, hertz, watt) — they convert to the prefixed unit that best fits the magnitude and return the scaled `%Localize.Unit{}`. `system: :si` (powers of 1000, default) or `system: :iec` (powers of 1024, bits/bytes only); CLDR has compact narrow patterns (`"MB"`, `"MHz"`) only for SI prefixes, IEC units render with full names. Other bases return `InvalidValueError`.
95+
96+
```elixir
8697
Localize.Unit.new!(2_750_000_000, "byte") |> Localize.Unit.humanize!() |> Localize.Unit.to_string(format: :narrow, fractional_digits: 1)
8798
#=> {:ok, "2.8GB"}
8899
{:ok, iec} = Localize.Unit.new!(1_048_576, "byte") |> Localize.Unit.humanize(system: :iec)

0 commit comments

Comments
 (0)