@vyaz/core 0.0.3 → 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
 
@@ -6,11 +6,13 @@
6
6
  * import { systemFontRegistry } from './SystemFontRegistry.js';
7
7
  * await systemFontRegistry.scan();
8
8
  * console.log(systemFontRegistry.getRegisteredFamilies());
9
+ *
10
+ * ⚠️ Node.js built-in module imports are dynamic (lazy) to avoid
11
+ * Vite/Webpack externalization errors in browser builds.
9
12
  */
10
13
 
11
- import { readFileSync } from 'node:fs';
12
- import getSystemFonts from 'get-system-fonts';
13
14
  import { fontMetricsProvider } from './FontMetricsProvider.js';
15
+ import { isNodeLike } from '../utils/env.js';
14
16
 
15
17
  interface ScanResult {
16
18
  /** Total font files found on the system */
@@ -86,6 +88,21 @@ export class SystemFontRegistry {
86
88
  * @returns stats about what was found and registered
87
89
  */
88
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
+
97
+ // Dynamic imports — hidden from bundler static analysis.
98
+ // Only resolves on Node.js. No-op in browser.
99
+ const [{ readFileSync }, getSystemFontsModule] = await Promise.all([
100
+ // @ts-ignore — 'node:fs' is a Node.js built-in; not resolvable with moduleResolution:bundler.
101
+ import('node:fs'),
102
+ import('get-system-fonts') as any,
103
+ ]);
104
+ const getSystemFonts = (getSystemFontsModule.default || getSystemFontsModule) as (opts?: any) => Promise<string[]>;
105
+
89
106
  const paths: string[] = await getSystemFonts();
90
107
  let registered = 0;
91
108
 
@@ -93,22 +110,16 @@ export class SystemFontRegistry {
93
110
  try {
94
111
  const buffer = readFileSync(fontPath);
95
112
 
96
- // Dynamic import fontkit may not be available in browser
97
- let fontkit: any;
98
- try {
99
- fontkit = await import('fontkit');
100
- // @ts-ignore fontkit CJS/ESM compatibility
101
- fontkit = fontkit.default || fontkit;
102
- } catch {
103
- // fontkit not available — skip font registration
104
- continue;
105
- }
106
-
107
- const font = fontkit.create(buffer);
108
- 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;
109
120
  if (!family) continue;
110
121
 
111
- const { weight, style } = parseSubfamily(font.subfamilyName || 'Regular');
122
+ const { weight, style } = parseSubfamily(fontkit.subfamilyName || 'Regular');
112
123
 
113
124
  await fontMetricsProvider.registerFont(family, { weight, style }, buffer, fontPath);
114
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,21 +9,41 @@
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.
13
+ *
14
+ * ⚠️ All Node.js built-in module imports are dynamic (lazy) to avoid
15
+ * Vite/Webpack externalization errors in browser builds.
12
16
  */
13
- import { createRequire } from 'module';
14
17
 
15
- // ⚠️ Dynamic import prevents esbuild from resolving @napi-rs/canvas at bundle time.
16
- // This module is a native Node.js addon. In the browser, Canvas APIs are already available.
18
+ // ── Lazy Node.js module loader ─────────────────────────────────────
19
+ // Dynamic import('module') hidden from bundler static analysis.
20
+ // Only resolves on Node.js / Bun. No-op in browser.
21
+ // Use 'any' for process to avoid requiring @types/node in browser contexts.
22
+ const _process: any = typeof globalThis !== 'undefined'
23
+ ? (globalThis as any).process
24
+ : undefined;
25
+
26
+ let _require: ((id: string) => any) | null = null;
17
27
  let _createCanvas: ((w: number, h: number) => any) | null = null;
18
28
 
19
- const _require = createRequire(import.meta.url);
20
- try {
21
- const mod: any = _require('@napi-rs/canvas');
22
- _createCanvas = mod.createCanvas;
23
- } catch {
24
- // Browser — document.createElement('canvas') is available natively, no polyfill needed
25
- _createCanvas = null;
29
+ async function _initNodeDeps(): Promise<void> {
30
+ try {
31
+ // @ts-ignore 'module' is a Node.js built-in; not resolvable with moduleResolution:bundler.
32
+ // This dynamic import is guarded by a runtime check and never executes in browser.
33
+ const m: any = await import('module');
34
+ _require = m.createRequire(import.meta.url);
35
+ const canvas: any = _require!('@napi-rs/canvas');
36
+ _createCanvas = canvas.createCanvas;
37
+ } catch {
38
+ // Browser or server without @napi-rs/canvas
39
+ _createCanvas = null;
40
+ }
41
+ }
42
+
43
+ // ESM top-level await — guarded by runtime check so bundlers don't
44
+ // attempt to resolve 'module' at compile time.
45
+ if (_process && (_process.versions?.node || _process.versions?.bun)) {
46
+ await _initNodeDeps();
26
47
  }
27
48
 
28
49
  // ── Polyfill document.createElement('canvas') ────────────────────────────
@@ -90,7 +111,10 @@ const originalMeasureText = (globalThis as any).CanvasRenderingContext2D
90
111
  | ((text: string) => TextMetrics)
91
112
  | undefined;
92
113
 
93
- /** 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
+ */
94
118
  let officeFontCache: Map<string, any> | null = null;
95
119
  let officeEnabled = false;
96
120
 
@@ -126,17 +150,19 @@ function officeMeasureText(this: any, text: string): TextMetrics {
126
150
  }
127
151
 
128
152
  const key = cacheKey(parsed.family, parsed.weight);
129
- const font = officeFontCache.get(key);
130
- if (!font) {
153
+ const fontFace = officeFontCache.get(key);
154
+ if (!fontFace) {
131
155
  return originalMeasureText?.call(this, text) ?? createEmptyMetrics();
132
156
  }
133
157
 
134
- 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;
135
161
  let totalWidth = 0;
136
162
 
137
163
  for (let i = 0; i < text.length; i++) {
138
164
  const codePoint = text.codePointAt(i)!;
139
- const glyph = font.glyphForCodePoint(codePoint);
165
+ const glyph = raw.glyphForCodePoint(codePoint);
140
166
  if (glyph) {
141
167
  totalWidth += glyph.advanceWidth * scale;
142
168
  } else {
@@ -144,7 +170,9 @@ function officeMeasureText(this: any, text: string): TextMetrics {
144
170
  if (originalMeasureText) {
145
171
  return originalMeasureText.call(this, text);
146
172
  }
147
- 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;
148
176
  }
149
177
  // Skip surrogate pairs
150
178
  if (codePoint > 0xffff) i++;
@@ -174,6 +202,7 @@ function createEmptyMetrics(): TextMetrics {
174
202
  * No-op in browser or when @napi-rs/canvas is not available.
175
203
  */
176
204
  export function registerCanvasFont(fontPath: string, family: string): void {
205
+ if (!_require) return; // @napi-rs/canvas not available
177
206
  try {
178
207
  const mod = _require('@napi-rs/canvas');
179
208
  if (mod?.registerFont) {
@@ -185,8 +214,8 @@ export function registerCanvasFont(fontPath: string, family: string): void {
185
214
  }
186
215
 
187
216
  /**
188
- * Enable Office measurement: replaces ctx.measureText with fontkit-based version.
189
- * @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
190
219
  */
191
220
  export function enableOfficeTextMeasure(fontCache: Map<string, any>): void {
192
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