@office-kit/pptx-preview 0.6.2 → 0.8.0

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 CHANGED
@@ -66,6 +66,57 @@ Cambria, Liberation ≈ Arial/Times/Courier; OFL / Apache-2.0, see
66
66
  all reference the same fonts, so wrap/positioning math agrees with the painted
67
67
  glyphs and the result is deterministic (no system fonts).
68
68
 
69
+ ### Text-layout audit (overflow / soft-wrap detection)
70
+
71
+ ```ts
72
+ import { auditTextLayout } from '@office-kit/pptx-preview';
73
+ import { buildFontkitMeasurer } from '@office-kit/pptx-preview/node';
74
+
75
+ // Register the fonts the deck actually uses (especially CJK) for accurate widths.
76
+ const measureText = buildFontkitMeasurer({
77
+ fonts: [{ family: '游ゴシック', source: 'fonts/YuGothic.ttf' }],
78
+ });
79
+
80
+ const issues = auditTextLayout(pres, { measureText, reportSoftWraps: true });
81
+ // → [{ kind: 'overflow-y', slideIndex: 2, shapeName: 'Title 1', overflowPx: 14.3, approximate: false }, …]
82
+ ```
83
+
84
+ `auditTextLayout` measures every shape's text body — placeholders, text boxes,
85
+ autoshapes, shapes inside groups — with the same engine the preview renders
86
+ with, honouring the effective bodyPr cascade (insets, wrap, anchor, vertical
87
+ text, columns) and `normAutofit` shrinking. It returns one issue per finding:
88
+
89
+ | `kind` | Meaning | Extra fields |
90
+ | ------------ | ----------------------------------------------------------------------------- | ------------------------------ |
91
+ | `overflow-x` | Text ink escapes the box horizontally (typically `wrap="none"`) | `overflowPx` |
92
+ | `overflow-y` | Wrapped text is taller than the box (or escapes the top when bottom-anchored) | `overflowPx` |
93
+ | `soft-wrap` | A paragraph wrapped onto more lines than its authored `<a:br>` count (段落ち) | `paragraphIndex`, `extraLines` |
94
+
95
+ Every issue also carries `slideIndex`, `shapeName`, and `approximate` —
96
+ `true` when a width had to be estimated (the heuristic measurer, or glyphs the
97
+ available fonts don't cover), in which case treat borderline verdicts as
98
+ advisory.
99
+
100
+ Options:
101
+
102
+ - `measureText` — the measurer. Defaults to the browser-safe heuristic
103
+ (≈0.55 em per Latin glyph, 1 em per CJK glyph, always `approximate`); pass
104
+ `buildFontkitMeasurer()` in Node for real glyph metrics.
105
+ - `tolerancePx` — overflow at or below this many px (96 DPI) is ignored.
106
+ Default `1`; raise it if you only care about visibly broken slides.
107
+ - `reportSoftWraps` — opt into `soft-wrap` issues. Off by default because
108
+ wrapping is normal for body text; turn it on when auditing titles or labels
109
+ meant to stay on one line.
110
+
111
+ Accuracy: the bundled fonts are metric-compatible with the Office defaults, so
112
+ verdicts for Calibri / Cambria / Arial / Times / Courier decks match real glyph
113
+ widths; for any other font (custom brand fonts, Japanese fonts), register the
114
+ real files via `buildFontkitMeasurer({ fonts })` — a registered font is used
115
+ both for runs that name it and as a glyph fallback for CJK text in runs that
116
+ resolve to a Latin face. Line-break positions can differ from PowerPoint by a
117
+ few characters in edge cases (kinsoku, hyphenation), which is what the default
118
+ 1 px tolerance absorbs. Table cell text is not audited yet.
119
+
69
120
  ## Fidelity
70
121
 
71
122
  This is a high-fidelity preview, not a spec-complete PowerPoint renderer.
@@ -0,0 +1,93 @@
1
+ import { PresentationData, SlideData, getParagraphBullet, getParagraphBulletStyle, getParagraphIndent, getShapeBodyPrEffective, getShapeTextAutoFitParams, getShapeTextDirection } 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
+ /** True when the width is an estimate rather than a glyph measurement —
23
+ * the heuristic measurer always, the fontkit measurer when the resolved
24
+ * font lacks a glyph and per-character ratios filled the gap. The audit
25
+ * API surfaces this so borderline verdicts can be treated as advisory. */
26
+ readonly approximate?: boolean;
27
+ }
28
+ type TextMeasurer = (text: string, spec: FontSpec) => MeasureResult;
29
+ type TextLayoutMode = 'foreignObject' | 'svg';
30
+ interface RenderSlideOptions {
31
+ /** Measurer used by the pure-SVG text path. Required when `textLayout` is
32
+ * 'svg'; ignored otherwise. */
33
+ readonly measureText?: TextMeasurer;
34
+ /** Which text path to use. Defaults to 'foreignObject' (the browser path)
35
+ * so existing callers are unaffected; the harness opts into 'svg'. */
36
+ readonly textLayout?: TextLayoutMode;
37
+ }
38
+ declare const SANS = "Carlito";
39
+ declare const SERIF = "Caladea";
40
+ declare const ARIAL = "Liberation Sans";
41
+ declare const TIMES = "Liberation Serif";
42
+ declare const MONO = "Liberation Mono";
43
+ declare const substituteFamily: (family: string | null | undefined) => string;
44
+ declare const defaultMeasurer: TextMeasurer;
45
+ //#endregion
46
+ //#region src/render-slide.d.ts
47
+ declare const renderSlideSvg: (pres: PresentationData, slide: SlideData, opts?: RenderSlideOptions) => string;
48
+ //#endregion
49
+ //#region src/audit.d.ts
50
+ interface IssueBase {
51
+ /** 0-based deck position of the slide the shape sits on. */
52
+ readonly slideIndex: number;
53
+ readonly shapeName: string | null;
54
+ /** True when any run was measured by estimate rather than real glyph
55
+ * metrics (heuristic measurer, or a font without the needed glyphs).
56
+ * Treat borderline overflows as advisory when set. */
57
+ readonly approximate: boolean;
58
+ }
59
+ type TextAuditIssue = (IssueBase & {
60
+ readonly kind: 'overflow-x' | 'overflow-y'; /** How far the text ink escapes the box, in px at 96 DPI. */
61
+ readonly overflowPx: number;
62
+ }) | (IssueBase & {
63
+ readonly kind: 'soft-wrap';
64
+ readonly paragraphIndex: number;
65
+ /** Lines beyond the paragraph's authored line count (1 + explicit
66
+ * breaks) — i.e. how many times the text wrapped on its own. */
67
+ readonly extraLines: number;
68
+ });
69
+ interface AuditTextLayoutOptions {
70
+ /** Measurer for run widths / vertical metrics. Defaults to the heuristic
71
+ * measurer (browser-safe, approximate); pass the fontkit measurer from
72
+ * `@office-kit/pptx-preview/node` for real glyph metrics. */
73
+ readonly measureText?: TextMeasurer;
74
+ /** Overflow at or below this many px (96 DPI) is ignored. Default 1 —
75
+ * measurement and PowerPoint disagree by sub-pixel amounts routinely. */
76
+ readonly tolerancePx?: number;
77
+ /** Also report paragraphs that wrap onto more lines than their explicit
78
+ * breaks author (段落ち). Off by default: wrapping is normal for body
79
+ * text, so this is opt-in for single-line-intent content like titles. */
80
+ readonly reportSoftWraps?: boolean;
81
+ }
82
+ /**
83
+ * Measures every text body in the deck and reports text that escapes its box
84
+ * (`overflow-x` / `overflow-y`) and, opt-in, paragraphs that soft-wrap
85
+ * (`soft-wrap`, 段落ち).
86
+ *
87
+ * Table cell text is not audited (v1 covers shape text bodies, including
88
+ * placeholders and shapes inside groups).
89
+ */
90
+ declare const auditTextLayout: (pres: PresentationData, options?: AuditTextLayoutOptions) => ReadonlyArray<TextAuditIssue>;
91
+ //#endregion
92
+ export { ARIAL as a, MeasureResult as c, SERIF as d, TIMES as f, substituteFamily as g, defaultMeasurer as h, renderSlideSvg as i, RenderSlideOptions as l, TextMeasurer as m, TextAuditIssue as n, FontSpec as o, TextLayoutMode as p, auditTextLayout as r, MONO as s, AuditTextLayoutOptions as t, SANS as u };
93
+ //# sourceMappingURL=index-B6ISDjnu.d.ts.map
package/dist/index.d.ts CHANGED
@@ -1,2 +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 };
1
+ import { a as ARIAL, c as MeasureResult, d as SERIF, f as TIMES, g as substituteFamily, h as defaultMeasurer, i as renderSlideSvg, l as RenderSlideOptions, m as TextMeasurer, n as TextAuditIssue, o as FontSpec, p as TextLayoutMode, r as auditTextLayout, s as MONO, t as AuditTextLayoutOptions, u as SANS } from "./index-B6ISDjnu.js";
2
+ export { ARIAL, type AuditTextLayoutOptions, type FontSpec, MONO, type MeasureResult, type RenderSlideOptions, SANS, SERIF, TIMES, type TextAuditIssue, type TextLayoutMode, type TextMeasurer, auditTextLayout, defaultMeasurer, renderSlideSvg as renderSlideToSvg, substituteFamily };
package/dist/index.js CHANGED
@@ -1,2 +1,2 @@
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
- export { ARIAL, MONO, SANS, SERIF, TIMES, defaultMeasurer, renderSlideSvg as renderSlideToSvg, substituteFamily };
1
+ import { a as MONO, c as TIMES, d as substituteFamily, l as defaultMeasurer, n as renderSlideSvg, o as SANS, r as ARIAL, s as SERIF, t as auditTextLayout } from "./src-D95l1T9U.js";
2
+ export { ARIAL, MONO, SANS, SERIF, TIMES, auditTextLayout, defaultMeasurer, renderSlideSvg as renderSlideToSvg, substituteFamily };
package/dist/node.d.ts CHANGED
@@ -1,11 +1,28 @@
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";
1
+ import { a as ARIAL, c as MeasureResult, d as SERIF, f as TIMES, g as substituteFamily, h as defaultMeasurer, i as renderSlideSvg, l as RenderSlideOptions, m as TextMeasurer, n as TextAuditIssue, o as FontSpec, p as TextLayoutMode, r as auditTextLayout, s as MONO, t as AuditTextLayoutOptions, u as SANS } from "./index-B6ISDjnu.js";
2
2
  import { PresentationData, SlideData } from "@office-kit/pptx";
