The problem, in detail
Add Flutter icons without changing existing codepoints
A common project starts with three icons from Figma, then grows to five, ten, and one hundred. The codepoint behind AppIcons.backmust keep meaning the same glyph through every batch.
The normal project lifecycle
When Figma sends 3 icons, then 5, 10, and 100
Pack growth should append assignments, not recalculate the API your widgets already use.
- 3initial navigation icons
- 5two states arrive
- 10another Figma batch
- 100the product expands
With a committed lock, existing sources retain their assignments and only new files receive unused values. A removed icon leaves a tombstone. A unique content-preserving rename retains its identity. If somebody forgets to regenerate after the next Figma export,--check fails CI.
This progression describes the repeated-delivery workflow. The verified lock diff later on this page remains a smaller two-to-six fixture so every assignment is readable.
Convert the complete SVG folder with the bulk Flutter icon guide.
The mechanism
Icon fonts place glyphs in a Unicode private use area, most often starting at U+E000. Somebody has to decide which icon gets which value. A generator that derives assignments from the current file list may walk the sources, hand out consecutive codepoints, and stop.
That makes the assignment a function of the file list rather than a stored decision. Nothing anywhere records thatback.svg was U+E000 last Tuesday. Re-derive the mapping from a different file list and you get a different mapping.
Start with two icons:
arrows/back.svg -> 0xE000 status/verified.svg -> 0xE001
Now add one icon, actions/add.svg. It sorts before everything else, so under a list-derived scheme it takes the first codepoint and pushes both existing icons along:
actions/add.svg -> 0xE000 <- was back.svg arrows/back.svg -> 0xE001 <- was verified.svg status/verified.svg -> 0xE002 <- moved
The new font is perfectly valid and internally consistent. Itscmap agrees with its glyph table, it passes validation, and it renders. The damage is entirely outside the font, in the code that still says 0xE000 and now gets a plus sign where a back arrow used to be.
This is why the failure is expensive. There is no crash, no exception, and no failing test. A reviewer reading the diff sees a regenerated binary font and a changed constant, both of which look like the expected result of adding an icon. The wrong glyph ships, and it gets found in a screenshot.
The same shift happens when a file is renamed, when a directory is reorganised, when an icon is deleted from the middle of a set, or when a new GUI import starts without restoring its prior project or config. None of those are unusual events. Under a list-derived scheme, each one can rewrite the mapping.
The fix is to store the decision
If instability comes from re-deriving the mapping, stability comes from recording it. GlyphPact writes every assignment toiconfont.lock.json in the generated output directory and reads that file back on every subsequent build. An assignment already present is reused exactly; only sources with no recorded assignment draw a new codepoint.
The lock is a plain, documented JSON file. It is meant to be committed, read, and reviewed in a pull request.
{
"schemaVersion": 1,
"generator": "glyphpact",
"generatorVersion": "1.0.0",
"fontFamily": "AppIcons",
"className": "AppIcons",
"fontPackage": null,
"startCodepoint": "0xE000",
"unitsPerEm": 1000,
"glyphs": [
{
"source": "arrows/back.svg",
"name": "back",
"codepoint": "0xE000",
"sourceSha256": "38121f4ed8a1e2ab...",
"matchTextDirection": true,
"geometrySha256": "a86146b122485cd9...",
"metadata": {
"author": "GlyphPact contributors",
"license": "MIT"
}
}
],
"retired": []
}Each entry carries more than a number. sourceSha256 andgeometrySha256 let a later build tell the difference between a file that was renamed and a file whose artwork actually changed, which is what makes a content-preserving rename keep its codepoint instead of being treated as a deletion plus an addition.
The lock file JSON schema is published alongside the compiler, so other tooling can read it without guessing.
The same change, with a lock
What actually happens
This is a real build. A two-icon pack gained four icons, one of which sorts before everything that was already there.
Before: two icons
U+E000arrows/back.svgU+E001status/verified.svg
After: four added, in discovery order
U+E002actions/add.svgU+E004arrows/arrow_down.svgU+E000arrows/back.svgunchangedU+E001status/verified.svgunchangedU+E005status/warning.svg
actions/add.svg is now the first source alphabetically, and it holds U+E002. arrows/back.svg is now third, and it still holds the U+E000 it was given when it was first. Position in the pack stopped mattering the moment the assignment was written down.
"unitsPerEm": 1000, "glyphs": [ {+ "source": "actions/add.svg",+ "name": "actionsAdd",+ "codepoint": "0xE002",+ "sourceSha256": "a169819fd451...",+ "matchTextDirection": false,+ "geometrySha256": "2025c5f8dff2..."+ },+ {+ "source": "arrows/arrow_down.svg",+ "name": "arrowsArrowDown",+ "codepoint": "0xE004",+ "sourceSha256": "b6e87581e2f2...",+ "matchTextDirection": false,+ "geometrySha256": "48a927141186..."+ },+ { "source": "arrows/back.svg", "name": "back", "codepoint": "0xE000", "sourceSha256": "38121f4ed8a1...", "matchTextDirection": true, }, { "source": "status/verified.svg", "name": "verified", "codepoint": "0xE001", },+ {+ "source": "status/warning.svg",+ "name": "statusWarning",+ "codepoint": "0xE005",+ } ], "retired": []Deleting an icon does not free its codepoint
Reuse is the second way this breaks, and it is worse than shifting, because a recycled codepoint is one that already shipped. If0xE003 pointed at an archive box in a released app, handing it to a different icon means old builds, cached fonts, and any code still referencing the constant now render the new picture.
So GlyphPact tombstones instead. A removed source moves to theretired array with its codepoint intact, and the value is never allocated again. The active sequence is left with a permanent gap, which is the correct outcome.
"glyphs": [
{ "source": "actions/add.svg", "codepoint": "0xE002" },
{ "source": "arrows/arrow_down.svg", "codepoint": "0xE004" },
{ "source": "arrows/back.svg", "codepoint": "0xE000" },
{ "source": "status/verified.svg", "codepoint": "0xE001" },
{ "source": "status/warning.svg", "codepoint": "0xE005" }
],
"retired": [
{ "source": "actions/archive.svg", "codepoint": "0xE003" }
]Restoring the file later reactivates the same slot rather than allocating a fresh one, so a revert is a real revert.
Tombstones consume allocation slots. Both active icons and retired ones draw from the range, because that is what permanence costs. The default BMP private use area,U+E000 - U+F8FF, holds 6,400 lifetime slots. For a bigger catalogue, start in a supplementary area with--start-codepoint 0xF0000, which provides 65,534 slots.
Report schema v3 tracks codepointsRemaining andrangeUtilization. At 80% utilization, GlyphPact emitsCODEPOINT_RANGE_NEAR_EXHAUSTION so the next stable shard can be planned before the current allocation window is full.
A lock only helps if something enforces it
A stored mapping still fails if the committed font stops matching the committed sources. Someone edits an SVG without regenerating, or regenerates without committing, and the repository now holds a font built from an older pack. Nothing in a normal test suite notices.
--check closes that gap. It rebuilds a candidate artifact set from current sources and compares it against what is committed, without writing to the owned output directory. Exit code3 means stale.
- name: Verify generated icon output is current run: glyphpact --config icon_font.json --check
With that step in place, the three failure modes are all covered by something mechanical: the lock prevents reassignment, tombstones prevent reuse, and the check prevents drift. None of them depend on anybody remembering a procedure.
Try it
Start with your own pack
uv tool install glyphpactInstalls the latest stable release from PyPI, currently v1.1.0. Already installed? Run uv tool upgrade glyphpact. Release notes.
Compiling is read-only with respect to your sources, and the strict default policy will tell you about anything in the pack an icon font cannot hold before it writes a font.