fontisan 0.2.0 → 0.2.1

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 (74) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop_todo.yml +270 -131
  3. data/README.adoc +158 -4
  4. data/Rakefile +44 -47
  5. data/lib/fontisan/cli.rb +84 -33
  6. data/lib/fontisan/collection/builder.rb +81 -0
  7. data/lib/fontisan/collection/table_deduplicator.rb +76 -0
  8. data/lib/fontisan/commands/base_command.rb +16 -0
  9. data/lib/fontisan/commands/convert_command.rb +97 -170
  10. data/lib/fontisan/commands/instance_command.rb +71 -80
  11. data/lib/fontisan/commands/validate_command.rb +25 -0
  12. data/lib/fontisan/config/validation_rules.yml +1 -1
  13. data/lib/fontisan/constants.rb +10 -0
  14. data/lib/fontisan/converters/format_converter.rb +150 -1
  15. data/lib/fontisan/converters/outline_converter.rb +80 -18
  16. data/lib/fontisan/converters/woff_writer.rb +1 -1
  17. data/lib/fontisan/font_loader.rb +3 -5
  18. data/lib/fontisan/font_writer.rb +7 -6
  19. data/lib/fontisan/hints/hint_converter.rb +133 -0
  20. data/lib/fontisan/hints/postscript_hint_applier.rb +221 -140
  21. data/lib/fontisan/hints/postscript_hint_extractor.rb +100 -0
  22. data/lib/fontisan/hints/truetype_hint_applier.rb +90 -44
  23. data/lib/fontisan/hints/truetype_hint_extractor.rb +127 -0
  24. data/lib/fontisan/loading_modes.rb +2 -0
  25. data/lib/fontisan/models/font_export.rb +2 -2
  26. data/lib/fontisan/models/hint.rb +173 -1
  27. data/lib/fontisan/models/validation_report.rb +1 -1
  28. data/lib/fontisan/open_type_font.rb +25 -9
  29. data/lib/fontisan/open_type_font_extensions.rb +54 -0
  30. data/lib/fontisan/pipeline/format_detector.rb +249 -0
  31. data/lib/fontisan/pipeline/output_writer.rb +154 -0
  32. data/lib/fontisan/pipeline/strategies/base_strategy.rb +75 -0
  33. data/lib/fontisan/pipeline/strategies/instance_strategy.rb +93 -0
  34. data/lib/fontisan/pipeline/strategies/named_strategy.rb +118 -0
  35. data/lib/fontisan/pipeline/strategies/preserve_strategy.rb +56 -0
  36. data/lib/fontisan/pipeline/transformation_pipeline.rb +411 -0
  37. data/lib/fontisan/pipeline/variation_resolver.rb +165 -0
  38. data/lib/fontisan/tables/cff/charstring.rb +33 -4
  39. data/lib/fontisan/tables/cff/charstring_builder.rb +34 -0
  40. data/lib/fontisan/tables/cff/charstring_parser.rb +237 -0
  41. data/lib/fontisan/tables/cff/charstring_rebuilder.rb +172 -0
  42. data/lib/fontisan/tables/cff/dict_builder.rb +15 -0
  43. data/lib/fontisan/tables/cff/hint_operation_injector.rb +207 -0
  44. data/lib/fontisan/tables/cff/offset_recalculator.rb +70 -0
  45. data/lib/fontisan/tables/cff/private_dict_writer.rb +125 -0
  46. data/lib/fontisan/tables/cff/table_builder.rb +221 -0
  47. data/lib/fontisan/tables/cff.rb +2 -0
  48. data/lib/fontisan/tables/cff2/private_dict_blend_handler.rb +246 -0
  49. data/lib/fontisan/tables/cff2/region_matcher.rb +200 -0
  50. data/lib/fontisan/tables/cff2/table_builder.rb +574 -0
  51. data/lib/fontisan/tables/cff2/table_reader.rb +419 -0
  52. data/lib/fontisan/tables/cff2/variation_data_extractor.rb +212 -0
  53. data/lib/fontisan/tables/cff2.rb +9 -4
  54. data/lib/fontisan/tables/cvar.rb +2 -41
  55. data/lib/fontisan/tables/gvar.rb +2 -41
  56. data/lib/fontisan/true_type_font.rb +24 -9
  57. data/lib/fontisan/true_type_font_extensions.rb +54 -0
  58. data/lib/fontisan/utilities/checksum_calculator.rb +42 -0
  59. data/lib/fontisan/validation/checksum_validator.rb +2 -2
  60. data/lib/fontisan/validation/table_validator.rb +1 -1
  61. data/lib/fontisan/validation/variable_font_validator.rb +218 -0
  62. data/lib/fontisan/variation/converter.rb +120 -13
  63. data/lib/fontisan/variation/instance_writer.rb +341 -0
  64. data/lib/fontisan/variation/tuple_variation_header.rb +51 -0
  65. data/lib/fontisan/variation/variable_svg_generator.rb +268 -0
  66. data/lib/fontisan/variation/variation_preserver.rb +288 -0
  67. data/lib/fontisan/version.rb +1 -1
  68. data/lib/fontisan/version.rb.orig +9 -0
  69. data/lib/fontisan/woff2/glyf_transformer.rb +666 -0
  70. data/lib/fontisan/woff2/hmtx_transformer.rb +164 -0
  71. data/lib/fontisan/woff2_font.rb +475 -470
  72. data/lib/fontisan/woff_font.rb +16 -11
  73. data/lib/fontisan.rb +12 -0
  74. metadata +31 -2
