fontico 0.2.0 → 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: 3ad5f56ad9c1523dbd17d2cdb07da3a2d6aac315008ebd08ba84551d125083c0
4
- data.tar.gz: 207804df603f81d1da9117d5b081a73dbcf649bf882dbeb43fdec33678fcacb4
3
+ metadata.gz: 1ee86cae31c0f7f4acb9b303bfd2c06638ea383d86e27ff603452537ec58cfed
4
+ data.tar.gz: cf9b3485369dd8601822dad9b1e367bccb41c440620071412b595b755afa97c9
5
5
  SHA512:
6
- metadata.gz: 4a4bf9595c5257803d26ff2c79607d0a44b48b0c4bc7b1584ce9a71e6e61f18fd71447884eba9d9e48d296a5aec6c42ad6973b7c82e197443be02c6b1032756e
7
- data.tar.gz: 3132cfd1d3a9fa3997a23ed450fa749416aac364414b883e5c65e6995b7708cbbb4155ed672de376c0089543ea3b36167cc1d93deb055e402529ca705685f030
6
+ metadata.gz: e9e776c9fb99a18085b38a4069f23b72c808ba48c45b321d7948a166c99dd0bf9e93f413dd13a729a5a085148ffdf8e4a1feef544c4babec168e37d037e3b316
7
+ data.tar.gz: c5e3727514c2ebaff8c1b879ca9c2468bd2b14df0bef16797f983580b5830ac52b75fd93b9b3f639d433b7e11bac28aedc4c6cacf1d448d66bb2a715cdeccbc5
data/README.md CHANGED
@@ -1,5 +1,3 @@
1
- <div align="center">
2
-
3
1
  ```
4
2
  ╔═╗╔═╗╔╗╔╔╦╗╦╔═╗╔═╗
5
3
  ╠╣ ║ ║║║║ ║ ║║ ║ ║
@@ -12,8 +10,6 @@
12
10
  [![CI](https://github.com/fireho/fontico/actions/workflows/ci.yml/badge.svg)](https://github.com/fireho/fontico/actions/workflows/ci.yml)
13
11
  [![License](https://img.shields.io/badge/license-MIT-blue)](LICENSE.txt)
14
12
 
15
- </div>
16
-
17
13
  ---
18
14
 
19
15
  ```erb
@@ -25,13 +21,15 @@
25
21
  Three providers, one call. Templates never name a vendor, so re-skinning the
26
22
  app — or surviving an upstream rename — is a diff in one file.
27
23
 
28
- | | |
29
- | --- | --- |
30
- | **353,000 icons** | any of [Iconify's 238 sets](https://icon-sets.iconify.design/), plus your own folder |
31
- | **One request** | a single sprite, 2.3KB brotli, not render-blocking |
32
- | **361ms cold, 2ms warm** | measured on 35 icons across two remote providers and five local files |
33
- | **No Node, no npm** | the sprite target is pure Ruby; deploys rebuild offline from the lockfile |
34
- | **Also PDFs** | a real TTF for Prawn, with pinned codepoints |
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 |
35
33
 
36
34
  ## Install
37
35
 
@@ -134,13 +132,13 @@ and no Node. Commit it.
134
132
  First-party exports are not uniform the way vendor icons are, so everything
135
133
  entering `app/assets/icons/` is normalised first:
136
134
 
137
- | | |
138
- | --- | --- |
139
- | Editor chrome | `sodipodi:`, `inkscape:`, `<metadata>`, RDF, empty `<defs>` stripped |
140
- | Ids | rewritten to `slug__id`, with `url(#…)`, `href`, `clip-path`, `mask` following |
141
- | viewBox | any source box refitted into the target, centred, aspect preserved |
142
- | Colour | folded to `currentColor`, unless the icon is detected as multicolour |
143
- | 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 |
144
142
 
145
143
  The id rewriting is not optional hygiene. Across 40 SVGs sampled from a real
146
144
  machine, **34 shared `id="layer1"`** and 11 shared `id="path1"` — merging any
@@ -223,11 +221,11 @@ codepoint, so nothing gets hardcoded.
223
221
  A font glyph is filled contours; it has no strokes and no colour. Each icon
224
222
  takes one of three routes:
225
223
 
226
- | | |
227
- | --- | --- |
228
- | **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 |
229
227
  | **extracted** | stroke-based, but the provider ships a font whose glyphs are already expanded — lifted from there, losslessly (Lucide) |
230
- | **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 |
231
229
 
232
230
  There is deliberately no raster-trace fallback. Both published JS expanders
233
231
  (`svg-outline-stroke`, `oslllo-svg-fixer`) run artwork through potrace and hand
@@ -243,22 +241,121 @@ build names each one it dropped.
243
241
  Font targets need Node, installed on demand into `~/.cache/fontico`. A manifest
244
242
  with `targets: [sprite]` never touches it and stays pure Ruby.
245
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
+
246
341
  ## How it compares
247
342
 
248
- | | vendor gems (`lucide-rails`, `heroicon`) | `iconify-icon` web component | fontico |
249
- | --- | --- | --- | --- |
250
- | Icon sets | one, per gem | 238 | 238, plus your own folder |
251
- | Where the SVG comes from | inlined per render | fetched at runtime, per client | one build-time sprite |
252
- | Requests | 0, but repeated in every response body | 1+ per icon, per visitor | 1, cached |
253
- | JS required | no | yes | no |
254
- | Rename-proof templates | no — `lucide_icon "pencil"` | no — `icon="lucide:pencil"` | yes — `icon "edit"` |
255
- | Offline / reproducible build | yes | no | yes, via `icons.lock` |
256
- | PDF | no | no | yes, TTF for Prawn |
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 |
257
352
 
258
353
  ## Status
259
354
 
260
355
  - ✅ Manifest, resolver, preprocessor, lockfile, sprite emitter, Rails helper
261
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
262
359
  - ⏳ `woff2` — declared targets are skipped with a notice
263
360
  - ⏳ `variant:` — accepted and ignored by the helper; see [TODO.md](TODO.md)
264
361
 
@@ -31,7 +31,15 @@ module Fontico
31
31
  # are already on disk: treating them as fresh meant editing logo.svg
32
32
  # rebuilt nothing. Force is `rake fontico:update` — re-fetch remotes,
33
33
  # keep the append-only codepoints.
34
- stale = @manifest.icons.select { _1.local? || @force || !@lock.fresh?(_1.name, _1.source) }
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
35
43
  cached = @manifest.icons.map(&:name) - stale.map(&:name)
36
44
 
37
45
  unless stale.empty?
@@ -40,10 +48,7 @@ module Fontico
40
48
  # when a remote body has to come off the wire — which under force is
41
49
  # every remote, lock or no lock. Saying "missing" there would be a
42
50
  # lie: the bodies are present, force is what made them stale.
43
- if @offline && remote_stale.any?
44
- reason = @force ? "re-fetch #{remote_stale.size} icon(s)" : "resolve #{remote_stale.size} icon(s) missing from icons.lock"
45
- raise Error, "cannot #{reason} and --offline was given"
46
- end
51
+ raise Error, "cannot #{offline_blocker(remote_stale)} and --offline was given" if @offline && remote_stale.any?
47
52
 
48
53
  resolver = Resolver.new(@manifest, root: @root)
49
54
  sources = resolver.call(only: stale.map(&:name))
@@ -63,7 +68,7 @@ module Fontico
63
68
  # remote is judged by the wire, not the digest: under force it was
64
69
  # genuinely re-fetched even when it came back byte-identical.
65
70
  before = @lock.entry(icon.name)&.fetch("digest", nil)
66
- @lock.store(icon.name, source: icon.source, body: pre.body,
71
+ @lock.store(icon.name, source: icon.source, body: pre.body, size: @manifest.size,
67
72
  multicolor: pre.multicolor, warnings: pre.warnings)
68
73
  unchanged = icon.local? && @lock.entry(icon.name)["digest"] == before
69
74
  (unchanged ? cached : fetched) << icon.name
@@ -89,6 +94,25 @@ module Fontico
89
94
 
90
95
  private
91
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
+
92
116
  def emit(icons)
93
117
  FileUtils.mkdir_p(@output)
94
118
  written = []
@@ -96,9 +120,18 @@ module Fontico
96
120
 
97
121
  targets = @manifest.targets - PENDING
98
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
99
129
 
100
130
  targets.each do |target|
101
- 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))
102
135
  emitter = emitter_for(target, [], path: path)
