fontisan 0.4.6 → 0.4.8

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 (90) 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 +119 -0
  6. data/README.adoc +121 -68
  7. data/benchmark/compile_benchmark.rb +70 -0
  8. data/docs/.vitepress/config.ts +0 -7
  9. data/docs/CFF2_SUPPORT.adoc +184 -0
  10. data/docs/STITCHER_GUIDE.adoc +151 -0
  11. data/docs/SVG_TO_GLYF.adoc +118 -0
  12. data/docs/UFO_COMPILATION.adoc +119 -0
  13. data/docs/cli/index.md +5 -28
  14. data/docs/index.md +0 -2
  15. data/lib/fontisan/cli.rb +29 -8
  16. data/lib/fontisan/collection/reader/stats.rb +23 -0
  17. data/lib/fontisan/collection/reader.rb +90 -0
  18. data/lib/fontisan/collection/writer.rb +5 -6
  19. data/lib/fontisan/collection.rb +1 -0
  20. data/lib/fontisan/commands/convert_command.rb +96 -18
  21. data/lib/fontisan/commands/multi_format_output.rb +59 -0
  22. data/lib/fontisan/commands/validate_collection_command.rb +121 -0
  23. data/lib/fontisan/commands.rb +2 -0
  24. data/lib/fontisan/error.rb +56 -0
  25. data/lib/fontisan/models.rb +0 -1
  26. data/lib/fontisan/stitcher/collection_result.rb +18 -0
  27. data/lib/fontisan/stitcher/deduplicator.rb +47 -0
  28. data/lib/fontisan/stitcher/glyph_limit.rb +53 -0
  29. data/lib/fontisan/stitcher/glyph_signature.rb +51 -0
  30. data/lib/fontisan/stitcher/partition_strategy/base.rb +23 -0
  31. data/lib/fontisan/stitcher/partition_strategy/blueprint.rb +24 -0
  32. data/lib/fontisan/stitcher/partition_strategy/by_plane.rb +131 -0
  33. data/lib/fontisan/stitcher/partition_strategy/partition.rb +24 -0
  34. data/lib/fontisan/stitcher/partition_strategy.rb +22 -0
  35. data/lib/fontisan/stitcher.rb +223 -168
  36. data/lib/fontisan/svg_to_glyf/assembler.rb +132 -0
  37. data/lib/fontisan/svg_to_glyf/document.rb +83 -0
  38. data/lib/fontisan/svg_to_glyf/geometry/affine_transform.rb +112 -0
  39. data/lib/fontisan/svg_to_glyf/geometry/normalizer.rb +45 -0
  40. data/lib/fontisan/svg_to_glyf/geometry/transform_parser.rb +91 -0
  41. data/lib/fontisan/svg_to_glyf/geometry.rb +13 -0
  42. data/lib/fontisan/svg_to_glyf/path/command.rb +18 -0
  43. data/lib/fontisan/svg_to_glyf/path/contour_builder.rb +140 -0
  44. data/lib/fontisan/svg_to_glyf/path/parser.rb +98 -0
  45. data/lib/fontisan/svg_to_glyf/path/state.rb +79 -0
  46. data/lib/fontisan/svg_to_glyf/path.rb +14 -0
  47. data/lib/fontisan/svg_to_glyf.rb +62 -0
  48. data/lib/fontisan/tables/cff/cff2_charstring_builder.rb +216 -0
  49. data/lib/fontisan/tables/cff.rb +1 -0
  50. data/lib/fontisan/tables/cff2/dict_encoder.rb +94 -0
  51. data/lib/fontisan/tables/cff2/fd_select.rb +69 -0
  52. data/lib/fontisan/tables/cff2/header.rb +34 -0
  53. data/lib/fontisan/tables/cff2/index_builder.rb +79 -0
  54. data/lib/fontisan/tables/cff2.rb +4 -0
  55. data/lib/fontisan/ufo/compile/cbdt_cblc.rb +103 -0
  56. data/lib/fontisan/ufo/compile/cff2.rb +181 -0
  57. data/lib/fontisan/ufo/compile/cff2_subroutines.rb +39 -0
  58. data/lib/fontisan/ufo/compile/colr.rb +80 -0
  59. data/lib/fontisan/ufo/compile/cpal.rb +61 -0
  60. data/lib/fontisan/ufo/compile/math.rb +143 -0
  61. data/lib/fontisan/ufo/compile/meta.rb +51 -0
  62. data/lib/fontisan/ufo/compile/name.rb +2 -2
  63. data/lib/fontisan/ufo/compile/otf2_compiler.rb +46 -0
  64. data/lib/fontisan/ufo/compile/sbix.rb +99 -0
  65. data/lib/fontisan/ufo/compile/svg_table.rb +60 -0
  66. data/lib/fontisan/ufo/compile/variable_otf.rb +75 -0
  67. data/lib/fontisan/ufo/compile.rb +11 -0
  68. data/lib/fontisan/ufo/info.rb +48 -0
  69. data/lib/fontisan/unicode/plane.rb +56 -0
  70. data/lib/fontisan/unicode.rb +17 -0
  71. data/lib/fontisan/version.rb +1 -1
  72. data/lib/fontisan.rb +5 -2
  73. metadata +53 -19
  74. data/docs/cli/audit.md +0 -337
  75. data/lib/fontisan/cldr/aggregator.rb +0 -33
  76. data/lib/fontisan/cldr/cache_manager.rb +0 -110
  77. data/lib/fontisan/cldr/config.rb +0 -59
  78. data/lib/fontisan/cldr/download_error.rb +0 -9
  79. data/lib/fontisan/cldr/downloader.rb +0 -79
  80. data/lib/fontisan/cldr/error.rb +0 -8
  81. data/lib/fontisan/cldr/index.rb +0 -64
  82. data/lib/fontisan/cldr/index_builder.rb +0 -72
  83. data/lib/fontisan/cldr/unicode_set_parser.rb +0 -189
  84. data/lib/fontisan/cldr/unknown_version_error.rb +0 -9
  85. data/lib/fontisan/cldr/version_resolver.rb +0 -91
  86. data/lib/fontisan/cldr.rb +0 -23
  87. data/lib/fontisan/cli/cldr_cli.rb +0 -85
  88. data/lib/fontisan/config/cldr.yml +0 -22
  89. data/lib/fontisan/models/cldr/language_coverage.rb +0 -31
  90. data/lib/fontisan/models/cldr.rb +0 -12
