fontisan 0.4.6 → 0.4.7

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 (51) hide show
  1. checksums.yaml +4 -4
  2. data/BUG-stitcher-drops-isolated-cps.md +58 -0
  3. data/BUG-stitcher-drops-plane1-codepoints.md +310 -0
  4. data/BUG-stitcher-gid-cap-65535.md +110 -0
  5. data/CHANGELOG.md +106 -0
  6. data/README.adoc +121 -68
  7. data/benchmark/compile_benchmark.rb +70 -0
  8. data/docs/CFF2_SUPPORT.adoc +184 -0
  9. data/docs/STITCHER_GUIDE.adoc +151 -0
  10. data/docs/SVG_TO_GLYF.adoc +118 -0
  11. data/docs/UFO_COMPILATION.adoc +119 -0
  12. data/lib/fontisan/collection/writer.rb +5 -6
  13. data/lib/fontisan/error.rb +31 -0
  14. data/lib/fontisan/stitcher/deduplicator.rb +47 -0
  15. data/lib/fontisan/stitcher/glyph_limit.rb +53 -0
  16. data/lib/fontisan/stitcher/glyph_signature.rb +51 -0
  17. data/lib/fontisan/stitcher.rb +188 -167
  18. data/lib/fontisan/svg_to_glyf/assembler.rb +132 -0
  19. data/lib/fontisan/svg_to_glyf/document.rb +83 -0
  20. data/lib/fontisan/svg_to_glyf/geometry/affine_transform.rb +112 -0
  21. data/lib/fontisan/svg_to_glyf/geometry/normalizer.rb +45 -0
  22. data/lib/fontisan/svg_to_glyf/geometry/transform_parser.rb +91 -0
  23. data/lib/fontisan/svg_to_glyf/geometry.rb +13 -0
  24. data/lib/fontisan/svg_to_glyf/path/command.rb +18 -0
  25. data/lib/fontisan/svg_to_glyf/path/contour_builder.rb +140 -0
  26. data/lib/fontisan/svg_to_glyf/path/parser.rb +98 -0
  27. data/lib/fontisan/svg_to_glyf/path/state.rb +79 -0
  28. data/lib/fontisan/svg_to_glyf/path.rb +14 -0
  29. data/lib/fontisan/svg_to_glyf.rb +62 -0
  30. data/lib/fontisan/tables/cff/cff2_charstring_builder.rb +216 -0
  31. data/lib/fontisan/tables/cff.rb +1 -0
  32. data/lib/fontisan/tables/cff2/dict_encoder.rb +94 -0
  33. data/lib/fontisan/tables/cff2/fd_select.rb +69 -0
  34. data/lib/fontisan/tables/cff2/header.rb +34 -0
  35. data/lib/fontisan/tables/cff2/index_builder.rb +79 -0
  36. data/lib/fontisan/tables/cff2.rb +4 -0
  37. data/lib/fontisan/ufo/compile/cbdt_cblc.rb +103 -0
  38. data/lib/fontisan/ufo/compile/cff2.rb +181 -0
  39. data/lib/fontisan/ufo/compile/cff2_subroutines.rb +39 -0
  40. data/lib/fontisan/ufo/compile/colr.rb +80 -0
  41. data/lib/fontisan/ufo/compile/cpal.rb +61 -0
  42. data/lib/fontisan/ufo/compile/math.rb +143 -0
  43. data/lib/fontisan/ufo/compile/meta.rb +51 -0
  44. data/lib/fontisan/ufo/compile/otf2_compiler.rb +46 -0
  45. data/lib/fontisan/ufo/compile/sbix.rb +99 -0
  46. data/lib/fontisan/ufo/compile/svg_table.rb +60 -0
  47. data/lib/fontisan/ufo/compile/variable_otf.rb +75 -0
  48. data/lib/fontisan/ufo/compile.rb +11 -0
  49. data/lib/fontisan/version.rb +1 -1
  50. data/lib/fontisan.rb +3 -0
  51. metadata +41 -2
@@ -1,102 +1,101 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Fontisan
4
- # Multi-source font stitcher. Combines glyphs from one or more
5
- # source fonts (UFO or loaded TTF/OTF) into a single new font.
4
+ # Multi-source font stitcher with explicit subfont declaration.
6
5
  #
