646 lines
26 KiB
Python
Executable File
646 lines
26 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""Bake the embedded mono font's anti-aliased atlas, at authoring time.
|
|
|
|
Reads a TrueType/OpenType file and writes a C++ translation unit holding a
|
|
greyscale coverage atlas, one fixed cell per glyph, plus the per-glyph rect,
|
|
bearing and advance table the text pipeline stages from.
|
|
|
|
tools/fonts/gen_atlas.py [--font PATH] [--em N] [--supersample N] [--out PATH] [--check]
|
|
|
|
This is an **authoring-time** tool. `plot_core` and every public header under
|
|
`include/plot/` stay third-party-free: nothing here runs at build time, the
|
|
atlas is committed as data, and `fontTools` is never needed to compile or run the
|
|
library. Regenerating is a deliberate act followed by a commit.
|
|
|
|
Rasterisation is ours, not a rasteriser's: the glyph outlines are flattened with
|
|
`fontTools.pens`, scanline-filled at `supersample` times the em, and box-filtered
|
|
down to one coverage byte per texel. Nothing shells out.
|
|
|
|
Determinism
|
|
-----------
|
|
Same font file plus the same em size and supersample factor must produce a
|
|
byte-identical translation unit -- `--check` depends on it, and so does a
|
|
reviewable diff. Everything emitted is derived from integers (cell geometry, the
|
|
font's own advance units) or from a fixed, sorted walk of the coverage list; no
|
|
timestamp, no set/dict iteration order, no float formatting, no host path beyond
|
|
the source font's file name.
|
|
|
|
Blank ink is a hard failure
|
|
---------------------------
|
|
Every requested codepoint must rasterise to at least one inked texel. A silent
|
|
blank cell is exactly the failure that makes a font swap look like it worked, so
|
|
the generator exits non-zero instead. The single documented exception is U+0020,
|
|
which is blank *by definition* -- an intentional blank with a full advance.
|
|
|
|
Provenance
|
|
----------
|
|
The default face is Adwaita Mono, `AdwaitaMono-Regular.ttf`, released under the
|
|
SIL Open Font License 1.1 (a copy is committed at `licences/OFL-1.1.txt`). The
|
|
backend is named neutrally (`EmbeddedMonoFont`) because OFL reserves the font
|
|
name for modified redistributions. The generated file records the source file
|
|
name, the font's own name/version strings, the em size, the supersample factor
|
|
and the exact coverage set.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import math
|
|
import os
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
DEFAULT_FONT = "/usr/share/fonts/Adwaita/AdwaitaMono-Regular.ttf"
|
|
DEFAULT_EM = 13
|
|
DEFAULT_SUPERSAMPLE = 4
|
|
DEFAULT_OUT = "src/render/embedded_mono_font.cpp"
|
|
|
|
SCRIPT = "tools/fonts/gen_atlas.py"
|
|
|
|
# The fallback box, in font units with the origin at the pen on the baseline, y up
|
|
# like the outlines it stands in for. Its two contours wind oppositely, so the
|
|
# non-zero fill leaves a hollow box: an outer rectangle and an inner hole, each
|
|
# stroke 100 units (1.3 px at 13 px em). It is authored here, not taken from the
|
|
# font -- it is what an uncovered codepoint draws.
|
|
FALLBACK_OUTER = ((60.0, -100.0), (580.0, -100.0), (580.0, 700.0), (60.0, 700.0))
|
|
FALLBACK_INNER = ((160.0, 0.0), (160.0, 600.0), (480.0, 600.0), (480.0, 0.0))
|
|
|
|
# The codepoint side of the coverage set: printable ASCII, the three codepoints
|
|
# `plot::format_si` emits, and the two dashes a plotting label uses. The fallback
|
|
# slot is appended after them.
|
|
ASCII = list(range(0x20, 0x7F))
|
|
SPECIALS = [0xB0, 0xB5, 0x03A9, 0x2013, 0x2014]
|
|
|
|
FALLBACK_CODEPOINT = 0xFFFFFFFF # not a Unicode scalar; cannot collide
|
|
|
|
# Sub-curve flattening steps. Fixed, not tolerance-adaptive: at a 13 px em they
|
|
# are far below one supersampled texel even at 4x, and a constant keeps the
|
|
# output independent of anything but the font file.
|
|
QUAD_STEPS = 12
|
|
CUBIC_STEPS = 16
|
|
|
|
EXIT_OK = 0
|
|
EXIT_MISMATCH = 1
|
|
EXIT_BAD_FONT = 2
|
|
EXIT_NO_FONTTOOLS = 3
|
|
|
|
|
|
def _fail(code: int, message: str) -> int:
|
|
print(f"{SCRIPT}: {message}", file=sys.stderr)
|
|
return code
|
|
|
|
|
|
def _require_fonttools():
|
|
try:
|
|
from fontTools.pens.basePen import BasePen # noqa: F401
|
|
from fontTools.ttLib import TTFont # noqa: F401
|
|
except ImportError:
|
|
raise SystemExit(
|
|
_fail(
|
|
EXIT_NO_FONTTOOLS,
|
|
"fontTools is not installed; this is an authoring-time tool. "
|
|
"Install it (pip install fonttools) to regenerate the atlas.",
|
|
)
|
|
)
|
|
|
|
|
|
def _contour_pen_class():
|
|
from fontTools.pens.basePen import BasePen
|
|
|
|
class ContourPen(BasePen):
|
|
"""Flattens one glyph to contour point lists in cell space.
|
|
|
|
`xf` maps a font-unit point (y up, origin at the pen on the baseline) to
|
|
supersampled cell coordinates (y down, origin at the cell's top-left),
|
|
so every geometric decision below is made once, here.
|
|
"""
|
|
|
|
def __init__(self, glyph_set, xf):
|
|
super().__init__(glyph_set)
|
|
self._xf = xf
|
|
self.contours = []
|
|
self._current = []
|
|
|
|
def _map(self, point):
|
|
return self._xf(point[0], point[1])
|
|
|
|
def _moveTo(self, pt):
|
|
self._current = [self._map(pt)]
|
|
|
|
def _lineTo(self, pt):
|
|
self._current.append(self._map(pt))
|
|
|
|
def _qCurveToOne(self, control, point):
|
|
p0 = self._current[-1]
|
|
p1 = self._map(control)
|
|
p2 = self._map(point)
|
|
for step in range(1, QUAD_STEPS + 1):
|
|
t = step / QUAD_STEPS
|
|
u = 1.0 - t
|
|
self._current.append(
|
|
(
|
|
u * u * p0[0] + 2.0 * u * t * p1[0] + t * t * p2[0],
|
|
u * u * p0[1] + 2.0 * u * t * p1[1] + t * t * p2[1],
|
|
)
|
|
)
|
|
|
|
def _curveToOne(self, c1, c2, point):
|
|
p0 = self._current[-1]
|
|
p1 = self._map(c1)
|
|
p2 = self._map(c2)
|
|
p3 = self._map(point)
|
|
for step in range(1, CUBIC_STEPS + 1):
|
|
t = step / CUBIC_STEPS
|
|
u = 1.0 - t
|
|
b0 = u * u * u
|
|
b1 = 3.0 * u * u * t
|
|
b2 = 3.0 * u * t * t
|
|
b3 = t * t * t
|
|
self._current.append(
|
|
(
|
|
b0 * p0[0] + b1 * p1[0] + b2 * p2[0] + b3 * p3[0],
|
|
b0 * p0[1] + b1 * p1[1] + b2 * p2[1] + b3 * p3[1],
|
|
)
|
|
)
|
|
|
|
def _closePath(self):
|
|
if len(self._current) >= 3:
|
|
self.contours.append(self._current)
|
|
self._current = []
|
|
|
|
def _endPath(self):
|
|
self._closePath()
|
|
|
|
return ContourPen
|
|
|
|
|
|
def _scanline_mask(contours, width, height):
|
|
"""Non-zero-winding scanline fill of `contours` into a `height` x `width` 0/1 grid.
|
|
|
|
The fill rule is non-zero rather than even-odd because TrueType orients a
|
|
hole opposite its outer contour and a font is entitled to overlapping
|
|
contours; even-odd would punch a hole out of any overlap.
|
|
"""
|
|
mask = [[0] * width for _ in range(height)]
|
|
edges = []
|
|
for contour in contours:
|
|
count = len(contour)
|
|
for i in range(count):
|
|
x0, y0 = contour[i]
|
|
x1, y1 = contour[(i + 1) % count]
|
|
if y0 != y1:
|
|
edges.append((x0, y0, x1, y1))
|
|
for row in range(height):
|
|
yc = row + 0.5
|
|
crossings = []
|
|
for x0, y0, x1, y1 in edges:
|
|
if (y0 <= yc) != (y1 <= yc):
|
|
t = (yc - y0) / (y1 - y0)
|
|
crossings.append((x0 + t * (x1 - x0), 1 if y1 > y0 else -1))
|
|
if not crossings:
|
|
continue
|
|
crossings.sort()
|
|
winding = 0
|
|
span_start = 0.0
|
|
for x, direction in crossings:
|
|
previous = winding
|
|
winding += direction
|
|
if previous == 0 and winding != 0:
|
|
span_start = x
|
|
elif previous != 0 and winding == 0:
|
|
first = max(0, int(math.ceil(span_start - 0.5)))
|
|
last = min(width, int(math.floor(x - 0.5)) + 1)
|
|
target = mask[row]
|
|
for column in range(first, last):
|
|
target[column] = 1
|
|
return mask
|
|
|
|
|
|
def _coverage_cell(contours, cell_w, cell_h, supersample):
|
|
"""Box-filter `contours` into `cell_h` rows of `cell_w` coverage bytes.
|
|
|
|
`contours` are in supersampled cell space, so each output texel averages the
|
|
`supersample` x `supersample` sub-samples that land on it -- a plain coverage
|
|
count, which is what makes the result greyscale.
|
|
"""
|
|
area = supersample * supersample
|
|
mask = _scanline_mask(contours, cell_w * supersample, cell_h * supersample)
|
|
rows = []
|
|
for ty in range(cell_h):
|
|
row = []
|
|
for tx in range(cell_w):
|
|
covered = 0
|
|
for sy in range(supersample):
|
|
sub = mask[ty * supersample + sy]
|
|
base = tx * supersample
|
|
for sx in range(supersample):
|
|
covered += sub[base + sx]
|
|
# Round-half-up in integers: deterministic, and independent of the
|
|
# host's floating-point formatting.
|
|
row.append((covered * 255 + area // 2) // area)
|
|
rows.append(row)
|
|
return rows
|
|
|
|
|
|
def _cell_transform(em, units_per_em, ascent_px, supersample):
|
|
scale = em / units_per_em
|
|
|
|
def xf(x_units, y_units):
|
|
return (x_units * scale * supersample, (ascent_px - y_units * scale) * supersample)
|
|
|
|
return xf
|
|
|
|
|
|
def _read_font(path):
|
|
from fontTools.ttLib import TTFont
|
|
|
|
if not os.path.isfile(path):
|
|
raise SystemExit(_fail(EXIT_BAD_FONT, f"no such font file: {path}"))
|
|
try:
|
|
return TTFont(path, recalcBBoxes=False, recalcTimestamp=False)
|
|
except Exception as exc: # fontTools raises a spread of parser errors
|
|
raise SystemExit(_fail(EXIT_BAD_FONT, f"cannot parse {path}: {exc}"))
|
|
|
|
|
|
def build(font_path, em, supersample):
|
|
"""Rasterise the coverage set at `em` px; returns the geometry and cells."""
|
|
font = _read_font(font_path)
|
|
head = font["head"]
|
|
os2 = font["OS/2"]
|
|
units_per_em = head.unitsPerEm
|
|
glyph_set = font.getGlyphSet()
|
|
cmap = font.getBestCmap()
|
|
hmtx = font["hmtx"]
|
|
|
|
name_record = font["name"]
|
|
|
|
def name(identifier, fallback):
|
|
value = name_record.getDebugName(identifier)
|
|
return value if value else fallback
|
|
|
|
scale = em / units_per_em
|
|
ascent_units = os2.sTypoAscender
|
|
descent_units = -os2.sTypoDescender
|
|
ascent_px = ascent_units * scale
|
|
|
|
contours_class = _contour_pen_class()
|
|
from fontTools.pens.boundsPen import BoundsPen
|
|
|
|
codepoints = list(ASCII) + list(SPECIALS)
|
|
|
|
# The cell: wide enough for the widest advance (the pen must never leave the
|
|
# cell) and for the widest ink; tall enough for the face's ascent-to-descent
|
|
# extent. Never trimmed to any one glyph's ink -- a fixed-cell grid is what
|
|
# lets the pipeline carry one rect size per draw.
|
|
widest_advance = 0
|
|
widest_ink = 0.0
|
|
for codepoint in codepoints:
|
|
glyph_name = cmap.get(codepoint)
|
|
if glyph_name is None:
|
|
raise SystemExit(
|
|
_fail(EXIT_BAD_FONT, f"{font_path} has no glyph for U+{codepoint:04X}")
|
|
)
|
|
advance, _bearing = hmtx[glyph_name]
|
|
widest_advance = max(widest_advance, advance)
|
|
bounds = BoundsPen(glyph_set)
|
|
glyph_set[glyph_name].draw(bounds)
|
|
if bounds.bounds is not None:
|
|
widest_ink = max(widest_ink, bounds.bounds[2])
|
|
|
|
cell_w = int(math.ceil(max(widest_advance * scale, widest_ink * scale) - 1e-9))
|
|
cell_h = int(math.ceil(ascent_px + descent_units * scale - 1e-9))
|
|
if cell_w <= 0 or cell_h <= 0:
|
|
raise SystemExit(_fail(EXIT_BAD_FONT, "the computed cell is empty"))
|
|
|
|
xf = _cell_transform(em, units_per_em, ascent_px, supersample)
|
|
|
|
cells = []
|
|
advances = []
|
|
for codepoint in codepoints:
|
|
glyph_name = cmap[codepoint]
|
|
pen = contours_class(glyph_set, xf)
|
|
glyph_set[glyph_name].draw(pen)
|
|
rows = _coverage_cell(pen.contours, cell_w, cell_h, supersample)
|
|
ink = sum(sum(row) for row in rows)
|
|
if codepoint != 0x20 and ink == 0:
|
|
# A silent blank cell is the failure mode this whole exercise exists
|
|
# to remove: refuse to ship it.
|
|
raise SystemExit(
|
|
_fail(
|
|
EXIT_BAD_FONT,
|
|
f"U+{codepoint:04X} ({glyph_name}) rasterised to no ink at "
|
|
f"{em} px; the cell is too small or the glyph is missing",
|
|
)
|
|
)
|
|
cells.append(rows)
|
|
advances.append(hmtx[glyph_name][0])
|
|
|
|
fallback_pen = contours_class(glyph_set, xf)
|
|
fallback_pen.contours = [
|
|
[fallback_pen._xf(x, y) for x, y in FALLBACK_OUTER],
|
|
[fallback_pen._xf(x, y) for x, y in FALLBACK_INNER],
|
|
]
|
|
fallback_rows = _coverage_cell(fallback_pen.contours, cell_w, cell_h, supersample)
|
|
if sum(sum(row) for row in fallback_rows) == 0:
|
|
raise SystemExit(_fail(EXIT_BAD_FONT, "the fallback box rasterised to no ink"))
|
|
cells.append(fallback_rows)
|
|
advances.append(widest_advance)
|
|
|
|
geometry = {
|
|
"em": em,
|
|
"supersample": supersample,
|
|
"units_per_em": units_per_em,
|
|
"scale": scale,
|
|
"ascent_units": ascent_units,
|
|
"descent_units": descent_units,
|
|
"ascent_px": ascent_px,
|
|
"cell_w": cell_w,
|
|
"cell_h": cell_h,
|
|
"glyph_count": len(codepoints) + 1,
|
|
"fallback_slot": len(codepoints),
|
|
"codepoints": codepoints,
|
|
"advances": advances,
|
|
"cells": cells,
|
|
}
|
|
provenance = {
|
|
"font_file": os.path.basename(font_path),
|
|
"font_name": name(4, "unknown"),
|
|
"font_version": name(5, "unknown"),
|
|
}
|
|
font.close()
|
|
return geometry, provenance
|
|
|
|
|
|
def _codepoint_label(codepoint):
|
|
if codepoint == FALLBACK_CODEPOINT:
|
|
return "fallback box"
|
|
return f"U+{codepoint:04X}"
|
|
|
|
|
|
def emit(geometry, provenance):
|
|
"""Render one `build()` result as the generated translation unit."""
|
|
em = geometry["em"]
|
|
supersample = geometry["supersample"]
|
|
units_per_em = geometry["units_per_em"]
|
|
cell_w = geometry["cell_w"]
|
|
cell_h = geometry["cell_h"]
|
|
glyph_count = geometry["glyph_count"]
|
|
fallback_slot = geometry["fallback_slot"]
|
|
atlas_w = cell_w * glyph_count
|
|
atlas_h = cell_h
|
|
codepoints = geometry["codepoints"]
|
|
advances = geometry["advances"]
|
|
slot_codepoints = list(codepoints) + [FALLBACK_CODEPOINT]
|
|
|
|
ascii_lo = ASCII[0]
|
|
ascii_hi = ASCII[-1]
|
|
|
|
lines = []
|
|
add = lines.append
|
|
|
|
add("// GENERATED FILE -- do not edit by hand.")
|
|
add("//")
|
|
add(f"// Regenerate with: {SCRIPT}")
|
|
add(f"// The generator takes the source font, the em size and the supersample factor;")
|
|
add(f"// its defaults bake the face this file holds. `{SCRIPT} --check`")
|
|
add("// regenerates into memory and fails if this file has drifted from its input.")
|
|
add("//")
|
|
add("// Provenance")
|
|
add("// source font : " + provenance["font_file"])
|
|
add("// font name : " + provenance["font_name"])
|
|
add("// font version : " + provenance["font_version"])
|
|
add("// licence : SIL Open Font License 1.1 (licences/OFL-1.1.txt)")
|
|
add(f"// units per em : {units_per_em}")
|
|
add(f"// em size : {em} px")
|
|
add(f"// supersample : {supersample}x ({supersample * supersample} samples per texel)")
|
|
add(f"// coverage : U+{ascii_lo:04X}..U+{ascii_hi:04X}, "
|
|
+ ", ".join(f"U+{cp:04X}" for cp in SPECIALS)
|
|
+ ", plus a fallback box")
|
|
add(f"// cell : {cell_w} x {cell_h} texels, one row of {glyph_count}")
|
|
add(f"// atlas : {atlas_w} x {atlas_h} texels")
|
|
add("//")
|
|
add("// The cell is the face's maximum advance wide and its ascent-to-descent extent")
|
|
add("// tall; every glyph is drawn into that cell with its bearing preserved, never")
|
|
add("// trimmed to its ink, because the pipeline stages one rect *origin* per glyph and")
|
|
add("// carries one rect *size* for the whole draw (see render::TextBackend::cell_uv).")
|
|
add("// Coverage is a plain supersampled coverage count, so a stroke's edges are grey")
|
|
add("// and not a 1-bit threshold.")
|
|
add("")
|
|
add("#include <plot/render/embedded_mono_font.hpp>")
|
|
add("")
|
|
add("#include <array>")
|
|
add("#include <utility>")
|
|
add("")
|
|
add("namespace plot::render {")
|
|
add("namespace {")
|
|
add("")
|
|
add("// Geometry, all of it derived from the bake above by the generator.")
|
|
add(f"constexpr u32 kEmPx = {em}u;")
|
|
add(f"constexpr u32 kSupersample = {supersample}u;")
|
|
add(f"constexpr u32 kUnitsPerEm = {units_per_em}u;")
|
|
add(f"constexpr u32 kCellW = {cell_w}u;")
|
|
add(f"constexpr u32 kCellH = {cell_h}u;")
|
|
add(f"constexpr u32 kGlyphCount = {glyph_count}u;")
|
|
add(f"constexpr u32 kFallbackSlot = {fallback_slot}u;")
|
|
add("constexpr u32 kAtlasW = kCellW * kGlyphCount;")
|
|
add("constexpr u32 kAtlasH = kCellH;")
|
|
add("constexpr u32 kAsciiFirst = " + f"{ascii_lo}u;")
|
|
add("constexpr u32 kAsciiGlyphs = " + f"{len(ASCII)}u;")
|
|
add("constexpr u32 kFallbackCodepoint = " + f"0x{FALLBACK_CODEPOINT:08X}u;")
|
|
add("")
|
|
add(f"constexpr u32 kAscentUnits = {geometry['ascent_units']}u;")
|
|
add("// Font pixels per font unit. The one non-integer factor, and the only place a")
|
|
add("// float enters: everything else here is an integer.")
|
|
add("constexpr f32 kScale = static_cast<f32>(kEmPx) / static_cast<f32>(kUnitsPerEm);")
|
|
add("constexpr f32 kAscentPx = static_cast<f32>(kAscentUnits) * kScale;")
|
|
add("")
|
|
add("// The public header repeats the em size, the supersample factor and the")
|
|
add("// fallback marker so a caller can read them without opening this table.")
|
|
add("// These asserts are what make that a pin and not a copy: re-baking at another")
|
|
add("// em or supersample factor, or moving the fallback marker, fails to compile")
|
|
add("// until the header agrees, so the two can never silently disagree.")
|
|
add("static_assert(kEmPx == EmbeddedMonoFont::kEmPx,")
|
|
add(" \"embedded_mono_font.hpp kEmPx disagrees with the bake\");")
|
|
add("static_assert(kSupersample == EmbeddedMonoFont::kSupersample,")
|
|
add(" \"embedded_mono_font.hpp kSupersample disagrees with the bake\");")
|
|
add("static_assert(kFallbackCodepoint == EmbeddedMonoFont::kFallbackCodepoint,")
|
|
add(" \"embedded_mono_font.hpp kFallbackCodepoint disagrees with the bake\");")
|
|
add("")
|
|
add("/// The codepoint each slot was baked for, in slot order; the fallback slot")
|
|
add("/// carries `kFallbackCodepoint`, which is not a Unicode scalar.")
|
|
add(f"constexpr u32 kCodepoints[kGlyphCount] = {{")
|
|
for slot, codepoint in enumerate(codepoints):
|
|
add(f" 0x{codepoint:04X}u, // [{slot:3d}] {_codepoint_label(codepoint)}")
|
|
add(f" 0x{FALLBACK_CODEPOINT:08X}u, // [{fallback_slot:3d}] {_codepoint_label(FALLBACK_CODEPOINT)}")
|
|
add("};")
|
|
add("")
|
|
add("/// The advance each slot contributes to the pen, in font units. The face is")
|
|
add("/// monospaced, so these are all equal -- but the table is per glyph, because the")
|
|
add("/// advance is the font's own `hmtx` value and not an assumption about the face.")
|
|
add(f"constexpr u32 kAdvanceUnits[kGlyphCount] = {{")
|
|
for slot, advance in enumerate(advances):
|
|
codepoint = codepoints[slot] if slot < len(codepoints) else FALLBACK_CODEPOINT
|
|
add(f" {advance}u, // [{slot:3d}] {_codepoint_label(codepoint)}")
|
|
add("};")
|
|
add("")
|
|
add("/// The packed atlas: one row of cells, top row first, straight coverage, 0 for")
|
|
add("/// background and 255 for full ink. GL_R8-ready, no dilation, no gamma.")
|
|
add(f"constexpr u8 kAtlas[kAtlasW * kAtlasH] = {{")
|
|
for row in range(atlas_h):
|
|
for slot in range(glyph_count):
|
|
values = geometry["cells"][slot][row]
|
|
body = ", ".join(f"0x{value:02X}" for value in values)
|
|
label = _codepoint_label(slot_codepoints[slot])
|
|
add(f" {body}, // [{slot:3d}] {label} row {row:2d}")
|
|
add("};")
|
|
add("")
|
|
add("/// One slot's glyph: the cell it occupies in the atlas, drawn from the cell's")
|
|
add("/// top-left (the pen) with no extra bearing -- the bearing is baked into the")
|
|
add("/// cell's ink -- and the advance the pen moves by.")
|
|
add("constexpr Glyph make_glyph(u32 slot) noexcept {")
|
|
add(" const f32 u0 = static_cast<f32>(slot * kCellW) / static_cast<f32>(kAtlasW);")
|
|
add(" const f32 du = static_cast<f32>(kCellW) / static_cast<f32>(kAtlasW);")
|
|
add(" const f32 dv = static_cast<f32>(kCellH) / static_cast<f32>(kAtlasH);")
|
|
add(" return Glyph{u0, 0.0f, u0 + du, dv, 0.0f, 0.0f,")
|
|
add(" static_cast<f32>(kAdvanceUnits[slot]) * kScale};")
|
|
add("}")
|
|
add("")
|
|
add("template <u32... Slots>")
|
|
add("constexpr std::array<Glyph, sizeof...(Slots)> make_glyphs(")
|
|
add(" std::integer_sequence<u32, Slots...>) noexcept {")
|
|
add(" return {make_glyph(Slots)...};")
|
|
add("}")
|
|
add("")
|
|
add("constexpr std::array<Glyph, kGlyphCount> kGlyphs =")
|
|
add(" make_glyphs(std::make_integer_sequence<u32, kGlyphCount>{});")
|
|
add("")
|
|
add("} // namespace")
|
|
add("")
|
|
add("u32 EmbeddedMonoFont::glyph_index(u32 codepoint) noexcept {")
|
|
add(" if (codepoint >= kAsciiFirst && codepoint < kAsciiFirst + kAsciiGlyphs) {")
|
|
add(" return codepoint - kAsciiFirst;")
|
|
add(" }")
|
|
add(" switch (codepoint) {")
|
|
for offset, codepoint in enumerate(SPECIALS):
|
|
add(f" case 0x{codepoint:04X}u:")
|
|
add(f" return kAsciiGlyphs + {offset}u;")
|
|
add(" default:")
|
|
add(" return kFallbackSlot;")
|
|
add(" }")
|
|
add("}")
|
|
add("")
|
|
add("f32 EmbeddedMonoFont::advance_codepoint(u32 codepoint) noexcept {")
|
|
add(" return static_cast<f32>(kAdvanceUnits[glyph_index(codepoint)]) * kScale;")
|
|
add("}")
|
|
add("")
|
|
add("f32 EmbeddedMonoFont::measure(const char* utf8) const {")
|
|
add(" if (utf8 == nullptr) {")
|
|
add(" return 0.0f;")
|
|
add(" }")
|
|
add(" f32 width = 0.0f;")
|
|
add(" for (const char* p = utf8; *p != '\\0';) {")
|
|
add(" width += advance_codepoint(next_codepoint(p));")
|
|
add(" }")
|
|
add(" return width;")
|
|
add("}")
|
|
add("")
|
|
add("f32 EmbeddedMonoFont::height() const { return static_cast<f32>(kCellH); }")
|
|
add("")
|
|
add("f32 EmbeddedMonoFont::ascent() const { return kAscentPx; }")
|
|
add("")
|
|
add("f32 EmbeddedMonoFont::em_px() const { return static_cast<f32>(kEmPx); }")
|
|
add("")
|
|
add("const Glyph* EmbeddedMonoFont::glyph(u32 codepoint) const {")
|
|
add(" return &kGlyphs[glyph_index(codepoint)];")
|
|
add("}")
|
|
add("")
|
|
add("Vec2 EmbeddedMonoFont::cell_uv() const {")
|
|
add(" return Vec2{static_cast<f32>(kCellW) / static_cast<f32>(kAtlasW),")
|
|
add(" static_cast<f32>(kCellH) / static_cast<f32>(kAtlasH)};")
|
|
add("}")
|
|
add("")
|
|
add("const u8* EmbeddedMonoFont::atlas_pixels() const { return kAtlas; }")
|
|
add("")
|
|
add("u32 EmbeddedMonoFont::atlas_width() const { return kAtlasW; }")
|
|
add("")
|
|
add("u32 EmbeddedMonoFont::atlas_height() const { return kAtlasH; }")
|
|
add("")
|
|
add("u32 EmbeddedMonoFont::revision() const { return 0u; }")
|
|
add("")
|
|
add("u32 EmbeddedMonoFont::glyph_count() noexcept { return kGlyphCount; }")
|
|
add("")
|
|
add("u32 EmbeddedMonoFont::codepoint_of_slot(u32 slot) noexcept {")
|
|
add(" return slot < kGlyphCount ? kCodepoints[slot] : kFallbackCodepoint;")
|
|
add("}")
|
|
add("")
|
|
add("u32 EmbeddedMonoFont::cell_width() noexcept { return kCellW; }")
|
|
add("")
|
|
add("u32 EmbeddedMonoFont::cell_height() noexcept { return kCellH; }")
|
|
add("")
|
|
add("Vec2 EmbeddedMonoFont::atlas_origin(u32 slot) noexcept {")
|
|
add(" const u32 s = slot < kGlyphCount ? slot : kFallbackSlot;")
|
|
add(" return Vec2{static_cast<f32>(s * kCellW), 0.0f};")
|
|
add("}")
|
|
add("")
|
|
add("const u8* EmbeddedMonoFont::atlas_image() noexcept { return kAtlas; }")
|
|
add("")
|
|
add("} // namespace plot::render")
|
|
add("")
|
|
return "\n".join(lines)
|
|
|
|
|
|
def generate(font_path, em, supersample):
|
|
geometry, provenance = build(font_path, em, supersample)
|
|
return emit(geometry, provenance), geometry
|
|
|
|
|
|
def main(argv=None):
|
|
parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
|
|
parser.add_argument("--font", default=DEFAULT_FONT, help="source TrueType/OpenType file")
|
|
parser.add_argument("--em", type=int, default=DEFAULT_EM, help="em size in pixels")
|
|
parser.add_argument(
|
|
"--supersample", type=int, default=DEFAULT_SUPERSAMPLE, help="coverage samples per texel"
|
|
)
|
|
parser.add_argument("--out", default=DEFAULT_OUT, help="translation unit to write")
|
|
parser.add_argument(
|
|
"--check",
|
|
action="store_true",
|
|
help="regenerate and fail if the committed file differs, instead of writing",
|
|
)
|
|
args = parser.parse_args(argv)
|
|
|
|
if args.em <= 0 or args.supersample <= 0:
|
|
return _fail(EXIT_BAD_FONT, "em size and supersample factor must be positive")
|
|
|
|
_require_fonttools()
|
|
|
|
if args.check:
|
|
out = Path(args.out)
|
|
committed = out.read_bytes() if out.is_file() else None
|
|
fresh, _ = generate(args.font, args.em, args.supersample)
|
|
if committed is None:
|
|
return _fail(EXIT_MISMATCH, f"{out} does not exist; run {SCRIPT} to generate it")
|
|
if committed != fresh.encode():
|
|
return _fail(
|
|
EXIT_MISMATCH,
|
|
f"{out} differs from a fresh bake; regenerate with {SCRIPT} and commit the result",
|
|
)
|
|
print(f"{SCRIPT}: {out} matches a fresh bake", file=sys.stderr)
|
|
return EXIT_OK
|
|
|
|
text, geometry = generate(args.font, args.em, args.supersample)
|
|
Path(args.out).write_bytes(text.encode())
|
|
ink = sum(sum(row) for cell in geometry["cells"] for row in cell)
|
|
print(
|
|
f"{SCRIPT}: {args.out}: {geometry['glyph_count']} glyphs, "
|
|
f"cell {geometry['cell_w']}x{geometry['cell_h']}, "
|
|
f"atlas {geometry['cell_w'] * geometry['glyph_count']}x{geometry['cell_h']}, "
|
|
f"{args.em} px em, {args.supersample}x, {ink} ink units",
|
|
file=sys.stderr,
|
|
)
|
|
return EXIT_OK
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|