fontisan 0.4.22 → 0.4.23

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 (48) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop_todo.yml +5 -0
  3. data/lib/fontisan/config/subset_profiles.yml +2 -0
  4. data/lib/fontisan/stitcher/cbdt_propagator.rb +44 -22
  5. data/lib/fontisan/stitcher/glyph_cloner.rb +41 -0
  6. data/lib/fontisan/stitcher/glyph_copier.rb +3 -33
  7. data/lib/fontisan/stitcher/unique_glyph_name.rb +46 -0
  8. data/lib/fontisan/stitcher.rb +2 -0
  9. data/lib/fontisan/subset/shared_state.rb +42 -0
  10. data/lib/fontisan/subset/subset_context.rb +20 -0
  11. data/lib/fontisan/subset/table_strategy/cbdt.rb +30 -0
  12. data/lib/fontisan/subset/table_strategy/cblc.rb +27 -0
  13. data/lib/fontisan/subset/table_strategy/cmap.rb +146 -0
  14. data/lib/fontisan/subset/table_strategy/color_bitmap_placement.rb +20 -0
  15. data/lib/fontisan/subset/table_strategy/color_bitmap_strike_plan.rb +45 -0
  16. data/lib/fontisan/subset/table_strategy/color_bitmap_subset_plan.rb +62 -0
  17. data/lib/fontisan/subset/table_strategy/color_bitmap_subsetter.rb +182 -0
  18. data/lib/fontisan/subset/table_strategy/color_bitmap_subtable_plan.rb +59 -0
  19. data/lib/fontisan/subset/table_strategy/glyf.rb +29 -0
  20. data/lib/fontisan/subset/table_strategy/glyf_loca_builder.rb +145 -0
  21. data/lib/fontisan/subset/table_strategy/head.rb +42 -0
  22. data/lib/fontisan/subset/table_strategy/hhea.rb +67 -0
  23. data/lib/fontisan/subset/table_strategy/hmtx.rb +52 -0
  24. data/lib/fontisan/subset/table_strategy/loca.rb +41 -0
  25. data/lib/fontisan/subset/table_strategy/maxp.rb +23 -0
  26. data/lib/fontisan/subset/table_strategy/name.rb +19 -0
  27. data/lib/fontisan/subset/table_strategy/os2.rb +21 -0
  28. data/lib/fontisan/subset/table_strategy/pass_through.rb +20 -0
  29. data/lib/fontisan/subset/table_strategy/post.rb +37 -0
  30. data/lib/fontisan/subset/table_strategy.rb +75 -0
  31. data/lib/fontisan/subset/table_subsetter.rb +49 -616
  32. data/lib/fontisan/subset.rb +3 -0
  33. data/lib/fontisan/tables/cbdt.rb +66 -121
  34. data/lib/fontisan/tables/cblc.rb +110 -231
  35. data/lib/fontisan/tables/cblc_big_glyph_metrics.rb +28 -0
  36. data/lib/fontisan/tables/cblc_bitmap_size.rb +67 -0
  37. data/lib/fontisan/tables/cblc_glyph_bitmap_location.rb +28 -0
  38. data/lib/fontisan/tables/cblc_index_subtable.rb +104 -0
  39. data/lib/fontisan/tables/cblc_index_subtable_array_entry.rb +20 -0
  40. data/lib/fontisan/tables/cblc_index_subtable_format_parser.rb +150 -0
  41. data/lib/fontisan/tables/cblc_index_subtable_header.rb +18 -0
  42. data/lib/fontisan/tables/cblc_sbit_line_metrics.rb +28 -0
  43. data/lib/fontisan/tables.rb +11 -0
  44. data/lib/fontisan/ufo/glyph_exists_error.rb +23 -0
  45. data/lib/fontisan/ufo/layer.rb +42 -2
  46. data/lib/fontisan/ufo.rb +2 -1
  47. data/lib/fontisan/version.rb +1 -1
  48. metadata +35 -2
@@ -2,675 +2,108 @@
2
2
 
3
3
  module Fontisan
4
4
  module Subset
5
- # Table-specific subsetting strategies
5
+ # Table-specific subsetting dispatcher.
6
6
  #
7
- # This class provides methods for subsetting individual font tables according
8
- # to the glyph mapping. Each table type has different subsetting requirements:
7
+ # Per-table subsetting logic lives in [TableStrategy] classes (one
8
+ # per OpenType tag). This class:
9
9
  #