103
136
 
104
137
  accepted = icons.select { emitter.accepts?(_1) }
@@ -115,12 +148,29 @@ module Fontico
115
148
  [written, skipped]
116
149
  end
117
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
+
118
163
  def emitter_for(target, build = [], path: nil)
119
164
  case target
120
165
  when "sprite" then Emitters::Sprite.new(@manifest, build)
121
166
  when "css" then Emitters::Stylesheet.new(@manifest, build)
122
167
  when "ttf" then Emitters::Font.new(@manifest, build, lock: @lock, output: path)
123
- 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)"
124
174
  end
125
175
  end
126
176
 
@@ -128,7 +178,9 @@ module Fontico
128
178
  case target
129
179
  when "sprite" then "icons.svg"
130
180
  when "css" then "icons.css"
131
- when "ttf" then "icons.ttf"
181
+ when "ttf" then "icons.ttf"
182
+ when "c" then "icons.h"
183
+ when "gfxfont" then "icons_font.h"
132
184
  else raise Error, "unknown target #{target.inspect}"
133
185
  end
134
186
  end
@@ -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
@@ -0,0 +1,165 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Fontico
4
+ module Emitters
5
+ # A C header, so firmware names icons by intent like every other consumer.
6
+ # The device draws them out of the TTF this is emitted alongside, and the
7
+ # two agree because both read codepoints from icons.lock.
8
+ #
9
+ # That pinning matters more here than anywhere else fontico uses it. A
10
+ # codepoint compiled into a flashed .bin cannot be hotfixed: if adding an
11
+ # icon renumbered the others, every board in the field would start drawing
12
+ # the wrong picture after an OTA. The lock is append-only precisely so that
13
+ # cannot happen.
14
+ #
15
+ # What the firmware gets out of this, beyond the naming: the UTF-8 bytes
16
+ # are literals decided at build time, so nothing parses "U+e63e" or
17
+ # assembles a String on the heap inside a draw loop.
18
+ class Header
19
+ PREFIX = "ICON_"
20
+
21
+ # Every byte of a private-use codepoint's UTF-8 is emitted as a \x
22
+ # escape and nothing else follows it inside the quotes, so C's greedy
23
+ # hex escapes have nothing extra to swallow.
24
+ def initialize(manifest, build, lock: nil)
25
+ @manifest = manifest
26
+ @build = build
27
+ @lock = lock
28
+ end
29
+
30
+ # A glyph carries no colour, so the same icons the font refuses are the
31
+ # ones this cannot name — there would be no glyph at the codepoint.
32
+ def accepts?(icon) = !(@lock&.multicolor?(icon.name) || icon.multicolor?)
33
+ def rules_only? = false
34
+
35
+ def self.identifier(name) = "#{PREFIX}#{name.upcase.gsub(/[^A-Z0-9]+/, "_")}"
36
+
37
+ # U+E001 -> "\xEE\x80\x81"
38
+ def self.utf8_literal(codepoint)
39
+ [codepoint].pack("U").bytes.map { format("\\x%02X", _1) }.join
40
+ end
41
+
42
+ def call
43
+ rows = @build.map { |icon, _| row(icon) }.sort_by { _1[:name] }
44
+ guard_ascii!(rows)
45
+ guard_collisions!(rows)
46
+
47
+ <<~HEADER
48
+ /* generated by fontico — do not edit.
49
+ *
50
+ * #{rows.size} icon(s) from #{File.basename(@manifest.path || "icons.yml")}, drawn out of
51
+ * the icons.ttf built beside it. Codepoints are pinned in icons.lock
52
+ * and never reissued, so a name compiled into a flashed image keeps
53
+ * meaning the same picture across every later build.
54
+ */
55
+ #ifndef FONTICO_ICONS_H
56
+ #define FONTICO_ICONS_H
57
+
58
+ #include <stdint.h>
59
+
60
+ #ifdef __cplusplus
61
+ extern "C" {
62
+ #endif
63
+
64
+ #define FONTICO_ICON_COUNT #{rows.size}
65
+
66
+ /* UTF-8 literals, ready to hand to OpenFontRender / FreeType. */
67
+ #{defines(rows).join("\n")}
68
+
69
+ typedef struct {
70
+ const char *name;
71
+ const char *utf8;
72
+ uint16_t codepoint;
73
+ } FonticoIcon;
74
+
75
+ /* Sorted by name, for an icon chosen at runtime rather than compiled
76
+ * in — a name arriving in an MQTT payload, say. Returns NULL when the
77
+ * manifest has no such icon, which is the one honest answer: drawing
78
+ * a codepoint the font has no glyph for is an invisible box.
79
+ *
80
+ * The table lives in whichever translation unit defines
81
+ * FONTICO_ICONS_IMPLEMENTATION before including this file. Exactly
82
+ * one must, or the linker will say so. Nothing else needs to change,
83
+ * and a firmware that only ever uses the #{PREFIX}* defines can skip it
84
+ * entirely and carry no table at all.
85
+ */
86
+ const char *fontico_icon(const char *name);
87
+ extern const FonticoIcon fontico_icons[FONTICO_ICON_COUNT];
88
+
89
+ #ifdef FONTICO_ICONS_IMPLEMENTATION
90
+ #include <stdlib.h>
91
+ #include <string.h>
92
+
93
+ const FonticoIcon fontico_icons[FONTICO_ICON_COUNT] = {
94
+ #{rows.map { %( { "#{_1[:name]}", #{_1[:ident]}, 0x#{format("%04X", _1[:codepoint])} },) }.join("\n")}
95
+ };
96
+
97
+ static int fontico_icon_cmp(const void *key, const void *el) {
98
+ return strcmp((const char *)key, ((const FonticoIcon *)el)->name);
99
+ }
100
+
101
+ const char *fontico_icon(const char *name) {
102
+ const FonticoIcon *hit;
103
+ if (!name) return NULL;
104
+ hit = (const FonticoIcon *)bsearch(name, fontico_icons, FONTICO_ICON_COUNT,
105
+ sizeof(FonticoIcon), fontico_icon_cmp);
106
+ return hit ? hit->utf8 : NULL;
107
+ }
108
+ #endif /* FONTICO_ICONS_IMPLEMENTATION */
109
+
110
+ #ifdef __cplusplus
111
+ }
112
+ #endif
113
+
114
+ #endif /* FONTICO_ICONS_H */
115
+ HEADER
116
+ end
117
+
118
+ private
119
+
120
+ def row(icon)
121
+ # Never mint one here. The builder has already save!d the lock by the
122
+ # time it emits, so a codepoint invented now would reach the firmware
123
+ # without ever reaching disk — and the font would have no glyph there.
124
+ cp = @lock&.codepoint_for(icon.name)
125
+ raise Fontico::Error, "#{icon.name} has no codepoint in icons.lock" if cp.nil?
126
+
127
+ { name: icon.name, ident: self.class.identifier(icon.name),
128
+ codepoint: cp, source: icon.source }
129
+ end
130
+
131
+ # The table is sorted here with Ruby's byte-order <=> and searched on the
132
+ # device with strcmp, where `char` is signed on most toolchains. A byte
133
+ # above 0x7F orders one way here and the opposite there, so bsearch would
134
+ # quietly miss the icon rather than fail — an invisible box again.
135
+ def guard_ascii!(rows)
136
+ wide = rows.reject { _1[:name].ascii_only? }
137
+ return if wide.empty?
138
+
139
+ raise Fontico::Error,
140
+ "the c target needs ASCII icon names; these are not: " \
141
+ "#{wide.map { _1[:name] }.join(", ")}"
142
+ end
143
+
144
+ # ICON_NAV_MENU can be reached from nav.menu and from nav-menu, and C
145
+ # would take the second #define silently. Name both instead.
146
+ def guard_collisions!(rows)
147
+ clashes = rows.group_by { _1[:ident] }.select { |_, v| v.size > 1 }
148
+ return if clashes.empty?
149
+
150
+ detail = clashes.map { |ident, group| "#{ident} <- #{group.map { _1[:name] }.join(", ")}" }
151
+ raise Fontico::Error,
152
+ "icon names collide as C identifiers:\n #{detail.join("\n ")}\n" \
153
+ "Rename one side in icons.yml; dots, dashes and case all fold to the same macro."
154
+ end
155
+
156
+ def defines(rows)
157
+ width = rows.map { _1[:ident].length }.max.to_i
158
+ rows.map do |r|
159
+ format(%(#define %-#{width}s "%s" /* %s U+%04X */),
160
+ r[:ident], self.class.utf8_literal(r[:codepoint]), r[:source], r[:codepoint])
161
+ end
162
+ end
163
+ end
164
+ end
165
+ end
@@ -18,9 +18,13 @@ module Fontico
18
18
  def accepts?(_icon) = false
19
19
  def rules_only? = true
20
20
 
21
+ # In `@layer base`: an unlayered rule outranks every layered one, so
22
+ # Tailwind 4's `size-6` (in `@layer utilities`) used to lose to the
23
+ # 1em default here. Layered, the defaults hold and a class still wins.
21
24
  def call
22
25
  <<~CSS
23
26
  /* generated by fontico — do not edit */
27
+ @layer base {
24
28
  .#{Fontico.css_class} {
25
29
  display: inline-block;
26
30
  width: 1em;
@@ -38,6 +42,7 @@ module Fontico
38
42
  /* Optical sizing for icons set beside larger text. */
39
43
  .#{Fontico.css_class}-lg { width: 1.25em; height: 1.25em; }
40
44
  .#{Fontico.css_class}-xl { width: 1.5em; height: 1.5em; }
45
+ }
41
46
  CSS
42
47
  end
43
48
  end
@@ -10,27 +10,22 @@ module Fontico
10
10
  # it on the baseline. Pass size: to override, or a CSS class — classes win
11
11
  # over the attributes, so `class: "size-6"` works untouched.
12
12
  def icon(name, size: nil, variant: nil, **options)
13
- Fontico.check!(name.to_s)
14
- entry = Fontico.manifest[name.to_s]
15
- return missing(name) if entry.nil?
13
+ key = name.to_s
14
+ Fontico.check!(key)
16
15
 
17
- symbol = entry.key
18
- attrs = {
19
- class: [Fontico.css_class, options.delete(:class)].compact.join(" "),
20
- width: size || "1em",
21
- height: size || "1em",
22
- style: inline_size(size, options.delete(:style)),
23
- "aria-hidden": options.key?(:title) ? nil : "true",
24
- role: options.key?(:title) ? "img" : nil
25
- }.merge(options).compact
26
-
27
- title = attrs.delete(:title)
28
- body = +""
29
- body << "<title>#{ERB::Util.html_escape(title)}</title>" if title
30
- body << %(<use href="#{sprite_path(variant)}##{symbol}"></use>)
16
+ # A bare icon(name) is deterministic apart from one thing. The attrs
17
+ # hash, the html_escape of constants like "1em" and "true", the string
18
+ # assembly — 22 objects a call, every call, for markup that never
19
+ # changes. The exception is the sprite path, because asset_host is
20
+ # allowed to be a proc that reads the request, so what gets cached is
21
+ # the markup either side of the href and the path is still resolved per
22
+ # call. Fontico.reset! drops the cache, which the dev watcher already
23
+ # calls after every rebuild.
24
+ bare = size.nil? && variant.nil? && options.empty?
25
+ pair = bare ? Fontico.icon_cache[key] ||= halves(key) : halves(key, size: size, **options)
26
+ return missing(name) if pair.nil?
31
27
 
32
- tag = %(<svg #{attrs.map { |k, v| %(#{k}="#{ERB::Util.html_escape(v)}") }.join(" ")}>#{body}</svg>)
33
- tag.respond_to?(:html_safe) ? tag.html_safe : tag
28
+ safe("#{pair[0]}#{sprite_path(variant)}#{pair[1]}")
34
29
  end
35
30
 
36
31
  # The `<use href>` for one icon, for markup this helper doesn't build —
@@ -45,8 +40,9 @@ module Fontico
45
40
  # icons_sprite for the inline case, where the path half is empty and the
46
41
  # fragment resolves against the current document.
47
42
  def icon_href(name)
48
- Fontico.check!(name.to_s)
49
- entry = Fontico.manifest[name.to_s]
43
+ key = name.to_s
44
+ Fontico.check!(key)
45
+ entry = Fontico.manifest[key]
50
46
  raise Fontico::Error, "no icon named #{name.inspect} in #{Fontico.manifest_path}" if entry.nil?
51
47
 
52
48
  "#{sprite_path}##{entry.key}"
@@ -56,12 +52,38 @@ module Fontico
56
52
  # where a cross-origin <use href> would silently render nothing.
57
53
  def icons_sprite
58
54
  Fontico.check!
59
- svg = File.read(Fontico.sprite_file)
60
- svg.respond_to?(:html_safe) ? svg.html_safe : svg
55
+ safe(Fontico.sprite_markup)
61
56
  end
62
57
 
63
58
  private
64
59
 
60
+ # The markup either side of the `href` value, so a caller can drop a
61
+ # freshly resolved sprite path between them. Nil when the manifest has no
62
+ # such name. Everything in here is a pure function of the entry and the
63
+ # options, which is exactly what makes the bare case worth caching.
64
+ def halves(key, size: nil, **options)
65
+ entry = Fontico.manifest[key]
66
+ return nil if entry.nil?
67
+
68
+ attrs = {
69
+ class: [Fontico.css_class, options.delete(:class)].compact.join(" "),
70
+ width: size || "1em",
71
+ height: size || "1em",
72
+ style: inline_size(size, options.delete(:style)),
73
+ "aria-hidden": options.key?(:title) ? nil : "true",
74
+ role: options.key?(:title) ? "img" : nil
75
+ }.merge(options).compact
76
+
77
+ title = attrs.delete(:title)
78
+ open = +"<svg #{attrs.map { |k, v| %(#{k}="#{ERB::Util.html_escape(v)}") }.join(" ")}>"
79
+ open << "<title>#{ERB::Util.html_escape(title)}</title>" if title
80
+ open << %(<use href=")
81
+
82
+ [open.freeze, %(##{entry.key}"></use></svg>).freeze]
83
+ end
84
+
85
+ def safe(str) = str.respond_to?(:html_safe) ? str.html_safe : str
86
+
65
87
  # width/height on an <svg> are presentation attributes, and *any* CSS
66
88
  # declaration outranks those — including icons.css's own `.ico { width:
67
89
  # 1em }`. So an explicitly requested size has to be inline or the
@@ -41,9 +41,12 @@ module Fontico
41
41
 
42
42
  def entry(name) = @entries[name]
43
43
 
44
- def store(name, source:, body:, multicolor: false, warnings: [])
44
+ # +size+ is the grid the body was refit to. It is recorded because it is
45
+ # an input to the body, not a property of the request for it — see #fresh?.
46
+ def store(name, source:, body:, size:, multicolor: false, warnings: [])
45
47
  @entries[name] = {
46
48
  "source" => source,
49
+ "size" => size,
47
50
  "digest" => Digest::SHA256.hexdigest(body)[0, 16],
48
51
  "multicolor" => multicolor,
49
52
  "warnings" => warnings,
@@ -52,6 +55,16 @@ module Fontico
52
55
  allocate(name)
53
56
  end
54
57
 
58
+ # A lock written before sizes were recorded cannot say what grid its
59
+ # bodies were refit to. Taking them at the size now in the manifest costs
60
+ # nothing when it is right, and `rake fontico:update` fixes it when it is
61
+ # not. The alternative — calling every body stale — would re-fetch a whole
62
+ # manifest on a gem upgrade, and put a network round trip in the first
63
+ # deploy after it.
64
+ def assume_size(size)
65
+ @entries.each_value { _1["size"] ||= size }
66
+ end
67
+
55
68
  # Names present in the lock but absent from the manifest keep their
56
69
  # codepoint reserved so it is never handed to a different icon.
57
70
  def retire_missing!(names)
@@ -61,8 +74,28 @@ module Fontico
61
74
  end
62
75
  end
63
76
 
64
- def fresh?(name, source)
65
- entry(name)&.fetch("source", nil) == source && entry(name)["body"]
77
+ # A cached body is only reusable if every input that produced it still
78
+ # holds. The source is the obvious one; the other two were the bug.
79
+ #
80
+ # size the body carries its refit baked in as a <g transform>,
81
+ # while the sprite emitter reads the target size live. Keyed
82
+ # on the source alone, changing `defaults: size:` moved every
83
+ # <symbol> to the new viewBox and left the geometry inside
84
+ # fit to the old grid — every icon a fraction of its size in
85
+ # the corner of its box, on a page that 200s. Local files are
86
+ # re-read every build and self-heal, so this only ever showed
87
+ # on cached remotes: most of a real manifest.
88
+ #
89
+ # multicolor the *override* from the manifest, not the detected result.
90
+ # When one is set the result always equals it, so the stored
91
+ # result is the record of whether this body was preprocessed
92
+ # under it. Nil leaves detection in charge, and detection on
93
+ # an unchanged body gives an unchanged answer.
94
+ def fresh?(name, source, size:, multicolor: nil)
95
+ e = entry(name)
96
+ return false unless e && e["body"] && e["source"] == source && e["size"] == size
97
+
98
+ multicolor.nil? || e["multicolor"] == multicolor
66
99
  end
67
100
 
68
101
  def body(name) = entry(name)&.fetch("body", nil)
@@ -11,7 +11,7 @@ module Fontico
11
11
  # Where local/ SVGs live when the manifest doesn't say otherwise.
12
12
  LOCAL_PATH = "app/assets/icons"
13
13
 
14
- attr_reader :path, :defaults, :providers, :targets, :icons
14
+ attr_reader :path, :defaults, :providers, :targets, :target_paths, :icons
15
15
 
16
16
  def self.load(*paths)
17
17
  paths = paths.flatten.compact.select { File.file?(_1) }
@@ -40,16 +40,29 @@ module Fontico
40
40
  @path = path
41
41
  @defaults = data["defaults"] || {}
42
42
  @providers = data["providers"] || {}
43
- @targets = data["targets"] || ["sprite"]
43
+ @targets, @target_paths = parse_targets(data["targets"])
44
44
  @icons = flatten(data["icons"] || {}).freeze
45
45
  validate!
46
46
  end
47
47
 
48
+ # Where a target's artifact goes, relative to the root, or nil to take the
49
+ # default name in output_dir.
50
+ def target_path(target) = target_options(target)["path"]
51
+
52
+ # Anything else the target was given in the manifest, e.g. a gfxfont size.
53
+ def target_options(target) = target_paths.fetch(target.to_s, {})
54
+
48
55
  def default_provider = defaults.fetch("provider", "lucide")
49
56
  def local_path = providers.dig("local", "path") || LOCAL_PATH
50
57
  def size = defaults.fetch("size", 24).to_i
51
58
 
52
- def [](name) = icons.find { _1.name == name }
59
+ # Every icon() call in every template comes through here, and a linear
60
+ # scan charged for the size of the manifest rather than for the icons a
61
+ # page actually draws: 11.5us a call at 300 names against 5us at 92, so
62
+ # naming icons you never draw slowed down the ones you do. Backwards for
63
+ # a gem whose pitch is 353,000 icons to choose from. The table is built
64
+ # once and @icons is frozen, so there is nothing to invalidate.
65
+ def [](name) = index[name]
53
66
 
54
67
  # Vendor icons grouped by provider so the resolver can batch one HTTP
55
68
  # request per provider instead of one per icon.
@@ -61,6 +74,43 @@ module Fontico
61
74
 
62
75
  private
63
76
 
77
+ # A list names the targets and each takes its default filename inside
78
+ # output_dir. A mapping also says where one goes, which is what firmware
79
+ # needs: icons.h belongs in the device's include/ directory, not in
80
+ # app/assets/builds next to the web artifacts. A target with more to say
81
+ # than a path takes a block instead.
82
+ #
83
+ # targets: [sprite, ttf]
84
+ #
85
+ # targets:
86
+ # sprite: # default location
87
+ # ttf: devs/data/icons.ttf # just a path
88
+ # gfxfont: # a path and a size
89
+ # path: devs/include/icons_font.h
90
+ # size: 24
91
+ def parse_targets(value)
92
+ case value
93
+ when nil then [["sprite"], {}]
94
+ when Array then [value.map(&:to_s), {}]
95
+ when Hash
96
+ [value.keys.map(&:to_s),
97
+ value.compact.to_h { [_1.to_s, target_settings(_1, _2)] }]
98
+ else
99
+ raise Error, "targets: must be a list or a mapping, got #{value.class}"
100
+ end
101
+ end
102
+
103
+ def target_settings(target, value)
104
+ case value
105
+ when String then { "path" => value }
106
+ when Hash then value.to_h { [_1.to_s, _2] }
107
+ else
108
+ raise Error, "targets: #{target} must be a path or a mapping, got #{value.class}"
109
+ end
110
+ end
111
+
112
+ def index = @index ||= icons.to_h { [_1.name, _1] }
113
+
64
114
  def flatten(tree, prefix = nil)
65
115
  tree.flat_map do |key, value|
66
116
  name = [prefix, key].compact.join(".")
@@ -0,0 +1,192 @@
1
+ // Rasterise glyphs out of a built TTF into 1-bit bitmaps.
2
+ //
3
+ // Reading the font rather than the SVG bodies is deliberate: build_font.mjs
4
+ // has already normalised every icon to the em box and put it on a common
5
+ // baseline, so bitmaps taken from here inherit the same geometry the PDF
6
+ // uses. Rasterising the SVGs separately would re-derive all of that and drift.
7
+ //
8
+ // opentype.js is already a toolchain dependency, so this needs nothing new —
9
+ // it gives us outlines, and a scanline fill turns those into pixels.
10
+ //
11
+ // stdin: { ttf, size, glyphs: [{ name, codepoint }] }
12
+ // stdout: { glyphs: [{ name, codepoint, width, height, xAdvance,
13
+ // xOffset, yOffset, bytes: [...] }], yAdvance }
14
+ import fs from 'fs';
15
+ import opentype from 'opentype.js';
16
+
17
+ const job = JSON.parse(fs.readFileSync(0, 'utf8'));
18
+
19
+ // Coverage is sampled this many times per pixel on each axis. 4 is enough to
20
+ // place an edge convincingly at 24px and still runs in milliseconds; the
21
+ // result is thresholded to one bit regardless, so more buys very little.
22
+ const SS = 4;
23
+ // Curve subdivision. Flat enough that the error sits well under a subsample.
24
+ const CURVE_STEPS = 12;
25
+
26
+ const lerp = (a, b, t) => a + (b - a) * t;
27
+
28
+ function cubicAt(x0, y0, x1, y1, x2, y2, x3, y3, t) {
29
+ const ax = lerp(x0, x1, t), ay = lerp(y0, y1, t);
30
+ const bx = lerp(x1, x2, t), by = lerp(y1, y2, t);
31
+ const cx = lerp(x2, x3, t), cy = lerp(y2, y3, t);
32
+ const dx = lerp(ax, bx, t), dy = lerp(ay, by, t);
33
+ const ex = lerp(bx, cx, t), ey = lerp(by, cy, t);
34
+ return { x: lerp(dx, ex, t), y: lerp(dy, ey, t) };
35
+ }
36
+
37
+ function quadAt(x0, y0, x1, y1, x2, y2, t) {
38
+ const ax = lerp(x0, x1, t), ay = lerp(y0, y1, t);
39
+ const bx = lerp(x1, x2, t), by = lerp(y1, y2, t);
40
+ return { x: lerp(ax, bx, t), y: lerp(ay, by, t) };
41
+ }
42
+
43
+ // Path commands to closed polygons. Every contour is implicitly closed: a
44
+ // fill has no notion of an open one, and leaving it open drops an edge.
45
+ function contoursOf(path) {
46
+ const out = [];
47
+ let cur = null;
48
+ let cx = 0, cy = 0, sx = 0, sy = 0;
49
+
50
+ const close = () => {
51
+ if (cur && cur.length > 2) out.push(cur);
52
+ cur = null;
53
+ };
54
+
55
+ for (const cmd of path.commands) {
56
+ switch (cmd.type) {
57
+ case 'M':
58
+ close();
59
+ cur = [{ x: cmd.x, y: cmd.y }];
60
+ cx = sx = cmd.x; cy = sy = cmd.y;
61
+ break;
62
+ case 'L':
63
+ if (cur) cur.push({ x: cmd.x, y: cmd.y });
64
+ cx = cmd.x; cy = cmd.y;
65
+ break;
66
+ case 'C':
67
+ for (let i = 1; i <= CURVE_STEPS; i++) {
68
+ cur.push(cubicAt(cx, cy, cmd.x1, cmd.y1, cmd.x2, cmd.y2, cmd.x, cmd.y, i / CURVE_STEPS));
69
+ }
70
+ cx = cmd.x; cy = cmd.y;
71
+ break;
72
+ case 'Q':
73
+ for (let i = 1; i <= CURVE_STEPS; i++) {
74
+ cur.push(quadAt(cx, cy, cmd.x1, cmd.y1, cmd.x, cmd.y, i / CURVE_STEPS));
75
+ }
76
+ cx = cmd.x; cy = cmd.y;
77
+ break;
78
+ case 'Z':
79
+ close();
80
+ cx = sx; cy = sy;
81
+ break;
82
+ }
83
+ }
84
+ close();
85
+ return out;
86
+ }
87
+
88
+ function edgesOf(contours) {
89
+ const edges = [];
90
+ for (const c of contours) {
91
+ for (let i = 0; i < c.length; i++) {
92
+ const a = c[i], b = c[(i + 1) % c.length];
93
+ if (a.y !== b.y) edges.push([a.x, a.y, b.x, b.y]);
94
+ }
95
+ }
96
+ return edges;
97
+ }
98
+
99
+ // Add a horizontal span's contribution, splitting it across the pixels it
100
+ // partially covers so edges land between pixels rather than on one side.
101
+ function addSpan(cov, w, row, xa, xb, weight) {
102
+ const lo = Math.max(0, xa), hi = Math.min(w, xb);
103
+ if (hi <= lo) return;
104
+ for (let px = Math.floor(lo); px < Math.ceil(hi); px++) {
105
+ const l = Math.max(lo, px), r = Math.min(hi, px + 1);
106
+ if (r > l) cov[row + px] += (r - l) * weight;
107
+ }
108
+ }
109
+
110
+ // Scanline fill with the nonzero winding rule, which is what TrueType uses —
111
+ // even-odd would punch holes in any glyph whose contours overlap.
112
+ function coverage(edges, x0, y0, w, h) {
113
+ const cov = new Float64Array(w * h);
114
+ const weight = 1 / SS;
115
+
116
+ for (let py = 0; py < h; py++) {
117
+ for (let s = 0; s < SS; s++) {
118
+ const sy = y0 + py + (s + 0.5) / SS;
119
+ const hits = [];
120
+ for (const [ex0, ey0, ex1, ey1] of edges) {
121
+ const lo = Math.min(ey0, ey1), hi = Math.max(ey0, ey1);
122
+ if (sy < lo || sy >= hi) continue;
123
+ hits.push({ x: ex0 + ((sy - ey0) / (ey1 - ey0)) * (ex1 - ex0), dir: ey1 > ey0 ? 1 : -1 });
124
+ }
125
+ if (hits.length < 2) continue;
126
+ hits.sort((a, b) => a.x - b.x);
127
+
128
+ let wind = 0;
129
+ for (let i = 0; i < hits.length - 1; i++) {
130
+ wind += hits[i].dir;
131
+ if (wind !== 0) addSpan(cov, w, py * w, hits[i].x - x0, hits[i + 1].x - x0, weight);
132
+ }
133
+ }
134
+ }
135
+ return cov;
136
+ }
137
+
138
+ // GFXfont bitmaps are one continuous MSB-first bitstream with no padding
139
+ // between rows — drawBitmap's row-aligned layout is a different format, and
140
+ // mixing them up shears the glyph.
141
+ function pack(cov, w, h) {
142
+ const bytes = [];
143
+ let acc = 0, n = 0;
144
+ for (let i = 0; i < w * h; i++) {
145
+ acc = (acc << 1) | (cov[i] > 0.5 ? 1 : 0);
146
+ if (++n === 8) { bytes.push(acc & 0xff); acc = 0; n = 0; }
147
+ }
148
+ if (n) bytes.push((acc << (8 - n)) & 0xff);
149
+ return bytes;
150
+ }
151
+
152
+ const font = opentype.parse(new Uint8Array(fs.readFileSync(job.ttf)).buffer);
153
+ const size = job.size;
154
+ const scale = size / font.unitsPerEm;
155
+ const out = [];
156
+
157
+ for (const g of job.glyphs) {
158
+ const glyph = font.charToGlyph(String.fromCodePoint(g.codepoint));
159
+ // getPath flips y so it grows downward from the baseline, which is the
160
+ // direction GFXglyph's yOffset is measured in too.
161
+ const contours = contoursOf(glyph.getPath(0, 0, size));
162
+ const edges = edgesOf(contours);
163
+ const advance = Math.round((glyph.advanceWidth ?? font.unitsPerEm) * scale);
164
+
165
+ if (!edges.length) {
166
+ out.push({ ...g, width: 0, height: 0, xAdvance: advance, xOffset: 0, yOffset: 0, bytes: [], blank: true });
167
+ continue;
168
+ }
169
+
170
+ // Tight box: a GFXglyph stores only the inked area and offsets it, so
171
+ // whitespace costs nothing in flash.
172
+ let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
173
+ for (const [ex0, ey0, ex1, ey1] of edges) {
174
+ minX = Math.min(minX, ex0, ex1); maxX = Math.max(maxX, ex0, ex1);
175
+ minY = Math.min(minY, ey0, ey1); maxY = Math.max(maxY, ey0, ey1);
176
+ }
177
+ const x0 = Math.floor(minX), y0 = Math.floor(minY);
178
+ const w = Math.max(1, Math.ceil(maxX) - x0);
179
+ const h = Math.max(1, Math.ceil(maxY) - y0);
180
+
181
+ const cov = coverage(edges, x0, y0, w, h);
182
+ const bytes = pack(cov, w, h);
183
+ const inked = cov.some((v) => v > 0.5);
184
+
185
+ out.push({
186
+ ...g,
187
+ width: w, height: h, xAdvance: advance, xOffset: x0, yOffset: y0,
188
+ bytes, blank: !inked
189
+ });
190
+ }
191
+
192
+ process.stdout.write(JSON.stringify({ glyphs: out, yAdvance: Math.round(size * 1.2) }));
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Fontico
4
- VERSION = "0.2.0"
4
+ VERSION = "1.2.0"
5
5
  end
data/lib/fontico.rb CHANGED
@@ -23,6 +23,8 @@ module Fontico
23
23
  autoload :Sprite, "fontico/emitters/sprite"
24
24
  autoload :Font, "fontico/emitters/font"
25
25
  autoload :Stylesheet, "fontico/emitters/stylesheet"
26
+ autoload :Header, "fontico/emitters/header"
27
+ autoload :GfxFont, "fontico/emitters/gfx_font"
26
28
  end
27
29
 
28
30
  class << self
@@ -46,6 +48,24 @@ module Fontico
46
48
  def manifest = @manifest ||= Manifest.load(*load_path)
47
49
  def sprite_file = File.join(root, output_dir, "icons.svg")
48
50
 
51
+ # Rendered markup for bare icon() calls, minus the sprite path. Keyed by
52
+ # name and bounded by the manifest, so it cannot grow with traffic.
53
+ def icon_cache = @icon_cache ||= {}
54
+
55
+ # Inline mode embeds the sprite in every response, and it used to be
56
+ # re-read off disk on every call: 13us and a fresh 10KB string per
57
+ # request, more than a whole page of icon() calls costs. Held instead,
58
+ # behind a stat, which is cheap next to the read and means a rebuild from
59
+ # anywhere — the dev watcher, or `rake fontico:build` in another terminal
60
+ # — is picked up without waiting for a reset!.
61
+ def sprite_markup
62
+ stamp = File.stat(sprite_file).then { [_1.mtime, _1.size] }
63
+ return @sprite_markup if @sprite_stamp == stamp
64
+
65
+ @sprite_stamp = stamp
66
+ @sprite_markup = File.read(sprite_file).freeze
67
+ end
68
+
49
69
  # Rails engines that ship config/icons.yml (or icons.yml at the gem
50
70
  # root), then the app's file. The app is last on purpose.
51
71
  def discover(app)
@@ -117,7 +137,12 @@ module Fontico
117
137
  end
118
138
 
119
139
  def glyph(name) = [codepoint(name)].pack("U")
120
- def reset! = (@manifest = @build_error = @missing_icons = nil)
140
+ # Drops everything derived from a build. The dev watcher calls this after
141
+ # every rebuild, which is what keeps the caches above honest.
142
+ def reset!
143
+ @manifest = @build_error = @missing_icons = nil
144
+ @icon_cache = @sprite_markup = @sprite_stamp = nil
145
+ end
121
146
 
122
147
  def configure = yield(self)
123
148
  end
@@ -67,10 +67,13 @@ if Rake::Task.task_defined?("assets:clobber")
67
67
  end
68
68
 
69
69
  namespace :fontico do
70
+ # Only what lands in output_dir. A target given an explicit path in the
71
+ # manifest is pointing somewhere the app owns — a firmware include/ that is
72
+ # very likely committed — and deleting that is not precompile's business.
70
73
  desc "Remove generated icon artifacts (icons.lock is kept: it is source)"
71
74
  task :clobber do
72
75
  require "fontico"
73
- %w[icons.svg icons.css icons.ttf].each do |name|
76
+ %w[icons.svg icons.css icons.ttf icons.h icons_font.h].each do |name|
74
77
  path = File.join(Fontico.root, Fontico.output_dir, name)
75
78
  File.delete(path) if File.exist?(path)
76
79
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: fontico
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.0
4
+ version: 1.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - nofxx
@@ -86,6 +86,8 @@ files:
86
86
  - lib/fontico.rb
87
87
  - lib/fontico/builder.rb
88
88
  - lib/fontico/emitters/font.rb
89
+ - lib/fontico/emitters/gfx_font.rb
90
+ - lib/fontico/emitters/header.rb
89
91
  - lib/fontico/emitters/sprite.rb
90
92
  - lib/fontico/emitters/stylesheet.rb
91
93
  - lib/fontico/helper.rb
@@ -95,6 +97,7 @@ files:
95
97
  - lib/fontico/node/build_font.mjs
96
98
  - lib/fontico/node/extract_glyphs.mjs
97
99
  - lib/fontico/node/package.json
100
+ - lib/fontico/node/rasterize.mjs
98
101
  - lib/fontico/node_runner.rb
99
102
  - lib/fontico/outliner.rb
100
103
  - lib/fontico/prawn.rb