Class: Fontico::Outliner

Inherits:
Object
  • Object
show all
Defined in:
lib/fontico/outliner.rb

Overview

Decides where each icon's filled outline comes from, which is the whole difficulty of the font target.

:fill    already filled geometry — used as-is (Material Symbols, flat
       first-party exports, most Iconify sets)
:glyph   stroke-based, but the provider ships a font whose glyphs are
       already expanded — extracted from there, losslessly (Lucide)
:none    stroke-based with no provider font — refused, loudly

There is deliberately no raster-trace fallback. Both published JS expanders (svg-outline-stroke, oslllo-svg-fixer) run the artwork through potrace; corners round off and strokes wobble. Refusing beats shipping geometry that quietly stopped matching the sprite.

Constant Summary collapse

Unsupported =
Class.new(Fontico::Error)

Class Method Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(runner: NodeRunner.new, cache_dir: nil) ⇒ Outliner

Returns a new instance of Outliner.



22
23
24
25
# File 'lib/fontico/outliner.rb', line 22

def initialize(runner: NodeRunner.new, cache_dir: nil)
  @runner = runner
  @cache = cache_dir || File.join(Dir.home, ".cache", "fontico", "fonts")
end

Class Method Details

.stroke_based?(body) ⇒ Boolean

Returns:

  • (Boolean)


27
28
29
# File 'lib/fontico/outliner.rb', line 27

def self.stroke_based?(body)
  body.match?(/stroke\s*=\s*['"](?!none)/) || body.match?(/stroke\s*:\s*(?!none)/)
end

Instance Method Details

#outlines(pairs, size: 24) ⇒ Object

=> { icon_name => path_data } for every :glyph icon, batched per provider so each provider font is downloaded and parsed once.



40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
# File 'lib/fontico/outliner.rb', line 40

def outlines(pairs, size: 24)
  by_provider = pairs.select { |icon, body| strategy_for(icon, body) == :glyph }
                     .group_by { |icon, _| icon.provider }
  return {} if by_provider.empty?

  FileUtils.mkdir_p(@cache)
  by_provider.each_with_object({}) do |(provider, group), out|
    font, codepoints = ProviderFonts.fetch(provider, cache_dir: @cache)
    result = @runner.run("extract_glyphs.mjs", {
      fontPath: font, codepointsPath: codepoints, size: size,
      names: group.map { |icon, _| icon.slug }.uniq
    })

    if result["missing"]&.any?
      raise Unsupported, "#{provider} font has no glyph for: #{result["missing"].join(", ")}"
    end

    group.each { |icon, _| out[icon.name] = result.dig("glyphs", icon.slug) }
  end
end

#refuse(unsupported) ⇒ Object

Raises:



61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
# File 'lib/fontico/outliner.rb', line 61

def refuse(unsupported)
  names = unsupported.map { "#{_1.name} (#{_1.source})" }
  raise Unsupported, <<~MSG
    #{unsupported.size} icon(s) are stroke-based and their provider ships no
    font to take outlines from, so they cannot become glyphs:

      #{names.join("\n  ")}

    A stroked path does not fail loudly in a font — it fills its centreline
    and produces a solid blob. Options:

      * outline the strokes at source (Path > Stroke to Path) for local icons
      * pick the filled variant of the icon from its provider
      * drop `ttf` from targets: and use the sprite, which renders strokes natively

    Providers with extractable fonts: #{ProviderFonts.known.join(", ")}
  MSG
end

#strategy_for(icon, body) ⇒ Object



31
32
33
34
35
36
# File 'lib/fontico/outliner.rb', line 31

def strategy_for(icon, body)
  return :fill unless self.class.stroke_based?(body)
  return :glyph if ProviderFonts.available?(icon.provider)

  :none
end