10
- # - maxp: Update glyph count
11
- # - hhea: Update horizontal metrics count
12
- # - hmtx: Subset horizontal metrics
13
- # - glyf: Subset glyph data and remap component references
14
- # - loca: Rebuild glyph location index
15
- # - cmap: Remap character to glyph mappings
16
- # - post: Optionally drop glyph names
17
- # - name: Pass through (no subsetting needed)
18
- # - head: Update checksum adjustment (handled by FontWriter)
19
- # - OS/2: Optionally prune Unicode ranges
10
+ # 1. Holds the inputs to a subsetting run (font, mapping, options)
11
+ # plus the cross-strategy [SharedState] (glyf/loca data, bbox,
12
+ # max advance, color bitmap subsetter cache).
13
+ # 2. Dispatches `subset_table(tag, table)` to the right strategy
14
+ # via [TableStrategy.for(tag)].
15
+ # 3. Keeps `subset_<tag>` wrappers for backward compatibility with
16
+ # specs that exercise a single strategy in isolation.
20
17
  #
21
- # The subsetting process preserves font validity by updating all references
22
- # and recalculating offsets and checksums.
18
+ # Adding a new subsetting strategy does NOT require editing this
19
+ # file register it in `lib/fontisan/subset/table_strategy.rb`.
23
20
  #
24
21
  # @example Subset a single table
25
22
  # subsetter = TableSubsetter.new(font, mapping, options)
26
- # maxp_data = subsetter.subset_maxp(maxp_table)
27
- #
28
- # @example Subset all tables
29
- # subsetter = TableSubsetter.new(font, mapping, options)
30
- # subset_tables = {}
31
- # profile_tables.each do |tag|
32
- # table = font.table(tag)
33
- # subset_tables[tag] = subsetter.subset_table(tag, table) if table
34
- # end
23
+ # maxp_data = subsetter.subset_table("maxp", font.table("maxp"))
35
24
  class TableSubsetter
36
- # Font instance being subset
37
- # @return [TrueTypeFont, OpenTypeFont]
25
+ # @return [SfntFont]
38
26
  attr_reader :font
39
27
 
40
- # Glyph ID mapping (old GID → new GID)
41
28
  # @return [GlyphMapping]
42
29
  attr_reader :mapping
43
30
 
44
- # Subsetting options
45
31
  # @return [Options]
46
32
  attr_reader :options
47
33
 
48
- # Initialize table subsetter
49
- #
50
- # @param font [TrueTypeFont, OpenTypeFont] Font to subset
51
- # @param mapping [GlyphMapping] Glyph ID mapping
52
- # @param options [Options] Subsetting options
34
+ # @return [SharedState]
35
+ attr_reader :state
36
+
53
37
  def initialize(font, mapping, options)
54
38
  @font = font
55
39
  @mapping = mapping
56
40
  @options = options
57
- @glyf_data = nil
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
41
+ @state = SharedState.new
61
42
  end
62
43
 
63
- # Subset a table by tag
44
+ # Subset one table by dispatching to its strategy. Unknown tables
45
+ # fall through to [TableStrategy::PassThrough] which preserves the
46
+ # source bytes verbatim.
64
47
  #
65
- # Delegates to table-specific subsetting methods. Unknown tables
66
- # are passed through unchanged.
67
- #
68
- # @param tag [String] Table tag (e.g., "glyf", "hmtx")
69
- # @param table [Object] Parsed table object
70
- # @return [String] Binary data of subset table
48
+ # @param tag [String] OpenType table tag (e.g. "glyf", "CBDT")
49
+ # @param table [Object] parsed table object
50
+ # @return [String] subset table binary bytes
71
51
  def subset_table(tag, table)
72
- case tag
73
- when "maxp"
74
- subset_maxp(table)
75
- when "hhea"
76
- subset_hhea(table)
77
- when "hmtx"
78
- subset_hmtx(table)
79
- when "loca"
80
- subset_loca(table)
81
- when "glyf"
82
- subset_glyf(table)
83
- when "cmap"
84
- subset_cmap(table)
85
- when "post"
86
- subset_post(table)
87
- when "name"
88
- subset_name(table)
89
- when "head"
90
- subset_head(table)
91
- when "OS/2"
92
- subset_os2(table)
93
- else
94
- # Unknown tables pass through unchanged
95
- font.table_data[tag]
96
- end
52
+ TableStrategy.for(tag).call(
53
+ context: subset_context, tag: tag, table: table,
54
+ )
97
55
  end
