fontisan 0.4.11 → 0.4.13

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 (49) 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/converters/woff2_encoder.rb +147 -242
  9. data/lib/fontisan/sfnt_font.rb +7 -2
  10. data/lib/fontisan/stitcher/source.rb +11 -7
  11. data/lib/fontisan/subset/table_subsetter.rb +104 -6
  12. data/lib/fontisan/svg/standalone_glyph.rb +129 -0
  13. data/lib/fontisan/svg.rb +1 -0
  14. data/lib/fontisan/tables/head.rb +10 -0
  15. data/lib/fontisan/ufo/cli.rb +34 -21
  16. data/lib/fontisan/ufo/compile/feature_writers/base.rb +56 -0
  17. data/lib/fontisan/ufo/compile/feature_writers/curs.rb +72 -0
  18. data/lib/fontisan/ufo/compile/feature_writers/gdef.rb +90 -0
  19. data/lib/fontisan/ufo/compile/feature_writers/kern.rb +51 -0
  20. data/lib/fontisan/ufo/compile/feature_writers/kern2.rb +57 -0
  21. data/lib/fontisan/ufo/compile/feature_writers/mark.rb +45 -0
  22. data/lib/fontisan/ufo/compile/feature_writers/mark_family_base.rb +122 -0
  23. data/lib/fontisan/ufo/compile/feature_writers/mkmk.rb +53 -0
  24. data/lib/fontisan/ufo/compile/feature_writers.rb +37 -0
  25. data/lib/fontisan/ufo/compile/filters/propagate_anchors.rb +102 -0
  26. data/lib/fontisan/ufo/compile/filters/remove_overlaps.rb +94 -0
  27. data/lib/fontisan/ufo/compile/filters/sort_contours.rb +69 -0
  28. data/lib/fontisan/ufo/compile/filters/transformations.rb +113 -0
  29. data/lib/fontisan/ufo/compile/filters.rb +12 -0
  30. data/lib/fontisan/ufo/compile.rb +1 -0
  31. data/lib/fontisan/ufo/convert/to_dfont.rb +41 -0
  32. data/lib/fontisan/ufo/convert/to_otc.rb +37 -0
  33. data/lib/fontisan/ufo/convert/to_otf.rb +18 -0
  34. data/lib/fontisan/ufo/convert/to_otf2.rb +20 -0
  35. data/lib/fontisan/ufo/convert/to_postscript.rb +59 -0
  36. data/lib/fontisan/ufo/convert/to_ttc.rb +35 -0
  37. data/lib/fontisan/ufo/convert/to_ttf.rb +21 -0
  38. data/lib/fontisan/ufo/convert/to_woff.rb +56 -0
  39. data/lib/fontisan/ufo/convert/to_woff2.rb +45 -0
  40. data/lib/fontisan/ufo/convert.rb +68 -5
  41. data/lib/fontisan/ufo/transformation.rb +11 -0
  42. data/lib/fontisan/version.rb +1 -1
  43. data/lib/fontisan/woff2/directory.rb +37 -40
  44. data/lib/fontisan/woff2/encoder_rules.rb +66 -0
  45. data/lib/fontisan/woff2/glyf_loca_transform.rb +323 -0
  46. data/lib/fontisan/woff2/triplet_codec.rb +181 -0
  47. data/lib/fontisan/woff2.rb +3 -0
  48. data/lib/fontisan/woff_font.rb +5 -2
  49. metadata +30 -1
@@ -56,6 +56,8 @@ module Fontisan
56
56
  @options = options
57
57
  @glyf_data = nil
58
58
  @loca_offsets = nil
59
+ @subset_bbox = nil # [xMin, yMin, xMax, yMax] over actual subset glyphs
60
+ @subset_max_advance = 0 # largest advanceWidth in subset hmtx
59
61
  end
60
62
 
61
63
  # Subset a table by tag
@@ -110,10 +112,12 @@ module Fontisan
110
112
  data
111
113
  end
112
114
 
113
- # Subset hhea table (update numberOfHMetrics)
115
+ # Subset hhea table (update numberOfHMetrics + advanceWidthMax)
114
116
  #
