fontisan 0.4.10 → 0.4.12

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.
Files changed (47) hide show
  1. checksums.yaml +4 -4
  2. data/README.adoc +27 -1
  3. data/Rakefile +53 -39
  4. data/docs/AFDKO_MIGRATION.adoc +175 -0
  5. data/docs/FEATURE_PARITY.adoc +263 -0
  6. data/docs/STITCHER_GUIDE.adoc +100 -6
  7. data/docs/TTX.adoc +176 -0
  8. data/docs/UFO_COMPILATION.adoc +281 -3
  9. data/lib/fontisan/stitcher/partition_strategy/by_block.rb +429 -0
  10. data/lib/fontisan/stitcher/partition_strategy/by_script.rb +305 -0
  11. data/lib/fontisan/stitcher/partition_strategy.rb +2 -0
  12. data/lib/fontisan/stitcher/source.rb +11 -7
  13. data/lib/fontisan/subset/table_subsetter.rb +227 -11
  14. data/lib/fontisan/svg/standalone_glyph.rb +129 -0
  15. data/lib/fontisan/svg.rb +1 -0
  16. data/lib/fontisan/tasks/fixture_downloader.rb +162 -0
  17. data/lib/fontisan/tasks.rb +11 -0
  18. data/lib/fontisan/ufo/cli.rb +34 -21
  19. data/lib/fontisan/ufo/compile/feature_writers/base.rb +56 -0
  20. data/lib/fontisan/ufo/compile/feature_writers/curs.rb +72 -0
  21. data/lib/fontisan/ufo/compile/feature_writers/gdef.rb +90 -0
  22. data/lib/fontisan/ufo/compile/feature_writers/kern.rb +51 -0
  23. data/lib/fontisan/ufo/compile/feature_writers/kern2.rb +57 -0
  24. data/lib/fontisan/ufo/compile/feature_writers/mark.rb +45 -0
  25. data/lib/fontisan/ufo/compile/feature_writers/mark_family_base.rb +122 -0
  26. data/lib/fontisan/ufo/compile/feature_writers/mkmk.rb +53 -0
  27. data/lib/fontisan/ufo/compile/feature_writers.rb +37 -0
  28. data/lib/fontisan/ufo/compile/filters/propagate_anchors.rb +102 -0
  29. data/lib/fontisan/ufo/compile/filters/remove_overlaps.rb +94 -0
  30. data/lib/fontisan/ufo/compile/filters/sort_contours.rb +69 -0
  31. data/lib/fontisan/ufo/compile/filters/transformations.rb +113 -0
  32. data/lib/fontisan/ufo/compile/filters.rb +12 -0
  33. data/lib/fontisan/ufo/compile.rb +1 -0
  34. data/lib/fontisan/ufo/convert/to_dfont.rb +41 -0
  35. data/lib/fontisan/ufo/convert/to_otc.rb +37 -0
  36. data/lib/fontisan/ufo/convert/to_otf.rb +18 -0
  37. data/lib/fontisan/ufo/convert/to_otf2.rb +20 -0
  38. data/lib/fontisan/ufo/convert/to_postscript.rb +59 -0
  39. data/lib/fontisan/ufo/convert/to_ttc.rb +35 -0
  40. data/lib/fontisan/ufo/convert/to_ttf.rb +21 -0
  41. data/lib/fontisan/ufo/convert/to_woff.rb +56 -0
  42. data/lib/fontisan/ufo/convert/to_woff2.rb +45 -0
  43. data/lib/fontisan/ufo/convert.rb +68 -5
  44. data/lib/fontisan/ufo/transformation.rb +11 -0
  45. data/lib/fontisan/version.rb +1 -1
  46. data/lib/fontisan.rb +1 -0
  47. metadata +32 -2
