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.
@@ -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