fontisan 0.4.7 → 0.4.9

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 (52) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +13 -0
  3. data/README.adoc +116 -2
  4. data/docs/.vitepress/config.ts +1 -7
  5. data/docs/COLLECTION_VALIDATION.adoc +61 -0
  6. data/docs/STITCHER_GUIDE.adoc +138 -1
  7. data/docs/cli/convert.md +44 -2
  8. data/docs/cli/index.md +21 -28
  9. data/docs/cli/validate-collection.md +95 -0
  10. data/docs/cli/validate.md +8 -0
  11. data/docs/index.md +0 -2
  12. data/lib/fontisan/cli.rb +29 -8
  13. data/lib/fontisan/collection/reader/stats.rb +23 -0
  14. data/lib/fontisan/collection/reader.rb +90 -0
  15. data/lib/fontisan/collection.rb +1 -0
  16. data/lib/fontisan/commands/convert_command.rb +96 -18
  17. data/lib/fontisan/commands/multi_format_output.rb +59 -0
  18. data/lib/fontisan/commands/validate_collection_command.rb +121 -0
  19. data/lib/fontisan/commands.rb +2 -0
  20. data/lib/fontisan/error.rb +25 -0
  21. data/lib/fontisan/models.rb +0 -1
  22. data/lib/fontisan/stitcher/collection_result.rb +18 -0
  23. data/lib/fontisan/stitcher/partition_strategy/base.rb +23 -0
  24. data/lib/fontisan/stitcher/partition_strategy/blueprint.rb +24 -0
  25. data/lib/fontisan/stitcher/partition_strategy/by_plane.rb +131 -0
  26. data/lib/fontisan/stitcher/partition_strategy/partition.rb +24 -0
  27. data/lib/fontisan/stitcher/partition_strategy.rb +22 -0
  28. data/lib/fontisan/stitcher.rb +44 -10
  29. data/lib/fontisan/ufo/compile/name.rb +2 -2
  30. data/lib/fontisan/ufo/info.rb +48 -0
  31. data/lib/fontisan/unicode/plane.rb +56 -0
  32. data/lib/fontisan/unicode.rb +17 -0
  33. data/lib/fontisan/version.rb +1 -1
  34. data/lib/fontisan.rb +2 -2
  35. metadata +14 -18
  36. data/docs/cli/audit.md +0 -337
  37. data/lib/fontisan/cldr/aggregator.rb +0 -33
  38. data/lib/fontisan/cldr/cache_manager.rb +0 -110
  39. data/lib/fontisan/cldr/config.rb +0 -59
  40. data/lib/fontisan/cldr/download_error.rb +0 -9
  41. data/lib/fontisan/cldr/downloader.rb +0 -79
  42. data/lib/fontisan/cldr/error.rb +0 -8
  43. data/lib/fontisan/cldr/index.rb +0 -64
  44. data/lib/fontisan/cldr/index_builder.rb +0 -72
  45. data/lib/fontisan/cldr/unicode_set_parser.rb +0 -189
  46. data/lib/fontisan/cldr/unknown_version_error.rb +0 -9
  47. data/lib/fontisan/cldr/version_resolver.rb +0 -91
  48. data/lib/fontisan/cldr.rb +0 -23
  49. data/lib/fontisan/cli/cldr_cli.rb +0 -85
  50. data/lib/fontisan/config/cldr.yml +0 -22
  51. data/lib/fontisan/models/cldr/language_coverage.rb +0 -31
  52. data/lib/fontisan/models/cldr.rb +0 -12
data/docs/cli/validate.md CHANGED
@@ -46,3 +46,11 @@ fontisan validate font.ttf --profile opentype --format json
46
46
  ## Detailed Documentation
47
47
 
48
48
  For comprehensive documentation including profile details and validation helpers, see the [validate command guide](/guide/cli/validate).
49
+
50
+ ## See also
51
+
52
+ For collection-level structural checks (face count, per-face glyph cap,
53
+ cmap-union size) on a TTC/OTC/dfont, see
54
+ [validate-collection](/cli/validate-collection). The two commands are
55
+ complementary: `validate` runs profile-based per-face checks;
56
+ `validate-collection` runs collection-level structural checks.
data/docs/index.md CHANGED
@@ -18,8 +18,6 @@ hero:
18
18
  link: /guide/migrations/fonttools
19
19
 
20
20
  features:
21
- - title: "🔍 Font Audit"
22
- details: Comprehensive per-face audit (replaces otfinfo) — identity, style, metrics, coverage, licensing, hinting, color, variation, OpenType layout, UCD/CLDR aggregation, compare, and library-summary modes.
23
21
  - title: "🔄 Font Conversion"
