import { CombineGeometry, ContourSetGeometry, ReferenceGeometry, SimplifyGeometry } from '@iosevka/geometry'; import { Anchor } from '@iosevka/geometry/anchor'; import { Point } from '@iosevka/geometry/point'; import { Transform } from '@iosevka/geometry/transform'; import { Glyph } from '@iosevka/glyph'; import { Dotless } from '@iosevka/glyph/relation'; import { GlyphStore } from '@iosevka/glyph/store'; import { refineArrows } from './iocgn-arrows.mjs'; import config from './iocgn-config.mjs'; import { bounds, roundCorners } from './iocgn-round.mjs'; const CELL = 500, MARGIN = 14; const MARKS = [0x300, 0x301, 0x302, 0x303, 0x304, 0x306, 0x307, 0x308, 0x30A, 0x30B, 0x30C, 0x327, 0x328]; const range = (first, last) => Array.from({ length: last - first + 1 }, (_, i) => first + i); const codes = string => [...string].map(char => char.codePointAt(0)); const ARROWS = new Set(codes(config.terminal.arrows)); const CELL_GRAPHICS = new Set(config.terminal.ranges.flatMap(([first, last]) => range(first, last))); // Geometry widths are independent of the exported 500-unit advances. function geometryWidth(cp) { if (cp === 0x2C6) return 540; if (CELL_GRAPHICS.has(cp) || (MARKS.includes(cp) && cp !== 0x327)) return 500; const char = String.fromCodePoint(cp); if (cp < 384 && !config.symbols.characters.includes(char)) { const base = char.normalize('NFD')[0]; for (const width of [540, 500]) if (config.geometry[width].includes(base)) return width; } return 660; } const transformed = (contours, tfm) => contours.map(c => c.map(z => Point.transformed(tfm, z))); const translated = (contours, x, y) => transformed(contours, Transform.Translate(x, y)); function cellTransform(contours, width, scale = 1, nudge = 0) { const box = bounds(contours); let dx = (CELL - width * scale) / 2; if (box && scale * (box.xMax - box.xMin) <= CELL - 2 * MARGIN) { dx = Math.max(MARGIN - scale * box.xMin, Math.min(dx, CELL - MARGIN - scale * box.xMax)); } return new Transform(scale, 0, 0, 1, dx + nudge, 0); } export function buildNative(para, buildGlyphs) { const base = buildGlyphs(para.createFork(argv => { argv.shape.width = 660; }), undefined, true); const { executor } = base; const forks = new Map([[660, base]]); const candidates = [...range(32, 383), 0x2C6, ...MARKS, ...CELL_GRAPHICS]; for (const width of [540, 500]) { const codes = candidates.filter(cp => geometryWidth(cp) === width); const forkPara = para.createFork(argv => { argv.shape.width = width; }); forkPara.koelnCompactM = width === 500; const roots = codes.map(cp => base.glyphStore.queryByUnicodeEnsured(cp)); // Enclosure registration also resolves its mosaic ingredients eagerly. if (codes.includes(0xA9)) { roots.push(base.glyphStore.queryByUnicodeEnsured(0x2714), base.glyphStore.queryByUnicodeEnsured(0x21B2)); } // Miniature ingredients belong to another executor's dependency graph. if (codes.includes(0xAE)) roots.push(base.glyphStore.queryByNameEnsured('combRingCap/adwsN')); const filter = executor.dependencyManager.traverseDependencies(roots); // Derived glyph names depend on geometry, so only filter whole blocks. filter.glyphIsNeeded = () => true; const blockIsNeeded = filter.blockIsNeeded.bind(filter); const rootBlocks = new Set(roots.map(glyph => executor.dependencyManager.glyphToBlock.get(glyph))); filter.blockIsNeeded = id => id === 'AutoBuild-Accents' || (/^autobuild-/i.test(id) ? rootBlocks.has(id) : blockIsNeeded(id)); filter.koeln = true; filter.koelnCodes = new Set(codes); forks.set(width, buildGlyphs(forkPara, filter, true)); } refineArrows(base.glyphStore); return { glyphStore: assemble(forks, base.fontMetrics), fontMetrics: base.fontMetrics }; } // Outlines are simplified, rounded and placed here; Iosevka exports them verbatim. function assemble(forks, fontMetrics) { const xHeight = fontMetrics.os2.sxHeight, capHeight = fontMetrics.os2.sCapHeight; const ascent = fontMetrics.os2.sTypoAscender, descent = fontMetrics.os2.sTypoDescender; // hhea mirrors the typographic line box. fontMetrics.hhea.descender = descent; fontMetrics.hhea.lineGap = fontMetrics.os2.sTypoLineGap; fontMetrics.os2.xAvgCharWidth = CELL; const sourceOf = (cp, width = geometryWidth(cp)) => forks.get(width).glyphStore.queryByUnicodeEnsured(cp); const radius = width => { const stroke = forks.get(width).metrics.Stroke; return Math.max(stroke * .16, stroke * .5 - 21.5); }; // Terminal symbols and cell graphics keep their sharp corners. const radiusOf = cp => cp < 0x2100 ? radius(geometryWidth(cp)) : 0; const outlines = new Map(); // Like TrueType components, referenced glyphs are simplified and rounded on their own. function outline(glyph, r) { if (!outlines.has(glyph)) outlines.set(glyph, new Map()); const memo = outlines.get(glyph); if (memo.has(r)) return memo.get(r); const refs = glyph.geometry.toReferences(); let contours; if (refs) contours = refs.flatMap(ref => translated(outline(ref.glyph, r), ref.x, ref.y)); // Simplifying these polygons makes their point topology vary across weights. else if (glyph.koelnPreserveTopology) contours = glyph.geometry.toContours(); else { contours = SimplifyGeometry.wrapWithGizmo(glyph.geometry, glyph.gizmo).toContours(); if (r) contours = roundCorners(contours, r); } memo.set(r, contours); return contours; } const store = new GlyphStore(); function addGlyph(name, source, geometry, anchorTransform, advance) { if (store.queryByName(name)) throw new Error(`Duplicate glyph name ${name}`); const glyph = new Glyph(name); // Other relations name glyphs outside this store. if (Dotless.get(source)) Dotless.set(glyph, Dotless.get(source)); glyph.cloneRankFromGlyph(source); if (anchorTransform) { for (const kind of ['baseAnchors', 'markAnchors']) { for (const [key, anchor] of Object.entries(source[kind])) { glyph[kind][key] = Anchor.transform(anchorTransform, anchor); } } } glyph.geometry = geometry; glyph.koelnFinal = true; glyph.advanceWidth = advance; store.addGlyph(name, glyph); return glyph; } const placed = new Map(); function emit(cp, contours, anchorTransform, encode = true) { const fork = forks.get(geometryWidth(cp)).glyphStore; const source = fork.queryByUnicodeEnsured(cp); placed.set(cp, contours); const glyph = addGlyph(fork.queryNameOf(source), source, new ContourSetGeometry(contours), anchorTransform, MARKS.includes(cp) ? 0 : CELL); if (encode) store.encodeGlyph(cp, glyph); return glyph; } const notdef = forks.get(660).glyphStore.queryByNameEnsured('.notdef'); const notdefContours = outline(notdef, 0); const notdefTransform = cellTransform(notdefContours, 660); addGlyph('.notdef', notdef, new ContourSetGeometry(transformed(notdefContours, notdefTransform)), notdefTransform, CELL); const offsets = new Map(); for (const cp of range(32, 126)) { const contours = outline(sourceOf(cp), radiusOf(cp)); const tfm = cellTransform(contours, geometryWidth(cp), 1, config.nudge[String.fromCodePoint(cp)] ?? 0); let result = transformed(contours, tfm); if (cp === 0x69 || cp === 0x6A) { // Keep the approved tittles independent of accent metrics. const ys = result.flat().filter(z => z.y > xHeight).map(z => z.y); const shift = Math.round(xHeight + 150 - (Math.min(...ys) + Math.max(...ys)) / 2); result = result.map(c => c.map(z => z.y > xHeight ? Point.translated(z, 0, shift) : z)); } offsets.set(cp, tfm.tx); emit(cp, result, tfm); } const dotless = new Map(); for (const [cp, letter] of [[0x131, 0x69], [0x237, 0x6A]]) { const tfm = Transform.Translate(offsets.get(letter), 0); dotless.set(letter, emit(cp, transformed(outline(sourceOf(cp), radiusOf(cp)), tfm), tfm, cp === 0x131)); } const decompositions = new Map(); for (const cp of range(160, 383)) { const parts = codes(String.fromCodePoint(cp).normalize('NFD')); if (parts.length === 2) decompositions.set(cp, parts); else if (cp === 0xA1 || cp === 0xBF) { const upright = placed.get(cp === 0xA1 ? 0x21 : 0x3F); emit(cp, transformed(upright, new Transform(-1, 0, 0, -1, CELL, xHeight)), null); } else if (cp === 0xB1) { emit(cp, [...translated(placed.get(0x2B), 0, 150), ...translated(placed.get(0x2D), 0, -308)], null); } else if (cp !== 0x131) { const contours = outline(sourceOf(cp), radiusOf(cp)), box = bounds(contours); const scale = box ? Math.min(1, (CELL - 2 * MARGIN) / (box.xMax - box.xMin)) : 1; const tfm = cellTransform(contours, geometryWidth(cp), scale, config.nudge[String.fromCodePoint(cp)] ?? 0); emit(cp, transformed(contours, tfm), tfm); } } for (const cp of MARKS) { const tfm = Transform.Translate((CELL + geometryWidth(cp)) / 2, 0); emit(cp, transformed(outline(sourceOf(cp), radiusOf(cp)), tfm), tfm); } for (const [cp, [base, mark]] of decompositions) { const width = geometryWidth(base), dx = offsets.get(base), r = radius(width); // A lone reference is a merged letter-and-mark design, which Iosevka would export as one outline. const source = sourceOf(cp), refs = source.geometry.toReferences(); let accent; if (mark === 0x30B) { // Wider native marks merge the two acute strokes at heavy weights. const upper = String.fromCodePoint(base) !== String.fromCodePoint(base).toLowerCase(); accent = translated(outline(sourceOf(mark), radiusOf(mark)), CELL, upper ? capHeight - xHeight : 0); } else if (width === 540 && mark === 0x302) { // Preserve the spacing accent's unsplit outline on the narrow w. accent = translated(outline(sourceOf(0x2C6), r), dx, 0); } else if (refs?.length > 1) { // Apostrophe-carons use reverse component order upstream. const baseIndex = [0x10F, 0x13D, 0x13E, 0x165].includes(cp) ? refs.length - 1 : 0; accent = refs.filter((_, i) => i !== baseIndex) .flatMap(ref => translated(outline(ref.glyph, r), ref.x + dx, ref.y)); } else { const markWidth = mark === 0x328 && width === 660 ? 660 : geometryWidth(mark); let x = width + dx; if (mark === 0x328) x += (bounds(outline(sourceOf(base), r)).xMax - width / 2) * .65; accent = translated(outline(sourceOf(mark, markWidth), radius(markWidth)), x, 0); } const box = bounds(accent); if (box) { // Preserve d's bowl rather than narrowing it for the apostrophe-caron. const shiftY = cp === 0x10F ? Math.max(0, bounds(placed.get(base)).yMax + 40 - box.yMin) : 0; accent = translated(accent, Math.max(MARGIN - box.xMin, Math.min(0, CELL - MARGIN - box.xMax)), shiftY); } const name = forks.get(width).glyphStore.queryNameOf(source); const accentGlyph = addGlyph(`accent.${name}`, source, new ContourSetGeometry(accent), null, 0); const baseGlyph = dotless.has(base) && mark < 0x320 ? dotless.get(base) : store.queryByUnicode(base); const composite = addGlyph(name, source, new CombineGeometry([ new ReferenceGeometry(baseGlyph, 0, 0), new ReferenceGeometry(accentGlyph, 0, 0), ]), Transform.Translate(dx, 0), CELL); store.encodeGlyph(cp, composite); } for (const cp of [...CELL_GRAPHICS, ...ARROWS]) emit(cp, outline(sourceOf(cp), 0), Transform.Id()); for (const cp of codes(config.terminal.characters)) { if (cp === 0xFFFD) { // A stable diamond/question cutout avoids upstream contour collapse. const diamond = [[250, 800], [485, 340], [250, -120], [15, 340]].map(([x, y]) => Point.corner(x, y)); const question = transformed(placed.get(0x3F), new Transform(.46, 0, 0, .46, 135, 171)); emit(cp, [diamond, ...question.map(c => [...c].reverse())], null); continue; } const contours = outline(sourceOf(cp), radiusOf(cp)), box = bounds(contours); const scale = Math.min(1, (cp === 0x2013 ? 400 : CELL - 2 * MARGIN) / (box.xMax - box.xMin)); // Geometric indicators should remain circles and squares, not ellipses. const vertical = Math.min(0x25A0 <= cp && cp <= 0x2718 ? scale : 1, (ascent - descent) / (box.yMax - box.yMin)); const dy = Math.max(descent - vertical * box.yMin, Math.min((1 - vertical) * (ascent + descent) / 2, ascent - vertical * box.yMax)); const tfm = new Transform(scale, 0, 0, vertical, CELL / 2 - scale * (box.xMin + box.xMax) / 2, dy); emit(cp, transformed(contours, tfm), tfm); } for (const [cp, contours] of placed) { const box = bounds(contours); if (!box || !store.queryByUnicode(cp)) continue; const exact = CELL_GRAPHICS.has(cp) || ARROWS.has(cp); const margin = exact ? (0x2571 <= cp && cp <= 0x2573 ? -64 : 0) : MARGIN - 1; if (Math.round(box.xMin) < margin || Math.round(box.xMax) > CELL - margin) { throw new Error(`U+${cp.toString(16).toUpperCase()} ink ${box.xMin}..${box.xMax} leaves its cell`); } } return store; }