@@ -0,0 +1,129 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Fontisan
4
+ module Svg
5
+ # Renders a single UFO glyph as a standalone SVG document (a
6
+ # `<svg>` root with one `<path>` and a viewBox sized to the
7
+ # glyph's bounding box). Distinct from {FontGenerator}, which
8
+ # emits a `<svg><defs><font>` document covering every glyph.
9
+ #
10
+ # Used by the `fontisan ufo extract` CLI command and by
11
+ # downstream consumers that want per-glyph SVG exports without
12
+ # having to slice up a font-format SVG themselves.
13
+ #
14
+ # Path rendering supports quadratic and cubic Bezier curves
15
+ # (UFO's standard outline model). Curve logic is inlined here
16
+ # rather than reaching into GlyphGenerator's private path code
17
+ # — that logic is font-format-specific (Y-axis flip via
18
+ # ViewBoxCalculator) and doesn't cleanly factor out.
19
+ class StandaloneGlyph
20
+ # @param units_per_em [Integer] font units-per-em (default 1000)
21
+ # @param ascent [Integer] font ascender in font units (default 800)
22
+ # @param descent [Integer] font descender in font units (default -200)
23
+ def initialize(units_per_em: 1000, ascent: 800, descent: -200)
24
+ @units_per_em = units_per_em.to_i
25
+ @ascent = ascent.to_i
26
+ @descent = descent.to_i
27
+ end
28
+
29
+ # @param glyph [Fontisan::Ufo::Glyph]
30
+ # @return [String] standalone SVG XML
31
+ def generate(glyph)
32
+ path_data = path_data_for(glyph)
33
+ view_box = view_box_for(glyph)
34
+
35
+ <<~SVG
36
+ <?xml version="1.0" encoding="UTF-8"?>
37
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="#{view_box}">
38
+ <path d="#{path_data}"/>
39
+ </svg>
40
+ SVG
41
+ end
42
+
43
+ private
44
+
45
+ def path_data_for(glyph)
46
+ return "" if glyph.contours.empty?
47
+
48
+ glyph.contours.filter_map { |c| contour_path(c) }.join(" ")
49
+ end
50
+
51
+ # Emit an SVG path for one contour with Y-axis flip applied.
52
+ # Walks points left-to-right; collects off-curve runs and
53
+ # flushes them as a Bezier curve (Q for 1 control point, C
54
+ # for 2). Single on-curve points become line segments.
55
+ def contour_path(contour)
56
+ points = contour.points
57
+ return nil if points.empty?
58
+
59
+ parts = []
60
+ first = points.first
61
+ parts << "M #{first.x} #{flip_y(first.y)}"
62
+
63
+ i = 1
64
+ while i < points.length
65
+ if points[i].on_curve?
66
+ parts << "L #{points[i].x} #{flip_y(points[i].y)}"
67
+ i += 1
68
+ else
69
+ # Collect consecutive off-curve points (1 → quadratic,
70
+ # 2 → cubic, 3+ → rare; treat as multiple quadratics).
71
+ controls = []
72
+ while i < points.length && !points[i].on_curve?
73
+ controls << points[i]
74
+ i += 1
75
+ end
76
+ end_pt = i < points.length ? points[i] : first
77
+ i += 1 if i < points.length
78
+
79
+ parts << curve_segment(controls, end_pt)
80
+ end
81
+ end
82
+
83
+ parts << "Z"
84
+ parts.join(" ")
85
+ end
86
+
87
+ def curve_segment(controls, end_pt)
88
+ end_x = end_pt.x
89
+ end_y = flip_y(end_pt.y)
90
+
91
+ case controls.size
92
+ when 1
93
+ "Q #{controls[0].x} #{flip_y(controls[0].y)} #{end_x} #{end_y}"
94
+ when 2
95
+ "C #{controls[0].x} #{flip_y(controls[0].y)} " \
96
+ "#{controls[1].x} #{flip_y(controls[1].y)} " \
97
+ "#{end_x} #{end_y}"
98
+ else
99
+ # 3+ controls: emit a Q to the midpoint of the first two,
100
+ # then continue. Rare in practice; degrades to Q chain.
101
+ mid_x = (controls[0].x + controls[1].x) / 2.0
102
+ mid_y = flip_y((controls[0].y + controls[1].y) / 2.0)
103
+ "Q #{controls[0].x} #{flip_y(controls[0].y)} #{mid_x} #{mid_y}"
104
+ end
105
+ end
106
+
107
+ def flip_y(y)
108
+ @ascent - y.to_f
109
+ end
110
+
111
+ def view_box_for(glyph)
112
+ bbox = bounding_box(glyph)
113
+ format("%<xmin>s %<ymin>s %<w>s %<h>s",
114
+ xmin: bbox[:x_min], ymin: bbox[:y_min],
115
+ w: (bbox[:x_max] - bbox[:x_min]),
116
+ h: (bbox[:y_max] - bbox[:y_min]))
117
+ end
118
+
119
+ def bounding_box(glyph)
120
+ points = glyph.contours.flat_map(&:points)
121
+ return { x_min: 0, y_min: 0, x_max: @units_per_em, y_max: @units_per_em } if points.empty?
122
+
123
+ xs = points.map(&:x)
124
+ ys = points.map(&:y)
125
+ { x_min: xs.min, y_min: ys.min, x_max: xs.max, y_max: ys.max }
126
+ end
127
+ end
128
+ end
129
+ end
data/lib/fontisan/svg.rb CHANGED
@@ -7,6 +7,7 @@ module Fontisan
7
7
  autoload :FontFaceGenerator, "fontisan/svg/font_face_generator"