24
22
  details: Convert between TTF, OTF, WOFF, WOFF2, Type 1 (PFB/PFA), and SVG formats with curve conversion and optimization.
25
23
  - title: "✅ Font Validation"
data/lib/fontisan/cli.rb CHANGED
@@ -25,9 +25,6 @@ module Fontisan
25
25
  desc: "Suppress non-error output",
26
26
  aliases: "-q"
27
27
 
28
- desc "cldr", "Manage local CLDR cache (subcommands)", hide: true
29
- subcommand "cldr", CldrCli
30
-
31
28
  desc "ufo", "UFO source operations (build, convert, validate)"
32
29
  subcommand "ufo", Fontisan::Ufo::Cli
33
30
 
@@ -211,10 +208,12 @@ module Fontisan
211
208
  end
212
209
 
213
210
  desc "convert FONT_FILE", "Convert font to different format"
214
- option :to, type: :string, required: true,
215
- desc: "Target format: ttf, otf, type1, t1, ttc, otc, dfont, svg, " \
211
+ option :to, type: :array, required: true,
212
+ desc: "Target format(s): ttf, otf, type1, t1, ttc, otc, dfont, svg, " \
216
213
  "woff (zlib — works on all browsers incl. legacy), " \
217
- "woff2 (Brotli — ~30% smaller, modern browsers only)",
214
+ "woff2 (Brotli — ~30% smaller, modern browsers only). " \
215
+ "Pass multiple times (--to woff --to woff2) or comma-separated " \
216
+ "(--to woff,woff2) for multi-format output.",
218
217
  aliases: "-t"
219
218
  option :output, type: :string,
220
219
  desc: "Output file path (required unless --show-options)",
@@ -361,7 +360,7 @@ module Fontisan
361
360
 
362
361
  # Handle --show-options
363
362
  if options[:show_options]
364
- show_recommended_options(source_format, options[:to])
363
+ show_recommended_options(source_format, options[:to].is_a?(Array) ? options[:to].first : options[:to])
365
364
  return
366
365
  end
367
366
 
@@ -371,7 +370,8 @@ module Fontisan
371
370
  end
372
371
 
373
372
  # Build ConversionOptions
