@vyaz/core 0.0.4 → 0.0.5

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.
@@ -2,19 +2,28 @@
2
2
  * FontMetricsProvider.ts — isomorphic font metrics provider.
3
3
  *
4
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)
5
+ * 1. FontEngine (fontkit) — from registered buffer
6
+ * - 'browser' mode: hhea.ascender / hhea.descender
7
+ * - 'office' mode: OS/2.usWinAscent / OS/2.usWinDescent
8
+ * 2. Canvas TextMetrics (browser fallback when fontkit unavailable)
10
9
  *
11
- * Uses FontRegistry for font registration.
10
+ * Uses FontEngine as the single entry point for all fontkit operations.
12
11
  */
13
12
 
14
13
  import type { FontMetrics, IFontMetricsProvider } from '../types/FontTypes.js';
15
- import { enableOfficeTextMeasure, disableOfficeTextMeasure, registerCanvasFont } from './canvas-polyfill.js';
14
+ import type { FontFace } from './FontEngine.js';
15
+ import { enableOfficeTextMeasure, disableOfficeTextMeasure } from './canvas-polyfill.js';
16
16
  import { FontNotFoundError } from './FontNotFoundError.js';
17
17
 
18
+ // ── Reasonable default for missing glyphs ──────────────────────────────────
19
+
20
+ /**
21
+ * Factor used when a glyph is not found in the font.
22
+ * Multiplied by fontSize to estimate the missing glyph width.
23
+ * Used across all measurement code paths (ParagraphLayoutEngine, canvas-polyfill).
24
+ */
25
+ export const MISSING_GLYPH_FACTOR = 0.5;
26
+
18
27
  // ── Weight normalisation ─────────────────────────────────────────────────
19
28
 
20
29
  /**
@@ -52,7 +61,8 @@ function cacheKey(family: string, weight: string, style: string): string {
52
61
  }
53
62
 
54
63
  export class FontMetricsProvider implements IFontMetricsProvider {
55
- private cache = new Map<string, any>(); // fontkit.Font | undefined
64
+ /** Map<string, FontFace> font engine font face cache */
65
+ private cache = new Map<string, FontFace>();
56
66
  private metricsCache = new Map<string, FontMetrics>();
57
67
  private mode: 'browser' | 'office' = 'browser';
58
68
 