3
3
 
4
4
  //#region src/measure.d.ts
5
5
  declare const FONT_DIR: string;
6
6
  /** Absolute paths of every bundled face — passed to resvg's `fontFiles`. */
7
7
  declare const FONT_FILES: string[];
8
- declare const buildFontkitMeasurer: () => TextMeasurer;
8
+ /** A font the caller supplies for measurement, keyed by the AUTHORED family
9
+ * name (`<a:latin typeface>` / `<a:ea typeface>` — e.g. "游ゴシック",
10
+ * "Montserrat"), which routinely differs from the file's internal name. */
11
+ interface RegisteredFont {
12
+ readonly family: string;
13
+ /** Path to a ttf/otf file, or its bytes. */
14
+ readonly source: string | Uint8Array;
15
+ readonly bold?: boolean;
16
+ readonly italic?: boolean;
17
+ }
18
+ interface FontkitMeasurerOptions {
19
+ /** Fonts the deck actually uses. A run whose family matches a registered
20
+ * font measures with it; any registered font also serves as a fallback
21
+ * for glyphs the resolved font lacks (the bundled faces are Latin-only,
22
+ * so this is how CJK text gets real metrics). */
23
+ readonly fonts?: ReadonlyArray<RegisteredFont>;
24
+ }
25
+ declare const buildFontkitMeasurer: (options?: FontkitMeasurerOptions) => TextMeasurer;
9
26
  //#endregion
