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 +4 -4
- data/README.md +182 -31
- data/lib/fontico/builder.rb +81 -8
- data/lib/fontico/emitters/font.rb +10 -3
- data/lib/fontico/emitters/gfx_font.rb +190 -0
- data/lib/fontico/emitters/header.rb +165 -0
- data/lib/fontico/emitters/stylesheet.rb +5 -0
- data/lib/fontico/helper.rb +45 -23
- data/lib/fontico/lockfile.rb +44 -5
- data/lib/fontico/manifest.rb +75 -4
- data/lib/fontico/node/rasterize.mjs +192 -0
- data/lib/fontico/railtie.rb +7 -1
- data/lib/fontico/version.rb +1 -1
- data/lib/fontico.rb +54 -3
- data/lib/tasks/fontico.rake +42 -30
- metadata +5 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 1ee86cae31c0f7f4acb9b303bfd2c06638ea383d86e27ff603452537ec58cfed
|
|
4
|
+
data.tar.gz: cf9b3485369dd8601822dad9b1e367bccb41c440620071412b595b755afa97c9
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: e9e776c9fb99a18085b38a4069f23b72c808ba48c45b321d7948a166c99dd0bf9e93f413dd13a729a5a085148ffdf8e4a1feef544c4babec168e37d037e3b316
|
|
7
|
+
data.tar.gz: c5e3727514c2ebaff8c1b879ca9c2468bd2b14df0bef16797f983580b5830ac52b75fd93b9b3f639d433b7e11bac28aedc4c6cacf1d448d66bb2a715cdeccbc5
|
data/README.md
CHANGED
|
@@ -1,6 +1,16 @@
|
|
|
1
|
-
|
|
1
|
+
```
|
|
2
|
+
╔═╗╔═╗╔╗╔╔╦╗╦╔═╗╔═╗
|
|
3
|
+
╠╣ ║ ║║║║ ║ ║║ ║ ║
|
|
4
|
+
╚ ╚═╝╝╚╝ ╩ ╩╚═╝╚═╝
|
|
5
|
+
```
|
|
6
|
+
|
|
7
|
+
**Name icons by intent. Source them from anywhere. Ship one artifact.**
|
|
2
8
|
|
|
3
|
-
|
|
9
|
+
[](https://rubygems.org/gems/fontico)
|
|
10
|
+
[](https://github.com/fireho/fontico/actions/workflows/ci.yml)
|
|
11
|
+
[](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
|
|
39
|
-
|
|
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
|
|
87
|
+
rake fontico:update # re-fetch bodies; codepoints stay pinned
|
|
46
88
|
```
|
|
47
89
|
|
|
48
|
-
|
|
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
|
-
|
|
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
|
-
|
|
77
|
-
|
|
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
|
-
`
|
|
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
|
-
|
|
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
|
|
102
|
-
| viewBox
|
|
103
|
-
| Colour
|
|
104
|
-
| Safety
|
|
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**
|
|
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**
|
|
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.
|
data/lib/fontico/builder.rb
CHANGED
|
@@ -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",
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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"
|
|
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
|
-
|
|
30
|
-
|
|
31
|
-
|
|
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
|