Skip to content

Commit 36d562f

Browse files
authored
fix: match Word list marker geometry and section-carrier pagination (#2358)
* fix: match Word list marker geometry and section-carrier pagination * chore: fix types * chore: fix Word list marker spacing and painter import resolution * chore: fix build * chore: fix tests * chore: fix lock
1 parent 018469a commit 36d562f

13 files changed

Lines changed: 783 additions & 348 deletions

File tree

packages/layout-engine/painters/dom/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
"test": "vitest run"
1818
},
1919
"dependencies": {
20+
"@superdoc/common": "workspace:*",
2021
"@superdoc/contracts": "workspace:*",
2122
"@superdoc/font-utils": "workspace:*",
2223
"@superdoc/measuring-dom": "workspace:*",

packages/layout-engine/painters/dom/src/index.test.ts

Lines changed: 213 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { describe, expect, it, beforeEach, afterEach } from 'vitest';
22
import { createDomPainter, sanitizeUrl, linkMetrics, applyRunDataAttributes } from './index.js';
3+
import { resolveListMarkerGeometry } from '../../../../../shared/common/list-marker-utils.js';
34
import type {
45
FlowBlock,
56
Measure,
@@ -888,13 +889,11 @@ describe('DomPainter', () => {
888889
painter.paint(listParaLayout, mount);
889890

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

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

2338+
it('positions first-line list text from the resolved tab stop instead of stale wordLayout.textStartPx', () => {
2339+
const block: FlowBlock = {
2340+
kind: 'paragraph',
2341+
id: 'list-tab-stop-block',
2342+
runs: [{ text: 'Closing.', fontFamily: 'Arial', fontSize: 16 }],
2343+
attrs: {
2344+
indent: { left: 48, hanging: 24 },
2345+
numberingProperties: { numId: 1, ilvl: 0 },
2346+
wordLayout: {
2347+
firstLineIndentMode: true,
2348+
indentLeftPx: 48,
2349+
textStartPx: 48,
2350+
tabsPx: [144],
2351+
marker: {
2352+
markerText: '2.1',
2353+
glyphWidthPx: 20,
2354+
markerBoxWidthPx: 20,
2355+
markerX: 0,
2356+
justification: 'left',
2357+
suffix: 'tab',
2358+
run: { fontFamily: 'Arial', fontSize: 16 },
2359+
},
2360+
},
2361+
},
2362+
};
2363+
2364+
const measure: ParagraphMeasure = {
2365+
kind: 'paragraph',
2366+
lines: [
2367+
{
2368+
fromRun: 0,
2369+
fromChar: 0,
2370+
toRun: 0,
2371+
toChar: 8,
2372+
width: 64,
2373+
ascent: 12,
2374+
descent: 4,
2375+
lineHeight: 20,
2376+
segments: [{ runIndex: 0, fromChar: 0, toChar: 8, width: 64, x: 0 }],
2377+
},
2378+
],
2379+
totalHeight: 20,
2380+
marker: {
2381+
markerWidth: 20,
2382+
markerTextWidth: 20,
2383+
indentLeft: 48,
2384+
},
2385+
};
2386+
2387+
const listLayout: Layout = {
2388+
pageSize: layout.pageSize,
2389+
pages: [
2390+
{
2391+
number: 1,
2392+
fragments: [
2393+
{
2394+
kind: 'para',
2395+
blockId: 'list-tab-stop-block',
2396+
fromLine: 0,
2397+
toLine: 1,
2398+
x: 0,
2399+
y: 0,
2400+
width: 240,
2401+
markerWidth: 20,
2402+
},
2403+
],
2404+
},
2405+
],
2406+
};
2407+
2408+
const painter = createDomPainter({ blocks: [block], measures: [measure] });
2409+
painter.paint(listLayout, mount);
2410+
2411+
const lineEl = mount.querySelector('.superdoc-line') as HTMLElement;
2412+
expect(lineEl).toBeTruthy();
2413+
2414+
const textSpan = Array.from(lineEl.querySelectorAll('span')).find((el) => el.textContent === 'Closing.') as
2415+
| HTMLElement
2416+
| undefined;
2417+
expect(textSpan).toBeTruthy();
2418+
expect(textSpan?.style.left).toBe('144px');
2419+
});
2420+
2421+
it('preserves measured justification width for inline list first lines without explicit segments', () => {
2422+
const block: FlowBlock = {
2423+
kind: 'paragraph',
2424+
id: 'inline-justify-list-block',
2425+
runs: [
2426+
{
2427+
text: 'Subject to the terms of this Agreement, Company will use',
2428+
fontFamily: 'Times New Roman',
2429+
fontSize: 13.333333333333332,
2430+
},
2431+
],
2432+
attrs: {
2433+
alignment: 'justify',
2434+
numberingProperties: { numId: 1, ilvl: 3 },
2435+
wordLayout: {
2436+
indentLeftPx: 0,
2437+
hangingPx: 18,
2438+
firstLinePx: 0,
2439+
tabsPx: [],
2440+
textStartPx: 0,
2441+
marker: {
2442+
markerText: '1.1',
2443+
glyphWidthPx: 16.6669921875,
2444+
markerBoxWidthPx: 24.6669921875,
2445+
justification: 'left',
2446+
suffix: 'tab',
2447+
run: {
2448+
fontFamily: 'Times New Roman',
2449+
fontSize: 13.333333333333332,
2450+
},
2451+
},
2452+
},
2453+
},
2454+
};
2455+
2456+
const measure: ParagraphMeasure = {
2457+
kind: 'paragraph',
2458+
lines: [
2459+
{
2460+
fromRun: 0,
2461+
fromChar: 0,
2462+
toRun: 0,
2463+
toChar: 57,
2464+
width: 309.5732421875,
2465+
ascent: 11.69921875,
2466+
descent: 2.876953125,
2467+
lineHeight: 15.33333333333333,
2468+
maxWidth: 325.7330078125,
2469+
segments: [{ runIndex: 0, fromChar: 0, toChar: 57, width: 312.90625 }],
2470+
spaceCount: 9,
2471+
},
2472+
{
2473+
fromRun: 0,
2474+
fromChar: 57,
2475+
toRun: 0,
2476+
toChar: 61,
2477+
width: 24,
2478+
ascent: 11.69921875,
2479+
descent: 2.876953125,
2480+
lineHeight: 15.33333333333333,
2481+
maxWidth: 350.4,
2482+
segments: [{ runIndex: 0, fromChar: 57, toChar: 61, width: 24 }],
2483+
spaceCount: 0,
2484+
},
2485+
],
2486+
totalHeight: 30.66666666666666,
2487+
marker: {
2488+
markerWidth: 24.6669921875,
2489+
markerTextWidth: 16.6669921875,
2490+
indentLeft: 0,
2491+
gutterWidth: 8,
2492+
},
2493+
};
2494+
2495+
const listLayout: Layout = {
2496+
pageSize: layout.pageSize,
2497+
pages: [
2498+
{
2499+
number: 1,
2500+
fragments: [
2501+
{
2502+
kind: 'para',
2503+
blockId: 'inline-justify-list-block',
2504+
fromLine: 0,
2505+
toLine: 2,
2506+
x: 0,
2507+
y: 0,
2508+
width: 350.4,
2509+
markerWidth: 24.6669921875,
2510+
markerTextWidth: 16.6669921875,
2511+
},
2512+
],
2513+
},
2514+
],
2515+
};
2516+
2517+
const painter = createDomPainter({ blocks: [block], measures: [measure] });
2518+
painter.paint(listLayout, mount);
2519+
2520+
const lineEl = mount.querySelector('.superdoc-line') as HTMLElement;
2521+
expect(lineEl).toBeTruthy();
2522+
const markerEl = mount.querySelector('.superdoc-paragraph-marker') as HTMLElement;
2523+
const tabEl = mount.querySelector('.superdoc-tab') as HTMLElement;
2524+
2525+
const expectedMarkerGeometry = resolveListMarkerGeometry(
2526+
block.attrs?.wordLayout as Parameters<typeof resolveListMarkerGeometry>[0],
2527+
0,
2528+
0,
2529+
0,
2530+
() => 16.6669921875,
2531+
);
2532+
2533+
const appliedWordSpacing = Number.parseFloat(lineEl.style.wordSpacing);
2534+
const expectedWordSpacing = (325.7330078125 - 309.5732421875) / 9;
2535+
2536+
expect(markerEl).toBeTruthy();
2537+
expect(tabEl).toBeTruthy();
2538+
expect(expectedMarkerGeometry).toBeTruthy();
2539+
expect(lineEl.style.paddingLeft).toBe(`${expectedMarkerGeometry!.markerStartPx}px`);
2540+
expect(Number.parseFloat(tabEl.style.width)).toBeCloseTo(expectedMarkerGeometry!.suffixWidthPx, 4);
2541+
expect(appliedWordSpacing).toBeGreaterThan(0);
2542+
expect(appliedWordSpacing).toBeCloseTo(expectedWordSpacing, 5);
2543+
});
2544+
23392545
it('reuses fragment DOM nodes when layout geometry changes', () => {
23402546
const painter = createDomPainter({ blocks: [block], measures: [measure] });
23412547
painter.paint(layout, mount);

0 commit comments

Comments
 (0)