diff --git a/packages/layout-engine/contracts/src/index.ts b/packages/layout-engine/contracts/src/index.ts index 165b379d95..f28a58d939 100644 --- a/packages/layout-engine/contracts/src/index.ts +++ b/packages/layout-engine/contracts/src/index.ts @@ -1568,6 +1568,14 @@ export type ParagraphAttrs = { trackedChangesEnabled?: boolean; /** Marks an empty paragraph that only exists to carry section properties. */ sectPrMarker?: boolean; + /** + * w:specVanish on the paragraph-mark rPr (ECMA-376 §17.3.2.36): + * "a paragraph mark shall never be used to break the end of a paragraph for display". + * The pm-adapter post-process fuses the next paragraph's runs into this block and + * drops the successor; the successor's auto-generated list marker disappears with + * it. Numbering counters on subsequent paragraphs are unchanged, matching Word. + */ + specVanish?: boolean; /** * Resolved direction context for the paragraph (inline direction + writing mode). * Single source of truth for paragraph direction-aware rendering decisions. 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 e985738314..7a706c2089 100644 --- a/packages/layout-engine/pm-adapter/src/attributes/paragraph.test.ts +++ b/packages/layout-engine/pm-adapter/src/attributes/paragraph.test.ts @@ -312,6 +312,106 @@ describe('computeParagraphAttrs', () => { expect(markerRun?.fontSize).toBe(11); }); + // SD-3269: w:vanish / w:specVanish on the paragraph-mark rPr (w:pPr/w:rPr) + // apply to the paragraph-mark glyph only (ECMA-376 §17.3.2.36/§17.3.2.41). + // They must not leak into the auto-generated list marker's run properties, + // or the renderer drops the marker (e.g. "Section 2.01" disappears). + it('does not leak paragraph-mark vanish/specVanish into the list marker run', () => { + const paragraph: PMNode = { + type: { name: 'paragraph' }, + attrs: { + paragraphProperties: { + numberingProperties: { numId: 1, ilvl: 0 }, + runProperties: { + vanish: true, + specVanish: true, + }, + }, + listRendering: { + markerText: 'Section 1.01', + justification: 'left', + path: [0], + numberingType: 'decimalZero', + suffix: 'tab', + }, + }, + }; + + const minimalContext = { + translatedNumbering: { + definitions: { '1': { numId: 1, abstractNumId: 1 } }, + abstracts: { + '1': { + abstractNumId: 1, + levels: { + '0': { + ilvl: 0, + runProperties: {}, + }, + }, + }, + }, + }, + translatedLinkedStyles: { docDefaults: {}, styles: {} }, + tableInfo: null, + }; + + const { paragraphAttrs } = computeParagraphAttrs(paragraph as never, minimalContext as never); + const markerRun = ( + paragraphAttrs as { + wordLayout?: { marker?: { run?: { vanish?: boolean; specVanish?: boolean } } }; + } + )?.wordLayout?.marker?.run; + + expect(markerRun?.vanish).not.toBe(true); + expect(markerRun?.specVanish).not.toBe(true); + }); + + // Vanish defined on the numbering definition itself is still honoured. + // That is the supported way to hide an auto-generated list marker. + it('honours w:vanish defined on the numbering definition rPr for the marker', () => { + const paragraph: PMNode = { + type: { name: 'paragraph' }, + attrs: { + paragraphProperties: { + numberingProperties: { numId: 1, ilvl: 0 }, + }, + listRendering: { + markerText: '1.', + justification: 'left', + path: [0], + numberingType: 'decimal', + suffix: 'tab', + }, + }, + }; + + const minimalContext = { + translatedNumbering: { + definitions: { '1': { numId: 1, abstractNumId: 1 } }, + abstracts: { + '1': { + abstractNumId: 1, + levels: { + '0': { + ilvl: 0, + runProperties: { vanish: true }, + }, + }, + }, + }, + }, + translatedLinkedStyles: { docDefaults: {}, styles: {} }, + tableInfo: null, + }; + + const { paragraphAttrs } = computeParagraphAttrs(paragraph as never, minimalContext as never); + const markerRun = (paragraphAttrs as { wordLayout?: { marker?: { run?: { vanish?: boolean } } } })?.wordLayout + ?.marker?.run; + + expect(markerRun?.vanish).toBe(true); + }); + it('preserves explicit paragraph bidi direction', () => { const paragraph: PMNode = { type: { name: 'paragraph' }, diff --git a/packages/layout-engine/pm-adapter/src/attributes/paragraph.ts b/packages/layout-engine/pm-adapter/src/attributes/paragraph.ts index f20e7967d5..c7b9dc8eb2 100644 --- a/packages/layout-engine/pm-adapter/src/attributes/paragraph.ts +++ b/packages/layout-engine/pm-adapter/src/attributes/paragraph.ts @@ -391,6 +391,17 @@ export const computeParagraphAttrs = ( directionContext, }; + // SD-3269: w:specVanish on the paragraph-mark rPr suppresses the paragraph + // break for display per ECMA-376 §17.3.2.36. The pm-adapter post-process + // fuses the next paragraph's runs into this block and drops the successor; + // numbering counters on subsequent paragraphs are unchanged, matching Word. + if ( + resolvedParagraphProperties.runProperties?.specVanish === true || + paragraphProperties.runProperties?.specVanish === true + ) { + paragraphAttrs.specVanish = true; + } + if (normalizedNumberingProperties && normalizedListRendering) { const markerRunProperties = resolveRunProperties( converterContext!, diff --git a/packages/layout-engine/pm-adapter/src/integration.test.ts b/packages/layout-engine/pm-adapter/src/integration.test.ts index d723b6eb45..af15729449 100644 --- a/packages/layout-engine/pm-adapter/src/integration.test.ts +++ b/packages/layout-engine/pm-adapter/src/integration.test.ts @@ -527,6 +527,95 @@ describe('PM → FlowBlock → Measure integration', () => { expect(xPositions.size).toBeGreaterThan(1); }); + // SD-3269 follow-up: w:specVanish on the paragraph-mark rPr suppresses the + // paragraph break for display (ECMA-376 §17.3.2.36). Word renders the next + // paragraph as a continuation of the specVanish paragraph; the next + // paragraph's auto-generated marker is dropped along with its block. + it('fuses a specVanish paragraph forward into the next paragraph block', () => { + const fixture = { + type: 'doc', + content: [ + { + type: 'paragraph', + attrs: { + paragraphProperties: { + runProperties: { vanish: true, specVanish: true }, + }, + }, + content: [{ type: 'text', text: 'Head ' }], + }, + { + type: 'paragraph', + attrs: {}, + content: [{ type: 'text', text: 'Tail' }], + }, + ], + }; + + const { blocks } = toFlowBlocks(fixture); + + expect(blocks).toHaveLength(1); + expect(blocks[0].kind).toBe('paragraph'); + const merged = blocks[0] as { runs: Array<{ text?: string }>; attrs?: { specVanish?: boolean } }; + expect(merged.attrs?.specVanish).toBeUndefined(); + const text = merged.runs.map((r) => r.text ?? '').join(''); + expect(text).toBe('Head Tail'); + }); + + it('chains multiple specVanish paragraphs into a single block', () => { + const fixture = { + type: 'doc', + content: [ + { + type: 'paragraph', + attrs: { paragraphProperties: { runProperties: { vanish: true, specVanish: true } } }, + content: [{ type: 'text', text: 'A ' }], + }, + { + type: 'paragraph', + attrs: { paragraphProperties: { runProperties: { vanish: true, specVanish: true } } }, + content: [{ type: 'text', text: 'B ' }], + }, + { + type: 'paragraph', + attrs: {}, + content: [{ type: 'text', text: 'C' }], + }, + ], + }; + + const { blocks } = toFlowBlocks(fixture); + + expect(blocks).toHaveLength(1); + const merged = blocks[0] as { runs: Array<{ text?: string }>; attrs?: { specVanish?: boolean } }; + expect(merged.attrs?.specVanish).toBeUndefined(); + const text = merged.runs.map((r) => r.text ?? '').join(''); + expect(text).toBe('A B C'); + }); + + // A bare paragraph (no successor) keeps its specVanish flag — there's + // nothing to fuse into. Importer round-trip preservation still works since + // pm-adapter only re-emits the flag, it does not invent it. + it('leaves a trailing specVanish paragraph untouched when there is no successor', () => { + const fixture = { + type: 'doc', + content: [ + { + type: 'paragraph', + attrs: { paragraphProperties: { runProperties: { vanish: true, specVanish: true } } }, + content: [{ type: 'text', text: 'Alone' }], + }, + ], + }; + + const { blocks } = toFlowBlocks(fixture); + + expect(blocks).toHaveLength(1); + const block = blocks[0] as { runs: Array<{ text?: string }>; attrs?: { specVanish?: boolean } }; + expect(block.attrs?.specVanish).toBe(true); + expect(block.runs.map((r) => r.text ?? '').join('')).toBe('Alone'); + }); + it('renders paragraph shading backgrounds end to end', async () => { const fixture = { type: 'doc', diff --git a/packages/layout-engine/pm-adapter/src/internal.ts b/packages/layout-engine/pm-adapter/src/internal.ts index f2e39387c1..15aa53b367 100644 --- a/packages/layout-engine/pm-adapter/src/internal.ts +++ b/packages/layout-engine/pm-adapter/src/internal.ts @@ -10,7 +10,7 @@ * - Normalize whitespace and handle empty paragraphs */ -import type { FlowBlock, ParagraphBlock } from '@superdoc/contracts'; +import type { FlowBlock, ParagraphAttrs, ParagraphBlock } from '@superdoc/contracts'; import { resolveSectionDirection } from './direction/resolveSectionDirection.js'; import { isValidTrackedMode } from './tracked-changes.js'; import { @@ -283,7 +283,11 @@ export function toFlowBlocks(pmDoc: PMNode | object, options?: AdapterOptions): const hydratedBlocks = hydrateImageBlocks(blocks, options?.mediaFiles); // Post-process: Merge drop-cap paragraphs with their following text paragraphs - const mergedBlocks = mergeDropCapParagraphs(hydratedBlocks); + const dropCapMergedBlocks = mergeDropCapParagraphs(hydratedBlocks); + + // Post-process: Fuse specVanish paragraphs forward into the next paragraph, + // matching Word per ECMA-376 §17.3.2.36 (SD-3269 follow-up). + const mergedBlocks = mergeSpecVanishParagraphs(dropCapMergedBlocks); // Commit cache cycle - swaps next to previous, retaining only blocks seen this render flowBlockCache?.commit(); @@ -375,6 +379,57 @@ function mergeDropCapParagraphs(blocks: FlowBlock[]): FlowBlock[] { return result; } +/** + * Fuse paragraphs whose paragraph-mark rPr carries `w:specVanish` into the + * next paragraph block. ECMA-376 §17.3.2.36 states the paragraph mark "shall + * never be used to break the end of a paragraph for display" in that case; + * Word renders the two paragraphs as one continuous flow and drops the + * successor's auto-generated numbering marker. Numbering counters still + * advance per OOXML paragraph, so subsequent items skip a slot (matches + * pdftotext extraction of Word's own PDF export). + * + * Strategy: keep the predecessor's block id, attrs, marker, and indent; + * concatenate `predecessor.runs ++ successor.runs`; drop the successor block. + * Chains of specVanish paragraphs collapse left-to-right. + * + * @param blocks - Array of flow blocks to process + * @returns New array with specVanish paragraphs fused into their successors + */ +function mergeSpecVanishParagraphs(blocks: FlowBlock[]): FlowBlock[] { + const result: FlowBlock[] = []; + + for (const block of blocks) { + const prev = result.length > 0 ? result[result.length - 1] : undefined; + if (block.kind === 'paragraph' && prev?.kind === 'paragraph' && (prev as ParagraphBlock).attrs?.specVanish) { + const head = prev as ParagraphBlock; + const tail = block as ParagraphBlock; + // Keep the predecessor's id/attrs (style, marker, indent) and append + // the successor's runs. The successor's marker is implicitly dropped + // because its block no longer exists. Carry the specVanish flag forward + // only if the successor itself has one — that keeps chains collapsing + // left-to-right across consecutive specVanish paragraphs. + const mergedAttrs: ParagraphAttrs = { ...head.attrs }; + if (tail.attrs?.specVanish) { + mergedAttrs.specVanish = true; + } else { + delete (mergedAttrs as Record).specVanish; + } + const merged: ParagraphBlock = { + kind: 'paragraph', + id: head.id, + runs: [...head.runs, ...tail.runs], + attrs: mergedAttrs, + ...(head.sourceAnchor ? { sourceAnchor: head.sourceAnchor } : {}), + }; + result[result.length - 1] = merged; + continue; + } + result.push(block); + } + + return result; +} + /** * Normalize and populate the converter context with defaults. * diff --git a/packages/layout-engine/style-engine/src/ooxml/index.ts b/packages/layout-engine/style-engine/src/ooxml/index.ts index 7cb3a680dc..0791ee9472 100644 --- a/packages/layout-engine/style-engine/src/ooxml/index.ts +++ b/packages/layout-engine/style-engine/src/ooxml/index.ts @@ -133,6 +133,17 @@ export function resolveRunProperties( delete inlineRpr.underline; } + // w:vanish and w:specVanish on the paragraph-mark rPr (w:pPr/w:rPr) apply only + // to the paragraph-mark glyph (¶), per ECMA-376 §17.3.2.41 and §17.3.2.36. + // They must not leak into the auto-generated list marker (SD-3269). Vanish set + // on the numbering definition's own rPr still applies via numberingProps below. + if (inlineRpr?.vanish) { + delete inlineRpr.vanish; + } + if (inlineRpr?.specVanish) { + delete inlineRpr.specVanish; + } + styleChain = [ ...defaultsChain, tableStyleProps,