Skip to content
Merged
13 changes: 9 additions & 4 deletions packages/layout-engine/measuring/dom/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,7 @@ import {
import { resolveListTextStartPx, type MinimalMarker } from '@superdoc/common/list-marker-utils';
import { calculateRotatedBounds, normalizeRotation } from '@superdoc/geometry-utils';
import { toCssFontFamily } from '@superdoc/font-utils';
import { type FontMeasureContext, DEFAULT_FONT_MEASURE_CONTEXT } from '@superdoc/font-system';
import { DEFAULT_FONT_MEASURE_CONTEXT, type FaceKey, type FontMeasureContext } from '@superdoc/font-system';
export { installNodeCanvasPolyfill } from './setup.js';
import { clearMeasurementCache, getMeasuredTextWidth, setCacheSize } from './measurementCache.js';
import { getFontMetrics, clearFontMetricsCache, type FontInfo } from './fontMetricsCache.js';
Expand Down Expand Up @@ -306,6 +306,11 @@ function getCanvasContext(): CanvasRenderingContext2D {
return canvasContext;
}

/** The face (weight/style) a run renders at, for face-aware resolution. */
function faceOf(run: { bold?: boolean; italic?: boolean }): FaceKey {
return { weight: run.bold ? '700' : '400', style: run.italic ? 'italic' : 'normal' };
}

/**
* Build a CSS font string from Run styling properties
*
Expand All @@ -332,7 +337,7 @@ function buildFontString(
// (e.g. "Carlito") so text is MEASURED in the same font it is painted with, using THIS
// document's resolver so a per-document `fonts.map` is honored. The measure cache keys
// on this font string, so the physical family is in the key.
const physicalFamily = fontContext.resolvePhysical(run.fontFamily);
const physicalFamily = fontContext.resolvePhysical(run.fontFamily, faceOf(run));

if (measurementConfig.mode === 'deterministic') {
// Deterministic mode still flattens to one family for reproducible server-side
Expand Down Expand Up @@ -581,7 +586,7 @@ function lineHeightFontSize(run: TextRun): number {
*/
function getFontInfoFromRun(run: TextRun, fontContext: FontMeasureContext): FontInfo {
return {
fontFamily: normalizeFontFamily(fontContext.resolvePhysical(run.fontFamily)),
fontFamily: normalizeFontFamily(fontContext.resolvePhysical(run.fontFamily, faceOf(run))),
fontSize: normalizeFontSize(lineHeightFontSize(run)),
bold: run.bold,
italic: run.italic,
Expand Down Expand Up @@ -1966,7 +1971,7 @@ async function measureParagraphBlock(
: DEFAULT_FIELD_ANNOTATION_FONT_SIZE;
// Resolve to the physical render family (a per-document fonts.map or the bundled substitute),
// the same family the pill paints, so the measured pill width matches the painted glyphs.
const annotationFontFamily = fontContext.resolvePhysical(run.fontFamily || 'Arial, sans-serif');
const annotationFontFamily = fontContext.resolvePhysical(run.fontFamily || 'Arial, sans-serif', faceOf(run));

// Build font string for measurement
const fontWeight = run.bold ? 'bold' : 'normal';
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -805,7 +805,11 @@ function buildFontString(
if (run.italic) parts.push('italic');
if (run.bold) parts.push('bold');
parts.push(`${normalizeFontSize(run.fontSize)}px`);
const physicalFamily = normalizeFontFamily(fontContext.resolvePhysical(normalizeFontFamily(run.fontFamily)));
const face = {
weight: run.bold ? ('700' as const) : ('400' as const),
style: run.italic ? ('italic' as const) : ('normal' as const),
};
const physicalFamily = normalizeFontFamily(fontContext.resolvePhysical(normalizeFontFamily(run.fontFamily), face));
parts.push(toCssFontFamily(physicalFamily) ?? physicalFamily);
return parts.join(' ');
}
Expand Down
2 changes: 1 addition & 1 deletion packages/layout-engine/painters/dom/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -110,7 +110,7 @@ export type DomPainterOptions = {
* so glyph advances match the laid-out positions. Set per painter instance (per document) so two
* editors can map one logical family differently. Defaults to the global bundled resolver.
*/
resolvePhysical?: (cssFontFamily: string) => string;
resolvePhysical?: (cssFontFamily: string, face: { weight: '400' | '700'; style: 'normal' | 'italic' }) => string;
};

export type DomPainterHandle = {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { DOM_CLASS_NAMES } from '@superdoc/dom-contract';
import { toCssFontFamily } from '@superdoc/font-utils';
import { resolvePhysicalFamily } from '@superdoc/font-system';
import { resolvePhysicalFamily, type ResolvePhysicalFamily } from '@superdoc/font-system';
import type { ParagraphMeasure, ResolvedListMarkerItem, SourceAnchor } from '@superdoc/contracts';
import {
computeTabWidth,
Expand Down Expand Up @@ -89,7 +89,7 @@ export const createListMarkerElement = (
markerText: string,
run: MarkerRunStyle,
sourceAnchor?: SourceAnchor,
resolvePhysical: (cssFontFamily: string) => string = resolvePhysicalFamily,
resolvePhysical: ResolvePhysicalFamily = (css) => resolvePhysicalFamily(css),
): HTMLElement => {
const markerContainer = doc.createElement('span');
markerContainer.classList.add(DOM_CLASS_NAMES.LIST_MARKER);
Expand All @@ -103,7 +103,12 @@ export const createListMarkerElement = (
// Compose the Word fallback stack first, then let the resolver swap only the primary family.
// This keeps Times New Roman -> Liberation Serif on a serif fallback instead of inventing sans-serif.
const cssFontFamily = toCssFontFamily(run.fontFamily) ?? run.fontFamily ?? '';
markerEl.style.fontFamily = resolvePhysical(cssFontFamily);
// Resolve for the marker's ACTUAL face so a single-face substitute is not mis-mapped
// (e.g. a Bold marker on a Regular-only fallback) - matching how the marker text is measured.
markerEl.style.fontFamily = resolvePhysical(cssFontFamily, {
weight: run.bold ? '700' : '400',
style: run.italic ? 'italic' : 'normal',
});

if (run.fontSize != null) {
markerEl.style.fontSize = `${run.fontSize}px`;
Expand Down Expand Up @@ -145,7 +150,7 @@ export const renderLegacyListMarker = (params: {
firstLineIndentPx: number;
isRtl?: boolean;
sourceAnchor?: SourceAnchor;
resolvePhysical?: (cssFontFamily: string) => string;
resolvePhysical?: ResolvePhysicalFamily;
}): void => {
const {
doc,
Expand All @@ -159,7 +164,7 @@ export const renderLegacyListMarker = (params: {
firstLineIndentPx,
isRtl,
sourceAnchor,
resolvePhysical = resolvePhysicalFamily,
resolvePhysical = (css) => resolvePhysicalFamily(css),
} = params;
const markerTextWidth = markerTextWidthPx ?? markerMeasure?.markerTextWidth ?? 0;
const shouldUseSharedInlinePrefixGeometry =
Expand Down Expand Up @@ -262,9 +267,9 @@ export const renderResolvedListMarker = (params: {
marker: ResolvedListMarkerItem;
isRtl?: boolean;
sourceAnchor?: SourceAnchor;
resolvePhysical?: (cssFontFamily: string) => string;
resolvePhysical?: ResolvePhysicalFamily;
}): void => {
const { doc, lineEl, marker, isRtl, sourceAnchor, resolvePhysical = resolvePhysicalFamily } = params;
const { doc, lineEl, marker, isRtl, sourceAnchor, resolvePhysical } = params;
if (isRtl) {
lineEl.style.paddingRight = `${marker.firstLinePaddingLeftPx}px`;
} else {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ import {
sliceRunsForLine,
} from '@superdoc/contracts';
import { resolveMarkerIndent, type MinimalWordLayout } from '@superdoc/common/list-marker-utils';
import { resolvePhysicalFamily } from '@superdoc/font-system';
import { resolvePhysicalFamily, type ResolvePhysicalFamily } from '@superdoc/font-system';
import {
applySdtContainerChrome,
getSdtContainerMetadata,
Expand Down Expand Up @@ -106,7 +106,7 @@ export type RenderParagraphContentParams = {
* per-document resolver so a marker paints the same physical family it was measured in. Undefined
* (or omitted) falls back to the global resolver, matching text runs and field annotations.
*/
resolvePhysical?: (cssFontFamily: string) => string;
resolvePhysical?: ResolvePhysicalFamily;
captureLineSnapshot?: (
lineEl: HTMLElement,
options?: { inTableParagraph?: boolean; wrapperEl?: HTMLElement; sourceAnchor?: SourceAnchor },
Expand Down Expand Up @@ -479,7 +479,7 @@ const renderResolvedLines = (
convertFinalParagraphMark,
lineTopOffset = 0,
sourceAnchor,
resolvePhysical = resolvePhysicalFamily,
resolvePhysical = (css) => resolvePhysicalFamily(css),
} = params;
const renderedLines: RenderedParagraphLineInfo[] = [];
const resolvedMarker = content.marker;
Expand Down Expand Up @@ -563,7 +563,7 @@ const renderMeasuredLines = (
convertFinalParagraphMark,
lineTopOffset = 0,
sourceAnchor,
resolvePhysical = resolvePhysicalFamily,
resolvePhysical = (css) => resolvePhysicalFamily(css),
} = params;
const lines = linesOverride ?? measure.lines ?? [];
const paraIndent = block.attrs?.indent;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import type {
} from '@superdoc/contracts';
import { isMinimalWordLayout as isMinimalWordLayoutShared } from '@superdoc/common/list-marker-utils';
import type { MinimalWordLayout } from '@superdoc/common/list-marker-utils';
import { resolvePhysicalFamily } from '@superdoc/font-system';
import { resolvePhysicalFamily, type ResolvePhysicalFamily } from '@superdoc/font-system';
import { CLASS_NAMES, fragmentStyles } from '../styles.js';
import { shouldRenderSdtContainerChrome, type SdtBoundaryOptions } from '../sdt/container.js';
import type { BetweenBorderInfo } from './borders/index.js';
Expand Down Expand Up @@ -39,7 +39,7 @@ type RenderParagraphFragmentParams = {
* the renderer's per-document resolver so they paint the same physical family they were measured
* in. Undefined falls back to the global resolver, matching text runs and field annotations.
*/
resolvePhysical?: (cssFontFamily: string) => string;
resolvePhysical?: ResolvePhysicalFamily;
};

const isMinimalWordLayout = (value: unknown): value is MinimalWordLayout => isMinimalWordLayoutShared(value);
Expand All @@ -60,7 +60,7 @@ export const renderParagraphFragment = (params: RenderParagraphFragmentParams):
captureLineSnapshot,
createErrorPlaceholder,
contentControlsChrome,
resolvePhysical = resolvePhysicalFamily,
resolvePhysical = (css) => resolvePhysicalFamily(css),
} = params;

try {
Expand Down Expand Up @@ -157,7 +157,7 @@ const renderDropCap = (
doc: Document,
descriptor: DropCapDescriptor,
measure: ParagraphMeasure['dropCap'],
resolvePhysical: (cssFontFamily: string) => string = resolvePhysicalFamily,
resolvePhysical: ResolvePhysicalFamily = (css) => resolvePhysicalFamily(css),
): HTMLElement => {
const { run, mode } = descriptor;

Expand All @@ -166,9 +166,13 @@ const renderDropCap = (
dropCapEl.textContent = run.text;

// Paint the physical render family (a per-document fonts.map or the bundled substitute) - the
// same family the drop cap was measured in, so its box matches the laid-out geometry. Defaults
// to the global resolver when no per-document resolver is present (e.g. tests).
dropCapEl.style.fontFamily = resolvePhysical(run.fontFamily);
// same family the drop cap was measured in, so its box matches the laid-out geometry. Resolve for
// the drop cap's ACTUAL face so a single-face substitute is not mis-mapped. Defaults to the global
// resolver when no per-document resolver is present (e.g. tests).
dropCapEl.style.fontFamily = resolvePhysical(run.fontFamily, {
weight: run.bold ? '700' : '400',
style: run.italic ? 'italic' : 'normal',
});
dropCapEl.style.fontSize = `${run.fontSize}px`;
if (run.bold) {
dropCapEl.style.fontWeight = 'bold';
Expand Down
4 changes: 2 additions & 2 deletions packages/layout-engine/painters/dom/src/renderer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -248,8 +248,8 @@ type PainterOptions = {
showFormattingMarks?: boolean;
/** Built-in SDT chrome rendering mode. */
contentControlsChrome?: 'default' | 'none';
/** Per-document logical->physical font resolver; see DomPainterOptions.resolvePhysical. */
resolvePhysical?: (cssFontFamily: string) => string;
/** Per-document logical->physical font resolver (face-aware); see DomPainterOptions.resolvePhysical. */
resolvePhysical?: (cssFontFamily: string, face: { weight: '400' | '700'; style: 'normal' | 'italic' }) => string;
};

type FragmentDomState = {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,9 @@ import type { RunRenderContext } from './types.js';
* `doc`, `layoutEpoch`, `resolvePhysical`, and `applySdtDataset`; the rest are stubbed so the
* unit stays focused on font resolution.
*/
function makeContext(resolvePhysical: (cssFontFamily: string) => string): RunRenderContext {
function makeContext(
resolvePhysical: (cssFontFamily: string, face: { weight: '400' | '700'; style: 'normal' | 'italic' }) => string,
): RunRenderContext {
const doc = document.implementation.createHTMLDocument('field-annotation');
return {
doc,
Expand All @@ -33,7 +35,7 @@ describe('renderFieldAnnotationRun font resolution', () => {
it('paints a fontless annotation through the same fallback the measure path resolves', () => {
// The measure path resolves `run.fontFamily || 'Arial, sans-serif'`; paint must resolve the SAME
// fallback (not inherit host CSS) so the pill's painted glyphs match its measured width.
const resolvePhysical = vi.fn((family: string) =>
const resolvePhysical = vi.fn((family: string, _face: { weight: '400' | '700'; style: 'normal' | 'italic' }) =>
family === 'Arial, sans-serif' ? 'Liberation Sans, sans-serif' : family,
);
const run: FieldAnnotationRun = {
Expand All @@ -46,12 +48,14 @@ describe('renderFieldAnnotationRun font resolution', () => {

const el = renderFieldAnnotationRun(run, makeContext(resolvePhysical));

expect(resolvePhysical).toHaveBeenCalledWith('Arial, sans-serif');
expect(resolvePhysical).toHaveBeenCalledWith('Arial, sans-serif', { weight: '400', style: 'normal' });
expect(el?.style.fontFamily).toContain('Liberation Sans');
});

it('resolves an explicit logical family through the render-context resolver', () => {
const resolvePhysical = vi.fn((family: string) => (family === 'Calibri' ? 'Carlito' : family));
const resolvePhysical = vi.fn((family: string, _face: { weight: '400' | '700'; style: 'normal' | 'italic' }) =>
family === 'Calibri' ? 'Carlito' : family,
);
const run: FieldAnnotationRun = {
kind: 'fieldAnnotation',
variant: 'text',
Expand All @@ -63,7 +67,7 @@ describe('renderFieldAnnotationRun font resolution', () => {

const el = renderFieldAnnotationRun(run, makeContext(resolvePhysical));

expect(resolvePhysical).toHaveBeenCalledWith('Calibri');
expect(resolvePhysical).toHaveBeenCalledWith('Calibri', { weight: '400', style: 'normal' });
expect(el?.style.fontFamily).toContain('Carlito');
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,10 @@ export const renderFieldAnnotationRun = (run: FieldAnnotationRun, context: RunRe
// instead of inheriting host CSS and disagreeing with its measured width. Falls back to the
// global resolver when the render context has none (e.g. context-free paint in tests).
const resolvePhysical = context.resolvePhysical ?? resolvePhysicalFamily;
annotation.style.fontFamily = resolvePhysical(run.fontFamily || 'Arial, sans-serif');
annotation.style.fontFamily = resolvePhysical(run.fontFamily || 'Arial, sans-serif', {
weight: run.bold ? '700' : '400',
style: run.italic ? 'italic' : 'normal',
});
}
{
const fontSize = run.fontSize
Expand Down
10 changes: 8 additions & 2 deletions packages/layout-engine/painters/dom/src/runs/text-run.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,10 @@ export const applyRunStyles = (
element: HTMLElement,
run: Run,
_isLink = false,
resolvePhysical: (cssFontFamily: string) => string = resolvePhysicalFamily,
resolvePhysical: (
cssFontFamily: string,
face: { weight: '400' | '700'; style: 'normal' | 'italic' },
) => string = resolvePhysicalFamily,
): void => {
if (
run.kind === 'tab' ||
Expand All @@ -102,7 +105,10 @@ export const applyRunStyles = (
// text was measured in, so glyph advances match the laid-out positions. The resolver is the
// per-document one (passed by the caller from the render context), so two editors that map a
// logical family differently paint different physical families. Defaults to the global bundled.
element.style.fontFamily = resolvePhysical(run.fontFamily);
element.style.fontFamily = resolvePhysical(run.fontFamily, {
weight: run.bold ? '700' : '400',
style: run.italic ? 'italic' : 'normal',
});
element.style.fontSize = `${run.fontSize}px`;
if (run.bold) element.style.fontWeight = 'bold';
if (run.italic) element.style.fontStyle = 'italic';
Expand Down
8 changes: 6 additions & 2 deletions packages/layout-engine/painters/dom/src/runs/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,8 +26,12 @@ export type RunRenderContext = {
layoutEpoch: number;
showFormattingMarks: boolean;
contentControlsChrome: 'default' | 'none';
/** Per-document logical->physical font resolver. Undefined => global bundled default. */
resolvePhysical?: (cssFontFamily: string) => string;
/**
* Per-document logical->physical font resolver, FACE-aware: the substitute applies only when it
* provides the run's face (weight/style), else the logical family passes through (no faux-style).
* Undefined => global bundled default (family-level).
*/
resolvePhysical?: (cssFontFamily: string, face: { weight: '400' | '700'; style: 'normal' | 'italic' }) => string;
pendingTooltips: WeakMap<HTMLElement, string>;
getNextLinkId: () => string;
applySdtDataset: (el: HTMLElement | null, metadata?: SdtMetadata | null) => void;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import type {
WrapTextMode,
} from '@superdoc/contracts';
import { rescaleColumnWidths, normalizeZIndex, getCellSpacingPx } from '@superdoc/contracts';
import type { ResolvePhysicalFamily } from '@superdoc/font-system';
import type { MinimalWordLayout } from '@superdoc/common/list-marker-utils';
import type { FragmentRenderContext, RenderedLineInfo } from '../renderer.js';
import { applySquareWrapExclusionsToLines } from '../utils/anchor-helpers';
Expand Down Expand Up @@ -609,7 +610,7 @@ type TableCellRenderDependencies = {
* from the renderer's per-document resolver so they paint the same physical family they were
* measured in. Undefined falls back to the global resolver.
*/
resolvePhysical?: (cssFontFamily: string) => string;
resolvePhysical?: ResolvePhysicalFamily;
};

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import type {
TableMeasure,
} from '@superdoc/contracts';
import { getTableVisualDirection } from '@superdoc/contracts';
import type { ResolvePhysicalFamily } from '@superdoc/font-system';
import { CLASS_NAMES, fragmentStyles } from '../styles.js';
import { DOM_CLASS_NAMES } from '../constants.js';
import type { FragmentRenderContext } from '../renderer.js';
Expand Down Expand Up @@ -89,7 +90,7 @@ export type TableRenderDependencies = {
* from the renderer's per-document resolver so they paint the same physical family they were
* measured in. Undefined falls back to the global resolver.
*/
resolvePhysical?: (cssFontFamily: string) => string;
resolvePhysical?: ResolvePhysicalFamily;
};

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import type {
TableBorders,
TableMeasure,
} from '@superdoc/contracts';
import type { ResolvePhysicalFamily } from '@superdoc/font-system';
import { renderTableCell } from './renderTableCell.js';
import {
resolveTableCellBorders,
Expand Down Expand Up @@ -215,7 +216,7 @@ type TableRowRenderDependencies = {
* from the renderer's per-document resolver so they paint the same physical family they were
* measured in. Undefined falls back to the global resolver.
*/
resolvePhysical?: (cssFontFamily: string) => string;
resolvePhysical?: ResolvePhysicalFamily;
};

/**
Expand Down
Loading
Loading