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
@@ -42,11 +42,20 @@ module Fontisan
42
42
  # )
43
43
  # command.run
44
44
  class ConvertCommand < BaseCommand
45
+ # Public readers for the parsed target formats and per-format output
46
+ # paths. Exposed (rather than relying on @ivar access from specs) so
47
+ # multi-format behaviour can be tested through the public surface.
48
+ attr_reader :target_formats, :output_paths
49
+
45
50
  # Initialize convert command
46
51
  #
47
52
  # @param font_path [String] Path to input font file
48
53
  # @param options [Hash] Conversion options
49
- # @option options [String] :to Target format (ttf, otf, woff, woff2)
54
+ # @option options [String, Array<String>] :to Target format(s):
55
+ # ttf, otf, woff, woff2, type1, ttc, otc, dfont, svg. Pass a
56
+ # comma-separated string ("woff,woff2") or an array (["woff",
57
+ # "woff2"]) for multi-format output. Single-font → single-font
58
+ # only; multi-format + collection input is rejected.
50
59
  # @option options [String] :output Output file path (required)
51
60
  # @option options [Integer] :font_index Index for TTC/OTC (default: 0)
52
61
  # @option options [String] :coordinates Coordinate string (e.g., "wght=700,wdth=100")
@@ -69,8 +78,13 @@ module Fontisan
69
78
 
70
79
  @output_path = @options[:output]
71
80
 
72
- # Parse target format
73
- @target_format = parse_target_format(@options[:to])
81
+ # Parse target format(s). Always an Array<Symbol>, deduped, order
82
+ # preserved. Single-format callsites get a one-element array.
83
+ @target_formats = parse_target_formats(@options[:to])
84
+
85
+ # Resolve per-format output paths up front so filename ambiguity
86
+ # surfaces before any pipeline work is done.
87
+ @output_paths = resolve_output_paths
74
88
 
75
89
  # Extract ConversionOptions if provided
76
90
  @conv_options = extract_conversion_options(@options)
@@ -92,17 +106,26 @@ module Fontisan
92
106
 
93
107
  # Execute the conversion
94
108
  #
95
- # @return [Hash] Result information
109
+ # @return [Hash, Array<Hash>] Result information. Single-format
110
+ # returns one result hash (back-compat). Multi-format returns
111
+ # an array of result hashes, one per target format.
96
112
  # @raise [ArgumentError] If output path is not specified
97
113
  # @raise [Error] If conversion fails
98
114
  def run
99
115
  validate_options!
100
116
 
101
- # Check if input is a collection
102
117
  if collection_file?
118
+ if multi_format?
119
+ raise ArgumentError,
120
+ "Multi-format conversion is not supported for collection " \
121
+ "input. Specify a single target format."
122
+ end
123
+
103
124
  convert_collection
125
+ elsif multi_format?
126
+ convert_multi_format
104
127
  else
105
- convert_single_font
128
+ convert_single_font(@target_formats.first, @output_paths.first)
106
129
  end
107
130
  rescue ArgumentError
108
131
  # Let ArgumentError propagate for validation errors
@@ -113,6 +136,11 @@ module Fontisan
113
136
 
114
137
  private
115
138
 
139
+ # @return [Boolean] true when more than one target format was given
140
+ def multi_format?
141
+ @target_formats.size > 1
142
+ end
143
+
116
144
  # Check if input file is a collection
117
145
  #
118
146
  # @return [Boolean] true if collection
@@ -123,15 +151,36 @@ module Fontisan
123
151
  false
124
152
  end
125
153
 
126
- # Convert a single font (original implementation)
154
+ # Convert to each target format and aggregate results.
127
155
  #
