diff --git a/CLAUDE.md b/CLAUDE.md index 6eb0760..f31bd21 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,70 +4,123 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co ## Project Overview -`mml` is a Ruby gem that provides MathML 3 and MathML 4 XML parsing and serialization. It maps MathML elements into Ruby model classes using the `lutaml-model` framework. Part of the [Plurimath](https://github.com/plurimath/mml) ecosystem. +`mml` is a Ruby gem that provides MathML 2, MathML 3, and MathML 4 XML parsing and serialization. It maps MathML elements into Ruby model classes using the `lutaml-model` framework. Part of the [Plurimath](https://github.com/plurimath/mml) ecosystem. ## Commands ```bash -rake # Run specs + rubocop (default task) -bundle exec rspec # Run tests -bundle exec rspec spec/mml_spec.rb:42 # Run single test by line -bundle exec rspec --only-failures # Run only previously failing tests -bundle exec rubocop # Lint -bundle exec rubocop -a # Auto-fix lint issues -bin/console # IRB with gem loaded +rake # Run specs + rubocop (default task) +bundle exec rspec # Run all tests +bundle exec rspec spec/mml/v3/msub_spec.rb:14 # Run single test by line +bundle exec rspec --only-failures # Previously failing tests only +bundle exec rubocop # Lint +bundle exec rubocop -a # Auto-fix lint issues +bin/console # IRB with gem loaded ``` ## Versioned Architecture -**MathML 3 vs MathML 4:** The gem maintains separate class hierarchies for MathML 3 (`Mml::V3::`) and MathML 4 (`Mml::V4::`). Users must reference the versioned namespace explicitly — no backward-compat aliases. +The gem maintains **three** separate class hierarchies: `Mml::V2::` (MathML 2), `Mml::V3::` (MathML 3), and `Mml::V4::` (MathML 4). Users must reference the versioned namespace explicitly — no backward-compat aliases. ```ruby -Mml.parse(input) # Default: MathML 3 -Mml.parse(input, version: 3) # Explicit MathML 3 -Mml.parse(input, version: 4) # MathML 4 with intent/arg attributes +Mml.parse(input) # Default: MathML 3 +Mml.parse(input, version: 2) # MathML 2 +Mml.parse(input, version: 3) # Explicit MathML 3 +Mml.parse(input, version: 4) # MathML 4 with intent/arg attributes ``` **Directory structure:** -- `lib/mml/v3/` — MathML 3 element classes (original) -- `lib/mml/v4/` — MathML 4 element classes (intent/arg added, deprecated attrs removed) +- `lib/mml/base/` — version-agnostic element modules, mixed into each version's class via `include Base::XYZ`. The single source of truth for the XML mapping DSL. +- `lib/mml/v2/`, `lib/mml/v3/`, `lib/mml/v4/` — per-version class declarations (each ~3 lines: declare class, mix in base module, register). -**Key difference:** MathML 4 adds `intent`, `arg`, `displaystyle`, and `scriptlevel` attributes as universal presentation attributes for accessibility markup. +**Key difference (V3 → V4):** MathML 4 adds `intent`, `arg`, `displaystyle`, and `scriptlevel` as universal presentation attributes for accessibility markup; removes some deprecated attributes. **No hidden delegation:** The `Mml` module does not alias or delegate constants. Use `Mml::V3::Math`, `Mml::V4::Mi`, etc. directly. ## Entry Points -- `Mml.parse(input, version: N)` — parse XML, returns `Mml::V3::Math` or `Mml::V4::Math` object graph +- `Mml.parse(input, version: N)` — parse XML, returns `Mml::V{2,3,4}::Math` object graph - `Mml::V3.parse(input)` / `Mml::V4.parse(input)` — version-specific parsing - `Mml::V4::Math.from_xml(input)` — directly parse with v4 classes - Call `to_xml` on any element to serialize back -## Pattern +## Element Mapping Pattern -Each MathML element is a `Mml::V3::` or `Mml::V4::` class inheriting from `Lutaml::Model::Serializable` with an `xml do...end` DSL block. Two element types: -- **Leaf elements** (e.g., `Mi`, `Mn`, `Mo`): use `map_content to: :value` for text content -- **Container elements** (e.g., `Math`, `Mrow`, `Mfrac`): use `mixed_content` to accept arbitrary child elements +Each MathML element is a `Lutaml::Model::Serializable` subclass with an `xml do...end` DSL block. The XML mapping lives in `lib/mml/base/.rb` as a module included into each version's class (e.g., `Mml::V3::Msub` includes `Mml::Base::Msub`). -**CommonAttributes:** A `no_root` Lutaml model imported into container elements via `import_model`. It dynamically creates `#{tag}_value` attributes for each tag in `Configuration::SUPPORTED_TAGS`. Classes that receive it are listed in `Configuration::COMMON_ATTRIBUTES_CLASSES`. +### Content model — `mixed_content` vs `ordered` vs `map_content` -**Autoloading:** Each version (`lib/mml/v3.rb`, `lib/mml/v4.rb`) autoloads its element classes. `CommonAttributes` is required after all classes exist, then `update_attributes` mixes it into the configured classes. +This is the most important decision when adding or editing an element. Pick based on the **schema** (see `schemas/mathml3/`, `schemas/mathml4/`, `reference-docs/mathml-source/`): -**Namespace:** Both versions use the same URI (`http://www.w3.org/1998/Math/MathML`) — MathML 4 chose backward compatibility over a new namespace. +| Schema content model | Use | Examples | +|---------------------------------------------------|--------------------------------------|--------------------------------| +| True mixed `(#PCDATA \| mglyph \| malignmark)*` | `mixed_content` + `map_content` | `Mi`, `Mn`, `Mo`, `Ms`, `Mtext`| +| Element-only (`ImpliedMrow`, ``, etc.) | `ordered` | `Msub`, `Mfrac`, `Mrow`, `Math`, `Munder`, `Mfenced`, `Msgroup`, … | + +**Why this matters:** lutaml-model's `each_mixed_content` iteration exposes all children to consumers (Plurimath's translator does positional indexing like `children[0]`, `children[1]` for `msub`/`mfrac`/etc.). +- Under `mixed_content`, whitespace between elements is yielded as a String child, shifting positional indices. +- Under `ordered`, whitespace-only text nodes are skipped — only elements and non-whitespace text are yielded. + +If you mistakenly use `mixed_content` on an element-only schema element, downstream consumers see phantom whitespace children and silently misinterpret the tree (e.g., render `t90` with whitespace between children as base=whitespace, sub=`t`, dropping `90`). + +**Verification workflow for any element edit:** +```bash +grep -A15 'name=""' schemas/mathml3/mathml3-presentation.xsd schemas/mathml3/mathml3-common.xsd +grep -A3 '\s*=\s*element' schemas/mathml4/mathml4-core.rnc schemas/mathml4/mathml4-presentation.rnc +``` + +If the schema says `ImpliedMrow`, `MathExpression, MathExpression`, `MstackExpression*`, `TableRowExpression*`, etc. (any element-only sequence), use `ordered`. If it says `(#PCDATA | mglyph | malignmark)*`, use `mixed_content` + `map_content` + `map_element` for the inline children. + +### Token elements are the only true mixed content + +Per MathML schema, token elements (`mi`, `mn`, `mo`, `ms`, `mtext`) accept `(#PCDATA | mglyph | malignmark)*` — text interleaved with inline elements. They use: + +```ruby +attribute :value, :string, collection: true # collection required for mixed_content +attribute :mglyph_value, :mglyph, collection: true +attribute :malignmark_value, :malignmark, collection: true + +xml do + element "" + mixed_content + map_content to: :value + map_element "mglyph", to: :mglyph_value + map_element "malignmark", to: :malignmark_value +end +``` + +`value` is a `String` **collection** (Array) because text can be split by intervening inline elements (e.g., `xy` → `["x", "y"]`). + +### CommonAttributes + +A `no_root` Lutaml model imported into container elements via `import_model`. It dynamically creates `#{tag}_value` attributes for each tag in `Configuration::SUPPORTED_TAGS`. Classes that receive it are listed in `Configuration::COMMON_ATTRIBUTES_CLASSES`. + +### Per-file registration + +Each `lib/mml/v{2,3,4}/.rb` ends with `Configuration.register_model(Klass, id: :tag)` so the type is registered as soon as the file is loaded (eager via `require_relative` at the bottom of each version file). + +**Namespace:** All versions use the same URI (`http://www.w3.org/1998/Math/MathML`) — MathML 4 chose backward compatibility over a new namespace. + +## Adapter + +`Mml.default_adapter` returns `:oga` under Opal, otherwise delegates to `Lutaml::Model::Config.xml_adapter_type` (defaults to `:nokogiri`). Specs pin `:nokogiri` in `spec_helper.rb`. Users can override globally via `Lutaml::Model::Config`. ## Spec Structure -- `spec/mml_spec.rb` — tests `Mml::V3` and `Mml::V4` with separate fixture directories -- `spec/fixtures/with_namespace/` — v3 fixtures (MathML 3) -- `spec/fixtures/with_namespace_prefix/` — v3 fixtures with namespace prefix -- `spec/fixtures/v4/` — v4 fixtures (MathML 4 with intent attributes) +- `spec/mml/v2/`, `spec/mml/v3/`, `spec/mml/v4/` — per-version element specs (round-trip + attribute preservation) +- `spec/mml/v3_spec.rb`, `spec/mml/v4_spec.rb` — whole-testsuite round-trip tests against `spec/fixtures/mml3-testsuite/` and `spec/fixtures/mmlcore-testsuite/` +- `spec/mml/ordered_content_spec.rb` — regression specs locking in the mixed_content vs ordered distinction (whitespace handling + token element inline children) +- `spec/mml/adapter_configuration_spec.rb` — adapter delegation +- `spec/context_support_spec.rb`, `spec/lutaml_default_register_spec.rb` — registry/context behavior +- `spec/fixtures/mml2-testsuite/`, `mml3-testsuite/`, `mmlcore-testsuite/` — W3C test suites (submodules) +- `spec/fixtures/v2/`, `v4/` — version-specific fixtures -Specs use the `:nokogiri` Lutaml adapter (configured in `spec_helper.rb`). Runtime uses `:ox` adapter by default. +Specs use `canon`'s `be_xml_equivalent_to` matcher with the `:spec_friendly` profile (whitespace-tolerant). When adding a regression spec for an ordering bug, **explicitly test the inter-element-whitespace scenario** — the suite's whitespace tolerance masks positional bugs. ## Key Dependencies -- `lutaml-model` (~ 0.8.0) — data mapper framework; all element classes inherit from `Lutaml::Model::Serializable` -- `moxml` — XML parsing library (uses `:ox` adapter by default) +- `lutaml-model` (~> 0.8.0) — data mapper framework; all element classes inherit from `Lutaml::Model::Serializable` +- `moxml` — XML parsing backbone (adapter selected via `Lutaml::Model::Config`) - `canon` — XML comparison for specs ## Conventions @@ -77,3 +130,10 @@ Specs use the `:nokogiri` Lutaml adapter (configured in `spec_helper.rb`). Runti - CI workflows are auto-generated by Cimas — do not edit manually - `Gemfile.lock` is gitignored; dependencies come from the gemspec - Type signatures exist in `sig/mml.rbs` + +## Reference Materials + +- `schemas/mathml2/`, `schemas/mathml3/`, `schemas/mathml4/` — official W3C schemas (XSD for 2/3, RelaxNG for 4). **Authoritative** source for content models and attribute lists. +- `reference-docs/mathml-source/` — W3C spec source XML (presentation-markup.xml, validation-grammar.xml, etc.) + +When deciding whether an element should have `mixed_content`, `ordered`, or `map_content`, **always** verify against the schema first. diff --git a/lib/mml/base/maction.rb b/lib/mml/base/maction.rb index c862489..b3484b0 100644 --- a/lib/mml/base/maction.rb +++ b/lib/mml/base/maction.rb @@ -15,7 +15,7 @@ def self.included(klass) xml do namespace Mml::Namespace element "maction" - mixed_content + ordered map_attribute "mathcolor", to: :mathcolor map_attribute "mathbackground", to: :mathbackground diff --git a/lib/mml/base/math.rb b/lib/mml/base/math.rb index 4808b8d..973f9b4 100644 --- a/lib/mml/base/math.rb +++ b/lib/mml/base/math.rb @@ -37,7 +37,7 @@ def self.included(klass) xml do namespace Mml::Namespace element "math" - mixed_content + ordered map_attribute :display, to: :display map_attribute "mode", to: :mode diff --git a/lib/mml/base/menclose.rb b/lib/mml/base/menclose.rb index 01a9d59..cca1660 100644 --- a/lib/mml/base/menclose.rb +++ b/lib/mml/base/menclose.rb @@ -14,7 +14,7 @@ def self.included(klass) xml do namespace Mml::Namespace element "menclose" - mixed_content + ordered map_attribute "mathcolor", to: :mathcolor map_attribute "mathbackground", to: :mathbackground diff --git a/lib/mml/base/merror.rb b/lib/mml/base/merror.rb index c06c0fc..dc3721b 100644 --- a/lib/mml/base/merror.rb +++ b/lib/mml/base/merror.rb @@ -13,7 +13,7 @@ def self.included(klass) xml do namespace Mml::Namespace element "merror" - mixed_content + ordered map_attribute "mathcolor", to: :mathcolor map_attribute "mathbackground", to: :mathbackground diff --git a/lib/mml/base/mfenced.rb b/lib/mml/base/mfenced.rb index 747912f..4f3e8c6 100644 --- a/lib/mml/base/mfenced.rb +++ b/lib/mml/base/mfenced.rb @@ -10,16 +10,13 @@ def self.included(klass) attribute :mathbackground, :string attribute :separators, :string attribute :mathcolor, :string - attribute :content, :string, collection: true attribute :close, :string attribute :open, :string xml do namespace Mml::Namespace element "mfenced" - mixed_content - - map_content to: :content + ordered map_attribute "mathbackground", to: :mathbackground map_attribute "separators", to: :separators, render_empty: true diff --git a/lib/mml/base/mfrac.rb b/lib/mml/base/mfrac.rb index 08f1941..04b0663 100644 --- a/lib/mml/base/mfrac.rb +++ b/lib/mml/base/mfrac.rb @@ -17,7 +17,7 @@ def self.included(klass) xml do namespace Mml::Namespace element "mfrac" - mixed_content + ordered map_attribute "mathcolor", to: :mathcolor map_attribute "mathbackground", to: :mathbackground diff --git a/lib/mml/base/mfraction.rb b/lib/mml/base/mfraction.rb index a90aabf..267b95c 100644 --- a/lib/mml/base/mfraction.rb +++ b/lib/mml/base/mfraction.rb @@ -17,7 +17,7 @@ def self.included(klass) xml do namespace Mml::Namespace element "mfraction" - mixed_content + ordered map_attribute "mathcolor", to: :mathcolor map_attribute "mathbackground", to: :mathbackground diff --git a/lib/mml/base/mi.rb b/lib/mml/base/mi.rb index 116a840..537f47d 100644 --- a/lib/mml/base/mi.rb +++ b/lib/mml/base/mi.rb @@ -13,6 +13,7 @@ def self.included(klass) attribute :mathsize, :string attribute :mathvariant, :string attribute :mglyph_value, :mglyph, collection: true + attribute :malignmark_value, :malignmark, collection: true attribute :lang, :string xml do @@ -27,6 +28,7 @@ def self.included(klass) map_attribute "mathvariant", to: :mathvariant map_attribute "xml:lang", to: :lang map_element "mglyph", to: :mglyph_value + map_element "malignmark", to: :malignmark_value end end end diff --git a/lib/mml/base/mlabeledtr.rb b/lib/mml/base/mlabeledtr.rb index bc243f6..37f552f 100644 --- a/lib/mml/base/mlabeledtr.rb +++ b/lib/mml/base/mlabeledtr.rb @@ -18,7 +18,7 @@ def self.included(klass) xml do namespace Mml::Namespace element "mlabeledtr" - mixed_content + ordered map_attribute "mathbackground", to: :mathbackground map_attribute "columnalign", to: :columnalign diff --git a/lib/mml/base/mlongdiv.rb b/lib/mml/base/mlongdiv.rb index 7df85d9..56a624a 100644 --- a/lib/mml/base/mlongdiv.rb +++ b/lib/mml/base/mlongdiv.rb @@ -16,7 +16,7 @@ def self.included(klass) xml do namespace Mml::Namespace element "mlongdiv" - mixed_content + ordered map_attribute "mathbackground", to: :mathbackground map_attribute "longdivstyle", to: :longdivstyle diff --git a/lib/mml/base/mmultiscripts.rb b/lib/mml/base/mmultiscripts.rb index 24af427..144985a 100644 --- a/lib/mml/base/mmultiscripts.rb +++ b/lib/mml/base/mmultiscripts.rb @@ -16,7 +16,7 @@ def self.included(klass) xml do namespace Mml::Namespace element "mmultiscripts" - mixed_content + ordered map_attribute "mathcolor", to: :mathcolor map_attribute "mathbackground", to: :mathbackground diff --git a/lib/mml/base/mn.rb b/lib/mml/base/mn.rb index a1a927a..e2152cf 100644 --- a/lib/mml/base/mn.rb +++ b/lib/mml/base/mn.rb @@ -13,6 +13,7 @@ def self.included(klass) attribute :mathvariant, :string attribute :mathsize, :string attribute :mglyph_value, :mglyph, collection: true + attribute :malignmark_value, :malignmark, collection: true xml do namespace Mml::Namespace @@ -25,6 +26,7 @@ def self.included(klass) map_attribute "mathvariant", to: :mathvariant map_attribute "mathsize", to: :mathsize map_element "mglyph", to: :mglyph_value + map_element "malignmark", to: :malignmark_value end end end diff --git a/lib/mml/base/mo.rb b/lib/mml/base/mo.rb index 2217c24..c855e4a 100644 --- a/lib/mml/base/mo.rb +++ b/lib/mml/base/mo.rb @@ -7,7 +7,7 @@ module Mo # Use fully qualified names (e.g., Mml::Namespace). def self.included(klass) klass.class_eval do - attribute :value, :string + attribute :value, :string, collection: true attribute :mathcolor, :string attribute :mathbackground, :string attribute :mathvariant, :string @@ -34,11 +34,14 @@ def self.included(klass) attribute :indentshiftfirst, :string attribute :indentalignlast, :string attribute :indentshiftlast, :string + attribute :mglyph_value, :mglyph, collection: true + attribute :malignmark_value, :malignmark, collection: true # rubocop:disable Metrics/BlockLength xml do namespace Mml::Namespace element "mo" + mixed_content map_content to: :value map_attribute "form", to: :form @@ -67,6 +70,8 @@ def self.included(klass) map_attribute "indentalignfirst", to: :indentalignfirst map_attribute "indentshiftfirst", to: :indentshiftfirst map_attribute "linebreakmultchar", to: :linebreakmultchar + map_element "mglyph", to: :mglyph_value + map_element "malignmark", to: :malignmark_value end # rubocop:enable Metrics/BlockLength end diff --git a/lib/mml/base/mover.rb b/lib/mml/base/mover.rb index eaa4334..486299c 100644 --- a/lib/mml/base/mover.rb +++ b/lib/mml/base/mover.rb @@ -15,7 +15,7 @@ def self.included(klass) xml do namespace Mml::Namespace element "mover" - mixed_content + ordered map_attribute "mathbackground", to: :mathbackground map_attribute "mathcolor", to: :mathcolor diff --git a/lib/mml/base/mpadded.rb b/lib/mml/base/mpadded.rb index e6cc726..a8a0810 100644 --- a/lib/mml/base/mpadded.rb +++ b/lib/mml/base/mpadded.rb @@ -19,7 +19,7 @@ def self.included(klass) xml do namespace Mml::Namespace element "mpadded" - mixed_content + ordered map_attribute "mathbackground", to: :mathbackground map_attribute "mathcolor", to: :mathcolor diff --git a/lib/mml/base/mphantom.rb b/lib/mml/base/mphantom.rb index 5bc8a25..ba50a6f 100644 --- a/lib/mml/base/mphantom.rb +++ b/lib/mml/base/mphantom.rb @@ -13,7 +13,7 @@ def self.included(klass) xml do namespace Mml::Namespace element "mphantom" - mixed_content + ordered map_attribute "mathcolor", to: :mathcolor map_attribute "mathbackground", to: :mathbackground diff --git a/lib/mml/base/mroot.rb b/lib/mml/base/mroot.rb index 54fa563..6705569 100644 --- a/lib/mml/base/mroot.rb +++ b/lib/mml/base/mroot.rb @@ -13,7 +13,7 @@ def self.included(klass) xml do namespace Mml::Namespace element "mroot" - mixed_content + ordered map_attribute "mathcolor", to: :mathcolor map_attribute "mathbackground", to: :mathbackground diff --git a/lib/mml/base/mrow.rb b/lib/mml/base/mrow.rb index a31952b..94da843 100644 --- a/lib/mml/base/mrow.rb +++ b/lib/mml/base/mrow.rb @@ -9,13 +9,11 @@ def self.included(klass) klass.class_eval do attribute :mathbackground, :string attribute :mathcolor, :string - attribute :content, :string, collection: true xml do namespace Mml::Namespace element "mrow" - mixed_content + ordered - map_content to: :content map_attribute "mathcolor", to: :mathcolor map_attribute "mathbackground", to: :mathbackground end diff --git a/lib/mml/base/ms.rb b/lib/mml/base/ms.rb index 54c3504..30bbc32 100644 --- a/lib/mml/base/ms.rb +++ b/lib/mml/base/ms.rb @@ -14,6 +14,8 @@ def self.included(klass) attribute :lquote, :string attribute :rquote, :string attribute :value, :string, collection: true + attribute :mglyph_value, :mglyph, collection: true + attribute :malignmark_value, :malignmark, collection: true xml do namespace Mml::Namespace element "ms" @@ -26,6 +28,8 @@ def self.included(klass) map_attribute "mathvariant", to: :mathvariant map_attribute "lquote", to: :lquote, render_empty: true map_attribute "rquote", to: :rquote, render_empty: true + map_element "mglyph", to: :mglyph_value + map_element "malignmark", to: :malignmark_value end end end diff --git a/lib/mml/base/mscarries.rb b/lib/mml/base/mscarries.rb index 8652e66..b5d4843 100644 --- a/lib/mml/base/mscarries.rb +++ b/lib/mml/base/mscarries.rb @@ -17,7 +17,7 @@ def self.included(klass) xml do namespace Mml::Namespace element "mscarries" - mixed_content + ordered map_attribute "scriptsizemultiplier", to: :scriptsizemultiplier map_attribute "mathbackground", to: :mathbackground diff --git a/lib/mml/base/mscarry.rb b/lib/mml/base/mscarry.rb index 6e64460..aa8cb81 100644 --- a/lib/mml/base/mscarry.rb +++ b/lib/mml/base/mscarry.rb @@ -15,7 +15,7 @@ def self.included(klass) xml do namespace Mml::Namespace element "mscarry" - mixed_content + ordered map_attribute "mathcolor", to: :mathcolor map_attribute "mathbackground", to: :mathbackground diff --git a/lib/mml/base/msgroup.rb b/lib/mml/base/msgroup.rb index 8854052..44cc88e 100644 --- a/lib/mml/base/msgroup.rb +++ b/lib/mml/base/msgroup.rb @@ -11,13 +11,11 @@ def self.included(klass) attribute :mathbackground, :string attribute :position, :integer attribute :shift, :integer - attribute :msgroup_text, :string, collection: true xml do namespace Mml::Namespace element "msgroup" - mixed_content + ordered - map_content to: :msgroup_text map_attribute "mathcolor", to: :mathcolor map_attribute "mathbackground", to: :mathbackground map_attribute "position", to: :position diff --git a/lib/mml/base/msqrt.rb b/lib/mml/base/msqrt.rb index 72aeb31..7a90826 100644 --- a/lib/mml/base/msqrt.rb +++ b/lib/mml/base/msqrt.rb @@ -13,7 +13,7 @@ def self.included(klass) xml do namespace Mml::Namespace element "msqrt" - mixed_content + ordered map_attribute "mathcolor", to: :mathcolor map_attribute "mathbackground", to: :mathbackground diff --git a/lib/mml/base/msrow.rb b/lib/mml/base/msrow.rb index 1c96b43..5ad20ec 100644 --- a/lib/mml/base/msrow.rb +++ b/lib/mml/base/msrow.rb @@ -14,7 +14,7 @@ def self.included(klass) xml do namespace Mml::Namespace element "msrow" - mixed_content + ordered map_attribute "mathcolor", to: :mathcolor map_attribute "mathbackground", to: :mathbackground diff --git a/lib/mml/base/mstack.rb b/lib/mml/base/mstack.rb index aeae8b3..9b920f7 100644 --- a/lib/mml/base/mstack.rb +++ b/lib/mml/base/mstack.rb @@ -17,7 +17,7 @@ def self.included(klass) xml do namespace Mml::Namespace element "mstack" - mixed_content + ordered map_attribute "mathcolor", to: :mathcolor map_attribute "mathbackground", to: :mathbackground diff --git a/lib/mml/base/mstyle.rb b/lib/mml/base/mstyle.rb index e6ac1fa..c0549e8 100644 --- a/lib/mml/base/mstyle.rb +++ b/lib/mml/base/mstyle.rb @@ -94,7 +94,7 @@ def self.included(klass) xml do namespace Mml::Namespace element "mstyle" - mixed_content + ordered map_attribute "scriptsizemultiplier", to: :scriptsizemultiplier map_attribute "scriptminsize", to: :scriptminsize diff --git a/lib/mml/base/msub.rb b/lib/mml/base/msub.rb index 986572c..41731b0 100644 --- a/lib/mml/base/msub.rb +++ b/lib/mml/base/msub.rb @@ -14,7 +14,7 @@ def self.included(klass) xml do namespace Mml::Namespace element "msub" - mixed_content + ordered map_attribute "mathbackground", to: :mathbackground map_attribute "subscriptshift", to: :subscriptshift diff --git a/lib/mml/base/msubsup.rb b/lib/mml/base/msubsup.rb index 762caa7..6fc67cb 100644 --- a/lib/mml/base/msubsup.rb +++ b/lib/mml/base/msubsup.rb @@ -15,7 +15,7 @@ def self.included(klass) xml do namespace Mml::Namespace element "msubsup" - mixed_content + ordered map_attribute "mathcolor", to: :mathcolor map_attribute "mathbackground", to: :mathbackground diff --git a/lib/mml/base/msup.rb b/lib/mml/base/msup.rb index 3380ede..d333531 100644 --- a/lib/mml/base/msup.rb +++ b/lib/mml/base/msup.rb @@ -14,7 +14,7 @@ def self.included(klass) xml do namespace Mml::Namespace element "msup" - mixed_content + ordered map_attribute "mathcolor", to: :mathcolor map_attribute "mathbackground", to: :mathbackground diff --git a/lib/mml/base/mtable.rb b/lib/mml/base/mtable.rb index 6052101..801f018 100644 --- a/lib/mml/base/mtable.rb +++ b/lib/mml/base/mtable.rb @@ -33,7 +33,7 @@ def self.included(klass) xml do namespace Mml::Namespace element "mtable" - mixed_content + ordered map_attribute "mathcolor", to: :mathcolor map_attribute "mathbackground", to: :mathbackground diff --git a/lib/mml/base/mtd.rb b/lib/mml/base/mtd.rb index 35d5768..8087dc0 100644 --- a/lib/mml/base/mtd.rb +++ b/lib/mml/base/mtd.rb @@ -18,7 +18,7 @@ def self.included(klass) xml do namespace Mml::Namespace element "mtd" - mixed_content + ordered map_attribute "mathcolor", to: :mathcolor map_attribute "mathbackground", to: :mathbackground diff --git a/lib/mml/base/mtext.rb b/lib/mml/base/mtext.rb index 4b83a90..62d2b32 100644 --- a/lib/mml/base/mtext.rb +++ b/lib/mml/base/mtext.rb @@ -7,21 +7,26 @@ module Mtext # Use fully qualified names (e.g., Mml::Namespace). def self.included(klass) klass.class_eval do - attribute :value, :string + attribute :value, :string, collection: true attribute :mathcolor, :string attribute :mathbackground, :string attribute :mathvariant, :string attribute :mathsize, :string + attribute :mglyph_value, :mglyph, collection: true + attribute :malignmark_value, :malignmark, collection: true xml do namespace Mml::Namespace element "mtext" + mixed_content map_content to: :value map_attribute "mathcolor", to: :mathcolor map_attribute "mathbackground", to: :mathbackground map_attribute "mathvariant", to: :mathvariant map_attribute "mathsize", to: :mathsize + map_element "mglyph", to: :mglyph_value + map_element "malignmark", to: :malignmark_value end end end diff --git a/lib/mml/base/mtr.rb b/lib/mml/base/mtr.rb index c7f7e46..564e5ce 100644 --- a/lib/mml/base/mtr.rb +++ b/lib/mml/base/mtr.rb @@ -18,7 +18,7 @@ def self.included(klass) xml do namespace Mml::Namespace element "mtr" - mixed_content + ordered map_attribute "mathcolor", to: :mathcolor map_attribute "mathbackground", to: :mathbackground diff --git a/lib/mml/base/munder.rb b/lib/mml/base/munder.rb index d731676..b2c00ed 100644 --- a/lib/mml/base/munder.rb +++ b/lib/mml/base/munder.rb @@ -10,19 +10,17 @@ def self.included(klass) attribute :mathbackground, :string attribute :accentunder, :string attribute :mathcolor, :string - attribute :content, :string, collection: true attribute :align, :string xml do namespace Mml::Namespace element "munder" - mixed_content + ordered map_attribute "mathbackground", to: :mathbackground map_attribute "accentunder", to: :accentunder map_attribute "mathcolor", to: :mathcolor map_attribute "align", to: :align - map_content to: :content end end end diff --git a/lib/mml/base/munderover.rb b/lib/mml/base/munderover.rb index c53eef4..f0baf08 100644 --- a/lib/mml/base/munderover.rb +++ b/lib/mml/base/munderover.rb @@ -16,7 +16,7 @@ def self.included(klass) xml do namespace Mml::Namespace element "munderover" - mixed_content + ordered map_attribute "mathcolor", to: :mathcolor map_attribute "mathbackground", to: :mathbackground diff --git a/lib/mml/base/semantics.rb b/lib/mml/base/semantics.rb index 6975b22..7d7a982 100644 --- a/lib/mml/base/semantics.rb +++ b/lib/mml/base/semantics.rb @@ -13,7 +13,7 @@ def self.included(klass) xml do namespace Mml::Namespace element "semantics" - mixed_content + ordered map_attribute "definitionURL", to: :definition_url map_attribute "encoding", to: :semantics_encoding diff --git a/spec/context_support_spec.rb b/spec/context_support_spec.rb index 3f9bc2a..bba2fc9 100644 --- a/spec/context_support_spec.rb +++ b/spec/context_support_spec.rb @@ -145,7 +145,7 @@ class LegacyMi < Mml::V3::Mi math = Mml::V4.parse(xml, context: :custom_models) expect(math.mover_value.first).to be_a(MmlSubst::V4Mover) - expect(math.mover_value.first.mo_value.first.value).to eq("∫") + expect(math.mover_value.first.mo_value.first.value).to eq(["∫"]) expect(math.mover_value.first.mi_value.first.value).to eq(["b"]) ensure Mml::V4::Configuration.clear_custom_models diff --git a/spec/mml/adapter_configuration_spec.rb b/spec/mml/adapter_configuration_spec.rb index 63fa90d..4cbe4fb 100644 --- a/spec/mml/adapter_configuration_spec.rb +++ b/spec/mml/adapter_configuration_spec.rb @@ -98,7 +98,7 @@ it "handles namespace-free XML with inline text content" do xml = "hello world" math = Mml::V3.parse(xml, namespace_exist: false) - expect(math.mtext_value.first.value).to eq("hello world") + expect(math.mtext_value.first.value).to eq(["hello world"]) end it "handles multi-level nested elements without namespace" do diff --git a/spec/mml/ordered_content_spec.rb b/spec/mml/ordered_content_spec.rb new file mode 100644 index 0000000..64982c4 --- /dev/null +++ b/spec/mml/ordered_content_spec.rb @@ -0,0 +1,125 @@ +# frozen_string_literal: true + +require "spec_helper" + +# Regression specs covering the mixed_content → ordered migration on +# element-only content models. Under `mixed_content`, lutaml-model's +# `each_mixed_content` yields inter-element whitespace as String children, +# which breaks consumers that do positional indexing +# (e.g., Plurimath's `msub_to_base` reads `children[0]` as the base). +# +# Under `ordered`, whitespace-only text nodes are skipped. +# These specs assert that contract for each positional-index element. +# If a base file drifts back to `mixed_content`, the corresponding spec +# below will fail. + +# rubocop:disable RSpec/DescribeClass +RSpec.describe "ordered content" do + def yielded_children(node) + children = [] + node.each_mixed_content { |c| children << c } + children + end + + def expect_no_whitespace_strings(node) + children = yielded_children(node) + offenders = children.select { |c| c.is_a?(String) && c.strip.empty? } + expect(offenders).to be_empty, + "expected no whitespace-only String children, " \ + "got #{offenders.inspect} among #{children.inspect}" + end + + shared_context "with namespace declared" do + let(:xmlns) { 'xmlns="http://www.w3.org/1998/Math/MathML"' } + end + + # rubocop:disable RSpec/EmptyExampleGroup + positional_specs = { + "msub" => ["t", "90"], + "msup" => ["t", "90"], + "msubsup" => ["t", "90", "2"], + "mfrac" => ["1", "2"], + "mroot" => ["x", "3"], + "mover" => ["x", "^"], + "munder" => ["x", "_"], + "munderover" => ["x", "_", "^"], + } + + container_specs = { + "mrow" => ["a", "+", "b"], + "mfenced" => ["a", "b"], + "msgroup" => ["1", "2"], + } + # rubocop:enable RSpec/EmptyExampleGroup + + positional_specs.merge(container_specs).each do |tag, children_xml| + [Mml::V3, Mml::V4].each do |version| + describe "#{version} #{tag} with whitespace between children" do + include_context "with namespace declared" + + let(:inner) { children_xml.join("\n ") } + let(:input) { "<#{tag}>\n #{inner}\n" } + let(:math) { version.parse(input) } + let(:node) { math.public_send("#{tag}_value").first } + + it "does not yield whitespace-only String children from each_mixed_content" do + expect_no_whitespace_strings(node) + end + + it "round-trips the child elements" do + expect(math.to_xml).to be_xml_equivalent_to(input) + end + end + end + end + + # Token elements should still preserve text + inline elements (mglyph, + # malignmark) — they are the legitimate `mixed_content` use case per + # MathML schema: token content is `(#PCDATA | mglyph | malignmark)*`. + describe "token elements (genuine mixed content)" do + include_context "with namespace declared" + + [Mml::V3, Mml::V4].each do |version| + it "#{version}::Mi preserves inline malignmark inside text" do + input = "xy" + math = version.parse(input) + mi = math.mi_value.first + expect(mi.value).to eq(%w[x y]) + expect(mi.malignmark_value).not_to be_empty + end + + it "#{version}::Mo captures inline mglyph" do + input = %(+) + math = version.parse(input) + mo = math.mo_value.first + expect(mo.value).to eq(["+"]) + expect(mo.mglyph_value.first.alt).to eq("g") + end + + it "#{version}::Mtext preserves text and malignmark" do + input = "helloworld" + math = version.parse(input) + mtext = math.mtext_value.first + expect(mtext.value).to eq(%w[hello world]) + expect(mtext.malignmark_value).not_to be_empty + end + + it "#{version}::Mn captures inline mglyph" do + input = %(12) + math = version.parse(input) + mn = math.mn_value.first + expect(mn.value).to eq(%w[1 2]) + expect(mn.mglyph_value.first.alt).to eq("g") + end + + it "#{version}::Ms captures inline malignmark" do + input = "ab" + math = version.parse(input) + ms = math.ms_value.first + expect(ms.value).to eq(%w[a b]) + expect(ms.malignmark_value).not_to be_empty + end + end + end +end +# rubocop:enable RSpec/DescribeClass diff --git a/spec/mml/v2/math_spec.rb b/spec/mml/v2/math_spec.rb index 16a529d..6bf8770 100644 --- a/spec/mml/v2/math_spec.rb +++ b/spec/mml/v2/math_spec.rb @@ -43,7 +43,7 @@ "x+1" math = Mml::V2.parse(input) expect(math.mi_value.first.value).to eq(["x"]) - expect(math.mo_value.first.value).to eq("+") + expect(math.mo_value.first.value).to eq(["+"]) expect(math.mn_value.first.value).to eq(["1"]) end diff --git a/spec/mml/v2/mo_spec.rb b/spec/mml/v2/mo_spec.rb index c511403..b1779f2 100644 --- a/spec/mml/v2/mo_spec.rb +++ b/spec/mml/v2/mo_spec.rb @@ -15,7 +15,7 @@ input = '' \ "" math = Mml::V2.parse(input) - expect(math.mo_value.first.value).to eq("∑") + expect(math.mo_value.first.value).to eq(["∑"]) end it "preserves lspace attribute" do diff --git a/spec/mml/v2/mtext_spec.rb b/spec/mml/v2/mtext_spec.rb index 7539f9a..dca516b 100644 --- a/spec/mml/v2/mtext_spec.rb +++ b/spec/mml/v2/mtext_spec.rb @@ -15,7 +15,7 @@ input = '' \ "some text" math = Mml::V2.parse(input) - expect(math.mtext_value.first.value).to eq("some text") + expect(math.mtext_value.first.value).to eq(["some text"]) end end end diff --git a/spec/mml/v3/math_spec.rb b/spec/mml/v3/math_spec.rb index 1e92e9e..b1f54e4 100644 --- a/spec/mml/v3/math_spec.rb +++ b/spec/mml/v3/math_spec.rb @@ -42,7 +42,7 @@ "x+1" math = Mml.parse(input) expect(math.mi_value.first.value).to eq(["x"]) - expect(math.mo_value.first.value).to eq("+") + expect(math.mo_value.first.value).to eq(["+"]) expect(math.mn_value.first.value).to eq(["1"]) end end diff --git a/spec/mml/v3/merror_spec.rb b/spec/mml/v3/merror_spec.rb index b68612e..8897394 100644 --- a/spec/mml/v3/merror_spec.rb +++ b/spec/mml/v3/merror_spec.rb @@ -16,7 +16,7 @@ "Error message" math = Mml.parse(input) expect(math.merror_value.first.mtext_value.first.value) - .to eq("Error message") + .to eq(["Error message"]) end it "parses error with complex children" do diff --git a/spec/mml/v3/mo_spec.rb b/spec/mml/v3/mo_spec.rb index 5a3ef41..83fcfbc 100644 --- a/spec/mml/v3/mo_spec.rb +++ b/spec/mml/v3/mo_spec.rb @@ -36,7 +36,7 @@ input = '' \ "+" math = Mml.parse(input) - expect(math.mo_value.first.value).to eq("+") + expect(math.mo_value.first.value).to eq(["+"]) end it "preserves form attribute" do diff --git a/spec/mml/v3/mover_spec.rb b/spec/mml/v3/mover_spec.rb index 626b787..f89328e 100644 --- a/spec/mml/v3/mover_spec.rb +++ b/spec/mml/v3/mover_spec.rb @@ -29,7 +29,7 @@ input = '' \ "b" math = Mml.parse(input) - expect(math.mover_value.first.mo_value.first.value).to eq("∫") + expect(math.mover_value.first.mo_value.first.value).to eq(["∫"]) expect(math.mover_value.first.mi_value.first.value).to eq(["b"]) end end diff --git a/spec/mml/v3/mtext_spec.rb b/spec/mml/v3/mtext_spec.rb index 60bcc87..e35c009 100644 --- a/spec/mml/v3/mtext_spec.rb +++ b/spec/mml/v3/mtext_spec.rb @@ -29,7 +29,7 @@ input = '' \ "sample text" math = Mml.parse(input) - expect(math.mtext_value.first.value).to eq("sample text") + expect(math.mtext_value.first.value).to eq(["sample text"]) end it "preserves mathsize attribute" do diff --git a/spec/mml/v3/munder_spec.rb b/spec/mml/v3/munder_spec.rb index 1a02425..608de40 100644 --- a/spec/mml/v3/munder_spec.rb +++ b/spec/mml/v3/munder_spec.rb @@ -29,7 +29,7 @@ input = '' \ "a" math = Mml.parse(input) - expect(math.munder_value.first.mo_value.first.value).to eq("∫") + expect(math.munder_value.first.mo_value.first.value).to eq(["∫"]) expect(math.munder_value.first.mi_value.first.value).to eq(["a"]) end end diff --git a/spec/mml/v3/munderover_spec.rb b/spec/mml/v3/munderover_spec.rb index a4120c5..551c652 100644 --- a/spec/mml/v3/munderover_spec.rb +++ b/spec/mml/v3/munderover_spec.rb @@ -39,7 +39,7 @@ input = '' \ "ab" math = Mml.parse(input) - expect(math.munderover_value.first.mo_value.first.value).to eq("∫") + expect(math.munderover_value.first.mo_value.first.value).to eq(["∫"]) expect(math.munderover_value.first.mi_value.first.value).to eq(["a"]) expect(math.munderover_value.first.mi_value.last.value).to eq(["b"]) end diff --git a/spec/mml/v4/math_spec.rb b/spec/mml/v4/math_spec.rb index ee09b37..b0b7d51 100644 --- a/spec/mml/v4/math_spec.rb +++ b/spec/mml/v4/math_spec.rb @@ -42,7 +42,7 @@ input = '' \ "x+1" math = Mml::V4.parse(input) - expect(math.mo_value.first.value).to eq("+") + expect(math.mo_value.first.value).to eq(["+"]) end it "extracts mn child element" do diff --git a/spec/mml/v4/mo_spec.rb b/spec/mml/v4/mo_spec.rb index 3f44dc6..19004de 100644 --- a/spec/mml/v4/mo_spec.rb +++ b/spec/mml/v4/mo_spec.rb @@ -19,7 +19,7 @@ it "extracts operator value" do input = '+' math = Mml::V4.parse(input) - expect(math.mo_value.first.value).to eq("+") + expect(math.mo_value.first.value).to eq(["+"]) end it "preserves form attribute" do diff --git a/spec/mml/v4/mtext_spec.rb b/spec/mml/v4/mtext_spec.rb index cea2d2c..9920ae5 100644 --- a/spec/mml/v4/mtext_spec.rb +++ b/spec/mml/v4/mtext_spec.rb @@ -14,7 +14,7 @@ input = '' \ "sample text" math = Mml::V4.parse(input) - expect(math.mtext_value.first.value).to eq("sample text") + expect(math.mtext_value.first.value).to eq(["sample text"]) end it "preserves mathcolor attribute" do