@@ -1,102 +1,123 @@
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"
22
-
23
- attr_reader :sources, :bindings
24
-
25
- def initialize
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"
33
+ autoload :CollectionResult, "fontisan/stitcher/collection_result"
34
+ autoload :SubfontStats, "fontisan/stitcher/collection_result"
35
+ autoload :PartitionStrategy, "fontisan/stitcher/partition_strategy"
36
+
37
+ # Internal: pairs a compiled loaded font with its stats so
38
+ # +write_collection+ can build the collection and the result from a
39
+ # single compilation pass.
40
+ CompiledSubfont = Struct.new(:name, :font, :stats, keyword_init: true)
41
+ private_constant :CompiledSubfont
42
+
43
+ DEFAULT_DEDUPLICATE = true
44
+
45
+ attr_reader :sources, :subfonts, :info
46
+
47
+ def initialize(deduplicate: DEFAULT_DEDUPLICATE)
26
48
  @sources = {}
27
- @bindings = []
28
- @target = Ufo::Font.new
49
+ @subfonts = Hash.new { |h, k| h[k] = [] }
50
+ @info = nil
51
+ @deduplicate = deduplicate
29
52
  end
30
53
 
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
54
  def add_source(label, font)
35
55
  @sources[label.to_sym] = Source.new(font)
36
56
  end
37
57
 
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)
58
+ def include_range(range, from:, into:)
59
+ Selector::Range.new(range).apply(source(from), @subfonts[into])
43
60
  end
44
61
 
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)
62
+ def include_codepoints(codepoints, from:, into:)
63
+ Selector::Codepoints.new(codepoints).apply(source(from), @subfonts[into])
50
64
  end
