@vyaz/core 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.
@@ -0,0 +1,391 @@
1
+ /**
2
+ * PositioningEngine.ts — pure X/Y positioning math.
3
+ *
4
+ * Takes pretext output (lines with fragments) + font metrics +
5
+ * paragraph style → returns LineBox[] with absolute coordinates.
6
+ *
7
+ * X: alignment (left/center/right/justify) + indent
8
+ * Y: baseline + lineHeight + spaceBefore/After
9
+ * Justify: fragmented approach (each space → separate FragmentBox)
10
+ *
11
+ * Specs:
12
+ * - CSS Text Module Level 3/4 (browser mode)
13
+ * - ISO/IEC 29500 (Office Open XML / DrawingML, office mode)
14
+ * - Parley alignment.rs (conceptually close, but here justify is simpler:
15
+ * slack is divided equally among stretchable space-fragments,
16
+ * without mutating ClusterData.advance)
17
+ */
18
+
19
+ import type { ParagraphStyle } from '../types/Document.js';
20
+ import type { FontMetrics } from '../types/FontTypes.js';
21
+ import type { LineBox, FragmentBox, FragmentFontMetrics } from '../types/LayoutTypes.js';
22
+ import type { PreparedRichInlineItem } from '../compile/DocumentCompiler.js';
23
+
24
+ // ── Helper types for pretext ───────────────────────────────────────────
25
+
26
+ interface PretextFragment {
27
+ itemIndex: number;
28
+ text: string;
29
+ gapBefore: number;
30
+ occupiedWidth: number;
31
+ start: { segmentIndex: number; graphemeIndex: number };
32
+ end: { segmentIndex: number; graphemeIndex: number };
33
+ }
34
+
35
+ interface PretextLine {
36
+ fragments: PretextFragment[];
37
+ width: number; // natural width (without stretching)
38
+ end: { segmentIndex: number; graphemeIndex: number };
39
+ }
40
+
41
+ // ── PositioningEngine ─────────────────────────────────────────────────
42
+
43
+ /**
44
+ * Build LineBox[] from pretext lines with alignment and metrics.
45
+ *
46
+ * @param pretextLines — pretext result (materializeRichInlineLineRange)
47
+ * @param items — original PreparedRichInlineItem[] (for metadata)
48
+ * @param fontMetricsFn — function to get font metrics for a fragment
49
+ * @param style — paragraph style
50
+ * @param maxWidth — available container width
51
+ * @param startY — initial Y position
52
+ * @param mode — metric mode ('browser' | 'office'), affects line height calculation
53
+ * @returns { lines: LineBox[], contentWidth: number }
54
+ */
55
+ export function positionLineBoxes(
56
+ pretextLines: PretextLine[],
57
+ items: PreparedRichInlineItem[],
58
+ fontMetricsFn: (item: PreparedRichInlineItem) => FontMetrics,
59
+ style: ParagraphStyle,
60
+ maxWidth: number,
61
+ startY: number = 0,
62
+ mode: 'browser' | 'office' = 'browser',
63
+ paragraphId?: string,
64
+ ): { lines: LineBox[]; contentWidth: number } {
65
+ const lines: LineBox[] = [];
66
+ let currentY = startY + style.spaceBefore;
67
+ let charIndex = 0;
68
+ let isFirstLine = true;
69
+ let contentWidth = 0;
70
+
71
+ for (let lineIdx = 0; lineIdx < pretextLines.length; lineIdx++) {
72
+ const ptLine = pretextLines[lineIdx];
73
+
74
+ // ── Build FragmentBox[] ────────────────────────────
75
+ let maxAscent = 0;
76
+ let maxDescent = 0;
77
+ let maxLineHeightBase = 0; // max(ascent + descent) — for Office mode
78
+ const fragments: FragmentBox[] = [];
79
+
80
+ for (const frag of ptLine.fragments) {
81
+ const item = items[frag.itemIndex];
82
+ if (!item) continue;
83
+
84
+ const metrics = fontMetricsFn(item);
85
+ const effectiveAscent = metrics.ascent + (item.metadata.baselineOffset || 0);
86
+ const effectiveDescent = metrics.descent - (item.metadata.baselineOffset || 0);
87
+
88
+ maxAscent = Math.max(maxAscent, effectiveAscent);
89
+ maxDescent = Math.max(maxDescent, effectiveDescent);
90
+ // Office: line height base = ascent + descent (OS/2 usWinAscent + usWinDescent, scaled)
91
+ maxLineHeightBase = Math.max(maxLineHeightBase, metrics.ascent + metrics.descent);
92
+
93
+ // pretext: gapBefore — inter-word space BEFORE the word
94
+ // occupiedWidth = gapBefore + textWidth
95
+ // Split into two FragmentBox: space + word
96
+ const gapWidth = frag.gapBefore || 0;
97
+ const textWidth = frag.occupiedWidth;
98
+
99
+ const baseFontMetrics = {
100
+ ascent: metrics.ascent,
101
+ descent: metrics.descent,
102
+ fontSize: item.metadata.effectiveFontSize,
103
+ };
104
+
105
+ if (gapWidth > 0) {
106
+ fragments.push({
107
+ x: 0,
108
+ width: gapWidth,
109
+ text: ' ',
110
+ itemIndex: frag.itemIndex,
111
+ paragraphId,
112
+ fontMetrics: baseFontMetrics,
113
+ style: item.metadata.style,
114
+ inlineWidget: item.metadata.inlineWidget,
115
+ type: 'space',
116
+ });
117
+ }
118
+
119
+ // Split leading/trailing spaces from frag.text into separate space fragments
120
+ // This is needed for SVG rendering to avoid xml:space="preserve" dependency
121
+ const text = frag.text;
122
+ const leadingMatch = text.match(/^(\s+)/);
123
+
124
+ const totalChars = text.length;
125
+ let remainingText = text;
126
+ let leadingSpaceChars = 0;
127
+ let trailingSpaceChars = 0;
128
+
129
+ if (leadingMatch) {
130
+ leadingSpaceChars = leadingMatch[1].length;
131
+ remainingText = remainingText.slice(leadingSpaceChars);
132
+ }
133
+ if (remainingText.length > 0) {
134
+ const trailMatch = remainingText.match(/(\s+)$/);
135
+ if (trailMatch) {
136
+ trailingSpaceChars = trailMatch[1].length;
137
+ remainingText = remainingText.slice(0, -trailingSpaceChars);
138
+ }
139
+ }
140
+
141
+ const computePartialWidth = (charCount: number) => {
142
+ return totalChars > 0 ? (charCount / totalChars) * textWidth : 0;
143
+ };
144
+
145
+ // Leading space fragment
146
+ if (leadingSpaceChars > 0) {
147
+ fragments.push({
148
+ x: 0,
149
+ width: computePartialWidth(leadingSpaceChars),
150
+ text: text.slice(0, leadingSpaceChars),
151
+ itemIndex: frag.itemIndex,
152
+ paragraphId,
153
+ fontMetrics: baseFontMetrics,
154
+ style: item.metadata.style,
155
+ inlineWidget: item.metadata.inlineWidget,
156
+ type: 'space',
157
+ });
158
+ }
159
+
160
+ // Text fragment (trimmed)
161
+ if (remainingText.length > 0) {
162
+ const textWidth = computePartialWidth(remainingText.length);
163
+ // Compute per-glyph advances by distributing textWidth equally across glyphs.
164
+ // For monospace fonts this is exact; for proportional it's a linear approximation.
165
+ // The FontMetricsProvider can later supply real glyph advances when available.
166
+ const glyphAdvances: number[] = [];
167
+ if (remainingText.length > 1) {
168
+ const perGlyph = textWidth / remainingText.length;
169
+ for (let i = 0; i < remainingText.length; i++) {
170
+ glyphAdvances.push(perGlyph);
171
+ }
172
+ }
173
+ fragments.push({
174
+ x: 0,
175
+ width: textWidth,
176
+ text: remainingText,
177
+ itemIndex: frag.itemIndex,
178
+ paragraphId,
179
+ fontMetrics: baseFontMetrics,
180
+ style: item.metadata.style,
181
+ inlineWidget: item.metadata.inlineWidget,
182
+ type: 'text',
183
+ glyphAdvances: glyphAdvances.length > 0 ? glyphAdvances : undefined,
184
+ });
185
+ }
186
+
187
+ // Trailing space fragment
188
+ if (trailingSpaceChars > 0) {
189
+ const trailingStart = leadingSpaceChars + remainingText.length;
190
+ fragments.push({
191
+ x: 0,
192
+ width: computePartialWidth(trailingSpaceChars),
193
+ text: text.slice(trailingStart, trailingStart + trailingSpaceChars),
194
+ itemIndex: frag.itemIndex,
195
+ paragraphId,
196
+ fontMetrics: baseFontMetrics,
197
+ style: item.metadata.style,
198
+ inlineWidget: item.metadata.inlineWidget,
199
+ type: 'space',
200
+ });
201
+ }
202
+ }
203
+
204
+ // ── Mark trailing whitespace fragments ───────────────────
205
+ // CSS Text §4.1.3: end-of-line spaces have zero measure for line-advance calculations.
206
+ // Parley: LineItemData.has_trailing_whitespace → trailing whitespace is excluded from advance.
207
+ //
208
+ // Find the last space(s) at the end of the line and mark them trailing.
209
+ let trailingStartIndex = fragments.length;
210
+ for (let i = fragments.length - 1; i >= 0; i--) {
211
+ if (fragments[i].type === 'space') {
212
+ trailingStartIndex = i;
213
+ } else {
214
+ break;
215
+ }
216
+ }
217
+
218
+ const trailingWidth = fragments
219
+ .slice(trailingStartIndex)
220
+ .reduce((sum, f) => sum + f.width, 0);
221
+
222
+ for (let i = trailingStartIndex; i < fragments.length; i++) {
223
+ fragments[i].trailing = true;
224
+ }
225
+
226
+ // ── Compute effective line width (excluding trailing whitespace) ─
227
+ // Use sum of actual fragment widths instead of ptLine.width, because
228
+ // ptLine.width may not include gapBefore spaces that were split into
229
+ // separate FragmentBox (e.g. "AA" + " A" → [AA][ ][A]).
230
+ const totalFragWidth = fragments.reduce((sum, f) => sum + f.width, 0);
231
+ const effectiveLineWidth = totalFragWidth - trailingWidth;
232
+
233
+ // ── X positioning ───────────────────────────────
234
+ const indent = isFirstLine
235
+ ? (style.leftIndent || 0) + (style.indent || 0)
236
+ : (style.leftIndent || 0);
237
+ const rightIndent = style.rightIndent || 0;
238
+ const availableWidth = maxWidth - indent - rightIndent;
239
+
240
+ const slack = Math.max(0, availableWidth - effectiveLineWidth);
241
+
242
+ let xOffset = indent;
243
+
244
+ if (style.alignment === 'center') {
245
+ xOffset = indent + slack / 2;
246
+ } else if (style.alignment === 'right') {
247
+ xOffset = indent + slack;
248
+ } else if (style.alignment === 'justify') {
249
+ // Justify: distribute slack evenly among whitespace fragments
250
+ // (excluding trailing whitespace).
251
+ //
252
+ // CSS Text Module Level 3 §4.1.3: trailing spaces do not participate in justify.
253
+ // CSS text-align-last: last line is not justify, but start-align.
254
+ // OOXML (ISO 29500): last line of paragraph is not stretched.
255
+ // Parley alignment.rs: excludes last line (line.break_reason == None/Explicit)
256
+ // and lines with num_spaces == 0.
257
+ //
258
+ // Determine if this is the last line in the paragraph.
259
+ const isLastLine = lineIdx === pretextLines.length - 1;
260
+
261
+ // Count only "stretchable" spaces: type === 'space' and !trailing
262
+ const stretchableSpaces = fragments.filter(
263
+ (f) => f.type === 'space' && !f.trailing,
264
+ );
265
+ const spaceCount = stretchableSpaces.length;
266
+
267
+ if (!isLastLine && spaceCount > 0 && slack > 0) {
268
+ const extraPerSpace = slack / spaceCount;
269
+ for (const sf of stretchableSpaces) {
270
+ sf.width += extraPerSpace;
271
+ }
272
+ }
273
+
274
+ // If this is the last line or no spaces — fallback to start-align
275
+ // (LTR → left, RTL → right — always left for now, Bidi in Phase 2)
276
+ xOffset = indent;
277
+ }
278
+
279
+ // Assign X positions
280
+ let runX = xOffset;
281
+ for (const frag of fragments) {
282
+ frag.x = Math.round(runX * 100) / 100;
283
+ runX += frag.width;
284
+ }
285
+
286
+ const lineWidth = runX - xOffset;
287
+ contentWidth = Math.max(contentWidth, lineWidth);
288
+
289
+ // ── Y positioning ───────────────────────────────
290
+ // Line height algorithm depends on mode:
291
+ //
292
+ // 'browser' (CSS-compatible, parley/Chrome matching):
293
+ // 1. lineHeightPx = maxFontSize * style.lineHeight
294
+ //
295
+ // 'office' (MS Office / DrawingML pixel-perfect):
296
+ // PowerPoint has no line-height multiplier for single lines.
297
+ // Line height strictly = ascent + descent (OS/2.usWinAscent + usWinDescent).
298
+ // Baseline = Top + ascent without any half-leading additions.
299
+ // See pixel-perfect-text-layout.md §1 and ECMA-376.
300
+ const maxFontSize = fragments.reduce((max, f) => Math.max(max, f.fontMetrics.fontSize), 0);
301
+
302
+ const ascentRounded = Math.round(maxAscent);
303
+ const descentRounded = Math.round(maxDescent);
304
+
305
+ let lineBoxHeight: number;
306
+ let baseline: number;
307
+
308
+ if (mode === 'office') {
309
+ // DrawingML: lineHeight = ascent + descent, no lineHeight ×1.15 and no leading.
310
+ // DrawingML: base line height = OS/2 (usWinAscent + usWinDescent), without
311
+ // sum of rounded ascent/descent — that formula caused pixel-perfect
312
+ // mismatch with PowerPoint, so we use maxLineHeightBase.
313
+ lineBoxHeight = maxLineHeightBase;
314
+ baseline = ascentRounded;
315
+ } else {
316
+ // Browser: CSS-compatible with leading distribution.
317
+ const lineHeightPx = maxFontSize * style.lineHeight;
318
+ const ascentDescentRounded = ascentRounded + descentRounded;
319
+
320
+ const rawLineBoxHeight = Math.round(lineHeightPx);
321
+ lineBoxHeight = Math.max(rawLineBoxHeight, ascentDescentRounded);
322
+ const leading = lineBoxHeight - ascentDescentRounded; // always integer
323
+
324
+ if (leading <= 0) {
325
+ // Negative or zero leading: don't shrink the line
326
+ baseline = ascentRounded;
327
+ } else {
328
+ // Positive leading: distribute as integers with above_leading < below_leading
329
+ const ascentDescent = maxAscent + maxDescent;
330
+ const aboveLeadingFloat = ascentDescent > 0 ? leading * maxAscent / ascentDescent : leading / 2;
331
+ let aboveLeading = Math.round(aboveLeadingFloat);
332
+ let belowLeading = leading - aboveLeading;
333
+
334
+ // Ensure above_leading < below_leading (parley/Chrome heuristic)
335
+ if (aboveLeading >= belowLeading) {
336
+ aboveLeading = Math.floor((leading - 1) / 2);
337
+ belowLeading = leading - aboveLeading;
338
+ }
339
+
340
+ baseline = ascentRounded + aboveLeading;
341
+ }
342
+ }
343
+
344
+ const startIdx = charIndex;
345
+
346
+ // Count characters in line (for INDEX_CONSIST)
347
+ let lineCharCount = 0;
348
+ for (const frag of fragments) {
349
+ lineCharCount += frag.text.length;
350
+ }
351
+ const endIdx = startIdx + lineCharCount;
352
+ charIndex = endIdx;
353
+
354
+ // ── Mark break type on the last fragment ─────────────
355
+ // 'soft' — line wrap due to width constraint
356
+ // 'hard' — explicit break (\n)
357
+ // 'none'/undefined — not a line end (no break)
358
+ if (fragments.length > 0) {
359
+ const lastFrag = fragments[fragments.length - 1];
360
+ const lastFragItem = items[lastFrag.itemIndex];
361
+ // If last character is \n, it's a hard break
362
+ if (lastFragItem && lastFrag.text.endsWith('\n')) {
363
+ lastFrag.breakType = 'hard';
364
+ } else if (lineIdx < pretextLines.length - 1) {
365
+ lastFrag.breakType = 'soft';
366
+ }
367
+ }
368
+
369
+ lines.push({
370
+ x: Math.round(xOffset * 100) / 100,
371
+ y: Math.round(currentY * 100) / 100,
372
+ width: Math.round(lineWidth * 100) / 100,
373
+ height: Math.round(lineBoxHeight * 100) / 100,
374
+ baseline: Math.round(baseline * 100) / 100,
375
+ ascent: Math.round(maxAscent * 100) / 100,
376
+ descent: Math.round(maxDescent * 100) / 100,
377
+ startIndex: startIdx,
378
+ endIndex: endIdx,
379
+ alignment: style.alignment,
380
+ fragments,
381
+ });
382
+
383
+ currentY += lineBoxHeight;
384
+ isFirstLine = false;
385
+ }
386
+
387
+ // Add spaceAfter to last line height
388
+ // (return as is — ParagraphLayoutEngine will add spaceAfter to total height)
389
+
390
+ return { lines, contentWidth };
391
+ }
@@ -0,0 +1,86 @@
1
+ /**
2
+ * TextFrameLayoutEngine.ts — Layout a full TextFrame (multi-paragraph).
3
+ *
4
+ * Pipeline:
5
+ * TextFrame → Paragraph[] → paragraphLayoutEngine.layout() each → merge LineBox[]
6
+ *
7
+ * Handles:
8
+ * - Paragraph stacking with Y offset accumulation
9
+ * - Padding (left reduces available width, left shifts X)
10
+ * - frame.width/height optional → fitHorizontal/fitVertical flags
11
+ */
12
+ import type { TextFrame } from '../types/Document.js';
13
+ import type { LineBox } from '../types/LayoutTypes.js';
14
+ import { paragraphLayoutEngine } from './ParagraphLayoutEngine.js';
15
+
16
+ /**
17
+ * Result of laying out a full TextFrame.
18
+ *
19
+ * `fitHorizontal` / `fitVertical` tell the renderer which dimension to use:
20
+ * - `'frame'` → use `frameWidth` / `frameHeight`
21
+ * - `'content'` → use `contentWidth` / `contentHeight`
22
+ */
23
+ export interface TextFrameLayoutResult {
24
+ lines: LineBox[];
25
+ /** Frame width (set when TextFrame.width was provided). */
26
+ frameWidth?: number;
27
+ /** Frame height (set when TextFrame.height was provided). */
28
+ frameHeight?: number;
29
+ /** Actual content width (may exceed frameWidth when wrap=false). */
30
+ contentWidth: number;
31
+ /** Actual content height (may exceed frameHeight). */
32
+ contentHeight: number;
33
+ /** Whether horizontal dimension should use frame or content size. */
34
+ fitHorizontal: 'frame' | 'content';
35
+ /** Whether vertical dimension should use frame or content size. */
36
+ fitVertical: 'frame' | 'content';
37
+ }
38
+
39
+ /**
40
+ * Layout a full TextFrame by stacking paragraphs with Y offset accumulation.
41
+ */
42
+ export function layoutTextFrame(frame: TextFrame): TextFrameLayoutResult {
43
+ const allLines: LineBox[] = [];
44
+ let yOffset = 0;
45
+ let contentWidth = 0;
46
+
47
+ const leftPad = frame.padding?.left ?? 0;
48
+ const rightPad = frame.padding?.right ?? 0;
49
+
50
+ for (let i = 0; i < frame.paragraphs.length; i++) {
51
+ const p = frame.paragraphs[i];
52
+
53
+ // Available width: frame.width minus horizontal padding.
54
+ // If width is undefined → Infinite (no wrap constraint).
55
+ const maxWidth = frame.width !== undefined
56
+ ? frame.width - leftPad - rightPad
57
+ : Infinity;
58
+
59
+ const result = paragraphLayoutEngine.layout(p, maxWidth, yOffset);
60
+
61
+ for (const line of result.lines) {
62
+ // Shift lines by left padding
63
+ line.x += leftPad;
64
+ allLines.push(line);
65
+ }
66
+
67
+ contentWidth = Math.max(contentWidth, result.contentWidth);
68
+ // result.height is absolute (includes yOffset passed in).
69
+ // Set, don't accumulate — otherwise yOffset compounds.
70
+ yOffset = result.height;
71
+ }
72
+
73
+ const contentHeight = allLines.length > 0
74
+ ? allLines[allLines.length - 1].y + allLines[allLines.length - 1].height
75
+ : 0;
76
+
77
+ return {
78
+ lines: allLines,
79
+ frameWidth: frame.width,
80
+ frameHeight: frame.height,
81
+ contentWidth,
82
+ contentHeight,
83
+ fitHorizontal: frame.width !== undefined ? 'frame' : 'content',
84
+ fitVertical: frame.height !== undefined ? 'frame' : 'content',
85
+ };
86
+ }
@@ -0,0 +1,190 @@
1
+ /**
2
+ * FontMetricsProvider.ts — isomorphic font metrics provider.
3
+ *
4
+ * Strategy (priority):
5
+ * 1. fontkit (Node.js) — from registered buffer
6
+ * - 'browser' mode: font.hhea.ascent / font.hhea.descent
7
+ * - 'office' mode: font['OS/2'].usWinAscent / font['OS/2'].usWinDescent
8
+ * 2. Canvas TextMetrics (browser)
9
+ * 3. Fallback (fontSize * 0.85 / 0.15)
10
+ *
11
+ * Uses FontRegistry for font registration.
12
+ */
13
+
14
+ import type { FontMetrics, IFontMetricsProvider } from '../types/FontTypes.js';
15
+ import { enableOfficeTextMeasure, disableOfficeTextMeasure } from './canvas-polyfill.js';
16
+
17
+ /** Cache key: "${family}_${weight}_${style}" */
18
+ function cacheKey(family: string, weight: string, style: string): string {
19
+ return `${family}_${weight}_${style}`;
20
+ }
21
+
22
+ export class FontMetricsProvider implements IFontMetricsProvider {
23
+ private cache = new Map<string, any>(); // fontkit.Font | undefined
24
+ private metricsCache = new Map<string, FontMetrics>();
25
+ private mode: 'browser' | 'office' = 'browser';
26
+
27
+ // ── Mode ──────────────────────────────────────────────────────────
28
+
29
+ setMode(mode: 'browser' | 'office'): void {
30
+ if (this.mode === mode) return;
31
+ this.mode = mode;
32
+ // Invalidate metrics cache on mode change
33
+ this.metricsCache.clear();
34
+
35
+ // Toggle ctx.measureText for pretext (canvas-based line breaking)
36
+ if (mode === 'office') {
37
+ enableOfficeTextMeasure(this.cache); // fontkit-based hmtx advance widths
38
+ } else {
39
+ disableOfficeTextMeasure(); // original Canvas 2D measureText
40
+ }
41
+ }
42
+
43
+ getMode(): 'browser' | 'office' {
44
+ return this.mode;
45
+ }
46
+
47
+ // ── FontRegistry ──────────────────────────────────────────────────
48
+
49
+ /**
50
+ * Register a binary font for use with fontkit.
51
+ * In browser — no-op.
52
+ */
53
+ async registerFont(
54
+ family: string,
55
+ options: { weight?: string; style?: string },
56
+ source: string | Buffer,
57
+ ): Promise<void> {
58
+ try {
59
+ // Dynamic ESM import — fontkit may not be available in browser
60
+ const fontkit = await import('fontkit');
61
+ const buffer = typeof source === 'string' ? Buffer.from(source) : source;
62
+ // @ts-ignore fontkit CJS/ESM compatibility
63
+ const fk = fontkit.default || fontkit;
64
+ const font = fk.create(buffer);
65
+ const key = cacheKey(
66
+ family,
67
+ options.weight || 'normal',
68
+ options.style || 'normal',
69
+ );
70
+ this.cache.set(key, font);
71
+ // Invalidate metrics for this font
72
+ this.metricsCache.delete(key);
73
+ } catch {
74
+ // fontkit not available (browser) — no-op
75
+ }
76
+ return Promise.resolve();
77
+ }
78
+
79
+ // ── Font object access (for per-glyph advance) ────────────────────
80
+
81
+ /**
82
+ * Get fontkit font object for per-character calculations.
83
+ * Returns undefined if font is not registered or fontkit unavailable.
84
+ */
85
+ getFont(family: string, weight = 'normal', style = 'normal'): any | undefined {
86
+ const key = cacheKey(family, weight, style);
87
+ return this.cache.get(key);
88
+ }
89
+
90
+ // ── Metrics retrieval ─────────────────────────────────────────────
91
+
92
+ getMetrics(
93
+ fontFamily: string,
94
+ fontSize: number,
95
+ weight = 'normal',
96
+ style = 'normal',
97
+ ): FontMetrics {
98
+ const key = cacheKey(fontFamily, weight, style);
99
+
100
+ // Metrics cache (depends on fontSize, so include in key)
101
+ const metricsKey = `${key}_${fontSize}_${this.mode}`;
102
+ const cached = this.metricsCache.get(metricsKey);
103
+ if (cached) return cached;
104
+
105
+ let metrics: FontMetrics;
106
+
107
+ // Strategy 1: fontkit
108
+ const font = this.cache.get(key);
109
+
110
+ if (font) {
111
+ const scale = fontSize / font.unitsPerEm;
112
+
113
+ if (this.mode === 'office') {
114
+ // Office mode: OS/2.usWinAscent + usWinDescent
115
+ const os2 = font['OS/2'];
116
+ let ascent: number;
117
+ let descent: number;
118
+ let sourceTable: 'OS/2' | 'hhea';
119
+
120
+ if (os2 && os2.winAscent != null && os2.winDescent != null) {
121
+ ascent = os2.winAscent * scale * 1.078;
122
+ descent = Math.abs(os2.winDescent) * scale * 1.078;
123
+ sourceTable = 'OS/2';
124
+ } else {
125
+ // Fallback to hhea if OS/2 is absent
126
+ ascent = font.ascent * scale;
127
+ descent = Math.abs(font.descent) * scale;
128
+ sourceTable = 'hhea';
129
+ }
130
+
131
+ metrics = {
132
+ ascent,
133
+ descent,
134
+ capHeight: (font.capHeight ?? ascent) * scale,
135
+ unitsPerEm: font.unitsPerEm,
136
+ sourceTable,
137
+ };
138
+ } else {
139
+ // Browser mode: hhea.ascender/descender
140
+ metrics = {
141
+ ascent: font.ascent * scale,
142
+ descent: Math.abs(font.descent) * scale,
143
+ capHeight: (font.capHeight ?? font.ascent) * scale,
144
+ unitsPerEm: font.unitsPerEm,
145
+ sourceTable: 'hhea',
146
+ };
147
+ }
148
+
149
+ this.metricsCache.set(metricsKey, metrics);
150
+ return metrics;
151
+ }
152
+
153
+ // Strategy 2: Canvas TextMetrics (browser)
154
+ if (typeof document !== 'undefined') {
155
+ try {
156
+ const canvas = document.createElement('canvas');
157
+ const ctx = canvas.getContext('2d')!;
158
+ ctx.font = `${style} ${weight} ${fontSize}px ${fontFamily}`;
159
+ const m = ctx.measureText('M');
160
+
161
+ metrics = {
162
+ ascent: m.fontBoundingBoxAscent || fontSize * 0.85,
163
+ descent: m.fontBoundingBoxDescent || fontSize * 0.15,
164
+ capHeight: m.actualBoundingBoxAscent || fontSize * 0.7,
165
+ unitsPerEm: 1000,
166
+ sourceTable: 'canvas',
167
+ };
168
+ this.metricsCache.set(metricsKey, metrics);
169
+ return metrics;
170
+ } catch {
171
+ // Fall through to fallback
172
+ }
173
+ }
174
+
175
+ console.error("Font not found")
176
+ // Strategy 3: Fallback
177
+ metrics = {
178
+ ascent: fontSize * 0.85,
179
+ descent: fontSize * 0.15,
180
+ capHeight: fontSize * 0.7,
181
+ unitsPerEm: 1000,
182
+ sourceTable: 'fallback',
183
+ };
184
+ this.metricsCache.set(metricsKey, metrics);
185
+ return metrics;
186
+ }
187
+ }
188
+
189
+ /** Singleton */
190
+ export const fontMetricsProvider = new FontMetricsProvider();
@@ -0,0 +1,6 @@
1
+ /**
2
+ * Type declarations for @napi-rs/canvas used by canvas-polyfill.ts.
3
+ */
4
+ declare module '@napi-rs/canvas' {
5
+ export function createCanvas(width: number, height: number): any;
6
+ }