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
145 changes: 144 additions & 1 deletion packages/layout-engine/pm-adapter/src/attributes/paragraph.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,14 @@
*/

import { describe, it, expect } from 'vitest';
import { deepClone, normalizeFramePr, normalizeDropCap, computeParagraphAttrs, computeRunAttrs } from './paragraph.js';
import {
deepClone,
normalizeFramePr,
normalizeDropCap,
computeParagraphAttrs,
computeRunAttrs,
hasExplicitParagraphRunProperties,
} from './paragraph.js';
import { twipsToPx } from '../utilities.js';

type PMNode = {
Expand Down Expand Up @@ -90,6 +97,42 @@ describe('normalizeDropCap', () => {
});

describe('computeParagraphAttrs', () => {
it('treats only raw paragraph runProperties as explicit', () => {
expect(hasExplicitParagraphRunProperties({ runProperties: { fontSize: 24 } } as never)).toBe(true);
expect(hasExplicitParagraphRunProperties({ styleId: 'Heading1' } as never)).toBe(false);
expect(hasExplicitParagraphRunProperties({ runProperties: {} } as never)).toBe(false);
});

it('ignores tracked change metadata in runProperties', () => {
expect(
hasExplicitParagraphRunProperties({
runProperties: { trackInsert: { id: '1', author: 'Author', date: '2026-01-01' } },
} as never),
).toBe(false);
expect(
hasExplicitParagraphRunProperties({
runProperties: { trackDelete: { id: '2', author: 'Author', date: '2026-01-01' } },
} as never),
).toBe(false);
expect(
hasExplicitParagraphRunProperties({
runProperties: {
trackInsert: { id: '1', author: 'Author', date: '2026-01-01' },
trackDelete: { id: '2', author: 'Author', date: '2026-01-01' },
},
} as never),
).toBe(false);
// Real formatting alongside tracked changes should still count as explicit
expect(
hasExplicitParagraphRunProperties({
runProperties: {
trackInsert: { id: '1', author: 'Author', date: '2026-01-01' },
fontSize: 24,
},
} as never),
).toBe(true);
});

it('normalizes spacing, indent, alignment, and tabs from paragraphProperties', () => {
const paragraph: PMNode = {
type: { name: 'paragraph' },
Expand Down Expand Up @@ -127,6 +170,94 @@ describe('computeParagraphAttrs', () => {
const { resolvedParagraphProperties } = computeParagraphAttrs(paragraph as never);
expect(resolvedParagraphProperties.styleId).toBe('Heading1');
});

it('passes previousParagraphFont to marker run when paragraph has listRendering and numbering', () => {
const previousFont = { fontFamily: 'MarkerFont, sans-serif', fontSize: 11 };
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: {},
translatedLinkedStyles: { docDefaults: {}, styles: {} },
tableInfo: null,
};

const { paragraphAttrs } = computeParagraphAttrs(paragraph as never, minimalContext as never, previousFont);
const markerRun = (
paragraphAttrs as { wordLayout?: { marker?: { run?: { fontFamily?: string; fontSize?: number } } } }
)?.wordLayout?.marker?.run;
expect(markerRun?.fontFamily).toBeDefined();
expect(markerRun?.fontFamily).toContain('MarkerFont');
expect(markerRun?.fontSize).toBe(11);
});

it('does not overwrite numbering marker font family with previousParagraphFont', () => {
const previousFont = { fontFamily: 'PrevMarkerFont, sans-serif', fontSize: 11 };

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: {
fontFamily: { ascii: 'Symbol' },
},
},
},
},
},
},
translatedLinkedStyles: { docDefaults: {}, styles: {} },
tableInfo: null,
};

const { paragraphAttrs } = computeParagraphAttrs(paragraph as never, minimalContext as never, previousFont);
const markerRun = (
paragraphAttrs as { wordLayout?: { marker?: { run?: { fontFamily?: string; fontSize?: number } } } }
)?.wordLayout?.marker?.run;

expect(markerRun?.fontFamily).toContain('Symbol');
// Font size still inherits from previous paragraph when the paragraph has no explicit run props.
expect(markerRun?.fontSize).toBe(11);
});
});

