Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changeset/preview-resolve-family.md
Original file line number Diff line number Diff line change
@@ -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 `<text font-family>` 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 `<a:normAutofit/>` 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.
22 changes: 22 additions & 0 deletions packages/preview/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,28 @@ const svg = renderSlideToSvg(pres, getSlides(pres)[0]);
// → '<svg …>…</svg>' (text laid out via <foreignObject> — the browser wraps it)
```

Pass `{ textLayout: 'svg', measureText }` to lay text out as pure SVG
`<text>` instead of `<foreignObject>` (for example when the SVG is shown as an
`<img>` 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
Expand Down
2 changes: 2 additions & 0 deletions packages/preview/src/audit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,8 @@ const auditShape = (
getShapePlaceholderType(shape),
measure,
'#000000', // colors don't affect metrics
false,
passthroughFamily,
);
if (model === null) return;

Expand Down
4 changes: 3 additions & 1 deletion packages/preview/src/autofit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand Down
9 changes: 5 additions & 4 deletions packages/preview/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
52 changes: 42 additions & 10 deletions packages/preview/src/render-slide.ts
Original file line number Diff line number Diff line change
Expand Up @@ -188,6 +188,7 @@ import {
layoutCore,
measureTextBodyHeight,
substituteFamily,
cssFamilyName,
type BulletInput,
type ColumnLayout,
type ParaInput,
Expand All @@ -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 (`<a:ext>` over
// `<a:chExt>`). 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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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;
Expand All @@ -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,
Expand All @@ -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
Expand All @@ -1772,7 +1794,7 @@ export const buildSvgTextInput = (a: SvgTextArgs): TextBodyInput => {
const cap = fmt?.cap;
const caps = cap === 'all' || cap === 'small';
const base: Omit<PieceInput, 'text' | 'isBreak'> = {
family,
...face,
sizePx,
bold: fmt?.bold ?? false,
italic: fmt?.italic ?? false,
Expand Down Expand Up @@ -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 } : {}),
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -2376,6 +2399,7 @@ export const resolveTextBodyModel = (
measure,
vert: fitVert,
columns: fitColumns,
resolveFamily,
};
let s = 1;
while (s > AUTOFIT_FLOOR) {
Expand Down Expand Up @@ -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 }
Expand Down Expand Up @@ -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));
Expand Down Expand Up @@ -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,
Expand All @@ -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))
Expand Down Expand Up @@ -2872,7 +2898,7 @@ const renderTextBody = (
vert: svgVert,
columns: null,
effectsOnly: true,
resolveFamily: browserFontFamily,
...BROWSER_FAMILIES,
bevel,
};
const hasCharacterReflection = paraData.some((para) =>
Expand Down Expand Up @@ -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: '',
Expand Down Expand Up @@ -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: '',
Expand Down Expand Up @@ -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 } : {}),
Expand Down Expand Up @@ -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: '',
Expand Down
37 changes: 32 additions & 5 deletions packages/preview/src/text-layout.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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
* `<text font-family>` 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;
}

// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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 (`<a:buBlip>`); when set, the bullet
Expand Down Expand Up @@ -477,6 +494,16 @@ const bulletSpec = (b: BulletInput): FontSpec => ({
const escapeXml = (s: string): string =>
s.replace(/[&<>"]/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' })[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);
Expand Down Expand Up @@ -1042,7 +1069,7 @@ const emitPlacements = (
);
} else {
parts.push(
`<text x="${fmt(line.bullet.x + dx + GRID_NUDGE_X)}" y="${fmt(baselineY)}" font-family="${escapeXml(b.family)}" font-size="${fmt(b.sizePx)}" fill="${b.fillHex}" xml:space="preserve">${escapeXml(b.text)}</text>`,
`<text x="${fmt(line.bullet.x + dx + GRID_NUDGE_X)}" y="${fmt(baselineY)}" font-family="${escapeXml(b.cssFamily ?? b.family)}" font-size="${fmt(b.sizePx)}" fill="${b.fillHex}" xml:space="preserve">${escapeXml(b.text)}</text>`,
);
}
}
Expand Down Expand Up @@ -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}"`,
];
Expand Down
Loading
Loading