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.
@@ -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
@@ -29,21 +29,40 @@ module Fontico
29
29
  @entries = data["icons"] || {}
30
30
  end
31
31
 
32
- def codepoint_for(name)
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
- 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: [])
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
- codepoint_for(name)
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
- def fresh?(name, source)
59
- 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
60
99
  end
61
100
 
62
101
  def body(name) = entry(name)&.fetch("body", nil)
@@ -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(path) = new(YAML.safe_load_file(path), path: path)
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 = data["targets"] || ["sprite"]
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
- 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]
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) }));
@@ -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([Fontico.manifest_path], Railtie.local_dirs) do
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