8
8
  autoload :FontGenerator, "fontisan/svg/font_generator"
9
9
  autoload :GlyphGenerator, "fontisan/svg/glyph_generator"
10
+ autoload :StandaloneGlyph, "fontisan/svg/standalone_glyph"
10
11
  autoload :ViewBoxCalculator, "fontisan/svg/view_box_calculator"
11
12
  end
12
13
  end
@@ -0,0 +1,162 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "open-uri"
4
+ require "net/http"
5
+ require "fileutils"
6
+
7
+ module Fontisan
8
+ # Tasks supporting the developer workflow: fixture downloads, etc.
9
+ # Lives under its own namespace so Rakefiles and other tooling can
10
+ # load just the task plumbing without pulling in the full fontisan
11
+ # stack (BinData tables, UFO, etc.).
12
+ module Tasks
13
+ # Downloads a single fixture file with retry on transient network
14
+ # failures. Used by `rake fixtures:download` so a single CDN blip
15
+ # (5xx, connection reset, OpenTimeout) doesn't sink a fresh
16
+ # checkout. Permanent failures (404, malformed URL) surface
17
+ # immediately.
18
+ #
19
+ # The downloader is a focused class, not a procedural Rakefile
20
+ # patch, so the retry logic is unit-testable in isolation.
21
+ #
22
+ # @example
23
+ # Fontisan::Tasks::FixtureDownloader.new(
24
+ # url: "https://github.com/.../font.ttf",
25
+ # destination: "spec/fixtures/font.ttf",
26
+ # ).call
27
+ class FixtureDownloader
28
+ RETRIABLE_ERRORS = [
29
+ Net::OpenTimeout,
30
+ Net::ReadTimeout,
31
+ Errno::ECONNRESET,
32
+ Errno::ECONNREFUSED,
33
+ Errno::EHOSTUNREACH,
34
+ Errno::ETIMEDOUT,
35
+ EOFError,
36
+ IOError,
37
+ ].freeze
38
+
39
+ # 5xx HTTP responses are transient server errors worth retrying.
40
+ # 4xx are permanent (404, 403) and must fail fast.
41
+ RETRIABLE_HTTP_STATUSES = (500..599)
42
+
43
+ DEFAULT_MAX_RETRIES = 3
44
+ DEFAULT_BASE_BACKOFF = 0.5 # seconds; doubles per attempt
45
+
46
+ # Error raised after exhausting all retries. Carries the last
47
+ # underlying exception so callers can log the root cause.
48
+ class Error < StandardError
49
+ attr_reader :last_error
50
+
51
+ def initialize(url:, attempts:, last_error:)
52
+ @last_error = last_error
53
+ super("Failed to download #{url} after #{attempts} attempts: " \
54
+ "#{last_error.class}: #{last_error.message}")
55
+ end
56
+ end
57
+
58
+ attr_reader :url, :destination, :max_retries, :base_backoff, :sleep_method
59
+
60
+ # @param url [String] source URL.
61
+ # @param destination [String] path to write bytes to. Parent dir
62
+ # is auto-created.
63
+ # @param max_retries [Integer] total attempts including the
64
+ # first. 3 means: try, retry, retry.
65
+ # @param base_backoff [Float] seconds to sleep before the first
66
+ # retry. Doubles per attempt.
67
+ # @param sleep_method [#call] injectable sleep (for tests).
68
+ # Defaults to Kernel.sleep.
69
+ def initialize(url:, destination:, max_retries: DEFAULT_MAX_RETRIES,
70
+ base_backoff: DEFAULT_BASE_BACKOFF, sleep_method: method(:sleep))
71
+ @url = url
72
+ @destination = destination
73
+ @max_retries = max_retries
74
+ @base_backoff = base_backoff
75
+ @sleep_method = sleep_method
76
+ end
77
+
78
+ # Performs the download. Returns the destination path on
79
+ # success. Raises {Error} after exhausting retries.
80
+ #
81
+ # @return [String] destination path
82
+ # @raise [Error]
83
+ def call
84
+ attempts = 0
85
+ nil
86
+
87
+ begin
88
+ attempts += 1
89
+ fetch_to_destination
90
+ destination
91
+ rescue StandardError => e
92
+ e
93
+ raise if permanent_failure?(e)
94
+ raise Error.new(url: url, attempts: attempts, last_error: e) if attempts >= max_retries
95
+
96
+ backoff = base_backoff * (2**(attempts - 1))
97
+ sleep_method.call(backoff)
98
+ retry
99
+ end
100
+ end
101
+
102
+ private
103
+
104
+ def fetch_to_destination
105
+ FileUtils.mkdir_p(File.dirname(destination))
106
+
107
+ # IO.copy_stream avoids loading the whole response into memory
108
+ # and is more Windows-compatible than remote.read + File.binwrite.
109
+ # URLs come from FixtureFonts config (version-controlled), not
110
+ # user input — same trust model as the previous inline URI.open
111
+ # call in the Rakefile.
112
+ #
113
+ # Parsing with URI.parse first satisfies CodeQL's "open with
114
+ # non-constant value" check: any string that isn't a valid URI
115
+ # raises URI::InvalidURIError before OpenURI can dispatch on
116
+ # it. The parsed URI's .open is OpenURI's standard entry.
117
+ # rubocop:disable Security/Open
118
+ URI.parse(url).open(open_uri_options) do |remote|
119
+ File.open(destination, "wb") do |file|
120
+ IO.copy_stream(remote, file)
121
+ end
122
+ end
123
+ # rubocop:enable Security/Open
124
+ end
125
+
126
+ # `open-uri` follows redirects by default and surfaces HTTP
127
+ # errors as `OpenURI::HTTPError` whose `io.status` is the `[code,
128
+ # message]` array. We re-raise non-retriable 4xx as
129
+ # `permanent-failure`-tagged exceptions so the retry loop exits.
130
+ def open_uri_options
131
+ {
132
+ "User-Agent" => "fontisan-fixtures/1.0",
133
+ redirect: true,
134
+ open_timeout: 30,
135
+ read_timeout: 120,
136
+ }
137
+ end
138
+
139
+ def permanent_failure?(error)
140
+ case error
141
+ when OpenURI::HTTPError
142
+ status = parse_http_status(error)
143
+ status && !RETRIABLE_HTTP_STATUSES.cover?(status)
144
+ else
145
+ RETRIABLE_ERRORS.none? { |klass| error.is_a?(klass) }
146
+ end
147
+ end
148
+
149
+ def parse_http_status(error)
150
+ io = error.io
151
+ return nil unless io
152
+
153
+ status = io.status
154
+ return nil unless status
155
+
156
+ status.first.to_i
157
+ rescue StandardError
158
+ nil
159
+ end
160
+ end
161
+ end
162
+ end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Fontisan
4
+ # Tasks supporting the developer workflow: fixture downloads, etc.
5
+ # Lives under its own namespace so Rakefiles and other tooling can
6
+ # load just the task plumbing without pulling in the full fontisan
7
+ # stack (BinData tables, UFO, etc.).
8
+ module Tasks
9
+ autoload :FixtureDownloader, "fontisan/tasks/fixture_downloader"
10
+ end
11
+ end
@@ -14,20 +14,15 @@ module Fontisan
14
14
  method_option :output, type: :string, required: true,
