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,305 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Fontisan
4
+ class Stitcher
5
+ module PartitionStrategy
6
+ # Partition codepoints by Unicode script property.
7
+ #
8
+ # Each non-empty script becomes one or more partitions. Scripts
9
+ # that overflow +cap+ are chunked (Han has 80k+ codepoints) —
10
+ # chunks are named +:script_<name>_a+, +:script_<name>_b+, etc.,
11
+ # matching ByPlane's sub-split convention.
12
+ #
13
+ # Codepoints whose script is unknown (unassigned blocks) fall
14
+ # into +:script_other+. Codepoints in the +Common+ script
15
+ # (digits, punctuation shared across scripts) and +Inherited+
16
+ # script (combining marks) get their own buckets — they're
17
+ # typically useful as standalone subfonts.
18
+ #
19
+ # The script data is derived from {ByBlock::BLOCKS}: each Unicode
20
+ # block maps to exactly one primary script (with a few exceptions
21
+ # that we resolve explicitly). This keeps the data DRY — the
22
+ # authoritative block list lives in one place.
23
+ class ByScript < Base
24
+ # Maps each Unicode block label to its primary script.
25
+ # Block labels match ByBlock::BLOCKS keys.
26
+ #
27
+ # Special scripts:
28
+ # - +:common+ — shared across scripts (digits, punctuation)
29
+ # - +:inherited+ — combining marks that inherit the base char's script
30
+ # - +:other+ — fallback for unlisted blocks
31
+ SCRIPT_OF_BLOCK = {
32
+ # Latin family
33
+ "Basic_Latin" => :common,
34
+ "Latin-1_Supplement" => :latin,
35
+ "Latin_Extended-A" => :latin,
36
+ "Latin_Extended-B" => :latin,
37
+ "Latin_Extended-C" => :latin,
38
+ "Latin_Extended-D" => :latin,
39
+ "Latin_Extended-E" => :latin,
40
+ "Latin_Extended-F" => :latin,
41
+ "Latin_Extended_Additional" => :latin,
42
+ "Phonetic_Extensions" => :latin,
43
+ "Phonetic_Extensions_Supplement" => :latin,
44
+ "Modifier_Tone_Letters" => :latin,
45
+ "Spacing_Modifier_Letters" => :common,
46
+ "Combining_Diacritical_Marks" => :inherited,
47
+ "Combining_Diacritical_Marks_Extended" => :inherited,
48
+ "Combining_Diacritical_Marks_Supplement" => :inherited,
49
+ "Combining_Diacritical_Marks_for_Symbols" => :inherited,
50
+ "Combining_Half_Marks" => :inherited,
51
+
52
+ # Greek
53
+ "Greek_and_Coptic" => :greek,
54
+ "Greek_Extended" => :greek,
55
+
56
+ # Cyrillic
57
+ "Cyrillic" => :cyrillic,
58
+ "Cyrillic_Supplement" => :cyrillic,
59
+ "Cyrillic_Extended-A" => :cyrillic,
60
+ "Cyrillic_Extended-B" => :cyrillic,
61
+ "Cyrillic_Extended-C" => :cyrillic,
62
+ "Cyrillic_Extended-D" => :cyrillic,
63
+
64
+ # Middle Eastern
65
+ "Armenian" => :armenian,
66
+ "Hebrew" => :hebrew,
67
+ "Arabic" => :arabic,
68
+ "Arabic_Supplement" => :arabic,
69
+ "Arabic_Extended-A" => :arabic,
70
+ "Arabic_Extended-B" => :arabic,
71
+ "Arabic_Extended-C" => :arabic,
72
+ "Arabic_Extended-D" => :arabic,
73
+ "Arabic_Presentation_Forms-A" => :arabic,
74
+ "Arabic_Presentation_Forms-B" => :arabic,
75
+ "Syriac" => :syriac,
76
+ "Syriac_Supplement" => :syriac,
77
+ "Thaana" => :thaana,
78
+ "Samaritan" => :samaritan,
79
+ "Mandaic" => :mandaic,
80
+
81
+ # Indic
82
+ "Devanagari" => :devanagari,
83
+ "Devanagari_Extended" => :devanagari,
84
+ "Bengali" => :bengali,
85
+ "Gurmukhi" => :gurmukhi,
86
+ "Gujarati" => :gujarati,
87
+ "Oriya" => :oriya,
88
+ "Tamil" => :tamil,
89
+ "Tamil_Supplement" => :tamil,
90
+ "Telugu" => :telugu,
91
+ "Kannada" => :kannada,
92
+ "Malayalam" => :malayalam,
93
+ "Sinhala" => :sinhala,
94
+ "Sinhala_Archaic_Numbers" => :sinhala,
95
+ "Vedic_Extensions" => :inherited,
96
+
97
+ # Southeast Asian
98
+ "Thai" => :thai,
99
+ "Lao" => :lao,
100
+ "Tibetan" => :tibetan,
101
+ "Myanmar" => :myanmar,
102
+ "Myanmar_Extended-A" => :myanmar,
103
+ "Myanmar_Extended-B" => :myanmar,
104
+ "Myanmar_Extended-C" => :myanmar,
105
+ "Khmer" => :khmer,
106
+ "Khmer_Symbols" => :khmer,
107
+ "Tagalog" => :tagalog,
108
+ "Hanunoo" => :hanunoo,
109
+ "Buhid" => :buhid,
110
+ "Tagbanwa" => :tagbanwa,
111
+
112
+ # Hangul
113
+ "Hangul_Jamo" => :hangul,
114
+ "Hangul_Compatibility_Jamo" => :hangul,
115
+ "Hangul_Jamo_Extended-A" => :hangul,
116
+ "Hangul_Jamo_Extended-B" => :hangul,
117
+ "Hangul_Syllables" => :hangul,
118
+
119
+ # CJK — Han + native Japanese / Korean
120
+ "CJK_Unified_Ideographs" => :han,
121
+ "CJK_Unified_Ideographs_Extension_A" => :han,
122
+ "CJK_Unified_Ideographs_Extension_B" => :han,
123
+ "CJK_Unified_Ideographs_Extension_C" => :han,
124
+ "CJK_Unified_Ideographs_Extension_D" => :han,
125
+ "CJK_Unified_Ideographs_Extension-E" => :han,
126
+ "CJK_Unified_Ideographs_Extension-F" => :han,
127
+ "CJK_Unified_Ideographs_Extension_G" => :han,
128
+ "CJK_Unified_Ideographs_Extension_H" => :han,
129
+ "CJK_Unified_Ideographs_Extension_I" => :han,
130
+ "CJK_Compatibility_Ideographs" => :han,
131
+ "CJK_Compatibility_Ideographs_Supplement" => :han,
132
+ "CJK_Radicals_Supplement" => :han,
133
+ "Kangxi_Radicals" => :han,
134
+ "CJK_Symbols_and_Punctuation" => :common,
135
+ "CJK_Compatibility" => :han,
136
+ "CJK_Compatibility_Forms" => :common,
137
+ "CJK_Strokes" => :han,
138
+ "Ideographic_Description_Characters" => :common,
139
+ "Enclosed_CJK_Letters_and_Months" => :common,
140
+ "Hiragana" => :hiragana,
141
+ "Katakana" => :katakana,
142
+ "Katakana_Phonetic_Extensions" => :katakana,
143
+ "Kana_Supplement" => :hiragana,
144
+ "Kana_Extended-A" => :hiragana,
145
+ "Kana_Extended-B" => :hiragana,
146
+ "Small_Kana_Extension" => :hiragana,
147
+ "Halfwidth_and_Fullwidth_Forms" => :common,
148
+ "Vertical_Forms" => :common,
149
+ "Ideographic_Symbols_and_Punctuation" => :common,
150
+
151
+ # Other scripts (single-block each)
152
+ "Ethiopic" => :ethiopic,
153
+ "Ethiopic_Supplement" => :ethiopic,
154
+ "Ethiopic_Extended" => :ethiopic,
155
+ "Ethiopic_Extended-A" => :ethiopic,
156
+ "Ethiopian_Extended-B" => :ethiopic,
157
+ "Cherokee" => :cherokee,
158
+ "Cherokee_Supplement" => :cherokee,
159
+ "Unified_Canadian_Aboriginal_Syllabics" => :canadian_aboriginal,
160
+ "Unified_Canadian_Aboriginal_Syllabics_Extended" => :canadian_aboriginal,
161
+ "Unified_Canadian_Aboriginal_Syllabics_Extended-A" => :canadian_aboriginal,
162
+ "Ogham" => :ogham,
163
+ "Runic" => :runic,
164
+ "Glagolitic" => :glagolitic,
165
+ "Glagolitic_Supplement" => :glagolitic,
166
+ "Tifinagh" => :tifinagh,
167
+ "Georgian" => :georgian,
168
+ "Georgian_Supplement" => :georgian,
169
+ "Georgian_Extended" => :georgian,
170
+ "Mongolian" => :mongolian,
171
+ "Mongolian_Supplement" => :mongolian,
172
+ "Limbu" => :limbu,
173
+ "Tai_Le" => :tai_le,
174
+ "New_Tai_Lue" => :new_tai_lue,
175
+ "Tai_Tham" => :tai_tham,
176
+ "Tai_Viet" => :tai_viet,
177
+ "Ol_Chiki" => :ol_chiki,
178
+ "Bopomofo" => :bopomofo,
179
+ "Bopomofo_Extended" => :bopomofo,
180
+ "Yi_Syllables" => :yi,
181
+ "Yi_Radicals" => :yi,
182
+ "Vai" => :vai,
183
+ "Bamum" => :bamum,
184
+ "Bamum_Supplement" => :bamum,
185
+ "Syloti_Nagri" => :syloti_nagri,
186
+ "Phags-pa" => :phags_pa,
187
+ "Saurashtra" => :saurashtra,
188
+ "Kayah_Li" => :kayah_li,
189
+ "Rejang" => :rejang,
190
+ "Javanese" => :javanese,
191
+ "Cham" => :cham,
192
+ "Lepcha" => :lepcha,
193
+ "Meetei_Mayek" => :meetei_mayek,
194
+ "Meetei_Mayek_Extensions" => :meetei_mayek,
195
+ "Lisu" => :lisu,
196
+ "Lisu_Supplement" => :lisu,
197
+ "Sundanese" => :sundanese,
198
+ "Sundanese_Supplement" => :sundanese,
199
+ "Batak" => :batak,
200
+ "Buginese" => :buginese,
201
+ "Ahom" => :ahom,
202
+ "Dogra" => :dogra,
203
+ "Tulu-Tigalari" => :tulu_tigalari,
204
+ "Grantha" => :grantha,
205
+ "Newa" => :newa,
206
+ "Tirhuta" => :tirhuta,
207
+ "Siddham" => :siddham,
208
+ "Modi" => :modi,
209
+ "Sharada" => :sharada,
210
+ "Takri" => :takri,
211
+ "Kaithi" => :kaithi,
212
+ "Mahajani" => :mahajani,
213
+ "Multani" => :multani,
214
+ "Khudawadi" => :khudawadi,
215
+ "Nandinagari" => :nandinagari,
216
+ "Nushu" => :nushu,
217
+ "Wancho" => :wancho,
218
+ "Toto" => :toto,
219
+ "Nag_Mundari" => :nag_mundari,
220
+
221
+ # Numerals / symbols (Common)
222
+ "Superscripts_and_Subscripts" => :common,
223
+ "Number_Forms" => :common,
224
+ "Currency_Symbols" => :common,
225
+ "Letterlike_Symbols" => :common,
226
+ "Arrows" => :common,
227
+ "Mathematical_Operators" => :common,
228
+ "Miscellaneous_Technical" => :common,
229
+ "Control_Pictures" => :common,
230
+ "Optical_Character_Recognition" => :common,
231
+ "Enclosed_Alphanumerics" => :common,
232
+ "Box_Drawing" => :common,
233
+ "Block_Elements" => :common,
234
+ "Geometric_Shapes" => :common,
235
+ "Miscellaneous_Symbols" => :common,
236
+ "Dingbats" => :common,
237
+ "Miscellaneous_Mathematical_Symbols-A" => :common,
238
+ "Miscellaneous_Mathematical_Symbols-B" => :common,
239
+ "Supplemental_Arrows-A" => :common,
240
+ "Supplemental_Arrows-B" => :common,
241
+ "Supplemental_Arrows-C" => :common,
242
+ "Supplemental_Mathematical_Operators" => :common,
243
+ "Miscellaneous_Symbols_and_Arrows" => :common,
244
+ "Braille_Patterns" => :braille,
245
+ "General_Punctuation" => :common,
246
+ "Supplemental_Punctuation" => :common,
247
+ "Alphabetic_Presentation_Forms" => :common,
248
+ "Specials" => :common,
249
+ "Variation_Selectors" => :inherited,
250
+ "Variation_Selectors_Supplement" => :inherited,
251
+ "Tags" => :common,
252
+ }.freeze
253
+
254
+ # @param cp_map [Hash{Integer=>Object}] codepoint → donor label
255
+ # @param cap [Integer] max codepoints per partition
256
+ # @return [Blueprint]
257
+ def call(cp_map, cap: DEFAULT_CAP)
258
+ grouped = group_by_script(cp_map)
259
+ partitions = []
260
+
261
+ grouped.each do |script, entries|
262
+ chunks(entries, cap).each_with_index do |chunk, idx|
263
+ partitions << Partition.new(
264
+ name: partition_name(script, idx),
265
+ cps: chunk.map(&:first),
266
+ donor_map: chunk.to_h,
267
+ )
268
+ end
269
+ end
270
+
271
+ Blueprint.new(partitions: partitions)
272
+ end
273
+
274
+ private
275
+
276
+ def group_by_script(cp_map)
277
+ cp_map.each_with_object(Hash.new { |h, k| h[k] = [] }) do |(cp, label), h|
278
+ script = script_of_codepoint(cp)
279
+ h[script] << [cp, label]
280
+ end
281
+ end
282
+
283
+ def script_of_codepoint(cp)
284
+ block_label = ByBlock::BLOCKS.find { |_label, range| range.cover?(cp) }&.first
285
+ return :other unless block_label
286
+
287
+ SCRIPT_OF_BLOCK[block_label] || :other
288
+ end
289
+
290
+ def chunks(entries, cap)
291
+ entries.each_slice(cap).to_a
292
+ end
293
+
294
+ # First partition per script has no suffix; subsequent ones
295
+ # get _a, _b, ... matching ByPlane's sub-split convention.
296
+ def partition_name(script, idx)
297
+ return :"script_#{script}" if idx.zero?
298
+
299
+ suffix = ("a".ord + idx - 1).chr
300
+ :"script_#{script}_#{suffix}"
301
+ end
302
+ end
303
+ end
304
+ end
305
+ end
@@ -17,6 +17,8 @@ module Fontisan
17
17
  autoload :Blueprint, "fontisan/stitcher/partition_strategy/blueprint"
