normalize-metrics 0.1.1 → 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 +38 -13
- package/dist/args.js +28 -6
- package/dist/css.js +134 -0
- package/dist/index.js +51 -9
- package/lib/engine.py +34 -0
- package/package.json +24 -9
package/README.md
CHANGED
|
@@ -1,24 +1,52 @@
|
|
|
1
1
|
# normalize-metrics
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Fix font vertical metrics so a word sits in the box.
|
|
4
4
|
|
|
5
|
-
The text within a button is not always centered. That is the font’s fault, not yours. You can
|
|
5
|
+
The text within a button is not always centered. That is the font’s fault, not yours. You can trim the line box in CSS with `text-box: trim-both cap alphabetic`. The default is still the untrimmed box, and most type ramps and component kits were written for that default.
|
|
6
6
|
|
|
7
|
-
|
|
7
|
+
Trim cuts descenders. An input, a textarea, or any box that clips overflow will cut g, p, and y once the line stops at the cap and the baseline. Turn trim off on those controls and you are back to the font’s real metrics.
|
|
8
|
+
|
|
9
|
+
For the web, fix those metrics in CSS and leave the file alone. Rewriting the font file is the other path, mostly for typographers who need that box everywhere the font is used, not only in one stylesheet.
|
|
10
|
+
|
|
11
|
+
## Install
|
|
8
12
|
|
|
9
13
|
```
|
|
10
14
|
npm i -g normalize-metrics
|
|
11
|
-
|
|
12
|
-
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
Or in a repo:
|
|
18
|
+
|
|
19
|
+
```
|
|
20
|
+
npm i -D normalize-metrics
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
## CSS
|
|
24
|
+
|
|
25
|
+
For the web, write a stylesheet and leave the font files alone. `--css` saves `style.css` with `ascent-override`, `descent-override`, and `line-gap-override`.
|
|
26
|
+
|
|
27
|
+
A single file, a set of files, or a folder:
|
|
28
|
+
|
|
29
|
+
```
|
|
30
|
+
normalize-metrics Inter-Regular.woff2 Inter-Regular.otf --css
|
|
31
|
+
normalize-metrics ./fonts --css
|
|
13
32
|
```
|
|
14
33
|
|
|
15
34
|
Or once, without installing:
|
|
16
35
|
|
|
17
36
|
```
|
|
18
|
-
npx normalize-metrics ./fonts
|
|
37
|
+
npx normalize-metrics ./fonts --css
|
|
19
38
|
```
|
|
20
39
|
|
|
21
|
-
|
|
40
|
+
The stylesheet points at the original files. Do not add those descriptors to an already rewritten font.
|
|
41
|
+
|
|
42
|
+
## Rewrite
|
|
43
|
+
|
|
44
|
+
For typographers, and for anywhere a stylesheet cannot go. A folder means every `.otf`, `.ttf`, `.woff`, and `.woff2` inside it. It writes a normalized copy next to the original. Fonts that already have good metrics are left alone. Variable fonts and TTC collections are skipped in v1.
|
|
45
|
+
|
|
46
|
+
```
|
|
47
|
+
normalize-metrics Inter-Regular.woff2 Inter-Regular.otf
|
|
48
|
+
normalize-metrics ./fonts
|
|
49
|
+
```
|
|
22
50
|
|
|
23
51
|
Rewriting a licensed font and redistributing the result may violate the EULA. This tool does not legalize the file.
|
|
24
52
|
|
|
@@ -30,13 +58,14 @@ Rewriting a licensed font and redistributing the result may violate the EULA. Th
|
|
|
30
58
|
## Usage
|
|
31
59
|
|
|
32
60
|
```
|
|
33
|
-
normalize-metrics <file|folder> [options]
|
|
61
|
+
normalize-metrics <file|folder> [more files] [options]
|
|
34
62
|
```
|
|
35
63
|
|
|
36
64
|
| Option | What it does |
|
|
37
65
|
| --- | --- |
|
|
38
66
|
| `--check` | Report fonts that are still off; write nothing; exit 1 if any are off |
|
|
39
67
|
| `--dry-run` | Same report; write nothing |
|
|
68
|
+
| `--css [file]` | Write `ascent-override`, `descent-override`, and `line-gap-override` for the original files. Default file is `style.css`. Does not rewrite fonts |
|
|
40
69
|
| `--in-place` | Overwrite the original (opt-in) |
|
|
41
70
|
| `--out-dir <dir>` | Write copies into this directory |
|
|
42
71
|
| `--suffix <text>` | Filename suffix for copies (default: `-normalized`) |
|
|
@@ -44,11 +73,7 @@ normalize-metrics <file|folder> [options]
|
|
|
44
73
|
|
|
45
74
|
Never overwrites unless you pass `--in-place`.
|
|
46
75
|
|
|
47
|
-
In a repo,
|
|
48
|
-
|
|
49
|
-
```
|
|
50
|
-
npm i -D normalize-metrics
|
|
51
|
-
```
|
|
76
|
+
In a repo, list the folders in the config. `--check` reports fonts that are still off and exits without writing — useful in CI.
|
|
52
77
|
|
|
53
78
|
## Config
|
|
54
79
|
|
package/dist/args.js
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
export const HELP = `normalize-metrics — rewrite vertical metrics so a word sits in the box
|
|
2
2
|
|
|
3
3
|
Usage:
|
|
4
|
-
normalize-metrics <file|folder> [options]
|
|
4
|
+
normalize-metrics <file|folder> [more files] [options]
|
|
5
5
|
normalize-metrics [options]
|
|
6
6
|
|
|
7
|
+
Pass one file, a set of files, or a folder.
|
|
7
8
|
A folder means every .otf, .ttf, .woff, and .woff2 inside it.
|
|
8
9
|
Writes a normalized copy next to the original. Never overwrites unless --in-place.
|
|
9
10
|
Fonts that already have good metrics are left alone.
|
|
@@ -11,6 +12,7 @@ Fonts that already have good metrics are left alone.
|
|
|
11
12
|
Options:
|
|
12
13
|
--check report fonts that are still off; write nothing; exit 1 if any are off
|
|
13
14
|
--dry-run same report; write nothing
|
|
15
|
+
--css [file] write metric overrides for the original files (default: style.css); do not rewrite fonts
|
|
14
16
|
--in-place overwrite the original (opt-in)
|
|
15
17
|
--out-dir <dir> write copies into this directory
|
|
16
18
|
--suffix <text> filename suffix for copies (default: -normalized)
|
|
@@ -23,19 +25,23 @@ Config (normalize-metrics.config.json or "normalize-metrics" in package.json):
|
|
|
23
25
|
|
|
24
26
|
Examples:
|
|
25
27
|
normalize-metrics Inter-Regular.otf
|
|
28
|
+
normalize-metrics Inter-Regular.woff2 GT-America-Regular.otf --css
|
|
26
29
|
normalize-metrics ./fonts
|
|
30
|
+
normalize-metrics ./fonts --css
|
|
27
31
|
npx normalize-metrics ./fonts --check
|
|
28
32
|
`;
|
|
29
33
|
const FLAGS_WITH_VALUE = new Set(["--out-dir", "--suffix", "--config"]);
|
|
34
|
+
export const DEFAULT_CSS_FILE = "style.css";
|
|
30
35
|
export function parseArgs(argv) {
|
|
31
36
|
const args = {
|
|
32
|
-
|
|
37
|
+
paths: [],
|
|
33
38
|
check: false,
|
|
34
39
|
dryRun: false,
|
|
35
40
|
inPlace: false,
|
|
36
41
|
outDir: null,
|
|
37
42
|
suffix: null,
|
|
38
43
|
configPath: null,
|
|
44
|
+
cssPath: null,
|
|
39
45
|
help: false,
|
|
40
46
|
version: false,
|
|
41
47
|
};
|
|
@@ -61,6 +67,17 @@ export function parseArgs(argv) {
|
|
|
61
67
|
args.inPlace = true;
|
|
62
68
|
continue;
|
|
63
69
|
}
|
|
70
|
+
if (token === "--css") {
|
|
71
|
+
const value = argv[i + 1];
|
|
72
|
+
if (value && !value.startsWith("-")) {
|
|
73
|
+
args.cssPath = value;
|
|
74
|
+
i += 1;
|
|
75
|
+
}
|
|
76
|
+
else {
|
|
77
|
+
args.cssPath = DEFAULT_CSS_FILE;
|
|
78
|
+
}
|
|
79
|
+
continue;
|
|
80
|
+
}
|
|
64
81
|
if (FLAGS_WITH_VALUE.has(token)) {
|
|
65
82
|
const value = argv[i + 1];
|
|
66
83
|
if (!value || value.startsWith("-")) {
|
|
@@ -78,17 +95,22 @@ export function parseArgs(argv) {
|
|
|
78
95
|
if (token.startsWith("-")) {
|
|
79
96
|
throw new Error(`Unknown flag: ${token}`);
|
|
80
97
|
}
|
|
81
|
-
|
|
82
|
-
throw new Error("Pass one file or one folder.");
|
|
83
|
-
}
|
|
84
|
-
args.path = token;
|
|
98
|
+
args.paths.push(token);
|
|
85
99
|
}
|
|
86
100
|
if (args.inPlace && args.outDir) {
|
|
87
101
|
throw new Error("Use either --in-place or --out-dir, not both.");
|
|
88
102
|
}
|
|
103
|
+
if (args.cssPath && !args.cssPath.toLowerCase().endsWith(".css")) {
|
|
104
|
+
throw new Error("--css needs a .css file.");
|
|
105
|
+
}
|
|
106
|
+
if (args.cssPath && (args.check || args.dryRun || args.inPlace || args.outDir)) {
|
|
107
|
+
throw new Error("--css writes a stylesheet and no font. Leave off --check, --dry-run, --in-place, and --out-dir.");
|
|
108
|
+
}
|
|
89
109
|
return args;
|
|
90
110
|
}
|
|
91
111
|
export function modeOf(args) {
|
|
112
|
+
if (args.cssPath)
|
|
113
|
+
return "css";
|
|
92
114
|
if (args.check)
|
|
93
115
|
return "check";
|
|
94
116
|
if (args.dryRun)
|
package/dist/css.js
ADDED
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import { dirname, extname, relative } from "node:path";
|
|
2
|
+
const HEADER = `/* Generated by normalize-metrics.
|
|
3
|
+
Line-box overrides for the original files. Does not rewrite the fonts. */`;
|
|
4
|
+
export function metricPercent(units, upm) {
|
|
5
|
+
if (!Number.isFinite(upm) || upm <= 0) {
|
|
6
|
+
throw new Error("Font has no units per em.");
|
|
7
|
+
}
|
|
8
|
+
const trimmed = ((Math.abs(units) / upm) * 100).toFixed(4).replace(/\.?0+$/, "");
|
|
9
|
+
return `${trimmed}%`;
|
|
10
|
+
}
|
|
11
|
+
export function fontSrc(cssFile, fontFile) {
|
|
12
|
+
const rel = relative(dirname(cssFile), fontFile).split("\\").join("/");
|
|
13
|
+
if (!rel || rel.startsWith("."))
|
|
14
|
+
return rel || ".";
|
|
15
|
+
return `./${rel}`;
|
|
16
|
+
}
|
|
17
|
+
export function fontFormat(fontFile) {
|
|
18
|
+
switch (extname(fontFile).toLowerCase()) {
|
|
19
|
+
case ".woff2":
|
|
20
|
+
return "woff2";
|
|
21
|
+
case ".woff":
|
|
22
|
+
return "woff";
|
|
23
|
+
case ".otf":
|
|
24
|
+
return "opentype";
|
|
25
|
+
case ".ttf":
|
|
26
|
+
return "truetype";
|
|
27
|
+
default:
|
|
28
|
+
return null;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
function cssString(value) {
|
|
32
|
+
const escaped = value.replace(/\\/g, "\\\\").replace(/"/g, '\\"').replace(/\r?\n/g, " ");
|
|
33
|
+
return `"${escaped}"`;
|
|
34
|
+
}
|
|
35
|
+
function cssStyle(style) {
|
|
36
|
+
if (style === "italic" || style === "oblique")
|
|
37
|
+
return style;
|
|
38
|
+
return "normal";
|
|
39
|
+
}
|
|
40
|
+
function cssWeight(weight) {
|
|
41
|
+
if (!weight || weight < 1 || weight > 1000)
|
|
42
|
+
return 400;
|
|
43
|
+
return Math.round(weight);
|
|
44
|
+
}
|
|
45
|
+
const FORMAT_ORDER = ["woff2", "woff", "truetype", "opentype"];
|
|
46
|
+
function faceKey(face) {
|
|
47
|
+
return [
|
|
48
|
+
face.family,
|
|
49
|
+
cssWeight(face.weight),
|
|
50
|
+
cssStyle(face.style),
|
|
51
|
+
face.ascent,
|
|
52
|
+
face.descent,
|
|
53
|
+
face.lineGap,
|
|
54
|
+
face.upm,
|
|
55
|
+
].join("\0");
|
|
56
|
+
}
|
|
57
|
+
function srcRank(fontFile) {
|
|
58
|
+
const format = fontFormat(fontFile);
|
|
59
|
+
const index = format ? FORMAT_ORDER.indexOf(format) : -1;
|
|
60
|
+
return index === -1 ? FORMAT_ORDER.length : index;
|
|
61
|
+
}
|
|
62
|
+
export function groupFaces(faces) {
|
|
63
|
+
const groups = [];
|
|
64
|
+
const indexByKey = new Map();
|
|
65
|
+
for (const face of faces) {
|
|
66
|
+
const key = faceKey(face);
|
|
67
|
+
const existing = indexByKey.get(key);
|
|
68
|
+
if (existing == null) {
|
|
69
|
+
indexByKey.set(key, groups.length);
|
|
70
|
+
groups.push([face]);
|
|
71
|
+
continue;
|
|
72
|
+
}
|
|
73
|
+
groups[existing].push(face);
|
|
74
|
+
}
|
|
75
|
+
return groups.map((group) => [...group].sort((a, b) => srcRank(a.fontPath) - srcRank(b.fontPath)));
|
|
76
|
+
}
|
|
77
|
+
function srcList(faces, cssFile) {
|
|
78
|
+
return faces
|
|
79
|
+
.map((face) => {
|
|
80
|
+
const url = `url(${cssString(fontSrc(cssFile, face.fontPath))})`;
|
|
81
|
+
const format = fontFormat(face.fontPath);
|
|
82
|
+
return format ? `${url} format(${cssString(format)})` : url;
|
|
83
|
+
})
|
|
84
|
+
.join(",\n ");
|
|
85
|
+
}
|
|
86
|
+
export function formatFontFace(faces, cssFile) {
|
|
87
|
+
const face = faces[0];
|
|
88
|
+
if (!face)
|
|
89
|
+
throw new Error("No font faces to write.");
|
|
90
|
+
return [
|
|
91
|
+
"@font-face {",
|
|
92
|
+
` font-family: ${cssString(face.family)};`,
|
|
93
|
+
` src: ${srcList(faces, cssFile)};`,
|
|
94
|
+
` font-weight: ${cssWeight(face.weight)};`,
|
|
95
|
+
` font-style: ${cssStyle(face.style)};`,
|
|
96
|
+
` ascent-override: ${metricPercent(face.ascent, face.upm)};`,
|
|
97
|
+
` descent-override: ${metricPercent(face.descent, face.upm)};`,
|
|
98
|
+
` line-gap-override: ${metricPercent(face.lineGap, face.upm)};`,
|
|
99
|
+
"}",
|
|
100
|
+
].join("\n");
|
|
101
|
+
}
|
|
102
|
+
export function formatStylesheet(faces, cssFile) {
|
|
103
|
+
const blocks = groupFaces(faces).map((group) => formatFontFace(group, cssFile));
|
|
104
|
+
return `${HEADER}\n\n${blocks.join("\n\n")}\n`;
|
|
105
|
+
}
|
|
106
|
+
export function cssFaces(files, outcomes) {
|
|
107
|
+
const faces = [];
|
|
108
|
+
files.forEach((file, index) => {
|
|
109
|
+
const outcome = outcomes[index];
|
|
110
|
+
if (!outcome || outcome.status !== "css")
|
|
111
|
+
return;
|
|
112
|
+
const after = outcome.result.after;
|
|
113
|
+
if (!after)
|
|
114
|
+
return;
|
|
115
|
+
faces.push(faceFromResult(file, outcome.result));
|
|
116
|
+
});
|
|
117
|
+
return faces;
|
|
118
|
+
}
|
|
119
|
+
function faceFromResult(file, result) {
|
|
120
|
+
const after = result.after;
|
|
121
|
+
if (!after) {
|
|
122
|
+
throw new Error(`No metrics for ${file.name}`);
|
|
123
|
+
}
|
|
124
|
+
return {
|
|
125
|
+
family: result.family?.trim() || file.name,
|
|
126
|
+
weight: result.weight ?? 400,
|
|
127
|
+
style: result.style ?? "normal",
|
|
128
|
+
fontPath: file.abs,
|
|
129
|
+
ascent: after.ascent,
|
|
130
|
+
descent: after.descent,
|
|
131
|
+
lineGap: after.lineGap,
|
|
132
|
+
upm: after.upm,
|
|
133
|
+
};
|
|
134
|
+
}
|
package/dist/index.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { mkdir, writeFile } from "node:fs/promises";
|
|
2
|
+
import { basename, dirname, resolve } from "node:path";
|
|
2
3
|
import { pathToFileURL } from "node:url";
|
|
3
4
|
import { HELP, modeOf, parseArgs } from "./args.js";
|
|
4
5
|
import { emptyConfig, findConfig, mergeConfig } from "./config.js";
|
|
@@ -7,6 +8,7 @@ import { runEngine } from "./engine.js";
|
|
|
7
8
|
import { formatOffset, outputPath } from "./paths.js";
|
|
8
9
|
import { formatReports } from "./report.js";
|
|
9
10
|
import { ProgressStack } from "./progress.js";
|
|
11
|
+
import { cssFaces, formatStylesheet } from "./css.js";
|
|
10
12
|
import { EULA, invocationDir, packageVersion, resolvePython } from "./runtime.js";
|
|
11
13
|
import { pathExists } from "./fs-exists.js";
|
|
12
14
|
function phaseFromEngine(phase) {
|
|
@@ -58,8 +60,18 @@ async function processFile(file, options) {
|
|
|
58
60
|
function alreadyGoodDetail(result) {
|
|
59
61
|
return `already good ${formatOffset(result.before?.offset ?? result.after?.offset)}`;
|
|
60
62
|
}
|
|
63
|
+
const CSS_NOTICE = "This stylesheet overrides the line box in CSS. It does not rewrite the font file.";
|
|
61
64
|
function finishResult(id, dest, result, mode, stack) {
|
|
62
65
|
const offset = formatOffset(result.before?.offset);
|
|
66
|
+
if (mode === "css") {
|
|
67
|
+
const good = result.off === false;
|
|
68
|
+
stack.update(id, {
|
|
69
|
+
percent: 100,
|
|
70
|
+
phase: good ? "ok" : "done",
|
|
71
|
+
detail: good ? alreadyGoodDetail(result) : `${offset} → ${formatOffset(result.after?.offset)}`,
|
|
72
|
+
});
|
|
73
|
+
return { status: "css", result };
|
|
74
|
+
}
|
|
63
75
|
if (result.off === false) {
|
|
64
76
|
stack.update(id, { percent: 100, phase: "ok", detail: alreadyGoodDetail(result) });
|
|
65
77
|
return { status: "ok", result };
|
|
@@ -88,6 +100,7 @@ function summarize(outcomes, mode) {
|
|
|
88
100
|
wrote: outcomes.filter((item) => item.status === "wrote").length,
|
|
89
101
|
would: outcomes.filter((item) => item.status === "would-write").length,
|
|
90
102
|
ok: outcomes.filter((item) => item.status === "ok").length,
|
|
103
|
+
css: outcomes.filter((item) => item.status === "css").length,
|
|
91
104
|
off: outcomes.filter((item) => item.status === "off").length,
|
|
92
105
|
skipped: outcomes.filter((item) => item.status === "skipped").length,
|
|
93
106
|
failed: outcomes.filter((item) => item.status === "failed").length,
|
|
@@ -107,6 +120,9 @@ function summarize(outcomes, mode) {
|
|
|
107
120
|
parts.push(`${counts.off} off`);
|
|
108
121
|
parts.push(`${counts.ok} ok`);
|
|
109
122
|
}
|
|
123
|
+
if (mode === "css") {
|
|
124
|
+
parts.push(`${counts.css} measured`);
|
|
125
|
+
}
|
|
110
126
|
if (counts.skipped)
|
|
111
127
|
parts.push(`${counts.skipped} skipped`);
|
|
112
128
|
if (counts.failed)
|
|
@@ -118,22 +134,35 @@ function exitCode(outcomes, mode) {
|
|
|
118
134
|
return 1;
|
|
119
135
|
if (mode === "check" && outcomes.some((item) => item.status === "off"))
|
|
120
136
|
return 1;
|
|
137
|
+
if (mode === "css" && !outcomes.some((item) => item.status === "css"))
|
|
138
|
+
return 1;
|
|
121
139
|
if (outcomes.length === 0)
|
|
122
140
|
return 1;
|
|
123
141
|
return 0;
|
|
124
142
|
}
|
|
125
143
|
async function resolveTargets(args, cwd, config) {
|
|
126
|
-
if (args.
|
|
127
|
-
const
|
|
128
|
-
|
|
129
|
-
|
|
144
|
+
if (args.paths.length) {
|
|
145
|
+
const found = [];
|
|
146
|
+
const seen = new Set();
|
|
147
|
+
for (const entry of args.paths) {
|
|
148
|
+
const target = resolve(cwd, entry);
|
|
149
|
+
if (!(await pathExists(target))) {
|
|
150
|
+
throw new Error(`Not found: ${entry}`);
|
|
151
|
+
}
|
|
152
|
+
const files = await discover(target, config);
|
|
153
|
+
for (const file of files) {
|
|
154
|
+
if (seen.has(file.abs))
|
|
155
|
+
continue;
|
|
156
|
+
seen.add(file.abs);
|
|
157
|
+
found.push(file);
|
|
158
|
+
}
|
|
130
159
|
}
|
|
131
|
-
return
|
|
160
|
+
return found;
|
|
132
161
|
}
|
|
133
162
|
if (config.include.length) {
|
|
134
163
|
return discoverFromInclude(cwd, config);
|
|
135
164
|
}
|
|
136
|
-
throw new Error("Point it at a file or a folder. See --help.");
|
|
165
|
+
throw new Error("Point it at a file, a set of files, or a folder. See --help.");
|
|
137
166
|
}
|
|
138
167
|
export async function main(argv = process.argv.slice(2)) {
|
|
139
168
|
let args;
|
|
@@ -161,7 +190,7 @@ export async function main(argv = process.argv.slice(2)) {
|
|
|
161
190
|
console.error(error instanceof Error ? error.message : error);
|
|
162
191
|
return 2;
|
|
163
192
|
}
|
|
164
|
-
if (!config.suffix && !args.inPlace) {
|
|
193
|
+
if (!config.suffix && !args.inPlace && !args.cssPath) {
|
|
165
194
|
console.error("A suffix is required unless you pass --in-place. Default is -normalized.");
|
|
166
195
|
return 2;
|
|
167
196
|
}
|
|
@@ -193,7 +222,7 @@ export async function main(argv = process.argv.slice(2)) {
|
|
|
193
222
|
};
|
|
194
223
|
process.on("SIGINT", onInterrupt);
|
|
195
224
|
process.on("SIGTERM", onInterrupt);
|
|
196
|
-
stack.identify(files.map((file) => ({ id: file.abs, label: files.length > 1 ? file.rel : file.name })), EULA);
|
|
225
|
+
stack.identify(files.map((file) => ({ id: file.abs, label: files.length > 1 ? file.rel : file.name })), mode === "css" ? CSS_NOTICE : EULA);
|
|
197
226
|
const outcomes = [];
|
|
198
227
|
for (const file of files) {
|
|
199
228
|
outcomes.push(await processFile(file, { python, mode, inPlace: args.inPlace, config, stack }));
|
|
@@ -204,6 +233,19 @@ export async function main(argv = process.argv.slice(2)) {
|
|
|
204
233
|
const reports = formatReports(outcomes);
|
|
205
234
|
if (reports)
|
|
206
235
|
process.stdout.write(`\n${reports}\n`);
|
|
236
|
+
if (mode === "css" && args.cssPath) {
|
|
237
|
+
const faces = cssFaces(files, outcomes);
|
|
238
|
+
if (!faces.length) {
|
|
239
|
+
console.error("No faces to write.");
|
|
240
|
+
}
|
|
241
|
+
else {
|
|
242
|
+
const cssFile = resolve(cwd, args.cssPath);
|
|
243
|
+
await mkdir(dirname(cssFile), { recursive: true });
|
|
244
|
+
await writeFile(cssFile, formatStylesheet(faces, cssFile), "utf8");
|
|
245
|
+
const wrote = process.stderr.isTTY ? process.stderr : process.stdout;
|
|
246
|
+
wrote.write(`\nWrote ${cssFile}\n`);
|
|
247
|
+
}
|
|
248
|
+
}
|
|
207
249
|
const summary = summarize(outcomes, mode);
|
|
208
250
|
if (summary) {
|
|
209
251
|
const stream = process.stderr.isTTY ? process.stderr : process.stdout;
|
package/lib/engine.py
CHANGED
|
@@ -86,6 +86,13 @@ def measure_bbox(font: TTFont, progress: Progress | None = None) -> tuple[int, i
|
|
|
86
86
|
return y_min, y_max
|
|
87
87
|
|
|
88
88
|
|
|
89
|
+
def units_percent(units: float, upm: int) -> float:
|
|
90
|
+
"""CSS ascent-override / descent-override: |units| as a percent of the em."""
|
|
91
|
+
if upm <= 0:
|
|
92
|
+
return 0.0
|
|
93
|
+
return round(abs(units) / upm * 100, 1)
|
|
94
|
+
|
|
95
|
+
|
|
89
96
|
def snapshot(font: TTFont, cap: int, x_height: int) -> dict:
|
|
90
97
|
upm = font["head"].unitsPerEm
|
|
91
98
|
hhea = font["hhea"]
|
|
@@ -108,10 +115,35 @@ def snapshot(font: TTFont, cap: int, x_height: int) -> dict:
|
|
|
108
115
|
"below": round(below / upm * 1000, 1),
|
|
109
116
|
"offset": round(offset_pm, 1),
|
|
110
117
|
"centered": centered,
|
|
118
|
+
"ascentOverride": units_percent(hhea.ascent, upm),
|
|
119
|
+
"descentOverride": units_percent(hhea.descent, upm),
|
|
111
120
|
"grade": grade,
|
|
112
121
|
}
|
|
113
122
|
|
|
114
123
|
|
|
124
|
+
ITALIC = 1 << 0
|
|
125
|
+
OBLIQUE = 1 << 9
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def face_style(font: TTFont) -> str:
|
|
129
|
+
selection = int(getattr(font["OS/2"], "fsSelection", 0))
|
|
130
|
+
if selection & ITALIC:
|
|
131
|
+
return "italic"
|
|
132
|
+
if selection & OBLIQUE:
|
|
133
|
+
return "oblique"
|
|
134
|
+
subfamily = (font["name"].getDebugName(2) or "").lower()
|
|
135
|
+
if "italic" in subfamily:
|
|
136
|
+
return "italic"
|
|
137
|
+
if "oblique" in subfamily:
|
|
138
|
+
return "oblique"
|
|
139
|
+
return "normal"
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def face_weight(font: TTFont) -> int:
|
|
143
|
+
weight = int(getattr(font["OS/2"], "usWeightClass", 400) or 400)
|
|
144
|
+
return weight
|
|
145
|
+
|
|
146
|
+
|
|
115
147
|
def set_name_suffix(font: TTFont, suffix: str = " Normalized") -> None:
|
|
116
148
|
name = font["name"]
|
|
117
149
|
for rec in list(name.names):
|
|
@@ -225,6 +257,8 @@ def normalize(font: TTFont, progress: Progress | None = None) -> dict:
|
|
|
225
257
|
family = font["name"].getDebugName(1) or "Font"
|
|
226
258
|
return {
|
|
227
259
|
"family": family.replace(" Normalized", ""),
|
|
260
|
+
"weight": face_weight(font),
|
|
261
|
+
"style": face_style(font),
|
|
228
262
|
"before": before,
|
|
229
263
|
"after": after,
|
|
230
264
|
"off": off,
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "normalize-metrics",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "Fix font vertical metrics so a word sits in the box, as CSS overrides or a rewritten file",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": {
|
|
7
7
|
"name": "Oles Gergun",
|
|
@@ -9,12 +9,12 @@
|
|
|
9
9
|
},
|
|
10
10
|
"repository": {
|
|
11
11
|
"type": "git",
|
|
12
|
-
"url": "git+https://github.com/gerguno/normalize-metrics
|
|
12
|
+
"url": "git+https://github.com/gerguno/normalize-metrics.git"
|
|
13
13
|
},
|
|
14
14
|
"bugs": {
|
|
15
|
-
"url": "https://github.com/gerguno/normalize-metrics
|
|
15
|
+
"url": "https://github.com/gerguno/normalize-metrics/issues"
|
|
16
16
|
},
|
|
17
|
-
"homepage": "https://
|
|
17
|
+
"homepage": "https://normalize-metrics.olesgergun.com",
|
|
18
18
|
"keywords": [
|
|
19
19
|
"font",
|
|
20
20
|
"fonts",
|
|
@@ -23,6 +23,8 @@
|
|
|
23
23
|
"otf",
|
|
24
24
|
"ttf",
|
|
25
25
|
"woff",
|
|
26
|
+
"woff2",
|
|
27
|
+
"css",
|
|
26
28
|
"typography",
|
|
27
29
|
"cli"
|
|
28
30
|
],
|
|
@@ -41,14 +43,27 @@
|
|
|
41
43
|
"access": "public"
|
|
42
44
|
},
|
|
43
45
|
"scripts": {
|
|
46
|
+
"dev": "next dev",
|
|
47
|
+
"build": "next build",
|
|
48
|
+
"start": "next start",
|
|
49
|
+
"deploy": "next build && next start -p 3014",
|
|
44
50
|
"cli": "node --experimental-strip-types --experimental-default-type=module --no-warnings=ExperimentalWarning cli/index.ts",
|
|
45
|
-
"test": "node --experimental-strip-types --experimental-default-type=module --test cli/*.test.ts",
|
|
46
|
-
"build": "tsc -p cli/tsconfig.build.json && printf '{\"type\":\"module\"}\\n' > dist/package.json",
|
|
47
|
-
"prepack": "npm run build",
|
|
48
|
-
"prepublishOnly": "npm test"
|
|
51
|
+
"test:cli": "node --experimental-strip-types --experimental-default-type=module --test cli/*.test.ts",
|
|
52
|
+
"build:cli": "tsc -p cli/tsconfig.build.json && printf '{\"type\":\"module\"}\\n' > dist/package.json",
|
|
53
|
+
"prepack": "npm run build:cli",
|
|
54
|
+
"prepublishOnly": "npm run test:cli"
|
|
49
55
|
},
|
|
50
56
|
"devDependencies": {
|
|
51
57
|
"@types/node": "^22.18.1",
|
|
58
|
+
"@types/react": "^19.1.12",
|
|
59
|
+
"@types/react-dom": "^19.1.9",
|
|
60
|
+
"motion": "^13.4.0",
|
|
61
|
+
"next": "^15.5.3",
|
|
62
|
+
"overlayscrollbars": "^2.16.0",
|
|
63
|
+
"overlayscrollbars-react": "^0.5.6",
|
|
64
|
+
"react": "^19.1.0",
|
|
65
|
+
"react-dom": "^19.1.0",
|
|
66
|
+
"sass": "^1.104.1",
|
|
52
67
|
"typescript": "^5.9.2"
|
|
53
68
|
}
|
|
54
69
|
}
|