115
- # Updates the numberOfHMetrics field to reflect the number of
116
- # horizontal metrics in the subset font.
117
+ # Updates numberOfHMetrics to reflect the subset's glyph count and
118
+ # recomputes advanceWidthMax from the subset's actual hmtx. The
119
+ # source TTC's advanceWidthMax covers every donor font and is far
120
+ # larger than any per-block subset needs.
117
121
  #
118
122
  # @param table [Hhea] Parsed hhea table
119
123
  # @param hmtx [Hmtx, nil] Optional parsed hmtx table (for calculating metrics)
@@ -128,9 +132,18 @@ module Fontisan
128
132
  calculate_number_of_h_metrics
129
133
  end
130
134
 
131
- # Update numberOfHMetrics field (at offset 34, uint16)
135
+ # numberOfHMetrics at offset 34 (uint16)
132
136
  data[34, 2] = [new_num_h_metrics].pack("n")
133
137
 
138
+ # advanceWidthMax at offset 10 (uint16). Recompute from subset
139
+ # hmtx so a per-block subset doesn't keep the source TTC's
140
+ # max (which can be 4x larger than any glyph in the subset).
141
+ # Tables are processed alphabetically (hhea before hmtx), so
142
+ # we read hmtx directly here rather than relying on a cached
143
+ # value from subset_hmtx.
144
+ new_max = compute_subset_max_advance
145
+ data[10, 2] = [new_max].pack("n") if new_max.positive?
146
+
134
147
  data
135
148
  end
136
149
 
@@ -152,14 +165,18 @@ module Fontisan
152
165
  # Build new hmtx data
153
166
  data = String.new(encoding: Encoding::BINARY)
154
167
 
168
+ max_advance = 0
155
169
  mapping.old_ids.each do |old_id|
156
170
  metric = table.metric_for(old_id)
157
171
  next unless metric
158
172
 
159
- data << [metric[:advance_width]].pack("n")
173
+ advance = metric[:advance_width]
174
+ max_advance = advance if advance && advance > max_advance
175
+ data << [advance].pack("n")
160
176
  data << [metric[:lsb]].pack("n")
161
177
  end
162
178
 
179
+ @subset_max_advance = max_advance
163
180
  data
164
181
  end
165
182
 
@@ -264,8 +281,32 @@ module Fontisan
264
281
  #
265
282
  # @param table [Head] Parsed head table
266
283
  # @return [String] Binary data of subset head table
284
+ # Subset head table (recompute bbox from actual subset glyphs)
285
+ #
286
+ # The source TTC's head.xMin/yMin/xMax/yMax covers every donor
287
+ # font in the collection — far larger than any per-block subset.
288
+ # Browsers and layout engines use head bbox (plus hhea + OS/2)
289
+ # to compute line height and clip text; a too-large bbox makes
290
+ # glyphs render at the wrong visual size.
291
+ #
292
+ # @param _table [Head] Parsed head table (unused; we re-serialize
293
+ # directly from font.table_data so we don't lose other fields)
294
+ # @return [String] Binary data of subset head table
267
295
  def subset_head(_table)
268
- font.table_data["head"]
296
+ # Trigger glyf build (which populates @subset_bbox) if not done.
297
+ glyf = font.table("glyf")
298
+ build_glyf_and_loca(glyf) unless @glyf_data
299
+
300
+ data = font.table_data["head"].dup
301
+
302
+ if @subset_bbox
303
+ x_min, y_min, x_max, y_max = @subset_bbox
304
+ # head layout: xMin at offset 36, yMin 38, xMax 40, yMax 42
305
+ # (each int16, big-endian, signed)
306
+ data[36, 8] = [x_min, y_min, x_max, y_max].pack("n4")
307
+ end
308
+
309
+ data
269
310
  end
270
311
 
271
312
  # Subset OS/2 table (optionally prune Unicode ranges)
@@ -295,6 +336,32 @@ module Fontisan
295
336
  mapping.size
296
337
  end
297
338
 
