i18n: file sizes and measures with translatable unit names

I18n::file_size writes a byte count in steps of 1024 (KB or KiB) or 1000
(KB), with decimals per unit, no decimals from a given value up, a
smallest and largest unit, and bytes as B or in words. I18n::measure
writes any value with a unit template such as "{n} px". Unit names are
catalog rows in context `unit`, keyed by the English template, so a
translation can change the symbol and its spacing.
This commit is contained in:
aiongg committed 2026-10-21 04:44:53 +00:00
1 parent e36f189018
commit 3fcaddbdae
3 files changed
+253

No files matched your search

+8
View File
@@ -68,6 +68,14 @@ The app keeps its `tl!` macro, its language setting (`"auto"` → `I18N.system_l
`zh-hant`, `pt-PT` → `pt-br` when that is the only Portuguese, `C`/`POSIX` → English. A script
is never crossed: `sr_RS@latin` and `sr-Latn-RS` pick `sr-latn`, never `sr`, while `sr-Cyrl-RS`
picks `sr`.
- **Sizes and units:** `file_size(lang, bytes, style)` writes `1.5 MB` or `512 bytes`; `FileSize`
picks steps of 1024 named KB (`BINARY`), 1024 named KiB (`IEC`) or 1000 named KB (`DECIMAL`),
the decimals per unit, no decimals from a given value up, the smallest and largest unit (below
the smallest, sizes round up), and `B` or words for bytes. `measure(lang, value, decimals,
"{n} px")` writes any other measure. Unit names are rows in context `unit`, keyed by the English
template (`{n} MB` → `{n} Mo`, `{n}%` → `{n} %`) and read with `get`, so a plain `B` row (bold)
is never used; bytes in words are the `@plural` row `{n} byte|{n} bytes`. Numbers are
ASCII digits with a decimal point.
- **Pseudo locale** (`pseudo`, debug builds only, never in `languages()`): every lookup, `get`
included, comes back as `⟦…⟧`, line by line, so text drawn without the marks never went through
translation. `looked_up()` lists the source texts looked up so far, for an audit against a
+3
View File
@@ -6,6 +6,7 @@
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
mod catalog;
mod size;
use std::collections::{BTreeMap, BTreeSet, HashMap};
use std::sync::{Mutex, OnceLock, PoisonError};
@@ -13,6 +14,8 @@ use std::sync::{Mutex, OnceLock, PoisonError};
use intl_pluralrules::{PluralCategory, PluralRuleType, PluralRules};
use unic_langid::LanguageIdentifier;
pub use size::{FileSize, SizeUnit};
/// The catalogs an app embeds, one entry per locale: (locale, shared catalog, the app's catalog).
/// A missing catalog is `""`.
pub type Embedded = [(&'static str, &'static str, &'static str)];
+242
View File
@@ -0,0 +1,242 @@
//! File sizes and other measures with a unit, in the interface language.
//!
//! Unit names are catalog rows in context `unit`, keyed by the English template with the
//! number as `{n}` (`{n} MB`, `{n} px`, `{n}%`), so a translation can change the symbol and its
//! spacing (`{n} Mo`, `{n} %`). They are looked up with [`I18n::get`], never the plain row: a
//! plain `B` row means bold. A byte count in words is the `@plural` row `{n} byte|{n} bytes`.
use crate::{I18n, Lang, fmt};
/// The context unit rows are in.
const CONTEXT: &str = "unit";
/// A file-size unit.
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub enum SizeUnit {
Byte,
Kilo,
Mega,
Giga,
Tera,
}
impl SizeUnit {
const ALL: [SizeUnit; 5] = [SizeUnit::Byte, SizeUnit::Kilo, SizeUnit::Mega, SizeUnit::Giga, SizeUnit::Tera];
}
/// How [`I18n::file_size`] writes a byte count: the step between units, their names, and the
/// digits after the decimal point. Start from [`FileSize::BINARY`], [`FileSize::IEC`] or
/// [`FileSize::DECIMAL`] and adjust:
///
/// ```
/// use craft_i18n::{FileSize, SizeUnit};
/// // `12 MB`, `3.4 MB`, `640 KB`: decimal steps, whole kilobytes, no decimals from 10 MB.
/// let style = FileSize::DECIMAL.smallest(SizeUnit::Kilo).largest(SizeUnit::Mega).unit_decimals(SizeUnit::Kilo, 0).whole_from(10);
/// ```
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub struct FileSize {
step: u64,
iec: bool,
/// Per unit, in [`SizeUnit::ALL`] order; bytes are always whole.
decimals: [u8; 5],
whole_from: u32,
smallest: SizeUnit,
largest: SizeUnit,
byte_symbol: bool,
}
impl FileSize {
/// Steps of 1024 named KB, MB, GB and TB, as Windows Explorer shows sizes; one decimal, bytes
/// in words.
pub const BINARY: FileSize = FileSize {
step: 1024,
iec: false,
decimals: [0, 1, 1, 1, 1],
whole_from: 0,
smallest: SizeUnit::Byte,
largest: SizeUnit::Tera,
byte_symbol: false,
};
/// Steps of 1024 named KiB, MiB, GiB and TiB.
pub const IEC: FileSize = FileSize { iec: true, ..FileSize::BINARY };
/// Steps of 1000 named KB, MB, GB and TB, as macOS shows sizes and as download and disk sizes
/// are sold.
pub const DECIMAL: FileSize = FileSize { step: 1000, ..FileSize::BINARY };
/// `n` digits after the decimal point in every unit above bytes.
pub const fn decimals(mut self, n: u8) -> FileSize {
self.decimals = [0, n, n, n, n];
self
}
/// `n` digits after the decimal point in `unit` (`0` for whole kilobytes, `2` for gigabytes).
pub const fn unit_decimals(mut self, unit: SizeUnit, n: u8) -> FileSize {
if !matches!(unit, SizeUnit::Byte) {
self.decimals[unit as usize] = n;
}
self
}
/// No decimals from `n` of a unit up: `whole_from(10)` writes `9.5 MB` and `12 MB`. `0`, the
/// default, keeps them.
pub const fn whole_from(mut self, n: u32) -> FileSize {
self.whole_from = n;
self
}
/// The smallest unit shown. Sizes below it are written in it, rounded up, so a file that
/// isn't empty never reads `0 KB`.
pub const fn smallest(mut self, unit: SizeUnit) -> FileSize {
self.smallest = unit;
self
}
/// The largest unit shown; larger sizes are counted in it (`2048 GB`).
pub const fn largest(mut self, unit: SizeUnit) -> FileSize {
self.largest = unit;
self
}
/// Bytes as the symbol `B` (`512 B`) instead of words (`512 bytes`).
pub const fn byte_symbol(mut self) -> FileSize {
self.byte_symbol = true;
self
}
/// The unit `bytes` is written in and the number in it, in English.
fn parts(&self, bytes: u64) -> (SizeUnit, String) {
let (smallest, largest) = (self.smallest.min(self.largest), self.largest);
let mut unit = smallest;
for &u in SizeUnit::ALL.iter().filter(|u| (smallest..=largest).contains(u)) {
if bytes as f64 >= self.scale(u) {
unit = u;
}
}
loop {
let value = bytes as f64 / self.scale(unit);
let mut decimals = self.decimals.get(unit as usize).copied().unwrap_or(0);
if unit == SizeUnit::Byte || (self.whole_from > 0 && value >= f64::from(self.whole_from)) {
decimals = 0;
}
let factor = 10f64.powi(i32::from(decimals));
let rounded = if unit == smallest && bytes > 0 && value < 1.0 { (value * factor).ceil() } else { (value * factor).round() } / factor;
// 1023.96 KB rounds to 1024.0 KB: the next unit writes it as 1.0 MB.
if rounded >= self.step as f64
&& let Some(&next) = SizeUnit::ALL.get(unit as usize + 1)
&& next <= largest
{
unit = next;
continue;
}
return (unit, number(rounded, usize::from(decimals)));
}
}
fn scale(&self, unit: SizeUnit) -> f64 {
(self.step as f64).powi(unit as i32)
}
/// The English template for `unit`, which is also its catalog key.
fn template(&self, unit: SizeUnit) -> &'static str {
match (unit, self.iec) {
(SizeUnit::Byte, _) => "{n} B",
(SizeUnit::Kilo, false) => "{n} KB",
(SizeUnit::Mega, false) => "{n} MB",
(SizeUnit::Giga, false) => "{n} GB",
(SizeUnit::Tera, false) => "{n} TB",
(SizeUnit::Kilo, true) => "{n} KiB",
(SizeUnit::Mega, true) => "{n} MiB",
(SizeUnit::Giga, true) => "{n} GiB",
(SizeUnit::Tera, true) => "{n} TiB",
}
}
}
impl I18n {
/// `bytes` as a file size in `lang`, written the way `style` says (`1.5 MB`, `512 bytes`).
pub fn file_size(&self, lang: Lang, bytes: u64, style: FileSize) -> String {
let (unit, n) = style.parts(bytes);
if unit == SizeUnit::Byte && !style.byte_symbol {
return self.trn(lang, bytes, "{n} byte", "{n} bytes");
}
self.unit_text(lang, style.template(unit), &n)
}
/// `value` with `decimals` digits after the decimal point in the unit template `unit`, an
/// English catalog key with the number as `{n}` (`{n} px`, `{n}%`, `{n} dB`).
pub fn measure(&self, lang: Lang, value: f64, decimals: usize, unit: &str) -> String {
self.unit_text(lang, unit, &number(value, decimals))
}
fn unit_text(&self, lang: Lang, template: &str, n: &str) -> String {
fmt(self.get(lang, CONTEXT, template).unwrap_or(template), &[("n", n)])
}
}
/// `value` with `decimals` digits after the decimal point, in ASCII digits with a decimal point.
/// The one place sizes and measures write numbers, for craft-i18n's locale number formatting
/// (digits, separators) to take over.
fn number(value: f64, decimals: usize) -> String {
format!("{value:.decimals$}")
}
#[cfg(test)]
mod tests {
use super::*;
use crate::Embedded;
static E: &Embedded = &[(
"fr",
"unit\t{n} KB\t{n} Ko\nunit\t{n} MB\t{n} Mo\nunit\t{n} B\t{n} o\nunit\t{n}%\t{n} %\n\tB\tG\n@plural\t{n} byte|{n} bytes\t{n} octet|{n} octets\n",
"",
)];
fn en(bytes: u64, style: FileSize) -> String {
let (i18n, _) = I18n::new(&[], &[]);
i18n.file_size(Lang::EN, bytes, style)
}
#[test]
fn binary_decimal_and_iec_steps() {
assert_eq!(en(512, FileSize::BINARY), "512 bytes");
assert_eq!(en(1, FileSize::BINARY), "1 byte");
assert_eq!(en(1536, FileSize::BINARY), "1.5 KB");
assert_eq!(en(1536, FileSize::IEC), "1.5 KiB");
assert_eq!(en(1500, FileSize::DECIMAL), "1.5 KB");
assert_eq!(en(3 << 30, FileSize::BINARY), "3.0 GB");
assert_eq!(en(5 << 40, FileSize::IEC.decimals(0)), "5 TiB");
assert_eq!(en(512, FileSize::BINARY.byte_symbol()), "512 B");
}
#[test]
fn precision_and_unit_range() {
// Rounding up to the next unit's step moves to that unit.
assert_eq!(en(1_048_575, FileSize::BINARY), "1.0 MB");
assert_eq!(en(999_999, FileSize::DECIMAL.decimals(0)), "1 MB");
assert_eq!(en(1_610_612_736, FileSize::BINARY.unit_decimals(SizeUnit::Giga, 2)), "1.50 GB");
// Lightcraft's denoise sizes: whole kilobytes, at least 1, no decimals from 10 MB.
let style = FileSize::DECIMAL.smallest(SizeUnit::Kilo).largest(SizeUnit::Mega).unit_decimals(SizeUnit::Kilo, 0).whole_from(10);
assert_eq!(en(1, style), "1 KB");
assert_eq!(en(0, style), "0 KB");
assert_eq!(en(640_400, style), "640 KB");
assert_eq!(en(3_400_000, style), "3.4 MB");
assert_eq!(en(12_600_000, style), "13 MB");
assert_eq!(en(5_000_000_000, style), "5000 MB");
// Only sizes below the smallest unit round up.
assert_eq!(en(1025, FileSize::BINARY.smallest(SizeUnit::Kilo).unit_decimals(SizeUnit::Kilo, 0)), "1 KB");
assert_eq!(en(100, FileSize::BINARY.smallest(SizeUnit::Kilo).unit_decimals(SizeUnit::Kilo, 0)), "1 KB");
}
#[test]
fn unit_names_are_catalog_rows() {
let (i18n, problems) = I18n::new(E, &["fr"]);
assert!(problems.is_empty(), "{problems:?}");
let fr = i18n.from_code("fr").unwrap();
assert_eq!(i18n.file_size(fr, 1536, FileSize::BINARY), "1.5 Ko");
assert_eq!(i18n.file_size(fr, 512, FileSize::BINARY), "512 octets");
assert_eq!(i18n.file_size(fr, 512, FileSize::BINARY.byte_symbol()), "512 o", "not the plain row B (bold)");
assert_eq!(i18n.file_size(fr, 3 << 30, FileSize::BINARY), "3.0 GB", "a missing row is English");
assert_eq!(i18n.measure(fr, 50.0, 0, "{n}%"), "50 %");
assert_eq!(i18n.measure(Lang::EN, 12.34, 1, "{n} px"), "12.3 px");
}
}