15
15
  desc: "Output file path"
16
16
  method_option :to, type: :string, default: "ttf",
17
- desc: "Output format (ttf or otf)"
17
+ desc: "Output format (ttf, otf, or otf2)"
18
18
  def build(ufo)
19
19
  font = Font.open(ufo)
20
- format_sym = (options[:to] || "ttf").to_s.downcase.to_sym
21
- compiler =
22
- case format_sym
23
- when :ttf then Compile::TtfCompiler
24
- when :otf then Compile::OtfCompiler
25
- else
26
- warn "unknown format: #{options[:to].inspect}"
27
- exit 1
28
- end
29
- compiler.new(font).compile(output_path: options[:output])
20
+ format = (options[:to] || "ttf").to_s.downcase.to_sym
21
+ Convert.convert(font, to: format, output_path: options[:output])
30
22
  puts "wrote #{options[:output]} (#{File.size(options[:output])} bytes)"
23
+ rescue ArgumentError => e
24
+ warn e.message
25
+ exit 1
31
26
  rescue Errno::ENOENT
32
27
  warn "UFO not found: #{ufo}"
33
28
  exit 1
@@ -35,20 +30,12 @@ module Fontisan
35
30
 
36
31
  desc "convert INPUT OUTPUT", "Convert between UFO and binary formats"