10
27
  //#region src/node.d.ts
11
28
  /** Raw, un-encoded raster: RGBA bytes in row-major order. */
@@ -27,6 +44,22 @@ interface RenderImageOptions {
27
44
  * result to control its lifetime, or a custom measurer to swap fonts.
28
45
  */
29
46
  readonly measureText?: TextMeasurer;
47
+ /**
48
+ * Extra font files (ttf / otf / ttc paths) loaded into the rasterizer in
49
+ * addition to the bundled Latin faces. The bundled set has no CJK
50
+ * coverage, so decks with Japanese / Chinese / Korean text render as
51
+ * missing-glyph boxes unless the caller supplies a face here (pair it with
52
+ * a {@link buildFontkitMeasurer} `fonts` registration so wrap math uses the
53
+ * same metrics).
54
+ */
55
+ readonly fontFiles?: ReadonlyArray<string>;
56
+ /**
57
+ * Also let the rasterizer discover OS-installed fonts as a fallback for
58
+ * families the bundled + `fontFiles` faces don't cover. Defaults to
59
+ * `false`: system fonts vary per machine, so the default keeps output
60
+ * deterministic.
61
+ */
62
+ readonly loadSystemFonts?: boolean;
30
63
  }
31
64
  /** Render one slide to PNG-encoded bytes. */