156
+ # @return [Array<Hash>]
157
+ def convert_multi_format
158
+ unless @options[:quiet]
159
+ puts "Converting #{File.basename(font_path)} to " \
160
+ "#{@target_formats.join(', ')}..."
161
+ end
162
+
163
+ @target_formats.zip(@output_paths).map do |fmt, out_path|
164
+ result = convert_single_font(fmt, out_path)
165
+ unless @options[:quiet]
166
+ puts " #{fmt}: wrote #{File.basename(out_path)} " \
167
+ "(#{format_size(File.size(out_path))})"
168
+ end
169
+ result
170
+ end
171
+ end
172
+
173
+ # Convert a single font to a single target format at +out_path+.
174
+ #
175
+ # @param target_format [Symbol]
176
+ # @param out_path [String]
128
177
  # @return [Hash] Result information
129
- def convert_single_font
130
- puts "Converting #{File.basename(font_path)} to #{@target_format}..." unless @options[:quiet]
178
+ def convert_single_font(target_format, out_path)
179
+ puts "Converting #{File.basename(font_path)} to #{target_format}..." unless @options[:quiet]
131
180
 
132
181
  # Build pipeline options
133
182
  pipeline_options = {
134
- target_format: @target_format,
183
+ target_format: target_format,
135
184
  validate: @validate,
136
185
  verbose: @options[:verbose],
137
186
  }
@@ -160,7 +209,7 @@ module Fontisan
160
209
  # Use TransformationPipeline for universal conversion
161
210
  pipeline = Pipeline::TransformationPipeline.new(
162
211
  font_path,
163
- @output_path,
212
+ out_path,
164
213
  pipeline_options,
165
214
  )
166
215
 
@@ -168,12 +217,12 @@ module Fontisan
168
217
 
169
218
  # Display results
170
219
  unless @options[:quiet]
171
- output_size = File.size(@output_path)
220
+ output_size = File.size(out_path)
172
221
  input_size = File.size(font_path)
173
222
 
174
223
  puts "Conversion complete!"
175
224
  puts " Input: #{font_path} (#{format_size(input_size)})"
176
- puts " Output: #{@output_path} (#{format_size(output_size)})"
225
+ puts " Output: #{out_path} (#{format_size(output_size)})"
177
226
  puts " Format: #{result[:details][:source_format]} → #{result[:details][:target_format]}"
178
227
 
179
228
  if result[:details][:variation_preserved]
@@ -186,11 +235,11 @@ module Fontisan
186
235
  {
187
236
  success: true,
188
237
  input_path: font_path,
189
- output_path: @output_path,
238
+ output_path: out_path,
190
239
  source_format: result[:details][:source_format],
191
240
  target_format: result[:details][:target_format],
192
241
  input_size: File.size(font_path),
193
- output_size: File.size(@output_path),
242
+ output_size: File.size(out_path),
194
243
  variation_strategy: result[:details][:variation_strategy],
195
244
  }
196
245
  end
@@ -200,11 +249,11 @@ module Fontisan
200
249
  # @return [Hash] Result information
201
250
  def convert_collection
202
251
  # Determine target collection type from target format
203
- target_type = collection_type_from_format(@target_format)
252
+ target_type = collection_type_from_format(@target_formats.first)
204
253
 
205
254
  unless target_type
206
255
  raise ArgumentError,
207
- "Target format #{@target_format} is not a collection format. " \
256
+ "Target format #{@target_formats.first} is not a collection format. " \
208
257
  "Use ttc, otc, or dfont for collection conversion."
209
258
  end
210
259
 
@@ -302,12 +351,41 @@ module Fontisan
302
351
  "Output path is required. Use --output option."
303
352
  end
304
353
 
305
- unless @target_format
354
+ if @target_formats.empty?
306
355
  raise ArgumentError,
307
356
  "Target format is required. Use --to option."
308
357
  end
309
358
  end
310
359
 
