@office-kit/pptx-preview 0.18.0 → 0.20.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
@@ -64,6 +64,12 @@ const svg = renderSlideToSvg(pres, slide, {
64
64
  });
65
65
  ```
66
66
 
67
+ The bundled substitutes have no Japanese, Chinese or Korean glyphs. Pass
68
+ `eastAsianFamily` to write a run's East Asian characters (kana, kanji, Hangul,
69
+ and CJK and full-width punctuation) in a family the viewer has, in the weights
70
+ the deck uses; otherwise the glyphs are left to the rasterizer's fallback,
71
+ which in resvg ignores bold.
72
+
67
73
  `renderSlideLayoutToSvg(pres, layout, { master })` draws what a slide master
68
74
  editor shows behind the placeholders: the layout's background and decorative
69
75
  shapes over its master's (the master's are left out when the layout hides
@@ -105,6 +111,11 @@ Cambria, Liberation ≈ Arial/Times/Courier; OFL / Apache-2.0, see
105
111
  all reference the same fonts, so wrap/positioning math agrees with the painted
106
112
  glyphs and the result is deterministic (no system fonts).
107
113
 
114
+ For Japanese, Chinese or Korean text, pass the faces in `fontFiles` — the
115
+ regular and the bold one — and register them with `buildFontkitMeasurer({ fonts })`
116
+ for the measurer. East Asian characters are drawn in the family of the first
117
+ `fontFiles` face that covers kana, in the face closest to each run's weight.
118
+
108
119
  ### Text-layout audit (overflow / soft-wrap detection)
109
120
 
110
121
  ```ts
@@ -58,6 +58,15 @@ interface RenderSlideOptions {
58
58
  * `@font-face` rule the host embeds), and pair it with a `measureText` that
59
59
  * knows its metrics so line breaks match the glyphs. */
60
60
  readonly resolveFamily?: (family: string | null) => string;
61
+ /** Family written for East Asian characters (kana, kanji, Hangul, and CJK
62
+ * and full-width punctuation) with `textLayout: 'svg'`, split from the
63
+ * run's Latin text the way presentation apps switch to a run's East Asian
64
+ * font. Unset keeps them in the run's family and leaves the face to the
65
+ * rasterizer's fallback; resvg's fallback ignores the weight, so bold
66
+ * Japanese text there paints with whichever face it finds first. Name a
67
+ * family the rasterizer has loaded in the weights the deck uses. The
68
+ * `/node` entry sets it from the first `fontFiles` face that covers kana. */
69
+ readonly eastAsianFamily?: string;
61
70
  }
62
71
  declare const SANS = "Carlito";
63
72
  declare const SERIF = "Caladea";
