diff --git a/packages/preview/num2words/0.2.0/CONTRIBUTING.md b/packages/preview/num2words/0.2.0/CONTRIBUTING.md new file mode 100644 index 0000000000..92a59694bc --- /dev/null +++ b/packages/preview/num2words/0.2.0/CONTRIBUTING.md @@ -0,0 +1,43 @@ +# Contributing + +Thanks for your interest in contributing to `num2words`! Feel free to open an [issue][issues] to report bugs, request +features, or suggest support for new languages. Pull requests are also appreciated. + +## Development environment + +The easiest way to set up the development environment is with [devenv][devenv] (Nix-based). Once installed, run `devenv +shell` to enter the dev shell with all tooling available. + +If you prefer a manual setup, you will need the following tools: + +- [Typst][typst] (>=0.14.0): the Typst compiler. +- [just][just]: command runner for common tasks. +- [tytanic][tytanic] (`tt`): test runner for Typst. +- [typstyle][typstyle]: Typst formatter. +- [prek][prek]: pre-commit hook manager. Run `prek install -t pre-commit -t commit-msg` to install hooks. + +## Key commands + +As mentioned, the `just` command runner is used to simplify common tasks. Here are some key commands: + +- `just test`: run all tests. +- `just format-typst` (or `just ft`): format Typst files. + +Check the [justfile](/justfile) for the full list of commands. + +## Commit conventions + +This project follows [Conventional Commits][conventional-commits]. The convention is enforced by a +[commitizen][commitizen] pre-commit hook, so make sure hooks are installed before committing. + + + +[issues]: https://github.com/mariovagomarzal/typst-num2words/issues +[typst]: https://typst.app/ +[devenv]: https://devenv.sh/ +[just]: https://just.systems/ +[tytanic]: https://typst-community.github.io/tytanic/ +[typstyle]: https://github.com/Enter-tainer/typstyle +[prek]: https://github.com/j178/prek +[conventional-commits]: https://www.conventionalcommits.org/ +[commitizen]: https://commitizen-tools.github.io/commitizen/ diff --git a/packages/preview/num2words/0.2.0/LICENSE b/packages/preview/num2words/0.2.0/LICENSE new file mode 100644 index 0000000000..0a041280bd --- /dev/null +++ b/packages/preview/num2words/0.2.0/LICENSE @@ -0,0 +1,165 @@ + GNU LESSER GENERAL PUBLIC LICENSE + Version 3, 29 June 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + + This version of the GNU Lesser General Public License incorporates +the terms and conditions of version 3 of the GNU General Public +License, supplemented by the additional permissions listed below. + + 0. Additional Definitions. + + As used herein, "this License" refers to version 3 of the GNU Lesser +General Public License, and the "GNU GPL" refers to version 3 of the GNU +General Public License. + + "The Library" refers to a covered work governed by this License, +other than an Application or a Combined Work as defined below. + + An "Application" is any work that makes use of an interface provided +by the Library, but which is not otherwise based on the Library. +Defining a subclass of a class defined by the Library is deemed a mode +of using an interface provided by the Library. + + A "Combined Work" is a work produced by combining or linking an +Application with the Library. The particular version of the Library +with which the Combined Work was made is also called the "Linked +Version". + + The "Minimal Corresponding Source" for a Combined Work means the +Corresponding Source for the Combined Work, excluding any source code +for portions of the Combined Work that, considered in isolation, are +based on the Application, and not on the Linked Version. + + The "Corresponding Application Code" for a Combined Work means the +object code and/or source code for the Application, including any data +and utility programs needed for reproducing the Combined Work from the +Application, but excluding the System Libraries of the Combined Work. + + 1. Exception to Section 3 of the GNU GPL. + + You may convey a covered work under sections 3 and 4 of this License +without being bound by section 3 of the GNU GPL. + + 2. Conveying Modified Versions. + + If you modify a copy of the Library, and, in your modifications, a +facility refers to a function or data to be supplied by an Application +that uses the facility (other than as an argument passed when the +facility is invoked), then you may convey a copy of the modified +version: + + a) under this License, provided that you make a good faith effort to + ensure that, in the event an Application does not supply the + function or data, the facility still operates, and performs + whatever part of its purpose remains meaningful, or + + b) under the GNU GPL, with none of the additional permissions of + this License applicable to that copy. + + 3. Object Code Incorporating Material from Library Header Files. + + The object code form of an Application may incorporate material from +a header file that is part of the Library. You may convey such object +code under terms of your choice, provided that, if the incorporated +material is not limited to numerical parameters, data structure +layouts and accessors, or small macros, inline functions and templates +(ten or fewer lines in length), you do both of the following: + + a) Give prominent notice with each copy of the object code that the + Library is used in it and that the Library and its use are + covered by this License. + + b) Accompany the object code with a copy of the GNU GPL and this license + document. + + 4. Combined Works. + + You may convey a Combined Work under terms of your choice that, +taken together, effectively do not restrict modification of the +portions of the Library contained in the Combined Work and reverse +engineering for debugging such modifications, if you also do each of +the following: + + a) Give prominent notice with each copy of the Combined Work that + the Library is used in it and that the Library and its use are + covered by this License. + + b) Accompany the Combined Work with a copy of the GNU GPL and this license + document. + + c) For a Combined Work that displays copyright notices during + execution, include the copyright notice for the Library among + these notices, as well as a reference directing the user to the + copies of the GNU GPL and this license document. + + d) Do one of the following: + + 0) Convey the Minimal Corresponding Source under the terms of this + License, and the Corresponding Application Code in a form + suitable for, and under terms that permit, the user to + recombine or relink the Application with a modified version of + the Linked Version to produce a modified Combined Work, in the + manner specified by section 6 of the GNU GPL for conveying + Corresponding Source. + + 1) Use a suitable shared library mechanism for linking with the + Library. A suitable mechanism is one that (a) uses at run time + a copy of the Library already present on the user's computer + system, and (b) will operate properly with a modified version + of the Library that is interface-compatible with the Linked + Version. + + e) Provide Installation Information, but only if you would otherwise + be required to provide such information under section 6 of the + GNU GPL, and only to the extent that such information is + necessary to install and execute a modified version of the + Combined Work produced by recombining or relinking the + Application with a modified version of the Linked Version. (If + you use option 4d0, the Installation Information must accompany + the Minimal Corresponding Source and Corresponding Application + Code. If you use option 4d1, you must provide the Installation + Information in the manner specified by section 6 of the GNU GPL + for conveying Corresponding Source.) + + 5. Combined Libraries. + + You may place library facilities that are a work based on the +Library side by side in a single library together with other library +facilities that are not Applications and are not covered by this +License, and convey such a combined library under terms of your +choice, if you do both of the following: + + a) Accompany the combined library with a copy of the same work based + on the Library, uncombined with any other library facilities, + conveyed under the terms of this License. + + b) Give prominent notice with the combined library that part of it + is a work based on the Library, and explaining where to find the + accompanying uncombined form of the same work. + + 6. Revised Versions of the GNU Lesser General Public License. + + The Free Software Foundation may publish revised and/or new versions +of the GNU Lesser General Public License from time to time. Such new +versions will be similar in spirit to the present version, but may +differ in detail to address new problems or concerns. + + Each version is given a distinguishing version number. If the +Library as you received it specifies that a certain numbered version +of the GNU Lesser General Public License "or any later version" +applies to it, you have the option of following the terms and +conditions either of that published version or of any later version +published by the Free Software Foundation. If the Library as you +received it does not specify a version number of the GNU Lesser +General Public License, you may choose any version of the GNU Lesser +General Public License ever published by the Free Software Foundation. + + If the Library as you received it specifies that a proxy can decide +whether future versions of the GNU Lesser General Public License shall +apply, that proxy's public statement of acceptance of any version is +permanent authorization for you to choose that version for the +Library. diff --git a/packages/preview/num2words/0.2.0/README.md b/packages/preview/num2words/0.2.0/README.md new file mode 100644 index 0000000000..dc4ea5e8b2 --- /dev/null +++ b/packages/preview/num2words/0.2.0/README.md @@ -0,0 +1,57 @@ +# num2words + +A Typst package that converts numbers to their written word form. + +## Usage + +Check out the [package manual][manual] for detailed documentation. Here's a quick example: + +```typst +#import "@preview/num2words:0.2.0": num2words + +// Auto-detection from `text.lang` +#set text(lang: "en") +#num2words(42) // "forty-two" + +#set text(lang: "es") +#num2words(42) // "cuarenta y dos" + +// Explicit language code overrides `text.lang` +#num2words(100, lang: "en") // "one hundred" +#num2words(100, lang: "es") // "cien" + +// Some languages have different number forms +#num2words(1, lang: "en", form: "ordinal") // "first" +#num2words(1, lang: "es", form: "ordinal") // "primero" +``` + +The `num2words` function _always_ accepts: + +- `number` (int) — the number to convert. +- `lang` (str or auto) — the language code. When `auto` (the default), uses the current `text.lang`. + +Other langugages might support additional parameters. + +## Supported languages + +| Language | Code | +| --- | --- | +| English (US) | `en` | +| Spanish | `es` | +| Catalan | `ca` | + +More languages are planned. Contributions are welcome! + +## Contributing + +Contributions are welcome! See [CONTRIBUTING.md](/CONTRIBUTING.md) for development setup and guidelines. Also, you're +always welcome to open an [issue][issues] to report bugs, request features, or suggest support for new languages. + +## License + +This project is licensed under the GNU Lesser General Public License v3.0. See the [LICENSE](/LICENSE) file for details. + + + +[manual]: https://mariovagomarzal.github.io/typst-num2words/manual.pdf +[issues]: https://github.com/mariovagomarzal/typst-num2words/issues diff --git a/packages/preview/num2words/0.2.0/src/errors.typ b/packages/preview/num2words/0.2.0/src/errors.typ new file mode 100644 index 0000000000..2750195e38 --- /dev/null +++ b/packages/preview/num2words/0.2.0/src/errors.typ @@ -0,0 +1,75 @@ +/// Error helpers for consistent `num2words` error messages. + +/// Formats the `num2words` prefix, optionally scoped to a language. +/// +/// - lang (str, none): The language code, or `none` for the top-level function. +/// -> str +#let _prefix(lang) = { + if lang == none { + "num2words" + } else { + "num2words (" + lang + ")" + } +} + +/// Asserts that a value has the expected type. Panics with a consistent message if not. +/// +/// - param (str): The parameter name. +/// - expected-type (type): The expected type (e.g., `int`, `str`). +/// - value (any): The actual value received. +/// - lang (str, none): The language code, or `none` for the top-level function. +#let assert-type(param, expected-type, value, lang: none) = { + let value-type = type(value) + assert( + value-type == expected-type, + message: _prefix(lang) + ": expected " + str(expected-type) + " for '" + param + "', got " + str(value-type), + ) +} + +/// Asserts that a language code is supported. +/// +/// - lang (str): The language code to check. +/// - supported (array, dictionary): The supported languages (array of strings or dictionary with language keys). +#let assert-lang(lang, supported) = { + assert( + lang in supported, + message: _prefix(none) + ": unsupported language '" + lang + "'", + ) +} + +/// Asserts that a parameter value is among a set of supported values. Used for +/// any option with a finite set of valid choices (e.g. `form`, `gender`). +/// +/// - param (str): The parameter name. +/// - value (any): The value to check. +/// - supported (array, dictionary): The supported values (array, or dictionary whose keys are the supported values). +/// - lang (str, none): The language code, or `none` for the top-level function. +#let assert-option(param, value, supported, lang: none) = { + assert( + value in supported, + message: _prefix(lang) + ": unsupported value '" + str(value) + "' for '" + param + "'", + ) +} + +/// Asserts that a number is within the supported range. Panics if not. +/// +/// - number (int): The number to check. +/// - min (int, none): The minimum supported value, or `none` if unbounded below. +/// - max (int, none): The maximum supported value, or `none` if unbounded above. +/// - lang (str, none): The language code, or `none` for the top-level function. +#let out-of-range(number, min: none, max: none, lang: none) = { + let in-range = ( + (min == none or number >= min) and (max == none or number <= max) + ) + let range-str = if min != none and max != none { + "[" + str(min) + ", " + str(max) + "]" + } else if min != none { + ">= " + str(min) + } else { + "<= " + str(max) + } + assert( + in-range, + message: _prefix(lang) + ": number " + str(number) + " is out of range (" + range-str + ")", + ) +} diff --git a/packages/preview/num2words/0.2.0/src/langs/ca.typ b/packages/preview/num2words/0.2.0/src/langs/ca.typ new file mode 100644 index 0000000000..6518dd53af --- /dev/null +++ b/packages/preview/num2words/0.2.0/src/langs/ca.typ @@ -0,0 +1,507 @@ +/// Catalan number-to-words conversion. +#import "../errors.typ" + +/// The language code for this module. +#let _lang-code = "ca" + +// Cardinal data tables. + +/// Words for numbers 0–19. Numbers 16–19 are single irregular words in Catalan +/// (`setze`, `disset`, `divuit`, `dinou`). +#let _units = ( + "zero", + "u", + "dos", + "tres", + "quatre", + "cinc", + "sis", + "set", + "vuit", + "nou", + "deu", + "onze", + "dotze", + "tretze", + "catorze", + "quinze", + "setze", + "disset", + "divuit", + "dinou", +) + +/// Apocopated unit words. Used when the trailing "u" precedes a noun-like +/// element (a scale word `mil`/`milió`/… or a noun in the document): "u" → "un". +/// All other entries match `_units`. +#let _units-apocopated = ( + "zero", + "un", + "dos", + "tres", + "quatre", + "cinc", + "sis", + "set", + "vuit", + "nou", + "deu", + "onze", + "dotze", + "tretze", + "catorze", + "quinze", + "setze", + "disset", + "divuit", + "dinou", +) + +/// Words for multiples of ten from 30–90. Indexed by `tens-digit - 3`. +#let _tens = ( + "trenta", + "quaranta", + "cinquanta", + "seixanta", + "setanta", + "vuitanta", + "noranta", +) + +/// Words for multiples of one hundred from 100–900. Indexed by hundreds digit; +/// index 0 is unused. The same form is used both alone (100 → "cent") and as +/// the leading element of a 1XX number (101 → "cent u"); there is no separate +/// combining form in Catalan. +#let _hundreds = ( + "", + "cent", + "dos-cents", + "tres-cents", + "quatre-cents", + "cinc-cents", + "sis-cents", + "set-cents", + "vuit-cents", + "nou-cents", +) + +/// Singular long-scale words by 6-digit group index (long scale: each step +/// adds 6 zeros). Index 0 is empty (no scale word at the bottom group). +#let _scales-singular = ( + "", + "milió", + "bilió", + "trilió", + "quatrilió", + "quintilió", + "sextilió", +) + +/// Plural long-scale words, paired with `_scales-singular`. +#let _scales-plural = ( + "", + "milions", + "bilions", + "trilions", + "quatrilions", + "quintilions", + "sextilions", +) + +// Ordinal data tables. + +/// Standalone ordinal forms for 1–9 (masculine). Index 0 is unused. +#let _ord-units = ( + "", + "primer", + "segon", + "tercer", + "quart", + "cinquè", + "sisè", + "setè", + "vuitè", + "novè", +) + +/// Compound-ordinal forms for the trailing unit 1–9 used inside hyphenated +/// blocks like "vint-i-unè" or "trenta-dosè". Differs from `_ord-units` only +/// for 1–4 (`unè`, `dosè`, `tresè`, `quatrè` instead of `primer`…`quart`). +#let _ord-units-compound = ( + "", + "unè", + "dosè", + "tresè", + "quatrè", + "cinquè", + "sisè", + "setè", + "vuitè", + "novè", +) + +/// Ordinal forms for 10–19, indexed by `n - 10`. +#let _ord-teens-and-ten = ( + "desè", + "onzè", + "dotzè", + "tretzè", + "catorzè", + "quinzè", + "setzè", + "dissetè", + "divuitè", + "dinovè", +) + +/// Ordinal forms for tens 10–90, indexed by tens digit. Index 0 is unused. +#let _ord-tens = ( + "", + "desè", + "vintè", + "trentè", + "quarantè", + "cinquantè", + "seixantè", + "setantè", + "vuitantè", + "norantè", +) + +/// Fused ordinal forms for hundreds 100–900, indexed by hundreds digit. Used +/// when the number is an exact multiple of 100. Index 0 is unused. +#let _ord-hundreds = ( + "", + "centè", + "dos-centè", + "tres-centè", + "quatre-centè", + "cinc-centè", + "sis-centè", + "set-centè", + "vuit-centè", + "nou-centè", +) + +/// Supported forms for this language module. +#let _supported-forms = ("cardinal", "ordinal") + +/// Supported gender values. +#let _supported-genders = ("masculine", "feminine") + +// Gender / apocope helpers. + +/// Returns the feminine form of a cardinal unit word. Only "u" and "dos" +/// inflect: "u" → "una", "dos" → "dues". Compound forms ending in "-u" or +/// "-dos" (e.g. "vint-i-u", "trenta-dos") inflect at the suffix. +#let _feminine-unit(word) = if word == "u" or word.ends-with("-u") { + word.slice(0, -1) + "una" +} else if word == "dos" or word.ends-with("-dos") { + word.slice(0, -3) + "dues" +} else { + word +} + +/// Returns the feminine form of a hundreds word. "cent" is invariable; +/// "dos-cents" → "dues-centes" (both prefix and suffix inflect); the rest +/// "tres-cents"…"nou-cents" only change the suffix to "-centes". +#let _feminine-hundred(word) = if word == "cent" { + "cent" +} else if word == "dos-cents" { + "dues-centes" +} else { + word.slice(0, -5) + "centes" +} + +/// Feminizes the last token of a (possibly multi-word) ordinal expression: +/// trailing "-è" becomes "-ena"; the standalone forms `primer`/`segon`/ +/// `tercer`/`quart` get a final "a". All preceding tokens stay unchanged. +#let _feminine-ordinal(words) = { + let parts = words.split(" ") + let last-idx = parts.len() - 1 + let last = parts.at(last-idx) + let new-last = if last.ends-with("è") { + last.trim("è", at: end) + "ena" + } else { + last + "a" + } + if last-idx == 0 { + new-last + } else { + parts.slice(0, last-idx).join(" ") + " " + new-last + } +} + +// Cardinal helpers. + +/// Converts a number in the range 0–99 to its cardinal word form. The +/// `apocopate` and `feminine` flags control the form of a trailing "u"/"dos": +/// apocopated ("un"), feminine ("una"/"dues"), or default ("u"/"dos"). +/// `apocopate` takes precedence over `feminine`. +/// +/// - number (int): The number to convert (0–99). +/// - apocopate (bool): Whether to apocopate a trailing "u". +/// - feminine (bool): Whether to use the feminine form. +/// -> str +#let _convert-below-100(number, apocopate: false, feminine: false) = { + let unit-word(i) = if apocopate { + _units-apocopated.at(i) + } else if feminine { + _feminine-unit(_units.at(i)) + } else { + _units.at(i) + } + if number < 20 { + unit-word(number) + } else if number == 20 { + "vint" + } else if number < 30 { + "vint-i-" + unit-word(number - 20) + } else { + let tens-digit = calc.quo(number, 10) + let units-digit = calc.rem(number, 10) + if units-digit == 0 { + _tens.at(tens-digit - 3) + } else { + _tens.at(tens-digit - 3) + "-" + unit-word(units-digit) + } + } +} + +/// Converts a number in the range 1–999 to its cardinal word form. The +/// `apocopate` and `feminine` flags are forwarded to the trailing 1–99 part +/// and used to pick the feminine variant of a hundreds word (e.g. 200 → +/// "dues-centes"). +/// +/// - number (int): The number to convert (1–999). +/// - apocopate (bool): Whether to apocopate a trailing "u". +/// - feminine (bool): Whether to use feminine forms. +/// -> str +#let _convert-below-1000(number, apocopate: false, feminine: false) = { + if number < 100 { + _convert-below-100(number, apocopate: apocopate, feminine: feminine) + } else { + let hundreds-digit = calc.quo(number, 100) + let remainder = calc.rem(number, 100) + let masc-hundreds = _hundreds.at(hundreds-digit) + let hundreds-word = if feminine { _feminine-hundred(masc-hundreds) } else { masc-hundreds } + if remainder == 0 { + hundreds-word + } else { + hundreds-word + " " + _convert-below-100(remainder, apocopate: apocopate, feminine: feminine) + } + } +} + +/// Converts a number in the range 1–999_999 (one long-scale group) to its +/// cardinal word form. Splits the number into a thousands part (joined with +/// "mil") and a units part. The thousands part is always apocopated because +/// "mil" follows; the units part is apocopated only if a scale noun follows +/// the entire group OR the caller requested apocopation for the final unit +/// (controlled by `apocopate-units`). +/// +/// - number (int): The number to convert (1–999_999). +/// - apocopate-units (bool): Whether the units part should apocopate (true +/// when a scale noun like "milió" follows this 6-digit group, or when the +/// user requested `apocopated` for the bottom chunk). +/// - feminine (bool): Whether the chunk modifies a feminine noun. Affects the +/// thousands part (e.g. "vint-i-una mil persones") and the units part when +/// no scale word follows; ignored for units when `apocopate-units` is true, +/// since scale nouns (milió, bilió…) are masculine. +/// -> str +#let _convert-below-million(number, apocopate-units: false, feminine: false) = { + let thousands = calc.quo(number, 1000) + let units = calc.rem(number, 1000) + let parts = () + if thousands == 1 { + parts.push("mil") + } else if thousands > 1 { + let thousands-word = if feminine { + _convert-below-1000(thousands, feminine: true) + } else { + _convert-below-1000(thousands, apocopate: true) + } + parts.push(thousands-word + " mil") + } + if units > 0 { + let units-word = if apocopate-units { + _convert-below-1000(units, apocopate: true) + } else { + _convert-below-1000(units, feminine: feminine) + } + parts.push(units-word) + } + parts.join(" ") +} + +/// Recursively splits a number into 6-digit chunks (one long-scale group each) +/// and converts each chunk, appending the appropriate scale word. +/// +/// - number (int): The remaining number to convert. +/// - scale-index (int): The current scale index (0 = bottom group, 1 = milions, …). +/// - feminine (bool): Whether the overall number modifies a feminine noun. +/// Only the bottom chunk (scale-index 0) inherits the gender, since scale +/// words (milió, bilió…) are masculine and impose their own agreement. +/// - apocopated (bool): Whether the user requested the apocopated form. Only +/// affects the bottom chunk when no scale word follows. +/// -> array +#let _chunk-and-convert(number, scale-index, feminine: false, apocopated: false) = { + if number == 0 { + () + } else { + errors.out-of-range(scale-index, max: _scales-singular.len() - 1, lang: _lang-code) + let chunk = calc.rem(number, 1000000) + let rest = calc.quo(number, 1000000) + let higher = _chunk-and-convert(rest, scale-index + 1, feminine: feminine, apocopated: apocopated) + if chunk == 0 { + higher + } else { + let words = _convert-below-million( + chunk, + apocopate-units: scale-index > 0 or (scale-index == 0 and apocopated), + feminine: feminine and scale-index == 0, + ) + if scale-index > 0 { + let scale-word = if chunk == 1 { + _scales-singular.at(scale-index) + } else { + _scales-plural.at(scale-index) + } + words = words + " " + scale-word + } + higher + (words,) + } + } +} + +/// Converts a positive integer to its cardinal word form. +/// +/// - number (int): The number to convert (>= 1). +/// - feminine (bool): Whether the number modifies a feminine noun. +/// - apocopated (bool): Whether to apocopate a trailing "u" → "un". +/// -> str +#let _convert-cardinal(number, feminine: false, apocopated: false) = { + _chunk-and-convert(number, 0, feminine: feminine, apocopated: apocopated).join(" ") +} + +// Ordinal helpers. + +/// Converts a number in the range 1–99 to its ordinal word form (masculine, +/// non-feminine). Compound forms 21–29 and 31–99 use the fused suffix variants +/// (`vint-i-unè`, `trenta-dosè`, `quaranta-cinquè`). +/// +/// - number (int): The number to convert (1–99). +/// -> str +#let _convert-ordinal-below-100(number) = { + if number < 10 { + _ord-units.at(number) + } else if number < 20 { + _ord-teens-and-ten.at(number - 10) + } else if number == 20 { + "vintè" + } else if number < 30 { + "vint-i-" + _ord-units-compound.at(number - 20) + } else { + let tens-digit = calc.quo(number, 10) + let units-digit = calc.rem(number, 10) + if units-digit == 0 { + _ord-tens.at(tens-digit) + } else { + _tens.at(tens-digit - 3) + "-" + _ord-units-compound.at(units-digit) + } + } +} + +/// Converts a positive integer in the range 1–999 to its ordinal word form. +/// Panics if `number` is outside [1, 999]. The masculine form is built first +/// and `feminine` swaps the trailing suffix on the last word for `-ena` (or +/// appends `-a` to `primer`/`segon`/`tercer`/`quart`). +/// +/// - number (int): The number to convert (1–999). +/// - feminine (bool): Whether to return the feminine form. +/// -> str +#let _convert-ordinal(number, feminine: false) = { + errors.out-of-range(number, min: 1, max: 999, lang: _lang-code) + let masculine = if number < 100 { + _convert-ordinal-below-100(number) + } else { + let hundreds-digit = calc.quo(number, 100) + let remainder = calc.rem(number, 100) + if remainder == 0 { + _ord-hundreds.at(hundreds-digit) + } else { + _hundreds.at(hundreds-digit) + " " + _convert-ordinal-below-100(remainder) + } + } + if feminine { + _feminine-ordinal(masculine) + } else { + masculine + } +} + +// Public entry point. + +/// Converts a number to its Catalan word form. +/// +/// Cardinals are returned across the full long-scale range. Ordinals are +/// supported within the closed range [1, 999]; values outside that range panic +/// with an out-of-range error. +/// +/// `gender` controls grammatical agreement: with `"feminine"`, cardinals +/// inflect "u"/"dos" and the hundreds 200–900 ("una", "vint-i-una", "dues", +/// "vint-i-dues", "dues-centes", "vint-i-una mil persones"); ordinals end in +/// `-ena` (or `-a` for `primer`/`segon`/`tercer`/`quart`). Scale nouns (mil, +/// milió, bilió…) are invariable and stay masculine. +/// +/// `apocopated` is only available for cardinals in masculine. It produces the +/// short form "un" instead of "u" for the trailing unit ("vint-i-un", +/// "trenta-un", "cent un"). Combining `apocopated: true` with +/// `form: "ordinal"` or `gender: "feminine"` panics. +/// +/// - number (int): The number to convert. +/// - form (str): The form: `"cardinal"` (default) or `"ordinal"`. +/// - gender (str): `"masculine"` (default) or `"feminine"`. +/// - apocopated (bool): Use the apocopated cardinal form. Cardinal + masculine only. +/// - negative (str): The prefix for negative numbers (default: `"menys"`). +/// -> str +#let convert( + number, + form: "cardinal", + gender: "masculine", + apocopated: false, + negative: "menys", +) = { + errors.assert-type("form", str, form, lang: _lang-code) + errors.assert-option("form", form, _supported-forms, lang: _lang-code) + errors.assert-type("gender", str, gender, lang: _lang-code) + errors.assert-option("gender", gender, _supported-genders, lang: _lang-code) + errors.assert-type("apocopated", bool, apocopated, lang: _lang-code) + errors.assert-type("negative", str, negative, lang: _lang-code) + + let feminine = gender == "feminine" + + if apocopated { + assert( + form == "cardinal", + message: "num2words (ca): 'apocopated' is only available for cardinals", + ) + assert( + not feminine, + message: "num2words (ca): 'apocopated' is not available for feminine gender", + ) + } + + if number == 0 and form == "cardinal" { + "zero" + } else { + let prefix = if number < 0 { negative + " " } else { "" } + let abs-number = calc.abs(number) + let result = if form == "cardinal" { + _convert-cardinal(abs-number, feminine: feminine, apocopated: apocopated) + } else { + _convert-ordinal(abs-number, feminine: feminine) + } + prefix + result + } +} diff --git a/packages/preview/num2words/0.2.0/src/langs/en.typ b/packages/preview/num2words/0.2.0/src/langs/en.typ new file mode 100644 index 0000000000..664ec4c0d6 --- /dev/null +++ b/packages/preview/num2words/0.2.0/src/langs/en.typ @@ -0,0 +1,270 @@ +/// English (American) number-to-words conversion. +#import "../errors.typ" + +/// The language code for this module. +#let _lang-code = "en" + +// Data tables. + +/// Words for numbers 0–19. +#let _units = ( + "zero", + "one", + "two", + "three", + "four", + "five", + "six", + "seven", + "eight", + "nine", + "ten", + "eleven", + "twelve", + "thirteen", + "fourteen", + "fifteen", + "sixteen", + "seventeen", + "eighteen", + "nineteen", +) + +/// Words for multiples of ten from 20–90. +#let _tens = ( + "twenty", + "thirty", + "forty", + "fifty", + "sixty", + "seventy", + "eighty", + "ninety", +) + +/// Scale words for groups of three digits (short scale). +#let _scales = ( + "", + "thousand", + "million", + "billion", + "trillion", + "quadrillion", + "quintillion", + "sextillion", + "septillion", + "octillion", + "nonillion", + "decillion", + "undecillion", + "duodecillion", + "tredecillion", + "quattuordecillion", + "quindecillion", + "sexdecillion", + "septendecillion", + "octodecillion", + "novemdecillion", + "vigintillion", +) + +/// Cardinal words whose ordinal form is irregular. +#let _ordinal-irregulars = ( + one: "first", + two: "second", + three: "third", + five: "fifth", + eight: "eighth", + nine: "ninth", + twelve: "twelfth", +) + +/// Supported forms for this language module. +#let _supported-forms = ("cardinal", "ordinal", "year") + +// Cardinal helpers. + +/// Converts a number in the range 1–99 to its cardinal word form. +/// +/// - number (int): The number to convert (1–99). +/// -> str +#let _convert-below-100(number) = { + if number < 20 { + _units.at(number) + } else { + let tens-digit = calc.quo(number, 10) + let units-digit = calc.rem(number, 10) + if units-digit == 0 { + _tens.at(tens-digit - 2) + } else { + _tens.at(tens-digit - 2) + "-" + _units.at(units-digit) + } + } +} + +/// Converts a number in the range 1–999 to its cardinal word form. +/// +/// - number (int): The number to convert (1–999). +/// -> str +#let _convert-below-1000(number) = { + if number < 100 { + _convert-below-100(number) + } else { + let hundreds-digit = calc.quo(number, 100) + let remainder = calc.rem(number, 100) + if remainder == 0 { + _units.at(hundreds-digit) + " hundred" + } else { + _units.at(hundreds-digit) + " hundred " + _convert-below-100(remainder) + } + } +} + +/// Recursively splits a number into 3-digit chunks and converts each chunk, +/// appending the appropriate scale word. +/// +/// - number (int): The remaining number to convert. +/// - scale-index (int): The current scale index (0 = units, 1 = thousands, etc.). +/// -> array +#let _chunk-and-convert(number, scale-index) = { + if number == 0 { + () + } else { + errors.out-of-range(scale-index, max: _scales.len() - 1, lang: _lang-code) + let chunk = calc.rem(number, 1000) + let rest = calc.quo(number, 1000) + let higher = _chunk-and-convert(rest, scale-index + 1) + if chunk == 0 { + higher + } else { + let words = _convert-below-1000(chunk) + if scale-index > 0 { + words = words + " " + _scales.at(scale-index) + } + higher + (words,) + } + } +} + +/// Converts a positive integer to its cardinal word form. +/// +/// - number (int): The number to convert (>= 1). +/// -> str +#let _convert-cardinal(number) = { + _chunk-and-convert(number, 0).join(" ") +} + +// Ordinal helpers. + +/// Converts a single cardinal word to its ordinal form. +/// +/// - word (str): The cardinal word to ordinalize. +/// -> str +#let _ordinalize(word) = { + if word in _ordinal-irregulars { + _ordinal-irregulars.at(word) + } else if word.ends-with("y") { + word.slice(0, word.len() - 1) + "ieth" + } else { + word + "th" + } +} + +/// Transforms a full cardinal string into its ordinal form by ordinalizing +/// only the last word (handling hyphenated compounds like "forty-two"). +/// +/// - cardinal (str): The cardinal string to transform. +/// -> str +#let _cardinal-to-ordinal(cardinal) = { + let tokens = cardinal.split(" ") + let last = tokens.last() + if "-" in last { + let parts = last.split("-") + let ordinal-part = _ordinalize(parts.last()) + let new-last = ( + parts.slice(0, parts.len() - 1).join("-") + "-" + ordinal-part + ) + if tokens.len() == 1 { + new-last + } else { + tokens.slice(0, tokens.len() - 1).join(" ") + " " + new-last + } + } else { + let ordinal-last = _ordinalize(last) + if tokens.len() == 1 { + ordinal-last + } else { + tokens.slice(0, tokens.len() - 1).join(" ") + " " + ordinal-last + } + } +} + +/// Converts a positive integer to its ordinal word form. +/// +/// - number (int): The number to convert (>= 1). +/// -> str +#let _convert-ordinal(number) = { + let cardinal = _convert-cardinal(number) + _cardinal-to-ordinal(cardinal) +} + +// Year helpers. + +/// Converts a positive integer to its year reading form. +/// +/// - number (int): The number to convert (>= 1). +/// -> str +#let _convert-year(number) = { + if number < 1000 { + _convert-cardinal(number) + } else if number < 10000 { + let high = calc.quo(number, 100) + let low = calc.rem(number, 100) + if calc.rem(number, 1000) == 0 { + _convert-cardinal(number) + } else if low == 0 { + _convert-below-100(high) + " hundred" + } else if high == 20 and low < 10 { + "two thousand " + _convert-below-100(low) + } else if low < 10 { + _convert-below-100(high) + " oh " + _units.at(low) + } else { + _convert-below-100(high) + " " + _convert-below-100(low) + } + } else { + _convert-cardinal(number) + } +} + +// Public entry point. + +/// Converts a number to its English word form. +/// +/// - number (int): The number to convert. +/// - form (str): The form: `"cardinal"`, `"ordinal"`, or `"year"` (default: `"cardinal"`). +/// - negative (str): The prefix for negative numbers (default: `"negative"`). +/// -> str +#let convert(number, form: "cardinal", negative: "negative") = { + errors.assert-type("form", str, form, lang: _lang-code) + errors.assert-option("form", form, _supported-forms, lang: _lang-code) + errors.assert-type("negative", str, negative, lang: _lang-code) + + if number == 0 { + if form == "ordinal" { + "zeroth" + } else { + "zero" + } + } else { + let prefix = if number < 0 { negative + " " } else { "" } + let abs-number = calc.abs(number) + let result = if form == "cardinal" { + _convert-cardinal(abs-number) + } else if form == "ordinal" { + _convert-ordinal(abs-number) + } else { + _convert-year(abs-number) + } + prefix + result + } +} diff --git a/packages/preview/num2words/0.2.0/src/langs/es.typ b/packages/preview/num2words/0.2.0/src/langs/es.typ new file mode 100644 index 0000000000..11f14c7372 --- /dev/null +++ b/packages/preview/num2words/0.2.0/src/langs/es.typ @@ -0,0 +1,485 @@ +/// Spanish number-to-words conversion. +#import "../errors.typ" + +/// The language code for this module. +#let _lang-code = "es" + +// Cardinal data tables. + +/// Words for numbers 0–29. Numbers 16–29 are written as a single word per RAE. +#let _units = ( + "cero", + "uno", + "dos", + "tres", + "cuatro", + "cinco", + "seis", + "siete", + "ocho", + "nueve", + "diez", + "once", + "doce", + "trece", + "catorce", + "quince", + "dieciséis", + "diecisiete", + "dieciocho", + "diecinueve", + "veinte", + "veintiuno", + "veintidós", + "veintitrés", + "veinticuatro", + "veinticinco", + "veintiséis", + "veintisiete", + "veintiocho", + "veintinueve", +) + +/// Apocopated unit words. Used when "uno"/"veintiuno" precedes a noun-like +/// scale word (mil, millón, …): "uno" → "un", "veintiuno" → "veintiún". +/// All other entries match `_units`. +#let _units-apocopated = ( + "cero", + "un", + "dos", + "tres", + "cuatro", + "cinco", + "seis", + "siete", + "ocho", + "nueve", + "diez", + "once", + "doce", + "trece", + "catorce", + "quince", + "dieciséis", + "diecisiete", + "dieciocho", + "diecinueve", + "veinte", + "veintiún", + "veintidós", + "veintitrés", + "veinticuatro", + "veinticinco", + "veintiséis", + "veintisiete", + "veintiocho", + "veintinueve", +) + +/// Words for multiples of ten from 30–90. Indexed by `tens-digit - 3`. +#let _tens = ( + "treinta", + "cuarenta", + "cincuenta", + "sesenta", + "setenta", + "ochenta", + "noventa", +) + +/// Words for multiples of one hundred from 100–900. Indexed by hundreds digit; +/// index 0 is unused. Note: the form for exactly 100 is "cien", handled inline +/// in `_convert-below-1000`; this table holds the combining form "ciento" for 1. +#let _hundreds = ( + "", + "ciento", + "doscientos", + "trescientos", + "cuatrocientos", + "quinientos", + "seiscientos", + "setecientos", + "ochocientos", + "novecientos", +) + +/// Singular long-scale words by 6-digit group index (RAE long scale: each step +/// adds 6 zeros). Index 0 is empty (no scale word at the bottom group). +#let _scales-singular = ( + "", + "millón", + "billón", + "trillón", + "cuatrillón", + "quintillón", + "sextillón", +) + +/// Plural long-scale words, paired with `_scales-singular`. +#let _scales-plural = ( + "", + "millones", + "billones", + "trillones", + "cuatrillones", + "quintillones", + "sextillones", +) + +// Ordinal data tables. + +/// Ordinal forms for 1–9. Index 0 is unused. +#let _ord-units = ( + "", + "primero", + "segundo", + "tercero", + "cuarto", + "quinto", + "sexto", + "séptimo", + "octavo", + "noveno", +) + +/// Ordinal forms for tens 10–90, indexed by tens digit. Index 0 is unused. +#let _ord-tens = ( + "", + "décimo", + "vigésimo", + "trigésimo", + "cuadragésimo", + "quincuagésimo", + "sexagésimo", + "septuagésimo", + "octogésimo", + "nonagésimo", +) + +/// Ordinal forms for hundreds 100–900, indexed by hundreds digit. Index 0 is unused. +#let _ord-hundreds = ( + "", + "centésimo", + "ducentésimo", + "tricentésimo", + "cuadringentésimo", + "quingentésimo", + "sexcentésimo", + "septingentésimo", + "octingentésimo", + "noningentésimo", +) + +/// Ordinal forms for 13–19 (the contemporary fused single-word forms). +/// Indexed by `n - 13`. 11 → "undécimo" and 12 → "duodécimo" are special-cased. +#let _ord-teens = ( + "decimotercero", + "decimocuarto", + "decimoquinto", + "decimosexto", + "decimoséptimo", + "decimoctavo", + "decimonoveno", +) + +/// Supported forms for this language module. +#let _supported-forms = ("cardinal", "ordinal") + +/// Supported gender values. +#let _supported-genders = ("masculine", "feminine") + +// Gender / apocope helpers. + +/// Returns the feminine form of a cardinal unit word (0–29). Only "uno" and +/// "veintiuno" inflect; words like "cuatro" or "ocho" end in "o" but are +/// invariable, so the match is on the "uno" suffix specifically. +#let _feminine-unit(word) = if word.ends-with("uno") { word.slice(0, -1) + "a" } else { word } + +/// Returns the feminine form of a hundreds word. "ciento" is invariable when +/// used as a combiner; "doscientos"…"novecientos" become "doscientas"…"novecientas". +#let _feminine-hundred(word) = if word.ends-with("os") { word.slice(0, -2) + "as" } else { word } + +/// Feminizes every word in a (possibly multi-word) ordinal expression. Every +/// supported ordinal ends in "o", which is replaced by "a". +#let _feminine-ordinal(words) = words.split(" ").map(w => w.slice(0, -1) + "a").join(" ") + +/// Returns the apocopated form of a (possibly compound) ordinal: "primero"/ +/// "tercero" suffixes drop their final "o". Other ordinals are unchanged. +#let _apocopate-ordinal(word) = if word.ends-with("primero") or word.ends-with("tercero") { + word.slice(0, -1) +} else { + word +} + +// Cardinal helpers. + +/// Converts a number in the range 1–99 to its cardinal word form. The +/// `apocopate` and `feminine` flags control the form of a trailing +/// "uno"/"veintiuno": apocopated ("un"/"veintiún"), feminine ("una"/"veintiuna"), +/// or default masculine. `apocopate` takes precedence (used before "mil"). +/// +/// - number (int): The number to convert (1–99). +/// - apocopate (bool): Whether to apocopate a trailing "uno". +/// - feminine (bool): Whether to use the feminine form. +/// -> str +#let _convert-below-100(number, apocopate: false, feminine: false) = { + let unit-word(i) = if apocopate { + _units-apocopated.at(i) + } else if feminine { + _feminine-unit(_units.at(i)) + } else { + _units.at(i) + } + if number < 30 { + unit-word(number) + } else { + let tens-digit = calc.quo(number, 10) + let units-digit = calc.rem(number, 10) + if units-digit == 0 { + _tens.at(tens-digit - 3) + } else { + _tens.at(tens-digit - 3) + " y " + unit-word(units-digit) + } + } +} + +/// Converts a number in the range 1–999 to its cardinal word form. The +/// `apocopate` flag is forwarded to the trailing 1–99 part; it does not affect +/// the 100 -> "cien" rule, which is intrinsic to this helper. +/// +/// - number (int): The number to convert (1–999). +/// - apocopate (bool): Whether to apocopate a trailing "uno". +/// - feminine (bool): Whether to use feminine forms for "uno" and the hundreds +/// 200–900. "cien"/"ciento" are invariable. +/// -> str +#let _convert-below-1000(number, apocopate: false, feminine: false) = { + if number < 100 { + _convert-below-100(number, apocopate: apocopate, feminine: feminine) + } else { + let hundreds-digit = calc.quo(number, 100) + let remainder = calc.rem(number, 100) + let hundreds-word = if hundreds-digit == 1 and remainder == 0 { + "cien" + } else { + let masc = _hundreds.at(hundreds-digit) + if feminine { _feminine-hundred(masc) } else { masc } + } + if remainder == 0 { + hundreds-word + } else { + hundreds-word + " " + _convert-below-100(remainder, apocopate: apocopate, feminine: feminine) + } + } +} + +/// Converts a number in the range 1–999_999 (one long-scale group) to its +/// cardinal word form. Splits the number into a thousands part (joined with +/// "mil") and a units part. The thousands part is always apocopated because +/// "mil" follows; the units part is apocopated only if a scale noun follows +/// the entire group (controlled by `apocopate-units`). +/// +/// - number (int): The number to convert (1–999_999). +/// - apocopate-units (bool): Whether the units part should apocopate (true +/// when a scale noun like "millón" follows this 6-digit group). +/// - feminine (bool): Whether the chunk modifies a feminine noun. Affects the +/// thousands part (e.g. "veintiuna mil personas") and the units part when no +/// scale word follows; ignored for units when `apocopate-units` is true, +/// since scale nouns (millón, billón…) are masculine. +/// -> str +#let _convert-below-million(number, apocopate-units: false, feminine: false) = { + let thousands = calc.quo(number, 1000) + let units = calc.rem(number, 1000) + let parts = () + if thousands == 1 { + parts.push("mil") + } else if thousands > 1 { + let thousands-word = if feminine { + _convert-below-1000(thousands, feminine: true) + } else { + _convert-below-1000(thousands, apocopate: true) + } + parts.push(thousands-word + " mil") + } + if units > 0 { + let units-word = if apocopate-units { + _convert-below-1000(units, apocopate: true) + } else { + _convert-below-1000(units, feminine: feminine) + } + parts.push(units-word) + } + parts.join(" ") +} + +/// Recursively splits a number into 6-digit chunks (one long-scale group each) +/// and converts each chunk, appending the appropriate scale word. +/// +/// - number (int): The remaining number to convert. +/// - scale-index (int): The current scale index (0 = bottom group, 1 = millones, …). +/// - feminine (bool): Whether the overall number modifies a feminine noun. +/// Only the bottom chunk (scale-index 0) inherits the gender, since scale +/// words (millón, billón…) are masculine and impose their own agreement. +/// -> array +#let _chunk-and-convert(number, scale-index, feminine: false) = { + if number == 0 { + () + } else { + errors.out-of-range(scale-index, max: _scales-singular.len() - 1, lang: _lang-code) + let chunk = calc.rem(number, 1000000) + let rest = calc.quo(number, 1000000) + let higher = _chunk-and-convert(rest, scale-index + 1, feminine: feminine) + if chunk == 0 { + higher + } else { + let words = _convert-below-million( + chunk, + apocopate-units: scale-index > 0, + feminine: feminine and scale-index == 0, + ) + if scale-index > 0 { + let scale-word = if chunk == 1 { + _scales-singular.at(scale-index) + } else { + _scales-plural.at(scale-index) + } + words = words + " " + scale-word + } + higher + (words,) + } + } +} + +/// Converts a positive integer to its cardinal word form. +/// +/// - number (int): The number to convert (>= 1). +/// - feminine (bool): Whether the number modifies a feminine noun. +/// -> str +#let _convert-cardinal(number, feminine: false) = { + _chunk-and-convert(number, 0, feminine: feminine).join(" ") +} + +// Ordinal helpers. + +/// Converts a number in the range 1–99 to its ordinal word form (masculine, +/// non-apocopated). +/// +/// - number (int): The number to convert (1–99). +/// -> str +#let _convert-ordinal-below-100(number) = { + if number < 10 { + _ord-units.at(number) + } else if number == 10 { + "décimo" + } else if number == 11 { + "undécimo" + } else if number == 12 { + "duodécimo" + } else if number < 20 { + _ord-teens.at(number - 13) + } else { + let tens-digit = calc.quo(number, 10) + let units-digit = calc.rem(number, 10) + if units-digit == 0 { + _ord-tens.at(tens-digit) + } else { + _ord-tens.at(tens-digit) + " " + _ord-units.at(units-digit) + } + } +} + +/// Converts a positive integer in the range 1–999 to its ordinal word form. +/// Panics if `number` is outside [1, 999]. The masculine form is built first +/// and then transformed: `apocopated` drops the final "o" of a trailing +/// "primero"/"tercero"; `feminine` swaps the final "o" of every word for "a". +/// +/// - number (int): The number to convert (1–999). +/// - feminine (bool): Whether to return the feminine form. +/// - apocopated (bool): Whether to return the apocopated form (masculine only; +/// the public entry point rejects the feminine combination). +/// -> str +#let _convert-ordinal(number, feminine: false, apocopated: false) = { + errors.out-of-range(number, min: 1, max: 999, lang: _lang-code) + let masculine = if number < 100 { + _convert-ordinal-below-100(number) + } else { + let hundreds-digit = calc.quo(number, 100) + let remainder = calc.rem(number, 100) + if remainder == 0 { + _ord-hundreds.at(hundreds-digit) + } else { + _ord-hundreds.at(hundreds-digit) + " " + _convert-ordinal-below-100(remainder) + } + } + if apocopated { + _apocopate-ordinal(masculine) + } else if feminine { + _feminine-ordinal(masculine) + } else { + masculine + } +} + +// Public entry point. + +/// Converts a number to its Spanish word form. +/// +/// Cardinals are returned across the full long-scale range. Ordinals are +/// supported within the closed range [1, 999]; values outside that range panic +/// with an out-of-range error. +/// +/// `gender` controls grammatical agreement: with `"feminine"`, cardinals +/// produce forms like "una", "veintiuna", "doscientas" (and "veintiuna mil +/// personas"); ordinals end in "-a". Scale nouns (mil, millón, billón…) are +/// invariable and stay masculine. +/// +/// `apocopated` is only available for ordinals in masculine. It produces the +/// short form used before a noun: "primer", "tercer", "vigésimo primer", +/// "decimotercer". Combining `apocopated: true` with `gender: "feminine"` +/// panics, since Spanish has no feminine apocopated ordinal. +/// +/// - number (int): The number to convert. +/// - form (str): The form: `"cardinal"` (default) or `"ordinal"`. +/// - gender (str): `"masculine"` (default) or `"feminine"`. +/// - apocopated (bool): Use the apocopated ordinal form. Ordinal + masculine only. +/// - negative (str): The prefix for negative numbers (default: `"menos"`). +/// -> str +#let convert( + number, + form: "cardinal", + gender: "masculine", + apocopated: false, + negative: "menos", +) = { + errors.assert-type("form", str, form, lang: _lang-code) + errors.assert-option("form", form, _supported-forms, lang: _lang-code) + errors.assert-type("gender", str, gender, lang: _lang-code) + errors.assert-option("gender", gender, _supported-genders, lang: _lang-code) + errors.assert-type("apocopated", bool, apocopated, lang: _lang-code) + errors.assert-type("negative", str, negative, lang: _lang-code) + + let feminine = gender == "feminine" + + if apocopated { + assert( + form == "ordinal", + message: "num2words (es): 'apocopated' is only available for ordinals", + ) + assert( + not feminine, + message: "num2words (es): 'apocopated' is not available for feminine gender", + ) + } + + if number == 0 and form == "cardinal" { + "cero" + } else { + let prefix = if number < 0 { negative + " " } else { "" } + let abs-number = calc.abs(number) + let result = if form == "cardinal" { + _convert-cardinal(abs-number, feminine: feminine) + } else { + _convert-ordinal(abs-number, feminine: feminine, apocopated: apocopated) + } + prefix + result + } +} diff --git a/packages/preview/num2words/0.2.0/src/lib.typ b/packages/preview/num2words/0.2.0/src/lib.typ new file mode 100644 index 0000000000..43ea0e0388 --- /dev/null +++ b/packages/preview/num2words/0.2.0/src/lib.typ @@ -0,0 +1,70 @@ +/// Typst num2words: convert numbers to their written word form. +#import "errors.typ" +#import "langs/en.typ" +#import "langs/es.typ" +#import "langs/ca.typ" + +#let converters = ( + en: en.convert, + es: es.convert, + ca: ca.convert, +) + +/// Validates the shape of a `fallback` argument and normalizes it to an array. Accepts a single string, a single +/// `none`, or an array whose entries are strings or `none`. +#let _normalize-fallback(fallback) = { + let chain = if type(fallback) == array { fallback } else { (fallback,) } + for (i, item) in chain.enumerate() { + let t = type(item) + if not (item == none or t == str) { + panic( + "num2words: 'fallback' entries must be strings or `none`, got " + str(t) + " at index " + str(i), + ) + } + } + chain +} + +/// Converts a number to its written word form. +/// +/// - number (int): The number to convert. +/// - lang (str, auto): The language code (e.g., `"en"`). When `auto`, uses the current `text.lang`. +/// - fallback (str, none, array): The fallback chain, where `none` can be used to return an empty result instead of +/// panicking. +/// - ..args: Additional arguments forwarded to the language-specific converter. +/// -> content +#let num2words(number, lang: auto, fallback: none, ..args) = { + errors.assert-type("number", int, number) + let chain = _normalize-fallback(fallback) + + return context { + let resolved-lang = if lang == auto { text.lang } else { lang } + errors.assert-type("lang", str, resolved-lang) + + let candidates = (resolved-lang,) + chain + let result = none + let matched = false + let attempted = () + for cand in candidates { + if matched { continue } + if cand == none { + result = "" + matched = true + } else { + attempted.push(cand) + if cand in converters { + result = converters.at(cand)(number, ..args) + matched = true + } + } + } + if not matched { + panic( + "num2words: no supported language in fallback chain (tried: " + + attempted.map(c => "'" + c + "'").join(", ") + + ")", + ) + } + result + } +} diff --git a/packages/preview/num2words/0.2.0/typst.toml b/packages/preview/num2words/0.2.0/typst.toml new file mode 100644 index 0000000000..585ecfed5d --- /dev/null +++ b/packages/preview/num2words/0.2.0/typst.toml @@ -0,0 +1,17 @@ +[package] +name = "num2words" +description = "Convert numbers to their written word form." +version = "0.2.0" +entrypoint = "src/lib.typ" +compiler = "0.14.0" +repository = "https://github.com/mariovagomarzal/typst-num2words" +authors = [ + "Mario Vago Marzal " +] +license = "LGPL-3.0-or-later" +categories = ["utility", "languages"] +keywords = ["number", "words", "conversion"] +exclude = ["CONTRIBUTING.md"] + +[tool.tytanic] +tests = "tests"