Soul Font

"A tiger leaves its skin behind when it dies; a person leaves their handwriting."

Abstract

Soul Font turns one filled-in A4 sheet into an installable Korean handwriting font. The writer fills 130 cells by hand: 33 Hangul syllables the model reads as style references, and the Latin alphabet, digits and punctuation that are traced straight from the page. From those 33 the model synthesizes all 11,172 Hangul syllables, a vectorizer fits real curves to them, and the result is a Light / Regular / Bold TTF family. It began as a Django site for a 2025 class project and, since August 2026, ships as a desktop app for macOS and Windows with the model bundled inside.

Motivation

Handwriting carries individuality and emotional weight that digital typography cannot replicate. Two concrete use cases drove this project: restoring written expression for individuals with physical limitations, and preserving the handwriting of people who are no longer present. Anyone can make a font by hand — write all 11,172 syllables, split them, fill the glyphs, clean the outlines — which is exactly why nobody does. The model is what makes 33 cells enough.

Demo

The template

One A4 page, a 10×13 grid, read in row-major order. The first 33 cells are the Korean style references and the only cells the model ever sees. The remaining 97 are A–Z, a–z, 0–9, the whole of printable ASCII, and the three marks everyday Korean writing needs that ASCII has no room for: the won sign, the reference mark ※, and the degree sign. Those bypass the model and are traced from the writer's own hand at the crop's full 512 px resolution. Curly quotes, dashes, the ellipsis and the middle dot have no cell; each is derived later from a mark the writer already drew, so asking for them would cost cells and buy nothing.

Thirty-three is a chosen number, not a constructed one. A Hangul syllable is an initial, a medial and an optional final — 19, 21 and 28 possibilities — and DM-Font composes every other syllable out of the pieces it reads from these cells, so every component has to appear at least once or the model invents it for the whole font. The first version met that with 28 cells by walking the three component tables diagonally: 각, 깪, 냓, 댼… perfectly packed, each final used exactly once, and 21 of the 28 outside KS X 1001 altogether. Writers reported the cells were unwritable, and they were right. You cannot produce natural handwriting for a syllable you have never written in your life.

The current set is a minimum-cost cover over the 2,350 syllables of KS X 1001, scored by how familiar each syllable is and solved exactly. Thirty-one of the 33 are ordinary words — 얘기, 돼지, 왼쪽, 괜찮다, 읽다, 웹사이트, 부엌, 옆집 — and the other two are as good as the language allows: 읊 is the only common syllable carrying the final ㄿ, and 곬 is the least obscure of the three carrying ㄽ. Real syllables pack worse than a diagonal walk, which costs five cells; the sheet already had five spare. A coverage check now runs at import, because dropping a component still builds a font that looks fine until someone reads it.

Pipeline

render PDF → crop cells → generate Hangul → clean rasters → trace → fit metrics → name the font

Everything lives in foundry/, one file per step, in that order. The upload is rendered at 300 dpi with pdfium; Poppler is gone, because a system binary the user had to install first was the single most common setup failure. crop.py locates the printed table border from projection profiles and divides that, so page size and scan resolution don't matter and notes in the margin are ignored. Each cell is trimmed of its grid line and its printed guide glyph, then binarized to 128×128.

A bad upload fails in the first second rather than minutes later: an empty or multi-page file, the wrong cell count, a blank Korean cell, or a sheet from the previous layout — which crops to the same 130 images and is told apart only by its last five cells being empty.

inference.py runs DM-Font's generator in two stages: encode the 33 style images into a component memory, then decode each of the 11,172 targets out of it. On Apple Silicon it runs on the GPU at fp32, 4.25× faster than CPU with identical glyphs. Half precision is refused there: it corrupted every glyph in the probe, because the component memory accumulates over 33 images and fp16 loses the small contributions. Two CPU runs are byte-identical, so SOUL_FONT_DEVICE=cpu is the reproducibility switch.

The model's rasters swell and pinch along each stroke — about a third more width variation than the writers' own pens. Before tracing, each glyph has its pinholes closed and its speckle dropped, with every threshold a multiple of the glyph's own stroke width rather than of the frame, so a Hangul syllable filling its cell and a lowercase o sitting small inside its own are cleaned to the same standard. The outline is then smoothed through its signed distance field, and stroke width is pulled toward the glyph's median along its skeleton at a strength of 0.6. At 1, every stroke is a constant width, which is no longer handwriting. The model's raw output is never modified in place.

