From 90eb97e0938719879840504a010223111ea39851 Mon Sep 17 00:00:00 2001 From: G Pardhiv Varma Date: Fri, 10 Apr 2026 20:53:34 +0530 Subject: [PATCH 1/4] feat(math): implement m:limLow and m:limUpp limit converters (SD-2377, SD-2378) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add lower limit (m:limLow → ) and upper limit (m:limUpp → ) OMML-to-MathML converters per ECMA-376 §22.1.2.54 and §22.1.2.56. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../dom/src/features/math/converters/index.ts | 2 + .../features/math/converters/lower-limit.ts | 32 ++ .../features/math/converters/upper-limit.ts | 32 ++ .../src/features/math/omml-to-mathml.test.ts | 287 ++++++++++++++++++ .../dom/src/features/math/omml-to-mathml.ts | 8 +- 5 files changed, 358 insertions(+), 3 deletions(-) create mode 100644 packages/layout-engine/painters/dom/src/features/math/converters/lower-limit.ts create mode 100644 packages/layout-engine/painters/dom/src/features/math/converters/upper-limit.ts diff --git a/packages/layout-engine/painters/dom/src/features/math/converters/index.ts b/packages/layout-engine/painters/dom/src/features/math/converters/index.ts index 2857cfa29b..f94a3d6132 100644 --- a/packages/layout-engine/painters/dom/src/features/math/converters/index.ts +++ b/packages/layout-engine/painters/dom/src/features/math/converters/index.ts @@ -15,3 +15,5 @@ export { convertSubscript } from './subscript.js'; export { convertSuperscript } from './superscript.js'; export { convertSubSuperscript } from './sub-superscript.js'; export { convertRadical } from './radical.js'; +export { convertLowerLimit } from './lower-limit.js'; +export { convertUpperLimit } from './upper-limit.js'; diff --git a/packages/layout-engine/painters/dom/src/features/math/converters/lower-limit.ts b/packages/layout-engine/painters/dom/src/features/math/converters/lower-limit.ts new file mode 100644 index 0000000000..bda0ffabd7 --- /dev/null +++ b/packages/layout-engine/painters/dom/src/features/math/converters/lower-limit.ts @@ -0,0 +1,32 @@ +import type { MathObjectConverter } from '../types.js'; + +const MATHML_NS = 'http://www.w3.org/1998/Math/MathML'; + +/** + * Convert m:limLow (lower limit) to MathML . + * + * OMML structure: + * m:limLow → m:limLowPr (optional), m:e (base, e.g. "lim"), m:lim (limit expression) + * + * MathML output: + * base lim + * + * @spec ECMA-376 §22.1.2.54 + */ +export const convertLowerLimit: MathObjectConverter = (node, doc, convertChildren) => { + const elements = node.elements ?? []; + const base = elements.find((e) => e.name === 'm:e'); + const lim = elements.find((e) => e.name === 'm:lim'); + + const munder = doc.createElementNS(MATHML_NS, 'munder'); + + const baseRow = doc.createElementNS(MATHML_NS, 'mrow'); + baseRow.appendChild(convertChildren(base?.elements ?? [])); + munder.appendChild(baseRow); + + const limRow = doc.createElementNS(MATHML_NS, 'mrow'); + limRow.appendChild(convertChildren(lim?.elements ?? [])); + munder.appendChild(limRow); + + return munder; +}; diff --git a/packages/layout-engine/painters/dom/src/features/math/converters/upper-limit.ts b/packages/layout-engine/painters/dom/src/features/math/converters/upper-limit.ts new file mode 100644 index 0000000000..7a6f1e487a --- /dev/null +++ b/packages/layout-engine/painters/dom/src/features/math/converters/upper-limit.ts @@ -0,0 +1,32 @@ +import type { MathObjectConverter } from '../types.js'; + +const MATHML_NS = 'http://www.w3.org/1998/Math/MathML'; + +/** + * Convert m:limUpp (upper limit) to MathML . + * + * OMML structure: + * m:limUpp → m:limUppPr (optional), m:e (base), m:lim (limit expression placed above) + * + * MathML output: + * base lim + * + * @spec ECMA-376 §22.1.2.56 + */ +export const convertUpperLimit: MathObjectConverter = (node, doc, convertChildren) => { + const elements = node.elements ?? []; + const base = elements.find((e) => e.name === 'm:e'); + const lim = elements.find((e) => e.name === 'm:lim'); + + const mover = doc.createElementNS(MATHML_NS, 'mover'); + + const baseRow = doc.createElementNS(MATHML_NS, 'mrow'); + baseRow.appendChild(convertChildren(base?.elements ?? [])); + mover.appendChild(baseRow); + + const limRow = doc.createElementNS(MATHML_NS, 'mrow'); + limRow.appendChild(convertChildren(lim?.elements ?? [])); + mover.appendChild(limRow); + + return mover; +}; diff --git a/packages/layout-engine/painters/dom/src/features/math/omml-to-mathml.test.ts b/packages/layout-engine/painters/dom/src/features/math/omml-to-mathml.test.ts index ba65368aa2..4dc7d99ef2 100644 --- a/packages/layout-engine/painters/dom/src/features/math/omml-to-mathml.test.ts +++ b/packages/layout-engine/painters/dom/src/features/math/omml-to-mathml.test.ts @@ -1642,3 +1642,290 @@ describe('m:rad converter', () => { expect(msqrt!.textContent).toBe(''); }); }); + +describe('m:limLow converter', () => { + it('converts m:limLow to with base and lower limit', () => { + // lim_{n→∞} + const omml = { + name: 'm:oMath', + elements: [ + { + name: 'm:limLow', + elements: [ + { + name: 'm:e', + elements: [{ name: 'm:r', elements: [{ name: 'm:t', elements: [{ type: 'text', text: 'lim' }] }] }], + }, + { + name: 'm:lim', + elements: [ + { name: 'm:r', elements: [{ name: 'm:t', elements: [{ type: 'text', text: 'n' }] }] }, + { name: 'm:r', elements: [{ name: 'm:t', elements: [{ type: 'text', text: '\u2192' }] }] }, + { name: 'm:r', elements: [{ name: 'm:t', elements: [{ type: 'text', text: '\u221E' }] }] }, + ], + }, + ], + }, + ], + }; + + const result = convertOmmlToMathml(omml, doc); + expect(result).not.toBeNull(); + const munder = result!.querySelector('munder'); + expect(munder).not.toBeNull(); + expect(munder!.children.length).toBe(2); + expect(munder!.children[0]!.textContent).toBe('lim'); + expect(munder!.children[1]!.textContent).toBe('n\u2192\u221E'); + }); + + it('ignores m:limLowPr properties element', () => { + const omml = { + name: 'm:oMath', + elements: [ + { + name: 'm:limLow', + elements: [ + { name: 'm:limLowPr', elements: [{ name: 'm:ctrlPr' }] }, + { + name: 'm:e', + elements: [{ name: 'm:r', elements: [{ name: 'm:t', elements: [{ type: 'text', text: 'inf' }] }] }], + }, + { + name: 'm:lim', + elements: [{ name: 'm:r', elements: [{ name: 'm:t', elements: [{ type: 'text', text: 'x' }] }] }], + }, + ], + }, + ], + }; + + const result = convertOmmlToMathml(omml, doc); + expect(result).not.toBeNull(); + const munder = result!.querySelector('munder'); + expect(munder).not.toBeNull(); + expect(munder!.children.length).toBe(2); + expect(munder!.children[0]!.textContent).toBe('inf'); + expect(munder!.children[1]!.textContent).toBe('x'); + }); + + it('wraps multi-part base and limit in for valid arity', () => { + // lim_{n→∞} — limit has 3 runs that must be grouped + const omml = { + name: 'm:oMath', + elements: [ + { + name: 'm:limLow', + elements: [ + { + name: 'm:e', + elements: [{ name: 'm:r', elements: [{ name: 'm:t', elements: [{ type: 'text', text: 'lim' }] }] }], + }, + { + name: 'm:lim', + elements: [ + { name: 'm:r', elements: [{ name: 'm:t', elements: [{ type: 'text', text: 'n' }] }] }, + { name: 'm:r', elements: [{ name: 'm:t', elements: [{ type: 'text', text: '\u2192' }] }] }, + { name: 'm:r', elements: [{ name: 'm:t', elements: [{ type: 'text', text: '\u221E' }] }] }, + ], + }, + ], + }, + ], + }; + const result = convertOmmlToMathml(omml, doc); + expect(result).not.toBeNull(); + const munder = result!.querySelector('munder'); + expect(munder).not.toBeNull(); + // must have exactly 2 children (base + limit), each wrapped in + expect(munder!.children.length).toBe(2); + expect(munder!.children[0]!.textContent).toBe('lim'); + expect(munder!.children[1]!.textContent).toBe('n\u2192\u221E'); + }); + + it('handles missing m:lim gracefully', () => { + const omml = { + name: 'm:oMath', + elements: [ + { + name: 'm:limLow', + elements: [ + { + name: 'm:e', + elements: [{ name: 'm:r', elements: [{ name: 'm:t', elements: [{ type: 'text', text: 'lim' }] }] }], + }, + ], + }, + ], + }; + + const result = convertOmmlToMathml(omml, doc); + expect(result).not.toBeNull(); + const munder = result!.querySelector('munder'); + expect(munder).not.toBeNull(); + expect(munder!.children[0]!.textContent).toBe('lim'); + }); + + it('handles missing m:e gracefully', () => { + const omml = { + name: 'm:oMath', + elements: [ + { + name: 'm:limLow', + elements: [ + { + name: 'm:lim', + elements: [{ name: 'm:r', elements: [{ name: 'm:t', elements: [{ type: 'text', text: 'k' }] }] }], + }, + ], + }, + ], + }; + + const result = convertOmmlToMathml(omml, doc); + expect(result).not.toBeNull(); + const munder = result!.querySelector('munder'); + expect(munder).not.toBeNull(); + expect(munder!.children[1]!.textContent).toBe('k'); + }); +}); + +describe('m:limUpp converter', () => { + it('converts m:limUpp to with base and upper limit', () => { + const omml = { + name: 'm:oMath', + elements: [ + { + name: 'm:limUpp', + elements: [ + { + name: 'm:e', + elements: [{ name: 'm:r', elements: [{ name: 'm:t', elements: [{ type: 'text', text: 'max' }] }] }], + }, + { + name: 'm:lim', + elements: [{ name: 'm:r', elements: [{ name: 'm:t', elements: [{ type: 'text', text: 'x' }] }] }], + }, + ], + }, + ], + }; + + const result = convertOmmlToMathml(omml, doc); + expect(result).not.toBeNull(); + const mover = result!.querySelector('mover'); + expect(mover).not.toBeNull(); + expect(mover!.children.length).toBe(2); + expect(mover!.children[0]!.textContent).toBe('max'); + expect(mover!.children[1]!.textContent).toBe('x'); + }); + + it('ignores m:limUppPr properties element', () => { + const omml = { + name: 'm:oMath', + elements: [ + { + name: 'm:limUpp', + elements: [ + { name: 'm:limUppPr', elements: [{ name: 'm:ctrlPr' }] }, + { + name: 'm:e', + elements: [{ name: 'm:r', elements: [{ name: 'm:t', elements: [{ type: 'text', text: '=' }] }] }], + }, + { + name: 'm:lim', + elements: [{ name: 'm:r', elements: [{ name: 'm:t', elements: [{ type: 'text', text: 'def' }] }] }], + }, + ], + }, + ], + }; + + const result = convertOmmlToMathml(omml, doc); + expect(result).not.toBeNull(); + const mover = result!.querySelector('mover'); + expect(mover).not.toBeNull(); + expect(mover!.children.length).toBe(2); + expect(mover!.children[0]!.textContent).toBe('='); + expect(mover!.children[1]!.textContent).toBe('def'); + }); + + it('wraps multi-part base and limit in for valid arity', () => { + // A^{i+1} — limit has 3 runs that must be grouped + const omml = { + name: 'm:oMath', + elements: [ + { + name: 'm:limUpp', + elements: [ + { + name: 'm:e', + elements: [{ name: 'm:r', elements: [{ name: 'm:t', elements: [{ type: 'text', text: 'A' }] }] }], + }, + { + name: 'm:lim', + elements: [ + { name: 'm:r', elements: [{ name: 'm:t', elements: [{ type: 'text', text: 'i' }] }] }, + { name: 'm:r', elements: [{ name: 'm:t', elements: [{ type: 'text', text: '+' }] }] }, + { name: 'm:r', elements: [{ name: 'm:t', elements: [{ type: 'text', text: '1' }] }] }, + ], + }, + ], + }, + ], + }; + const result = convertOmmlToMathml(omml, doc); + expect(result).not.toBeNull(); + const mover = result!.querySelector('mover'); + expect(mover).not.toBeNull(); + // must have exactly 2 children (base + limit), each wrapped in + expect(mover!.children.length).toBe(2); + expect(mover!.children[0]!.textContent).toBe('A'); + expect(mover!.children[1]!.textContent).toBe('i+1'); + }); + + it('handles missing m:lim gracefully', () => { + const omml = { + name: 'm:oMath', + elements: [ + { + name: 'm:limUpp', + elements: [ + { + name: 'm:e', + elements: [{ name: 'm:r', elements: [{ name: 'm:t', elements: [{ type: 'text', text: 'sup' }] }] }], + }, + ], + }, + ], + }; + + const result = convertOmmlToMathml(omml, doc); + expect(result).not.toBeNull(); + const mover = result!.querySelector('mover'); + expect(mover).not.toBeNull(); + expect(mover!.children[0]!.textContent).toBe('sup'); + }); + + it('handles missing m:e gracefully', () => { + const omml = { + name: 'm:oMath', + elements: [ + { + name: 'm:limUpp', + elements: [ + { + name: 'm:lim', + elements: [{ name: 'm:r', elements: [{ name: 'm:t', elements: [{ type: 'text', text: 'n' }] }] }], + }, + ], + }, + ], + }; + + const result = convertOmmlToMathml(omml, doc); + expect(result).not.toBeNull(); + const mover = result!.querySelector('mover'); + expect(mover).not.toBeNull(); + expect(mover!.children[1]!.textContent).toBe('n'); + }); +}); diff --git a/packages/layout-engine/painters/dom/src/features/math/omml-to-mathml.ts b/packages/layout-engine/painters/dom/src/features/math/omml-to-mathml.ts index 8c3d9509a8..149869d0b7 100644 --- a/packages/layout-engine/painters/dom/src/features/math/omml-to-mathml.ts +++ b/packages/layout-engine/painters/dom/src/features/math/omml-to-mathml.ts @@ -20,6 +20,8 @@ import { convertSuperscript, convertSubSuperscript, convertRadical, + convertLowerLimit, + convertUpperLimit, } from './converters/index.js'; export const MATHML_NS = 'http://www.w3.org/1998/Math/MathML'; @@ -44,6 +46,9 @@ const MATH_OBJECT_REGISTRY: Record = { 'm:d': convertDelimiter, // Delimiter (parentheses, brackets, braces) 'm:f': convertFraction, // Fraction (numerator/denominator) 'm:func': convertFunction, // Function apply (sin, cos, log, etc.) + 'm:limLow': convertLowerLimit, // Lower limit (e.g., lim) + 'm:limUpp': convertUpperLimit, // Upper limit + 'm:rad': convertRadical, // Radical (square root, nth root) 'm:sSub': convertSubscript, // Subscript 'm:sSup': convertSuperscript, // Superscript 'm:sSubSup': convertSubSuperscript, // Sub-superscript (both) @@ -54,12 +59,9 @@ const MATH_OBJECT_REGISTRY: Record = { 'm:box': null, // Box (invisible grouping container) 'm:eqArr': null, // Equation array (vertical array of equations) 'm:groupChr': null, // Group character (overbrace, underbrace) - 'm:limLow': null, // Lower limit (e.g., lim) - 'm:limUpp': null, // Upper limit 'm:m': null, // Matrix (grid of elements) 'm:nary': null, // N-ary operator (integral, summation, product) 'm:phant': null, // Phantom (invisible spacing placeholder) - 'm:rad': convertRadical, // Radical (square root, nth root) 'm:sPre': null, // Pre-sub-superscript (left of base) }; From 4ef3f3273b2d206c29f9b7414227fcabbc66d633 Mon Sep 17 00:00:00 2001 From: Caio Pizzol Date: Sun, 12 Apr 2026 14:16:54 -0700 Subject: [PATCH 2/4] feat(math): handle m:sty and m:scr in convertMathRun (SD-2377/2378) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ECMA-376 §22.1.2 defines m:sty (style: p/b/i/bi) and m:scr (script: roman, script, fraktur, double-struck, sans-serif, monospace) on math run properties. Word emits m:sty="p" on function names like "lim", "max", "sup" — without this handling those names rendered italic instead of upright when they appear outside an m:func wrapper. Precedence: m:sty > m:scr > m:nor. m:nor stays as the legacy normal flag, equivalent to m:sty="p" when no explicit style or script is set. --- .../src/features/math/converters/math-run.ts | 52 ++++++++++++++++--- 1 file changed, 45 insertions(+), 7 deletions(-) diff --git a/packages/layout-engine/painters/dom/src/features/math/converters/math-run.ts b/packages/layout-engine/painters/dom/src/features/math/converters/math-run.ts index 4620bfd5c5..79fddd2375 100644 --- a/packages/layout-engine/painters/dom/src/features/math/converters/math-run.ts +++ b/packages/layout-engine/painters/dom/src/features/math/converters/math-run.ts @@ -77,6 +77,44 @@ function classifyMathText(text: string): 'mn' | 'mo' | 'mi' { return 'mi'; } +/** ECMA-376 m:sty → MathML mathvariant (§22.1.2 math run properties). */ +const STY_TO_VARIANT: Record = { + p: 'normal', + b: 'bold', + i: 'italic', + bi: 'bold-italic', +}; + +/** ECMA-376 m:scr → MathML mathvariant (§22.1.2 math run properties). */ +const SCR_TO_VARIANT: Record = { + roman: 'normal', + script: 'script', + fraktur: 'fraktur', + 'double-struck': 'double-struck', + 'sans-serif': 'sans-serif', + monospace: 'monospace', +}; + +/** + * Resolve the effective MathML mathvariant from OMML m:rPr. + * + * Precedence (highest first): m:sty > m:scr > m:nor. + * m:nor is the legacy "normal text" flag (ECMA-376 §22.1.2); it is treated as + * equivalent to m:sty="p" when neither m:sty nor m:scr is present. + */ +function resolveMathVariant(rPr: OmmlJsonNode | undefined): string | null { + const elements = rPr?.elements ?? []; + const sty = elements.find((el) => el.name === 'm:sty')?.attributes?.['m:val']; + if (sty && STY_TO_VARIANT[sty]) return STY_TO_VARIANT[sty]!; + + const scr = elements.find((el) => el.name === 'm:scr')?.attributes?.['m:val']; + if (scr && SCR_TO_VARIANT[scr]) return SCR_TO_VARIANT[scr]!; + + if (elements.some((el) => el.name === 'm:nor')) return 'normal'; + + return null; +} + /** * Convert an m:r (math run) element to MathML. * @@ -105,18 +143,18 @@ export const convertMathRun: MathObjectConverter = (node, doc) => { if (!text) return null; - // Check m:rPr for normal text flag (m:nor) which disables math italics const rPr = elements.find((el) => el.name === 'm:rPr'); - const isNormalText = rPr?.elements?.some((el) => el.name === 'm:nor') ?? false; - + const variant = resolveMathVariant(rPr); const tag = classifyMathText(text); + const el = doc.createElementNS(MATHML_NS, tag); el.textContent = text; - // MathML with single-char content is italic by default (spec). - // Multi-char is normal by default. The m:nor flag forces normal. - if (tag === 'mi' && isNormalText) { - el.setAttribute('mathvariant', 'normal'); + // Apply mathvariant when the spec properties resolve to one. The default + // for single-char is italic and for multi-char // is + // normal — we only set an attribute when m:rPr explicitly specifies it. + if (variant) { + el.setAttribute('mathvariant', variant); } return el; From e71fc43bcc3a58e409d01ec94471363236bbaafb Mon Sep 17 00:00:00 2001 From: Caio Pizzol Date: Sun, 12 Apr 2026 14:17:06 -0700 Subject: [PATCH 3/4] test(math): expand limLow/limUpp unit coverage (SD-2377/2378) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Tighten "handles missing m:e/m:lim gracefully" tests: assert arity-2 / with an empty on the missing side (was only checking the present side). - Add multi-run base tests for both converters — existing tests only exercised multi-run m:lim, leaving the m:e base-wrapping path untested. - Add nested-math tests (fraction inside m:lim) for both converters. - Add m:func-wrapped tests (m:func > m:fName > m:limLow/m:limUpp) — the shape Word actually emits when the base is a function keyword. - Add full coverage for m:sty (p/b/i/bi), m:scr (6 variants), and m:sty > m:scr precedence per ECMA-376 §22.1.2. --- .../src/features/math/omml-to-mathml.test.ts | 359 ++++++++++++++++++ 1 file changed, 359 insertions(+) diff --git a/packages/layout-engine/painters/dom/src/features/math/omml-to-mathml.test.ts b/packages/layout-engine/painters/dom/src/features/math/omml-to-mathml.test.ts index dbf5a05dcc..8682a48cf1 100644 --- a/packages/layout-engine/painters/dom/src/features/math/omml-to-mathml.test.ts +++ b/packages/layout-engine/painters/dom/src/features/math/omml-to-mathml.test.ts @@ -208,6 +208,105 @@ describe('convertOmmlToMathml', () => { expect(mi!.getAttribute('mathvariant')).toBe('normal'); }); + // ── m:sty (ECMA-376 §22.1.2 math style) → mathvariant ──────────────────── + // Word-native documents use m:sty, not m:nor, to signal upright function + // names like "lim" / "sin" / "max" — so these values must be honored. + + const runWithRPr = (text: string, rPrElements: Array) => ({ + name: 'm:oMath', + elements: [ + { + name: 'm:r', + elements: [ + { name: 'm:rPr', elements: rPrElements }, + { name: 'm:t', elements: [{ type: 'text', text }] }, + ], + }, + ], + }); + + it('sets mathvariant=normal for m:sty val=p', () => { + const result = convertOmmlToMathml(runWithRPr('lim', [{ name: 'm:sty', attributes: { 'm:val': 'p' } }]), doc); + const mi = result!.querySelector('mi'); + expect(mi!.getAttribute('mathvariant')).toBe('normal'); + }); + + it('sets mathvariant=bold for m:sty val=b', () => { + const result = convertOmmlToMathml(runWithRPr('x', [{ name: 'm:sty', attributes: { 'm:val': 'b' } }]), doc); + expect(result!.querySelector('mi')!.getAttribute('mathvariant')).toBe('bold'); + }); + + it('sets mathvariant=italic for m:sty val=i', () => { + const result = convertOmmlToMathml(runWithRPr('abc', [{ name: 'm:sty', attributes: { 'm:val': 'i' } }]), doc); + expect(result!.querySelector('mi')!.getAttribute('mathvariant')).toBe('italic'); + }); + + it('sets mathvariant=bold-italic for m:sty val=bi', () => { + const result = convertOmmlToMathml(runWithRPr('x', [{ name: 'm:sty', attributes: { 'm:val': 'bi' } }]), doc); + expect(result!.querySelector('mi')!.getAttribute('mathvariant')).toBe('bold-italic'); + }); + + // ── m:scr (ECMA-376 §22.1.2 math script) → mathvariant ─────────────────── + + it('sets mathvariant=normal for m:scr val=roman', () => { + const result = convertOmmlToMathml(runWithRPr('lim', [{ name: 'm:scr', attributes: { 'm:val': 'roman' } }]), doc); + expect(result!.querySelector('mi')!.getAttribute('mathvariant')).toBe('normal'); + }); + + it('sets mathvariant=script for m:scr val=script', () => { + const result = convertOmmlToMathml(runWithRPr('L', [{ name: 'm:scr', attributes: { 'm:val': 'script' } }]), doc); + expect(result!.querySelector('mi')!.getAttribute('mathvariant')).toBe('script'); + }); + + it('sets mathvariant=fraktur for m:scr val=fraktur', () => { + const result = convertOmmlToMathml(runWithRPr('g', [{ name: 'm:scr', attributes: { 'm:val': 'fraktur' } }]), doc); + expect(result!.querySelector('mi')!.getAttribute('mathvariant')).toBe('fraktur'); + }); + + it('sets mathvariant=double-struck for m:scr val=double-struck', () => { + const result = convertOmmlToMathml( + runWithRPr('R', [{ name: 'm:scr', attributes: { 'm:val': 'double-struck' } }]), + doc, + ); + expect(result!.querySelector('mi')!.getAttribute('mathvariant')).toBe('double-struck'); + }); + + it('sets mathvariant=sans-serif for m:scr val=sans-serif', () => { + const result = convertOmmlToMathml( + runWithRPr('x', [{ name: 'm:scr', attributes: { 'm:val': 'sans-serif' } }]), + doc, + ); + expect(result!.querySelector('mi')!.getAttribute('mathvariant')).toBe('sans-serif'); + }); + + it('sets mathvariant=monospace for m:scr val=monospace', () => { + const result = convertOmmlToMathml(runWithRPr('x', [{ name: 'm:scr', attributes: { 'm:val': 'monospace' } }]), doc); + expect(result!.querySelector('mi')!.getAttribute('mathvariant')).toBe('monospace'); + }); + + // ── Precedence: m:sty wins over m:scr ──────────────────────────────────── + + it('gives m:sty precedence over m:scr when both are present', () => { + // Spec doesn't explicitly rank them, but m:sty is the more specific + // rendering intent (upright/bold/italic) so we honor it first. + const result = convertOmmlToMathml( + runWithRPr('x', [ + { name: 'm:sty', attributes: { 'm:val': 'b' } }, + { name: 'm:scr', attributes: { 'm:val': 'fraktur' } }, + ]), + doc, + ); + expect(result!.querySelector('mi')!.getAttribute('mathvariant')).toBe('bold'); + }); + + it('omits mathvariant when rPr has no recognized style properties', () => { + const result = convertOmmlToMathml( + runWithRPr('x', [{ name: 'w:rFonts', attributes: { 'w:ascii': 'Cambria Math' } }]), + doc, + ); + expect(result!.querySelector('mi')!.hasAttribute('mathvariant')).toBe(false); + }); + it('handles empty m:r (no m:t children)', () => { const omml = { name: 'm:oMath', @@ -1777,7 +1876,10 @@ describe('m:limLow converter', () => { expect(result).not.toBeNull(); const munder = result!.querySelector('munder'); expect(munder).not.toBeNull(); + // is arity-2: preserve an empty on the missing side. + expect(munder!.children.length).toBe(2); expect(munder!.children[0]!.textContent).toBe('lim'); + expect(munder!.children[1]!.textContent).toBe(''); }); it('handles missing m:e gracefully', () => { @@ -1800,8 +1902,137 @@ describe('m:limLow converter', () => { expect(result).not.toBeNull(); const munder = result!.querySelector('munder'); expect(munder).not.toBeNull(); + expect(munder!.children.length).toBe(2); + expect(munder!.children[0]!.textContent).toBe(''); expect(munder!.children[1]!.textContent).toBe('k'); }); + + it('wraps multi-run base (m:e) in ', () => { + // lim inf with a two-run base: exercises the base-wrapping code path. + const omml = { + name: 'm:oMath', + elements: [ + { + name: 'm:limLow', + elements: [ + { + name: 'm:e', + elements: [ + { name: 'm:r', elements: [{ name: 'm:t', elements: [{ type: 'text', text: 'lim' }] }] }, + { name: 'm:r', elements: [{ name: 'm:t', elements: [{ type: 'text', text: ' inf' }] }] }, + ], + }, + { + name: 'm:lim', + elements: [{ name: 'm:r', elements: [{ name: 'm:t', elements: [{ type: 'text', text: 'x' }] }] }], + }, + ], + }, + ], + }; + const result = convertOmmlToMathml(omml, doc); + expect(result).not.toBeNull(); + const munder = result!.querySelector('munder'); + expect(munder).not.toBeNull(); + expect(munder!.children.length).toBe(2); + expect(munder!.children[0]!.textContent).toBe('lim inf'); + expect(munder!.children[1]!.textContent).toBe('x'); + }); + + it('preserves nested math object inside m:lim (fraction)', () => { + // lim_(x/y → 0) — limit expression contains a fraction. + const omml = { + name: 'm:oMath', + elements: [ + { + name: 'm:limLow', + elements: [ + { + name: 'm:e', + elements: [{ name: 'm:r', elements: [{ name: 'm:t', elements: [{ type: 'text', text: 'lim' }] }] }], + }, + { + name: 'm:lim', + elements: [ + { + name: 'm:f', + elements: [ + { + name: 'm:num', + elements: [{ name: 'm:r', elements: [{ name: 'm:t', elements: [{ type: 'text', text: 'x' }] }] }], + }, + { + name: 'm:den', + elements: [{ name: 'm:r', elements: [{ name: 'm:t', elements: [{ type: 'text', text: 'y' }] }] }], + }, + ], + }, + ], + }, + ], + }, + ], + }; + const result = convertOmmlToMathml(omml, doc); + expect(result).not.toBeNull(); + const munder = result!.querySelector('munder'); + expect(munder).not.toBeNull(); + // The limit side must contain the recursively converted . + const mfrac = munder!.querySelector('mfrac'); + expect(mfrac).not.toBeNull(); + expect(mfrac!.children.length).toBe(2); + expect(mfrac!.children[0]!.textContent).toBe('x'); + expect(mfrac!.children[1]!.textContent).toBe('y'); + }); + + it('converts m:limLow nested inside m:func > m:fName (real Word output)', () => { + // Word wraps "lim_(n→∞)" as m:func > m:fName > m:limLow when the + // equation is recognized as a function operator. + const omml = { + name: 'm:oMath', + elements: [ + { + name: 'm:func', + elements: [ + { + name: 'm:fName', + elements: [ + { + name: 'm:limLow', + elements: [ + { + name: 'm:e', + elements: [ + { + name: 'm:r', + elements: [ + { name: 'm:rPr', elements: [{ name: 'm:sty', attributes: { 'm:val': 'p' } }] }, + { name: 'm:t', elements: [{ type: 'text', text: 'lim' }] }, + ], + }, + ], + }, + { + name: 'm:lim', + elements: [{ name: 'm:r', elements: [{ name: 'm:t', elements: [{ type: 'text', text: 'n' }] }] }], + }, + ], + }, + ], + }, + { name: 'm:e', elements: [] }, + ], + }, + ], + }; + const result = convertOmmlToMathml(omml, doc); + expect(result).not.toBeNull(); + const munder = result!.querySelector('munder'); + expect(munder).not.toBeNull(); + expect(munder!.children.length).toBe(2); + expect(munder!.children[0]!.textContent).toBe('lim'); + expect(munder!.children[1]!.textContent).toBe('n'); + }); }); describe('m:limUpp converter', () => { @@ -1918,7 +2149,10 @@ describe('m:limUpp converter', () => { expect(result).not.toBeNull(); const mover = result!.querySelector('mover'); expect(mover).not.toBeNull(); + // is arity-2: preserve an empty on the missing side. + expect(mover!.children.length).toBe(2); expect(mover!.children[0]!.textContent).toBe('sup'); + expect(mover!.children[1]!.textContent).toBe(''); }); it('handles missing m:e gracefully', () => { @@ -1941,6 +2175,131 @@ describe('m:limUpp converter', () => { expect(result).not.toBeNull(); const mover = result!.querySelector('mover'); expect(mover).not.toBeNull(); + expect(mover!.children.length).toBe(2); + expect(mover!.children[0]!.textContent).toBe(''); expect(mover!.children[1]!.textContent).toBe('n'); }); + + it('wraps multi-run base (m:e) in ', () => { + const omml = { + name: 'm:oMath', + elements: [ + { + name: 'm:limUpp', + elements: [ + { + name: 'm:e', + elements: [ + { name: 'm:r', elements: [{ name: 'm:t', elements: [{ type: 'text', text: 'a' }] }] }, + { name: 'm:r', elements: [{ name: 'm:t', elements: [{ type: 'text', text: '+' }] }] }, + { name: 'm:r', elements: [{ name: 'm:t', elements: [{ type: 'text', text: 'b' }] }] }, + ], + }, + { + name: 'm:lim', + elements: [{ name: 'm:r', elements: [{ name: 'm:t', elements: [{ type: 'text', text: 'def' }] }] }], + }, + ], + }, + ], + }; + const result = convertOmmlToMathml(omml, doc); + expect(result).not.toBeNull(); + const mover = result!.querySelector('mover'); + expect(mover).not.toBeNull(); + expect(mover!.children.length).toBe(2); + expect(mover!.children[0]!.textContent).toBe('a+b'); + expect(mover!.children[1]!.textContent).toBe('def'); + }); + + it('preserves nested math object inside m:lim (fraction)', () => { + const omml = { + name: 'm:oMath', + elements: [ + { + name: 'm:limUpp', + elements: [ + { + name: 'm:e', + elements: [{ name: 'm:r', elements: [{ name: 'm:t', elements: [{ type: 'text', text: '=' }] }] }], + }, + { + name: 'm:lim', + elements: [ + { + name: 'm:f', + elements: [ + { + name: 'm:num', + elements: [{ name: 'm:r', elements: [{ name: 'm:t', elements: [{ type: 'text', text: 'p' }] }] }], + }, + { + name: 'm:den', + elements: [{ name: 'm:r', elements: [{ name: 'm:t', elements: [{ type: 'text', text: 'q' }] }] }], + }, + ], + }, + ], + }, + ], + }, + ], + }; + const result = convertOmmlToMathml(omml, doc); + expect(result).not.toBeNull(); + const mover = result!.querySelector('mover'); + expect(mover).not.toBeNull(); + const mfrac = mover!.querySelector('mfrac'); + expect(mfrac).not.toBeNull(); + expect(mfrac!.children[0]!.textContent).toBe('p'); + expect(mfrac!.children[1]!.textContent).toBe('q'); + }); + + it('converts m:limUpp nested inside m:func > m:fName (real Word output)', () => { + // Word emits this shape when typing "lim┴x". + const omml = { + name: 'm:oMath', + elements: [ + { + name: 'm:func', + elements: [ + { + name: 'm:fName', + elements: [ + { + name: 'm:limUpp', + elements: [ + { + name: 'm:e', + elements: [ + { + name: 'm:r', + elements: [ + { name: 'm:rPr', elements: [{ name: 'm:sty', attributes: { 'm:val': 'p' } }] }, + { name: 'm:t', elements: [{ type: 'text', text: 'lim' }] }, + ], + }, + ], + }, + { + name: 'm:lim', + elements: [{ name: 'm:r', elements: [{ name: 'm:t', elements: [{ type: 'text', text: 'x' }] }] }], + }, + ], + }, + ], + }, + { name: 'm:e', elements: [] }, + ], + }, + ], + }; + const result = convertOmmlToMathml(omml, doc); + expect(result).not.toBeNull(); + const mover = result!.querySelector('mover'); + expect(mover).not.toBeNull(); + expect(mover!.children.length).toBe(2); + expect(mover!.children[0]!.textContent).toBe('lim'); + expect(mover!.children[1]!.textContent).toBe('x'); + }); }); From af1441b6b6a6b833545bad1fc00e0ccc2f915670 Mon Sep 17 00:00:00 2001 From: Caio Pizzol Date: Sun, 12 Apr 2026 14:17:24 -0700 Subject: [PATCH 4/4] test(math): add behavior fixture and tests for limLow/limUpp (SD-2377/2378) New fixture math-limit-tests.docx contains 8 Word-native limit shapes: m:limLow in m:func, bare m:limUpp, m:limLow with fraction in m:lim, bare m:limLow, m:limUpp in m:func, multi-char non-lim function bases (max, sup), and m:limLow with a nested m:sSub in the limit expression. Seven new it blocks assert: - arity of (6) and (2) - nested inside (case 3) - nested inside (case 8) - mathvariant=normal on function keywords via m:sty val=p - bare identifiers keep italic default (no stray mathvariant) - property elements (m:limLowPr/m:limUppPr/m:ctrlPr) don't leak to DOM The same fixture is uploaded to the shared R2 corpus as sd-2377-limlow-limupp-showcase-all-cases.docx so test:layout and test:visual cover these shapes automatically. --- .../importing/fixtures/math-limit-tests.docx | Bin 0 -> 14494 bytes .../tests/importing/math-equations.spec.ts | 175 ++++++++++++++++++ 2 files changed, 175 insertions(+) create mode 100644 tests/behavior/tests/importing/fixtures/math-limit-tests.docx diff --git a/tests/behavior/tests/importing/fixtures/math-limit-tests.docx b/tests/behavior/tests/importing/fixtures/math-limit-tests.docx new file mode 100644 index 0000000000000000000000000000000000000000..5e9d26ff24e6d2e70255d65ada75170d1c9184e7 GIT binary patch literal 14494 zcmeHuWmH_*wr=6>5Zv9}r2@g-gS)%CBv^2FcY+6ZcZc8<7A(OfxIVh?>D%2o{oWhn z{=KKhs5NTuS>Ly7@0x4Px#s$oq6`EiCIA`$3jhE}0UL#1ZM47u01_ww00RIEuKmv5 z&c)QuMPJp^!PHro$-~x$Bp(u-CKmwy`u+dB{ue)i`ouB2UKUibo1}Y`xF$8DANgf; z;1NQ}jLQ2kINspuPw_+T&z%_HN@`$n@HS+W>^JM|>VttZE9o|HXbtX^huDJ29~1N} zx#^b|cNqOmaH+l}vaAb_aDA}hXz#$4D!!cQQp4BiDA5lOk`=ofmPS)!=GK0lUuBPW#YUHhDztF%GM}f5qh;4%iQE1~f@c{ha<%6(ffz{&YW9=A)`Kw{6 zc+CRh)@yKa77%%!*He2-E1BVila7~QmId3u!S!mJ%1O&j0PFs&zix))g83B8m2K`@ z!r?v8gY7K@0Pykx0Z{x~mn4eAYdU@PGx^sZhw$1Z^_@&@oSB(^YyZ;~{}=bazubC7 ze2-Nh3*y_;z^A~O4yBbIoC0}f<7p7iG7PM?lq?2l$!hWWNnmLSTzB74V(jZ|!nBuj zrnu{7qV5@9vKmr&2i(G)UZ3_uy9XdCxV?zgW5HnyF=OxA>}ix-igq+GLK7=|3J*H& zHZ*mj8)KJF^m?}h!IF%2YR;IVt{~e{ZSf}6k3Bbyb$-E0QrjD*Oi-*dgs^oI*ALzd zSd+re7#q{OUaQ-$dXUh}7G*_y#eiqS!c3VNXH)@?ch)|0;8oC7G7uKb0G9_B#{A%8 zrh~5a{5^aR7sz!=s#&l9%^p8fSNP{^VEXs|!QNv)umKGKtOx=CXs-{&-QLNB*~H%1 z)%G=R{T8r}v`3skTsWQX#i!kd%#%HPWZ_y=PT~t&X2mBSTRUJR=D8zn`_`(OrZI+w#O{r`#L5%Y}vuaSEkR3Z5 z=9GcHM>z{>!H$@%!_=g9rS7Svr<4BA`^Sa*=+wv#!&o0+HJ#CU8C}Oc=jJ%}A{{?0 zX>Rf+vas!#Bg1j95qKdlPzAN7m*TfFh$Zr*vf@++_G7FL6iRE_(c7AvneJB2fDX(O(( zDQ;;e?$>xes4;kXG6Iq9*lB=cb0In7+e>GHEO3XQX6`eem<_OEJ9Cy{1fYF4tk(o; zKzJLMh#W#_#8Y@iUTiYn?3c7_VAd7(T!z%kCCA~%C#Sw%2P7ngmMM>gl;k+wuN@hD zZ)iEAjEHw6MYyi>)7)XX!~zE$p`9L50d^3;HhXkjO1Wdw#Ra)!QW%DJK;5=(g;Zd) z#Scn#mAB(wiFqtQvq1^~(j?5fBnQ+tJjyHuXagp&C1fUC2DV7A@I2lYR|JCULQPYi zkuq2UX6y>3uP{vUw1DPwEmSkgZBqGsZ?K4#Rl;hRl*%}&d$Q=k(1}PYVS;2ANZfq2 zx3ZYy)RW^maI)>$(`D@4ZMk1UW-R#Mbre!WJ8{_a*Wa{^WryxqB2~<7M#zWSO6-|M zlAY~*ob>_1!-+u<<9xiMGx!*;1Lg=R_J&%sUqzGolA00GSINOxtXcGmAif*S@wA^e z0JI4fkcz zZka4oP^n%(1xVDqZcQ0=2Sg?tmJBwAiBMiBQS)YdE4@GNK9#Q zuXl!@Mx&+th$#j?6iII*ON9(>n5pBh=*Mc!xgGUk3q6+V%NR!0^>~Qxss6aak{u2< zVclX*&e>|ckj^Yt(wH)hY+7-N5FBqH5f1CR8PPqgG?)yY$CuWQdM2!(qgoo&JPo-w zq%eG9nC!ClHC}d^w52BPsG{tVeWC`6rKlc?iXy=n7WiJI6kKwq@;s=PRV3Zv0Y6f+ z20jbVew))5gy<(gk*y320K1AMBMG9Z4QrSy$KQ=VtN9*!;K*-6f8wTbE+mJziDjOH zF&$nJ(p3P65MeuPckw8UXs0mMjOT`y8$2~=DZ~EnlPtg$TQ z&PI=H98&ywO!TT;4mA6oGMfPtsur|*Oy(;)1tq=s*|z>d(!f*M~s%5nasr* zI{8)_=a|TWulj>Ux$lgf3&S^41fkZib@zmzM&z#&_V7P*-MOa{5kwt{eg;_&`S%aQ zc*MkDVWVJi03=?%m@9bg6^Txuj4Mrgm_|F<>d~9(Ot#cFHe4a`3K-Jj-+d(vg>bQw zC;9m-b)an5HdS<|sZQSDBN<12jTuiMQOHG2O5wkxwmD+*xR*NLUQr`ttX>CkwI;k! zM;Y;n(US&6o9O_#LsptH^5&a)afBXK(YGxJ6q}Pb%aV8^;t%ih2n#<=M|nOd(%A`SfM7%ELM-TZz|cVLMnpm023;ht>yP==76#T zeno|bZ+gz@%MTN5L6C~NcIo?8H&EG>cIC`rI`-8hy(dk@C?oKkqq%*TdUkhTB_?RY zSnH8csE=rN%Y2Dp^&`z5#uS9~i83LO${mG9c7LZzxuH^i3cb1t-cZE;vaKO{-kZ>{ zS4gGmL%lnlP=w~>L1F?xE=HUBL0%k%PW*#7fpPx<&iQIzN$Vv}(L=Sqe(D_f$BcJT z8AEb$gadK~#_3lf^I0(&N$I9)_k`yS{q&(34JBJt5e3Gp0)rC)#*k4wgA<~d-~6`r z4p4ZXpY6NAW_h5;i3JQ+I$D=!+am1ITE&}mOS$zVx+|uHgcI9h7>HOtd7)v05~epD zcl!r;lxf_I?uLqtF9pP>0#XEavZUNFP-=fB%e1{iNvc~O;;$^axsHdFfxPO~X%}OP z7pKeb_)e_fdCdJX9dkE?lI@?G<<@tg+Mn!3`trG`t8Wxoi_3C!X04T+PQlW8hk-}U zIL{&{lj0QY)PQVCJ8t0;3`s_fHm5zpIENqQ>BmcwnLyHYeg0*dZ*X{k#cm=o<(-Jf zHz!PI&ot!ksOL|%$#7xbCJi!~GIYbfD`os^O#CB#1bdyyy++ml-LH!HvDftJbtV)19GL3M z$nUEtvSTAn3R-vqh!FulGr&z;e(S21lscR?XgzbPN_C$4zy+G>33rd2^DfC9c0&CT zS?#?@=TS;jeIkr%{oeYJkPau#KwD4GCVSdSUS{szjex_7HqRDx^S%m&oyb z9%&6iZ4;;j78%jl?_%mQ;nTzs#%v{u3}xK~1Et-D+2vijwb`>w&yB=`r(aT?n>D}Y zok!DmG-1T#{GcIK5c0Zvo!I|-4mGXj7S8|!03eY809dci@VnQzSeV+HGXLJP{+>r^ zX~*DmV){vJ0X2SRo0&OPr|0X?9^198ti3t^VUmf0J0&O!u`c)pq|o+O>T6WAT;Si>&Rb?*zm-vQFDk5vd>8`+16`HqNVB zkl&7ELi4~qOg_z8RvrSF4A9?-V9dEMg!0?3`aCt@pS6Z6Mt(?CxOH7}CnNoOc*U19 zW;k3ugGuRRC#;DWd{p~|h{c2HYOwI6&905;T_z97#~M{{`yiSFefs)aS{TDpcT?b+;bJ&;DzD+M3BuRe8zd|b?}SYGerrVfk- zvYz2#qGdAN%pA)%fhK35OoT@m>Dw3W+OiyxH}VgPevIy5C}CN%`baLTg4a9eb;F?^ zVlr;(Y(bai5=62p!0GUl1jFq*EnNvJK>wS0Ipggs{lVW< zq+SB)dGT{9hWP3Bc19rvc)NLCaM}%qOl}z029@xI;bv%&_~mZ*Ve^K#^L{%);n+iO z5cqW6SJL%(?|u+0d{}py^L+c1Fa^9n&QM@D0}HHUd&C?H4Q%^CKrpLeH(d)zulb(9 z6Gg%#gBTy%2T6yE;6+9T;p>hBSHBrDT)z!LH5-g{yLXT$d`n?O|B zw8Fc!V1Z8CFe&JIV7V-kKjdt@@!Dc&EJ2NKn+T*DO=dDJK=yT)MHCMG?ZgbF$eS8> z7Oo?&(C?8>t*D!Xh#{!ja!%pq;vBZYcHcDaO>N0(o@|%)&cJY#jZ}c*=2XhrNRl>m zXsqM6R61}t+PHnGiE%KBqFnLR)?D0(lfjoRp|DU107tuomZ1gcHGaiC#&QtK2p?CR z>}C;?CxDibe8`12+#pkncbkIFcw1g%LlyIAo%!&|ZSA zd{u-Cv>IYoQRWxWT#=;eXza+aCqA5=8>@cCt8+?`%_(a%T*k4Xn@-A}#_EY#wWxC@iw+E%68p;Hq^r}8yta*}z;n2Tp)8jO{| zl~pi>4y}2Mh)OVK!2Ix$g3?LQ(?Uwzqvq#UvBv7BSvV2UUMzoNuCPw2(t z0C`K|^!<~nalg(q&Ci*ay^AMx!E>Ut*6OPy2P^f|u_^9jDH^sIT|IPTYi%P{O;T=Z zi~SKBu#{Gy0Yt0tn&1_;xk9%vEMdzQGLCYlu(3{Lw*vJw)Qn!`o9oWS;<`_47elo= zx^Xr^6tyL^B@(jEIWq=X+;CI!QjH~YcqAjNS^xr_;Y3Sz`R4)0xc=xmsJUG=SglGc z_&tx{F=QLkOsLxpcx-epQ%!H|-d2VtT2$ota~ zgBiK-PE>;dhYim~TcmXIF3z%+wS~e38;&)tCQgev=t}jzjCuigfy<4AOHoYk%JGjX zIL5V;{tvlBH-0au_CjBDGG?3QuS&vN-z;?bpFJLI042*MwVwN}?;-qE@uNE2D)(bR z4R$50pQ9eHmxzaD*Rzq=ox9VZz5C8R_-yYQt|hn9Pjc1p5%CMO+W2wrRqVeKw4rD6 zZ;?&)v@mihdH!U64W9oVUgzUknI#|rfKtT2lqj4{U0f{f%$JpuREef-Nz1g;wUjY7-#-)6F zSmHZcKQrtguP0xO@rn~-_eJWbt)Cu)FhnbqTzD@!EE15-LJ?#{_dG{^>BNqfHV&Rd zS-^si4PwwqlEieih73!dkt1lvDh9H9F+-UDe>M z98$qHFBQFcB#tZPaOxU*3r0TvssLn0&O^Uy9vlLTBzQMcy zMsb9~@ImUMl^Ky@BUdeW5@SFrwIjlJN@W^u(MtNnzNEIeUYe11qGW$5X9z}xo+uke z9f%|K!{W4n;I{;B=M}<; z(tF_k6%BdaKUQ{_Mb8QVp;HYEIuTd}L5V(jLV9tmU4;Oq=ccU}(mMtSuvVnjXr6bY51ZTD^z~QkZ>s z4UVyMia22A9cpmodZjQ*#rP?ZKh0cRV6sWYn{WsO%N9_iDJYP>oSL7LW>P!iJLA(T z92Y)o-z72L5ZN=>p-y3nbtGl&LX^jq4q4L)WTRO~?%-%HrojZ}f^kShGLoJXIY@2O z7Po4}_Es*#m&y%+tUf0w{}3cb(P?BxyDEuyozLYDE-Q}gfZr)?DC}KTIoWNUTv(TW ziy#BG^Uc0t!tIbpu^uRQPrNwgw~VNz!(~7L_=B_aMfNZUcAs4KMb#?rV_hM zm41c0mPl1g1-;JV1J;^igVCgM^rg9H6=C#PT)0iwrFLm7`Z7e1>&PT5g)+-#$FxA6 z>$Zup7TW-xx3&(vZ*NTlCI$o!!re z%Tz+jDhv=dE4)}DMvjQb9DAIMo+*rr={=wD@!SsxF+7CMb)0~nd}61qilb^CXy5#Sv9LA%h}4GJa(D$Hw*K{R__fmwzmbI&mVPA)*2R zuq6P%>s;}_l6q$sPaD(U$DAWwkP}E8-FKtx2_`YyjhG;eOl+i#hF15e!P-`Wb$8+? zDo%Ll+YbmIl9Q@8TEGZuSxLO6jW8FJ%NfuOa&X7@XH)HT_J(r07~?nTry_V{n317@ z1cZ;@@#I=+P1CyG`%o? zK2pR+5ILD}o1Ijy)6aS*NBYAdMPhO5_O8ik0R3+J9Xrc?HK)$lv3*&J%$vb76&rOY zw0Y=l^5sB^WZ}!(%ZmF8Ps$dPayIrT8o*Oih#+KSm;trOhNy%=P0ExItp4^)AtpZ) zvZ}VxcFIkYXXY;1s2)MPpDmaCEtwy-1e!u6aq&-{t*!~r`=ZSmuZL>;B=QU-!$#?W z@89+{Je8D1?K^{gXt%4DGT27;sV!rN^{cmBw?`klu$H=~7fgWPWwU((dbolPgy6R{ zv@jQDxQweBqjjI)=cgU?k)p2Wr$;*R@J`F~W_>ve3p-*$Fvp#_FiYZiVm@#5nE?)% z2tMJ>@F3UZITC1zZ1WTb3_8+)MQuB{a76dTyc)R0HWM{}T!@Sca zmHw@Per*WPo*dkV+<~iW-&RrF-7l|SPgR^ZdzE3eW3$POtkMaW= zFR9r|fXs8=LlnIH{9fj$AeCwiZiPSH$E-FzIVxXGGE0=-a70?c;-Ne$=}FLr@k5iN zUhGINZgO`ZaAd!tFoEo%(tEkf*t+7mb|qT6aE(0{vLg{&$FldVt%Pz)OzV0Yf8REl zVwpVV2P(B`wxxa@rGEv@KE)k(#BL^5&1e!ANo&qQc^h*;jLq`H2Rn>6C9jLjo(%?$ z=+hG<>ubJ`sH>(jX=bAz^T66iN7=Mv4b!DJfqvVw{$E|rlqq`lF=l4;(33u4F1gA& z#Lq}-iUv^>r#a#!WWK{%@4B1iF!mG;bjxt|1xk;Ep;Ft_%q1X$+lDpY(rlsh*hqeR z`m|e9fuIz25Dhs_hySL0|5V_J_&~@MOv>~}idyMGj8$2A#s22K0tVM>gNB*0bxHD+ zvT4A*c~5)fc|YQS_NPvghfU@ zqCQu^C|JUy8$RaV15Q``8(QKH=6cr!4;lHpk4mh0-JdNK4O9teSd_uUUkV0 zfgk!;M?;gRgKHS5`iXnpQ@4&9e^k0l124E>hZ5g4hmRbc3gHIJndqlTJ;5{_N9BSTIYZxRatv#t+O2=Z&h2VTrn@|Hb;;+cjw2=DQg1_h(D`l9gj zomU;-xgfu_3zd%>wv2|CvP+z-89|!im24rMo`KIsbC`i&+0MtRzX?-E9iY~_N`-1B z-iorO5+#X3i7AtXCw3)cTAH&Zx=*)(3~RvK<6}>>C8H;F+OU zEm;sU3WW$+t#by6BAd#wSb8pc?0~DPl@i?b+s~B4Yib{x%WP)d*R0^PbuqwaZXSJ3 zRos@zUORxoer|8SkuudadL_Tw7EV5}pfR`HMRP5YOkSe9nQ>fW5aN@kc75acH`Jy9 zi!sz_2W^u^#BYG60pqbVir5}c(0$=HPt@Uq446m;zZPtXT@Y7OkdE3n9QXoyOS_<| zqZYK*uRp}!p6>_sU>t^supfn76yS{_$~1H-uEA=Qkp^JT0|s-fV0Yy>gI+Dr;tcbs zU@{H<4sPTgN=OEer6G#zs_$rkW#38>WjIB#099v=6u0<87qa0Qn;APUTm2xI`F`bHz^4_BZ@%+}z>OgH3CB%W`!DGjso z&ue}CP207U)W01kS?M&+Npmu3JE=sCC8vKLG?kH4eSk6Ne!bKGn|Zsoh^ovxJ2s2u z`IVR}lnA z(;jsCkxO56`9vE!toFPnojTl)%jWGPaTbRzJhHJJRgZ=ET3dK|1=$Sy81$~Y?=BK7 z^lcOLA%X3R8w-c&a=ZKk{ji8vy=G= zT%fV9`?d=KjzW{pM=nGd0T!9xn*)DVn3noCk&c?XG1u9x%iold^||lqy?fVqtmmbz zaD&IUHnw^qysg)F+TT$|>fXG_M7{ErPOT>4Y5P(hxh@#z}^w-J9`l6PMM6gdvnCDRcA_gb@sZ$!6h_%BgeYKy{^YQ&dn)Y z)a&t=v;=>4`n+#1N5L0e5spwT@1KZsM*VefyYf=WJKZkqHC}C3`iU=Ap*bq&myw^0 zzGzzLjh}ojNP|i~;D#*SO=|S*sew-jlu#i64HUF(6jkgsUTUg&Ya@JUB{_1bpUhU5 z$B=8dJrhcbzNYCYJh8ltvZSebNv9r>DE7}{Tm#D8%8rU15fYT%INBZO928MzUbnPD ze}m*%J&Jr@IidEd8PNa|Y*#dmig@=qlv6j{N4rQ6Bfk&ENe_GMaeLviDg||L@COQi zi3VzeRVu+$v6u<0;`Xiz@4uL;a1^!n?&n{ohAOL+9D1qWKjWULw1~1*L_{)$d=f>> zXr&SC^H&G!HY-;+M;%G7`q{FChgM@MoxuF5(ii(Iw&I6YMnn<@nYyg!*~1JfFG}nB z?v|l!e<1D56Xl$+{>WS9{0Mm_Y56c3R)~9%qVtl3TzViA`edjii+N915K}hq#)e!N z(_k}=r<`aQ?j3engm2RFB?4RHke0nIo{^2O9BCz-HO**h5Rw`{1QeU^fb5&0{^@9de2tfg&SV8s({SVDOZCE)CPfTafE+rfx#c zVfg$AV5=yy;I$k5?t$pocfL@q`=WM}`nUb(X|L7JVm%du4BU<+k%ZUcL+p?l+qS1$ z!>O4{*R5a$gXzhrTHD(UM(44axW~m{&I1k)LDeXiOSQ0p(v09N^yR~0&bEqnt6IHe zWABQ&cSwh8Z9X{21JiR;ipaR-^g0*!i9Q3_jsuM?Iuh=sC@bymRereZ*M@;jyWa?k zddL;2N7vSChCub>8XTV*Ck9mAS|n0X&AAuf?1u<>_FvE``(L>#UA2jL#A_x$pm;`P zJ(4`i3}O@saUNxxp5MbCus2bd8bsSHI%8dAxY5fPn;XyLy9&SeR{`$hKfcVd(VP{i zFCKgIEuoGe$M`z7+DFA5zISegc6f^DRH|*A`xG9pvQ~y$DFT_alg?;4h%8H1{=nQM zUVpFpcnSQ+awXOJy`=qXNrCaTWQqAl;lSP0NaZgT%gos|yIvAZz~%#`_ooo=B0f$T zV@}OCDQgTeje>yQQcCa=FdbI~;OF^y4`X)~C#QR^>V5YR?I`}O6f}dT>E8=4J4b0;+aV`bQR!I> zYu}cqq3E{dr7s^>;W^A<{d8hXx`g4ApT)Jr^^OP-Bjpt!->_uq94UFDiaOJ*tbS5B z-(UvG)S^m*b)@10Q&YtFojwkJ#vK*fM z&>@84So=1rOHlnjuD}nEkaY8E{J`W&wb$a8Z0~?H9*2C$))kGOP5DRL^4U`v*@PlamV|)~kupU#Z()2TwBo1iViiL-vDNPIs4OaFDDK;`b_`~w<><|4vo!m0ZVTD=fshHnny>@ zb(!974F;9*q{0FRDLD_9+q>BwpHx~)Il$dlL@3)n3Em`tCP& zR~+aX`cvV8`Jx{gGgu~%*uv2cg0aNAg7a%5(Y%U>Wfd)b{R(Me^5dz@gEeRO?DHm) zHCWJ-FILPuwQ{jW>M`;Mq>V9ge?v zIR)M+8iD0*_tI^f#v9vy=5Ohl77#Jqd5MDB9&51L8&AE+HAvpEeuB2%qoa>O%;@bI zmz|~wf`QND+5c4Z)yd?=^1XDsl44PP4t>A#qpdYdRU&nCH9H+>#SUe3tFr@K#?-=k zRxeUs5ydqY6mkKgjd&p;p3Fk}ZmjRW-$zUHROns1+qdaTEG-W~yqX%wZU!Hn@^ac- zotjJ4T=;V{16|_;#|JUczJLj_3&F8%X0ze@Gk+j(zz94E3VUgf|Hy$W(TzttT@dE< zRf=@r=3-#JH7V}scFTAl3e|E;$YUtC0f$M{}>w~$gdsFL^ zTEq2LoAHNxR9B_i*}5IEsUYc6zmfV#MvK~XAHaec za+-1kj9q^RhFyo+ZNj8C(Qm?}?Y7t;aU9fWSsvQu2L&myH-9oY1(bJsDOUo|fbtCl1Hh>8WxVIvr<4y=pEa%r-BjA$l2G zeVsZb4t{IkYk3K+my=sLrN33xzJ|<2q4C2uOXe0jgJD1VD(t1Ii zO$fL&>cS>X=vh)ju8gyD?(rj;jGZ+U_e>}6G<6@$m$I_l^!m>rU@!4l%HnmI5O&mi zOvP-rbPYR%VHF=FT@z&ejkmV%iO{iCkou*wrr=W6!*ix;Ez&yIc{)F}#;4>vzoR6BXz9h;qK%aLeO!u<{$cXL z{Od%%NcO0&9)DqpPJ}!h_z=#FWxK!co;LZqs zX!y)>2KS}vgZp7b_jeToU8pfF4^nc|A^WQ8k4P*6JS9n(amu^Q(q8yBxCu=K^MzS3 zTms-G{(%>>irxBS`u!?rF(nZFbKecQ>O zPyis|wQu}o6Uwi^U-kHZLhD{t`v0cR{}ujs0pp)w0HEjfR{vje#$TC!6}A1zb@RF$ z_Ky;`UnzcFxc`$v9s7?3wZA0AzrufAf%_9aj{gVzzt`h_1^=3b{|Sa-_!sz>ocvdY zU(>`t8HySI#qhVJ@mKucL-L { expect(data.brokenMrootCount).toBe(0); }); }); + +test.describe('m:limLow / m:limUpp (limit object) rendering', () => { + // Fixture (math-limit-tests.docx) contains 8 Word-native equations: + // 1. lim_(n→∞) — m:limLow inside m:func > m:fName + // 2. =^def — bare m:limUpp (at root of m:oMath) + // 3. lim_(x/y) — m:limLow with m:f (fraction) inside m:lim + // 4. a_b — bare m:limLow (non-function base) + // 5. lim^x — m:limUpp inside m:func > m:fName + // 6. max_(x∈S) — m:limLow with multi-char non-"lim" function base + // 7. sup_(n≥1) — m:limLow with another non-"lim" function base + // 8. lim_(x_i→0) — m:limLow with m:sSub (subscript) inside m:lim + + test('renders all 8 limit equations as elements', async ({ superdoc }) => { + await superdoc.loadDocument(LIMIT_DOC); + await superdoc.waitForStable(); + + const mathCount = await superdoc.page.evaluate(() => { + return document.querySelectorAll('math').length; + }); + expect(mathCount).toBe(8); + }); + + test('renders m:limLow cases as with arity 2', async ({ superdoc }) => { + await superdoc.loadDocument(LIMIT_DOC); + await superdoc.waitForStable(); + + // Cases 1, 3, 4, 6, 7, 8 are m:limLow — all produce with exactly 2 children. + const data = await superdoc.page.evaluate(() => { + const munders = Array.from(document.querySelectorAll('munder')); + return munders.map((el) => ({ + childCount: el.children.length, + baseText: el.children[0]?.textContent ?? null, + limitText: el.children[1]?.textContent ?? null, + })); + }); + + expect(data.length).toBe(6); + for (const m of data) { + expect(m.childCount).toBe(2); + } + // Case 1 base is "lim" (upright function operator) + expect(data.some((m) => m.baseText === 'lim' && m.limitText === 'n→∞')).toBe(true); + // Case 4 bare: "a" over "b" + expect(data.some((m) => m.baseText === 'a' && m.limitText === 'b')).toBe(true); + // Case 6: "max" over "x∈S" + expect(data.some((m) => m.baseText === 'max' && m.limitText === 'x∈S')).toBe(true); + // Case 7: "sup" over "n≥1" + expect(data.some((m) => m.baseText === 'sup' && m.limitText === 'n≥1')).toBe(true); + }); + + test('renders m:limUpp cases as with arity 2', async ({ superdoc }) => { + await superdoc.loadDocument(LIMIT_DOC); + await superdoc.waitForStable(); + + const data = await superdoc.page.evaluate(() => { + const movers = Array.from(document.querySelectorAll('mover')); + return movers.map((el) => ({ + childCount: el.children.length, + baseText: el.children[0]?.textContent ?? null, + limitText: el.children[1]?.textContent ?? null, + })); + }); + + expect(data.length).toBe(2); + for (const m of data) { + expect(m.childCount).toBe(2); + } + // Case 2 bare limUpp: "=" above "def" + expect(data.some((m) => m.baseText === '=' && m.limitText === 'def')).toBe(true); + // Case 5 limUpp in func: "lim" above "x" + expect(data.some((m) => m.baseText === 'lim' && m.limitText === 'x')).toBe(true); + }); + + test('preserves nested inside (case 3: lim of x/y)', async ({ superdoc }) => { + await superdoc.loadDocument(LIMIT_DOC); + await superdoc.waitForStable(); + + // The limLow whose limit contains x/y must have a inside its second child. + const hasFracInMunder = await superdoc.page.evaluate(() => { + const munders = Array.from(document.querySelectorAll('munder')); + for (const mu of munders) { + const frac = mu.children[1]?.querySelector('mfrac'); + if ( + frac && + frac.children.length === 2 && + frac.children[0]?.textContent === 'x' && + frac.children[1]?.textContent === 'y' + ) { + return true; + } + } + return false; + }); + + expect(hasFracInMunder).toBe(true); + }); + + test('applies mathvariant=normal via m:sty val=p (ECMA-376 §22.1.2)', async ({ superdoc }) => { + await superdoc.loadDocument(LIMIT_DOC); + await superdoc.waitForStable(); + + // Every function-keyword base the fixture produces (lim/max/sup) originates + // from m:r with m:rPr > m:sty m:val="p", so convertMathRun must set + // mathvariant="normal" on those elements. + const counts = await superdoc.page.evaluate(() => { + const count = (text: string) => + Array.from(document.querySelectorAll('mi[mathvariant="normal"]')).filter((mi) => mi.textContent === text) + .length; + return { lim: count('lim'), max: count('max'), sup: count('sup') }; + }); + // "lim" appears in cases 1, 3, 5, 8 (4 total). + expect(counts.lim).toBe(4); + // "max" appears in case 6 (1). + expect(counts.max).toBe(1); + // "sup" appears in case 7 (1). + expect(counts.sup).toBe(1); + }); + + test('preserves nested inside (case 8: lim of x_i → 0)', async ({ superdoc }) => { + await superdoc.loadDocument(LIMIT_DOC); + await superdoc.waitForStable(); + + // The limLow whose limit contains x_i must have an inside its second child. + const hasSubInMunder = await superdoc.page.evaluate(() => { + const munders = Array.from(document.querySelectorAll('munder')); + return munders.some((mu) => { + const sub = mu.children[1]?.querySelector('msub'); + return sub !== null && sub !== undefined && sub.children.length === 2; + }); + }); + expect(hasSubInMunder).toBe(true); + }); + + test('bare m:limLow (case 4) leaves identifiers italic (no m:rPr styling)', async ({ superdoc }) => { + await superdoc.loadDocument(LIMIT_DOC); + await superdoc.waitForStable(); + + // Case 4 "a_b" is bare m:limLow with no m:rPr — identifiers keep the MathML default + // (single-char is italic) and therefore must NOT carry mathvariant="normal". + // The other bare case (case 2 "=^def") has no a, so finding an a + // without mathvariant is a sufficient signal for case 4. + const data = await superdoc.page.evaluate(() => { + const a = Array.from(document.querySelectorAll('mi')).find((el) => el.textContent === 'a'); + const b = Array.from(document.querySelectorAll('mi')).find((el) => el.textContent === 'b'); + return { + aHasVariant: a?.hasAttribute('mathvariant') ?? null, + bHasVariant: b?.hasAttribute('mathvariant') ?? null, + }; + }); + + expect(data.aHasVariant).toBe(false); + expect(data.bHasVariant).toBe(false); + }); + + test('m:limLowPr and m:limUppPr property elements are filtered out', async ({ superdoc }) => { + await superdoc.loadDocument(LIMIT_DOC); + await superdoc.waitForStable(); + + // Word emits m:limLowPr / m:limUppPr wrapping m:ctrlPr on every limit object. + // These must be stripped by the converter — they should never appear as DOM + // elements named "limlowpr" / "limupppr" / "ctrlpr". + const leaked = await superdoc.page.evaluate(() => { + const leaks: string[] = []; + for (const el of document.querySelectorAll('math *')) { + const name = el.localName.toLowerCase(); + if (name === 'limlowpr' || name === 'limupppr' || name === 'ctrlpr') { + leaks.push(name); + } + } + return leaks; + }); + expect(leaked).toEqual([]); + }); +});