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,66 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "open3"
|
|
5
|
+
require "fileutils"
|
|
6
|
+
|
|
7
|
+
module Fontico
|
|
8
|
+
# Font assembly needs a toolchain the sprite does not. It is installed on
|
|
9
|
+
# demand into a cache directory and never touched unless a font target is
|
|
10
|
+
# actually built, so `targets: [sprite]` stays pure Ruby with no node at all.
|
|
11
|
+
class NodeRunner
|
|
12
|
+
class MissingNode < Fontico::Error; end
|
|
13
|
+
class ScriptFailed < Fontico::Error; end
|
|
14
|
+
|
|
15
|
+
SCRIPTS = File.expand_path("node", __dir__)
|
|
16
|
+
|
|
17
|
+
def initialize(cache_dir: nil)
|
|
18
|
+
@cache = cache_dir || File.join(Dir.home, ".cache", "fontico", "toolchain")
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def available? = !which("node").nil?
|
|
22
|
+
|
|
23
|
+
def run(script, payload)
|
|
24
|
+
ensure_toolchain!
|
|
25
|
+
# Run from inside the cache: ESM resolves bare imports by walking up
|
|
26
|
+
# from the script's own directory, and ignores NODE_PATH entirely.
|
|
27
|
+
path = File.join(@cache, script)
|
|
28
|
+
|
|
29
|
+
out, err, status = Open3.capture3(
|
|
30
|
+
which("node"), path, stdin_data: JSON.dump(payload), chdir: @cache
|
|
31
|
+
)
|
|
32
|
+
raise ScriptFailed, "#{script}: #{err.strip.empty? ? "exited #{status.exitstatus}" : err.strip}" unless status.success?
|
|
33
|
+
|
|
34
|
+
JSON.parse(out)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
private
|
|
38
|
+
|
|
39
|
+
def ensure_toolchain!
|
|
40
|
+
raise MissingNode, <<~MSG unless available?
|
|
41
|
+
Building a font target needs Node.js, which was not found on PATH.
|
|
42
|
+
|
|
43
|
+
The sprite target has no such requirement — remove `ttf` from
|
|
44
|
+
`targets:` in your manifest to build without it.
|
|
45
|
+
MSG
|
|
46
|
+
|
|
47
|
+
FileUtils.mkdir_p(@cache)
|
|
48
|
+
Dir[File.join(SCRIPTS, "*.mjs")].each { FileUtils.cp(_1, @cache) }
|
|
49
|
+
return if File.directory?(File.join(@cache, "node_modules"))
|
|
50
|
+
|
|
51
|
+
FileUtils.cp(File.join(SCRIPTS, "package.json"), @cache)
|
|
52
|
+
_, err, status = Open3.capture3(
|
|
53
|
+
which("npm"), "install", "--silent", "--no-audit", "--no-fund", chdir: @cache
|
|
54
|
+
)
|
|
55
|
+
raise ScriptFailed, "installing font toolchain into #{@cache}: #{err}" unless status.success?
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def which(cmd)
|
|
59
|
+
ENV.fetch("PATH", "").split(File::PATH_SEPARATOR).each do |dir|
|
|
60
|
+
exe = File.join(dir, cmd)
|
|
61
|
+
return exe if File.executable?(exe) && !File.directory?(exe)
|
|
62
|
+
end
|
|
63
|
+
nil
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
|
|
5
|
+
module Fontico
|
|
6
|
+
# Decides where each icon's *filled outline* comes from, which is the whole
|
|
7
|
+
# difficulty of the font target.
|
|
8
|
+
#
|
|
9
|
+
# :fill already filled geometry — used as-is (Material Symbols, flat
|
|
10
|
+
# first-party exports, most Iconify sets)
|
|
11
|
+
# :glyph stroke-based, but the provider ships a font whose glyphs are
|
|
12
|
+
# already expanded — extracted from there, losslessly (Lucide)
|
|
13
|
+
# :none stroke-based with no provider font — refused, loudly
|
|
14
|
+
#
|
|
15
|
+
# There is deliberately no raster-trace fallback. Both published JS
|
|
16
|
+
# expanders (svg-outline-stroke, oslllo-svg-fixer) run the artwork through
|
|
17
|
+
# potrace; corners round off and strokes wobble. Refusing beats shipping
|
|
18
|
+
# geometry that quietly stopped matching the sprite.
|
|
19
|
+
class Outliner
|
|
20
|
+
Unsupported = Class.new(Fontico::Error)
|
|
21
|
+
|
|
22
|
+
def initialize(runner: NodeRunner.new, cache_dir: nil)
|
|
23
|
+
@runner = runner
|
|
24
|
+
@cache = cache_dir || File.join(Dir.home, ".cache", "fontico", "fonts")
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def self.stroke_based?(body)
|
|
28
|
+
body.match?(/stroke\s*=\s*['"](?!none)/) || body.match?(/stroke\s*:\s*(?!none)/)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def strategy_for(icon, body)
|
|
32
|
+
return :fill unless self.class.stroke_based?(body)
|
|
33
|
+
return :glyph if ProviderFonts.available?(icon.provider)
|
|
34
|
+
|
|
35
|
+
:none
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# => { icon_name => path_data } for every :glyph icon, batched per provider
|
|
39
|
+
# so each provider font is downloaded and parsed once.
|
|
40
|
+
def outlines(pairs, size: 24)
|
|
41
|
+
by_provider = pairs.select { |icon, body| strategy_for(icon, body) == :glyph }
|
|
42
|
+
.group_by { |icon, _| icon.provider }
|
|
43
|
+
return {} if by_provider.empty?
|
|
44
|
+
|
|
45
|
+
FileUtils.mkdir_p(@cache)
|
|
46
|
+
by_provider.each_with_object({}) do |(provider, group), out|
|
|
47
|
+
font, codepoints = ProviderFonts.fetch(provider, cache_dir: @cache)
|
|
48
|
+
result = @runner.run("extract_glyphs.mjs", {
|
|
49
|
+
fontPath: font, codepointsPath: codepoints, size: size,
|
|
50
|
+
names: group.map { |icon, _| icon.slug }.uniq
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
if result["missing"]&.any?
|
|
54
|
+
raise Unsupported, "#{provider} font has no glyph for: #{result["missing"].join(", ")}"
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
group.each { |icon, _| out[icon.name] = result.dig("glyphs", icon.slug) }
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def refuse(unsupported)
|
|
62
|
+
names = unsupported.map { "#{_1.name} (#{_1.source})" }
|
|
63
|
+
raise Unsupported, <<~MSG
|
|
64
|
+
#{unsupported.size} icon(s) are stroke-based and their provider ships no
|
|
65
|
+
font to take outlines from, so they cannot become glyphs:
|
|
66
|
+
|
|
67
|
+
#{names.join("\n ")}
|
|
68
|
+
|
|
69
|
+
A stroked path does not fail loudly in a font — it fills its centreline
|
|
70
|
+
and produces a solid blob. Options:
|
|
71
|
+
|
|
72
|
+
* outline the strokes at source (Path > Stroke to Path) for local icons
|
|
73
|
+
* pick the filled variant of the icon from its provider
|
|
74
|
+
* drop `ttf` from targets: and use the sprite, which renders strokes natively
|
|
75
|
+
|
|
76
|
+
Providers with extractable fonts: #{ProviderFonts.known.join(", ")}
|
|
77
|
+
MSG
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Fontico
|
|
4
|
+
# Icons in generated PDFs, from the same manifest as the web sprite.
|
|
5
|
+
#
|
|
6
|
+
# This is what the TTF target is actually for. Prawn reads TTF/OTF through
|
|
7
|
+
# ttfunk and cannot read woff2, so TTF is the right container — it is not a
|
|
8
|
+
# web font, and at this icon count the sprite beats one on the web anyway.
|
|
9
|
+
#
|
|
10
|
+
# require "fontico/prawn"
|
|
11
|
+
#
|
|
12
|
+
# Prawn::Document.generate("out.pdf") do |pdf|
|
|
13
|
+
# pdf.fontico! # register the family once
|
|
14
|
+
# pdf.icon "save", size: 18
|
|
15
|
+
# pdf.text "#{pdf.glyph("mail")} hello", inline_format: false
|
|
16
|
+
# end
|
|
17
|
+
module Prawn
|
|
18
|
+
FAMILY = "Fontico"
|
|
19
|
+
|
|
20
|
+
module DocumentExtensions
|
|
21
|
+
# Registers the built TTF as a font family. Call once per document.
|
|
22
|
+
def fontico!(family: FAMILY, path: Fontico.font_file)
|
|
23
|
+
unless File.exist?(path)
|
|
24
|
+
raise Fontico::Error,
|
|
25
|
+
"#{path} does not exist — add `ttf` to targets: and run rake fontico:build"
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
font_families.update(family => { normal: path })
|
|
29
|
+
self
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# The glyph character for +name+, for interpolating into a text run.
|
|
33
|
+
# Wrap the run in font(Fontico::Prawn::FAMILY) so it renders.
|
|
34
|
+
def glyph(name) = Fontico.glyph(name)
|
|
35
|
+
|
|
36
|
+
# Draws one icon. Colour and size come from Prawn, exactly like text,
|
|
37
|
+
# because a glyph *is* text.
|
|
38
|
+
def icon(name, size: font_size, color: nil, family: FAMILY, **options)
|
|
39
|
+
if Fontico.lockfile.multicolor?(name.to_s)
|
|
40
|
+
raise Fontico::Error,
|
|
41
|
+
"#{name} is multicolour and is not in the font. Draw it with " \
|
|
42
|
+
"prawn-svg from the sprite source instead."
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
previous = fill_color
|
|
46
|
+
fill_color(color) if color
|
|
47
|
+
font(family, size: size) { text(Fontico.glyph(name), **options) }
|
|
48
|
+
fill_color(previous) if color
|
|
49
|
+
self
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# Same, positioned — for letterheads and table cells.
|
|
53
|
+
def icon_at(name, at:, size: font_size, family: FAMILY)
|
|
54
|
+
font(family, size: size) { draw_text(Fontico.glyph(name), at: at) }
|
|
55
|
+
self
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
require "prawn"
|
|
62
|
+
Prawn::Document.include(Fontico::Prawn::DocumentExtensions)
|
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rexml/document"
|
|
4
|
+
|
|
5
|
+
module Fontico
|
|
6
|
+
# Normalises one SVG into a body fragment that is safe to merge with any
|
|
7
|
+
# other. Implements docs/icon-authoring.html section 01.
|
|
8
|
+
#
|
|
9
|
+
# Vendor icons arrive uniform; first-party exports do not. Everything here
|
|
10
|
+
# exists because a real Inkscape or Illustrator export breaks it.
|
|
11
|
+
class Preprocessor
|
|
12
|
+
# Editor state, document furniture, and anything executable.
|
|
13
|
+
DROP_ELEMENTS = %w[
|
|
14
|
+
script metadata foreignObject sodipodi:namedview
|
|
15
|
+
title desc inkscape:templateinfo
|
|
16
|
+
].freeze
|
|
17
|
+
|
|
18
|
+
# Attributes that never survive: editor namespaces and event handlers.
|
|
19
|
+
DROP_ATTR_PREFIXES = %w[sodipodi: inkscape: serif: xml:space].freeze
|
|
20
|
+
|
|
21
|
+
# Attributes whose value may contain url(#id) and must be rewritten
|
|
22
|
+
# alongside the ids themselves.
|
|
23
|
+
URL_ATTRS = %w[
|
|
24
|
+
fill stroke clip-path mask filter style
|
|
25
|
+
marker-start marker-mid marker-end
|
|
26
|
+
].freeze
|
|
27
|
+
|
|
28
|
+
COLOR_ATTRS = %w[fill stroke stop-color].freeze
|
|
29
|
+
|
|
30
|
+
Result = Struct.new(:body, :width, :height, :warnings, :multicolor, keyword_init: true)
|
|
31
|
+
|
|
32
|
+
def initialize(icon, size: 24)
|
|
33
|
+
@icon = icon
|
|
34
|
+
@size = size
|
|
35
|
+
@warnings = []
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# +source+ is a whole SVG document (local files) or a body fragment
|
|
39
|
+
# (Iconify, which returns inner markup only).
|
|
40
|
+
def call(source, width: nil, height: nil)
|
|
41
|
+
doc = REXML::Document.new(wrap(source, width, height))
|
|
42
|
+
root = doc.root
|
|
43
|
+
|
|
44
|
+
vb_w, vb_h, min_x, min_y = viewbox_of(root, width, height)
|
|
45
|
+
|
|
46
|
+
strip_comments!(root)
|
|
47
|
+
strip_elements!(root)
|
|
48
|
+
strip_attributes!(root)
|
|
49
|
+
namespace_ids!(root)
|
|
50
|
+
|
|
51
|
+
# The manifest stays one line per icon: multicolor is detected, not
|
|
52
|
+
# declared, unless the author overrides it explicitly.
|
|
53
|
+
multicolor = @icon.multicolor.nil? ? distinct_colors(root).size > 1 : @icon.multicolor?
|
|
54
|
+
fold_colors!(root) unless multicolor
|
|
55
|
+
flag_hard_failures!(root)
|
|
56
|
+
|
|
57
|
+
Result.new(
|
|
58
|
+
body: refit(inner_markup(root), vb_w, vb_h, min_x, min_y),
|
|
59
|
+
width: @size, height: @size, warnings: @warnings, multicolor: multicolor
|
|
60
|
+
)
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
private
|
|
64
|
+
|
|
65
|
+
# A fragment carries no viewBox, so the dimensions supplied out of band
|
|
66
|
+
# are the only record of the grid it was drawn on and have to go on the
|
|
67
|
+
# wrapper. Assuming @size here silently cropped every provider that is
|
|
68
|
+
# not 24: arcticons (48) shipped as its own top-left quarter, game-icons
|
|
69
|
+
# (512) as a near-empty corner. lucide is 24, which is why it never showed.
|
|
70
|
+
def wrap(source, width = nil, height = nil)
|
|
71
|
+
return source if source.lstrip.start_with?("<svg", "<?xml", "<!DOCTYPE")
|
|
72
|
+
|
|
73
|
+
w = width.to_f.positive? ? width : @size
|
|
74
|
+
h = height.to_f.positive? ? height : @size
|
|
75
|
+
%(<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 #{w} #{h}">#{source}</svg>)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# Inkscape writes width="1024" alongside viewBox="0 0 270.93 270.93"; the
|
|
79
|
+
# viewBox is authoritative. Iconify supplies dimensions out of band.
|
|
80
|
+
def viewbox_of(root, width, height)
|
|
81
|
+
if (vb = root.attributes["viewBox"])
|
|
82
|
+
min_x, min_y, w, h = vb.split(/[\s,]+/).map(&:to_f)
|
|
83
|
+
return [w, h, min_x, min_y] if w.to_f.positive? && h.to_f.positive?
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
w = (width || root.attributes["width"]).to_f
|
|
87
|
+
h = (height || root.attributes["height"]).to_f
|
|
88
|
+
w = @size if w.zero?
|
|
89
|
+
h = @size if h.zero?
|
|
90
|
+
[w, h, 0.0, 0.0]
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# Author notes are document furniture. Harmless in one file, but the
|
|
94
|
+
# sprite merges every body into one document, so they all ship together.
|
|
95
|
+
def strip_comments!(root)
|
|
96
|
+
REXML::XPath.each(root, "//comment()") { _1.parent&.delete(_1) }
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def strip_elements!(root)
|
|
100
|
+
each_element(root) do |el|
|
|
101
|
+
el.parent&.delete_element(el) if DROP_ELEMENTS.include?(el.expanded_name)
|
|
102
|
+
end
|
|
103
|
+
# Empty <defs> and childless groups are pure noise in a merged sprite.
|
|
104
|
+
each_element(root) do |el|
|
|
105
|
+
next unless %w[defs g].include?(el.expanded_name)
|
|
106
|
+
|
|
107
|
+
el.parent&.delete_element(el) if el.elements.empty? && el.texts.join.strip.empty?
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def strip_attributes!(root)
|
|
112
|
+
each_element(root) do |el|
|
|
113
|
+
el.attributes.each_attribute.to_a.each do |attr|
|
|
114
|
+
name = attr.expanded_name
|
|
115
|
+
drop = DROP_ATTR_PREFIXES.any? { name.start_with?(_1) } ||
|
|
116
|
+
name.start_with?("on") ||
|
|
117
|
+
name.start_with?("xmlns:") ||
|
|
118
|
+
external_reference?(name, attr.value)
|
|
119
|
+
el.attributes.delete(name) if drop
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def external_reference?(name, value)
|
|
125
|
+
return false unless %w[href xlink:href].include?(name)
|
|
126
|
+
|
|
127
|
+
!value.to_s.strip.start_with?("#")
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
# The collision fix. 34 of 40 sampled exports shared id="layer1"; without
|
|
131
|
+
# this, merging any two of them silently drops one definition.
|
|
132
|
+
def namespace_ids!(root)
|
|
133
|
+
prefix = @icon.key
|
|
134
|
+
seen = {}
|
|
135
|
+
|
|
136
|
+
each_element(root) do |el|
|
|
137
|
+
next unless (id = el.attributes["id"])
|
|
138
|
+
|
|
139
|
+
seen[id] = "#{prefix}__#{id}"
|
|
140
|
+
el.attributes["id"] = seen[id]
|
|
141
|
+
end
|
|
142
|
+
return if seen.empty?
|
|
143
|
+
|
|
144
|
+
each_element(root) do |el|
|
|
145
|
+
el.attributes.each_attribute.to_a.each do |attr|
|
|
146
|
+
next unless URL_ATTRS.include?(attr.name) || %w[href xlink:href].include?(attr.expanded_name)
|
|
147
|
+
|
|
148
|
+
value = attr.value.dup
|
|
149
|
+
seen.each do |old, new|
|
|
150
|
+
value.gsub!("url(##{old})", "url(##{new})")
|
|
151
|
+
value.gsub!(/\A##{Regexp.escape(old)}\z/, "##{new}")
|
|
152
|
+
end
|
|
153
|
+
el.attributes[attr.expanded_name] = value
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
# Anything that resolves to paint: literal colours plus gradient
|
|
159
|
+
# references, which are multicolour by construction.
|
|
160
|
+
def distinct_colors(root)
|
|
161
|
+
found = []
|
|
162
|
+
each_element(root) do |el|
|
|
163
|
+
COLOR_ATTRS.each do |name|
|
|
164
|
+
v = el.attributes[name].to_s.strip
|
|
165
|
+
found << v unless v.empty? || v == "none" || v == "currentColor"
|
|
166
|
+
end
|
|
167
|
+
next unless (style = el.attributes["style"])
|
|
168
|
+
|
|
169
|
+
style.scan(/\b(?:fill|stroke|stop-color)\s*:\s*([^;]+)/i) do
|
|
170
|
+
v = Regexp.last_match(1).strip
|
|
171
|
+
found << v unless v == "none" || v == "currentColor"
|
|
172
|
+
end
|
|
173
|
+
end
|
|
174
|
+
found.map { _1.start_with?("url(") ? "#{_1}-gradient" : _1.downcase }.uniq
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
def fold_colors!(root)
|
|
178
|
+
each_element(root) do |el|
|
|
179
|
+
COLOR_ATTRS.each do |name|
|
|
180
|
+
value = el.attributes[name]
|
|
181
|
+
next if value.nil? || value == "none" || value == "currentColor"
|
|
182
|
+
|
|
183
|
+
el.attributes[name] = "currentColor"
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
next unless (style = el.attributes["style"])
|
|
187
|
+
|
|
188
|
+
folded = style.gsub(/\b(fill|stroke|stop-color)\s*:\s*([^;]+)/i) do
|
|
189
|
+
$2.strip.casecmp("none").zero? ? "#{$1}:none" : "#{$1}:currentColor"
|
|
190
|
+
end
|
|
191
|
+
el.attributes["style"] = folded
|
|
192
|
+
end
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
# Section 03 of the spec: things no preprocessing can rescue. Surfaced as
|
|
196
|
+
# warnings so the build names the file instead of shipping something broken.
|
|
197
|
+
def flag_hard_failures!(root)
|
|
198
|
+
each_element(root) do |el|
|
|
199
|
+
case el.expanded_name
|
|
200
|
+
when "text", "tspan"
|
|
201
|
+
@warnings << "contains live <#{el.expanded_name}>; convert text to paths"
|
|
202
|
+
when "image"
|
|
203
|
+
@warnings << "embeds a raster <image>; redraw as vector"
|
|
204
|
+
when "filter"
|
|
205
|
+
@warnings << "uses a <filter>; effects do not survive font conversion"
|
|
206
|
+
end
|
|
207
|
+
end
|
|
208
|
+
@warnings.uniq!
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
# Fit any source box into the target box, centred, aspect preserved.
|
|
212
|
+
# fa6-solid is 512 with per-icon 576 overrides; bi declares no width at
|
|
213
|
+
# all. The pipeline cannot assume 24.
|
|
214
|
+
def refit(markup, w, h, min_x, min_y)
|
|
215
|
+
scale = @size.to_f / [w, h].max
|
|
216
|
+
return markup if (scale - 1.0).abs < 1e-9 && min_x.zero? && min_y.zero?
|
|
217
|
+
|
|
218
|
+
tx = (@size - (w * scale)) / 2.0
|
|
219
|
+
ty = (@size - (h * scale)) / 2.0
|
|
220
|
+
transform = "translate(#{fmt(tx)} #{fmt(ty)}) scale(#{fmt(scale)}) " \
|
|
221
|
+
"translate(#{fmt(-min_x)} #{fmt(-min_y)})"
|
|
222
|
+
%(<g transform="#{transform}">#{markup}</g>)
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
def fmt(n) = format("%g", n.round(4))
|
|
226
|
+
|
|
227
|
+
def inner_markup(root)
|
|
228
|
+
out = +""
|
|
229
|
+
formatter = REXML::Formatters::Default.new
|
|
230
|
+
root.children.each { formatter.write(_1, out) }
|
|
231
|
+
out.strip
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
def each_element(root, &block)
|
|
235
|
+
root.elements.to_a("//*").each { block.call(_1) if _1.parent }
|
|
236
|
+
end
|
|
237
|
+
end
|
|
238
|
+
end
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "net/http"
|
|
4
|
+
require "uri"
|
|
5
|
+
require "zlib"
|
|
6
|
+
require "rubygems/package"
|
|
7
|
+
require "fileutils"
|
|
8
|
+
|
|
9
|
+
module Fontico
|
|
10
|
+
# Providers that publish their own font alongside a name -> codepoint map.
|
|
11
|
+
#
|
|
12
|
+
# This is the only lossless source of outlines for a stroke-based set: the
|
|
13
|
+
# provider has already expanded the strokes for their font build. Every
|
|
14
|
+
# available JS expander traces a raster and visibly degrades the geometry.
|
|
15
|
+
module ProviderFonts
|
|
16
|
+
REGISTRY = {
|
|
17
|
+
"lucide" => {
|
|
18
|
+
package: "lucide-static",
|
|
19
|
+
version: "1.32.0",
|
|
20
|
+
font: "package/font/lucide.ttf",
|
|
21
|
+
codepoints: "package/font/codepoints.json"
|
|
22
|
+
}
|
|
23
|
+
}.freeze
|
|
24
|
+
|
|
25
|
+
module_function
|
|
26
|
+
|
|
27
|
+
def available?(provider) = REGISTRY.key?(provider)
|
|
28
|
+
|
|
29
|
+
def known = REGISTRY.keys
|
|
30
|
+
|
|
31
|
+
# Downloads and unpacks once into the cache; returns local paths.
|
|
32
|
+
def fetch(provider, cache_dir:)
|
|
33
|
+
spec = REGISTRY.fetch(provider) { raise Error, "no known font for provider #{provider}" }
|
|
34
|
+
dir = File.join(cache_dir, "#{spec[:package]}-#{spec[:version]}")
|
|
35
|
+
font = File.join(dir, File.basename(spec[:font]))
|
|
36
|
+
codepoints = File.join(dir, File.basename(spec[:codepoints]))
|
|
37
|
+
return [font, codepoints] if File.exist?(font) && File.exist?(codepoints)
|
|
38
|
+
|
|
39
|
+
FileUtils.mkdir_p(dir)
|
|
40
|
+
unpack(download(spec), spec, font, codepoints)
|
|
41
|
+
[font, codepoints]
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def download(spec)
|
|
45
|
+
url = "https://registry.npmjs.org/#{spec[:package]}/-/#{spec[:package]}-#{spec[:version]}.tgz"
|
|
46
|
+
uri = URI(url)
|
|
47
|
+
res = Net::HTTP.get_response(uri)
|
|
48
|
+
res = Net::HTTP.get_response(URI(res["location"])) if res.is_a?(Net::HTTPRedirection)
|
|
49
|
+
raise Error, "downloading #{url}: HTTP #{res.code}" unless res.is_a?(Net::HTTPSuccess)
|
|
50
|
+
|
|
51
|
+
res.body
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def unpack(tgz, spec, font_out, codepoints_out)
|
|
55
|
+
wanted = { spec[:font] => font_out, spec[:codepoints] => codepoints_out }
|
|
56
|
+
Gem::Package::TarReader.new(Zlib::GzipReader.new(StringIO.new(tgz))) do |tar|
|
|
57
|
+
tar.each do |entry|
|
|
58
|
+
dest = wanted[entry.full_name]
|
|
59
|
+
File.binwrite(dest, entry.read) if dest
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
missing = wanted.reject { File.exist?(_2) }.keys
|
|
63
|
+
raise Error, "#{spec[:package]} did not contain #{missing.join(", ")}" if missing.any?
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rails/railtie"
|
|
4
|
+
|
|
5
|
+
module Fontico
|
|
6
|
+
class Railtie < ::Rails::Railtie
|
|
7
|
+
initializer "fontico.helper" do
|
|
8
|
+
ActiveSupport.on_load(:action_view) { include Fontico::Helper }
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
initializer "fontico.root" do
|
|
12
|
+
Fontico.root = Rails.root.to_s
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
# Saving icons.yml — or a local SVG — rebuilds the artifacts and drops the
|
|
16
|
+
# memoized manifest, so a new icon is live on the next request with no
|
|
17
|
+
# restart and no rake. That memo is the only thing that ever needed one:
|
|
18
|
+
# this gem is not Zeitwerk's to reload, so @manifest outlives a code
|
|
19
|
+
# reload, while Propshaft re-digests the rebuilt sprite on its own in dev.
|
|
20
|
+
initializer "fontico.reloader" do |app|
|
|
21
|
+
next unless app.config.enable_reloading
|
|
22
|
+
|
|
23
|
+
watcher = app.config.file_watcher.new([Fontico.manifest_path], Railtie.local_dirs) do
|
|
24
|
+
Fontico.reset!
|
|
25
|
+
Fontico.rebuild!
|
|
26
|
+
# Recorded rather than raised: a save that half-breaks the manifest
|
|
27
|
+
# must not take down a page that draws none of the broken icons. The
|
|
28
|
+
# ones that do draw them raise, at the call site. See Fontico.check!.
|
|
29
|
+
Rails.logger.error("fontico: #{Fontico.build_error.message}") if Fontico.build_error
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Both halves are load-bearing: Rails runs to_run callbacks only when
|
|
33
|
+
# some registered reloader reports itself updated.
|
|
34
|
+
app.reloaders << watcher
|
|
35
|
+
app.reloader.to_run { watcher.execute_if_updated }
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
rake_tasks { load File.expand_path("../tasks/fontico.rake", __dir__) }
|
|
39
|
+
|
|
40
|
+
# First-party SVGs are sources too, and editing one has to re-preprocess.
|
|
41
|
+
# Asked of the manifest, but never at the cost of the boot: an icons.yml
|
|
42
|
+
# too broken to parse must still come up, because the watcher is what
|
|
43
|
+
# picks up the fix.
|
|
44
|
+
def self.local_dirs
|
|
45
|
+
path = begin
|
|
46
|
+
Fontico.manifest.local_path
|
|
47
|
+
rescue StandardError
|
|
48
|
+
Manifest::LOCAL_PATH
|
|
49
|
+
end
|
|
50
|
+
dir = File.join(Fontico.root, path)
|
|
51
|
+
File.directory?(dir) ? { dir => %w[svg] } : {}
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|