pptx-ts 0.0.1

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.
Files changed (78) hide show
  1. package/LICENSE +22 -0
  2. package/README.md +319 -0
  3. package/dist/autopage-DkjYZUFD.js +423 -0
  4. package/dist/autopage-DkjYZUFD.js.map +1 -0
  5. package/dist/browser.d.ts +31 -0
  6. package/dist/browser.d.ts.map +1 -0
  7. package/dist/browser.js +110 -0
  8. package/dist/browser.js.map +1 -0
  9. package/dist/diagnostics-Du_s75MT.d.ts +829 -0
  10. package/dist/diagnostics-Du_s75MT.d.ts.map +1 -0
  11. package/dist/enums-CvRYniUA.d.ts +463 -0
  12. package/dist/enums-CvRYniUA.d.ts.map +1 -0
  13. package/dist/errors-Bvln0H-7.js +112 -0
  14. package/dist/errors-Bvln0H-7.js.map +1 -0
  15. package/dist/errors-PflMPWBn.d.ts +182 -0
  16. package/dist/errors-PflMPWBn.d.ts.map +1 -0
  17. package/dist/fit-BKs0zxMI.js +1356 -0
  18. package/dist/fit-BKs0zxMI.js.map +1 -0
  19. package/dist/html-dom-B0I0QSbU.js +679 -0
  20. package/dist/html-dom-B0I0QSbU.js.map +1 -0
  21. package/dist/html.d.ts +26 -0
  22. package/dist/html.d.ts.map +1 -0
  23. package/dist/html.js +63 -0
  24. package/dist/html.js.map +1 -0
  25. package/dist/index.d.ts +24 -0
  26. package/dist/index.d.ts.map +1 -0
  27. package/dist/index.js +57 -0
  28. package/dist/index.js.map +1 -0
  29. package/dist/inspect.d.ts +197 -0
  30. package/dist/inspect.d.ts.map +1 -0
  31. package/dist/inspect.js +317 -0
  32. package/dist/inspect.js.map +1 -0
  33. package/dist/math.d.ts +37 -0
  34. package/dist/math.d.ts.map +1 -0
  35. package/dist/math.js +203 -0
  36. package/dist/math.js.map +1 -0
  37. package/dist/measure.d.ts +313 -0
  38. package/dist/measure.d.ts.map +1 -0
  39. package/dist/measure.js +3 -0
  40. package/dist/namespaces-BVnNu7sg.js +666 -0
  41. package/dist/namespaces-BVnNu7sg.js.map +1 -0
  42. package/dist/node.d.ts +20 -0
  43. package/dist/node.d.ts.map +1 -0
  44. package/dist/node.js +55 -0
  45. package/dist/node.js.map +1 -0
  46. package/dist/notes-RLCwdlTE.js +1703 -0
  47. package/dist/notes-RLCwdlTE.js.map +1 -0
  48. package/dist/package-CaOLXSNi.d.ts +193 -0
  49. package/dist/package-CaOLXSNi.d.ts.map +1 -0
  50. package/dist/pres-C5__xkDh.d.ts +202 -0
  51. package/dist/pres-C5__xkDh.d.ts.map +1 -0
  52. package/dist/presentation-Cp7vzlAw.js +5427 -0
  53. package/dist/presentation-Cp7vzlAw.js.map +1 -0
  54. package/dist/presentation-types-DP_8jog7.d.ts +4872 -0
  55. package/dist/presentation-types-DP_8jog7.d.ts.map +1 -0
  56. package/dist/read.d.ts +55 -0
  57. package/dist/read.d.ts.map +1 -0
  58. package/dist/read.js +4 -0
  59. package/dist/script.d.ts +581 -0
  60. package/dist/script.d.ts.map +1 -0
  61. package/dist/script.js +3741 -0
  62. package/dist/script.js.map +1 -0
  63. package/dist/shapes-BemroU-z.js +6951 -0
  64. package/dist/shapes-BemroU-z.js.map +1 -0
  65. package/dist/slide-js0QpRV-.d.ts +3873 -0
  66. package/dist/slide-js0QpRV-.d.ts.map +1 -0
  67. package/dist/text-CyetbF6p.js +11278 -0
  68. package/dist/text-CyetbF6p.js.map +1 -0
  69. package/dist/units-1kRzYShk.js +231 -0
  70. package/dist/units-1kRzYShk.js.map +1 -0
  71. package/dist/units-internal-Dg95w_YD.js +753 -0
  72. package/dist/units-internal-Dg95w_YD.js.map +1 -0
  73. package/dist/zip.d.ts +68 -0
  74. package/dist/zip.d.ts.map +1 -0
  75. package/dist/zip.js +163 -0
  76. package/dist/zip.js.map +1 -0
  77. package/package.json +152 -0
  78. package/skills/ts-pptx-upstream/SKILL.md +340 -0
