Skip to content

Commit 16e0024

Browse files
feat(math): implement m:func function converter with tests (SD-2384) (#2709)
Implement m:func (Function Apply) OMML-to-MathML converter with upright function names per ECMA-376. Includes unit tests for edge cases and behavior E2E tests with a Word-native fixture. Rebased on main to resolve conflicts with m:sSubSup (#2668). Co-authored-by: Caio Pizzol <caio@harbourshare.com>
1 parent 9f25ab8 commit 16e0024

6 files changed

Lines changed: 381 additions & 1 deletion

File tree

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
import type { MathObjectConverter } from '../types.js';
2+
3+
const MATHML_NS = 'http://www.w3.org/1998/Math/MathML';
4+
const FUNCTION_APPLY_OPERATOR = '\u2061';
5+
6+
function forceNormalMathVariant(root: ParentNode): void {
7+
root.querySelectorAll('mi').forEach((identifier) => {
8+
identifier.setAttribute('mathvariant', 'normal');
9+
});
10+
}
11+
12+
/**
13+
* Convert m:func (function apply) to MathML.
14+
*
15+
* OMML structure:
16+
* m:func → m:funcPr (optional), m:fName (function name), m:e (argument)
17+
*
18+
* MathML output:
19+
* <mrow> <mrow>name</mrow> <mo>&#x2061;</mo> <mrow>argument</mrow> </mrow>
20+
*
21+
* Function names are rendered upright (mathvariant="normal") instead of the
22+
* default italic identifier style used by MathML.
23+
*
24+
* @spec ECMA-376 §22.1.2.39
25+
*/
26+
export const convertFunction: MathObjectConverter = (node, doc, convertChildren) => {
27+
const elements = node.elements ?? [];
28+
const functionName = elements.find((element) => element.name === 'm:fName');
29+
const argument = elements.find((element) => element.name === 'm:e');
30+
31+
const wrapper = doc.createElementNS(MATHML_NS, 'mrow');
32+
33+
const functionNameRow = doc.createElementNS(MATHML_NS, 'mrow');
34+
functionNameRow.appendChild(convertChildren(functionName?.elements ?? []));
35+
forceNormalMathVariant(functionNameRow);
36+
37+
if (functionNameRow.childNodes.length > 0) {
38+
wrapper.appendChild(functionNameRow);
39+
}
40+
41+
const argumentRow = doc.createElementNS(MATHML_NS, 'mrow');
42+
argumentRow.appendChild(convertChildren(argument?.elements ?? []));
43+
44+
if (functionNameRow.childNodes.length > 0 && argumentRow.childNodes.length > 0) {
45+
const applyOperator = doc.createElementNS(MATHML_NS, 'mo');
46+
applyOperator.textContent = FUNCTION_APPLY_OPERATOR;
47+
wrapper.appendChild(applyOperator);
48+
}
49+
50+
if (argumentRow.childNodes.length > 0) {
51+
wrapper.appendChild(argumentRow);
52+
}
53+
54+
return wrapper.childNodes.length > 0 ? wrapper : null;
55+
};

packages/layout-engine/painters/dom/src/features/math/converters/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
export { convertMathRun } from './math-run.js';
1010
export { convertFraction } from './fraction.js';
1111
export { convertBar } from './bar.js';
12+
export { convertFunction } from './function.js';
1213
export { convertSubscript } from './subscript.js';
1314
export { convertSuperscript } from './superscript.js';
1415
export { convertSubSuperscript } from './sub-superscript.js';

packages/layout-engine/painters/dom/src/features/math/omml-to-mathml.test.ts

Lines changed: 243 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -684,3 +684,246 @@ describe('m:sSubSup converter', () => {
684684
expect(msubsup!.children[0]!.textContent).toBe('x');
685685
});
686686
});
687+
688+
describe('m:func converter', () => {
689+
it('converts m:func to function name + apply operator + argument', () => {
690+
const omml = {
691+
name: 'm:oMath',
692+
elements: [
693+
{
694+
name: 'm:func',
695+
elements: [
696+
{
697+
name: 'm:fName',
698+
elements: [{ name: 'm:r', elements: [{ name: 'm:t', elements: [{ type: 'text', text: 'sin' }] }] }],
699+
},
700+
{
701+
name: 'm:e',
702+
elements: [{ name: 'm:r', elements: [{ name: 'm:t', elements: [{ type: 'text', text: 'x' }] }] }],
703+
},
704+
],
705+
},
706+
],
707+
};
708+
709+
const result = convertOmmlToMathml(omml, doc);
710+
expect(result).not.toBeNull();
711+
expect(result!.textContent).toBe(`sin${'\u2061'}x`);
712+
713+
const mrow = result!.querySelector('mrow');
714+
expect(mrow).not.toBeNull();
715+
716+
const functionIdentifier = mrow!.querySelector('mi');
717+
expect(functionIdentifier).not.toBeNull();
718+
expect(functionIdentifier!.textContent).toBe('sin');
719+
expect(functionIdentifier!.getAttribute('mathvariant')).toBe('normal');
720+
721+
const applyOperator = mrow!.querySelector('mo');
722+
expect(applyOperator).not.toBeNull();
723+
expect(applyOperator!.textContent).toBe('\u2061');
724+
});
725+
726+
it('ignores m:funcPr properties element', () => {
727+
const omml = {
728+
name: 'm:oMath',
729+
elements: [
730+
{
731+
name: 'm:func',
732+
elements: [
733+
{ name: 'm:funcPr', elements: [{ name: 'm:ctrlPr' }] },
734+
{
735+
name: 'm:fName',
736+
elements: [{ name: 'm:r', elements: [{ name: 'm:t', elements: [{ type: 'text', text: 'log' }] }] }],
737+
},
738+
{
739+
name: 'm:e',
740+
elements: [{ name: 'm:r', elements: [{ name: 'm:t', elements: [{ type: 'text', text: '10' }] }] }],
741+
},
742+
],
743+
},
744+
],
745+
};
746+
747+
const result = convertOmmlToMathml(omml, doc);
748+
expect(result).not.toBeNull();
749+
expect(result!.textContent).toBe(`log${'\u2061'}10`);
750+
});
751+
752+
it('renders single-character function names upright', () => {
753+
const omml = {
754+
name: 'm:oMath',
755+
elements: [
756+
{
757+
name: 'm:func',
758+
elements: [
759+
{
760+
name: 'm:fName',
761+
elements: [{ name: 'm:r', elements: [{ name: 'm:t', elements: [{ type: 'text', text: 'f' }] }] }],
762+
},
763+
{
764+
name: 'm:e',
765+
elements: [{ name: 'm:r', elements: [{ name: 'm:t', elements: [{ type: 'text', text: 'x' }] }] }],
766+
},
767+
],
768+
},
769+
],
770+
};
771+
772+
const result = convertOmmlToMathml(omml, doc);
773+
const firstMi = result!.querySelector('mi');
774+
expect(firstMi).not.toBeNull();
775+
expect(firstMi!.textContent).toBe('f');
776+
expect(firstMi!.getAttribute('mathvariant')).toBe('normal');
777+
});
778+
779+
it('wraps multi-part arguments in <mrow>', () => {
780+
const omml = {
781+
name: 'm:oMath',
782+
elements: [
783+
{
784+
name: 'm:func',
785+
elements: [
786+
{
787+
name: 'm:fName',
788+
elements: [{ name: 'm:r', elements: [{ name: 'm:t', elements: [{ type: 'text', text: 'sin' }] }] }],
789+
},
790+
{
791+
name: 'm:e',
792+
elements: [
793+
{ name: 'm:r', elements: [{ name: 'm:t', elements: [{ type: 'text', text: 'x' }] }] },
794+
{ name: 'm:r', elements: [{ name: 'm:t', elements: [{ type: 'text', text: '+' }] }] },
795+
{ name: 'm:r', elements: [{ name: 'm:t', elements: [{ type: 'text', text: '1' }] }] },
796+
],
797+
},
798+
],
799+
},
800+
],
801+
};
802+
803+
const result = convertOmmlToMathml(omml, doc);
804+
expect(result).not.toBeNull();
805+
806+
const outerRow = result!.querySelector('math > mrow');
807+
expect(outerRow).not.toBeNull();
808+
expect(outerRow!.children.length).toBe(3);
809+
expect(outerRow!.children[0]!.textContent).toBe('sin');
810+
expect(outerRow!.children[1]!.textContent).toBe('\u2061');
811+
expect(outerRow!.children[2]!.textContent).toBe('x+1');
812+
});
813+
814+
it('renders only the argument when m:fName is missing', () => {
815+
const omml = {
816+
name: 'm:oMath',
817+
elements: [
818+
{
819+
name: 'm:func',
820+
elements: [
821+
{
822+
name: 'm:e',
823+
elements: [{ name: 'm:r', elements: [{ name: 'm:t', elements: [{ type: 'text', text: 'x' }] }] }],
824+
},
825+
],
826+
},
827+
],
828+
};
829+
830+
const result = convertOmmlToMathml(omml, doc);
831+
expect(result).not.toBeNull();
832+
expect(result!.textContent).toBe('x');
833+
834+
// No apply operator when function name is missing
835+
const mo = result!.querySelector('mo');
836+
expect(mo).toBeNull();
837+
});
838+
839+
it('renders only the function name when m:e is missing', () => {
840+
const omml = {
841+
name: 'm:oMath',
842+
elements: [
843+
{
844+
name: 'm:func',
845+
elements: [
846+
{
847+
name: 'm:fName',
848+
elements: [{ name: 'm:r', elements: [{ name: 'm:t', elements: [{ type: 'text', text: 'sin' }] }] }],
849+
},
850+
],
851+
},
852+
],
853+
};
854+
855+
const result = convertOmmlToMathml(omml, doc);
856+
expect(result).not.toBeNull();
857+
expect(result!.textContent).toBe('sin');
858+
859+
// No apply operator when argument is missing
860+
const mo = result!.querySelector('mo');
861+
expect(mo).toBeNull();
862+
863+
// Function name should still be upright
864+
const mi = result!.querySelector('mi');
865+
expect(mi!.getAttribute('mathvariant')).toBe('normal');
866+
});
867+
868+
it('returns null for empty m:func', () => {
869+
const omml = {
870+
name: 'm:oMath',
871+
elements: [
872+
{
873+
name: 'm:func',
874+
elements: [],
875+
},
876+
],
877+
};
878+
879+
const result = convertOmmlToMathml(omml, doc);
880+
expect(result).toBeNull();
881+
});
882+
883+
it('handles nested m:func (sin of cos x)', () => {
884+
const omml = {
885+
name: 'm:oMath',
886+
elements: [
887+
{
888+
name: 'm:func',
889+
elements: [
890+
{
891+
name: 'm:fName',
892+
elements: [{ name: 'm:r', elements: [{ name: 'm:t', elements: [{ type: 'text', text: 'sin' }] }] }],
893+
},
894+
{
895+
name: 'm:e',
896+
elements: [
897+
{
898+
name: 'm:func',
899+
elements: [
900+
{
901+
name: 'm:fName',
902+
elements: [
903+
{ name: 'm:r', elements: [{ name: 'm:t', elements: [{ type: 'text', text: 'cos' }] }] },
904+
],
905+
},
906+
{
907+
name: 'm:e',
908+
elements: [{ name: 'm:r', elements: [{ name: 'm:t', elements: [{ type: 'text', text: 'x' }] }] }],
909+
},
910+
],
911+
},
912+
],
913+
},
914+
],
915+
},
916+
],
917+
};
918+
919+
const result = convertOmmlToMathml(omml, doc);
920+
expect(result).not.toBeNull();
921+
expect(result!.textContent).toBe(`sin${'\u2061'}cos${'\u2061'}x`);
922+
923+
// Both function names should be upright
924+
const mis = result!.querySelectorAll('mi[mathvariant="normal"]');
925+
expect(mis.length).toBe(2);
926+
expect(mis[0]!.textContent).toBe('sin');
927+
expect(mis[1]!.textContent).toBe('cos');
928+
});
929+
});

