diff --git a/.changeset/preview-resolve-family.md b/.changeset/preview-resolve-family.md new file mode 100644 index 000000000..8351c166a --- /dev/null +++ b/.changeset/preview-resolve-family.md @@ -0,0 +1,7 @@ +--- +'@office-kit/pptx-preview': minor +--- + +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 0c44b5c5c..bd5b2991f 100644 --- a/packages/preview/README.md +++ b/packages/preview/README.md @@ -42,6 +42,28 @@ 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 +(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'; + +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/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 398ea3c3a..5b1fa1a9f 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, @@ -213,6 +214,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 @@ -284,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 @@ -1639,12 +1648,16 @@ 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. */ 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 @@ -1726,6 +1739,15 @@ const smallCapsParts = ( return parts; }; +// 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) }; +}; + // Build the px-native engine input from the resolved paraData (at a.autoFitScale). export const buildSvgTextInput = (a: SvgTextArgs): TextBodyInput => { const scale = a.autoFitScale; @@ -1736,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, @@ -1755,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 @@ -1772,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, @@ -1999,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 } : {}), @@ -2065,6 +2087,7 @@ export const resolveTextBodyModel = ( measure: TextMeasurer, defaultColor: string, useSvgTextRect = false, + resolveFamily: (family: string | null) => string = substituteFamily, ): TextBodyModel | null => { let paragraphCount: number; try { @@ -2376,6 +2399,7 @@ export const resolveTextBodyModel = ( measure, vert: fitVert, columns: fitColumns, + resolveFamily, }; let s = 1; while (s > AUTOFIT_FLOOR) { @@ -2414,6 +2438,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 +2697,7 @@ const renderTextBody = ( ctx.measure, activeDeckTextColor, ctx.mode === 'svg' && output === 'body', + ctx.resolveFamily, ); if (model === null) return ''; const bevel = textBevelOf(getShapeText3D(shape)); @@ -2753,7 +2779,7 @@ const renderTextBody = ( innerW: vInnerW, innerH: vInnerH, measure: ctx.mode === 'svg' ? ctx.measure : (browserTextMeasurer() ?? ctx.measure), - ...(ctx.mode === 'foreignObject' ? { resolveFamily: browserFontFamily } : {}), + ...(ctx.mode === 'foreignObject' ? BROWSER_FAMILIES : { resolveFamily: ctx.resolveFamily }), vert: svgVert, columns: svgColumns, bevel, @@ -2763,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)) @@ -2872,7 +2898,7 @@ const renderTextBody = ( vert: svgVert, columns: null, effectsOnly: true, - resolveFamily: browserFontFamily, + ...BROWSER_FAMILIES, bevel, }; const hasCharacterReflection = paraData.some((para) => @@ -2938,6 +2964,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 +3005,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 +5723,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 } : {}), + ...(ctx.mode === 'foreignObject' ? BROWSER_FAMILIES : { resolveFamily: ctx.resolveFamily }), vert: verticalLayoutOf(getTableCellTextDirection(cell)), columns: null, ...(effectsOnly ? { effectsOnly: true } : {}), @@ -7093,6 +7121,10 @@ const renderSurface = ( groupReflected: false, mode: opts.textLayout ?? 'foreignObject', measure: opts.measureText ?? defaultMeasurer, + // 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, 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..991449080 100644 --- a/packages/preview/src/text-layout.ts +++ b/packages/preview/src/text-layout.ts @@ -15,8 +15,10 @@ // 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`). The + * text audit passes the authored name instead. */ export interface FontSpec { readonly family: string; readonly sizePx: number; @@ -62,6 +64,17 @@ 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`. 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 + * 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; } // --------------------------------------------------------------------------- @@ -170,7 +183,9 @@ 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 readonly bold: boolean; readonly italic: boolean; @@ -302,6 +317,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 @@ -477,6 +494,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; +export 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); @@ -1042,7 +1069,7 @@ const emitPlacements = ( ); } else { parts.push( - `${escapeXml(b.text)}`, + `${escapeXml(b.text)}`, ); } } @@ -1711,7 +1738,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(p.cssFamily ?? 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 new file mode 100644 index 000000000..1682047ce --- /dev/null +++ b/test/preview-resolve-family.test.ts @@ -0,0 +1,201 @@ +// `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 { + auditTextLayout, + 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'; +// 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); + +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: LINES, + }); + 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('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(2), + h: inches(1), + 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', + }); + + 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]); + }); +}); diff --git a/test/preview-text-fill.test.ts b/test/preview-text-fill.test.ts index 089320638..0525d0cc3 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('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); + const after = svg.slice(svg.indexOf('')); + // 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"`, + ); + }); + 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) });