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 d771d7af28..8ba89726a7 100644 --- a/packages/layout-engine/pm-adapter/src/attributes/paragraph.test.ts +++ b/packages/layout-engine/pm-adapter/src/attributes/paragraph.test.ts @@ -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 = { @@ -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' }, @@ -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', () => { @@ -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'); diff --git a/packages/layout-engine/pm-adapter/src/attributes/paragraph.ts b/packages/layout-engine/pm-adapter/src/attributes/paragraph.ts index 15a2cf9feb..a7dff234a4 100644 --- a/packages/layout-engine/pm-adapter/src/attributes/paragraph.ts +++ b/packages/layout-engine/pm-adapter/src/attributes/paragraph.ts @@ -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'; @@ -27,6 +27,7 @@ import { resolveParagraphProperties, resolveRunProperties, resolveDocxFontFamily, + getNumberingProperties, type ParagraphFrameProperties, type ParagraphProperties, type RunProperties, @@ -119,6 +120,31 @@ export const normalizeNumberingProperties = ( } return value; }; + +const TRACKED_CHANGE_KEYS = new Set(['trackInsert', 'trackDelete']); + +export const hasExplicitParagraphRunProperties = ( + paragraphProperties?: Pick | 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, +): 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, @@ -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; @@ -297,10 +324,38 @@ export const computeParagraphAttrs = ( true, Boolean(paragraphProperties.numberingProperties), ); + + 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 | 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', 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), }); } @@ -314,6 +369,7 @@ export const computeRunAttrs = ( defaultFontFamily = 'Times New Roman', ): ResolvedRunProperties => { let fontFamily; + if (converterContext) { fontFamily = resolveDocxFontFamily(runProps.fontFamily as Record, converterContext.docx) || defaultFontFamily; diff --git a/packages/layout-engine/pm-adapter/src/converters/index.ts b/packages/layout-engine/pm-adapter/src/converters/index.ts index 720a470e02..6098fae239 100644 --- a/packages/layout-engine/pm-adapter/src/converters/index.ts +++ b/packages/layout-engine/pm-adapter/src/converters/index.ts @@ -11,7 +11,7 @@ */ // Paragraphs (converter + handler) -export { paragraphToFlowBlocks, mergeAdjacentRuns, handleParagraphNode } from './paragraph.js'; +export { paragraphToFlowBlocks, mergeAdjacentRuns, handleParagraphNode, getLastParagraphFont } from './paragraph.js'; // Content blocks (converter) export { contentBlockNodeToDrawingBlock } from './content-block.js'; diff --git a/packages/layout-engine/pm-adapter/src/converters/paragraph.test.ts b/packages/layout-engine/pm-adapter/src/converters/paragraph.test.ts index 74a1848218..05ee691b11 100644 --- a/packages/layout-engine/pm-adapter/src/converters/paragraph.test.ts +++ b/packages/layout-engine/pm-adapter/src/converters/paragraph.test.ts @@ -13,6 +13,7 @@ import { mergeAdjacentRuns, dataAttrsCompatible, commentsCompatible, + getLastParagraphFont, } from './paragraph.js'; import { isInlineImage, imageNodeToRun } from './inline-converters/image.js'; import type { @@ -218,6 +219,141 @@ const paragraphToFlowBlocks = ( }); }; +describe('getLastParagraphFont', () => { + it('returns undefined when blocks is empty', () => { + expect(getLastParagraphFont([])).toBeUndefined(); + }); + + it('returns undefined when blocks has no paragraph', () => { + const blocks: FlowBlock[] = [ + { kind: 'sectionBreak', id: '0-sectionBreak', attrs: { type: 'nextPage' } }, + { kind: 'pageBreak', id: '1-pageBreak', attrs: { source: 'pageBreakBefore' } }, + ]; + expect(getLastParagraphFont(blocks)).toBeUndefined(); + }); + + it('returns font from last paragraph block when first run has fontFamily and fontSize', () => { + const blocks: FlowBlock[] = [ + { + kind: 'paragraph', + id: '0-paragraph', + runs: [ + { + kind: 'text', + text: 'Hello', + fontFamily: 'Arial, sans-serif', + fontSize: 16, + pmStart: 0, + pmEnd: 5, + }, + ], + attrs: {}, + }, + ]; + const result = getLastParagraphFont(blocks); + expect(result).toEqual({ fontFamily: 'Arial, sans-serif', fontSize: 16 }); + }); + + it('returns the last paragraph font when there are multiple paragraphs', () => { + const blocks: FlowBlock[] = [ + { + kind: 'paragraph', + id: '0-paragraph', + runs: [ + { + kind: 'text', + text: 'First', + fontFamily: 'FirstFont', + fontSize: 12, + pmStart: 0, + pmEnd: 5, + }, + ], + attrs: {}, + }, + { + kind: 'paragraph', + id: '1-paragraph', + runs: [ + { + kind: 'text', + text: 'Second', + fontFamily: 'SecondFont, sans-serif', + fontSize: 14, + pmStart: 0, + pmEnd: 6, + }, + ], + attrs: {}, + }, + ]; + const result = getLastParagraphFont(blocks); + expect(result).toEqual({ fontFamily: 'SecondFont, sans-serif', fontSize: 14 }); + }); + + it('skips last paragraph when its first run has no fontFamily and returns previous paragraph font', () => { + const blocks: FlowBlock[] = [ + { + kind: 'paragraph', + id: '0-paragraph', + runs: [ + { + kind: 'text', + text: 'Valid', + fontFamily: 'ValidFont', + fontSize: 11, + pmStart: 0, + pmEnd: 5, + }, + ], + attrs: {}, + }, + { + kind: 'paragraph', + id: '1-paragraph', + runs: [{ kind: 'text', text: '', fontSize: 16, pmStart: 0, pmEnd: 0 } as TextRun], + attrs: {}, + }, + ]; + const result = getLastParagraphFont(blocks); + expect(result).toEqual({ fontFamily: 'ValidFont', fontSize: 11 }); + }); + + it('returns undefined when last paragraph has no runs', () => { + const blocks: FlowBlock[] = [ + { + kind: 'paragraph', + id: '0-paragraph', + runs: [], + attrs: {}, + }, + ]; + expect(getLastParagraphFont(blocks)).toBeUndefined(); + }); + + it('returns undefined when last paragraph first run has invalid fontSize (not a number)', () => { + const blocks: FlowBlock[] = [ + { + kind: 'paragraph', + id: '0-paragraph', + runs: [ + { + kind: 'text', + text: 'x', + fontFamily: 'SomeFont', + fontSize: 'large' as unknown as number, + pmStart: 0, + pmEnd: 1, + }, + ], + attrs: {}, + }, + ]; + const result = getLastParagraphFont(blocks); + expect(result).toBeUndefined(); + }); +}); + describe('paragraph converters', () => { describe('mergeAdjacentRuns', () => { it('should return empty array unchanged', () => { @@ -2773,7 +2909,152 @@ describe('paragraph converters', () => { converterContext, ); - expect(vi.mocked(computeParagraphAttrs)).toHaveBeenCalledWith(para, converterContext); + expect(vi.mocked(computeParagraphAttrs)).toHaveBeenCalledWith(para, converterContext, undefined); + }); + + describe('previousParagraphFont', () => { + const emptyNumberedPara: PMNode = { + type: 'paragraph', + content: [], + attrs: { + paragraphProperties: { + numberingProperties: { numId: 1, ilvl: 0 }, + }, + }, + }; + + it('uses previousParagraphFont for default run when paragraph has numbering and no explicit run properties', () => { + const previousFont = { fontFamily: 'CustomFont, sans-serif', fontSize: 14 }; + vi.mocked(computeParagraphAttrs).mockReturnValue({ + paragraphAttrs: {}, + resolvedParagraphProperties: { + numberingProperties: { numId: 1, ilvl: 0 }, + runProperties: {}, + }, + }); + + const blocks = baseParagraphToFlowBlocks({ + para: emptyNumberedPara, + nextBlockId, + positions, + trackedChangesConfig: undefined, + bookmarks: new Map(), + hyperlinkConfig: DEFAULT_HYPERLINK_CONFIG, + themeColors: undefined, + converters: {} as NestedConverters, + converterContext: defaultConverterContext, + enableComments: true, + previousParagraphFont: previousFont, + }); + + expect(blocks).toHaveLength(1); + expect(blocks[0].kind).toBe('paragraph'); + const paraBlock = blocks[0] as ParagraphBlock; + expect(paraBlock.runs).toHaveLength(1); + expect(paraBlock.runs[0].fontFamily).toBe(previousFont.fontFamily); + expect(paraBlock.runs[0].fontSize).toBe(previousFont.fontSize); + }); + + it('uses extracted default font when previousParagraphFont is not provided', () => { + vi.mocked(computeParagraphAttrs).mockReturnValue({ + paragraphAttrs: {}, + resolvedParagraphProperties: { + numberingProperties: { numId: 1, ilvl: 0 }, + runProperties: {}, + }, + }); + + const blocks = baseParagraphToFlowBlocks({ + para: emptyNumberedPara, + nextBlockId, + positions, + trackedChangesConfig: undefined, + bookmarks: new Map(), + hyperlinkConfig: DEFAULT_HYPERLINK_CONFIG, + themeColors: undefined, + converters: {} as NestedConverters, + converterContext: defaultConverterContext, + enableComments: true, + }); + + expect(blocks).toHaveLength(1); + const paraBlock = blocks[0] as ParagraphBlock; + expect(paraBlock.runs[0].fontFamily).toBeDefined(); + expect(paraBlock.runs[0].fontSize).toBeDefined(); + // Should come from extractDefaultFontProperties (converterContext/docDefaults), not previous + expect(paraBlock.runs[0].fontFamily).not.toBe('CustomFont, sans-serif'); + }); + + it('ignores previousParagraphFont when paragraph has explicit run properties', () => { + const previousFont = { fontFamily: 'PreviousFont', fontSize: 10 }; + const paraWithExplicitRunProps: PMNode = { + ...emptyNumberedPara, + attrs: { + paragraphProperties: { + numberingProperties: { numId: 1, ilvl: 0 }, + runProperties: { fontFamily: { ascii: 'ExplicitFont' }, fontSize: 24 }, + }, + }, + }; + + vi.mocked(computeParagraphAttrs).mockReturnValue({ + paragraphAttrs: {}, + resolvedParagraphProperties: { + numberingProperties: { numId: 1, ilvl: 0 }, + runProperties: { fontFamily: { ascii: 'ExplicitFont' }, fontSize: 24 }, + }, + }); + + const blocks = baseParagraphToFlowBlocks({ + para: paraWithExplicitRunProps, + nextBlockId, + positions, + trackedChangesConfig: undefined, + bookmarks: new Map(), + hyperlinkConfig: DEFAULT_HYPERLINK_CONFIG, + themeColors: undefined, + converters: {} as NestedConverters, + converterContext: defaultConverterContext, + enableComments: true, + previousParagraphFont: previousFont, + }); + + expect(blocks).toHaveLength(1); + const paraBlock = blocks[0] as ParagraphBlock; + // Should use resolved run properties, not previousParagraphFont + expect(paraBlock.runs[0].fontFamily).toContain('ExplicitFont'); + expect(paraBlock.runs[0].fontSize).not.toBe(10); + }); + + it('uses previousParagraphFont when run properties are only inherited from styles', () => { + const previousFont = { fontFamily: 'PreviousFont', fontSize: 10 }; + vi.mocked(computeParagraphAttrs).mockReturnValue({ + paragraphAttrs: {}, + resolvedParagraphProperties: { + numberingProperties: { numId: 1, ilvl: 0 }, + runProperties: { fontFamily: { ascii: 'StyledFont' }, fontSize: 24 }, + }, + }); + + const blocks = baseParagraphToFlowBlocks({ + para: emptyNumberedPara, + nextBlockId, + positions, + trackedChangesConfig: undefined, + bookmarks: new Map(), + hyperlinkConfig: DEFAULT_HYPERLINK_CONFIG, + themeColors: undefined, + converters: {} as NestedConverters, + converterContext: defaultConverterContext, + enableComments: true, + previousParagraphFont: previousFont, + }); + + expect(blocks).toHaveLength(1); + const paraBlock = blocks[0] as ParagraphBlock; + expect(paraBlock.runs[0].fontFamily).toBe(previousFont.fontFamily); + expect(paraBlock.runs[0].fontSize).toBe(previousFont.fontSize); + }); }); it('should clone paragraph attrs for each paragraph block', () => { diff --git a/packages/layout-engine/pm-adapter/src/converters/paragraph.ts b/packages/layout-engine/pm-adapter/src/converters/paragraph.ts index 7e34f4be2f..5b1e3ea336 100644 --- a/packages/layout-engine/pm-adapter/src/converters/paragraph.ts +++ b/packages/layout-engine/pm-adapter/src/converters/paragraph.ts @@ -8,7 +8,15 @@ */ import type { ParagraphProperties, RunProperties } from '@superdoc/style-engine/ooxml'; -import type { FlowBlock, Run, TextRun, SdtMetadata, DrawingBlock, TrackedChangeMeta } from '@superdoc/contracts'; +import type { + FlowBlock, + ParagraphBlock, + Run, + TextRun, + SdtMetadata, + DrawingBlock, + TrackedChangeMeta, +} from '@superdoc/contracts'; import type { PMNode, PMMark, @@ -16,6 +24,7 @@ import type { ParagraphToFlowBlocksParams, BlockIdGenerator, PositionMap, + ParagraphFont, } from '../types.js'; import { getStableParagraphId, shiftCachedBlocks } from '../cache.js'; import type { ConverterContext } from '../converter-context.js'; @@ -25,7 +34,7 @@ import { trackedChangesCompatible, applyMarksToRun, collectTrackedChangeFromMark import { applyTrackedChangesModeToRuns } from '../tracked-changes.js'; import { textNodeToRun } from './inline-converters/text-run.js'; import { DEFAULT_HYPERLINK_CONFIG, TOKEN_INLINE_TYPES } from '../constants.js'; -import { computeRunAttrs } from '../attributes/paragraph.js'; +import { computeRunAttrs, hasExplicitParagraphRunProperties } from '../attributes/paragraph.js'; import { resolveRunProperties } from '@superdoc/style-engine/ooxml'; import { footnoteReferenceToBlock } from './inline-converters/footnote-reference.js'; import { endnoteReferenceToBlock } from './inline-converters/endnote-reference.js'; @@ -507,6 +516,7 @@ export function paragraphToFlowBlocks({ converterContext, enableComments = true, stableBlockId, + previousParagraphFont, }: ParagraphToFlowBlocksParams): FlowBlock[] { // Use stable ID if provided, otherwise fall back to generator const baseBlockId = stableBlockId ?? nextBlockId('paragraph'); @@ -522,7 +532,11 @@ export function paragraphToFlowBlocks({ typeof para.attrs?.paragraphProperties === 'object' && para.attrs.paragraphProperties !== null ? (para.attrs.paragraphProperties as ParagraphProperties) : {}; - const { paragraphAttrs, resolvedParagraphProperties } = computeParagraphAttrs(para, converterContext); + const { paragraphAttrs, resolvedParagraphProperties } = computeParagraphAttrs( + para, + converterContext, + previousParagraphFont, + ); const blocks: FlowBlock[] = []; const paraAttrs = (para.attrs ?? {}) as Record; @@ -532,7 +546,17 @@ export function paragraphToFlowBlocks({ : undefined; const hasSectPr = Boolean(rawParagraphProps?.sectPr); const isSectPrMarker = hasSectPr || paraAttrs.pageBreakSource === 'sectPr'; - const { defaultFont, defaultSize } = extractDefaultFontProperties(converterContext, resolvedParagraphProperties); + + // Extract font data for list items + const extracted = extractDefaultFontProperties(converterContext, resolvedParagraphProperties); + const usePreviousFont = + previousParagraphFont != null && + resolvedParagraphProperties.numberingProperties != null && + !hasExplicitParagraphRunProperties(paragraphProps); + const defaultFont = + usePreviousFont && previousParagraphFont.fontFamily ? previousParagraphFont.fontFamily : extracted.defaultFont; + const defaultSize = + usePreviousFont && previousParagraphFont.fontSize ? previousParagraphFont.fontSize : extracted.defaultSize; if (paragraphAttrs.pageBreakBefore) { blocks.push({ @@ -912,6 +936,32 @@ const SHAPE_CONVERTERS_REGISTRY: Record< chart: chartNodeToDrawingBlock, }; +/** + * Returns the font of the last paragraph block's first run in the given blocks array. + * Used to pass previous paragraph font into paragraphToFlowBlocks for new list items without explicit run properties. + * + * Only returns when the run has both valid fontFamily (non-empty string) and fontSize (positive finite number). + * If the latest paragraph's first run has partial or empty font info, the loop continues to the previous + * paragraph so callers never receive a partial object and can fall back to defaults consistently. + */ +export function getLastParagraphFont(blocks: FlowBlock[]): ParagraphFont | undefined { + for (let i = blocks.length - 1; i >= 0; i--) { + const block = blocks[i]; + if (block.kind === 'paragraph') { + const para = block as ParagraphBlock; + const firstRun = para.runs?.[0]; + if (!firstRun) continue; + const run = firstRun as { fontFamily?: string; fontSize?: number }; + const fontFamily = typeof run.fontFamily === 'string' ? run.fontFamily.trim() : ''; + const fontSize = typeof run.fontSize === 'number' && Number.isFinite(run.fontSize) ? run.fontSize : NaN; + if (fontFamily.length > 0 && fontSize > 0) { + return { fontFamily, fontSize }; + } + } + } + return undefined; +} + /** * Handle paragraph nodes. * Special handling: Emits section breaks BEFORE processing the paragraph @@ -971,6 +1021,12 @@ export function handleParagraphNode(node: PMNode, context: NodeHandlerContext): const { entry: cached, nodeJson, nodeRev } = flowBlockCache.get(prefixedStableId, node); if (cached) { // Cache hit: reuse blocks with position adjustment + // Cache hit reuses previously-converted blocks as-is. That means we don't + // recompute previousParagraphFont (used for empty list items without + // explicit run properties). If the user changes the font on the prior + // paragraph (e.g. paragraph A), an empty list item (paragraph B) can keep + // the old font until the cache entry is invalidated. Narrow case, but + // avoids confusing incremental-edit behavior. const delta = pmStart - cached.pmStart; const reusedBlocks = shiftCachedBlocks(cached.blocks, delta); applyTrackedGhostListAdjustments(node, reusedBlocks, context); @@ -987,6 +1043,7 @@ export function handleParagraphNode(node: PMNode, context: NodeHandlerContext): } // Cache miss: convert normally, then store using pre-computed nodeJson + const previousParagraphFont = getLastParagraphFont(blocks); const paragraphBlocks = paragraphToFlowBlocks({ para: node, nextBlockId, @@ -999,6 +1056,7 @@ export function handleParagraphNode(node: PMNode, context: NodeHandlerContext): converterContext, enableComments, stableBlockId: prefixedStableId, + previousParagraphFont, }); applyTrackedGhostListAdjustments(node, paragraphBlocks, context); @@ -1013,6 +1071,7 @@ export function handleParagraphNode(node: PMNode, context: NodeHandlerContext): return; } + const previousParagraphFont = getLastParagraphFont(blocks); const paragraphBlocks = paragraphToFlowBlocks({ para: node, nextBlockId, @@ -1025,6 +1084,7 @@ export function handleParagraphNode(node: PMNode, context: NodeHandlerContext): converterContext, enableComments, stableBlockId: prefixedStableId ?? undefined, + previousParagraphFont, }); applyTrackedGhostListAdjustments(node, paragraphBlocks, context); diff --git a/packages/layout-engine/pm-adapter/src/index.test.ts b/packages/layout-engine/pm-adapter/src/index.test.ts index 4048c97a9e..f1f110148d 100644 --- a/packages/layout-engine/pm-adapter/src/index.test.ts +++ b/packages/layout-engine/pm-adapter/src/index.test.ts @@ -118,6 +118,45 @@ describe('toFlowBlocks', () => { }); expect(blocks[0].runs[0]?.fontSize).toBeCloseTo(14, 5); }); + + it('uses previous paragraph font for empty numbered paragraph (new list item)', () => { + const pmDoc = { + type: 'doc', + content: [ + { + type: 'paragraph', + content: [{ type: 'text', text: 'First item' }], + }, + { + type: 'paragraph', + content: [], + attrs: { + paragraphProperties: { + numberingProperties: { numId: 1, ilvl: 0 }, + }, + }, + }, + ], + }; + + const { blocks } = toFlowBlocks(pmDoc, { + defaultFont: 'CustomListFont', + defaultSize: 13, + }); + + expect(blocks).toHaveLength(2); + const firstBlock = blocks[0]; + const secondBlock = blocks[1]; + expect(firstBlock.kind).toBe('paragraph'); + expect(secondBlock.kind).toBe('paragraph'); + expect((secondBlock as { runs: Array<{ fontFamily?: string; fontSize?: number }> }).runs).toHaveLength(1); + + const firstFont = (firstBlock as { runs: Array<{ fontFamily?: string; fontSize?: number }> }).runs[0]; + const secondFont = (secondBlock as { runs: Array<{ fontFamily?: string; fontSize?: number }> }).runs[0]; + expect(firstFont.fontFamily).toBeDefined(); + expect(secondFont.fontFamily).toBe(firstFont.fontFamily); + expect(secondFont.fontSize).toBe(firstFont.fontSize); + }); }); describe('mark mapping', () => { diff --git a/packages/layout-engine/pm-adapter/src/types.ts b/packages/layout-engine/pm-adapter/src/types.ts index 8b44501b1e..5a98b205ed 100644 --- a/packages/layout-engine/pm-adapter/src/types.ts +++ b/packages/layout-engine/pm-adapter/src/types.ts @@ -341,6 +341,8 @@ export type ParagraphToFlowBlocksParams = { enableComments: boolean; converterContext: ConverterContext; stableBlockId?: string; + /** When set, used as default/marker font for list paragraphs that have no explicit run properties (e.g. new list item after Enter). */ + previousParagraphFont?: ParagraphFont; }; export type TableNodeToBlockParams = { @@ -416,3 +418,8 @@ export interface OoxmlBorder { * Underline style type derived from TextRun contract */ export type UnderlineStyle = NonNullable['style']; + +export type ParagraphFont = { + fontFamily: string; + fontSize: number; +}; diff --git a/tests/behavior/tests/lists/list-marker-font-inheritance.spec.ts b/tests/behavior/tests/lists/list-marker-font-inheritance.spec.ts new file mode 100644 index 0000000000..277548604c --- /dev/null +++ b/tests/behavior/tests/lists/list-marker-font-inheritance.spec.ts @@ -0,0 +1,56 @@ +import { test, expect } from '../../fixtures/superdoc.js'; +import { createOrderedList, LIST_MARKER_SELECTOR } from '../../helpers/lists.js'; + +test.use({ config: { toolbar: 'full' } }); + +/** + * Helper: get the computed font-family of a list marker by index. + * DomPainter renders markers as .superdoc-paragraph-marker — CSS is the + * authoritative source for visual font since the layout engine sets it. + */ +async function getMarkerFontFamily( + superdoc: Parameters[2]>[0]['superdoc'], + markerIndex: number, +): Promise { + return superdoc.page.evaluate((idx) => { + const markers = document.querySelectorAll('.superdoc-paragraph-marker'); + const marker = markers[idx]; + if (!marker) throw new Error(`Marker at index ${idx} not found`); + return getComputedStyle(marker).fontFamily; + }, markerIndex); +} + +test('new empty list item marker inherits font from previous paragraph', async ({ superdoc }) => { + // Create a 2-item ordered list and change text font to Georgia. + // The toolbar applies a textStyle mark on the text runs — this does NOT + // change existing marker fonts (markers resolve from the numbering cascade). + // But previousParagraphFont reads the first run's resolved font, so a new + // empty list item should inherit Georgia for its marker. + await createOrderedList(superdoc, ['first item', 'second item']); + await superdoc.waitForStable(); + + await superdoc.selectAll(); + await superdoc.waitForStable(); + await superdoc.page.locator('[data-item="btn-fontFamily"]').click(); + await superdoc.page.locator('[data-item="btn-fontFamily-option"]').filter({ hasText: 'Georgia' }).click(); + await superdoc.waitForStable(); + + // Verify the text itself is in Georgia + await superdoc.assertTextMarkAttrs('first item', 'textStyle', { fontFamily: 'Georgia' }); + + // Place cursor at end of last item and press Enter to create a new empty item + const pos = await superdoc.findTextPos('second item'); + await superdoc.setTextSelection(pos + 'second item'.length); + await superdoc.waitForStable(); + await superdoc.newLine(); + await superdoc.waitForStable(); + + // Should now have 3 markers + const markerCount = await superdoc.page.locator(LIST_MARKER_SELECTOR).count(); + expect(markerCount).toBe(3); + + // The new (third) marker should inherit Georgia from the previous paragraph's + // text run, not fall back to the document default (Arial). + const newMarkerFont = await getMarkerFontFamily(superdoc, 2); + expect(newMarkerFont.toLowerCase()).toContain('georgia'); +});