fontisan 0.3.1 → 0.4.1

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: '0979321b74749068da214fbb4880701c1f78905cce2db2aa7a8a2263da669af3'
4
- data.tar.gz: 3ccbb49464b62e831475fe601fecc9b7085c6dda5a16ec66d9b9c56c14ff0742
3
+ metadata.gz: 58c1e2768684af1fcdc55fdc2d5b3e47bbd89b0277794dc64ff627ef8d571c3d
4
+ data.tar.gz: 8d1da959f850b3872acb01e96deb098ea2cbb0d596a0923b5db4960edfd818bf
5
5
  SHA512:
6
- metadata.gz: 87538383c69009f3610778a717ae6263accb950e07b8803914358927bfc1e7510c18c664d88267b17dcabbeaab4fa97d4ac72b3294ae492472d0659843dbe540
7
- data.tar.gz: 459343281dd3b295141eed4c8091ccc3d9ce6228d49c293eb6a182d26676428eaf4cb571295b8f55ac4101ba88d879c7525b06f5ad1596a83ea544d2f88a8351
6
+ metadata.gz: 3e46507d14358098c09f5ee2a3584c601e3d28e6a762f506962007445fb9fcd4432db7f591601575fac87d9cad93ae7c57d2c464728888e33dd6b242387c5611
7
+ data.tar.gz: 1e2dc941fe06dc9769aad1477c970ac48e799d62f3340a08448c0528290562ce2f9ccfd0e021615ebc57a26bcdd19773fd51135d4d214ef31e2a927576c7d06f
data/README.adoc CHANGED
@@ -2475,6 +2475,36 @@ fontisan stitch \
2475
2475
  --output stitched.ttf
2476
2476
  ----
2477
2477
 
2478
+ === Color emoji (CBDT/CBLC passthrough)
2479
+
2480
+ Sources with CBDT/CBLC tables (e.g. NotoColorEmoji) can be used as
2481
+ Stitcher sources. The bitmap data is propagated byte-for-byte from the
2482
+ source into the output. The Stitcher automatically detects the source's
2483
+ storage mode via `bitmap_mode`:
2484
+
2485
+ [source,ruby]
2486
+ ----
2487
+ stitcher = Fontisan::Stitcher.new
2488
+ stitcher.add_source(:text, Fontisan::FontLoader.load("NotoSans.ttf"))
2489
+ stitcher.add_source(:emoji, Fontisan::FontLoader.load("NotoColorEmoji.ttf"))
2490
+
2491
+ # All emoji glyphs from the CBDT source are added; the CBLC table
2492
+ # is copied byte-for-byte and references the right GIDs.
2493
+ stitcher.include_range(0x41..0x5A, from: :text)
2494
+ stitcher.include_range(0x1F600..0x1F64F, from: :emoji)
2495
+
2496
+ stitcher.write_to("out.ttf", format: :ttf)
2497
+ # → out.ttf has glyf + CBDT + CBLC, emoji renders correctly
2498
+ ----
2499
+
2500
+ **Constraints:**
2501
+ - Only ONE CBDT source per Stitcher (multiple raise
2502
+ `Fontisan::MultipleCbdtSourcesError`)
2503
+ - The CBDT source is processed first (GIDs 0..N-1) so the CBLC's GID
2504
+ references remain valid in the output
2505
+ - Multi-source CBDT merge is tracked as TODO (REVIEW
2506
+ `TODO.full/ufo/23-cbdt-passthrough.md`)
2507
+
2478
2508
 
2479
2509
  == Testing
2480
2510
 
@@ -199,6 +199,13 @@ module Fontisan
199
199
  end
200
200
  end
201
201
 
202
+ # Multiple CBDT sources detected in Stitcher
203
+ #
204
+ # Raised when more than one CBDT/CBLC source is registered with the
205
+ # Stitcher. Only single-source CBDT passthrough is supported; merging
206
+ # CBDT/CBLC across multiple sources requires a dedicated rebuild.
207
+ class MultipleCbdtSourcesError < Error; end
208
+
202
209
  # Variation data corrupted (for use in data_extractor)
203
210
  #
204
211
  # Raised when extracted variation data appears corrupted.
@@ -6,16 +6,21 @@ module Fontisan
6
6
  # extraction API used by the selectors.
7
7
  #
8
8
  # For UFO sources, glyphs are accessed by name directly. For TTF