374
- conv_options = build_conversion_options(source_format, options[:to],
373
+ conv_options = build_conversion_options(source_format,
374
+ Array(options[:to]).first,
375
375
  options)
376
376
 
377
377
  # Build instance coordinates from axis options
@@ -573,6 +573,27 @@ module Fontisan
573
573
  exit command.run
574
574
  end
575
575
 
576
+ desc "validate-collection PATH",
577
+ "Validate a TTC/OTC/dfont (face count, glyph cap, cmap union)"
578
+ option :expected_faces, type: :numeric,
579
+ desc: "Required face count"
580
+ option :max_glyphs, type: :numeric, default: Fontisan::Commands::ValidateCollectionCommand::DEFAULT_MAX_GLYPHS,
581
+ desc: "Per-face glyph cap"
582
+ option :expected_cmap_union, type: :numeric,
583
+ desc: "Minimum cmap union size across all faces"
584
+ def validate_collection(path)
585
+ cmd = Commands::ValidateCollectionCommand.new(
586
+ input: path,
587
+ expected_faces: options[:expected_faces],
588
+ max_glyphs: options[:max_glyphs],
589
+ expected_cmap_union: options[:expected_cmap_union],
590
+ )
591
+ exit cmd.run
592
+ rescue ArgumentError => e
593
+ warn "ERROR: #{e.message}"
594
+ exit 1
595
+ end
596
+
576
597
  desc "version", "Display version information"
577
598
  # Display the Fontisan version.
578
599
  def version
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Fontisan
4
+ module Collection
5
+ class Reader
6
+ # Snapshot of one face's headline metrics. Pure value object —
7
+ # no methods beyond accessors. Separate file from {Reader} so
8
+ # the Stats struct can be referenced without pulling in the
9
+ # full Reader (and its FontLoader dependency).
10
+ #
11
+ # @!attribute [r] index
12
+ # @return [Integer] 0-based face index inside the collection
13
+ # @!attribute [r] glyph_count
14
+ # @return [Integer] face's maxp.numGlyphs
15
+ # @!attribute [r] codepoint_count
16
+ # @return [Integer] count of keys in face's cmap unicode_mappings
17
+ # @!attribute [r] sfnt_version
18
+ # @return [Integer] face sfnt version (0x00010000 for TTF, 0x4F54544F for OTF)
19
+ Stats = Struct.new(:index, :glyph_count, :codepoint_count, :sfnt_version,
20
+ keyword_init: true)
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,90 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Fontisan
4
+ module Collection
5
+ # Read-only counterpart to {Builder}. Opens an existing TTC/OTC/dfont
6
+ # and exposes per-face metadata (glyph count, codepoint count, sfnt
7
+ # version) and the cmap union across all faces.
8
+ #
9
+ # Delegates header parsing to {FontLoader} — never hand-rolls the
10
+ # TTC header bytes (that would duplicate BaseCollection.from_file
11
+ # and only handle TTC/OTC, not dfont).
12
+ #
13
+ # @example Per-face glyph counts
14
+ # reader = Collection::Reader.open("family.ttc")
15
+ # reader.stats.map { |s| [s.index, s.glyph_count] }
16
+ #
17
+ # @example Cmap union
18
+ # Collection::Reader.open("family.otc").cmap_union.size
19
+ class Reader
20
+ autoload :Stats, "fontisan/collection/reader/stats"
21
+
22
+ # @!attribute [r] path
23
+ # @return [String] the file path this reader was opened with
24
+ attr_reader :path
25
+
26
+ # Collection formats this reader accepts. Single source of truth —
27
+ # matches {FontLoader::COLLECTION_CLASSES}.
28
+ FORMATS = %i[ttc otc dfont].freeze
29
+
30
+ # @param path [String] path to a TTC/OTC/dfont file
31
+ # @raise [ArgumentError] if the file is not a recognized collection
32
+ def initialize(path)
33
+ @path = path
34
+ detected = FontLoader.detect_format(path)
35
+ unless FORMATS.include?(detected)
36
+ raise ArgumentError,
37
+ "#{path} is not a TTC/OTC/dfont (detected: #{detected.inspect})"
38
+ end
39
+
40
+ @collection = FontLoader.load_collection(path)
41
+ end
42
+
43
+ # Convenience constructor.
44
+ # @param path [String]
45
+ # @return [Reader]
46
+ def self.open(path)
47
+ new(path)
48
+ end
49
+
50
+ # @return [Integer] number of faces in the collection
51
+ def face_count
52
+ @collection.num_fonts
53
+ end
54
+
55
+ # Yields each face as a loaded TTF/OTF font. Without a block,
56
+ # returns an Enumerator (so callers can chain +each_with_index+).
57
+ # The Enumerator's size is set to +face_count+ so size-based
58
+ # assertions work without consuming it.
59
+ #
60
+ # @yieldparam face [TrueTypeFont, OpenTypeFont]
61
+ # @return [Enumerator, void]
62
+ def each_face
63
+ return enum_for(:each_face) { face_count } unless block_given?
64
+
65
+ face_count.times { |i| yield FontLoader.load(@path, font_index: i) }
66
+ end
67
+
68
+ # @return [Array<Stats>] one Stats per face, in face-index order
69
+ def stats
70
+ each_face.map.with_index do |face, i|
71
+ Stats.new(
72
+ index: i,
73
+ glyph_count: face.table("maxp")&.num_glyphs || 0,
74
+ codepoint_count: (face.table("cmap")&.unicode_mappings || {}).size,
75
+ sfnt_version: face.header.sfnt_version,
76
+ )
77
+ end
78
+ end
79
+
80
+ # Union of every face's cmap keys.
81
+ #
82
+ # @return [Set<Integer>]
83
+ def cmap_union
84
+ each_face.with_object(Set.new) do |face, union|
85
+ union.merge((face.table("cmap")&.unicode_mappings || {}).keys)
86
+ end
87
+ end
88
+ end
89
+ end
90
+ end
@@ -7,6 +7,7 @@ module Fontisan
7
7
  autoload :Builder, "fontisan/collection/builder"
8
8
  autoload :DfontBuilder, "fontisan/collection/dfont_builder"
9
9
  autoload :OffsetCalculator, "fontisan/collection/offset_calculator"
10
+ autoload :Reader, "fontisan/collection/reader"
10
11
  autoload :SharedLogic, "fontisan/collection/shared_logic"
11
12
  autoload :TableAnalyzer, "fontisan/collection/table_analyzer"
12
13
  autoload :TableDeduplicator, "fontisan/collection/table_deduplicator"
@@ -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
@@ -237,6 +237,31 @@ module Fontisan
237
237
  end
238
238
  end
239
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
+
240
265
  # Variation data corrupted (for use in data_extractor)
241
266
  #
242
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"