360
+ # Normalize the +--to+ value into a deduplicated Array<Symbol>.
361
+ #
362
+ # Accepts:
363
+ # - "woff" → [:woff]
364
+ # - "woff,woff2" → [:woff, :woff2]
365
+ # - ["woff", "woff2"] → [:woff, :woff2]
366
+ # - ["woff,woff2", "ttf"] → [:woff, :woff2, :ttf]
367
+ # - nil / "" → []
368
+ #
369
+ # @return [Array<Symbol>]
370
+ def parse_target_formats(raw)
371
+ array = Array(raw).flat_map { |s| s.to_s.split(",") }
372
+ .map { |s| s.strip.downcase }
373
+ .reject(&:empty?)
374
+ .map { |s| parse_target_format(s) }
375
+ array.uniq
376
+ end
377
+
378
+ # Resolve the per-format output paths via {MultiFormatOutput}.
379
+ # Returns nil when no target formats have been parsed yet (the
380
+ # validate_options! path will surface the missing-format error).
381
+ #
382
+ # @return [Array<String>, nil]
383
+ def resolve_output_paths
384
+ return nil if @target_formats.empty? || @output_path.nil?
385
+
386
+ MultiFormatOutput.new(@output_path, @target_formats).paths
387
+ end
388
+
311
389
  # Parse target format from string/symbol
312
390
  #
313
391
  # @param format [String, Symbol, nil] Target format