9
- # or OTF sources, the source is lazily converted to a UFO::Font
10
- # via Ufo::Convert::FromBinData on first glyph access, then cached.
11
- # This is O(n) in donor glyph count but amortized across all
12
- # codepoint extractions from that donor.
9
+ # or OTF sources, individual glyphs are extracted on demand from
10
+ # the BinData tables (glyf/loca/head for TTF, CFF for OTF). This
11
+ # is O(1) per glyph rather than the previous O(n) full-donor
12
+ # conversion.
13
+ #
14
+ # CBDT/CBLC sources (e.g. NotoColorEmoji) are detected via
15
+ # #bitmap_mode. When a source is :cbdt, the Stitcher propagates
16
+ # the raw CBDT/CBLC tables into the output instead of extracting
17
+ # outlines. The glyph data lives in the bitmap tables, not in glyf.
13
18
  class Source
14
19
  attr_reader :font
15
20
 
16
21
  def initialize(font)
17
22
  @font = font
18
- @ufo_cache = nil
23
+ @bin_data_cache = nil
19
24
  end
20
25
 
21
26
  # @return [Symbol] :ufo, :ttf, :otf
@@ -28,6 +33,27 @@ module Fontisan
28
33
  end
29
34
  end
30
35
 
36
+ # Detect how this source stores glyph data.
37
+ #
38
+ # - :glyf — TrueType outlines (glyf table present)
39
+ # - :cbdt — Color bitmaps (CBDT + CBLC tables, no glyf)
40
+ # - :mixed — Both glyf and CBDT
41
+ # - :none — UFO source or neither table present
42
+ #
43
+ # @return [Symbol]
44
+ def bitmap_mode
45
+ return :none if @font.is_a?(Fontisan::Ufo::Font)
46
+ return :none unless @font.respond_to?(:has_table?)
47
+
48
+ has_cbdt = @font.has_table?("CBDT") && @font.has_table?("CBLC")
49
+ has_glyf = @font.has_table?("glyf") || @font.has_table?("CFF ")
50
+ return :mixed if has_cbdt && has_glyf
51
+ return :cbdt if has_cbdt
52
+ return :glyf if has_glyf
53
+
54
+ :none
55
+ end
56
+
31
57
  # Find the gid for a Unicode codepoint in this source.
32
58
  # @param codepoint [Integer]
33
59
  # @return [Integer, nil]
@@ -39,15 +65,44 @@ module Fontisan
39
65
  end
40
66
 
41
67
  # Extract a glyph by gid.
68
+ #
69
+ # For TTF/OTF sources, this is O(1) per glyph: it parses just
70
+ # the requested glyph from glyf/CFF on demand, not the entire
71
+ # donor. The full-donor conversion is avoided entirely.
72
+ #
73
+ # For CBDT sources, returns a placeholder glyph (no contours)
74
+ # since the glyph data is in the bitmap tables, not outlines.
75
+ #
42
76
  # @param gid [Integer]
43
77
  # @return [Fontisan::Ufo::Glyph, nil]
44
78
  def glyph_for_gid(gid)
45
79
  case @font
46
80
  when Fontisan::Ufo::Font then ufo_glyph_at(gid)
47
- else converted_ufo_glyph_at(gid)
81
+ else extract_single_glyph_from_bindata(gid)
48
82
  end
49
83
  end
50
84
 
85
+ # Raw table bytes from the loaded font (for passthrough).
86
+ # @param tag [String] 4-byte table tag (e.g. "CBDT", "CBLC")
87
+ # @return [String, nil] raw bytes or nil if table not present
88
+ def raw_table_bytes(tag)
89
+ sfnt_table = @font.table(tag)
90
+ return nil unless sfnt_table
91
+
92
+ sfnt_table.raw_data
93
+ rescue StandardError
94
+ nil
95
+ end
96
+
97
+ # Width of a specific glyph (extracted from hmtx).
98
+ # Falls back to 0 if hmtx is missing.
99
+ # @param gid [Integer]
100
+ # @return [Integer]
101
+ def glyph_width(gid)
102
+ widths = bin_data_widths
103
+ widths[gid] || 0
104
+ end
105
+
51
106
  private
52
107
 
53
108
  # ---------- UFO source ----------
@@ -67,7 +122,7 @@ module Fontisan
67
122
  @font.glyph(name)
68
123
  end
69
124
 
70
- # ---------- TTF/OTF source ----------
125
+ # ---------- TTF/OTF source: O(1) per-glyph extraction ----------
71
126
 