51
65
 
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)
66
+ def include_codepoints_map(cp_map, into:)
67
+ cp_map.to_h
68
+ .group_by { |_cp, label| label }
69
+ .transform_values { |pairs| pairs.map(&:first).sort }
70
+ .each { |label, cps| include_codepoints(cps, from: label, into: into) }
58
71
  end
59
72
 
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)
73
+ def include_gid(donor_gid, from:, into:)
74
+ Selector::Gid.new(donor_gid).apply(source(from), @subfonts[into])
75
+ end
76
+
77
+ def include_notdef(from:, into:)
78
+ include_gid(0, from: from, into: into)
64
79
  end
65
80
 
66
- # Set font-wide metadata on the stitched font.
67
- # @param info_hash [Hash] any subset of Fontisan::Ufo::Info fields
68
81
  def set_info(info_hash)
69
- @target.info = Ufo::Info.new(info_hash)
82
+ @info = Ufo::Info.new(info_hash)
83
+ end
84
+
85
+ def subfont_names
86
+ @subfonts.keys
70
87
  end
71
88
 
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
89
+ def build_target_font(subfont:)
90
+ build_target_for(subfont)
91
+ end
92
+
93
+ def write_to(path, format:, subfont:)
94
+ target = build_target_for(subfont)
95
+ GlyphLimit.check!(target.glyphs.size, format: format)
96
+
84
97
  compiler = compiler_for(format)
85
- compiler_instance = compiler.new(@target)
86
- compiler_instance.compile(output_path: path)
98
+ compiler.new(target).compile(output_path: path)
87
99
 
88
100
  propagate_cbdt_tables(path) if cbdt_source
89
-
90
101
  path
91
102
  end
92
103
 
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
104
+ def write_collection(path, format:)
105
+ raise ArgumentError, "no subfonts declared" if @subfonts.empty?
106
+
107
+ compiled = @subfonts.keys.map do |name|
108
+ compile_subfont_with_stats(name, format: format)
109
+ end
110
+ fonts = compiled.map(&:font)
111
+
112
+ collection_format = collection_format_for(format)
113
+ Collection::Builder.new(fonts, format: collection_format,
114
+ optimize: true).build_to_file(path)
115
+
116
+ CollectionResult.new(
117
+ path: path,
118
+ bytes: File.size(path),
119
+ subfonts: compiled.map(&:stats),
120
+ )
100
121
  end
101
122
 
102
123
  private
@@ -107,9 +128,6 @@ module Fontisan
107
128
  end
108
129
  end
109
130
 
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
131
  def cbdt_source
114
132
  cbdts = @sources.values.select { |s| s.bitmap_mode == :cbdt }
115
133
  if cbdts.size > 1
@@ -120,87 +138,105 @@ module Fontisan
120
138
  cbdts.first
121
139
  end
122
140
 
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
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}"
137
148
  end
149
+ end
138
150
 
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)
151
+ def collection_format_for(subfont_format)
152
+ subfont_format == :ttf ? :ttc : :otc
146
153
  end
147
154
 
148
- def extract_raw_table(font, tag)
149
- sfnt_table = font.table(tag)
150
- return nil unless sfnt_table
155
+ def build_target_for(subfont_name)
156
+ bindings = @subfonts[subfont_name] || []
157
+ target = Ufo::Font.new
158
+ target.info = @info ? @info.dup : Ufo::Info.new
159
+ dedup = @deduplicate ? Deduplicator.new : nil
160
+ assign_gids_and_copy_glyphs(bindings, target, dedup)
161
+ target
162
+ end
151
163
 
152
- sfnt_table.raw_data
153
- rescue StandardError
154
- nil
164
+ def compile_subfont_to_loaded_font(subfont_name, format:)
165
+ compile_subfont_with_stats(subfont_name, format: format).font
155
166
  end
156
167
 