7
- # The Stitcher builds a Fontisan::Ufo::Font from selected glyphs,
8
- # then delegates compilation to the existing TtfCompiler or
9
- # OtfCompiler. Single source of truth: one compiler pipeline,
10
- # whether the input is one UFO or many sources.
6
+ # Every set of codepoints is explicitly assigned to a named subfont
7
+ # via the required `into:` keyword. The user controls the collection
8
+ # structure upfront there are no defaults and no after-the-fact
9
+ # splitting.
11
10
  #
12
- # @example Stitch ASCII from one UFO, Hiragana from another
11
+ # Single-font output: `write_to` requires a `subfont:` name.
12
+ # Collection output: `write_collection` writes all declared subfonts.
13
+ #
14
+ # @example Single font
13
15
  # stitcher = Fontisan::Stitcher.new
14
16
  # stitcher.add_source(:latin, Fontisan::Ufo::Font.open("latin.ufo"))
15
- # stitcher.add_source(:jp, Fontisan::Ufo::Font.open("jp.ufo"))
16
- # stitcher.include_range(0x41..0x5A, from: :latin)
17
- # stitcher.include_range(0x3040..0x309F, from: :jp)
18
- # stitcher.write_to("stitched.ttf", format: :ttf)
17
+ # stitcher.include_range(0x41..0x5A, from: :latin, into: :main)
18
+ # stitcher.write_to("out.ttf", format: :ttf, subfont: :main)
19
+ #
20
+ # @example Collection
21
+ # stitcher = Fontisan::Stitcher.new
22
+ # stitcher.add_source(:noto_sans, noto_sans)
23
+ # stitcher.add_source(:noto_cjk, noto_cjk)
24
+ # stitcher.include_range(0x41..0x5A, from: :noto_sans, into: :latin)
25
+ # stitcher.include_range(0x4E00..0x9FFF, from: :noto_cjk, into: :cjk)
26
+ # stitcher.write_collection("out.otc", format: :otf2)
19
27
  class Stitcher
20
- autoload :Source, "fontisan/stitcher/source"
21
- autoload :Selector, "fontisan/stitcher/selector"
28
+ autoload :Source, "fontisan/stitcher/source"
29
+ autoload :Selector, "fontisan/stitcher/selector"
30
+ autoload :GlyphSignature, "fontisan/stitcher/glyph_signature"
31
+ autoload :Deduplicator, "fontisan/stitcher/deduplicator"
32
+ autoload :GlyphLimit, "fontisan/stitcher/glyph_limit"
22
33
 
23
- attr_reader :sources, :bindings
34
+ DEFAULT_DEDUPLICATE = true
24
35
 
25
- def initialize
36
+ attr_reader :sources, :subfonts, :info
37
+
38
+ def initialize(deduplicate: DEFAULT_DEDUPLICATE)
26
39
  @sources = {}
27
- @bindings = []
28
- @target = Ufo::Font.new
40
+ @subfonts = Hash.new { |h, k| h[k] = [] }
41
+ @info = nil
42
+ @deduplicate = deduplicate
29
43
  end
30
44
 
31
- # Register a named source font.
32
- # @param label [Symbol, String] name to reference this source by
33
- # @param font [Fontisan::Ufo::Font, Fontisan::SfntFont] the source
34
45
  def add_source(label, font)
35
46
  @sources[label.to_sym] = Source.new(font)
36
47
  end
37
48
 
38
- # Include all codepoints in a range from a named source.
39
- # @param range [Range<Integer>] codepoint range
40
- # @param from [Symbol, String] source label
41
- def include_range(range, from:)
42
- Selector::Range.new(range).apply(source(from), @bindings)
49
+ def include_range(range, from:, into:)
50
+ Selector::Range.new(range).apply(source(from), @subfonts[into])
43
51
  end
44
52
 
45
- # Include an explicit list of codepoints.
46
- # @param codepoints [Array<Integer>]
47
- # @param from [Symbol, String] source label
48
- def include_codepoints(codepoints, from:)
49
- Selector::Codepoints.new(codepoints).apply(source(from), @bindings)
53
+ def include_codepoints(codepoints, from:, into:)
54
+ Selector::Codepoints.new(codepoints).apply(source(from), @subfonts[into])
50
55
  end
51
56
 
