fontisan 0.4.7 → 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 (46) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +13 -0
  3. data/docs/.vitepress/config.ts +0 -7
  4. data/docs/cli/index.md +5 -28
  5. data/docs/index.md +0 -2
  6. data/lib/fontisan/cli.rb +29 -8
  7. data/lib/fontisan/collection/reader/stats.rb +23 -0
  8. data/lib/fontisan/collection/reader.rb +90 -0
  9. data/lib/fontisan/collection.rb +1 -0
  10. data/lib/fontisan/commands/convert_command.rb +96 -18
  11. data/lib/fontisan/commands/multi_format_output.rb +59 -0
  12. data/lib/fontisan/commands/validate_collection_command.rb +121 -0
  13. data/lib/fontisan/commands.rb +2 -0
  14. data/lib/fontisan/error.rb +25 -0
  15. data/lib/fontisan/models.rb +0 -1
  16. data/lib/fontisan/stitcher/collection_result.rb +18 -0
  17. data/lib/fontisan/stitcher/partition_strategy/base.rb +23 -0
  18. data/lib/fontisan/stitcher/partition_strategy/blueprint.rb +24 -0
  19. data/lib/fontisan/stitcher/partition_strategy/by_plane.rb +131 -0
  20. data/lib/fontisan/stitcher/partition_strategy/partition.rb +24 -0
  21. data/lib/fontisan/stitcher/partition_strategy.rb +22 -0
  22. data/lib/fontisan/stitcher.rb +44 -10
  23. data/lib/fontisan/ufo/compile/name.rb +2 -2
  24. data/lib/fontisan/ufo/info.rb +48 -0
  25. data/lib/fontisan/unicode/plane.rb +56 -0
  26. data/lib/fontisan/unicode.rb +17 -0
  27. data/lib/fontisan/version.rb +1 -1
  28. data/lib/fontisan.rb +2 -2
  29. metadata +13 -18
  30. data/docs/cli/audit.md +0 -337
  31. data/lib/fontisan/cldr/aggregator.rb +0 -33
  32. data/lib/fontisan/cldr/cache_manager.rb +0 -110
  33. data/lib/fontisan/cldr/config.rb +0 -59
  34. data/lib/fontisan/cldr/download_error.rb +0 -9
  35. data/lib/fontisan/cldr/downloader.rb +0 -79
  36. data/lib/fontisan/cldr/error.rb +0 -8
  37. data/lib/fontisan/cldr/index.rb +0 -64
  38. data/lib/fontisan/cldr/index_builder.rb +0 -72
  39. data/lib/fontisan/cldr/unicode_set_parser.rb +0 -189
  40. data/lib/fontisan/cldr/unknown_version_error.rb +0 -9
  41. data/lib/fontisan/cldr/version_resolver.rb +0 -91
  42. data/lib/fontisan/cldr.rb +0 -23
  43. data/lib/fontisan/cli/cldr_cli.rb +0 -85
  44. data/lib/fontisan/config/cldr.yml +0 -22
  45. data/lib/fontisan/models/cldr/language_coverage.rb +0 -31
  46. data/lib/fontisan/models/cldr.rb +0 -12
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 9957716f86dad58d8ff9b5f0bc49bc7044ab20f073bd26b5b87f09d8a5aa1cb1
4
- data.tar.gz: 5ac5083c8db47532bc1331c82209c02bc6d8b7095b8f2c27c41fecced8230b32
3
+ metadata.gz: 5a519f7a53629decc2b968e9c99b5979459fe33c49750aa851818a330e6fd6af
4
+ data.tar.gz: 805f840f58f042288c34610fa355b1be74b428a8669abc15188c08e635494148
5
5
  SHA512:
