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"