37
32
  method_option :to, type: :string,
38
- desc: "Override format detection (ttf, otf, ufo)"
33
+ desc: "Override format detection (ttf, otf, otf2, ufo)"
39
34
  def convert(input, output)
40
35
  if ufo?(input)
41
36
  font = Font.open(input)
42
37
  format = options[:to] || File.extname(output).delete(".").downcase
43
- compiler =
44
- case format.to_sym
45
- when :ttf then Compile::TtfCompiler
46
- when :otf then Compile::OtfCompiler
47
- else
48
- warn "unsupported output format: #{format.inspect}"
49
- exit 1
50
- end
51
- compiler.new(font).compile(output_path: output)
38
+ Convert.convert(font, to: format.to_sym, output_path: output)
52
39
  else
53
40
  # Binary → UFO
54
41
  loaded = Fontisan::FontLoader.load(input)
@@ -56,6 +43,9 @@ module Fontisan
56
43
  Writer.new(ufo).write(output)
57
44
  end
58
45
  puts "wrote #{output}"
46
+ rescue ArgumentError => e
47
+ warn e.message
48
+ exit 1
59
49
  end
60
50
 
61
51
  desc "validate UFO", "Check a UFO source for structural issues"
@@ -75,6 +65,29 @@ module Fontisan
75
65
  end
76
66
  end
77
67
 
68
+ desc "extract UFO GLYPH_NAME OUTPUT.svg",
69
+ "Render a single glyph from a UFO as a standalone SVG"
70
+ def extract(ufo, glyph_name, output)
71
+ font = Font.open(ufo)
72
+ glyph = font.glyph(glyph_name)
73
+ raise ArgumentError, "glyph not found: #{glyph_name.inspect}" unless glyph
74
+
75
+ head = font.info
76
+ renderer = Fontisan::Svg::StandaloneGlyph.new(
77
+ units_per_em: head.units_per_em || 1000,
78
+ ascent: head.ascender || 800,
79
+ descent: head.descender || -200,
80
+ )
81
+ File.write(output, renderer.generate(glyph))
82
+ puts "wrote #{output} (#{File.size(output)} bytes)"
83
+ rescue ArgumentError => e
84
+ warn e.message
85
+ exit 1
86
+ rescue Errno::ENOENT
87
+ warn "UFO not found: #{ufo}"
88
+ exit 1
89
+ end
90
+
78
91
  private
79
92
 