157
- def compiler_for(format)
158
- case format.to_sym
159
- when :ttf then Ufo::Compile::TtfCompiler
160
- when :otf then Ufo::Compile::OtfCompiler
161
- else
162
- raise ArgumentError, "unknown format: #{format.inspect}"
168
+ def compile_subfont_with_stats(subfont_name, format:)
169
+ target = build_target_for(subfont_name)
170
+ GlyphLimit.check!(target.glyphs.size, format: format)
171
+
172
+ ext = format == :ttf ? ".ttf" : ".otf"
173
+ 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)
177
+ propagate_cbdt_tables(sub_path) if cbdt_source
178
+
179
+ loaded = Fontisan::FontLoader.load(sub_path)
180
+ stats = SubfontStats.new(
181
+ name: subfont_name,
182
+ glyph_count: loaded.table("maxp")&.num_glyphs || 0,
183
+ codepoint_count: (loaded.table("cmap")&.unicode_mappings || {}).size,
184
+ )
185
+ CompiledSubfont.new(name: subfont_name, font: loaded, stats: stats)
163
186
  end
164
187
  end
165
188
 
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
189
+ def assign_gids_and_copy_glyphs(bindings, target, deduplicator)
190
+ cbdt = safe_cbdt_source
174
191
 
175
192
  if cbdt
176
- add_all_cbdt_glyphs(cbdt)
193
+ add_all_cbdt_glyphs(cbdt, target)
177
194
  else
178
- add_notdef_from_bindings
195
+ add_notdef_from(bindings, target, deduplicator)
179
196
  end
180
197
 
181
- sorted_bindings.each do |binding|
198
+ sorted_bindings(bindings).each do |binding|
182
199
  next if binding[:donor_gid].zero?
183
-
184
- # Skip bindings from the CBDT source — its glyphs are already added.
185
200
  next if cbdt && binding[:source].equal?(cbdt)
186
201
 
187
202
  glyph = binding[:source].glyph_for_gid(binding[:donor_gid])
188
203
  next unless glyph
189
204
 
190
- if @target.glyphs.key?(glyph.name)
191
- add_extra_unicode(glyph.name, binding[:codepoint])
205
+ canonical = deduplicator&.find(glyph)
206
+ if canonical && target.glyphs.key?(canonical)
207
+ add_extra_unicode(target, canonical, binding[:codepoint])
192
208
  else
193
- copy_glyph_into(@target, name: glyph.name,
194
- source: binding[:source],
195
- donor_gid: binding[:donor_gid],
196
- codepoint: binding[:codepoint])
209
+ name = unique_target_name(target, glyph.name)
210
+ copy_glyph_into(target, name: name, source: binding[:source],
211
+ donor_gid: binding[:donor_gid],
212
+ codepoint: binding[:codepoint])
213
+ deduplicator&.register(glyph, name)
197
214
  end
198
215
  end
199
216
  end
200
217
 
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]] }
218
+ def safe_cbdt_source
219
+ cbdts = @sources.values.select { |s| s.bitmap_mode == :cbdt }
220
+ cbdts.size == 1 ? cbdts.first : nil
221
+ rescue MultipleCbdtSourcesError
222
+ nil
223
+ end
224
+
225
+ def sorted_bindings(bindings)
226
+ bindings.sort_by { |b| [b[:codepoint] || Float::INFINITY, b[:donor_gid]] }
227
+ end
228
+
229
+ def add_notdef_from(bindings, target, deduplicator)
230
+ notdef_binding = bindings.find { |b| b[:donor_gid].zero? }
231
+ if notdef_binding
232
+ copy_glyph_into(target, name: ".notdef",
233
+ source: notdef_binding[:source],
234
+ donor_gid: 0)
235
+ else
236
+ target.layers.default_layer.add(Ufo::Glyph.new(name: ".notdef"))
237
+ end
238
+ dedup_target = target.glyphs[".notdef"]
239
+ deduplicator&.register(dedup_target, ".notdef") if dedup_target
204
240
  end
205
241
 
206
242
  def copy_glyph_into(target_font, name:, source:, donor_gid:, codepoint: nil)
@@ -212,46 +248,85 @@ module Fontisan
212
248
  target_font.layers.default_layer.add(copy)
213
249
  end
214
250
 
215
- def add_extra_unicode(glyph_name, codepoint)
251
+ def add_extra_unicode(target_font, glyph_name, codepoint)
216
252
  return unless codepoint