98
56
 
99
- # Subset maxp table (update numGlyphs)
100
- #
101
- # Updates the numGlyphs field to reflect the number of glyphs in
102
- # the subset font.
103
- #
104
- # @param table [Maxp] Parsed maxp table
105
- # @return [String] Binary data of subset maxp table
106
- def subset_maxp(table)
107
- data = table.to_binary_s.dup
108
-
109
- # Update numGlyphs field (at offset 4, uint16)
110
- data[4, 2] = [mapping.size].pack("n")
57
+ # --- Backward-compat wrappers --------------------------------------
58
+ # Specs and external callers may invoke `subset_<tag>(table)`
59
+ # directly. Each wrapper delegates to the corresponding strategy.
111
60
 
112
- data
61
+ def subset_maxp(table)
62
+ subset_table("maxp", table)
113
63
  end
114
64
 
115
- # Subset hhea table (update numberOfHMetrics + advanceWidthMax)
116
- #
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.
121
- #
122
- # @param table [Hhea] Parsed hhea table
123
- # @param hmtx [Hmtx, nil] Optional parsed hmtx table (for calculating metrics)
124
- # @return [String] Binary data of subset hhea table
125
65
  def subset_hhea(table, hmtx = nil)
126
- data = table.to_binary_s.dup
127
-
128
- # Calculate new numberOfHMetrics
129
- new_num_h_metrics = if hmtx&.h_metrics
130
- hmtx.h_metrics.size
131
- else
132
- calculate_number_of_h_metrics
133
- end
134
-
135
- # numberOfHMetrics at offset 34 (uint16)
136
- data[34, 2] = [new_num_h_metrics].pack("n")
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
-
147
- data
66
+ _ = hmtx # ignored; the strategy recomputes from source hmtx
67
+ subset_table("hhea", table)
148
68
  end
149
69
 
150
- # Subset hmtx table (subset horizontal metrics)
151
- #
152
- # Builds new hmtx table with metrics for subset glyphs only,
153
- # preserving the order of the glyph mapping.
154
- #
155
- # @param table [Hmtx] Parsed hmtx table
156
- # @return [String] Binary data of subset hmtx table
157
70
  def subset_hmtx(table)
158
- # Ensure hmtx is parsed
159
- unless table.parsed?
160
- hhea = font.table("hhea")
161
- maxp = font.table("maxp")
162
- table.parse_with_context(hhea.number_of_h_metrics, maxp.num_glyphs)
163
- end
164
-
165
- # Build new hmtx data
166
- data = String.new(encoding: Encoding::BINARY)
167
-
168
- max_advance = 0
169
- mapping.old_ids.each do |old_id|
170
- metric = table.metric_for(old_id)
171
- next unless metric
172
-
173
- advance = metric[:advance_width]
174
- max_advance = advance if advance && advance > max_advance
175
- data << [advance].pack("n")
176
- data << [metric[:lsb]].pack("n")
177
- end
178
-
179
- @subset_max_advance = max_advance
180
- data
71
+ subset_table("hmtx", table)
181
72
  end
182
73
 
183
- # Subset glyf table (subset glyph data)
184
- #
185
- # Extracts glyph data for subset glyphs and remaps component
186
- # references in compound glyphs. Also builds loca offsets.
187
- #
188
- # @param table [Glyf] Parsed glyf table
189
- # @return [String] Binary data of subset glyf table
190
74
  def subset_glyf(table)
191
- # Build glyf and loca together
192
- build_glyf_and_loca(table)
193
- @glyf_data
75
+ subset_table("glyf", table)
194
76
  end
195
77
 
196
- # Subset loca table (rebuild glyph location index)
197
- #
198
- # Builds new loca table based on subset glyph offsets. Must be
199
- # called after subset_glyf.
200
- #
201
- # @param table [Loca] Parsed loca table
202
- # @return [String] Binary data of subset loca table
203
- def subset_loca(_table)
204
- # Build glyf and loca together if not already done
205
- glyf = font.table("glyf")
206
- build_glyf_and_loca(glyf) unless @loca_offsets
207
-
208
- head = font.table("head")
209
- format = head.index_to_loc_format
210
-
211
- data = String.new(encoding: Encoding::BINARY)
212
-
213
- if format.zero?
214
- # Short format: offsets / 2 as uint16
215
- @loca_offsets.each do |offset|
216
- data << [offset / 2].pack("n")
217
- end
218
- else
219
- # Long format: offsets as uint32
220
- @loca_offsets.each do |offset|
221
- data << [offset].pack("N")
222
- end
223
- end
224
-
225
- data
78
+ def subset_loca(table)
79
+ subset_table("loca", table)
226
80
  end