52
- # Include a single glyph by donor gid (rare; for unencoded glyphs
53
- # like .notdef).
54
- # @param donor_gid [Integer]
55
- # @param from [Symbol, String] source label
56
- def include_gid(donor_gid, from:)
57
- Selector::Gid.new(donor_gid).apply(source(from), @bindings)
57
+ def include_gid(donor_gid, from:, into:)
58
+ Selector::Gid.new(donor_gid).apply(source(from), @subfonts[into])
58
59
  end
59
60
 
60
- # Always include .notdef from a named source.
61
- # @param from [Symbol, String] source label
62
- def include_notdef(from:)
63
- include_gid(0, from: from)
61
+ def include_notdef(from:, into:)
62
+ include_gid(0, from: from, into: into)
64
63
  end
65
64
 
66
- # Set font-wide metadata on the stitched font.
67
- # @param info_hash [Hash] any subset of Fontisan::Ufo::Info fields
68
65
  def set_info(info_hash)
69
- @target.info = Ufo::Info.new(info_hash)
66
+ @info = Ufo::Info.new(info_hash)
70
67
  end
71
68
 
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
- #
80
- # @param path [String] output file path
81
- # @param format [Symbol] :ttf or :otf
82
- def write_to(path, format: :ttf)
83
- build_target_font
69
+ def subfont_names
70
+ @subfonts.keys
71
+ end
72
+
73
+ def build_target_font(subfont:)
74
+ build_target_for(subfont)
75
+ end
76
+
77
+ def write_to(path, format:, subfont:)
78
+ target = build_target_for(subfont)
79
+ GlyphLimit.check!(target.glyphs.size, format: format)
80
+
84
81
  compiler = compiler_for(format)
85
- compiler_instance = compiler.new(@target)
86
- compiler_instance.compile(output_path: path)
82
+ compiler.new(target).compile(output_path: path)
87
83
 
88
84
  propagate_cbdt_tables(path) if cbdt_source
89
-
90
85
  path
91
86
  end
92
87
 
93
- # Build the internal UFO::Font from the current bindings. Useful
94
- # for testing or for further manipulation before writing.
95
- # @return [Fontisan::Ufo::Font]
96
- def build_target_font
97
- @target = Ufo::Font.new
98
- assign_gids_and_copy_glyphs
99
- @target
88
+ def write_collection(path, format:)
89
+ raise ArgumentError, "no subfonts declared" if @subfonts.empty?
90
+
91
+ compiled = @subfonts.keys.map do |name|
92
+ compile_subfont_to_loaded_font(name, format: format)
93
+ end
94
+
95
+ collection_format = collection_format_for(format)
96
+ Collection::Builder.new(compiled, format: collection_format,
97
+ optimize: true).build_to_file(path)
98
+ path
100
99
  end
101
100
 
102
101
  private
@@ -107,9 +106,6 @@ module Fontisan
107
106
  end
108
107
  end
109
108
 
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
109
  def cbdt_source
114
110
  cbdts = @sources.values.select { |s| s.bitmap_mode == :cbdt }
115
111
  if cbdts.size > 1
@@ -120,87 +116,93 @@ module Fontisan
120
116
  cbdts.first
121
117
  end
122
118
 
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
-
157
119
  def compiler_for(format)
158
120
  case format.to_sym
159
121
  when :ttf then Ufo::Compile::TtfCompiler
160
122
  when :otf then Ufo::Compile::OtfCompiler
123
+ when :otf2 then Ufo::Compile::Otf2Compiler
161
124
  else
162
125
  raise ArgumentError, "unknown format: #{format.inspect}"
163
126
  end
164
127
  end
165
128
 
166
- # Walk bindings in codepoint order, assign sequential new gids,
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.
172
- def assign_gids_and_copy_glyphs
173
- cbdt = cbdt_source
129
+ def collection_format_for(subfont_format)
130
+ subfont_format == :ttf ? :ttc : :otc
131
+ end
132
+
133
+ def build_target_for(subfont_name)
134
+ bindings = @subfonts[subfont_name] || []
135
+ target = Ufo::Font.new
136
+ target.info = @info ? @info.dup : Ufo::Info.new
137
+ dedup = @deduplicate ? Deduplicator.new : nil
138
+ assign_gids_and_copy_glyphs(bindings, target, dedup)
139
+ target
140
+ end
141
+
142
+ def compile_subfont_to_loaded_font(subfont_name, format:)
143
+ target = build_target_for(subfont_name)
144
+ GlyphLimit.check!(target.glyphs.size, format: format)
145
+
146
+ ext = format == :ttf ? ".ttf" : ".otf"
147
+ Dir.mktmpdir do |dir|
148
+ sub_path = File.join(dir, "sub#{subfont_name}#{ext}")
149
+ compiler = compiler_for(format)
150
+ compiler.new(target).compile(output_path: sub_path)
151
+ return Fontisan::FontLoader.load(sub_path)
152
+ end
153
+ end
154
+
155
+ def assign_gids_and_copy_glyphs(bindings, target, deduplicator)
156
+ cbdt = safe_cbdt_source
174
157
 
