fontico 0.1.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 +7 -0
- data/LICENSE.txt +21 -0
- data/README.md +214 -0
- data/docs/expand-check.png +0 -0
- data/docs/icon-authoring.html +371 -0
- data/docs/lucide-glyphs-extracted.png +0 -0
- data/docs/lucide-ttf-glyph.png +0 -0
- data/docs/rails-preview.png +0 -0
- data/docs/sprite-sheet.png +0 -0
- data/docs/stroke-vs-fill.png +0 -0
- data/docs/ttf-glyphs.png +0 -0
- data/lib/fontico/builder.rb +115 -0
- data/lib/fontico/emitters/font.rb +62 -0
- data/lib/fontico/emitters/sprite.rb +30 -0
- data/lib/fontico/emitters/stylesheet.rb +45 -0
- data/lib/fontico/helper.rb +105 -0
- data/lib/fontico/icon.rb +15 -0
- data/lib/fontico/lockfile.rb +87 -0
- data/lib/fontico/manifest.rb +75 -0
- data/lib/fontico/node/build_font.mjs +52 -0
- data/lib/fontico/node/extract_glyphs.mjs +35 -0
- data/lib/fontico/node/package.json +11 -0
- data/lib/fontico/node_runner.rb +66 -0
- data/lib/fontico/outliner.rb +80 -0
- data/lib/fontico/prawn.rb +62 -0
- data/lib/fontico/preprocessor.rb +238 -0
- data/lib/fontico/provider_fonts.rb +66 -0
- data/lib/fontico/railtie.rb +54 -0
- data/lib/fontico/resolver.rb +148 -0
- data/lib/fontico/version.rb +5 -0
- data/lib/fontico.rb +100 -0
- data/lib/tasks/fontico.rake +69 -0
- metadata +132 -0
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
|
|
5
|
+
module Fontico
|
|
6
|
+
# Resolve -> preprocess -> lock -> emit. Everything the rake task does.
|
|
7
|
+
class Builder
|
|
8
|
+
# Declared in manifests, emitter not landed yet. Skipped with a notice so
|
|
9
|
+
# the manifest can state intent without breaking the build.
|
|
10
|
+
PENDING = %w[woff2].freeze
|
|
11
|
+
|
|
12
|
+
Report = Struct.new(:written, :warnings, :skipped, :cached, :fetched, :pending,
|
|
13
|
+
:missing, keyword_init: true)
|
|
14
|
+
|
|
15
|
+
def initialize(manifest, root: Dir.pwd, output: "app/assets/builds", offline: false)
|
|
16
|
+
@manifest = manifest
|
|
17
|
+
@root = root
|
|
18
|
+
@output = File.join(root, output)
|
|
19
|
+
@offline = offline
|
|
20
|
+
@lock = Lockfile.new(File.join(root, "icons.lock"))
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def call
|
|
24
|
+
warnings = Hash.new { |h, k| h[k] = [] }
|
|
25
|
+
cached, fetched = [], []
|
|
26
|
+
missing = {}
|
|
27
|
+
|
|
28
|
+
stale = @manifest.icons.reject { @lock.fresh?(_1.name, _1.source) }
|
|
29
|
+
cached = @manifest.icons.map(&:name) - stale.map(&:name)
|
|
30
|
+
|
|
31
|
+
unless stale.empty?
|
|
32
|
+
raise Error, "icons.lock is missing #{stale.size} icon(s) and --offline was given" if @offline
|
|
33
|
+
|
|
34
|
+
resolver = Resolver.new(@manifest, root: @root)
|
|
35
|
+
sources = resolver.call(only: stale.map(&:name))
|
|
36
|
+
missing = resolver.missing
|
|
37
|
+
stale.each do |icon|
|
|
38
|
+
src = sources[icon.name]
|
|
39
|
+
# Named in #missing already, and reported by the caller. It keeps
|
|
40
|
+
# its codepoint and whatever the lock still holds, so fixing the
|
|
41
|
+
# manifest entry is all it takes to bring it back.
|
|
42
|
+
next if src.nil?
|
|
43
|
+
|
|
44
|
+
pre = Preprocessor.new(icon, size: @manifest.size)
|
|
45
|
+
.call(src.markup, width: src.width, height: src.height)
|
|
46
|
+
@lock.store(icon.name, source: icon.source, body: pre.body,
|
|
47
|
+
multicolor: pre.multicolor, warnings: pre.warnings)
|
|
48
|
+
fetched << icon.name
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# Missing icons stay in the list: their codepoints must not be reissued
|
|
53
|
+
# while the manifest still claims them.
|
|
54
|
+
@lock.retire_missing!(@manifest.icons.map(&:name))
|
|
55
|
+
@lock.save!
|
|
56
|
+
|
|
57
|
+
buildable = @manifest.icons.reject { missing.key?(_1.name) }
|
|
58
|
+
buildable.each do |icon|
|
|
59
|
+
found = @lock.warnings(icon.name)
|
|
60
|
+
warnings[icon.name] = found if found.any?
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
written, skipped = emit(buildable)
|
|
64
|
+
Report.new(written: written, warnings: warnings, skipped: skipped,
|
|
65
|
+
cached: cached, fetched: fetched, missing: missing,
|
|
66
|
+
pending: @manifest.targets & PENDING)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
private
|
|
70
|
+
|
|
71
|
+
def emit(icons)
|
|
72
|
+
FileUtils.mkdir_p(@output)
|
|
73
|
+
written = []
|
|
74
|
+
skipped = Hash.new { |h, k| h[k] = [] }
|
|
75
|
+
|
|
76
|
+
targets = @manifest.targets - PENDING
|
|
77
|
+
targets += ["css"] if targets.include?("sprite") && !targets.include?("css")
|
|
78
|
+
|
|
79
|
+
targets.each do |target|
|
|
80
|
+
path = File.join(@output, filename_for(target))
|
|
81
|
+
emitter = emitter_for(target, [], path: path)
|
|
82
|
+
|
|
83
|
+
accepted = icons.select { emitter.accepts?(_1) }
|
|
84
|
+
# A rules-only emitter refuses every icon by design; that is not a skip
|
|
85
|
+
# anyone needs to hear about.
|
|
86
|
+
(icons - accepted).each { skipped[target] << _1.name } unless emitter.rules_only?
|
|
87
|
+
|
|
88
|
+
pairs = accepted.map { [_1, @lock.body(_1.name)] }
|
|
89
|
+
result = emitter_for(target, pairs, path: path).call
|
|
90
|
+
File.write(path, result) if result.is_a?(String)
|
|
91
|
+
written << path
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
[written, skipped]
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def emitter_for(target, build = [], path: nil)
|
|
98
|
+
case target
|
|
99
|
+
when "sprite" then Emitters::Sprite.new(@manifest, build)
|
|
100
|
+
when "css" then Emitters::Stylesheet.new(@manifest, build)
|
|
101
|
+
when "ttf" then Emitters::Font.new(@manifest, build, lock: @lock, output: path)
|
|
102
|
+
else raise Error, "unknown target #{target.inspect} (have: sprite, ttf)"
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def filename_for(target)
|
|
107
|
+
case target
|
|
108
|
+
when "sprite" then "icons.svg"
|
|
109
|
+
when "css" then "icons.css"
|
|
110
|
+
when "ttf" then "icons.ttf"
|
|
111
|
+
else raise Error, "unknown target #{target.inspect}"
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
end
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Fontico
|
|
4
|
+
module Emitters
|
|
5
|
+
# A TTF for Prawn. Not for the web: at this icon count the sprite is both
|
|
6
|
+
# smaller over the wire and not render-blocking. Prawn reads TTF/OTF via
|
|
7
|
+
# ttfunk and cannot read woff2, so TTF is the right container here.
|
|
8
|
+
class Font
|
|
9
|
+
def initialize(manifest, build, lock: nil, outliner: nil, output: nil)
|
|
10
|
+
@manifest = manifest
|
|
11
|
+
@build = build
|
|
12
|
+
@lock = lock
|
|
13
|
+
@outliner = outliner || Outliner.new
|
|
14
|
+
@output = output
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# Glyphs store no colour, so multicolour icons cannot be represented at
|
|
18
|
+
# all. They stay in the sprite and the build names each one it dropped.
|
|
19
|
+
def accepts?(icon) = !(@lock&.multicolor?(icon.name) || icon.multicolor?)
|
|
20
|
+
def rules_only? = false
|
|
21
|
+
|
|
22
|
+
def call
|
|
23
|
+
unsupported = @build.select { |icon, body| @outliner.strategy_for(icon, body) == :none }
|
|
24
|
+
@outliner.refuse(unsupported.map(&:first)) if unsupported.any?
|
|
25
|
+
|
|
26
|
+
outlines = @outliner.outlines(@build, size: @manifest.size)
|
|
27
|
+
|
|
28
|
+
glyphs = @build.map do |icon, body|
|
|
29
|
+
{ name: icon.key,
|
|
30
|
+
codepoint: @lock.codepoint_for(icon.name),
|
|
31
|
+
svg: document(outlines[icon.name], body) }
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
result = @outliner.instance_variable_get(:@runner)
|
|
35
|
+
.run("build_font.mjs", {
|
|
36
|
+
fontName: "fontico", size: @manifest.size,
|
|
37
|
+
output: @output, glyphs: glyphs
|
|
38
|
+
})
|
|
39
|
+
if result["empty"]&.any?
|
|
40
|
+
raise Fontico::Error, <<~MSG
|
|
41
|
+
#{result["empty"].size} icon(s) produced an empty glyph and would ship invisible:
|
|
42
|
+
|
|
43
|
+
#{result["empty"].join("\n ")}
|
|
44
|
+
|
|
45
|
+
The usual cause is live <text> in the source SVG, which has no outline
|
|
46
|
+
to convert. Convert text to paths (Path > Object to Path), or remove
|
|
47
|
+
the icon from the font target.
|
|
48
|
+
MSG
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
result
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
private
|
|
55
|
+
|
|
56
|
+
def document(outline, body)
|
|
57
|
+
inner = outline ? %(<path d="#{outline}"/>) : body.gsub("currentColor", "#000")
|
|
58
|
+
%(<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 #{@manifest.size} #{@manifest.size}">#{inner}</svg>)
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Fontico
|
|
4
|
+
module Emitters
|
|
5
|
+
# One <svg> of <symbol> definitions, referenced with <use href="…#name">.
|
|
6
|
+
# Pure Ruby string assembly: no external toolchain, builds anywhere.
|
|
7
|
+
class Sprite
|
|
8
|
+
def initialize(manifest, build)
|
|
9
|
+
@manifest = manifest
|
|
10
|
+
@build = build
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def accepts?(_icon) = true
|
|
14
|
+
def rules_only? = false
|
|
15
|
+
|
|
16
|
+
def call
|
|
17
|
+
symbols = @build.map do |icon, body|
|
|
18
|
+
%(<symbol id="#{icon.key}" viewBox="0 0 #{@manifest.size} #{@manifest.size}" ) +
|
|
19
|
+
%(fill="none">#{body}</symbol>)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
<<~SVG
|
|
23
|
+
<svg xmlns="http://www.w3.org/2000/svg" aria-hidden="true" style="display:none">
|
|
24
|
+
#{symbols.join("\n")}
|
|
25
|
+
</svg>
|
|
26
|
+
SVG
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Fontico
|
|
4
|
+
module Emitters
|
|
5
|
+
# The small amount of CSS that makes an <svg> behave like a glyph.
|
|
6
|
+
#
|
|
7
|
+
# Colour is already handled: bodies are folded to `currentColor`, which is
|
|
8
|
+
# what makes an *external* sprite themeable at all — host CSS does not
|
|
9
|
+
# cascade into a cross-document <use>, but inherited properties like
|
|
10
|
+
# `color` do reach it.
|
|
11
|
+
class Stylesheet
|
|
12
|
+
def initialize(manifest, _build)
|
|
13
|
+
@manifest = manifest
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
# Contributes no icons, only rules — so the builder must not count all
|
|
17
|
+
# 91 of them as "skipped" on its behalf.
|
|
18
|
+
def accepts?(_icon) = false
|
|
19
|
+
def rules_only? = true
|
|
20
|
+
|
|
21
|
+
def call
|
|
22
|
+
<<~CSS
|
|
23
|
+
/* generated by fontico — do not edit */
|
|
24
|
+
.#{Fontico.css_class} {
|
|
25
|
+
display: inline-block;
|
|
26
|
+
width: 1em;
|
|
27
|
+
height: 1em;
|
|
28
|
+
|
|
29
|
+
/* An <svg> sits on the text baseline's bottom edge, a glyph sits
|
|
30
|
+
slightly below it. This is the conventional nudge; tune it if
|
|
31
|
+
your body face has unusual metrics. */
|
|
32
|
+
vertical-align: -0.125em;
|
|
33
|
+
|
|
34
|
+
/* Never let a flex or grid parent squash an icon. */
|
|
35
|
+
flex: none;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/* Optical sizing for icons set beside larger text. */
|
|
39
|
+
.#{Fontico.css_class}-lg { width: 1.25em; height: 1.25em; }
|
|
40
|
+
.#{Fontico.css_class}-xl { width: 1.5em; height: 1.5em; }
|
|
41
|
+
CSS
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Fontico
|
|
4
|
+
# The whole point of the manifest: templates say what they mean, and never
|
|
5
|
+
# name a vendor. icon("save") and icon("logo") are the same call.
|
|
6
|
+
module Helper
|
|
7
|
+
# A glyph inherits font-size and colour from the text around it; an <svg>
|
|
8
|
+
# does not. width/height of 1em restores the first, `currentColor` baked
|
|
9
|
+
# into every body restores the second, and the generated stylesheet puts
|
|
10
|
+
# it on the baseline. Pass size: to override, or a CSS class — classes win
|
|
11
|
+
# over the attributes, so `class: "size-6"` works untouched.
|
|
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?
|
|
16
|
+
|
|
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>)
|
|
31
|
+
|
|
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
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# The `<use href>` for one icon, for markup this helper doesn't build —
|
|
37
|
+
# a Vue or Stimulus template, a JSON blob on its way to the client.
|
|
38
|
+
# Both halves are things a template shouldn't have to know: the sprite
|
|
39
|
+
# carries an asset digest, and a dotted manifest name is a dashed symbol
|
|
40
|
+
# id inside the file.
|
|
41
|
+
#
|
|
42
|
+
# icon_href("game.aim") # => "/assets/icons-ac89a32b.svg#game-aim"
|
|
43
|
+
#
|
|
44
|
+
# Hand it to the client rather than reconstructing it there; see
|
|
45
|
+
# icons_sprite for the inline case, where the path half is empty and the
|
|
46
|
+
# fragment resolves against the current document.
|
|
47
|
+
def icon_href(name)
|
|
48
|
+
Fontico.check!(name.to_s)
|
|
49
|
+
entry = Fontico.manifest[name.to_s]
|
|
50
|
+
raise Fontico::Error, "no icon named #{name.inspect} in #{Fontico.manifest_path}" if entry.nil?
|
|
51
|
+
|
|
52
|
+
"#{sprite_path}##{entry.key}"
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Embeds the symbol definitions directly, for pages served from a CDN
|
|
56
|
+
# where a cross-origin <use href> would silently render nothing.
|
|
57
|
+
def icons_sprite
|
|
58
|
+
Fontico.check!
|
|
59
|
+
svg = File.read(Fontico.sprite_file)
|
|
60
|
+
svg.respond_to?(:html_safe) ? svg.html_safe : svg
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
private
|
|
64
|
+
|
|
65
|
+
# width/height on an <svg> are presentation attributes, and *any* CSS
|
|
66
|
+
# declaration outranks those — including icons.css's own `.ico { width:
|
|
67
|
+
# 1em }`. So an explicitly requested size has to be inline or the
|
|
68
|
+
# stylesheet silently swallows it and every icon comes out 1em.
|
|
69
|
+
#
|
|
70
|
+
# With no size given the attributes stay the only word on the matter,
|
|
71
|
+
# which is what lets `class: "size-6"` or `class: "h-7 w-7"` still win.
|
|
72
|
+
def inline_size(size, style = nil)
|
|
73
|
+
return style if size.nil?
|
|
74
|
+
|
|
75
|
+
dim = size.is_a?(Numeric) ? "#{size}px" : size.to_s
|
|
76
|
+
["width:#{dim}", "height:#{dim}", style].compact.join(";")
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# Propshaft always digests, so there is no undigested path to guess at:
|
|
80
|
+
# "/assets/icons.svg" is a 404 in every environment, and a <use> pointing
|
|
81
|
+
# at one renders an empty box rather than raising. That matters most for
|
|
82
|
+
# icon_href, which gets called from serializers, jobs and broadcasts —
|
|
83
|
+
# payload-building code that has no view context to borrow asset_path
|
|
84
|
+
# from. Rails hands one out off ActionController::Base either way.
|
|
85
|
+
def sprite_path(_variant = nil)
|
|
86
|
+
return "" if Fontico.inline_sprite?
|
|
87
|
+
return asset_path("icons.svg") if respond_to?(:asset_path)
|
|
88
|
+
return ActionController::Base.helpers.asset_path("icons.svg") if defined?(::ActionController::Base)
|
|
89
|
+
|
|
90
|
+
"/assets/icons.svg"
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# An icon missing from the manifest is a typo, and typos should not reach
|
|
94
|
+
# production as an invisible empty box.
|
|
95
|
+
def missing(name)
|
|
96
|
+
raise Fontico::Error, "no icon named #{name.inspect} in #{Fontico.manifest_path}" unless production?
|
|
97
|
+
|
|
98
|
+
"".respond_to?(:html_safe) ? "".html_safe : ""
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def production?
|
|
102
|
+
defined?(Rails) && Rails.env.production?
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
end
|
data/lib/fontico/icon.rb
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Fontico
|
|
4
|
+
# One resolved entry from the manifest: the name templates use, plus where
|
|
5
|
+
# it came from and the flags that decide which emitters accept it.
|
|
6
|
+
Icon = Struct.new(:name, :provider, :slug, :multicolor, keyword_init: true) do
|
|
7
|
+
def source = "#{provider}/#{slug}"
|
|
8
|
+
def local? = provider == "local"
|
|
9
|
+
def multicolor? = !!multicolor
|
|
10
|
+
|
|
11
|
+
# Sprite symbol ids and the id-namespacing prefix both derive from here,
|
|
12
|
+
# so a rename in the manifest moves them together.
|
|
13
|
+
def key = name.tr(".", "-")
|
|
14
|
+
end
|
|
15
|
+
end
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "yaml"
|
|
4
|
+
require "digest"
|
|
5
|
+
|
|
6
|
+
module Fontico
|
|
7
|
+
# icons.lock pins two things that must never drift:
|
|
8
|
+
#
|
|
9
|
+
# codepoints — append-only. Adding an icon must not renumber the others,
|
|
10
|
+
# or every glyph in the built font moves and the committed
|
|
11
|
+
# artifact churns whole-file on each addition. Codepoints of
|
|
12
|
+
# removed icons are retired, never reissued.
|
|
13
|
+
#
|
|
14
|
+
# bodies — the normalised SVG for each icon, so builds are
|
|
15
|
+
# reproducible and run offline. The Iconify API serves
|
|
16
|
+
# *latest*; without this an icon can change shape between
|
|
17
|
+
# two builds of the same manifest.
|
|
18
|
+
class Lockfile
|
|
19
|
+
PUA_START = 0xE001
|
|
20
|
+
FORMAT = 1
|
|
21
|
+
|
|
22
|
+
attr_reader :path
|
|
23
|
+
|
|
24
|
+
def initialize(path)
|
|
25
|
+
@path = path
|
|
26
|
+
data = File.exist?(path) ? (YAML.safe_load_file(path) || {}) : {}
|
|
27
|
+
@codepoints = data["codepoints"] || {}
|
|
28
|
+
@retired = data["retired"] || {}
|
|
29
|
+
@entries = data["icons"] || {}
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def codepoint_for(name)
|
|
33
|
+
@codepoints[name] ||= next_free
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def entry(name) = @entries[name]
|
|
37
|
+
|
|
38
|
+
def store(name, source:, body:, multicolor: false, warnings: [])
|
|
39
|
+
@entries[name] = {
|
|
40
|
+
"source" => source,
|
|
41
|
+
"digest" => Digest::SHA256.hexdigest(body)[0, 16],
|
|
42
|
+
"multicolor" => multicolor,
|
|
43
|
+
"warnings" => warnings,
|
|
44
|
+
"body" => body
|
|
45
|
+
}
|
|
46
|
+
codepoint_for(name)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Names present in the lock but absent from the manifest keep their
|
|
50
|
+
# codepoint reserved so it is never handed to a different icon.
|
|
51
|
+
def retire_missing!(names)
|
|
52
|
+
(@codepoints.keys - names).each do |gone|
|
|
53
|
+
@retired[gone] = @codepoints.delete(gone)
|
|
54
|
+
@entries.delete(gone)
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def fresh?(name, source)
|
|
59
|
+
entry(name)&.fetch("source", nil) == source && entry(name)["body"]
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def body(name) = entry(name)&.fetch("body", nil)
|
|
63
|
+
def multicolor?(name) = !!entry(name)&.fetch("multicolor", false)
|
|
64
|
+
|
|
65
|
+
# Replayed on cached builds so a hard failure keeps being reported until
|
|
66
|
+
# the source file is actually fixed.
|
|
67
|
+
def warnings(name) = entry(name)&.fetch("warnings", nil) || []
|
|
68
|
+
|
|
69
|
+
def save!
|
|
70
|
+
File.write(@path, {
|
|
71
|
+
"format" => FORMAT,
|
|
72
|
+
"codepoints" => @codepoints.sort.to_h,
|
|
73
|
+
"retired" => @retired.sort.to_h,
|
|
74
|
+
"icons" => @entries.sort.to_h
|
|
75
|
+
}.to_yaml)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
private
|
|
79
|
+
|
|
80
|
+
def next_free
|
|
81
|
+
used = (@codepoints.values + @retired.values).map(&:to_i)
|
|
82
|
+
cp = PUA_START
|
|
83
|
+
cp += 1 while used.include?(cp)
|
|
84
|
+
cp
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "yaml"
|
|
4
|
+
|
|
5
|
+
module Fontico
|
|
6
|
+
# Parses icons.yml. Nested groups flatten to dotted names, so
|
|
7
|
+
# `nav: { menu: lucide/menu }` is addressable as icon("nav.menu").
|
|
8
|
+
class Manifest
|
|
9
|
+
class Error < Fontico::Error; end
|
|
10
|
+
|
|
11
|
+
# Where local/ SVGs live when the manifest doesn't say otherwise.
|
|
12
|
+
LOCAL_PATH = "app/assets/icons"
|
|
13
|
+
|
|
14
|
+
attr_reader :path, :defaults, :providers, :targets, :icons
|
|
15
|
+
|
|
16
|
+
def self.load(path) = new(YAML.safe_load_file(path), path: path)
|
|
17
|
+
|
|
18
|
+
def initialize(data, path: nil)
|
|
19
|
+
@path = path
|
|
20
|
+
@defaults = data["defaults"] || {}
|
|
21
|
+
@providers = data["providers"] || {}
|
|
22
|
+
@targets = data["targets"] || ["sprite"]
|
|
23
|
+
@icons = flatten(data["icons"] || {}).freeze
|
|
24
|
+
validate!
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def default_provider = defaults.fetch("provider", "lucide")
|
|
28
|
+
def local_path = providers.dig("local", "path") || LOCAL_PATH
|
|
29
|
+
def size = defaults.fetch("size", 24).to_i
|
|
30
|
+
|
|
31
|
+
def [](name) = icons.find { _1.name == name }
|
|
32
|
+
|
|
33
|
+
# Vendor icons grouped by provider so the resolver can batch one HTTP
|
|
34
|
+
# request per provider instead of one per icon.
|
|
35
|
+
def remote_by_provider
|
|
36
|
+
icons.reject(&:local?).group_by(&:provider).transform_values { _1.map(&:slug).uniq.sort }
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def local_icons = icons.select(&:local?)
|
|
40
|
+
|
|
41
|
+
private
|
|
42
|
+
|
|
43
|
+
def flatten(tree, prefix = nil)
|
|
44
|
+
tree.flat_map do |key, value|
|
|
45
|
+
name = [prefix, key].compact.join(".")
|
|
46
|
+
case value
|
|
47
|
+
when String then [build(name, value)]
|
|
48
|
+
when Hash
|
|
49
|
+
if value.key?("icon") || value.key?("file")
|
|
50
|
+
[build(name, value["icon"] || "local/#{File.basename(value["file"], ".svg")}", value)]
|
|
51
|
+
else
|
|
52
|
+
flatten(value, name)
|
|
53
|
+
end
|
|
54
|
+
else
|
|
55
|
+
raise Error, "icon #{name.inspect} must be a string or a mapping, got #{value.class}"
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def build(name, spec, opts = {})
|
|
61
|
+
provider, slug = spec.include?("/") ? spec.split("/", 2) : [default_provider, spec]
|
|
62
|
+
Icon.new(name: name, provider: provider, slug: slug, multicolor: opts["multicolor"])
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def validate!
|
|
66
|
+
raise Error, "manifest declares no icons" if icons.empty?
|
|
67
|
+
|
|
68
|
+
dupes = icons.map(&:name).tally.select { |_, n| n > 1 }.keys
|
|
69
|
+
raise Error, "duplicate icon names: #{dupes.join(", ")}" if dupes.any?
|
|
70
|
+
|
|
71
|
+
unknown = icons.map(&:provider).uniq - providers.keys
|
|
72
|
+
raise Error, "icons reference undeclared providers: #{unknown.join(", ")}" if unknown.any?
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
// Assemble normalised SVG bodies into a TTF.
|
|
2
|
+
//
|
|
3
|
+
// stdin: { fontName, size, output, glyphs: [{ name, codepoint, svg }] }
|
|
4
|
+
import fs from 'fs';
|
|
5
|
+
import { Readable } from 'stream';
|
|
6
|
+
import { SVGIcons2SVGFontStream } from 'svgicons2svgfont';
|
|
7
|
+
import svg2ttf from 'svg2ttf';
|
|
8
|
+
|
|
9
|
+
const job = JSON.parse(fs.readFileSync(0, 'utf8'));
|
|
10
|
+
|
|
11
|
+
// 1000 upm with the full em above the baseline keeps icons aligned with text
|
|
12
|
+
// instead of drifting against it.
|
|
13
|
+
const UPM = 1000;
|
|
14
|
+
|
|
15
|
+
const stream = new SVGIcons2SVGFontStream({
|
|
16
|
+
fontName: job.fontName,
|
|
17
|
+
fontHeight: UPM,
|
|
18
|
+
ascent: UPM,
|
|
19
|
+
descent: 0,
|
|
20
|
+
normalize: true,
|
|
21
|
+
centerHorizontally: false,
|
|
22
|
+
log: () => {}
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
let svgFont = '';
|
|
26
|
+
stream.on('data', (chunk) => { svgFont += chunk; });
|
|
27
|
+
stream.on('error', (err) => {
|
|
28
|
+
process.stderr.write(`svgicons2svgfont: ${err.message}\n`);
|
|
29
|
+
process.exit(1);
|
|
30
|
+
});
|
|
31
|
+
stream.on('finish', () => {
|
|
32
|
+
const ttf = svg2ttf(svgFont, { copyright: job.copyright ?? '' });
|
|
33
|
+
fs.writeFileSync(job.output, Buffer.from(ttf.buffer));
|
|
34
|
+
|
|
35
|
+
// Report empty glyphs rather than shipping invisible icons silently.
|
|
36
|
+
// svgicons2svgfont emits d="" rather than omitting the attribute, so an
|
|
37
|
+
// absent-d check alone misses exactly the case that matters.
|
|
38
|
+
const empty = [...svgFont.matchAll(/<glyph glyph-name="([^"]+)"[^>]*\/>/g)]
|
|
39
|
+
.filter((m) => !/\sd="[^"]+"/.test(m[0]))
|
|
40
|
+
.map((m) => m[1]);
|
|
41
|
+
process.stdout.write(JSON.stringify({ bytes: fs.statSync(job.output).size, empty }));
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
for (const glyph of job.glyphs) {
|
|
45
|
+
const readable = Readable.from([glyph.svg]);
|
|
46
|
+
readable.metadata = {
|
|
47
|
+
unicode: [String.fromCodePoint(glyph.codepoint)],
|
|
48
|
+
name: glyph.name
|
|
49
|
+
};
|
|
50
|
+
stream.write(readable);
|
|
51
|
+
}
|
|
52
|
+
stream.end();
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
// Pull already-outlined glyphs out of a provider's own font.
|
|
2
|
+
//
|
|
3
|
+
// Stroke-based icon sets (Lucide) cannot be filled directly as glyphs — a
|
|
4
|
+
// path with fill="none" fills its centreline and produces a blob. The only
|
|
5
|
+
// lossless source of expanded outlines is the provider's own font build,
|
|
6
|
+
// where the expansion has already been done and tuned. Every JS "stroke to
|
|
7
|
+
// fill" package traces a raster and degrades the geometry.
|
|
8
|
+
//
|
|
9
|
+
// stdin: { fontPath, codepointsPath, size, names: [] }
|
|
10
|
+
// stdout: { glyphs: { name: pathData }, missing: [] }
|
|
11
|
+
import fs from 'fs';
|
|
12
|
+
import opentype from 'opentype.js';
|
|
13
|
+
|
|
14
|
+
const job = JSON.parse(fs.readFileSync(0, 'utf8'));
|
|
15
|
+
const font = opentype.parse(fs.readFileSync(job.fontPath).buffer);
|
|
16
|
+
const codepoints = JSON.parse(fs.readFileSync(job.codepointsPath, 'utf8'));
|
|
17
|
+
|
|
18
|
+
const size = job.size ?? 24;
|
|
19
|
+
const glyphs = {};
|
|
20
|
+
const missing = [];
|
|
21
|
+
|
|
22
|
+
for (const name of job.names) {
|
|
23
|
+
const cp = codepoints[name];
|
|
24
|
+
if (cp === undefined) { missing.push(name); continue; }
|
|
25
|
+
|
|
26
|
+
const glyph = font.charToGlyph(String.fromCodePoint(cp));
|
|
27
|
+
if (!glyph || glyph.index === 0) { missing.push(name); continue; }
|
|
28
|
+
|
|
29
|
+
// Font space is y-up with the baseline at 0; SVG is y-down in a 0..size
|
|
30
|
+
// box. Shifting by the ascender puts the glyph inside the viewBox.
|
|
31
|
+
const path = glyph.getPath(0, size * (font.ascender / font.unitsPerEm), size);
|
|
32
|
+
glyphs[name] = path.toPathData(3);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
process.stdout.write(JSON.stringify({ glyphs, missing }));
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "fontico-toolchain",
|
|
3
|
+
"private": true,
|
|
4
|
+
"type": "module",
|
|
5
|
+
"description": "Font assembly toolchain for the fontico gem. Installed on demand into a cache directory; not required unless a font target is built.",
|
|
6
|
+
"dependencies": {
|
|
7
|
+
"opentype.js": "^2.0.0",
|
|
8
|
+
"svgicons2svgfont": "^16.0.0",
|
|
9
|
+
"svg2ttf": "^6.1.0"
|
|
10
|
+
}
|
|
11
|
+
}
|