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
1 change: 1 addition & 0 deletions packages/layout-engine/painters/dom/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
"test": "vitest run"
},
"dependencies": {
"@superdoc/common": "workspace:*",
"@superdoc/contracts": "workspace:*",
"@superdoc/font-utils": "workspace:*",
"@superdoc/measuring-dom": "workspace:*",
Expand Down
220 changes: 213 additions & 7 deletions packages/layout-engine/painters/dom/src/index.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { describe, expect, it, beforeEach, afterEach } from 'vitest';
import { createDomPainter, sanitizeUrl, linkMetrics, applyRunDataAttributes } from './index.js';
import { resolveListMarkerGeometry } from '../../../../../shared/common/list-marker-utils.js';
import type {
FlowBlock,
Measure,
Expand Down Expand Up @@ -888,13 +889,11 @@ describe('DomPainter', () => {
painter.paint(listParaLayout, mount);

const firstLine = mount.querySelector('.superdoc-line') as HTMLElement;
// Word-spacing is calculated based on available width AFTER accounting for marker position + inline width.
// Fragment has indent: { left: 48, hanging: 24 }, so markerStartPos = 48 - 24 = 24
// fragment.markerTextWidth is 12
// Text starts at: markerStartPos (24) + markerTextWidth (12) + space (4px) = 40px
// availableWidth = 400 - 40 = 360
// slack = 360 - 180 = 180, wordSpacing = 180 / 5 = 36px
expect(firstLine.style.wordSpacing).toBe('36px');
// Inline list first lines without explicit segment positioning keep the measured width contract.
// The painter caps line.maxWidth by fragment width minus positive paragraph indents.
// availableWidth = 400 - leftIndent(48) = 352
// slack = 352 - 180 = 172, wordSpacing = 172 / 5 = 34.4px
expect(firstLine.style.wordSpacing).toBe('34.4px');

const suffix = firstLine.querySelector('.superdoc-marker-suffix-space') as HTMLElement;
expect(suffix).toBeTruthy();
Expand Down Expand Up @@ -2336,6 +2335,213 @@ describe('DomPainter', () => {
expect(textSpan?.style.left).toBe('48px');
});

it('positions first-line list text from the resolved tab stop instead of stale wordLayout.textStartPx', () => {
const block: FlowBlock = {
kind: 'paragraph',
id: 'list-tab-stop-block',
runs: [{ text: 'Closing.', fontFamily: 'Arial', fontSize: 16 }],
attrs: {
indent: { left: 48, hanging: 24 },
numberingProperties: { numId: 1, ilvl: 0 },
wordLayout: {
firstLineIndentMode: true,
indentLeftPx: 48,
textStartPx: 48,
tabsPx: [144],
marker: {
markerText: '2.1',
glyphWidthPx: 20,
markerBoxWidthPx: 20,
markerX: 0,
justification: 'left',
suffix: 'tab',
run: { fontFamily: 'Arial', fontSize: 16 },
},
},
},
};

const measure: ParagraphMeasure = {
kind: 'paragraph',
lines: [
{
fromRun: 0,
fromChar: 0,
toRun: 0,
toChar: 8,
width: 64,
ascent: 12,
descent: 4,
lineHeight: 20,
segments: [{ runIndex: 0, fromChar: 0, toChar: 8, width: 64, x: 0 }],
},
],
totalHeight: 20,
marker: {
markerWidth: 20,
markerTextWidth: 20,
indentLeft: 48,
},
};

const listLayout: Layout = {
pageSize: layout.pageSize,
pages: [
{
number: 1,
fragments: [
{
kind: 'para',
blockId: 'list-tab-stop-block',
fromLine: 0,
toLine: 1,
x: 0,
y: 0,
width: 240,
markerWidth: 20,
},
],
},
],
};

const painter = createDomPainter({ blocks: [block], measures: [measure] });
painter.paint(listLayout, mount);

const lineEl = mount.querySelector('.superdoc-line') as HTMLElement;
expect(lineEl).toBeTruthy();

const textSpan = Array.from(lineEl.querySelectorAll('span')).find((el) => el.textContent === 'Closing.') as
| HTMLElement
| undefined;
expect(textSpan).toBeTruthy();
expect(textSpan?.style.left).toBe('144px');
});

it('preserves measured justification width for inline list first lines without explicit segments', () => {
const block: FlowBlock = {
kind: 'paragraph',
id: 'inline-justify-list-block',
runs: [
{
text: 'Subject to the terms of this Agreement, Company will use',
fontFamily: 'Times New Roman',
fontSize: 13.333333333333332,
},
],
attrs: {
alignment: 'justify',
numberingProperties: { numId: 1, ilvl: 3 },
wordLayout: {
indentLeftPx: 0,
hangingPx: 18,
firstLinePx: 0,
tabsPx: [],
textStartPx: 0,
marker: {
markerText: '1.1',
glyphWidthPx: 16.6669921875,
markerBoxWidthPx: 24.6669921875,
justification: 'left',
suffix: 'tab',
run: {
fontFamily: 'Times New Roman',
fontSize: 13.333333333333332,
},
},
},
},
};

const measure: ParagraphMeasure = {
kind: 'paragraph',
lines: [
{
fromRun: 0,
fromChar: 0,
toRun: 0,
toChar: 57,
width: 309.5732421875,
ascent: 11.69921875,
descent: 2.876953125,
lineHeight: 15.33333333333333,
maxWidth: 325.7330078125,
segments: [{ runIndex: 0, fromChar: 0, toChar: 57, width: 312.90625 }],
spaceCount: 9,
},
{
fromRun: 0,
fromChar: 57,
toRun: 0,
toChar: 61,
width: 24,
ascent: 11.69921875,
descent: 2.876953125,
lineHeight: 15.33333333333333,
maxWidth: 350.4,
segments: [{ runIndex: 0, fromChar: 57, toChar: 61, width: 24 }],
spaceCount: 0,
},
],
totalHeight: 30.66666666666666,
marker: {
markerWidth: 24.6669921875,
markerTextWidth: 16.6669921875,
indentLeft: 0,
gutterWidth: 8,
},
};

const listLayout: Layout = {
pageSize: layout.pageSize,
pages: [
{
number: 1,
fragments: [
{
kind: 'para',
blockId: 'inline-justify-list-block',
fromLine: 0,
toLine: 2,
x: 0,
y: 0,
width: 350.4,
markerWidth: 24.6669921875,
markerTextWidth: 16.6669921875,
},
],
},
],
};

const painter = createDomPainter({ blocks: [block], measures: [measure] });
painter.paint(listLayout, mount);

const lineEl = mount.querySelector('.superdoc-line') as HTMLElement;
expect(lineEl).toBeTruthy();
const markerEl = mount.querySelector('.superdoc-paragraph-marker') as HTMLElement;
const tabEl = mount.querySelector('.superdoc-tab') as HTMLElement;

const expectedMarkerGeometry = resolveListMarkerGeometry(
block.attrs?.wordLayout as Parameters<typeof resolveListMarkerGeometry>[0],
0,
0,
0,
() => 16.6669921875,
);

const appliedWordSpacing = Number.parseFloat(lineEl.style.wordSpacing);
const expectedWordSpacing = (325.7330078125 - 309.5732421875) / 9;

expect(markerEl).toBeTruthy();
expect(tabEl).toBeTruthy();
expect(expectedMarkerGeometry).toBeTruthy();
expect(lineEl.style.paddingLeft).toBe(`${expectedMarkerGeometry!.markerStartPx}px`);
expect(Number.parseFloat(tabEl.style.width)).toBeCloseTo(expectedMarkerGeometry!.suffixWidthPx, 4);
expect(appliedWordSpacing).toBeGreaterThan(0);
expect(appliedWordSpacing).toBeCloseTo(expectedWordSpacing, 5);
});

it('reuses fragment DOM nodes when layout geometry changes', () => {
const painter = createDomPainter({ blocks: [block], measures: [measure] });
painter.paint(layout, mount);
Expand Down
Loading
Loading