fontisan 0.4.9 → 0.4.10

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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 38b49343b74d1876d57ec13992fc49fdc2879a44d1f0fe3c6529bd3a2f8f7a75
4
- data.tar.gz: cc69156566e86294177d9046f39ebde8334f87807da38a7a5dd83ddd2eea8247
3
+ metadata.gz: 797250db52b46986a1458fd680d2c2f2eab6896ba1bcf617ba0d74622ce7ff51
4
+ data.tar.gz: 81cf20d3aff4f40b33e0fec8cc8de359a1a4acf139d14fe75ec3a4fc839ac827
5
5
  SHA512:
6
- metadata.gz: 2822ae02861c4183bbd42ded8005fa17d28d59175155767413f94b4400faaf28fb61dad38736f3fcd11235746e1b254151c8e8918480b5ccb146882742c3ec99
7
- data.tar.gz: eefb0dbf8e3360e7b8aeb89dfcfbf62425c255075ee1a3d2c030cd2551f90f08d980f7ea7b8c56c382b0a1cb69933654a57fc4e57d069ebe6809ace84e7d0b42
6
+ metadata.gz: dc92df9fbb61326d199f51ceda010e8f990cd109355679d463066e0f590a461e71241239b6b90f3a1acf5832362fba778d51833d24eb01c46b42b95c488c3550
7
+ data.tar.gz: c331cceb05fd29864a85e4e7d7dca99aa96c9c2ef152c3e877842aaffd0a82d1ad46e6770d5e80d3799bb67a905306a1eb6e5b4d18d03f5c8a02072c030c33f8
@@ -165,8 +165,18 @@ must either raise the cap, drop codepoints, or switch to a format
165
165
  with a higher glyph limit.
166
166
 
167
167
  The Unicode plane metadata used by ByPlane lives in
168
- `Fontisan::Unicode::Plane` (`Plane.of(cp)`, `Plane.label(n)`,
169
- `Plane::LARGE_CJK_BLOCKS`).
168
+ `Fontisan::Unicode::Plane` (`Plane.of(cp)`, `Plane.label(n)`).
169
+
170
+ The sub-plane block boundaries used for sub-splitting live on ByPlane
171
+ itself:
172
+
173
+ * `ByPlane::ATOMIC_BLOCKS` — five SIP CJK extension blocks that cannot
174
+ be sub-split; raise if one alone exceeds `cap`.
175
+ * `ByPlane::CARVE_OUT_BLOCKS` — BMP-resident blocks (CJK Unified
176
+ Ideographs, Hangul Syllables) that get their own partition bucket
177
+ when BMP overflows `cap`; chunkable, never raised.
178
+ * `ByPlane::RECOGNIZED_BLOCKS` — union of the two; the lookup table
179
+ the bucketing pass walks.
170
180
 
171
181
  === Partition / Blueprint
172
182
 
@@ -145,7 +145,7 @@ palette_index: 0)
145
145
  #
146
146
  # @param font [TrueTypeFont, OpenTypeFont] Source font
147
147
  # @param options [Hash] Extraction options
148
- # @return [Hash] Glyph data map (glyph_id => {outline, unicode, name, advance})
148
+ # @return [Hash] Glyph data map (glyph_id => {outline, codepoints, name, advance})
149
149
  def extract_glyph_data(font, options = {})
150
150
  extractor = OutlineExtractor.new(font)
151
151
  cmap = font.table("cmap")
@@ -157,33 +157,20 @@ palette_index: 0)
157
157
  num_glyphs = maxp&.num_glyphs || 0
158
158
  max_glyphs = options[:max_glyphs] || num_glyphs
159
159
 
160
- # Get unicode mappings
161
- unicode_map = build_unicode_map(cmap)
160
+ gid_to_codepoints = build_glyph_to_codepoints(cmap)
161
+ glyph_names = post&.glyph_names || []
162
162
 
163
- # Extract specified or all glyphs
164
163
  glyph_ids = options[:glyph_ids] || (0...num_glyphs).to_a
165
164
  glyph_ids = glyph_ids.take(max_glyphs) if max_glyphs
166
165
 
167
166
  glyph_ids.each do |glyph_id|
168
167
  next if glyph_id >= num_glyphs
169
168
 