describe('computeRunAttrs', () => {
Expand Down Expand Up @@ -154,6 +285,18 @@ describe('computeRunAttrs', () => {
expect(result.vanish).toBe(true);
});

it('uses runProps font settings when previousParagraphFont is not provided', () => {
const runProps = {
fontFamily: { ascii: 'RunFont' },
fontSize: 20,
};

const result = computeRunAttrs(runProps as never);

expect(result.fontFamily).toContain('RunFont');
expect(result.fontSize).toBeGreaterThan(10);
});

it('passes through vertAlign', () => {
const result = computeRunAttrs({ vertAlign: 'superscript', fontSize: 24 } as never);
expect(result.vertAlign).toBe('superscript');
Expand Down
60 changes: 58 additions & 2 deletions packages/layout-engine/pm-adapter/src/attributes/paragraph.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import type {
DropCapRun,
ParagraphFrame,
} from '@superdoc/contracts';
import type { PMNode } from '../types.js';
import type { PMNode, ParagraphFont } from '../types.js';
import type { ResolvedRunProperties } from '@superdoc/word-layout';
import { computeWordParagraphLayout } from '@superdoc/word-layout';
import { pickNumber, twipsToPx, isFiniteNumber, ptToPx } from '../utilities.js';
Expand All @@ -27,6 +27,7 @@ import {
resolveParagraphProperties,
resolveRunProperties,
resolveDocxFontFamily,
getNumberingProperties,
type ParagraphFrameProperties,
type ParagraphProperties,
type RunProperties,
Expand Down Expand Up @@ -119,6 +120,31 @@ export const normalizeNumberingProperties = (
}
return value;
};

const TRACKED_CHANGE_KEYS = new Set(['trackInsert', 'trackDelete']);

export const hasExplicitParagraphRunProperties = (
paragraphProperties?: Pick<ParagraphProperties, 'runProperties'> | null,
): boolean => {
if (paragraphProperties?.runProperties == null) return false;
return Object.keys(paragraphProperties.runProperties).some((key) => !TRACKED_CHANGE_KEYS.has(key));
};

const applyParagraphFontFallback = (
runAttrs: ResolvedRunProperties,
previousParagraphFont?: Partial<ParagraphFont>,
): ResolvedRunProperties => {
if (!previousParagraphFont) {
return runAttrs;
}

return {
...runAttrs,
fontFamily: previousParagraphFont.fontFamily ?? runAttrs.fontFamily,
fontSize: previousParagraphFont.fontSize ?? runAttrs.fontSize,
};
};

export const normalizeDropCap = (
framePr: ParagraphFrameProperties | undefined,
para: PMNode,
Expand Down Expand Up @@ -230,6 +256,7 @@ const extractDropCapRunFromParagraph = (para: PMNode, converterContext?: Convert
export const computeParagraphAttrs = (
para: PMNode,
converterContext?: ConverterContext,
previousParagraphFont?: ParagraphFont,
): { paragraphAttrs: ParagraphAttrs; resolvedParagraphProperties: ParagraphProperties } => {
const attrs = para.attrs ?? {};
const paragraphProperties = (attrs.paragraphProperties ?? {}) as ParagraphProperties;
Expand Down Expand Up @@ -297,10 +324,38 @@ export const computeParagraphAttrs = (
true,
Boolean(paragraphProperties.numberingProperties),
);

Comment thread
caio-pizzol marked this conversation as resolved.
const markerRunAttrs = computeRunAttrs(markerRunProperties, converterContext);

// Only attempt to inherit `previousParagraphFont` when the paragraph doesn't define
// explicit runProperties. Otherwise markerRunProperties/resolveRunProperties already
// fully defines marker font.
let markerFontFallback: Partial<ParagraphFont> | undefined;
if (!hasExplicitParagraphRunProperties(paragraphProperties) && previousParagraphFont) {
// Detect whether numbering explicitly overrides the marker font family
// (e.g. Symbol/Wingdings). If it does, we must NOT overwrite it.
const numProps = paragraphProperties.numberingProperties;
const numId = numProps?.numId;
const ilvl = numProps?.ilvl ?? 0;
const numberingRunProps =
numId != null && numId !== 0
? getNumberingProperties<RunProperties>('runProperties', converterContext!, ilvl, numId)
: ({} as RunProperties);
const numberingDefinesMarkerFontFamily = numberingRunProps.fontFamily != null;

markerFontFallback = {
// When numbering explicitly sets a marker font (Symbol/Wingdings), keep it.
fontFamily: numberingDefinesMarkerFontFamily ? undefined : previousParagraphFont.fontFamily,
// Preserve existing behavior: if the paragraph has no explicit run props,
// marker font size inherits from the previous paragraph.
fontSize: previousParagraphFont.fontSize,
};
}

paragraphAttrs.wordLayout = computeWordParagraphLayout({
paragraph: paragraphAttrs,
listRenderingAttrs: normalizedListRendering,
markerRun: computeRunAttrs(markerRunProperties, converterContext),
markerRun: applyParagraphFontFallback(markerRunAttrs, markerFontFallback),
});
}

Expand All @@ -314,6 +369,7 @@ export const computeRunAttrs = (
defaultFontFamily = 'Times New Roman',
): ResolvedRunProperties => {
let fontFamily;

if (converterContext) {
fontFamily =
resolveDocxFontFamily(runProps.fontFamily as Record<string, unknown>, converterContext.docx) || defaultFontFamily;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
*/

// Paragraphs (converter + handler)
export { paragraphToFlowBlocks, mergeAdjacentRuns, handleParagraphNode } from './paragraph.js';
export { paragraphToFlowBlocks, mergeAdjacentRuns, handleParagraphNode, getLastParagraphFont } from './paragraph.js';
Comment thread
caio-pizzol marked this conversation as resolved.

// Content blocks (converter)
export { contentBlockNodeToDrawingBlock } from './content-block.js';
Expand Down
Loading
Loading