Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions packages/layout-engine/painters/dom/src/renderer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4739,8 +4739,9 @@ export class DomPainter {
wrapper.className = 'sd-math';
wrapper.style.display = 'inline-block';
wrapper.style.verticalAlign = 'middle';
wrapper.style.width = `${run.width}px`;
wrapper.style.height = `${run.height}px`;
// Let browser auto-size to MathML content; estimated dimensions are for layout only
wrapper.style.minWidth = `${run.width}px`;
wrapper.style.minHeight = `${run.height}px`;
wrapper.dataset.layoutEpoch = String(this.layoutEpoch ?? 0);

const mathEl = convertOmmlToMathml(run.ommlJson, this.doc);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1545,6 +1545,34 @@ describe('computeDomCaretPageLocal', () => {
y: 20,
});
});

it('positions caret at right edge for non-text nodes when pos equals pmEnd', () => {
painterHost.innerHTML = `
<div class="superdoc-page" data-page-index="0">
<div class="superdoc-line">
<img data-pm-start="1" data-pm-end="2" />
</div>
</div>
`;

domPositionIndex.rebuild(painterHost);

const pageEl = painterHost.querySelector('.superdoc-page') as HTMLElement;
const imgEl = painterHost.querySelector('img') as HTMLElement;

pageEl.getBoundingClientRect = vi.fn(() => createRect(0, 0, 612, 792));
imgEl.getBoundingClientRect = vi.fn(() => createRect(10, 20, 100, 100));

const options = createCaretOptions();
const caret = computeDomCaretPageLocal(options, 2);

expect(caret).not.toBe(null);
expect(caret).toMatchObject({
pageIndex: 0,
x: 110, // elRect.right (10 + 100) - pageRect.left (0)
y: 20,
});
});
});

describe('index rebuild for disconnected elements', () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -580,9 +580,12 @@ export function computeDomCaretPageLocal(
const textNode = targetEl.firstChild;
if (!textNode || textNode.nodeType !== Node.TEXT_NODE) {
const elRect = targetEl.getBoundingClientRect();
// For non-text elements (images, math), position caret at the right edge
// when pos matches pmEnd (cursor after the element)
const atEnd = pos >= entry.pmEnd;
return {
pageIndex: Number(page.dataset.pageIndex ?? '0'),
x: (elRect.left - pageRect.left) / zoom,
x: ((atEnd ? elRect.right : elRect.left) - pageRect.left) / zoom,
y: (elRect.top - pageRect.top) / zoom,
};
}
Expand Down
Binary file not shown.
119 changes: 119 additions & 0 deletions tests/behavior/tests/importing/math-equations.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { test, expect } from '../../fixtures/superdoc.js';

const __dirname = path.dirname(fileURLToPath(import.meta.url));
const ALL_OBJECTS_DOC = path.resolve(__dirname, 'fixtures/math-all-objects.docx');
// Single-object test docs are used for focused verification by community contributors.
// The all-objects doc is used for behavior tests since it exercises the full pipeline.

test.use({ config: { toolbar: 'none', comments: 'off' } });

test.describe('math equation import and rendering', () => {
test('imports inline and block math nodes from docx', async ({ superdoc }) => {
await superdoc.loadDocument(ALL_OBJECTS_DOC);
await superdoc.waitForStable();

// Verify math nodes exist in the PM document
const mathNodeCount = await superdoc.page.evaluate(() => {
const view = (window as any).editor?.view;
if (!view) return 0;
let count = 0;
view.state.doc.descendants((node: any) => {
if (node.type.name === 'mathInline' || node.type.name === 'mathBlock') count++;
});
return count;
});

expect(mathNodeCount).toBeGreaterThan(0);
});

test('renders MathML elements in the DOM', async ({ superdoc }) => {
await superdoc.loadDocument(ALL_OBJECTS_DOC);
await superdoc.waitForStable();

// Verify <math> elements are rendered by the DomPainter
const mathElementCount = await superdoc.page.evaluate(() => {
return document.querySelectorAll('math').length;
});

expect(mathElementCount).toBeGreaterThan(0);
});

test('renders fraction as <mfrac> with numerator and denominator', async ({ superdoc }) => {
await superdoc.loadDocument(ALL_OBJECTS_DOC);
await superdoc.waitForStable();

// The test doc has a display fraction (a/b) — should render as <mfrac>
const fractionData = await superdoc.page.evaluate(() => {
const mfrac = document.querySelector('mfrac');
if (!mfrac) return null;
return {
childCount: mfrac.children.length,
numerator: mfrac.children[0]?.textContent,
denominator: mfrac.children[1]?.textContent,
};
});

expect(fractionData).not.toBeNull();
expect(fractionData!.childCount).toBe(2);
expect(fractionData!.numerator).toBe('a');
expect(fractionData!.denominator).toBe('b');
});

test('math wrapper spans have PM position attributes', async ({ superdoc }) => {
await superdoc.loadDocument(ALL_OBJECTS_DOC);
await superdoc.waitForStable();

// Verify sd-math elements have data-pm-start and data-pm-end
const mathSpanData = await superdoc.page.evaluate(() => {
const spans = document.querySelectorAll('.sd-math');
return Array.from(spans).map((el) => ({
hasPmStart: el.hasAttribute('data-pm-start'),
hasPmEnd: el.hasAttribute('data-pm-end'),
hasLayoutEpoch: el.hasAttribute('data-layout-epoch'),
}));
});

expect(mathSpanData.length).toBeGreaterThan(0);
for (const span of mathSpanData) {
expect(span.hasPmStart).toBe(true);
expect(span.hasPmEnd).toBe(true);
expect(span.hasLayoutEpoch).toBe(true);
}
});

test('math text content is preserved for unimplemented objects', async ({ superdoc }) => {
await superdoc.loadDocument(ALL_OBJECTS_DOC);
await superdoc.waitForStable();

// Unimplemented math objects (e.g., superscript, radical) should still
// have their text content accessible in the PM document
const mathTexts = await superdoc.page.evaluate(() => {
const view = (window as any).editor?.view;
if (!view) return [];
const texts: string[] = [];
view.state.doc.descendants((node: any) => {
if (node.type.name === 'mathInline' && node.attrs?.textContent) {
texts.push(node.attrs.textContent);
}
});
return texts;
});

// Should have multiple inline math nodes with text content
expect(mathTexts.length).toBeGreaterThan(0);
// The first inline math should be E=mc2
expect(mathTexts).toContain('E=mc2');
});

test('document text labels render alongside math elements', async ({ superdoc }) => {
await superdoc.loadDocument(ALL_OBJECTS_DOC);
await superdoc.waitForStable();

// The labels (e.g., "1. Inline E=mc2:") should be visible
await superdoc.assertTextContains('Inline E=mc2');
await superdoc.assertTextContains('Display fraction');
await superdoc.assertTextContains('Superscript');
});
});
Loading