72
127
  def bin_data_gid_for(codepoint)
73
128
  cmap = @font.table("cmap")
@@ -76,21 +131,111 @@ module Fontisan
76
131
  cmap.unicode_mappings[codepoint]
77
132
  end
78
133
 
79
- # Lazily convert the loaded TTF/OTF to a UFO::Font, then
80
- # extract glyphs from the cached UFO model.
81
- def converted_ufo
82
- return @ufo_cache if @ufo_cache
134
+ # Lazily parse the relevant BinData tables. Cached so we only
135
+ # pay the parse cost once per source.
136
+ def bin_data_cache
137
+ @bin_data_cache ||= parse_bin_data_tables
138
+ end
83
139
 
84
- @ufo_cache = Fontisan::Ufo::Convert::FromBinData.convert(@font)
140
+ def parse_bin_data_tables
141
+ cache = { head: @font.table("head") }
142
+
143
+ if @font.has_table?("glyf")
144
+ cache[:loca] = @font.table("loca")
145
+ cache[:glyf] = @font.table("glyf")
146
+ # loca needs head's index_to_loc_format to size its offsets
147
+ if cache[:loca].respond_to?(:parse_with_context) && cache[:head]
148
+ cache[:loca].parse_with_context(
149
+ cache[:head].index_to_loc_format,
150
+ @font.table("maxp")&.num_glyphs || 0,
151
+ )
152
+ end
153
+ end
154
+
155
+ cache
85
156
  end
86
157
 
87
- def converted_ufo_glyph_at(gid)
88
- ufo = converted_ufo
89
- names = ufo.glyphs.keys
90
- name = names[gid]
91
- return nil unless name
158
+ # Build {gid → advance_width} from hmtx (cached).
159
+ def bin_data_widths
160
+ @bin_data_widths ||= build_bin_data_widths
161
+ end
162
+
163
+ def build_bin_data_widths
164
+ widths = {}
165
+ hmtx = @font.table("hmtx")
166
+ return widths unless hmtx
167
+
168
+ hhea = @font.table("hhea")
169
+ maxp = @font.table("maxp")
170
+ num_h_metrics = hhea&.number_of_h_metrics || 1
171
+ num_glyphs = maxp&.num_glyphs || 0
92
172
 
93
- ufo.glyph(name)
173
+ if hmtx.respond_to?(:parse_with_context)
174
+ hmtx.parse_with_context(num_h_metrics, num_glyphs)
175
+ end
176
+
177
+ num_glyphs.times do |gid|
178
+ metric = hmtx.respond_to?(:metric_for) ? hmtx.metric_for(gid) : nil
179
+ widths[gid] = metric ? metric[:advance_width] : 0
180
+ rescue StandardError
181
+ widths[gid] = 0
182
+ end
183
+ widths
184
+ end
185
+
186
+ # Extract a single glyph by gid, parsing just the relevant bytes.
187
+ # O(1) per call (after the first call's table-parsing overhead).
188
+ def extract_single_glyph_from_bindata(gid)
189
+ cache = bin_data_cache
190
+
191
+ if cache[:glyf] && cache[:loca] && cache[:head]
192
+ extract_truetype_glyph(gid, cache)
193
+ end
194
+ end
195
+
196
+ def extract_truetype_glyph(gid, cache)
197
+ simple = cache[:glyf].glyph_for(gid, cache[:loca], cache[:head])
198
+ return nil unless simple
199
+ return nil unless simple.respond_to?(:simple?) && simple.simple?
200
+
201
+ name = gid.zero? ? ".notdef" : "gid#{gid}"
202
+ glyph = Fontisan::Ufo::Glyph.new(name: name)
203
+ glyph.width = glyph_width(gid)
204
+ copy_simple_contours(simple, glyph)
205
+ add_cmap_unicodes(gid, glyph)
206
+ glyph
207
+ rescue StandardError
208
+ nil
209
+ end
210
+
211
+ # Copy a SimpleGlyph's contours + points into a Ufo::Glyph.
212
+ def copy_simple_contours(simple, ufo_glyph)
213
+ num_contours = simple.end_pts_of_contours&.size || 0
214
+ return if num_contours.zero?
215
+
216
+ num_contours.times do |ci|
217
+ points = simple.points_for_contour(ci)
218
+ next unless points && !points.empty?
219
+
220
+ ufo_points = points.map do |pt|
221
+ x = pt[:x] || pt["x"]
222
+ y = pt[:y] || pt["y"]
223
+ on_curve = pt[:on_curve].nil? || pt[:on_curve]
224
+ type = on_curve ? "line" : "offcurve"
225
+ Fontisan::Ufo::Point.new(x: x.to_f, y: y.to_f, type: type)
226
+ end
227
+ ufo_glyph.add_contour(Fontisan::Ufo::Contour.new(ufo_points))
228
+ end
229
+ end
230
+
231
+ # Add Unicode codepoints from the cmap that map to this gid.
232
+ def add_cmap_unicodes(gid, glyph)
233
+ cmap = @font.table("cmap")
234
+ return unless cmap
235
+
236
+ (cmap.unicode_mappings || {}).each do |cp, g|
237
+ glyph.add_unicode(cp) if g == gid
238
+ end
94
239
  end
