@vyaz/core 0.0.1 → 0.0.3

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,7 +2,7 @@
2
2
  * TextFrameLayoutEngine.ts — Layout a full TextFrame (multi-paragraph).
3
3
  *
4
4
  * Pipeline:
5
- * TextFrame → Paragraph[] → paragraphLayoutEngine.layout() each → merge LineBox[]
5
+ * TextFrame → Paragraph[] → paragraphLayoutEngine.layout() each → merge Line[]
6
6
  *
7
7
  * Handles:
8
8
  * - Paragraph stacking with Y offset accumulation
@@ -10,7 +10,7 @@
10
10
  * - frame.width/height optional → fitHorizontal/fitVertical flags
11
11
  */
12
12
  import type { TextFrame } from '../types/Document.js';
13
- import type { LineBox } from '../types/LayoutTypes.js';
13
+ import type { Line } from '../types/LayoutTypes.js';
14
14
  import { paragraphLayoutEngine } from './ParagraphLayoutEngine.js';
15
15
 
16
16
  /**
@@ -21,7 +21,7 @@ import { paragraphLayoutEngine } from './ParagraphLayoutEngine.js';
21
21
  * - `'content'` → use `contentWidth` / `contentHeight`
22
22
  */
23
23
  export interface TextFrameLayoutResult {
24
- lines: LineBox[];
24
+ lines: Line[];
25
25
  /** Frame width (set when TextFrame.width was provided). */
26
26
  frameWidth?: number;
27
27
  /** Frame height (set when TextFrame.height was provided). */
@@ -40,8 +40,8 @@ export interface TextFrameLayoutResult {
40
40
  * Layout a full TextFrame by stacking paragraphs with Y offset accumulation.
41
41
  */
42
42
  export function layoutTextFrame(frame: TextFrame): TextFrameLayoutResult {
43
- const allLines: LineBox[] = [];
44
- let yOffset = 0;
43
+ const allLines: Line[] = [];
44
+ let yOffset = frame.padding?.top ?? 0;
45
45
  let contentWidth = 0;
46
46
 
47
47
  const leftPad = frame.padding?.left ?? 0;
@@ -56,6 +56,11 @@ export function layoutTextFrame(frame: TextFrame): TextFrameLayoutResult {
56
56
  ? frame.width - leftPad - rightPad
57
57
  : Infinity;
58
58
 
59
+ // If wrap is disabled, force no-wrap on the paragraph
60
+ if (frame.wrap === false) {
61
+ p.style = { ...p.style, whiteSpace: 'nowrap' };
62
+ }
63
+
59
64
  const result = paragraphLayoutEngine.layout(p, maxWidth, yOffset);
60
65
 
61
66
  for (const line of result.lines) {
@@ -83,4 +88,4 @@ export function layoutTextFrame(frame: TextFrame): TextFrameLayoutResult {
83
88
  fitHorizontal: frame.width !== undefined ? 'frame' : 'content',
84
89
  fitVertical: frame.height !== undefined ? 'frame' : 'content',
85
90
  };
86
- }
91
+ }
@@ -12,11 +12,43 @@
12
12
  */
13
13
 
14
14
  import type { FontMetrics, IFontMetricsProvider } from '../types/FontTypes.js';
15
- import { enableOfficeTextMeasure, disableOfficeTextMeasure } from './canvas-polyfill.js';
15
+ import { enableOfficeTextMeasure, disableOfficeTextMeasure, registerCanvasFont } from './canvas-polyfill.js';
16
+ import { FontNotFoundError } from './FontNotFoundError.js';
16
17
 
17
- /** Cache key: "${family}_${weight}_${style}" */
18
+ // ── Weight normalisation ─────────────────────────────────────────────────
19
+
20
+ /**
21
+ * Map named font weights to numeric strings.
22
+ * Both directions work: 'bold' → '700', 700 → '700', '700' → '700'.
23
+ */
24
+ const WEIGHT_TO_NUM: Record<string, string> = {
25
+ thin: '100',
26
+ hairline: '100',
27
+ ultralight: '200',
28
+ extralight: '200',
29
+ light: '300',
30
+ normal: '400',
31
+ medium: '500',
32
+ semibold: '600',
33
+ demibold: '600',
34
+ bold: '700',
35
+ ultrabold: '800',
36
+ extrabold: '800',
37
+ heavy: '900',
38
+ black: '900',
39
+ };
40
+
41
+ /** Normalise weight to a numeric string ("400", "700", etc.). */
42
+ function normaliseWeight(weight: string): string {
43
+ const lower = weight.toLowerCase();
44
+ const mapped = WEIGHT_TO_NUM[lower];
45
+ if (mapped) return mapped;
46
+ return weight; // already numeric or unrecognised — pass through
47
+ }
48
+
49
+ /** Cache key: "${family}_${weight}_${style}" with normalised weight. */
18
50
  function cacheKey(family: string, weight: string, style: string): string {
19
- return `${family}_${weight}_${style}`;
51
+ return `${family}_${normaliseWeight(weight)}_${style}`;
20
52
  }
21
53
 
22
54
  export class FontMetricsProvider implements IFontMetricsProvider {
@@ -48,12 +80,15 @@ export class FontMetricsProvider implements IFontMetricsProvider {
48
80
 
49
81
  /**
50
82
  * Register a binary font for use with fontkit.
51
- * In browser — no-op.
83
+ * In browser — no-op (fonts are registered via CSS @font-face).
84
+ *
85
+ * @param sourcePath — if provided, also registers with @napi-rs/canvas for Node.js canvas measureText
52
86
  */
53
87
  async registerFont(
54
88
  family: string,
55
89
  options: { weight?: string; style?: string },
56
90
  source: string | Buffer,
91
+ sourcePath?: string,
57
92
  ): Promise<void> {
58
93
  try {
59
94
  // Dynamic ESM import — fontkit may not be available in browser
@@ -70,6 +105,11 @@ export class FontMetricsProvider implements IFontMetricsProvider {
70
105
  this.cache.set(key, font);
71
106
  // Invalidate metrics for this font
72
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
+ }
73
113
  } catch {
74
114
  // fontkit not available (browser) — no-op
75
115
  }
@@ -150,7 +190,12 @@ export class FontMetricsProvider implements IFontMetricsProvider {
150
190
  return metrics;
151
191
  }
152
192
 
153
- // Strategy 2: Canvas TextMetrics (browser)
193
+ // Strategy 2: If fontkit cache is non-empty, fontkit is available but font not found → throw
194
+ if (this.cache.size > 0) {
195
+ throw new FontNotFoundError(fontFamily, weight, style);
196
+ }
197
+
198
+ // Strategy 3: Canvas TextMetrics (browser, fallback when fontkit unavailable)
154
199
  if (typeof document !== 'undefined') {
155
200
  try {
156
201
  const canvas = document.createElement('canvas');
@@ -172,17 +217,8 @@ export class FontMetricsProvider implements IFontMetricsProvider {
172
217
  }
173
218
  }
174
219
 
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;
220
+ // Font not found — throw error with clear message
221
+ throw new FontNotFoundError(fontFamily, weight, style);
186
222
  }
187
223
  }
188
224
 
@@ -0,0 +1,16 @@
1
+ /**
2
+ * FontNotFoundError.ts — thrown when a requested font is not registered.
3
+ */
4
+ export class FontNotFoundError extends Error {
5
+ constructor(
6
+ family: string,
7
+ weight = 'normal',
8
+ style = 'normal',
9
+ ) {
10
+ super(
11
+ `Font not found: "${family}" (weight: ${weight}, style: ${style}). ` +
12
+ `Use SystemFontRegistry.scan() to register system fonts.`,
13
+ );
14
+ this.name = 'FontNotFoundError';
15
+ }
16
+ }
@@ -0,0 +1,141 @@
1
+ /**
2
+ * SystemFontRegistry.ts — singleton that scans system fonts using `get-system-fonts`
3
+ * and registers them in FontMetricsProvider for both fontkit and @napi-rs/canvas.
4
+ *
5
+ * Usage:
6
+ * import { systemFontRegistry } from './SystemFontRegistry.js';
7
+ * await systemFontRegistry.scan();
8
+ * console.log(systemFontRegistry.getRegisteredFamilies());
9
+ */
10
+
11
+ import { readFileSync } from 'node:fs';
12
+ import getSystemFonts from 'get-system-fonts';
13
+ import { fontMetricsProvider } from './FontMetricsProvider.js';
14
+
15
+ interface ScanResult {
16
+ /** Total font files found on the system */
17
+ total: number;
18
+ /** Number of successfully registered font files */
19
+ registered: number;
20
+ }
21
+
22
+ /**
23
+ * Parse font subfamily name into weight/style.
24
+ * Examples:
25
+ * "Regular" → { weight: 'normal', style: 'normal' }
26
+ * "Bold" → { weight: 'bold', style: 'normal' }
27
+ * "Italic" → { weight: 'normal', style: 'italic' }
28
+ * "Bold Italic" → { weight: 'bold', style: 'italic' }
29
+ * "Light" → { weight: 'light', style: 'normal' }
30
+ * "Medium" → { weight: 'medium', style: 'normal' }
31
+ * "Semi Bold" → { weight: 'semibold', style: 'normal' }
32
+ * "Black" → { weight: 'black', style: 'normal' }
33
+ */
34
+ function parseSubfamily(subfamily: string): { weight: string; style: string } {
35
+ const lower = subfamily.toLowerCase();
36
+
37
+ let style: string;
38
+ if (lower.includes('italic')) {
39
+ style = 'italic';
40
+ } else {
41
+ style = 'normal';
42
+ }
43
+
44
+ let weight: string;
45
+ if (lower.includes('thin') || lower.includes('hairline')) {
46
+ weight = 'thin';
47
+ } else if (lower.includes('extralight') || lower.includes('ultralight')) {
48
+ weight = 'extralight';
49
+ } else if (lower.includes('light')) {
50
+ weight = 'light';
51
+ } else if (lower.includes('semibold') || lower.includes('demibold')) {
52
+ weight = 'semibold';
53
+ } else if (lower.includes('bold') || lower.includes('heavy') || lower.includes('black')) {
54
+ weight = 'bold';
55
+ } else if (lower.includes('medium') || lower.includes('medium')) {
56
+ weight = 'medium';
57
+ } else {
58
+ weight = 'normal';
59
+ }
60
+
61
+ return { weight, style };
62
+ }
63
+
64
+ export class SystemFontRegistry {
65
+ private static _instance: SystemFontRegistry;
66
+
67
+ /** Map of registered family names → true */
68
+ private registered = new Map<string, true>();
69
+
70
+ private constructor() {}
71
+
72
+ static get instance(): SystemFontRegistry {
73
+ if (!SystemFontRegistry._instance) {
74
+ SystemFontRegistry._instance = new SystemFontRegistry();
75
+ }
76
+ return SystemFontRegistry._instance;
77
+ }
78
+
79
+ /**
80
+ * Scan the system for all fonts and register them in FontMetricsProvider.
81
+ *
82
+ * - Uses `get-system-fonts` to find all .ttf/.otf files
83
+ * - Opens each with fontkit to extract familyName/subfamilyName
84
+ * - Registers in fontMetricsProvider (fontkit buffer + canvas path)
85
+ *
86
+ * @returns stats about what was found and registered
87
+ */
88
+ async scan(): Promise<ScanResult> {
89
+ const paths: string[] = await getSystemFonts();
90
+ let registered = 0;
91
+
92
+ for (const fontPath of paths) {
93
+ try {
94
+ const buffer = readFileSync(fontPath);
95
+
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;
109
+ if (!family) continue;
110
+
111
+ const { weight, style } = parseSubfamily(font.subfamilyName || 'Regular');
112
+
113
+ await fontMetricsProvider.registerFont(family, { weight, style }, buffer, fontPath);
114
+ this.registered.set(family, true);
115
+ registered++;
116
+ } catch {
117
+ // Skip unreadable / invalid font files
118
+ continue;
119
+ }
120
+ }
121
+
122
+ return { total: paths.length, registered };
123
+ }
124
+
125
+ /**
126
+ * Check if a font family is registered.
127
+ */
128
+ isRegistered(family: string): boolean {
129
+ return this.registered.has(family);
130
+ }
131
+
132
+ /**
133
+ * Get list of all registered font families.
134
+ */
135
+ getRegisteredFamilies(): string[] {
136
+ return Array.from(this.registered.keys());
137
+ }
138
+ }
139
+
140
+ /** Singleton instance */
141
+ export const systemFontRegistry = SystemFontRegistry.instance;
@@ -10,13 +10,15 @@
10
10
  * Exports enableOfficeTextMeasure / disableOfficeTextMeasure —
11
11
  * ctx.measureText override for fontkit-based measurements in Office mode.
12
12
  */
13
+ import { createRequire } from 'module';
14
+
13
15
  // ⚠️ Dynamic import — prevents esbuild from resolving @napi-rs/canvas at bundle time.
14
16
  // This module is a native Node.js addon. In the browser, Canvas APIs are already available.
15
17
  let _createCanvas: ((w: number, h: number) => any) | null = null;
16
18
 
19
+ const _require = createRequire(import.meta.url);
17
20
  try {
18
- // Dynamic require kept as eval to avoid esbuild static analysis
19
- const mod: any = (Function('return require("@napi-rs/canvas")'))();
21
+ const mod: any = _require('@napi-rs/canvas');
20
22
  _createCanvas = mod.createCanvas;
21
23
  } catch {
22
24
  // Browser — document.createElement('canvas') is available natively, no polyfill needed
@@ -28,7 +30,18 @@ try {
28
30
  // to create a temporary Canvas and obtain a 2d context for measureText.
29
31
  // Without this polyfill, prepare() crashes with "document is not defined" in Bun.
30
32
 
31
- if (typeof globalThis.document === 'undefined') {
33
+ function needsCanvasPolyfill(): boolean {
34
+ if (typeof globalThis.document === 'undefined') return true;
35
+ try {
36
+ const el = globalThis.document.createElement('canvas');
37
+ if (!el || typeof el.getContext !== 'function') return true;
38
+ return false;
39
+ } catch {
40
+ return true;
41
+ }
42
+ }
43
+
44
+ if (needsCanvasPolyfill()) {
32
45
  (globalThis as any).document = {
33
46
  createElement: (tag: string) => {
34
47
  if (tag === 'canvas' && _createCanvas) return _createCanvas(1, 1);
@@ -156,6 +169,21 @@ function createEmptyMetrics(): TextMetrics {
156
169
  return createMetricsObject(0);
157
170
  }
158
171
 
172
+ /**
173
+ * Register a font with @napi-rs/canvas so ctx.measureText() works in Node.js.
174
+ * No-op in browser or when @napi-rs/canvas is not available.
175
+ */
176
+ export function registerCanvasFont(fontPath: string, family: string): void {
177
+ try {
178
+ const mod = _require('@napi-rs/canvas');
179
+ if (mod?.registerFont) {
180
+ mod.registerFont(fontPath, { family });
181
+ }
182
+ } catch {
183
+ // @napi-rs/canvas not available — no-op
184
+ }
185
+ }
186
+
159
187
  /**
160
188
  * Enable Office measurement: replaces ctx.measureText with fontkit-based version.
161
189
  * @param fontCache — Map key->font from FontMetricsProvider
@@ -45,11 +45,14 @@ export interface IFontMetricsProvider {
45
45
  /**
46
46
  * Register a binary font for use with fontkit.
47
47
  * In browser — no-op (fonts are registered via CSS @font-face).
48
+ *
49
+ * @param sourcePath — path to .ttf/.otf file for optional @napi-rs/canvas.registerFont()
48
50
  */
49
51
  registerFont(
50
52
  family: string,
51
53
  options: { weight?: string; style?: string },
52
54
  source: string | Buffer,
55
+ sourcePath?: string,
53
56
  ): void;
54
57
 
55
58
  /**
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * LayoutTypes.ts — output types (Physical Box Model).
3
3
  *
4
- * ParagraphLayoutResult → LineBox[] → FragmentBox[]
4
+ * ParagraphLayoutResult → Line[] → Span[]
5
5
  * This is the contract between layout engine and renderers.
6
6
  *
7
7
  * Based on plan.md §2.5 (Output — Physical Box Model / Layout Tree)
@@ -9,22 +9,22 @@
9
9
 
10
10
  import type { TextRun, InlineWidget, TextAlignment } from './Document.js';
11
11
 
12
- // ── FragmentBox (render atom) ─────────────────────────────────────────────
12
+ // ── Span (render atom, formerly FragmentBox) ─────────────────────────────
13
13
 
14
- export interface FragmentBox {
15
- /** Offset from LineBox.x */
14
+ export interface Span {
15
+ /** Offset from Line.x */
16
16
  x: number;
17
- /** Physical fragment width */
17
+ /** Physical span width */
18
18
  width: number;
19
- /** Fragment text (or " " for justify spaces) */
19
+ /** Span text (or " " for justify spaces) */
20
20
  text: string;
21
21
  /** Index of the source run in the paragraph's `children` array. */
22
22
  itemIndex: number;
23
23
  /** ID of the source paragraph (for SVG grouping). */
24
24
  paragraphId?: string;
25
25
 
26
- /** Physical font metrics for this fragment */
27
- fontMetrics: FragmentFontMetrics;
26
+ /** Physical font metrics for this span */
27
+ fontMetrics: SpanFontMetrics;
28
28
 
29
29
  /**
30
30
  * A snapshot of the source run's style at layout time.
@@ -32,20 +32,20 @@ export interface FragmentBox {
32
32
  */
33
33
  style: TextRun;
34
34
 
35
- /** InlineWidget data (if fragment is an inline-box) */
35
+ /** InlineWidget data (if span is an inline-box) */
36
36
  inlineWidget?: InlineWidget;
37
37
 
38
38
  /** Per-character advance widths (for selection/tracking) */
39
39
  glyphAdvances?: number[];
40
40
 
41
- /** Fragment type: 'text' — regular text, 'space' — whitespace fragment */
41
+ /** Span type: 'text' — regular text, 'space' — whitespace span */
42
42
  type: 'text' | 'space';
43
43
 
44
44
  /**
45
45
  * Trailing whitespace flag.
46
- * - true: fragment is at end of line, does not participate in line advance
46
+ * - true: span is at end of line, does not participate in line advance
47
47
  * and is not stretched during justify (zero width for calculations).
48
- * - undefined/false: regular fragment.
48
+ * - undefined/false: regular span.
49
49
  *
50
50
  * See CSS Text Module Level 3 §4.1.3 (Tracking and Dropping Spaces)
51
51
  * and Parley LineItemData::has_trailing_whitespace.
@@ -53,7 +53,7 @@ export interface FragmentBox {
53
53
  trailing?: boolean;
54
54
 
55
55
  /**
56
- * Line break mode after this fragment.
56
+ * Line break mode after this span.
57
57
  * 'soft' — soft line break (insufficient space)
58
58
  * 'hard' — forced break (\n, explicit separator)
59
59
  * undefined — not end of line
@@ -61,22 +61,26 @@ export interface FragmentBox {
61
61
  breakType?: 'soft' | 'hard';
62
62
  }
63
63
 
64
- export interface FragmentFontMetrics {
64
+ export interface SpanFontMetrics {
65
65
  ascent: number;
66
66
  descent: number;
67
67
  fontSize: number;
68
+ /** Vertical offset from baseline (px). Used for sub/superscript positioning.
69
+ * Negative = above baseline (superscript). Positive = below baseline (subscript).
70
+ * Undefined or 0 = normal baseline position. */
71
+ baselineOffset?: number;
68
72
  }
69
73
 
70
- // ── LineBox (single line) ────────────────────────────────────────────────
74
+ // ── Line (single line, formerly LineBox) ─────────────────────────────────
71
75
 
72
- export interface LineBox {
76
+ export interface Line {
73
77
  /** Absolute X within container (alignment + indent) */
74
78
  x: number;
75
79
  /** Absolute Y of line top edge */
76
80
  y: number;
77
81
  /** Line content width (without alignment) */
78
82
  width: number;
79
- /** Full line height (max fragments × lineHeight) */
83
+ /** Full line height (max spans × lineHeight) */
80
84
  height: number;
81
85
 
82
86
  /** Baseline offset from y */
@@ -94,7 +98,7 @@ export interface LineBox {
94
98
  /** Paragraph alignment (optional, for PowerPoint render) */
95
99
  alignment?: TextAlignment;
96
100
 
97
- fragments: FragmentBox[];
101
+ spans: Span[];
98
102
  }
99
103
 
100
104
  // ── ParagraphLayoutResult (single paragraph) ─────────────────────────────
@@ -102,7 +106,7 @@ export interface LineBox {
102
106
  export interface ParagraphLayoutResult {
103
107
  width: number; // paragraph width (maxWidth)
104
108
  height: number; // full paragraph height including spacing
105
- lines: LineBox[];
109
+ lines: Line[];
106
110
  /** Actual content width (text bbox, without voids) */
107
111
  contentWidth: number;
108
112
  /** Actual content height (text bbox) */
@@ -111,7 +115,7 @@ export interface ParagraphLayoutResult {
111
115
 
112
116
  // ── Text region for YAML snapshots ───────────────────────────────────────
113
117
 
114
- /** Semantic fragment for snapshots (without physical metrics) */
118
+ /** Semantic span for snapshots (without physical metrics) */
115
119
  export interface SemanticFragment {
116
120
  text: string;
117
121
  x: number;
@@ -133,4 +137,5 @@ export interface SemanticParagraph {
133
137
  width: number;
134
138
  height: number;
135
139
  lines: SemanticLine[];
136
- }
140
+ }
141
+