18
18
  autoload :Partition, "fontisan/stitcher/partition_strategy/partition"
19
19
  autoload :ByPlane, "fontisan/stitcher/partition_strategy/by_plane"
20
+ autoload :ByBlock, "fontisan/stitcher/partition_strategy/by_block"
21
+ autoload :ByScript, "fontisan/stitcher/partition_strategy/by_script"
20
22
  end
21
23
  end
22
24
  end
@@ -336,10 +336,15 @@ module Fontisan
336
336
 
337
337
  # Apply a 2×3 affine matrix [a, b, c, d, e, f] to each point of
338
338
  # a simple component, appending the transformed contours.
339
- # x' = a*x + c*y + e
340
- # y' = b*x + d*y + f
339
+ # The math is delegated to +Transformation#apply+ so the
340
+ # affine-matrix logic lives in one place (DRY) — same code path
341
+ # the UFO Transformations filter uses.
341
342
  def flatten_simple_component(simple, ufo_glyph, matrix)
342
- a, b, c, d, e, f = matrix
343
+ transform = Ufo::Transformation.new(
344
+ a: matrix[0], b: matrix[1],
345
+ c: matrix[2], d: matrix[3],
346
+ e: matrix[4], f: matrix[5]
347
+ )
343
348
  num_contours = simple.end_pts_of_contours&.size || 0
