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.
- package/LICENSE +22 -0
- package/README.md +319 -0
- package/dist/autopage-DkjYZUFD.js +423 -0
- package/dist/autopage-DkjYZUFD.js.map +1 -0
- package/dist/browser.d.ts +31 -0
- package/dist/browser.d.ts.map +1 -0
- package/dist/browser.js +110 -0
- package/dist/browser.js.map +1 -0
- package/dist/diagnostics-Du_s75MT.d.ts +829 -0
- package/dist/diagnostics-Du_s75MT.d.ts.map +1 -0
- package/dist/enums-CvRYniUA.d.ts +463 -0
- package/dist/enums-CvRYniUA.d.ts.map +1 -0
- package/dist/errors-Bvln0H-7.js +112 -0
- package/dist/errors-Bvln0H-7.js.map +1 -0
- package/dist/errors-PflMPWBn.d.ts +182 -0
- package/dist/errors-PflMPWBn.d.ts.map +1 -0
- package/dist/fit-BKs0zxMI.js +1356 -0
- package/dist/fit-BKs0zxMI.js.map +1 -0
- package/dist/html-dom-B0I0QSbU.js +679 -0
- package/dist/html-dom-B0I0QSbU.js.map +1 -0
- package/dist/html.d.ts +26 -0
- package/dist/html.d.ts.map +1 -0
- package/dist/html.js +63 -0
- package/dist/html.js.map +1 -0
- package/dist/index.d.ts +24 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +57 -0
- package/dist/index.js.map +1 -0
- package/dist/inspect.d.ts +197 -0
- package/dist/inspect.d.ts.map +1 -0
- package/dist/inspect.js +317 -0
- package/dist/inspect.js.map +1 -0
- package/dist/math.d.ts +37 -0
- package/dist/math.d.ts.map +1 -0
- package/dist/math.js +203 -0
- package/dist/math.js.map +1 -0
- package/dist/measure.d.ts +313 -0
- package/dist/measure.d.ts.map +1 -0
- package/dist/measure.js +3 -0
- package/dist/namespaces-BVnNu7sg.js +666 -0
- package/dist/namespaces-BVnNu7sg.js.map +1 -0
- package/dist/node.d.ts +20 -0
- package/dist/node.d.ts.map +1 -0
- package/dist/node.js +55 -0
- package/dist/node.js.map +1 -0
- package/dist/notes-RLCwdlTE.js +1703 -0
- package/dist/notes-RLCwdlTE.js.map +1 -0
- package/dist/package-CaOLXSNi.d.ts +193 -0
- package/dist/package-CaOLXSNi.d.ts.map +1 -0
- package/dist/pres-C5__xkDh.d.ts +202 -0
- package/dist/pres-C5__xkDh.d.ts.map +1 -0
- package/dist/presentation-Cp7vzlAw.js +5427 -0
- package/dist/presentation-Cp7vzlAw.js.map +1 -0
- package/dist/presentation-types-DP_8jog7.d.ts +4872 -0
- package/dist/presentation-types-DP_8jog7.d.ts.map +1 -0
- package/dist/read.d.ts +55 -0
- package/dist/read.d.ts.map +1 -0
- package/dist/read.js +4 -0
- package/dist/script.d.ts +581 -0
- package/dist/script.d.ts.map +1 -0
- package/dist/script.js +3741 -0
- package/dist/script.js.map +1 -0
- package/dist/shapes-BemroU-z.js +6951 -0
- package/dist/shapes-BemroU-z.js.map +1 -0
- package/dist/slide-js0QpRV-.d.ts +3873 -0
- package/dist/slide-js0QpRV-.d.ts.map +1 -0
- package/dist/text-CyetbF6p.js +11278 -0
- package/dist/text-CyetbF6p.js.map +1 -0
- package/dist/units-1kRzYShk.js +231 -0
- package/dist/units-1kRzYShk.js.map +1 -0
- package/dist/units-internal-Dg95w_YD.js +753 -0
- package/dist/units-internal-Dg95w_YD.js.map +1 -0
- package/dist/zip.d.ts +68 -0
- package/dist/zip.d.ts.map +1 -0
- package/dist/zip.js +163 -0
- package/dist/zip.js.map +1 -0
- package/package.json +152 -0
- 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
|