@@ -0,0 +1,59 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Fontisan
4
+ module Commands
5
+ # Resolves the on-disk output path for each target format in a
6
+ # multi-format conversion (TODO 72).
7
+ #
8
+ # Rules:
9
+ # - One format + path has extension → use the path as-is.
10
+ # - One format + path has no extension → append ".<format>".
11
+ # - Many formats + path has extension → +ArgumentError+ (ambiguous).
12
+ # - Many formats + path has no extension → append ".<format>" per target.
13
+ #
14
+ # Pure value object: no I/O, no mutation. Extracted from
15
+ # {ConvertCommand} so multi-format path resolution can be tested
16
+ # independently of the transformation pipeline.
17
+ class MultiFormatOutput
18
+ # @param base_path [String] user-supplied --output value
19
+ # @param target_formats [Array<Symbol>] non-empty, deduplicated
20
+ def initialize(base_path, target_formats)
21
+ @base_path = base_path
22
+ @target_formats = target_formats
23
+ end
24
+
25
+ # @return [Array<String>] one resolved path per target format,
26
+ # in the same order as +target_formats+
27
+ # @raise [ArgumentError] if the base path is ambiguous
28
+ def paths
29
+ single? ? [single_format_path] : multi_format_paths
30
+ end
31
+
32
+ private
33
+
34
+ def single?
35
+ @target_formats.size == 1
36
+ end
37
+
38
+ def single_format_path
39
+ has_extension? ? @base_path : "#{@base_path}.#{@target_formats.first}"
40
+ end
41
+
42
+ def multi_format_paths
43
+ if has_extension?
44
+ raise ArgumentError,
45
+ "Output path #{@base_path.inspect} has an extension but " \
46
+ "#{@target_formats.size} target formats were given " \
47
+ "(#{@target_formats.join(', ')}). Drop the extension or " \
48
+ "specify a single format."
49
+ end
50
+
51
+ @target_formats.map { |fmt| "#{@base_path}.#{fmt}" }
52
+ end
53
+
54
+ def has_extension?
55
+ !File.extname(@base_path).strip.downcase.empty?
56
+ end
57
+ end
58
+ end
59
+ end
@@ -0,0 +1,121 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Fontisan
4
+ module Commands
5
+ # Validates the structural integrity of a TTC/OTC/dfont collection
6
+ # (TODO 74). Complements {ValidateCommand}, which runs profile-based
7
+ # checks against a single face. This command runs collection-level
8
+ # checks: face count, per-face glyph cap, optional cmap-union size.
9
+ #
10
+ # Returns an integer exit code (0 = all checks passed, 1 = any check
11
+ # failed) suitable for use as the CLI's exit status.
12
+ #
13
+ # The command is intentionally narrow: it does not subclass
14
+ # {BaseCommand} (which eagerly loads a single font at construction
15
+ # time) because the input here is a collection, not a single face.
16
+ # It owns its own loading via {Collection::Reader}.
17
+ class ValidateCollectionCommand
18
+ # Per-check result. The +message+ is +nil+ on pass.
19
+ #
20
+ # @!attribute [r] name
21
+ # @return [Symbol] check identifier (:face_count, :glyph_cap, :cmap_union)
22
+ # @!attribute [r] passed
23
+ # @return [Boolean]
24
+ # @!attribute [r] message
25
+ # @return [String, nil] human-readable failure detail
26
+ Check = Struct.new(:name, :passed, :message, keyword_init: true) do
27
+ # Predicate form so callers can write +check.passed?+ instead of
28
+ # +check.passed+. Struct does not provide the +?+ suffix
29
+ # automatically.
30
+ def passed?
31
+ passed
32
+ end
33
+ end
34
+
35
+ DEFAULT_MAX_GLYPHS = 65_535
36
+
37
+ # @param input [String] path to a TTC/OTC/dfont
38
+ # @param expected_faces [Integer, nil] required face count, or nil to skip
39
+ # @param max_glyphs [Integer] per-face glyph cap (default 65,535)
40
+ # @param expected_cmap_union [Integer, nil] minimum cmap-union size, or nil to skip
41
+ def initialize(input:, expected_faces: nil, max_glyphs: DEFAULT_MAX_GLYPHS,
42
+ expected_cmap_union: nil)
43
+ @input = input
44
+ @expected_faces = expected_faces
45
+ @max_glyphs = max_glyphs
46
+ @expected_cmap_union = expected_cmap_union
47
+ end
48
+
49
+ # @return [Integer] 0 if all checks passed, 1 otherwise
50
+ def run
51
+ reader = Collection::Reader.open(@input)
52
+ @checks = [
53
+ check_face_count(reader),
54
+ check_glyph_cap(reader),
55
+ check_cmap_union(reader),
56
+ ].compact
57
+
58
+ render_report(reader)
59
+ @checks.all?(&:passed?) ? 0 : 1
60
+ end
61
+
62
+ # @return [Array<Check>] the most recent run's checks
63
+ attr_reader :checks
64
+
65
+ private
66
+
67
+ def check_face_count(reader)
68
+ return nil unless @expected_faces
69
+
70
+ actual = reader.face_count
71
+ passed = actual == @expected_faces
72
+ Check.new(
73
+ name: :face_count,
74
+ passed: passed,
75
+ message: passed ? nil : "expected #{@expected_faces} faces, got #{actual}",
76
+ )
77
+ end
78
+
79
+ def check_glyph_cap(reader)
80
+ over = reader.stats.select { |s| s.glyph_count > @max_glyphs }
81
+ Check.new(
82
+ name: :glyph_cap,
83
+ passed: over.empty?,
84
+ message: over.empty? ? nil : "faces over cap: #{over.map { |s| "##{s.index}=#{s.glyph_count}" }.join(', ')}",
85
+ )
86
+ end
87
+
88
+ def check_cmap_union(reader)
89
+ return nil unless @expected_cmap_union
90
+
91
+ actual = reader.cmap_union.size
92
+ passed = actual >= @expected_cmap_union
93
+ Check.new(
94
+ name: :cmap_union,
95
+ passed: passed,
96
+ message: passed ? nil : "cmap union #{actual} < expected #{@expected_cmap_union}",
97
+ )
98
+ end
99
+
100
+ # Default rendering. Callers wanting structured output can
101
+ # instantiate the command, call +#run+, then read +#checks+
102
+ # directly instead of relying on stdout.
103
+ def render_report(reader)
104
+ reader.stats.each do |s|
105
+ marker = s.glyph_count <= @max_glyphs ? "✓" : "✗"
106
+ puts format("face %<index>d: %<glyphs>7d glyphs %<marker>s",
107
+ index: s.index, glyphs: s.glyph_count, marker: marker)
108
+ end
109
+ puts "all #{reader.face_count} faces within #{@max_glyphs}-glyph cap ✓"
110
+
111
+ @checks.each do |check|
112
+ if check.passed?
113
+ puts "#{check.name}: ✓"
114
+ else
115
+ puts "#{check.name}: ✗ (#{check.message})"
116
+ end
117
+ end
118
+ end
119
+ end
120
+ end
121
+ end
@@ -13,6 +13,7 @@ module Fontisan
13
13
  autoload :InfoCommand, "fontisan/commands/info_command"