170
- # Extract outline
171
- outline = extractor.extract(glyph_id)
172
-
173
- # Get advance width
174
- advance = extract_advance_width(hmtx, glyph_id)
175
-
176
- # Get unicode character
177
- unicode = unicode_map[glyph_id]
178
-
179
- # Get glyph name
180
- glyph_name = extract_glyph_name(post, glyph_id)
181
-
182
169
  glyph_data[glyph_id] = {
183
- outline: outline,
184
- unicode: unicode,
185
- name: glyph_name,
186
- advance: advance,
170
+ outline: extractor.extract(glyph_id),
171
+ codepoints: gid_to_codepoints[glyph_id] || [],
172
+ name: glyph_name_for(glyph_names, glyph_id),
173
+ advance: extract_advance_width(hmtx, glyph_id),
187
174
  }
188
175
  rescue StandardError => e
189
176
  warn "Failed to extract glyph #{glyph_id}: #{e.message}"
@@ -193,61 +180,38 @@ palette_index: 0)
193
180
  glyph_data
194
181
  end
195
182
 
196
- # Build unicode to glyph ID map from cmap table
183
+ # Build reverse cmap: glyph_id => [codepoint, ...].
197
184
  #
198
- # @param cmap [Tables::Cmap, nil] Cmap table
199
- # @return [Hash<Integer, String>] Map of glyph_id to unicode character
200
- def build_unicode_map(cmap)
185
+ # A glyph can be referenced by multiple codepoints (e.g. space
186
+ # and non-breaking space might share a glyph). Uses
187
+ # +cmap.unicode_mappings+ directly — that is the canonical
188
+ # {codepoint => gid} hash.
189
+ #
190
+ # @param cmap [Tables::Cmap, nil]
191
+ # @return [Hash<Integer, Array<Integer>>]
192
+ def build_glyph_to_codepoints(cmap)
201
193
  return {} unless cmap
202
194
 
203
- unicode_map = {}
204
-
205
- # Get best cmap subtable (prefer Unicode BMP or full)
206
- subtable = find_best_cmap_subtable(cmap)
207
- return {} unless subtable
208
-
209
- # Build reverse map: glyph_id => unicode
210
- subtable.each do |code_point, glyph_id|
211
- # Store first unicode for each glyph
212
- next if unicode_map[glyph_id]
213
-
214
- unicode_map[glyph_id] = [code_point].pack("U")
215
- rescue StandardError
216
- # Skip invalid code points
217
- next
195
+ cmap.unicode_mappings.each_with_object(Hash.new { |h, k| h[k] = [] }) do |(cp, gid), h|
196
+ h[gid] << cp
218
197
  end
219
-
220
- unicode_map
221
198
  rescue StandardError => e
222
- warn "Failed to build unicode map: #{e.message}"
199
+ warn "Failed to build glyph to codepoints map: #{e.message}"
223
200
  {}
224
201
  end
225
202
 
226
- # Find best cmap subtable for unicode mapping
203
+ # Look up glyph name from the post table's per-gid name array.
227
204
  #
228
- # @param cmap [Tables::Cmap] Cmap table
229
- # @return [Hash, nil] Subtable or nil
230
- def find_best_cmap_subtable(cmap)
231
- # Try Unicode BMP (platform 3, encoding 1) - Windows Unicode BMP
232
- subtable = cmap.subtable(3, 1)
233
- return subtable if subtable
234
-
235
- # Try Unicode full (platform 3, encoding 10) - Windows Unicode full
236
- subtable = cmap.subtable(3, 10)
237
- return subtable if subtable
238
-
239
- # Try Unicode (platform 0, encoding 3) - Unicode 2.0+ BMP
240
- subtable = cmap.subtable(0, 3)
241
- return subtable if subtable
242
-
243
- # Try Unicode (platform 0, encoding 4) - Unicode 2.0+ full
244
- subtable = cmap.subtable(0, 4)
245
- return subtable if subtable
246
-
247
- # Fallback to any available subtable
248
- cmap.subtables.first
249
- rescue StandardError
250
- nil
205
+ # Falls back to +"gidN"+ when the post table has no name for the
206
+ # given gid (post version 3.0 fonts omit per-glyph names, and
207
+ # version 2.0 may have gaps). Always returns a non-nil string so
208
+ # the SVG <glyph> element carries a useful glyph-name attribute.
209
+ #
210
+ # @param glyph_names [Array<String>] post.glyph_names
211
+ # @param glyph_id [Integer]
212
+ # @return [String]
213
+ def glyph_name_for(glyph_names, glyph_id)
214
+ glyph_names[glyph_id] || "gid#{glyph_id}"
251
215
  end
