@nowline/export-core 0.2.0
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/README.md +131 -0
- package/assets/fonts/DejaVuSans.ttf +0 -0
- package/assets/fonts/DejaVuSansMono.ttf +0 -0
- package/assets/fonts/LICENSE-DejaVu.txt +187 -0
- package/dist/ast-helpers.d.ts +16 -0
- package/dist/ast-helpers.d.ts.map +1 -0
- package/dist/ast-helpers.js +44 -0
- package/dist/ast-helpers.js.map +1 -0
- package/dist/fonts/bundled.d.ts +7 -0
- package/dist/fonts/bundled.d.ts.map +1 -0
- package/dist/fonts/bundled.js +52 -0
- package/dist/fonts/bundled.js.map +1 -0
- package/dist/fonts/index.d.ts +6 -0
- package/dist/fonts/index.d.ts.map +1 -0
- package/dist/fonts/index.js +5 -0
- package/dist/fonts/index.js.map +1 -0
- package/dist/fonts/probe-list.d.ts +29 -0
- package/dist/fonts/probe-list.d.ts.map +1 -0
- package/dist/fonts/probe-list.js +119 -0
- package/dist/fonts/probe-list.js.map +1 -0
- package/dist/fonts/resolve.d.ts +35 -0
- package/dist/fonts/resolve.d.ts.map +1 -0
- package/dist/fonts/resolve.js +159 -0
- package/dist/fonts/resolve.js.map +1 -0
- package/dist/fonts/sfns.d.ts +6 -0
- package/dist/fonts/sfns.d.ts.map +1 -0
- package/dist/fonts/sfns.js +37 -0
- package/dist/fonts/sfns.js.map +1 -0
- package/dist/generated/bundled-fonts.d.ts +3 -0
- package/dist/generated/bundled-fonts.d.ts.map +1 -0
- package/dist/generated/bundled-fonts.js +9 -0
- package/dist/generated/bundled-fonts.js.map +1 -0
- package/dist/index.d.ts +9 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +8 -0
- package/dist/index.js.map +1 -0
- package/dist/pdf-page.d.ts +66 -0
- package/dist/pdf-page.d.ts.map +1 -0
- package/dist/pdf-page.js +170 -0
- package/dist/pdf-page.js.map +1 -0
- package/dist/types.d.ts +61 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +6 -0
- package/dist/types.js.map +1 -0
- package/dist/units.d.ts +16 -0
- package/dist/units.d.ts.map +1 -0
- package/dist/units.js +56 -0
- package/dist/units.js.map +1 -0
- package/package.json +50 -0
- package/src/ast-helpers.ts +47 -0
- package/src/fonts/bundled.ts +59 -0
- package/src/fonts/index.ts +18 -0
- package/src/fonts/probe-list.ts +143 -0
- package/src/fonts/resolve.ts +228 -0
- package/src/fonts/sfns.ts +34 -0
- package/src/generated/bundled-fonts.ts +10 -0
- package/src/index.ts +62 -0
- package/src/pdf-page.ts +224 -0
- package/src/types.ts +88 -0
- package/src/units.ts +60 -0
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
// Per-platform candidate-font tables walked by `resolveFonts()`.
|
|
2
|
+
//
|
|
3
|
+
// Spec: specs/handoffs/m2c.md § 10 "Platform probe list".
|
|
4
|
+
//
|
|
5
|
+
// `path` segments use forward slashes; the resolver normalizes via
|
|
6
|
+
// `path.normalize()` at lookup time so Windows paths work correctly.
|
|
7
|
+
// `face` is set for `.ttc` collections — the resolver loads the TTC and
|
|
8
|
+
// hands the face PostScript name to PDFKit / resvg via `ResolvedFont.face`.
|
|
9
|
+
|
|
10
|
+
// Use platform-specific path joiners so Windows paths render with backslashes
|
|
11
|
+
// even when the resolver runs on a non-Windows host (e.g. test mocks on macOS
|
|
12
|
+
// CI exercising the win32 probe list).
|
|
13
|
+
import * as path from 'node:path';
|
|
14
|
+
|
|
15
|
+
export interface FontCandidate {
|
|
16
|
+
path: string;
|
|
17
|
+
face?: string;
|
|
18
|
+
/** Friendly family name surfaced in `ResolvedFont.name`. */
|
|
19
|
+
name: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface PlatformProbe {
|
|
23
|
+
sans: readonly FontCandidate[];
|
|
24
|
+
mono: readonly FontCandidate[];
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const MACOS: PlatformProbe = {
|
|
28
|
+
sans: [
|
|
29
|
+
{ path: '/System/Library/Fonts/SFNS.ttf', name: 'SF Pro' },
|
|
30
|
+
{ path: '/System/Library/Fonts/Helvetica.ttc', face: 'Helvetica', name: 'Helvetica' },
|
|
31
|
+
{ path: '/System/Library/Fonts/Supplemental/Arial.ttf', name: 'Arial' },
|
|
32
|
+
],
|
|
33
|
+
mono: [
|
|
34
|
+
{ path: '/System/Library/Fonts/SFNSMono.ttf', name: 'SF Mono' },
|
|
35
|
+
{ path: '/System/Library/Fonts/Menlo.ttc', face: 'Menlo-Regular', name: 'Menlo' },
|
|
36
|
+
{ path: '/System/Library/Fonts/Monaco.ttf', name: 'Monaco' },
|
|
37
|
+
],
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
function windowsCandidate(fontsDir: string, file: string, name: string): FontCandidate {
|
|
41
|
+
return { path: path.win32.join(fontsDir, file), name };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function windowsProbe(fontsDir: string): PlatformProbe {
|
|
45
|
+
return {
|
|
46
|
+
sans: [
|
|
47
|
+
windowsCandidate(fontsDir, 'segoeui.ttf', 'Segoe UI'),
|
|
48
|
+
windowsCandidate(fontsDir, 'arial.ttf', 'Arial'),
|
|
49
|
+
windowsCandidate(fontsDir, 'tahoma.ttf', 'Tahoma'),
|
|
50
|
+
windowsCandidate(fontsDir, 'verdana.ttf', 'Verdana'),
|
|
51
|
+
],
|
|
52
|
+
mono: [
|
|
53
|
+
windowsCandidate(fontsDir, 'consola.ttf', 'Consolas'),
|
|
54
|
+
windowsCandidate(fontsDir, 'cour.ttf', 'Courier New'),
|
|
55
|
+
],
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const LINUX: PlatformProbe = {
|
|
60
|
+
sans: [
|
|
61
|
+
{ path: '/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf', name: 'DejaVu Sans' },
|
|
62
|
+
{ path: '/usr/share/fonts/dejavu/DejaVuSans.ttf', name: 'DejaVu Sans' },
|
|
63
|
+
{ path: '/usr/share/fonts/TTF/DejaVuSans.ttf', name: 'DejaVu Sans' },
|
|
64
|
+
{ path: '/usr/share/fonts/liberation/LiberationSans-Regular.ttf', name: 'Liberation Sans' },
|
|
65
|
+
{
|
|
66
|
+
path: '/usr/share/fonts/truetype/liberation/LiberationSans-Regular.ttf',
|
|
67
|
+
name: 'Liberation Sans',
|
|
68
|
+
},
|
|
69
|
+
{ path: '/usr/share/fonts/truetype/noto/NotoSans-Regular.ttf', name: 'Noto Sans' },
|
|
70
|
+
{ path: '/usr/share/fonts/ubuntu/Ubuntu-R.ttf', name: 'Ubuntu' },
|
|
71
|
+
{ path: '/usr/share/fonts/cantarell/Cantarell-Regular.otf', name: 'Cantarell' },
|
|
72
|
+
],
|
|
73
|
+
mono: [
|
|
74
|
+
{ path: '/usr/share/fonts/truetype/dejavu/DejaVuSansMono.ttf', name: 'DejaVu Sans Mono' },
|
|
75
|
+
{ path: '/usr/share/fonts/dejavu/DejaVuSansMono.ttf', name: 'DejaVu Sans Mono' },
|
|
76
|
+
{ path: '/usr/share/fonts/TTF/DejaVuSansMono.ttf', name: 'DejaVu Sans Mono' },
|
|
77
|
+
{ path: '/usr/share/fonts/liberation/LiberationMono-Regular.ttf', name: 'Liberation Mono' },
|
|
78
|
+
{
|
|
79
|
+
path: '/usr/share/fonts/truetype/liberation/LiberationMono-Regular.ttf',
|
|
80
|
+
name: 'Liberation Mono',
|
|
81
|
+
},
|
|
82
|
+
{ path: '/usr/share/fonts/ubuntu/UbuntuMono-R.ttf', name: 'Ubuntu Mono' },
|
|
83
|
+
{ path: '/usr/share/fonts/truetype/noto/NotoSansMono-Regular.ttf', name: 'Noto Sans Mono' },
|
|
84
|
+
],
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
export function probeListFor(
|
|
88
|
+
platform: NodeJS.Platform,
|
|
89
|
+
env: NodeJS.ProcessEnv = process.env,
|
|
90
|
+
): PlatformProbe {
|
|
91
|
+
if (platform === 'darwin') return MACOS;
|
|
92
|
+
if (platform === 'win32') {
|
|
93
|
+
const fontsDir = path.win32.join(env.WINDIR ?? 'C:\\Windows', 'Fonts');
|
|
94
|
+
return windowsProbe(fontsDir);
|
|
95
|
+
}
|
|
96
|
+
return LINUX;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Aliases for `--font-sans` / `--font-mono`. Resolves to the platform-default
|
|
101
|
+
* candidate for that family. `dejavu` always resolves to the bundled fallback
|
|
102
|
+
* regardless of platform — handled in resolver.ts.
|
|
103
|
+
*/
|
|
104
|
+
export const ALIASES: Readonly<Record<string, { sans?: string; mono?: string }>> = {
|
|
105
|
+
sf: { sans: 'SF Pro', mono: 'SF Mono' },
|
|
106
|
+
segoe: { sans: 'Segoe UI', mono: 'Consolas' },
|
|
107
|
+
dejavu: { sans: 'DejaVu Sans', mono: 'DejaVu Sans Mono' },
|
|
108
|
+
helvetica: { sans: 'Helvetica' },
|
|
109
|
+
arial: { sans: 'Arial' },
|
|
110
|
+
tahoma: { sans: 'Tahoma' },
|
|
111
|
+
verdana: { sans: 'Verdana' },
|
|
112
|
+
liberation: { sans: 'Liberation Sans', mono: 'Liberation Mono' },
|
|
113
|
+
noto: { sans: 'Noto Sans', mono: 'Noto Sans Mono' },
|
|
114
|
+
ubuntu: { sans: 'Ubuntu', mono: 'Ubuntu Mono' },
|
|
115
|
+
cantarell: { sans: 'Cantarell' },
|
|
116
|
+
menlo: { mono: 'Menlo' },
|
|
117
|
+
consolas: { mono: 'Consolas' },
|
|
118
|
+
monaco: { mono: 'Monaco' },
|
|
119
|
+
courier: { mono: 'Courier New' },
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
export function isAlias(value: string): boolean {
|
|
123
|
+
return Object.hasOwn(ALIASES, value.toLowerCase());
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Look up the candidate corresponding to an alias for the given role.
|
|
128
|
+
*
|
|
129
|
+
* Returns `undefined` if the alias doesn't have an entry for that role
|
|
130
|
+
* (e.g. `--font-sans menlo` makes no sense — menlo is a mono family).
|
|
131
|
+
*/
|
|
132
|
+
export function aliasCandidate(
|
|
133
|
+
alias: string,
|
|
134
|
+
role: 'sans' | 'mono',
|
|
135
|
+
probe: PlatformProbe,
|
|
136
|
+
): FontCandidate | undefined {
|
|
137
|
+
const entry = ALIASES[alias.toLowerCase()];
|
|
138
|
+
if (!entry) return undefined;
|
|
139
|
+
const target = entry[role];
|
|
140
|
+
if (!target) return undefined;
|
|
141
|
+
const list = probe[role];
|
|
142
|
+
return list.find((c) => c.name === target);
|
|
143
|
+
}
|
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
// 5-step font resolver shared by PDF and PNG.
|
|
2
|
+
//
|
|
3
|
+
// Spec: specs/handoffs/m2c.md § 10 "Font strategy — system first, one
|
|
4
|
+
// bundled headless fallback".
|
|
5
|
+
//
|
|
6
|
+
// Resolution order (run independently for sans / mono — first hit wins):
|
|
7
|
+
// 1. Explicit flag — `--font-sans <path|alias>` / `--font-mono <path|alias>`
|
|
8
|
+
// 2. Environment — NOWLINE_FONT_SANS / NOWLINE_FONT_MONO
|
|
9
|
+
// 3. Headless override — `--headless`, NOWLINE_HEADLESS=1, or auto in CI
|
|
10
|
+
// without a TTY
|
|
11
|
+
// 4. Platform probe — first existing entry from `probe-list.ts`
|
|
12
|
+
// 5. Bundled fallback — DejaVuSans.ttf / DejaVuSansMono.ttf
|
|
13
|
+
|
|
14
|
+
import { existsSync as defaultExistsSync, promises as fs } from 'node:fs';
|
|
15
|
+
import * as path from 'node:path';
|
|
16
|
+
|
|
17
|
+
import type { FontRole, FontSource, ResolvedFont } from '../types.js';
|
|
18
|
+
import { loadBundledMono, loadBundledSans } from './bundled.js';
|
|
19
|
+
import { aliasCandidate, isAlias, type PlatformProbe, probeListFor } from './probe-list.js';
|
|
20
|
+
import { isVariableFontBytes } from './sfns.js';
|
|
21
|
+
|
|
22
|
+
export interface ResolveOptions {
|
|
23
|
+
/** Path or alias for the sans role. Wins over env / probe / bundled. */
|
|
24
|
+
fontSans?: string;
|
|
25
|
+
/** Path or alias for the mono role. */
|
|
26
|
+
fontMono?: string;
|
|
27
|
+
/**
|
|
28
|
+
* Skip steps 4–5; go straight to step 5 (bundled DejaVu pair). Implied by
|
|
29
|
+
* `NOWLINE_HEADLESS=1` and by `CI=true` without a TTY (unless
|
|
30
|
+
* `disableAutoHeadless` is set).
|
|
31
|
+
*/
|
|
32
|
+
headless?: boolean;
|
|
33
|
+
/** Disable the CI-no-TTY auto-headless heuristic. Defaults to false. */
|
|
34
|
+
disableAutoHeadless?: boolean;
|
|
35
|
+
// Test seams — defaults wire to real `node:fs` / `node:process`.
|
|
36
|
+
platform?: NodeJS.Platform;
|
|
37
|
+
fileExists?: (p: string) => boolean;
|
|
38
|
+
readFileBytes?: (p: string) => Promise<Uint8Array>;
|
|
39
|
+
env?: NodeJS.ProcessEnv;
|
|
40
|
+
isStdoutTty?: boolean;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface ResolveResult {
|
|
44
|
+
sans: ResolvedFont;
|
|
45
|
+
mono: ResolvedFont;
|
|
46
|
+
/**
|
|
47
|
+
* True when EITHER role landed at step 5 without explicit `--headless`.
|
|
48
|
+
* Callers (CLI) emit a `--strict` warning on this.
|
|
49
|
+
*/
|
|
50
|
+
sansFellBackToBundled: boolean;
|
|
51
|
+
monoFellBackToBundled: boolean;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export async function resolveFonts(options: ResolveOptions = {}): Promise<ResolveResult> {
|
|
55
|
+
const env = options.env ?? process.env;
|
|
56
|
+
const platform = options.platform ?? process.platform;
|
|
57
|
+
const fileExists = options.fileExists ?? defaultExistsSync;
|
|
58
|
+
const readFileBytes = options.readFileBytes ?? defaultReadFileBytes;
|
|
59
|
+
const probe = probeListFor(platform, env);
|
|
60
|
+
|
|
61
|
+
const headlessRequested = isHeadlessRequested(options, env);
|
|
62
|
+
const sans = await resolveRole({
|
|
63
|
+
role: 'sans',
|
|
64
|
+
flag: options.fontSans,
|
|
65
|
+
envValue: env.NOWLINE_FONT_SANS,
|
|
66
|
+
headless: headlessRequested,
|
|
67
|
+
probe,
|
|
68
|
+
fileExists,
|
|
69
|
+
readFileBytes,
|
|
70
|
+
});
|
|
71
|
+
const mono = await resolveRole({
|
|
72
|
+
role: 'mono',
|
|
73
|
+
flag: options.fontMono,
|
|
74
|
+
envValue: env.NOWLINE_FONT_MONO,
|
|
75
|
+
headless: headlessRequested,
|
|
76
|
+
probe,
|
|
77
|
+
fileExists,
|
|
78
|
+
readFileBytes,
|
|
79
|
+
});
|
|
80
|
+
return {
|
|
81
|
+
sans,
|
|
82
|
+
mono,
|
|
83
|
+
sansFellBackToBundled: sans.source === 'bundled' && !headlessRequested,
|
|
84
|
+
monoFellBackToBundled: mono.source === 'bundled' && !headlessRequested,
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function isHeadlessRequested(options: ResolveOptions, env: NodeJS.ProcessEnv): boolean {
|
|
89
|
+
if (options.headless) return true;
|
|
90
|
+
if (env.NOWLINE_HEADLESS === '1' || env.NOWLINE_HEADLESS === 'true') return true;
|
|
91
|
+
if (options.disableAutoHeadless) return false;
|
|
92
|
+
const inCI = env.CI === 'true' || env.CI === '1';
|
|
93
|
+
const stdoutIsTty = options.isStdoutTty ?? Boolean(process.stdout.isTTY);
|
|
94
|
+
return inCI && !stdoutIsTty;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
interface RoleArgs {
|
|
98
|
+
role: FontRole;
|
|
99
|
+
flag?: string;
|
|
100
|
+
envValue?: string;
|
|
101
|
+
headless: boolean;
|
|
102
|
+
probe: PlatformProbe;
|
|
103
|
+
fileExists: (p: string) => boolean;
|
|
104
|
+
readFileBytes: (p: string) => Promise<Uint8Array>;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
async function resolveRole(args: RoleArgs): Promise<ResolvedFont> {
|
|
108
|
+
// Step 1 — flag (path or alias)
|
|
109
|
+
if (args.flag) {
|
|
110
|
+
return loadFlag(args.flag, args.role, 'flag', args);
|
|
111
|
+
}
|
|
112
|
+
// Step 2 — environment
|
|
113
|
+
if (args.envValue) {
|
|
114
|
+
return loadFlag(args.envValue, args.role, 'env', args);
|
|
115
|
+
}
|
|
116
|
+
// Step 3 — headless: skip probe, go to bundled
|
|
117
|
+
if (args.headless) {
|
|
118
|
+
return loadBundled(args.role, 'headless');
|
|
119
|
+
}
|
|
120
|
+
// Step 4 — platform probe
|
|
121
|
+
for (const candidate of args.probe[args.role]) {
|
|
122
|
+
if (args.fileExists(candidate.path)) {
|
|
123
|
+
const bytes = await args.readFileBytes(candidate.path);
|
|
124
|
+
return decorate(bytes, {
|
|
125
|
+
name: candidate.name,
|
|
126
|
+
source: 'probe',
|
|
127
|
+
path: candidate.path,
|
|
128
|
+
face: candidate.face,
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
// Step 5 — bundled fallback
|
|
133
|
+
return loadBundled(args.role, 'bundled');
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
async function loadFlag(
|
|
137
|
+
raw: string,
|
|
138
|
+
role: FontRole,
|
|
139
|
+
source: 'flag' | 'env',
|
|
140
|
+
args: RoleArgs,
|
|
141
|
+
): Promise<ResolvedFont> {
|
|
142
|
+
if (
|
|
143
|
+
path.isAbsolute(raw) ||
|
|
144
|
+
raw.startsWith('.') ||
|
|
145
|
+
raw.includes(path.sep) ||
|
|
146
|
+
raw.endsWith('.ttf') ||
|
|
147
|
+
raw.endsWith('.otf') ||
|
|
148
|
+
raw.endsWith('.ttc')
|
|
149
|
+
) {
|
|
150
|
+
const abs = path.resolve(raw);
|
|
151
|
+
if (!args.fileExists(abs)) {
|
|
152
|
+
throw new FontResolveError(`font path does not exist: ${raw} (resolved to ${abs})`);
|
|
153
|
+
}
|
|
154
|
+
const bytes = await args.readFileBytes(abs);
|
|
155
|
+
return decorate(bytes, {
|
|
156
|
+
name: deriveDisplayName(abs),
|
|
157
|
+
source,
|
|
158
|
+
path: abs,
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
if (isAlias(raw)) {
|
|
163
|
+
const candidate = aliasCandidate(raw, role, args.probe);
|
|
164
|
+
if (!candidate) {
|
|
165
|
+
throw new FontResolveError(`alias "${raw}" has no ${role} mapping on this platform`);
|
|
166
|
+
}
|
|
167
|
+
if (!args.fileExists(candidate.path)) {
|
|
168
|
+
// Alias is known but the underlying file is missing — fall through
|
|
169
|
+
// to bundled with the original source preserved is wrong; surface
|
|
170
|
+
// the missing-file error so the user knows their alias didn't land.
|
|
171
|
+
throw new FontResolveError(
|
|
172
|
+
`alias "${raw}" maps to ${candidate.path} which does not exist on this system`,
|
|
173
|
+
);
|
|
174
|
+
}
|
|
175
|
+
const bytes = await args.readFileBytes(candidate.path);
|
|
176
|
+
return decorate(bytes, {
|
|
177
|
+
name: candidate.name,
|
|
178
|
+
source,
|
|
179
|
+
path: candidate.path,
|
|
180
|
+
face: candidate.face,
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
throw new FontResolveError(`font value "${raw}" is neither a path nor a known alias`);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
async function loadBundled(role: FontRole, source: FontSource): Promise<ResolvedFont> {
|
|
188
|
+
const bytes = role === 'sans' ? await loadBundledSans() : await loadBundledMono();
|
|
189
|
+
return decorate(bytes, {
|
|
190
|
+
name: role === 'sans' ? 'DejaVu Sans' : 'DejaVu Sans Mono',
|
|
191
|
+
source,
|
|
192
|
+
});
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
interface DecorateMeta {
|
|
196
|
+
name: string;
|
|
197
|
+
source: FontSource;
|
|
198
|
+
path?: string;
|
|
199
|
+
face?: string;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function decorate(bytes: Uint8Array, meta: DecorateMeta): ResolvedFont {
|
|
203
|
+
return {
|
|
204
|
+
name: meta.name,
|
|
205
|
+
bytes,
|
|
206
|
+
source: meta.source,
|
|
207
|
+
path: meta.path,
|
|
208
|
+
face: meta.face,
|
|
209
|
+
isVariableFont: isVariableFontBytes(bytes),
|
|
210
|
+
};
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function deriveDisplayName(absPath: string): string {
|
|
214
|
+
const base = path.basename(absPath, path.extname(absPath));
|
|
215
|
+
return base.replace(/-Regular$/, '').replace(/[_-]/g, ' ');
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
async function defaultReadFileBytes(p: string): Promise<Uint8Array> {
|
|
219
|
+
const buf = await fs.readFile(p);
|
|
220
|
+
return new Uint8Array(buf.buffer, buf.byteOffset, buf.byteLength);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
export class FontResolveError extends Error {
|
|
224
|
+
constructor(message: string) {
|
|
225
|
+
super(message);
|
|
226
|
+
this.name = 'FontResolveError';
|
|
227
|
+
}
|
|
228
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
// SF Pro variable-font handling.
|
|
2
|
+
//
|
|
3
|
+
// Spec: specs/handoffs/m2c.md § 10 "Variable-font handling (SFNS.ttf)".
|
|
4
|
+
//
|
|
5
|
+
// Detection only — actual VF instancing for PDF embedding lives in
|
|
6
|
+
// @nowline/export-pdf, which depends on fontkit directly. The resolver
|
|
7
|
+
// surfaces `isVariableFont: true` so consumers know they may need to
|
|
8
|
+
// pre-instance.
|
|
9
|
+
|
|
10
|
+
const FVAR_TAG = 0x66766172; // 'fvar'
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Returns true when the given TTF/OTF byte buffer carries an `fvar` table —
|
|
14
|
+
* i.e. it is an OpenType variable font with continuous axes.
|
|
15
|
+
*/
|
|
16
|
+
export function isVariableFontBytes(bytes: Uint8Array): boolean {
|
|
17
|
+
if (bytes.byteLength < 12) return false;
|
|
18
|
+
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
|
19
|
+
const sfntVersion = view.getUint32(0);
|
|
20
|
+
// Accept both TrueType ('\x00\x01\x00\x00') and OpenType/CFF ('OTTO').
|
|
21
|
+
const isSfnt = sfntVersion === 0x00010000 || sfntVersion === 0x4f54544f;
|
|
22
|
+
const isCollection = sfntVersion === 0x74746366; // 'ttcf' — handled separately
|
|
23
|
+
if (!isSfnt && !isCollection) return false;
|
|
24
|
+
if (isCollection) return false; // collection: caller should index by face first
|
|
25
|
+
|
|
26
|
+
const numTables = view.getUint16(4);
|
|
27
|
+
for (let i = 0; i < numTables; i++) {
|
|
28
|
+
const recordOffset = 12 + i * 16;
|
|
29
|
+
if (recordOffset + 16 > bytes.byteLength) return false;
|
|
30
|
+
const tag = view.getUint32(recordOffset);
|
|
31
|
+
if (tag === FVAR_TAG) return true;
|
|
32
|
+
}
|
|
33
|
+
return false;
|
|
34
|
+
}
|