95
240
  end
96
241
  end
@@ -70,12 +70,23 @@ module Fontisan
70
70
  end
71
71
 
72
72
  # Write the stitched font to disk.
73
+ #
74
+ # For CBDT/CBLC sources (e.g. NotoColorEmoji), the raw CBDT and
75
+ # CBLC tables are copied byte-for-byte into the output. This works
76
+ # because CBDT-mode glyphs are processed first (GIDs 0..N-1),
77
+ # matching the source's GID layout. Only one CBDT source is
78
+ # supported; multiple CBDT sources raise MultipleCbdtSourcesError.
79
+ #
73
80
  # @param path [String] output file path
74
81
  # @param format [Symbol] :ttf or :otf
75
82
  def write_to(path, format: :ttf)
76
83
  build_target_font
77
84
  compiler = compiler_for(format)
78
- compiler.new(@target).compile(output_path: path)
85
+ compiler_instance = compiler.new(@target)
86
+ compiler_instance.compile(output_path: path)
87
+
88
+ propagate_cbdt_tables(path) if cbdt_source
89
+
79
90
  path
80
91
  end
81
92
 
@@ -96,6 +107,53 @@ module Fontisan
96
107
  end
97
108
  end
98
109
 
110
+ # Find the single CBDT source among registered sources, if any.
111
+ # Raises if more than one CBDT source is present (merge not supported).
112
+ # @return [Source, nil]
113
+ def cbdt_source
114
+ cbdts = @sources.values.select { |s| s.bitmap_mode == :cbdt }
115
+ if cbdts.size > 1
116
+ raise MultipleCbdtSourcesError,
117
+ "multiple CBDT sources not supported (found #{cbdts.size})"
118
+ end
119
+
120
+ cbdts.first
121
+ end
122
+
123
+ # Copy raw CBDT + CBLC table bytes from the CBDT source into the
124
+ # compiled output file. The GIDs must match (CBDT glyphs are at
125
+ # the same GIDs in both source and output because they were added
126
+ # first during build_target_font).
127
+ def propagate_cbdt_tables(path)
128
+ source = cbdt_source
129
+ return unless source
130
+
131
+ compiled = Fontisan::FontLoader.load(path)
132
+
133
+ tables = {}
134
+ compiled.table_names.each do |tag|
135
+ raw = extract_raw_table(compiled, tag)
136
+ tables[tag] = raw if raw
137
+ end
138
+
139
+ cbdt_bytes = source.raw_table_bytes("CBDT")
140
+ cblc_bytes = source.raw_table_bytes("CBLC")
141
+ tables["CBDT"] = cbdt_bytes if cbdt_bytes
142
+ tables["CBLC"] = cblc_bytes if cblc_bytes
143
+
144
+ sfnt = tables.key?("CFF ") ? 0x4F54544F : 0x00010000
145
+ Fontisan::FontWriter.write_to_file(tables, path, sfnt_version: sfnt)
146
+ end
147
+
148
+ def extract_raw_table(font, tag)
149
+ sfnt_table = font.table(tag)
150
+ return nil unless sfnt_table
151
+
152
+ sfnt_table.raw_data
153
+ rescue StandardError
154
+ nil
155
+ end
156
+
99
157
  def compiler_for(format)
100
158
  case format.to_sym
101
159
  when :ttf then Ufo::Compile::TtfCompiler
@@ -107,26 +165,28 @@ module Fontisan
107
165
 
108
166
  # Walk bindings in codepoint order, assign sequential new gids,
109
167
  # copy each glyph into the target font's default layer.