packages/layout-engine/painters/dom/src/features/math/omml-to-mathml.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import {
1414
convertMathRun,
1515
convertFraction,
1616
convertBar,
17+
convertFunction,
1718
convertSubscript,
1819
convertSuperscript,
1920
convertSubSuperscript,
@@ -39,6 +40,7 @@ const MATH_OBJECT_REGISTRY: Record<string, MathObjectConverter | null> = {
3940
'm:r': convertMathRun,
4041
'm:bar': convertBar, // Bar (overbar/underbar)
4142
'm:f': convertFraction, // Fraction (numerator/denominator)
43+
'm:func': convertFunction, // Function apply (sin, cos, log, etc.)
4244
'm:sSub': convertSubscript, // Subscript
4345
'm:sSup': convertSuperscript, // Superscript
4446
'm:sSubSup': convertSubSuperscript, // Sub-superscript (both)
@@ -49,7 +51,6 @@ const MATH_OBJECT_REGISTRY: Record<string, MathObjectConverter | null> = {
4951
'm:box': null, // Box (invisible grouping container)
5052
'm:d': null, // Delimiter (parentheses, brackets, braces)
5153
'm:eqArr': null, // Equation array (vertical array of equations)
52-
'm:func': null, // Function apply (sin, cos, log, etc.)
5354
'm:groupChr': null, // Group character (overbrace, underbrace)
5455
'm:limLow': null, // Lower limit (e.g., lim)
5556
'm:limUpp': null, // Upper limit
13.5 KB
Binary file not shown.

0 commit comments

Comments
 (0)