252
216
 
253
217
  # Extract advance width for glyph
@@ -265,22 +229,6 @@ palette_index: 0)
265
229
  rescue StandardError
266
230
  0
267
231
  end
268
-
269
- # Extract glyph name from post table
270
- #
271
- # @param post [Tables::Post, nil] Post table
272
- # @param glyph_id [Integer] Glyph ID
273
- # @return [String, nil] Glyph name or nil
274
- def extract_glyph_name(post, glyph_id)
275
- return nil unless post
276
-
277
- name = post.glyph_name_for(glyph_id)
278
- return nil if name.nil? || name.empty?
279
-
280
- name
281
- rescue StandardError
282
- nil
283
- end
284
232
  end
285
233
  end
286
234
  end
@@ -59,8 +59,16 @@ module Fontisan
59
59
  header.num_tables
60
60
  }
61
61
 
62
- # Table data is stored separately since it's at variable offsets
63
- attr_accessor :table_data
62
+ # Table data is stored separately since it's at variable offsets.
63
+ # The setter normalizes all keys to UTF-8 so Hash lookups don't
64
+ # silently miss due to encoding mismatch — BinData-derived tag
65
+ # strings are ASCII-8BIT, literals callers pass are usually UTF-8,
66
+ # and String#eql? is encoding-sensitive.
67
+ attr_reader :table_data
68
+
69
+ def table_data=(hash)
70
+ @table_data = hash.transform_keys { |tag| normalize_tag(tag) }
71
+ end
64
72
 
65
73
  # Parsed table instances cache
66
74
  attr_accessor :parsed_tables
@@ -470,6 +478,8 @@ module Fontisan
470
478
  # @return [Tables::*, nil] Parsed table object or nil if not found
471
479
  # @raise [ArgumentError] if table is not available in current loading mode
472
480
  def table(tag)
481
+ tag = normalize_tag(tag)
482
+
473
483
  # Check mode restrictions
474
484
  unless table_available?(tag)
475
485
  if has_table?(tag)
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Fontisan
4
+ class Stitcher
5
+ # Immutable metadata about a stitcher output format. Single source
6
+ # of truth for "given a format symbol, what compiler / collection
7
+ # format / file extension does it use?". Adding a new format =
8
+ # adding a +when+ branch in {resolve}; no other site in Stitcher
9
+ # needs to know the mapping.
10
+ class FormatMetadata
11
+ attr_reader :name, :compiler_class, :collection_format, :extension
12
+
13
+ # @param name [Symbol] canonical format name (:ttf, :otf, :otf2)
14
+ # @param compiler_class [Class] the +Ufo::Compile::*Compiler+ that
15
+ # compiles a +Ufo::Font+ target into the format's binary
16
+ # @param collection_format [Symbol] :ttc or :otc — the
17
+ # +Collection::Builder+ format used when packing multiple
18
+ # subfonts of this format into a collection
19
+ # @param extension [String] file extension including the dot
20
+ def initialize(name:, compiler_class:, collection_format:, extension:)
21
+ @name = name
22
+ @compiler_class = compiler_class
23
+ @collection_format = collection_format
24
+ @extension = extension
25
+ end
26
+
27
+ # Resolve a format name (Symbol or String) to its metadata.
28
+ # Constants are referenced inside +when+ branches so autoload
29
+ # fires only for the requested format, not all of them.
30
+ #
31
+ # @param name [Symbol, String]
32
+ # @raise [ArgumentError] if +name+ is not a known stitcher format
33
+ # @return [FormatMetadata]
34
+ def self.resolve(name)
35
+ case name.to_sym
36
+ when :ttf
37
+ new(name: :ttf, compiler_class: Ufo::Compile::TtfCompiler,
38
+ collection_format: :ttc, extension: ".ttf")
39
+ when :otf
40
+ new(name: :otf, compiler_class: Ufo::Compile::OtfCompiler,
41
+ collection_format: :otc, extension: ".otf")
42
+ when :otf2
43
+ new(name: :otf2, compiler_class: Ufo::Compile::Otf2Compiler,
44
+ collection_format: :otc, extension: ".otf")
45
+ else
46
+ raise ArgumentError, "unknown format: #{name.inspect}"
47
+ end
48
+ end
49
+ end
50
+ end
51
+ end
@@ -6,9 +6,38 @@ module Fontisan
6
6
  # Abstract base. Concrete partitioners (ByPlane, ByBlock, …)
