@office-kit/pptx-preview 0.6.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +103 -0
- package/dist/index-BNnucizH.d.ts +45 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +2 -0
- package/dist/node.d.ts +44 -0
- package/dist/node.js +133 -0
- package/dist/node.js.map +1 -0
- package/dist/src-CVYeIBFN.js +4426 -0
- package/dist/src-CVYeIBFN.js.map +1 -0
- package/fonts/Caladea-Bold.ttf +0 -0
- package/fonts/Caladea-BoldItalic.ttf +0 -0
- package/fonts/Caladea-Italic.ttf +0 -0
- package/fonts/Caladea-Regular.ttf +0 -0
- package/fonts/Carlito-Bold.ttf +0 -0
- package/fonts/Carlito-BoldItalic.ttf +0 -0
- package/fonts/Carlito-Italic.ttf +0 -0
- package/fonts/Carlito-Regular.ttf +0 -0
- package/fonts/LICENSES.md +123 -0
- package/fonts/LiberationMono-Bold.ttf +0 -0
- package/fonts/LiberationMono-BoldItalic.ttf +0 -0
- package/fonts/LiberationMono-Italic.ttf +0 -0
- package/fonts/LiberationMono-Regular.ttf +0 -0
- package/fonts/LiberationSans-Bold.ttf +0 -0
- package/fonts/LiberationSans-BoldItalic.ttf +0 -0
- package/fonts/LiberationSans-Italic.ttf +0 -0
- package/fonts/LiberationSans-Regular.ttf +0 -0
- package/fonts/LiberationSerif-Bold.ttf +0 -0
- package/fonts/LiberationSerif-BoldItalic.ttf +0 -0
- package/fonts/LiberationSerif-Italic.ttf +0 -0
- package/fonts/LiberationSerif-Regular.ttf +0 -0
- package/package.json +62 -0
package/README.md
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
# @office-kit/pptx-preview
|
|
2
|
+
|
|
3
|
+
Preview renderer for [`@office-kit/pptx`](https://github.com/office-kit/pptx).
|
|
4
|
+
Turns a `@office-kit/pptx` slide model into an **SVG** (browser + Node) or rasterizes
|
|
5
|
+
it to a **PNG / RGBA image in Node — with no headless browser**.
|
|
6
|
+
|
|
7
|
+
> **Experimental (0.x).** This package lives in the `@office-kit/pptx` monorepo and
|
|
8
|
+
> also powers the docs-site playground and the fidelity harness. The renderer
|
|
9
|
+
> is an _approximation_ of PowerPoint / LibreOffice output and is still
|
|
10
|
+
> evolving; the API may change between minor versions, and `0.x` semver applies
|
|
11
|
+
> (a minor bump may break). See [fidelity](#fidelity) below.
|
|
12
|
+
|
|
13
|
+
## Why
|
|
14
|
+
|
|
15
|
+
`@office-kit/pptx` core does not render — by design. But "show me this deck" comes up
|
|
16
|
+
constantly: a docs playground, a thumbnail service, a visual-diff test. The
|
|
17
|
+
hard requirement is that rendering must work **in Node and rasterize to an
|
|
18
|
+
image without spawning a browser**, so it fits CI and serverless. This package
|
|
19
|
+
lays text out as pure SVG `<text>` (no `<foreignObject>`) and paints it with
|
|
20
|
+
[resvg](https://github.com/yisibl/resvg-js), which has no browser dependency.
|
|
21
|
+
|
|
22
|
+
## Entry points
|
|
23
|
+
|
|
24
|
+
| Import | Runtime | Use |
|
|
25
|
+
| ------------------------------- | -------------- | --------------------------------------------------- |
|
|
26
|
+
| `@office-kit/pptx-preview` | browser + Node | `renderSlideToSvg` → an SVG string |
|
|
27
|
+
| `@office-kit/pptx-preview/node` | Node only | `renderSlideToImage` / `renderSlideToRgba` → pixels |
|
|
28
|
+
|
|
29
|
+
The browser entry pulls in **no** Node built-ins (no `node:fs`, resvg, or
|
|
30
|
+
fontkit), so it bundles cleanly for the web.
|
|
31
|
+
|
|
32
|
+
## Usage
|
|
33
|
+
|
|
34
|
+
### SVG (browser or Node)
|
|
35
|
+
|
|
36
|
+
```ts
|
|
37
|
+
import { renderSlideToSvg } from '@office-kit/pptx-preview';
|
|
38
|
+
import { loadPresentation, getSlides } from '@office-kit/pptx';
|
|
39
|
+
|
|
40
|
+
const pres = await loadPresentation(bytes);
|
|
41
|
+
const svg = renderSlideToSvg(pres, getSlides(pres)[0]);
|
|
42
|
+
// → '<svg …>…</svg>' (text laid out via <foreignObject> — the browser wraps it)
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
### PNG / RGBA (Node, no browser)
|
|
46
|
+
|
|
47
|
+
```ts
|
|
48
|
+
import { renderSlideToImage, renderSlideToRgba } from '@office-kit/pptx-preview/node';
|
|
49
|
+
import { loadPresentationFile, getSlides } from '@office-kit/pptx/node';
|
|
50
|
+
|
|
51
|
+
const pres = await loadPresentationFile('deck.pptx');
|
|
52
|
+
const slide = getSlides(pres)[0];
|
|
53
|
+
|
|
54
|
+
// PNG-encoded bytes:
|
|
55
|
+
const png = renderSlideToImage(pres, slide, { width: 1280 });
|
|
56
|
+
|
|
57
|
+
// Raw RGBA pixels (+ the same frame PNG-encoded), for SSIM / diffing:
|
|
58
|
+
const { image, png: png2 } = renderSlideToRgba(pres, slide, { width: 1280 });
|
|
59
|
+
// image: { width, height, data: Uint8Array } // row-major RGBA
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
The Node path lays text out as pure `<text>` and measures it with a fontkit
|
|
63
|
+
measurer over **bundled** metric-compatible fonts (Carlito ≈ Calibri, Caladea ≈
|
|
64
|
+
Cambria, Liberation ≈ Arial/Times/Courier; OFL / Apache-2.0, see
|
|
65
|
+
`fonts/LICENSES.md`). The measurer, resvg's font set, and the SVG family names
|
|
66
|
+
all reference the same fonts, so wrap/positioning math agrees with the painted
|
|
67
|
+
glyphs and the result is deterministic (no system fonts).
|
|
68
|
+
|
|
69
|
+
## Fidelity
|
|
70
|
+
|
|
71
|
+
This is a high-fidelity preview, not a spec-complete PowerPoint renderer.
|
|
72
|
+
Preset and custom geometry, solid/gradient/pattern/image fills (including the
|
|
73
|
+
placeholder layout/master cascade), strokes, rotation, effects (shadow, glow,
|
|
74
|
+
soft edge, reflection), images with adjustments, charts (column, bar, line,
|
|
75
|
+
area, pie, doughnut, scatter, radar, bubble), tables with per-run cell text,
|
|
76
|
+
vertical and multi-column text in both text-layout modes, picture bullets, and
|
|
77
|
+
template (layout/master) decoration all render. SmartArt, animations, 3D, and
|
|
78
|
+
EMF/WMF fall back to labelled placeholders carrying a machine-readable marker
|
|
79
|
+
(below). Per-slide closeness to a LibreOffice baseline is measured and gated
|
|
80
|
+
in CI by the fidelity harness in the monorepo (`site/fidelity`) — mean
|
|
81
|
+
fg-SSIM ≈ 0.78 across the corpus, with the residual gaps documented there.
|
|
82
|
+
|
|
83
|
+
### Fallback markers
|
|
84
|
+
|
|
85
|
+
When a shape cannot be rendered (unsupported format, missing bytes, or
|
|
86
|
+
unrecognised content type), the renderer emits a labelled placeholder rectangle.
|
|
87
|
+
The placeholder's top-level `<g>` element carries a `data-pptx-fallback`
|
|
88
|
+
attribute so automated tooling can detect partial renders without string-parsing
|
|
89
|
+
the label text:
|
|
90
|
+
|
|
91
|
+
| Value | Trigger |
|
|
92
|
+
| ---------------- | ----------------------------------------------------------- |
|
|
93
|
+
| `"image"` | Image bytes missing (external link) or format not decodable |
|
|
94
|
+
| `"chart"` | Chart kind not modelled by this renderer |
|
|
95
|
+
| `"graphicFrame"` | Graphic frame with no recognised content (SmartArt, etc.) |
|
|
96
|
+
| `"custGeom"` | Shape uses custom geometry (`<a:custGeom>`) |
|
|
97
|
+
|
|
98
|
+
Example: `svg.querySelectorAll('[data-pptx-fallback]')` lists every shape that
|
|
99
|
+
did not fully render.
|
|
100
|
+
|
|
101
|
+
## License
|
|
102
|
+
|
|
103
|
+
MIT (code). Bundled fonts: OFL-1.1 / Apache-2.0 — see `fonts/LICENSES.md`.
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { PresentationData, SlideData } from "@office-kit/pptx";
|
|
2
|
+
|
|
3
|
+
//#region src/text-layout.d.ts
|
|
4
|
+
/** What a measurer needs to size one run. Pixels at 96 DPI; the caller has
|
|
5
|
+
* already applied EMU→px, pt→px and the autofit fontScale. `family` is the
|
|
6
|
+
* resolved internal family name (see `substituteFamily`), not a CSS list. */
|
|
7
|
+
interface FontSpec {
|
|
8
|
+
readonly family: string;
|
|
9
|
+
readonly sizePx: number;
|
|
10
|
+
readonly bold: boolean;
|
|
11
|
+
readonly italic: boolean;
|
|
12
|
+
readonly letterSpacingPx: number;
|
|
13
|
+
}
|
|
14
|
+
/** Advance width of `text` in px, plus optional vertical metrics. A real
|
|
15
|
+
* measurer returns ascent/descent/lineGap so line height matches the font;
|
|
16
|
+
* the heuristic returns width only and the engine falls back to a ratio. */
|
|
17
|
+
interface MeasureResult {
|
|
18
|
+
readonly widthPx: number;
|
|
19
|
+
readonly ascentPx?: number;
|
|
20
|
+
readonly descentPx?: number;
|
|
21
|
+
readonly lineGapPx?: number;
|
|
22
|
+
}
|
|
23
|
+
type TextMeasurer = (text: string, spec: FontSpec) => MeasureResult;
|
|
24
|
+
type TextLayoutMode = 'foreignObject' | 'svg';
|
|
25
|
+
interface RenderSlideOptions {
|
|
26
|
+
/** Measurer used by the pure-SVG text path. Required when `textLayout` is
|
|
27
|
+
* 'svg'; ignored otherwise. */
|
|
28
|
+
readonly measureText?: TextMeasurer;
|
|
29
|
+
/** Which text path to use. Defaults to 'foreignObject' (the browser path)
|
|
30
|
+
* so existing callers are unaffected; the harness opts into 'svg'. */
|
|
31
|
+
readonly textLayout?: TextLayoutMode;
|
|
32
|
+
}
|
|
33
|
+
declare const SANS = "Carlito";
|
|
34
|
+
declare const SERIF = "Caladea";
|
|
35
|
+
declare const ARIAL = "Liberation Sans";
|
|
36
|
+
declare const TIMES = "Liberation Serif";
|
|
37
|
+
declare const MONO = "Liberation Mono";
|
|
38
|
+
declare const substituteFamily: (family: string | null | undefined) => string;
|
|
39
|
+
declare const defaultMeasurer: TextMeasurer;
|
|
40
|
+
//#endregion
|
|
41
|
+
//#region src/render-slide.d.ts
|
|
42
|
+
declare const renderSlideSvg: (pres: PresentationData, slide: SlideData, opts?: RenderSlideOptions) => string;
|
|
43
|
+
//#endregion
|
|
44
|
+
export { MeasureResult as a, SERIF as c, TextMeasurer as d, defaultMeasurer as f, MONO as i, TIMES as l, ARIAL as n, RenderSlideOptions as o, substituteFamily as p, FontSpec as r, SANS as s, renderSlideSvg as t, TextLayoutMode as u };
|
|
45
|
+
//# sourceMappingURL=index-BNnucizH.d.ts.map
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,2 @@
|
|
|
1
|
+
import { a as MeasureResult, c as SERIF, d as TextMeasurer, f as defaultMeasurer, i as MONO, l as TIMES, n as ARIAL, o as RenderSlideOptions, p as substituteFamily, r as FontSpec, s as SANS, t as renderSlideSvg, u as TextLayoutMode } from "./index-BNnucizH.js";
|
|
2
|
+
export { ARIAL, type FontSpec, MONO, type MeasureResult, type RenderSlideOptions, SANS, SERIF, TIMES, type TextLayoutMode, type TextMeasurer, defaultMeasurer, renderSlideSvg as renderSlideToSvg, substituteFamily };
|
package/dist/index.js
ADDED
package/dist/node.d.ts
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { a as MeasureResult, c as SERIF, d as TextMeasurer, f as defaultMeasurer, i as MONO, l as TIMES, n as ARIAL, o as RenderSlideOptions, p as substituteFamily, r as FontSpec, s as SANS, t as renderSlideSvg, u as TextLayoutMode } from "./index-BNnucizH.js";
|
|
2
|
+
import { PresentationData, SlideData } from "@office-kit/pptx";
|
|
3
|
+
|
|
4
|
+
//#region src/measure.d.ts
|
|
5
|
+
declare const FONT_DIR: string;
|
|
6
|
+
/** Absolute paths of every bundled face — passed to resvg's `fontFiles`. */
|
|
7
|
+
declare const FONT_FILES: string[];
|
|
8
|
+
declare const buildFontkitMeasurer: () => TextMeasurer;
|
|
9
|
+
//#endregion
|
|
10
|
+
//#region src/node.d.ts
|
|
11
|
+
/** Raw, un-encoded raster: RGBA bytes in row-major order. */
|
|
12
|
+
interface RgbaImage {
|
|
13
|
+
readonly width: number;
|
|
14
|
+
readonly height: number;
|
|
15
|
+
/** `width * height * 4` bytes, R,G,B,A per pixel. */
|
|
16
|
+
readonly data: Uint8Array;
|
|
17
|
+
}
|
|
18
|
+
interface RenderImageOptions {
|
|
19
|
+
/**
|
|
20
|
+
* Target raster width in pixels. Height follows the slide's aspect ratio.
|
|
21
|
+
* Defaults to 1280 (matches the fidelity harness baseline).
|
|
22
|
+
*/
|
|
23
|
+
readonly width?: number;
|
|
24
|
+
/**
|
|
25
|
+
* Text measurer used for wrap/positioning. Defaults to a shared fontkit
|
|
26
|
+
* measurer over the bundled fonts. Pass {@link buildFontkitMeasurer}'s
|
|
27
|
+
* result to control its lifetime, or a custom measurer to swap fonts.
|
|
28
|
+
*/
|
|
29
|
+
readonly measureText?: TextMeasurer;
|
|
30
|
+
}
|
|
31
|
+
/** Render one slide to PNG-encoded bytes. */
|
|
32
|
+
declare const renderSlideToImage: (pres: PresentationData, slide: SlideData, opts?: RenderImageOptions) => Uint8Array;
|
|
33
|
+
/**
|
|
34
|
+
* Render one slide to raw RGBA pixels plus the PNG encoding of the same frame.
|
|
35
|
+
* Both come from a single rasterization, so callers needing pixel access (SSIM,
|
|
36
|
+
* diffing) and a saveable file don't pay to render twice.
|
|
37
|
+
*/
|
|
38
|
+
declare const renderSlideToRgba: (pres: PresentationData, slide: SlideData, opts?: RenderImageOptions) => {
|
|
39
|
+
readonly image: RgbaImage;
|
|
40
|
+
readonly png: Uint8Array;
|
|
41
|
+
};
|
|
42
|
+
//#endregion
|
|
43
|
+
export { ARIAL, FONT_DIR, FONT_FILES, type FontSpec, MONO, type MeasureResult, RenderImageOptions, type RenderSlideOptions, RgbaImage, SANS, SERIF, TIMES, type TextLayoutMode, type TextMeasurer, buildFontkitMeasurer, defaultMeasurer, renderSlideToImage, renderSlideToRgba, renderSlideSvg as renderSlideToSvg, substituteFamily };
|
|
44
|
+
//# sourceMappingURL=node.d.ts.map
|
package/dist/node.js
ADDED
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import { a as SERIF, c as substituteFamily, i as SANS, n as ARIAL, o as TIMES, r as MONO, s as defaultMeasurer, t as renderSlideSvg } from "./src-CVYeIBFN.js";
|
|
2
|
+
import { getSlideSize } from "@office-kit/pptx";
|
|
3
|
+
import { Resvg } from "@resvg/resvg-js";
|
|
4
|
+
import * as fontkit from "fontkit";
|
|
5
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
6
|
+
import { fileURLToPath } from "node:url";
|
|
7
|
+
//#region src/measure.ts
|
|
8
|
+
const FONT_DIR = fileURLToPath(new URL("../fonts/", import.meta.url));
|
|
9
|
+
const FAMILY_TO_PREFIX = {
|
|
10
|
+
[SANS]: "Carlito",
|
|
11
|
+
[SERIF]: "Caladea",
|
|
12
|
+
[ARIAL]: "LiberationSans",
|
|
13
|
+
[TIMES]: "LiberationSerif",
|
|
14
|
+
[MONO]: "LiberationMono"
|
|
15
|
+
};
|
|
16
|
+
const STYLES = [
|
|
17
|
+
"Regular",
|
|
18
|
+
"Bold",
|
|
19
|
+
"Italic",
|
|
20
|
+
"BoldItalic"
|
|
21
|
+
];
|
|
22
|
+
const facePath = (prefix, style) => `${FONT_DIR}${prefix}-${style}.ttf`;
|
|
23
|
+
/** Absolute paths of every bundled face — passed to resvg's `fontFiles`. */
|
|
24
|
+
const FONT_FILES = Object.values(FAMILY_TO_PREFIX).flatMap((prefix) => STYLES.map((s) => facePath(prefix, s)));
|
|
25
|
+
const openFont = (path) => {
|
|
26
|
+
const font = fontkit.create(readFileSync(path));
|
|
27
|
+
if (!("layout" in font)) throw new Error(`Expected a single font in ${path}, got a collection`);
|
|
28
|
+
return font;
|
|
29
|
+
};
|
|
30
|
+
const styleSuffix = (spec) => {
|
|
31
|
+
if (spec.bold && spec.italic) return "BoldItalic";
|
|
32
|
+
if (spec.bold) return "Bold";
|
|
33
|
+
if (spec.italic) return "Italic";
|
|
34
|
+
return "Regular";
|
|
35
|
+
};
|
|
36
|
+
const buildFontkitMeasurer = () => {
|
|
37
|
+
const cache = /* @__PURE__ */ new Map();
|
|
38
|
+
for (const [family, prefix] of Object.entries(FAMILY_TO_PREFIX)) for (const style of STYLES) {
|
|
39
|
+
const path = facePath(prefix, style);
|
|
40
|
+
if (!existsSync(path)) throw new Error(`Missing bundled font: ${path}`);
|
|
41
|
+
const font = openFont(path);
|
|
42
|
+
if (style === "Regular" && font.familyName !== family) throw new Error(`Font family mismatch: ${path} reports "${font.familyName}", expected "${family}". The substitution map and resvg matching rely on this name.`);
|
|
43
|
+
cache.set(`${prefix}-${style}`, font);
|
|
44
|
+
}
|
|
45
|
+
const pick = (spec) => {
|
|
46
|
+
const prefix = FAMILY_TO_PREFIX[spec.family] ?? "Carlito";
|
|
47
|
+
const style = styleSuffix(spec);
|
|
48
|
+
return cache.get(`${prefix}-${style}`) ?? cache.get(`${prefix}-Regular`) ?? cache.get("Carlito-Regular");
|
|
49
|
+
};
|
|
50
|
+
return (text, spec) => {
|
|
51
|
+
const font = pick(spec);
|
|
52
|
+
const scale = spec.sizePx / font.unitsPerEm;
|
|
53
|
+
const run = font.layout(text);
|
|
54
|
+
const glyphCount = [...text].length;
|
|
55
|
+
const tracking = glyphCount > 1 ? spec.letterSpacingPx * (glyphCount - 1) : 0;
|
|
56
|
+
const vm = verticalMetrics(font);
|
|
57
|
+
return {
|
|
58
|
+
widthPx: run.advanceWidth * scale + tracking,
|
|
59
|
+
ascentPx: vm.ascent * scale,
|
|
60
|
+
descentPx: vm.descent * scale,
|
|
61
|
+
lineGapPx: vm.lineGap * scale
|
|
62
|
+
};
|
|
63
|
+
};
|
|
64
|
+
};
|
|
65
|
+
const verticalMetrics = (font) => {
|
|
66
|
+
const os2 = font["OS/2"];
|
|
67
|
+
if (os2 && typeof os2 === "object") {
|
|
68
|
+
const o = os2;
|
|
69
|
+
if (o.fsSelection?.useTypoMetrics && o.typoAscender !== void 0) return {
|
|
70
|
+
ascent: o.typoAscender,
|
|
71
|
+
descent: Math.abs(o.typoDescender ?? 0),
|
|
72
|
+
lineGap: o.typoLineGap ?? 0
|
|
73
|
+
};
|
|
74
|
+
if (o.winAscent !== void 0 && o.winDescent !== void 0) return {
|
|
75
|
+
ascent: o.winAscent,
|
|
76
|
+
descent: Math.abs(o.winDescent),
|
|
77
|
+
lineGap: 0
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
return {
|
|
81
|
+
ascent: font.ascent,
|
|
82
|
+
descent: Math.abs(font.descent),
|
|
83
|
+
lineGap: font.lineGap
|
|
84
|
+
};
|
|
85
|
+
};
|
|
86
|
+
//#endregion
|
|
87
|
+
//#region src/node.ts
|
|
88
|
+
const DEFAULT_WIDTH = 1280;
|
|
89
|
+
let sharedMeasurer;
|
|
90
|
+
const getSharedMeasurer = () => sharedMeasurer ??= buildFontkitMeasurer();
|
|
91
|
+
const rasterize = (pres, slide, opts) => {
|
|
92
|
+
getSlideSize(pres);
|
|
93
|
+
const width = opts.width ?? DEFAULT_WIDTH;
|
|
94
|
+
return new Resvg(renderSlideSvg(pres, slide, {
|
|
95
|
+
measureText: opts.measureText ?? getSharedMeasurer(),
|
|
96
|
+
textLayout: "svg"
|
|
97
|
+
}), {
|
|
98
|
+
fitTo: {
|
|
99
|
+
mode: "width",
|
|
100
|
+
value: width
|
|
101
|
+
},
|
|
102
|
+
font: {
|
|
103
|
+
loadSystemFonts: false,
|
|
104
|
+
fontFiles: FONT_FILES,
|
|
105
|
+
defaultFontFamily: SANS,
|
|
106
|
+
sansSerifFamily: SANS,
|
|
107
|
+
serifFamily: SERIF,
|
|
108
|
+
monospaceFamily: MONO
|
|
109
|
+
}
|
|
110
|
+
}).render();
|
|
111
|
+
};
|
|
112
|
+
/** Render one slide to PNG-encoded bytes. */
|
|
113
|
+
const renderSlideToImage = (pres, slide, opts = {}) => new Uint8Array(rasterize(pres, slide, opts).asPng());
|
|
114
|
+
/**
|
|
115
|
+
* Render one slide to raw RGBA pixels plus the PNG encoding of the same frame.
|
|
116
|
+
* Both come from a single rasterization, so callers needing pixel access (SSIM,
|
|
117
|
+
* diffing) and a saveable file don't pay to render twice.
|
|
118
|
+
*/
|
|
119
|
+
const renderSlideToRgba = (pres, slide, opts = {}) => {
|
|
120
|
+
const rendered = rasterize(pres, slide, opts);
|
|
121
|
+
return {
|
|
122
|
+
image: {
|
|
123
|
+
width: rendered.width,
|
|
124
|
+
height: rendered.height,
|
|
125
|
+
data: new Uint8Array(rendered.pixels)
|
|
126
|
+
},
|
|
127
|
+
png: new Uint8Array(rendered.asPng())
|
|
128
|
+
};
|
|
129
|
+
};
|
|
130
|
+
//#endregion
|
|
131
|
+
export { ARIAL, FONT_DIR, FONT_FILES, MONO, SANS, SERIF, TIMES, buildFontkitMeasurer, defaultMeasurer, renderSlideToImage, renderSlideToRgba, renderSlideSvg as renderSlideToSvg, substituteFamily };
|
|
132
|
+
|
|
133
|
+
//# sourceMappingURL=node.js.map
|
package/dist/node.js.map
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"node.js","names":[],"sources":["../src/measure.ts","../src/node.ts"],"sourcesContent":["// Node-only fontkit-backed TextMeasurer. NEVER imported by render-slide.ts /\n// text-layout.ts (which must stay browser-safe) — only by the node entry. It\n// measures advance widths and vertical metrics from the same bundled TTFs that\n// resvg rasterizes with (and that LibreOffice renders ground truth with), so\n// the engine's wrap/positioning math agrees with the painted pixels.\n\nimport * as fontkit from 'fontkit';\nimport { existsSync, readFileSync } from 'node:fs';\nimport { fileURLToPath } from 'node:url';\nimport {\n ARIAL,\n MONO,\n SANS,\n SERIF,\n TIMES,\n type FontSpec,\n type TextMeasurer,\n} from './text-layout.ts';\n\nexport const FONT_DIR = fileURLToPath(new URL('../fonts/', import.meta.url));\n\n// Internal family name (what the emitter writes + resvg matches) → file prefix.\nconst FAMILY_TO_PREFIX: Record<string, string> = {\n [SANS]: 'Carlito',\n [SERIF]: 'Caladea',\n [ARIAL]: 'LiberationSans',\n [TIMES]: 'LiberationSerif',\n [MONO]: 'LiberationMono',\n};\n\nconst STYLES = ['Regular', 'Bold', 'Italic', 'BoldItalic'] as const;\n\nconst facePath = (prefix: string, style: string): string => `${FONT_DIR}${prefix}-${style}.ttf`;\n\n/** Absolute paths of every bundled face — passed to resvg's `fontFiles`. */\nexport const FONT_FILES: string[] = Object.values(FAMILY_TO_PREFIX).flatMap((prefix) =>\n STYLES.map((s) => facePath(prefix, s)),\n);\n\n// fontkit.create returns Font | FontCollection; our TTFs are single fonts.\nconst openFont = (path: string): fontkit.Font => {\n const font = fontkit.create(readFileSync(path));\n if (!('layout' in font)) {\n throw new Error(`Expected a single font in ${path}, got a collection`);\n }\n return font;\n};\n\nconst styleSuffix = (spec: FontSpec): (typeof STYLES)[number] => {\n if (spec.bold && spec.italic) return 'BoldItalic';\n if (spec.bold) return 'Bold';\n if (spec.italic) return 'Italic';\n return 'Regular';\n};\n\nexport const buildFontkitMeasurer = (): TextMeasurer => {\n const cache = new Map<string, fontkit.Font>();\n // Load + verify every face up front. The familyName assertion guarantees the\n // emit-side family === resvg match === the face we measure, which is the load-\n // bearing invariant for \"x= is where glyphs land\".\n for (const [family, prefix] of Object.entries(FAMILY_TO_PREFIX)) {\n for (const style of STYLES) {\n const path = facePath(prefix, style);\n if (!existsSync(path)) throw new Error(`Missing bundled font: ${path}`);\n const font = openFont(path);\n if (style === 'Regular' && font.familyName !== family) {\n throw new Error(\n `Font family mismatch: ${path} reports \"${font.familyName}\", expected \"${family}\". ` +\n `The substitution map and resvg matching rely on this name.`,\n );\n }\n cache.set(`${prefix}-${style}`, font);\n }\n }\n\n const pick = (spec: FontSpec): fontkit.Font => {\n const prefix = FAMILY_TO_PREFIX[spec.family] ?? 'Carlito';\n const style = styleSuffix(spec);\n return (\n cache.get(`${prefix}-${style}`) ??\n cache.get(`${prefix}-Regular`) ??\n cache.get('Carlito-Regular')!\n );\n };\n\n return (text, spec) => {\n const font = pick(spec);\n const scale = spec.sizePx / font.unitsPerEm;\n const run = font.layout(text); // applies the font's kerning / GPOS\n const glyphCount = [...text].length;\n const tracking = glyphCount > 1 ? spec.letterSpacingPx * (glyphCount - 1) : 0;\n const vm = verticalMetrics(font);\n return {\n widthPx: run.advanceWidth * scale + tracking,\n ascentPx: vm.ascent * scale,\n descentPx: vm.descent * scale,\n lineGapPx: vm.lineGap * scale,\n };\n };\n};\n\n// Which vertical-metric set a renderer uses depends on the OS/2 fsSelection\n// USE_TYPO_METRICS bit. When it's clear (the common case, incl. Carlito /\n// Liberation), GDI / LibreOffice place the baseline at usWinAscent and size the\n// line box as usWinAscent + usWinDescent (no extra gap). When set, they use the\n// sTypo* metrics plus typoLineGap. fontkit's `.ascent`/`.descent` expose the\n// hhea values, which for these fonts differ from usWinAscent by ~12px at 44pt —\n// exactly the baseline offset that otherwise mismatches ground truth. Returns\n// font-unit values; the caller scales to px.\ninterface VMetrics {\n readonly ascent: number;\n readonly descent: number;\n readonly lineGap: number;\n}\nconst verticalMetrics = (font: fontkit.Font): VMetrics => {\n const os2: unknown = (font as { 'OS/2'?: unknown })['OS/2'];\n if (os2 && typeof os2 === 'object') {\n const o = os2 as {\n fsSelection?: { useTypoMetrics?: boolean };\n typoAscender?: number;\n typoDescender?: number;\n typoLineGap?: number;\n winAscent?: number;\n winDescent?: number;\n };\n if (o.fsSelection?.useTypoMetrics && o.typoAscender !== undefined) {\n return {\n ascent: o.typoAscender,\n descent: Math.abs(o.typoDescender ?? 0),\n lineGap: o.typoLineGap ?? 0,\n };\n }\n if (o.winAscent !== undefined && o.winDescent !== undefined) {\n return { ascent: o.winAscent, descent: Math.abs(o.winDescent), lineGap: 0 };\n }\n }\n // Fall back to hhea metrics if OS/2 is absent.\n return { ascent: font.ascent, descent: Math.abs(font.descent), lineGap: font.lineGap };\n};\n","// `@office-kit/pptx-preview/node` — Node entry: rasterize a slide to a PNG or raw\n// RGBA image, with no browser binary.\n//\n// Pipeline: `renderSlideToSvg(..., { textLayout: 'svg', measureText })` lays\n// text out as pure `<text>`, then resvg paints the SVG to pixels. The fontkit\n// measurer, resvg's `fontFiles`, and the SVG's family names all reference the\n// SAME bundled fonts, so the wrap/positioning math agrees with the rasterized\n// glyphs.\n\nimport { Resvg } from '@resvg/resvg-js';\nimport { getSlideSize, type PresentationData, type SlideData } from '@office-kit/pptx';\nimport { renderSlideSvg } from './render-slide.ts';\nimport { MONO, SANS, SERIF, type TextMeasurer } from './text-layout.ts';\nimport { buildFontkitMeasurer, FONT_FILES } from './measure.ts';\n\n// Re-export the browser-safe surface so a Node consumer gets everything from\n// one import.\nexport * from './index.ts';\n\n// Advanced: pre-build / share a fontkit measurer, or locate the bundled fonts.\nexport { buildFontkitMeasurer, FONT_FILES, FONT_DIR } from './measure.ts';\n\n/** Raw, un-encoded raster: RGBA bytes in row-major order. */\nexport interface RgbaImage {\n readonly width: number;\n readonly height: number;\n /** `width * height * 4` bytes, R,G,B,A per pixel. */\n readonly data: Uint8Array;\n}\n\nexport interface RenderImageOptions {\n /**\n * Target raster width in pixels. Height follows the slide's aspect ratio.\n * Defaults to 1280 (matches the fidelity harness baseline).\n */\n readonly width?: number;\n /**\n * Text measurer used for wrap/positioning. Defaults to a shared fontkit\n * measurer over the bundled fonts. Pass {@link buildFontkitMeasurer}'s\n * result to control its lifetime, or a custom measurer to swap fonts.\n */\n readonly measureText?: TextMeasurer;\n}\n\nconst DEFAULT_WIDTH = 1280;\n\n// The fontkit measurer reads + verifies every bundled face on construction.\n// Build it once and reuse across calls; do it lazily so importing this module\n// (e.g. just for the types) doesn't touch the filesystem.\nlet sharedMeasurer: TextMeasurer | undefined;\nconst getSharedMeasurer = (): TextMeasurer => (sharedMeasurer ??= buildFontkitMeasurer());\n\nconst rasterize = (pres: PresentationData, slide: SlideData, opts: RenderImageOptions) => {\n // `getSlideSize` is only consulted to fail fast on a malformed deck; the SVG\n // carries its own viewBox and resvg fits to the requested width.\n getSlideSize(pres);\n const width = opts.width ?? DEFAULT_WIDTH;\n const measureText = opts.measureText ?? getSharedMeasurer();\n const svg = renderSlideSvg(pres, slide, { measureText, textLayout: 'svg' });\n const resvg = new Resvg(svg, {\n fitTo: { mode: 'width', value: width },\n font: {\n loadSystemFonts: false, // deterministic: only the bundled fonts\n fontFiles: FONT_FILES,\n defaultFontFamily: SANS,\n sansSerifFamily: SANS,\n serifFamily: SERIF,\n monospaceFamily: MONO,\n },\n });\n return resvg.render();\n};\n\n/** Render one slide to PNG-encoded bytes. */\nexport const renderSlideToImage = (\n pres: PresentationData,\n slide: SlideData,\n opts: RenderImageOptions = {},\n): Uint8Array => new Uint8Array(rasterize(pres, slide, opts).asPng());\n\n/**\n * Render one slide to raw RGBA pixels plus the PNG encoding of the same frame.\n * Both come from a single rasterization, so callers needing pixel access (SSIM,\n * diffing) and a saveable file don't pay to render twice.\n */\nexport const renderSlideToRgba = (\n pres: PresentationData,\n slide: SlideData,\n opts: RenderImageOptions = {},\n): { readonly image: RgbaImage; readonly png: Uint8Array } => {\n const rendered = rasterize(pres, slide, opts);\n return {\n image: {\n width: rendered.width,\n height: rendered.height,\n data: new Uint8Array(rendered.pixels),\n },\n png: new Uint8Array(rendered.asPng()),\n };\n};\n"],"mappings":"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"}
|