fontisan 0.4.9 → 0.4.11

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.
@@ -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
@@ -15,14 +15,49 @@ module Fontisan
15
15
  # #bitmap_mode. When a source is :cbdt, the Stitcher propagates
16
16
  # the raw CBDT/CBLC tables into the output instead of extracting
17
17
  # outlines. The glyph data lives in the bitmap tables, not in glyf.
18
+ #
19
+ # = Codepoint remap
20
+ #
21
+ # Some donor fonts ship glyphs at non-canonical codepoints — a
22
+ # keyboard-mapped font whose "A" is at U+0041, or a PUA-allocated
23
+ # pre-Unicode font. Pass a +remap:+ hash at construction to expose
24
+ # those glyphs at their canonical codepoints without mutating the
25
+ # donor font's cmap:
26
+ #
27
+ # Source.new(font, remap: { 0x41 => 0x11DB0 })
28
+ #
29
+ # With a remap set, the source answers +gid_for_codepoint+ for the
30
+ # *target* codepoints (0x11DB0 in the example) by translating them
31
+ # to the donor's *source* codepoints (0x41). Source codepoints not
32
+ # listed in the remap are hidden — only the remapped coverage is
33
+ # exposed. This matches the typical "use this donor for exactly
34
+ # these output characters" intent, and avoids leaking the donor's
35
+ # ASCII/PUA noise into the output.
18
36
  class Source
19
37
  MAX_COMPOUND_DEPTH = 32
20
38
 
21
- attr_reader :font
39
+ attr_reader :font, :remap
22
40
 
23
- def initialize(font)
41
+ # @param font [TrueTypeFont, OpenTypeFont, Ufo::Font] donor font
42
+ # @param remap [Hash{Integer=>Integer}, nil] optional
43
+ # {source_codepoint => target_codepoint}. When present (even
44
+ # empty), source codepoints not listed are hidden from
45
+ # lookups. Pass +nil+ (the default) for the passthrough
46
+ # behavior — every donor codepoint is exposed as-is.
47
+ def initialize(font, remap: nil)
24
48
  @font = font
25
49
  @bin_data_cache = nil
50
+
51
+ # Distinguish "no remap kwarg" (nil → passthrough) from
52
+ # "explicit empty remap" ({} → drop everything). The latter
53
+ # still flips the source into remapped mode, just with no
54
+ # entries — every donor codepoint gets filtered out.
55
+ return unless remap
56
+
57
+ @remap = remap.to_h.transform_keys(&:to_i).transform_values(&:to_i)
58
+ @inverse_remap = @remap.each_with_object({}) do |(src, target), h|
59
+ h[target] = src
60
+ end
26
61
  end
27
62
 
28
63
  # @return [Symbol] :ufo, :ttf, :otf
@@ -57,12 +92,21 @@ module Fontisan
57
92
  end
58
93
 
59
94
  # Find the gid for a Unicode codepoint in this source.
95
+ #
96
+ # When +remap+ was set at construction, +codepoint+ is treated as
97
+ # a *target* codepoint — the source codepoint that maps to it via
98
+ # +remap+ is what actually gets looked up. Returns +nil+ for
99
+ # codepoints the remap does not cover.
100
+ #
60
101
  # @param codepoint [Integer]
61
102
  # @return [Integer, nil]
62
103
  def gid_for_codepoint(codepoint)
104
+ src_cp = source_codepoint_for(codepoint)
105
+ return nil unless src_cp
106
+
63
107
  case @font
64
- when Fontisan::Ufo::Font then ufo_gid_for(codepoint)
65
- else bin_data_gid_for(codepoint)
108
+ when Fontisan::Ufo::Font then ufo_gid_for(src_cp)
109
+ else bin_data_gid_for(src_cp)
66
110
  end
67
111
  end
68
112
 
@@ -317,14 +361,51 @@ module Fontisan
317
361
  end
318
362
 
319
363
  # Add Unicode codepoints from the cmap that map to this gid.
364
+ #
365
+ # When +remap+ was set at construction, the source's raw codepoints
366
+ # are translated through the remap (source→target), and source
367
+ # codepoints not in the remap are dropped. Otherwise the raw cmap
368
+ # codepoints are attached as-is.
320
369
  def add_cmap_unicodes(gid, glyph)