glyph_vectorizer.py replaced the Node tracer. ImageTracer walks the pixel grid and emits mostly straight segments — 87% of its points on-curve, against 35–41% in commercial Korean fonts — and that ratio is the faceted look; no parameter tuning changes it, because it never fits curves in the first place. The replacement does the three things a tracer needs: marching squares for sub-pixel contours, Taubin smoothing with corners detected first so the pen's sharp turns survive, and least-squares quadratic fitting that subdivides only where the error exceeds tolerance. TrueType stores quadratics, so fitting them directly avoids a lossy cubic conversion.

refine_metrics.py turns geometry into a font. Hangul gets one scale shared by every syllable, so the relative size, slant and baseline drift of the handwriting survive untouched; tall syllables reach 80% of the em, and side bearings sit at 0.025 em, which is where Nanum 손글씨 and 온글잎 sit regardless of how wide their syllables are. Latin is fitted separately to a cap height set against the Hangul, since Korean fonts draw a syllable 1.2–1.5× the height of a capital and taller English towers over the Korean beside it. Marks are sorted into those that stand on the baseline, hang below it, or straddle the x-height, and the punctuation the sheet has no cell for is derived from what was drawn. set_font_metadata.py writes the name table under both en-US and ko-KR, so a Korean family name survives installation, and stamps the weight class.

Light and Bold are the same cleaned rasters with each glyph's distance field offset in or out — a fraction of a pixel, where the rank filter this replaced was stuck with whole pixels and erased thin strokes from Light entirely. The step is stated in finished-font units and converted per script, because Latin is magnified more than three times as much as Hangul on the way into the em. All three weights use the fit measured once on Regular, so the family is fitted as one. Both are best-effort: if either fails, Regular still ships.

Design

The site is a System 7 desktop. Each application is an icon you double-click: Fonts, Letter, New Font, My Fonts, About. Fonts is the gallery, as Coverflow or a list, with every entry set in the font it represents. Letter picks any font, weight, size, paper and design, and saves the page as a picture. A font's own page shows build progress by count — "Generating 11,172 characters (4,352/11,170)" — and downloads the family as a zip. There is one interface at every width. The window manager is a script, but icons are links and forms are forms, so the site works with the scripts absent, which is also what a touch screen gets.

Desktop app

The UI was already a web app, so the desktop build doesn't reimplement it: desktop.py starts Django on waitress on a loopback port and puts the OS's own webview in front of it. Three things had to change before it could be packaged.

The model runs on ONNX Runtime, not PyTorch. The generator is stateful — a Python dict of component features written by the encoder and averaged on read — and dicts don't trace, but they hold no weights, so the export splits at that seam: an encoder graph, the memory bank in numpy, a decoder graph. That takes 310 MB out of the bundle and is 1.77× faster on a CPU, which is what most Windows machines have. Against the PyTorch model, 0.00038% of pixels change side of the ink threshold.

Writable state moved out of the project, because a bundle is read-only and, on macOS, signed. The database, uploads and build scratch go to the platform's per-user directory, and each install generates its own secret key. And the app stopped promising hardware it doesn't have: it probes what is usable and says so, and the estimate is honest — 15–20 minutes with a GPU, 20–40 without — where it used to say "about two minutes" and anyone who believed it concluded the app had hung.

PyInstaller doesn't cross-compile, so a GitHub Actions workflow builds each platform on its own runner: it downloads the upstream checkpoint, exports the graphs, verifies them against PyTorch, smoke-tests the bundle, and attaches a DMG and a zip with checksums to the release. Builds are unsigned, so both systems warn on first launch. Apple Silicon only.

History

The first model was zi2zi, trained from scratch for every writer. Reusing the reference implementation's freeze_encoder default collapsed every output toward NanumGothic regardless of input, because there was no pretrained model to freeze against; finding that took over a month, and even fixed, training took 12–15 hours per font. Switching to DM-Font, which is pretrained and composes syllables from components, removed training from the loop entirely. What remains is 11,172 decodes and the tracing.

The 28-cell set, the Node tracer, Poppler and the font editor have all been replaced or removed since.

Limits

Builds are threads in the web process: no queue, no retry, and a build interrupted by quitting starts over. The app reclaims those rows at startup so they don't sit at a percentage forever. The model cache is per process, so every worker holds its own copy of the weights. One run leaves tens of thousands of PNGs behind.

PosterPresentation