@vyaz/core 0.0.5 → 0.0.6
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/dist/compile/DocumentCompiler.d.ts +40 -0
- package/dist/index.browser.d.ts +28 -0
- package/dist/index.browser.js +18 -0
- package/dist/index.d.ts +30 -0
- package/dist/index.js +11 -147930
- package/dist/layout/AutoFitEngine.d.ts +44 -0
- package/dist/layout/LineBoxValidator.d.ts +25 -0
- package/dist/layout/ParagraphLayoutEngine.d.ts +46 -0
- package/dist/layout/PositioningEngine.d.ts +68 -0
- package/dist/layout/TextFrameLayoutEngine.d.ts +50 -0
- package/{src/layout/estimateWidth.ts → dist/layout/estimateWidth.d.ts} +2 -40
- package/dist/measure/FontEngine.d.ts +47 -0
- package/dist/measure/FontMetricsProvider.d.ts +49 -0
- package/dist/measure/FontNotFoundError.d.ts +6 -0
- package/dist/measure/SystemFontRegistry.d.ts +46 -0
- package/dist/measure/canvas-polyfill.d.ts +30 -0
- package/dist/types/Document.d.ts +593 -0
- package/dist/types/FontTypes.d.ts +61 -0
- package/dist/types/LayoutTypes.d.ts +128 -0
- package/{src/utils/env.ts → dist/utils/env.d.ts} +1 -8
- package/{src/utils/font.ts → dist/utils/font.d.ts} +1 -13
- package/{src/utils/groupLinesByParagraph.ts → dist/utils/groupLinesByParagraph.d.ts} +7 -37
- package/dist/utils/list.d.ts +41 -0
- package/dist/utils/textTransform.d.ts +32 -0
- package/package.json +13 -13
- package/src/compile/DocumentCompiler.ts +0 -144
- package/src/index.browser.ts +0 -90
- package/src/index.ts +0 -97
- package/src/layout/AutoFitEngine.ts +0 -101
- package/src/layout/LineBoxValidator.ts +0 -162
- package/src/layout/ParagraphLayoutEngine.ts +0 -264
- package/src/layout/PositioningEngine.ts +0 -615
- package/src/layout/TextFrameLayoutEngine.ts +0 -363
- package/src/measure/FontEngine.ts +0 -144
- package/src/measure/FontMetricsProvider.ts +0 -224
- package/src/measure/FontNotFoundError.ts +0 -16
- package/src/measure/SystemFontRegistry.ts +0 -152
- package/src/measure/canvas-polyfill.d.ts +0 -6
- package/src/measure/canvas-polyfill.ts +0 -243
- package/src/measure/fontkit.d.ts +0 -44
- package/src/types/Document.ts +0 -666
- package/src/types/FontTypes.ts +0 -74
- package/src/types/LayoutTypes.ts +0 -158
- package/src/utils/list.ts +0 -107
- package/src/utils/textTransform.ts +0 -96
|
@@ -1,363 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* TextFrameLayoutEngine.ts — Layout a full TextFrame (multi-paragraph).
|
|
3
|
-
*
|
|
4
|
-
* Pipeline:
|
|
5
|
-
* TextFrame → Paragraph[] → paragraphLayoutEngine.layout() each → merge Line[]
|
|
6
|
-
*
|
|
7
|
-
* Handles:
|
|
8
|
-
* - Paragraph stacking with Y offset accumulation
|
|
9
|
-
* - Multi-column layout (CSS multi-column model)
|
|
10
|
-
* - Padding (left reduces available width, left shifts X)
|
|
11
|
-
* - frame.width/height optional → fitHorizontal/fitVertical flags
|
|
12
|
-
* - List grouping: consecutive paragraphs with listStyle form a list group.
|
|
13
|
-
* Numbered list indices are auto-incremented within each group.
|
|
14
|
-
* `listRestart: true` breaks a group and restarts numbering.
|
|
15
|
-
*
|
|
16
|
-
* Multi-column algorithm:
|
|
17
|
-
* 1. Calculate colWidth = (frameWidth - (count-1)*gap - padding) / count
|
|
18
|
-
* 2. Layout each paragraph with maxWidth = colWidth (NOT frame.width)
|
|
19
|
-
* 3. Distribute lines column-by-column (column-fill: auto)
|
|
20
|
-
* 4. If frame.height is set, lines overflow to next column when colHeight exceeded
|
|
21
|
-
* 5. If no frame.height, columns are infinite (all lines stay in column 0)
|
|
22
|
-
*/
|
|
23
|
-
import type { TextFrame, ListStyle, VerticalAlignment } from '../types/Document.js';
|
|
24
|
-
import type { Line } from '../types/LayoutTypes.js';
|
|
25
|
-
import { paragraphLayoutEngine } from './ParagraphLayoutEngine.js';
|
|
26
|
-
import { formatListNumber, defaultBulletChar } from '../utils/list.js';
|
|
27
|
-
|
|
28
|
-
/**
|
|
29
|
-
* Result of laying out a full TextFrame.
|
|
30
|
-
*
|
|
31
|
-
* `fitHorizontal` / `fitVertical` tell the renderer which dimension to use:
|
|
32
|
-
* - `'frame'` → use `frameWidth` / `frameHeight`
|
|
33
|
-
* - `'content'` → use `contentWidth` / `contentHeight`
|
|
34
|
-
*/
|
|
35
|
-
export interface TextFrameLayoutResult {
|
|
36
|
-
lines: Line[];
|
|
37
|
-
/** Frame width (set when TextFrame.width was provided). */
|
|
38
|
-
frameWidth?: number;
|
|
39
|
-
/** Frame height (set when TextFrame.height was provided). */
|
|
40
|
-
frameHeight?: number;
|
|
41
|
-
/** Actual content width (may exceed frameWidth when wrap=false). */
|
|
42
|
-
contentWidth: number;
|
|
43
|
-
/** Actual content height (may exceed frameHeight). */
|
|
44
|
-
contentHeight: number;
|
|
45
|
-
/** Whether horizontal dimension should use frame or content size. */
|
|
46
|
-
fitHorizontal: 'frame' | 'content';
|
|
47
|
-
/** Whether vertical dimension should use frame or content size. */
|
|
48
|
-
fitVertical: 'frame' | 'content';
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
/**
|
|
52
|
-
* Resolve the marker text for a list item (needed for width measurement).
|
|
53
|
-
*/
|
|
54
|
-
function getMarkerTextHelper(listStyle: ListStyle, listIndex: number): string {
|
|
55
|
-
if (listStyle.type === 'bullet') {
|
|
56
|
-
return listStyle.bulletChar ?? defaultBulletChar(listStyle.level ?? 0);
|
|
57
|
-
}
|
|
58
|
-
if (listStyle.type === 'number') {
|
|
59
|
-
const fmt = listStyle.numberFormat ?? 'decimal';
|
|
60
|
-
return formatListNumber(listIndex, fmt) + '.';
|
|
61
|
-
}
|
|
62
|
-
return '';
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
/**
|
|
66
|
-
* Compute the widest marker across a list group, used to expand bulletIndent
|
|
67
|
-
* when numbered markers have varying widths (e.g. "9." vs "10.").
|
|
68
|
-
*/
|
|
69
|
-
function computeMaxMarkerWidth(
|
|
70
|
-
listStyle: ListStyle,
|
|
71
|
-
startIndex: number,
|
|
72
|
-
count: number,
|
|
73
|
-
measureText: (text: string, fontSize: number) => number,
|
|
74
|
-
): number {
|
|
75
|
-
if (listStyle.type !== 'number') return 0;
|
|
76
|
-
let maxWidth = 0;
|
|
77
|
-
for (let i = 0; i < count; i++) {
|
|
78
|
-
const markerText = getMarkerTextHelper(listStyle, startIndex + i);
|
|
79
|
-
const width = measureText(markerText, 12); // approximate, will be refined by positionLines
|
|
80
|
-
maxWidth = Math.max(maxWidth, width);
|
|
81
|
-
}
|
|
82
|
-
return maxWidth;
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
/**
|
|
86
|
-
* Apply vertical alignment to lines within a column.
|
|
87
|
-
*
|
|
88
|
-
* @param lines — lines belonging to this column (already has correct x/y)
|
|
89
|
-
* @param colHeight — total column height (frame.height or content height)
|
|
90
|
-
*/
|
|
91
|
-
function applyVerticalAlignment(
|
|
92
|
-
lines: Line[],
|
|
93
|
-
colHeight: number,
|
|
94
|
-
alignment: VerticalAlignment,
|
|
95
|
-
): void {
|
|
96
|
-
if (alignment === 'top' || lines.length === 0) return;
|
|
97
|
-
|
|
98
|
-
const firstLineY = lines[0].y;
|
|
99
|
-
const lastLineEnd = lines[lines.length - 1].y + lines[lines.length - 1].height;
|
|
100
|
-
const contentHeight = lastLineEnd - firstLineY;
|
|
101
|
-
const extraSpace = colHeight - contentHeight;
|
|
102
|
-
if (extraSpace <= 0) return;
|
|
103
|
-
|
|
104
|
-
let offset = 0;
|
|
105
|
-
if (alignment === 'middle') {
|
|
106
|
-
offset = extraSpace / 2;
|
|
107
|
-
} else if (alignment === 'bottom') {
|
|
108
|
-
offset = extraSpace;
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
for (const line of lines) {
|
|
112
|
-
line.y += offset;
|
|
113
|
-
}
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
/**
|
|
117
|
-
* Layout a full TextFrame by stacking paragraphs with Y offset accumulation.
|
|
118
|
-
*/
|
|
119
|
-
export function layoutTextFrame(frame: TextFrame): TextFrameLayoutResult {
|
|
120
|
-
// ── Multi-column setup ────────────────────────────────────────────
|
|
121
|
-
const leftPad = frame.padding?.left ?? 0;
|
|
122
|
-
const rightPad = frame.padding?.right ?? 0;
|
|
123
|
-
const topPad = frame.padding?.top ?? 0;
|
|
124
|
-
const bottomPad = frame.padding?.bottom ?? 0;
|
|
125
|
-
|
|
126
|
-
const hasColumns = frame.columns != null && frame.columns.count > 1 && frame.width != null;
|
|
127
|
-
let colWidth: number | undefined;
|
|
128
|
-
let colCount = 1;
|
|
129
|
-
let colGap = 0;
|
|
130
|
-
|
|
131
|
-
if (hasColumns) {
|
|
132
|
-
colCount = frame.columns!.count;
|
|
133
|
-
colGap = frame.columns!.gap;
|
|
134
|
-
const totalPad = leftPad + rightPad + (colCount - 1) * colGap;
|
|
135
|
-
colWidth = (frame.width! - totalPad) / colCount;
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
const colHeight = frame.height != null
|
|
139
|
-
? frame.height - topPad - bottomPad
|
|
140
|
-
: Infinity;
|
|
141
|
-
|
|
142
|
-
const verticalAlign: VerticalAlignment = frame.verticalAlignment ?? 'top';
|
|
143
|
-
|
|
144
|
-
// ── List grouping pass ──────────────────────────────────────────
|
|
145
|
-
// (identical to before, but uses colWidth for maxWidth later)
|
|
146
|
-
const listIndices: (number | undefined)[] = new Array(frame.paragraphs.length).fill(undefined);
|
|
147
|
-
const listMarkerWidths: (number | undefined)[] = new Array(frame.paragraphs.length).fill(undefined);
|
|
148
|
-
|
|
149
|
-
let i = 0;
|
|
150
|
-
while (i < frame.paragraphs.length) {
|
|
151
|
-
const p = frame.paragraphs[i];
|
|
152
|
-
const ls = p.style.listStyle;
|
|
153
|
-
|
|
154
|
-
if (!ls || ls.type === 'none') {
|
|
155
|
-
i++;
|
|
156
|
-
continue;
|
|
157
|
-
}
|
|
158
|
-
|
|
159
|
-
// Find end of this list group
|
|
160
|
-
let groupStart = i;
|
|
161
|
-
let groupEnd = i + 1;
|
|
162
|
-
while (groupEnd < frame.paragraphs.length) {
|
|
163
|
-
const nextP = frame.paragraphs[groupEnd];
|
|
164
|
-
const nextLs = nextP.style.listStyle;
|
|
165
|
-
if (!nextLs || nextLs.type !== ls.type || nextP.style.listRestart) {
|
|
166
|
-
break;
|
|
167
|
-
}
|
|
168
|
-
// Same nesting level only
|
|
169
|
-
if ((nextLs.level ?? 0) !== (ls.level ?? 0)) {
|
|
170
|
-
break;
|
|
171
|
-
}
|
|
172
|
-
groupEnd++;
|
|
173
|
-
}
|
|
174
|
-
|
|
175
|
-
const groupSize = groupEnd - groupStart;
|
|
176
|
-
const startNumber = ls.startNumber ?? 1;
|
|
177
|
-
|
|
178
|
-
// Assign indices
|
|
179
|
-
for (let j = 0; j < groupSize; j++) {
|
|
180
|
-
listIndices[groupStart + j] = startNumber + j;
|
|
181
|
-
}
|
|
182
|
-
|
|
183
|
-
// Compute max marker width for numbered lists in this group
|
|
184
|
-
const paraFontSize = p.children[0]?.fontSize ?? 12;
|
|
185
|
-
const measureMarkerWidth = (text: string, fontSize: number): number => {
|
|
186
|
-
return text.length * fontSize * 0.6;
|
|
187
|
-
};
|
|
188
|
-
const maxMW = computeMaxMarkerWidth(ls, startNumber, groupSize, measureMarkerWidth);
|
|
189
|
-
for (let j = 0; j < groupSize; j++) {
|
|
190
|
-
listMarkerWidths[groupStart + j] = maxMW;
|
|
191
|
-
}
|
|
192
|
-
|
|
193
|
-
i = groupEnd;
|
|
194
|
-
}
|
|
195
|
-
|
|
196
|
-
// ── Layout pass ─────────────────────────────────────────────────
|
|
197
|
-
const allLines: Line[] = [];
|
|
198
|
-
let contentWidth = 0;
|
|
199
|
-
|
|
200
|
-
// Helper: push a line and update position
|
|
201
|
-
const currentColY: number[] = new Array(colCount).fill(topPad);
|
|
202
|
-
|
|
203
|
-
// For non-column layout, we use a single "virtual column" approach
|
|
204
|
-
for (let i = 0; i < frame.paragraphs.length; i++) {
|
|
205
|
-
const p = frame.paragraphs[i];
|
|
206
|
-
|
|
207
|
-
// Available width: colWidth if columns, otherwise frame.width minus padding
|
|
208
|
-
const maxWidth = hasColumns
|
|
209
|
-
? colWidth!
|
|
210
|
-
: frame.width !== undefined
|
|
211
|
-
? frame.width - leftPad - rightPad
|
|
212
|
-
: Infinity;
|
|
213
|
-
|
|
214
|
-
// If wrap is disabled, force no-wrap on the paragraph
|
|
215
|
-
if (frame.wrap === false) {
|
|
216
|
-
p.style = { ...p.style, whiteSpace: 'nowrap' };
|
|
217
|
-
}
|
|
218
|
-
|
|
219
|
-
const listIndex = listIndices[i];
|
|
220
|
-
const listMarkerWidth = listMarkerWidths[i];
|
|
221
|
-
const listStyle = p.style.listStyle;
|
|
222
|
-
|
|
223
|
-
// Start paragraph on current column (column 0 initially, or current active column)
|
|
224
|
-
// We layout the paragraph with the full maxWidth — the paragraph's own
|
|
225
|
-
// line-breaking handles wrapping.
|
|
226
|
-
// For multi-column, we use a relative yOffset = 0, then position lines below.
|
|
227
|
-
const result = paragraphLayoutEngine.layout(
|
|
228
|
-
p,
|
|
229
|
-
maxWidth,
|
|
230
|
-
0, // relative yOffset — we'll position lines ourselves
|
|
231
|
-
undefined,
|
|
232
|
-
listStyle,
|
|
233
|
-
listIndex,
|
|
234
|
-
listMarkerWidth,
|
|
235
|
-
);
|
|
236
|
-
|
|
237
|
-
// Apply paragraph-level spaceBefore (CSS margin-top equivalent).
|
|
238
|
-
// positionLines() already offsets Y by spaceBefore internally, but
|
|
239
|
-
// line.y is overwritten below with the global Y from currentColY.
|
|
240
|
-
// So we must add spaceBefore to currentColY before placing lines.
|
|
241
|
-
if (!hasColumns) {
|
|
242
|
-
currentColY[0] += p.style.spaceBefore;
|
|
243
|
-
}
|
|
244
|
-
|
|
245
|
-
for (const line of result.lines) {
|
|
246
|
-
if (!hasColumns) {
|
|
247
|
-
// Non-column: simple accumulation (existing behavior)
|
|
248
|
-
line.x += leftPad;
|
|
249
|
-
for (const span of line.spans) {
|
|
250
|
-
span.pIdx = i;
|
|
251
|
-
}
|
|
252
|
-
allLines.push(line);
|
|
253
|
-
contentWidth = Math.max(contentWidth, result.contentWidth);
|
|
254
|
-
// Result height already includes the passed yOffset (0), so this is relative.
|
|
255
|
-
// We accumulate absolute y from result.height (which is total paragraph height).
|
|
256
|
-
line.y = currentColY[0];
|
|
257
|
-
currentColY[0] += line.height;
|
|
258
|
-
continue;
|
|
259
|
-
}
|
|
260
|
-
|
|
261
|
-
// ── Multi-column: distribute lines across columns ──────────
|
|
262
|
-
// Try to place the current line in the current column.
|
|
263
|
-
// If it doesn't fit — move to next column.
|
|
264
|
-
let colIdx = 0;
|
|
265
|
-
for (let c = 0; c < colCount; c++) {
|
|
266
|
-
if (currentColY[c] < currentColY[colIdx]) colIdx = c;
|
|
267
|
-
}
|
|
268
|
-
|
|
269
|
-
// Try current column; if line doesn't fit, advance to next.
|
|
270
|
-
// For auto fill: each column fills completely before moving to next.
|
|
271
|
-
// We use a greedy column selection: find the column with smallest Y
|
|
272
|
-
// that has room for this line.
|
|
273
|
-
let placed = false;
|
|
274
|
-
for (let attempt = 0; attempt < colCount; attempt++) {
|
|
275
|
-
if (currentColY[colIdx] + line.height <= colHeight) {
|
|
276
|
-
// Fits in this column
|
|
277
|
-
line.x = colIdx * (colWidth! + colGap) + leftPad;
|
|
278
|
-
line.y = currentColY[colIdx];
|
|
279
|
-
line.columnIndex = colIdx;
|
|
280
|
-
currentColY[colIdx] += line.height;
|
|
281
|
-
for (const span of line.spans) {
|
|
282
|
-
span.pIdx = i;
|
|
283
|
-
}
|
|
284
|
-
allLines.push(line);
|
|
285
|
-
contentWidth = Math.max(contentWidth, line.x + line.width + rightPad);
|
|
286
|
-
placed = true;
|
|
287
|
-
break;
|
|
288
|
-
}
|
|
289
|
-
// Advance to next column
|
|
290
|
-
colIdx = (colIdx + 1) % colCount;
|
|
291
|
-
|
|
292
|
-
// If we've wrapped around, all columns are full — overflow stays in last column
|
|
293
|
-
if (attempt === colCount - 1) {
|
|
294
|
-
// Place in last attempted column even if it overflows
|
|
295
|
-
line.x = colIdx * (colWidth! + colGap) + leftPad;
|
|
296
|
-
line.y = currentColY[colIdx];
|
|
297
|
-
line.columnIndex = colIdx;
|
|
298
|
-
currentColY[colIdx] += line.height;
|
|
299
|
-
for (const span of line.spans) {
|
|
300
|
-
span.pIdx = i;
|
|
301
|
-
}
|
|
302
|
-
allLines.push(line);
|
|
303
|
-
contentWidth = Math.max(contentWidth, line.x + line.width + rightPad);
|
|
304
|
-
placed = true;
|
|
305
|
-
}
|
|
306
|
-
}
|
|
307
|
-
|
|
308
|
-
if (!placed) {
|
|
309
|
-
// Fallback: place in column 0 (shouldn't happen)
|
|
310
|
-
line.x = leftPad;
|
|
311
|
-
line.y = currentColY[0];
|
|
312
|
-
line.columnIndex = 0;
|
|
313
|
-
currentColY[0] += line.height;
|
|
314
|
-
for (const span of line.spans) {
|
|
315
|
-
span.pIdx = i;
|
|
316
|
-
}
|
|
317
|
-
allLines.push(line);
|
|
318
|
-
}
|
|
319
|
-
}
|
|
320
|
-
|
|
321
|
-
// Apply paragraph-level spaceAfter (CSS margin-bottom equivalent).
|
|
322
|
-
// positionLines() in PositioningEngine applies spaceBefore but does NOT
|
|
323
|
-
// add spaceAfter — it is the caller's responsibility.
|
|
324
|
-
if (!hasColumns) {
|
|
325
|
-
currentColY[0] += p.style.spaceAfter;
|
|
326
|
-
}
|
|
327
|
-
}
|
|
328
|
-
|
|
329
|
-
// ── Apply vertical alignment per column ──────────────────────────
|
|
330
|
-
if (hasColumns && verticalAlign !== 'top' && frame.height != null) {
|
|
331
|
-
const colLines: Line[][] = new Array(colCount).fill(null).map(() => []);
|
|
332
|
-
for (const line of allLines) {
|
|
333
|
-
const ci = line.columnIndex ?? 0;
|
|
334
|
-
colLines[ci].push(line);
|
|
335
|
-
}
|
|
336
|
-
for (let c = 0; c < colCount; c++) {
|
|
337
|
-
applyVerticalAlignment(colLines[c], colHeight, verticalAlign);
|
|
338
|
-
}
|
|
339
|
-
}
|
|
340
|
-
|
|
341
|
-
// ── Compute final content dimensions ─────────────────────────────
|
|
342
|
-
if (hasColumns) {
|
|
343
|
-
// contentWidth = total frame width (includes all columns + gaps + padding)
|
|
344
|
-
contentWidth = frame.width!;
|
|
345
|
-
} else {
|
|
346
|
-
contentWidth += rightPad;
|
|
347
|
-
}
|
|
348
|
-
|
|
349
|
-
const lastLine = allLines.length > 0 ? allLines[allLines.length - 1] : null;
|
|
350
|
-
const contentHeight = lastLine
|
|
351
|
-
? lastLine.y + lastLine.height + bottomPad
|
|
352
|
-
: bottomPad;
|
|
353
|
-
|
|
354
|
-
return {
|
|
355
|
-
lines: allLines,
|
|
356
|
-
frameWidth: frame.width,
|
|
357
|
-
frameHeight: frame.height,
|
|
358
|
-
contentWidth,
|
|
359
|
-
contentHeight,
|
|
360
|
-
fitHorizontal: frame.width !== undefined ? 'frame' : 'content',
|
|
361
|
-
fitVertical: frame.height !== undefined ? 'frame' : 'content',
|
|
362
|
-
};
|
|
363
|
-
}
|
|
@@ -1,144 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* FontEngine.ts — unified facade over fontkit.
|
|
3
|
-
*
|
|
4
|
-
* Is the single entry point for all fontkit operations:
|
|
5
|
-
* - create(buffer) → font face
|
|
6
|
-
* - getGlyphAdvance(font, codePoint) → per‑glyph advance
|
|
7
|
-
* - getMetrics(font) → structured metric values
|
|
8
|
-
*
|
|
9
|
-
* fontkit works in both Node.js (native addon) and browser (dist/browser-module.mjs).
|
|
10
|
-
* Bundlers pick the correct entry automatically when `package.json` browser map
|
|
11
|
-
* is removed (or when the import is not blocked by stubs).
|
|
12
|
-
*/
|
|
13
|
-
|
|
14
|
-
import type { FontMetrics } from '../types/FontTypes.js';
|
|
15
|
-
|
|
16
|
-
// ── Internal font object shape ─────────────────────────────────────────
|
|
17
|
-
// We keep fontkit.Font opaque — users of FontEngine never import fontkit.
|
|
18
|
-
|
|
19
|
-
/** Opaque font face handle returned by FontEngine.create() */
|
|
20
|
-
export interface FontFace {
|
|
21
|
-
/** fontkit font object (private — not meant for direct access) */
|
|
22
|
-
readonly _raw: any;
|
|
23
|
-
/** Cached values extracted once after creation */
|
|
24
|
-
readonly unitsPerEm: number;
|
|
25
|
-
readonly ascent: number;
|
|
26
|
-
readonly descent: number;
|
|
27
|
-
readonly capHeight: number;
|
|
28
|
-
readonly winAscent: number | null;
|
|
29
|
-
readonly winDescent: number | null;
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
// ── FontEngine ─────────────────────────────────────────────────────────
|
|
33
|
-
|
|
34
|
-
let _fontkitModule: any | null = null;
|
|
35
|
-
|
|
36
|
-
/**
|
|
37
|
-
* Lazily import fontkit (avoids top‑level side‑effects in bundlers).
|
|
38
|
-
* fontkit exposes both CJS and ESM browser builds — bundlers resolve
|
|
39
|
-
* the correct entry from package.json exports.
|
|
40
|
-
*/
|
|
41
|
-
async function _getFontkit(): Promise<any> {
|
|
42
|
-
if (_fontkitModule) return _fontkitModule;
|
|
43
|
-
const mod = await import('fontkit');
|
|
44
|
-
_fontkitModule = mod.default || mod;
|
|
45
|
-
return _fontkitModule;
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
/**
|
|
49
|
-
* Extract metric values from a raw fontkit font object.
|
|
50
|
-
*/
|
|
51
|
-
function _extractMetrics(raw: any): {
|
|
52
|
-
unitsPerEm: number;
|
|
53
|
-
ascent: number;
|
|
54
|
-
descent: number;
|
|
55
|
-
capHeight: number;
|
|
56
|
-
winAscent: number | null;
|
|
57
|
-
winDescent: number | null;
|
|
58
|
-
} {
|
|
59
|
-
const os2 = raw['OS/2'];
|
|
60
|
-
return {
|
|
61
|
-
unitsPerEm: raw.unitsPerEm,
|
|
62
|
-
ascent: raw.ascent,
|
|
63
|
-
descent: raw.descent,
|
|
64
|
-
capHeight: raw.capHeight ?? raw.ascent,
|
|
65
|
-
winAscent: os2?.winAscent ?? null,
|
|
66
|
-
winDescent: os2?.winDescent ?? null,
|
|
67
|
-
};
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
/**
|
|
71
|
-
* Get a glyph handle for a code point.
|
|
72
|
-
* Returns null when the glyph is not present (e.g. .notdef).
|
|
73
|
-
*/
|
|
74
|
-
function _getGlyph(raw: any, codePoint: number): any | null {
|
|
75
|
-
return raw.glyphForCodePoint(codePoint) ?? null;
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
// ── Public API ─────────────────────────────────────────────────────────
|
|
79
|
-
|
|
80
|
-
/**
|
|
81
|
-
* Create a font face from a binary buffer.
|
|
82
|
-
*
|
|
83
|
-
* @param buffer Font file bytes (ArrayBuffer in browser, Uint8Array/Buffer in Node.js)
|
|
84
|
-
* @returns Opaque FontFace handle
|
|
85
|
-
*/
|
|
86
|
-
export async function createFontFace(buffer: ArrayBuffer | Uint8Array): Promise<FontFace> {
|
|
87
|
-
const fontkit = await _getFontkit();
|
|
88
|
-
const raw = fontkit.create(buffer);
|
|
89
|
-
const metrics = _extractMetrics(raw);
|
|
90
|
-
return {
|
|
91
|
-
_raw: raw,
|
|
92
|
-
...metrics,
|
|
93
|
-
};
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
/**
|
|
97
|
-
* Get the advance width (in font units) for a single code point.
|
|
98
|
-
*
|
|
99
|
-
* @returns advance width in font units, or `null` if the glyph is missing
|
|
100
|
-
*/
|
|
101
|
-
export function getGlyphAdvance(font: FontFace, codePoint: number): number | null {
|
|
102
|
-
const glyph = _getGlyph(font._raw, codePoint);
|
|
103
|
-
if (!glyph) return null;
|
|
104
|
-
return glyph.advanceWidth;
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
/**
|
|
108
|
-
* Compute pixel‑scale metrics for a given font size.
|
|
109
|
-
*/
|
|
110
|
-
export function computePixelMetrics(font: FontFace, fontSize: number, mode: 'browser' | 'office'): FontMetrics {
|
|
111
|
-
const scale = fontSize / font.unitsPerEm;
|
|
112
|
-
|
|
113
|
-
if (mode === 'office' && font.winAscent != null && font.winDescent != null) {
|
|
114
|
-
return {
|
|
115
|
-
ascent: font.winAscent * scale * 1.078,
|
|
116
|
-
descent: Math.abs(font.winDescent) * scale * 1.078,
|
|
117
|
-
capHeight: (font.capHeight ?? font.ascent) * scale,
|
|
118
|
-
unitsPerEm: font.unitsPerEm,
|
|
119
|
-
sourceTable: 'OS/2',
|
|
120
|
-
};
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
// browser mode (or Office fallback when OS/2 is absent)
|
|
124
|
-
return {
|
|
125
|
-
ascent: font.ascent * scale,
|
|
126
|
-
descent: Math.abs(font.descent) * scale,
|
|
127
|
-
capHeight: (font.capHeight ?? font.ascent) * scale,
|
|
128
|
-
unitsPerEm: font.unitsPerEm,
|
|
129
|
-
sourceTable: 'hhea',
|
|
130
|
-
};
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
/**
|
|
134
|
-
* Whether the fontkit module was successfully loaded.
|
|
135
|
-
* Useful for tests to verify the bundler isn't blocking fontkit.
|
|
136
|
-
*/
|
|
137
|
-
export async function isFontEngineAvailable(): Promise<boolean> {
|
|
138
|
-
try {
|
|
139
|
-
const fk = await _getFontkit();
|
|
140
|
-
return typeof fk.create === 'function';
|
|
141
|
-
} catch {
|
|
142
|
-
return false;
|
|
143
|
-
}
|
|
144
|
-
}
|