217
253
 
218
- glyph = @target.glyph(glyph_name)
254
+ glyph = target_font.glyph(glyph_name)
219
255
  glyph.add_unicode(codepoint) unless glyph.unicodes.include?(codepoint)
220
256
  end
221
257
 
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"))
258
+ def unique_target_name(target_font, base_name)
259
+ return base_name unless target_font.glyphs.key?(base_name)
260
+
261
+ suffix = 1
262
+ loop do
263
+ candidate = "#{base_name}.#{suffix}"
264
+ return candidate unless target_font.glyphs.key?(candidate)
265
+
266
+ suffix += 1
232
267
  end
233
268
  end
234
269
 
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)
270
+ def clone_glyph(original, name:)
271
+ copy = Ufo::Glyph.new(name: name)
272
+ copy.width = original.width
273
+ copy.height = original.height
274
+ original.contours.each { |c| copy.add_contour(clone_contour(c)) }
275
+ original.components.each { |c| copy.add_component(c) }
276
+ original.anchors.each { |a| copy.add_anchor(a) }
277
+ original.guidelines.each { |g| copy.add_guideline(g) }
278
+ copy
279
+ end
280
+
281
+ def clone_contour(original)
282
+ points = original.points.map do |p|
283
+ Ufo::Point.new(x: p.x, y: p.y, type: p.type, smooth: p.smooth)
284
+ end
285
+ Ufo::Contour.new(points)
286
+ end
287
+
288
+ def propagate_cbdt_tables(path)
289
+ source = cbdt_source
290
+ return unless source
291
+
292
+ compiled = Fontisan::FontLoader.load(path)
293
+
294
+ tables = {}
295
+ compiled.table_names.each do |tag|
296
+ raw = extract_raw_table(compiled, tag)
297
+ tables[tag] = raw if raw
298
+ end
299
+
300
+ cbdt_bytes = source.raw_table_bytes("CBDT")
301
+ cblc_bytes = source.raw_table_bytes("CBLC")
302
+ tables["CBDT"] = cbdt_bytes if cbdt_bytes
303
+ tables["CBLC"] = cblc_bytes if cblc_bytes
304
+
305
+ sfnt = tables.key?("CFF ") || tables.key?("CFF2") ? 0x4F54544F : 0x00010000
306
+ Fontisan::FontWriter.write_to_file(tables, path, sfnt_version: sfnt)
307
+ end
308
+
309
+ def extract_raw_table(font, tag)
310
+ sfnt_table = font.table(tag)
311
+ return nil unless sfnt_table
312
+
313
+ sfnt_table.raw_data
314
+ rescue StandardError
315
+ nil
316
+ end
317
+
318
+ def add_all_cbdt_glyphs(source, target)
240
319
  ufo = source.font.is_a?(Ufo::Font) ? source.font : nil
241
320
  if ufo
242
- ufo.glyphs.each_value { |g| @target.layers.default_layer.add(clone_glyph(g, name: g.name)) }
321
+ ufo.glyphs.each_value { |g| target.layers.default_layer.add(clone_glyph(g, name: g.name)) }
243
322
  return
244
323
  end
245
324
 
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
325
  maxp = source.font.table("maxp")
250
326
  num_glyphs = maxp&.num_glyphs || 0
251
327
  cmap = source.font.table("cmap")
252
328
  mappings = cmap&.unicode_mappings || {}
253
329
 
254
- # Build gid → [codepoints] from cmap
255
330
  gid_cps = Hash.new { |h, k| h[k] = [] }
256
331
  mappings.each { |cp, gid| gid_cps[gid] << cp }
257
332
 
@@ -260,28 +335,8 @@ module Fontisan
260
335
  glyph = Ufo::Glyph.new(name: name)
261
336
  glyph.width = 0
262
337
  gid_cps[gid].each { |cp| glyph.add_unicode(cp) }
263
- @target.layers.default_layer.add(glyph)
338
+ target.layers.default_layer.add(glyph)
264
339
  end
265
340
  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)
283
- end
284
- Ufo::Contour.new(points)
285
- end
286
341
  end
287
342
  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