339
+ # Compute the largest advanceWidth across all subset glyphs by
340
+ # reading the source hmtx directly. Called from subset_hhea
341
+ # because hhea is processed before hmtx (alphabetical order).
342
+ #
343
+ # @return [Integer] max advanceWidth, or 0 if no metrics found
344
+ def compute_subset_max_advance
345
+ hmtx = font.table("hmtx")
346
+ return 0 unless hmtx
347
+
348
+ unless hmtx.parsed?
349
+ hhea = font.table("hhea")
350
+ maxp = font.table("maxp")
351
+ hmtx.parse_with_context(hhea.number_of_h_metrics, maxp.num_glyphs)
352
+ end
353
+
354
+ max_advance = 0
355
+ mapping.old_ids.each do |old_id|
356
+ metric = hmtx.metric_for(old_id)
357
+ next unless metric
358
+
359
+ advance = metric[:advance_width]
360
+ max_advance = advance if advance && advance > max_advance
361
+ end
362
+ max_advance
363
+ end
364
+
298
365
  # Build glyf and loca tables together
299
366
  #
300
367
  # This method extracts glyph data for all glyphs in the mapping,
@@ -318,6 +385,17 @@ module Fontisan
318
385
  @loca_offsets = []
319
386
  current_offset = 0
320
387
 
388
+ # Track union bbox across all subset glyphs. Glyph binary layout:
389
+ # int16 numberOfContours (offset 0)
390
+ # int16 xMin (offset 2)
391
+ # int16 yMin (offset 4)
392
+ # int16 xMax (offset 6)
393
+ # int16 yMax (offset 8)
394
+ bbox_x_min = 1 << 30
395
+ bbox_y_min = 1 << 30
396
+ bbox_x_max = -(1 << 30)
397
+ bbox_y_max = -(1 << 30)
398
+
321
399
  # Process glyphs in mapping order
322
400
  mapping.old_ids.each do |old_id|
323
401
  @loca_offsets << current_offset
@@ -334,6 +412,21 @@ module Fontisan
334
412
  # Extract glyph data
335
413
  glyph_data = glyf_table.raw_data[offset, size]
336
414
 
415
+ # Update bbox union. Each glyph has at least 10 bytes of
416
+ # header (numberOfContours + 4 int16 bbox fields).
417
+ if glyph_data.bytesize >= 10
418
+ _n, gx_min, gy_min, gx_max, gy_max = glyph_data[0, 10].unpack("n5")
419
+ # Treat as signed int16
420
+ gx_min = (gx_min ^ 0x8000) - 0x8000
421
+ gy_min = (gy_min ^ 0x8000) - 0x8000
422
+ gx_max = (gx_max ^ 0x8000) - 0x8000
423
+ gy_max = (gy_max ^ 0x8000) - 0x8000
424
+ bbox_x_min = gx_min if gx_min < bbox_x_min
425
+ bbox_y_min = gy_min if gy_min < bbox_y_min
426
+ bbox_x_max = gx_max if gx_max > bbox_x_max
427
+ bbox_y_max = gy_max if gy_max > bbox_y_max
428
+ end
429
+
337
430
  # Check if compound glyph and remap components
338
431
  if compound_glyph?(glyph_data)
339
432
  glyph_data = remap_compound_glyph(glyph_data)
@@ -346,6 +439,11 @@ module Fontisan
346
439
 
347
440
  # Add final offset
348
441
  @loca_offsets << current_offset
442
+
443
+ # Stash union bbox if we saw at least one non-empty glyph.
444
+ return if bbox_x_min > bbox_x_max
445
+
446
+ @subset_bbox = [bbox_x_min, bbox_y_min, bbox_x_max, bbox_y_max]
349
447
  end
350
448
 
351
449
  # Check if glyph data represents a compound glyph
@@ -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
@@ -18,6 +18,16 @@ module Fontisan
18
18
  # Magic number that must be present in the head table
19
19
  MAGIC_NUMBER = 0x5F0F3CF5
20
20
 
21
+ # Bit flags for the `flags` field (uint16), per OpenType spec.
22
+ # Only bits we currently reference are named here; see the OpenType
23
+ # `head` table specification for the full list.
24
+ #
25
+ # Bit 11 indicates the font data has been losslessly modified
26
+ # (e.g. by subsetting or WOFF2 encoding). Per WOFF2 spec section 5,
27
+ # encoders MUST set this bit. Chrome's OpenType Sanitizer (OTS)
28
+ # rejects WOFF2 files where it is not set.
29
+ FLAG_LOSSLESS_MODIFYING = 0x0800
30
+
21
31
  # Version as 16.16 fixed-point (stored as int32)
22
32
  int32 :version_raw
23
33
 
@@ -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