From 7aff83515eeaecd2af345c60e23c653011dd6bd6 Mon Sep 17 00:00:00 2001 From: Mc-Zen <52877387+Mc-Zen@users.noreply.github.com> Date: Sun, 10 May 2026 13:19:26 +0200 Subject: [PATCH 01/24] [add] accessibility for num --- src/accessibility.typ | 171 ++++++++++++++++++++++++++++++++++++++++++ src/formatting.typ | 3 +- src/num.typ | 31 +++++++- src/state.typ | 1 + 4 files changed, 203 insertions(+), 3 deletions(-) create mode 100644 src/accessibility.typ diff --git a/src/accessibility.typ b/src/accessibility.typ new file mode 100644 index 0000000..7b7d97f --- /dev/null +++ b/src/accessibility.typ @@ -0,0 +1,171 @@ +#let translations = ( + "fallback": ( + "times": "×", + "power": "^", + "plus": "+", + "minus": "−", + ), + "en": ( + "times": "times", + "power": "to the power of", + "plus": "plus", + "minus": "minus", + ), + "de": ( + "times": "mal", + "power": "hoch", + "plus": "plus", + "minus": "minus", + ), + "fr": ( + "times": "fois", + "power": "à la puissance", + "plus": "plus", + "minus": "moins", + ), + "es": ( + "times": "veces", + "power": "elevado a", + "plus": "más", + "minus": "menos", + ), + "it": ( + "times": "per", + "power": "alla potenza di", + "plus": "più", + "minus": "meno", + ), + "ru": ( + "times": "на", + "power": "в степени", + "plus": "плюс", + "minus": "минус", + ), +) + +#let prefixes = ( + "en": ( + "a": "atto", + "f": "femto", + "p": "pico", + "n": "nano", + "µ": "micro", + "m": "milli", + "c": "centi", + "d": "deci", + "da": "deca", + "h": "hecto", + "k": "kilo", + "M": "mega", + "G": "giga", + "T": "tera", + "P": "peta", + "E": "exa", + ), + "de": ( + "p": "piko", + "µ": "mikro", + "c": "zenti", + "d": "dezi", + "da": "deka", + "h": "hekto", + ), + "fr": ( + "d": "déci", + "da": "déca", + "M": "méga", + "T": "téra", + "P": "péta", + ), + "ru": ( + "a": "атто", + "f": "фемто", + "p": "пико", + "n": "нано", + "µ": "микро", + "m": "милли", + "c": "санти", + "d": "деци", + "da": "дека", + "h": "гекто", + "k": "кило", + "M": "мега", + "G": "гига", + "T": "тера", + "P": "пета", + "E": "экса", + ), + "es": (:), + "it": ( + "k": "chilo", + "h": "etto", + ), + "da": ( + "h": "hekto", + "p": "piko", + "µ": "mikro", + ), + "po": ( + "h": "hekto", + "k": "quilo", + "d": "decy", + "da": "deka", + "c": "centy", + "m": "mili", + "p": "piko", + "µ": "mikro", + ), +) +#let generate-alt-description(info, translation: auto) = { + let lang = text.lang + if translation == auto { + translation = translations.at(lang, default: translations.fallback) + } + let format-comma-number(int, frac) = { + int + if frac != "" { info.decimal-separator + frac } + } + + let description = "" + description += if info.sign == "-" { "-" } + description += format-comma-number(info.int, info.frac) + + if info.uncertainty != none { + if type(info.uncertainty.first()) == array { + description += ( + " " + + translation.plus + + " " + + format-comma-number(..info.uncertainty.first()) + ) + description += ( + " " + + translation.minus + + " " + + format-comma-number(..info.uncertainty.last()) + ) + } else { + description += ( + " " + + translation.plus + + " " + + translation.minus + + " " + + format-comma-number(..info.uncertainty) + ) + } + } + if info.e != none { + description += ( + " " + + translation.times + + " " + + str(info.base) + + " " + + translation.power + + " " + + info.e + ) + } + + description +} diff --git a/src/formatting.typ b/src/formatting.typ index db2e34b..11d21e0 100644 --- a/src/formatting.typ +++ b/src/formatting.typ @@ -10,8 +10,9 @@ /// An array of content items. /// -> array items, + alt: none ) = { - math.equation(sequence(items)) + math.equation(sequence(items), alt: alt) } diff --git a/src/num.typ b/src/num.typ index 963338d..2aac663 100644 --- a/src/num.typ +++ b/src/num.typ @@ -3,7 +3,7 @@ #import "rounding.typ": * #import "assertions.typ": * #import "parsing.typ" as parsing: nonum - +#import "accessibility.typ": generate-alt-description #let update-state(state, args, name: none) = { assert-no-fixed(args) state.update(s => { @@ -142,7 +142,34 @@ // Format number let components = show-num-impl(info + it) - let collect = if it.math { equation-from-items } else { it => it.join() } + + let description + if it.alt == auto { + it.alt = generate-alt-description + } + if type(it.alt) == dictionary { + assert( + "times" in it.alt and "power" in it.alt and "plus" in it.alt and "minus" in it.alt, + message: "Expected keys \"times\", \"power\", \"plus\", and \"minus\", got " + repr(it.alt), + ) + it.alt = generate-alt-description.with(translation: it.alt) + } + if type(it.alt) == function { + description = (it.alt)( + ( + sign: info.sign, + int: info.int, + frac: info.frac, + decimal-separator: it.decimal-separator, + uncertainty: info.pm, + e: info.e, + base: it.base, + ) + ) + } else { + description = it.alt + } + let collect = if it.math { equation-from-items.with(alt: description) } else { it => it.join() } if it.align == none { set text(dir: ltr) diff --git a/src/state.typ b/src/state.typ index d3edcea..57c6729 100644 --- a/src/state.typ +++ b/src/state.typ @@ -8,6 +8,7 @@ positive-sign: false, tight: false, math: true, + alt: auto, // Power: product: sym.times, positive-sign-exponent: false, From d8560a191448c69c1ea9e2e9fd016c55bcdd5130 Mon Sep 17 00:00:00 2001 From: Mc-Zen <52877387+Mc-Zen@users.noreply.github.com> Date: Mon, 11 May 2026 23:43:12 +0200 Subject: [PATCH 02/24] [refactor] unit power internal representation to string --- src/units.typ | 37 +++++++++++++++++++++---------------- 1 file changed, 21 insertions(+), 16 deletions(-) diff --git a/src/units.typ b/src/units.typ index 97ff194..a457c7c 100644 --- a/src/units.typ +++ b/src/units.typ @@ -59,7 +59,7 @@ per = not per exponent = exponent.slice(1) } - exponent = [#exponent] + // exponent = [#exponent] if unit != "1" { // make calls like "1/s" possible in addition to "/s" if per { denominator.push((symbol, exponent)) } else { @@ -89,7 +89,7 @@ for child in children { if type(child) in (str, content, symbol) { - numerator.push((child, [1])) + numerator.push((child, "1")) } else if type(child) == array { assert( child.len() == 2, @@ -107,9 +107,9 @@ } if exponent.starts-with("-") { exponent = exponent.slice(1) - denominator.push((unit, [#exponent])) + denominator.push((unit, exponent)) } else { - numerator.push((unit, [#exponent])) + numerator.push((unit, exponent)) } } else { assert( @@ -123,14 +123,22 @@ (numerator: numerator, denominator: denominator) } -#let format-unit-power(unit, exponent, math: true) = { - if math { - if type(exponent) in (int, float) { - exponent = str(exponent) - } - if exponent in (1, [1]) { unit } else { std.math.attach(unit, t: exponent) } +#let format-unit-power(unit, exponent, math: true, negative: false) = { + if type(exponent) in (int, float) { + exponent = str(exponent) + } + + exponent = [#exponent] + if negative { + exponent = sym.minus + exponent + } + + if exponent in (1, [1], "1") { + unit } else { - if exponent in (1, [1]) { unit } else { + if math { + std.math.attach(unit, t: exponent) + } else { unit + super(typographic: false, exponent) } } @@ -146,13 +154,10 @@ let units = units .pos() .map(((unit, exponent)) => { - if exp-multiplier == -1 { - exponent = sym.minus + exponent - } - if use-sqrt and exponent == [0.5] and math { + if use-sqrt and exponent == "0.5" and exp-multiplier == 1 and math { return std.math.sqrt(unit) } - format-unit-power(unit, exponent, math: math) + format-unit-power(unit, exponent, math: math, negative: exp-multiplier == -1) }) let folded-units = units.join(unit-separator) From a102980bf5e30347d3edf0945346a550aca203bb Mon Sep 17 00:00:00 2001 From: Mc-Zen <52877387+Mc-Zen@users.noreply.github.com> Date: Tue, 12 May 2026 09:29:38 +0200 Subject: [PATCH 03/24] [fix] conversion of base to string --- src/accessibility.typ | 25 ++++++++++++++++++++++++- tests/num/power/test.typ | 2 +- 2 files changed, 25 insertions(+), 2 deletions(-) diff --git a/src/accessibility.typ b/src/accessibility.typ index 7b7d97f..249454b 100644 --- a/src/accessibility.typ +++ b/src/accessibility.typ @@ -1,3 +1,5 @@ +#import "parsing.typ": content-to-string + #let translations = ( "fallback": ( "times": "×", @@ -116,6 +118,27 @@ "µ": "mikro", ), ) + +#let base-to-string(base) = { + if type(base) == str { + return base + } else if type(base) in (int, float, symbol) { + return str(base) + } else if type(base) == content { + if base.func() == math.equation { + let result = content-to-string(base.body) + if result != none { + return result + } + } + } + + assert( + false, + message: "Failed to convert base to string. Please provide a manual alt text for this numeral.", + ) +} + #let generate-alt-description(info, translation: auto) = { let lang = text.lang if translation == auto { @@ -159,7 +182,7 @@ " " + translation.times + " " - + str(info.base) + + base-to-string(info.base) + " " + translation.power + " " diff --git a/tests/num/power/test.typ b/tests/num/power/test.typ index 1086e4e..99b3816 100644 --- a/tests/num/power/test.typ +++ b/tests/num/power/test.typ @@ -20,7 +20,7 @@ // base #num("1e2", base: 2) \ #num("1e2", base: $e$) \ -#num("1e2", base: $π$) \ +#num("1e2", base: math.pi) \ #set-num(base: "4") #num("1e2") \ #set-num(base: 10) From c3b9ccfaff9089766d81bb18d551443717589251 Mon Sep 17 00:00:00 2001 From: Mc-Zen <52877387+Mc-Zen@users.noreply.github.com> Date: Wed, 3 Jun 2026 10:04:55 +0200 Subject: [PATCH 04/24] [add] unit translations --- src/accessibility.typ | 272 +++++++++++++++++++++++++++++++----------- src/num.typ | 6 +- src/units.typ | 11 +- 3 files changed, 215 insertions(+), 74 deletions(-) diff --git a/src/accessibility.typ b/src/accessibility.typ index 249454b..bb5551e 100644 --- a/src/accessibility.typ +++ b/src/accessibility.typ @@ -46,79 +46,200 @@ ) #let prefixes = ( - "en": ( - "a": "atto", - "f": "femto", - "p": "pico", - "n": "nano", - "µ": "micro", - "m": "milli", - "c": "centi", - "d": "deci", - "da": "deca", - "h": "hecto", - "k": "kilo", - "M": "mega", - "G": "giga", - "T": "tera", - "P": "peta", - "E": "exa", + en: ( + a: "atto", + f: "femto", + p: "pico", + n: "nano", + µ: "micro", + m: "milli", + c: "centi", + d: "deci", + da: "deca", + h: "hecto", + k: "kilo", + M: "mega", + G: "giga", + T: "tera", + P: "peta", + E: "exa", ), - "de": ( - "p": "piko", - "µ": "mikro", - "c": "zenti", - "d": "dezi", - "da": "deka", - "h": "hekto", + // identical prefixes are inherited from English + de: ( + p: "piko", + µ: "mikro", + c: "zenti", + d: "dezi", + da: "deka", + h: "hekto", ), - "fr": ( - "d": "déci", - "da": "déca", - "M": "méga", - "T": "téra", - "P": "péta", + fr: ( + d: "déci", + da: "déca", + M: "méga", + T: "téra", + P: "péta", ), - "ru": ( - "a": "атто", - "f": "фемто", - "p": "пико", - "n": "нано", - "µ": "микро", - "m": "милли", - "c": "санти", - "d": "деци", - "da": "дека", - "h": "гекто", - "k": "кило", - "M": "мега", - "G": "гига", - "T": "тера", - "P": "пета", - "E": "экса", - ), - "es": (:), - "it": ( - "k": "chilo", - "h": "etto", - ), - "da": ( - "h": "hekto", - "p": "piko", - "µ": "mikro", - ), - "po": ( - "h": "hekto", - "k": "quilo", - "d": "decy", - "da": "deka", - "c": "centy", - "m": "mili", - "p": "piko", - "µ": "mikro", + es: (:), + ru: ( + a: "атто", + f: "фемто", + p: "пико", + n: "нано", + µ: "микро", + m: "милли", + c: "санти", + d: "деци", + da: "дека", + h: "гекто", + k: "кило", + M: "мега", + G: "гига", + T: "тера", + P: "пета", + E: "экса", + ), + it: ( + k: "chilo", + h: "etto", + ), + da: ( + h: "hekto", + p: "piko", + µ: "mikro", + ), + po: ( + h: "hekto", + k: "quilo", + d: "decy", + da: "deka", + c: "centy", + m: "mili", + p: "piko", + µ: "mikro", ), ) +#let units = ( + en: ( + A: "ampere", + au: "astronomical unit", + B: "bel", + Bq: "becquerel", + C: "coulomb", + cd: "candela", + d: "day", + Da: "dalton", + dB: "decibel", + sym.degree: "degree", + sym.degree + "C": "degree Celsius", + eV: "electronvolt", + F: "farad", + g: "gram", + Gy: "gray", + H: "henry", + h: "hour", + ha: "hectare", + Hz: "hertz", + J: "joule", + K: "kelvin", + kat: "katal", + kg: "kilogram", + L: "liter", + lm: "lumen", + lx: "lux", + m: "meter", + sym.prime: "arc minute", + min: "minute", + mol: "mole", + sym.prime.double: "arc second", + N: "newton", + Np: "neper", + sym.Omega: "ohm", + Pa: "pascal", + rad: "radian", + s: "second", + S: "siemens", + sr: "steradian", + Sv: "sievert", + t: "tonne", + T: "tesla", + V: "volt", + W: "watt", + Wb: "weber", + ), + de: ( + // identical/similar units are inherited from English + sym.degree: "Grad", + sym.degree + "C": "Grad Celsius", + h: "Stunde", + min: "Minute", + s: "Sekunde", + d: "Tag", + au: "astronomische Einheit", + sym.prime.double: "Bogensekunde", + sym.prime: "Bogenminute", + mol: "Mol", + ), + fr: ( + sym.degree: "degré", + sym.degree + "C": "degré Celsius", + h: "heure", + min: "minute", + s: "seconde", + d: "jour", + au: "unité astronomique", + sym.prime.double: "seconde d'arc", + sym.prime: "minute d'arc", + ), + es: ( + sym.degree: "grado", + sym.degree + "C": "grado Celsius", + h: "hora", + min: "minuto", + s: "segundo", + d: "día", + au: "unidad astronómica", + sym.prime.double: "segundo de arco", + sym.prime: "minuto de arco", + ), + it: ( + sym.degree: "grado", + sym.degree + "C": "grado Celsius", + h: "ora", + min: "minuto", + s: "secondo", + d: "giorno", + au: "unità astronomica", + sym.prime.double: "secondo d'arco", + sym.prime: "minuto d'arco", + ), +) + + +#let unit-component-description(component) = { + let units = units.at(text.lang) + if component in units { + return units.at(component) + } + if component.len() > 1 { + let prefixes = prefixes.at(text.lang) + + let prefix = component.at(0) + let unit = component.slice(1) + if prefix in prefixes and unit in units { + return prefixes.at(prefix) + " " + units.at(unit) + } + } + assert( + false, + message: "Failed to auto-generate alt description for unit component " + + component + + ". Please provide a manual alt text for this unit.", + ) +} + + #let base-to-string(base) = { if type(base) == str { return base @@ -139,7 +260,7 @@ ) } -#let generate-alt-description(info, translation: auto) = { +#let generate-num-alt-description(info, translation: auto) = { let lang = text.lang if translation == auto { translation = translations.at(lang, default: translations.fallback) @@ -192,3 +313,18 @@ description } + + +#let generate-unit-alt-description(numerator, denominator, translation: auto) = { + let lang = text.lang + if translation == auto { + translation = translations.at(lang, default: translations.fallback) + } +} +#context { + // assert(generate-alt-description() + assert(unit-component-description("mm") == "milli meter") + assert(unit-component-description("kg") == "kilogram") + assert(unit-component-description("au") == "astronomical unit") + assert(unit-component-description("aau") == "atto astronomical unit") // doesn't make sense but it should work +} diff --git a/src/num.typ b/src/num.typ index 2aac663..3a564c1 100644 --- a/src/num.typ +++ b/src/num.typ @@ -3,7 +3,7 @@ #import "rounding.typ": * #import "assertions.typ": * #import "parsing.typ" as parsing: nonum -#import "accessibility.typ": generate-alt-description +#import "accessibility.typ": generate-num-alt-description #let update-state(state, args, name: none) = { assert-no-fixed(args) state.update(s => { @@ -145,14 +145,14 @@ let description if it.alt == auto { - it.alt = generate-alt-description + it.alt = generate-num-alt-description } if type(it.alt) == dictionary { assert( "times" in it.alt and "power" in it.alt and "plus" in it.alt and "minus" in it.alt, message: "Expected keys \"times\", \"power\", \"plus\", and \"minus\", got " + repr(it.alt), ) - it.alt = generate-alt-description.with(translation: it.alt) + it.alt = generate-num-alt-description.with(translation: it.alt) } if type(it.alt) == function { description = (it.alt)( diff --git a/src/units.typ b/src/units.typ index a457c7c..a8aceed 100644 --- a/src/units.typ +++ b/src/units.typ @@ -2,6 +2,7 @@ #import "state.typ": num-state, update-num-state #import "assertions.typ": assert-settable-args #import "parsing.typ": compute-eng-digits, parse-numeral +#import "accessibility.typ": generate-unit-alt-description #import "utility.typ" /// [internal function] @@ -196,6 +197,10 @@ + ". Expected \"power\", \"fraction\", or \"inline\"", ) + let equation = std.math.equation.with( + alt: generate-unit-alt-description(numerator, denominator) + ) + let fold-units = fold-units.with( unit-separator: unit-separator + sym.wj, math: math, @@ -205,7 +210,7 @@ let numerator-content = fold-units(..numerator, 1) if denominator.len() == 0 { - return if math { $#numerator-content$ } else { numerator-content } + return if math { equation($#numerator-content$) } else { numerator-content } } let denom-exp-multiplier = if fraction == "power" { -1 } else { 1 } @@ -217,7 +222,7 @@ if numerator.len() != 0 { result = numerator-content + unit-separator + sym.wj + result } - return if math { $result$ } else { result } + return if math { equation($result$) } else { result } } // For the two fractional modes, the numerator shall not be empty. @@ -228,7 +233,7 @@ denominator-content = $(#denominator-content)$ } set std.math.frac(style: "horizontal") if fraction == "inline" - $#numerator-content/#denominator-content$ + equation($#numerator-content/#denominator-content$) } else { if denominator.len() > 1 { denominator-content = [(#denominator-content)] From b79d463bab4f481eb8cbb49ff361e8bbfaab8108 Mon Sep 17 00:00:00 2001 From: Mc-Zen <52877387+Mc-Zen@users.noreply.github.com> Date: Mon, 8 Jun 2026 22:14:09 +0200 Subject: [PATCH 05/24] [add] support for unit alt descriptions --- src/accessibility.typ | 165 ++++++++++++++++++++++++++++++++---------- 1 file changed, 126 insertions(+), 39 deletions(-) diff --git a/src/accessibility.typ b/src/accessibility.typ index bb5551e..2c4c1fc 100644 --- a/src/accessibility.typ +++ b/src/accessibility.typ @@ -6,42 +6,49 @@ "power": "^", "plus": "+", "minus": "−", + "per": "/", ), "en": ( "times": "times", "power": "to the power of", "plus": "plus", "minus": "minus", + "per": "per", ), "de": ( "times": "mal", "power": "hoch", "plus": "plus", "minus": "minus", + "per": "pro", ), "fr": ( "times": "fois", "power": "à la puissance", "plus": "plus", "minus": "moins", + "per": "par", ), "es": ( "times": "veces", "power": "elevado a", "plus": "más", "minus": "menos", + "per": "por", ), "it": ( "times": "per", "power": "alla potenza di", "plus": "più", "minus": "meno", + "per": "per", ), "ru": ( "times": "на", "power": "в степени", "plus": "плюс", "minus": "минус", + "per": "в", ), ) @@ -103,21 +110,6 @@ k: "chilo", h: "etto", ), - da: ( - h: "hekto", - p: "piko", - µ: "mikro", - ), - po: ( - h: "hekto", - k: "quilo", - d: "decy", - da: "deka", - c: "centy", - m: "mili", - p: "piko", - µ: "mikro", - ), ) #let units = ( @@ -176,25 +168,57 @@ min: "Minute", s: "Sekunde", d: "Tag", + kg: "Kilogramm", + g: "Gramm", + dB: "Dezibel", + eV: "Elektronvolt", au: "astronomische Einheit", sym.prime.double: "Bogensekunde", sym.prime: "Bogenminute", mol: "Mol", ), fr: ( + A: "ampère", sym.degree: "degré", sym.degree + "C": "degré Celsius", h: "heure", + em: "mètre", min: "minute", s: "seconde", d: "jour", au: "unité astronomique", sym.prime.double: "seconde d'arc", sym.prime: "minute d'arc", + decibel: "décibel", + eV: "électronvolt", + g: "gramme", + kg: "kilogramme", + l: "litre", + Np: "néper", + sr: "stéradian", ), es: ( - sym.degree: "grado", + A: "amperio", + C: "culombio", + sym.degree: "grado sexagesimal", sym.degree + "C": "grado Celsius", + eV: "electronvoltio", + F: "faradio", + g: "gramo", + kg: "kilogramo", + l: "litro", + m: "metro", + ha: "hectárea", + J: "julio", + H: "henrio", + Hz: "hercio", + mol: "mol", + sym.Omega: "ohmio", + rad: "radián", + sr: "esterorradián", + t: "tonelada", + V: "voltio", + W: "vatio", h: "hora", min: "minuto", s: "segundo", @@ -204,6 +228,10 @@ sym.prime: "minuto de arco", ), it: ( + m: "metro", + kg: "kilogrammo", + g: "grammo", + eV: "elettronvolt", sym.degree: "grado", sym.degree + "C": "grado Celsius", h: "ora", @@ -216,28 +244,28 @@ ), ) - -#let unit-component-description(component) = { - let units = units.at(text.lang) - if component in units { - return units.at(component) - } - if component.len() > 1 { - let prefixes = prefixes.at(text.lang) - - let prefix = component.at(0) - let unit = component.slice(1) - if prefix in prefixes and unit in units { - return prefixes.at(prefix) + " " + units.at(unit) - } - } - assert( - false, - message: "Failed to auto-generate alt description for unit component " - + component - + ". Please provide a manual alt text for this unit.", - ) -} +#let special-powers = ( + en: ( + "2": "squared", + "3": "cubed", + ), + de: ( + "2": "hoch 2", + "3": "hoch 3", + ), + fr: ( + "2": "carré", + "3": "cubo", + ), + es: ( + "2": "al cuadrado", + "3": "al cubo", + ), + it: ( + "2": "al quadrato", + "3": "al cubo", + ), +) #let base-to-string(base) = { @@ -315,11 +343,70 @@ } -#let generate-unit-alt-description(numerator, denominator, translation: auto) = { + +#let unit-component-description(component) = { + let units = units.en + units.at(text.lang, default: units.en) + if component in units { + return units.at(component) + } + if component.len() > 1 { + let prefixes = prefixes.at(text.lang, default: prefixes.en) + + let prefix = component.at(0) + let unit = component.slice(1) + if prefix in prefixes and unit in units { + return prefixes.at(prefix) + " " + units.at(unit) + } + } + assert( + false, + message: "Failed to auto-generate alt description for unit component " + + component + + ". Please provide a manual alt text for this unit.", + ) +} + +#let generate-unit-alt-description( + numerator, + denominator, + translation: auto, +) = { let lang = text.lang if translation == auto { translation = translations.at(lang, default: translations.fallback) + assert( + lang in translations, + message: "Unsupported language " + + lang + + " for alt text generation. Please provide a manual alt text for this unit. Supported languages are: " + + repr(translations.keys()) + + ". If you want to contribute a translation for your language, please open a pull request.", + ) + } + + + let alt = "" + let special-powers = special-powers.at(lang, default: (:)) + let power-of-unit-component-description((unit-component, exponent)) = { + unit-component-description(unit-component) + if exponent != "1" { + if exponent in special-powers { + " " + special-powers.at(exponent) + } else { + " " + translation.power + " " + exponent + } + } + } + alt += numerator + .map(power-of-unit-component-description) + .join(" ") + if denominator.len() > 0 { + alt += " " + translation.per + " " + alt += denominator + .map(power-of-unit-component-description) + .join(" " + translation.per + " ") } + alt } #context { // assert(generate-alt-description() From 349ce796be1c5308158da88492b5f12286bb2645 Mon Sep 17 00:00:00 2001 From: Mc-Zen <52877387+Mc-Zen@users.noreply.github.com> Date: Sat, 20 Jun 2026 14:31:14 +0200 Subject: [PATCH 06/24] [fix] diverse units --- src/accessibility.typ | 97 ++++++++++++++++++++++++++++++++++++++----- 1 file changed, 87 insertions(+), 10 deletions(-) diff --git a/src/accessibility.typ b/src/accessibility.typ index 2c4c1fc..efe9910 100644 --- a/src/accessibility.typ +++ b/src/accessibility.typ @@ -162,6 +162,7 @@ ), de: ( // identical/similar units are inherited from English + ha: "Hektar", sym.degree: "Grad", sym.degree + "C": "Grad Celsius", h: "Stunde", @@ -176,13 +177,43 @@ sym.prime.double: "Bogensekunde", sym.prime: "Bogenminute", mol: "Mol", + A: "Ampère", + B: "Bel", + Bq: "Becquerel", + C: "Coulomb", + cd: "Candela", + Da: "Dalton", + F: "Farad", + Gy: "Gray", + H: "Henry", + Hz: "Hertz", + J: "Joule", + K: "Kelvin", + kat: "Katal", + L: "Liter", + lm: "Lumen", + lx: "Lux", + m: "Meter", + N: "Newton", + Np: "Neper", + sym.Omega: "Ohm", + Pa: "Pascal", + rad: "Radian", + S: "Siemens", + sr: "Steradian", + Sv: "Sievert", + t: "Tonne", + T: "Tesla", + V: "Volt", + W: "Watt", + Wb: "Weber", ), fr: ( A: "ampère", sym.degree: "degré", sym.degree + "C": "degré Celsius", h: "heure", - em: "mètre", + m: "mètre", min: "minute", s: "seconde", d: "jour", @@ -255,7 +286,7 @@ ), fr: ( "2": "carré", - "3": "cubo", + "3": "cubique", ), es: ( "2": "al cuadrado", @@ -350,10 +381,10 @@ return units.at(component) } if component.len() > 1 { - let prefixes = prefixes.at(text.lang, default: prefixes.en) - - let prefix = component.at(0) - let unit = component.slice(1) + let prefixes = prefixes.en + prefixes.at(text.lang, default: prefixes.en) + let clusters = component.clusters() + let prefix = clusters.at(0) + let unit = clusters.slice(1).join() if prefix in prefixes and unit in units { return prefixes.at(prefix) + " " + units.at(unit) } @@ -397,9 +428,7 @@ } } } - alt += numerator - .map(power-of-unit-component-description) - .join(" ") + alt += numerator.map(power-of-unit-component-description).join(" ") if denominator.len() > 0 { alt += " " + translation.per + " " alt += denominator @@ -408,10 +437,58 @@ } alt } + #context { // assert(generate-alt-description() assert(unit-component-description("mm") == "milli meter") assert(unit-component-description("kg") == "kilogram") assert(unit-component-description("au") == "astronomical unit") - assert(unit-component-description("aau") == "atto astronomical unit") // doesn't make sense but it should work + assert(unit-component-description("aau") == "atto astronomical unit") // silly but it should work + + import "/src/units.typ": parse-unit + + assert.eq( + generate-unit-alt-description( + ..parse-unit("m").values(), + ), + "meter", + ) + assert.eq( + generate-unit-alt-description( + ..parse-unit("m^2/s^3").values(), + ), + "meter squared per second cubed", + ) + + set text(lang: "de") + context { + assert.eq( + generate-unit-alt-description( + ..parse-unit("m^2/µs^3").values(), + ), + "Meter hoch 2 pro mikro Sekunde hoch 3", + ) + assert.eq( + generate-unit-alt-description( + ..parse-unit("mN m").values(), + ), + "milli Newton Meter", + ) + } + + set text(lang: "fr") + context { + assert.eq( + generate-unit-alt-description( + ..parse-unit("m^2/s^3/l^4").values(), + ), + "mètre carré par seconde cubique par litre à la puissance 4", + ) + assert.eq( + generate-unit-alt-description( + ..parse-unit("MN m").values(), + ), + "méga newton mètre", + ) + } } From c7e073506018291849daf268e28ca71615948022 Mon Sep 17 00:00:00 2001 From: Mc-Zen <52877387+Mc-Zen@users.noreply.github.com> Date: Sat, 20 Jun 2026 15:24:29 +0200 Subject: [PATCH 07/24] [fix] german spelling prefixes --- src/accessibility.typ | 42 +++++++++++++++++++++++++++++------------- 1 file changed, 29 insertions(+), 13 deletions(-) diff --git a/src/accessibility.typ b/src/accessibility.typ index efe9910..ce1fbc1 100644 --- a/src/accessibility.typ +++ b/src/accessibility.typ @@ -73,12 +73,22 @@ ), // identical prefixes are inherited from English de: ( - p: "piko", - µ: "mikro", - c: "zenti", - d: "dezi", - da: "deka", - h: "hekto", + a: "Atto", + f: "Femto", + p: "Piko", + n: "Nano", + µ: "Mikro", + m: "Milli", + c: "Zenti", + d: "Dezi", + da: "Deka", + h: "Hekto", + k: "Kilo", + M: "Mega", + G: "Giga", + T: "Tera", + P: "Peta", + E: "Exa", ), fr: ( d: "déci", @@ -373,7 +383,13 @@ description } - +#let join-prefix-unit(prefix, unit, lang) = { + if lang == "de" { + prefix + lower(unit) + } else { + prefix + unit + } +} #let unit-component-description(component) = { let units = units.en + units.at(text.lang, default: units.en) @@ -386,7 +402,7 @@ let prefix = clusters.at(0) let unit = clusters.slice(1).join() if prefix in prefixes and unit in units { - return prefixes.at(prefix) + " " + units.at(unit) + return join-prefix-unit(prefixes.at(prefix), units.at(unit), text.lang) } } assert( @@ -440,10 +456,10 @@ #context { // assert(generate-alt-description() - assert(unit-component-description("mm") == "milli meter") + assert(unit-component-description("mm") == "millimeter") assert(unit-component-description("kg") == "kilogram") assert(unit-component-description("au") == "astronomical unit") - assert(unit-component-description("aau") == "atto astronomical unit") // silly but it should work + assert(unit-component-description("aau") == "attoastronomical unit") // silly but it should work import "/src/units.typ": parse-unit @@ -466,13 +482,13 @@ generate-unit-alt-description( ..parse-unit("m^2/µs^3").values(), ), - "Meter hoch 2 pro mikro Sekunde hoch 3", + "Meter hoch 2 pro Mikrosekunde hoch 3", ) assert.eq( generate-unit-alt-description( ..parse-unit("mN m").values(), ), - "milli Newton Meter", + "Millinewton Meter", ) } @@ -488,7 +504,7 @@ generate-unit-alt-description( ..parse-unit("MN m").values(), ), - "méga newton mètre", + "méganewton mètre", ) } } From 7ad7e5a3c943946a252f85dd532fb356bbd900ee Mon Sep 17 00:00:00 2001 From: Mc-Zen <52877387+Mc-Zen@users.noreply.github.com> Date: Sat, 20 Jun 2026 15:35:37 +0200 Subject: [PATCH 08/24] [add] special powers for german --- src/accessibility.typ | 27 +++++++++++++++++++-------- 1 file changed, 19 insertions(+), 8 deletions(-) diff --git a/src/accessibility.typ b/src/accessibility.typ index ce1fbc1..c9800f7 100644 --- a/src/accessibility.typ +++ b/src/accessibility.typ @@ -291,8 +291,8 @@ "3": "cubed", ), de: ( - "2": "hoch 2", - "3": "hoch 3", + "2": unit => "Quadrat" + lower(unit), + "3": unit => "Kubik" + lower(unit), ), fr: ( "2": "carré", @@ -435,14 +435,19 @@ let alt = "" let special-powers = special-powers.at(lang, default: (:)) let power-of-unit-component-description((unit-component, exponent)) = { - unit-component-description(unit-component) - if exponent != "1" { - if exponent in special-powers { - " " + special-powers.at(exponent) + let unit = unit-component-description(unit-component) + if exponent == "1" { return unit } + if exponent in special-powers { + let special-power = special-powers.at(exponent) + if type(special-power) == function { + unit = special-power(unit) } else { - " " + translation.power + " " + exponent + unit += " " + special-power } + } else { + unit += " " + translation.power + " " + exponent } + unit } alt += numerator.map(power-of-unit-component-description).join(" ") if denominator.len() > 0 { @@ -482,7 +487,13 @@ generate-unit-alt-description( ..parse-unit("m^2/µs^3").values(), ), - "Meter hoch 2 pro Mikrosekunde hoch 3", + "Quadratmeter pro Kubikmikrosekunde", + ) + assert.eq( + generate-unit-alt-description( + ..parse-unit("m^4/s/K").values(), + ), + "Meter hoch 4 pro Sekunde pro Kelvin", ) assert.eq( generate-unit-alt-description( From 221cfe0e5589266d4749e02174cb19b6de9a2c82 Mon Sep 17 00:00:00 2001 From: Mc-Zen <52877387+Mc-Zen@users.noreply.github.com> Date: Sat, 20 Jun 2026 15:59:37 +0200 Subject: [PATCH 09/24] [add] tests --- src/accessibility.typ | 70 +------------- src/num.typ | 2 +- tests/accessibility/.gitignore | 5 + tests/accessibility/test.typ | 170 +++++++++++++++++++++++++++++++++ 4 files changed, 181 insertions(+), 66 deletions(-) create mode 100644 tests/accessibility/.gitignore create mode 100644 tests/accessibility/test.typ diff --git a/src/accessibility.typ b/src/accessibility.typ index c9800f7..791a320 100644 --- a/src/accessibility.typ +++ b/src/accessibility.typ @@ -342,19 +342,19 @@ description += if info.sign == "-" { "-" } description += format-comma-number(info.int, info.frac) - if info.uncertainty != none { - if type(info.uncertainty.first()) == array { + if info.pm != none { + if type(info.pm.first()) == array { description += ( " " + translation.plus + " " - + format-comma-number(..info.uncertainty.first()) + + format-comma-number(..info.pm.first()) ) description += ( " " + translation.minus + " " - + format-comma-number(..info.uncertainty.last()) + + format-comma-number(..info.pm.last()) ) } else { description += ( @@ -363,7 +363,7 @@ + " " + translation.minus + " " - + format-comma-number(..info.uncertainty) + + format-comma-number(..info.pm) ) } } @@ -459,63 +459,3 @@ alt } -#context { - // assert(generate-alt-description() - assert(unit-component-description("mm") == "millimeter") - assert(unit-component-description("kg") == "kilogram") - assert(unit-component-description("au") == "astronomical unit") - assert(unit-component-description("aau") == "attoastronomical unit") // silly but it should work - - import "/src/units.typ": parse-unit - - assert.eq( - generate-unit-alt-description( - ..parse-unit("m").values(), - ), - "meter", - ) - assert.eq( - generate-unit-alt-description( - ..parse-unit("m^2/s^3").values(), - ), - "meter squared per second cubed", - ) - - set text(lang: "de") - context { - assert.eq( - generate-unit-alt-description( - ..parse-unit("m^2/µs^3").values(), - ), - "Quadratmeter pro Kubikmikrosekunde", - ) - assert.eq( - generate-unit-alt-description( - ..parse-unit("m^4/s/K").values(), - ), - "Meter hoch 4 pro Sekunde pro Kelvin", - ) - assert.eq( - generate-unit-alt-description( - ..parse-unit("mN m").values(), - ), - "Millinewton Meter", - ) - } - - set text(lang: "fr") - context { - assert.eq( - generate-unit-alt-description( - ..parse-unit("m^2/s^3/l^4").values(), - ), - "mètre carré par seconde cubique par litre à la puissance 4", - ) - assert.eq( - generate-unit-alt-description( - ..parse-unit("MN m").values(), - ), - "méganewton mètre", - ) - } -} diff --git a/src/num.typ b/src/num.typ index 3a564c1..b9434db 100644 --- a/src/num.typ +++ b/src/num.typ @@ -161,7 +161,7 @@ int: info.int, frac: info.frac, decimal-separator: it.decimal-separator, - uncertainty: info.pm, + pm: info.pm, e: info.e, base: it.base, ) diff --git a/tests/accessibility/.gitignore b/tests/accessibility/.gitignore new file mode 100644 index 0000000..2e4c5d8 --- /dev/null +++ b/tests/accessibility/.gitignore @@ -0,0 +1,5 @@ +# generated by tytanic, do not edit + +/diff/ +/out/ +/ref/ diff --git a/tests/accessibility/test.typ b/tests/accessibility/test.typ new file mode 100644 index 0000000..d420b1e --- /dev/null +++ b/tests/accessibility/test.typ @@ -0,0 +1,170 @@ +#import "/src/zero.typ": * +#set page(width: auto, height: auto, margin: 2pt) + + +#import "/src/units.typ": parse-unit +#import "/src/accessibility.typ": * +#import "/src/parsing.typ": parse-numeral +#import "/src/state.typ": num-state + + +#context { + assert.eq( + generate-num-alt-description( + parse-numeral("-1.34+-2e-2") + num-state.get(), + ), + "-1.34 plus minus 2 times 10 to the power of -2", + ) + assert.eq( + generate-num-alt-description( + parse-numeral("-1.34+0.5-2e-2") + num-state.get() + (base: 2), + ), + "-1.34 plus 0.5 minus 2 times 2 to the power of -2", + ) + + set text(lang: "de") + context { + assert.eq( + generate-num-alt-description( + parse-numeral("-1.34+-2e-2") + num-state.get() + (decimal-separator: ","), + ), + "-1,34 plus minus 2 mal 10 hoch -2", + ) + assert.eq( + generate-num-alt-description( + parse-numeral("-1.34+0.5-2e-2") + num-state.get() + (base: 2,decimal-separator: ","), + ), + "-1,34 plus 0,5 minus 2 mal 2 hoch -2", + ) + } + set text(lang: "fr") + context { + assert.eq( + generate-num-alt-description( + parse-numeral("-1.34+-2e-2") + num-state.get(), + ), + "-1.34 plus moins 2 fois 10 à la puissance -2", + ) + assert.eq( + generate-num-alt-description( + parse-numeral("-1.34+0.5-2e-2") + num-state.get() + (base: 2), + ), + "-1.34 plus 0.5 moins 2 fois 2 à la puissance -2", + ) + } + + set text(lang: "be") + context { + assert.eq( + generate-num-alt-description( + parse-numeral("-1.34+-2e-2") + num-state.get(), + ), + "-1.34 + − 2 × 10 ^ -2", + ) + assert.eq( + generate-num-alt-description( + parse-numeral("-1.34+0.5-2e-2") + num-state.get() + (base: 2), + ), + "-1.34 + 0.5 − 2 × 2 ^ -2", + ) + } + + + + + + + + assert(unit-component-description("mm") == "millimeter") + assert(unit-component-description("kg") == "kilogram") + assert(unit-component-description("au") == "astronomical unit") + assert(unit-component-description("aau") == "attoastronomical unit") // silly but it should work + + assert.eq( + generate-unit-alt-description( + ..parse-unit("m").values(), + ), + "meter", + ) + assert.eq( + generate-unit-alt-description( + ..parse-unit("m^2/s^3").values(), + ), + "meter squared per second cubed", + ) + + set text(lang: "de") + context { + assert.eq( + generate-unit-alt-description( + ..parse-unit("m^2/µs^3").values(), + ), + "Quadratmeter pro Kubikmikrosekunde", + ) + assert.eq( + generate-unit-alt-description( + ..parse-unit("m^4/s/K").values(), + ), + "Meter hoch 4 pro Sekunde pro Kelvin", + ) + assert.eq( + generate-unit-alt-description( + ..parse-unit("mN m").values(), + ), + "Millinewton Meter", + ) + } + + set text(lang: "fr") + context { + assert.eq( + generate-unit-alt-description( + ..parse-unit("m^2/s^3/l^4").values(), + ), + "mètre carré par seconde cubique par litre à la puissance 4", + ) + assert.eq( + generate-unit-alt-description( + ..parse-unit("MN m").values(), + ), + "méganewton mètre", + ) + } +} + + + + + + +// #set document(title: "title") +// // Custom description +// #num(alt: "asd")[1.34] + +// hELLO +// // Auto description +// #num[-1.34+-2] +// #num[-8.34(3)] +// #num[1.34+-2e-2] + +// #set text(lang: "fr") +// #num[-1.34+-2] +// #num[-8.34(3)] +// #num[1.34+-2e-2] +// #zi.m-s2() + +// #set text(lang: "CA") +// #num[-1.34+-2] +// #num[-8.34(3)] +// #num[1.34+-2e-2] + + + +// // Custom translation +// #num(alt: ( +// "times": "mul", +// "power": "pow", +// "plus": "plu", +// "minus": "min", +// ))[1.34+-3e-7] +// #math.equation(alt: "high", $a+b$) From 2d4e8681883045c568c262732a015a259c688083 Mon Sep 17 00:00:00 2001 From: Mc-Zen <52877387+Mc-Zen@users.noreply.github.com> Date: Sat, 20 Jun 2026 19:45:21 +0200 Subject: [PATCH 10/24] [add] pronunciation of minus sign via minus translation --- src/accessibility.typ | 2 +- tests/accessibility/test.typ | 17 +++++++++-------- 2 files changed, 10 insertions(+), 9 deletions(-) diff --git a/src/accessibility.typ b/src/accessibility.typ index 791a320..2056624 100644 --- a/src/accessibility.typ +++ b/src/accessibility.typ @@ -339,7 +339,7 @@ } let description = "" - description += if info.sign == "-" { "-" } + description += if info.sign == "-" { translation.minus + " " } description += format-comma-number(info.int, info.frac) if info.pm != none { diff --git a/tests/accessibility/test.typ b/tests/accessibility/test.typ index d420b1e..08a2fbc 100644 --- a/tests/accessibility/test.typ +++ b/tests/accessibility/test.typ @@ -13,13 +13,13 @@ generate-num-alt-description( parse-numeral("-1.34+-2e-2") + num-state.get(), ), - "-1.34 plus minus 2 times 10 to the power of -2", + "minus 1.34 plus minus 2 times 10 to the power of -2", ) assert.eq( generate-num-alt-description( parse-numeral("-1.34+0.5-2e-2") + num-state.get() + (base: 2), ), - "-1.34 plus 0.5 minus 2 times 2 to the power of -2", + "minus 1.34 plus 0.5 minus 2 times 2 to the power of -2", ) set text(lang: "de") @@ -28,13 +28,13 @@ generate-num-alt-description( parse-numeral("-1.34+-2e-2") + num-state.get() + (decimal-separator: ","), ), - "-1,34 plus minus 2 mal 10 hoch -2", + "minus 1,34 plus minus 2 mal 10 hoch -2", ) assert.eq( generate-num-alt-description( parse-numeral("-1.34+0.5-2e-2") + num-state.get() + (base: 2,decimal-separator: ","), ), - "-1,34 plus 0,5 minus 2 mal 2 hoch -2", + "minus 1,34 plus 0,5 minus 2 mal 2 hoch -2", ) } set text(lang: "fr") @@ -43,13 +43,13 @@ generate-num-alt-description( parse-numeral("-1.34+-2e-2") + num-state.get(), ), - "-1.34 plus moins 2 fois 10 à la puissance -2", + "moins 1.34 plus moins 2 fois 10 à la puissance -2", ) assert.eq( generate-num-alt-description( parse-numeral("-1.34+0.5-2e-2") + num-state.get() + (base: 2), ), - "-1.34 plus 0.5 moins 2 fois 2 à la puissance -2", + "moins 1.34 plus 0.5 moins 2 fois 2 à la puissance -2", ) } @@ -59,13 +59,13 @@ generate-num-alt-description( parse-numeral("-1.34+-2e-2") + num-state.get(), ), - "-1.34 + − 2 × 10 ^ -2", + "− 1.34 + − 2 × 10 ^ -2", ) assert.eq( generate-num-alt-description( parse-numeral("-1.34+0.5-2e-2") + num-state.get() + (base: 2), ), - "-1.34 + 0.5 − 2 × 2 ^ -2", + "− 1.34 + 0.5 − 2 × 2 ^ -2", ) } @@ -168,3 +168,4 @@ // "minus": "min", // ))[1.34+-3e-7] // #math.equation(alt: "high", $a+b$) +if necessary \ No newline at end of file From 4d38d7773ba50a5696b1dbe0d03c0a9e80d0e398 Mon Sep 17 00:00:00 2001 From: Mc-Zen <52877387+Mc-Zen@users.noreply.github.com> Date: Sat, 20 Jun 2026 20:16:41 +0200 Subject: [PATCH 11/24] [add] translation contribution guide --- ...ribute a new language for accessibility.md | 61 +++++++++++++++++++ 1 file changed, 61 insertions(+) create mode 100644 docs/how to contribute a new language for accessibility.md diff --git a/docs/how to contribute a new language for accessibility.md b/docs/how to contribute a new language for accessibility.md new file mode 100644 index 0000000..e540f82 --- /dev/null +++ b/docs/how to contribute a new language for accessibility.md @@ -0,0 +1,61 @@ +## How to contribute a new language for accessibility +Zero generates accessible output! Numbers, units, and quantities that are formatted with Zero have automatically generated alt descriptions that specify how a screen reader should read them. + +This is supported for a selection of languages and you can extend this selection by opening a PR and providing the necessary translations for the new language. When opening a PR, please read this document carefully and answer all questions below. + +### Translations for numbers +In order to provide descriptions for numerals, add an entry for the new language in the file `translations.typ` and specify translations for the following keys +- `times` +- `power` + +These two determine how multiplication and exponentiation is pronounced in the language. This is used for exponent notation, e.g. `num[1e5]` is read as `1 times 10 to the power of 5` when the language is set to English. +- `plus` +- `minus` + +These two are used to read uncertainties, e.g. `num[10+-2]` is read as `10 plus minus 2` when the language is set to English. The translation of `minus` is also used for the sign, e.g. `num[-5]` becomes `minus 5`. + +> [!IMPORTANT] +> Does the translation work for both symmetric and asymmetric uncertainties? +> - `num[10+-2]` → `10 [plus] [minus] 2` +> - `num[10+3-2]` → `10 [plus] 3 [minus] 2` + + +> [!IMPORTANT] +> Does the translation for `minus` also work for the sign in front of the number? + +- `per` + +For every unit component in the denominator of a compound unit, this word is prepended, e.g. `meter per second`. + +### Translations of prefixes +Please provide translations of all common prefixes like milli, giga etc. +Prefixes that are the same as in English should be left unspecified; they will automatically be inherited from the English translation. + +### Translations of base units +Please provide translations of all base units where they deviate from the English translation. + +### Power Shorthands +> [!IMPORTANT] +> Many languages have dedicated shorthand expressions for certain powers like `newton squared` instead of `newton to the power of two`. If your language has such shorthands, you can define them in `power-shorthands`. + +```typ +#let power-shorthands = ( + en: ( + "2": "squared", + "3": "cubed", + ), + de: ( + "2": unit => "Quadrat" + lower(unit), + "3": unit => "Kubik" + lower(unit), + ), +) +``` +Each entry defines one shorthand for a specific exponent. If the shorthand is just added as a separate word after the unit as in `newton squared`, it is sufficient to provide a string. If the necessary modification is more complicated, the shorthand can also be given as a function that takes the base unit as argument. + +### Take joining prefixes with units + + +> [!IMPORTANT] +> How are prefixes (like centi) joined to units? + +In most languages, the prefix is just joined with the unit, e.g. `cm` becomes `centimeter`. However, in German for example, it is necessary to transform the unit to lower case. In such an instance, modify the function `join-prefix-unit` to cover the case of the new language accordingly. \ No newline at end of file From 646d967205c85e5df60b21f318eeb24db77a6661 Mon Sep 17 00:00:00 2001 From: Mc-Zen <52877387+Mc-Zen@users.noreply.github.com> Date: Tue, 23 Jun 2026 08:55:14 +0200 Subject: [PATCH 12/24] [improve] language contribution guide --- ...how to contribute a new language for accessibility.md | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/docs/how to contribute a new language for accessibility.md b/docs/how to contribute a new language for accessibility.md index e540f82..a38d3f8 100644 --- a/docs/how to contribute a new language for accessibility.md +++ b/docs/how to contribute a new language for accessibility.md @@ -52,10 +52,13 @@ Please provide translations of all base units where they deviate from the Englis ``` Each entry defines one shorthand for a specific exponent. If the shorthand is just added as a separate word after the unit as in `newton squared`, it is sufficient to provide a string. If the necessary modification is more complicated, the shorthand can also be given as a function that takes the base unit as argument. -### Take joining prefixes with units - +### Joining prefixes with units > [!IMPORTANT] > How are prefixes (like centi) joined to units? -In most languages, the prefix is just joined with the unit, e.g. `cm` becomes `centimeter`. However, in German for example, it is necessary to transform the unit to lower case. In such an instance, modify the function `join-prefix-unit` to cover the case of the new language accordingly. \ No newline at end of file +In most languages, the prefix is just joined with the unit, e.g. `cm` becomes `centimeter`. However, in German for example, it is necessary to transform the unit to lower case. In such an instance, modify the function `join-prefix-unit` to cover the case of the new language accordingly. + + +### Anything else? +Is there anything else that the new language requires? Mind that the descriptions might necessarily be a bit simplified, it is for example infeasible to adapt units to the grammatical case of surrounding text. Nonetheless, the infrastructure of Zero regarding accessibility can be extended to accommodate needs for languages that were not considered during its conception. From fda6be6ec577e9b2d1575ef32fae089e9eeefedf Mon Sep 17 00:00:00 2001 From: Mc-Zen <52877387+Mc-Zen@users.noreply.github.com> Date: Tue, 23 Jun 2026 08:57:13 +0200 Subject: [PATCH 13/24] [add] alt parameter to unit --- src/units.typ | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/src/units.typ b/src/units.typ index a8aceed..9491749 100644 --- a/src/units.typ +++ b/src/units.typ @@ -187,6 +187,7 @@ unit-separator: sym.space.thin, /// Whether to display a square root symbol when the exponent is 1/2. use-sqrt: true, + alt: auto, /// Unprocessed arguments. ..args, ) = { @@ -196,10 +197,11 @@ + fraction + ". Expected \"power\", \"fraction\", or \"inline\"", ) + if alt == auto { + alt = generate-unit-alt-description(numerator, denominator) + } - let equation = std.math.equation.with( - alt: generate-unit-alt-description(numerator, denominator) - ) + let equation = std.math.equation.with(alt: alt) let fold-units = fold-units.with( unit-separator: unit-separator + sym.wj, @@ -245,6 +247,7 @@ #let unit( unit, + alt: auto, ..args, ) = context { let args = (unit: args.named()) @@ -258,6 +261,7 @@ unit.denominator, ..num-state.unit, math: num-state.math, + alt: alt, )) result } @@ -268,6 +272,7 @@ #let qty( value, unit, + alt: auto, ..args, ) = context { let unit = unit @@ -329,7 +334,8 @@ fraction: num-state.unit.fraction, unit-separator: num-state.unit.unit-separator, math: num-state.math, - use-sqrt: num-state.unit.use-sqrt + use-sqrt: num-state.unit.use-sqrt, + alt: alt, ) } From 3b8d121e9e0eb2efb76517114af895089df7e78c Mon Sep 17 00:00:00 2001 From: Mc-Zen <52877387+Mc-Zen@users.noreply.github.com> Date: Tue, 23 Jun 2026 08:57:42 +0200 Subject: [PATCH 14/24] [fix] prefix generation --- src/units.typ | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/src/units.typ b/src/units.typ index 9491749..7143122 100644 --- a/src/units.typ +++ b/src/units.typ @@ -301,18 +301,18 @@ if eng != 0 { let prefixes = ( - "3": [k], - "6": [M], - "9": [G], - "12": [T], - "15": [P], - "18": [E], - "−3": [m], - "−6": [#sym.mu], - "−9": [n], - "−12": [p], - "−15": [f], - "−18": [a], + "3": "k", + "6": "M", + "9": "G", + "12": "T", + "15": "P", + "18": "E", + "−3": "m", + "−6": sym.mu, + "−9": "n", + "−12": "p", + "−15": "f", + "−18": "a", ) From d80cf4af753c253934198cf8b1021ffa102e59c8 Mon Sep 17 00:00:00 2001 From: Mc-Zen <52877387+Mc-Zen@users.noreply.github.com> Date: Tue, 23 Jun 2026 08:58:22 +0200 Subject: [PATCH 15/24] [refactor] naming --- src/accessibility.typ | 79 +++++++++++++++++++++---------------------- src/zi.typ | 6 ++-- 2 files changed, 42 insertions(+), 43 deletions(-) diff --git a/src/accessibility.typ b/src/accessibility.typ index 2056624..eb99757 100644 --- a/src/accessibility.typ +++ b/src/accessibility.typ @@ -1,6 +1,6 @@ #import "parsing.typ": content-to-string -#let translations = ( +#let phrases = ( "fallback": ( "times": "×", "power": "^", @@ -285,7 +285,7 @@ ), ) -#let special-powers = ( +#let power-shorthands = ( en: ( "2": "squared", "3": "cubed", @@ -332,32 +332,26 @@ #let generate-num-alt-description(info, translation: auto) = { let lang = text.lang if translation == auto { - translation = translations.at(lang, default: translations.fallback) + translation = phrases.at(lang, default: phrases.fallback) } let format-comma-number(int, frac) = { int + if frac != "" { info.decimal-separator + frac } } - let description = "" - description += if info.sign == "-" { translation.minus + " " } - description += format-comma-number(info.int, info.frac) + let alt = "" + alt += if info.sign == "-" { translation.minus + " " } + alt += format-comma-number(info.int, info.frac) if info.pm != none { if type(info.pm.first()) == array { - description += ( - " " - + translation.plus - + " " - + format-comma-number(..info.pm.first()) + alt += ( + " " + translation.plus + " " + format-comma-number(..info.pm.first()) ) - description += ( - " " - + translation.minus - + " " - + format-comma-number(..info.pm.last()) + alt += ( + " " + translation.minus + " " + format-comma-number(..info.pm.last()) ) } else { - description += ( + alt += ( " " + translation.plus + " " @@ -368,7 +362,7 @@ } } if info.e != none { - description += ( + alt += ( " " + translation.times + " " @@ -380,7 +374,7 @@ ) } - description + alt } #let join-prefix-unit(prefix, unit, lang) = { @@ -393,22 +387,25 @@ #let unit-component-description(component) = { let units = units.en + units.at(text.lang, default: units.en) - if component in units { - return units.at(component) - } - if component.len() > 1 { - let prefixes = prefixes.en + prefixes.at(text.lang, default: prefixes.en) - let clusters = component.clusters() - let prefix = clusters.at(0) - let unit = clusters.slice(1).join() - if prefix in prefixes and unit in units { - return join-prefix-unit(prefixes.at(prefix), units.at(unit), text.lang) + if type(component) in (symbol, str) { + component = str(component) + if component in units { + return units.at(component) + } + if component.len() > 1 { + let prefixes = prefixes.en + prefixes.at(text.lang, default: prefixes.en) + let clusters = component.clusters() + let prefix = clusters.at(0) + let unit = clusters.slice(1).join() + if prefix in prefixes and unit in units { + return join-prefix-unit(prefixes.at(prefix), units.at(unit), text.lang) + } } } assert( false, message: "Failed to auto-generate alt description for unit component " - + component + + repr(component) + ". Please provide a manual alt text for this unit.", ) } @@ -420,35 +417,36 @@ ) = { let lang = text.lang if translation == auto { - translation = translations.at(lang, default: translations.fallback) + translation = phrases.at(lang, default: phrases.fallback) assert( - lang in translations, + lang in phrases, message: "Unsupported language " + lang + " for alt text generation. Please provide a manual alt text for this unit. Supported languages are: " - + repr(translations.keys()) + + repr(phrases.keys()) + ". If you want to contribute a translation for your language, please open a pull request.", ) } - let alt = "" - let special-powers = special-powers.at(lang, default: (:)) + let power-shorthands = power-shorthands.at(lang, default: (:)) + let power-of-unit-component-description((unit-component, exponent)) = { let unit = unit-component-description(unit-component) if exponent == "1" { return unit } - if exponent in special-powers { - let special-power = special-powers.at(exponent) - if type(special-power) == function { - unit = special-power(unit) + if exponent in power-shorthands { + let power-shorthand = power-shorthands.at(exponent) + if type(power-shorthand) == function { + unit = power-shorthand(unit) } else { - unit += " " + special-power + unit += " " + power-shorthand } } else { unit += " " + translation.power + " " + exponent } unit } + alt += numerator.map(power-of-unit-component-description).join(" ") if denominator.len() > 0 { alt += " " + translation.per + " " @@ -456,6 +454,7 @@ .map(power-of-unit-component-description) .join(" " + translation.per + " ") } + alt } diff --git a/src/zi.typ b/src/zi.typ index 34a3acf..89a266d 100644 --- a/src/zi.typ +++ b/src/zi.typ @@ -1,12 +1,12 @@ #import "units.typ" -#let declare(..unit) = { +#let declare(alt: auto, ..unit) = { assert(unit.named().len() == 0, ) (..value) => if value.pos().len() == 0 { - units.unit(units.parse-unit(..unit.pos()), ..value) + units.unit(units.parse-unit(..unit.pos()), ..value, alt: alt) } else { - units.qty(..value, units.parse-unit(..unit.pos())) + units.qty(..value, units.parse-unit(..unit.pos()), alt: alt) } } From 325afb93781b5f6e5e524d7724c9334d065315a7 Mon Sep 17 00:00:00 2001 From: Mc-Zen <52877387+Mc-Zen@users.noreply.github.com> Date: Tue, 23 Jun 2026 08:58:56 +0200 Subject: [PATCH 16/24] [update] tests --- tests/accessibility/test.typ | 1 - tests/breakable/ref.typ | 4 ++-- tests/breakable/test.typ | 2 +- tests/zi/basic/ref.typ | 6 +++--- tests/zi/basic/test.typ | 6 +++--- tests/zi/declare-advanced/test.typ | 6 +++--- tests/zi/extend-zi-namespace/ref.typ | 2 +- tests/zi/extend-zi-namespace/test.typ | 2 +- tests/zi/extend-zi-namespace/zi.typ | 2 +- 9 files changed, 15 insertions(+), 16 deletions(-) diff --git a/tests/accessibility/test.typ b/tests/accessibility/test.typ index 08a2fbc..8f20df9 100644 --- a/tests/accessibility/test.typ +++ b/tests/accessibility/test.typ @@ -168,4 +168,3 @@ // "minus": "min", // ))[1.34+-3e-7] // #math.equation(alt: "high", $a+b$) -if necessary \ No newline at end of file diff --git a/tests/breakable/ref.typ b/tests/breakable/ref.typ index 8472607..4dfc70c 100644 --- a/tests/breakable/ref.typ +++ b/tests/breakable/ref.typ @@ -108,6 +108,6 @@ #set page(width: 9em, height: auto, margin: .5em) -Unit of force is $"kg"th"m"th"stel"th"A"th"A"th"s"^(-2)th"mol"^(-1)$ +Unit of force is $"kg"th"m"th"sr"th"A"th"A"th"s"^(-2)th"mol"^(-1)$ -Unit of force is \ #box(width: 10cm)[kg#th;m#th;stel#th;A#th;A#th;s#super[−2]#th;mol#super[−1]] \ \ No newline at end of file +Unit of force is \ #box(width: 10cm)[kg#th;m#th;sr#th;A#th;A#th;s#super[−2]#th;mol#super[−1]#th;N] \ \ No newline at end of file diff --git a/tests/breakable/test.typ b/tests/breakable/test.typ index f25933c..d9ba81d 100644 --- a/tests/breakable/test.typ +++ b/tests/breakable/test.typ @@ -76,7 +76,7 @@ #set-num(tight: false, math: true) -#let long-unit = zi.declare("kg m/s^2/mol/N stel A A") +#let long-unit = zi.declare("kg m/s^2/mol/N sr A A") #set-num(breakable: false) diff --git a/tests/zi/basic/ref.typ b/tests/zi/basic/ref.typ index daf0c09..961d5df 100644 --- a/tests/zi/basic/ref.typ +++ b/tests/zi/basic/ref.typ @@ -75,9 +75,9 @@ $(0.20plus.minus 0.11)×10^1th"m"th"s"^(-1)$ \ #pagebreak() $"m"\/"s"$ \ -$"m"^3th√"b"th"c"^(-0.5)$ \ -$("m"^3th√"b")/√"c"$ \ -$"m"^3th√"b"\/√"c"$ \ +$"m"^3th√"s"th"N"^(-0.5)$ \ +$("m"^3th√"s")/√"N"$ \ +$"m"^3th√"s"\/√"N"$ \ #pagebreak() diff --git a/tests/zi/basic/test.typ b/tests/zi/basic/test.typ index 828450b..1900134 100644 --- a/tests/zi/basic/test.typ +++ b/tests/zi/basic/test.typ @@ -91,9 +91,9 @@ #set-unit(use-sqrt: true, fraction: "inline") #zi.m-s() \ -#zi.declare("m^3 b^0.5/c^0.5")(fraction: "power") \ -#zi.declare("m^3 b^0.5/c^0.5")(fraction: "fraction") \ -#zi.declare("m^3 b^0.5/c^0.5")(fraction: "inline") \ +#zi.declare("m^3 s^0.5/N^0.5")(fraction: "power") \ +#zi.declare("m^3 s^0.5/N^0.5")(fraction: "fraction") \ +#zi.declare("m^3 s^0.5/N^0.5")(fraction: "inline") \ #pagebreak() diff --git a/tests/zi/declare-advanced/test.typ b/tests/zi/declare-advanced/test.typ index dd8ece4..52cea40 100644 --- a/tests/zi/declare-advanced/test.typ +++ b/tests/zi/declare-advanced/test.typ @@ -3,7 +3,7 @@ #set-unit(fraction: "inline") -#zi.declare(($Pi$, 2))() \ -#zi.declare($M_dot.o$, ("s", -2), ($β$, -2))() \ -#zi.declare(($M_dot.o$, -1), ("s", -0.5), ($β$, "-a"))() \ +#zi.declare(($Pi$, 2), alt: "Pi")() \ +#zi.declare($M_dot.o$, ("s", -2), ($β$, -2), alt: "Solar masses per seconds squared per beta squared")() \ +#zi.declare(($M_dot.o$, -1), ("s", -0.5), ($β$, "-a"), alt: "")() \ #zi.declare((sym.prime.double, 2))() \ diff --git a/tests/zi/extend-zi-namespace/ref.typ b/tests/zi/extend-zi-namespace/ref.typ index 43f41fb..3eafcad 100644 --- a/tests/zi/extend-zi-namespace/ref.typ +++ b/tests/zi/extend-zi-namespace/ref.typ @@ -2,4 +2,4 @@ $1#sym.space.thin"fs"$ \ -$1#sym.space.thin"µZero"$ \ \ No newline at end of file +$1#sym.space.thin"µN"$ \ \ No newline at end of file diff --git a/tests/zi/extend-zi-namespace/test.typ b/tests/zi/extend-zi-namespace/test.typ index 80f726f..8c0860d 100644 --- a/tests/zi/extend-zi-namespace/test.typ +++ b/tests/zi/extend-zi-namespace/test.typ @@ -2,4 +2,4 @@ #import "zi.typ" #zi.fs[1] \ -#zi.µZ[1] \ +#zi.µN[1] \ diff --git a/tests/zi/extend-zi-namespace/zi.typ b/tests/zi/extend-zi-namespace/zi.typ index 92be500..3a78a6b 100644 --- a/tests/zi/extend-zi-namespace/zi.typ +++ b/tests/zi/extend-zi-namespace/zi.typ @@ -2,5 +2,5 @@ #let fs = declare("fs") -#let µZ = declare("muZero") +#let µN = declare("muN") From afbf98459d0a04807e3f3c3423951f7aeef77936 Mon Sep 17 00:00:00 2001 From: Mc-Zen <52877387+Mc-Zen@users.noreply.github.com> Date: Tue, 23 Jun 2026 09:11:38 +0200 Subject: [PATCH 17/24] [refactor] liter implementation --- src/units.typ | 15 +++++++++++++++ src/zi.typ | 9 ++++----- 2 files changed, 19 insertions(+), 5 deletions(-) diff --git a/src/units.typ b/src/units.typ index 7143122..2485542 100644 --- a/src/units.typ +++ b/src/units.typ @@ -124,6 +124,17 @@ (numerator: numerator, denominator: denominator) } + + + +#let liter-impl = context { + if not num-state.get().unit.lowercase-liter { + "L" + } else { + "l" + } +} + #let format-unit-power(unit, exponent, math: true, negative: false) = { if type(exponent) in (int, float) { exponent = str(exponent) @@ -133,6 +144,10 @@ if negative { exponent = sym.minus + exponent } + + if type(unit) == str and unit.ends-with("L") { + unit = unit.replace("L", "") + liter-impl + } if exponent in (1, [1], "1") { unit diff --git a/src/zi.typ b/src/zi.typ index 89a266d..cb331c4 100644 --- a/src/zi.typ +++ b/src/zi.typ @@ -76,7 +76,6 @@ #let W = watt #let Wb = weber - #let astronomicalunit = declare("au") #let bel = declare("B") #let dalton = declare("Da") @@ -86,7 +85,7 @@ #let electronvolt = declare("eV") #let hectare = declare("ha") #let hour = declare("h") -#let liter = declare(units.liter-impl) +#let liter = declare("L") #let arcminute = declare(sym.prime) #let minute = declare("min") #let arcsecond = declare(sym.prime.double) @@ -160,9 +159,9 @@ #let kW = declare("kW") #let mW = declare("mW") #let mSv = declare("mSv") -#let hL = declare("h" + units.liter-impl) -#let mL = declare("m" + units.liter-impl) -#let µL = declare("µ" + units.liter-impl) +#let hL = declare("h" + "L") +#let mL = declare("m" + "L") +#let µL = declare("µ" + "L") #let uL = µL #let meV = declare("meV") #let keV = declare("keV") From 4c9e898e4e0f1aea029164643cc314cf9ba0aee2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Santtu=20S=C3=B6derholm?= Date: Sun, 28 Jun 2026 11:05:57 +0300 Subject: [PATCH 18/24] [add] Finnish translations (#88) --- src/accessibility.typ | 76 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 76 insertions(+) diff --git a/src/accessibility.typ b/src/accessibility.typ index eb99757..00d49e0 100644 --- a/src/accessibility.typ +++ b/src/accessibility.typ @@ -50,6 +50,13 @@ "minus": "минус", "per": "в", ), + "fi": ( + "times": "kertaa", + "power": "potenssiin", + "plus": "plus", + "minus": "miinus", + "per": "jaettuna", + ), ) #let prefixes = ( @@ -120,6 +127,24 @@ k: "chilo", h: "etto", ), + fi: ( + a: "atto", + f: "femto", + p: "piko", + n: "nano", + µ: "mikro", + m: "milli", + c: "sentti", + d: "desi", + da: "deka", + h: "hehto", + k: "kilo", + M: "mega", + G: "giga", + T: "tera", + P: "peta", + E: "eksa", + ) ) #let units = ( @@ -283,6 +308,53 @@ sym.prime.double: "secondo d'arco", sym.prime: "minuto d'arco", ), + fi: ( + A: "ampeeria", + au: "astronomista yksikköä", + B: "beliä", + Bq: "becquereliä", + C: "coulombia", + cd: "kandelaa", + d: "päivää", + Da: "daltonia", + dB: "desibeliä", + sym.degree: "astetta", + sym.degree + "C": "Celsiusastetta", + eV: "elektronivolttia", + F: "faradia", + g: "grammaa", + Gy: "graytä", + H: "henryä", + h: "tuntia", + ha: "hehtaaria", + Hz: "hertsiä", + J: "joulea", + K: "kelviniä", + kat: "katalia", + kg: "kilogrammaa", + L: "litraa", + lm: "luumenia", + lx: "luksia", + m: "metriä", + sym.prime: "kaariminuuttia", + min: "minuuttia", + mol: "moolia", + sym.prime.double: "kaarisekuntia", + N: "newtonia", + Np: "neperiä", + sym.Omega: "ohmia", + Pa: "pascalia", + rad: "radiaania", + s: "sekuntia", + S: "siemensiä", + sr: "steradiaania", + Sv: "sieverttiä", + t: "tonnia", + T: "teslaa", + V: "volttia", + W: "wattia", + Wb: "weberiä", + ) ) #let power-shorthands = ( @@ -306,6 +378,10 @@ "2": "al quadrato", "3": "al cubo", ), + fi: ( + "2": "toiseen", + "3": "kolmanteen", + ), ) From 2a50aed0d4bb659163e10965b0a7a437d74c3ed5 Mon Sep 17 00:00:00 2001 From: Mc-Zen <52877387+Mc-Zen@users.noreply.github.com> Date: Sun, 28 Jun 2026 10:12:34 +0200 Subject: [PATCH 19/24] [change] don't panic for unsupported languages Instead set alt description to none --- src/accessibility.typ | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/src/accessibility.typ b/src/accessibility.typ index 00d49e0..8f7e9d6 100644 --- a/src/accessibility.typ +++ b/src/accessibility.typ @@ -494,14 +494,17 @@ let lang = text.lang if translation == auto { translation = phrases.at(lang, default: phrases.fallback) - assert( - lang in phrases, - message: "Unsupported language " - + lang - + " for alt text generation. Please provide a manual alt text for this unit. Supported languages are: " - + repr(phrases.keys()) - + ". If you want to contribute a translation for your language, please open a pull request.", - ) + if lang not in phrases { + return none + } + // assert( + // lang in phrases, + // message: "Unsupported language " + // + lang + // + " for alt text generation. Please provide a manual alt text for this unit. Supported languages are: " + // + repr(phrases.keys()) + // + ". If you want to contribute a translation for your language, please open a pull request.", + // ) } let alt = "" From d6310f0675b9fbb1aa388fab563e41dd1dd92715 Mon Sep 17 00:00:00 2001 From: Mc-Zen <52877387+Mc-Zen@users.noreply.github.com> Date: Sun, 28 Jun 2026 11:45:47 +0200 Subject: [PATCH 20/24] [add] pluraliziation --- src/accessibility.typ | 100 ++++++++++++++++++++++++++++++++--- src/units.typ | 8 ++- tests/accessibility/test.typ | 76 ++++++++++++++++++++++---- 3 files changed, 166 insertions(+), 18 deletions(-) diff --git a/src/accessibility.typ b/src/accessibility.typ index 8f7e9d6..121dead 100644 --- a/src/accessibility.typ +++ b/src/accessibility.typ @@ -144,7 +144,7 @@ T: "tera", P: "peta", E: "eksa", - ) + ), ) #let units = ( @@ -304,6 +304,8 @@ min: "minuto", s: "secondo", d: "giorno", + rad: "radiante", + sr: "steradiante", au: "unità astronomica", sym.prime.double: "secondo d'arco", sym.prime: "minuto d'arco", @@ -354,7 +356,7 @@ V: "volttia", W: "wattia", Wb: "weberiä", - ) + ), ) #let power-shorthands = ( @@ -384,6 +386,73 @@ ), ) +#let pluralize = ( + en: unit => { + let singular = units.en.at(unit) + if unit in ("lx", "Hz", "S") { + return singular + } + if unit == sym.degree + "C" { + return "degrees Celsius" + } + if unit == "H" { + return "henries" + } + return singular + "s" + }, + de: unit => { + let singular = units.de.at(unit) + if unit in ("h", "min", "s", sym.prime.double, sym.prime, "t") { + return singular + "n" + } + if unit == "au" { + return singular + "en" + } + if unit == "d" { + return singular + "e" + } + + return singular + }, + + fr: unit => { + let singular = units.fr.at(unit, default: units.en.at(unit)) + if unit in ("lx", "Hz", "S") { + return singular + } + if unit == sym.degree + "C" { return "degrés Celsius" } + if unit == sym.prime.double { return "secondes d'arc" } + if unit == sym.prime { return "minutes d'arc" } + if unit == "au" { return "unités astronomique" } + return singular + "s" + }, + es: unit => { + let singular = units.es.at(unit, default: units.en.at(unit)) + if unit in ("lx", "Hz", "S") { + return singular + } + if unit == sym.degree + "C" { return "grados Celsius" } + if unit == "rad" { return "radianes" } + if unit == "sr" { return "esterorradianes" } + if ( + singular.endswith("o") or singular.endswith("y") or singular.endswith("a") + ) { + return singular + "s" + } else { + return singular + "es" + } + }, + it: unit => { + let singular = units.it.at(unit, default: units.en.at(unit)) + if unit in ("J", "A", "T") { return singular } + if unit == sym.degree + "C" { return "gradi Celsius" } + if unit.endswith("o") or unit.endswith("e") { + return singular.slice(0, -1) + "i" + } + if unit.endswith("a") { return singular.slice(0, -1) + "e" } + return singular + }, +) #let base-to-string(base) = { if type(base) == str { @@ -461,7 +530,14 @@ } } -#let unit-component-description(component) = { +#let needs-plural(value, lang) = { + if lang == "fr" { + return calc.abs(value) >= 2 + } + calc.abs(value) != 1 +} + +#let unit-component-description(component, plural: false) = { let units = units.en + units.at(text.lang, default: units.en) if type(component) in (symbol, str) { component = str(component) @@ -490,6 +566,7 @@ numerator, denominator, translation: auto, + value: 1 ) = { let lang = text.lang if translation == auto { @@ -510,8 +587,12 @@ let alt = "" let power-shorthands = power-shorthands.at(lang, default: (:)) - let power-of-unit-component-description((unit-component, exponent)) = { - let unit = unit-component-description(unit-component) + let power-of-unit-component-description((unit-component, exponent), plural: false) = { + let unit = unit-component-description(unit-component, plural: plural) + if plural { + let pluralize = pluralize.at(lang) + unit = pluralize(unit-component) + } if exponent == "1" { return unit } if exponent in power-shorthands { let power-shorthand = power-shorthands.at(exponent) @@ -526,7 +607,14 @@ unit } - alt += numerator.map(power-of-unit-component-description).join(" ") + + let plural = needs-plural(value, lang) + if numerator.len() > 0 { + alt += ( + numerator.slice(0, -1).map(power-of-unit-component-description) + + (power-of-unit-component-description(numerator.at(-1), plural: plural),) + ).join(" ") + } if denominator.len() > 0 { alt += " " + translation.per + " " alt += denominator diff --git a/src/units.typ b/src/units.typ index 2485542..991fc8b 100644 --- a/src/units.typ +++ b/src/units.typ @@ -203,6 +203,9 @@ /// Whether to display a square root symbol when the exponent is 1/2. use-sqrt: true, alt: auto, + + // if part of quantity + value: 1, /// Unprocessed arguments. ..args, ) = { @@ -213,7 +216,7 @@ + ". Expected \"power\", \"fraction\", or \"inline\"", ) if alt == auto { - alt = generate-unit-alt-description(numerator, denominator) + alt = generate-unit-alt-description(numerator, denominator, value: value) } let equation = std.math.equation.with(alt: alt) @@ -307,10 +310,10 @@ separator = none } + let info = parse-numeral(value) if num-state.unit.prefix == auto and num-state.exponent == "eng" { num-state.prefixed-eng = true - let info = parse-numeral(value) let e = if info.e == none { 0 } else { int(info.e) } let eng = compute-eng-digits(info) @@ -351,6 +354,7 @@ math: num-state.math, use-sqrt: num-state.unit.use-sqrt, alt: alt, + value: float(info.int + "." + info.frac) ) } diff --git a/tests/accessibility/test.typ b/tests/accessibility/test.typ index 8f20df9..1b3e4e7 100644 --- a/tests/accessibility/test.typ +++ b/tests/accessibility/test.typ @@ -26,13 +26,17 @@ context { assert.eq( generate-num-alt-description( - parse-numeral("-1.34+-2e-2") + num-state.get() + (decimal-separator: ","), + parse-numeral("-1.34+-2e-2") + + num-state.get() + + (decimal-separator: ","), ), "minus 1,34 plus minus 2 mal 10 hoch -2", ) assert.eq( generate-num-alt-description( - parse-numeral("-1.34+0.5-2e-2") + num-state.get() + (base: 2,decimal-separator: ","), + parse-numeral("-1.34+0.5-2e-2") + + num-state.get() + + (base: 2, decimal-separator: ","), ), "minus 1,34 plus 0,5 minus 2 mal 2 hoch -2", ) @@ -52,7 +56,7 @@ "moins 1.34 plus 0.5 moins 2 fois 2 à la puissance -2", ) } - + set text(lang: "be") context { assert.eq( @@ -68,12 +72,6 @@ "− 1.34 + 0.5 − 2 × 2 ^ -2", ) } - - - - - - assert(unit-component-description("mm") == "millimeter") assert(unit-component-description("kg") == "kilogram") @@ -93,6 +91,28 @@ "meter squared per second cubed", ) + // plural + assert.eq( + generate-unit-alt-description(..parse-unit("lx").values(), value: 2), + "lux", + ) + assert.eq( + generate-unit-alt-description(..parse-unit("°C").values(), value: 2), + "degrees Celsius", + ) + assert.eq( + generate-unit-alt-description(..parse-unit("m").values(), value: 2), + "meters", + ) + assert.eq( + generate-unit-alt-description(..parse-unit("1/m").values(), value: 2), + " per meter", + ) + assert.eq( + generate-unit-alt-description(..parse-unit("N s/m").values(), value: 2), + "newton seconds per meter", + ) + set text(lang: "de") context { assert.eq( @@ -113,6 +133,25 @@ ), "Millinewton Meter", ) + + // Plural + + assert.eq( + generate-unit-alt-description(..parse-unit("h").values(), value: 2), + "Stunden", + ) + assert.eq( + generate-unit-alt-description(..parse-unit("d").values(), value: 2), + "Tage", + ) + assert.eq( + generate-unit-alt-description(..parse-unit("T").values(), value: 2), + "Tesla", + ) + assert.eq( + generate-unit-alt-description(..parse-unit("au").values(), value: 2), + "astronomische Einheiten", + ) } set text(lang: "fr") @@ -129,6 +168,23 @@ ), "méganewton mètre", ) + + assert.eq( + generate-unit-alt-description(..parse-unit("h").values(), value: 2), + "heures", + ) + assert.eq( + generate-unit-alt-description(..parse-unit("h").values(), value: 1.5), + "heure", + ) + assert.eq( + generate-unit-alt-description(..parse-unit("h").values(), value: -0.5), + "heure", + ) + assert.eq( + generate-unit-alt-description(..parse-unit("°C").values(), value: 2), + "degrés Celsius", + ) } } @@ -151,7 +207,7 @@ // #num[-1.34+-2] // #num[-8.34(3)] // #num[1.34+-2e-2] -// #zi.m-s2() +#zi.m-s2[2] // #set text(lang: "CA") // #num[-1.34+-2] From a91777b833ceaa4621f9e57a642a805ae802df93 Mon Sep 17 00:00:00 2001 From: Mc-Zen <52877387+Mc-Zen@users.noreply.github.com> Date: Sun, 28 Jun 2026 13:13:23 +0200 Subject: [PATCH 21/24] [fix] pluralization --- src/accessibility.typ | 20 ++++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/src/accessibility.typ b/src/accessibility.typ index 121dead..4a9eca3 100644 --- a/src/accessibility.typ +++ b/src/accessibility.typ @@ -537,20 +537,28 @@ calc.abs(value) != 1 } + + #let unit-component-description(component, plural: false) = { - let units = units.en + units.at(text.lang, default: units.en) + let lang = text.lang + let units = units.en + units.at(lang, default: units.en) + let get-unit(unit-code) = { + if plural { (pluralize.at(lang))(unit-code) } + else { units.at(unit-code) } + } + if type(component) in (symbol, str) { component = str(component) if component in units { - return units.at(component) + return get-unit(component) } if component.len() > 1 { - let prefixes = prefixes.en + prefixes.at(text.lang, default: prefixes.en) + let prefixes = prefixes.en + prefixes.at(lang, default: prefixes.en) let clusters = component.clusters() let prefix = clusters.at(0) let unit = clusters.slice(1).join() if prefix in prefixes and unit in units { - return join-prefix-unit(prefixes.at(prefix), units.at(unit), text.lang) + return join-prefix-unit(prefixes.at(prefix), get-unit(unit), text.lang) } } } @@ -589,10 +597,6 @@ let power-of-unit-component-description((unit-component, exponent), plural: false) = { let unit = unit-component-description(unit-component, plural: plural) - if plural { - let pluralize = pluralize.at(lang) - unit = pluralize(unit-component) - } if exponent == "1" { return unit } if exponent in power-shorthands { let power-shorthand = power-shorthands.at(exponent) From 9b330c37b3875bb6188694af783a36722fbf5fb7 Mon Sep 17 00:00:00 2001 From: Mc-Zen <52877387+Mc-Zen@users.noreply.github.com> Date: Sun, 28 Jun 2026 16:35:55 +0200 Subject: [PATCH 22/24] [docs] for accessibility internals --- src/accessibility.typ | 63 +++++++++++++++++++++++++++++++++---------- src/units.typ | 50 ++++++++++++++++++++++------------ 2 files changed, 82 insertions(+), 31 deletions(-) diff --git a/src/accessibility.typ b/src/accessibility.typ index 4a9eca3..27471a8 100644 --- a/src/accessibility.typ +++ b/src/accessibility.typ @@ -359,6 +359,9 @@ ), ) +// Register of the shorthands for special powers that a language has. This +// can be a string that is appended to the constituent unit (with a space) +// or a function that takes the translated constituent unit. #let power-shorthands = ( en: ( "2": "squared", @@ -386,6 +389,8 @@ ), ) +// How to form the plural of a constituent unit. For each language, this should +// be a function that takes a unit symbol string, e.g. "Hz". #let pluralize = ( en: unit => { let singular = units.en.at(unit) @@ -400,6 +405,7 @@ } return singular + "s" }, + de: unit => { let singular = units.de.at(unit) if unit in ("h", "min", "s", sym.prime.double, sym.prime, "t") { @@ -426,6 +432,7 @@ if unit == "au" { return "unités astronomique" } return singular + "s" }, + es: unit => { let singular = units.es.at(unit, default: units.en.at(unit)) if unit in ("lx", "Hz", "S") { @@ -442,6 +449,7 @@ return singular + "es" } }, + it: unit => { let singular = units.it.at(unit, default: units.en.at(unit)) if unit in ("J", "A", "T") { return singular } @@ -454,6 +462,47 @@ }, ) + +// How to join a prefix to a unit. This can be overridden for individual languages. +#let join-prefix-unit( + /// The translated prefix, e.g. "milli" + /// -> str + prefix, + + /// The translated unit, e.g. "meter". + /// -> str + unit, + + /// The language code. + /// -> str + lang +) = { + if lang == "de" { + prefix + lower(unit) + } else { + prefix + unit + } +} + + +// Check whether a quantity requires the (composed) unit to be in plural. This +// can be overridden for individual languages. +#let needs-plural( + /// The value of the quantity, e.g. `1.5`. + /// -> float + value, + + /// The language code. + /// -> str + lang +) = { + if lang == "fr" { + return calc.abs(value) >= 2 + } + calc.abs(value) != 1 +} + + #let base-to-string(base) = { if type(base) == str { return base @@ -522,20 +571,6 @@ alt } -#let join-prefix-unit(prefix, unit, lang) = { - if lang == "de" { - prefix + lower(unit) - } else { - prefix + unit - } -} - -#let needs-plural(value, lang) = { - if lang == "fr" { - return calc.abs(value) >= 2 - } - calc.abs(value) != 1 -} diff --git a/src/units.typ b/src/units.typ index 991fc8b..55f6cb8 100644 --- a/src/units.typ +++ b/src/units.typ @@ -13,7 +13,8 @@ /// - Exponents are allowed as in "m^2" /// - A unit in the fraction can be specified either with a negative /// exponent "s^-1" or by adding a slash before "/s" -/// - Prefixes are allowed and should be prepended to the base unit without +/// - Prefixes are allowed and should be prepended to the +/// constituent unit without /// a space in between. Example: `"/mm^2"`. Occurrences of "mu" will be replaced /// by the greek mu symbol. /// Returns: a dictionary with the keys "numerator" and "denominator", @@ -139,7 +140,7 @@ if type(exponent) in (int, float) { exponent = str(exponent) } - + exponent = [#exponent] if negative { exponent = sym.minus + exponent @@ -148,7 +149,7 @@ if type(unit) == str and unit.ends-with("L") { unit = unit.replace("L", "") + liter-impl } - + if exponent in (1, [1], "1") { unit } else { @@ -173,7 +174,12 @@ if use-sqrt and exponent == "0.5" and exp-multiplier == 1 and math { return std.math.sqrt(unit) } - format-unit-power(unit, exponent, math: math, negative: exp-multiplier == -1) + format-unit-power( + unit, + exponent, + math: math, + negative: exp-multiplier == -1, + ) }) let folded-units = units.join(unit-separator) @@ -189,23 +195,33 @@ /// The unit elements in the numerator. /// -> array numerator, + /// The unit elements in the denominator. /// -> array denominator, + /// Mode for displaying fractions. /// -> "power" | "fraction" | "inline" fraction: "power", + /// Whether to use an equation or plain text elements. math: true, - /// Symbol to use between base units. + + /// Symbol to use between constituent units. /// -> content unit-separator: sym.space.thin, + /// Whether to display a square root symbol when the exponent is 1/2. use-sqrt: true, + + /// The alt description for the unit. + /// -> auto | str alt: auto, - - // if part of quantity + + /// Value of the mantissa, if part of quantity + /// -> float value: 1, + /// Unprocessed arguments. ..args, ) = { @@ -227,7 +243,6 @@ use-sqrt: use-sqrt, ) - let numerator-content = fold-units(..numerator, 1) if denominator.len() == 0 { return if math { equation($#numerator-content$) } else { numerator-content } @@ -274,13 +289,15 @@ } let num-state = update-num-state(num-state.get(), args) - let result = (show-unit( - unit.numerator, - unit.denominator, - ..num-state.unit, - math: num-state.math, - alt: alt, - )) + let result = ( + show-unit( + unit.numerator, + unit.denominator, + ..num-state.unit, + math: num-state.math, + alt: alt, + ) + ) result } @@ -333,7 +350,6 @@ "−18": "a", ) - let prefix = prefixes.at(str(eng)) assert(unit.numerator.len() != 0) unit.numerator.first().first() = prefix + unit.numerator.first().first() @@ -354,7 +370,7 @@ math: num-state.math, use-sqrt: num-state.unit.use-sqrt, alt: alt, - value: float(info.int + "." + info.frac) + value: float(info.int + "." + info.frac), ) } From 52d03b1a53cbfb69d68a53e76d9b06214fe1b8af Mon Sep 17 00:00:00 2001 From: Mc-Zen <52877387+Mc-Zen@users.noreply.github.com> Date: Sun, 28 Jun 2026 16:36:48 +0200 Subject: [PATCH 23/24] [docs] for accessibility contribution guide --- ...ribute a new language for accessibility.md | 22 ++++++++++++++----- 1 file changed, 16 insertions(+), 6 deletions(-) diff --git a/docs/how to contribute a new language for accessibility.md b/docs/how to contribute a new language for accessibility.md index a38d3f8..a08c186 100644 --- a/docs/how to contribute a new language for accessibility.md +++ b/docs/how to contribute a new language for accessibility.md @@ -4,15 +4,15 @@ Zero generates accessible output! Numbers, units, and quantities that are format This is supported for a selection of languages and you can extend this selection by opening a PR and providing the necessary translations for the new language. When opening a PR, please read this document carefully and answer all questions below. ### Translations for numbers -In order to provide descriptions for numerals, add an entry for the new language in the file `translations.typ` and specify translations for the following keys +In order to provide descriptions for numerals, add an entry for the new language in the file `translations.typ` and specify translations for the following keys in the dictionary `phrases`. - `times` - `power` -These two determine how multiplication and exponentiation is pronounced in the language. This is used for exponent notation, e.g. `num[1e5]` is read as `1 times 10 to the power of 5` when the language is set to English. +These two determine how multiplication and exponentiation is pronounced in the language. This is used for exponent notation; e.g. `num[1e5]` is read as `1 times 10 to the power of 5` when the language is set to English. - `plus` - `minus` -These two are used to read uncertainties, e.g. `num[10+-2]` is read as `10 plus minus 2` when the language is set to English. The translation of `minus` is also used for the sign, e.g. `num[-5]` becomes `minus 5`. +These two are used to read uncertainties; e.g. `num[10+-2]` is read as `10 plus minus 2` when the language is set to English. The translation of `minus` is also used for the sign, e.g. `num[-5]` becomes `minus 5`. > [!IMPORTANT] > Does the translation work for both symmetric and asymmetric uncertainties? @@ -23,6 +23,8 @@ These two are used to read uncertainties, e.g. `num[10+-2]` is read as `10 plus > [!IMPORTANT] > Does the translation for `minus` also work for the sign in front of the number? +If not, let us know in the PR discussion. + - `per` For every unit component in the denominator of a compound unit, this word is prepended, e.g. `meter per second`. @@ -31,8 +33,16 @@ For every unit component in the denominator of a compound unit, this word is pre Please provide translations of all common prefixes like milli, giga etc. Prefixes that are the same as in English should be left unspecified; they will automatically be inherited from the English translation. -### Translations of base units -Please provide translations of all base units where they deviate from the English translation. +### Translations of units +Please provide translations of all units where they deviate from the English translation. All units should be given as singular words. The `pluralize` dictionary defines how the plural for a given unit symbol is formed. Sometimes this involves just adding or changing a suffix, sometimes the plural version is entirely different. + +All languages that are supported so far have the following rules regarding to when and where to use to plural: + +1. In the context of a quantity that combines a value with a unit, the plural is only used when the value is not 1 or -1. In French, the rule is more particular and requires the absolute of the value to be greater than or equal to 2. +2. In a composed unit, only the last unit in the numerator is in plural form; the leading units are singular. +3. Units in the denominator are always singular. + +In order to control the rule for the first point, you can override the function `needs-plural`. If any of the other rules does not apply in your language, please mention this in the PR. ### Power Shorthands > [!IMPORTANT] @@ -50,7 +60,7 @@ Please provide translations of all base units where they deviate from the Englis ), ) ``` -Each entry defines one shorthand for a specific exponent. If the shorthand is just added as a separate word after the unit as in `newton squared`, it is sufficient to provide a string. If the necessary modification is more complicated, the shorthand can also be given as a function that takes the base unit as argument. +Each entry defines one shorthand for a specific exponent. If the shorthand is just added as a separate word after the unit as in `newton squared`, it is sufficient to provide a string. If the necessary modification is more complicated, the shorthand can also be given as a function that takes the unit as argument. ### Joining prefixes with units From e730570685b870932696ed983af605547ea71dd4 Mon Sep 17 00:00:00 2001 From: Mc-Zen <52877387+Mc-Zen@users.noreply.github.com> Date: Sun, 28 Jun 2026 16:46:39 +0200 Subject: [PATCH 24/24] [docs] Update readme --- README.md | 12 +++++++++++- ...cessibility.md => language-contribution-guide.md} | 0 2 files changed, 11 insertions(+), 1 deletion(-) rename docs/{how to contribute a new language for accessibility.md => language-contribution-guide.md} (100%) diff --git a/README.md b/README.md index 3b318ca..e516268 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,7 @@ _Precise scientific number and unit formatting for Typst._ - [**Number Formatting**](#number-formatting) - [**Table Number Alignment**](#table-alignment) - [**Units and Quantities**](#units-and-quantities) +- [Accessibility](#accessibility) - [Zero for Third-Party Packages](#zero-for-third-party-packages) - [Changelog](#changelog) @@ -28,7 +29,7 @@ Proper number formatting is essential for clear and readable scientific document - Quick scientific notation, e.g., `"2e4"` becomes $2\times10^4$ - [**Rounding**](#rounding) in various modes - Digit [**grouping**](#grouping), e.g., $`299\,792\,458`$ instead of $299792458$ -- Helpers for package authors +- [**Accessibility**](#accessibility) through auto-generated alt descriptions for numbers and units A number in scientific notation consists of three parts: the _mantissa_, an optional _uncertainty_, and an optional _power_ (exponent). The following figure illustrates the anatomy of a formatted number: @@ -435,6 +436,15 @@ By default, the symbol for liter is an uppercase L. In order to display a lowerc ``` +--- + +## Accessibility + +Zero generates accessible output! Numbers, units, and quantities that are formatted with Zero have automatically generated alt descriptions that specify how a screen reader should read them. + +This is supported for a selection of languages and you can extend this selection by opening a PR and providing the necessary translations for the new language. When opening a PR, please read the [language contribution guide](docs/language-contribution-guide.md) first. + + --- ## Zero for Third-party Packages diff --git a/docs/how to contribute a new language for accessibility.md b/docs/language-contribution-guide.md similarity index 100% rename from docs/how to contribute a new language for accessibility.md rename to docs/language-contribution-guide.md