344
349
  return if num_contours.zero?
345
350
 
@@ -350,13 +355,12 @@ module Fontisan
350
355
  ufo_points = points.map do |pt|
351
356
  x = (pt[:x] || pt["x"]).to_f
352
357
  y = (pt[:y] || pt["y"]).to_f
353
- tx = a * x + c * y + e
354
- ty = b * x + d * y + f
358
+ tx, ty = transform.apply(x, y)
355
359
  on_curve = pt[:on_curve].nil? || pt[:on_curve]
356
360
  type = on_curve ? "line" : "offcurve"
357
- Fontisan::Ufo::Point.new(x: tx, y: ty, type: type)
361
+ Ufo::Point.new(x: tx, y: ty, type: type)
358
362
  end
359
- ufo_glyph.add_contour(Fontisan::Ufo::Contour.new(ufo_points))
363
+ ufo_glyph.add_contour(Ufo::Contour.new(ufo_points))
360
364
  end
361
365
  end
362
366
 
@@ -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
@@ -417,12 +515,130 @@ module Fontisan
417
515
  # Creates a minimal cmap table with format 4 subtable for BMP
418
516
  # and format 12 for supplementary planes if needed.
419
517
  #
420
- # @param mappings [Hash<Integer, Integer>] Char code => glyph ID
518
+ # @param mappings [Hash<Integer, Integer>] Char code => new glyph ID
421
519
  # @return [String] Binary cmap data
