char/iocgn-mono

iosevka variant inspired by Simple Köln-Bonn

git clone https://git.t4t.associates/char/iocgn-mono

Charlotte Somkerning tweaksa3bd267

main
13.6 KiB256 linesraw
1import { CombineGeometry, ContourSetGeometry, ReferenceGeometry, SimplifyGeometry } from '@iosevka/geometry';
2import { Anchor } from '@iosevka/geometry/anchor';
3import { Point } from '@iosevka/geometry/point';
4import { Transform } from '@iosevka/geometry/transform';
5import { Glyph } from '@iosevka/glyph';
6import { Dotless } from '@iosevka/glyph/relation';
7import { GlyphStore } from '@iosevka/glyph/store';
8import { refineArrows } from './iocgn-arrows.mjs';
9import config from './iocgn-config.mjs';
10import { bounds, roundCorners } from './iocgn-round.mjs';
11
12const CELL = 500, MARGIN = 14;
13const MARKS = [0x300, 0x301, 0x302, 0x303, 0x304, 0x306, 0x307, 0x308, 0x30A, 0x30B, 0x30C, 0x327, 0x328];
14const range = (first, last) => Array.from({ length: last - first + 1 }, (_, i) => first + i);
15const codes = string => [...string].map(char => char.codePointAt(0));
16const ARROWS = new Set(codes(config.terminal.arrows));
17const CELL_GRAPHICS = new Set(config.terminal.ranges.flatMap(([first, last]) => range(first, last)));
18
19// Geometry widths are independent of the exported 500-unit advances.
20function geometryWidth(cp) {
21    if (cp === 0x2C6) return 540;
22    if (CELL_GRAPHICS.has(cp) || (MARKS.includes(cp) && cp !== 0x327)) return 500;
23    const char = String.fromCodePoint(cp);
24    if (cp < 384 && !config.symbols.characters.includes(char)) {
25        const base = char.normalize('NFD')[0];
26        for (const width of [540, 500]) if (config.geometry[width].includes(base)) return width;
27    }
28    return 660;
29}
30
31const transformed = (contours, tfm) => contours.map(c => c.map(z => Point.transformed(tfm, z)));
32const translated = (contours, x, y) => transformed(contours, Transform.Translate(x, y));
33
34function cellTransform(contours, width, scale = 1, nudge = 0) {
35    const box = bounds(contours);
36    let dx = (CELL - width * scale) / 2;
37    if (box && scale * (box.xMax - box.xMin) <= CELL - 2 * MARGIN) {
38        dx = Math.max(MARGIN - scale * box.xMin, Math.min(dx, CELL - MARGIN - scale * box.xMax));
39    }
40    return new Transform(scale, 0, 0, 1, dx + nudge, 0);
41}
42
43export function buildNative(para, buildGlyphs) {
44    const base = buildGlyphs(para.createFork(argv => { argv.shape.width = 660; }), undefined, true);
45    const { executor } = base;
46    const forks = new Map([[660, base]]);
47    const candidates = [...range(32, 383), 0x2C6, ...MARKS, ...CELL_GRAPHICS];
48    for (const width of [540, 500]) {
49        const codes = candidates.filter(cp => geometryWidth(cp) === width);
50        const forkPara = para.createFork(argv => { argv.shape.width = width; });
51        forkPara.koelnCompactM = width === 500;
52        const roots = codes.map(cp => base.glyphStore.queryByUnicodeEnsured(cp));
53        // Enclosure registration also resolves its mosaic ingredients eagerly.
54        if (codes.includes(0xA9)) {
55            roots.push(base.glyphStore.queryByUnicodeEnsured(0x2714), base.glyphStore.queryByUnicodeEnsured(0x21B2));
56        }
57        // Miniature ingredients belong to another executor's dependency graph.
58        if (codes.includes(0xAE)) roots.push(base.glyphStore.queryByNameEnsured('combRingCap/adwsN'));
59        const filter = executor.dependencyManager.traverseDependencies(roots);
60        // Derived glyph names depend on geometry, so only filter whole blocks.
61        filter.glyphIsNeeded = () => true;
62        const blockIsNeeded = filter.blockIsNeeded.bind(filter);
63        const rootBlocks = new Set(roots.map(glyph => executor.dependencyManager.glyphToBlock.get(glyph)));
64        filter.blockIsNeeded = id => id === 'AutoBuild-Accents' ||
65            (/^autobuild-/i.test(id) ? rootBlocks.has(id) : blockIsNeeded(id));
66        filter.koeln = true;
67        filter.koelnCodes = new Set(codes);
68        forks.set(width, buildGlyphs(forkPara, filter, true));
69    }
70    refineArrows(base.glyphStore);
71    return { glyphStore: assemble(forks, base.fontMetrics), fontMetrics: base.fontMetrics };
72}
73
74// Outlines are simplified, rounded and placed here; Iosevka exports them verbatim.
75function assemble(forks, fontMetrics) {
76    const xHeight = fontMetrics.os2.sxHeight, capHeight = fontMetrics.os2.sCapHeight;
77    const ascent = fontMetrics.os2.sTypoAscender, descent = fontMetrics.os2.sTypoDescender;
78    // hhea mirrors the typographic line box.
79    fontMetrics.hhea.descender = descent;
80    fontMetrics.hhea.lineGap = fontMetrics.os2.sTypoLineGap;
81    fontMetrics.os2.xAvgCharWidth = CELL;
82
83    const sourceOf = (cp, width = geometryWidth(cp)) => forks.get(width).glyphStore.queryByUnicodeEnsured(cp);
84    const radius = width => {
85        const stroke = forks.get(width).metrics.Stroke;
86        return Math.max(stroke * .16, stroke * .5 - 21.5);
87    };
88    // Terminal symbols and cell graphics keep their sharp corners.
89    const radiusOf = cp => cp < 0x2100 ? radius(geometryWidth(cp)) : 0;
90
91    const outlines = new Map();
92    // Like TrueType components, referenced glyphs are simplified and rounded on their own.
93    function outline(glyph, r) {
94        if (!outlines.has(glyph)) outlines.set(glyph, new Map());
95        const memo = outlines.get(glyph);
96        if (memo.has(r)) return memo.get(r);
97        const refs = glyph.geometry.toReferences();
98        let contours;
99        if (refs) contours = refs.flatMap(ref => translated(outline(ref.glyph, r), ref.x, ref.y));
100        // Simplifying these polygons makes their point topology vary across weights.
101        else if (glyph.koelnPreserveTopology) contours = glyph.geometry.toContours();
102        else {
103            contours = SimplifyGeometry.wrapWithGizmo(glyph.geometry, glyph.gizmo).toContours();
104            if (r) contours = roundCorners(contours, r);
105        }
106        memo.set(r, contours);
107        return contours;
108    }
109
110    const store = new GlyphStore();
111    function addGlyph(name, source, geometry, anchorTransform, advance) {
112        if (store.queryByName(name)) throw new Error(`Duplicate glyph name ${name}`);
113        const glyph = new Glyph(name);
114        // Other relations name glyphs outside this store.
115        if (Dotless.get(source)) Dotless.set(glyph, Dotless.get(source));
116        glyph.cloneRankFromGlyph(source);
117        if (anchorTransform) {
118            for (const kind of ['baseAnchors', 'markAnchors']) {
119                for (const [key, anchor] of Object.entries(source[kind])) {
120                    glyph[kind][key] = Anchor.transform(anchorTransform, anchor);
121                }
122            }
123        }
124        glyph.geometry = geometry;
125        glyph.koelnFinal = true;
126        glyph.advanceWidth = advance;
127        store.addGlyph(name, glyph);
128        return glyph;
129    }
130
131    const placed = new Map();
132    function emit(cp, contours, anchorTransform, encode = true) {
133        const fork = forks.get(geometryWidth(cp)).glyphStore;
134        const source = fork.queryByUnicodeEnsured(cp);
135        placed.set(cp, contours);
136        const glyph = addGlyph(fork.queryNameOf(source), source, new ContourSetGeometry(contours),
137            anchorTransform, MARKS.includes(cp) ? 0 : CELL);
138        if (encode) store.encodeGlyph(cp, glyph);
139        return glyph;
140    }
141
142    const notdef = forks.get(660).glyphStore.queryByNameEnsured('.notdef');
143    const notdefContours = outline(notdef, 0);
144    const notdefTransform = cellTransform(notdefContours, 660);
145    addGlyph('.notdef', notdef, new ContourSetGeometry(transformed(notdefContours, notdefTransform)), notdefTransform, CELL);
146
147    const offsets = new Map();
148    for (const cp of range(32, 126)) {
149        const contours = outline(sourceOf(cp), radiusOf(cp));
150        const tfm = cellTransform(contours, geometryWidth(cp), 1, config.nudge[String.fromCodePoint(cp)] ?? 0);
151        let result = transformed(contours, tfm);
152        if (cp === 0x69 || cp === 0x6A) {
153            // Keep the approved tittles independent of accent metrics.
154            const ys = result.flat().filter(z => z.y > xHeight).map(z => z.y);
155            const shift = Math.round(xHeight + 150 - (Math.min(...ys) + Math.max(...ys)) / 2);
156            result = result.map(c => c.map(z => z.y > xHeight ? Point.translated(z, 0, shift) : z));
157        }
158        offsets.set(cp, tfm.tx);
159        emit(cp, result, tfm);
160    }
161    const dotless = new Map();
162    for (const [cp, letter] of [[0x131, 0x69], [0x237, 0x6A]]) {
163        const tfm = Transform.Translate(offsets.get(letter), 0);
164        dotless.set(letter, emit(cp, transformed(outline(sourceOf(cp), radiusOf(cp)), tfm), tfm, cp === 0x131));
165    }
166
167    const decompositions = new Map();
168    for (const cp of range(160, 383)) {
169        const parts = codes(String.fromCodePoint(cp).normalize('NFD'));
170        if (parts.length === 2) decompositions.set(cp, parts);
171        else if (cp === 0xA1 || cp === 0xBF) {
172            const upright = placed.get(cp === 0xA1 ? 0x21 : 0x3F);
173            emit(cp, transformed(upright, new Transform(-1, 0, 0, -1, CELL, xHeight)), null);
174        } else if (cp === 0xB1) {
175            emit(cp, [...translated(placed.get(0x2B), 0, 150), ...translated(placed.get(0x2D), 0, -308)], null);
176        } else if (cp !== 0x131) {
177            const contours = outline(sourceOf(cp), radiusOf(cp)), box = bounds(contours);
178            const scale = box ? Math.min(1, (CELL - 2 * MARGIN) / (box.xMax - box.xMin)) : 1;
179            const tfm = cellTransform(contours, geometryWidth(cp), scale, config.nudge[String.fromCodePoint(cp)] ?? 0);
180            emit(cp, transformed(contours, tfm), tfm);
181        }
182    }
183
184    for (const cp of MARKS) {
185        const tfm = Transform.Translate((CELL + geometryWidth(cp)) / 2, 0);
186        emit(cp, transformed(outline(sourceOf(cp), radiusOf(cp)), tfm), tfm);
187    }
188
189    for (const [cp, [base, mark]] of decompositions) {
190        const width = geometryWidth(base), dx = offsets.get(base), r = radius(width);
191        // A lone reference is a merged letter-and-mark design, which Iosevka would export as one outline.
192        const source = sourceOf(cp), refs = source.geometry.toReferences();
193        let accent;
194        if (mark === 0x30B) {
195            // Wider native marks merge the two acute strokes at heavy weights.
196            const upper = String.fromCodePoint(base) !== String.fromCodePoint(base).toLowerCase();
197            accent = translated(outline(sourceOf(mark), radiusOf(mark)), CELL, upper ? capHeight - xHeight : 0);
198        } else if (width === 540 && mark === 0x302) {
199            // Preserve the spacing accent's unsplit outline on the narrow w.
200            accent = translated(outline(sourceOf(0x2C6), r), dx, 0);
201        } else if (refs?.length > 1) {
202            // Apostrophe-carons use reverse component order upstream.
203            const baseIndex = [0x10F, 0x13D, 0x13E, 0x165].includes(cp) ? refs.length - 1 : 0;
204            accent = refs.filter((_, i) => i !== baseIndex)
205                .flatMap(ref => translated(outline(ref.glyph, r), ref.x + dx, ref.y));
206        } else {
207            const markWidth = mark === 0x328 && width === 660 ? 660 : geometryWidth(mark);
208            let x = width + dx;
209            if (mark === 0x328) x += (bounds(outline(sourceOf(base), r)).xMax - width / 2) * .65;
210            accent = translated(outline(sourceOf(mark, markWidth), radius(markWidth)), x, 0);
211        }
212        const box = bounds(accent);
213        if (box) {
214            // Preserve d's bowl rather than narrowing it for the apostrophe-caron.
215            const shiftY = cp === 0x10F ? Math.max(0, bounds(placed.get(base)).yMax + 40 - box.yMin) : 0;
216            accent = translated(accent, Math.max(MARGIN - box.xMin, Math.min(0, CELL - MARGIN - box.xMax)), shiftY);
217        }
218        const name = forks.get(width).glyphStore.queryNameOf(source);
219        const accentGlyph = addGlyph(`accent.${name}`, source, new ContourSetGeometry(accent), null, 0);
220        const baseGlyph = dotless.has(base) && mark < 0x320 ? dotless.get(base) : store.queryByUnicode(base);
221        const composite = addGlyph(name, source, new CombineGeometry([
222            new ReferenceGeometry(baseGlyph, 0, 0), new ReferenceGeometry(accentGlyph, 0, 0),
223        ]), Transform.Translate(dx, 0), CELL);
224        store.encodeGlyph(cp, composite);
225    }
226
227    for (const cp of [...CELL_GRAPHICS, ...ARROWS]) emit(cp, outline(sourceOf(cp), 0), Transform.Id());
228    for (const cp of codes(config.terminal.characters)) {
229        if (cp === 0xFFFD) {
230            // A stable diamond/question cutout avoids upstream contour collapse.
231            const diamond = [[250, 800], [485, 340], [250, -120], [15, 340]].map(([x, y]) => Point.corner(x, y));
232            const question = transformed(placed.get(0x3F), new Transform(.46, 0, 0, .46, 135, 171));
233            emit(cp, [diamond, ...question.map(c => [...c].reverse())], null);
234            continue;
235        }
236        const contours = outline(sourceOf(cp), radiusOf(cp)), box = bounds(contours);
237        const scale = Math.min(1, (cp === 0x2013 ? 400 : CELL - 2 * MARGIN) / (box.xMax - box.xMin));
238        // Geometric indicators should remain circles and squares, not ellipses.
239        const vertical = Math.min(0x25A0 <= cp && cp <= 0x2718 ? scale : 1, (ascent - descent) / (box.yMax - box.yMin));
240        const dy = Math.max(descent - vertical * box.yMin,
241            Math.min((1 - vertical) * (ascent + descent) / 2, ascent - vertical * box.yMax));
242        const tfm = new Transform(scale, 0, 0, vertical, CELL / 2 - scale * (box.xMin + box.xMax) / 2, dy);
243        emit(cp, transformed(contours, tfm), tfm);
244    }
245
246    for (const [cp, contours] of placed) {
247        const box = bounds(contours);
248        if (!box || !store.queryByUnicode(cp)) continue;
249        const exact = CELL_GRAPHICS.has(cp) || ARROWS.has(cp);
250        const margin = exact ? (0x2571 <= cp && cp <= 0x2573 ? -64 : 0) : MARGIN - 1;
251        if (Math.round(box.xMin) < margin || Math.round(box.xMax) > CELL - margin) {
252            throw new Error(`U+${cp.toString(16).toUpperCase()} ink ${box.xMin}..${box.xMax} leaves its cell`);
253        }
254    }
255    return store;
256}