14
14
  autoload :InstanceCommand, "fontisan/commands/instance_command"
15
15
  autoload :LsCommand, "fontisan/commands/ls_command"
16
+ autoload :MultiFormatOutput, "fontisan/commands/multi_format_output"
16
17
  autoload :OpticalSizeCommand, "fontisan/commands/optical_size_command"
17
18
  autoload :PackCommand, "fontisan/commands/pack_command"
18
19
  autoload :ScriptsCommand, "fontisan/commands/scripts_command"
@@ -20,6 +21,7 @@ module Fontisan
20
21
  autoload :TablesCommand, "fontisan/commands/tables_command"
21
22
  autoload :UnicodeCommand, "fontisan/commands/unicode_command"
22
23
  autoload :UnpackCommand, "fontisan/commands/unpack_command"
24
+ autoload :ValidateCollectionCommand, "fontisan/commands/validate_collection_command"
23
25
  autoload :ValidateCommand, "fontisan/commands/validate_command"
24
26
  autoload :VariableCommand, "fontisan/commands/variable_command"
25
27
  end
@@ -206,6 +206,62 @@ module Fontisan
206
206
  # CBDT/CBLC across multiple sources requires a dedicated rebuild.
207
207
  class MultipleCbdtSourcesError < Error; end
208
208
 
209
+ # Stitcher produced more glyphs than the output format supports.
210
+ #
211
+ # Raised by Stitcher::GlyphLimit.check! before writing, so the user
212
+ # gets an actionable message instead of a silently truncated font.
213
+ class GlyphLimitExceededError < Error
214
+ attr_reader :actual, :limit, :format
215
+
216
+ def initialize(actual:, limit:, format:)
217
+ @actual = actual
218
+ @limit = limit
219
+ @format = format
220
+ super(build_message)
221
+ end
222
+
223
+ private
224
+
225
+ def build_message
226
+ "Stitcher produced #{actual} unique glyphs, exceeding the " \
227
+ "#{format.to_s.upcase} limit of #{format_limit}. Both TTF and OTF " \
228
+ "(CFF1) cap at 65,535 glyphs — the maxp.num_glyphs field is uint16 " \
229
+ "and the CFF CharStrings INDEX count is card16. Options: " \
230
+ "(1) split the output into a TTC (TrueType Collection) by Unicode plane, " \
231
+ "(2) reduce the number of donors, " \
232
+ "(3) wait for CFF2 support in fontisan (card24 INDEX counts, no glyph cap)."
233
+ end
234
+
235
+ def format_limit
236
+ limit == Float::INFINITY ? "infinity" : limit.to_s
237
+ end
238
+ end
239
+
240
+ # Partition strategy could not satisfy the requested cap.
241
+ #
242
+ # Raised by {Stitcher::PartitionStrategy} when a single Unicode block
243
+ # contains more codepoints than the configured cap, so the partitioner
244
+ # cannot sub-split it further. This is a partitioning-time detection
245
+ # distinct from {GlyphLimitExceededError}, which fires at compile time
246
+ # after the Stitcher has produced more glyphs than the output format
247
+ # supports. Both surface the same underlying constraint (65,535-glyph
248
+ # cap) but at different stages.
249
+ class PartitionCapExceededError < Error
250
+ attr_reader :block_label, :actual, :cap
251
+
252
+ # @param block_label [String] e.g. "CJK_Ext_B", or "plane_N" if the
253
+ # overflow is not attributable to a known block
254
+ # @param actual [Integer] number of codepoints in the block
255
+ # @param cap [Integer] the cap that was exceeded
256
+ def initialize(block_label:, actual:, cap:)
257
+ @block_label = block_label
258
+ @actual = actual
259
+ @cap = cap
260
+ super("single Unicode block #{block_label} (#{actual} cps) exceeds " \
261
+ "cap #{cap}; cannot sub-split further")
262
+ end
263
+ end
264
+
209
265
  # Variation data corrupted (for use in data_extractor)