80
93
  def ufo?(path)
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Fontisan
4
+ module Ufo
5
+ module Compile
6
+ module FeatureWriters
7
+ # Value object returned by every feature writer's +#write+
8
+ # method. Carries the structured data the corresponding
9
+ # table builder needs to encode the binary subtable.
10
+ #
11
+ # Writers return +nil+ instead when the UFO has no data for
12
+ # the feature — the compiler skips +nil+ outputs.
13
+ FeatureOutput = Struct.new(
14
+ :table_tag, # "GPOS", "GSUB", "GDEF"
15
+ :feature_tag, # "kern", "mark", "curs", etc. (nil for GDEF)
16
+ :lookup_type, # Integer GSUB/GPOS lookup type
17
+ :data, # Hash — writer-specific structure
18
+ keyword_init: true,
19
+ ) do
20
+ def table_tag
21
+ self[:table_tag]
22
+ end
23
+
24
+ def feature_tag
25
+ self[:feature_tag]
26
+ end
27
+
28
+ def lookup_type
29
+ self[:lookup_type]
30
+ end
31
+
32
+ def data
33
+ self[:data]
34
+ end
35
+ end
36
+
37
+ # Abstract base class for feature writers. Concrete writers
38
+ # (Kern, Gdef, Mark, etc.) extend this and implement +#write+.
39
+ class Base
40
+ attr_reader :font
41
+
42
+ # @param font [Fontisan::Ufo::Font] the UFO source
43
+ def initialize(font)
44
+ @font = font
45
+ end
46
+
47
+ # @return [FeatureOutput, nil] structured data for the
48
+ # feature, or +nil+ if the UFO has nothing for it
49
+ def write
50
+ raise NotImplementedError, "#{self.class} must implement #write"
51
+ end
52
+ end
53
+ end
54
+ end
55
+ end
56
+ end
@@ -0,0 +1,72 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Fontisan
4
+ module Ufo
5
+ module Compile
6
+ module FeatureWriters
7
+ # GPOS Cursive (lookup type 3) — cursive attachment.
8
+ #
9
+ # Pairs glyphs via their entry and exit anchors for cursive
10
+ # (joining) scripts. UFO convention: a glyph's +<name>entry+
11
+ # and +<name>exit+ anchors define where it connects to its
12
+ # neighbors.
13
+ #
14
+ # Pairs every glyph that has at least one +entry+ anchor with
15
+ # every glyph that has the corresponding +exit+ anchor.
16
+ # Multiple entry/exit class names are supported.
17
+ class Curs < Base
18
+ LOOKUP_TYPE = 3
19
+ FEATURE_TAG = "curs"
20
+ TABLE_TAG = "GPOS"
21
+
22
+ # @return [FeatureOutput, nil]
23
+ def write
24
+ attachments = {}
25
+
26
+ entry_glyphs = glyphs_with_anchor(/entry\z/)
27
+ exit_glyphs = glyphs_with_anchor(/exit\z/)
28
+
29
+ return nil if entry_glyphs.empty? && exit_glyphs.empty?
30
+
31
+ # Cursive attachment: each glyph can be both a "previous"
32
+ # (with exit anchor) and "next" (with entry anchor) in
33
+ # a cursive chain. The GPOS builder handles the
34
+ # cross-references; we emit every glyph with its entry
35
+ # and/or exit anchor positions.
36
+ font.glyphs.each_value do |glyph|
37
+ entry = find_anchor(glyph, /entry\z/)
38
+ exit = find_anchor(glyph, /exit\z/)
39
+ next unless entry || exit
40
+
41
+ attachments[glyph.name] = {
42
+ entry: entry && [entry.x, entry.y],
43
+ exit: exit && [exit.x, exit.y],
44
+ }
45
+ end
46
+
47
+ return nil if attachments.empty?
48
+
49
+ FeatureOutput.new(
50
+ table_tag: TABLE_TAG,
51
+ feature_tag: FEATURE_TAG,
52
+ lookup_type: LOOKUP_TYPE,
53
+ data: { attachments: attachments },
54
+ )
55
+ end
56
+
57
+ private
58
+
59
+ def glyphs_with_anchor(pattern)
60
+ font.glyphs.select do |_name, glyph|
61
+ glyph.anchors.any? { |a| pattern.match?(a.name.to_s) }
62
+ end
63
+ end
64
+
65
+ def find_anchor(glyph, pattern)
66
+ glyph.anchors.find { |a| pattern.match?(a.name.to_s) }
67
+ end
68
+ end
69
+ end
70
+ end
71
+ end
72
+ end
@@ -0,0 +1,90 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Fontisan
4
+ module Ufo
5
+ module Compile
6
+ module FeatureWriters
7
+ # GDEF GlyphClassDef — classifies every glyph into one of
8
+ # the four OpenType glyph classes:
9
+ #
10
+ # 1 — Base glyph
11
+ # 2 — Ligature glyph
12
+ # 3 — Combining mark
13
+ # 4 — Glyph spacing variant (e.g. alternate space)
14
+ #
15
+ # The classification drives how GSUB/GPOS lookups apply. UFO
16
+ # sources carry this in the glyph's +lib+ plist under the
17
+ # +public.openTypeCategory+ key; this writer translates that
18
+ # into the structured form the GDEF builder expects.
19
+ class Gdef < Base
20
+ TABLE_TAG = "GDEF"
21
+
22
+ # Map UFO public.openTypeCategory values → OpenType glyph
23
+ # class integers. Glyphs whose category isn't listed get
24
+ # class 0 (unclassified — the GDEF ClassDef omits them).
25
+ CATEGORY_TO_CLASS = {
26
+ "base" => 1,
27
+ "ligature" => 2,
28
+ "mark" => 3,
29
+ "component" => 3, # UFO treats component as mark for GDEF
30
+ "spacing" => 4,
31
+ }.freeze
32
+
33
+ # @return [FeatureOutput]
34
+ def write
35
+ classifications = {}
36
+
37
+ font.glyphs.each_value do |glyph|
38
+ cls = classify(glyph)
39
+ classifications[glyph.name] = cls if cls
40
+ end
41
+
42
+ # A GDEF without any classified glyphs is meaningless —
43
+ # return nil so the compiler skips the table.
44
+ return nil if classifications.empty?
45
+
46
+ FeatureOutput.new(
47
+ table_tag: TABLE_TAG,
48
+ feature_tag: nil,
49
+ lookup_type: nil,
50
+ data: { classes: classifications },
51
+ )
52
+ end
53
+
54
+ private
55
+
56
+ # Resolve a glyph's GDEF class. Prefer the explicit
57
+ # +public.openTypeCategory+ lib entry; fall back to a
58
+ # heuristic (glyphs with anchors named "top"/"bottom" are
59
+ # marks; glyphs with component references are ligatures
60
+ # only if their name has a +.liga+ suffix).
61
+ def classify(glyph)
62
+ category = glyph_lib_category(glyph)
63
+ return CATEGORY_TO_CLASS[category] if category && CATEGORY_TO_CLASS.key?(category)
64
+
65
+ heuristic_class(glyph)
66
+ end
67
+
68
+ def glyph_lib_category(glyph)
69
+ return nil unless glyph.lib
70
+
71
+ lib = glyph.lib
72
+ return lib["public.openTypeCategory"] if lib["public.openTypeCategory"]
73
+
74
+ categories = lib["public.openTypeCategories"]
75
+ categories[glyph.name] if categories.is_a?(Hash)
76
+ end
77
+
78
+ def heuristic_class(glyph)
79
+ anchor_names = glyph.anchors.map(&:name)
80
+ mark_anchor_pattern = /\A_[a-zA-Z]+\z/
81
+ return 3 if anchor_names.any? { |n| mark_anchor_pattern.match?(n.to_s) }
82
+ return 2 if glyph.name.to_s.end_with?(".liga")
83
+
84
+ nil
85
+ end
86
+ end
87
+ end
88
+ end
89
+ end
90
+ end
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Fontisan
4
+ module Ufo
5
+ module Compile
6
+ module FeatureWriters
7
+ # GPOS PairPos (lookup type 2) — kerning pairs.
8
+ #
9
+ # Reads +font.kerning+ (parsed from kerning.plist) and emits
10
+ # a {FeatureOutput} whose +data+ is a structured list of
11
+ # pairs ready for the GPOS PairPos builder. Returns +nil+
12
+ # when the UFO has no kerning data — the compiler then
13
+ # omits the GPOS table entirely.
14
+ #
15
+ # Group-based kerning keys (e.g. +"@MMK_L_A @MMK_R_B"+) are
16
+ # emitted verbatim with +group: true+; the GPOS builder is
17
+ # responsible for resolving group references via the UFO's
18
+ # groups.plist data (TODO: groups.plist support is partial).
19
+ class Kern < Base
20
+ # GPOS lookup type 2 — PairPos (pair-positioning).
21
+ LOOKUP_TYPE = 2
22
+ FEATURE_TAG = "kern"
23
+ TABLE_TAG = "GPOS"
24
+
25
+ # @return [FeatureOutput, nil]
26
+ def write
27
+ return nil if font.kerning.nil? || font.kerning.empty?
28
+
29
+ pairs = font.kerning.pairs.map do |key, value|
30
+ left, right = key.to_s.split(" ", 2)
31
+ {
32
+ left: left,
33
+ right: right,
34
+ value: value.to_f,
35
+ group_left: left.to_s.start_with?("@"),
36
+ group_right: right.to_s.start_with?("@"),
37
+ }
38
+ end
39
+
40
+ FeatureOutput.new(
41
+ table_tag: TABLE_TAG,
42
+ feature_tag: FEATURE_TAG,
43
+ lookup_type: LOOKUP_TYPE,
44
+ data: { pairs: pairs },
45
+ )
46
+ end
47
+ end
48
+ end
49
+ end
50
+ end
51
+ end