7
7
  # implement {#call} and return a {Blueprint}.
8
8
  class Base
9
- # Default cap: 65,535 .notdef safety margin. Matches the
10
- # Stitcher's own +GlyphLimit+ cap for TTF.
11
- DEFAULT_CAP = 65_484
9
+ # Glyph-cap headroom below the format's hard cap
10
+ # (GlyphLimit::TTF_GLYPH_CAP / OTF_GLYPH_CAP = 65,535). The
11
+ # partitioner must keep each partition's *codepoint* count low
12
+ # enough that compile-time *glyph* expansion still fits under
13
+ # the hard cap.
14
+ #
15
+ # Reserved slots:
16
+ # - .notdef (1 slot, mandated by OpenType — every font has it
17
+ # at GID 0 even when no codepoint binds to it)
18
+ # - composite expansion: compiling adds composites (ligatures,
19
+ # decomposable glyphs, variant attachments) on top of the
20
+ # source codepoint count. Empirically ~5% of typical BMP
21
+ # size for CJK-heavy fonts (≈2,500 composites on a 50k-cp
22
+ # BMP font). 5,534 slots covers that with margin.
23
+ NOTDEF_RESERVED = 1
24
+ COMPOSITE_HEADROOM = 5_534
25
+
26
+ # Derive the partition cap from a format's hard glyph limit.
27
+ # Mirrors GlyphLimit so a future bump (e.g. CFF2 card24 INDEX
28
+ # counts) cascades automatically — no second magic number to
29
+ # keep in sync.
30
+ #
31
+ # @param format [Symbol] :ttf, :otf, or :otf2
32
+ # @return [Integer]
33
+ def self.cap_for(format)
34
+ GlyphLimit.for_format(format) - NOTDEF_RESERVED - COMPOSITE_HEADROOM
35
+ end
36
+
37
+ # Default cap for the most common format (TTF, where the glyph
38
+ # cap equals 65,535). Use {.cap_for} when partitioning for a
39
+ # different format.
40
+ DEFAULT_CAP = cap_for(:ttf)
12
41
 
13
42
  # @param cp_map [Hash{Integer=>Object}] codepoint → donor label
14
43
  # @param cap [Integer] max codepoints per partition
@@ -8,15 +8,47 @@ module Fontisan
8
8
  # For each plane with codepoints:
9
9
  # - if the count fits under +cap+, emit one partition named
10
10
  # +:plane_<n>+ (e.g. +:plane_0+, +:plane_2+);
11
- # - otherwise sub-split using the large CJK extension block
12
- # boundaries, naming partitions +:plane_<n>_a+, +:plane_<n>_b+,
13
- # and so on.
11
+ # - otherwise sub-split along the recognized sub-plane block
12
+ # boundaries (+RECOGNIZED_BLOCKS+), naming partitions
13
+ # +:plane_<n>_a+, +:plane_<n>_b+, and so on.
14
14
  #
15
- # If a single CJK extension block alone exceeds +cap+, raises
16
- # {Fontisan::GlyphLimitExceededError} — the partitioner cannot
15
+ # If a single +ATOMIC_BLOCKS+ entry alone exceeds +cap+, raises
16
+ # {Fontisan::PartitionCapExceededError} — the partitioner cannot
17
17
  # satisfy the cap and the caller must use a smaller cap, split
18
18
  # manually, or switch to a format with a higher glyph limit.
19
+ # +CARVE_OUT_BLOCKS+ entries are chunkable and will be sliced
20
+ # like +:other+ if they exceed +cap+.
19
21
  class ByPlane < Base
