diff --git a/.changeset/preview-raster-resolve-family.md b/.changeset/preview-raster-resolve-family.md new file mode 100644 index 000000000..c26a50edc --- /dev/null +++ b/.changeset/preview-raster-resolve-family.md @@ -0,0 +1,5 @@ +--- +'@office-kit/pptx-preview': minor +--- + +Added `resolveFamily` to the options of `renderSlideToImage` / `renderSlideToRgba`, the same mapping `renderSlideToSvg` already takes for `textLayout: 'svg'`. The PNG path mapped every authored font name to a bundled substitute, so a caller that measured a deck's font with its own face (for example a `buildFontkitMeasurer` alias from the deck's font to a face in `fontFiles`) got lines wrapped for that face but painted in the substitute. Return a family loaded through `fontFiles` to draw and measure the run in it. East Asian characters still take the family of the first `fontFiles` face that covers kana; `resolveFamily` decides the rest of the run. Unset, the output is unchanged. diff --git a/packages/preview/README.md b/packages/preview/README.md index 0d1199d67..34a33078d 100644 --- a/packages/preview/README.md +++ b/packages/preview/README.md @@ -116,6 +116,20 @@ regular and the bold one — and register them with `buildFontkitMeasurer({ font for the measurer. East Asian characters are drawn in the family of the first `fontFiles` face that covers kana, in the face closest to each run's weight. +Every authored font name is drawn in a bundled substitute unless you pass +`resolveFamily`. Map a deck's font to a family you load through `fontFiles` +(and register with the measurer) to draw the run in that face; East Asian +characters keep the kana face above, and `resolveFamily` decides the rest. + +```ts +const noto = 'fonts/NotoSansCJKjp-Regular.otf'; +const png = renderSlideToImage(pres, slide, { + fontFiles: [noto], + measureText: buildFontkitMeasurer({ fonts: [{ family: 'Noto Sans CJK JP', source: noto }] }), + resolveFamily: (family) => (family === 'Meiryo' ? 'Noto Sans CJK JP' : substituteFamily(family)), +}); +``` + ### Text-layout audit (overflow / soft-wrap detection) ```ts diff --git a/packages/preview/src/node.ts b/packages/preview/src/node.ts index 11256a24f..6f86299c9 100644 --- a/packages/preview/src/node.ts +++ b/packages/preview/src/node.ts @@ -12,7 +12,7 @@ import * as fontkit from 'fontkit'; import { existsSync, readFileSync } from 'node:fs'; import { getSlideSize, type PresentationData, type SlideData } from '@office-kit/pptx'; import { renderSlideSvg } from './render-slide.ts'; -import { MONO, SANS, SERIF, type TextMeasurer } from './text-layout.ts'; +import { MONO, SANS, SERIF, type RenderSlideOptions, type TextMeasurer } from './text-layout.ts'; import { buildFontkitMeasurer, FONT_FILES } from './measure.ts'; // Re-export the browser-safe surface so a Node consumer gets everything from @@ -61,6 +61,15 @@ export interface RenderImageOptions { * deterministic. */ readonly loadSystemFonts?: boolean; + /** + * Maps an authored font name to the family drawn and measured, as + * {@link RenderSlideOptions.resolveFamily}. Defaults to `substituteFamily`. + * Return a family loaded through `fontFiles` (and registered with the + * measurer) to draw a deck's own font instead of a bundled substitute. + * East Asian characters still take the family of the first `fontFiles` + * face that covers kana; this only decides the rest of the run. + */ + readonly resolveFamily?: RenderSlideOptions['resolveFamily']; } const DEFAULT_WIDTH = 1280; @@ -124,6 +133,7 @@ const rasterize = (pres: PresentationData, slide: SlideData, opts: RenderImageOp const svg = renderSlideSvg(pres, slide, { measureText, textLayout: 'svg', + ...(opts.resolveFamily === undefined ? {} : { resolveFamily: opts.resolveFamily }), ...(eastAsianFamily === undefined ? {} : { eastAsianFamily }), }); const resvg = new Resvg(svg, { diff --git a/test/preview-raster-resolve-family.test.ts b/test/preview-raster-resolve-family.test.ts new file mode 100644 index 000000000..6f343a423 --- /dev/null +++ b/test/preview-raster-resolve-family.test.ts @@ -0,0 +1,144 @@ +// `RenderImageOptions.resolveFamily` lets a PNG render draw a deck's font in a +// family the caller loaded, instead of the bundled substitute every authored +// name otherwise maps to, so the raster matches a measurer that knows that font. + +import { readFile } from 'node:fs/promises'; +import { fileURLToPath } from 'node:url'; +import { describe, expect, it } from 'vitest'; +import { + addSlide, + addSlideTextBox, + findSlideLayout, + inches, + loadPresentation, + setShapeTextFormat, +} from '@office-kit/pptx'; +import { + defaultMeasurer, + renderSlideToSvg, + substituteFamily, + type FontSpec, + type TextMeasurer, +} from '../packages/preview/src/index.ts'; +import { renderSlideToRgba, type RgbaImage } from '../packages/preview/src/node.ts'; + +const fixturePath = fileURLToPath(new URL('./fixtures/minimal/blank.pptx', import.meta.url)); + +const AUTHORED = 'Meiryo'; +// A bundled face standing in for one the caller adds through `fontFiles`, so +// the test needs no font outside the repository. +const LOADED = 'Liberation Mono'; +const EAST_ASIAN = 'Test Sans JP'; +const WIDTH = 960; +// Channel value below which a pixel counts as text ink on the white slide. +const INK_THRESHOLD = 128; + +const resolveAuthored = (family: string | null): string => + family === AUTHORED ? LOADED : substituteFamily(family); + +const deck = async (text: string) => { + const pres = await loadPresentation(await readFile(fixturePath)); + const slide = addSlide(pres, { layout: findSlideLayout(pres, 'Blank')! }); + const shape = addSlideTextBox(slide, { + x: inches(1), + y: inches(1), + w: inches(8), + h: inches(1), + text, + }); + setShapeTextFormat(shape, { font: AUTHORED, size: 28 }); + return { pres, slide }; +}; + +const inkBox = (image: RgbaImage) => { + let ink = 0; + let minX = image.width; + let maxX = -1; + for (let i = 0; i < image.data.length; i += 4) { + if (image.data[i]! < INK_THRESHOLD) { + ink++; + const x = (i / 4) % image.width; + minX = Math.min(minX, x); + maxX = Math.max(maxX, x); + } + } + return { ink, width: maxX - minX + 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('renderSlideToRgba resolveFamily', () => { + it('draws the run in the resolved family instead of the bundled substitute', async () => { + const { pres, slide } = await deck('Quarterly revenue grew'); + + const substituted = inkBox(renderSlideToRgba(pres, slide, { width: WIDTH }).image); + const resolved = inkBox( + renderSlideToRgba(pres, slide, { width: WIDTH, resolveFamily: resolveAuthored }).image, + ); + + // Liberation Mono's 0.6 em advance is far wider than Carlito's letters. + expect(resolved.width).toBeGreaterThan(substituted.width * 1.2); + expect(resolved.ink).not.toBe(substituted.ink); + }); + + it('measures the run with the resolved family', async () => { + const { pres, slide } = await deck('Quarterly revenue grew'); + const { measure, families } = recordingMeasurer(); + + renderSlideToRgba(pres, slide, { + width: WIDTH, + measureText: measure, + resolveFamily: resolveAuthored, + }); + + expect(families).toContain(LOADED); + expect(families).not.toContain(substituteFamily(AUTHORED)); + }); + + it('leaves the render unchanged when unset', async () => { + const { pres, slide } = await deck('Quarterly revenue grew'); + + const unset = renderSlideToRgba(pres, slide, { width: WIDTH }).png; + const substitute = renderSlideToRgba(pres, slide, { + width: WIDTH, + resolveFamily: substituteFamily, + }).png; + + expect(Buffer.from(substitute).equals(Buffer.from(unset))).toBe(true); + }); +}); + +describe('resolveFamily with eastAsianFamily', () => { + it('writes East Asian characters in eastAsianFamily and the rest in the resolved family', async () => { + const { pres, slide } = await deck('売上 Q3 は(前年比)+12%'); + const { measure, families } = recordingMeasurer(); + + const svg = renderSlideToSvg(pres, slide, { + textLayout: 'svg', + measureText: measure, + resolveFamily: resolveAuthored, + eastAsianFamily: EAST_ASIAN, + }); + const spans = [...svg.matchAll(/]*)>([^<]*)<\/tspan>/g)].map((m) => [ + m[2], + /font-family="([^"]*)"/.exec(m[1]!)?.[1], + ]); + + expect(spans).toEqual([ + ['売上', EAST_ASIAN], + [' Q3 ', LOADED], + ['は(前年比)', EAST_ASIAN], + ['+12%', LOADED], + ]); + expect([...families].sort()).toEqual([EAST_ASIAN, LOADED].sort()); + }); +});