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
5 changes: 5 additions & 0 deletions .changeset/preview-raster-resolve-family.md
Original file line number Diff line number Diff line change
@@ -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.
14 changes: 14 additions & 0 deletions packages/preview/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
12 changes: 11 additions & 1 deletion packages/preview/src/node.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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, {
Expand Down
144 changes: 144 additions & 0 deletions test/preview-raster-resolve-family.test.ts
Original file line number Diff line number Diff line change
@@ -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<string> } => {
const families = new Set<string>();
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([^>]*)>([^<]*)<\/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());
});
});
Loading