data/README.adoc CHANGED
@@ -71,7 +71,7 @@ gem install fontisan
71
71
  * Collection management (pack/unpack TTC/OTC files with table deduplication)
72
72
  * Support for TTF, OTF, TTC, OTC font formats (production ready)
73
73
  * WOFF format support (reading complete, writing functional, pending full integration)
74
- * WOFF2 format support (reading partial, writing planned)
74
+ * WOFF2 format support (reading complete with table transformations, writing planned)
75
75
  * SVG font generation (complete)
76
76
  * TTX/YAML/JSON export (complete)
77
77
  * Command-line interface with 18 commands
@@ -85,8 +85,14 @@ gem install fontisan
85
85
  * Compound glyph decomposition with transformation support (complete)
86
86
  * CFF subroutine optimization for space-efficient OTF generation (preview mode)
87
87
  * Various loading modes for high-performance font indexing (5x faster)
88
+ * Hint preservation during TTF ↔ OTF conversion (optional, partially complete)
89
+ * CFF2 variable font support for PostScript hint conversion (complete)
88
90
 
89
- NOTE: TTF ↔ OTF outline format conversion is in active development (Phase 1, ~80% complete). The universal outline model, CFF builders, curve converter, and compound glyph support are fully functional. Simple and compound glyphs convert successfully. See link:docs/IMPLEMENTATION_STATUS_V4.md[Implementation Status] for detailed progress tracking.
91
+ NOTE: TTF ↔ OTF outline format conversion is in active development (~80%
92
+ complete). The universal outline model, CFF builders, curve converter, and
93
+ compound glyph support are fully functional. Simple and compound glyphs convert
94
+ successfully. See link:docs/IMPLEMENTATION_STATUS_V4.md[Implementation Status]
95
+ for detailed progress tracking.
90
96
 
91
97
 
92
98
  == Loading Modes
@@ -160,6 +166,7 @@ font.table_available?(tag) # => boolean
160
166
  # Access restricted based on mode
161
167
  font.table(tag) # => Returns table or raises error
162
168
  ----
169
+ "
163
170
 
164
171
 
165
172
 
@@ -256,7 +263,7 @@ Fontisan supports bidirectional conversion between TrueType (TTF) and OpenType/C
256
263
 
257
264
  The outline converter enables transformation between glyph outline formats:
258
265
 
259
- * **TrueType (TTF)**: Uses quadratic Bézier curves stored in glyf/loc tables
266
+ * **TrueType (TTF)**: Uses quadratic Bézier curves stored in glyf/loca tables
260
267
  * **OpenType/CFF (OTF)**: Uses cubic Bézier curves stored in CFF table
