fontico 0.1.1 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: e650a7b2e4dd27bd57a0318811c9e5557b229c7b21151fd40b670f6d513532b1
4
- data.tar.gz: 0a59aa8f304d339c3d5a151c3359473c585ef1c9fe438252d24147a19462c937
3
+ metadata.gz: 1ee86cae31c0f7f4acb9b303bfd2c06638ea383d86e27ff603452537ec58cfed
4
+ data.tar.gz: cf9b3485369dd8601822dad9b1e367bccb41c440620071412b595b755afa97c9
5
5
  SHA512:
6
- metadata.gz: 07f21c106e47d7dbc23e73a0c8d091d7b9284bc6a32b0d2fde04a7fef5fc9d11e3b650a9e223dfd204214511d35ae454ffa67602e2e0e48426295456cd72d682
7
- data.tar.gz: fccdc71296e585a335f9a7539234ced2cf6fe1e8f51721aa288825cb840e9a3866db1e31cd1c947e2293383828068dcd112263d31cfeffbfa2548c04e1be913a
6
+ metadata.gz: e9e776c9fb99a18085b38a4069f23b72c808ba48c45b321d7948a166c99dd0bf9e93f413dd13a729a5a085148ffdf8e4a1feef544c4babec168e37d037e3b316
7
+ data.tar.gz: c5e3727514c2ebaff8c1b879ca9c2468bd2b14df0bef16797f983580b5830ac52b75fd93b9b3f639d433b7e11bac28aedc4c6cacf1d448d66bb2a715cdeccbc5
data/README.md CHANGED
@@ -1,6 +1,16 @@
1
- # fontico
1
+ ```
2
+ ╔═╗╔═╗╔╗╔╔╦╗╦╔═╗╔═╗
3
+ ╠╣ ║ ║║║║ ║ ║║ ║ ║
4
+ ╚ ╚═╝╝╚╝ ╩ ╩╚═╝╚═╝
5
+ ```
6
+
7
+ **Name icons by intent. Source them from anywhere. Ship one artifact.**
2
8
 