@@ -0,0 +1,1356 @@
1
+ import { d as emuToInches, i as EMU_PER_POINT, x as warn } from "./units-1kRzYShk.js";
2
+ import { n as InvalidOptionError, o as UnsupportedFeatureError, r as MediaError } from "./errors-Bvln0H-7.js";
3
+ import { M as LINEH_MODIFIER, a as inch2Emu, i as getSmartParseNumber, s as marginToEmu, u as resolveTableColWidthsEmu, y as DEF_CELL_MARGIN_IN } from "./units-internal-Dg95w_YD.js";
4
+ //#region src/measure/font-collection.ts
5
+ /**
6
+ * Font **collection** (`.ttc` / `.otc`) support for the write-side metrics.
7
+ *
8
+ * A collection is a single file holding several fonts that share their heavy tables.
9
+ * `msgothic.ttc` is three fonts (MS Gothic, MS UI Gothic, MS PGothic) over one 5.5 MB
10
+ * `glyf`; `cambria.ttc` is Cambria plus Cambria Math over one outline set. On Windows
11
+ * that is how most of the East Asian faces ship, and Cambria too, so a measurement API
12
+ * that only reads a bare sfnt cannot measure them at all.
13
+ *
14
+ * `opentype.js` does not read the `ttcf` wrapper (it throws `Unsupported OpenType
15
+ * signature ttcf` from `parseBuffer`, still true in 2.0.0), so this module unwraps one
16
+ * member into a standalone sfnt before handing the bytes over. It parses only the two
17
+ * structures that requires: the table directories and the `name` table.
18
+ *
19
+ * Upstream tracks the gap as opentypejs/opentype.js#379, open since 2019, with #866 an
20
+ * unmerged PR implementing it; check both before rewriting any of this.
21
+ *
22
+ * ## Layout
23
+ *
24
+ * ```text
25
+ * 0 'ttcf' uint16 major, uint16 minor, uint32 numFonts, uint32 dirOffset[numFonts]
26
+ * ... per member: uint32 sfntVersion, uint16 numTables, uint16 searchRange,
27
+ * uint16 entrySelector, uint16 rangeShift, then numTables x 16-byte records
28
+ * { char tag[4], uint32 checksum, uint32 offset, uint32 length }
29
+ * ```
30
+ *
31
+ * The record `offset` is **absolute from the start of the file**, which is the whole
32
+ * trick: two members name the same `glyf` bytes by pointing at them. So a member does
33
+ * not have to be *copied out* to be read on its own; it is enough to give the reader a
34
+ * buffer whose first bytes are that member's directory, leaving every table where it
35
+ * already sits. See {@link extractFontFace}.
36
+ */
37
+ /** `'ttcf'`, the tag that marks a font collection. */
38
+ const TTC_TAG = "ttcf";
39
+ /** Bytes in one sfnt table record: tag, checksum, offset, length. */
40
+ const RECORD_SIZE = 16;
41
+ /** Bytes in an sfnt table directory header, before the first record. */
42
+ const DIRECTORY_HEADER_SIZE = 12;
43
+ /** `name` IDs read for face identity: family, subfamily, full name, PostScript name. */
44
+ const NAME_FAMILY = 1;
45
+ const NAME_SUBFAMILY = 2;
46
+ const NAME_FULL = 4;
47
+ const NAME_POSTSCRIPT = 6;
48
+ function view(data) {
49
+ return new DataView(data.buffer, data.byteOffset, data.byteLength);
50
+ }
51
+ /** Four ASCII bytes at `off` as a string, or `''` when they run past the end. */
52
+ function tagAt(data, off) {
53
+ if (off < 0 || off + 4 > data.byteLength) return "";
54
+ let s = "";
55
+ for (let i = 0; i < 4; i++) s += String.fromCharCode(data[off + i]);
56
+ return s;
57
+ }
58
+ /** True when `data` starts with the `ttcf` tag, i.e. holds more than one font. */
59
+ function isFontCollection(data) {
60
+ return tagAt(data, 0) === TTC_TAG;
61
+ }
62
+ /**
63
+ * Offsets of every member's table directory, in file order. A plain sfnt has exactly
64
+ * one, at 0.
65
+ */
66
+ function directoryOffsets(data) {
67
+ if (!isFontCollection(data)) return [0];
68
+ const dv = view(data);
69
+ if (data.byteLength < 12) throw new MediaError("font/parse-failed", "Font collection header is truncated");
70
+ const numFonts = dv.getUint32(8);
71
+ if (numFonts === 0) throw new MediaError("font/parse-failed", "Font collection declares 0 fonts");
72
+ if (12 + numFonts * 4 > data.byteLength) throw new MediaError("font/parse-failed", `Font collection declares ${numFonts} fonts but the offset table is truncated`);
73
+ const offsets = [];
74
+ for (let i = 0; i < numFonts; i++) offsets.push(dv.getUint32(12 + i * 4));
75
+ return offsets;
76
+ }
77
+ /** Read the table directory at `dirOff`, materializing every record before any write. */
78
+ function readDirectory(data, dirOff) {
79
+ const dv = view(data);
80
+ if (dirOff + DIRECTORY_HEADER_SIZE > data.byteLength) throw new MediaError("font/parse-failed", `Font table directory at ${dirOff} is truncated`);
81
+ const sfntVersion = dv.getUint32(dirOff);
82
+ const numTables = dv.getUint16(dirOff + 4);
83
+ if (numTables === 0) throw new MediaError("font/parse-failed", `Font at ${dirOff} declares 0 tables`);
84
+ if (dirOff + DIRECTORY_HEADER_SIZE + numTables * RECORD_SIZE > data.byteLength) throw new MediaError("font/parse-failed", `Font at ${dirOff} declares ${numTables} tables but the directory is truncated`);
85
+ const tables = [];
86
+ for (let i = 0; i < numTables; i++) {
87
+ const rec = dirOff + DIRECTORY_HEADER_SIZE + i * RECORD_SIZE;
88
+ tables.push({
89
+ tag: tagAt(data, rec),
90
+ checksum: dv.getUint32(rec + 4),
91
+ offset: dv.getUint32(rec + 8),
92
+ length: dv.getUint32(rec + 12)
93
+ });
94
+ }
95
+ return {
96
+ sfntVersion,
97
+ tables
98
+ };
99
+ }
100
+ /**
101
+ * Decode one `name` record's string. Windows (platform 3) and Unicode (platform 0)
102
+ * records are UTF-16BE; Macintosh (platform 1) Roman is byte-per-character and
103
+ * coincides with Latin-1 over the ASCII range these names use.
104
+ */
105
+ function decodeNameString(data, off, length, platformID) {
106
+ if (off < 0 || off + length > data.byteLength) return "";
107
+ if (platformID === 1) {
108
+ let s = "";
109
+ for (let i = 0; i < length; i++) s += String.fromCharCode(data[off + i]);
110
+ return s;
111
+ }
112
+ let s = "";
113
+ for (let i = 0; i + 1 < length; i += 2) s += String.fromCharCode(data[off + i] << 8 | data[off + i + 1]);
114
+ return s;
115
+ }
116
+ /**
117
+ * How much a `name` record is preferred when several carry the same ID. Windows English
118
+ * wins, then any Windows or Unicode record, then Macintosh. The ranking matters because
119
+ * `msgothic.ttc` carries both `'MS Gothic'` (0x409) and the Japanese localized name for
120
+ * the same ID, and a caller selecting by name types the former.
121
+ */
122
+ function nameRecordRank(platformID, languageID) {
123
+ if (platformID === 3) return languageID === 1033 ? 3 : 2;
124
+ if (platformID === 0) return 2;
125
+ if (platformID === 1) return languageID === 0 ? 1 : 0;
126
+ return 0;
127
+ }
128
+ /** Read the wanted IDs out of a `name` table, keeping the best-ranked record for each. */
129
+ function readNames(data, base, length) {
130
+ const out = /* @__PURE__ */ new Map();
131
+ const ranks = /* @__PURE__ */ new Map();
132
+ if (base + 6 > data.byteLength || base + length > data.byteLength) return out;
133
+ const dv = view(data);
134
+ const count = dv.getUint16(base + 2);
135
+ const storage = base + dv.getUint16(base + 4);
136
+ for (let i = 0; i < count; i++) {
137
+ const rec = base + 6 + i * 12;
138
+ if (rec + 12 > data.byteLength) break;
139
+ const nameID = dv.getUint16(rec + 6);
140
+ if (nameID !== NAME_FAMILY && nameID !== NAME_SUBFAMILY && nameID !== NAME_FULL && nameID !== NAME_POSTSCRIPT) continue;
141
+ const platformID = dv.getUint16(rec);
142
+ const rank = nameRecordRank(platformID, dv.getUint16(rec + 4));
143
+ if (rank <= (ranks.get(nameID) ?? -1)) continue;
144
+ const text = decodeNameString(data, storage + dv.getUint16(rec + 10), dv.getUint16(rec + 8), platformID);
145
+ if (!text) continue;
146
+ out.set(nameID, text);
147
+ ranks.set(nameID, rank);
148
+ }
149
+ return out;
150
+ }
151
+ /**
152
+ * The fonts inside `data`, in file order. A plain TTF/OTF yields exactly one entry, so a
153
+ * caller never has to branch on the container format.
154
+ *
155
+ * Identity comes from each member's own `name` table, which is one of the tables a
156
+ * collection does *not* share. A field is absent when the table carries no readable
157
+ * record for it; a font whose `name` table is missing or malformed still yields an entry
158
+ * (with only `index`), because the metrics do not depend on it.
159
+ * @param {Uint8Array} data - the font file's bytes
160
+ * @returns {FontFaceInfo[]} one entry per font, index-ordered
161
+ * @example listFontFaces(readFileSync('C:/Windows/Fonts/msgothic.ttc')).map((f) => f.family)
162
+ */
163
+ function listFontFaces(data) {
164
+ return directoryOffsets(data).map((dirOff, index) => {
165
+ const { tables } = readDirectory(data, dirOff);
166
+ const name = tables.find((t) => t.tag === "name");
167
+ const names = name ? readNames(data, name.offset, name.length) : /* @__PURE__ */ new Map();
168
+ const family = names.get(NAME_FAMILY);
169
+ const subfamily = names.get(NAME_SUBFAMILY);
170
+ const fullName = names.get(NAME_FULL);
171
+ const postScriptName = names.get(NAME_POSTSCRIPT);
172
+ return {
173
+ index,
174
+ ...family ? { family } : {},
175
+ ...subfamily ? { subfamily } : {},
176
+ ...fullName ? { fullName } : {},
177
+ ...postScriptName ? { postScriptName } : {}
178
+ };
179
+ });
180
+ }
181
+ /** A face's names, lowercased, for case-insensitive selection. */
182
+ function selectableNames(face) {
183
+ return [
184
+ face.family,
185
+ face.fullName,
186
+ face.postScriptName
187
+ ].filter((n) => typeof n === "string" && n.length > 0).map((n) => n.toLowerCase());
188
+ }
189
+ /** Render the available faces for an error message, e.g. ``0 `MS Gothic`, 1 `MS PGothic` ``. */
190
+ function describeFaces(faces) {
191
+ return faces.map((f) => `${f.index} \`${f.family ?? f.fullName ?? "(unnamed)"}\``).join(", ");
192
+ }
193
+ /**
194
+ * Resolve a `font` selector against the faces in `data`.
195
+ *
196
+ * The selector means the same thing whatever the container: a plain TTF/OTF is a
197
+ * one-entry list, so `{ font: 1 }` or a name that does not match is an error there too
198
+ * rather than being quietly ignored. Silently falling back to the only/first font is the
199
+ * failure mode worth refusing: it measures one face's advances while the caller believes
200
+ * it registered another's, and nothing downstream can tell.
201
+ * @param {Uint8Array} data - the font file's bytes
202
+ * @param {number | string} [font] - 0-based index, or a name matched case-insensitively
203
+ * against the family, full, and PostScript names. Absent selects index 0.
204
+ * @returns {number} the index of the selected font
205
+ */
206
+ function resolveFontFace(data, font) {
207
+ if (font === void 0) return 0;
208
+ const faces = listFontFaces(data);
209
+ if (typeof font === "number") {
210
+ if (!Number.isInteger(font) || font < 0 || font >= faces.length) throw new InvalidOptionError("font/collection-index-out-of-range", `Font index ${font} is out of range: the file holds ${faces.length} font(s) (${describeFaces(faces)})`);
211
+ return font;
212
+ }
213
+ const wanted = font.toLowerCase();
214
+ const hit = faces.find((f) => selectableNames(f).includes(wanted));
215
+ if (!hit) throw new InvalidOptionError("font/collection-face-not-found", `No font named "${font}" in this file. It holds: ${describeFaces(faces)}`);
216
+ return hit.index;
217
+ }
218
+ /**
219
+ * Bytes for one font of `data` as a standalone sfnt, ready for a parser that does not
220
+ * read the `ttcf` wrapper.
221
+ *
222
+ * A plain TTF/OTF at index 0 is returned as-is. For a collection the member's tables are
223
+ * **not copied out**: the file is copied once and the member's directory is written over
224
+ * the prologue at offset 0, so every record keeps the absolute offset it already had and
225
+ * the shared `glyf` is neither duplicated nor moved. The prologue is always large enough
226
+ * to hold that directory, because a well-formed collection places the first table after
227
+ * the `ttcf` header *and* every member's directory, which already includes this one.
228
+ * That is asserted rather than assumed: a file that violates it would otherwise have its
229
+ * first table silently overwritten.
230
+ * @param {Uint8Array} data - the font file's bytes
231
+ * @param {number} index - which font, as resolved by {@link resolveFontFace}
232
+ * @returns {Uint8Array} a buffer whose first bytes are a plain sfnt table directory
233
+ */
234
+ function extractFontFace(data, index) {
235
+ const offsets = directoryOffsets(data);
236
+ const dirOff = offsets[index];
237
+ if (dirOff === void 0) throw new InvalidOptionError("font/collection-index-out-of-range", `Font index ${index} is out of range: the file holds ${offsets.length} font(s)`);
238
+ if (!isFontCollection(data)) return data;
239
+ const { sfntVersion, tables } = readDirectory(data, dirOff);
240
+ const prologue = DIRECTORY_HEADER_SIZE + tables.length * RECORD_SIZE;
241
+ const firstTable = Math.min(...tables.map((t) => t.offset));
242
+ if (prologue > firstTable) throw new MediaError("font/parse-failed", `Font ${index} in this collection cannot be unwrapped: its ${tables.length}-table directory needs ${prologue} bytes but the first table starts at ${firstTable}`);
243
+ const out = new Uint8Array(data);
244
+ const dv = new DataView(out.buffer, out.byteOffset, out.byteLength);
245
+ const entrySelector = Math.floor(Math.log2(tables.length));
246
+ const searchRange = RECORD_SIZE * 2 ** entrySelector;
247
+ dv.setUint32(0, sfntVersion);
248
+ dv.setUint16(4, tables.length);
249
+ dv.setUint16(6, searchRange);
250
+ dv.setUint16(8, entrySelector);
251
+ dv.setUint16(10, tables.length * RECORD_SIZE - searchRange);
252
+ let p = DIRECTORY_HEADER_SIZE;
253
+ for (const t of tables) {
254
+ for (let i = 0; i < 4; i++) out[p + i] = t.tag.charCodeAt(i) & 255;
255
+ dv.setUint32(p + 4, t.checksum);
256
+ dv.setUint32(p + 8, t.offset);
257
+ dv.setUint32(p + 12, t.length);
258
+ p += RECORD_SIZE;
259
+ }
260
+ return out;
261
+ }
262
+ //#endregion
263
+ //#region src/measure/font-metrics.ts
264
+ /**
265
+ * Write-side font metrics.
266
+ *
267
+ * Provides per-glyph advance widths for measured text fit (see `text-fit.ts`).
268
+ * The library has no write-side font registration otherwise; consumers register
269
+ * a face's font file via `pptx.registerFontMetrics(...)` and the measured-fit
270
+ * pass uses these advances to compute a baked `fontScale` for `fit: 'shrink'`.
271
+ *
272
+ * Width is summed from **raw `hmtx` advances with no kerning/shaping** (GPOS/GSUB
273
+ * are deliberately ignored): kerning almost always narrows a line, so raw advances
274
+ * over-estimate width — the conservative direction (shrink a touch too much, never
275
+ * overflow). See `docs/measured-text-fit.md` ("Font metrics provider").
276
+ */
277
+ var OpentypeFontMetrics = class {
278
+ font;
279
+ constructor(font) {
280
+ this.font = font;
281
+ }
282
+ get unitsPerEm() {
283
+ return this.font.unitsPerEm;
284
+ }
285
+ advanceWidthPt(text, sizePt, charSpacingPt = 0) {
286
+ if (!text) return 0;
287
+ const scale = sizePt / this.font.unitsPerEm;
288
+ let width = 0;
289
+ let count = 0;
290
+ for (const ch of text) {
291
+ const glyph = this.font.charToGlyph(ch);
292
+ width += (glyph?.advanceWidth ?? 0) * scale;
293
+ count++;
294
+ }
295
+ if (charSpacingPt) width += charSpacingPt * count;
296
+ return width;
297
+ }
298
+ hasCodepoint(cp) {
299
+ return this.font.hasChar(String.fromCodePoint(cp));
300
+ }
301
+ };
302
+ const HEURISTIC_UNITS_PER_EM = 1e3;
303
+ /**
304
+ * Advance width of an ASCII/Unicode code point as a fraction of em, for the
305
+ * unregistered-font fallback. Values are biased to **over-estimate** a typical
306
+ * proportional Latin face: over-estimating width is the conservative direction for
307
+ * both solvers (shrink a touch too much / grow a touch too tall, never overflow).
308
+ * Non-Latin code points are treated as full-em (safe for CJK, which is otherwise
309
+ * out of scope; slightly over-wide for accented Latin, which is harmless).
310
+ */
311
+ function heuristicCharRatio(ch) {
312
+ const cp = ch.codePointAt(0) ?? 0;
313
+ if (cp > 11903) return 1;
314
+ if (cp > 255) return .62;
315
+ if (ch === " " || ch === " ") return .3;
316
+ if ("ijl.,'`!|:;".includes(ch)) return .3;
317
+ if ("ftr()[]{}/\\I".includes(ch)) return .4;
318
+ if (ch === "m" || ch === "w") return .9;
319
+ if (ch === "M" || ch === "W") return .98;
320
+ if (ch === "@" || ch === "%") return 1;
321
+ if (ch >= "A" && ch <= "Z") return .72;
322
+ return .58;
323
+ }
324
+ var HeuristicFontMetrics = class {
325
+ unitsPerEm = HEURISTIC_UNITS_PER_EM;
326
+ advanceWidthPt(text, sizePt, charSpacingPt = 0) {
327
+ if (!text) return 0;
328
+ let width = 0;
329
+ let count = 0;
330
+ for (const ch of text) {
331
+ width += heuristicCharRatio(ch) * sizePt;
332
+ count++;
333
+ }
334
+ if (charSpacingPt) width += charSpacingPt * count;
335
+ return width;
336
+ }
337
+ hasCodepoint() {
338
+ return true;
339
+ }
340
+ };
341
+ let heuristicSingleton;
342
+ /**
343
+ * Shared, font-independent `FontMetrics` used when a named face has no registered
344
+ * metrics but the deck has opted into measured fit (some other face is registered).
345
+ * Approximate but conservative — see {@link heuristicCharRatio}. A deck with no
346
+ * registered metrics at all does not engage measured fit, so this never fires there.
347
+ */
348
+ function getHeuristicFontMetrics() {
349
+ if (!heuristicSingleton) heuristicSingleton = new HeuristicFontMetrics();
350
+ return heuristicSingleton;
351
+ }
352
+ /**
353
+ * Parse a font file into a `FontMetrics`. Lazily imports opentype.js.
354
+ *
355
+ * Reads plain TTF/OTF and **font collections** (`.ttc`/`.otc`); for a collection, pass
356
+ * `font` to say which member, since a collection has no single answer. The underlying
357
+ * parser does not read the `ttcf` wrapper, so the selected member is unwrapped into a
358
+ * standalone sfnt first (see `measure/font-collection.ts`).
359
+ * @param {Uint8Array} data - the font file's bytes
360
+ * @param {ParseFontMetricsOptions} [opts] - which font, for a file holding several
361
+ * @returns {Promise<FontMetrics>} advance widths and cmap coverage for that font
362
+ * @example await parseFontMetrics(bytes) // a plain .ttf
363
+ * @example await parseFontMetrics(ttcBytes, { font: 'MS PGothic' })
364
+ */
365
+ async function parseFontMetrics(data, opts) {
366
+ const mod = await import("opentype.js");
367
+ const parse = mod.parse ?? mod.default?.parse;
368
+ if (typeof parse !== "function") throw new UnsupportedFeatureError("font/opentype-unavailable", "opentype.js: parse() not found in module exports");
369
+ const sfnt = extractFontFace(data, resolveFontFace(data, opts?.font));
370
+ const buf = sfnt.buffer.slice(sfnt.byteOffset, sfnt.byteOffset + sfnt.byteLength);
371
+ try {
372
+ return new OpentypeFontMetrics(parse(buf));
373
+ } catch (ex) {
374
+ throw new MediaError("font/parse-failed", `Unable to parse font file: ${String(ex)}`, { cause: ex });
375
+ }
376
+ }
377
+ /**
378
+ * Registry of `FontMetrics` keyed by `(face, bold, italic)`. Variant advances
379
+ * differ, so bold/italic are stored separately; lookup falls back to the regular
380
+ * variant (then any registered variant of the face) so a deck that only registers
381
+ * the regular weight still measures bold runs approximately rather than not at all.
382
+ */
383
+ var FontMetricsRegistry = class {
384
+ map = /* @__PURE__ */ new Map();
385
+ key(face, bold, italic) {
386
+ return `${face.toLowerCase()}\0${bold ? 1 : 0}\0${italic ? 1 : 0}`;
387
+ }
388
+ set(face, metrics, opts) {
389
+ this.map.set(this.key(face, !!opts?.bold, !!opts?.italic), metrics);
390
+ }
391
+ /** True if any variant of `face` has registered metrics. */
392
+ hasFace(face) {
393
+ const prefix = `${face.toLowerCase()}\0`;
394
+ for (const k of this.map.keys()) if (k.startsWith(prefix)) return true;
395
+ return false;
396
+ }
397
+ /**
398
+ * Coverage of code point `cp` for `face`, read from the registered font's cmap:
399
+ * `true`/`false` when a variant of `face` is registered (see {@link get} for the
400
+ * exact→regular→any-variant fallback), or `undefined` when the face has **no**
401
+ * registered metrics — "unknown", deliberately distinct from a `false`
402
+ * "registered but not covered". A coverage audit must not treat an unregistered
403
+ * face as fully covered, so callers get `undefined` here rather than the per-face
404
+ * {@link FontMetrics.hasCodepoint} boolean (which, for the heuristic fallback,
405
+ * always reports covered). Register the face via {@link parseFontMetrics} +
406
+ * {@link set} first.
407
+ */
408
+ hasCodepoint(face, cp, opts) {
409
+ return this.get(face, opts?.bold, opts?.italic)?.hasCodepoint(cp);
410
+ }
411
+ /** Resolve metrics for a run, falling back from exact variant → regular → any variant. */
412
+ get(face, bold = false, italic = false) {
413
+ if (typeof face !== "string" || face.length === 0) return void 0;
414
+ const exact = this.map.get(this.key(face, bold, italic));
415
+ if (exact) return exact;
416
+ const regular = this.map.get(this.key(face, false, false));
417
+ if (regular) return regular;
418
+ const prefix = `${face.toLowerCase()}\0`;
419
+ for (const [k, v] of this.map) if (k.startsWith(prefix)) return v;
420
+ }
421
+ get size() {
422
+ return this.map.size;
423
+ }
424
+ };
425
+ /**
426
+ * A {@link MetricsResolver} backed by a registry: exact metrics for a registered face, otherwise
427
+ * the shared heuristic fallback (with `onHeuristic` told which face it stood in for). A run with
428
+ * no `fontFace` at all resolves to `undefined`, which the callers treat as unmeasurable.
429
+ */
430
+ function makeRegistryResolver(registry, onHeuristic) {
431
+ return (run) => {
432
+ const exact = registry.get(run.fontFace, run.bold, run.italic);
433
+ if (exact) return exact;
434
+ if (typeof run.fontFace === "string" && run.fontFace.length > 0) {
435
+ onHeuristic?.(run.fontFace);
436
+ return getHeuristicFontMetrics();
437
+ }
438
+ };
439
+ }
440
+ /**
441
+ * Accumulate, per face, the code points `paragraphs` uses that the run's **registered**
442
+ * metrics have no glyph for.
443
+ *
444
+ * This is the one thing the width arithmetic cannot see. PowerPoint substitutes another
445
+ * face per code point and lays the run out in *that* font's advances; the model has no
446
+ * fallback and charges the registered font's `.notdef` advance instead, which is a single
447
+ * flat number unrelated to the glyph that actually paints. Unlike the other approximations
448
+ * here it is **not** conservative in a fixed direction: a `.notdef` wider than the real
449
+ * glyph over-reports (a phantom line — safe), a narrower one under-reports and can drop a
450
+ * line, which is the overflow direction the resize bake has no safety net for. So it is
451
+ * surfaced rather than absorbed — see `docs/measured-text-fit.md` ("No font fallback").
452
+ *
453
+ * Runs whose face has no registered metrics at all are skipped: they measure through the
454
+ * cmap-less heuristic, which has no coverage to report, and the caller already flags them
455
+ * as approximated faces.
456
+ * @param paragraphs - the runs to audit, as handed to the layout
457
+ * @param registry - the metrics the layout resolved against
458
+ * @param into - face → uncovered code points, added to in place
459
+ */
460
+ function collectUncoveredCodepoints(paragraphs, registry, into) {
461
+ for (const para of paragraphs) for (const run of para.runs) {
462
+ const face = run.fontFace;
463
+ if (typeof face !== "string" || face.length === 0) continue;
464
+ const metrics = registry.get(face, run.bold, run.italic);
465
+ if (!metrics) continue;
466
+ let missing;
467
+ for (const ch of run.text) {
468
+ const cp = ch.codePointAt(0);
469
+ if (cp === void 0 || metrics.hasCodepoint(cp)) continue;
470
+ missing ??= into.get(face) ?? /* @__PURE__ */ new Set();
471
+ missing.add(cp);
472
+ }
473
+ if (missing) into.set(face, missing);
474
+ }
475
+ }
476
+ //#endregion
477
+ //#region src/measure/text-fit.ts
478
+ /**
479
+ * PowerPoint single-spacing line pitch as a multiple of font size.
480
+ *
481
+ * Calibrated from `autofit-calibration.json` (Deck 1, `autofit-line-metrics`):
482
+ * the per-line `cy` delta is a **font-independent** 1.2117× at 12/18/32 pt across
483
+ * {Aptos, Aptos SemiBold, Calibri, Tahoma, Arial}, and PowerPoint vs LibreOffice
484
+ * agree to ≤ 0.041 pt. See `test/read/fixtures/README.md` → "Findings" #1–#2.
485
+ */
486
+ const SINGLE_LINE_PITCH = 1.2117;
487
+ /** PowerPoint's `fontScale` search grid: discrete 2.5% steps (Findings #3). */
488
+ const FONT_SCALE_STEP_PCT = 2.5;
489
+ /** Floor for the shrink search (see `docs/measured-text-fit.md` → Solvers). */
490
+ const MIN_FONT_SCALE_PCT = 25;
491
+ /**
492
+ * Conservative safety factors applied by the shrink solver (not by the pure
493
+ * `measureHeightPt`). Raw `hmtx` advances summed without shaping still pack a line
494
+ * a little tighter than PowerPoint, which lays text out at a device DPI and rounds
495
+ * each glyph advance up — so PowerPoint wraps marginally earlier and its fitted
496
+ * box is marginally taller. Inflating measured width and height by these factors
497
+ * makes the computed `fontScale` ≤ PowerPoint's across the calibration oracle
498
+ * (`test/read/autofit-calibration-oracle.test.js`: 0 violations over the Aptos
499
+ * cases). They err on the side of shrinking slightly too much, never overflowing.
500
+ */
501
+ const WIDTH_SAFETY_FACTOR = 1.03;
502
+ const HEIGHT_SAFETY_FACTOR = 1.04;
503
+ const isWhitespace = (ch) => ch === " " || ch === " " || ch === "\xA0" || /\s/.test(ch);
504
+ /**
505
+ * A code point that is its own wrap opportunity: a line may break either side of
506
+ * it, with no whitespace involved. Latin words break only at whitespace, but
507
+ * Chinese and Japanese text breaks between any two characters, so treating one
508
+ * long run of them as a single unbreakable "word" moves the whole run to the next
509
+ * line, over-reports the laid-out line count, and shrinks text that actually fits.
510
+ *
511
+ * **Hangul is deliberately absent.** UAX #14 makes Hangul breakable between
512
+ * syllables, but PowerPoint does not: Korean is written with spaces between words
513
+ * and the app breaks it like Latin. `autofit-cjk-wrap.pptx` pins that directly —
514
+ * the same box that lays out in 2 lines in Han or Kana takes 3 in Hangul, because
515
+ * the run moves down whole. A Hangul run longer than one line still breaks between
516
+ * syllables, but that is the over-long-token character-wrap fallback in
517
+ * `countLines`, which handles it without a break class. Adding Hangul here would
518
+ * *under*-report the line count for Korean, which is the direction that overflows.
519
+ *
520
+ * Backed by `autofit-cjk-wrap.pptx` (PowerPoint-authored, one case per claim):
521
+ * CJK Unified Ideographs, Hiragana/Katakana, fullwidth Latin, halfwidth Katakana
522
+ * and Plane 2 all break per character; Hangul syllables do not. The remaining
523
+ * ranges are the sibling blocks of the same UAX #14 class ID, generalized from
524
+ * those cases rather than pinned one by one.
525
+ *
526
+ * Known gap: no kinsoku (line-break prohibition) rules. PowerPoint will not start
527
+ * a line with `。` or `、` and hangs them past the right inset instead; this model
528
+ * breaks before them. That is a widest-line difference, not a line-count one, and
529
+ * `docs/measured-text-fit.md` records it.
530
+ */
531
+ function isCjkBreakCharacter(ch) {
532
+ const cp = ch.codePointAt(0) ?? 0;
533
+ return cp >= 11904 && cp <= 12351 || cp >= 12352 && cp <= 12543 || cp >= 12544 && cp <= 12591 || cp >= 12688 && cp <= 12703 || cp >= 12704 && cp <= 12799 || cp >= 12800 && cp <= 19903 || cp >= 19968 && cp <= 40959 || cp >= 63744 && cp <= 64255 || cp >= 65040 && cp <= 65055 || cp >= 65072 && cp <= 65103 || cp >= 65280 && cp <= 65439 || cp >= 65504 && cp <= 65519 || cp >= 110592 && cp <= 110959 || cp >= 131072 && cp <= 196605 || cp >= 196608 && cp <= 205743;
534
+ }
535
+ /**
536
+ * Tokenize one paragraph at a given `fontScalePct`. Words can span run boundaries
537
+ * (a break is only allowed at whitespace, or either side of a per-character
538
+ * breaking code point — see `isCjkBreakCharacter`). Returns `null` if any run's
539
+ * face has no registered metrics (the paragraph cannot be measured).
540
+ */
541
+ function tokenizeParagraph(para, resolve, fontScalePct, widthSafety) {
542
+ const tokens = [];
543
+ let curWord = null;
544
+ const flushWord = () => {
545
+ if (curWord) {
546
+ tokens.push(curWord);
547
+ curWord = null;
548
+ }
549
+ };
550
+ for (const run of para.runs) {
551
+ const metrics = resolve(run);
552
+ if (!metrics) return null;
553
+ const scaledSize = run.sizePt * (fontScalePct / 100);
554
+ const charSpacingPt = run.charSpacingPt ?? 0;
555
+ for (const ch of run.text ?? "") if (ch === "\n") {
556
+ flushWord();
557
+ tokens.push({
558
+ kind: "newline",
559
+ w: 0
560
+ });
561
+ } else if (isWhitespace(ch)) {
562
+ flushWord();
563
+ const w = metrics.advanceWidthPt(ch === " " ? " " : ch, scaledSize, charSpacingPt) * widthSafety;
564
+ tokens.push({
565
+ kind: "space",
566
+ w
567
+ });
568
+ } else {
569
+ const w = metrics.advanceWidthPt(ch, scaledSize, charSpacingPt) * widthSafety;
570
+ if (isCjkBreakCharacter(ch)) {
571
+ flushWord();
572
+ tokens.push({
573
+ kind: "word",
574
+ w,
575
+ charWidths: [w]
576
+ });
577
+ continue;
578
+ }
579
+ if (!curWord) curWord = {
580
+ kind: "word",
581
+ w: 0,
582
+ charWidths: []
583
+ };
584
+ curWord.w += w;
585
+ curWord.charWidths.push(w);
586
+ }
587
+ }
588
+ flushWord();
589
+ return tokens;
590
+ }
591
+ /**
592
+ * Greedy `wrap=square` line layout: the wrapped line count plus the widest
593
+ * laid-out line width. `maxLineWidthPt` tracks the running line width and records
594
+ * it whenever a line completes (a wrap or the final line), so callers can measure
595
+ * the actual text extent, not just the box width. Trailing whitespace counts
596
+ * toward width (Findings #7), which slightly over-reports — the conservative
597
+ * direction, consistent with the wrap model erring wide.
598
+ */
599
+ function countLines(tokens, innerWidthPt) {
600
+ let lines = 1;
601
+ let lineW = 0;
602
+ let maxLineWidthPt = 0;
603
+ const finishLine = () => {
604
+ if (lineW > maxLineWidthPt) maxLineWidthPt = lineW;
605
+ };
606
+ for (const tok of tokens) if (tok.kind === "newline") {
607
+ finishLine();
608
+ lines++;
609
+ lineW = 0;
610
+ } else if (tok.kind === "space") {
611
+ if (lineW + tok.w > innerWidthPt && lineW > 0) {
612
+ finishLine();
613
+ lines++;
614
+ lineW = 0;
615
+ } else lineW += tok.w;
616
+ } else if (tok.w <= innerWidthPt) {
617
+ if (lineW + tok.w > innerWidthPt && lineW > 0) {
618
+ finishLine();
619
+ lines++;
620
+ lineW = tok.w;
621
+ } else lineW += tok.w;
622
+ } else {
623
+ if (lineW > 0) {
624
+ finishLine();
625
+ lines++;
626
+ lineW = 0;
627
+ }
628
+ for (const cw of tok.charWidths) if (lineW + cw > innerWidthPt && lineW > 0) {
629
+ finishLine();
630
+ lines++;
631
+ lineW = cw;
632
+ } else lineW += cw;
633
+ }
634
+ finishLine();
635
+ return {
636
+ lines,
637
+ maxLineWidthPt
638
+ };
639
+ }
640
+ /** Max run size in a paragraph (line height follows the tallest run — Findings #7). */
641
+ function maxRunSizePt(para) {
642
+ let max = 0;
643
+ for (const run of para.runs) if (run.sizePt > max) max = run.sizePt;
644
+ return max;
645
+ }
646
+ /**
647
+ * Lay out `paragraphs` at a given scale/reduction and return the total height
648
+ * (points) and wrapped line count, or `null` if any paragraph is unmeasurable.
649
+ * Errs tall (conservative): includes space-before/after and the calibrated line
650
+ * pitch. The single source of truth for both {@link measureHeightPt} (the solvers)
651
+ * and the public layout-time measure API.
652
+ */
653
+ function measureLayout(paragraphs, innerWidthPt, resolve, fontScalePct, lnSpcReductionPct, widthSafety = 1) {
654
+ if (innerWidthPt <= 0) return null;
655
+ let total = 0;
656
+ let lineCount = 0;
657
+ let widestLineWidthPt = 0;
658
+ for (const para of paragraphs) {
659
+ const tokens = tokenizeParagraph(para, resolve, fontScalePct, widthSafety);
660
+ if (tokens === null) return null;
661
+ const { lines, maxLineWidthPt } = countLines(tokens, innerWidthPt);
662
+ lineCount += lines;
663
+ if (maxLineWidthPt > widestLineWidthPt) widestLineWidthPt = maxLineWidthPt;
664
+ const scaledMax = maxRunSizePt(para) * (fontScalePct / 100);
665
+ let lineHeight = para.lineSpacingPts != null && para.lineSpacingPts > 0 ? para.lineSpacingPts : SINGLE_LINE_PITCH * scaledMax * ((para.lineSpacingPct ?? 100) / 100);
666
+ lineHeight *= 1 - lnSpcReductionPct / 100;
667
+ total += lines * lineHeight + (para.spaceBeforePts ?? 0) + (para.spaceAfterPts ?? 0);
668
+ }
669
+ return {
670
+ heightPt: total,
671
+ lineCount,
672
+ widestLineWidthPt
673
+ };
674
+ }
675
+ /**
676
+ * Total laid-out height (points) at a given scale/reduction, or `null` if any
677
+ * paragraph is unmeasurable. Thin wrapper over {@link measureLayout}.
678
+ */
679
+ function measureHeightPt(paragraphs, innerWidthPt, resolve, fontScalePct, lnSpcReductionPct, widthSafety = 1) {
680
+ const result = measureLayout(paragraphs, innerWidthPt, resolve, fontScalePct, lnSpcReductionPct, widthSafety);
681
+ return result === null ? null : result.heightPt;
682
+ }
683
+ /**
684
+ * Inner height (points) the text needs at full size — the value to bake into the
685
+ * shape's `a:ext/@cy` for `fit: 'resize'` (`spAutoFit`). Unlike shrink, resize has
686
+ * **no safety net**: an under-estimate overflows (there is no text scaling
687
+ * fallback), so this errs **tall** — width is inflated (earlier wrap ⇒ more lines)
688
+ * and the laid-out height by the calibrated height factor, so the computed `cy` is
689
+ * ≥ PowerPoint's and ≥ the LibreOffice-rendered height across the resize oracle.
690
+ */
691
+ /**
692
+ * Width used for wrap layout: the inner box width when wrapping, or unbounded for a
693
+ * non-wrapping frame (`wrap:false`) so each paragraph lays out as a single line. A
694
+ * non-wrapping frame's width constraint is enforced separately, against the widest
695
+ * laid-out line — see {@link solveShrink}.
696
+ */
697
+ function layoutWidthPt(box) {
698
+ return box.wrap === false ? Infinity : box.innerWidthPt;
699
+ }
700
+ function solveResize(paragraphs, box, resolve) {
701
+ const h = measureHeightPt(paragraphs, layoutWidthPt(box), resolve, 100, 0, WIDTH_SAFETY_FACTOR);
702
+ if (h === null) return { kind: "unmeasurable" };
703
+ return {
704
+ kind: "resize",
705
+ neededInnerHeightPt: h * HEIGHT_SAFETY_FACTOR
706
+ };
707
+ }
708
+ function solveShrink(paragraphs, box, resolve) {
709
+ const width = layoutWidthPt(box);
710
+ const noWrap = box.wrap === false;
711
+ const fits = (scale) => {
712
+ const layout = measureLayout(paragraphs, width, resolve, scale, 0, WIDTH_SAFETY_FACTOR);
713
+ if (layout === null) return null;
714
+ if (layout.heightPt * 1.04 > box.innerHeightPt) return false;
715
+ if (noWrap && layout.widestLineWidthPt > box.innerWidthPt) return false;
716
+ return true;
717
+ };
718
+ const at100 = fits(100);
719
+ if (at100 === null) return { kind: "unmeasurable" };
720
+ if (at100) return { kind: "fits" };
721
+ for (let scale = 97.5; scale >= 25; scale -= FONT_SCALE_STEP_PCT) if (fits(scale)) return {
722
+ kind: "shrink",
723
+ result: {
724
+ fontScalePct: scale,
725
+ lnSpcReductionPct: 0
726
+ }
727
+ };
728
+ return {
729
+ kind: "shrink",
730
+ result: {
731
+ fontScalePct: 25,
732
+ lnSpcReductionPct: 0
733
+ }
734
+ };
735
+ }
736
+ //#endregion
737
+ //#region src/measure/paragraphs.ts
738
+ /**
739
+ * ts-pptx: turning a slide text object into the simulator's inputs
740
+ *
741
+ * The lower half of the measured-fit pass: normalize a text object's runs into `FitParagraph`s,
742
+ * resolve its body insets and box, and work out how a height change is shared between `off.y` and
743
+ * `ext.cy` for a given anchor. Everything here is about *reading* an authored object; the solving
744
+ * and the rewriting live in the siblings (`table-fit.ts`, `fit.ts`).
745
+ */
746
+ const DEF_INS_LR_EMU = 91440;
747
+ const DEF_INS_TB_EMU = 45720;
748
+ const CRLF_RE = /\r*\n/g;
749
+ /** Normalize `slideObj.text` (string | TextProps | TextProps[]) to a run list. */
750
+ function normalizeRuns(obj) {
751
+ const opts = obj.options ?? {};
752
+ const text = obj.text;
753
+ if (text == null) return [];
754
+ if (typeof text === "string" || typeof text === "number") return [{
755
+ text: String(text),
756
+ options: opts
757
+ }];
758
+ if (!Array.isArray(text) && typeof text === "object" && "text" in text) {
759
+ const t = text;
760
+ return [{
761
+ text: t.text,
762
+ options: t.options ?? opts
763
+ }];
764
+ }
765
+ if (Array.isArray(text)) return text.map((t) => ({
766
+ text: t.text,
767
+ options: t.options ?? opts
768
+ }));
769
+ return [];
770
+ }
771
+ /** Build a measurable `FitParagraph[]` from a text object, or null if not measurable. */
772
+ function extractParagraphs(obj) {
773
+ const opts = obj.options ?? {};
774
+ const runs = normalizeRuns(obj);
775
+ if (runs.length === 0) return null;
776
+ return buildFitParagraphs(runs, opts);
777
+ }
778
+ /**
779
+ * Convert a run list (+ box-level default options) into a measurable
780
+ * `FitParagraph[]`, or null if empty. The single converter shared by the
781
+ * export-time pass ({@link extractParagraphs}) and the public layout-time
782
+ * `measureText` API, so a layout-time prediction and the baked export never drift.
783
+ */
784
+ function buildFitParagraphs(runs, opts) {
785
+ if (runs.length === 0) return null;
786
+ const pieces = [];
787
+ for (const run of runs) {
788
+ const ro = run.options ?? opts;
789
+ const raw = String(run.text ?? "").replace(CRLF_RE, "\n");
790
+ if (raw.includes("\n")) {
791
+ const lines = raw.split("\n");
792
+ lines.forEach((line, i) => {
793
+ const isLast = i === lines.length - 1;
794
+ pieces.push({
795
+ text: line,
796
+ options: ro,
797
+ breakAfter: isLast ? !!ro.breakLine : true
798
+ });
799
+ });
800
+ } else pieces.push({
801
+ text: raw,
802
+ options: ro,
803
+ breakAfter: !!ro.breakLine
804
+ });
805
+ }
806
+ const toRun = (p) => {
807
+ const ro = p.options;
808
+ const sizePt = Number(ro.fontSize ?? opts.fontSize ?? 12);
809
+ return {
810
+ text: p.text,
811
+ sizePt: Number.isFinite(sizePt) && sizePt > 0 ? sizePt : 12,
812
+ bold: !!(ro.bold ?? opts.bold),
813
+ italic: !!(ro.italic ?? opts.italic),
814
+ fontFace: ro.fontFace ?? opts.fontFace,
815
+ charSpacingPt: (ro.charSpacing ?? opts.charSpacing) || void 0
816
+ };
817
+ };
818
+ const toPara = (runsForPara, paraOpts) => {
819
+ const lineSpacing = paraOpts.lineSpacing ?? opts.lineSpacing;
820
+ const lineSpacingMultiple = paraOpts.lineSpacingMultiple ?? opts.lineSpacingMultiple;
821
+ return {
822
+ runs: runsForPara,
823
+ lineSpacingPts: typeof lineSpacing === "number" && lineSpacing > 0 ? lineSpacing : void 0,
824
+ lineSpacingPct: typeof lineSpacingMultiple === "number" && lineSpacingMultiple > 0 ? lineSpacingMultiple * 100 : 100,
825
+ spaceBeforePts: Number(paraOpts.paraSpaceBefore ?? opts.paraSpaceBefore ?? 0) || 0,
826
+ spaceAfterPts: Number(paraOpts.paraSpaceAfter ?? opts.paraSpaceAfter ?? 0) || 0
827
+ };
828
+ };
829
+ const paras = [];
830
+ let cur = [];
831
+ let curParaOpts = opts;
832
+ for (const piece of pieces) {
833
+ cur.push(toRun(piece));
834
+ curParaOpts = piece.options;
835
+ if (piece.breakAfter) {
836
+ paras.push(toPara(cur, curParaOpts));
837
+ cur = [];
838
+ }
839
+ }
840
+ if (cur.length > 0) paras.push(toPara(cur, curParaOpts));
841
+ return paras.length > 0 ? paras : null;
842
+ }
843
+ /** Resolve text-frame insets (EMU): explicit `_bodyProp` (from `inset`) → `margin` → PowerPoint defaults. */
844
+ function resolveInsetsEmu(opts) {
845
+ const bp = opts._bodyProp ?? {};
846
+ const margin = opts.margin;
847
+ let lIns = bp.lIns;
848
+ let rIns = bp.rIns;
849
+ let tIns = bp.tIns;
850
+ let bIns = bp.bIns;
851
+ if (lIns == null && rIns == null && tIns == null && bIns == null && margin != null) {
852
+ if (Array.isArray(margin)) {
853
+ tIns = marginToEmu(margin[0] ?? 0);
854
+ rIns = marginToEmu(margin[1] ?? 0);
855
+ bIns = marginToEmu(margin[2] ?? 0);
856
+ lIns = marginToEmu(margin[3] ?? 0);
857
+ } else if (typeof margin === "number") lIns = rIns = tIns = bIns = marginToEmu(margin);
858
+ }
859
+ return {
860
+ lIns: lIns ?? DEF_INS_LR_EMU,
861
+ rIns: rIns ?? DEF_INS_LR_EMU,
862
+ tIns: tIns ?? DEF_INS_TB_EMU,
863
+ bIns: bIns ?? DEF_INS_TB_EMU
864
+ };
865
+ }
866
+ /**
867
+ * Resolve the inner box (shape minus insets) in points; null if degenerate.
868
+ *
869
+ * Reads the object's own `opts.w/h` with no ancestor walk — correct only because a group keeps an
870
+ * identity child coordinate space (`chOff/chExt == off/ext`, see the `gen/slide/object.ts` group renderer and
871
+ * `docs/groups.md`), so a group never scales its children and a grouped text box's authored w/h is its
872
+ * true rendered size. `applyMeasuredFit` reaches here for grouped text via its explicit group descent
873
+ * (`measureObject`). If `addGroup` ever authored a scaled group (non-identity `chExt`), this would need
874
+ * the same ancestor-scale composition `Shape.absoluteFrame` performs on the read path.
875
+ */
876
+ function computeBox(obj, presLayout) {
877
+ const opts = obj.options ?? {};
878
+ const wEmu = getSmartParseNumber(opts.w, "X", presLayout);
879
+ const hEmu = getSmartParseNumber(opts.h, "Y", presLayout);
880
+ if (!(wEmu > 0) || !(hEmu > 0)) return null;
881
+ const { lIns, rIns, tIns, bIns } = resolveInsetsEmu(opts);
882
+ const wrap = (opts._bodyProp ?? {}).wrap !== false;
883
+ const innerWidthPt = (wEmu - lIns - rIns) / EMU_PER_POINT;
884
+ const innerHeightPt = (hEmu - tIns - bIns) / EMU_PER_POINT;
885
+ if (!(innerHeightPt > 0)) return null;
886
+ if (!(innerWidthPt > 0)) return null;
887
+ return {
888
+ innerWidthPt,
889
+ innerHeightPt,
890
+ wrap
891
+ };
892
+ }
893
+ /** Vertical-anchor share of a height change that moves the box top up (`off.y` shift). */
894
+ function anchorTopShareOfDelta(opts) {
895
+ const anchor = (opts._bodyProp ?? {}).anchor;
896
+ if (anchor === "t") return 0;
897
+ if (anchor === "b") return 1;
898
+ return .5;
899
+ }
900
+ /** Text/format options that a cell inherits from the table when it sets none itself (mirrors `gen/slide/objects/table.ts`). */
901
+ //#endregion
902
+ //#region src/measure/table-fit.ts
903
+ /**
904
+ * ts-pptx: measuring a table's row heights and cell fits
905
+ *
906
+ * A table is measured cell by cell against its resolved grid: each cell inherits the table's text
907
+ * options where it sets none, gets its own margins resolved to EMU insets, and is laid out inside
908
+ * the column span it actually occupies. A merged region is measured once, at its origin, across
909
+ * the columns it covers — which is what `walkTableGrid` exists to hand out.
910
+ */
911
+ const CELL_INHERIT_KEYS = [
912
+ "fontFace",
913
+ "fontSize",
914
+ "bold",
915
+ "italic",
916
+ "charSpacing",
917
+ "align",
918
+ "lineSpacing",
919
+ "lineSpacingMultiple",
920
+ "valign",
921
+ "margin"
922
+ ];
923
+ /** Effective cell options: the cell's own values, with table-level values filled in where unset. */
924
+ function effectiveCellOpts(cellOpts, tableOpts) {
925
+ const merged = { ...cellOpts };
926
+ for (const k of CELL_INHERIT_KEYS) if (merged[k] === void 0 && tableOpts[k] !== void 0) merged[k] = tableOpts[k];
927
+ return merged;
928
+ }
929
+ /** Resolve a cell's margins to EMU insets, mirroring `gen/slide/objects/table.ts` (array is `[T,R,B,L]`, inches; see `marginToEmu`). */
930
+ function resolveCellInsetsEmu(margin) {
931
+ let m = margin === 0 || margin ? margin : DEF_CELL_MARGIN_IN;
932
+ if (typeof m === "number") m = [
933
+ m,
934
+ m,
935
+ m,
936
+ m
937
+ ];
938
+ if (!Array.isArray(m) || m.length !== 4 || m.some((v) => typeof v !== "number" || !isFinite(v))) m = DEF_CELL_MARGIN_IN;
939
+ const arr = m;
940
+ return {
941
+ marT: marginToEmu(arr[0]),
942
+ marR: marginToEmu(arr[1]),
943
+ marB: marginToEmu(arr[2]),
944
+ marL: marginToEmu(arr[3])
945
+ };
946
+ }
947
+ /**
948
+ * Bake a reduced font size onto a cell's runs by factor `f` (< 1). Clones every
949
+ * options object before mutating: a plain-string cell shares the table's `opt`
950
+ * object (`gen/define/`), so in-place mutation would corrupt every other such cell.
951
+ */
952
+ function scaleCellFontSizes(cell, eff, f) {
953
+ const shrink = (sizePt) => Math.floor(sizePt * f * 10) / 10;
954
+ const baseSize = Number(eff.fontSize ?? 12);
955
+ cell.options = {
956
+ ...cell.options,
957
+ fontSize: shrink(baseSize)
958
+ };
959
+ if (Array.isArray(cell.text)) cell.text = cell.text.map((run) => run && typeof run === "object" && typeof run.options?.fontSize === "number" ? {
960
+ ...run,
961
+ options: {
962
+ ...run.options,
963
+ fontSize: shrink(run.options.fontSize)
964
+ }
965
+ } : run);
966
+ }
967
+ /** Grid column count of a table (sums the first row's colspans), mirroring `gen/slide/objects/table.ts`. */
968
+ function tableColCount(rows) {
969
+ const first = rows[0];
970
+ return first ? first.reduce((n, c) => n + (Number(c?.options?.colspan) || 1), 0) : 0;
971
+ }
972
+ /**
973
+ * Walk a table's cell grid in row-major order, resolving each authored cell to its
974
+ * grid origin (`row`/`col`) and clamped colspan/rowspan. Tracks rowspan occupancy so
975
+ * a cell never lands beneath one spanned from above (grid build). This is
976
+ * the single traversal shared by the measured-fit shrink pass and
977
+ * {@link computeTableLayout}, so cell placement cannot drift between them.
978
+ */
979
+ function* walkTableGrid(rows, numCols) {
980
+ const occupied = new Array(numCols).fill(0);
981
+ for (let r = 0; r < rows.length; r++) {
982
+ const row = rows[r];
983
+ if (!row) continue;
984
+ let col = 0;
985
+ for (const cell of row) {
986
+ while (col < numCols && (occupied[col] ?? 0) > 0) col++;
987
+ if (col >= numCols) break;
988
+ const colspan = Math.max(1, Number(cell?.options?.colspan) || 1);
989
+ const rowspan = Math.min(Math.max(1, Number(cell?.options?.rowspan) || 1), rows.length - r);
990
+ const colStart = col;
991
+ const colEnd = Math.min(colStart + colspan, numCols);
992
+ for (let c = colStart; c < colEnd; c++) occupied[c] = rowspan;
993
+ col = colEnd;
994
+ yield {
995
+ cell,
996
+ row: r,
997
+ col: colStart,
998
+ rowSpan: rowspan,
999
+ colSpan: colEnd - colStart
1000
+ };
1001
+ }
1002
+ for (let c = 0; c < numCols; c++) {
1003
+ const cur = occupied[c] ?? 0;
1004
+ if (cur > 0) occupied[c] = cur - 1;
1005
+ }
1006
+ }
1007
+ }
1008
+ /**
1009
+ * Compute per-cell geometry (inches) for a table laid out at `opts.x`/`y`/`w` — the
1010
+ * engine behind `pptx.tableLayout()`. Column widths come from the same
1011
+ * {@link resolveTableColWidthsEmu} the writer uses, so cell x/width are exact. Row
1012
+ * heights are exact when pinned by `rowH` (array or scalar) or table `h`; otherwise
1013
+ * each auto-height row is estimated with the same conservative (tall) text model as
1014
+ * {@link measureText} and flagged `heightExact: false`. Single-slide only —
1015
+ * `autoPage` paging across slides is not modeled. Rowspan cells do not drive a row's
1016
+ * estimated height (mirrors `gen/table/autopage.ts`, which exempts them from line-height growth).
1017
+ */
1018
+ function computeTableLayout(rows, opts, presLayout, registry) {
1019
+ const empty = {
1020
+ cells: [],
1021
+ widthIn: 0,
1022
+ heightIn: 0,
1023
+ heightExact: true
1024
+ };
1025
+ if (!rows || rows.length === 0 || !rows[0]) return empty;
1026
+ const numRows = rows.length;
1027
+ const numCols = tableColCount(rows);
1028
+ if (!(numCols > 0)) return empty;
1029
+ const o = opts;
1030
+ const tableXEmu = opts.x != null ? getSmartParseNumber(opts.x, "X", presLayout) : 0;
1031
+ const tableYEmu = opts.y != null ? getSmartParseNumber(opts.y, "Y", presLayout) : 0;
1032
+ const cxEmu = opts.w != null ? getSmartParseNumber(opts.w, "X", presLayout) : getSmartParseNumber("75%", "X", presLayout);
1033
+ const colWidthsEmu = resolveTableColWidthsEmu(opts.colW, cxEmu, numCols);
1034
+ const colXEmu = new Array(numCols + 1).fill(0);
1035
+ for (let c = 0; c < numCols; c++) colXEmu[c + 1] = (colXEmu[c] ?? 0) + (colWidthsEmu[c] ?? 0);
1036
+ const tableHeightEmu = opts.h != null ? getSmartParseNumber(opts.h, "Y", presLayout) : 0;
1037
+ const explicitRowHEmu = (r) => {
1038
+ if (Array.isArray(opts.rowH)) {
1039
+ const v = opts.rowH[r];
1040
+ if (typeof v === "number" && isFinite(v)) return inch2Emu(v);
1041
+ return tableHeightEmu > 0 ? Math.round(tableHeightEmu / numRows) : null;
1042
+ }
1043
+ if (opts.rowH != null && !isNaN(Number(opts.rowH))) return inch2Emu(Number(opts.rowH));
1044
+ if (tableHeightEmu > 0) return Math.round(tableHeightEmu / numRows);
1045
+ return null;
1046
+ };
1047
+ const resolve = makeRegistryResolver(registry);
1048
+ const defLineEmu = inch2Emu(12 * LINEH_MODIFIER / 100);
1049
+ const estimateContentHeightEmu = (cell, eff, innerWidthPt) => {
1050
+ const fontSizePt = Number(eff.fontSize ?? 12) || 12;
1051
+ const oneLineEmu = inch2Emu(fontSizePt * LINEH_MODIFIER / 100);
1052
+ if (!(innerWidthPt > 0)) return oneLineEmu;
1053
+ const paragraphs = extractParagraphs({
1054
+ text: cell.text,
1055
+ options: eff
1056
+ });
1057
+ if (!paragraphs) return oneLineEmu;
1058
+ const layout = measureLayout(paragraphs, innerWidthPt, resolve, 100, 0, WIDTH_SAFETY_FACTOR);
1059
+ if (layout === null) return oneLineEmu;
1060
+ return Math.max(Math.round(layout.heightPt * HEIGHT_SAFETY_FACTOR * EMU_PER_POINT), oneLineEmu);
1061
+ };
1062
+ const placed = [];
1063
+ const rowHeightsEmu = new Array(numRows).fill(0);
1064
+ const rowExact = new Array(numRows).fill(false);
1065
+ for (let r = 0; r < numRows; r++) {
1066
+ const ex = explicitRowHEmu(r);
1067
+ if (ex != null) {
1068
+ rowHeightsEmu[r] = ex;
1069
+ rowExact[r] = true;
1070
+ }
1071
+ }
1072
+ for (const p of walkTableGrid(rows, numCols)) {
1073
+ const colStart = p.col;
1074
+ const colEnd = p.col + p.colSpan;
1075
+ placed.push({
1076
+ p,
1077
+ colStart,
1078
+ colEnd
1079
+ });
1080
+ if (p.rowSpan === 1 && !rowExact[p.row]) {
1081
+ let widthEmu = 0;
1082
+ for (let c = colStart; c < colEnd; c++) widthEmu += colWidthsEmu[c] ?? 0;
1083
+ const eff = effectiveCellOpts(p.cell?.options ?? {}, o);
1084
+ const ins = resolveCellInsetsEmu(eff.margin);
1085
+ const innerWidthPt = (widthEmu - ins.marL - ins.marR) / EMU_PER_POINT;
1086
+ const contentEmu = estimateContentHeightEmu(p.cell, eff, innerWidthPt) + ins.marT + ins.marB;
1087
+ if (contentEmu > (rowHeightsEmu[p.row] ?? 0)) rowHeightsEmu[p.row] = contentEmu;
1088
+ }
1089
+ }
1090
+ for (let r = 0; r < numRows; r++) if ((rowHeightsEmu[r] ?? 0) <= 0) rowHeightsEmu[r] = defLineEmu;
1091
+ const rowYEmu = new Array(numRows + 1).fill(0);
1092
+ for (let r = 0; r < numRows; r++) rowYEmu[r + 1] = (rowYEmu[r] ?? 0) + (rowHeightsEmu[r] ?? 0);
1093
+ return {
1094
+ cells: placed.map(({ p, colStart, colEnd }) => {
1095
+ const rowEnd = p.row + p.rowSpan;
1096
+ let heightExact = true;
1097
+ for (let rr = p.row; rr < rowEnd; rr++) if (!rowExact[rr]) heightExact = false;
1098
+ return {
1099
+ row: p.row,
1100
+ col: p.col,
1101
+ rowSpan: p.rowSpan,
1102
+ colSpan: p.colSpan,
1103
+ xIn: emuToInches(tableXEmu + (colXEmu[colStart] ?? 0)),
1104
+ yIn: emuToInches(tableYEmu + (rowYEmu[p.row] ?? 0)),
1105
+ wIn: emuToInches((colXEmu[colEnd] ?? 0) - (colXEmu[colStart] ?? 0)),
1106
+ hIn: emuToInches((rowYEmu[rowEnd] ?? 0) - (rowYEmu[p.row] ?? 0)),
1107
+ heightExact
1108
+ };
1109
+ }),
1110
+ widthIn: emuToInches(colXEmu[numCols] ?? 0),
1111
+ heightIn: emuToInches(rowYEmu[numRows] ?? 0),
1112
+ heightExact: rowExact.every(Boolean)
1113
+ };
1114
+ }
1115
+ /**
1116
+ * Build the `MetricsResolver` both the export pass and `measureText` use, so they
1117
+ * agree run-for-run: exact registered metrics → conservative heuristic for any
1118
+ * **named** face without exact metrics → `undefined` only for an unnamed
1119
+ * (theme-default) face that cannot be guessed. `onHeuristic` is called with each
1120
+ * named face that fell back to the heuristic (for the export pass's warn-once).
1121
+ */
1122
+ //#endregion
1123
+ //#region src/measure/fit.ts
1124
+ /** Map the public {@link MeasureTextOptions} onto the internal run-option shape. */
1125
+ function measureOptsToRunOpts(opts) {
1126
+ return {
1127
+ fontSize: opts.fontSize,
1128
+ fontFace: opts.fontFace,
1129
+ bold: opts.bold,
1130
+ italic: opts.italic,
1131
+ charSpacing: opts.charSpacing,
1132
+ lineSpacing: opts.lineSpacing,
1133
+ lineSpacingMultiple: opts.lineSpacingMultiple,
1134
+ paraSpaceBefore: opts.paraSpaceBefore,
1135
+ paraSpaceAfter: opts.paraSpaceAfter
1136
+ };
1137
+ }
1138
+ /** A fresh result each call: the arrays are caller-owned, so they cannot be shared. */
1139
+ const unmeasurable = () => ({
1140
+ heightIn: 0,
1141
+ lineCount: 0,
1142
+ widestLineIn: 0,
1143
+ measurable: false,
1144
+ approximatedFaces: [],
1145
+ uncoveredCodepoints: [],
1146
+ fitsBox: () => false,
1147
+ shrinkScaleFor: () => 100
1148
+ });
1149
+ /** Flatten a face → uncovered-code-point map into one sorted, deduplicated list. */
1150
+ function flattenCodepoints(byFace) {
1151
+ const all = /* @__PURE__ */ new Set();
1152
+ for (const cps of byFace.values()) for (const cp of cps) all.add(cp);
1153
+ return [...all].sort((a, b) => a - b);
1154
+ }
1155
+ /**
1156
+ * Render uncovered code points for a warning as `U+XXXX`, capped so one bad run cannot
1157
+ * flood the console with a whole unsupported script.
1158
+ */
1159
+ function formatCodepoints(cps) {
1160
+ const all = [...cps].sort((a, b) => a - b);
1161
+ const shown = all.slice(0, 8).map((cp) => `U+${cp.toString(16).toUpperCase().padStart(4, "0")}`);
1162
+ return all.length > shown.length ? `${shown.join(" ")} +${all.length - shown.length} more` : shown.join(" ");
1163
+ }
1164
+ /**
1165
+ * Layout-time text measurement against registered metrics — the public engine
1166
+ * behind `pptx.measureText()`. Uses the **same** calibrated wrap model, resolver
1167
+ * semantics, and conservative safety factors as the export-time bake
1168
+ * ({@link applyMeasuredFit} / {@link solveResize} / {@link solveShrink}), so a
1169
+ * layout-time prediction matches the value the export would bake **for any deck
1170
+ * that opted into measured fit (i.e. registered at least one face)**.
1171
+ *
1172
+ * With an **empty** registry the two intentionally diverge: {@link applyMeasuredFit}
1173
+ * treats "no metrics" as "not opted in" and bakes nothing, while this returns
1174
+ * heuristic numbers so the API is useful with zero setup. Check
1175
+ * `approximatedFaces` if that distinction matters.
1176
+ *
1177
+ * Synchronous: assumes metrics are pre-registered (lookup is sync). A named face
1178
+ * with no exact metrics silently uses the conservative heuristic (same as export)
1179
+ * and is reported in `approximatedFaces`; an unnamed theme-default face returns
1180
+ * `measurable: false`. Code points a *registered* face has no glyph for are reported
1181
+ * in `uncoveredCodepoints`, and are the one case where the height may come back short
1182
+ * rather than tall — there is no font fallback here, only the font's `.notdef` advance.
1183
+ */
1184
+ function measureText(registry, text, opts) {
1185
+ const paragraphs = buildFitParagraphs(typeof text === "string" || typeof text === "number" ? [{ text: String(text) }] : Array.isArray(text) ? text : [], measureOptsToRunOpts(opts));
1186
+ if (!paragraphs) return unmeasurable();
1187
+ const inset = opts.insetIn ?? 0;
1188
+ const innerWidthPt = (opts.wIn - 2 * inset) * 72;
1189
+ const heuristicFaces = /* @__PURE__ */ new Set();
1190
+ const resolve = makeRegistryResolver(registry, (face) => heuristicFaces.add(face));
1191
+ const uncoveredByFace = /* @__PURE__ */ new Map();
1192
+ collectUncoveredCodepoints(paragraphs, registry, uncoveredByFace);
1193
+ const layout = measureLayout(paragraphs, innerWidthPt, resolve, 100, 0, WIDTH_SAFETY_FACTOR);
1194
+ if (layout === null) return unmeasurable();
1195
+ const heightPt = layout.heightPt * HEIGHT_SAFETY_FACTOR;
1196
+ return {
1197
+ heightIn: heightPt / 72,
1198
+ lineCount: layout.lineCount,
1199
+ widestLineIn: layout.widestLineWidthPt / 72,
1200
+ measurable: true,
1201
+ approximatedFaces: [...heuristicFaces],
1202
+ uncoveredCodepoints: flattenCodepoints(uncoveredByFace),
1203
+ fitsBox: (hIn) => heightPt <= hIn * 72,
1204
+ shrinkScaleFor: (hIn) => {
1205
+ const box = {
1206
+ innerWidthPt,
1207
+ innerHeightPt: hIn * 72
1208
+ };
1209
+ const outcome = solveShrink(paragraphs, box, resolve);
1210
+ if (outcome.kind === "shrink") return outcome.result.fontScalePct;
1211
+ return 100;
1212
+ }
1213
+ };
1214
+ }
1215
+ /**
1216
+ * Apply measured fit across every slide. For each text box that opts in via
1217
+ * `fit:'shrink'` or `fit:'resize'` and whose font has registered metrics, this
1218
+ * bakes the computed result before the sync XML pass reads it:
1219
+ * - `'shrink'` → rewrites `options.fit` to the object form so `<a:normAutofit
1220
+ * fontScale=…/>` is emitted (text renders pre-shrunk).
1221
+ * - `'resize'` → rewrites `options.h` (and `options.y` per vertical anchor) so the
1222
+ * shape's `a:ext/@cy` is the height the text needs; the `<a:spAutoFit/>` marker is
1223
+ * left in place (the renderer draws the baked `cy`).
1224
+ *
1225
+ * Safe to call with an empty registry (no-op). Warns once if any opted-in box could
1226
+ * not be measured (missing metrics), and once if one held code points its registered
1227
+ * face has no glyph for, so neither kind of overflow is silently ignored.
1228
+ */
1229
+ function applyMeasuredFit(slides, registry) {
1230
+ if (registry.size === 0) return;
1231
+ const heuristicFaces = /* @__PURE__ */ new Set();
1232
+ const resolve = makeRegistryResolver(registry, (face) => heuristicFaces.add(face));
1233
+ const unmeasuredShrink = /* @__PURE__ */ new Set();
1234
+ const unmeasuredResize = /* @__PURE__ */ new Set();
1235
+ const uncoveredByFace = /* @__PURE__ */ new Map();
1236
+ const collectUnmeasured = (paragraphs, into) => {
1237
+ for (const para of paragraphs) for (const run of para.runs) if (!resolve(run)) into.add(run.fontFace ?? "(theme default)");
1238
+ };
1239
+ /** Audit every set of runs the pass is about to measure — see the warning below. */
1240
+ const auditCoverage = (paragraphs) => collectUncoveredCodepoints(paragraphs, registry, uncoveredByFace);
1241
+ /**
1242
+ * Bake measured shrink into a table's cells. Walks the cell grid (accounting for
1243
+ * colspan/rowspan) to size each cell's box from its column widths and row heights,
1244
+ * then lowers the run font sizes of any `fit:'shrink'` cell that overflows. Cells
1245
+ * in auto-height rows (no fixed `rowH`/table `h`) are skipped — the row grows instead.
1246
+ */
1247
+ const measureTableCells = (tableObj, layout) => {
1248
+ const rows = tableObj.arrTabRows;
1249
+ if (!rows || rows.length === 0 || !rows[0]) return;
1250
+ const tableOpts = tableObj.options ?? {};
1251
+ const numRows = rows.length;
1252
+ const numCols = tableColCount(rows);
1253
+ if (!(numCols > 0)) return;
1254
+ const cxEmu = tableOpts.w != null ? getSmartParseNumber(tableOpts.w, "X", layout) : getSmartParseNumber("75%", "X", layout);
1255
+ const colWidthsEmu = resolveTableColWidthsEmu(tableOpts.colW, cxEmu, numCols);
1256
+ const tableHeightEmu = tableOpts.h != null ? getSmartParseNumber(tableOpts.h, "Y", layout) : typeof tableOpts.cy === "number" ? tableOpts.cy : 0;
1257
+ const rowHeightEmu = (rIdx) => {
1258
+ if (Array.isArray(tableOpts.rowH) && tableOpts.rowH[rIdx]) return inch2Emu(Number(tableOpts.rowH[rIdx]));
1259
+ if (tableOpts.rowH != null && !Array.isArray(tableOpts.rowH) && !isNaN(Number(tableOpts.rowH))) return inch2Emu(Number(tableOpts.rowH));
1260
+ if (tableHeightEmu > 0) return Math.round(tableHeightEmu / numRows);
1261
+ return 0;
1262
+ };
1263
+ for (const { cell, row: r, col: colStart, colSpan, rowSpan } of walkTableGrid(rows, numCols)) {
1264
+ const colEnd = colStart + colSpan;
1265
+ const cellOpts = cell?.options ?? {};
1266
+ if ((cellOpts.fit ?? (tableOpts.fit === "shrink" ? "shrink" : void 0)) !== "shrink") continue;
1267
+ let widthEmu = 0;
1268
+ for (let c = colStart; c < colEnd; c++) widthEmu += colWidthsEmu[c] ?? 0;
1269
+ let heightEmu = 0;
1270
+ let autoHeight = false;
1271
+ for (let rr = r; rr < r + rowSpan; rr++) {
1272
+ const h = rowHeightEmu(rr);
1273
+ if (h <= 0) {
1274
+ autoHeight = true;
1275
+ break;
1276
+ }
1277
+ heightEmu += h;
1278
+ }
1279
+ if (autoHeight) continue;
1280
+ const eff = effectiveCellOpts(cellOpts, tableOpts);
1281
+ const ins = resolveCellInsetsEmu(eff.margin);
1282
+ const innerWidthPt = (widthEmu - ins.marL - ins.marR) / EMU_PER_POINT;
1283
+ const innerHeightPt = (heightEmu - ins.marT - ins.marB) / EMU_PER_POINT;
1284
+ if (!(innerWidthPt > 0) || !(innerHeightPt > 0)) continue;
1285
+ const paragraphs = extractParagraphs({
1286
+ text: cell.text,
1287
+ options: eff
1288
+ });
1289
+ if (!paragraphs) continue;
1290
+ auditCoverage(paragraphs);
1291
+ const outcome = solveShrink(paragraphs, {
1292
+ innerWidthPt,
1293
+ innerHeightPt
1294
+ }, resolve);
1295
+ if (outcome.kind === "shrink") {
1296
+ const f = outcome.result.fontScalePct / 100;
1297
+ if (f < 1) scaleCellFontSizes(cell, eff, f);
1298
+ } else if (outcome.kind === "unmeasurable") collectUnmeasured(paragraphs, unmeasuredShrink);
1299
+ }
1300
+ };
1301
+ const measureObject = (obj, layout) => {
1302
+ if (obj._type === "group") {
1303
+ for (const child of obj._groupObjects ?? []) measureObject(child, layout);
1304
+ return;
1305
+ }
1306
+ if (obj._type === "table") {
1307
+ measureTableCells(obj, layout);
1308
+ return;
1309
+ }
1310
+ if (obj._type !== "text") return;
1311
+ const options = obj.options;
1312
+ if (!options) return;
1313
+ const fit = options.fit;
1314
+ if (fit !== "shrink" && fit !== "resize") return;
1315
+ const paragraphs = extractParagraphs(obj);
1316
+ if (!paragraphs) return;
1317
+ auditCoverage(paragraphs);
1318
+ const box = computeBox(obj, layout);
1319
+ if (!box) return;
1320
+ if (fit === "shrink") {
1321
+ const outcome = solveShrink(paragraphs, box, resolve);
1322
+ if (outcome.kind === "shrink") {
1323
+ const { fontScalePct, lnSpcReductionPct } = outcome.result;
1324
+ options.fit = {
1325
+ type: "shrink",
1326
+ fontScale: fontScalePct,
1327
+ lnSpcReduction: lnSpcReductionPct || void 0
1328
+ };
1329
+ } else if (outcome.kind === "unmeasurable") collectUnmeasured(paragraphs, unmeasuredShrink);
1330
+ } else {
1331
+ const outcome = solveResize(paragraphs, box, resolve);
1332
+ if (outcome.kind === "resize") {
1333
+ const opts = obj.options;
1334
+ const { tIns, bIns } = resolveInsetsEmu(opts);
1335
+ const oldHeightEmu = getSmartParseNumber(opts.h, "Y", layout);
1336
+ const newHeightEmu = Math.round(outcome.neededInnerHeightPt * EMU_PER_POINT) + tIns + bIns;
1337
+ const oldYEmu = getSmartParseNumber(opts.y, "Y", layout);
1338
+ const shiftEmu = Math.round((newHeightEmu - oldHeightEmu) * anchorTopShareOfDelta(opts));
1339
+ opts.h = `${newHeightEmu}emu`;
1340
+ if (shiftEmu !== 0) opts.y = `${oldYEmu - shiftEmu}emu`;
1341
+ } else collectUnmeasured(paragraphs, unmeasuredResize);
1342
+ }
1343
+ };
1344
+ for (const slide of slides) for (const obj of slide._slideObjects ?? []) measureObject(obj, slide._presLayout);
1345
+ if (unmeasuredShrink.size > 0) warn("measure/shrink-unmeasured", `fit:'shrink' could not be measured for font(s) [${[...unmeasuredShrink].join(", ")}] — no registered metrics. Emitting bare <a:normAutofit/> (text will not pre-shrink in headless renders). Call pptx.registerFontMetrics(face, fontFilePathOrBytes) to enable measured fit.`);
1346
+ if (unmeasuredResize.size > 0) warn("measure/resize-unmeasured", `fit:'resize' could not be measured for font(s) [${[...unmeasuredResize].join(", ")}] — no registered metrics. Emitting bare <a:spAutoFit/> with the authored height (box will not auto-grow in headless renders). Call pptx.registerFontMetrics(face, fontFilePathOrBytes) to enable measured fit.`);
1347
+ if (uncoveredByFace.size > 0) {
1348
+ const perFace = [...uncoveredByFace].map(([face, cps]) => `${face} → ${formatCodepoints(cps)}`).join("; ");
1349
+ warn("measure/uncovered-codepoints", `Measured fit charged the .notdef advance for code point(s) the registered face does not cover (${perFace}) — PowerPoint renders those from a substituted font, so the measured width can be wrong in EITHER direction. Too narrow drops a line and the text overflows the baked size, the one case measured fit does not err safe. Register a face that covers them, or set an explicit fontFace on those runs.`);
1350
+ }
1351
+ if (heuristicFaces.size > 0) warn("measure/heuristic-metrics", `Note: measured fit used a conservative average-advance estimate for font(s) [${[...heuristicFaces].join(", ")}] — no exact metrics registered. Fit is approximate (may shrink/grow more than necessary). Call pptx.registerFontMetrics(face, fontFilePathOrBytes) for an exact fit.`);
1352
+ }
1353
+ //#endregion
1354
+ export { parseFontMetrics as _, FONT_SCALE_STEP_PCT as a, SINGLE_LINE_PITCH as c, measureLayout as d, solveResize as f, makeRegistryResolver as g, getHeuristicFontMetrics as h, buildFitParagraphs as i, WIDTH_SAFETY_FACTOR as l, FontMetricsRegistry as m, measureText as n, HEIGHT_SAFETY_FACTOR as o, solveShrink as p, computeTableLayout as r, MIN_FONT_SCALE_PCT as s, applyMeasuredFit as t, measureHeightPt as u, isFontCollection as v, listFontFaces as y };
1355
+
1356
+ //# sourceMappingURL=fit-BKs0zxMI.js.map