422
- def build_cmap_binary(_mappings)
423
- # For now, pass through original cmap
424
- # TODO: Implement proper cmap building
425
- font.table_data["cmap"]
520
+ def build_cmap_binary(mappings)
521
+ # Edge case: empty mappings (e.g., block with no covered chars).
522
+ # Emit a minimal valid cmap with one format 4 subtable mapping
523
+ # only U+0000 → .notdef so the table isn't empty.
524
+ mappings = { 0 => 0 } if mappings.empty?
525
+
526
+ bmp = mappings.select { |cp, _| cp <= 0xFFFF }
527
+ supp = mappings.select { |cp, _| cp > 0xFFFF }
528
+
529
+ subtables = []
530
+ records = [] # [platform_id, encoding_id, subtable_index]
531
+
532
+ unless bmp.empty?
533
+ subtables << build_cmap_format_4(bmp)
534
+ idx = subtables.size - 1
535
+ records << [3, 1, idx] # Windows BMP
536
+ records << [0, 3, idx] # Unicode BMP
537
+ end
538
+
539
+ unless supp.empty?
540
+ # Format 12 covers both BMP and supplementary — include all
541
+ # mappings so a single subtable covers the full range.
542
+ subtables << build_cmap_format_12(mappings)
543
+ idx = subtables.size - 1
544
+ records << [3, 10, idx] # Windows UCS-4
545
+ records << [0, 4, idx] # Unicode full
546
+ end
547
+
548
+ # Header: version (uint16) + numTables (uint16)
549
+ num_tables = records.size
550
+ header = [0, num_tables].pack("nn")
551
+
552
+ # Encoding records start immediately after the header.
553
+ # Each record is 8 bytes; subtables follow.
554
+ subtable_base = 4 + (8 * num_tables)
555
+
556
+ offsets = []
557
+ running = subtable_base
558
+ subtables.each do |st|
559
+ offsets << running
560
+ running += st.bytesize
561
+ end
562
+
563
+ record_bytes = +""
564
+ records.each do |pid, eid, idx|
565
+ record_bytes << [pid, eid, offsets[idx]].pack("nnN")
566
+ end
567
+
568
+ header + record_bytes + subtables.join
569
+ end
570
+
571
+ # Format 4 subtable: segment-mapping with idDelta, suitable for
572
+ # BMP codepoints (U+0000..U+FFFF). Builds compact segments where
573
+ # consecutive codepoints map to consecutive glyph IDs.
574
+ def build_cmap_format_4(bmp_mappings)
575
+ segments = coalesce_segments(bmp_mappings)
576
+ # Mandatory final segment: U+FFFF → gid 0 (per OpenType spec).
577
+ segments << { start_cp: 0xFFFF, end_cp: 0xFFFF, start_gid: 0 }
578
+
579
+ seg_count = segments.size
580
+ seg_count_x2 = seg_count * 2
581
+ search_range = 2**Math.log2(seg_count).floor * 2
582
+ search_range = 2 if search_range < 2
583
+ entry_selector = Math.log2(search_range / 2).to_i
584
+ range_shift = seg_count_x2 - search_range
585
+
586
+ end_codes = segments.map { |s| s[:end_cp] }
587
+ start_codes = segments.map { |s| s[:start_cp] }
588
+ # idDelta is int16 stored as uint16 (two's complement). For a
589
+ # sequential segment, idDelta = (start_gid - start_cp) & 0xFFFF.
590
+ id_deltas = segments.map { |s| (s[:start_gid] - s[:start_cp]) & 0xFFFF }
591
+ id_range_offsets = [0] * seg_count
592
+
593
+ subtable = +""
594
+ subtable << [4, 0, 0, seg_count_x2,
595
+ search_range, entry_selector, range_shift].pack("n*")
596
+ subtable << end_codes.pack("n*")
597
+ subtable << [0].pack("n") # reservedPad
598
+ subtable << start_codes.pack("n*")
599
+ subtable << id_deltas.pack("n*")
600
+ subtable << id_range_offsets.pack("n*")
601
+
602
+ # Patch the length field (was placeholder 0).
603
+ subtable[2, 2] = [subtable.bytesize].pack("n")
604
+ subtable
605
+ end
606
+
607
+ # Format 12 subtable: segmented coverage for full Unicode range.
608
+ # Simpler than format 4 — just (start_char, end_char, start_gid)
609
+ # triples with no delta/offset indirection.
610
+ def build_cmap_format_12(all_mappings)
611
+ groups = coalesce_segments(all_mappings)
612
+ num_groups = groups.size
613
+
614
+ subtable = +""
615
+ subtable << [12, 0, 0, 0, num_groups].pack("nnNNN")
616
+ groups.each do |g|
617
+ subtable << [g[:start_cp], g[:end_cp], g[:start_gid]].pack("NNN")
618
+ end
619
+
620
+ # Patch the length field (was placeholder 0). Total length is
621
+ # 16-byte header + 12 bytes per group.
622
+ subtable[4, 4] = [subtable.bytesize].pack("N")
623
+ subtable
624
+ end
625
+
626
+ # Group codepoints into consecutive runs where both codepoint AND
627
+ # glyph ID are sequential. Each run becomes one segment/group.
628
+ def coalesce_segments(mappings)
629
+ sorted = mappings.sort_by { |cp, _| cp }
630
+ segments = []
631
+ current = nil
632
+ sorted.each do |cp, gid|
633
+ if current && cp == current[:end_cp] + 1 && gid == current[:start_gid] + (cp - current[:start_cp])
634
+ current[:end_cp] = cp
635
+ else
636
+ segments << current if current
637
+ current = { start_cp: cp, end_cp: cp, start_gid: gid }
638
+ end
639
+ end
640
+ segments << current if current
641
+ segments
426
642
  end
427
643
 
428
644
  # Build post table version 3.0 (no glyph names)