@vyaz/core 0.0.5 → 0.0.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/compile/DocumentCompiler.d.ts +40 -0
- package/dist/index.browser.d.ts +28 -0
- package/dist/index.browser.js +18 -0
- package/dist/index.d.ts +30 -0
- package/dist/index.js +11 -147930
- package/dist/layout/AutoFitEngine.d.ts +44 -0
- package/dist/layout/LineBoxValidator.d.ts +25 -0
- package/dist/layout/ParagraphLayoutEngine.d.ts +46 -0
- package/dist/layout/PositioningEngine.d.ts +68 -0
- package/dist/layout/TextFrameLayoutEngine.d.ts +50 -0
- package/{src/layout/estimateWidth.ts → dist/layout/estimateWidth.d.ts} +2 -40
- package/dist/measure/FontEngine.d.ts +47 -0
- package/dist/measure/FontMetricsProvider.d.ts +49 -0
- package/dist/measure/FontNotFoundError.d.ts +6 -0
- package/dist/measure/SystemFontRegistry.d.ts +46 -0
- package/dist/measure/canvas-polyfill.d.ts +30 -0
- package/dist/types/Document.d.ts +593 -0
- package/dist/types/FontTypes.d.ts +61 -0
- package/dist/types/LayoutTypes.d.ts +128 -0
- package/{src/utils/env.ts → dist/utils/env.d.ts} +1 -8
- package/{src/utils/font.ts → dist/utils/font.d.ts} +1 -13
- package/{src/utils/groupLinesByParagraph.ts → dist/utils/groupLinesByParagraph.d.ts} +7 -37
- package/dist/utils/list.d.ts +41 -0
- package/dist/utils/textTransform.d.ts +32 -0
- package/package.json +13 -13
- package/src/compile/DocumentCompiler.ts +0 -144
- package/src/index.browser.ts +0 -90
- package/src/index.ts +0 -97
- package/src/layout/AutoFitEngine.ts +0 -101
- package/src/layout/LineBoxValidator.ts +0 -162
- package/src/layout/ParagraphLayoutEngine.ts +0 -264
- package/src/layout/PositioningEngine.ts +0 -615
- package/src/layout/TextFrameLayoutEngine.ts +0 -363
- package/src/measure/FontEngine.ts +0 -144
- package/src/measure/FontMetricsProvider.ts +0 -224
- package/src/measure/FontNotFoundError.ts +0 -16
- package/src/measure/SystemFontRegistry.ts +0 -152
- package/src/measure/canvas-polyfill.d.ts +0 -6
- package/src/measure/canvas-polyfill.ts +0 -243
- package/src/measure/fontkit.d.ts +0 -44
- package/src/types/Document.ts +0 -666
- package/src/types/FontTypes.ts +0 -74
- package/src/types/LayoutTypes.ts +0 -158
- package/src/utils/list.ts +0 -107
- package/src/utils/textTransform.ts +0 -96
|
@@ -1,224 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* FontMetricsProvider.ts — isomorphic font metrics provider.
|
|
3
|
-
*
|
|
4
|
-
* Strategy (priority):
|
|
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)
|
|
9
|
-
*
|
|
10
|
-
* Uses FontEngine as the single entry point for all fontkit operations.
|
|
11
|
-
*/
|
|
12
|
-
|
|
13
|
-
import type { FontMetrics, IFontMetricsProvider } from '../types/FontTypes.js';
|
|
14
|
-
import type { FontFace } from './FontEngine.js';
|
|
15
|
-
import { enableOfficeTextMeasure, disableOfficeTextMeasure } from './canvas-polyfill.js';
|
|
16
|
-
import { FontNotFoundError } from './FontNotFoundError.js';
|
|
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
|
-
|
|
27
|
-
// ── Weight normalisation ─────────────────────────────────────────────────
|
|
28
|
-
|
|
29
|
-
/**
|
|
30
|
-
* Map named font weights to numeric strings.
|
|
31
|
-
* Both directions work: 'bold' → '700', 700 → '700', '700' → '700'.
|
|
32
|
-
*/
|
|
33
|
-
const WEIGHT_TO_NUM: Record<string, string> = {
|
|
34
|
-
thin: '100',
|
|
35
|
-
hairline: '100',
|
|
36
|
-
ultralight: '200',
|
|
37
|
-
extralight: '200',
|
|
38
|
-
light: '300',
|
|
39
|
-
normal: '400',
|
|
40
|
-
medium: '500',
|
|
41
|
-
semibold: '600',
|
|
42
|
-
demibold: '600',
|
|
43
|
-
bold: '700',
|
|
44
|
-
ultrabold: '800',
|
|
45
|
-
extrabold: '800',
|
|
46
|
-
heavy: '900',
|
|
47
|
-
black: '900',
|
|
48
|
-
};
|
|
49
|
-
|
|
50
|
-
/** Normalise weight to a numeric string ("400", "700", etc.). */
|
|
51
|
-
function normaliseWeight(weight: string): string {
|
|
52
|
-
const lower = weight.toLowerCase();
|
|
53
|
-
const mapped = WEIGHT_TO_NUM[lower];
|
|
54
|
-
if (mapped) return mapped;
|
|
55
|
-
return weight; // already numeric or unrecognised — pass through
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
/** Cache key: "${family}_${weight}_${style}" with normalised weight. */
|
|
59
|
-
function cacheKey(family: string, weight: string, style: string): string {
|
|
60
|
-
return `${family}_${normaliseWeight(weight)}_${style}`;
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
export class FontMetricsProvider implements IFontMetricsProvider {
|
|
64
|
-
/** Map<string, FontFace> — font engine font face cache */
|
|
65
|
-
private cache = new Map<string, FontFace>();
|
|
66
|
-
private metricsCache = new Map<string, FontMetrics>();
|
|
67
|
-
private mode: 'browser' | 'office' = 'browser';
|
|
68
|
-
|
|
69
|
-
// ── Mode ──────────────────────────────────────────────────────────
|
|
70
|
-
|
|
71
|
-
setMode(mode: 'browser' | 'office'): void {
|
|
72
|
-
if (this.mode === mode) return;
|
|
73
|
-
this.mode = mode;
|
|
74
|
-
// Invalidate metrics cache on mode change
|
|
75
|
-
this.metricsCache.clear();
|
|
76
|
-
|
|
77
|
-
// Toggle ctx.measureText for pretext (canvas-based line breaking)
|
|
78
|
-
if (mode === 'office') {
|
|
79
|
-
enableOfficeTextMeasure(this.cache); // fontkit-based hmtx advance widths
|
|
80
|
-
} else {
|
|
81
|
-
disableOfficeTextMeasure(); // original Canvas 2D measureText
|
|
82
|
-
}
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
getMode(): 'browser' | 'office' {
|
|
86
|
-
return this.mode;
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
// ── FontRegistry ──────────────────────────────────────────────────
|
|
90
|
-
|
|
91
|
-
/**
|
|
92
|
-
* Register a binary font for use with fontkit.
|
|
93
|
-
*
|
|
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)
|
|
100
|
-
*/
|
|
101
|
-
async registerFont(
|
|
102
|
-
family: string,
|
|
103
|
-
options: { weight?: string; style?: string },
|
|
104
|
-
source: string | ArrayBuffer | Uint8Array,
|
|
105
|
-
sourcePath?: string,
|
|
106
|
-
): Promise<void> {
|
|
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);
|
|
129
|
-
}
|
|
130
|
-
}
|
|
131
|
-
|
|
132
|
-
// ── Font object access (for per-glyph advance) ────────────────────
|
|
133
|
-
|
|
134
|
-
/**
|
|
135
|
-
* Get font engine FontFace object for per-character calculations.
|
|
136
|
-
* Returns undefined if font is not registered.
|
|
137
|
-
*/
|
|
138
|
-
getFont(family: string, weight = 'normal', style = 'normal'): FontFace | undefined {
|
|
139
|
-
const key = cacheKey(family, weight, style);
|
|
140
|
-
return this.cache.get(key);
|
|
141
|
-
}
|
|
142
|
-
|
|
143
|
-
// ── Metrics retrieval ─────────────────────────────────────────────
|
|
144
|
-
|
|
145
|
-
getMetrics(
|
|
146
|
-
fontFamily: string,
|
|
147
|
-
fontSize: number,
|
|
148
|
-
weight = 'normal',
|
|
149
|
-
style = 'normal',
|
|
150
|
-
): FontMetrics {
|
|
151
|
-
const key = cacheKey(fontFamily, weight, style);
|
|
152
|
-
|
|
153
|
-
// Metrics cache (depends on fontSize, so include in key)
|
|
154
|
-
const metricsKey = `${key}_${fontSize}_${this.mode}`;
|
|
155
|
-
const cached = this.metricsCache.get(metricsKey);
|
|
156
|
-
if (cached) return cached;
|
|
157
|
-
|
|
158
|
-
// Strategy 1: FontEngine (fontkit)
|
|
159
|
-
const font = this.cache.get(key);
|
|
160
|
-
|
|
161
|
-
if (font) {
|
|
162
|
-
// Inline computePixelMetrics to avoid circular ESM import
|
|
163
|
-
const scale = fontSize / font.unitsPerEm;
|
|
164
|
-
|
|
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';
|
|
173
|
-
} else {
|
|
174
|
-
ascent = font.ascent * scale;
|
|
175
|
-
descent = Math.abs(font.descent) * scale;
|
|
176
|
-
sourceTable = 'hhea';
|
|
177
|
-
}
|
|
178
|
-
|
|
179
|
-
const metrics: FontMetrics = {
|
|
180
|
-
ascent,
|
|
181
|
-
descent,
|
|
182
|
-
capHeight: (font.capHeight ?? font.ascent) * scale,
|
|
183
|
-
unitsPerEm: font.unitsPerEm,
|
|
184
|
-
sourceTable,
|
|
185
|
-
};
|
|
186
|
-
|
|
187
|
-
this.metricsCache.set(metricsKey, metrics);
|
|
188
|
-
return metrics;
|
|
189
|
-
}
|
|
190
|
-
|
|
191
|
-
// Strategy 2: If font cache is non-empty, fontkit is available but font not found → throw
|
|
192
|
-
if (this.cache.size > 0) {
|
|
193
|
-
throw new FontNotFoundError(fontFamily, weight, style);
|
|
194
|
-
}
|
|
195
|
-
|
|
196
|
-
// Strategy 3: Canvas TextMetrics (browser, fallback when fontkit unavailable)
|
|
197
|
-
if (typeof document !== 'undefined') {
|
|
198
|
-
try {
|
|
199
|
-
const canvas = document.createElement('canvas');
|
|
200
|
-
const ctx = canvas.getContext('2d')!;
|
|
201
|
-
ctx.font = `${style} ${weight} ${fontSize}px ${fontFamily}`;
|
|
202
|
-
const m = ctx.measureText('M');
|
|
203
|
-
|
|
204
|
-
const metrics: FontMetrics = {
|
|
205
|
-
ascent: m.fontBoundingBoxAscent || fontSize * 0.85,
|
|
206
|
-
descent: m.fontBoundingBoxDescent || fontSize * 0.15,
|
|
207
|
-
capHeight: m.actualBoundingBoxAscent || fontSize * 0.7,
|
|
208
|
-
unitsPerEm: 1000,
|
|
209
|
-
sourceTable: 'canvas',
|
|
210
|
-
};
|
|
211
|
-
this.metricsCache.set(metricsKey, metrics);
|
|
212
|
-
return metrics;
|
|
213
|
-
} catch {
|
|
214
|
-
// Fall through to throw
|
|
215
|
-
}
|
|
216
|
-
}
|
|
217
|
-
|
|
218
|
-
// Font not found — throw error with clear message
|
|
219
|
-
throw new FontNotFoundError(fontFamily, weight, style);
|
|
220
|
-
}
|
|
221
|
-
}
|
|
222
|
-
|
|
223
|
-
/** Singleton */
|
|
224
|
-
export const fontMetricsProvider = new FontMetricsProvider();
|
|
@@ -1,16 +0,0 @@
|
|
|
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
|
-
}
|
|
@@ -1,152 +0,0 @@
|
|
|
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
|
-
* ⚠️ Node.js built-in module imports are dynamic (lazy) to avoid
|
|
11
|
-
* Vite/Webpack externalization errors in browser builds.
|
|
12
|
-
*/
|
|
13
|
-
|
|
14
|
-
import { fontMetricsProvider } from './FontMetricsProvider.js';
|
|
15
|
-
import { isNodeLike } from '../utils/env.js';
|
|
16
|
-
|
|
17
|
-
interface ScanResult {
|
|
18
|
-
/** Total font files found on the system */
|
|
19
|
-
total: number;
|
|
20
|
-
/** Number of successfully registered font files */
|
|
21
|
-
registered: number;
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
/**
|
|
25
|
-
* Parse font subfamily name into weight/style.
|
|
26
|
-
* Examples:
|
|
27
|
-
* "Regular" → { weight: 'normal', style: 'normal' }
|
|
28
|
-
* "Bold" → { weight: 'bold', style: 'normal' }
|
|
29
|
-
* "Italic" → { weight: 'normal', style: 'italic' }
|
|
30
|
-
* "Bold Italic" → { weight: 'bold', style: 'italic' }
|
|
31
|
-
* "Light" → { weight: 'light', style: 'normal' }
|
|
32
|
-
* "Medium" → { weight: 'medium', style: 'normal' }
|
|
33
|
-
* "Semi Bold" → { weight: 'semibold', style: 'normal' }
|
|
34
|
-
* "Black" → { weight: 'black', style: 'normal' }
|
|
35
|
-
*/
|
|
36
|
-
function parseSubfamily(subfamily: string): { weight: string; style: string } {
|
|
37
|
-
const lower = subfamily.toLowerCase();
|
|
38
|
-
|
|
39
|
-
let style: string;
|
|
40
|
-
if (lower.includes('italic')) {
|
|
41
|
-
style = 'italic';
|
|
42
|
-
} else {
|
|
43
|
-
style = 'normal';
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
let weight: string;
|
|
47
|
-
if (lower.includes('thin') || lower.includes('hairline')) {
|
|
48
|
-
weight = 'thin';
|
|
49
|
-
} else if (lower.includes('extralight') || lower.includes('ultralight')) {
|
|
50
|
-
weight = 'extralight';
|
|
51
|
-
} else if (lower.includes('light')) {
|
|
52
|
-
weight = 'light';
|
|
53
|
-
} else if (lower.includes('semibold') || lower.includes('demibold')) {
|
|
54
|
-
weight = 'semibold';
|
|
55
|
-
} else if (lower.includes('bold') || lower.includes('heavy') || lower.includes('black')) {
|
|
56
|
-
weight = 'bold';
|
|
57
|
-
} else if (lower.includes('medium') || lower.includes('medium')) {
|
|
58
|
-
weight = 'medium';
|
|
59
|
-
} else {
|
|
60
|
-
weight = 'normal';
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
return { weight, style };
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
export class SystemFontRegistry {
|
|
67
|
-
private static _instance: SystemFontRegistry;
|
|
68
|
-
|
|
69
|
-
/** Map of registered family names → true */
|
|
70
|
-
private registered = new Map<string, true>();
|
|
71
|
-
|
|
72
|
-
private constructor() {}
|
|
73
|
-
|
|
74
|
-
static get instance(): SystemFontRegistry {
|
|
75
|
-
if (!SystemFontRegistry._instance) {
|
|
76
|
-
SystemFontRegistry._instance = new SystemFontRegistry();
|
|
77
|
-
}
|
|
78
|
-
return SystemFontRegistry._instance;
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
/**
|
|
82
|
-
* Scan the system for all fonts and register them in FontMetricsProvider.
|
|
83
|
-
*
|
|
84
|
-
* - Uses `get-system-fonts` to find all .ttf/.otf files
|
|
85
|
-
* - Opens each with fontkit to extract familyName/subfamilyName
|
|
86
|
-
* - Registers in fontMetricsProvider (fontkit buffer + canvas path)
|
|
87
|
-
*
|
|
88
|
-
* @returns stats about what was found and registered
|
|
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
|
-
|
|
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
|
-
|
|
106
|
-
const paths: string[] = await getSystemFonts();
|
|
107
|
-
let registered = 0;
|
|
108
|
-
|
|
109
|
-
for (const fontPath of paths) {
|
|
110
|
-
try {
|
|
111
|
-
const buffer = readFileSync(fontPath);
|
|
112
|
-
|
|
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;
|
|
120
|
-
if (!family) continue;
|
|
121
|
-
|
|
122
|
-
const { weight, style } = parseSubfamily(fontkit.subfamilyName || 'Regular');
|
|
123
|
-
|
|
124
|
-
await fontMetricsProvider.registerFont(family, { weight, style }, buffer, fontPath);
|
|
125
|
-
this.registered.set(family, true);
|
|
126
|
-
registered++;
|
|
127
|
-
} catch {
|
|
128
|
-
// Skip unreadable / invalid font files
|
|
129
|
-
continue;
|
|
130
|
-
}
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
return { total: paths.length, registered };
|
|
134
|
-
}
|
|
135
|
-
|
|
136
|
-
/**
|
|
137
|
-
* Check if a font family is registered.
|
|
138
|
-
*/
|
|
139
|
-
isRegistered(family: string): boolean {
|
|
140
|
-
return this.registered.has(family);
|
|
141
|
-
}
|
|
142
|
-
|
|
143
|
-
/**
|
|
144
|
-
* Get list of all registered font families.
|
|
145
|
-
*/
|
|
146
|
-
getRegisteredFamilies(): string[] {
|
|
147
|
-
return Array.from(this.registered.keys());
|
|
148
|
-
}
|
|
149
|
-
}
|
|
150
|
-
|
|
151
|
-
/** Singleton instance */
|
|
152
|
-
export const systemFontRegistry = SystemFontRegistry.instance;
|
|
@@ -1,243 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Canvas API polyfill for Bun/Node.js via @napi-rs/canvas.
|
|
3
|
-
*
|
|
4
|
-
* Required by @chenglou/pretext in server environments (Bun/Node.js without DOM).
|
|
5
|
-
*
|
|
6
|
-
* Two levels of polyfill:
|
|
7
|
-
* 1. globalThis.document.createElement('canvas') — pretext uses this
|
|
8
|
-
* inside prepare() to create a temporary Canvas and call measureText.
|
|
9
|
-
* 2. OffscreenCanvas — for some libraries and early versions.
|
|
10
|
-
*
|
|
11
|
-
* Exports enableOfficeTextMeasure / disableOfficeTextMeasure —
|
|
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.
|
|
16
|
-
*/
|
|
17
|
-
|
|
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;
|
|
27
|
-
let _createCanvas: ((w: number, h: number) => any) | null = null;
|
|
28
|
-
|
|
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();
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
// ── Polyfill document.createElement('canvas') ────────────────────────────
|
|
50
|
-
// Pretext internally calls document.createElement('canvas') during prepare(),
|
|
51
|
-
// to create a temporary Canvas and obtain a 2d context for measureText.
|
|
52
|
-
// Without this polyfill, prepare() crashes with "document is not defined" in Bun.
|
|
53
|
-
|
|
54
|
-
function needsCanvasPolyfill(): boolean {
|
|
55
|
-
if (typeof globalThis.document === 'undefined') return true;
|
|
56
|
-
try {
|
|
57
|
-
const el = globalThis.document.createElement('canvas');
|
|
58
|
-
if (!el || typeof el.getContext !== 'function') return true;
|
|
59
|
-
return false;
|
|
60
|
-
} catch {
|
|
61
|
-
return true;
|
|
62
|
-
}
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
if (needsCanvasPolyfill()) {
|
|
66
|
-
(globalThis as any).document = {
|
|
67
|
-
createElement: (tag: string) => {
|
|
68
|
-
if (tag === 'canvas' && _createCanvas) return _createCanvas(1, 1);
|
|
69
|
-
// For other elements — empty stub
|
|
70
|
-
return {};
|
|
71
|
-
},
|
|
72
|
-
};
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
// ── Polyfill OffscreenCanvas ─────────────────────────────────────────────
|
|
76
|
-
// @napi-rs/canvas does not provide OffscreenCanvas, so we create a shim.
|
|
77
|
-
|
|
78
|
-
if (typeof globalThis.OffscreenCanvas === 'undefined' && _createCanvas) {
|
|
79
|
-
(globalThis as any).OffscreenCanvas = class OffscreenCanvasShim {
|
|
80
|
-
private _canvas: any;
|
|
81
|
-
private _w: number;
|
|
82
|
-
private _h: number;
|
|
83
|
-
|
|
84
|
-
constructor(width: number, height: number) {
|
|
85
|
-
this._w = width;
|
|
86
|
-
this._h = height;
|
|
87
|
-
this._canvas = _createCanvas!(width, height);
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
get width(): number { return this._w; }
|
|
91
|
-
set width(v: number) { this._w = v; this._canvas.width = v; }
|
|
92
|
-
get height(): number { return this._h; }
|
|
93
|
-
set height(v: number) { this._h = v; this._canvas.height = v; }
|
|
94
|
-
|
|
95
|
-
getContext(type: string, attrs?: any): any {
|
|
96
|
-
return this._canvas.getContext(type, attrs);
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
async convertToBlob({ type: _type }: { type?: string } = {}): Promise<Blob> {
|
|
100
|
-
// @napi-rs/canvas toBuffer always returns PNG buffer
|
|
101
|
-
const buffer = this._canvas.toBuffer('image/png');
|
|
102
|
-
return new Blob([buffer], { type: 'image/png' });
|
|
103
|
-
}
|
|
104
|
-
};
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
// ── Office measureText override ─────────────────────────────────────
|
|
108
|
-
|
|
109
|
-
const originalMeasureText = (globalThis as any).CanvasRenderingContext2D
|
|
110
|
-
?.prototype?.measureText as
|
|
111
|
-
| ((text: string) => TextMetrics)
|
|
112
|
-
| undefined;
|
|
113
|
-
|
|
114
|
-
/**
|
|
115
|
-
* fontCache: Map<family_weight_style, FontFace> where FontFace._raw holds the
|
|
116
|
-
* raw fontkit font object. Used by enableOfficeTextMeasure.
|
|
117
|
-
*/
|
|
118
|
-
let officeFontCache: Map<string, any> | null = null;
|
|
119
|
-
let officeEnabled = false;
|
|
120
|
-
|
|
121
|
-
/**
|
|
122
|
-
* Parse ctx.font string like "italic bold 16px Arial" → { family, size, weight }
|
|
123
|
-
*/
|
|
124
|
-
function parseFont(fontStr: string): { family: string; size: number; weight: string } | null {
|
|
125
|
-
// "italic bold 16px Arial" or "bold 16px Inter" or "16px Arial"
|
|
126
|
-
const pxMatch = fontStr.match(/(\d+(?:\.\d+)?)px\s+(.+)/);
|
|
127
|
-
if (!pxMatch) return null;
|
|
128
|
-
const size = parseFloat(pxMatch[1]);
|
|
129
|
-
// Simplified: everything after px is the family
|
|
130
|
-
const family = pxMatch[2].trim();
|
|
131
|
-
// Parse weight from beginning
|
|
132
|
-
const weightMatch = fontStr.match(/\b(bold|italic|\d{3})\b/);
|
|
133
|
-
const weight = weightMatch ? (weightMatch[1] === 'bold' ? 'bold' : weightMatch[1]) : 'normal';
|
|
134
|
-
return { family, size, weight };
|
|
135
|
-
}
|
|
136
|
-
|
|
137
|
-
/** Cache key: "${family}_${weight}_normal" */
|
|
138
|
-
function cacheKey(family: string, weight: string): string {
|
|
139
|
-
return `${family}_${weight}_normal`;
|
|
140
|
-
}
|
|
141
|
-
|
|
142
|
-
function officeMeasureText(this: any, text: string): TextMetrics {
|
|
143
|
-
if (!officeFontCache) {
|
|
144
|
-
return originalMeasureText?.call(this, text) ?? createEmptyMetrics();
|
|
145
|
-
}
|
|
146
|
-
|
|
147
|
-
const parsed = parseFont(this.font);
|
|
148
|
-
if (!parsed) {
|
|
149
|
-
return originalMeasureText?.call(this, text) ?? createEmptyMetrics();
|
|
150
|
-
}
|
|
151
|
-
|
|
152
|
-
const key = cacheKey(parsed.family, parsed.weight);
|
|
153
|
-
const fontFace = officeFontCache.get(key);
|
|
154
|
-
if (!fontFace) {
|
|
155
|
-
return originalMeasureText?.call(this, text) ?? createEmptyMetrics();
|
|
156
|
-
}
|
|
157
|
-
|
|
158
|
-
// FontFace._raw holds the raw fontkit font object
|
|
159
|
-
const raw: any = fontFace._raw;
|
|
160
|
-
const scale = parsed.size / fontFace.unitsPerEm;
|
|
161
|
-
let totalWidth = 0;
|
|
162
|
-
|
|
163
|
-
for (let i = 0; i < text.length; i++) {
|
|
164
|
-
const codePoint = text.codePointAt(i)!;
|
|
165
|
-
const glyph = raw.glyphForCodePoint(codePoint);
|
|
166
|
-
if (glyph) {
|
|
167
|
-
totalWidth += glyph.advanceWidth * scale;
|
|
168
|
-
} else {
|
|
169
|
-
// Fallback for missing glyph: use original
|
|
170
|
-
if (originalMeasureText) {
|
|
171
|
-
return originalMeasureText.call(this, text);
|
|
172
|
-
}
|
|
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;
|
|
176
|
-
}
|
|
177
|
-
// Skip surrogate pairs
|
|
178
|
-
if (codePoint > 0xffff) i++;
|
|
179
|
-
}
|
|
180
|
-
|
|
181
|
-
return createMetricsObject(totalWidth);
|
|
182
|
-
}
|
|
183
|
-
|
|
184
|
-
function createMetricsObject(width: number): TextMetrics {
|
|
185
|
-
return {
|
|
186
|
-
width,
|
|
187
|
-
actualBoundingBoxAscent: 0,
|
|
188
|
-
actualBoundingBoxDescent: 0,
|
|
189
|
-
fontBoundingBoxAscent: 0,
|
|
190
|
-
fontBoundingBoxDescent: 0,
|
|
191
|
-
actualBoundingBoxLeft: 0,
|
|
192
|
-
actualBoundingBoxRight: width,
|
|
193
|
-
} as unknown as TextMetrics;
|
|
194
|
-
}
|
|
195
|
-
|
|
196
|
-
function createEmptyMetrics(): TextMetrics {
|
|
197
|
-
return createMetricsObject(0);
|
|
198
|
-
}
|
|
199
|
-
|
|
200
|
-
/**
|
|
201
|
-
* Register a font with @napi-rs/canvas so ctx.measureText() works in Node.js.
|
|
202
|
-
* No-op in browser or when @napi-rs/canvas is not available.
|
|
203
|
-
*/
|
|
204
|
-
export function registerCanvasFont(fontPath: string, family: string): void {
|
|
205
|
-
if (!_require) return; // @napi-rs/canvas not available
|
|
206
|
-
try {
|
|
207
|
-
const mod = _require('@napi-rs/canvas');
|
|
208
|
-
if (mod?.registerFont) {
|
|
209
|
-
mod.registerFont(fontPath, { family });
|
|
210
|
-
}
|
|
211
|
-
} catch {
|
|
212
|
-
// @napi-rs/canvas not available — no-op
|
|
213
|
-
}
|
|
214
|
-
}
|
|
215
|
-
|
|
216
|
-
/**
|
|
217
|
-
* Enable Office measurement: replaces ctx.measureText with FontEngine-based version.
|
|
218
|
-
* @param fontCache — Map<family_weight_style, FontFace> from FontMetricsProvider
|
|
219
|
-
*/
|
|
220
|
-
export function enableOfficeTextMeasure(fontCache: Map<string, any>): void {
|
|
221
|
-
if (officeEnabled) return;
|
|
222
|
-
officeFontCache = fontCache;
|
|
223
|
-
officeEnabled = true;
|
|
224
|
-
|
|
225
|
-
const CtxProto = (globalThis as any).CanvasRenderingContext2D?.prototype;
|
|
226
|
-
if (CtxProto && originalMeasureText) {
|
|
227
|
-
CtxProto.measureText = officeMeasureText;
|
|
228
|
-
}
|
|
229
|
-
}
|
|
230
|
-
|
|
231
|
-
/**
|
|
232
|
-
* Restore original ctx.measureText.
|
|
233
|
-
*/
|
|
234
|
-
export function disableOfficeTextMeasure(): void {
|
|
235
|
-
if (!officeEnabled) return;
|
|
236
|
-
officeEnabled = false;
|
|
237
|
-
officeFontCache = null;
|
|
238
|
-
|
|
239
|
-
const CtxProto = (globalThis as any).CanvasRenderingContext2D?.prototype;
|
|
240
|
-
if (CtxProto && originalMeasureText) {
|
|
241
|
-
CtxProto.measureText = originalMeasureText;
|
|
242
|
-
}
|
|
243
|
-
}
|
package/src/measure/fontkit.d.ts
DELETED
|
@@ -1,44 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Minimal type declarations for fontkit.
|
|
3
|
-
* fontkit does not ship native TypeScript types.
|
|
4
|
-
*/
|
|
5
|
-
|
|
6
|
-
declare module 'fontkit' {
|
|
7
|
-
interface BBox {
|
|
8
|
-
minX: number;
|
|
9
|
-
minY: number;
|
|
10
|
-
maxX: number;
|
|
11
|
-
maxY: number;
|
|
12
|
-
}
|
|
13
|
-
|
|
14
|
-
interface Glyph {
|
|
15
|
-
id: number;
|
|
16
|
-
advanceWidth: number;
|
|
17
|
-
bbox: BBox;
|
|
18
|
-
name: string;
|
|
19
|
-
path: any;
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
interface TTFFont {
|
|
23
|
-
unitsPerEm: number;
|
|
24
|
-
ascent: number;
|
|
25
|
-
descent: number;
|
|
26
|
-
capHeight: number;
|
|
27
|
-
xHeight: number;
|
|
28
|
-
lineGap: number;
|
|
29
|
-
underlinePosition: number;
|
|
30
|
-
underlineThickness: number;
|
|
31
|
-
familyName: string;
|
|
32
|
-
subfamilyName: string;
|
|
33
|
-
postscriptName: string;
|
|
34
|
-
format: string;
|
|
35
|
-
glyphsForString(text: string): Glyph[];
|
|
36
|
-
getGlyph(glyphId: number): Glyph;
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
function create(buffer: Buffer, postscriptName?: string): TTFFont;
|
|
40
|
-
function open(filename: string, postscriptName?: string): Promise<TTFFont>;
|
|
41
|
-
function openSync(filename: string, postscriptName?: string): TTFFont;
|
|
42
|
-
|
|
43
|
-
export { create, open, openSync, TTFFont, Glyph, BBox };
|
|
44
|
-
}
|