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,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
@@ -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
+ }