227
81
 
228
- # Subset cmap table (remap character to glyph mappings)
229
- #
230
- # Builds new cmap table with only mappings for glyphs in the subset.
231
- # Updates glyph IDs to new values from the mapping.
232
- #
233
- # @param table [Cmap] Parsed cmap table
234
- # @return [String] Binary data of subset cmap table
235
82
  def subset_cmap(table)
236
- # Get old mappings
237
- old_mappings = table.unicode_mappings
238
- new_mappings = {}
239
-
240
- # Remap to new glyph IDs
241
- old_mappings.each do |char_code, old_gid|
242
- new_gid = mapping.new_id(old_gid)
243
- new_mappings[char_code] = new_gid if new_gid
244
- end
245
-
246
- # Build cmap binary with new mappings
247
- build_cmap_binary(new_mappings)
83
+ subset_table("cmap", table)
248
84
  end
249
85
 
250
- # Subset post table (optionally drop glyph names)
251
- #
252
- # If drop_names option is set, converts to post version 3.0
253
- # (no glyph names). Otherwise passes through unchanged.
254
- #
255
- # @param table [Post] Parsed post table
256
- # @return [String] Binary data of subset post table
257
86
  def subset_post(table)
258
- if options.drop_names
259
- # Build post table version 3.0 (no glyph names)
260
- build_post_v3(table)
261
- else
262
- # Keep as-is
263
- font.table_data["post"]
264
- end
87
+ subset_table("post", table)
265
88
  end
266
89
 
267
- # Subset name table (pass through)
268
- #
269
- # Name table doesn't require subsetting, pass through unchanged.
270
- #
271
- # @param table [Name] Parsed name table
272
- # @return [String] Binary data of subset name table
273
- def subset_name(_table)
274
- font.table_data["name"]
90
+ def subset_name(table)
91
+ subset_table("name", table)
275
92
  end
276
93
 
277
- # Subset head table (pass through)
278
- #
279
- # head table will have checksum updated by FontWriter,
280
- # no subsetting needed.
281
- #
282
- # @param table [Head] Parsed head table
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
295
- def subset_head(_table)
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
94
+ def subset_head(table)
95
+ subset_table("head", table)
310
96
  end
311
97
 
312
- # Subset OS/2 table (optionally prune Unicode ranges)
313
- #
314
- # If unicode_ranges option is set, updates Unicode range bits
315
- # to reflect only the characters in the subset.
316
- #
317
- # @param table [Os2] Parsed OS/2 table
318
- # @return [String] Binary data of subset OS/2 table
319
- def subset_os2(_table)
320
- if options.unicode_ranges
321
- # TODO: Implement Unicode range pruning
322
- # For now, pass through
323
- end
324
- font.table_data["OS/2"]
98
+ def subset_os2(table)
99
+ subset_table("OS/2", table)
325
100
  end
326
101
 
327
102
  private
328
103
 