32
65
  declare const renderSlideToImage: (pres: PresentationData, slide: SlideData, opts?: RenderImageOptions) => Uint8Array;
@@ -40,5 +73,5 @@ declare const renderSlideToRgba: (pres: PresentationData, slide: SlideData, opts
40
73
  readonly png: Uint8Array;
41
74
  };
42
75
  //#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 };
76
+ export { ARIAL, type AuditTextLayoutOptions, FONT_DIR, FONT_FILES, type FontSpec, type FontkitMeasurerOptions, MONO, type MeasureResult, type RegisteredFont, RenderImageOptions, type RenderSlideOptions, RgbaImage, SANS, SERIF, TIMES, type TextAuditIssue, type TextLayoutMode, type TextMeasurer, auditTextLayout, buildFontkitMeasurer, defaultMeasurer, renderSlideToImage, renderSlideToRgba, renderSlideSvg as renderSlideToSvg, substituteFamily };
44
77
  //# sourceMappingURL=node.d.ts.map
package/dist/node.js CHANGED
@@ -1,4 +1,4 @@
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";
1
+ import { a as MONO, c as TIMES, d as substituteFamily, i as AVG_GLYPH_W_RATIO, l as defaultMeasurer, n as renderSlideSvg, o as SANS, r as ARIAL, s as SERIF, t as auditTextLayout, u as isCjk } from "./src-D95l1T9U.js";
2
2
  import { getSlideSize } from "@office-kit/pptx";
3
3
  import { Resvg } from "@resvg/resvg-js";
4
4
  import * as fontkit from "fontkit";
@@ -33,7 +33,14 @@ const styleSuffix = (spec) => {
33
33
  if (spec.italic) return "Italic";
34
34
  return "Regular";
35
35
  };