22
+ # Atomic sub-plane blocks: cannot be sub-split further because
23
+ # no finer-grained Unicode boundary exists inside them. If one
24
+ # alone exceeds +cap+, raise PartitionCapExceededError.
25
+ #
26
+ # Labels follow the Unicode Blocks.txt block names with spaces
27
+ # replaced by underscores.
28
+ ATOMIC_BLOCKS = {
29
+ "CJK_Ext_B" => 0x2A700..0x2B73F,
30
+ "CJK_Ext_C" => 0x2B740..0x2B81F,
31
+ "CJK_Ext_D" => 0x2B820..0x2CEAF,
32
+ "CJK_Ext_E" => 0x2CEB0..0x2EBEF,
33
+ "CJK_Ext_F" => 0x2EBF0..0x2EE5F,
34
+ }.freeze
35
+
36
+ # Carve-out boundaries: sub-plane regions large enough to
37
+ # warrant their own partition bucket when a plane overflows
38
+ # +cap+. Unlike atomic blocks, these are chunkable — if one
39
+ # alone exceeds +cap+, it gets sliced into +cap+-sized chunks
40
+ # like +:other+ does, never raised.
41
+ CARVE_OUT_BLOCKS = {
42
+ "CJK_Unified_Ideographs" => 0x4E00..0x9FFF,
43
+ "Hangul_Syllables" => 0xAC00..0xD7AF,
44
+ }.freeze
45
+
46
+ # Every codepoint range recognized as a distinct sub-plane
47
+ # bucket. Atomic blocks first, then carve-out blocks. Used by
48
+ # the bucketing pass to decide what gets its own partition vs.
49
+ # falls into +:other+.
50
+ RECOGNIZED_BLOCKS = ATOMIC_BLOCKS.merge(CARVE_OUT_BLOCKS).freeze
51
+
20
52
  # @param cp_map [Hash{Integer=>Object}] codepoint → donor label
21
53
  # @param cap [Integer] max codepoints per partition
22
54
  # @return [Blueprint]
@@ -50,20 +82,21 @@ module Fontisan
50
82
  )
51
83
  end
52
84
 
53
- # When a plane overflows +cap+, carve it along the large CJK
54
- # extension block boundaries. The +:other+ bucket (everything
55
- # outside the known mega-blocks) is split into chunks of +cap+,
56
- # while each known large block becomes one partition atomically —
57
- # if a single block alone exceeds +cap+, we cannot sub-split it
58
- # (its codepoints are contiguous and we don't have finer-grained
59
- # boundaries to use), so raise.
85
+ # When a plane overflows +cap+, carve it along the recognized
86
+ # sub-plane block boundaries. The +:other+ bucket (everything
87
+ # outside +RECOGNIZED_BLOCKS+) and any +CARVE_OUT_BLOCKS+ entries
88
+ # are split into chunks of +cap+. +ATOMIC_BLOCKS+ entries become
89
+ # one partition atomically — if a single atomic block alone
90
+ # exceeds +cap+, we cannot sub-split it (its codepoints are
91
+ # contiguous and we don't have finer-grained boundaries to use),
92
+ # so raise.
60
93
  def sub_split_by_block(plane_num, entries, cap)
61
- buckets = bucket_by_large_block(entries)
94
+ buckets = bucket_by_recognized_block(entries)
62
95
  partitions = []
63
96
  suffix = "a"
64
97
 
65
98
  buckets.each do |label, bucket_entries|