261
268
 
262
269
  Conversion uses a format-agnostic universal outline model as an intermediate representation, ensuring high-quality results while preserving glyph metrics and bounding boxes.
@@ -297,6 +304,11 @@ fontisan convert font.ttf --to cff --output font.otf
297
304
 
298
305
  ==== Validation
299
306
 
307
+ Font integrity validation is now enabled by default for all conversions.
308
+ The validator ensures proper OpenType checksum calculation including correct
309
+ handling of the head table's checksumAdjustment field per the OpenType
310
+ specification.
311
+
300
312
  After conversion, validate the output font:
301
313
 
302
314
  [source,bash]
@@ -1325,6 +1337,148 @@ Instance 2 position: 75 300
1325
1337
  ----
1326
1338
  ====
1327
1339
 
1340
+ ==== Generate static instances from variable fonts
1341
+
1342
+ Generate static font instances from variable fonts at specific variation coordinates
1343
+ and output in any supported format (TTF, OTF, WOFF).
1344
+
1345
+ Syntax:
1346
+
1347
+ [source,shell]
1348
+ ----
1349
+ $ fontisan instance VARIABLE_FONT [OPTIONS]
1350
+ ----
1351
+
1352
+ Where,
1353
+
1354
+ `VARIABLE_FONT`:: Path to the variable font file
1355
+ `OPTIONS`:: Instance generation options
1356
+
1357
+ Options:
1358
+
1359
+ `--wght VALUE`:: Weight axis value
1360
+ `--wdth VALUE`:: Width axis value
1361
+ `--slnt VALUE`:: Slant axis value
1362
+ `--ital VALUE`:: Italic axis value
1363
+ `--opsz VALUE`:: Optical size axis value
1364
+ `--to FORMAT`:: Output format: `ttf` (default), `otf`, `woff`, or `woff2`
1365
+ `--output FILE`:: Output file path
1366
+ `--optimize`:: Enable CFF optimization for OTF output
1367
+ `--named-instance INDEX`:: Use named instance by index
1368
+ `--list-instances`:: List available named instances
1369
+ `--validate`:: Validate font before generation
1370
+ `--dry-run`:: Preview instance without generating
1371
+ `--progress`:: Show progress during generation
1372
+
1373
+
1374
+ .Generate bold instance at wght=700
1375
+ [example]
1376
+ ====
1377
+ [source,shell]
1378
+ ----
1379
+ $ fontisan instance variable.ttf --wght 700 --output bold.ttf
1380
+
1381
+ Generating instance... done
1382
+ Writing output... done
1383
+ Static font instance written to: bold.ttf
1384
+ ----
1385
+ ====
1386
+
1387
+ .Generate instance and convert to OTF
1388
+ [example]
1389
+ ====
1390
+ [source,shell]
1391
+ ----
1392
+ $ fontisan instance variable.ttf --wght 300 --to otf --output light.otf
1393
+
1394
+ Generating instance... done
1395
+ Writing output... done
1396
+ Static font instance written to: light.otf
1397
+ ----
1398
+ ====
1399
+
1400
+ .Generate instance and convert to WOFF
1401
+ [example]
1402
+ ====
1403
+ [source,shell]
1404
+ ----
1405
+ $ fontisan instance variable.ttf --wght 600 --to woff --output semibold.woff
1406
+
1407
+ Generating instance... done
1408
+ Writing output... done
1409
+ Static font instance written to: semibold.woff
1410
+ ----
1411
+ ====
1412
+
1413
+ .Generate instance with multiple axes
1414
+ [example]
1415
+ ====
1416
+ [source,shell]
1417
+ ----
1418
+ $ fontisan instance variable.ttf --wght 600 --wdth 75 --output condensed.ttf
1419
+
1420
+ Generating instance... done
1421
+ Writing output... done
1422
+ Static font instance written to: condensed.ttf
1423
+ ----
1424
+ ====
1425
+
1426
+ .List available named instances
1427
+ [example]
1428
+ ====
1429
+ [source,shell]
1430
+ ----
1431
+ $ fontisan instance variable.ttf --list-instances
1432
+
1433
+ Available named instances:
1434
+
1435
+ [0] Instance 4
1436
+ Coordinates:
1437
+ wdth: 75.0
1438
+ wght: 200.0
1439
+
1440
+ [1] Instance 5
1441
+ Coordinates:
1442
+ wdth: 75.0
1443
+ wght: 250.0
1444
+
1445
+ [2] Instance 6
1446
+ Coordinates:
1447
+ wdth: 75.0
1448
+ wght: 300.0
1449
+ ----
1450
+ ====
1451
+
1452
+ .Use named instance
1453
+ [example]
1454
+ ====
1455
+ [source,shell]
1456
+ ----
1457
+ $ fontisan instance variable.ttf --named-instance 0 --output thin.ttf
1458
+ ----
1459
+ ====
1460
+
1461
+ .Preview instance generation (dry-run)
1462
+ [example]
1463
+ ====
1464
+ [source,shell]
1465
+ ----
1466
+ $ fontisan instance variable.ttf --wght 700 --dry-run
1467
+
1468
+ Dry-run mode: Preview of instance generation
1469
+
1470
+ Coordinates:
1471
+ wght: 700.0
1472
+
1473
+ Output would be written to: variable-instance.ttf
1474
+ Output
1475
+
1476
+ format: same as input
1477
+
1478
+ Use without --dry-run to actually generate the instance.
1479
+ ----
1480
+ ====
1481
+
1328
1482
  ==== Optical size information