36
- const buildFontkitMeasurer = () => {
36
+ const openFontSource = (source) => {
37
+ if (typeof source === "string") return openFont(source);
38
+ const font = fontkit.create(Buffer.from(source));
39
+ if (!("layout" in font)) throw new Error("Expected a single font, got a collection");
40
+ return font;
41
+ };
42
+ const estimateCharWidth = (ch, sizePx) => sizePx * (isCjk(ch.codePointAt(0) ?? 0) ? 1 : AVG_GLYPH_W_RATIO);
43
+ const buildFontkitMeasurer = (options = {}) => {
37
44
  const cache = /* @__PURE__ */ new Map();
38
45
  for (const [family, prefix] of Object.entries(FAMILY_TO_PREFIX)) for (const style of STYLES) {
39
46
  const path = facePath(prefix, style);
@@ -42,25 +49,65 @@ const buildFontkitMeasurer = () => {
42
49
  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
50
  cache.set(`${prefix}-${style}`, font);
44
51
  }
52
+ const registered = /* @__PURE__ */ new Map();
53
+ const fallbackFonts = [];
54
+ for (const reg of options.fonts ?? []) {
55
+ const font = openFontSource(reg.source);
56
+ const style = styleSuffix({
57
+ bold: reg.bold ?? false,
58
+ italic: reg.italic ?? false
59
+ });
60
+ registered.set(`${reg.family.toLowerCase()}-${style}`, font);
61
+ fallbackFonts.push(font);
62
+ }
45
63
  const pick = (spec) => {
46
- const prefix = FAMILY_TO_PREFIX[spec.family] ?? "Carlito";
47
64
  const style = styleSuffix(spec);
65
+ const regKey = spec.family.toLowerCase();
66
+ const reg = registered.get(`${regKey}-${style}`) ?? registered.get(`${regKey}-Regular`);
67
+ if (reg) return reg;
68
+ const prefix = FAMILY_TO_PREFIX[spec.family] ?? FAMILY_TO_PREFIX[substituteFamily(spec.family)];
48
69
  return cache.get(`${prefix}-${style}`) ?? cache.get(`${prefix}-Regular`) ?? cache.get("Carlito-Regular");
49
70
  };
50
- return (text, spec) => {
51
- const font = pick(spec);
52
- const scale = spec.sizePx / font.unitsPerEm;
53
- const run = font.layout(text);
71
+ const measureWithFallback = (text, spec, primary) => {
72
+ const chain = [primary, ...fallbackFonts.filter((f) => f !== primary)];
73
+ let widthPx = 0;
74
+ let approximate = false;
75
+ let metricsFont = null;
76
+ let segment = "";
77
+ let segmentFont = null;
78
+ const flush = () => {
79
+ if (segment === "" || segmentFont === null) return;
80
+ const scale = spec.sizePx / segmentFont.unitsPerEm;
81
+ widthPx += segmentFont.layout(segment).advanceWidth * scale;
82
+ metricsFont ??= segmentFont;
83
+ segment = "";
84
+ };
85
+ for (const ch of text) {
86
+ const cp = ch.codePointAt(0) ?? 0;
87
+ const font = chain.find((f) => f.hasGlyphForCodePoint(cp)) ?? null;
88
+ if (font !== segmentFont) {
89
+ flush();
90
+ segmentFont = font;
91
+ }
92
+ if (font === null) {
93
+ widthPx += estimateCharWidth(ch, spec.sizePx);
94
+ approximate = true;
95
+ } else segment += ch;
96
+ }
97
+ flush();
98
+ const vm = verticalMetrics(metricsFont ?? primary);
99
+ const vmScale = spec.sizePx / (metricsFont ?? primary).unitsPerEm;
54
100
  const glyphCount = [...text].length;
55
101
  const tracking = glyphCount > 1 ? spec.letterSpacingPx * (glyphCount - 1) : 0;
56
- const vm = verticalMetrics(font);
57
102
  return {
58
- widthPx: run.advanceWidth * scale + tracking,
59
- ascentPx: vm.ascent * scale,
60
- descentPx: vm.descent * scale,
61
- lineGapPx: vm.lineGap * scale
103
+ widthPx: widthPx + tracking,
104
+ ascentPx: vm.ascent * vmScale,
105
+ descentPx: vm.descent * vmScale,
106
+ lineGapPx: vm.lineGap * vmScale,
107
+ ...approximate ? { approximate } : {}
62
108
  };
63
109
  };
110
+ return (text, spec) => measureWithFallback(text, spec, pick(spec));
64
111
  };
65
112
  const verticalMetrics = (font) => {
66
113
  const os2 = font["OS/2"];
@@ -100,8 +147,8 @@ const rasterize = (pres, slide, opts) => {
100
147
  value: width
101
148
  },
102
149
  font: {
103
- loadSystemFonts: false,
104
- fontFiles: FONT_FILES,
150
+ loadSystemFonts: opts.loadSystemFonts ?? false,
151
+ fontFiles: [...FONT_FILES, ...opts.fontFiles ?? []],
105
152
  defaultFontFamily: SANS,
106
153
  sansSerifFamily: SANS,
107
154
  serifFamily: SERIF,
@@ -128,6 +175,6 @@ const renderSlideToRgba = (pres, slide, opts = {}) => {
128
175
  };
129
176
  };
130
177
  //#endregion
131
- export { ARIAL, FONT_DIR, FONT_FILES, MONO, SANS, SERIF, TIMES, buildFontkitMeasurer, defaultMeasurer, renderSlideToImage, renderSlideToRgba, renderSlideSvg as renderSlideToSvg, substituteFamily };
178
+ export { ARIAL, FONT_DIR, FONT_FILES, MONO, SANS, SERIF, TIMES, auditTextLayout, buildFontkitMeasurer, defaultMeasurer, renderSlideToImage, renderSlideToRgba, renderSlideSvg as renderSlideToSvg, substituteFamily };
132
179
 
133
180
  //# sourceMappingURL=node.js.map
package/dist/node.js.map CHANGED
@@ -1 +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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"}
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 AVG_GLYPH_W_RATIO,\n isCjk,\n MONO,\n SANS,\n SERIF,\n substituteFamily,\n TIMES,\n type FontSpec,\n type MeasureResult,\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: { bold: boolean; italic: boolean }): (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\n/** A font the caller supplies for measurement, keyed by the AUTHORED family\n * name (`<a:latin typeface>` / `<a:ea typeface>` — e.g. \"游ゴシック\",\n * \"Montserrat\"), which routinely differs from the file's internal name. */\nexport interface RegisteredFont {\n readonly family: string;\n /** Path to a ttf/otf file, or its bytes. */\n readonly source: string | Uint8Array;\n readonly bold?: boolean;\n readonly italic?: boolean;\n}\n\nexport interface FontkitMeasurerOptions {\n /** Fonts the deck actually uses. A run whose family matches a registered\n * font measures with it; any registered font also serves as a fallback\n * for glyphs the resolved font lacks (the bundled faces are Latin-only,\n * so this is how CJK text gets real metrics). */\n readonly fonts?: ReadonlyArray<RegisteredFont>;\n}\n\nconst openFontSource = (source: string | Uint8Array): fontkit.Font => {\n if (typeof source === 'string') return openFont(source);\n const font = fontkit.create(Buffer.from(source));\n if (!('layout' in font)) {\n throw new Error('Expected a single font, got a collection');\n }\n return font;\n};\n\n// Per-character width estimate for glyphs no available font covers — the same\n// ratios as the heuristic measurer, so estimates and defaultMeasurer agree.\nconst estimateCharWidth = (ch: string, sizePx: number): number =>\n sizePx * (isCjk(ch.codePointAt(0) ?? 0) ? 1 : AVG_GLYPH_W_RATIO);\n\nexport const buildFontkitMeasurer = (options: FontkitMeasurerOptions = {}): 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 // Registered fonts, keyed by lowercased authored family + style. No\n // familyName assertion here: the registration key is the DECK's name for\n // the font, which legitimately differs from the file's internal name.\n const registered = new Map<string, fontkit.Font>();\n const fallbackFonts: fontkit.Font[] = [];\n for (const reg of options.fonts ?? []) {\n const font = openFontSource(reg.source);\n const style = styleSuffix({ bold: reg.bold ?? false, italic: reg.italic ?? false });\n registered.set(`${reg.family.toLowerCase()}-${style}`, font);\n fallbackFonts.push(font);\n }\n\n const pick = (spec: FontSpec): fontkit.Font => {\n const style = styleSuffix(spec);\n const regKey = spec.family.toLowerCase();\n const reg = registered.get(`${regKey}-${style}`) ?? registered.get(`${regKey}-Regular`);\n if (reg) return reg;\n // The render path pre-substitutes families (spec.family is a bundled\n // internal name); the audit path passes the authored name through, so\n // resolve it with the same map before keying the bundled faces.\n const prefix =\n FAMILY_TO_PREFIX[spec.family] ?? FAMILY_TO_PREFIX[substituteFamily(spec.family)]!;\n return (\n cache.get(`${prefix}-${style}`) ??\n cache.get(`${prefix}-Regular`) ??\n cache.get('Carlito-Regular')!\n );\n };\n\n // Splits `text` into maximal segments coverable by one font: the resolved\n // font first, then each registered font in registration order. Characters\n // no font covers fall back to the per-character estimate (approximate).\n const measureWithFallback = (\n text: string,\n spec: FontSpec,\n primary: fontkit.Font,\n ): MeasureResult => {\n const chain = [primary, ...fallbackFonts.filter((f) => f !== primary)];\n let widthPx = 0;\n let approximate = false;\n let metricsFont: fontkit.Font | null = null;\n let segment = '';\n let segmentFont: fontkit.Font | null = null;\n const flush = (): void => {\n if (segment === '' || segmentFont === null) return;\n const scale = spec.sizePx / segmentFont.unitsPerEm;\n widthPx += segmentFont.layout(segment).advanceWidth * scale;\n metricsFont ??= segmentFont;\n segment = '';\n };\n for (const ch of text) {\n const cp = ch.codePointAt(0) ?? 0;\n const font = chain.find((f) => f.hasGlyphForCodePoint(cp)) ?? null;\n if (font !== segmentFont) {\n flush();\n segmentFont = font;\n }\n if (font === null) {\n widthPx += estimateCharWidth(ch, spec.sizePx);\n approximate = true;\n } else {\n segment += ch;\n }\n }\n flush();\n const vm = verticalMetrics(metricsFont ?? primary);\n const vmScale = spec.sizePx / (metricsFont ?? primary).unitsPerEm;\n const glyphCount = [...text].length;\n const tracking = glyphCount > 1 ? spec.letterSpacingPx * (glyphCount - 1) : 0;\n return {\n widthPx: widthPx + tracking,\n ascentPx: vm.ascent * vmScale,\n descentPx: vm.descent * vmScale,\n lineGapPx: vm.lineGap * vmScale,\n ...(approximate ? { approximate } : {}),\n };\n };\n\n return (text, spec) => measureWithFallback(text, spec, pick(spec));\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';\nexport type { FontkitMeasurerOptions, RegisteredFont } 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 * Extra font files (ttf / otf / ttc paths) loaded into the rasterizer in\n * addition to the bundled Latin faces. The bundled set has no CJK\n * coverage, so decks with Japanese / Chinese / Korean text render as\n * missing-glyph boxes unless the caller supplies a face here (pair it with\n * a {@link buildFontkitMeasurer} `fonts` registration so wrap math uses the\n * same metrics).\n */\n readonly fontFiles?: ReadonlyArray<string>;\n /**\n * Also let the rasterizer discover OS-installed fonts as a fallback for\n * families the bundled + `fontFiles` faces don't cover. Defaults to\n * `false`: system fonts vary per machine, so the default keeps output\n * deterministic.\n */\n readonly loadSystemFonts?: boolean;\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: opts.loadSystemFonts ?? false, // default: deterministic bundled set\n fontFiles: [...FONT_FILES, ...(opts.fontFiles ?? [])],\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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"}