321
- cmap = @font.table("cmap")
322
- return unless cmap
370
+ effective_codepoints_for_gid(gid).each { |cp| glyph.add_unicode(cp) }
371
+ end
372
+
373
+ # Translate a caller-facing codepoint to the donor's own codepoint.
374
+ # Without remap, the two are identical. With remap, the caller's
375
+ # codepoint is the *target*; look up the source codepoint that
376
+ # maps to it. Returns +nil+ if no remap entry covers the target.
377
+ def source_codepoint_for(codepoint)
378
+ return codepoint unless @remap
323
379
 
324
- (cmap.unicode_mappings || {}).each do |cp, g|
325
- glyph.add_unicode(cp) if g == gid
380
+ @inverse_remap[codepoint]
381
+ end
382
+
383
+ # Codepoints that should be attached to a glyph at the given gid,
384
+ # after applying the remap (if any).
385
+ def effective_codepoints_for_gid(gid)
386
+ raw_cmap = self.raw_cmap
387
+ raw_cmap.each_with_object([]) do |(src_cp, g), cps|
388
+ next unless g == gid
389
+
390
+ if @remap
391
+ target = @remap[src_cp]
392
+ cps << target if target
393
+ else
394
+ cps << src_cp
395
+ end
326
396
  end
327
397
  end
398
+
399
+ # The raw cmap hash from the donor's table. Always {src_cp => gid};
400
+ # never remapped. UFO sources return {} (no cmap table).
401
+ def raw_cmap
402
+ cmap = @font.table("cmap")
403
+ return {} unless cmap
404
+
405
+ cmap.unicode_mappings || {}
406
+ rescue StandardError
407
+ {}
408
+ end
328
409
  end
329
410
  end
330
411
  end
@@ -32,6 +32,7 @@ module Fontisan
32
32
  autoload :GlyphLimit, "fontisan/stitcher/glyph_limit"
33
33
  autoload :CollectionResult, "fontisan/stitcher/collection_result"
34
34
  autoload :SubfontStats, "fontisan/stitcher/collection_result"
35
+ autoload :FormatMetadata, "fontisan/stitcher/format_metadata"
35
36
  autoload :PartitionStrategy, "fontisan/stitcher/partition_strategy"
36
37
 
37
38
  # Internal: pairs a compiled loaded font with its stats so
@@ -51,8 +52,8 @@ module Fontisan
51
52
  @deduplicate = deduplicate
52
53
  end
53
54
 
54
- def add_source(label, font)
55
- @sources[label.to_sym] = Source.new(font)
55
+ def add_source(label, font, remap: nil)
56
+ @sources[label.to_sym] = Source.new(font, remap: remap)
56
57
  end
57
58
 
58
59
  def include_range(range, from:, into:)
@@ -94,8 +95,8 @@ module Fontisan
94
95
  target = build_target_for(subfont)
95
96
  GlyphLimit.check!(target.glyphs.size, format: format)
96
97
 
97
- compiler = compiler_for(format)
98
- compiler.new(target).compile(output_path: path)
98
+ metadata = FormatMetadata.resolve(format)
99
+ metadata.compiler_class.new(target).compile(output_path: path)
99
100
 
100
101
  propagate_cbdt_tables(path) if cbdt_source
101
102
  path
@@ -109,7 +110,7 @@ module Fontisan
109
110
  end
110
111
  fonts = compiled.map(&:font)
111
112
 
112
- collection_format = collection_format_for(format)
113
+ collection_format = FormatMetadata.resolve(format).collection_format
113
114
  Collection::Builder.new(fonts, format: collection_format,
114
115
  optimize: true).build_to_file(path)
115
116
 
@@ -138,20 +139,6 @@ module Fontisan
138
139
  cbdts.first
139
140
  end
140
141
 
141
- def compiler_for(format)
142
- case format.to_sym
143
- when :ttf then Ufo::Compile::TtfCompiler
144
- when :otf then Ufo::Compile::OtfCompiler
145
- when :otf2 then Ufo::Compile::Otf2Compiler
146
- else
147
- raise ArgumentError, "unknown format: #{format.inspect}"
148
- end
149
- end
150
-
151
- def collection_format_for(subfont_format)
152
- subfont_format == :ttf ? :ttc : :otc
153
- end
154
-
155
142
  def build_target_for(subfont_name)