1329
1483
 
1330
1484
  Display optical size range from the OS/2 table for fonts designed for specific
@@ -1644,7 +1798,7 @@ Font 3: Noto Serif CJK TC
1644
1798
 
1645
1799
  ===== Show collection info
1646
1800
 
1647
- Show detailed information about a TrueType Collection (TTC), OpenType Collection
1801
+ Show detailed information about a TrueType Collection (TTC) or OpenType Collection
1648
1802
  (OTC), including the number of fonts and metadata for each font.
1649
1803
 
1650
1804
  [source,shell]
data/Rakefile CHANGED
@@ -9,7 +9,8 @@ require "rubocop/rake_task"
9
9
  RuboCop::RakeTask.new
10
10
 
11
11
  namespace :fixtures do
12
- fixtures_dir = "spec/fixtures/fonts"
12
+ # Load centralized fixture configuration
13
+ require_relative "spec/support/fixture_fonts"
13
14
 
14
15
  # Helper method to download a single file
15
16
  def download_single_file(name, url, target_path)
@@ -30,61 +31,58 @@ namespace :fixtures do
30
31
  require "open-uri"
31
32
  require "zip"
32
33
 
33
- zip_file = "#{target_dir}/#{name}.zip"
34
-
35
34
  puts "[fixtures:download] Downloading #{name}..."
36
35
  FileUtils.mkdir_p(target_dir)
37
36
 
38
- URI.open(url) do |remote|
39
- File.binwrite(zip_file, remote.read)
37
+ # Create a manual temp file path - OS will clean up temp files automatically
38
+ temp_path = File.join(Dir.tmpdir, "fontisan_#{name}_#{Process.pid}_#{rand(10000)}.zip")
39
+
40
+ # Download using IO.copy_stream for better Windows compatibility
41
+ URI.open(url, "rb") do |remote|
42
+ File.open(temp_path, "wb") do |file|
43
+ IO.copy_stream(remote, file)
44
+ end
40
45
  end
41
46
 
42
47
  puts "[fixtures:download] Extracting #{name}..."
