Skip to content

Commit ae09d28

Browse files
committed
fix: review comments
1 parent 1202e24 commit ae09d28

5 files changed

Lines changed: 192 additions & 53 deletions

File tree

packages/layout-engine/pm-adapter/src/attributes/paragraph.test.ts

Lines changed: 69 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,14 @@
1010
*/
1111

1212
import { describe, it, expect } from 'vitest';
13-
import { deepClone, normalizeFramePr, normalizeDropCap, computeParagraphAttrs, computeRunAttrs } from './paragraph.js';
13+
import {
14+
deepClone,
15+
normalizeFramePr,
16+
normalizeDropCap,
17+
computeParagraphAttrs,
18+
computeRunAttrs,
19+
hasExplicitParagraphRunProperties,
20+
} from './paragraph.js';
1421
import { twipsToPx } from '../utilities.js';
1522

1623
type PMNode = {
@@ -90,6 +97,12 @@ describe('normalizeDropCap', () => {
9097
});
9198

9299
describe('computeParagraphAttrs', () => {
100+
it('treats only raw paragraph runProperties as explicit', () => {
101+
expect(hasExplicitParagraphRunProperties({ runProperties: { fontSize: 24 } } as never)).toBe(true);
102+
expect(hasExplicitParagraphRunProperties({ styleId: 'Heading1' } as never)).toBe(false);
103+
expect(hasExplicitParagraphRunProperties({ runProperties: {} } as never)).toBe(false);
104+
});
105+
93106
it('normalizes spacing, indent, alignment, and tabs from paragraphProperties', () => {
94107
const paragraph: PMNode = {
95108
type: { name: 'paragraph' },
@@ -160,6 +173,61 @@ describe('computeParagraphAttrs', () => {
160173
expect(markerRun?.fontFamily).toContain('MarkerFont');
161174
expect(markerRun?.fontSize).toBe(11);
162175
});
176+
177+
it('does not overwrite numbering marker font family with previousParagraphFont', () => {
178+
const previousFont = { fontFamily: 'PrevMarkerFont, sans-serif', fontSize: 11 };
179+
180+
const paragraph: PMNode = {
181+
type: { name: 'paragraph' },
182+
attrs: {
183+
paragraphProperties: {
184+
numberingProperties: { numId: 1, ilvl: 0 },
185+
},
186+
listRendering: {
187+
markerText: '1.',
188+
justification: 'left',
189+
path: [0],
190+
numberingType: 'decimal',
191+
suffix: 'tab',
192+
},
193+
},
194+
};
195+
196+
const minimalContext = {
197+
translatedNumbering: {
198+
definitions: {
199+
'1': {
200+
numId: 1,
201+
abstractNumId: 1,
202+
},
203+
},
204+
abstracts: {
205+
'1': {
206+
abstractNumId: 1,
207+
levels: {
208+
'0': {
209+
ilvl: 0,
210+
runProperties: {
211+
fontFamily: { ascii: 'Symbol' },
212+
},
213+
},
214+
},
215+
},
216+
},
217+
},
218+
translatedLinkedStyles: { docDefaults: {}, styles: {} },
219+
tableInfo: null,
220+
};
221+
222+
const { paragraphAttrs } = computeParagraphAttrs(paragraph as never, minimalContext as never, previousFont);
223+
const markerRun = (
224+
paragraphAttrs as { wordLayout?: { marker?: { run?: { fontFamily?: string; fontSize?: number } } } }
225+
)?.wordLayout?.marker?.run;
226+
227+
expect(markerRun?.fontFamily).toContain('Symbol');
228+
// Font size still inherits from previous paragraph when the paragraph has no explicit run props.
229+
expect(markerRun?.fontSize).toBe(11);
230+
});
163231
});
164232

165233
describe('computeRunAttrs', () => {
@@ -187,29 +255,6 @@ describe('computeRunAttrs', () => {
187255
expect(result.vanish).toBe(true);
188256
});
189257

190-
it('uses previousParagraphFont for fontFamily and fontSize when provided', () => {
191-
const runProps = {};
192-
const previousFont = { fontFamily: 'PreviousParaFont, sans-serif', fontSize: 14 };
193-
194-
const result = computeRunAttrs(runProps as never, undefined, previousFont);
195-
196-
expect(result.fontFamily).toContain('PreviousParaFont');
197-
expect(result.fontSize).toBe(14);
198-
});
199-
200-
it('prefers previousParagraphFont over runProps when both are provided', () => {
201-
const runProps = {
202-
fontFamily: { ascii: 'RunFont' },
203-
fontSize: 22,
204-
};
205-
const previousFont = { fontFamily: 'PreviousFont, sans-serif', fontSize: 10 };
206-
207-
const result = computeRunAttrs(runProps as never, undefined, previousFont);
208-
209-
expect(result.fontFamily).toContain('PreviousFont');
210-
expect(result.fontSize).toBe(10);
211-
});
212-
213258
it('uses runProps font settings when previousParagraphFont is not provided', () => {
214259
const runProps = {
215260
fontFamily: { ascii: 'RunFont' },

packages/layout-engine/pm-adapter/src/attributes/paragraph.ts

Lines changed: 61 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,26 @@ export const normalizeNumberingProperties = (
119119
}
120120
return value;
121121
};
122+
123+
export const hasExplicitParagraphRunProperties = (
124+
paragraphProperties?: Pick<ParagraphProperties, 'runProperties'> | null,
125+
): boolean => paragraphProperties?.runProperties != null && Object.keys(paragraphProperties.runProperties).length > 0;
126+
127+
const applyParagraphFontFallback = (
128+
runAttrs: ResolvedRunProperties,
129+
previousParagraphFont?: Partial<ParagraphFont>,
130+
): ResolvedRunProperties => {
131+
if (!previousParagraphFont) {
132+
return runAttrs;
133+
}
134+
135+
return {
136+
...runAttrs,
137+
fontFamily: previousParagraphFont.fontFamily ?? runAttrs.fontFamily,
138+
fontSize: previousParagraphFont.fontSize ?? runAttrs.fontSize,
139+
};
140+
};
141+
122142
export const normalizeDropCap = (
123143
framePr: ParagraphFrameProperties | undefined,
124144
para: PMNode,
@@ -205,7 +225,6 @@ const extractDropCapRunFromParagraph = (para: PMNode, converterContext?: Convert
205225
const runAttrs = computeRunAttrs(
206226
resolvedRunProperties,
207227
converterContext,
208-
undefined,
209228
DEFAULT_DROP_CAP_FONT_SIZE_PX,
210229
DEFAULT_DROP_CAP_FONT_FAMILY,
211230
);
@@ -299,18 +318,49 @@ export const computeParagraphAttrs = (
299318
Boolean(paragraphProperties.numberingProperties),
300319
);
301320

302-
const hasExplicitRunProps =
303-
resolvedParagraphProperties.runProperties != null &&
304-
Object.keys(resolvedParagraphProperties.runProperties).length > 0;
321+
const markerRunAttrs = computeRunAttrs(markerRunProperties, converterContext);
322+
323+
// Only attempt to inherit `previousParagraphFont` when the paragraph doesn't define
324+
// explicit runProperties. Otherwise markerRunProperties/resolveRunProperties already
325+
// fully defines marker font.
326+
let markerFontFallback: Partial<ParagraphFont> | undefined;
327+
if (!hasExplicitParagraphRunProperties(paragraphProperties) && previousParagraphFont) {
328+
// Detect whether numbering explicitly overrides the marker font family
329+
// (e.g. Symbol/Wingdings). If it does, we must NOT overwrite it.
330+
const markerRunPropertiesWithoutNumbering = resolveRunProperties(
331+
converterContext!,
332+
resolvedParagraphProperties.runProperties,
333+
resolvedParagraphProperties,
334+
converterContext!.tableInfo,
335+
false,
336+
Boolean(paragraphProperties.numberingProperties),
337+
);
338+
339+
const markerFontFamilyFromNumbering = resolveDocxFontFamily(
340+
markerRunProperties.fontFamily as Record<string, unknown>,
341+
converterContext!.docx,
342+
);
343+
const markerFontFamilyWithoutNumbering = resolveDocxFontFamily(
344+
markerRunPropertiesWithoutNumbering.fontFamily as Record<string, unknown>,
345+
converterContext!.docx,
346+
);
347+
348+
const numberingDefinesMarkerFontFamily =
349+
markerFontFamilyFromNumbering != null && markerFontFamilyFromNumbering !== markerFontFamilyWithoutNumbering;
350+
351+
markerFontFallback = {
352+
// When numbering explicitly sets a marker font (Symbol/Wingdings), keep it.
353+
fontFamily: numberingDefinesMarkerFontFamily ? undefined : previousParagraphFont.fontFamily,
354+
// Preserve existing behavior: if the paragraph has no explicit run props,
355+
// marker font size inherits from the previous paragraph.
356+
fontSize: previousParagraphFont.fontSize,
357+
};
358+
}
305359

306360
paragraphAttrs.wordLayout = computeWordParagraphLayout({
307361
paragraph: paragraphAttrs,
308362
listRenderingAttrs: normalizedListRendering,
309-
markerRun: computeRunAttrs(
310-
markerRunProperties,
311-
converterContext,
312-
hasExplicitRunProps ? undefined : previousParagraphFont,
313-
),
363+
markerRun: applyParagraphFontFallback(markerRunAttrs, markerFontFallback),
314364
});
315365
}
316366

@@ -320,15 +370,12 @@ export const computeParagraphAttrs = (
320370
export const computeRunAttrs = (
321371
runProps: RunProperties,
322372
converterContext?: ConverterContext,
323-
previousParagraphFont?: ParagraphFont,
324373
defaultFontSizePx = 12,
325374
defaultFontFamily = 'Times New Roman',
326375
): ResolvedRunProperties => {
327376
let fontFamily;
328377

329-
if (previousParagraphFont && previousParagraphFont.fontFamily) {
330-
fontFamily = previousParagraphFont.fontFamily;
331-
} else if (converterContext) {
378+
if (converterContext) {
332379
fontFamily =
333380
resolveDocxFontFamily(runProps.fontFamily as Record<string, unknown>, converterContext.docx) || defaultFontFamily;
334381
} else {
@@ -337,13 +384,7 @@ export const computeRunAttrs = (
337384
}
338385
const vertAlign = runProps.vertAlign as 'superscript' | 'subscript' | 'baseline' | undefined;
339386
const hasPosition = runProps.position != null && Number.isFinite(runProps.position);
340-
let fontSize;
341-
342-
if (previousParagraphFont && previousParagraphFont.fontSize) {
343-
fontSize = previousParagraphFont.fontSize;
344-
} else {
345-
fontSize = runProps.fontSize ? ptToPx(runProps.fontSize / 2)! : defaultFontSizePx;
346-
}
387+
let fontSize = runProps.fontSize ? ptToPx(runProps.fontSize / 2)! : defaultFontSizePx;
347388

348389
// Scale font size for superscript/subscript when no custom position override
349390
if (!hasPosition && (vertAlign === 'superscript' || vertAlign === 'subscript')) {

packages/layout-engine/pm-adapter/src/converters/paragraph.test.ts

Lines changed: 41 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2987,6 +2987,16 @@ describe('paragraph converters', () => {
29872987

29882988
it('ignores previousParagraphFont when paragraph has explicit run properties', () => {
29892989
const previousFont = { fontFamily: 'PreviousFont', fontSize: 10 };
2990+
const paraWithExplicitRunProps: PMNode = {
2991+
...emptyNumberedPara,
2992+
attrs: {
2993+
paragraphProperties: {
2994+
numberingProperties: { numId: 1, ilvl: 0 },
2995+
runProperties: { fontFamily: { ascii: 'ExplicitFont' }, fontSize: 24 },
2996+
},
2997+
},
2998+
};
2999+
29903000
vi.mocked(computeParagraphAttrs).mockReturnValue({
29913001
paragraphAttrs: {},
29923002
resolvedParagraphProperties: {
@@ -2996,7 +3006,7 @@ describe('paragraph converters', () => {
29963006
});
29973007

29983008
const blocks = baseParagraphToFlowBlocks({
2999-
para: emptyNumberedPara,
3009+
para: paraWithExplicitRunProps,
30003010
nextBlockId,
30013011
positions,
30023012
trackedChangesConfig: undefined,
@@ -3015,6 +3025,36 @@ describe('paragraph converters', () => {
30153025
expect(paraBlock.runs[0].fontFamily).toContain('ExplicitFont');
30163026
expect(paraBlock.runs[0].fontSize).not.toBe(10);
30173027
});
3028+
3029+
it('uses previousParagraphFont when run properties are only inherited from styles', () => {
3030+
const previousFont = { fontFamily: 'PreviousFont', fontSize: 10 };
3031+
vi.mocked(computeParagraphAttrs).mockReturnValue({
3032+
paragraphAttrs: {},
3033+
resolvedParagraphProperties: {
3034+
numberingProperties: { numId: 1, ilvl: 0 },
3035+
runProperties: { fontFamily: { ascii: 'StyledFont' }, fontSize: 24 },
3036+
},
3037+
});
3038+
3039+
const blocks = baseParagraphToFlowBlocks({
3040+
para: emptyNumberedPara,
3041+
nextBlockId,
3042+
positions,
3043+
trackedChangesConfig: undefined,
3044+
bookmarks: new Map(),
3045+
hyperlinkConfig: DEFAULT_HYPERLINK_CONFIG,
3046+
themeColors: undefined,
3047+
converters: {} as NestedConverters,
3048+
converterContext: defaultConverterContext,
3049+
enableComments: true,
3050+
previousParagraphFont: previousFont,
3051+
});
3052+
3053+
expect(blocks).toHaveLength(1);
3054+
const paraBlock = blocks[0] as ParagraphBlock;
3055+
expect(paraBlock.runs[0].fontFamily).toBe(previousFont.fontFamily);
3056+
expect(paraBlock.runs[0].fontSize).toBe(previousFont.fontSize);
3057+
});
30183058
});
30193059

30203060
it('should clone paragraph attrs for each paragraph block', () => {

packages/layout-engine/pm-adapter/src/converters/paragraph.ts

Lines changed: 19 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,15 @@
88
*/
99

1010
import type { ParagraphProperties, RunProperties } from '@superdoc/style-engine/ooxml';
11-
import type { FlowBlock, ParagraphBlock, Run, TextRun, SdtMetadata, DrawingBlock, TrackedChangeMeta } from '@superdoc/contracts';
11+
import type {
12+
FlowBlock,
13+
ParagraphBlock,
14+
Run,
15+
TextRun,
16+
SdtMetadata,
17+
DrawingBlock,
18+
TrackedChangeMeta,
19+
} from '@superdoc/contracts';
1220
import type {
1321
PMNode,
1422
PMMark,
@@ -25,7 +33,7 @@ import { trackedChangesCompatible, applyMarksToRun, collectTrackedChangeFromMark
2533
import { applyTrackedChangesModeToRuns } from '../tracked-changes.js';
2634
import { textNodeToRun } from './inline-converters/text-run.js';
2735
import { DEFAULT_HYPERLINK_CONFIG, TOKEN_INLINE_TYPES } from '../constants.js';
28-
import { computeRunAttrs } from '../attributes/paragraph.js';
36+
import { computeRunAttrs, hasExplicitParagraphRunProperties } from '../attributes/paragraph.js';
2937
import { resolveRunProperties } from '@superdoc/style-engine/ooxml';
3038
import { footnoteReferenceToBlock } from './inline-converters/footnote-reference.js';
3139
import { endnoteReferenceToBlock } from './inline-converters/endnote-reference.js';
@@ -539,11 +547,10 @@ export function paragraphToFlowBlocks({
539547

540548
// Extract font data for list items
541549
const extracted = extractDefaultFontProperties(converterContext, resolvedParagraphProperties);
542-
const hasExplicitRunProps =
543-
resolvedParagraphProperties.runProperties != null &&
544-
Object.keys(resolvedParagraphProperties.runProperties).length > 0;
545550
const usePreviousFont =
546-
previousParagraphFont != null && resolvedParagraphProperties.numberingProperties != null && !hasExplicitRunProps;
551+
previousParagraphFont != null &&
552+
resolvedParagraphProperties.numberingProperties != null &&
553+
!hasExplicitParagraphRunProperties(paragraphProps);
547554
const defaultFont =
548555
usePreviousFont && previousParagraphFont.fontFamily ? previousParagraphFont.fontFamily : extracted.defaultFont;
549556
const defaultSize =
@@ -1009,6 +1016,12 @@ export function handleParagraphNode(node: PMNode, context: NodeHandlerContext):
10091016
const { entry: cached, nodeJson, nodeRev } = flowBlockCache.get(prefixedStableId, node);
10101017
if (cached) {
10111018
// Cache hit: reuse blocks with position adjustment
1019+
// Cache hit reuses previously-converted blocks as-is. That means we don't
1020+
// recompute previousParagraphFont (used for empty list items without
1021+
// explicit run properties). If the user changes the font on the prior
1022+
// paragraph (e.g. paragraph A), an empty list item (paragraph B) can keep
1023+
// the old font until the cache entry is invalidated. Narrow case, but
1024+
// avoids confusing incremental-edit behavior.
10121025
const delta = pmStart - cached.pmStart;
10131026
const reusedBlocks = shiftCachedBlocks(cached.blocks, delta);
10141027
applyTrackedGhostListAdjustments(node, reusedBlocks, context);

packages/layout-engine/pm-adapter/src/types.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -420,6 +420,6 @@ export interface OoxmlBorder {
420420
export type UnderlineStyle = NonNullable<import('@superdoc/contracts').TextRun['underline']>['style'];
421421

422422
export type ParagraphFont = {
423-
fontFamily?: string;
424-
fontSize?: number;
423+
fontFamily: string;
424+
fontSize: number;
425425
};

0 commit comments

Comments
 (0)