Two notes worth keeping out of the commit log because they are about where to start next time rather than what changed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
120 lines
6.1 KiB
Markdown
120 lines
6.1 KiB
Markdown
---
|
||
name: schunix-ttf-rasteriser
|
||
description: The TrueType rasteriser — why glyf and not CFF, and the composite-glyph trap that loses the letter "i".
|
||
metadata:
|
||
type: project
|
||
---
|
||
|
||
Built 2026-08-09. `include/ttf.h` + `kernel/dev/ttf.c`, the second `font_ops`
|
||
provider behind [[schunix-text-rendering]]. Host harness: `tools/ttftest.c` →
|
||
`build/tools/ttftest font.ttf px "text" out.pgm`.
|
||
|
||
Renders real JetBrains Mono at 32 px: correct advances, anti-aliased edges,
|
||
the slashed zero, dots on i and j.
|
||
|
||
## glyf, not CFF — and that was checked, not assumed
|
||
|
||
`.ttf` and `.otf` are both sfnt containers with the same table directory, cmap
|
||
and hmtx. Only the *outlines* differ: TrueType puts quadratic B-splines in
|
||
`glyf`; OpenType/PostScript puts cubic béziers in `CFF ` as Type 2 charstrings,
|
||
a stack-based interpreted format and a separate project.
|
||
|
||
**Both chosen fonts ship `glyf` and neither ships `CFF `** — verified by dumping
|
||
JetBrains Mono's table directory before writing a line. So CFF buys nothing
|
||
today. `ttf_open()` rejects `OTTO` explicitly rather than failing later with an
|
||
empty glyph and no explanation.
|
||
|
||
## The trap: composite glyphs are not an edge case
|
||
|
||
First working render produced **"Schun x"** — the `i` was missing. `i` is a
|
||
*composite* glyph: dotless-i plus the dot, assembled from two other glyphs at
|
||
offsets. Skipping composites (`numberOfContours < 0`) does not just lose
|
||
accented characters, it loses a letter from the project's own name.
|
||
|
||
Now assembled: parse the component list (flags, glyph index, dx/dy, optional
|
||
scale), recurse, merge contours with the offset applied. Only the
|
||
**translation** case is handled — components with a scale or 2×2 matrix are
|
||
skipped, because applying a transform wrongly is worse than not applying one.
|
||
|
||
## Other things that bite
|
||
|
||
- Every sfnt field is **big-endian**; read byte by byte, never cast. The file is
|
||
not guaranteed aligned and a cast byte-swaps everything on x86.
|
||
- Glyph flags are **run-length encoded** (bit 3 = "next byte is a repeat
|
||
count"). Missing that turns any glyph with a repeated flag into garbage.
|
||
- Coordinates are **deltas** in one of three encodings per axis, selected by two
|
||
flag bits — including "same as previous", which occupies no bytes.
|
||
- TrueType **omits the on-curve point between two consecutive off-curve
|
||
points**; it must be synthesised at the midpoint or curves render as corners.
|
||
- `hmtx` stores `numberOfHMetrics` full entries then bearings only — clamp the
|
||
index.
|
||
- cmap format 4's `idRangeOffset` is a byte offset **from its own address**,
|
||
which is why that arithmetic looks the way it does.
|
||
|
||
## Glyph cache (added the same day)
|
||
|
||
Direct-mapped over printable ASCII 32..126 into a 48 KB pool, filled linearly,
|
||
never freed. No hashing, no eviction policy, no allocation — the contents are a
|
||
pure function of (codepoint, size), so nothing ever goes stale. A **size change
|
||
drops the whole cache**, which is the only event that invalidates it.
|
||
|
||
It fixes two things, not one: the obvious cost of re-rasterising every glyph,
|
||
and the contract — the single `ttf_cov` scratch buffer meant a glyph was valid
|
||
only until the next call, so a caller could not hold two at once. Cached glyphs
|
||
now point into the pool. Codepoints outside ASCII, and anything that arrives
|
||
after the pool is full, still render correctly through the scratch buffer under
|
||
the old contract.
|
||
|
||
`tools/ttftest.c` calls through `fo->ops->glyph`, not `ttf_render`, so the cache
|
||
is on the tested path rather than bypassed by the harness.
|
||
|
||
## Still not done
|
||
|
||
- **No font loaded from disk.** The kernel compiles the rasteriser but no `.ttf`
|
||
is in the tree and nothing reads one. This is what blocks pointing the console
|
||
at it.
|
||
- **Hinting ignored** (`fpgm`/`prep`/`cvt ` skipped). Fine above ~16 px, blurry
|
||
below. Real hinting is a bytecode interpreter — a project, not a fix.
|
||
- **No GSUB**, so ligatures do not form: JetBrains Mono's `==` renders as two
|
||
glyphs. Correct, just not what the font can do.
|
||
- **CFF/OTF outlines.** Requested 2026-08-09 for people who want a non-TrueType
|
||
font in the terminal. Not started, and it is not "more of the same": Type 2
|
||
charstrings are a stack-based bytecode to *interpret* — INDEX/DICT structures,
|
||
charset, local and global subrs with the biasing rule, ~40 operators, cubic
|
||
béziers. Roughly the size of everything else here, and a subtly wrong
|
||
interpreter draws plausible-looking wrong glyphs. It plugs in as a third
|
||
`font_ops` provider, so nothing above it changes.
|
||
|
||
|
||
## 2026-09-21: it is the console font now
|
||
|
||
Linked into the kernel image (`consfont` in the Makefile), opened at 16 px
|
||
before `vt_init()`. Three things had to be fixed first, and two of them were
|
||
the same bug twice:
|
||
|
||
- **`ttf_render` held 21 KB of locals** on a 4096-byte kernel stack. Now
|
||
`static`, safe because every caller is serialised behind `cons.lock`.
|
||
- **`load_outline` did it again, recursively**, with a 4736-byte outline per
|
||
level. Composite glyphs are exactly Ą/Ę/Ś/Ł/Ż, so the second crash arrived
|
||
the moment the first fix let Unicode reach the screen and looked like a
|
||
fresh bug. Static outline per level, depth capped at 4 (the font's real
|
||
maximum nesting is 2), and `load_simple()` split out so the leaf's 512-byte
|
||
flag array is not multiplied by depth.
|
||
- **Component transforms were being skipped.** `'` is the comma glyph scaled
|
||
by -1 — a 180° rotation — and its offset only lands right after the
|
||
rotation. Without it the mark went 12 px into a 10 px cell and was clipped
|
||
away, so the character read as *missing from the font* rather than as
|
||
misplaced. F2Dot14 2x2 is applied now.
|
||
|
||
Neither stack bug showed in `tools/ttftest`, which has an 8 MB stack. **That is
|
||
the standing hazard of this file**: one source built for host and kernel, and
|
||
the host is most forgiving about exactly the resource the kernel is strictest
|
||
about. Check frame sizes with `llvm-objdump -d` on the kernel object, not the
|
||
host build.
|
||
|
||
The harness speaks UTF-8 now. Iterating bytes meant it could only ask for
|
||
ASCII, and ASCII is entirely simple glyphs — it could not reach the path that
|
||
mattered.
|
||
|
||
Glyph cache: 95 reserved ASCII slots plus 224 shared slots keyed by codepoint,
|
||
64 KB pool, flushed whole when it fills.
|