43
- Zip::File.open(zip_file) do |zip|
44
- zip.each do |entry|
45
- dest_path = File.join(target_dir, entry.name)
48
+
49
+ # Open zip file and ensure it's fully closed before we're done
50
+ zip_file = Zip::File.open(temp_path)
51
+ begin
52
+ zip_file.each do |entry|
53
+ # Skip macOS metadata files and directories
54
+ next if entry.name.start_with?("__MACOSX/") || entry.name.include?("/._")
55
+ next if entry.directory?
56
+
57
+ # Ensure entry.name is relative by stripping leading slashes
58
+ relative_name = entry.name.sub(%r{^/+}, "")
59
+
60
+ dest_path = File.join(target_dir, relative_name)
46
61
  FileUtils.mkdir_p(File.dirname(dest_path))
47
- entry.extract(dest_path) unless File.exist?(dest_path)
62
+
63
+ # Skip if file already exists
64
+ next if File.exist?(dest_path)
65
+
66
+ # Write the file content directly using binary mode
67
+ File.open(dest_path, "wb") do |file|
68
+ IO.copy_stream(entry.get_input_stream, file)
69
+ end
48
70
  end
71
+ ensure
72
+ # Explicitly close the zip file to release file handle on Windows
73
+ zip_file.close if zip_file
49
74
  end
50
75
 
51
- FileUtils.rm(zip_file)
76
+ # Temp file left in Dir.tmpdir - OS will clean it up automatically
77
+
52
78
  puts "[fixtures:download] #{name} downloaded successfully"
53
79
  rescue LoadError => e
54
80
  warn "[fixtures:download] Error: Required gem not installed. Please run: gem install rubyzip"
55
81
  raise e
56
82
  end
57
83
 
58
- # Font configurations with target directories and marker files
59
- # All fonts are downloaded via Rake
60
- fonts = {
61
- "NotoSans" => {
62
- url: "https://github.com/notofonts/notofonts.github.io/raw/refs/heads/main/fonts/NotoSans/full/ttf/NotoSans-Regular.ttf",
63
- target_dir: fixtures_dir.to_s,
64
- marker: "#{fixtures_dir}/NotoSans-Regular.ttf",
65
- single_file: true,
66
- },
67
- "Libertinus" => {
68
- url: "https://github.com/alerque/libertinus/releases/download/v7.051/Libertinus-7.051.zip",
69
- target_dir: "#{fixtures_dir}/libertinus",
70
- marker: "#{fixtures_dir}/libertinus/Libertinus-7.051/static/OTF/LibertinusSerif-Regular.otf",
71
- },
72
- "MonaSans" => {
73
- url: "https://github.com/github/mona-sans/releases/download/v2.0/MonaSans.zip",
74
- target_dir: "#{fixtures_dir}/MonaSans",
75
- marker: "#{fixtures_dir}/MonaSans/MonaSans/variable/MonaSans[wdth,wght].ttf",
76
- },
77
- "NotoSerifCJK" => {
78
- url: "https://github.com/notofonts/noto-cjk/releases/download/Serif2.003/01_NotoSerifCJK.ttc.zip",
79
- target_dir: "#{fixtures_dir}/NotoSerifCJK",
80
- marker: "#{fixtures_dir}/NotoSerifCJK/NotoSerifCJK.ttc",
81
- },
82
- "NotoSerifCJK-VF" => {
83
- url: "https://github.com/notofonts/noto-cjk/releases/download/Serif2.003/02_NotoSerifCJK-OTF-VF.zip",
84
- target_dir: "#{fixtures_dir}/NotoSerifCJK-VF",
85
- marker: "#{fixtures_dir}/NotoSerifCJK-VF/Variable/OTC/NotoSerifCJK-VF.otf.ttc",
86
- },
87
- }
84
+ # Get font configurations from centralized source
85
+ fonts = FixtureFonts.rakefile_config
88
86
 
89
87
  # Create file tasks for each font
90
88
  fonts.each do |name, config|
@@ -102,13 +100,12 @@ namespace :fixtures do
102
100
 
103
101
  desc "Clean downloaded fixtures"
104
102
  task :clean do
