From 5d364d6568bd2fb2a05763447c6aea146438e084 Mon Sep 17 00:00:00 2001 From: baseballyama Date: Sat, 17 Oct 2026 04:52:10 +0900 Subject: [PATCH 1/5] feat(preview): add resolveFamily to keep custom font families in svg mode In `textLayout: 'svg'`, every family outside the bundled substitutes was written as Carlito, so a host embedding its own @font-face rules could not get its fonts applied. `RenderSlideOptions.resolveFamily` now maps each authored font name to the family written to the SVG and passed to `measureText`, including in the shrink-to-fit search. Closes #461 Co-Authored-By: Claude Opus 5.5 --- .changeset/preview-resolve-family.md | 5 + packages/preview/README.md | 19 ++++ packages/preview/src/render-slide.ts | 23 +++- packages/preview/src/text-layout.ts | 10 ++ test/preview-resolve-family.test.ts | 153 +++++++++++++++++++++++++++ 5 files changed, 206 insertions(+), 4 deletions(-) create mode 100644 .changeset/preview-resolve-family.md create mode 100644 test/preview-resolve-family.test.ts diff --git a/.changeset/preview-resolve-family.md b/.changeset/preview-resolve-family.md new file mode 100644 index 000000000..f2129478c --- /dev/null +++ b/.changeset/preview-resolve-family.md @@ -0,0 +1,5 @@ +--- +'@office-kit/pptx-preview': minor +--- + +Added `resolveFamily` to `RenderSlideOptions` (#461). With `textLayout: 'svg'`, every font other than the Calibri / Cambria / Arial / Times / Courier families used to be written to the SVG as `Carlito`, so a host that embeds its own `@font-face` rules (for example for a deck that uses Lora or Inter) could not get its font applied. `resolveFamily` maps each authored font name (or `null` when none applies) to the family written to `` and passed to `measureText`, and the shrink-to-fit search measures with it too. It defaults to `substituteFamily`, so output is unchanged unless you pass it. The `foreignObject` text path already keeps the authored name and ignores the option. diff --git a/packages/preview/README.md b/packages/preview/README.md index 0c44b5c5c..d21441cc8 100644 --- a/packages/preview/README.md +++ b/packages/preview/README.md @@ -42,6 +42,25 @@ const svg = renderSlideToSvg(pres, getSlides(pres)[0]); // → '…' (text laid out via — the browser wraps it) ``` +Pass `{ textLayout: 'svg', measureText }` to lay text out as pure SVG +`` instead of `` (for example when the SVG is shown as an +`` or sanitized). In that mode each run's font is replaced with one of the +bundled metric-compatible substitutes (`substituteFamily`: Carlito, Caladea, +Liberation Sans / Serif / Mono). To keep a family the viewer can load — say +through `@font-face` rules you embed in the SVG — pass `resolveFamily`, and a +`measureText` that knows that font's metrics so line breaks match its glyphs: + +```ts +import { renderSlideToSvg, substituteFamily } from '@office-kit/pptx-preview'; + +const svg = renderSlideToSvg(pres, slide, { + textLayout: 'svg', + measureText, + resolveFamily: (family) => + family === 'Lora' || family === 'Inter' ? family : substituteFamily(family), +}); +``` + `renderSlideLayoutToSvg(pres, layout, { master })` draws what a slide master editor shows behind the placeholders: the layout's background and decorative shapes over its master's (the master's are left out when the layout hides diff --git a/packages/preview/src/render-slide.ts b/packages/preview/src/render-slide.ts index 398ea3c3a..611dd3c14 100644 --- a/packages/preview/src/render-slide.ts +++ b/packages/preview/src/render-slide.ts @@ -213,6 +213,10 @@ export type { RenderSlideOptions, TextMeasurer, FontSpec, MeasureResult } from ' interface LayoutCtx { readonly mode: TextLayoutMode; readonly measure: TextMeasurer; + // Family mapping for text the SVG engine lays out in 'svg' mode, and for the + // autofit measurement shared by both modes. foreignObject text always uses + // `browserFontFamily`, a CSS stack that keeps the authored name. + readonly resolveFamily: (family: string | null) => string; // Product of the scale factors of every enclosing group (`` over // ``). Geometry scales with the group; text does not — the reference desktop app // and LibreOffice both keep a group child's glyphs at their authored size @@ -1639,8 +1643,9 @@ export interface SvgTextArgs { /** The text body's bevel, shaded over every run's glyphs. */ readonly bevel?: TextBevelInput | null; /** Maps an authored font name onto the family the measurer keys off. - * The render paths leave this unset (= `substituteFamily`, whose output - * must match the bundled TTFs' internal names for resvg). The audit path + * Unset means `substituteFamily`, whose output must match the bundled + * TTFs' internal names for resvg; svg-mode renders pass the host's + * `RenderSlideOptions.resolveFamily` when it has one. The audit path * passes the authored name through so a fontkit measurer with * user-registered fonts can resolve it before falling back to the * substitution map. */ @@ -2065,6 +2070,7 @@ export const resolveTextBodyModel = ( measure: TextMeasurer, defaultColor: string, useSvgTextRect = false, + resolveFamily: (family: string | null) => string = substituteFamily, ): TextBodyModel | null => { let paragraphCount: number; try { @@ -2376,6 +2382,7 @@ export const resolveTextBodyModel = ( measure, vert: fitVert, columns: fitColumns, + resolveFamily, }; let s = 1; while (s > AUTOFIT_FLOOR) { @@ -2414,6 +2421,7 @@ export const resolveTextBodyModel = ( measure, vert, autoFitScale, + resolveFamily, columns: vert === 'none' && cols && cols.count >= 2 ? { count: cols.count, gapPx: cols.gapEmu !== undefined ? cols.gapEmu / EMU_PER_PX : 0 } @@ -2672,6 +2680,7 @@ const renderTextBody = ( ctx.measure, activeDeckTextColor, ctx.mode === 'svg' && output === 'body', + ctx.resolveFamily, ); if (model === null) return ''; const bevel = textBevelOf(getShapeText3D(shape)); @@ -2753,7 +2762,7 @@ const renderTextBody = ( innerW: vInnerW, innerH: vInnerH, measure: ctx.mode === 'svg' ? ctx.measure : (browserTextMeasurer() ?? ctx.measure), - ...(ctx.mode === 'foreignObject' ? { resolveFamily: browserFontFamily } : {}), + resolveFamily: ctx.mode === 'foreignObject' ? browserFontFamily : ctx.resolveFamily, vert: svgVert, columns: svgColumns, bevel, @@ -2938,6 +2947,7 @@ export const renderTextEffectsSvg = ( groupReflected: false, mode: 'foreignObject', measure: opts.measureText ?? browserTextMeasurer() ?? defaultMeasurer, + resolveFamily: substituteFamily, ownShapes: true, background: { id: '', width: localBounds.w / EMU_PER_PX, height: localBounds.h / EMU_PER_PX }, inverseGroupTransform: '', @@ -2978,6 +2988,7 @@ const renderTableCellEffectsSvgInternal = ( groupReflected: false, mode: 'foreignObject', measure: opts.measureText ?? browserTextMeasurer() ?? defaultMeasurer, + resolveFamily: substituteFamily, ownShapes: true, background: { id: '', width: localBounds.w / EMU_PER_PX, height: localBounds.h / EMU_PER_PX }, inverseGroupTransform: '', @@ -5695,7 +5706,7 @@ const renderTableCellText = ( innerW: innerW * EMU_PER_PX, innerH: innerH * EMU_PER_PX, measure: ctx.mode === 'svg' ? ctx.measure : (browserTextMeasurer() ?? ctx.measure), - ...(ctx.mode === 'foreignObject' ? { resolveFamily: browserFontFamily } : {}), + resolveFamily: ctx.mode === 'foreignObject' ? browserFontFamily : ctx.resolveFamily, vert: verticalLayoutOf(getTableCellTextDirection(cell)), columns: null, ...(effectsOnly ? { effectsOnly: true } : {}), @@ -7093,6 +7104,10 @@ const renderSurface = ( groupReflected: false, mode: opts.textLayout ?? 'foreignObject', measure: opts.measureText ?? defaultMeasurer, + // The host's mapping is svg-only: in foreignObject mode the shared + // autofit measurement keeps the default, so both modes shrink alike. + resolveFamily: + opts.textLayout === 'svg' ? (opts.resolveFamily ?? substituteFamily) : substituteFamily, ownShapes: true, background: { id: '', width: W / EMU_PER_PX, height: H / EMU_PER_PX }, inverseGroupTransform: '', diff --git a/packages/preview/src/text-layout.ts b/packages/preview/src/text-layout.ts index 167479921..729f418b3 100644 --- a/packages/preview/src/text-layout.ts +++ b/packages/preview/src/text-layout.ts @@ -62,6 +62,16 @@ export interface RenderSlideOptions { /** `false` paints only the slide's own shapes on a transparent surface: * no background fill and no master or layout graphics. Defaults to `true`. */ readonly background?: boolean; + /** Maps an authored font name (a run's font, a bullet font, or the theme + * face; `null` when none applies) to the family written to the SVG's + * `` and handed to `measureText`. Only consulted when + * `textLayout` is `'svg'`; the foreignObject path writes the authored name + * into a CSS font stack itself. Defaults to `substituteFamily`, which maps + * every family to one of the bundled metric-compatible substitutes. Keep a + * family only when the SVG's viewer can load it (for example through an + * `@font-face` rule the host embeds), and pair it with a `measureText` that + * knows its metrics so line breaks match the glyphs. */ + readonly resolveFamily?: (family: string | null) => string; } // --------------------------------------------------------------------------- diff --git a/test/preview-resolve-family.test.ts b/test/preview-resolve-family.test.ts new file mode 100644 index 000000000..6109975fc --- /dev/null +++ b/test/preview-resolve-family.test.ts @@ -0,0 +1,153 @@ +// `RenderSlideOptions.resolveFamily` lets an svg-mode host keep font families +// it can supply itself (e.g. through embedded `@font-face` rules) instead of +// the bundled substitutes `substituteFamily` maps everything else to. + +import { readFile } from 'node:fs/promises'; +import { fileURLToPath } from 'node:url'; +import { describe, expect, it } from 'vitest'; +import { + addSlide, + addSlideTable, + addSlideTextBox, + findSlideLayout, + getTableCell, + inches, + loadPresentation, + setShapeTextAutoFit, + setShapeTextFormat, + setTableCellTextFormat, +} from '@office-kit/pptx'; +import { + defaultMeasurer, + renderSlideToSvg, + substituteFamily, + type FontSpec, + type TextMeasurer, +} from '../packages/preview/src/index.ts'; + +const fixturePath = fileURLToPath(new URL('./fixtures/minimal/blank.pptx', import.meta.url)); + +const CUSTOM = 'Lora'; +const keepCustom = (family: string | null): string => + family === CUSTOM ? family : substituteFamily(family); + +const deck = async () => { + const pres = await loadPresentation(await readFile(fixturePath)); + const slide = addSlide(pres, { layout: findSlideLayout(pres, 'Blank')! }); + return { pres, slide }; +}; + +const textFamilies = (svg: string): string[] => [ + ...new Set([...svg.matchAll(/<(?:text|tspan)[^>]*font-family="([^"]*)"/g)].map((m) => m[1]!)), +]; + +const recordingMeasurer = (): { measure: TextMeasurer; families: Set } => { + const families = new Set(); + return { + families, + measure: (text: string, spec: FontSpec) => { + families.add(spec.family); + return defaultMeasurer(text, spec); + }, + }; +}; + +describe('RenderSlideOptions.resolveFamily', () => { + it('substitutes unknown families by default in svg mode', async () => { + const { pres, slide } = await deck(); + const shape = addSlideTextBox(slide, { + x: inches(1), + y: inches(1), + w: inches(5), + h: inches(1), + text: 'Hello', + }); + setShapeTextFormat(shape, { font: CUSTOM }); + + expect(textFamilies(renderSlideToSvg(pres, slide, { textLayout: 'svg' }))).toEqual(['Carlito']); + }); + + it('writes and measures the family the host keeps', async () => { + const { pres, slide } = await deck(); + const custom = addSlideTextBox(slide, { + x: inches(1), + y: inches(1), + w: inches(5), + h: inches(1), + text: 'Hello', + }); + setShapeTextFormat(custom, { font: CUSTOM }); + const arial = addSlideTextBox(slide, { + x: inches(1), + y: inches(3), + w: inches(5), + h: inches(1), + text: 'World', + }); + setShapeTextFormat(arial, { font: 'Arial' }); + const { measure, families } = recordingMeasurer(); + + const svg = renderSlideToSvg(pres, slide, { + textLayout: 'svg', + measureText: measure, + resolveFamily: keepCustom, + }); + + expect(textFamilies(svg).sort()).toEqual(['Liberation Sans', CUSTOM]); + expect([...families].sort()).toEqual(['Liberation Sans', CUSTOM]); + }); + + it('applies to table cell text', async () => { + const { pres, slide } = await deck(); + const table = addSlideTable(slide, { + x: inches(1), + y: inches(1), + w: inches(4), + h: inches(1), + rows: [['a', 'b']], + }); + setTableCellTextFormat(getTableCell(table, 0, 0), { font: CUSTOM }); + + const svg = renderSlideToSvg(pres, slide, { textLayout: 'svg', resolveFamily: keepCustom }); + + expect(textFamilies(svg)).toContain(CUSTOM); + }); + + it('measures the shrink-to-fit search with the resolved family', async () => { + const { pres, slide } = await deck(); + const shape = addSlideTextBox(slide, { + x: inches(1), + y: inches(1), + w: inches(2), + h: inches(1), + text: Array.from({ length: 8 }, (_, i) => `Line ${i + 1}`).join('\n'), + }); + setShapeTextFormat(shape, { font: CUSTOM }); + setShapeTextAutoFit(shape, 'normal'); + const { measure, families } = recordingMeasurer(); + + renderSlideToSvg(pres, slide, { + textLayout: 'svg', + measureText: measure, + resolveFamily: () => CUSTOM, + }); + + expect([...families]).toEqual([CUSTOM]); + }); + + it('is ignored by the foreignObject path, which keeps the authored name itself', async () => { + const { pres, slide } = await deck(); + const shape = addSlideTextBox(slide, { + x: inches(1), + y: inches(1), + w: inches(5), + h: inches(1), + text: 'Hello', + }); + setShapeTextFormat(shape, { font: CUSTOM }); + + const plain = renderSlideToSvg(pres, slide); + expect(renderSlideToSvg(pres, slide, { resolveFamily: () => 'Other' })).toBe(plain); + expect(plain).toContain(`font-family:${CUSTOM}, Calibri`); + }); +}); From 6ff044e84ad01ae9f03a6869bcf3948cec99c1e3 Mon Sep 17 00:00:00 2001 From: baseballyama Date: Sat, 17 Oct 2026 04:56:28 +0900 Subject: [PATCH 2/5] fix(preview): quote kept families and fit the audit with the authored face A family returned by `resolveFamily` that is not a sequence of CSS identifiers (e.g. "M PLUS 1p") made the font-family value invalid, so it is now quoted in the SVG. The audit's shrink-to-fit search now measures with the same authored family its layout pass uses. Co-Authored-By: Claude Opus 5.5 --- .changeset/preview-resolve-family.md | 4 +- packages/preview/README.md | 5 ++- packages/preview/src/audit.ts | 2 + packages/preview/src/autofit.ts | 4 +- packages/preview/src/index.ts | 9 +++-- packages/preview/src/render-slide.ts | 4 +- packages/preview/src/text-layout.ts | 22 ++++++++--- test/preview-resolve-family.test.ts | 58 +++++++++++++++++++++++++--- 8 files changed, 89 insertions(+), 19 deletions(-) diff --git a/.changeset/preview-resolve-family.md b/.changeset/preview-resolve-family.md index f2129478c..8351c166a 100644 --- a/.changeset/preview-resolve-family.md +++ b/.changeset/preview-resolve-family.md @@ -2,4 +2,6 @@ '@office-kit/pptx-preview': minor --- -Added `resolveFamily` to `RenderSlideOptions` (#461). With `textLayout: 'svg'`, every font other than the Calibri / Cambria / Arial / Times / Courier families used to be written to the SVG as `Carlito`, so a host that embeds its own `@font-face` rules (for example for a deck that uses Lora or Inter) could not get its font applied. `resolveFamily` maps each authored font name (or `null` when none applies) to the family written to `` and passed to `measureText`, and the shrink-to-fit search measures with it too. It defaults to `substituteFamily`, so output is unchanged unless you pass it. The `foreignObject` text path already keeps the authored name and ignores the option. +Added `resolveFamily` to `RenderSlideOptions` (#461). With `textLayout: 'svg'`, each run's font is written to the SVG as one of the bundled substitutes (Calibri / Aptos and unknown fonts as `Carlito`, Cambria as `Caladea`, Arial / Helvetica as `Liberation Sans`, Times as `Liberation Serif`, Courier / Consolas as `Liberation Mono`), so a host that embeds its own `@font-face` rules — for a deck that uses Lora or Inter, say — could not get its font applied. `resolveFamily` maps each authored font name (or `null` when none applies) to the family written to `` and passed to `measureText`; the shrink-to-fit search measures with it too, and names that are not plain CSS identifiers (such as `M PLUS 1p`) are quoted. It defaults to `substituteFamily`, so output is unchanged unless you pass it. The `foreignObject` text path already keeps the authored name and ignores the option. + +Fixed: `auditTextLayout` sized a bare `` body's shrink with the substitute font while laying it out with the authored one, so with a `measureText` that registers that font the audit could report overflow (or miss it) for text the shrink was sized for. diff --git a/packages/preview/README.md b/packages/preview/README.md index d21441cc8..bd5b2991f 100644 --- a/packages/preview/README.md +++ b/packages/preview/README.md @@ -48,7 +48,10 @@ Pass `{ textLayout: 'svg', measureText }` to lay text out as pure SVG bundled metric-compatible substitutes (`substituteFamily`: Carlito, Caladea, Liberation Sans / Serif / Mono). To keep a family the viewer can load — say through `@font-face` rules you embed in the SVG — pass `resolveFamily`, and a -`measureText` that knows that font's metrics so line breaks match its glyphs: +`measureText` that knows that font's metrics so line breaks match its glyphs +(in a browser, for example, one that wraps `CanvasRenderingContext2D.measureText` +once the font has loaded). Return a single family name; the renderer quotes it +in the SVG when CSS requires that: ```ts import { renderSlideToSvg, substituteFamily } from '@office-kit/pptx-preview'; diff --git a/packages/preview/src/audit.ts b/packages/preview/src/audit.ts index 8a8dbd9b9..a0ae78994 100644 --- a/packages/preview/src/audit.ts +++ b/packages/preview/src/audit.ts @@ -121,6 +121,8 @@ const auditShape = ( getShapePlaceholderType(shape), measure, '#000000', // colors don't affect metrics + false, + passthroughFamily, ); if (model === null) return; diff --git a/packages/preview/src/autofit.ts b/packages/preview/src/autofit.ts index f611ce1db..9fcd0aae5 100644 --- a/packages/preview/src/autofit.ts +++ b/packages/preview/src/autofit.ts @@ -28,7 +28,9 @@ export interface ShapeAutoFitScaleOptions { }; /** Measurer for run widths, defaulting to the heuristic one the renderer * uses in a browser. Pass the same measurer the render call uses, or the - * two disagree about how much text fits. */ + * two disagree about how much text fits. Families are resolved with + * `substituteFamily`, as the foreignObject render does; an svg render + * with its own `resolveFamily` can measure a different face. */ readonly measureText?: TextMeasurer; } diff --git a/packages/preview/src/index.ts b/packages/preview/src/index.ts index 4ca186d83..635145620 100644 --- a/packages/preview/src/index.ts +++ b/packages/preview/src/index.ts @@ -28,10 +28,11 @@ export type { MeasureResult, } from './text-layout.ts'; -// The substitute family names a custom `measureText` must key off, plus the -// default (heuristic) measurer and the family-substitution map. Exposed so a -// caller supplying their own measurer can resolve the same families the -// emitter writes into the SVG. +// The substitute family names a custom `measureText` receives unless +// `resolveFamily` keeps another one, plus the default (heuristic) measurer and +// the family-substitution map. Exposed so a caller supplying their own +// measurer or `resolveFamily` can resolve the same families the emitter writes +// into the SVG. export { substituteFamily, defaultMeasurer, diff --git a/packages/preview/src/render-slide.ts b/packages/preview/src/render-slide.ts index 611dd3c14..6537fed97 100644 --- a/packages/preview/src/render-slide.ts +++ b/packages/preview/src/render-slide.ts @@ -7104,8 +7104,8 @@ const renderSurface = ( groupReflected: false, mode: opts.textLayout ?? 'foreignObject', measure: opts.measureText ?? defaultMeasurer, - // The host's mapping is svg-only: in foreignObject mode the shared - // autofit measurement keeps the default, so both modes shrink alike. + // The host's mapping is svg-only, so foreignObject output (including its + // autofit, measured with the SVG engine) never depends on it. resolveFamily: opts.textLayout === 'svg' ? (opts.resolveFamily ?? substituteFamily) : substituteFamily, ownShapes: true, diff --git a/packages/preview/src/text-layout.ts b/packages/preview/src/text-layout.ts index 729f418b3..5e1445234 100644 --- a/packages/preview/src/text-layout.ts +++ b/packages/preview/src/text-layout.ts @@ -15,8 +15,9 @@ // Public injection contract. /** What a measurer needs to size one run. Pixels at 96 DPI; the caller has - * already applied EMU→px, pt→px and the autofit fontScale. `family` is the - * resolved internal family name (see `substituteFamily`), not a CSS list. */ + * already applied EMU→px, pt→px and the autofit fontScale. `family` is a + * single family name, not a CSS list: what `RenderSlideOptions.resolveFamily` + * returned, by default a bundled substitute (see `substituteFamily`). */ export interface FontSpec { readonly family: string; readonly sizePx: number; @@ -64,7 +65,8 @@ export interface RenderSlideOptions { readonly background?: boolean; /** Maps an authored font name (a run's font, a bullet font, or the theme * face; `null` when none applies) to the family written to the SVG's - * `` and handed to `measureText`. Only consulted when + * `` and handed to `measureText`. Return one family name, + * unquoted; the renderer quotes it in the SVG when CSS requires that. Only consulted when * `textLayout` is `'svg'`; the foreignObject path writes the authored name * into a CSS font stack itself. Defaults to `substituteFamily`, which maps * every family to one of the bundled metric-compatible substitutes. Keep a @@ -487,6 +489,16 @@ const bulletSpec = (b: BulletInput): FontSpec => ({ const escapeXml = (s: string): string => s.replace(/[&<>"]/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"' })[c] ?? c); +// An unquoted `font-family` must be a sequence of CSS identifiers. Any other +// name (a word starting with a digit as in "M PLUS 1p", a comma, …) makes the +// whole value invalid, and the viewer silently falls back to its default +// font. The bundled substitute names are plain identifiers and stay unquoted. +const CSS_IDENT = /^-?[A-Za-z_\u0080-\u{10FFFF}][\w\u0080-\u{10FFFF}-]*$/u; +const cssFamilyName = (family: string): string => + family.split(' ').every((word) => CSS_IDENT.test(word)) + ? family + : `"${family.replace(/["\\]/g, '\\$&')}"`; + const fmt = (n: number): string => { const r = Math.round(n * 100) / 100; return Object.is(r, -0) ? '0' : String(r); @@ -1052,7 +1064,7 @@ const emitPlacements = ( ); } else { parts.push( - `${escapeXml(b.text)}`, + `${escapeXml(b.text)}`, ); } } @@ -1721,7 +1733,7 @@ const tspan = (g: Group): string => { const p = g.piece; const sizePx = renderedSizePxOf(p); const attrs: string[] = [ - `font-family="${escapeXml(p.family)}"`, + `font-family="${escapeXml(cssFamilyName(p.family))}"`, `font-size="${fmt(sizePx)}"`, `fill="${p.fillHex}"`, ]; diff --git a/test/preview-resolve-family.test.ts b/test/preview-resolve-family.test.ts index 6109975fc..1682047ce 100644 --- a/test/preview-resolve-family.test.ts +++ b/test/preview-resolve-family.test.ts @@ -18,6 +18,7 @@ import { setTableCellTextFormat, } from '@office-kit/pptx'; import { + auditTextLayout, defaultMeasurer, renderSlideToSvg, substituteFamily, @@ -28,6 +29,8 @@ import { const fixturePath = fileURLToPath(new URL('./fixtures/minimal/blank.pptx', import.meta.url)); const CUSTOM = 'Lora'; +// Far more than a 2×1 in box holds, so a normAutofit body runs the shrink search. +const LINES = Array.from({ length: 8 }, (_, i) => `Line ${i + 1}`).join('\n'); const keepCustom = (family: string | null): string => family === CUSTOM ? family : substituteFamily(family); @@ -120,7 +123,7 @@ describe('RenderSlideOptions.resolveFamily', () => { y: inches(1), w: inches(2), h: inches(1), - text: Array.from({ length: 8 }, (_, i) => `Line ${i + 1}`).join('\n'), + text: LINES, }); setShapeTextFormat(shape, { font: CUSTOM }); setShapeTextAutoFit(shape, 'normal'); @@ -135,19 +138,64 @@ describe('RenderSlideOptions.resolveFamily', () => { expect([...families]).toEqual([CUSTOM]); }); + it('quotes a kept family that is not a plain CSS identifier sequence', async () => { + const { pres, slide } = await deck(); + addSlideTextBox(slide, { x: inches(1), y: inches(1), w: inches(5), h: inches(1), text: 'x' }); + const { measure, families } = recordingMeasurer(); + + const svg = renderSlideToSvg(pres, slide, { + textLayout: 'svg', + measureText: measure, + resolveFamily: () => 'M PLUS 1p', + }); + + // Unquoted, "1p" is not an identifier and the browser drops the whole value. + expect(svg).toContain('font-family=""M PLUS 1p""'); + expect([...families]).toEqual(['M PLUS 1p']); + }); + it('is ignored by the foreignObject path, which keeps the authored name itself', async () => { const { pres, slide } = await deck(); const shape = addSlideTextBox(slide, { x: inches(1), y: inches(1), - w: inches(5), + w: inches(2), h: inches(1), - text: 'Hello', + text: LINES, }); setShapeTextFormat(shape, { font: CUSTOM }); + // The shrink-to-fit search measures with the SVG engine in this mode too. + setShapeTextAutoFit(shape, 'normal'); + const { measure, families } = recordingMeasurer(); + + const plain = renderSlideToSvg(pres, slide, { measureText: measure }); + const withOption = renderSlideToSvg(pres, slide, { + measureText: measure, + resolveFamily: () => 'Other', + }); - const plain = renderSlideToSvg(pres, slide); - expect(renderSlideToSvg(pres, slide, { resolveFamily: () => 'Other' })).toBe(plain); + expect(withOption).toBe(plain); + expect(families.has('Other')).toBe(false); expect(plain).toContain(`font-family:${CUSTOM}, Calibri`); }); }); + +describe('auditTextLayout', () => { + it('fits a normAutofit body with the authored family, as it lays it out', async () => { + const { pres, slide } = await deck(); + const shape = addSlideTextBox(slide, { + x: inches(1), + y: inches(1), + w: inches(2), + h: inches(1), + text: LINES, + }); + setShapeTextFormat(shape, { font: CUSTOM }); + setShapeTextAutoFit(shape, 'normal'); + const { measure, families } = recordingMeasurer(); + + auditTextLayout(pres, { measureText: measure }); + + expect([...families]).toEqual([CUSTOM]); + }); +}); From ca2d8b1b090fdea03ae0c43c5d18c21bde915958 Mon Sep 17 00:00:00 2001 From: baseballyama Date: Sat, 17 Oct 2026 05:03:02 +0900 Subject: [PATCH 3/5] fix(preview): keep the CSS font stack on foreignObject effect overlays The previous commit quoted every family at emit time, which wrapped the browser path's whole CSS stack (used by gradient-fill, reflection and bevel overlays in default mode) as one family name. Quoting now happens where a single resolved name becomes a piece, and the browser stack is written verbatim. Co-Authored-By: Claude Opus 5.5 --- packages/preview/src/render-slide.ts | 33 +++++++++++++++++++++------- packages/preview/src/text-layout.ts | 10 ++++++--- test/preview-text-fill.test.ts | 12 ++++++++++ 3 files changed, 44 insertions(+), 11 deletions(-) diff --git a/packages/preview/src/render-slide.ts b/packages/preview/src/render-slide.ts index 6537fed97..896753e55 100644 --- a/packages/preview/src/render-slide.ts +++ b/packages/preview/src/render-slide.ts @@ -188,6 +188,7 @@ import { layoutCore, measureTextBodyHeight, substituteFamily, + cssFamilyName, type BulletInput, type ColumnLayout, type ParaInput, @@ -288,6 +289,10 @@ const DEFAULT_TITLE_PT = 44; const DEFAULT_FONT = "Calibri, 'Helvetica Neue', Arial, sans-serif"; const browserFontFamily = (family: string | null): string => family ? `${JSON.stringify(family)}, ${DEFAULT_FONT}` : DEFAULT_FONT; +// SVG text laid out beside foreignObject HTML (custom tabs, character effects) +// names its face with the same CSS stack, which is already a valid +// `font-family` value and is written verbatim. +const BROWSER_FAMILIES = { resolveFamily: browserFontFamily, familyIsCssList: true } as const; // Bullet font when no buFont is authored or inherited. The stock // template's master bodyStyle sets buFont="Arial", and its '•'/'◦' glyphs are // smaller and higher than the theme minor face (Calibri) — so Arial, not the @@ -1650,6 +1655,9 @@ export interface SvgTextArgs { * user-registered fonts can resolve it before falling back to the * substitution map. */ readonly resolveFamily?: (family: string | null) => string; + /** `resolveFamily` returns a CSS font-family list rather than one name, so + * it is written as is instead of being quoted as a single family. */ + readonly familyIsCssList?: boolean; } // Runs whose glyphs the SVG fill layer paints — a non-solid fill or a @@ -1732,6 +1740,15 @@ const smallCapsParts = ( }; // Build the px-native engine input from the resolved paraData (at a.autoFitScale). +// The face a piece or bullet is measured and written with. +const faceOf = ( + a: SvgTextArgs, + font: string | null, +): { readonly family: string; readonly cssFamily?: string } => { + const family = (a.resolveFamily ?? substituteFamily)(font); + return a.familyIsCssList ? { family } : { family, cssFamily: cssFamilyName(family) }; +}; + export const buildSvgTextInput = (a: SvgTextArgs): TextBodyInput => { const scale = a.autoFitScale; const paragraphs: ParaInput[] = a.paraData.map((para, pi): ParaInput => { @@ -1741,7 +1758,7 @@ export const buildSvgTextInput = (a: SvgTextArgs): TextBodyInput => { if (run.text === '\n') { pieces.push({ ...breakPiece(), - family: (a.resolveFamily ?? substituteFamily)(run.fmt?.font ?? a.themeFace), + ...faceOf(a, run.fmt?.font ?? a.themeFace), sizePx: run.sizePt * scale * PX_PER_PT, bold: run.fmt?.bold ?? false, italic: run.fmt?.italic ?? false, @@ -1760,7 +1777,7 @@ export const buildSvgTextInput = (a: SvgTextArgs): TextBodyInput => { underline: fmt?.underline ?? true, }; } - const family = (a.resolveFamily ?? substituteFamily)(fmt?.font ?? a.themeFace); + const face = faceOf(a, fmt?.font ?? a.themeFace); const sizePx = run.sizePt * scale * PX_PER_PT; const fillHex = fmt?.color !== undefined && fmt.color !== null @@ -1777,7 +1794,7 @@ export const buildSvgTextInput = (a: SvgTextArgs): TextBodyInput => { const cap = fmt?.cap; const caps = cap === 'all' || cap === 'small'; const base: Omit = { - family, + ...face, sizePx, bold: fmt?.bold ?? false, italic: fmt?.italic ?? false, @@ -2004,7 +2021,7 @@ const buildBullet = (a: SvgTextArgs, para: ParaData, pi: number): BulletInput | : (para.bulletDetail.font ?? DEFAULT_BULLET_FONT); return { text: char, - family: (a.resolveFamily ?? substituteFamily)(bulletFont), + ...faceOf(a, bulletFont), sizePx, fillHex, ...(para.bulletImageHref ? { imageHref: para.bulletImageHref } : {}), @@ -2762,7 +2779,7 @@ const renderTextBody = ( innerW: vInnerW, innerH: vInnerH, measure: ctx.mode === 'svg' ? ctx.measure : (browserTextMeasurer() ?? ctx.measure), - resolveFamily: ctx.mode === 'foreignObject' ? browserFontFamily : ctx.resolveFamily, + ...(ctx.mode === 'foreignObject' ? BROWSER_FAMILIES : { resolveFamily: ctx.resolveFamily }), vert: svgVert, columns: svgColumns, bevel, @@ -2772,7 +2789,7 @@ const renderTextBody = ( ...svgArgsBase, autoFitScale: authoredAutofit ? autoFitScale : 1, effectsOnly: true, - resolveFamily: browserFontFamily, + ...BROWSER_FAMILIES, }; const effects = [ paraData.some((para) => para.runs.some((run) => run.fmt?.reflection != null)) @@ -2881,7 +2898,7 @@ const renderTextBody = ( vert: svgVert, columns: null, effectsOnly: true, - resolveFamily: browserFontFamily, + ...BROWSER_FAMILIES, bevel, }; const hasCharacterReflection = paraData.some((para) => @@ -5706,7 +5723,7 @@ const renderTableCellText = ( innerW: innerW * EMU_PER_PX, innerH: innerH * EMU_PER_PX, measure: ctx.mode === 'svg' ? ctx.measure : (browserTextMeasurer() ?? ctx.measure), - resolveFamily: ctx.mode === 'foreignObject' ? browserFontFamily : ctx.resolveFamily, + ...(ctx.mode === 'foreignObject' ? BROWSER_FAMILIES : { resolveFamily: ctx.resolveFamily }), vert: verticalLayoutOf(getTableCellTextDirection(cell)), columns: null, ...(effectsOnly ? { effectsOnly: true } : {}), diff --git a/packages/preview/src/text-layout.ts b/packages/preview/src/text-layout.ts index 5e1445234..9873826c8 100644 --- a/packages/preview/src/text-layout.ts +++ b/packages/preview/src/text-layout.ts @@ -183,6 +183,8 @@ export type UnderlineStyle = export interface PieceInput { readonly text: string; readonly family: string; // internal substituted name + /** `family` as written to `font-family` when that differs (a quoted name). */ + readonly cssFamily?: string; readonly sizePx: number; // post-autofit readonly bold: boolean; readonly italic: boolean; @@ -314,6 +316,8 @@ export interface TextReflectionInput { export interface BulletInput { readonly text: string; readonly family: string; + /** `family` as written to `font-family` when that differs (a quoted name). */ + readonly cssFamily?: string; readonly sizePx: number; readonly fillHex: string; // Data URL for a picture bullet (``); when set, the bullet @@ -494,7 +498,7 @@ const escapeXml = (s: string): string => // whole value invalid, and the viewer silently falls back to its default // font. The bundled substitute names are plain identifiers and stay unquoted. const CSS_IDENT = /^-?[A-Za-z_\u0080-\u{10FFFF}][\w\u0080-\u{10FFFF}-]*$/u; -const cssFamilyName = (family: string): string => +export const cssFamilyName = (family: string): string => family.split(' ').every((word) => CSS_IDENT.test(word)) ? family : `"${family.replace(/["\\]/g, '\\$&')}"`; @@ -1064,7 +1068,7 @@ const emitPlacements = ( ); } else { parts.push( - `${escapeXml(b.text)}`, + `${escapeXml(b.text)}`, ); } } @@ -1733,7 +1737,7 @@ const tspan = (g: Group): string => { const p = g.piece; const sizePx = renderedSizePxOf(p); const attrs: string[] = [ - `font-family="${escapeXml(cssFamilyName(p.family))}"`, + `font-family="${escapeXml(p.cssFamily ?? p.family)}"`, `font-size="${fmt(sizePx)}"`, `fill="${p.fillHex}"`, ]; diff --git a/test/preview-text-fill.test.ts b/test/preview-text-fill.test.ts index 089320638..a875743c0 100644 --- a/test/preview-text-fill.test.ts +++ b/test/preview-text-fill.test.ts @@ -152,6 +152,18 @@ describe('renderSlideToSvg: gradient and pattern text fills', () => { expect(after).toMatch(painted(id, 'Outline title')); }); + it('names the overlay glyphs with the same CSS font stack as the HTML', () => { + const { pres, slide, shape } = deck(nativeFormat('text-art-gray-gradient-shape.xml')); + setShapeTextFormat(shape, { font: 'M PLUS 1p' }); + const svg = renderSlideToSvg(pres, slide); + const after = svg.slice(svg.indexOf('')); + // A stack, not one quoted name, or the layer falls back to the viewer's + // default font and drifts from the HTML glyphs underneath. + expect(after).toContain( + `font-family=""M PLUS 1p", Calibri, 'Helvetica Neue', Arial, sans-serif"`, + ); + }); + it('includes the fill layer in the editor text-effects overlay', () => { const { pres, slide, shape } = deck(nativeFormat('text-art-white-pattern-shadow-shape.xml')); const overlay = renderTextEffectsSvg(pres, slide, shape, { w: inches(6), h: inches(2) }); From e5fff6517b2352d9cd4759b4ce719523d305dd75 Mon Sep 17 00:00:00 2001 From: baseballyama Date: Sat, 17 Oct 2026 05:09:37 +0900 Subject: [PATCH 4/5] chore(preview): tidy resolveFamily comments and test wording Co-Authored-By: Claude Opus 5.5 --- packages/preview/src/render-slide.ts | 2 +- packages/preview/src/text-layout.ts | 5 +++-- test/preview-text-fill.test.ts | 6 +++--- 3 files changed, 7 insertions(+), 6 deletions(-) diff --git a/packages/preview/src/render-slide.ts b/packages/preview/src/render-slide.ts index 896753e55..5b1fa1a9f 100644 --- a/packages/preview/src/render-slide.ts +++ b/packages/preview/src/render-slide.ts @@ -1739,7 +1739,6 @@ const smallCapsParts = ( return parts; }; -// Build the px-native engine input from the resolved paraData (at a.autoFitScale). // The face a piece or bullet is measured and written with. const faceOf = ( a: SvgTextArgs, @@ -1749,6 +1748,7 @@ const faceOf = ( return a.familyIsCssList ? { family } : { family, cssFamily: cssFamilyName(family) }; }; +// Build the px-native engine input from the resolved paraData (at a.autoFitScale). export const buildSvgTextInput = (a: SvgTextArgs): TextBodyInput => { const scale = a.autoFitScale; const paragraphs: ParaInput[] = a.paraData.map((para, pi): ParaInput => { diff --git a/packages/preview/src/text-layout.ts b/packages/preview/src/text-layout.ts index 9873826c8..991449080 100644 --- a/packages/preview/src/text-layout.ts +++ b/packages/preview/src/text-layout.ts @@ -17,7 +17,8 @@ /** What a measurer needs to size one run. Pixels at 96 DPI; the caller has * already applied EMU→px, pt→px and the autofit fontScale. `family` is a * single family name, not a CSS list: what `RenderSlideOptions.resolveFamily` - * returned, by default a bundled substitute (see `substituteFamily`). */ + * returned, by default a bundled substitute (see `substituteFamily`). The + * text audit passes the authored name instead. */ export interface FontSpec { readonly family: string; readonly sizePx: number; @@ -182,7 +183,7 @@ export type UnderlineStyle = export interface PieceInput { readonly text: string; - readonly family: string; // internal substituted name + readonly family: string; // what SvgTextArgs.resolveFamily returned /** `family` as written to `font-family` when that differs (a quoted name). */ readonly cssFamily?: string; readonly sizePx: number; // post-autofit diff --git a/test/preview-text-fill.test.ts b/test/preview-text-fill.test.ts index a875743c0..4a0965bb9 100644 --- a/test/preview-text-fill.test.ts +++ b/test/preview-text-fill.test.ts @@ -152,13 +152,13 @@ describe('renderSlideToSvg: gradient and pattern text fills', () => { expect(after).toMatch(painted(id, 'Outline title')); }); - it('names the overlay glyphs with the same CSS font stack as the HTML', () => { + it('writes the overlay's CSS font stack verbatim rather than quoting it as one name', () => { const { pres, slide, shape } = deck(nativeFormat('text-art-gray-gradient-shape.xml')); setShapeTextFormat(shape, { font: 'M PLUS 1p' }); const svg = renderSlideToSvg(pres, slide); const after = svg.slice(svg.indexOf('')); - // A stack, not one quoted name, or the layer falls back to the viewer's - // default font and drifts from the HTML glyphs underneath. + // Quoted as a single name, the stack would match no font, and the layer + // would fall back to the viewer's default and drift from the HTML glyphs. expect(after).toContain( `font-family=""M PLUS 1p", Calibri, 'Helvetica Neue', Arial, sans-serif"`, ); From e68e87964cc8760bcb1493cf6a7f6d266a9c1ee6 Mon Sep 17 00:00:00 2001 From: baseballyama Date: Sat, 17 Oct 2026 05:10:33 +0900 Subject: [PATCH 5/5] test(preview): fix overlay font stack test title Co-Authored-By: Claude Opus 5.5 --- test/preview-text-fill.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/preview-text-fill.test.ts b/test/preview-text-fill.test.ts index 4a0965bb9..0525d0cc3 100644 --- a/test/preview-text-fill.test.ts +++ b/test/preview-text-fill.test.ts @@ -152,7 +152,7 @@ describe('renderSlideToSvg: gradient and pattern text fills', () => { expect(after).toMatch(painted(id, 'Outline title')); }); - it('writes the overlay's CSS font stack verbatim rather than quoting it as one name', () => { + it('writes the overlay CSS font stack verbatim rather than quoting it as one name', () => { const { pres, slide, shape } = deck(nativeFormat('text-art-gray-gradient-shape.xml')); setShapeTextFormat(shape, { font: 'M PLUS 1p' }); const svg = renderSlideToSvg(pres, slide);