From ede111df2f05d3f72b30a5e3ab79a5d1e027e199 Mon Sep 17 00:00:00 2001 From: Jakub Date: Tue, 17 Feb 2026 13:42:14 +0100 Subject: [PATCH 1/2] fix(lists): support hidden list indicators via w:vanish (#2069) Propagate the vanish property from numbering level run properties through to the renderers so list markers with w:vanish are not displayed, while preserving list indentation. --- packages/layout-engine/contracts/src/index.ts | 1 + .../painters/dom/src/renderer.ts | 120 +++++++++--------- .../painters/dom/src/table/renderTableCell.ts | 5 +- .../pm-adapter/src/attributes/paragraph.ts | 1 + packages/word-layout/src/types.ts | 1 + 5 files changed, 69 insertions(+), 59 deletions(-) diff --git a/packages/layout-engine/contracts/src/index.ts b/packages/layout-engine/contracts/src/index.ts index 77bd061260..086e3eed5b 100644 --- a/packages/layout-engine/contracts/src/index.ts +++ b/packages/layout-engine/contracts/src/index.ts @@ -1084,6 +1084,7 @@ export type WordLayoutMarker = { italic?: boolean; color?: string; letterSpacing?: number; + vanish?: boolean; }; }; diff --git a/packages/layout-engine/painters/dom/src/renderer.ts b/packages/layout-engine/painters/dom/src/renderer.ts index d93953c990..0e08f0112c 100644 --- a/packages/layout-engine/painters/dom/src/renderer.ts +++ b/packages/layout-engine/painters/dom/src/renderer.ts @@ -109,6 +109,7 @@ type WordLayoutMarker = { italic?: boolean; color?: string; letterSpacing?: number; + vanish?: boolean; }; }; @@ -2285,66 +2286,69 @@ export class DomPainter { const marker = wordLayout.marker!; lineEl.style.paddingLeft = `${paraIndentLeft + (paraIndent?.firstLine ?? 0) - (paraIndent?.hanging ?? 0)}px`; // HERE CONTROLS WHERE TAB STARTS - I think this will vary with justification - const markerContainer = this.doc!.createElement('span'); - markerContainer.style.display = 'inline-block'; - // Justification is implemented via `word-spacing` on the line element. The list marker (and its - // tab/space suffix) must not inherit this spacing or it will shift the text start and can - // cause overflow for justified list paragraphs. - markerContainer.style.wordSpacing = '0px'; - - const markerEl = this.doc!.createElement('span'); - markerEl.classList.add('superdoc-paragraph-marker'); - markerEl.textContent = marker.markerText ?? ''; - markerEl.style.pointerEvents = 'none'; - - // Left-justified markers stay inline to share flow with the tab spacer. - // Other justifications use absolute positioning. - const markerJustification = marker.justification ?? 'left'; - - markerContainer.style.position = 'relative'; - if (markerJustification === 'right') { - markerContainer.style.position = 'absolute'; - markerContainer.style.left = `${markerStartPos}px`; // HERE CONTROLS MARKER POSITION - I think this will vary with justification - } else if (markerJustification === 'center') { - markerContainer.style.position = 'absolute'; - markerContainer.style.left = `${markerStartPos - fragment.markerTextWidth! / 2}px`; // HERE CONTROLS MARKER POSITION - I think this will vary with justification - lineEl.style.paddingLeft = parseFloat(lineEl.style.paddingLeft) + fragment.markerTextWidth! / 2 + 'px'; - } + // Skip marker rendering when hidden by vanish property (preserves list indentation) + if (!marker.run.vanish) { + const markerContainer = this.doc!.createElement('span'); + markerContainer.style.display = 'inline-block'; + // Justification is implemented via `word-spacing` on the line element. The list marker (and its + // tab/space suffix) must not inherit this spacing or it will shift the text start and can + // cause overflow for justified list paragraphs. + markerContainer.style.wordSpacing = '0px'; + + const markerEl = this.doc!.createElement('span'); + markerEl.classList.add('superdoc-paragraph-marker'); + markerEl.textContent = marker.markerText ?? ''; + markerEl.style.pointerEvents = 'none'; + + // Left-justified markers stay inline to share flow with the tab spacer. + // Other justifications use absolute positioning. + const markerJustification = marker.justification ?? 'left'; + + markerContainer.style.position = 'relative'; + if (markerJustification === 'right') { + markerContainer.style.position = 'absolute'; + markerContainer.style.left = `${markerStartPos}px`; // HERE CONTROLS MARKER POSITION - I think this will vary with justification + } else if (markerJustification === 'center') { + markerContainer.style.position = 'absolute'; + markerContainer.style.left = `${markerStartPos - fragment.markerTextWidth! / 2}px`; // HERE CONTROLS MARKER POSITION - I think this will vary with justification + lineEl.style.paddingLeft = parseFloat(lineEl.style.paddingLeft) + fragment.markerTextWidth! / 2 + 'px'; + } - // Apply marker run styling with font fallback chain - markerEl.style.fontFamily = toCssFontFamily(marker.run.fontFamily) ?? marker.run.fontFamily; - markerEl.style.fontSize = `${marker.run.fontSize}px`; - markerEl.style.fontWeight = marker.run.bold ? 'bold' : ''; - markerEl.style.fontStyle = marker.run.italic ? 'italic' : ''; - if (marker.run.color) { - markerEl.style.color = marker.run.color; - } - if (marker.run.letterSpacing != null) { - markerEl.style.letterSpacing = `${marker.run.letterSpacing}px`; - } - markerContainer.appendChild(markerEl); - - const suffix = marker.suffix ?? 'tab'; - if (suffix === 'tab') { - const tabEl = this.doc!.createElement('span'); - tabEl.className = 'superdoc-tab'; - tabEl.innerHTML = ' '; - tabEl.style.display = 'inline-block'; - tabEl.style.wordSpacing = '0px'; - tabEl.style.width = `${listTabWidth}px`; - - lineEl.prepend(tabEl); - } else if (suffix === 'space') { - // Insert a non-breaking space in the inline flow to separate marker and text. - // Wrap it so it can opt out of inherited `word-spacing` used for justification. - const spaceEl = this.doc!.createElement('span'); - spaceEl.classList.add('superdoc-marker-suffix-space'); - spaceEl.style.wordSpacing = '0px'; - spaceEl.textContent = '\u00A0'; - - lineEl.prepend(spaceEl); + // Apply marker run styling with font fallback chain + markerEl.style.fontFamily = toCssFontFamily(marker.run.fontFamily) ?? marker.run.fontFamily; + markerEl.style.fontSize = `${marker.run.fontSize}px`; + markerEl.style.fontWeight = marker.run.bold ? 'bold' : ''; + markerEl.style.fontStyle = marker.run.italic ? 'italic' : ''; + if (marker.run.color) { + markerEl.style.color = marker.run.color; + } + if (marker.run.letterSpacing != null) { + markerEl.style.letterSpacing = `${marker.run.letterSpacing}px`; + } + markerContainer.appendChild(markerEl); + + const suffix = marker.suffix ?? 'tab'; + if (suffix === 'tab') { + const tabEl = this.doc!.createElement('span'); + tabEl.className = 'superdoc-tab'; + tabEl.innerHTML = ' '; + tabEl.style.display = 'inline-block'; + tabEl.style.wordSpacing = '0px'; + tabEl.style.width = `${listTabWidth}px`; + + lineEl.prepend(tabEl); + } else if (suffix === 'space') { + // Insert a non-breaking space in the inline flow to separate marker and text. + // Wrap it so it can opt out of inherited `word-spacing` used for justification. + const spaceEl = this.doc!.createElement('span'); + spaceEl.classList.add('superdoc-marker-suffix-space'); + spaceEl.style.wordSpacing = '0px'; + spaceEl.textContent = '\u00A0'; + + lineEl.prepend(spaceEl); + } + lineEl.prepend(markerContainer); } - lineEl.prepend(markerContainer); } fragmentEl.appendChild(lineEl); }); diff --git a/packages/layout-engine/painters/dom/src/table/renderTableCell.ts b/packages/layout-engine/painters/dom/src/table/renderTableCell.ts index 1c8120f37a..c5cf38702e 100644 --- a/packages/layout-engine/painters/dom/src/table/renderTableCell.ts +++ b/packages/layout-engine/painters/dom/src/table/renderTableCell.ts @@ -67,6 +67,8 @@ type WordLayoutMarker = { color?: string; /** Letter spacing in pixels */ letterSpacing?: number; + /** Hidden text flag */ + vanish?: boolean; }; }; @@ -968,7 +970,8 @@ export const renderTableCell = (deps: TableCellRenderDependencies): TableCellRen * - The marker has a non-zero width */ const shouldRenderMarker = - markerLayout && markerMeasure && lineIdx === 0 && localStartLine === 0 && markerMeasure.markerWidth > 0; + markerLayout && markerMeasure && lineIdx === 0 && localStartLine === 0 && markerMeasure.markerWidth > 0 + && !markerLayout.run?.vanish; if (shouldRenderMarker) { /** diff --git a/packages/layout-engine/pm-adapter/src/attributes/paragraph.ts b/packages/layout-engine/pm-adapter/src/attributes/paragraph.ts index 0d3006b6ed..c62d0459c9 100644 --- a/packages/layout-engine/pm-adapter/src/attributes/paragraph.ts +++ b/packages/layout-engine/pm-adapter/src/attributes/paragraph.ts @@ -338,5 +338,6 @@ export const computeRunAttrs = ( allCaps: runProps?.textTransform === 'uppercase', letterSpacing: runProps.letterSpacing ? twipsToPx(runProps.letterSpacing) : undefined, lang: runProps.lang?.val || undefined, + vanish: runProps.vanish, }; }; diff --git a/packages/word-layout/src/types.ts b/packages/word-layout/src/types.ts index 780f1f0f48..de6f2b567d 100644 --- a/packages/word-layout/src/types.ts +++ b/packages/word-layout/src/types.ts @@ -51,6 +51,7 @@ export type ResolvedRunProperties = { letterSpacing?: number; scale?: number; lang?: string; + vanish?: boolean; }; export type NumberingProperties = { From cefebcc5f7180acd5c01ad646b9e0382105ba4ff Mon Sep 17 00:00:00 2001 From: J-michalek Date: Tue, 17 Feb 2026 21:03:47 +0100 Subject: [PATCH 2/2] test(paragraph): ensure that vanish property is present in resolved run props --- .../pm-adapter/src/attributes/paragraph.test.ts | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/packages/layout-engine/pm-adapter/src/attributes/paragraph.test.ts b/packages/layout-engine/pm-adapter/src/attributes/paragraph.test.ts index bd445430d5..60e04804d3 100644 --- a/packages/layout-engine/pm-adapter/src/attributes/paragraph.test.ts +++ b/packages/layout-engine/pm-adapter/src/attributes/paragraph.test.ts @@ -143,4 +143,14 @@ describe('computeRunAttrs', () => { expect(result.fontSize).toBeGreaterThan(0); expect(result.color).toBe('#FF0000'); }); + + it('includes the vanish property', () => { + const runProps = { + vanish: true, + }; + + const result = computeRunAttrs(runProps as never); + + expect(result.vanish).toBe(true); + }); });