105
- %w[libertinus MonaSans NotoSerifCJK NotoSerifCJK-VF
106
- NotoSans-Regular.ttf].each do |dir|
107
- path = File.join(fixtures_dir, dir)
108
- if File.exist?(path)
109
- FileUtils.rm_rf(path)
110
- puts "[fixtures:clean] Removed #{path}"
111
- end
103
+ fonts.values.map { |config| config[:target_dir] }.each do |path|
104
+ next unless File.exist?(path)
105
+
106
+ puts "[fixtures:clean] Removing #{path}..."
107
+ FileUtils.rm_rf(path)
108
+ puts "[fixtures:clean] Removed #{path}"
112
109
  end
113
110
  end
114
111
  end
data/lib/fontisan/cli.rb CHANGED
@@ -25,16 +25,21 @@ module Fontisan
25
25
  desc: "Suppress non-error output",
26
26
  aliases: "-q"
27
27
 
28
- desc "info FONT_FILE", "Display font information"
28
+ desc "info PATH", "Display font information"
29
29
  # Extract and display comprehensive font metadata.
30
30
  #
31
- # @param font_file [String] Path to the font file
32
- def info(font_file)
33
- command = Commands::InfoCommand.new(font_file, options)
34
- result = command.run
35
- output_result(result)
36
- rescue Errno::ENOENT, Error => e
37
- handle_error(e)
31
+ # @param path [String] Path to the font file or collection
32
+ def info(path)
33
+ command = Commands::InfoCommand.new(path, options)
34
+ info = command.run
35
+ output_result(info) unless options[:quiet]
36
+ rescue Errno::ENOENT => e
37
+ if options[:verbose]
38
+ raise
39
+ else
40
+ warn "File not found: #{path}" unless options[:quiet]
41
+ exit 1
42
+ end
38
43
  end
39
44
 
40
45
  desc "ls FILE", "List contents (fonts in collection or font summary)"
@@ -195,47 +200,79 @@ module Fontisan
195
200
 
196
201
  desc "convert FONT_FILE", "Convert font to different format"
197
202
  option :to, type: :string, required: true,
198
- desc: "Target format (ttf, otf, woff2, svg)",
203
+ desc: "Target format (ttf, otf, woff, woff2)",
199
204
  aliases: "-t"
200
205
  option :output, type: :string, required: true,
201
206
  desc: "Output file path",
202
207
  aliases: "-o"
203
- option :optimize, type: :boolean, default: false,
204
- desc: "Optimize CFF with subroutines (TTF→OTF only)"
205
- option :min_pattern_length, type: :numeric, default: 10,
206
- desc: "Minimum pattern length for subroutines"
207
- option :max_subroutines, type: :numeric, default: 65_535,
208
- desc: "Maximum number of subroutines"
209
- option :optimize_ordering, type: :boolean, default: true,
210
- desc: "Optimize subroutine ordering by frequency"
211
- # Convert a font to a different format.
208
+ option :coordinates, type: :string,
209
+ desc: "Instance coordinates (e.g., wght=700,wdth=100)",
210
+ aliases: "-c"
211
+ option :instance_index, type: :numeric,
212
+ desc: "Named instance index",
213
+ aliases: "-n"
214
+ option :preserve_variation, type: :boolean,
215
+ desc: "Force variation preservation (auto-detected by default)"
216
+ option :no_validate, type: :boolean, default: false,
217
+ desc: "Skip output validation"
218
+ option :preserve_hints, type: :boolean, default: false,
219
+ desc: "Preserve rendering hints during conversion (TTF→OTF preservations may be limited)"
220
+ option :wght, type: :numeric,
221
+ desc: "Weight axis value (alternative to --coordinates)"
222
+ option :wdth, type: :numeric,
223
+ desc: "Width axis value (alternative to --coordinates)"
224
+ option :slnt, type: :numeric,
225
+ desc: "Slant axis value (alternative to --coordinates)"
226
+ option :ital, type: :numeric,
227
+ desc: "Italic axis value (alternative to --coordinates)"
228
+ option :opsz, type: :numeric,
229
+ desc: "Optical size axis value (alternative to --coordinates)"
230
+ # Convert a font to a different format using the universal transformation pipeline.
212
231
  #
213
232
  # Supported conversions:
214
- # - Same format (ttf→ttf, otf→otf): Copy/optimize
215
- # - TTF ↔ OTF: Outline format conversion (foundation)
216
- # - Future: WOFF2 compression, SVG export
233
+ # - TTF OTF: Outline format conversion
234
+ # - WOFF/WOFF2: Web font packaging
235
+ # - Variable fonts: Automatic variation preservation or instance generation
236
+ #
237
+ # Variable Font Operations:
238
+ # The pipeline automatically detects whether variation data can be preserved based on
239
+ # source and target formats. For same outline family (TTF→WOFF or OTF→WOFF2), variation
240
+ # is preserved automatically. For cross-family conversions (TTF↔OTF), an instance is
241
+ # generated unless --preserve-variation is explicitly set.
217
242
  #
218
- # Subroutine Optimization (--optimize):
219
- # When converting TTF→OTF, you can enable automatic CFF subroutine generation
220
- # to reduce file size. This analyzes repeated byte patterns across glyphs and
221
- # creates shared subroutines, typically saving 30-50% in CFF table size.
243
+ # Instance Generation:
244
+ # Use --coordinates to specify exact axis values (e.g., wght=700,wdth=100) or
245
+ # --instance-index to use a named instance. Individual axis options (--wght, --wdth)
246
+ # are also supported for convenience.
222
247
  #
223
248
  # @param font_file [String] Path to the font file
224
249
  #
225
250
  # @example Convert TTF to OTF
226
251
  # fontisan convert font.ttf --to otf --output font.otf
227
252
  #
228
- # @example Convert with optimization
229
- # fontisan convert font.ttf --to otf --output font.otf --optimize --verbose
253
+ # @example Generate bold instance at specific coordinates
254
+ # fontisan convert variable.ttf --to ttf --output bold.ttf --coordinates "wght=700,wdth=100"
255
+ #
256
+ # @example Generate bold instance using individual axis options
257
+ # fontisan convert variable.ttf --to ttf --output bold.ttf --wght 700
258
+ #
259
+ # @example Use named instance
260
+ # fontisan convert variable.ttf --to woff2 --output bold.woff2 --instance-index 0
230
261
  #
231
- # @example Convert with custom optimization parameters
232
- # fontisan convert font.ttf --to otf --output font.otf --optimize \
233
- # --min-pattern-length 15 --max-subroutines 10000
262
+ # @example Force variation preservation (if compatible)
263
+ # fontisan convert variable.ttf --to woff2 --output variable.woff2 --preserve-variation
234
264
  #
235
- # @example Copy/optimize TTF
236
- # fontisan convert font.ttf --to ttf --output optimized.ttf
265
+ # @example Convert without validation
266
+ # fontisan convert font.ttf --to otf --output font.otf --no-validate
237
267
  def convert(font_file)
238
- command = Commands::ConvertCommand.new(font_file, options)
268
+ # Build instance coordinates from axis options
269
+ instance_coords = build_instance_coordinates(options)
270
+
271
+ # Merge coordinates into options
272
+ convert_options = options.to_h.dup
273
+ convert_options[:instance_coordinates] = instance_coords if instance_coords.any?
274
+
275
+ command = Commands::ConvertCommand.new(font_file, convert_options)
239
276
  command.run
240
277
  rescue Errno::ENOENT, Error => e
241
278
  handle_error(e)
@@ -444,6 +481,20 @@ module Fontisan
444
481
 
445
482
  private
446
483
 
484
+ # Build instance coordinates from CLI axis options
485
+ #
486
+ # @param options [Hash] CLI options
487
+ # @return [Hash] Coordinates hash
488
+ def build_instance_coordinates(options)
489
+ coords = {}
490
+ coords["wght"] = options[:wght].to_f if options[:wght]
491
+ coords["wdth"] = options[:wdth].to_f if options[:wdth]
492
+ coords["slnt"] = options[:slnt].to_f if options[:slnt]
493
+ coords["ital"] = options[:ital].to_f if options[:ital]
494
+ coords["opsz"] = options[:opsz].to_f if options[:opsz]
495
+ coords
496
+ end
497
+
447
498
  # Output the result in the requested format.