3
- Name icons by intent. Source them from anywhere. Ship one artifact.
9
+ [![Gem](https://img.shields.io/gem/v/fontico)](https://rubygems.org/gems/fontico)
10
+ [![CI](https://github.com/fireho/fontico/actions/workflows/ci.yml/badge.svg)](https://github.com/fireho/fontico/actions/workflows/ci.yml)
11
+ [![License](https://img.shields.io/badge/license-MIT-blue)](LICENSE.txt)
12
+
13
+ ---
4
14
 
5
15
  ```erb
6
16
  <%= icon "save" %> <%# Lucide %>
@@ -11,6 +21,32 @@ Name icons by intent. Source them from anywhere. Ship one artifact.
11
21
  Three providers, one call. Templates never name a vendor, so re-skinning the
12
22
  app — or surviving an upstream rename — is a diff in one file.
13
23
 
24
+ | | |
25
+ | ------------------------ | ----------------------------------------------------------------------------------- |
26
+ | **353,000 icons** | any of[Iconify's 238 sets](https://icon-sets.iconify.design/), plus your own folder |
27
+ | **One request** | a single sprite, 2.3KB brotli, not render-blocking |
28
+ | **361ms cold, 2ms warm** | measured on 35 icons across two remote providers and five local files |
29
+ | **No Node, no npm** | the sprite target is pure Ruby; deploys rebuild offline from the lockfile |
30
+ | **Also PDFs** | a real TTF for Prawn, with pinned codepoints |
31
+ | **Also firmware** | `icons.h` for C/C++, same names, 7KB of font instead of 357KB |
32
+ | **Also bare metal** | a `GFXfont` compiled in — 11KB for 221 icons, no renderer, no filesystem |
33
+
34
+ ## Install
35
+
36
+ ```ruby
37
+ # Gemfile
38
+ gem "fontico"
39
+ ```
40
+
41
+ ```bash
42
+ bundle install
43
+ $EDITOR icons.yml # the manifest, below
44
+ rake fontico:build
45
+ ```
46
+
47
+ No JS bundle, no `package.json`, no importmap pin. Artifacts land in
48
+ `app/assets/builds/`, which Propshaft already serves.
49
+
14
50
  ## The manifest
15
51
 
16
52
  `icons.yml`, one line per icon:
@@ -35,26 +71,25 @@ icons:
35
71
  ```
36
72
 
37
73
  Like pokemon, you gotta catch 'em all.
38
- Any of [Iconify's 200k+ icons](https://icon-sets.iconify.design/) work as a
39
- provider prefix. Your own SVGs go in `app/assets/icons/`, filename as slug.
74
+ Any of [Iconify's sets](https://icon-sets.iconify.design/) work as a provider
75
+ prefix. Your own SVGs go in `app/assets/icons/`, filename as slug.
76
+
77
+ A Rails engine that ships `config/icons.yml` (or `icons.yml` at its root) is
78
+ merged under the app's file, the same way `config/locales/` layers. Later
79
+ wins a name — the app always has the last word. Nested groups merge, so an
80
+ engine can ship `auth.google` and the app can add `auth.apple` without
81
+ copying the rest. `rake fontico:build` reads the same list.
40
82
 
41
83
  ## Build
42
84
 
43
85
  ```bash
44
86
  rake fontico:build # resolve, normalise, emit
45
- rake fontico:update # re-fetch, ignoring icons.lock
87
+ rake fontico:update # re-fetch bodies; codepoints stay pinned
46
88
  ```
47
89
 
48
- Artifacts land in `app/assets/builds/`, which Propshaft serves automatically —
49
- no manifest, no precompile list. `rake assets:precompile` is hooked, so deploys
50
- need no extra step.
90
+ `rake assets:precompile` is hooked, so deploys need no extra step.
51
91
 
52
- That directory is gitignored in a stock Rails app, so nothing is committed from
53
- it. **`icons.lock` is the thing you commit**: it holds every normalised body,
54
- so a deploy rebuilds the sprite from it in milliseconds with no network access
55
- and no Node.
56
-
57
- In development you rarely type either one. Saving `icons.yml` — or a local
92
+ **In development you rarely type either one.** Saving `icons.yml` — or a local
58
93
  SVG — rebuilds the artifacts and drops the cached manifest, so the icon is live
59
94
  on the next request: no rake, no restart. A save that only reshuffles known
60
95
  icons costs a couple of milliseconds; a brand-new one pays its provider fetch
@@ -73,13 +108,14 @@ there — is named in red and left out; the rest of the manifest still builds.
73
108
  It keeps its codepoint reserved, so fixing the entry and rebuilding brings it
74
109
  back with the same glyph. A provider that is unreachable is still fatal.
75
110
 
76
- Measured on 35 icons across two remote providers and five local files:
77
- **361ms cold, 2ms warm.** Vendor icons are fetched in one batched request per
78
- provider — not one per icon.
111
+ Vendor icons are fetched in one batched request per provider — not one per
112
+ icon.
79
113
 
80
114
  ## Why there is a lockfile
81
115
 
82
- `icons.lock` pins two things that must not drift:
116
+ `app/assets/builds/` is gitignored in a stock Rails app, so nothing is
117
+ committed from it. **`icons.lock` is the thing you commit.** It pins two things
118
+ that must not drift:
83
119
 
84
120
  - **Codepoints**, append-only. Adding an icon must not renumber the others, or
85
121
  every glyph in a built font moves and the committed artifact churns
@@ -88,20 +124,21 @@ provider — not one per icon.
88
124
  Iconify API serves *latest*; without this an icon can silently change shape
89
125
  between two builds of the same manifest.
90
126
 
91
- Commit it.
127
+ A deploy rebuilds the sprite from it in milliseconds, with no network access
128
+ and no Node. Commit it.
92
129
 
93
130
  ## What happens to your SVGs
94
131
 
95
132
  First-party exports are not uniform the way vendor icons are, so everything
96
133
  entering `app/assets/icons/` is normalised first:
97
134
 
98
- | | |
99
- | --- | --- |
100
- | Editor chrome | `sodipodi:`, `inkscape:`, `<metadata>`, RDF, empty `<defs>` stripped |
101
- | Ids | rewritten to `slug__id`, with `url(#…)`, `href`, `clip-path`, `mask` following |
102
- | viewBox | any source box refitted into the target, centred, aspect preserved |
103
- | Colour | folded to `currentColor`, unless the icon is detected as multicolour |
104
- | Safety | `<script>`, `on*` handlers, `<foreignObject>`, external refs removed |
135
+ | | |
136
+ | ------------- | ------------------------------------------------------------------------------ |
137
+ | Editor chrome | `sodipodi:`, `inkscape:`, `<metadata>`, RDF, empty `<defs>` stripped |
138
+ | Ids | rewritten to`slug__id`, with `url(#…)`, `href`, `clip-path`, `mask` following |
139
+ | viewBox | any source box refitted into the target, centred, aspect preserved |
140
+ | Colour | folded to`currentColor`, unless the icon is detected as multicolour |
141
+ | Safety | `<script>`, `on*` handlers, `<foreignObject>`, external refs removed |
105
142
 
106
143
  The id rewriting is not optional hygiene. Across 40 SVGs sampled from a real
107
144
  machine, **34 shared `id="layer1"`** and 11 shared `id="path1"` — merging any
@@ -184,11 +221,11 @@ codepoint, so nothing gets hardcoded.
184
221
  A font glyph is filled contours; it has no strokes and no colour. Each icon
185
222
  takes one of three routes:
186
223
 
187
- | | |
188
- | --- | --- |
189
- | **filled** | used as-is — Material Symbols, most Iconify sets, flat first-party exports |
224
+ | | |
225
+ | ------------- | ----------------------------------------------------------------------------------------------------------------------- |
226
+ | **filled** | used as-is — Material Symbols, most Iconify sets, flat first-party exports |
190
227
  | **extracted** | stroke-based, but the provider ships a font whose glyphs are already expanded — lifted from there, losslessly (Lucide) |
191
- | **refused** | stroke-based with no provider font — the build stops and names the icon |
228
+ | **refused** | stroke-based with no provider font — the build stops and names the icon |
192
229
 
193
230
  There is deliberately no raster-trace fallback. Both published JS expanders
194
231
  (`svg-outline-stroke`, `oslllo-svg-fixer`) run artwork through potrace and hand
@@ -204,12 +241,126 @@ build names each one it dropped.
204
241
  Font targets need Node, installed on demand into `~/.cache/fontico`. A manifest
205
242
  with `targets: [sprite]` never touches it and stays pure Ruby.
206
243
 
244
+ ## Firmware, and C
245
+
246
+ The `c` target emits `icons.h`, so a device names icons by intent like every
247
+ other consumer. It draws them out of the same TTF, which is why `c` implies
248
+ `ttf` the way `sprite` implies `css`.
249
+
250
+ `targets:` takes a mapping when an artifact belongs somewhere specific — a
251
+ firmware header goes in the device's `include/`, not in `app/assets/builds`
252
+ next to the web files:
253
+
254
+ ```yaml
255
+ targets:
256
+ sprite: # default location
257
+ ttf: devs/esplay/data/icons.ttf
258
+ c: devs/esplay/include/icons.h
259
+ ```
260
+
261
+ ```c
262
+ #include "icons.h"
263
+
264
+ display.drawIcon(ICON_WIFI, 86, 5, COLOR_WHITE, 20); /* "\xEE\x80\x81" */
265
+ ```
266
+
267
+ The UTF-8 bytes are literals decided at build time, so nothing parses
268
+ `"U+e63e"` or builds a `String` on the heap inside a draw loop. For an icon
269
+ named at runtime rather than compiled in — arriving in an MQTT payload, say —
270
+ the header also carries a name-sorted table:
271
+
272
+ ```c
273
+ #define FONTICO_ICONS_IMPLEMENTATION /* in exactly one translation unit */
274
+ #include "icons.h"
275
+
276
+ const char *glyph = fontico_icon(payload["icon"]); /* NULL if unknown */
277
+ ```
278
+
279
+ Two reasons this is a better deal than a vendor icon font on the device:
280
+
281
+ - **Size.** The font carries only the icons your manifest names. 35 icons
282
+ build a 7KB TTF, against the 357KB an unsubsetted Material Symbols drop-in
283
+ costs — and flash is the budget that actually binds.
284
+ - **Codepoints that cannot move.** They are pinned append-only in
285
+ `icons.lock`, which matters more here than anywhere else: a codepoint
286
+ compiled into a flashed image cannot be hotfixed. If adding an icon
287
+ renumbered the others, every board in the field would draw the wrong
288
+ picture after an OTA.
289
+
290
+ Names become macros — `nav.menu` is `ICON_NAV_MENU`. Two names that fold to
291
+ the same macro are refused rather than silently redefined, and non-ASCII
292
+ names are refused too: the table is sorted here and `bsearch`ed there, and a
293
+ byte above `0x7F` orders the other way under a signed `char`.
294
+
295
+ Multicolour icons are left out, the same as they are left out of the font —
296
+ there would be no glyph behind the codepoint. The build names each one.
297
+
298
+ ### `gfxfont`: no font renderer on the device at all
299
+
300
+ The `c` target still needs something on the device that can read a TTF. The
301
+ `gfxfont` target removes that: it emits an Adafruit `GFXfont` — 1-bit bitmaps
302
+ and a glyph table, compiled straight into the binary. No FreeType, no
303
+ OpenFontRender, no filesystem partition to flash. `Adafruit_GFX` and
304
+ `Arduino_GFX` both take it as-is:
305
+
306
+ ```yaml
307
+ targets:
308
+ sprite:
309
+ gfxfont:
310
+ path: devs/include/icons_font.h
311
+ size: 24
312
+ ```
313
+
314
+ ```c
315
+ #include <Arduino_GFX_Library.h>
316
+ #include "icons_font.h"
317
+
318
+ gfx->setFont(&fontico_icons24);
319
+ gfx->drawChar(x, y, ICON_WIFI, fg, bg, 1);
320
+ ```
321
+
322
+ 221 icons at 24px come to **9,961 bytes of bitmap** plus a 1,547-byte glyph
323
+ table — about 11KB for the set, against 357KB for an unsubsetted vendor font
324
+ on a filesystem. Bitmaps are rasterised out of the TTF fontico already
325
+ builds, not from the SVGs, so they inherit the same normalised geometry the
326
+ PDF uses instead of drifting from it through a second pipeline.
327
+
328
+ Two things follow from the format. A `GFXfont` is **fixed size**, so ask for
329
+ the size you will draw at rather than scaling one up. And it is indexed off a
330
+ byte, so it holds 224 icons; past that the build says so and points you at the
331
+ `c` target, which addresses codepoints directly. The char codes are positional
332
+ for that reason, which is why they and the bitmaps are emitted into one file —
333
+ they cannot disagree. Their order follows the append-only codepoints in
334
+ `icons.lock`, so adding an icon appends instead of renumbering what is already
335
+ flashed.
336
+
337
+ At one bit deep, thin strokes fall through the threshold at small sizes. An
338
+ icon that rasterises to nothing is refused rather than shipped as an icon that
339
+ draws as nothing.
340
+
341
+ ## How it compares
342
+
343
+ | | vendor gems (`lucide-rails`, `heroicon`) | `iconify-icon` web component | fontico |
344
+ | ---------------------------- | ---------------------------------------- | ------------------------------ | --------------------- |
345
+ | Icon sets | one, per gem | 238 | 238 + your own |
346
+ | Where the SVG comes from | inlined per render | fetched at runtime, per client | one build-time sprite |
347
+ | Requests | 0, but repeated in every response body | 1+ per icon, per visitor | 1, cached |
348
+ | JS required | no | yes | no |
349
+ | Rename-proof templates | no —`lucide_icon "pencil"` | no —`icon="lucide:pencil"` | yes —`icon "edit"` |
350
+ | Offline / reproducible build | yes | no | yes, via`icons.lock` |
351
+ | PDF | no | no | yes, TTF for Prawn |
352
+
207
353
  ## Status
208
354
 
209
355
  - ✅ Manifest, resolver, preprocessor, lockfile, sprite emitter, Rails helper
210
356
  - ✅ TTF emitter with glyph extraction, codepoint API, Prawn helpers
357
+ - ✅ `c` target: `icons.h` for firmware, with per-target output paths
358
+ - ✅ `gfxfont` target: 1-bit bitmaps compiled in, for Adafruit_GFX / Arduino_GFX
211
359
  - ⏳ `woff2` — declared targets are skipped with a notice
360
+ - ⏳ `variant:` — accepted and ignored by the helper; see [TODO.md](TODO.md)
212
361
 
213
362
  ## License
214
363
 
215
- MIT
364
+ MIT.
365
+ Icons keep their upstream licenses:
366
+ `icons.lock` records what you declared and shipped.
@@ -12,11 +12,13 @@ module Fontico
12
12
  Report = Struct.new(:written, :warnings, :skipped, :cached, :fetched, :pending,
13
13
  :missing, keyword_init: true)
14
14
 
15
- def initialize(manifest, root: Dir.pwd, output: "app/assets/builds", offline: false)
15
+ def initialize(manifest, root: Dir.pwd, output: "app/assets/builds",
16
+ offline: false, force: false)
16
17
  @manifest = manifest
17
18
  @root = root
18
19
  @output = File.join(root, output)
19
20
  @offline = offline
21
+ @force = force
20
22
  @lock = Lockfile.new(File.join(root, "icons.lock"))
21
23
  end
22
24
 
@@ -25,11 +27,28 @@ module Fontico
25
27
  cached, fetched = [], []
26
28
  missing = {}
27
29
 
28
- stale = @manifest.icons.reject { @lock.fresh?(_1.name, _1.source) }
30
+ # The lock caches Iconify bodies so deploys run offline. Local files
31
+ # are already on disk: treating them as fresh meant editing logo.svg
32
+ # rebuilt nothing. Force is `rake fontico:update` — re-fetch remotes,
33
+ # keep the append-only codepoints.
34
+ # A lock from before sizes were recorded is taken at the manifest's word
35
+ # once, here, so an upgrade does not re-fetch everything. From then on a
36
+ # change to `defaults: size:` is caught like any other input change.
37
+ @lock.assume_size(@manifest.size)
38
+
39
+ stale = @manifest.icons.select do |icon|
40
+ icon.local? || @force ||
41
+ !@lock.fresh?(icon.name, icon.source, size: @manifest.size, multicolor: icon.multicolor)
42
+ end
29
43
  cached = @manifest.icons.map(&:name) - stale.map(&:name)
30
44
 
31
45
  unless stale.empty?
32
- raise Error, "icons.lock is missing #{stale.size} icon(s) and --offline was given" if @offline
46
+ remote_stale = stale.reject(&:local?)
47
+ # Local files do not need the network. Offline is a hard fail only
48
+ # when a remote body has to come off the wire — which under force is
49
+ # every remote, lock or no lock. Saying "missing" there would be a
50
+ # lie: the bodies are present, force is what made them stale.
51
+ raise Error, "cannot #{offline_blocker(remote_stale)} and --offline was given" if @offline && remote_stale.any?
33
52
 
34
53
  resolver = Resolver.new(@manifest, root: @root)
35
54
  sources = resolver.call(only: stale.map(&:name))
@@ -43,9 +62,16 @@ module Fontico
43
62
 
44
63
  pre = Preprocessor.new(icon, size: @manifest.size)
45
64
  .call(src.markup, width: src.width, height: src.height)
46
- @lock.store(icon.name, source: icon.source, body: pre.body,
65
+ # Locals are re-read every build, so "stale" does not mean changed.
66
+ # Compare the stored digest to keep an untouched logo.svg counted as
67
+ # cached rather than reported as a fetch that never happened. A
68
+ # remote is judged by the wire, not the digest: under force it was
69
+ # genuinely re-fetched even when it came back byte-identical.
70
+ before = @lock.entry(icon.name)&.fetch("digest", nil)
71
+ @lock.store(icon.name, source: icon.source, body: pre.body, size: @manifest.size,
47
72
  multicolor: pre.multicolor, warnings: pre.warnings)
48
- fetched << icon.name
73
+ unchanged = icon.local? && @lock.entry(icon.name)["digest"] == before
74
+ (unchanged ? cached : fetched) << icon.name
49
75
  end
50
76
  end
51
77
 
@@ -68,6 +94,25 @@ module Fontico
68
94
 
69
95
  private
70
96
 
97
+ # "missing from icons.lock" is a lie when every body is present and
98
+ # something else invalidated them, so name the thing that actually did.
99
+ # A body carries its refit baked in, so a new size needs the original
100
+ # geometry back off the wire — there is nothing on disk to redo it from.
101
+ def offline_blocker(remote_stale)
102
+ count = remote_stale.size
103
+ return "re-fetch #{count} icon(s)" if @force
104
+ return "resolve #{count} icon(s) missing from icons.lock" unless remote_stale.all? { locked?(_1) }
105
+
106
+ "re-preprocess #{count} icon(s) at size: #{@manifest.size}"
107
+ end
108
+
109
+ # Present in the lock, with a body, for this source — so not "missing",
110
+ # only invalidated by one of the inputs #fresh? checks alongside it.
111
+ def locked?(icon)
112
+ e = @lock.entry(icon.name)
113
+ !e.nil? && !e["body"].nil? && e["source"] == icon.source
114
+ end
115
+
71
116
  def emit(icons)
72
117
  FileUtils.mkdir_p(@output)
73
118
  written = []
@@ -75,9 +120,18 @@ module Fontico
75
120
 
76
121
  targets = @manifest.targets - PENDING
77
122
  targets += ["css"] if targets.include?("sprite") && !targets.include?("css")
123
+ # Both C targets lean on the font: the header is a map into it, and the
124
+ # gfxfont is rasterised out of it. Same implication as sprite -> css.
125
+ needs_font = targets.include?("c") || targets.include?("gfxfont")
126
+ targets += ["ttf"] if needs_font && !targets.include?("ttf")
127
+ # And the gfxfont reads the .ttf off disk, so it has to run after it.
128
+ targets = targets.partition { _1 != "gfxfont" }.flatten
78
129
 
79
130
  targets.each do |target|
80
- path = File.join(@output, filename_for(target))
131
+ path = path_for(target)
132
+ # Before the emitter runs, not after it returns: a font target writes
133
+ # its own file through Node and needs the directory already there.
134
+ FileUtils.mkdir_p(File.dirname(path))
81
135
  emitter = emitter_for(target, [], path: path)
82
136
 
83
137
  accepted = icons.select { emitter.accepts?(_1) }
@@ -94,12 +148,29 @@ module Fontico
94
148
  [written, skipped]
95
149
  end
96
150
 
151
+ # An explicit path in the manifest wins, so a firmware header can land in
152
+ # the device's include/ directory instead of among the web artifacts.
153
+ #
154
+ # Always absolute: a font target hands its path to Node, which runs from
155
+ # its own toolchain directory and cannot resolve a relative one.
156
+ def path_for(target)
157
+ override = @manifest.target_path(target)
158
+ return File.expand_path(override, @root) if override
159
+
160
+ File.expand_path(filename_for(target), @output)
161
+ end
162
+
97
163
  def emitter_for(target, build = [], path: nil)
98
164
  case target
99
165
  when "sprite" then Emitters::Sprite.new(@manifest, build)
100
166
  when "css" then Emitters::Stylesheet.new(@manifest, build)
101
167
  when "ttf" then Emitters::Font.new(@manifest, build, lock: @lock, output: path)
102
- else raise Error, "unknown target #{target.inspect} (have: sprite, ttf)"
168
+ when "c" then Emitters::Header.new(@manifest, build, lock: @lock)
169
+ when "gfxfont"
170
+ opts = @manifest.target_options(target)
171
+ Emitters::GfxFont.new(@manifest, build, lock: @lock, ttf: path_for("ttf"),
172
+ size: (opts["size"] || 24).to_i)
173
+ else raise Error, "unknown target #{target.inspect} (have: sprite, ttf, c, gfxfont)"
103
174
  end
104
175
  end
105
176
 
@@ -107,7 +178,9 @@ module Fontico
107
178
  case target
108
179
  when "sprite" then "icons.svg"
109
180
  when "css" then "icons.css"
110
- when "ttf" then "icons.ttf"
181
+ when "ttf" then "icons.ttf"
182
+ when "c" then "icons.h"
183
+ when "gfxfont" then "icons_font.h"
111
184
  else raise Error, "unknown target #{target.inspect}"
112
185
  end
113
186
  end
@@ -26,9 +26,16 @@ module Fontico
26
26
  outlines = @outliner.outlines(@build, size: @manifest.size)
27
27
 
28
28
  glyphs = @build.map do |icon, body|
29
- { name: icon.key,
30
- codepoint: @lock.codepoint_for(icon.name),
31
- svg: document(outlines[icon.name], body) }
29
+ # Never fall back to allocating here. The builder has already
30
+ # save!d the lock by the time it emits, so a codepoint minted now
31
+ # would never reach disk: the font would ship a glyph that
32
+ # Fontico.glyph cannot name. A nil is worse still — it reaches
33
+ # String.fromCodePoint as null and maps the glyph to U+0000
34
+ # without complaint.
35
+ cp = @lock.codepoint_for(icon.name)
36
+ raise Fontico::Error, "#{icon.name} has no codepoint in icons.lock" if cp.nil?
37
+
38
+ { name: icon.key, codepoint: cp, svg: document(outlines[icon.name], body) }
32
39
  end
33
40
 
34
41
  result = @outliner.instance_variable_get(:@runner)
@@ -0,0 +1,190 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Fontico
4
+ module Emitters
5
+ # An Adafruit GFXfont, for a display driven by Adafruit_GFX or Arduino_GFX.
6
+ # It compiles into the binary, so unlike the TTF target there is no
7
+ # FreeType on the device, no font renderer, and no filesystem to flash.
8
+ #
9
+ # The trade is that a GFXfont is a fixed size and one bit deep. Ask for the
10
+ # size you will draw at; scaling one up is what the built-in 6x8 font
11
+ # already does badly.
12
+ #
13
+ # Char codes rather than codepoints, because that is what the draw path
14
+ # takes: Adafruit_GFX and Arduino_GFX both index glyphs as `c - first` off
15
+ # a byte. They are therefore positional, which is exactly why the codes and
16
+ # the bitmaps are emitted into one file — they cannot fall out of step with
17
+ # each other. What keeps them stable across builds is the order, and the
18
+ # order comes from the append-only codepoints in icons.lock.
19
+ class GfxFont
20
+ # 0x20..0xFF. Below 0x20 is control characters, which some print paths
21
+ # intercept before the font ever sees them.
22
+ FIRST = 0x20
23
+ LAST = 0xFF
24
+ CAPACITY = LAST - FIRST + 1
25
+
26
+ def initialize(manifest, build, lock: nil, ttf: nil, size: 24, runner: nil)
27
+ @manifest = manifest
28
+ @build = build
29
+ @lock = lock
30
+ @ttf = ttf
31
+ @size = size
32
+ @runner = runner || NodeRunner.new
33
+ end
34
+
35
+ # Same rule as the font it is rasterised from: one bit cannot hold
36
+ # colour, so a multicolour icon has nothing to become.
37
+ def accepts?(icon) = !(@lock&.multicolor?(icon.name) || icon.multicolor?)
38
+ def rules_only? = false
39
+
40
+ def call
41
+ icons = @build.map(&:first).sort_by { codepoint!(_1) }
42
+ # A GFXfont with no glyphs is not a font. This is reachable when every
43
+ # icon in the manifest was dropped as multicolour.
44
+ raise Fontico::Error, "the gfxfont target has no icons left to rasterise" if icons.empty?
45
+
46
+ guard_capacity!(icons)
47
+
48
+ result = @runner.run("rasterize.mjs", {
49
+ ttf: @ttf, size: @size,
50
+ glyphs: icons.map { { name: _1.name, codepoint: codepoint!(_1) } }
51
+ })
52
+ rows = rows_for(result["glyphs"], icons)
53
+ guard_blank!(rows)
54
+
55
+ render(rows, result["yAdvance"])
56
+ end
57
+
58
+ private
59
+
60
+ def codepoint!(icon)
61
+ @lock&.codepoint_for(icon.name) ||
62
+ raise(Fontico::Error, "#{icon.name} has no codepoint in icons.lock")
63
+ end
64
+
65
+ def rows_for(glyphs, icons)
66
+ by_name = glyphs.to_h { [_1["name"], _1] }
67
+ offset = 0
68
+ icons.each_with_index.map do |icon, i|
69
+ g = by_name.fetch(icon.name)
70
+ row = { name: icon.name, source: icon.source, char: FIRST + i,
71
+ ident: Header.identifier(icon.name), offset: offset, glyph: g }
72
+ offset += g["bytes"].size
73
+ row
74
+ end
75
+ end
76
+
77
+ # A GFXfont has room for one byte's worth of glyphs and no more. Failing
78
+ # here beats emitting a font that silently stops at icon 224.
79
+ def guard_capacity!(icons)
80
+ return if icons.size <= CAPACITY
81
+
82
+ raise Fontico::Error,
83
+ "the gfxfont target holds #{CAPACITY} icons (a GFXfont is indexed off a byte) " \
84
+ "and this manifest has #{icons.size}. Split the icons across two fonts, or " \
85
+ "build the c target instead, which addresses codepoints directly."
86
+ end
87
+
88
+ # An icon that rasterised to nothing would draw as empty space with no
89
+ # complaint, on a screen nobody is watching.
90
+ def guard_blank!(rows)
91
+ blank = rows.select { _1[:glyph]["blank"] }
92
+ return if blank.empty?
93
+
94
+ raise Fontico::Error, <<~MSG
95
+ #{blank.size} icon(s) rasterised blank at #{@size}px and would draw as nothing:
96
+
97
+ #{blank.map { _1[:name] }.join("\n ")}
98
+
99
+ Thin strokes fall through a one-bit threshold at small sizes. Either
100
+ raise the gfxfont size, or drop these from the target.
101
+ MSG
102
+ end
103
+
104
+ def render(rows, y_advance)
105
+ bytes = rows.flat_map { _1[:glyph]["bytes"] }
106
+ tag = "fontico_icons#{@size}"
107
+
108
+ <<~HEADER
109
+ /* generated by fontico — do not edit.
110
+ *
111
+ * #{rows.size} icon(s) as an Adafruit GFXfont, rasterised at #{@size}px, one bit
112
+ * deep. #{bytes.size} bytes of bitmap. Include this after your GFX library
113
+ * header; it defines the GFXglyph/GFXfont structs only if that has
114
+ * not already done so.
115
+ *
116
+ * gfx->setFont(&#{tag});
117
+ * gfx->drawChar(x, y, #{rows.first[:ident]}, fg, bg, 1);
118
+ *
119
+ * The char codes below are positional, so they and the bitmaps are
120
+ * one artifact and are regenerated together. Their order follows the
121
+ * append-only codepoints in icons.lock, so adding an icon appends
122
+ * rather than renumbering what is already flashed.
123
+ */
124
+ #ifndef FONTICO_GFXFONT_H
125
+ #define FONTICO_GFXFONT_H
126
+
127
+ #include <stdint.h>
128
+
129
+ /* Adafruit_GFX's own guard, so the library wins when it is present. */
130
+ #ifndef _GFXFONT_H_
131
+ #define _GFXFONT_H_
132
+ typedef struct {
133
+ uint16_t bitmapOffset;
134
+ uint8_t width, height, xAdvance;
135
+ int8_t xOffset, yOffset;
136
+ } GFXglyph;
137
+ typedef struct {
138
+ uint8_t *bitmap;
139
+ GFXglyph *glyph;
140
+ uint16_t first, last;
141
+ uint8_t yAdvance;
142
+ } GFXfont;
143
+ #endif
144
+
145
+ #ifndef PROGMEM
146
+ #define PROGMEM
147
+ #endif
148
+
149
+ #{defines(rows).join("\n")}
150
+
151
+ /* One continuous MSB-first bitstream, no padding between rows. */
152
+ static const uint8_t #{tag}_bitmaps[] PROGMEM = {
153
+ #{bitmap_lines(bytes).join("\n")}
154
+ };
155
+
156
+ static const GFXglyph #{tag}_glyphs[] PROGMEM = {
157
+ #{rows.map { glyph_line(_1) }.join("\n")}
158
+ };
159
+
160
+ static const GFXfont #{tag} PROGMEM = {
161
+ (uint8_t *)#{tag}_bitmaps,
162
+ (GFXglyph *)#{tag}_glyphs,
163
+ 0x#{format("%02X", FIRST)}, 0x#{format("%02X", rows.last[:char])}, #{y_advance}
164
+ };
165
+
166
+ #endif /* FONTICO_GFXFONT_H */
167
+ HEADER
168
+ end
169
+
170
+ def defines(rows)
171
+ width = rows.map { _1[:ident].length }.max.to_i
172
+ rows.map do |r|
173
+ format("#define %-#{width}s 0x%02X /* %s %s */",
174
+ r[:ident], r[:char], r[:name], r[:source])
175
+ end
176
+ end
177
+
178
+ def bitmap_lines(bytes)
179
+ bytes.each_slice(16).map { " #{_1.map { |b| format("0x%02X", b) }.join(", ")}," }
180
+ end
181
+
182
+ def glyph_line(row)
183
+ g = row[:glyph]
184
+ format(" { %5d, %3d, %3d, %3d, %4d, %4d }, /* %s */",
185
+ row[:offset], g["width"], g["height"], g["xAdvance"],
186
+ g["xOffset"], g["yOffset"], row[:name])
187
+ end
188
+ end
189
+ end
190
+ end