175
158
  if cbdt
176
- add_all_cbdt_glyphs(cbdt)
159
+ add_all_cbdt_glyphs(cbdt, target)
177
160
  else
178
- add_notdef_from_bindings
161
+ add_notdef_from(bindings, target, deduplicator)
179
162
  end
180
163
 
181
- sorted_bindings.each do |binding|
164
+ sorted_bindings(bindings).each do |binding|
182
165
  next if binding[:donor_gid].zero?
183
-
184
- # Skip bindings from the CBDT source — its glyphs are already added.
185
166
  next if cbdt && binding[:source].equal?(cbdt)
186
167
 
187
168
  glyph = binding[:source].glyph_for_gid(binding[:donor_gid])
188
169
  next unless glyph
189
170
 
190
- if @target.glyphs.key?(glyph.name)
191
- add_extra_unicode(glyph.name, binding[:codepoint])
171
+ canonical = deduplicator&.find(glyph)
172
+ if canonical && target.glyphs.key?(canonical)
173
+ add_extra_unicode(target, canonical, binding[:codepoint])
192
174
  else
193
- copy_glyph_into(@target, name: glyph.name,
194
- source: binding[:source],
195
- donor_gid: binding[:donor_gid],
196
- codepoint: binding[:codepoint])
175
+ name = unique_target_name(target, glyph.name)
176
+ copy_glyph_into(target, name: name, source: binding[:source],
177
+ donor_gid: binding[:donor_gid],
178
+ codepoint: binding[:codepoint])
179
+ deduplicator&.register(glyph, name)
197
180
  end
198
181
  end
199
182
  end
200
183
 
201
- # Bindings sorted by codepoint (nil codepoints come last).
202
- def sorted_bindings
203
- @bindings.sort_by { |b| [b[:codepoint] || Float::INFINITY, b[:donor_gid]] }
184
+ def safe_cbdt_source
185
+ cbdts = @sources.values.select { |s| s.bitmap_mode == :cbdt }
186
+ cbdts.size == 1 ? cbdts.first : nil
187
+ rescue MultipleCbdtSourcesError
188
+ nil
189
+ end
190
+
191
+ def sorted_bindings(bindings)
192
+ bindings.sort_by { |b| [b[:codepoint] || Float::INFINITY, b[:donor_gid]] }
193
+ end
194
+
195
+ def add_notdef_from(bindings, target, deduplicator)
196
+ notdef_binding = bindings.find { |b| b[:donor_gid].zero? }
197
+ if notdef_binding
198
+ copy_glyph_into(target, name: ".notdef",
199
+ source: notdef_binding[:source],
200
+ donor_gid: 0)
201
+ else
202
+ target.layers.default_layer.add(Ufo::Glyph.new(name: ".notdef"))
203
+ end
204
+ dedup_target = target.glyphs[".notdef"]
205
+ deduplicator&.register(dedup_target, ".notdef") if dedup_target
204
206
  end
205
207
 
206
208
  def copy_glyph_into(target_font, name:, source:, donor_gid:, codepoint: nil)
@@ -212,46 +214,85 @@ module Fontisan
212
214
  target_font.layers.default_layer.add(copy)
213
215
  end
214
216
 
215
- def add_extra_unicode(glyph_name, codepoint)
217
+ def add_extra_unicode(target_font, glyph_name, codepoint)
216
218
  return unless codepoint
217
219
 
218
- glyph = @target.glyph(glyph_name)
220
+ glyph = target_font.glyph(glyph_name)
219
221
  glyph.add_unicode(codepoint) unless glyph.unicodes.include?(codepoint)
220
222
  end