448
499
  #
449
500
  # @param result [Object] The result object to output
@@ -164,6 +164,9 @@ module Fontisan
164
164
  raise Error, "Format mismatch: #{incompatible.join(', ')}"
165
165
  end
166
166
 
167
+ # Check variable font compatibility
168
+ validate_variation_compatibility! if variable_fonts_in_collection?
169
+
167
170
  # Check all fonts have required tables
168
171
  @fonts.each_with_index do |font, index|
169
172
  required_tables = %w[head hhea maxp]
@@ -177,6 +180,26 @@ module Fontisan
177
180
  true
178
181
  end
179
182
 
183
+ # Check if collection contains variable fonts
184
+ #
185
+ # @return [Boolean] true if any font has fvar table
186
+ def variable_fonts_in_collection?
187
+ @fonts.any? { |font| font.has_table?("fvar") }
188
+ end
189
+
190
+ # Validate variable font compatibility
191
+ #
192
+ # Ensures all variable fonts in the collection are compatible:
193
+ # - All must be same variation type (TrueType or CFF2)
194
+ # - All must have the same axes
195
+ #
196
+ # @return [void]
197
+ # @raise [Error] if variable fonts are incompatible
198
+ def validate_variation_compatibility!
199
+ validate_all_same_variation_type!
200
+ validate_same_axes!
201
+ end
202
+
180
203
  private
181
204
 
182
205
  # Load configuration from file
@@ -255,6 +278,64 @@ module Fontisan
255
278
 
256
279
  incompatible
257
280
  end
281
+
282
+ # Validate all variable fonts use same variation type
283
+ #
284
+ # @return [void]
285
+ # @raise [Error] if mixing TrueType and CFF2 variable fonts
286
+ def validate_all_same_variation_type!
287
+ variable_fonts = @fonts.select { |f| f.has_table?("fvar") }
288
+ return if variable_fonts.empty?
289
+
290
+ ttf_count = variable_fonts.count { |f| f.has_table?("glyf") }
291
+ otf_count = variable_fonts.count { |f| f.has_table?("CFF2") }
292
+
293
+ if ttf_count.positive? && otf_count.positive?
294
+ raise Error, "Cannot mix TrueType and CFF2 variable fonts in collection"
295
+ end
296
+ end
297
+
298
+ # Validate all variable fonts have same axes
299
+ #
300
+ # @return [void]
301
+ # @raise [Error] if variable fonts have different axes
302
+ def validate_same_axes!
303
+ variable_fonts = @fonts.select { |f| f.has_table?("fvar") }
304
+ return if variable_fonts.size < 2
305
+
306
+ first_axes = extract_axes(variable_fonts.first)
307
+ variable_fonts.each_with_index do |font, index|
308
+ font_axes = extract_axes(font)
309
+ unless axes_match?(font_axes, first_axes)
310
+ raise Error,
311
+ "Variable font #{index} has different axes. " \
312
+ "Expected: #{first_axes.join(', ')}, " \
313
+ "Got: #{font_axes.join(', ')}"
314
+ end
315
+ end
316
+ end
317
+
318
+ # Extract axis tags from a font's fvar table
319
+ #
320
+ # @param font [TrueTypeFont, OpenTypeFont] Font to extract axes from
321
+ # @return [Array<String>] Sorted array of axis tags
322
+ def extract_axes(font)
323
+ return [] unless font.has_table?("fvar")
324
+
325
+ fvar_table = font.table("fvar")
326
+ return [] unless fvar_table.respond_to?(:axes)
327
+
328
+ fvar_table.axes.map(&:axis_tag).sort
329
+ end
330
+
331
+ # Check if two axis arrays match
332
+ #
333
+ # @param axes1 [Array<String>] First axis array
334
+ # @param axes2 [Array<String>] Second axis array
335
+ # @return [Boolean] true if axes match
336
+ def axes_match?(axes1, axes2)
337
+ axes1 == axes2
338
+ end
258
339
  end
259
340
  end
260
341
  end