6
- metadata.gz: e1fa7608f49e922735d6b677456cbf4864ced9a32e3e771960229f685208c67be6079f9b1bfcfc6f393c83bf8d5b7e0e158c397b618080229a14dd274718724e
7
- data.tar.gz: 730148059f3819b6dd49c1f8b86ab9d2e4d8508671b54b59cc240e638472b0da0dd5e5b7187f30b09bbd12d8e149a93903097068a45ac281224f2482464fa2ea
6
+ metadata.gz: 06edc2ca7cf97a919095afee9b5bc7327d0f01f4ce304c537f1ac4a920dd7950cce67beaba34d0bf93aa99d6cb21c385ad5238c8077d7aebd87545a8c35437eb
7
+ data.tar.gz: 8a43a243ad4aeae715f63bf94c5e907d07b2330f59b4b37f3bfafa1ee8af063796c8ab84547f92b674f46340bfb5c96686e7b25b88f490f18ff77848c7c30d6a
data/CHANGELOG.md CHANGED
@@ -7,6 +7,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ### Removed
11
+
12
+ - Audit/UCD documentation residue from the v0.3.0 audit/UCD subsystem
13
+ removal: deleted `docs/cli/audit.md`, dropped the "🔍 Font Audit"
14
+ feature card and "Font Audit" sidebar group, removed audit entries
15
+ from `docs/cli/index.md`. No behavior change.
16
+ - CLDR subsystem (`Fontisan::Cldr`, `Models::Cldr::LanguageCoverage`,
17
+ `lib/fontisan/config/cldr.yml`) and the `fontisan cldr` CLI
18
+ subcommand. CLDR was added in TODO 21 as infrastructure for
19
+ `fontisan audit`'s per-language coverage extractor; with the audit
20
+ subsystem moved to ucode (v0.3.0), CLDR had no remaining consumer
21
+ in fontisan. Not released in a published gem version.
22
+
10
23
  ### Added
11
24
 
12
25
  - `Stitcher` explicit subfont declaration model: every `include_*`
@@ -263,13 +263,6 @@ export default defineConfig({
263
263
  { text: "Overview", link: "/cli/" },
264
264
  ],
265
265
  },
266
- {
267
- text: "Font Audit",
268
- collapsed: true,
269
- items: [
270
- { text: "audit", link: "/cli/audit" },
271
- ],
272
- },
273
266
  {
274
267
  text: "Font Information",
275
268
  collapsed: true,
data/docs/cli/index.md CHANGED
@@ -20,18 +20,6 @@ Always check your font's End User License Agreement (EULA) before processing. Ma
20
20
 
21
21
  ## Quick Reference
22
22
 
23
- ### Font Audit
24
-
25
- | Command | Description | Example |
26
- |---------|-------------|---------|
27
- | `audit` | Comprehensive per-face audit (replaces `otfinfo`) | `fontisan audit font.ttf` |
28
-
29
- The `audit` command covers identity, style, metrics, codepoint coverage,
30
- licensing, hinting, color capabilities, variable-font detail, and
31
- OpenType layout — and supports collections, `--compare` mode, and
32
- whole-library `--recursive --summary` mode. See
33
- [audit](/cli/audit) for details.
34
-
35
23
  ### Font Information Commands
36
24
 
37
25
  | Command | Description | Example |
@@ -179,19 +167,11 @@ fontisan validate font.ttf --profile production
179
167
 
180
168
  ### Audit a Font (or Library)
181
169
 
182
- ```bash
183
- # Full per-face audit
184
- fontisan audit font.ttf
185
-
186
- # Diff two fonts (or saved reports)
187
- fontisan audit --compare baseline.yaml new.ttf
188
-
189
- # Whole-library summary
190
- fontisan audit lib/ --recursive --summary
191
-
192
- # Fast inventory pass
193
- fontisan audit lib/ --recursive --summary --brief
194
- ```
170
+ The `audit` and `ucd` commands have been removed from fontisan. The
171
+ audit/UCD subsystem moved to the
172
+ [ucode](https://github.com/fontist/ucode) gem. For per-face font
173
+ metadata, use [`info`](/cli/info); for pass/fail quality checks, use
174
+ [`validate`](/cli/validate).
195
175
 
196
176
  ### Export Font Data
197
177
 
@@ -210,9 +190,6 @@ fontisan export font.ttf --format ttx --tables head,name,cmap
210
190
 
211
191
  Detailed documentation for each command:
212
192
 
213
- ### Font Audit
214
- - [audit](/cli/audit) — Comprehensive per-face audit (replaces `otfinfo`); supports collections, compare, library summary
215
-
216
193
  ### Font Information
217
194
  - [info](/cli/info) — Extract font metadata and properties (includes brief mode)
218
195
  - [ls](/cli/ls) — List fonts in collections
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