@@ -80,49 +90,52 @@ export class FontMetricsProvider implements IFontMetricsProvider {
80
90
 
81
91
  /**
82
92
  * Register a binary font for use with fontkit.
83
- * In browser — no-op (fonts are registered via CSS @font-face).
84
93
  *
85
- * @param sourcePath if provided, also registers with @napi-rs/canvas for Node.js canvas measureText
94
+ * In both Node.js and browser the font is loaded via FontEngine.
95
+ * In the browser the caller must provide font bytes (e.g. fetched via
96
+ * `getFontBuffer()` from `../utils/font.js`).
97
+ *
98
+ * @param source Font file bytes (ArrayBuffer / Uint8Array), or a URL string
99
+ * @param sourcePath Optional filesystem path (used for @napi-rs/canvas in Node.js)
86
100
  */
87
101
  async registerFont(
88
102
  family: string,
89
103
  options: { weight?: string; style?: string },
90
- source: string | Buffer,
104
+ source: string | ArrayBuffer | Uint8Array,
91
105
  sourcePath?: string,
92
106
  ): Promise<void> {
93
- try {
94
- // Dynamic ESM import fontkit may not be available in browser
95
- const fontkit = await import('fontkit');
96
- const buffer = typeof source === 'string' ? Buffer.from(source) : source;
97
- // @ts-ignore fontkit CJS/ESM compatibility
98
- const fk = fontkit.default || fontkit;
99
- const font = fk.create(buffer);
100
- const key = cacheKey(
101
- family,
102
- options.weight || 'normal',
103
- options.style || 'normal',
104
- );
105
- this.cache.set(key, font);
106
- // Invalidate metrics for this font
107
- this.metricsCache.delete(key);
108
-
109
- // Also register with @napi-rs/canvas so ctx.measureText() uses real fonts
110
- if (sourcePath) {
111
- registerCanvasFont(sourcePath, family);
112
- }
113
- } catch {
114
- // fontkit not available (browser) — no-op
107
+ const { createFontFace } = await import('./FontEngine.js');
108
+ const { registerCanvasFont } = await import('./canvas-polyfill.js');
109
+
110
+ // Convert string (URL) to buffer works in browser via fetch
111
+ if (typeof source === 'string') {
112
+ const { getFontBuffer } = await import('../utils/font.js');
113
+ source = await getFontBuffer(source);
114
+ }
115
+
116
+ const font = await createFontFace(source);
117
+ const key = cacheKey(
118
+ family,
119
+ options.weight || 'normal',
120
+ options.style || 'normal',
121
+ );
122
+ this.cache.set(key, font);
123
+ // Invalidate metrics for this font
124
+ this.metricsCache.delete(key);
125
+
126
+ // Also register with @napi-rs/canvas so ctx.measureText() uses real fonts
127
+ if (sourcePath) {
128
+ registerCanvasFont(sourcePath, family);
115
129
  }
116
- return Promise.resolve();
117
130
  }
118
131
 
119
132
  // ── Font object access (for per-glyph advance) ────────────────────
120
133
 
121
134
  /**
122
- * Get fontkit font object for per-character calculations.
123
- * Returns undefined if font is not registered or fontkit unavailable.
135
+ * Get font engine FontFace object for per-character calculations.
136
+ * Returns undefined if font is not registered.
124
137
  */
125
- getFont(family: string, weight = 'normal', style = 'normal'): any | undefined {
138
+ getFont(family: string, weight = 'normal', style = 'normal'): FontFace | undefined {
126
139
  const key = cacheKey(family, weight, style);
127
140
  return this.cache.get(key);
128
141
  }
@@ -142,55 +155,40 @@ export class FontMetricsProvider implements IFontMetricsProvider {
142
155
  const cached = this.metricsCache.get(metricsKey);
143
156
  if (cached) return cached;
144
157
 
145
- let metrics: FontMetrics;
146
-
147
- // Strategy 1: fontkit
158
+ // Strategy 1: FontEngine (fontkit)
148
159
  const font = this.cache.get(key);
149
160
 
150
161
  if (font) {
162
+ // Inline computePixelMetrics to avoid circular ESM import
151
163
  const scale = fontSize / font.unitsPerEm;
152
164
 
153
- if (this.mode === 'office') {
154
- // Office mode: OS/2.usWinAscent + usWinDescent
155
- const os2 = font['OS/2'];
156
- let ascent: number;
157
- let descent: number;
158
- let sourceTable: 'OS/2' | 'hhea';
159
-
160
- if (os2 && os2.winAscent != null && os2.winDescent != null) {
161
- ascent = os2.winAscent * scale * 1.078;
162
- descent = Math.abs(os2.winDescent) * scale * 1.078;
163
- sourceTable = 'OS/2';
164
- } else {
165
- // Fallback to hhea if OS/2 is absent
166
- ascent = font.ascent * scale;
167
- descent = Math.abs(font.descent) * scale;
168
- sourceTable = 'hhea';
169
- }
170
-
171
- metrics = {
172
- ascent,
173
- descent,
174
- capHeight: (font.capHeight ?? ascent) * scale,
175
- unitsPerEm: font.unitsPerEm,
176
- sourceTable,
177
- };
165
+ let ascent: number;
166
+ let descent: number;
167
+ let sourceTable: 'hhea' | 'OS/2';
168
+
169
+ if (this.mode === 'office' && font.winAscent != null && font.winDescent != null) {
170
+ ascent = font.winAscent * scale * 1.078;
171
+ descent = Math.abs(font.winDescent) * scale * 1.078;
172
+ sourceTable = 'OS/2';
178
173
  } else {
179
- // Browser mode: hhea.ascender/descender
180
- metrics = {
181
- ascent: font.ascent * scale,
182
- descent: Math.abs(font.descent) * scale,
183
- capHeight: (font.capHeight ?? font.ascent) * scale,
184
- unitsPerEm: font.unitsPerEm,
185
- sourceTable: 'hhea',
186
- };
174
+ ascent = font.ascent * scale;
175
+ descent = Math.abs(font.descent) * scale;
176
+ sourceTable = 'hhea';
187
177
  }
188
178
 
179
+ const metrics: FontMetrics = {
180
+ ascent,
181
+ descent,
182
+ capHeight: (font.capHeight ?? font.ascent) * scale,
183
+ unitsPerEm: font.unitsPerEm,
184
+ sourceTable,
185
+ };
186
+
189
187
  this.metricsCache.set(metricsKey, metrics);
190
188
  return metrics;
191
189
  }
192
190
 
193
- // Strategy 2: If fontkit cache is non-empty, fontkit is available but font not found → throw
191
+ // Strategy 2: If font cache is non-empty, fontkit is available but font not found → throw
194
192
  if (this.cache.size > 0) {
195
193
  throw new FontNotFoundError(fontFamily, weight, style);
196
194
  }
@@ -203,7 +201,7 @@ export class FontMetricsProvider implements IFontMetricsProvider {
203
201
  ctx.font = `${style} ${weight} ${fontSize}px ${fontFamily}`;
204
202
  const m = ctx.measureText('M');
205
203
 
206
- metrics = {
204
+ const metrics: FontMetrics = {
207
205
  ascent: m.fontBoundingBoxAscent || fontSize * 0.85,
208
206
  descent: m.fontBoundingBoxDescent || fontSize * 0.15,
209
207
  capHeight: m.actualBoundingBoxAscent || fontSize * 0.7,
@@ -213,7 +211,7 @@ export class FontMetricsProvider implements IFontMetricsProvider {
213
211
  this.metricsCache.set(metricsKey, metrics);
214
212
  return metrics;
215
213
  } catch {
216
- // Fall through to fallback
214
+ // Fall through to throw
217
215
  }
218
216
  }
219
217
 
@@ -12,6 +12,7 @@
12
12
  */
13
13
 
14
14
  import { fontMetricsProvider } from './FontMetricsProvider.js';
15
+ import { isNodeLike } from '../utils/env.js';
15
16
 
16
17
  interface ScanResult {
17
18
  /** Total font files found on the system */
@@ -87,6 +88,12 @@ export class SystemFontRegistry {
87
88
  * @returns stats about what was found and registered
88
89
  */
89
90
  async scan(): Promise<ScanResult> {
91
+ // System font scanning requires Node.js — no-op in browser
92
+ if (!isNodeLike) {
93
+ console.warn('[vyaz] systemFontRegistry.scan() недоступен в браузере');
94
+ return { total: 0, registered: 0 };
95
+ }
96
+
90
97
  // Dynamic imports — hidden from bundler static analysis.
91
98
  // Only resolves on Node.js. No-op in browser.
92
99
  const [{ readFileSync }, getSystemFontsModule] = await Promise.all([
@@ -103,22 +110,16 @@ export class SystemFontRegistry {
103
110
  try {
104
111
  const buffer = readFileSync(fontPath);
105
112
 
106
- // Dynamic import fontkit may not be available in browser
107
- let fontkit: any;
108
- try {
109
- fontkit = await import('fontkit');
110
- // @ts-ignore fontkit CJS/ESM compatibility
111
- fontkit = fontkit.default || fontkit;
112
- } catch {
113
- // fontkit not available — skip font registration
114
- continue;
115
- }
116
-
117
- const font = fontkit.create(buffer);
118
- const family = font.familyName;
113
+ // Use FontEngine to extract family name avoids direct fontkit import
114
+ const { createFontFace } = await import('./FontEngine.js');
115
+ const font = await createFontFace(buffer);
116
+ // Access raw fontkit object for familyName — FontFace doesn't expose it directly
117
+ // but we need it to know the family before calling registerFont
118
+ const fontkit = (font as any)._raw;
119
+ const family = fontkit.familyName;
119
120
  if (!family) continue;
120
121
 
121
- const { weight, style } = parseSubfamily(font.subfamilyName || 'Regular');
122
+ const { weight, style } = parseSubfamily(fontkit.subfamilyName || 'Regular');
122
123
 
123
124
  await fontMetricsProvider.registerFont(family, { weight, style }, buffer, fontPath);
124
125
  this.registered.set(family, true);
@@ -1,5 +1,6 @@
1
1
  /**
2
2
  * Canvas API polyfill for Bun/Node.js via @napi-rs/canvas.
3
+ *
3
4
  * Required by @chenglou/pretext in server environments (Bun/Node.js without DOM).
4
5
  *
5
6
  * Two levels of polyfill:
@@ -8,7 +9,7 @@
8
9
  * 2. OffscreenCanvas — for some libraries and early versions.
9
10
  *
10
11
  * Exports enableOfficeTextMeasure / disableOfficeTextMeasure —
11
- * ctx.measureText override for fontkit-based measurements in Office mode.
12
+ * ctx.measureText override for FontEngine-based measurements in Office mode.
12
13
  *
13
14
  * ⚠️ All Node.js built-in module imports are dynamic (lazy) to avoid
14
15
  * Vite/Webpack externalization errors in browser builds.
@@ -110,7 +111,10 @@ const originalMeasureText = (globalThis as any).CanvasRenderingContext2D
110
111
  | ((text: string) => TextMetrics)
111
112
  | undefined;
112
113
 
113
- /** fontCache: Map<family_weight_style, fontkit.Font> */
114
+ /**
115
+ * fontCache: Map<family_weight_style, FontFace> where FontFace._raw holds the
116
+ * raw fontkit font object. Used by enableOfficeTextMeasure.
117
+ */
114
118
  let officeFontCache: Map<string, any> | null = null;
115
119
  let officeEnabled = false;
116
120
 
@@ -146,17 +150,19 @@ function officeMeasureText(this: any, text: string): TextMetrics {
146
150
  }
147
151
 
148
152
  const key = cacheKey(parsed.family, parsed.weight);
149
- const font = officeFontCache.get(key);
150
- if (!font) {
153
+ const fontFace = officeFontCache.get(key);
154
+ if (!fontFace) {
151
155
  return originalMeasureText?.call(this, text) ?? createEmptyMetrics();
152
156
  }
153
157
 
154
- const scale = parsed.size / font.unitsPerEm;
158
+ // FontFace._raw holds the raw fontkit font object
159
+ const raw: any = fontFace._raw;
160
+ const scale = parsed.size / fontFace.unitsPerEm;
155
161
  let totalWidth = 0;
156
162
 
157
163
  for (let i = 0; i < text.length; i++) {
158
164
  const codePoint = text.codePointAt(i)!;
159
- const glyph = font.glyphForCodePoint(codePoint);
165
+ const glyph = raw.glyphForCodePoint(codePoint);
160
166
  if (glyph) {
161
167
  totalWidth += glyph.advanceWidth * scale;
162
168
  } else {
@@ -164,7 +170,9 @@ function officeMeasureText(this: any, text: string): TextMetrics {
164
170
  if (originalMeasureText) {
165
171
  return originalMeasureText.call(this, text);
166
172
  }
167
- totalWidth += parsed.size * 0.5; // rough estimate
173
+ // Uses the same MISSING_GLYPH_FACTOR (0.5) as FontMetricsProvider
174
+ // to avoid circular dependency. Keep in sync.
175
+ totalWidth += parsed.size * 0.5;
168
176
  }
169
177
  // Skip surrogate pairs
170
178
  if (codePoint > 0xffff) i++;
@@ -206,8 +214,8 @@ export function registerCanvasFont(fontPath: string, family: string): void {
206
214
  }
207
215
 
208
216
  /**
209
- * Enable Office measurement: replaces ctx.measureText with fontkit-based version.
210
- * @param fontCache — Map key->font from FontMetricsProvider
217
+ * Enable Office measurement: replaces ctx.measureText with FontEngine-based version.
218
+ * @param fontCache — Map<family_weight_style, FontFace> from FontMetricsProvider
211
219
  */
212
220
  export function enableOfficeTextMeasure(fontCache: Map<string, any>): void {
213
221
  if (officeEnabled) return;
@@ -96,7 +96,6 @@ export type TextDecorationStyle = 'solid' | 'double' | 'dotted' | 'dashed' | 'wa
96
96
  * Text case transform.
97
97
  *
98
98
  * @see {@link https://www.w3.org/TR/css-text-3/#text-transform-property | CSS Text: text-transform}
99
- * @todo Not yet implemented in the layout engine.
100
99
  */
101
100
  export type TextTransform = 'none' | 'uppercase' | 'lowercase' | 'capitalize';
102
101
 
@@ -143,6 +142,40 @@ export type ScriptType = 'normal' | 'sub' | 'super';
143
142
  */
144
143
  export type WhiteSpace = 'normal' | 'nowrap' | 'pre';
145
144
 
145
+ /**
146
+ * Type of list marker.
147
+ *
148
+ * - `'bullet'`: unordered list (disc, circle, square).
149
+ * - `'number'`: ordered list (decimal, roman, alpha).
150
+ * - `'none'`: no list marker.
151
+ */
152
+ export type ListType = 'bullet' | 'number' | 'none';
153
+
154
+ /**
155
+ * Numbering format for ordered lists.
156
+ *
157
+ * - `'decimal'`: 1, 2, 3, ...
158
+ * - `'upper-roman'`: I, II, III, ...
159
+ * - `'lower-roman'`: i, ii, iii, ...
160
+ * - `'upper-alpha'`: A, B, C, ...
161
+ * - `'lower-alpha'`: a, b, c, ...
162
+ *
163
+ * @see {@link https://www.w3.org/TR/css-lists-3/#counter-format | CSS Lists: counter-format}
164
+ */
165
+ export type NumberFormat = 'decimal' | 'upper-roman' | 'lower-roman' | 'upper-alpha' | 'lower-alpha';
166
+
167
+ /**
168
+ * Position of the list marker relative to the text.
169
+ *
170
+ * - `'outside'`: marker hangs to the left of the text block (default). All lines
171
+ * share the same indent — the marker sits inside the indent zone.
172
+ * - `'inside'`: marker is the first inline element in the text flow, on the first
173
+ * line only.
174
+ *
175
+ * @see {@link https://www.w3.org/TR/css-lists-3/#list-style-position-property | CSS Lists: list-style-position}
176
+ */
177
+ export type ListStylePosition = 'outside' | 'inside';
178
+
146
179
  // ── Autofit ─────────────────────────────────────────────────────────────
147
180
 
148
181
  /**
@@ -248,7 +281,7 @@ export interface TextRun {
248
281
 
249
282
  // ── Text transform ────────────────────────────────────────────────
250
283
 
251
- /** Case transform (uppercase, lowercase, capitalize). @todo Not yet implemented. */
284
+ /** Case transform (uppercase, lowercase, capitalize). */
252
285
  textTransform?: TextTransform;
253
286
  /** Force full-width characters (CJK). @todo Not yet implemented. */
254
287
  fullWidth?: boolean;
@@ -281,6 +314,75 @@ export interface InlineWidget {
281
314
  baselineOffset?: number;
282
315
  }
283
316
 
317
+ // ── ListStyle (block-level list configuration) ──────────────────────────
318
+
319
+ /**
320
+ * Configuration for list markers (bullet or numbered).
321
+ *
322
+ * Applies to the paragraph via `ParagraphStyle.listStyle`.
323
+ * Nested lists are supported via the `level` field (0-based).
324
+ *
325
+ * **Width consistency:**
326
+ * For `position: 'outside'`, all lines share the same indent regardless of
327
+ * marker width. Paragraph indentation = `bulletIndent` (or default `fontSize * 1.5`).
328
+ * The marker is positioned inside that zone. If the marker text is wider than
329
+ * the indent zone, `bulletIndent` is expanded to fit the **widest marker**
330
+ * across the entire list group during layout.
331
+ *
332
+ * @see {@link https://www.w3.org/TR/css-lists-3/ | CSS Lists and Counters Module Level 3}
333
+ *
334
+ * @example
335
+ * ```ts
336
+ * // Simple bullet list
337
+ * { type: 'bullet', position: 'outside', bulletChar: '•' }
338
+ *
339
+ * // Numbered list starting at 5
340
+ * { type: 'number', numberFormat: 'decimal', startNumber: 5 }
341
+ * ```
342
+ */
343
+ export interface ListStyle {
344
+ /** List type. */
345
+ type: ListType;
346
+
347
+ /** List nesting level (0-based). 0 = top-level. */
348
+ level?: number;
349
+
350
+ /** Marker position. Defaults to `'outside'`. */
351
+ position?: ListStylePosition;
352
+
353
+ /**
354
+ * Override marker character for bullet lists.
355
+ * If not set, defaults depend on `level`:
356
+ * - level 0: `'•'` (U+2022 BULLET)
357
+ * - level 1: `'○'` (U+25CB WHITE CIRCLE)
358
+ * - level 2: `'▪'` (U+25AA BLACK SMALL SQUARE)
359
+ */
360
+ bulletChar?: string;
361
+
362
+ /** Numbering format (only used when `type === 'number'`). Defaults to `'decimal'`. */
363
+ numberFormat?: NumberFormat;
364
+
365
+ /** Starting number for numbered lists. Defaults to 1. */
366
+ startNumber?: number;
367
+
368
+ /**
369
+ * Indent in px for the marker zone.
370
+ * All lines of the paragraph share this indent (for `outside` position).
371
+ * Default: `fontSize * 1.5` (from the paragraph-level font size).
372
+ *
373
+ * For numbered lists, the engine expands this to fit the widest marker
374
+ * across the entire list group.
375
+ */
376
+ bulletIndent?: number;
377
+
378
+ /**
379
+ * Per-level indent overrides (indexed by nesting level).
380
+ * E.g. `indents[1]` is the indent for level 1 (first nested).
381
+ * Falls back to `bulletIndent * (level + 1)` if not specified.
382
+ */
383
+ indents?: number[];
384
+ }
385
+
284
386
  // ── Paragraph (block-level) ─────────────────────────────────────────────
285
387
 
286
388
  /**
@@ -357,6 +459,20 @@ export interface ParagraphStyle {
357
459
  * - `'pre'`: preserve whitespace, wrap on newline only.
358
460
  */
359
461
  whiteSpace?: WhiteSpace;
462
+
463
+ /**
464
+ * List marker configuration (bullet or numbered).
465
+ * When set, the paragraph is treated as a list item.
466
+ */
467
+ listStyle?: ListStyle;
468
+
469
+ /**
470
+ * Whether to restart numbering for this paragraph.
471
+ * Only has effect when `listStyle.type === 'number'`.
472
+ * When `true`, the auto-numbering counter resets to `listStyle.startNumber || 1`
473
+ * for this paragraph and subsequent ones in the same sequence.
474
+ */
475
+ listRestart?: boolean;
360
476
  }
361
477
 
362
478
  /**
@@ -400,13 +516,23 @@ export interface Paragraph {
400
516
  * use multiple `TextFrame` instances placed side-by-side.
401
517
  *
402
518
  * @see {@link https://www.w3.org/TR/css-multicol-1/ | CSS Multi-column Layout Level 1}
403
- * @todo Not yet implemented in the layout engine.
404
519
  */
405
520
  export interface MultiColumnConfig {
406
521
  /** Number of columns (like CSS `column-count`). */
407
522
  count: number;
408
523
  /** Gap between columns in px (like CSS `column-gap`). */
409
524
  gap: number;
525
+ /**
526
+ * Column fill strategy:
527
+ * - `'auto'`: fill columns sequentially (top-to-bottom, then next column).
528
+ * - `'balance'`: distribute lines evenly across columns.
529
+ *
530
+ * Defaults to `'auto'` when absent.
531
+ *
532
+ * @see {@link https://www.w3.org/TR/css-multicol-1/#cf | CSS Multi-column: column-fill}
533
+ * @todo `'balance'` not yet implemented.
534
+ */
535
+ fill?: 'auto' | 'balance';
410
536
  }
411
537
 
412
538
  // ── TextFrame (root container) ──────────────────────────────────────────
@@ -20,8 +20,10 @@ export interface Span {
20
20
  text: string;
21
21
  /** Index of the source run in the paragraph's `children` array. */
22
22
  itemIndex: number;
23
- /** ID of the source paragraph (for SVG grouping). */
24
- paragraphId?: string;
23
+ /** Paragraph index in TextFrame.paragraphs[]. Stable key for grouping & diff. */
24
+ pIdx: number;
25
+ /** Optional paragraph tag (set by user via Paragraph.id). */
26
+ tag?: string;
25
27
 
26
28
  /** Physical font metrics for this span */
27
29
  fontMetrics: SpanFontMetrics;
@@ -36,10 +38,15 @@ export interface Span {
36
38
  inlineWidget?: InlineWidget;
37
39
 
38
40
  /** Per-character advance widths (for selection/tracking) */
39
- glyphAdvances?: number[];
41
+ glyphAdvances?: number[] | Float32Array;
40
42
 
41
- /** Span type: 'text' — regular text, 'space' — whitespace span */
42
- type: 'text' | 'space';
43
+ /**
44
+ * Span type:
45
+ * - `'text'` — regular text
46
+ * - `'space'` — whitespace span
47
+ * - `'marker'` — list marker (bullet / number), rendered like text
48
+ */
49
+ type: 'text' | 'space' | 'marker';
43
50
 
44
51
  /**
45
52
  * Trailing whitespace flag.
@@ -74,12 +81,19 @@ export interface SpanFontMetrics {
74
81
  // ── Line (single line, formerly LineBox) ─────────────────────────────────
75
82
 
76
83
  export interface Line {
77
- /** Absolute X within container (alignment + indent) */
84
+ /**
85
+ * Absolute X of the line box left edge within the container.
86
+ * Includes outside list markers when present (marker may sit left of text).
87
+ */
78
88
  x: number;
79
89
  /** Absolute Y of line top edge */
80
90
  y: number;
81
- /** Line content width (without alignment) */
91
+ /**
92
+ * Line box width covering all spans (text + outside markers).
93
+ * Equals max(span.x + span.width) − min(span.x).
94
+ */
82
95
  width: number;
96
+
83
97
  /** Full line height (max spans × lineHeight) */
84
98
  height: number;
85
99
 
@@ -98,6 +112,9 @@ export interface Line {
98
112
  /** Paragraph alignment (optional, for PowerPoint render) */
99
113
  alignment?: TextAlignment;
100
114
 
115
+ /** Column index (0-based) when frame has multi-column layout. */
116
+ columnIndex?: number;
117
+
101
118
  spans: Span[];
102
119
  }
103
120
 
@@ -0,0 +1,18 @@
1
+ /**
2
+ * Runtime environment detection.
3
+ * Used to guard Node.js–only code paths (fontkit, fs, get-system-fonts)
4
+ * from being executed in the browser.
5
+ *
6
+ * ⚠️ Uses `globalThis.process` instead of bare `process` to avoid
7
+ * triggering bundler static analysis that could externalize the
8
+ * Node.js `process` global for browser targets.
9
+ */
10
+
11
+ const _process: any =
12
+ typeof globalThis !== 'undefined' ? (globalThis as any).process : undefined;
13
+
14
+ /** `true` when running on Node.js or Bun (has `process.versions.node`) */
15
+ export const isNodeLike: boolean =
16
+ _process != null &&
17
+ _process.versions != null &&
18
+ typeof _process.versions.node === 'string';
@@ -0,0 +1,28 @@
1
+ /**
2
+ * Font utility helpers.
3
+ *
4
+ * Provides cross‑environment helpers for loading font data in the browser
5
+ * (where there is no local filesystem).
6
+ */
7
+
8
+ /**
9
+ * Download a font file from a URL and return its bytes.
10
+ *
11
+ * Works in browser environments. In Node.js you'd typically read the file
12
+ * via `fs.readFileSync()` instead.
13
+ *
14
+ * @param fontUrl URL of the font file (.ttf, .otf, .woff, .woff2)
15
+ * @returns Font file bytes as an ArrayBuffer
16
+ */
17
+ export async function getFontBuffer(fontUrl: string): Promise<ArrayBuffer> {
18
+ const response = await fetch(fontUrl);
19
+
20
+ if (!response.ok) {
21
+ throw new Error(
22
+ `[vyaz] Failed to download font from "${fontUrl}": ${response.status} ${response.statusText}`,
23
+ );
24
+ }
25
+
26
+ const buffer = await response.arrayBuffer();
27
+ return buffer;
28
+ }