@@ -295,4 +304,4 @@ declare function shapeTextAnchorOffset(pres: PresentationData, shape: SlideShape
295
304
  declare function textUnderlineStyle(underline: TextFormat['underline'], color: string, includeDecorationColor?: boolean): string;
296
305
  //#endregion
297
306
  export { substituteFamily as A, RenderSlideOptions as C, TextLayoutMode as D, TIMES as E, TextMeasurer as O, MeasureResult as S, SERIF as T, renderSlideSvg as _, VerticalTextStyle as a, FontSpec as b, TextRectFractions as c, decimalSeparatorOf as d, paragraphNumberLabels as f, renderSlideLayoutSvg as g, auditTextLayout as h, shapeTextAnchorOffset as i, defaultMeasurer as k, resolveTextBodyRect as l, TextAuditIssue as m, ShapeAutoFitScaleOptions as n, textColumnsStyle as o, AuditTextLayoutOptions as p, shapeAutoFitScale as r, verticalTextStyle as s, textUnderlineStyle as t, shapeTextRect as u, renderTextEffectsSvg as v, SANS as w, MONO as x, ARIAL as y };
298
- //# sourceMappingURL=index-C8dvep3H.d.ts.map
307
+ //# sourceMappingURL=index-D3rjsHQW.d.ts.map
package/dist/index.d.ts CHANGED
@@ -1,2 +1,2 @@
1
- import { A as substituteFamily, C as RenderSlideOptions, D as TextLayoutMode, E as TIMES, O as TextMeasurer, S as MeasureResult, T as SERIF, _ as renderSlideSvg, a as VerticalTextStyle, b as FontSpec, c as TextRectFractions, d as decimalSeparatorOf, f as paragraphNumberLabels, g as renderSlideLayoutSvg, h as auditTextLayout, i as shapeTextAnchorOffset, k as defaultMeasurer, l as resolveTextBodyRect, m as TextAuditIssue, n as ShapeAutoFitScaleOptions, o as textColumnsStyle, p as AuditTextLayoutOptions, r as shapeAutoFitScale, s as verticalTextStyle, t as textUnderlineStyle, u as shapeTextRect, v as renderTextEffectsSvg, w as SANS, x as MONO, y as ARIAL } from "./index-C8dvep3H.js";
1
+ import { A as substituteFamily, C as RenderSlideOptions, D as TextLayoutMode, E as TIMES, O as TextMeasurer, S as MeasureResult, T as SERIF, _ as renderSlideSvg, a as VerticalTextStyle, b as FontSpec, c as TextRectFractions, d as decimalSeparatorOf, f as paragraphNumberLabels, g as renderSlideLayoutSvg, h as auditTextLayout, i as shapeTextAnchorOffset, k as defaultMeasurer, l as resolveTextBodyRect, m as TextAuditIssue, n as ShapeAutoFitScaleOptions, o as textColumnsStyle, p as AuditTextLayoutOptions, r as shapeAutoFitScale, s as verticalTextStyle, t as textUnderlineStyle, u as shapeTextRect, v as renderTextEffectsSvg, w as SANS, x as MONO, y as ARIAL } from "./index-D3rjsHQW.js";
2
2
  export { ARIAL, type AuditTextLayoutOptions, type FontSpec, MONO, type MeasureResult, type RenderSlideOptions, SANS, SERIF, type ShapeAutoFitScaleOptions, TIMES, type TextAuditIssue, type TextLayoutMode, type TextMeasurer, type TextRectFractions, type VerticalTextStyle, auditTextLayout, decimalSeparatorOf, defaultMeasurer, paragraphNumberLabels, renderSlideLayoutSvg as renderSlideLayoutToSvg, renderSlideSvg as renderSlideToSvg, renderTextEffectsSvg, resolveTextBodyRect, shapeAutoFitScale, shapeTextAnchorOffset, shapeTextRect, substituteFamily, textColumnsStyle, textUnderlineStyle, verticalTextStyle };
package/dist/index.js CHANGED
@@ -1,2 +1,2 @@
1
- import { S as shapeTextRect, _ as paragraphNumberLabels, a as renderSlideSvg, b as verticalTextStyle, d as SERIF, f as TIMES, g as decimalSeparatorOf, h as substituteFamily, i as renderSlideLayoutSvg, l as MONO, n as shapeTextAnchorOffset, o as renderTextEffectsSvg, p as defaultMeasurer, r as auditTextLayout, s as ARIAL, t as shapeAutoFitScale, u as SANS, v as textUnderlineStyle, x as resolveTextBodyRect, y as textColumnsStyle } from "./src-BGzSNWjt.js";
1
+ import { S as shapeTextRect, _ as paragraphNumberLabels, a as renderSlideSvg, b as verticalTextStyle, d as SERIF, f as TIMES, g as decimalSeparatorOf, h as substituteFamily, i as renderSlideLayoutSvg, l as MONO, n as shapeTextAnchorOffset, o as renderTextEffectsSvg, p as defaultMeasurer, r as auditTextLayout, s as ARIAL, t as shapeAutoFitScale, u as SANS, v as textUnderlineStyle, x as resolveTextBodyRect, y as textColumnsStyle } from "./src-BObFGqe2.js";
2
2
  export { ARIAL, MONO, SANS, SERIF, TIMES, auditTextLayout, decimalSeparatorOf, defaultMeasurer, paragraphNumberLabels, renderSlideLayoutSvg as renderSlideLayoutToSvg, renderSlideSvg as renderSlideToSvg, renderTextEffectsSvg, resolveTextBodyRect, shapeAutoFitScale, shapeTextAnchorOffset, shapeTextRect, substituteFamily, textColumnsStyle, textUnderlineStyle, verticalTextStyle };
package/dist/node.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { A as substituteFamily, C as RenderSlideOptions, D as TextLayoutMode, E as TIMES, O as TextMeasurer, S as MeasureResult, T as SERIF, _ as renderSlideSvg, a as VerticalTextStyle, b as FontSpec, c as TextRectFractions, d as decimalSeparatorOf, f as paragraphNumberLabels, g as renderSlideLayoutSvg, h as auditTextLayout, i as shapeTextAnchorOffset, k as defaultMeasurer, l as resolveTextBodyRect, m as TextAuditIssue, n as ShapeAutoFitScaleOptions, o as textColumnsStyle, p as AuditTextLayoutOptions, r as shapeAutoFitScale, s as verticalTextStyle, t as textUnderlineStyle, u as shapeTextRect, v as renderTextEffectsSvg, w as SANS, x as MONO, y as ARIAL } from "./index-C8dvep3H.js";
1
+ import { A as substituteFamily, C as RenderSlideOptions, D as TextLayoutMode, E as TIMES, O as TextMeasurer, S as MeasureResult, T as SERIF, _ as renderSlideSvg, a as VerticalTextStyle, b as FontSpec, c as TextRectFractions, d as decimalSeparatorOf, f as paragraphNumberLabels, g as renderSlideLayoutSvg, h as auditTextLayout, i as shapeTextAnchorOffset, k as defaultMeasurer, l as resolveTextBodyRect, m as TextAuditIssue, n as ShapeAutoFitScaleOptions, o as textColumnsStyle, p as AuditTextLayoutOptions, r as shapeAutoFitScale, s as verticalTextStyle, t as textUnderlineStyle, u as shapeTextRect, v as renderTextEffectsSvg, w as SANS, x as MONO, y as ARIAL } from "./index-D3rjsHQW.js";
2
2
  import { PresentationData, SlideData } from "@office-kit/pptx";
3
3
 
4
4
  //#region src/measure.d.ts
@@ -50,7 +50,9 @@ interface RenderImageOptions {
50
50
  * coverage, so decks with Japanese / Chinese / Korean text render as
51
51
  * missing-glyph boxes unless the caller supplies a face here (pair it with
52
52
  * a {@link buildFontkitMeasurer} `fonts` registration so wrap math uses the
53
- * same metrics).
53
+ * same metrics). East Asian text is drawn in the family of the first face
54
+ * here that covers kana, in the weight closest to the run's, so supply that
55
+ * family's bold face too for bold Japanese text.
54
56
  */
55
57
  readonly fontFiles?: ReadonlyArray<string>;
56
58
  /**
package/dist/node.js CHANGED
@@ -1,4 +1,4 @@
1
- import { S as shapeTextRect, _ as paragraphNumberLabels, a as renderSlideSvg, b as verticalTextStyle, c as AVG_GLYPH_W_RATIO, d as SERIF, f as TIMES, g as decimalSeparatorOf, h as substituteFamily, i as renderSlideLayoutSvg, l as MONO, m as isCjk, n as shapeTextAnchorOffset, o as renderTextEffectsSvg, p as defaultMeasurer, r as auditTextLayout, s as ARIAL, t as shapeAutoFitScale, u as SANS, v as textUnderlineStyle, x as resolveTextBodyRect, y as textColumnsStyle } from "./src-BGzSNWjt.js";
1
+ import { S as shapeTextRect, _ as paragraphNumberLabels, a as renderSlideSvg, b as verticalTextStyle, c as AVG_GLYPH_W_RATIO, d as SERIF, f as TIMES, g as decimalSeparatorOf, h as substituteFamily, i as renderSlideLayoutSvg, l as MONO, m as isCjk, n as shapeTextAnchorOffset, o as renderTextEffectsSvg, p as defaultMeasurer, r as auditTextLayout, s as ARIAL, t as shapeAutoFitScale, u as SANS, v as textUnderlineStyle, x as resolveTextBodyRect, y as textColumnsStyle } from "./src-BObFGqe2.js";
2
2
  import { getSlideSize } from "@office-kit/pptx";
3
3
  import { Resvg } from "@resvg/resvg-js";
4
4
  import * as fontkit from "fontkit";
@@ -155,12 +155,42 @@ const verticalMetrics = (font) => {
155
155
  const DEFAULT_WIDTH = 1280;
156
156
  let sharedMeasurer;
157
157
  const getSharedMeasurer = () => sharedMeasurer ??= buildFontkitMeasurer();
158
+ const KANA_PROBE = 12354;
159
+ const eastAsianFamilyCache = /* @__PURE__ */ new Map();
160
+ const matchFamilyOf = (font) => font.getName("preferredFamily", "en") ?? font.familyName;
161
+ const openFontFile = (path) => {
162
+ if (!existsSync(path)) return null;
163
+ try {
164
+ return fontkit.create(readFileSync(path));
165
+ } catch {
166
+ return null;
167
+ }
168
+ };
169
+ const eastAsianFamilyOf = (fontFiles) => {
170
+ const key = fontFiles.join("\n");
171
+ if (eastAsianFamilyCache.has(key)) return eastAsianFamilyCache.get(key);
172
+ let family;
173
+ for (const path of fontFiles) {
174
+ const opened = openFontFile(path);
175
+ if (opened === null) continue;
176
+ const face = ("fonts" in opened ? opened.fonts : [opened]).find((f) => f.hasGlyphForCodePoint(KANA_PROBE));
177
+ if (face) {
178
+ family = matchFamilyOf(face);
179
+ break;
180
+ }
181
+ }
182
+ eastAsianFamilyCache.set(key, family);
183
+ return family;
184
+ };
158
185
  const rasterize = (pres, slide, opts) => {
159
186
  getSlideSize(pres);
160
187
  const width = opts.width ?? DEFAULT_WIDTH;
188
+ const measureText = opts.measureText ?? getSharedMeasurer();
189
+ const eastAsianFamily = eastAsianFamilyOf(opts.fontFiles ?? []);
161
190
  return new Resvg(renderSlideSvg(pres, slide, {
162
- measureText: opts.measureText ?? getSharedMeasurer(),
163
- textLayout: "svg"
191
+ measureText,
192
+ textLayout: "svg",
193
+ ...eastAsianFamily === void 0 ? {} : { eastAsianFamily }
164
194
  }), {
165
195
  fitTo: {
166
196
  mode: "width",
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 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 // Keep the bounds signed until the end. Initialising either side at zero\n // would turn a glyph entirely above/below the baseline into a false ink\n // extent on the opposite side.\n let inkTop = Number.NEGATIVE_INFINITY;\n let inkBottom = Number.POSITIVE_INFINITY;\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 const run = segmentFont.layout(segment, { kern: spec.kerning !== false });\n widthPx += run.advanceWidth * scale;\n let penY = 0;\n for (const [index, glyph] of run.glyphs.entries()) {\n const box = glyph.bbox;\n if (\n !box ||\n !Number.isFinite(box.minY) ||\n !Number.isFinite(box.maxY) ||\n box.maxY <= box.minY\n ) {\n penY += run.positions[index]?.yAdvance ?? 0;\n continue;\n }\n // fontkit reports the glyph bbox in font units and shaping can move a\n // glyph vertically (combining marks are the common case). Include the\n // shaped position so the result describes the painted glyph, rather\n // than the unpositioned outline.\n const position = run.positions[index];\n const glyphY = penY + (position?.yOffset ?? 0);\n inkTop = Math.max(inkTop, (box.maxY + glyphY) * scale);\n inkBottom = Math.min(inkBottom, (box.minY + glyphY) * scale);\n penY += position?.yAdvance ?? 0;\n }\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 && metricsFont !== null\n ? {\n inkAscentPx: inkTop === Number.NEGATIVE_INFINITY ? 0 : inkTop,\n inkDescentPx:\n inkBottom === Number.POSITIVE_INFINITY || inkBottom === 0 ? 0 : -inkBottom,\n }\n : {}),\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":";;;;;;;AAuBA,MAAa,WAAW,cAAc,IAAI,IAAI,aAAa,OAAO,KAAK,GAAG,CAAC;AAG3E,MAAM,mBAA2C;EAC9C,OAAO;EACP,QAAQ;EACR,QAAQ;EACR,QAAQ;EACR,OAAO;AACV;AAEA,MAAM,SAAS;CAAC;CAAW;CAAQ;CAAU;AAAY;AAEzD,MAAM,YAAY,QAAgB,UAA0B,GAAG,WAAW,OAAO,GAAG,MAAM;;AAG1F,MAAa,aAAuB,OAAO,OAAO,gBAAgB,CAAC,CAAC,SAAS,WAC3E,OAAO,KAAK,MAAM,SAAS,QAAQ,CAAC,CAAC,CACvC;AAGA,MAAM,YAAY,SAA+B;CAC/C,MAAM,OAAO,QAAQ,OAAO,aAAa,IAAI,CAAC;CAC9C,IAAI,EAAE,YAAY,OAChB,MAAM,IAAI,MAAM,6BAA6B,KAAK,mBAAmB;CAEvE,OAAO;AACT;AAEA,MAAM,eAAe,SAAsE;CACzF,IAAI,KAAK,QAAQ,KAAK,QAAQ,OAAO;CACrC,IAAI,KAAK,MAAM,OAAO;CACtB,IAAI,KAAK,QAAQ,OAAO;CACxB,OAAO;AACT;AAqBA,MAAM,kBAAkB,WAA8C;CACpE,IAAI,OAAO,WAAW,UAAU,OAAO,SAAS,MAAM;CACtD,MAAM,OAAO,QAAQ,OAAO,OAAO,KAAK,MAAM,CAAC;CAC/C,IAAI,EAAE,YAAY,OAChB,MAAM,IAAI,MAAM,0CAA0C;CAE5D,OAAO;AACT;AAIA,MAAM,qBAAqB,IAAY,WACrC,UAAU,MAAM,GAAG,YAAY,CAAC,KAAK,CAAC,IAAI,IAAI;AAEhD,MAAa,wBAAwB,UAAkC,CAAC,MAAoB;CAC1F,MAAM,wBAAQ,IAAI,IAA0B;CAI5C,KAAK,MAAM,CAAC,QAAQ,WAAW,OAAO,QAAQ,gBAAgB,GAC5D,KAAK,MAAM,SAAS,QAAQ;EAC1B,MAAM,OAAO,SAAS,QAAQ,KAAK;EACnC,IAAI,CAAC,WAAW,IAAI,GAAG,MAAM,IAAI,MAAM,yBAAyB,MAAM;EACtE,MAAM,OAAO,SAAS,IAAI;EAC1B,IAAI,UAAU,aAAa,KAAK,eAAe,QAC7C,MAAM,IAAI,MACR,yBAAyB,KAAK,YAAY,KAAK,WAAW,eAAe,OAAO,8DAElF;EAEF,MAAM,IAAI,GAAG,OAAO,GAAG,SAAS,IAAI;CACtC;CAMF,MAAM,6BAAa,IAAI,IAA0B;CACjD,MAAM,gBAAgC,CAAC;CACvC,KAAK,MAAM,OAAO,QAAQ,SAAS,CAAC,GAAG;EACrC,MAAM,OAAO,eAAe,IAAI,MAAM;EACtC,MAAM,QAAQ,YAAY;GAAE,MAAM,IAAI,QAAQ;GAAO,QAAQ,IAAI,UAAU;EAAM,CAAC;EAClF,WAAW,IAAI,GAAG,IAAI,OAAO,YAAY,EAAE,GAAG,SAAS,IAAI;EAC3D,cAAc,KAAK,IAAI;CACzB;CAEA,MAAM,QAAQ,SAAiC;EAC7C,MAAM,QAAQ,YAAY,IAAI;EAC9B,MAAM,SAAS,KAAK,OAAO,YAAY;EACvC,MAAM,MAAM,WAAW,IAAI,GAAG,OAAO,GAAG,OAAO,KAAK,WAAW,IAAI,GAAG,OAAO,SAAS;EACtF,IAAI,KAAK,OAAO;EAIhB,MAAM,SACJ,iBAAiB,KAAK,WAAW,iBAAiB,iBAAiB,KAAK,MAAM;EAChF,OACE,MAAM,IAAI,GAAG,OAAO,GAAG,OAAO,KAC9B,MAAM,IAAI,GAAG,OAAO,SAAS,KAC7B,MAAM,IAAI,iBAAiB;CAE/B;CAKA,MAAM,uBACJ,MACA,MACA,YACkB;EAClB,MAAM,QAAQ,CAAC,SAAS,GAAG,cAAc,QAAQ,MAAM,MAAM,OAAO,CAAC;EACrE,IAAI,UAAU;EACd,IAAI,cAAc;EAClB,IAAI,cAAmC;EAIvC,IAAI,SAAS,OAAO;EACpB,IAAI,YAAY,OAAO;EACvB,IAAI,UAAU;EACd,IAAI,cAAmC;EACvC,MAAM,cAAoB;GACxB,IAAI,YAAY,MAAM,gBAAgB,MAAM;GAC5C,MAAM,QAAQ,KAAK,SAAS,YAAY;GACxC,MAAM,MAAM,YAAY,OAAO,SAAS,EAAE,MAAM,KAAK,YAAY,MAAM,CAAC;GACxE,WAAW,IAAI,eAAe;GAC9B,IAAI,OAAO;GACX,KAAK,MAAM,CAAC,OAAO,UAAU,IAAI,OAAO,QAAQ,GAAG;IACjD,MAAM,MAAM,MAAM;IAClB,IACE,CAAC,OACD,CAAC,OAAO,SAAS,IAAI,IAAI,KACzB,CAAC,OAAO,SAAS,IAAI,IAAI,KACzB,IAAI,QAAQ,IAAI,MAChB;KACA,QAAQ,IAAI,UAAU,MAAM,EAAE,YAAY;KAC1C;IACF;IAKA,MAAM,WAAW,IAAI,UAAU;IAC/B,MAAM,SAAS,QAAQ,UAAU,WAAW;IAC5C,SAAS,KAAK,IAAI,SAAS,IAAI,OAAO,UAAU,KAAK;IACrD,YAAY,KAAK,IAAI,YAAY,IAAI,OAAO,UAAU,KAAK;IAC3D,QAAQ,UAAU,YAAY;GAChC;GACA,gBAAgB;GAChB,UAAU;EACZ;EACA,KAAK,MAAM,MAAM,MAAM;GACrB,MAAM,KAAK,GAAG,YAAY,CAAC,KAAK;GAChC,MAAM,OAAO,MAAM,MAAM,MAAM,EAAE,qBAAqB,EAAE,CAAC,KAAK;GAC9D,IAAI,SAAS,aAAa;IACxB,MAAM;IACN,cAAc;GAChB;GACA,IAAI,SAAS,MAAM;IACjB,WAAW,kBAAkB,IAAI,KAAK,MAAM;IAC5C,cAAc;GAChB,OACE,WAAW;EAEf;EACA,MAAM;EACN,MAAM,KAAK,gBAAgB,eAAe,OAAO;EACjD,MAAM,UAAU,KAAK,UAAU,eAAe,QAAA,CAAS;EACvD,MAAM,aAAa,CAAC,GAAG,IAAI,CAAC,CAAC;EAC7B,MAAM,WAAW,aAAa,IAAI,KAAK,mBAAmB,aAAa,KAAK;EAC5E,OAAO;GACL,SAAS,UAAU;GACnB,UAAU,GAAG,SAAS;GACtB,WAAW,GAAG,UAAU;GACxB,WAAW,GAAG,UAAU;GACxB,GAAI,CAAC,eAAe,gBAAgB,OAChC;IACE,aAAa,WAAW,OAAO,oBAAoB,IAAI;IACvD,cACE,cAAc,OAAO,qBAAqB,cAAc,IAAI,IAAI,CAAC;GACrE,IACA,CAAC;GACL,GAAI,cAAc,EAAE,YAAY,IAAI,CAAC;EACvC;CACF;CAEA,QAAQ,MAAM,SAAS,oBAAoB,MAAM,MAAM,KAAK,IAAI,CAAC;AACnE;AAeA,MAAM,mBAAmB,SAAiC;CACxD,MAAM,MAAgB,KAA8B;CACpD,IAAI,OAAO,OAAO,QAAQ,UAAU;EAClC,MAAM,IAAI;EAQV,IAAI,EAAE,aAAa,kBAAkB,EAAE,iBAAiB,KAAA,GACtD,OAAO;GACL,QAAQ,EAAE;GACV,SAAS,KAAK,IAAI,EAAE,iBAAiB,CAAC;GACtC,SAAS,EAAE,eAAe;EAC5B;EAEF,IAAI,EAAE,cAAc,KAAA,KAAa,EAAE,eAAe,KAAA,GAChD,OAAO;GAAE,QAAQ,EAAE;GAAW,SAAS,KAAK,IAAI,EAAE,UAAU;GAAG,SAAS;EAAE;CAE9E;CAEA,OAAO;EAAE,QAAQ,KAAK;EAAQ,SAAS,KAAK,IAAI,KAAK,OAAO;EAAG,SAAS,KAAK;CAAQ;AACvF;;;AC5MA,MAAM,gBAAgB;AAKtB,IAAI;AACJ,MAAM,0BAAyC,mBAAmB,qBAAqB;AAEvF,MAAM,aAAa,MAAwB,OAAkB,SAA6B;CAGxF,aAAa,IAAI;CACjB,MAAM,QAAQ,KAAK,SAAS;CAc5B,OAAO,IAXW,MADN,eAAe,MAAM,OAAO;EAAE,aADtB,KAAK,eAAe,kBAAkB;EACH,YAAY;CAAM,CAC/C,GAAG;EAC3B,OAAO;GAAE,MAAM;GAAS,OAAO;EAAM;EACrC,MAAM;GACJ,iBAAiB,KAAK,mBAAmB;GACzC,WAAW,CAAC,GAAG,YAAY,GAAI,KAAK,aAAa,CAAC,CAAE;GACpD,mBAAmB;GACnB,iBAAiB;GACjB,aAAa;GACb,iBAAiB;EACnB;CACF,CACW,CAAC,CAAC,OAAO;AACtB;;AAGA,MAAa,sBACX,MACA,OACA,OAA2B,CAAC,MACb,IAAI,WAAW,UAAU,MAAM,OAAO,IAAI,CAAC,CAAC,MAAM,CAAC;;;;;;AAOpE,MAAa,qBACX,MACA,OACA,OAA2B,CAAC,MACgC;CAC5D,MAAM,WAAW,UAAU,MAAM,OAAO,IAAI;CAC5C,OAAO;EACL,OAAO;GACL,OAAO,SAAS;GAChB,QAAQ,SAAS;GACjB,MAAM,IAAI,WAAW,SAAS,MAAM;EACtC;EACA,KAAK,IAAI,WAAW,SAAS,MAAM,CAAC;CACtC;AACF"}
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 // Keep the bounds signed until the end. Initialising either side at zero\n // would turn a glyph entirely above/below the baseline into a false ink\n // extent on the opposite side.\n let inkTop = Number.NEGATIVE_INFINITY;\n let inkBottom = Number.POSITIVE_INFINITY;\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 const run = segmentFont.layout(segment, { kern: spec.kerning !== false });\n widthPx += run.advanceWidth * scale;\n let penY = 0;\n for (const [index, glyph] of run.glyphs.entries()) {\n const box = glyph.bbox;\n if (\n !box ||\n !Number.isFinite(box.minY) ||\n !Number.isFinite(box.maxY) ||\n box.maxY <= box.minY\n ) {\n penY += run.positions[index]?.yAdvance ?? 0;\n continue;\n }\n // fontkit reports the glyph bbox in font units and shaping can move a\n // glyph vertically (combining marks are the common case). Include the\n // shaped position so the result describes the painted glyph, rather\n // than the unpositioned outline.\n const position = run.positions[index];\n const glyphY = penY + (position?.yOffset ?? 0);\n inkTop = Math.max(inkTop, (box.maxY + glyphY) * scale);\n inkBottom = Math.min(inkBottom, (box.minY + glyphY) * scale);\n penY += position?.yAdvance ?? 0;\n }\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 && metricsFont !== null\n ? {\n inkAscentPx: inkTop === Number.NEGATIVE_INFINITY ? 0 : inkTop,\n inkDescentPx:\n inkBottom === Number.POSITIVE_INFINITY || inkBottom === 0 ? 0 : -inkBottom,\n }\n : {}),\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 * as fontkit from 'fontkit';\nimport { existsSync, readFileSync } from 'node:fs';\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). East Asian text is drawn in the family of the first face\n * here that covers kana, in the weight closest to the run's, so supply that\n * family's bold face too for bold Japanese text.\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\n// Hiragana あ: a face that has it is a Japanese (or pan-CJK) face.\nconst KANA_PROBE = 0x3042;\n\n// Reading a face's names means parsing the file, so remember the answer per\n// list of files: a deck renders every slide with the same list.\nconst eastAsianFamilyCache = new Map<string, string | undefined>();\n\n// The family resvg matches a face by: the typographic family when the face\n// has one (\"Hiragino Sans\" for \"Hiragino Sans W6\"), else the family name.\nconst matchFamilyOf = (font: fontkit.Font): string =>\n font.getName('preferredFamily', 'en') ?? font.familyName;\n\n// resvg skips a font file it cannot find or parse, and `fontFiles` promises\n// the same, so such a file names no family here either.\nconst openFontFile = (path: string): fontkit.Font | fontkit.FontCollection | null => {\n if (!existsSync(path)) return null;\n try {\n return fontkit.create(readFileSync(path));\n } catch {\n return null;\n }\n};\n\nconst eastAsianFamilyOf = (fontFiles: ReadonlyArray<string>): string | undefined => {\n const key = fontFiles.join('\\n');\n if (eastAsianFamilyCache.has(key)) return eastAsianFamilyCache.get(key);\n let family: string | undefined;\n for (const path of fontFiles) {\n const opened = openFontFile(path);\n if (opened === null) continue;\n const faces = 'fonts' in opened ? opened.fonts : [opened];\n const face = faces.find((f) => f.hasGlyphForCodePoint(KANA_PROBE));\n if (face) {\n family = matchFamilyOf(face);\n break;\n }\n }\n eastAsianFamilyCache.set(key, family);\n return family;\n};\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 // resvg's own fallback for glyphs the run's family lacks ignores the weight\n // (bold Japanese paints with the regular face), so name the face instead.\n const eastAsianFamily = eastAsianFamilyOf(opts.fontFiles ?? []);\n const svg = renderSlideSvg(pres, slide, {\n measureText,\n textLayout: 'svg',\n ...(eastAsianFamily === undefined ? {} : { eastAsianFamily }),\n });\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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"}
@@ -1058,6 +1058,30 @@ const emitPlacements = (placements, effectsOnly = false, effectKind = "all") =>
1058
1058
  close();
1059
1059
  return paragraphs.join("");
1060
1060
  };
1061
+ const familyOf = (g) => g.piece.cssFamily ?? g.piece.family;
1062
+ const lineText = (groups, textAnchor, x0, span) => {
1063
+ if (new Set(groups.filter((g) => !g.isTab).map(familyOf)).size < 2 || !groups.some((g) => EAST_ASIAN_CHAR.test(g.text))) return {
1064
+ x: x0,
1065
+ anchor: textAnchor,
1066
+ body: groups.map((g) => span(g)).join("")
1067
+ };
1068
+ const totalWidth = groups.reduce((sum, g) => sum + g.width, 0);
1069
+ const start = x0 - (textAnchor === "middle" ? totalWidth / 2 : textAnchor === "end" ? totalWidth : 0);
1070
+ let cursor = start;
1071
+ let family = null;
1072
+ const parts = [];
1073
+ for (const g of groups) {
1074
+ const changes = !g.isTab && familyOf(g) !== family;
1075
+ parts.push(span(g, changes && family !== null ? cursor : void 0));
1076
+ if (changes) family = familyOf(g);
1077
+ cursor += g.width;
1078
+ }
1079
+ return {
1080
+ x: start,
1081
+ anchor: "start",
1082
+ body: parts.join("")
1083
+ };
1084
+ };
1061
1085
  const topPad = (line) => {
1062
1086
  const lead = line.advance - (line.ascent + line.descent);
1063
1087
  return lead > 0 ? lead / 2 : 0;
@@ -1076,24 +1100,25 @@ const emitLine = (line, baselineY, dx, descent, nextEffectId, effectsOnly, effec
1076
1100
  const content = toks.filter((t) => !t.isBreak);
1077
1101
  if (content.length === 0) return "";
1078
1102
  const groups = groupTokens(content);
1079
- const tspans = groups.map((g) => {
1080
- const span = tspan(g);
1103
+ const x0 = line.anchorX + dx + GRID_NUDGE_X;
1104
+ const linked = lineText(groups, line.textAnchor, x0, (g, x) => {
1105
+ const span = tspan(g, x);
1081
1106
  const href = g.piece.href ? safeLinkHref(g.piece.href) : void 0;
1082
1107
  if (!href) return span;
1083
1108
  const target = href.startsWith("#") ? "" : " target=\"_blank\" rel=\"noopener noreferrer\"";
1084
1109
  const title = g.piece.hrefTip ? `<title>${escapeXml$1(g.piece.hrefTip)}</title>` : "";
1085
1110
  return `<a href="${escapeXml$1(href)}"${target}>${title}${span}</a>`;
1086
- }).join("");
1087
- if (tspans === "") return "";
1088
- const x0 = line.anchorX + dx + GRID_NUDGE_X;
1089
- const text = `<text x="${fmt(x0)}" y="${fmt(baselineY)}" text-anchor="${line.textAnchor}" xml:space="preserve">${tspans}</text>`;
1111
+ });
1112
+ if (linked.body === "") return "";
1113
+ const text = `<text x="${fmt(linked.x)}" y="${fmt(baselineY)}" text-anchor="${linked.anchor}" xml:space="preserve">${linked.body}</text>`;
1090
1114
  if (effectsOnly && effectKind === "fill") {
1091
1115
  const painted = groups.map((g) => g.piece.fillPaint ? g : {
1092
1116
  ...g,
1093
1117
  piece: unpaintedPiece(g.piece)
1094
1118
  });
1095
1119
  if (!painted.some((g) => g.piece.fillPaint)) return "";
1096
- const glyphs = `<text x="${fmt(x0)}" y="${fmt(baselineY)}" text-anchor="${line.textAnchor}" xml:space="preserve" aria-hidden="true" pointer-events="none">${painted.map(tspan).join("")}</text>`;
1120
+ const paintedText = lineText(painted, line.textAnchor, x0, tspan);
1121
+ const glyphs = `<text x="${fmt(paintedText.x)}" y="${fmt(baselineY)}" text-anchor="${paintedText.anchor}" xml:space="preserve" aria-hidden="true" pointer-events="none">${paintedText.body}</text>`;
1097
1122
  return emitTextOuterEffects(painted, line.textAnchor, x0, baselineY, nextEffectId) + glyphs + emitTextDecorations(painted, line.textAnchor, x0, baselineY);
1098
1123
  }
1099
1124
  const reflections = effectKind === "reflection" || effectKind === "all" ? emitTextReflections(groups, line.textAnchor, x0, baselineY, descent, nextEffectId) : "";
@@ -1258,11 +1283,11 @@ const emitTextBevel = (groups, textAnchor, x0, baselineY, nextId) => {
1258
1283
  const light = `<feDistantLight azimuth="${fmt(bevel.azimuthDeg)}" elevation="${fmt(bevel.elevationDeg)}"/>`;
1259
1284
  const specular = bevel.specular ? `<feSpecularLighting in="bevelHeight" surfaceScale="${fmt(surfaceScale)}" specularConstant="${fmt(bevel.specular.constant)}" specularExponent="${fmt(bevel.specular.exponent)}" lighting-color="#fff" result="bevelSpecular">${light}</feSpecularLighting>` : "";
1260
1285
  const defs = `<defs><filter id="${id}" x="-25%" y="-25%" width="150%" height="150%" color-interpolation-filters="sRGB" data-pptx-bevel="1"><feGaussianBlur in="SourceAlpha" stdDeviation="${fmt(widthPx / 2)}" result="bevelBlur"/><feComponentTransfer in="bevelBlur" result="bevelHeight"><feFuncA type="table" tableValues="${table.map(fmt).join(" ")}"/></feComponentTransfer><feDiffuseLighting in="bevelHeight" surfaceScale="${fmt(surfaceScale)}" diffuseConstant="1" lighting-color="#fff" result="bevelDiffuse">${light}</feDiffuseLighting><feColorMatrix in="bevelDiffuse" type="matrix" values="0 0 0 0 1 0 0 0 0 1 0 0 0 0 1 ${fmt(lightGain)} 0 0 0 ${fmt(-lightGain * flat)}" result="bevelLight"/><feColorMatrix in="bevelDiffuse" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 ${fmt(-shadeGain)} 0 0 0 ${fmt(shadeGain * flat)}" result="bevelShade"/>` + specular + `<feMerge result="bevelMerged"><feMergeNode in="bevelShade"/><feMergeNode in="bevelLight"/>${specular ? "<feMergeNode in=\"bevelSpecular\"/>" : ""}</feMerge><feComposite in="bevelMerged" in2="SourceAlpha" operator="in"/></filter></defs>`;
1261
- const beveled = groups.map((g) => g.piece.bevel && !g.isTab ? g : {
1286
+ const beveledText = lineText(groups.map((g) => g.piece.bevel && !g.isTab ? g : {
1262
1287
  ...g,
1263
1288
  piece: unpaintedPiece(g.piece)
1264
- });
1265
- return `${defs}<text x="${fmt(x0)}" y="${fmt(baselineY)}" text-anchor="${textAnchor}" xml:space="preserve" filter="url(#${id})" aria-hidden="true" pointer-events="none" data-pptx-bevel="text">${beveled.map(tspan).join("")}</text>`;
1289
+ }), textAnchor, x0, tspan);
1290
+ return `${defs}<text x="${fmt(beveledText.x)}" y="${fmt(baselineY)}" text-anchor="${beveledText.anchor}" xml:space="preserve" filter="url(#${id})" aria-hidden="true" pointer-events="none" data-pptx-bevel="text">${beveledText.body}</text>`;
1266
1291
  };
1267
1292
  const baselineShiftPxOf = (p) => p.sizePx * p.baseline;
1268
1293
  const SUPER_SUB_SIZE_RATIO = .65;
@@ -1408,11 +1433,12 @@ const sameGlow = (a, b) => {
1408
1433
  return a.color === b.color && a.radiusEmu === b.radiusEmu && a.opacity === b.opacity;
1409
1434
  };
1410
1435
  const samePiece = (a, b) => a.family === b.family && a.sizePx === b.sizePx && a.bold === b.bold && a.italic === b.italic && a.letterSpacingPx === b.letterSpacingPx && a.kerning === b.kerning && a.fillHex === b.fillHex && a.outlineHex === b.outlineHex && a.outlineWidthPx === b.outlineWidthPx && a.underlineHex === b.underlineHex && a.highlightHex === b.highlightHex && a.underline === b.underline && a.strike === b.strike && a.baseline === b.baseline && a.smallCaps === b.smallCaps && sameShadow(a.shadow, b.shadow) && sameGlow(a.glow, b.glow) && sameInnerShadow(a.innerShadow, b.innerShadow) && sameReflection(a.reflection, b.reflection) && a.href === b.href && a.hrefTip === b.hrefTip;
1411
- const tspan = (g) => {
1436
+ const tspan = (g, x) => {
1412
1437
  if (g.isTab) return `<tspan dx="${fmt(g.width)}">&#8203;</tspan>`;
1413
1438
  const p = g.piece;
1414
1439
  const sizePx = renderedSizePxOf(p);
1415
1440
  const attrs = [
1441
+ ...x === void 0 ? [] : [`x="${fmt(x)}"`],
1416
1442
  `font-family="${escapeXml$1(p.cssFamily ?? p.family)}"`,
1417
1443
  `font-size="${fmt(sizePx)}"`,
1418
1444
  `fill="${p.fillHex}"`
@@ -1756,6 +1782,10 @@ const BROWSER_FAMILIES = {
1756
1782
  resolveFamily: browserFontFamily,
1757
1783
  familyIsCssList: true
1758
1784
  };
1785
+ const svgFamilies = (ctx) => ({
1786
+ resolveFamily: ctx.resolveFamily,
1787
+ ...ctx.eastAsianFamily === void 0 ? {} : { eastAsianFamily: ctx.eastAsianFamily }
1788
+ });
1759
1789
  const DEFAULT_BULLET_FONT = "Arial";
1760
1790
  const DEFAULT_INSET_X = 91440;
1761
1791
  const DEFAULT_INSET_Y = 45720;
@@ -2607,6 +2637,36 @@ const faceOf = (a, font) => {
2607
2637
  cssFamily: cssFamilyName(family)
2608
2638
  };
2609
2639
  };
2640
+ const eastAsianParts = (piece, family) => {
2641
+ if (family === void 0 || !EAST_ASIAN_CHAR.test(piece.text)) return [piece];
2642
+ const face = {
2643
+ family,
2644
+ cssFamily: cssFamilyName(family)
2645
+ };
2646
+ const parts = [];
2647
+ let current = "";
2648
+ let currentEastAsian = false;
2649
+ const flush = () => {
2650
+ if (current === "") return;
2651
+ parts.push(currentEastAsian ? {
2652
+ ...piece,
2653
+ ...face,
2654
+ text: current
2655
+ } : {
2656
+ ...piece,
2657
+ text: current
2658
+ });
2659
+ current = "";
2660
+ };
2661
+ for (const ch of piece.text) {
2662
+ const eastAsian = EAST_ASIAN_CHAR.test(ch);
2663
+ if (eastAsian !== currentEastAsian) flush();
2664
+ currentEastAsian = eastAsian;
2665
+ current += ch;
2666
+ }
2667
+ flush();
2668
+ return parts;
2669
+ };
2610
2670
  const buildSvgTextInput = (a) => {
2611
2671
  const scale = a.autoFitScale;
2612
2672
  const paragraphs = a.paraData.map((para, pi) => {
@@ -2696,10 +2756,10 @@ const buildSvgTextInput = (a) => {
2696
2756
  const segs = run.text.split("\n");
2697
2757
  segs.forEach((seg, i) => {
2698
2758
  const segText = caps ? seg.toUpperCase() : seg;
2699
- const segPieces = cap === "small" ? smallCapsParts(seg, base) : [{
2759
+ const segPieces = (cap === "small" ? smallCapsParts(seg, base) : [{
2700
2760
  ...base,
2701
2761
  text: segText
2702
- }];
2762
+ }]).flatMap((piece) => eastAsianParts(piece, a.eastAsianFamily));
2703
2763
  if (a.vert === "upright") for (const segPiece of segPieces) for (const g of graphemesOf(segPiece.text)) {
2704
2764
  const last = pieces[pieces.length - 1];
2705
2765
  if (last !== void 0 && !last.isBreak) pieces.push(breakPiece());
@@ -2799,7 +2859,7 @@ const bulletFillOf = (para, theme, defaultColor) => {
2799
2859
  const runColor = para.runs.find((r) => r.text !== "\n" && r.text !== "")?.fmt?.color;
2800
2860
  return runColor === void 0 || runColor === null ? defaultColor : resolveColor(runColor, theme, defaultColor);
2801
2861
  };
2802
- const resolveTextBodyModel = (pres, shape, bounds, theme, phType, measure, defaultColor, useSvgTextRect = false, resolveFamily = substituteFamily) => {
2862
+ const resolveTextBodyModel = (pres, shape, bounds, theme, phType, measure, defaultColor, useSvgTextRect = false, resolveFamily = substituteFamily, eastAsianFamily) => {
2803
2863
  let paragraphCount;
2804
2864
  try {
2805
2865
  paragraphCount = getShapeParagraphCount(shape);
@@ -3019,7 +3079,8 @@ const resolveTextBodyModel = (pres, shape, bounds, theme, phType, measure, defau
3019
3079
  measure,
3020
3080
  vert: fitVert,
3021
3081
  columns: fitColumns,
3022
- resolveFamily
3082
+ resolveFamily,
3083
+ ...eastAsianFamily === void 0 ? {} : { eastAsianFamily }
3023
3084
  };
3024
3085
  let s = 1;
3025
3086
  while (s > AUTOFIT_FLOOR) {
@@ -3060,6 +3121,7 @@ const resolveTextBodyModel = (pres, shape, bounds, theme, phType, measure, defau
3060
3121
  vert,
3061
3122
  autoFitScale,
3062
3123
  resolveFamily,
3124
+ ...eastAsianFamily === void 0 ? {} : { eastAsianFamily },
3063
3125
  columns: vert === "none" && cols && cols.count >= 2 ? {
3064
3126
  count: cols.count,
3065
3127
  gapPx: cols.gapEmu !== void 0 ? cols.gapEmu / EMU_PER_PX : 0
@@ -3178,7 +3240,7 @@ const renderHtmlParagraphs = (paraData, numberLabels, theme, autoFitScale, defau
3178
3240
  return paragraphs;
3179
3241
  };
3180
3242
  const renderTextBody = (pres, shape, bounds, theme, phType, ctx, output = "body") => {
3181
- const model = resolveTextBodyModel(pres, shape, bounds, theme, phType, ctx.measure, activeDeckTextColor, ctx.mode === "svg" && output === "body", ctx.resolveFamily);
3243
+ const model = resolveTextBodyModel(pres, shape, bounds, theme, phType, ctx.measure, activeDeckTextColor, ctx.mode === "svg" && output === "body", ctx.resolveFamily, ctx.eastAsianFamily);
3182
3244
  if (model === null) return "";
3183
3245
  const bevel = textBevelOf(getShapeText3D(shape));
3184
3246
  const { paraData, numberLabels, authoredAutofit, autoFitScale, defaultPt, themeFace, effectiveDefaultFont, effectiveBody, anchor, innerX, innerY, innerW, innerH, svgTextRect } = model;
@@ -3214,7 +3276,7 @@ const renderTextBody = (pres, shape, bounds, theme, phType, ctx, output = "body"
3214
3276
  innerW: vInnerW,
3215
3277
  innerH: vInnerH,
3216
3278
  measure: ctx.mode === "svg" ? ctx.measure : browserTextMeasurer() ?? ctx.measure,
3217
- ...ctx.mode === "foreignObject" ? BROWSER_FAMILIES : { resolveFamily: ctx.resolveFamily },
3279
+ ...ctx.mode === "foreignObject" ? BROWSER_FAMILIES : svgFamilies(ctx),
3218
3280
  vert: svgVert,
3219
3281
  columns: svgColumns,
3220
3282
  bevel
@@ -3363,6 +3425,7 @@ const renderTextEffectsSvg = (pres, slide, shape, bounds, opts = {}) => {
3363
3425
  mode: "foreignObject",
3364
3426
  measure: opts.measureText ?? browserTextMeasurer() ?? defaultMeasurer,
3365
3427
  resolveFamily: substituteFamily,
3428
+ eastAsianFamily: void 0,
3366
3429
  ownShapes: true,
3367
3430
  background: {
3368
3431
  id: "",
@@ -3400,6 +3463,7 @@ const renderTableCellEffectsSvgInternal = (pres, slide, shape, cellPosition, bou
3400
3463
  mode: "foreignObject",
3401
3464
  measure: opts.measureText ?? browserTextMeasurer() ?? defaultMeasurer,
3402
3465
  resolveFamily: substituteFamily,
3466
+ eastAsianFamily: void 0,
3403
3467
  ownShapes: true,
3404
3468
  background: {
3405
3469
  id: "",
@@ -4994,7 +5058,7 @@ const renderTableCellText = (cell, paragraphs, cx, cy, cw, ch, color, pres, shap
4994
5058
  innerW: innerW * EMU_PER_PX,
4995
5059
  innerH: innerH * EMU_PER_PX,
4996
5060
  measure: ctx.mode === "svg" ? ctx.measure : browserTextMeasurer() ?? ctx.measure,
4997
- ...ctx.mode === "foreignObject" ? BROWSER_FAMILIES : { resolveFamily: ctx.resolveFamily },
5061
+ ...ctx.mode === "foreignObject" ? BROWSER_FAMILIES : svgFamilies(ctx),
4998
5062
  vert: verticalLayoutOf(getTableCellTextDirection(cell)),
4999
5063
  columns: null,
5000
5064
  ...effectsOnly ? { effectsOnly: true } : {},
@@ -5874,6 +5938,7 @@ const renderSurface = (pres, surface, opts) => {
5874
5938
  mode: opts.textLayout ?? "foreignObject",
5875
5939
  measure: opts.measureText ?? defaultMeasurer,
5876
5940
  resolveFamily: opts.textLayout === "svg" ? opts.resolveFamily ?? substituteFamily : substituteFamily,
5941
+ eastAsianFamily: opts.textLayout === "svg" ? opts.eastAsianFamily : void 0,
5877
5942
  ownShapes: true,
5878
5943
  background: {
5879
5944
  id: "",
@@ -6316,4 +6381,4 @@ function shapeTextAnchorOffset(pres, shape, options = {}) {
6316
6381
  //#endregion
6317
6382
  export { shapeTextRect as S, paragraphNumberLabels as _, renderSlideSvg as a, verticalTextStyle as b, AVG_GLYPH_W_RATIO as c, SERIF as d, TIMES as f, decimalSeparatorOf as g, substituteFamily as h, renderSlideLayoutSvg as i, MONO as l, isCjk as m, shapeTextAnchorOffset as n, renderTextEffectsSvg as o, defaultMeasurer as p, auditTextLayout as r, ARIAL as s, shapeAutoFitScale as t, SANS as u, textUnderlineStyle as v, resolveTextBodyRect as x, textColumnsStyle as y };
6318
6383
 
6319
- //# sourceMappingURL=src-BGzSNWjt.js.map
6384
+ //# sourceMappingURL=src-BObFGqe2.js.map