Skip to content
Open
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
8 changes: 8 additions & 0 deletions packages/layout-engine/contracts/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
100 changes: 100 additions & 0 deletions packages/layout-engine/pm-adapter/src/attributes/paragraph.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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' },
Expand Down
11 changes: 11 additions & 0 deletions packages/layout-engine/pm-adapter/src/attributes/paragraph.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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!,
Expand Down
89 changes: 89 additions & 0 deletions packages/layout-engine/pm-adapter/src/integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
59 changes: 57 additions & 2 deletions packages/layout-engine/pm-adapter/src/internal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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<string, unknown>).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.
*
Expand Down
11 changes: 11 additions & 0 deletions packages/layout-engine/style-engine/src/ooxml/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Loading