329
- # Calculate numberOfHMetrics for subset
330
- #
331
- # For now, use the size of the mapping. In the future, this could
332
- # be optimized by finding the last unique advance width.
333
- #
334
- # @return [Integer] Number of unique advance widths
335
- def calculate_number_of_h_metrics
336
- mapping.size
337
- end
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
-
365
- # Build glyf and loca tables together
366
- #
367
- # This method extracts glyph data for all glyphs in the mapping,
368
- # remaps component references in compound glyphs, and builds the
369
- # loca offset array.
370
- #
371
- # @param glyf_table [Glyf] Parsed glyf table
372
- def build_glyf_and_loca(glyf_table)
373
- return if @glyf_data && @loca_offsets
374
-
375
- loca = font.table("loca")
376
- head = font.table("head")
377
-
378
- # Ensure loca is parsed
379
- unless loca.parsed?
380
- maxp = font.table("maxp")
381
- loca.parse_with_context(head.index_to_loc_format, maxp.num_glyphs)
382
- end
383
-
384
- @glyf_data = String.new(encoding: Encoding::BINARY)
385
- @loca_offsets = []
386
- current_offset = 0
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
-
399
- # Process glyphs in mapping order
400
- mapping.old_ids.each do |old_id|
401
- @loca_offsets << current_offset
402
-
403
- # Get offset and size from original loca
404
- offset = loca.offset_for(old_id)
405
- size = loca.size_of(old_id)
406
-
407
- # Empty glyph
408
- if size.nil? || size.zero?
409
- next
410
- end
411
-
412
- # Extract glyph data
413
- glyph_data = glyf_table.raw_data[offset, size]
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
-
430
- # Check if compound glyph and remap components
431
- if compound_glyph?(glyph_data)
432
- glyph_data = remap_compound_glyph(glyph_data)
433
- end
434
-
435
- # Add to new glyf data
436
- @glyf_data << glyph_data
437
- current_offset += glyph_data.bytesize
438
- end
439
-
440
- # Add final offset
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]
447
- end
448
-
449
- # Check if glyph data represents a compound glyph
450
- #
451
- # @param data [String] Glyph binary data
452
- # @return [Boolean] True if compound glyph
453
- def compound_glyph?(data)
454
- return false if data.length < 2
455
-
456
- num_contours_raw = data[0, 2].unpack1("n")
457
- num_contours = to_signed_16(num_contours_raw)
458
- num_contours == -1
459
- end
460
-
461
- # Remap component glyph IDs in compound glyph
462
- #
463
- # @param data [String] Original compound glyph data
464
- # @return [String] Remapped compound glyph data
465
- def remap_compound_glyph(data)
466
- # Create a mutable copy
467
- new_data = data.dup
468
- offset = 10 # Skip header (10 bytes)
469
-
470
- loop do
471
- break if offset >= new_data.length - 4
472
-
473
- # Read flags and old glyph index
474
- flags = new_data[offset, 2].unpack1("n")
475
- old_glyph_index = new_data[offset + 2, 2].unpack1("n")
476
-
477
- # Remap glyph index
478
- new_glyph_index = mapping.new_id(old_glyph_index)
479
- unless new_glyph_index
480
- raise Fontisan::SubsettingError,
481
- "Component glyph #{old_glyph_index} not in subset"
482
- end
483
-
484
- # Write new glyph index
485
- new_data[offset + 2, 2] = [new_glyph_index].pack("n")
486
-
487
- # Move to next component
488
- offset += 4 # flags + glyph_index
489
-
490
- # Skip arguments
491
- offset += if (flags & 0x0001).zero?
492
- 2 # Two 8-bit arguments
493
- else
494
- 4 # Two 16-bit arguments
495
- end
496
-
497
- # Skip transformation
498
- if (flags & 0x0080) != 0
499
- offset += 8 # 2x2 matrix
500
- elsif (flags & 0x0040) != 0
501
- offset += 4 # X and Y scale
502
- elsif (flags & 0x0008) != 0
503
- offset += 2 # Uniform scale
504
- end
505
-
506
- # Check if more components
507
- break unless (flags & 0x0020) != 0
508
- end
509
-
510
- new_data
511
- end
512
-
513
- # Build cmap binary from mappings
514
- #
515
- # Creates a minimal cmap table with format 4 subtable for BMP
516
- # and format 12 for supplementary planes if needed.
517
- #
518
- # @param mappings [Hash<Integer, Integer>] Char code => new glyph ID
519
- # @return [String] Binary cmap data
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
642
- end
643
-
644
- # Build post table version 3.0 (no glyph names)
645
- #
646
- # @param table [Post] Original post table
647
- # @return [String] Binary post v3.0 data
648
- def build_post_v3(_table)
649
- # Post v3.0 header (32 bytes) - same as v2.0 but version = 3.0
650
- data = String.new(encoding: Encoding::BINARY)
651
-
652
- # Version 3.0
653
- data << [0x00030000].pack("N")
654
-
655
- # Copy italic angle, underline position/thickness from original
656
- original_data = font.table_data["post"]
657
- data << if original_data.length >= 32
658
- # Copy fields from offset 4 to 32
659
- original_data[4, 28]
660
- else
661
- # Use defaults
662
- [0, 0, 0, 0, 0, 0, 0].pack("N7")
663
- end
664
-
665
- data
666
- end
667
-
668
- # Convert unsigned 16-bit value to signed
669
- #
670
- # @param value [Integer] Unsigned 16-bit value
671
- # @return [Integer] Signed 16-bit value
672
- def to_signed_16(value)
673
- value > 0x7FFF ? value - 0x10000 : value
104
+ def subset_context
105
+ SubsetContext.new(font: font, mapping: mapping, options: options,
106
+ state: state)
674
107
  end
675
108
  end
676
109
  end