fontisan 0.4.11 → 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 (40) hide show
  1. checksums.yaml +4 -4
  2. data/README.adoc +27 -1
  3. data/docs/AFDKO_MIGRATION.adoc +175 -0
  4. data/docs/FEATURE_PARITY.adoc +263 -0
  5. data/docs/STITCHER_GUIDE.adoc +100 -6
  6. data/docs/TTX.adoc +176 -0
  7. data/docs/UFO_COMPILATION.adoc +281 -3
  8. data/lib/fontisan/stitcher/source.rb +11 -7
  9. data/lib/fontisan/subset/table_subsetter.rb +104 -6
  10. data/lib/fontisan/svg/standalone_glyph.rb +129 -0
  11. data/lib/fontisan/svg.rb +1 -0
  12. data/lib/fontisan/ufo/cli.rb +34 -21
  13. data/lib/fontisan/ufo/compile/feature_writers/base.rb +56 -0
  14. data/lib/fontisan/ufo/compile/feature_writers/curs.rb +72 -0
  15. data/lib/fontisan/ufo/compile/feature_writers/gdef.rb +90 -0
  16. data/lib/fontisan/ufo/compile/feature_writers/kern.rb +51 -0
  17. data/lib/fontisan/ufo/compile/feature_writers/kern2.rb +57 -0
  18. data/lib/fontisan/ufo/compile/feature_writers/mark.rb +45 -0
  19. data/lib/fontisan/ufo/compile/feature_writers/mark_family_base.rb +122 -0
  20. data/lib/fontisan/ufo/compile/feature_writers/mkmk.rb +53 -0
  21. data/lib/fontisan/ufo/compile/feature_writers.rb +37 -0
  22. data/lib/fontisan/ufo/compile/filters/propagate_anchors.rb +102 -0
  23. data/lib/fontisan/ufo/compile/filters/remove_overlaps.rb +94 -0
  24. data/lib/fontisan/ufo/compile/filters/sort_contours.rb +69 -0
  25. data/lib/fontisan/ufo/compile/filters/transformations.rb +113 -0
  26. data/lib/fontisan/ufo/compile/filters.rb +12 -0
  27. data/lib/fontisan/ufo/compile.rb +1 -0
  28. data/lib/fontisan/ufo/convert/to_dfont.rb +41 -0
  29. data/lib/fontisan/ufo/convert/to_otc.rb +37 -0
  30. data/lib/fontisan/ufo/convert/to_otf.rb +18 -0
  31. data/lib/fontisan/ufo/convert/to_otf2.rb +20 -0
  32. data/lib/fontisan/ufo/convert/to_postscript.rb +59 -0
  33. data/lib/fontisan/ufo/convert/to_ttc.rb +35 -0
  34. data/lib/fontisan/ufo/convert/to_ttf.rb +21 -0
  35. data/lib/fontisan/ufo/convert/to_woff.rb +56 -0
  36. data/lib/fontisan/ufo/convert/to_woff2.rb +45 -0
  37. data/lib/fontisan/ufo/convert.rb +68 -5
  38. data/lib/fontisan/ufo/transformation.rb +11 -0
  39. data/lib/fontisan/version.rb +1 -1
  40. metadata +27 -1
@@ -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
@@ -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
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Fontisan
4
+ module Ufo
5
+ module Compile
6
+ module FeatureWriters
7
+ # Extended kerning (format 2 / Device-table variant of PairPos).
8
+ #
9
+ # Same data shape as the basic {Kern} writer, but emits the
10
+ # extended format that supports:
11
+ # - device tables (sub-pixel kerning)
12
+ # - cross-stream kerning (y-direction adjustments)
13
+ # - per-pair value-format overrides
14
+ #
15
+ # The current implementation extracts the same kerning.pairs
16
+ # data as Kern; the differentiation is in how the GPOS table
17
+ # builder encodes the value format (Format1 vs Format2, plus
18
+ # device tables). Writers do not need to know about that —
19
+ # they just emit the structured data; the table builder
20
+ # picks the encoding.
21
+ #
22
+ # Downstream consumers (compilers) choose between Kern and
23
+ # Kern2 based on whether they need device tables. Most don't;
24
+ # basic Kern is sufficient for the common case.
25
+ class Kern2 < Base
26
+ LOOKUP_TYPE = 2
27
+ FEATURE_TAG = "kern"
28
+ TABLE_TAG = "GPOS"
29
+ FORMAT = 2
30
+
31
+ # @return [FeatureOutput, nil]
32
+ def write
33
+ return nil if font.kerning.nil? || font.kerning.empty?
34
+
35
+ pairs = font.kerning.pairs.map do |key, value|
36
+ left, right = key.to_s.split(" ", 2)
37
+ {
38
+ left: left,
39
+ right: right,
40
+ value: value.to_f,
41
+ group_left: left.to_s.start_with?("@"),
42
+ group_right: right.to_s.start_with?("@"),
43
+ }
44
+ end
45
+
46
+ FeatureOutput.new(
47
+ table_tag: TABLE_TAG,
48
+ feature_tag: FEATURE_TAG,
49
+ lookup_type: LOOKUP_TYPE,
50
+ data: { format: FORMAT, pairs: pairs },
51
+ )
52
+ end
53
+ end
54
+ end
55
+ end
56
+ end
57
+ end
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Fontisan
4
+ module Ufo
5
+ module Compile
6
+ module FeatureWriters
7
+ # GPOS MarkToBase (lookup type 4) + MarkToLigature (type 5).
8
+ #
9
+ # Builds mark attachments from the UFO's per-glyph anchors.
10
+ # Pair every mark glyph (one whose anchors include a
11
+ # +_<name>+ entry, e.g. +_top+) with every base glyph that
12
+ # has the matching non-underscore anchor (e.g. +top+).
13
+ #
14
+ # Prerequisite: {Filters::PropagateAnchors} should have run
15
+ # first so composite glyphs carry their inherited anchors.
16
+ class Mark < MarkFamilyBase
17
+ LOOKUP_TYPE = 4
18
+ FEATURE_TAG = "mark"
19
+
20
+ private
21
+
22
+ def feature_tag
23
+ FEATURE_TAG
24
+ end
25
+
26
+ def lookup_type
27
+ LOOKUP_TYPE
28
+ end
29
+
30
+ # Mark convention: _<name> → class_name = name (drop underscore).
31
+ def mark_class_from_anchor(anchor_name)
32
+ return nil unless anchor_name.start_with?("_")
33
+
34
+ anchor_name[1..]
35
+ end
36
+
37
+ # Base convention: class_name is the anchor name verbatim.
38
+ def base_anchor_name_for(class_name)
39
+ class_name
40
+ end
41
+ end
42
+ end
43
+ end
44
+ end
45
+ end