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
|
@@ -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
|
data/lib/fontico/helper.rb
CHANGED
|
@@ -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
|
-
|
|
14
|
-
|
|
15
|
-
return missing(name) if entry.nil?
|
|
13
|
+
key = name.to_s
|
|
14
|
+
Fontico.check!(key)
|
|
16
15
|
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
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
|
-
|
|
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
|
-
|
|
49
|
-
|
|
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
|
-
|
|
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
|
data/lib/fontico/lockfile.rb
CHANGED
|
@@ -29,21 +29,40 @@ module Fontico
|
|
|
29
29
|
@entries = data["icons"] || {}
|
|
30
30
|
end
|
|
31
31
|
|
|
32
|
-
|
|
32
|
+
# Lookup only. A missing name is nil — callers that draw a glyph must
|
|
33
|
+
# not invent a codepoint, or a typo silently becomes a .notdef.
|
|
34
|
+
def codepoint_for(name) = @codepoints[name]
|
|
35
|
+
|
|
36
|
+
# Append-only reservation. store is the production caller; tests use it
|
|
37
|
+
# to pin order without going through a full build.
|
|
38
|
+
def allocate(name)
|
|
33
39
|
@codepoints[name] ||= next_free
|
|
34
40
|
end
|
|
35
41
|
|
|
36
42
|
def entry(name) = @entries[name]
|
|
37
43
|
|
|
38
|
-
|
|
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: [])
|
|
39
47
|
@entries[name] = {
|
|
40
48
|
"source" => source,
|
|
49
|
+
"size" => size,
|
|
41
50
|
"digest" => Digest::SHA256.hexdigest(body)[0, 16],
|
|
42
51
|
"multicolor" => multicolor,
|
|
43
52
|
"warnings" => warnings,
|
|
44
53
|
"body" => body
|
|
45
54
|
}
|
|
46
|
-
|
|
55
|
+
allocate(name)
|
|
56
|
+
end
|
|
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 }
|
|
47
66
|
end
|
|
48
67
|
|
|
49
68
|
# Names present in the lock but absent from the manifest keep their
|
|
@@ -55,8 +74,28 @@ module Fontico
|
|
|
55
74
|
end
|
|
56
75
|
end
|
|
57
76
|
|
|
58
|
-
|
|
59
|
-
|
|
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
|
|
60
99
|
end
|
|
61
100
|
|
|
62
101
|
def body(name) = entry(name)&.fetch("body", nil)
|
data/lib/fontico/manifest.rb
CHANGED
|
@@ -11,24 +11,58 @@ 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
|
-
def self.load(
|
|
16
|
+
def self.load(*paths)
|
|
17
|
+
paths = paths.flatten.compact.select { File.file?(_1) }
|
|
18
|
+
raise Errno::ENOENT, "icons.yml" if paths.empty?
|
|
19
|
+
|
|
20
|
+
data = paths.map { YAML.safe_load_file(_1) || {} }.reduce { |a, b| merge_data(a, b) }
|
|
21
|
+
new(data, path: paths.last)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# Later overlay wins a leaf; nested groups merge so an engine can ship
|
|
25
|
+
# `auth.google` and the app can add `auth.apple` without copying.
|
|
26
|
+
def self.merge_data(base, overlay)
|
|
27
|
+
{
|
|
28
|
+
"defaults" => (base["defaults"] || {}).merge(overlay["defaults"] || {}),
|
|
29
|
+
"targets" => overlay["targets"] || base["targets"] || ["sprite"],
|
|
30
|
+
"providers" => (base["providers"] || {}).merge(overlay["providers"] || {}),
|
|
31
|
+
"icons" => deep_merge(base["icons"] || {}, overlay["icons"] || {})
|
|
32
|
+
}
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def self.deep_merge(left, right)
|
|
36
|
+
left.merge(right) { |_, a, b| a.is_a?(Hash) && b.is_a?(Hash) ? deep_merge(a, b) : b }
|
|
37
|
+
end
|
|
17
38
|
|
|
18
39
|
def initialize(data, path: nil)
|
|
19
40
|
@path = path
|
|
20
41
|
@defaults = data["defaults"] || {}
|
|
21
42
|
@providers = data["providers"] || {}
|
|
22
|
-
@targets
|
|
43
|
+
@targets, @target_paths = parse_targets(data["targets"])
|
|
23
44
|
@icons = flatten(data["icons"] || {}).freeze
|
|
24
45
|
validate!
|
|
25
46
|
end
|
|
26
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
|
+
|
|
27
55
|
def default_provider = defaults.fetch("provider", "lucide")
|
|
28
56
|
def local_path = providers.dig("local", "path") || LOCAL_PATH
|
|
29
57
|
def size = defaults.fetch("size", 24).to_i
|
|
30
58
|
|
|
31
|
-
|
|
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]
|
|
32
66
|
|
|
33
67
|
# Vendor icons grouped by provider so the resolver can batch one HTTP
|
|
34
68
|
# request per provider instead of one per icon.
|
|
@@ -40,6 +74,43 @@ module Fontico
|
|
|
40
74
|
|
|
41
75
|
private
|
|
42
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
|
+
|
|
43
114
|
def flatten(tree, prefix = nil)
|
|
44
115
|
tree.flat_map do |key, value|
|
|
45
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) }));
|
data/lib/fontico/railtie.rb
CHANGED
|
@@ -12,6 +12,12 @@ module Fontico
|
|
|
12
12
|
Fontico.root = Rails.root.to_s
|
|
13
13
|
end
|
|
14
14
|
|
|
15
|
+
# Engines' config/icons.yml under the app's icons.yml. After root: the
|
|
16
|
+
# app file is Rails.root/icons.yml, not whatever Dir.pwd was at require.
|
|
17
|
+
initializer "fontico.load_path", after: "fontico.root" do |app|
|
|
18
|
+
Fontico.load_path = Fontico.discover(app)
|
|
19
|
+
end
|
|
20
|
+
|
|
15
21
|
# Saving icons.yml — or a local SVG — rebuilds the artifacts and drops the
|
|
16
22
|
# memoized manifest, so a new icon is live on the next request with no
|
|
17
23
|
# restart and no rake. That memo is the only thing that ever needed one:
|
|
@@ -20,7 +26,7 @@ module Fontico
|
|
|
20
26
|
initializer "fontico.reloader" do |app|
|
|
21
27
|
next unless app.config.enable_reloading
|
|
22
28
|
|
|
23
|
-
watcher = app.config.file_watcher.new(
|
|
29
|
+
watcher = app.config.file_watcher.new(Fontico.load_path, Railtie.local_dirs) do
|
|
24
30
|
Fontico.reset!
|
|
25
31
|
Fontico.rebuild!
|
|
26
32
|
# Recorded rather than raised: a save that half-breaks the manifest
|