221
223
 
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"))
224
+ def unique_target_name(target_font, base_name)
225
+ return base_name unless target_font.glyphs.key?(base_name)
226
+
227
+ suffix = 1
228
+ loop do
229
+ candidate = "#{base_name}.#{suffix}"
230
+ return candidate unless target_font.glyphs.key?(candidate)
231
+
232
+ suffix += 1
232
233
  end
233
234
  end
234
235
 
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)
236
+ def clone_glyph(original, name:)
237
+ copy = Ufo::Glyph.new(name: name)
238
+ copy.width = original.width
239
+ copy.height = original.height
240
+ original.contours.each { |c| copy.add_contour(clone_contour(c)) }
241
+ original.components.each { |c| copy.add_component(c) }
242
+ original.anchors.each { |a| copy.add_anchor(a) }
243
+ original.guidelines.each { |g| copy.add_guideline(g) }
244
+ copy
245
+ end
246
+
247
+ def clone_contour(original)
248
+ points = original.points.map do |p|
249
+ Ufo::Point.new(x: p.x, y: p.y, type: p.type, smooth: p.smooth)
250
+ end
251
+ Ufo::Contour.new(points)
252
+ end
253
+
254
+ def propagate_cbdt_tables(path)
255
+ source = cbdt_source
256
+ return unless source
257
+
258
+ compiled = Fontisan::FontLoader.load(path)
259
+
260
+ tables = {}
261
+ compiled.table_names.each do |tag|
262
+ raw = extract_raw_table(compiled, tag)
263
+ tables[tag] = raw if raw
264
+ end
265
+
266
+ cbdt_bytes = source.raw_table_bytes("CBDT")
267
+ cblc_bytes = source.raw_table_bytes("CBLC")
268
+ tables["CBDT"] = cbdt_bytes if cbdt_bytes
269
+ tables["CBLC"] = cblc_bytes if cblc_bytes
270
+
271
+ sfnt = tables.key?("CFF ") || tables.key?("CFF2") ? 0x4F54544F : 0x00010000
272
+ Fontisan::FontWriter.write_to_file(tables, path, sfnt_version: sfnt)
273
+ end
274
+
275
+ def extract_raw_table(font, tag)
276
+ sfnt_table = font.table(tag)
277
+ return nil unless sfnt_table
278
+
279
+ sfnt_table.raw_data
280
+ rescue StandardError
281
+ nil
282
+ end
283
+
284
+ def add_all_cbdt_glyphs(source, target)
240
285
  ufo = source.font.is_a?(Ufo::Font) ? source.font : nil
241
286
  if ufo
242
- ufo.glyphs.each_value { |g| @target.layers.default_layer.add(clone_glyph(g, name: g.name)) }
287
+ ufo.glyphs.each_value { |g| target.layers.default_layer.add(clone_glyph(g, name: g.name)) }
243
288
  return
244
289
  end
245
290
 
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
291
  maxp = source.font.table("maxp")
250
292
  num_glyphs = maxp&.num_glyphs || 0
251
293
  cmap = source.font.table("cmap")
252
294
  mappings = cmap&.unicode_mappings || {}
253
295
 
254
- # Build gid → [codepoints] from cmap
255
296
  gid_cps = Hash.new { |h, k| h[k] = [] }
256
297
  mappings.each { |cp, gid| gid_cps[gid] << cp }
257
298
 
@@ -260,28 +301,8 @@ module Fontisan
260
301
  glyph = Ufo::Glyph.new(name: name)
261
302
  glyph.width = 0
262
303
  gid_cps[gid].each { |cp| glyph.add_unicode(cp) }
263
- @target.layers.default_layer.add(glyph)
264
- end
265
- end
266
-
267
- # Deep-copy a glyph with a new name. Used so multiple target
268
- # glyphs can share the same source outline without aliasing.
269
- def clone_glyph(original, name:)
270
- copy = Ufo::Glyph.new(name: name)
271
- copy.width = original.width
272
- copy.height = original.height
273
- original.contours.each { |c| copy.add_contour(clone_contour(c)) }
274
- original.components.each { |c| copy.add_component(c) }
275
- original.anchors.each { |a| copy.add_anchor(a) }
276
- original.guidelines.each { |g| copy.add_guideline(g) }
277
- copy
278
- end
279
-
280
- def clone_contour(original)
281
- points = original.points.map do |p|
282
- Ufo::Point.new(x: p.x, y: p.y, type: p.type, smooth: p.smooth)
304
+ target.layers.default_layer.add(glyph)
283
305
  end