66
- if large_block?(label) && bucket_entries.size > cap
99
+ if atomic_block?(label) && bucket_entries.size > cap
67
100
  raise PartitionCapExceededError.new(
68
101
  block_label: label,
69
102
  actual: bucket_entries.size,
@@ -83,8 +116,8 @@ module Fontisan
83
116
  partitions
84
117
  end
85
118
 
86
- def large_block?(label)
87
- Fontisan::Unicode::Plane::LARGE_CJK_BLOCKS.key?(label)
119
+ def atomic_block?(label)
120
+ ATOMIC_BLOCKS.key?(label)
88
121
  end
89
122
 
90
123
  # Split +entries+ into sub-arrays of at most +cap+ each.
@@ -92,17 +125,16 @@ module Fontisan
92
125
  entries.each_slice(cap).to_a
93
126
  end
94
127
 
95
- # Bucket entries by which large CJK block (or :other) they fall
96
- # into. Entries outside any known large block go into :other,
97
- # which is then packed into its own partition(s).
98
- def bucket_by_large_block(entries)
128
+ # Bucket entries by which recognized sub-plane block (or :other)
129
+ # they fall into. Entries outside any known block go into :other,
130
+ # which is then packed into its own partition(s). Order: :other
131
+ # first if non-empty, then recognized blocks in declaration order.
132
+ def bucket_by_recognized_block(entries)
99
133
  buckets = { other: [] }
100
- Fontisan::Unicode::Plane::LARGE_CJK_BLOCKS.each_key do |label|
101
- buckets[label] = []
102
- end
134
+ RECOGNIZED_BLOCKS.each_key { |label| buckets[label] = [] }
103
135
 
104
136
  entries.each do |cp, label|
105
- block = find_large_block(cp)
137
+ block = find_recognized_block(cp)
106
138
  if block
107
139
  buckets[block] << [cp, label]
108
140
  else
@@ -110,18 +142,16 @@ module Fontisan
110
142
  end
111
143
  end
112
144
 
113
- # Drop empty buckets; preserve order (other first if non-empty,
114
- # then CJK blocks in declaration order).
115
145
  ordered = []
116
146
  ordered << [:other, buckets[:other]] unless buckets[:other].empty?
117
- Fontisan::Unicode::Plane::LARGE_CJK_BLOCKS.each_key do |label|
147
+ RECOGNIZED_BLOCKS.each_key do |label|
118
148
  ordered << [label, buckets[label]] unless buckets[label].empty?
119
149
  end
120
150
  ordered
121
151
  end
122
152
 
123
- def find_large_block(codepoint)
124
- Fontisan::Unicode::Plane::LARGE_CJK_BLOCKS.find do |_label, range|
153
+ def find_recognized_block(codepoint)
154
+ RECOGNIZED_BLOCKS.find do |_label, range|
125
155
  range.include?(codepoint)
126
156
  end&.first
127
157
  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)
@@ -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: " ",
@@ -20,7 +20,7 @@ module Fontisan
20
20
  #
21
21
  # @example Generate SVG glyph element
22
22
  # generator = GlyphGenerator.new(calculator)
23
- # xml = generator.generate_glyph_xml(outline, unicode: "A", advance_width: 600)
23
+ # xml = generator.generate_glyph_xml(outline, codepoints: [0x41], advance_width: 600)
24
24
  class GlyphGenerator
25
25
  # @return [ViewBoxCalculator] Coordinate calculator
26
26
  attr_reader :calculator
@@ -38,23 +38,30 @@ module Fontisan
38
38
  # Generate SVG glyph element
39
39
  #
40
40
  # @param outline [Models::GlyphOutline] Glyph outline
41
- # @param unicode [String, nil] Unicode character
42
- # @param glyph_name [String, nil] Glyph name
41
+ # @param codepoints [Array<Integer>] Unicode codepoints that cmap
42
+ # maps to this glyph. Empty for unmapped glyphs (.notdef, raw
43
+ # ligatures, etc.). Each codepoint is rendered per SVG Fonts
44
+ # spec: printable ASCII as the character (XML-escaped), others
45
+ # as &#xHEX; entities. Multiple codepoints are concatenated —
46
+ # rare but valid (e.g. a glyph mapped from both space and
47
+ # non-breaking space).
48
+ # @param glyph_name [String, nil] Glyph name from the post table
43
49
  # @param advance_width [Integer] Horizontal advance width
44
50
  # @param indent [String] Indentation string
45
51
  # @return [String] XML glyph element
46
- def generate_glyph_xml(outline, unicode: nil, glyph_name: nil,
52
+ def generate_glyph_xml(outline, codepoints: [], glyph_name: nil,
47
53
  advance_width: 0, indent: " ")
48
- # Build attribute parts
49
54
  attr_parts = []
50
55
 
51
- attr_parts << "unicode=\"#{escape_xml(unicode)}\"" if unicode
52
- attr_parts << "glyph-name=\"#{escape_xml(glyph_name)}\"" if glyph_name
53
- attr_parts << "horiz-adv-x=\"#{advance_width}\"" if advance_width&.positive?
56
+ unless codepoints.empty?
57
+ attr_parts << %(unicode="#{format_codepoints(codepoints)}")
58
+ end
59
+ attr_parts << %(glyph-name="#{escape_xml(glyph_name)}") if glyph_name
60
+ attr_parts << %(horiz-adv-x="#{advance_width}") if advance_width&.positive?
54
61
 
55
62
  # Generate SVG path with Y-axis transformation
56
63
  path_data = generate_svg_path(outline)
57
- attr_parts << "d=\"#{path_data}\"" if path_data && !path_data.empty?
64
+ attr_parts << %(d="#{path_data}") if path_data && !path_data.empty?
58
65
 
59
66
  "#{indent}<glyph #{attr_parts.join(' ')}/>"
60
67
  end
@@ -88,6 +95,34 @@ advance_width: 0, indent: " ")
88
95
 