156
143
  bindings = @subfonts[subfont_name] || []
157
144
  target = Ufo::Font.new
@@ -161,19 +148,14 @@ module Fontisan
161
148
  target
162
149
  end
163
150
 
164
- def compile_subfont_to_loaded_font(subfont_name, format:)
165
- compile_subfont_with_stats(subfont_name, format: format).font
166
- end
167
-
168
151
  def compile_subfont_with_stats(subfont_name, format:)
169
152
  target = build_target_for(subfont_name)
170
153
  GlyphLimit.check!(target.glyphs.size, format: format)
171
154
 
172
- ext = format == :ttf ? ".ttf" : ".otf"
155
+ metadata = FormatMetadata.resolve(format)
173
156
  Dir.mktmpdir do |dir|
174
- sub_path = File.join(dir, "sub#{subfont_name}#{ext}")
175
- compiler = compiler_for(format)
176
- compiler.new(target).compile(output_path: sub_path)
157
+ sub_path = File.join(dir, "sub#{subfont_name}#{metadata.extension}")
158
+ metadata.compiler_class.new(target).compile(output_path: sub_path)
177
159
  propagate_cbdt_tables(sub_path) if cbdt_source
178
160
 
179
161
  loaded = Fontisan::FontLoader.load(sub_path)
@@ -417,12 +417,130 @@ module Fontisan
417
417
  # Creates a minimal cmap table with format 4 subtable for BMP
418
418
  # and format 12 for supplementary planes if needed.
419
419
  #
420
- # @param mappings [Hash<Integer, Integer>] Char code => glyph ID
420
+ # @param mappings [Hash<Integer, Integer>] Char code => new glyph ID
421
421
  # @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"]
