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/language-contribution-guide.md b/docs/language-contribution-guide.md new file mode 100644 index 0000000..a08c186 --- /dev/null +++ b/docs/language-contribution-guide.md @@ -0,0 +1,74 @@ +## 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 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. +- `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? + +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`. + +### 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 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] +> 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 unit as argument. + + +### 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. + + +### 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. diff --git a/src/accessibility.typ b/src/accessibility.typ new file mode 100644 index 0000000..27471a8 --- /dev/null +++ b/src/accessibility.typ @@ -0,0 +1,666 @@ +#import "parsing.typ": content-to-string + +#let phrases = ( + "fallback": ( + "times": "×", + "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": "в", + ), + "fi": ( + "times": "kertaa", + "power": "potenssiin", + "plus": "plus", + "minus": "miinus", + "per": "jaettuna", + ), +) + +#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", + ), + // identical prefixes are inherited from English + de: ( + 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", + da: "déca", + M: "méga", + T: "téra", + P: "péta", + ), + es: (:), + ru: ( + a: "атто", + f: "фемто", + p: "пико", + n: "нано", + µ: "микро", + m: "милли", + c: "санти", + d: "деци", + da: "дека", + h: "гекто", + k: "кило", + M: "мега", + G: "гига", + T: "тера", + P: "пета", + E: "экса", + ), + it: ( + 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 = ( + 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 + ha: "Hektar", + sym.degree: "Grad", + sym.degree + "C": "Grad Celsius", + h: "Stunde", + 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", + 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", + m: "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: ( + 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", + d: "día", + au: "unidad astronómica", + sym.prime.double: "segundo de arco", + sym.prime: "minuto de arco", + ), + it: ( + m: "metro", + kg: "kilogrammo", + g: "grammo", + eV: "elettronvolt", + sym.degree: "grado", + sym.degree + "C": "grado Celsius", + h: "ora", + min: "minuto", + s: "secondo", + d: "giorno", + rad: "radiante", + sr: "steradiante", + au: "unità astronomica", + 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ä", + ), +) + +// 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", + "3": "cubed", + ), + de: ( + "2": unit => "Quadrat" + lower(unit), + "3": unit => "Kubik" + lower(unit), + ), + fr: ( + "2": "carré", + "3": "cubique", + ), + es: ( + "2": "al cuadrado", + "3": "al cubo", + ), + it: ( + "2": "al quadrato", + "3": "al cubo", + ), + fi: ( + "2": "toiseen", + "3": "kolmanteen", + ), +) + +// 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) + 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 + }, +) + + +// 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 + } 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-num-alt-description(info, translation: auto) = { + let lang = text.lang + if translation == auto { + translation = phrases.at(lang, default: phrases.fallback) + } + let format-comma-number(int, frac) = { + int + if frac != "" { info.decimal-separator + 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 { + alt += ( + " " + translation.plus + " " + format-comma-number(..info.pm.first()) + ) + alt += ( + " " + translation.minus + " " + format-comma-number(..info.pm.last()) + ) + } else { + alt += ( + " " + + translation.plus + + " " + + translation.minus + + " " + + format-comma-number(..info.pm) + ) + } + } + if info.e != none { + alt += ( + " " + + translation.times + + " " + + base-to-string(info.base) + + " " + + translation.power + + " " + + info.e + ) + } + + alt +} + + + + +#let unit-component-description(component, plural: false) = { + 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 get-unit(component) + } + if component.len() > 1 { + 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), get-unit(unit), text.lang) + } + } + } + assert( + false, + message: "Failed to auto-generate alt description for unit component " + + repr(component) + + ". Please provide a manual alt text for this unit.", + ) +} + +#let generate-unit-alt-description( + numerator, + denominator, + translation: auto, + value: 1 +) = { + let lang = text.lang + if translation == auto { + translation = phrases.at(lang, default: phrases.fallback) + 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 = "" + let power-shorthands = power-shorthands.at(lang, default: (:)) + + let power-of-unit-component-description((unit-component, exponent), plural: false) = { + let unit = unit-component-description(unit-component, plural: plural) + if exponent == "1" { return unit } + if exponent in power-shorthands { + let power-shorthand = power-shorthands.at(exponent) + if type(power-shorthand) == function { + unit = power-shorthand(unit) + } else { + unit += " " + power-shorthand + } + } else { + unit += " " + translation.power + " " + exponent + } + unit + } + + + 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 + .map(power-of-unit-component-description) + .join(" " + translation.per + " ") + } + + alt +} + 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..b9434db 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-num-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-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-num-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, + pm: 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, diff --git a/src/units.typ b/src/units.typ index 97ff194..55f6cb8 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] @@ -12,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", @@ -59,7 +61,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 +91,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 +109,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 +125,37 @@ (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 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) + } + + exponent = [#exponent] + 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 } else { - if exponent in (1, [1]) { unit } else { + if math { + std.math.attach(unit, t: exponent) + } else { unit + super(typographic: false, exponent) } } @@ -146,13 +171,15 @@ 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) @@ -168,19 +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, + + /// Value of the mantissa, if part of quantity + /// -> float + value: 1, + /// Unprocessed arguments. ..args, ) = { @@ -190,6 +231,11 @@ + fraction + ". Expected \"power\", \"fraction\", or \"inline\"", ) + if alt == auto { + alt = generate-unit-alt-description(numerator, denominator, value: value) + } + + let equation = std.math.equation.with(alt: alt) let fold-units = fold-units.with( unit-separator: unit-separator + sym.wj, @@ -197,10 +243,9 @@ use-sqrt: use-sqrt, ) - 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 } @@ -212,7 +257,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. @@ -223,7 +268,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)] @@ -235,6 +280,7 @@ #let unit( unit, + alt: auto, ..args, ) = context { let args = (unit: args.named()) @@ -243,12 +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, - )) + let result = ( + show-unit( + unit.numerator, + unit.denominator, + ..num-state.unit, + math: num-state.math, + alt: alt, + ) + ) result } @@ -258,6 +307,7 @@ #let qty( value, unit, + alt: auto, ..args, ) = context { let unit = unit @@ -277,30 +327,29 @@ 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) 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", ) - let prefix = prefixes.at(str(eng)) assert(unit.numerator.len() != 0) unit.numerator.first().first() = prefix + unit.numerator.first().first() @@ -319,7 +368,9 @@ 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, + value: float(info.int + "." + info.frac), ) } diff --git a/src/zi.typ b/src/zi.typ index 34a3acf..cb331c4 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) } } @@ -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") 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..1b3e4e7 --- /dev/null +++ b/tests/accessibility/test.typ @@ -0,0 +1,226 @@ +#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(), + ), + "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), + ), + "minus 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: ","), + ), + "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: ","), + ), + "minus 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(), + ), + "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), + ), + "moins 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", + ) + + // 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( + 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", + ) + + // 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") + 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", + ) + + 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", + ) + } +} + + + + + + +// #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[2] + +// #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$) 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/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) 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")