89
96
  private
90
97
 
98
+ # Format an array of codepoints as the SVG `unicode=` attribute
99
+ # value. Returns the FINAL string — already escaped / entity-fied.
100
+ # The caller must not re-escape it (would double-escape the
101
+ # entities this method emits).
102
+ #
103
+ # Per SVG Fonts spec, each codepoint renders as either:
104
+ # - printable ASCII (0x20..0x7E): the character itself, with
105
+ # XML-special chars (&, <, >, ", ') escaped
106
+ # - any other codepoint: a numeric entity &#xHEX;
107
+ #
108
+ # Multiple codepoints are concatenated in codepoint order. The
109
+ # SVG spec accepts this for ligature-style mappings.
110
+ def format_codepoints(codepoints)
111
+ codepoints.map { |cp| format_codepoint(cp) }.join
112
+ end
113
+
114
+ def format_codepoint(cp)
115
+ if printable_ascii?(cp)
116
+ escape_xml(cp.chr(Encoding::UTF_8))
117
+ else
118
+ "&#x#{cp.to_s(16).upcase};"
119
+ end
120
+ end
121
+
122
+ def printable_ascii?(cp)
123
+ (0x20..0x7E).cover?(cp)
124
+ end
125
+
91
126
  # Build SVG path for a contour with Y-axis transformation
92
127
  #
93
128
  # @param contour [Array<Hash>] Array of point hashes
@@ -15,18 +15,6 @@ module Fontisan
15
15
  TIP = 3
16
16
  SSP = 14
17
17
 
18
- # The handful of mega-blocks large enough to overflow a single
19
- # plane's 65,535-glyph cap when partitioning by plane. Range +
20
- # label only — full Unicode Blocks.txt data lives in +Block+
21
- # (follow-up).
22
- LARGE_CJK_BLOCKS = {
23
- "CJK_Ext_B" => 0x2A700..0x2B73F,
24
- "CJK_Ext_C" => 0x2B740..0x2B81F,
25
- "CJK_Ext_D" => 0x2B820..0x2CEAF,
26
- "CJK_Ext_E" => 0x2CEB0..0x2EBEF,
27
- "CJK_Ext_F" => 0x2EBF0..0x2EE5F,
28
- }.freeze
29
-
30
18
  # @param codepoint [Integer]
31
19
  # @return [Integer] plane number (0..16)
32
20
  def self.of(codepoint)
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Fontisan
4
- VERSION = "0.4.9"
4
+ VERSION = "0.4.10"
5
5
  end
data/lib/fontisan.rb CHANGED
@@ -32,6 +32,7 @@ require "logger"
32
32
  require "bindata"
33
33
  require "zlib"
34
34
  require "stringio"
35
+ require "tmpdir"
35
36
  require "lutaml/model"
36
37
 
37
38
  # Configure lutaml-model to use Nokogiri adapter for XML serialization
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: fontisan
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.4.9
4
+ version: 0.4.10
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ribose Inc.
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2026-07-03 00:00:00.000000000 Z
11
+ date: 2026-07-04 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: base64
@@ -434,6 +434,7 @@ files:
434
434
  - lib/fontisan/stitcher.rb
435
435
  - lib/fontisan/stitcher/collection_result.rb
436
436
  - lib/fontisan/stitcher/deduplicator.rb
437
+ - lib/fontisan/stitcher/format_metadata.rb
437
438
  - lib/fontisan/stitcher/glyph_limit.rb
438
439
  - lib/fontisan/stitcher/glyph_signature.rb
439
440
  - lib/fontisan/stitcher/partition_strategy.rb