422
+ def build_cmap_binary(mappings)
423
+ # Edge case: empty mappings (e.g., block with no covered chars).
424
+ # Emit a minimal valid cmap with one format 4 subtable mapping
425
+ # only U+0000 → .notdef so the table isn't empty.
426
+ mappings = { 0 => 0 } if mappings.empty?
427
+
428
+ bmp = mappings.select { |cp, _| cp <= 0xFFFF }
429
+ supp = mappings.select { |cp, _| cp > 0xFFFF }
430
+
431
+ subtables = []
432
+ records = [] # [platform_id, encoding_id, subtable_index]
433
+
434
+ unless bmp.empty?
435
+ subtables << build_cmap_format_4(bmp)
436
+ idx = subtables.size - 1
437
+ records << [3, 1, idx] # Windows BMP
438
+ records << [0, 3, idx] # Unicode BMP
439
+ end
440
+
441
+ unless supp.empty?
442
+ # Format 12 covers both BMP and supplementary — include all
443
+ # mappings so a single subtable covers the full range.
444
+ subtables << build_cmap_format_12(mappings)
445
+ idx = subtables.size - 1
446
+ records << [3, 10, idx] # Windows UCS-4
447
+ records << [0, 4, idx] # Unicode full
448
+ end
449
+
450
+ # Header: version (uint16) + numTables (uint16)
451
+ num_tables = records.size
452
+ header = [0, num_tables].pack("nn")
453
+
454
+ # Encoding records start immediately after the header.
455
+ # Each record is 8 bytes; subtables follow.
456
+ subtable_base = 4 + (8 * num_tables)
457
+
458
+ offsets = []
459
+ running = subtable_base
460
+ subtables.each do |st|
461
+ offsets << running
462
+ running += st.bytesize
463
+ end
464
+
465
+ record_bytes = +""
466
+ records.each do |pid, eid, idx|
467
+ record_bytes << [pid, eid, offsets[idx]].pack("nnN")
468
+ end
469
+
470
+ header + record_bytes + subtables.join
471
+ end
472
+
473
+ # Format 4 subtable: segment-mapping with idDelta, suitable for
474
+ # BMP codepoints (U+0000..U+FFFF). Builds compact segments where
475
+ # consecutive codepoints map to consecutive glyph IDs.
476
+ def build_cmap_format_4(bmp_mappings)
477
+ segments = coalesce_segments(bmp_mappings)
478
+ # Mandatory final segment: U+FFFF → gid 0 (per OpenType spec).
479
+ segments << { start_cp: 0xFFFF, end_cp: 0xFFFF, start_gid: 0 }
480
+
481
+ seg_count = segments.size
482
+ seg_count_x2 = seg_count * 2
483
+ search_range = 2**Math.log2(seg_count).floor * 2
484
+ search_range = 2 if search_range < 2
485
+ entry_selector = Math.log2(search_range / 2).to_i
486
+ range_shift = seg_count_x2 - search_range
487
+
488
+ end_codes = segments.map { |s| s[:end_cp] }
489
+ start_codes = segments.map { |s| s[:start_cp] }
490
+ # idDelta is int16 stored as uint16 (two's complement). For a
491
+ # sequential segment, idDelta = (start_gid - start_cp) & 0xFFFF.
492
+ id_deltas = segments.map { |s| (s[:start_gid] - s[:start_cp]) & 0xFFFF }
493
+ id_range_offsets = [0] * seg_count
494
+
495
+ subtable = +""
496
+ subtable << [4, 0, 0, seg_count_x2,
497
+ search_range, entry_selector, range_shift].pack("n*")
498
+ subtable << end_codes.pack("n*")
499
+ subtable << [0].pack("n") # reservedPad
500
+ subtable << start_codes.pack("n*")
501
+ subtable << id_deltas.pack("n*")
502
+ subtable << id_range_offsets.pack("n*")
503
+
504
+ # Patch the length field (was placeholder 0).
505
+ subtable[2, 2] = [subtable.bytesize].pack("n")
506
+ subtable
507
+ end
508
+
509
+ # Format 12 subtable: segmented coverage for full Unicode range.
510
+ # Simpler than format 4 — just (start_char, end_char, start_gid)
511
+ # triples with no delta/offset indirection.
512
+ def build_cmap_format_12(all_mappings)
513
+ groups = coalesce_segments(all_mappings)
514
+ num_groups = groups.size
515
+
516
+ subtable = +""
517
+ subtable << [12, 0, 0, 0, num_groups].pack("nnNNN")
518
+ groups.each do |g|
519
+ subtable << [g[:start_cp], g[:end_cp], g[:start_gid]].pack("NNN")
520
+ end
521
+
522
+ # Patch the length field (was placeholder 0). Total length is
523
+ # 16-byte header + 12 bytes per group.
524
+ subtable[4, 4] = [subtable.bytesize].pack("N")
525
+ subtable
526
+ end
527
+
528
+ # Group codepoints into consecutive runs where both codepoint AND
529
+ # glyph ID are sequential. Each run becomes one segment/group.
530
+ def coalesce_segments(mappings)
531
+ sorted = mappings.sort_by { |cp, _| cp }
532
+ segments = []
533
+ current = nil
534
+ sorted.each do |cp, gid|
535
+ if current && cp == current[:end_cp] + 1 && gid == current[:start_gid] + (cp - current[:start_cp])
536
+ current[:end_cp] = cp
537
+ else
538
+ segments << current if current
539
+ current = { start_cp: cp, end_cp: cp, start_gid: gid }
540
+ end
541
+ end
542
+ segments << current if current
543
+ segments
426
544
  end
427
545
 
428
546
  # Build post table version 3.0 (no glyph names)
@@ -28,7 +28,7 @@ module Fontisan
28
28
  # @return [TrueTypeFont, OpenTypeFont] Font instance
29
29
  attr_reader :font
30
30
 
31
- # @return [Hash] Glyph data map (glyph_id => {outline, unicode, name, advance})
31
+ # @return [Hash] Glyph data map (glyph_id => {outline, codepoints, name, advance})
32
32
  attr_reader :glyph_data
33
33
 
34
34
  # @return [Hash] Generation options
@@ -179,7 +179,7 @@ module Fontisan
179
179
 
180
180
  glyph_generator.generate_glyph_xml(
181
181
  data[:outline],
182
- unicode: data[:unicode],
182
+ codepoints: data[:codepoints],
183
183
  glyph_name: data[:name],
184
184
  advance_width: data[:advance],
185
185
  indent: " ",