Skip to content
GlyphPact
Menu

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.

  1. 3initial navigation icons
  2. 5two states arrive
  3. 10another Figma batch
  4. 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:

Assignment derived from the file list
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:

After adding one icon that sorts firstevery assignment moved
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.

iconfont.lock.jsonschema version 1
{
  "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

  1. U+E000arrows/back.svg
  2. U+E001status/verified.svg

After: four added, in discovery order

  1. U+E002actions/add.svg
  2. U+E004arrows/arrow_down.svg
  3. U+E000arrows/back.svgunchanged
  4. U+E001status/verified.svgunchanged
  5. U+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.

iconfont.lock.jsongit diff, after adding four icons
--- a/lib/generated/app_icons/iconfont.lock.json+++ b/lib/generated/app_icons/iconfont.lock.json@@ -9,6 +9,30 @@   "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": []
+ addedunchanged assignment
The diff contains additions and nothing else. No existing assignment was modified, so no shipped IconData constant changed meaning.

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.

After deleting actions/archive.svg0xE003 retired, not reused
"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.

.github/workflows/ci.yml
- 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 glyphpact

Installs 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.

Wire the output into a Flutter app

FAQ

Questions about codepoint stability

Why do icon-font codepoints change when icons are added?

A generator that derives codepoints from the current source list can shift assignments when the list changes. Insert an icon whose name sorts early, rename a file, or start a new browser import without restoring the previous project, and every icon after that point can move.

The font itself is then internally consistent, so nothing fails at build time. The breakage appears in application code that still refers to the old value: a constant for a back arrow keeps pointing at U+E001 while U+E001 now contains a different glyph. The icon renders, it is simply the wrong picture.

How does GlyphPact keep existing codepoints stable?

GlyphPact writes a lock file, iconfont.lock.json, into the generated output directory and reads it back on every later build. Each entry records the source path, the Dart name, the assigned codepoint, and content hashes. An assignment already in the lock is reused as-is; only genuinely new sources draw new codepoints from the end of the allocated range.

Removing an icon does not free its codepoint. The entry moves to a "retired" list in the same file, leaving a permanent tombstone so the value is never handed to a different picture. Restoring the source later reactivates the original slot, and a unique content-preserving rename keeps both its codepoint and its generated Dart name.

How the lock file works

Should iconfont.lock.json be committed to Git?

Yes. The lock file is the codepoint ABI for the generated font, and it only protects existing assignments if the next build can read the same file the previous build wrote. Committing it alongside the generated font, Dart provider, report, and attribution file is the intended workflow.

If the lock is deleted or lost, the next build has no record of prior assignments and will allocate codepoints from scratch, which can reassign icons even when no source file changed.