168
+ #
169
+ # When a CBDT source is present, its glyphs are added FIRST (in
170
+ # source GID order) so that the CBLC's GID references remain valid.
171
+ # Glyf-source bindings are processed AFTER, appending new glyphs.
110
172
  def assign_gids_and_copy_glyphs
111
- # Always put .notdef at gid 0 first.
112
- notdef_binding = @bindings.find { |b| b[:donor_gid].zero? }
113
- if notdef_binding
114
- copy_glyph_into(@target, name: ".notdef",
115
- source: notdef_binding[:source],
116
- donor_gid: 0)
173
+ cbdt = cbdt_source
174
+
175
+ if cbdt
176
+ add_all_cbdt_glyphs(cbdt)
117
177
  else
118
- # Synthesize an empty .notdef
119
- @target.layers.default_layer.add(Ufo::Glyph.new(name: ".notdef"))
178
+ add_notdef_from_bindings
120
179
  end
121
180
 
122
181
  sorted_bindings.each do |binding|
123
- next if binding[:donor_gid].zero? # already handled
182
+ next if binding[:donor_gid].zero?
183
+
184
+ # Skip bindings from the CBDT source — its glyphs are already added.
185
+ next if cbdt && binding[:source].equal?(cbdt)
124
186
 
125
187
  glyph = binding[:source].glyph_for_gid(binding[:donor_gid])
126
188
  next unless glyph
127
189
 
128
- # If multiple codepoints map to the same glyph, only the first
129
- # binding creates the glyph; subsequent ones add unicode entries.
130
190
  if @target.glyphs.key?(glyph.name)
131
191
  add_extra_unicode(glyph.name, binding[:codepoint])
132
192
  else
@@ -159,6 +219,51 @@ module Fontisan
159
219
  glyph.add_unicode(codepoint) unless glyph.unicodes.include?(codepoint)
160
220
  end
161
221
 
222
+ # Add .notdef at GID 0 from the first binding that references gid 0.
223
+ # Falls back to a synthesized empty .notdef if none found.
224
+ def add_notdef_from_bindings
225
+ notdef_binding = @bindings.find { |b| b[:donor_gid].zero? }
226
+ if notdef_binding
227
+ copy_glyph_into(@target, name: ".notdef",
228
+ source: notdef_binding[:source],
229
+ donor_gid: 0)
230
+ else
231
+ @target.layers.default_layer.add(Ufo::Glyph.new(name: ".notdef"))
232
+ end
233
+ end
234
+
235
+ # Add ALL glyphs from a CBDT source in source GID order. This
236
+ # ensures the CBLC's GID references remain valid in the output
237
+ # without rewriting the table. Each glyph gets a placeholder
238
+ # (no contours) since the bitmap data is in CBDT, not glyf.
239
+ def add_all_cbdt_glyphs(source)
240
+ ufo = source.font.is_a?(Ufo::Font) ? source.font : nil
241
+ if ufo
242
+ ufo.glyphs.each_value { |g| @target.layers.default_layer.add(clone_glyph(g, name: g.name)) }
243
+ return
244
+ end
245
+
246
+ # For loaded TTF/OTF sources, iterate via cmap to get glyph names.
247
+ # CBDT fonts (like NotoColorEmoji) may have thousands of glyphs;
248
+ # we add them all as placeholders.
249
+ maxp = source.font.table("maxp")
250
+ num_glyphs = maxp&.num_glyphs || 0
251
+ cmap = source.font.table("cmap")
252
+ mappings = cmap&.unicode_mappings || {}
253
+
254
+ # Build gid → [codepoints] from cmap
255
+ gid_cps = Hash.new { |h, k| h[k] = [] }
256
+ mappings.each { |cp, gid| gid_cps[gid] << cp }
257
+
258
+ num_glyphs.times do |gid|
259
+ name = gid.zero? ? ".notdef" : "gid#{gid}"
260
+ glyph = Ufo::Glyph.new(name: name)
261
+ glyph.width = 0
262
+ gid_cps[gid].each { |cp| glyph.add_unicode(cp) }
263
+ @target.layers.default_layer.add(glyph)
264
+ end
265
+ end
266
+
162
267
  # Deep-copy a glyph with a new name. Used so multiple target
163
268
  # glyphs can share the same source outline without aliasing.
164
269
  def clone_glyph(original, name:)
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Fontisan
4
- VERSION = "0.3.1"
4
+ VERSION = "0.4.1"
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: fontisan
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.3.1
4
+ version: 0.4.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ribose Inc.