284
- Ufo::Contour.new(points)
285
306
  end
286
307
  end
287
308
  end
@@ -0,0 +1,132 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Fontisan
4
+ module SvgToGlyf
5
+ # Orchestrates the full SVG → Ufo::Glyph pipeline:
6
+ #
7
+ # path data + transforms
8
+ # → Path::Parser.parse → [Command]
9
+ # → Path::ContourBuilder.build → [Ufo::Contour]
10
+ # → apply final transform (normalization · group transform)
11
+ # → round to Integer
12
+ # → Ufo::Glyph
13
+ #
14
+ # For SVG files and directories, the Document class extracts the
15
+ # viewBox, accumulated transforms, and path data; the Assembler
16
+ # composes the normalizer with the group transform and runs the
17
+ # pipeline once per path.
18
+ class Assembler
19
+ attr_reader :upm
20
+
21
+ # @param upm [Integer] font units-per-em
22
+ def initialize(upm: SvgToGlyf::DEFAULT_UPM)
23
+ @upm = upm.to_i
24
+ end
25
+
26
+ # Build a glyph directly from a path data string.
27
+ #
28
+ # @param path_data [String] SVG path d= attribute
29
+ # @param codepoint [Integer, nil] Unicode codepoint
30
+ # @param name [String, nil] glyph name
31
+ # @param viewbox [Hash{Symbol=>Float}, nil] :width, :height
32
+ # @param transform [Geometry::AffineTransform, nil] group transform
33
+ # @return [Fontisan::Ufo::Glyph]
34
+ def build_from_path_data(path_data, codepoint: nil, name: nil,
35
+ viewbox: nil, transform: nil)
36
+ viewbox ||= { width: @upm, height: @upm }
37
+ final = normalizer_for(**viewbox).final_transform(transform || Geometry::AffineTransform.identity)
38
+ contours = build_contours(path_data, final)
39
+ assemble_glyph(name || glyph_name_for(codepoint), contours, codepoint)
40
+ end
41
+
42
+ # Build a glyph from an SVG file.
43
+ #
44
+ # @param file_path [String]
45
+ # @param codepoint [Integer, nil] override; otherwise derived from filename
46
+ # @return [Fontisan::Ufo::Glyph]
47
+ def build_from_file(file_path, codepoint: nil)
48
+ doc = Document.from_file(file_path)
49
+ codepoint ||= codepoint_from_filename(File.basename(file_path))
50
+
51
+ doc.each_path.with_object(nil) do |(data, transform), _|
52
+ return build_from_doc_path(data, transform, doc, codepoint)
53
+ end
54
+
55
+ empty_glyph(codepoint)
56
+ end
57
+
58
+ # Build a font from a directory of SVG files.
59
+ #
60
+ # @param dir [String]
61
+ # @return [Fontisan::Ufo::Font]
62
+ def build_from_directory(dir)
63
+ font = Fontisan::Ufo::Font.new
64
+ font.info.units_per_em = @upm
65
+
66
+ Dir.glob(File.join(dir, "*.svg")).each do |path|
67
+ glyph = build_from_file(path)
68
+ font.glyphs[glyph.name] = glyph
69
+ end
70
+
71
+ font
72
+ end
73
+
74
+ private
75
+
76
+ def build_from_doc_path(data, group_transform, doc, codepoint)
77
+ final = normalizer_for(width: doc.viewbox_width, height: doc.viewbox_height)
78
+ .final_transform(group_transform)
79
+ contours = build_contours(data, final)
80
+ assemble_glyph(glyph_name_for(codepoint), contours, codepoint)
81
+ end
82
+
83
+ def build_contours(path_data, final_transform)
84
+ commands = Path::Parser.parse(path_data)
85
+ contours = Path::ContourBuilder.new.build(commands)
86
+ contours.map { |c| transform_contour(c, final_transform) }
87
+ end
88
+
89
+ def transform_contour(contour, transform)
90
+ points = contour.points.map do |pt|
91
+ x, y = transform.apply(pt.x, pt.y)
92
+ Fontisan::Ufo::Point.new(x: x.round, y: y.round, type: pt.type, smooth: pt.smooth)
93
+ end
94
+ Fontisan::Ufo::Contour.new(points)
95
+ end
96
+
97
+ def normalizer_for(width:, height:)
98
+ Geometry::Normalizer.new(viewbox_width: width, viewbox_height: height, upm: @upm)
99
+ end
100
+
101
+ def assemble_glyph(name, contours, codepoint)
102
+ glyph = Fontisan::Ufo::Glyph.new(name: name)
103
+ glyph.width = @upm
104
+ contours.each { |c| glyph.add_contour(c) }
105
+ glyph.add_unicode(codepoint) if codepoint
106
+ glyph
107
+ end
108
+
109
+ def empty_glyph(codepoint)
110
+ glyph = Fontisan::Ufo::Glyph.new(name: glyph_name_for(codepoint))
111
+ glyph.width = @upm
112
+ glyph.add_unicode(codepoint) if codepoint
113
+ glyph
114
+ end
115
+
116
+ # UFO convention: uniXXXX for BMP, uXXXXX for supplementary planes.
117
+ def glyph_name_for(codepoint)
118
+ return "glyph" unless codepoint
119
+
120
+ codepoint < 0x10000 ? "uni%04X" % codepoint : "u%05X" % codepoint
121
+ end
122
+
123
+ # Derive a codepoint from a filename like "U+10940.svg" or "10940.svg".
124
+ def codepoint_from_filename(basename)
125
+ match = basename.match(/(?:U\+)?([0-9A-Fa-f]{4,6})\.svg\z/)
126
+ return nil unless match
127
+
128
+ match[1].to_i(16)
129
+ end
130
+ end
131
+ end
132
+ end
@@ -0,0 +1,83 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "nokogiri"
4
+
5
+ module Fontisan
6
+ module SvgToGlyf
7
+ # Walks an SVG XML document to extract path data and accumulated
8
+ # transforms. The Document is the single source of truth for which
9
+ # transforms apply to which paths and what coordinate space the
10
+ # SVG defines (via viewBox).
11
+ class Document
12
+ attr_reader :viewbox_width, :viewbox_height, :source
13
+
14
+ # @param xml [String] raw SVG XML
15
+ def self.from_xml(xml)
16
+ new(Nokogiri::XML(xml))
17
+ end
18
+
19
+ # @param path [String] file path to an .svg file
20
+ def self.from_file(path)
21
+ from_xml(File.read(path))
22
+ end
23
+
24
+ # @param doc [Nokogiri::XML::Document]
25
+ def initialize(doc)
26
+ @doc = doc
27
+ @source = doc
28
+ extract_viewbox
29
+ end
30
+
31
+ # Yield each <path> element's d= string along with the accumulated
32
+ # AffineTransform from all ancestor <g> transform= attributes.
33
+ #
34
+ # @yieldparam path_data [String] the d= attribute value
35
+ # @yieldparam transform [Geometry::AffineTransform] accumulated group transform
36
+ def each_path(&)
37
+ return enum_for(:each_path) unless block_given?
38
+
39
+ walk(@doc.root, Geometry::AffineTransform.identity, &)
40
+ end
41
+
42
+ private
43
+
44
+ def extract_viewbox
45
+ root = @doc.root
46
+ vb = root&.attribute("viewBox")&.value
47
+ if vb
48
+ _, _, w, h = vb.split(/\s+/).map(&:to_f)
49
+ @viewbox_width = w
50
+ @viewbox_height = h
51
+ else
52
+ @viewbox_width = (root&.attribute("width")&.value || DEFAULT_UPM).to_f
53
+ @viewbox_height = (root&.attribute("height")&.value || DEFAULT_UPM).to_f
54
+ end
55
+ end
56
+
57
+ # Recursively walk the XML tree. When a <g> has a transform=,
58
+ # compose it into the running accumulated transform. When a <path>
59
+ # is found, yield its d= with the current accumulated transform.
60
+ def walk(node, accumulated, &)
61
+ return unless node
62
+
63
+ node.children.each do |child|
64
+ next unless child.element?
65
+
66
+ case child.name
67
+ when "g"
68
+ child_transform = parse_transform(child)
69
+ walk(child, accumulated.compose(child_transform), &)
70
+ when "path"
71
+ data = child.attribute("d")&.value
72
+ yield(data, accumulated) if data
73
+ end
74
+ end
75
+ end
76
+
77
+ def parse_transform(element)
78
+ raw = element.attribute("transform")&.value
79
+ Geometry::TransformParser.parse(raw)
80
+ end
81
+ end
82
+ end
83
+ end