210
266
  #
211
267
  # Raised when extracted variation data appears corrupted.
@@ -15,7 +15,6 @@ module Fontisan
15
15
  autoload :CollectionListInfo, "fontisan/models/collection_list_info"
16
16
  autoload :CollectionValidationReport,
17
17
  "fontisan/models/collection_validation_report"
18
- autoload :Cldr, "fontisan/models/cldr"
19
18
  autoload :ColorGlyph, "fontisan/models/color_glyph"
20
19
  autoload :ColorLayer, "fontisan/models/color_layer"
21
20
  autoload :ColorPalette, "fontisan/models/color_palette"
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Fontisan
4
+ class Stitcher
5
+ # Per-subfont stats computed from the loaded, on-disk subfont (not the
6
+ # in-memory UFO target — the compiler may add glyphs, e.g. .notdef).
7
+ SubfontStats = Struct.new(:name, :glyph_count, :codepoint_count,
8
+ keyword_init: true)
9
+
10
+ # Return value of {Stitcher#write_collection}. Carries the output path,
11
+ # total bytes, and one {SubfontStats} per declared subfont.
12
+ CollectionResult = Struct.new(:path, :bytes, :subfonts, keyword_init: true) do
13
+ def face_count
14
+ subfonts.size
15
+ end
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Fontisan
4
+ class Stitcher
5
+ # Registry mapping glyph signatures to canonical glyph names in
6
+ # the target font. Enables signature-based deduplication: when
7
+ # two bindings produce glyphs with identical outlines, they share
8
+ # one gid and the duplicate's codepoint is redirected to the
9
+ # canonical glyph.
10
+ #
11
+ # Replaces the Stitcher's previous name-based dedup with
12
+ # outline-based dedup. This merges visually identical glyphs
13
+ # from different donors even when their names differ, reducing
14
+ # the glyph count below the TrueType 65,535 cap.
15
+ class Deduplicator
16
+ attr_reader :signatures
17
+
18
+ def initialize
19
+ @signatures = {}
20
+ end
21
+
22
+ # Record that `glyph` maps to `canonical_name` in the target.
23
+ # @param glyph [Fontisan::Ufo::Glyph]
24
+ # @param canonical_name [String] the name under which the glyph
25
+ # was added to the target font
26
+ def register(glyph, canonical_name)
27
+ @signatures[GlyphSignature.for(glyph)] = canonical_name
28
+ end
29
+
30
+ # @param glyph [Fontisan::Ufo::Glyph]
31
+ # @return [String, nil] the canonical name if an identical
32
+ # glyph was already registered, nil otherwise
33
+ def find(glyph)
34
+ @signatures[GlyphSignature.for(glyph)]
35
+ end
36
+
37
+ # @return [Integer] number of unique signatures registered
38
+ def size
39
+ @signatures.size
40
+ end
41
+
42
+ def empty?
43
+ @signatures.empty?
44
+ end
45
+ end
46
+ end
47
+ end
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Fontisan
4
+ class Stitcher
5
+ # Format-specific glyph-count caps.
6
+ #
7
+ # Both TTF and OTF (CFF1) cap at 65,535 because:
8
+ # - TTF: maxp.num_glyphs is uint16
9
+ # - OTF (CFF1): maxp.num_glyphs is uint16 AND the CFF CharStrings
10
+ # INDEX count is card16
11
+ #
12
+ # Exceeding the cap produces a silently truncated font (the BinData
13
+ # uint16 writer truncates without raising). The Stitcher checks
14
+ # the cap BEFORE writing so the user gets a clear error.
15
+ #
16
+ # To exceed 65,535 glyphs, the font must be split into a TTC
17
+ # (TrueType Collection) or fontisan must implement CFF2 (card24
18
+ # INDEX counts). Both are future work.
19
+ module GlyphLimit
20
+ TTF_GLYPH_CAP = 65_535
21
+ OTF_GLYPH_CAP = 65_535
22
+
23
+ # @param format [Symbol] :ttf or :otf
24
+ # @return [Integer, Float::INFINITY] the max glyph count
25
+ def self.for_format(format)
26
+ case format.to_sym
27
+ when :ttf then TTF_GLYPH_CAP
28
+ when :otf then OTF_GLYPH_CAP
29
+ when :otf2 then OTF_GLYPH_CAP # CFF2 CharStrings count must match maxp.numGlyphs
30
+ else
31
+ raise ArgumentError, "unknown format: #{format.inspect}"
32
+ end
33
+ end
34
+
35
+ # Raise GlyphLimitExceededError if `glyph_count` exceeds the cap
36
+ # for the given format.
37
+ #
38
+ # @param glyph_count [Integer]
39
+ # @param format [Symbol]
40
+ # @raise [GlyphLimitExceededError]
41
+ def self.check!(glyph_count, format:)
42
+ limit = for_format(format)
43
+ return if glyph_count <= limit
44
+
45
+ raise GlyphLimitExceededError.new(
46
+ actual: glyph_count,
47
+ limit: limit,
48
+ format: format,
49
+ )
50
+ end
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+
5
+ module Fontisan
6
+ class Stitcher
7
+ # Stateless signature computation for a Ufo::Glyph's visual identity.
8
+ #
9
+ # Two glyphs with the same signature are visually interchangeable
10
+ # and can share a gid in the output font. The signature captures
11
+ # advance width, every contour's points (x, y, type), and every
12
+ # component reference (base glyph + transform presence).
13
+ #
14
+ # Used by Deduplicator to merge identical outlines from different
15
+ # donors, reducing the glyph count below the TrueType 65,535 cap.
16
+ module GlyphSignature
17
+ # @param glyph [Fontisan::Ufo::Glyph]
18
+ # @return [String] SHA-256 hex digest of the glyph's outline identity
19
+ def self.for(glyph)
20
+ Digest::SHA256.hexdigest(canonical_representation(glyph))
21
+ end
22
+
23
+ # Build a deterministic string capturing the glyph's visual identity.
24
+ # The representation is ordered and normalized so that semantically
25
+ # identical glyphs produce byte-identical strings.
26
+ #
27
+ # @param glyph [Fontisan::Ufo::Glyph]
28
+ # @return [String]
29
+ def self.canonical_representation(glyph)
30
+ io = +""
31
+ io << "w:#{glyph.width.to_i};"
32
+
33
+ glyph.contours.each_with_index do |contour, ci|
34
+ io << "c#{ci}:"
35
+ contour.points.each do |pt|
36
+ io << "#{pt.x.to_i},#{pt.y.to_i},#{pt.type};"
37
+ end
38
+ end
39
+
40
+ glyph.components.each_with_index do |comp, i|
41
+ io << "comp#{i}:#{comp.base_glyph}"
42
+ io << ":t" if comp.transformation
43
+ end
44
+
45
+ io
46
+ end
47
+
48
+ private_class_method :canonical_representation
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Fontisan
4
+ class Stitcher
5
+ module PartitionStrategy
6
+ # Abstract base. Concrete partitioners (ByPlane, ByBlock, …)
7
+ # implement {#call} and return a {Blueprint}.
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
12
+
13
+ # @param cp_map [Hash{Integer=>Object}] codepoint → donor label
14
+ # @param cap [Integer] max codepoints per partition
15
+ # @return [Blueprint]
16
+ def call(cp_map, cap: DEFAULT_CAP)
17
+ raise NotImplementedError,
18
+ "#{self.class} must implement #call"
19
+ end
20
+ end
21
+ end
22
+ end
23
+ end