diff --git a/.github/workflows/translator-alert.yml b/.github/workflows/translator-alert.yml index 0ceacbae1..3a1ecd60a 100644 --- a/.github/workflows/translator-alert.yml +++ b/.github/workflows/translator-alert.yml @@ -136,11 +136,16 @@ jobs: ? Array.from(shaSet).map(sha => `- https://github.com/${context.repo.owner}/${context.repo.repo}/commit/${sha}`).join("\n") : "(No commits specifically touched monitored files)"; + const fileLinks = matchedList.map(file => { + const encodedPath = file.split("/").map(encodeURIComponent).join("/"); + return `- [${file}](https://github.com/${context.repo.owner}/${context.repo.repo}/blob/${context.sha}/${encodedPath})`; + }).join("\n"); + const body = ` Changes detected in english files. Changed files: - ${JSON.stringify(matchedList, null, 2)} + ${fileLinks} See changes in the commits affecting monitored files: ${commitUrls} diff --git a/AGENTS.md b/AGENTS.md index 7093cf698..d752728e2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -12,7 +12,7 @@ but add common mistakes of AI agents here instead. - Common per-language files: - `ClearSpeak_Rules.yaml`, `SimpleSpeak_Rules.yaml` - `SharedRules/`, `unicode.yaml`, `unicode-full.yaml`, `definitions.yaml`, `navigate.yaml` -- `build.rs` can bundle rules into `rules.zip` when `include-zip` is enabled (always the minimized tree). Shared zipper: `src/rules_archive.rs`, invoked as `cargo run --bin package-rules -- Rules [--minimize]`. +- `build.rs` can bundle rules into `rules.zip` when `include-zip` is enabled (always the minimized tree). The `lang-*` / `braille-*` features (all on by default via `rules-all`) choose which `Languages/` and `Braille/` directories it embeds; every rules directory needs a feature listed in `lang-all` / `braille-all`, and `build.rs` fails without one. Shared zipper: `src/rules_archive.rs`, invoked as `cargo run --bin package-rules -- Rules [--minimize]`. - CI packages `Rules.zip` (verbatim) and `Rules-minimized.zip` (comments stripped from `Languages/**/unicode.yaml` and `unicode-full.yaml` only). Each language/braille subdir is stored as `/.zip`; top-level and `Intent/` YAML stay loose. Inner language zips use BZIP2; the outer downloadable archive uses DEFLATE so `unzip` works. Both omit `Languages/zz`; the `test-rules-package` job restores `zz` from git after unzip so unit tests still run against release-like archives. ## Translation Conventions diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 000000000..ad245cc67 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,56 @@ +# Contributing to MathCAT + +Thanks for your interest in MathCAT! This is a short intro to get you started. +For more detail, see the [full documentation](https://daisy.github.io/MathCAT/). + +## Ways to help + +- **Report bugs / suggest features:** open an [issue](https://github.com/daisy/MathCAT/issues). + Include the MathML input, the output you got, and the output you expected + (the [MathCAT Demo](https://daisy.github.io/MathCATDemo/) is handy for this). +- **Code:** fixes and improvements to the Rust library (`src/`) or the rules (`Rules/`). +- **Translations:** translators have their own guide. Start with + [information for translators/rule writers](https://daisy.github.io/MathCAT/helpers.html). + If you want to add a new language, please open an issue first. + +## Getting set up + +1. Install [Rust](https://www.rust-lang.org/tools/install). +2. Fork and clone the repo. +3. Build and run the tests: + ```bash + cargo build + cargo test + ``` +4. For the Python tooling (e.g. translation audits), use [uv](https://docs.astral.sh/uv/). + See [`PythonScripts/README.md`](PythonScripts/README.md). + +The [Developer Guide](docs/developers.md) has more on testing and coverage. + +## Pull requests + +- Keep PRs focused: one fix or feature per PR. +- **Add relevant unit tests whenever it makes sense.** A bug fix should usually include + a test that would have caught it. Trivial changes (e.g. fixing a typo in a comment) + don't need one. +- Make sure `cargo test` and `cargo clippy` pass. CI runs both and treats warnings as errors. +- Don't change expected outputs (speech/braille/navigation strings) in existing tests + unless that change is the point of your PR. If so, explain why in the description. +- Link the issue your PR addresses, if there is one. + +## Using AI tools + +Using AI to help write code is fine. You are still responsible for what you submit: +review it, understand it, and test it. + +Issues, PR descriptions, and comments should be **written by a human**. If you want to +include AI-generated text, put it in a clearly labeled block at the end, for example: + +> **AI summary:** . + +Nobody should have to read through walls of generated text to find out what you mean. + +## Questions? + +Open an issue or ask in the relevant issue/PR thread. We're happy to help. + diff --git a/Cargo.lock b/Cargo.lock index 66743aed7..f1468ae2d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -233,6 +233,12 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" +[[package]] +name = "cfg_aliases" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" + [[package]] name = "clap" version = "4.6.1" @@ -750,9 +756,9 @@ checksum = "2c4a545a15244c7d945065b5d392b2d2d7f21526fba56ce51467b06ed445e8f7" [[package]] name = "libc" -version = "0.2.182" +version = "0.2.191" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6800badb6cb2082ffd7b6a67e6125bb39f18782f793520caee8cb8846be06112" +checksum = "65cd87c9a6d60536580b316ad985726104730a5151c99845f2490428ef91240a" [[package]] name = "libredox" @@ -892,6 +898,18 @@ dependencies = [ "jni-sys 0.3.1", ] +[[package]] +name = "nix" +version = "0.31.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf20d2fde8ff38632c426f1165ed7436270b44f199fc55284c38276f9db47c3d" +dependencies = [ + "bitflags 2.11.0", + "cfg-if", + "cfg_aliases", + "libc", +] + [[package]] name = "num-bigint" version = "0.4.6" @@ -2075,15 +2093,16 @@ dependencies = [ [[package]] name = "zip" -version = "8.6.0" +version = "9.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2d04a6b5381502aa6087c94c669499eb1602eb9c5e8198e534de571f7154809b" +checksum = "a954114b47a54df49f83d2722814e86b23d4bb1b54a1b90fb7d92b41619ddc22" dependencies = [ "bzip2", "crc32fast", "flate2", "indexmap", "memchr", + "nix", "typed-path", "zopfli", ] diff --git a/Cargo.toml b/Cargo.toml index a51507536..f479aeb2c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -12,12 +12,49 @@ exclude = ["src/main.rs", "docs", "BrailleDocs", "PythonScripts"] # should ha [features] +default = ["rules-all"] "rule-coverage" = ["dep:serde_json"] "include-zip" = [] "enable-logs" = ["android_logger"] "tts" = [ "natural-tts" ] "no-unsafe" = ["sxd-document-no-unsafe/no-unsafe", "sxd-xpath-no-unsafe/no-unsafe"] +# Which Rules/Languages and Rules/Braille directories `include-zip` embeds. Every one is on by default. +# To embed fewer, turn off default features and name the ones you want, for example +# `default-features = false, features = ["include-zip", "lang-en", "braille-ueb"]`. +# `en` is always embedded because the library falls back to it, and `build.rs` fails if a rules +# directory has no feature here or is missing from `lang-all` / `braille-all`. +# MathCAT itself never asks for ASCIIMath or LaTeX, but an application that copies math in those +# notations does so through the braille API, so it needs `braille-asciimath` / `braille-latex`. +# These only change what is embedded: without `include-zip`, the Rules directory is read from disk. +rules-all = ["lang-all", "braille-all"] +lang-all = ["lang-de", "lang-el", "lang-en", "lang-es", "lang-fi", "lang-fr", "lang-hu", "lang-id", "lang-nb", "lang-pl", "lang-pt", "lang-ru", "lang-sv", "lang-vi", "lang-zh"] +braille-all = ["braille-asciimath", "braille-cmu", "braille-french", "braille-latex", "braille-nemeth", "braille-russian", "braille-swedish", "braille-ueb", "braille-vietnam"] +lang-de = [] +lang-el = [] +lang-en = [] +lang-es = [] +lang-fi = [] +lang-fr = [] +lang-hu = [] +lang-id = [] +lang-nb = [] +lang-pl = [] +lang-pt = [] +lang-ru = [] +lang-sv = [] +lang-vi = [] +lang-zh = [] +braille-asciimath = [] +braille-cmu = [] +braille-french = [] +braille-latex = [] +braille-nemeth = [] +braille-russian = [] +braille-swedish = [] +braille-ueb = [] +braille-vietnam = [] + [dependencies] sxd-document-no-unsafe = "0.4.2" @@ -44,8 +81,8 @@ elements_rs = { version = "0.2.8", default-features = false } # Runtime extracts inner language/braille zips (BZIP2). The package-rules binary also # writes DEFLATE outer archives for Windows/unzip-compatible Rules.zip downloads. -# wasm uses the same features: zip 8.x bzip2 is pure Rust (libbz2-rs-sys), not clang C. -zip = { version = "8.2", default-features = false, features = ["deflate", "bzip2"] } +# wasm uses the same features: zip 9.x bzip2 is pure Rust (libbz2-rs-sys), not clang C. +zip = { version = "9.0", default-features = false, features = ["deflate", "bzip2"] } android_logger = {version = "0.15.1", optional = true} natural-tts = { version = "0.3.1", optional = true } @@ -53,7 +90,7 @@ natural-tts = { version = "0.3.1", optional = true } [build-dependencies] bitflags = "2.6" yaml-rust = "0.4" -zip = { version = "8.2", default-features = false, features = ["deflate", "bzip2"] } +zip = { version = "9.0", default-features = false, features = ["deflate", "bzip2"] } [dev-dependencies] tempfile = "3.23" diff --git a/Rules/Intent/general.yaml b/Rules/Intent/general.yaml index 54303de79..e16521589 100644 --- a/Rules/Intent/general.yaml +++ b/Rules/Intent/general.yaml @@ -347,7 +347,7 @@ - name: limit tag: [msub, munder] - variables: [NoSpacesBase: "translate(*[1], ' \u00A0\u2004\u2005\u2006\u2007\u2008\u2009\u200A','')"] # Sometimes these have (weird) spaces in them + variables: [NoSpacesBase: "translate(*[1], ' \u00A0\u2004\u2005\u2006\u2007\u2008\u2009\u200A\u2061','')"] # Sometimes these have spaces or an invisible function-application character in them match: "*[1][$NoSpacesBase='lim' or $NoSpacesBase='limsup' or $NoSpacesBase='liminf']" replace: - intent: @@ -809,6 +809,22 @@ variables: [TableProperty: "'determinant'"] replace: [x: "*[2]"] +- + # TeX's gathered environment is one derivation split across display lines, + # not a system whose rows are independent equations. + name: mtable-gathered-property + tag: mtable + match: "contains(concat(' ', normalize-space(@class), ' '), ' gathered ')" + replace: + - with: + variables: + - TableProperty: "'lines'" + replace: + - intent: + name: "lines" + children: + - x: "*" + - name: mtable-equations-property tag: mtable diff --git a/Rules/Intent/linear-algebra.yaml b/Rules/Intent/linear-algebra.yaml index f8c8fd7bf..4b7afe816 100644 --- a/Rules/Intent/linear-algebra.yaml +++ b/Rules/Intent/linear-algebra.yaml @@ -39,6 +39,49 @@ - x: "*[1]/*[2]" # grab the contents of the bracketed expr - x: "*[2]" +- + # TeX4ht commonly attaches the norm's subscript to the closing bar only: + # ∥...∥p. + name: norm-with-subscripted-closing-bar + tag: mrow + match: + - "count(*) >= 3 and *[1][.='∥'] and *[last()][self::m:msub and *[1][.='∥']]" + replace: + - intent: + name: "subscripted-norm" + attrs: "data-intent-property='concat(data-intent-property, \":function:\")'" + children: + - x: "*[position() > 1 and position() < last()]" + - x: "*[last()]/*[2]" + +- + # The same TeX4ht shape can use two ordinary vertical bars for each double bar. + name: norm-with-doubled-bars-and-subscripted-closing-bar + tag: mrow + match: + - "count(*) >= 5 and *[1][.='|'] and *[2][.='|'] and *[last()-1][.='|'] and *[last()][self::m:msub and *[1][.='|']]" + replace: + - intent: + name: "subscripted-norm" + attrs: "data-intent-property='concat(data-intent-property, \":function:\")'" + children: + - x: "*[position() > 2 and position() < last()-1]" + - x: "*[last()]/*[2]" + +- + # Canonicalization groups ||x||_p as an indexed row containing a nested |x|. + name: norm-with-canonicalized-doubled-bars + tag: msub + match: + - "*[1][self::m:mrow and count(*)=3 and *[1][.='|'] and *[2][self::m:mrow and IsBracketed(., '|', '|')] and *[3][.='|']]" + replace: + - intent: + name: "subscripted-norm" + attrs: "data-intent-property='concat(data-intent-property, \":function:\")'" + children: + - x: "*[1]/*[2]/*[2]" + - x: "*[2]" + - name: vector-magnitude tag: mrow diff --git a/Rules/Languages/hu/definitions.yaml b/Rules/Languages/hu/definitions.yaml index f070f72fc..c72e0facc 100644 --- a/Rules/Languages/hu/definitions.yaml +++ b/Rules/Languages/hu/definitions.yaml @@ -351,6 +351,10 @@ ## Other : Not tested, don't appear in https://w3c.github.io/mathml-docs/intent-core-concepts/ "modified-variable": "silent= " } + # The connector word to say for functional 'IntentMappings' (in English, "of") + # This is likely the word used for U+2061 in unicode.yaml, but for technical reasons needs to be defined here + # vector of length=1 because scalars not supported +- FunctionApplicationWord: ["a(z)"] # Names of functions that in terse mode don't say "of" (or it's equivalent in other languages) - TerseFunctionNames: { @@ -359,30 +363,30 @@ - NavigationParts: { # These are the parts of a formula that can be navigated to - "large-op": "base; lower limit; upper limit", - "mfrac": "numerator; denominator", + "large-op": "alap; alsó határ; felső határ", + "mfrac": "számláló; nevező", "fraction": "számláló; nevező", "msqrt": "gyök", "square-root": "gyök", - "mroot": "root; root index", + "mroot": "gyök; gyök index", "root": "gyök; gyök index", - "msub": "base; subscript", - "sub": "base; subscript", - "logarithm-with-base": "base", - "indexed-by": "base; subscript", - "msup": "base; superscript", - "say-super": "base; superscript", - "skip-super": "base; superscript", - "power": "base; exponent", - "msubsup": "base; subscript; superscript", - "munder": "base; under limit", - "mover": "base; upper limit", - "munderover": "base; under limit; upper limit", + "msub": "alap; alsó index", + "sub": "alap; alsó index", + "logarithm-with-base": "alap logaritmus", + "indexed-by": "alap; alsó index", + "msup": "alap; felső index", + "say-super": "alap; felső index", + "skip-super": "alap; felső index", + "power": "alap; kitevő", + "msubsup": "alap; alsó index; felső index", + "munder": "alap; határérték alatt", + "mover": "alap; határérték felett", + "munderover": "alap; határérték alatt; határérték felett", # words for moving into and out of one of the parts (e.g., "move right 'out of' numerator, 'in' denominator") # it's a hack to put them here, but at least they are grouped with the other navigation parts - "in": "in", - "out": "out of", + "in": "ban/ben", + "out": "ki", } - KnownWords: { diff --git a/Rules/Languages/hu/navigate.yaml b/Rules/Languages/hu/navigate.yaml index 475e1f625..1507973a8 100644 --- a/Rules/Languages/hu/navigate.yaml +++ b/Rules/Languages/hu/navigate.yaml @@ -39,7 +39,7 @@ # as they would likely be much more efficient and also cleaner. # Rules for announcing the command -- name: say-command +- name: say-command #audit-ignore tag: "!*" match: "$SayCommand != ''" # value should be '', 'true', or 'false' variables: [Prefix: "''"] @@ -48,7 +48,7 @@ - if: "$MatchCounter = 0 and $SayCommand = 'true'" then_test: - if: "self::m:math and starts-with($NavCommand, 'ZoomOut')" - then: [T: "teljesen kinagyítva", pause: "medium"] + then: [T: "teljesen kicsinyítve", pause: "medium"] # phrase('zoomed out all of the way') - else_if: "IsNode(., 'leaf') and starts-with($NavCommand, 'ZoomIn')" then: - test: @@ -56,41 +56,51 @@ then: [T: "teljesen ráközelített"] # phrase('zoomed in all of the way') - else_if: "$NavNodeOffset = 0" then: [T: "ráközelített az első karakterre"] # phrase('zoomed in to first character') - else: [T: "karakterre nagyítva"] # phrase('zoomed in to character') + else: [T: "ráközelített a karakterre"] # phrase('zoomed in to character') - pause: "medium" else: - test: # CommandOffset is 1 + length of the English NavCommand stem (Zoom/Move/Read/Describe), # not the spoken Prefix length — so translations can use a different Prefix word. - if: "starts-with($NavCommand, 'Zoom')" - then: [set_variables: [Prefix: "'zoom'", CommandOffset: "5"]] # phrase('zoom' in to see more details) + then: + # the whole phrase is chosen here ("kicsinyítés" is the Hungarian for "zoom out"), so no suffix word is needed below + - test: + - if: "$NavCommand = 'ZoomOut'" + then: [set_variables: [Prefix: "'kicsinyítés'", CommandOffset: "5"]] # phrase('zoom out') + - else_if: "$NavCommand = 'ZoomOutAll'" + then: [set_variables: [Prefix: "'teljes kicsinyítés'", CommandOffset: "5"]] # phrase('zoom out all of the way') + - else_if: "$NavCommand = 'ZoomInAll'" + then: [set_variables: [Prefix: "'teljes nagyítás'", CommandOffset: "5"]] # phrase('zoom in all of the way') + - else: [set_variables: [Prefix: "'nagyítás'", CommandOffset: "5"]] # phrase('zoom' in to see more details) - else_if: "starts-with($NavCommand, 'Move')" - then: [set_variables: [Prefix: "'move'", CommandOffset: "5"]] # phrase('move' to next entry in table) + then: [set_variables: [Prefix: "'ugrás'", CommandOffset: "5"]] # phrase('move' to next entry in table) - else_if: "starts-with($NavCommand, 'Read')" - then: [set_variables: [Prefix: "'read'", CommandOffset: "5"]] # phrase('read' to next entry in table) + then: + # "olvasás jelenlegi" is not Hungarian; the whole phrase is chosen here (no suffix word needed for 'Current') + - test: + - if: "$NavCommand = 'ReadCurrent'" + then: [set_variables: [Prefix: "'az aktuális elem felolvasása'", CommandOffset: "5"]] # phrase('read current') + - else: [set_variables: [Prefix: "'olvasás'", CommandOffset: "5"]] # phrase('read' to next entry in table) - else_if: "starts-with($NavCommand, 'Describe')" - then: [set_variables: [Prefix: "'describe'", CommandOffset: "9"]] # phrase('describe' to next entry in table) + then: + - test: + - if: "$NavCommand = 'DescribeCurrent'" + then: [set_variables: [Prefix: "'az aktuális elem leírása'", CommandOffset: "9"]] # phrase('describe current') + - else: [set_variables: [Prefix: "'leírás'", CommandOffset: "9"]] # phrase('describe' to next entry in table) - test: if: "$Prefix != ''" then: - x: "$Prefix" - test: - if: "substring($NavCommand, $CommandOffset) = 'In'" - then: [T: "ban"] # phrase(zoom 'in' to see more details) - - else_if: "substring($NavCommand, $CommandOffset) = 'InAll'" - # HACK: '\uF8FE' is used internally for the concatenation char by 'ct' -- this gets "ed" concatenated to "zoom" - then: [T: "Teljesen kinagyítva"] # phrase(zoom 'out all of the way' to see more details) - - else_if: "substring($NavCommand, $CommandOffset) = 'Out'" - then: [T: "ki"] # phrase(zoom 'out' to see more details) - - else_if: "substring($NavCommand, $CommandOffset) = 'OutAll'" - # HACK: '\uF8FE' is used internally for the concatenation char by 'ct' -- this gets "ed" concatenated to "zoom" - then: [T: "teljes táblázat kinagyítása"] # phrase(zoom 'out all of the way' to see more details) + then: [T: ""] # phrase(zoom 'in' to see more details) + # 'InAll', 'Out' and 'OutAll' are covered by the Prefix chosen above (nothing more to say) - else_if: "substring($NavCommand, $CommandOffset) = 'Next'" then: [T: "jobbra"] # phrase(move to the 'right') - else_if: "substring($NavCommand, $CommandOffset) = 'Previous'" then: [T: "balra"] # phrase(move to the 'left') - - else_if: "substring($NavCommand, $CommandOffset) = 'Current'" - then: [T: "jelenlegi"] # phrase(who is the 'current' president) + # 'Current' is covered by the Prefix chosen above (ReadCurrent / DescribeCurrent) - else_if: "substring($NavCommand, $CommandOffset) = 'LineStart'" then: [T: "a sor elejére"] # phrase(move 'to start of line') - else_if: "substring($NavCommand, $CommandOffset) = 'LineEnd'" @@ -122,7 +132,7 @@ - x: "count($Child2D/preceding-sibling::*)+1" - pause: "medium" -- name: into-or-out-of-mmultiscripts +- name: into-or-out-of-mmultiscripts #audit-ignore tag: "*" match: "$Move2D != '' and (@data-from-mathml='mmultiscripts' or self::m:mmultiscripts)" replace: @@ -133,25 +143,34 @@ variables: - NumPrecedingSiblings: "count($Child2D/preceding-sibling::*)" replace: - - x: "$Move2D" - test: - if: "$NumPrecedingSiblings=0" - then: [T: "alapja"] # phrase(the 'base' of the power) + then: [T: "alap"] # phrase(the 'base' of the power) - else_if: "$Child2D/preceding-sibling::*[self::m:mprescripts]" # are we before mprescripts and hence are postscripts then: - test: # in postscripts -- base shifts by one if: "$NumPrecedingSiblings mod 2 = 0" - then: [T: "alsó index kezdete"] # phrase(x with 'subscript' 2) - else: [T: "Felső index kezdete"] # phrase(x with 'superscript' 2) + then: [T: "bal alsó index"] # phrase(x with 'pre-subscript' 2) + else: [T: "bal felső index"] # phrase(x with 'pre-superscript' 2) else: - test: if: "$NumPrecedingSiblings mod 2 = 0" - then: [T: "felső index"] # phrase(x with 'pre-superscript' 2) - else: [T: "alsó index"] # phrase(x with 'pre-subscript' 2) + then: [T: "felső index"] # phrase(x with 'superscript' 2) + else: [T: "alsó index"] # phrase(x with 'subscript' 2) + # Hungarian puts the case suffix on the part name ("a számlálóban"), which cannot be built for arbitrary part names. + # So "in" is silent (the command word already says it) and the other words follow the part name as a + # possessive/genitive noun phrase that works for any name: "számláló elhagyása", "nevező eleje", "alsó index vége". + - test: + - if: "$Move2D = 'out of'" + then: [T: "elhagyása"] # phrase('out of' the denominator) + - else_if: "$Move2D = 'start of'" + then: [T: "eleje"] # phrase('start of' the denominator) + - else_if: "$Move2D = 'end of'" + then: [T: "vége"] # phrase('end of' the denominator) - pause: "medium" # Rules for speaking what happens when moving into or out of a notation -- name: into-or-out-of-default +- name: into-or-out-of-default #audit-ignore tag: "*" # saying "out of row n" is not very useful, so skip it # match: "$Move2D != '' and @data-from-mathml and @data-from-mathml != name(.) and count(*)>1 and @data-from-mathml != 'mtable'" @@ -162,7 +181,6 @@ - PartNumber: "count($Child2D/preceding-sibling::*)" - PartName: "GetNavigationPartName(name(.), $PartNumber)" replace: - - x: "$Move2D" - test: - if: "$PartName != ''" then: [x: "$PartName"] @@ -185,8 +203,18 @@ then: [x: "SpeakIntentName(name(.), $Verbosity, 'silent')"] - else: [x: "SpeakIntentName(name(.), $Verbosity, 'other')"] else: - - T: "része" # phrase(the 'part' of the expression) + - T: "rész" # phrase(the 'part' of the expression) - x: "count($Child2D/preceding-sibling::*) + 1" + # Hungarian puts the case suffix on the part name ("a számlálóban"), which cannot be built for arbitrary part names. + # So "in" is silent (the command word already says it) and the other words follow the part name as a + # possessive/genitive noun phrase that works for any name: "számláló elhagyása", "nevező eleje", "alsó index vége". + - test: + - if: "$Move2D = 'out of'" + then: [T: "elhagyása"] # phrase('out of' the denominator) + - else_if: "$Move2D = 'start of'" + then: [T: "eleje"] # phrase('start of' the denominator) + - else_if: "$Move2D = 'end of'" + then: [T: "vége"] # phrase('end of' the denominator) - pause: "medium" - name: default-move @@ -1205,16 +1233,15 @@ - test: if: "$MatchCounter = 0 and $NavVerbosity = 'Verbose'" then: - - T: "nem tud" # phrase('cannot' move right in expression) + - T: "nem tud jobbra" # phrase('cannot' move 'right' in expression) - test: - if: "$NavCommand = 'MoveNext'" - then: [T: "mozogni"] # phrase('move' to next entry in table) + then: [T: "mozogni"] # phrase('move' to next entry in table) - else_if: "$NavCommand = 'ReadNext'" then: [T: "olvasni"] # phrase('read' next entry in table) else: [T: "leírni"] # phrase('describe' next entry in table) - - T: "jobbra" # phrase(move 'right') - pause: short - - T: "matematematikai blokk vége" # phrase(move 'end of math') + - T: "matematikai blokk vége" # phrase(move 'end of math') - pause: long - set_variables: [SpeakExpression: "'false'"] @@ -1406,7 +1433,7 @@ - pause: medium - with: variables: - - Move2D: "'end of'" + - Move2D: "'start of'" - Child2D: "$EdgeNode/*[1]" - MatchCounter: $MatchCounter + 1 replace: [x: "$EdgeNode"] @@ -1680,9 +1707,8 @@ then: - test: - if: "$NavCommand = 'ReadCurrent'" - then: [T: "olvassa"] # phrase('read' next entry in table) - else: [T: "írja le"] # phrase('describe' next entry in table) - - T: "jelenlegi" # phrase('current' entry in table) + then: [T: "az aktuális elem felolvasása"] # phrase('read current' entry in table) + else: [T: "az aktuális elem leírása"] # phrase('describe current' entry in table) - pause: long - set_variables: [NavNode: "@id"] diff --git a/build.rs b/build.rs index e5a07cd8d..f2f2862fd 100644 --- a/build.rs +++ b/build.rs @@ -6,21 +6,80 @@ include!("src/rules_archive.rs"); +/// The `Rules/` directories whose `-` feature is on, or `None` when all of them +/// are, so a default build stages exactly what it always has. +/// +/// Panics if a directory is missing from `-all` in Cargo.toml. Otherwise a newly added +/// language or braille code would quietly drop out of every build, since nothing would turn it on. +fn subset_from_features(rules_dir: &Path, kind: &str, prefix: &str, manifest: &str) -> Option> { + let all_feature = format!("{}-all", prefix); + // The list can span lines: `cargo publish` rewrites Cargo.toml with one entry per line. + let all_list = manifest + .split(&format!(" +{} = [", all_feature)) + .nth(1) + .and_then(|rest| rest.split(']').next()) + .unwrap_or_else(|| panic!("Cargo.toml has no `{}` feature", all_feature)); + let mut kept = Vec::new(); + let mut everything = true; + for entry in read_dir(rules_dir.join(kind)).unwrap() { + let path = entry.unwrap().path(); + let name = path.file_name().unwrap().to_string_lossy().to_string(); + if !path.is_dir() || (kind == LANGUAGES_DIR && name == SKIP_LANGUAGE_DIR) { + continue; + } + let feature = format!("{}-{}", prefix, name.to_ascii_lowercase()); + if !all_list.contains(&format!("\"{}\"", feature)) { + panic!( + "Rules/{}/{} has no feature: add `{} = []` to Cargo.toml and list it in `{}`", + kind, name, feature, all_feature + ); + } + let env_var = format!("CARGO_FEATURE_{}", feature.to_ascii_uppercase().replace('-', "_")); + if std::env::var_os(env_var).is_some() { + kept.push(name); + } else { + everything = false; + } + } + kept.sort(); + return if everything { None } else { Some(kept) }; +} + fn main() { println!("cargo::rerun-if-changed=build.rs"); println!("cargo::rerun-if-changed=src/rules_archive.rs"); println!("cargo::rerun-if-changed=Rules"); + println!("cargo::rerun-if-changed=Cargo.toml"); + + let rules_dir = std::env::current_dir().unwrap().join("Rules"); + let manifest = fs::read_to_string("Cargo.toml").unwrap(); + // Checked on every build, not only include-zip ones, so a rules directory added without a + // feature is caught by whoever adds it. + let languages = subset_from_features(&rules_dir, LANGUAGES_DIR, "lang", &manifest); + let braille_codes = subset_from_features(&rules_dir, BRAILLE_DIR, "braille", &manifest); // This doesn't work because the build claims OUT_DIR is not defined(?) // let archive = PathBuf::from(concat!(env!("OUT_DIR"),"/rules.zip")); if std::env::var("CARGO_FEATURE_INCLUDE_ZIP").is_ok() { let out_dir = PathBuf::from(std::env::var_os("OUT_DIR").unwrap()); - let rules_dir = std::env::current_dir().unwrap().join("Rules"); let staging = out_dir.join("rules_src"); if staging.exists() { let _ = std::fs::remove_dir_all(&staging); } - if let Err(e) = copy_rules_tree(&rules_dir, &staging, true) { + let subset = RulesSubset { languages, braille_codes }; + if let Some(langs) = &subset.languages { + let mut langs = langs.clone(); + if !langs.iter().any(|l| l.eq_ignore_ascii_case("en")) { + langs.push("en (the fallback)".to_string()); + } + println!("cargo::warning=MathCAT rules limited to languages: {}", langs.join(", ")); + } + if let Some(codes) = &subset.braille_codes { + let codes = if codes.is_empty() { "UEB (the fallback)".to_string() } else { codes.join(", ") }; + println!("cargo::warning=MathCAT rules limited to braille codes: {}", codes); + } + if let Err(e) = copy_rules_subset(&rules_dir, &staging, true, &subset) { panic!("build.rs failed to stage minimized Rules: {}", e); } let archive_path = out_dir.join("rules.zip"); diff --git a/src/bin/package_rules.rs b/src/bin/package_rules.rs index 971e95175..d14bfe659 100644 --- a/src/bin/package_rules.rs +++ b/src/bin/package_rules.rs @@ -87,7 +87,7 @@ mod tests { fn outer_names(bytes: &[u8]) -> Vec { let mut archive = ZipArchive::new(Cursor::new(bytes)).unwrap(); let mut names: Vec = (0..archive.len()) - .map(|i| archive.by_index(i).unwrap().name().replace('\\', "/")) + .map(|i| archive.by_index(i).unwrap().name().unwrap().replace('\\', "/")) .collect(); names.sort(); return names; diff --git a/src/braille.rs b/src/braille.rs index 1766cb9d8..caec624c1 100644 --- a/src/braille.rs +++ b/src/braille.rs @@ -2289,19 +2289,14 @@ fn handle_contractions(full_chars: &[char], start: usize, end: usize, mut result } fn init_patterns(contractions: &[Replacement]) -> RegexSet { - let mut vec: Vec<&str> = Vec::with_capacity(contractions.len()); - for contraction in contractions { - vec.push(&contraction.pattern); - } - return RegexSet::new(&vec).unwrap(); + let patterns = contractions.iter().map(|c| c.pattern.as_str()); + return RegexSet::new(patterns).unwrap(); } fn init_regex(contractions: &[Replacement]) -> Vec { - let mut vec = Vec::with_capacity(contractions.len()); - for contraction in contractions { - vec.push(Regex::new(&contraction.pattern).unwrap()); - } - return vec; + return contractions.iter() + .map(|c| Regex::new(&c.pattern).unwrap()) + .collect(); } } diff --git a/src/canonicalize.rs b/src/canonicalize.rs index 0e0a81447..0ccf108fa 100644 --- a/src/canonicalize.rs +++ b/src/canonicalize.rs @@ -1684,12 +1684,12 @@ impl CanonicalizeContext { return mathml; } else if has_subscript { set_mathml_name(mathml, "msub"); - let children = vec!(children[0], children[1]); + let children = vec![children[0], children[1]]; mathml.replace_children(children); return mathml; } else if has_superscript { set_mathml_name(mathml, "msup"); - let children = vec!(children[0], children[2]); + let children = vec![children[0], children[2]]; mathml.replace_children(children); return mathml; } else { diff --git a/src/rules_archive.rs b/src/rules_archive.rs index 938937683..e12dd9d55 100644 --- a/src/rules_archive.rs +++ b/src/rules_archive.rs @@ -7,7 +7,7 @@ // // Inner language/braille zips always use BZIP2. Downloadable `Rules.zip` uses DEFLATE for the // outer archive so Windows Explorer / `unzip` can open it. The include-zip embed uses BZIP2 -// for both layers (MathCAT's `zip` crate reads it; zip 8.x bzip2 is pure Rust and works on wasm). +// for both layers (MathCAT's `zip` crate reads it; zip 9.x bzip2 is pure Rust and works on wasm). use std::fs::{self, File, read_dir}; use std::io::{self, Cursor, Read, Seek, Write}; @@ -20,6 +20,56 @@ use zip::CompressionMethod; const YAML_SUFFIXES: [&str; 2] = ["yaml", "yml"]; const SKIP_LANGUAGE_DIR: &str = "zz"; const ARCHIVE_ROOT: &str = "Rules"; +const LANGUAGES_DIR: &str = "Languages"; +const BRAILLE_DIR: &str = "Braille"; +const PREFS_FILE: &str = "prefs.yaml"; +const BRAILLE_CODE_PREF: &str = "BrailleCode"; +/// `find_file` falls back to this language by name, so it cannot be pruned away. +const FALLBACK_LANGUAGE: &str = "en"; +/// `set_rules_dir` fails unless the default braille code's directory exists, even for a caller that +/// never asks for braille, so a subset that keeps no braille code keeps this one. +const FALLBACK_BRAILLE_CODE: &str = "UEB"; + +/// Which `Languages/` and `Braille/` directories to keep, by directory name. +/// +/// The full Rules tree is about 10 MB, and a caller that only ever asks for one language and one +/// braille code pays for all of it: `include-zip` embeds every directory in the binary. `None` +/// means keep everything, which is what every existing caller gets. `build.rs` fills this in from +/// the `lang-*` and `braille-*` features. +#[derive(Clone, Default)] +pub struct RulesSubset { + pub languages: Option>, + pub braille_codes: Option>, +} + +impl RulesSubset { + /// Keeps everything, the behaviour before this existed. + pub fn all() -> Self { + return RulesSubset::default(); + } + + /// Regional variants such as `en/gb` and `zh/tw` live inside their language's directory, so + /// keeping a language keeps them too. + fn keeps(wanted: &Option>, name: &str) -> bool { + let Some(wanted) = wanted else { + return true; + }; + return wanted.iter().any(|w| w.eq_ignore_ascii_case(name)); + } + + fn keeps_language(&self, name: &str) -> bool { + // `find_file` falls back to "en" by name from inside the library, so a subset without it + // cannot resolve anything. + return name.eq_ignore_ascii_case(FALLBACK_LANGUAGE) || RulesSubset::keeps(&self.languages, name); + } + + fn keeps_braille_code(&self, name: &str) -> bool { + if self.braille_codes.as_ref().is_some_and(|codes| codes.is_empty()) { + return name.eq_ignore_ascii_case(FALLBACK_BRAILLE_CODE); + } + return RulesSubset::keeps(&self.braille_codes, name); + } +} /// Compression methods for nested language zips vs the outer archive. #[derive(Clone, Copy)] @@ -249,17 +299,95 @@ fn minimize_yaml_file(src: &Path, dst: &Path) -> Result<(), String> { /// Copy a Rules tree. When `minify` is true, rewrite every YAML file as comment-free flow YAML. /// `Languages/zz` is omitted. Returns the number of files successfully minimized. pub fn copy_rules_tree(src: &Path, dst: &Path, minify: bool) -> io::Result { + return copy_rules_subset(src, dst, minify, &RulesSubset::all()); +} + +/// As [`copy_rules_tree`], keeping only the languages and braille codes `subset` asks for. +pub fn copy_rules_subset(src: &Path, dst: &Path, minify: bool, subset: &RulesSubset) -> io::Result { fs::create_dir_all(dst)?; - return copy_dir(src, dst, minify, false); + let minimized = copy_dir(src, dst, minify, None, subset)?; + if let Some(codes) = &subset.braille_codes { + set_default_braille_code(&dst.join(PREFS_FILE), codes)?; + } + return Ok(minimized); } -fn copy_dir(src: &Path, dst: &Path, minify: bool, parent_is_languages: bool) -> io::Result { +/// Points the `BrailleCode` default in a staged `prefs.yaml` at the first of `kept` (or at +/// [`FALLBACK_BRAILLE_CODE`] when it is empty) when the default was pruned, and leaves it alone when +/// it was kept. +/// +/// Without this, the default (Nemeth) and the library's own fallback (UEB) are both missing from a +/// subset that asked for neither, and `set_rules_dir` reports that it could not read the braille +/// directory. Keeping Nemeth and UEB instead would cost about 50 KB for rules nothing asks for. +fn set_default_braille_code(prefs: &Path, kept: &[String]) -> io::Result<()> { + let text = fs::read_to_string(prefs)?; + let docs = YamlLoader::load_from_str(&text).map_err(io::Error::other)?; + let current = docs.iter().find_map(|doc| find_value(doc, BRAILLE_CODE_PREF)); + if current.is_some_and(|current| kept.iter().any(|code| code.eq_ignore_ascii_case(current))) { + return Ok(()); + } + let code = kept.first().map_or(FALLBACK_BRAILLE_CODE, |code| code.as_str()); + let mut out = String::new(); + for (i, doc) in docs.iter().enumerate() { + if i > 0 { + out.push_str("\n---\n"); + } + emit_flow(&mut out, &with_replaced_value(doc, BRAILLE_CODE_PREF, code)).map_err(io::Error::other)?; + } + out.push('\n'); + fs::write(prefs, out)?; + return Ok(()); +} + +/// The string value of the first `key` entry in `yaml`, at any depth. +fn find_value<'a>(yaml: &'a Yaml, key: &str) -> Option<&'a str> { + match yaml { + Yaml::Hash(hash) => { + return hash.iter().find_map(|(k, v)| match (k, v) { + (Yaml::String(name), Yaml::String(value)) if name == key => Some(value.as_str()), + _ => find_value(v, key), + }); + } + Yaml::Array(items) => return items.iter().find_map(|item| find_value(item, key)), + _ => return None, + } +} + +/// `yaml` with the value of every `key` entry, at any depth, replaced by `value`. +fn with_replaced_value(yaml: &Yaml, key: &str, value: &str) -> Yaml { + match yaml { + Yaml::Hash(hash) => { + let mut out = yaml_rust::yaml::Hash::new(); + for (k, v) in hash { + let replace = matches!(k, Yaml::String(name) if name == key); + let v = if replace { Yaml::String(value.to_string()) } else { with_replaced_value(v, key, value) }; + out.insert(k.clone(), v); + } + return Yaml::Hash(out); + } + Yaml::Array(items) => { + return Yaml::Array(items.iter().map(|item| with_replaced_value(item, key, value)).collect()); + } + other => return other.clone(), + } +} + +fn copy_dir( + src: &Path, + dst: &Path, + minify: bool, + parent: Option<&str>, + subset: &RulesSubset, +) -> io::Result { let mut minimized = 0usize; for entry in read_dir(src)? { let entry = entry?; let src_path = entry.path(); let name = file_name_str(&src_path)?; - if parent_is_languages && name == SKIP_LANGUAGE_DIR { + if parent == Some(LANGUAGES_DIR) && (name == SKIP_LANGUAGE_DIR || !subset.keeps_language(&name)) { + continue; + } + if parent == Some(BRAILLE_DIR) && src_path.is_dir() && !subset.keeps_braille_code(&name) { continue; } if is_zip_file(&src_path) { @@ -268,7 +396,7 @@ fn copy_dir(src: &Path, dst: &Path, minify: bool, parent_is_languages: bool) -> let dst_path = dst.join(&name); if src_path.is_dir() { fs::create_dir_all(&dst_path)?; - minimized += copy_dir(&src_path, &dst_path, minify, name == "Languages")?; + minimized += copy_dir(&src_path, &dst_path, minify, Some(name.as_str()), subset)?; } else if minify && is_yaml_file(&src_path) { match minimize_yaml_file(&src_path, &dst_path) { Ok(()) => minimized += 1, diff --git a/src/shim_filesystem.rs b/src/shim_filesystem.rs index d539a6300..ce5159c5c 100644 --- a/src/shim_filesystem.rs +++ b/src/shim_filesystem.rs @@ -108,7 +108,7 @@ cfg_if! { let path = match file.enclosed_name() { Some(path) => PathBuf::from(containing_dir).join(path), None => { - bail!("Entry {} has a suspicious path (outside of archive)", file.name()); + bail!("Entry {} has a suspicious path (outside of archive)", String::from_utf8_lossy(file.name_raw())); } }; // debug!("read_zip_file: file path='{}'", path.display()); diff --git a/src/xpath_functions.rs b/src/xpath_functions.rs index d4b174adf..dd7def12a 100644 --- a/src/xpath_functions.rs +++ b/src/xpath_functions.rs @@ -392,13 +392,7 @@ impl Function for IsNode { fn is_leaf_any_name(e: Element) -> bool { let children = e.children(); - if children.is_empty() { - return true; - } else if children.len() == 1 && - let ChildOfElement::Text(_) = children[0] { - return true; - } - return false + return children.is_empty() || (children.len() == 1 && children[0].text().is_some()); } } } @@ -864,24 +858,17 @@ impl IsBracketed { return false; } - if requires_comma { - if let ChildOfElement::Element(contents) = children[1] { - let children = contents.children(); - if !is_tag(contents, "mrow") || children.len() <= 1 { - return false; - } - // finally, we can check for a comma -- we might not have operands, so we to check first and second entry - if get_text_from_COE(&children[0]).as_str() == "," { - return true; - } - if children.len() > 1 && get_text_from_COE(&children[1]).as_str() == "," { - return true; - } - } - return false; - } else { + if !requires_comma { return true; } + let Some(contents) = children[1].element() else { + return false; + }; + let children = contents.children(); + // The comma can be the first or second child when operands are absent. + return is_tag(contents, "mrow") + && children.len() > 1 + && (get_text_from_COE(&children[0]) == "," || get_text_from_COE(&children[1]) == ","); } } diff --git a/tests/Languages/hu.rs b/tests/Languages/hu.rs index ac24dd22e..b15d27050 100644 --- a/tests/Languages/hu.rs +++ b/tests/Languages/hu.rs @@ -32,4 +32,4 @@ mod alphabets; mod definitions; mod intent; mod mtable; - +mod navigate; diff --git a/tests/Languages/hu/mtable.rs b/tests/Languages/hu/mtable.rs index f02050e47..8f33d176e 100644 --- a/tests/Languages/hu/mtable.rs +++ b/tests/Languages/hu/mtable.rs @@ -240,6 +240,105 @@ fn matrix_2x3() -> Result<()> { } +#[test] +fn augmented_matrix_2x3() -> Result<()> { + let expr = " + + + [ + + + + 3 + + + 1 + + + 4 + + + + + 0 + + + 2 + + + 6 + + + + ] + + "; + test("hu", "ClearSpeak", expr, "a(z) 2 x 3 mátrix; 1 . sor; 3, 1, elválasztó, 4; 2 . sor; 0, 2, elválasztó, 6")?; + test("hu", "SimpleSpeak", expr, "a(z) 2 x 3 mátrix; 1 . sor; 3, 1, elválasztó, 4; 2 . sor; 0, 2, elválasztó, 6")?; + Ok(()) +} + +#[test] +fn dashed_augmented_matrix_separator() -> Result<()> { + let expr = " + + [ + + 123 + + ] + "; + test("hu", "ClearSpeak", expr, "az 1-szer 3 -es sor mátrix; 1, elválasztó, 2, elválasztó, 3")?; + test("hu", "SimpleSpeak", expr, "az 1-szer 3 -es sor mátrix; 1, elválasztó, 2, elválasztó, 3")?; + Ok(()) +} + +/// A horizontal line is announced once, after the row it separates from the next row. +#[test] +fn matrix_row_separator() -> Result<()> { + let expr = " + + [ + + + 1 + 2 + + + 3 + 4 + + + ] + "; + test("hu", "ClearSpeak", expr, "a(z) 2 x 2 mátrix; 1 . sor; 1, 2, sorelválasztó; 2 . sor; 3, 4")?; + test("hu", "SimpleSpeak", expr, "a(z) 2 x 2 mátrix; 1 . sor; 1, 2, sorelválasztó; 2 . sor; 3, 4")?; + Ok(()) +} + +/// Horizontal and vertical lines use distinct announcements at their respective boundaries. +#[test] +fn matrix_row_and_column_separators() -> Result<()> { + let expr = " + + [ + + + 1 + 2 + + + 3 + 4 + + + ] + "; + test("hu", "ClearSpeak", expr, "a(z) 2 x 2 mátrix; 1 . sor; 1, elválasztó, 2, sorelválasztó; 2 . sor; 3, elválasztó, 4")?; + test("hu", "SimpleSpeak", expr, "a(z) 2 x 2 mátrix; 1 . sor; 1, elválasztó, 2, sorelválasztó; 2 . sor; 3, elválasztó, 4")?; + Ok(()) +} + // AI generated #[test] fn matrix_2x3_labeled() -> Result<()> { @@ -860,6 +959,40 @@ test_ClearSpeak("hu", "ClearSpeak_Matrix", "EndMatrix", return Ok(()); } +#[test] +fn augmented_matrix_3x4_end_matrix() -> Result<()> { +let expr = " + + [ + + + 1 + 2 + -1 + 3 + + + -3 + 3 + -1 + 2 + + + 2 + 3 + 2 + -1 + + + ] + +"; +test_ClearSpeak("hu", "ClearSpeak_Matrix", "EndMatrix", + expr, "a(z) 3 x 4 mátrix; 1 . sor; oszlop 1; 1, oszlop 2; 2, oszlop 3; negatív 1, elválasztó, oszlop 4; 3; 2 . sor; oszlop 1; negatív 3, oszlop 2; 3, oszlop 3; negatív 1, elválasztó, oszlop 4; 2; 3 . sor; oszlop 1; 2, oszlop 2; 3, oszlop 3; 2, elválasztó, oszlop 4; negatív 1; vége a mátrixnak")?; + test("hu", "SimpleSpeak", + expr, "a(z) 3 x 4 mátrix; 1 . sor; oszlop 1; 1, oszlop 2; 2, oszlop 3; negatív 1, elválasztó, oszlop 4; 3; 2 . sor; oszlop 1; negatív 3, oszlop 2; 3, oszlop 3; negatív 1, elválasztó, oszlop 4; 2; 3 . sor; oszlop 1; 2, oszlop 2; 3, oszlop 3; 2, elválasztó, oszlop 4; negatív 1; vége a mátrixnak")?; + Ok(()) + } // AI generated #[test] diff --git a/tests/Languages/hu/navigate.rs b/tests/Languages/hu/navigate.rs new file mode 100644 index 000000000..892d69481 --- /dev/null +++ b/tests/Languages/hu/navigate.rs @@ -0,0 +1,599 @@ +//! Navigation ZoomIn speech vs NavigationParts and intent fixity. +//! +//! Uses only real intents / MathML from `definitions.yaml` and `Rules/Intent/*.yaml` +//! (same shapes as other en language tests). Fixities covered are those that actually +//! appear there: prefix, infix, postfix, function, silent, nofix. + +use crate::common::*; +use anyhow::Result; +use std::panic::{catch_unwind, AssertUnwindSafe}; + +fn init_nav(mathml: &str) -> Result<()> { + init_nav_with_auto_zoom_out(mathml, false) +} + +/// Same setup as `init_nav`, but lets a test choose the "AutoZoomOut" preference. +/// +/// With AutoZoomOut == "False" the navigation rules stop at the edge of a notation and announce it +/// ("nem tud jobbra mozogni" + the part). With "True" they leave the notation automatically and announce +/// that with the "elhagyása" fragment ("nevező elhagyása"). +fn init_nav_with_auto_zoom_out(mathml: &str, auto_zoom_out: bool) -> Result<()> { + set_rules_dir(abs_rules_dir_path())?; + set_preference("Language", "hu")?; + set_preference("SpeechStyle", "SimpleSpeak")?; + set_preference("Verbosity", "Medium")?; + set_preference("NavMode", "Enhanced")?; + set_preference("NavVerbosity", "Verbose")?; + set_preference("AutoZoomOut", if auto_zoom_out { "True" } else { "False" })?; + set_preference("Overview", "False")?; + set_mathml(mathml)?; + Ok(()) +} + +fn assert_zoom_in(command: &str, mathml: &str, expected: &str) -> Result<()> { + init_panic_handler(); + let result = catch_unwind(AssertUnwindSafe(|| { + init_nav(mathml)?; + let speech = do_navigate_command(command)?; + let trimmed_speech = speech.trim_end_matches([' ', ',', ';']).to_string(); + assert_eq!(expected, trimmed_speech); + Ok(()) + })); + report_any_panic(result) +} + +// --- Intents in NavigationParts (prefix, infix, function, silent; no postfix/nofix) --- + +#[test] +fn parts_prefix_logarithm_with_base() -> Result<()> { + // Intent/general.yaml log-with-base → logarithm-with-base:prefix; parts "base" + let expr = r#" + + + log + b + + + "#; + assert_zoom_in("ZoomIn", expr, "nagyítás; alap logaritmus; b") +} + +#[test] +fn parts_infix_power() -> Result<()> { + // power:infix; parts "base; exponent" + let expr = r#" + + + x + 2 + + + "#; + assert_zoom_in("ZoomIn", expr, "nagyítás; alap; x") +} + +#[test] +fn parts_infix_indexed_by() -> Result<()> { + // indexed-by:infix; parts "base; subscript" + let expr = r#" + + + x + 1 + + + "#; + assert_zoom_in("ZoomIn", expr, "nagyítás; alap; x") +} + +#[test] +fn parts_function_fraction() -> Result<()> { + // fraction (from mfrac); parts "numerator; denominator" + let expr = r#" + + + 1 + 2 + + + "#; + assert_zoom_in("ZoomIn", expr, "nagyítás; számláló; 1") +} + +#[test] +fn parts_function_square_root() -> Result<()> { + // square-root:function; parts "root" + let expr = r#" + + + x + + + "#; + assert_zoom_in("ZoomIn", expr, "nagyítás; gyök; x") +} + +#[test] +fn parts_silent_skip_super() -> Result<()> { + // skip-super:silent (degree); parts "base; superscript" + let expr = r#" + + + x + ° + + + "#; + assert_zoom_in("ZoomIn", expr, "nagyítás; alap; x") +} + +// --- Intents not in NavigationParts --- + +#[test] +fn no_parts_prefix_unary_minus() -> Result<()> { + // minus:prefix (Intent/general.yaml positive-or-negative) — silent "in" + let expr = r#" + + + - + b + + + "#; + assert_zoom_in("ZoomIn", expr, "nagyítás; b") +} + +#[test] +fn no_parts_prefix_limit() -> Result<()> { + // limit:prefix — silent "in" + let expr = r#" + + + x + + + "#; + assert_zoom_in("ZoomIn", expr, "nagyítás; x") +} + +#[test] +fn no_parts_prefix_vector() -> Result<()> { + // vector:prefix from mover + arrow + let expr = r#" + + + v + → + + + "#; + assert_zoom_in("ZoomIn", expr, "nagyítás; v") +} + +#[test] +fn no_parts_infix_binomial() -> Result<()> { + // binomial:infix (choose); no NavigationParts → "part 1" + let expr = r#" + + + ( + + 7 + 3 + + ) + + + "#; + assert_zoom_in("ZoomIn", expr, "nagyítás; rész 1; 7") +} + +#[test] +fn no_parts_postfix_transpose() -> Result<()> { + // transpose:postfix — not prefix, so still announces its name + let expr = r#" + + + M + T + + + "#; + assert_zoom_in("ZoomIn", expr, "nagyítás; transzponált; nagy m") +} + +#[test] +fn no_parts_function_absolute_value() -> Result<()> { + // absolute-value:function + let expr = r#" + + + | + x + | + + + "#; + assert_zoom_in("ZoomIn", expr, "nagyítás; az abszolút érték; x") +} + +#[test] +fn no_parts_silent_modified_variable() -> Result<()> { + // modified-variable:silent (x-hat) — no NavigationParts → silent + let expr = r#" + + + x + ^ + + + "#; + assert_zoom_in("ZoomIn", expr, "nagyítás; x") +} + +#[test] +fn no_parts_nofix_set_of_reals() -> Result<()> { + // set-of-reals:nofix — leaf + let expr = r#" + + R + + "#; + assert_zoom_in("ZoomIn", expr, "teljesen ráközelített; az összes valós szám halmaza") +} + +// --- No intent= attribute (native MathML only; may still be inferred) --- + +#[test] +fn no_intent_sum_mrow() -> Result<()> { + let expr = r#" + + + x + + + y + + + "#; + assert_zoom_in("ZoomIn", expr, "nagyítás; x") +} + +#[test] +fn no_intent_times_mrow() -> Result<()> { + let expr = r#" + + + 2 + ⁢ + a + + + "#; + assert_zoom_in("ZoomIn", expr, "nagyítás; 2") +} + +#[test] +fn no_intent_mi() -> Result<()> { + let expr = r#" + + x + + "#; + assert_zoom_in("ZoomIn", expr, "teljesen ráközelített; x") +} + +// --- Move2D words (out of / start of / end of) and the zoom out / zoom in all commands --- +// +// Hungarian puts a case suffix on the part name ("a számlálóban"), which cannot be built for arbitrary +// part names. So "in" is silent and the other words follow the part name: "számláló elhagyása", +// "nevező vége", "számláló eleje". These tests check the fragments in order (not the exact separators +// or the speech of the node that is landed on) and that no English navigation word is left over. + +fn nav_speech(commands: &[&str], mathml: &str, auto_zoom_out: bool) -> Result { + init_nav_with_auto_zoom_out(mathml, auto_zoom_out)?; + let mut speech = String::new(); + for command in commands { + speech = do_navigate_command(command)?; + } + Ok(speech) +} + +/// Runs `commands` one after the other and checks the speech of the LAST one: every fragment must occur, +/// in the given order, and no English navigation word may be left over (AutoZoomOut is off). +fn assert_nav_fragments(commands: &[&str], mathml: &str, fragments: &[&str]) -> Result<()> { + assert_nav_fragments_with(commands, mathml, false, fragments) +} + +/// Like `assert_nav_fragments`, but with AutoZoomOut switched on. +fn assert_nav_fragments_auto_zoom_out(commands: &[&str], mathml: &str, fragments: &[&str]) -> Result<()> { + assert_nav_fragments_with(commands, mathml, true, fragments) +} + +fn assert_nav_fragments_with(commands: &[&str], mathml: &str, auto_zoom_out: bool, fragments: &[&str]) -> Result<()> { + init_panic_handler(); + let result = catch_unwind(AssertUnwindSafe(|| { + let speech = nav_speech(commands, mathml, auto_zoom_out)?; + let mut rest = speech.as_str(); + for fragment in fragments { + match rest.find(fragment) { + Some(pos) => rest = &rest[pos + fragment.len()..], + None => panic!("'{}' not found (in this order) in speech '{}'", fragment, speech), + } + } + let english = ["in", "out", "of", "start", "end", "zoom", "move", "cannot", "read", "describe", "current"]; + for word in speech.split(|c: char| !c.is_alphanumeric()) { + assert!(!english.contains(&word), "English word '{}' left in speech '{}'", word, speech); + } + Ok(()) + })); + report_any_panic(result) +} + +/// Runs `commands` and compares the speech of the LAST one exactly (trailing separators are ignored). +fn assert_nav_exact(commands: &[&str], mathml: &str, auto_zoom_out: bool, expected: &str) -> Result<()> { + init_panic_handler(); + let result = catch_unwind(AssertUnwindSafe(|| { + let speech = nav_speech(commands, mathml, auto_zoom_out)?; + assert_eq!(expected, speech.trim_end_matches([' ', ',', ';'])); + Ok(()) + })); + report_any_panic(result) +} + +const FRACTION: &str = r#" + + + 1 + 2 + + + "#; + +const POWER: &str = r#" + + + x + 2 + + + "#; + +/// 1/2 + y: the fraction has material to its right, so it is not at the edge of the math. +const FRACTION_PLUS_Y: &str = r#" + + + + 1 + 2 + + + + y + + + "#; + +/// y + 1/2: the fraction has material to its left. +const Y_PLUS_FRACTION: &str = r#" + + + y + + + + 1 + 2 + + + + "#; + +/// Native mmultiscripts: base x, postscripts (subscript 1, superscript 2), prescripts (subscript 3, superscript 4). +/// Children are: x, 1, 2, , 3, 4. Numbers are used for the scripts so the test does not depend +/// on how single letters are spoken. +const MULTISCRIPTS: &str = r#" + + + x + 1 + 2 + + 3 + 4 + + + "#; + +// --- ZoomOut / ZoomOutAll / ZoomInAll command phrases --- + +#[test] +fn zoom_out_of_numerator() -> Result<()> { + assert_nav_fragments(&["ZoomIn", "ZoomOut"], FRACTION, &["kicsinyítés", "számláló elhagyása"]) +} + +#[test] +fn zoom_out_of_power_base() -> Result<()> { + assert_nav_fragments(&["ZoomIn", "ZoomOut"], POWER, &["kicsinyítés", "alap elhagyása"]) +} + +#[test] +fn zoom_out_all_of_numerator() -> Result<()> { + // ZoomOutAll leaves every notation on the way up: the command phrase comes first, then the part that was left + assert_nav_fragments(&["ZoomIn", "ZoomOutAll"], FRACTION, &["teljes kicsinyítés", "számláló elhagyása"]) +} + +#[test] +fn zoom_out_all_of_power_base() -> Result<()> { + assert_nav_fragments(&["ZoomIn", "ZoomOutAll"], POWER, &["teljes kicsinyítés", "alap elhagyása"]) +} + +#[test] +fn zoom_out_at_top_of_math() -> Result<()> { + // first ZoomOut leaves the numerator, the second one reaches the top: "zoomed out all of the way" + assert_nav_fragments(&["ZoomIn", "ZoomOut", "ZoomOut"], FRACTION, &["teljesen kicsinyítve"]) +} + +#[test] +fn zoom_in_all_command_phrase() -> Result<()> { + assert_nav_fragments(&["ZoomInAll"], FRACTION, &["teljes nagyítás"]) +} + +// --- MoveNext / MovePrevious inside a notation and at its edges (AutoZoomOut off) --- + +#[test] +fn move_right_into_denominator() -> Result<()> { + assert_nav_fragments(&["ZoomIn", "MoveNext"], FRACTION, &["ugrás jobbra", "nevező", "2"]) +} + +#[test] +fn move_right_at_end_of_denominator() -> Result<()> { + assert_nav_fragments(&["ZoomIn", "MoveNext", "MoveNext"], FRACTION, &["nem tud jobbra mozogni", "nevező vége"]) +} + +#[test] +fn move_left_at_start_of_numerator() -> Result<()> { + assert_nav_fragments(&["ZoomIn", "MovePrevious"], FRACTION, &["nem tud balra mozogni", "számláló eleje"]) +} + +#[test] +fn move_right_at_end_of_math_without_auto_zoom_out() -> Result<()> { + // With AutoZoomOut off, the "edge of the notation" rule fires first, even at the right end of the whole + // math: it says only "nem tud jobbra mozogni" (no part name, no "matematikai blokk vége"). + let expr = r#" + + x + + "#; + assert_nav_exact(&["ZoomIn", "MoveNext"], expr, false, "nem tud jobbra mozogni") +} + +#[test] +fn move_left_at_start_of_math_without_auto_zoom_out() -> Result<()> { + let expr = r#" + + x + + "#; + assert_nav_fragments(&["ZoomIn", "MovePrevious"], expr, &["nem tud balra mozogni"]) +} + +// --- AutoZoomOut on: leaving a notation automatically ("out of" -> "elhagyása") --- +// +// With AutoZoomOut == "True" the edge-of-notation rule is skipped. If there is more math beyond the notation, +// MoveNext/MovePrevious leave it automatically and say which part was left. Only at the very end of the math +// the "cannot move" announcement is made, followed by "matematikai blokk vége" / "matematikai blokk kezdete". +// Expected walk through 1/2 + y: ZoomIn (fraction), ZoomIn (numerator), MoveNext (denominator), +// MoveNext (leaves the denominator, lands on "+"). + +#[test] +fn auto_zoom_out_move_right_leaves_denominator() -> Result<()> { + assert_nav_fragments_auto_zoom_out( + &["ZoomIn", "ZoomIn", "MoveNext", "MoveNext"], + FRACTION_PLUS_Y, + &["ugrás jobbra", "nevező elhagyása"], + ) +} + +#[test] +fn auto_zoom_out_move_left_leaves_numerator() -> Result<()> { + // y + 1/2: ZoomIn (y), MoveNext (+), MoveNext (fraction), ZoomIn (numerator), MovePrevious (leaves it) + assert_nav_fragments_auto_zoom_out( + &["ZoomIn", "MoveNext", "MoveNext", "ZoomIn", "MovePrevious"], + Y_PLUS_FRACTION, + &["ugrás balra", "számláló elhagyása"], + ) +} + +#[test] +fn auto_zoom_out_move_right_at_end_of_math() -> Result<()> { + let expr = r#" + + x + + "#; + assert_nav_fragments_auto_zoom_out(&["ZoomIn", "MoveNext"], expr, &["nem tud jobbra mozogni", "matematikai blokk vége"]) +} + +#[test] +fn auto_zoom_out_read_and_describe_right_at_end_of_math() -> Result<()> { + let expr = r#" + + x + + "#; + assert_nav_fragments_auto_zoom_out(&["ZoomIn", "ReadNext"], expr, &["nem tud jobbra olvasni", "matematikai blokk vége"])?; + assert_nav_fragments_auto_zoom_out(&["ZoomIn", "DescribeNext"], expr, &["nem tud jobbra leírni", "matematikai blokk vége"]) +} + +#[test] +fn auto_zoom_out_move_left_at_start_of_math() -> Result<()> { + let expr = r#" + + x + + "#; + assert_nav_fragments_auto_zoom_out(&["ZoomIn", "MovePrevious"], expr, &["matematikai blokk kezdete"]) +} + +// --- mmultiscripts part names --- +// +// Children of the mmultiscripts: base, subscript, superscript, , pre-subscript, pre-superscript. +// The names are "alap", "alsó index", "felső index", "bal alsó index" and "bal felső index". + +#[test] +fn multiscripts_zoom_in_base() -> Result<()> { + assert_nav_fragments(&["ZoomIn"], MULTISCRIPTS, &["nagyítás", "alap", "x"]) +} + +#[test] +fn multiscripts_move_right_to_subscript() -> Result<()> { + assert_nav_fragments(&["ZoomIn", "MoveNext"], MULTISCRIPTS, &["ugrás jobbra", "alsó index", "1"]) +} + +#[test] +fn multiscripts_move_right_to_superscript() -> Result<()> { + assert_nav_fragments(&["ZoomIn", "MoveNext", "MoveNext"], MULTISCRIPTS, &["ugrás jobbra", "felső index", "2"]) +} + +#[test] +fn multiscripts_move_right_to_pre_subscript() -> Result<()> { + assert_nav_fragments(&["ZoomIn", "MoveNext", "MoveNext", "MoveNext"], MULTISCRIPTS, &["ugrás jobbra", "bal alsó index", "3"]) +} + +#[test] +fn multiscripts_move_right_to_pre_superscript() -> Result<()> { + assert_nav_fragments( + &["ZoomIn", "MoveNext", "MoveNext", "MoveNext", "MoveNext"], + MULTISCRIPTS, + &["ugrás jobbra", "bal felső index", "4"], + ) +} + +#[test] +fn multiscripts_move_left_to_base() -> Result<()> { + assert_nav_fragments(&["ZoomIn", "MoveNext", "MovePrevious"], MULTISCRIPTS, &["ugrás balra", "alap", "x"]) +} + +#[test] +fn multiscripts_zoom_out_of_base() -> Result<()> { + assert_nav_fragments(&["ZoomIn", "ZoomOut"], MULTISCRIPTS, &["kicsinyítés", "alap elhagyása"]) +} + +#[test] +fn multiscripts_zoom_out_of_pre_superscript() -> Result<()> { + assert_nav_fragments( + &["ZoomIn", "MoveNext", "MoveNext", "MoveNext", "MoveNext", "ZoomOut"], + MULTISCRIPTS, + &["kicsinyítés", "bal felső index elhagyása"], + ) +} + +// --- ReadCurrent / DescribeCurrent --- + +#[test] +fn read_current() -> Result<()> { + assert_nav_fragments(&["ZoomIn", "ReadCurrent"], FRACTION, &["az aktuális elem felolvasása"]) +} + +#[test] +fn describe_current() -> Result<()> { + assert_nav_fragments(&["ZoomIn", "DescribeCurrent"], FRACTION, &["az aktuális elem leírása"]) +}