fontisan 0.2.2 → 0.2.4

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 (50) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop_todo.yml +94 -48
  3. data/README.adoc +293 -3
  4. data/Rakefile +20 -7
  5. data/lib/fontisan/base_collection.rb +296 -0
  6. data/lib/fontisan/commands/base_command.rb +2 -19
  7. data/lib/fontisan/commands/convert_command.rb +16 -13
  8. data/lib/fontisan/commands/info_command.rb +156 -50
  9. data/lib/fontisan/config/conversion_matrix.yml +58 -20
  10. data/lib/fontisan/converters/outline_converter.rb +6 -3
  11. data/lib/fontisan/converters/svg_generator.rb +45 -0
  12. data/lib/fontisan/converters/woff2_encoder.rb +106 -13
  13. data/lib/fontisan/font_loader.rb +109 -26
  14. data/lib/fontisan/formatters/text_formatter.rb +72 -19
  15. data/lib/fontisan/models/bitmap_glyph.rb +123 -0
  16. data/lib/fontisan/models/bitmap_strike.rb +94 -0
  17. data/lib/fontisan/models/collection_brief_info.rb +6 -0
  18. data/lib/fontisan/models/collection_info.rb +6 -1
  19. data/lib/fontisan/models/color_glyph.rb +57 -0
  20. data/lib/fontisan/models/color_layer.rb +53 -0
  21. data/lib/fontisan/models/color_palette.rb +60 -0
  22. data/lib/fontisan/models/font_info.rb +26 -0
  23. data/lib/fontisan/models/svg_glyph.rb +89 -0
  24. data/lib/fontisan/open_type_collection.rb +17 -220
  25. data/lib/fontisan/open_type_font.rb +6 -0
  26. data/lib/fontisan/optimizers/charstring_rewriter.rb +19 -8
  27. data/lib/fontisan/optimizers/pattern_analyzer.rb +4 -2
  28. data/lib/fontisan/optimizers/subroutine_builder.rb +6 -5
  29. data/lib/fontisan/optimizers/subroutine_optimizer.rb +5 -2
  30. data/lib/fontisan/pipeline/output_writer.rb +2 -2
  31. data/lib/fontisan/tables/cbdt.rb +169 -0
  32. data/lib/fontisan/tables/cblc.rb +290 -0
  33. data/lib/fontisan/tables/cff.rb +6 -12
  34. data/lib/fontisan/tables/colr.rb +291 -0
  35. data/lib/fontisan/tables/cpal.rb +281 -0
  36. data/lib/fontisan/tables/glyf/glyph_builder.rb +5 -1
  37. data/lib/fontisan/tables/sbix.rb +379 -0
  38. data/lib/fontisan/tables/svg.rb +301 -0
  39. data/lib/fontisan/true_type_collection.rb +29 -113
  40. data/lib/fontisan/true_type_font.rb +6 -0
  41. data/lib/fontisan/validation/woff2_header_validator.rb +278 -0
  42. data/lib/fontisan/validation/woff2_table_validator.rb +270 -0
  43. data/lib/fontisan/validation/woff2_validator.rb +248 -0
  44. data/lib/fontisan/version.rb +1 -1
  45. data/lib/fontisan/woff2/directory.rb +40 -11
  46. data/lib/fontisan/woff2/table_transformer.rb +506 -73
  47. data/lib/fontisan/woff2_font.rb +29 -9
  48. data/lib/fontisan/woff_font.rb +17 -4
  49. data/lib/fontisan.rb +12 -0
  50. metadata +18 -2
data/README.adoc CHANGED
@@ -1043,6 +1043,37 @@ Fontisan provides comprehensive tools for managing TrueType Collections (TTC)
1043
1043
  and OpenType Collections (OTC). You can list fonts in a collection, extract
1044
1044
  individual fonts, unpack entire collections, and validate collection integrity.
1045
1045
 
1046
+ Both TTC and OTC files use the same `ttcf` tag in their binary format, but
1047
+ differ in the type of font data they contain:
1048
+
1049
+ TTC (TrueType Collection):: Supported since OpenType 1.4. Contains fonts with
1050
+ TrueType outlines (glyf table). Multiple fonts can share identical tables for
1051
+ efficient storage. File extension: `.ttc`
1052
+
1053
+ OTC (OpenType Collection):: Supported since OpenType 1.8. Contains fonts with
1054
+ CFF-format outlines (CFF table). Provides the same storage benefits and
1055
+ glyph-count advantages as TTC but for CFF fonts. File extension: `.otc`
1056
+
1057
+ The collection format allows:
1058
+
1059
+ Table sharing::
1060
+ Identical tables are stored once and referenced by multiple fonts
1061
+
1062
+ Gap mode::
1063
+ Overcomes the 65,535 glyph limit per font by distributing glyphs across multiple
1064
+ fonts in a single file
1065
+
1066
+ Efficient storage::
1067
+ Significant size reduction, especially for CJK fonts (e.g., Noto CJK OTC is ~10
1068
+ MB smaller than separate OTF files)
1069
+
1070
+ Fontist returns the appropriate collection type based on the font data:
1071
+
1072
+ * Examines font data within collection to determine type (TTC vs OTC)
1073
+ * TTC contains fonts with TrueType outlines (glyf table)
1074
+ * OTC contains fonts with CFF outlines (CFF table)
1075
+ * If ANY font in the collection has CFF outlines, use OpenTypeCollection
1076
+ * Only use TrueTypeCollection if ALL fonts have TrueType outlines
1046
1077
 
1047
1078
  === List fonts
1048
1079
 
@@ -1269,6 +1300,173 @@ $ fontisan validate FONT.{ttc,otc}
1269
1300
  NOTE: In `extract_ttc`, this was done with `extract_ttc --validate FONT.ttc`.
1270
1301
 
1271
1302
 
1303
+
1304
+
1305
+ == Color font support
1306
+
1307
+ === General
1308
+
1309
+ Fontisan provides comprehensive analysis of all color font formats defined in the OpenType specification.
1310
+
1311
+ === Supported color font formats
1312
+
1313
+ Fontisan supports all four color font table formats:
1314
+
1315
+ COLR/CPAL:: Layered color glyphs with custom color palettes (Microsoft/Google standard)
1316
+
1317
+ SVG:: Embedded SVG graphics with gzip compression support (W3C standard)
1318
+
1319
+ CBDT/CBLC:: Bitmap glyphs at multiple ppem sizes (Google format)
1320
+
1321
+ sbix:: Bitmap graphics with PNG/JPEG/TIFF support (Apple format)
1322
+
1323
+ === Analyzing color fonts
1324
+
1325
+ [source,ruby]
1326
+ ----
1327
+ require 'fontisan'
1328
+
1329
+ # Load and analyze a color font
1330
+ info = Fontisan.info('emoji-font.ttf')
1331
+
1332
+ # Color glyph detection
1333
+ puts info.is_color_font # true if COLR/CPAL present
1334
+ puts info.has_svg_table # true if SVG table present
1335
+ puts info.has_bitmap_glyphs # true if CBDT/CBLC or sbix present
1336
+
1337
+ # Get color palette information (COLR/CPAL)
1338
+ if info.is_color_font
1339
+ puts "Color glyphs: #{info.color_glyphs}"
1340
+ puts "Color palettes: #{info.color_palettes}"
1341
+ puts "Colors per palette: #{info.colors_per_palette}"
1342
+ end
1343
+
1344
+ # Get SVG glyph information
1345
+ if info.has_svg_table
1346
+ puts "SVG glyphs: #{info.svg_glyph_count}"
1347
+ end
1348
+
1349
+ # Get bitmap information
1350
+ if info.has_bitmap_glyphs
1351
+ puts "Bitmap formats: #{info.bitmap_formats.join(', ')}"
1352
+ puts "Available sizes (ppem): #{info.bitmap_ppem_sizes.join(', ')}"
1353
+
1354
+ info.bitmap_strikes.each do |strike|
1355
+ puts "Strike at #{strike.ppem}ppem:"
1356
+ puts " - Glyphs: #{strike.num_glyphs}"
1357
+ puts " - Color depth: #{strike.color_depth}"
1358
+ end
1359
+ end
1360
+ ----
1361
+
1362
+ === Color font table access
1363
+
1364
+ Access color font tables directly:
1365
+
1366
+ [source,ruby]
1367
+ ----
1368
+ font = Fontisan::FontLoader.load('emoji-font.ttf')
1369
+
1370
+ # Access COLR table (layered color)
1371
+ if font.has_table?('COLR')
1372
+ colr = font.table('COLR')
1373
+ puts "Color glyphs: #{colr.num_color_glyphs}"
1374
+
1375
+ # Get color layers for a glyph
1376
+ layers = colr.layers_for_glyph(42)
1377
+ layers.each do |layer|
1378
+ puts "Layer glyph: #{layer.glyph_id}, Palette index: #{layer.palette_index}"
1379
+ end
1380
+ end
1381
+
1382
+ # Access CPAL table (color palettes)
1383
+ if font.has_table?('CPAL')
1384
+ cpal = font.table('CPAL')
1385
+
1386
+ # Get colors from first palette
1387
+ palette = cpal.palette(0)
1388
+ palette.each_with_index do |color, i|
1389
+ puts "Color #{i}: R=#{color.red} G=#{color.green} B=#{color.blue} A=#{color.alpha}"
1390
+ end
1391
+ end
1392
+
1393
+ # Access SVG table
1394
+ if font.has_table?('SVG ')
1395
+ svg = font.table('SVG ')
1396
+
1397
+ # Get SVG document for glyph 100
1398
+ svg_doc = svg.svg_document_for_glyph(100)
1399
+ puts "Compressed: #{svg_doc.compressed?}"
1400
+ puts "SVG data: #{svg_doc.svg_data}"
1401
+ end
1402
+
1403
+ # Access bitmap tables
1404
+ if font.has_table?('CBLC')
1405
+ cblc = font.table('CBLC')
1406
+ puts "Available ppem sizes: #{cblc.ppem_sizes.join(', ')}"
1407
+
1408
+ # Check if glyph 50 has bitmap at 64ppem
1409
+ if cblc.has_bitmap_for_glyph?(50, 64)
1410
+ puts "Glyph 50 has bitmap at 64ppem"
1411
+ end
1412
+ end
1413
+
1414
+ if font.has_table?('sbix')
1415
+ sbix = font.table('sbix')
1416
+
1417
+ # Get bitmap data for glyph 42 at 128ppem
1418
+ glyph_data = sbix.glyph_data(42, 128)
1419
+ if glyph_data
1420
+ puts "Format: #{glyph_data[:graphic_type_name]}"
1421
+ puts "Origin: (#{glyph_data[:origin_x]}, #{glyph_data[:origin_y]})"
1422
+ puts "Data size: #{glyph_data[:data].length} bytes"
1423
+ end
1424
+ end
1425
+ ----
1426
+
1427
+ === Output formats
1428
+
1429
+ Color font information can be serialized to multiple formats:
1430
+
1431
+ [source,ruby]
1432
+ ----
1433
+ info = Fontisan.info('emoji-font.ttf')
1434
+
1435
+ # YAML output
1436
+ puts info.to_yaml
1437
+
1438
+ # JSON output
1439
+ puts info.to_json
1440
+
1441
+ # XML output
1442
+ puts info.to_xml
1443
+ ----
1444
+
1445
+ Example YAML output:
1446
+
1447
+ [source,yaml]
1448
+ ----
1449
+ font_format: truetype
1450
+ family_name: "Emoji Font"
1451
+ is_color_font: true
1452
+ color_glyphs: 872
1453
+ color_palettes: 1
1454
+ colors_per_palette: 256
1455
+ has_svg_table: true
1456
+ svg_glyph_count: 872
1457
+ has_bitmap_glyphs: true
1458
+ bitmap_ppem_sizes: [16, 32, 64, 128, 256]
1459
+ bitmap_formats: ["PNG"]
1460
+ bitmap_strikes:
1461
+ - ppem: 128
1462
+ start_glyph_id: 1
1463
+ end_glyph_id: 872
1464
+ bit_depth: 32
1465
+ num_glyphs: 872
1466
+ color_depth: "32-bit (full color with alpha)"
1467
+ ----
1468
+
1469
+
1272
1470
  == Advanced features
1273
1471
 
1274
1472
  Fontisan provides capabilities:
@@ -1358,6 +1556,9 @@ The loading mode can be queried at any time.
1358
1556
 
1359
1557
  [source,ruby]
1360
1558
  ----
1559
+ # Check laziness
1560
+ font.lazy? # => true
1561
+
1361
1562
  # Mode stored as font property
1362
1563
  font.loading_mode # => :metadata or :full
1363
1564
 
@@ -1366,6 +1567,12 @@ font.table_available?(tag) # => boolean
1366
1567
 
1367
1568
  # Access restricted based on mode
1368
1569
  font.table(tag) # => Returns table or raises error
1570
+
1571
+ # Test lazy loading with an expensive operation
1572
+ glyphs = [] if font.subset_glyphs(lazy: true) { glyphs = font.subset_glyphs }
1573
+ glyphs.count # => Tests whether glyphs were already loaded
1574
+ font.table_available?("head") # => true (lazy loading enabled)
1575
+ font.table_available?("GSUB") # => false (lazy loading enabled)
1369
1576
  ----
1370
1577
 
1371
1578
 
@@ -1399,7 +1606,7 @@ fields:
1399
1606
  `family_name`:: Font family name (nameID 1)
1400
1607
  `subfamily_name`:: Font subfamily/style name (nameID 2)
1401
1608
  `full_name`:: Full font name (nameID 4)
1402
- `post_script_name`:: PostScript name (nameID 6)
1609
+ `postscript_name`:: PostScript name (nameID 6)
1403
1610
  `preferred_family_name`:: Preferred family name (nameID 16, may be nil)
1404
1611
  `preferred_subfamily_name`:: Preferred subfamily name (nameID 17, may be nil)
1405
1612
  `units_per_em`:: Units per em from head table
@@ -1453,6 +1660,64 @@ font = Fontisan::FontLoader.load('font.ttf', mode: :full, lazy: false)
1453
1660
  ----
1454
1661
 
1455
1662
 
1663
+ === Table preparedness
1664
+
1665
+ Every table uses preparedness to avoid lazy initialization overhead. Function
1666
+ `table_available?` was added to check if table prepared for access.
1667
+
1668
+ [source,ruby]
1669
+ ----
1670
+ font = Fontisan::FontLoader.load('font.ttf')
1671
+
1672
+ # Check preparedness
1673
+ font.table_available?("head") # => true
1674
+ font.table_available?("GSUB") # => true
1675
+ font.table_available?("GPOS") # => true
1676
+
1677
+ # Force loading of a table
1678
+ head = font.table("head")
1679
+ puts head.metrics.units_per_em # => Asks explicitly for metric value
1680
+ puts head.metrics.weight_class # => Asks explicitly for metric value
1681
+ print head.ty # => Prints force loaded table
1682
+
1683
+ # Check preparedness after loading
1684
+ font.table_available?("head") # => true in cache
1685
+ font.table_available?("GSUB") # => true in cache
1686
+ font.table_available?("GPOS") # => true in cache
1687
+ ----
1688
+
1689
+ A table not present in a font file is loaded lazily and `table_available?`
1690
+ returns `false` after loading this table or after calling directly `table` for
1691
+ the given tag:
1692
+
1693
+ [source,ruby]
1694
+ ----
1695
+ font = Fontisan::FontLoader.load('font.ttf')
1696
+
1697
+ # Check preparedness
1698
+ font.standard_glyphs # => instantly loading glyphs (CFF and GDEF)
1699
+ font.candlestick_and_widget_chart # => instantly loading glyphs (Colr)
1700
+ font.txt_encoder # => instantly loading glyphs (OutlinedFont)
1701
+ font.table_available?("post") # => true (cached)
1702
+
1703
+ font.table_available?("OS/2") # => false (not loaded)
1704
+ font.table("OS/2") # => instant loading tables (lazy loading)
1705
+ font.table_available?("OS/2") # => true (cached)
1706
+
1707
+ font.table_available?("VORG") # => false (not loaded)
1708
+ font.table("VORG") # => instant loading tables (lazy loading)
1709
+ # Raises Fontisan::Tables::VorgTableNotInFont: Not a valid sfnt font or sfnt table VORG not included in input font
1710
+ font.table_available?("VORG") # => true (cached)
1711
+
1712
+ font.table_available?("GLYC") # => nil
1713
+ font.table("GLYC") # => instant loading tables (lazy loading)
1714
+ # Raises Fontisan::TableNotFound: Table GLYC not found in font
1715
+ font.table_available?("GLYC") # => nil (not cached)
1716
+ ----
1717
+
1718
+ NOTE: If you do not need to check whether the table is present before access
1719
+ it is faster to access directly `table` method and catch error with validation
1720
+ than first call table_available? for the table not present in the font file.
1456
1721
 
1457
1722
 
1458
1723
  == Outline format conversion
@@ -1568,6 +1833,31 @@ converter.supported_targets(:ttf)
1568
1833
  # => [:ttf, :otf, :woff2, :svg]
1569
1834
  ----
1570
1835
 
1836
+ === Convert into WOFF2
1837
+
1838
+ ==== Validation
1839
+
1840
+ WOFF2 encoding can be validated automatically to ensure spec compliance:
1841
+
1842
+ .Validation example
1843
+ [example]
1844
+ ====
1845
+ [source,ruby]
1846
+ ----
1847
+ encoder = Fontisan::Converters::Woff2Encoder.new
1848
+ result = encoder.convert(font, {
1849
+ transform_tables: true,
1850
+ validate: true,
1851
+ validation_level: :strict # :strict, :standard, or :lenient
1852
+ })
1853
+
1854
+ report = result[:validation_report]
1855
+ puts "Valid: #{report.valid}"
1856
+ puts "Compression: #{report.info_issues.first.message}"
1857
+ ----
1858
+ ====
1859
+
1860
+
1571
1861
 
1572
1862
  === Validation
1573
1863
 
@@ -2086,7 +2376,7 @@ end
2086
2376
  ====
2087
2377
 
2088
2378
 
2089
- == Round-Trip validation
2379
+ == Round-trip validation
2090
2380
 
2091
2381
  === General
2092
2382
 
@@ -2159,7 +2449,7 @@ Loron.
2159
2449
 
2160
2450
  Support for layered import CFF color glyphs rasterizing on demand, with
2161
2451
  composite font support, a multi-layer color font represented by many
2162
- CFF fonts stacked on top of each other. ColorGlyph support contains
2452
+ CFF fonts stacked on top of each other. ColorGlyph support contains
2163
2453
  color glyphs, advanced color fonts glyphs and raster images (PNG or JPG)
2164
2454
  combined with TrueType outlines.
2165
2455
 
data/Rakefile CHANGED
@@ -87,6 +87,9 @@ namespace :fixtures do
87
87
 
88
88
  # Create file tasks for each font
89
89
  fonts.each do |name, config|
90
+ # Skip fonts that should not be downloaded (already committed)
91
+ next if config[:skip_download]
92
+
90
93
  file config[:marker] do
91
94
  if config[:single_file]
92
95
  download_single_file(name, config[:url], config[:marker])
@@ -97,16 +100,26 @@ namespace :fixtures do
97
100
  end
98
101
 
99
102
  desc "Download all test fixture fonts"
100
- task download: fonts.values.map { |config| config[:marker] }
103
+ task download: fonts.values.reject { |config| config[:skip_download] }.map { |config| config[:marker] }
101
104
 
102
105
  desc "Clean downloaded fixtures"
103
106
  task :clean do
104
- fonts.values.map { |config| config[:target_dir] }.each do |path|
105
- next unless File.exist?(path)
106
-
107
- puts "[fixtures:clean] Removing #{path}..."
108
- FileUtils.rm_rf(path)
109
- puts "[fixtures:clean] Removed #{path}"
107
+ fonts.values.reject { |config| config[:skip_download] }.each do |config|
108
+ if config[:single_file]
109
+ # For single files, just delete the marker file itself
110
+ if File.exist?(config[:marker])
111
+ puts "[fixtures:clean] Removing #{config[:marker]}..."
112
+ FileUtils.rm_f(config[:marker])
113
+ puts "[fixtures:clean] Removed #{config[:marker]}"
114
+ end
115
+ else
116
+ # For archives, delete the entire target directory
117
+ if File.exist?(config[:target_dir])
118
+ puts "[fixtures:clean] Removing #{config[:target_dir]}..."
119
+ FileUtils.rm_rf(config[:target_dir])
120
+ puts "[fixtures:clean] Removed #{config[:target_dir]}"
121
+ end
122
+ end
110
123
  end
111
124
  end
112
125
  end
@@ -0,0 +1,296 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bindata"
4
+ require_relative "constants"
5
+
6
+ module Fontisan
7
+ # Abstract base class for font collections (TTC/OTC)
8
+ #
9
+ # This class implements the shared logic for TrueTypeCollection and OpenTypeCollection
10
+ # using the Template Method pattern. Subclasses must implement the abstract methods
11
+ # to specify their font class and collection format.
12
+ #
13
+ # The BinData structure definition is shared between both collection types since
14
+ # both TTC and OTC files use the same "ttcf" tag and binary format. The only
15
+ # differences are:
16
+ # 1. The type of fonts contained (TrueType vs OpenType)
17
+ # 2. The format string used for display ("TTC" vs "OTC")
18
+ #
19
+ # @abstract Subclass and override {font_class} and {collection_format}
20
+ #
21
+ # @example Implementing a collection subclass
22
+ # class TrueTypeCollection < BaseCollection
23
+ # def self.font_class
24
+ # TrueTypeFont
25
+ # end
26
+ #
27
+ # def self.collection_format
28
+ # "TTC"
29
+ # end
30
+ # end
31
+ class BaseCollection < BinData::Record
32
+ endian :big
33
+
34
+ string :tag, length: 4, assert: "ttcf"
35
+ uint16 :major_version
36
+ uint16 :minor_version
37
+ uint32 :num_fonts
38
+ array :font_offsets, type: :uint32, initial_length: :num_fonts
39
+
40
+ # Abstract method: Get the font class for this collection type
41
+ #
42
+ # Subclasses must override this to return their specific font class
43
+ # (TrueTypeFont or OpenTypeFont).
44
+ #
45
+ # @return [Class] The font class (TrueTypeFont or OpenTypeFont)
46
+ # @raise [NotImplementedError] if not overridden by subclass
47
+ def self.font_class
48
+ raise NotImplementedError,
49
+ "#{name} must implement self.font_class"
50
+ end
51
+
52
+ # Abstract method: Get the collection format string
53
+ #
54
+ # Subclasses must override this to return "TTC" or "OTC".
55
+ #
56
+ # @return [String] Collection format ("TTC" or "OTC")
57
+ # @raise [NotImplementedError] if not overridden by subclass
58
+ def self.collection_format
59
+ raise NotImplementedError,
60
+ "#{name} must implement self.collection_format"
61
+ end
62
+
63
+ # Read collection from a file
64
+ #
65
+ # @param path [String] Path to the collection file
66
+ # @return [BaseCollection] A new instance
67
+ # @raise [ArgumentError] if path is nil or empty
68
+ # @raise [Errno::ENOENT] if file does not exist
69
+ # @raise [RuntimeError] if file format is invalid
70
+ def self.from_file(path)
71
+ if path.nil? || path.to_s.empty?
72
+ raise ArgumentError,
73
+ "path cannot be nil or empty"
74
+ end
75
+ raise Errno::ENOENT, "File not found: #{path}" unless File.exist?(path)
76
+
77
+ File.open(path, "rb") { |io| read(io) }
78
+ rescue BinData::ValidityError => e
79
+ raise "Invalid #{collection_format} file: #{e.message}"
80
+ rescue EOFError => e
81
+ raise "Invalid #{collection_format} file: unexpected end of file - #{e.message}"
82
+ end
83
+
84
+ # Extract fonts from the collection
85
+ #
86
+ # Reads each font from the collection file and returns them as font objects.
87
+ #
88
+ # @param io [IO] Open file handle to read fonts from
89
+ # @return [Array] Array of font objects (TrueTypeFont or OpenTypeFont)
90
+ def extract_fonts(io)
91
+ font_class = self.class.font_class
92
+
93
+ font_offsets.map do |offset|
94
+ font_class.from_collection(io, offset)
95
+ end
96
+ end
97
+
98
+ # Get a single font from the collection
99
+ #
100
+ # @param index [Integer] Index of the font (0-based)
101
+ # @param io [IO] Open file handle
102
+ # @param mode [Symbol] Loading mode (:metadata or :full, default: :full)
103
+ # @return [TrueTypeFont, OpenTypeFont, nil] Font object or nil if index out of range
104
+ def font(index, io, mode: LoadingModes::FULL)
105
+ return nil if index >= num_fonts
106
+
107
+ font_class = self.class.font_class
108
+ font_class.from_collection(io, font_offsets[index], mode: mode)
109
+ end
110
+
111
+ # Get font count
112
+ #
113
+ # @return [Integer] Number of fonts in collection
114
+ def font_count
115
+ num_fonts
116
+ end
117
+
118
+ # Validate format correctness
119
+ #
120
+ # @return [Boolean] true if the format is valid, false otherwise
121
+ def valid?
122
+ tag == Constants::TTC_TAG && num_fonts.positive? && font_offsets.length == num_fonts
123
+ rescue StandardError
124
+ false
125
+ end
126
+
127
+ # Get the collection version as a single integer
128
+ #
129
+ # @return [Integer] Version number (e.g., 0x00010000 for version 1.0)
130
+ def version
131
+ (major_version << 16) | minor_version
132
+ end
133
+
134
+ # Get the collection version as a string
135
+ #
136
+ # @return [String] Version string (e.g., "1.0")
137
+ def version_string
138
+ "#{major_version}.#{minor_version}"
139
+ end
140
+
141
+ # List all fonts in the collection with basic metadata
142
+ #
143
+ # Returns a CollectionListInfo model containing summaries of all fonts.
144
+ # This is the API method used by the `ls` command for collections.
145
+ #
146
+ # @param io [IO] Open file handle to read fonts from
147
+ # @return [CollectionListInfo] List of fonts with metadata
148
+ #
149
+ # @example List fonts in collection
150
+ # File.open("fonts.ttc", "rb") do |io|
151
+ # collection = TrueTypeCollection.read(io)
152
+ # list = collection.list_fonts(io)
153
+ # list.fonts.each { |f| puts "#{f.index}: #{f.family_name}" }
154
+ # end
155
+ def list_fonts(io)
156
+ require_relative "models/collection_list_info"
157
+ require_relative "models/collection_font_summary"
158
+ require_relative "tables/name"
159
+
160
+ font_class = self.class.font_class
161
+
162
+ fonts = font_offsets.map.with_index do |offset, index|
163
+ font = font_class.from_collection(io, offset)
164
+
165
+ # Extract basic font info
166
+ name_table = font.table("name")
167
+ post_table = font.table("post")
168
+
169
+ family_name = name_table&.english_name(Tables::Name::FAMILY) || "Unknown"
170
+ subfamily_name = name_table&.english_name(Tables::Name::SUBFAMILY) || "Regular"
171
+ postscript_name = name_table&.english_name(Tables::Name::POSTSCRIPT_NAME) || "Unknown"
172
+
173
+ # Determine font format
174
+ sfnt = font.header.sfnt_version
175
+ font_format = case sfnt
176
+ when 0x00010000, 0x74727565 # 0x74727565 = 'true'
177
+ "TrueType"
178
+ when 0x4F54544F # 'OTTO'
179
+ "OpenType"
180
+ else
181
+ "Unknown"
182
+ end
183
+
184
+ num_glyphs = post_table&.glyph_names&.length || 0
185
+ num_tables = font.table_names.length
186
+
187
+ Models::CollectionFontSummary.new(
188
+ index: index,
189
+ family_name: family_name,
190
+ subfamily_name: subfamily_name,
191
+ postscript_name: postscript_name,
192
+ font_format: font_format,
193
+ num_glyphs: num_glyphs,
194
+ num_tables: num_tables,
195
+ )
196
+ end
197
+
198
+ Models::CollectionListInfo.new(
199
+ collection_path: nil, # Will be set by command
200
+ num_fonts: num_fonts,
201
+ fonts: fonts,
202
+ )
203
+ end
204
+
205
+ # Get comprehensive collection metadata
206
+ #
207
+ # Returns a CollectionInfo model with header information, offsets,
208
+ # and table sharing statistics.
209
+ # This is the API method used by the `info` command for collections.
210
+ #
211
+ # @param io [IO] Open file handle to read fonts from
212
+ # @param path [String] Collection file path (for file size)
213
+ # @return [CollectionInfo] Collection metadata
214
+ #
215
+ # @example Get collection info
216
+ # File.open("fonts.ttc", "rb") do |io|
217
+ # collection = TrueTypeCollection.read(io)
218
+ # info = collection.collection_info(io, "fonts.ttc")
219
+ # puts "Version: #{info.version_string}"
220
+ # end
221
+ def collection_info(io, path)
222
+ require_relative "models/collection_info"
223
+ require_relative "models/table_sharing_info"
224
+
225
+ # Calculate table sharing statistics
226
+ table_sharing = calculate_table_sharing(io)
227
+
228
+ # Get file size
229
+ file_size = path ? File.size(path) : 0
230
+
231
+ Models::CollectionInfo.new(
232
+ collection_path: path,
233
+ collection_format: self.class.collection_format,
234
+ ttc_tag: tag,
235
+ major_version: major_version,
236
+ minor_version: minor_version,
237
+ num_fonts: num_fonts,
238
+ font_offsets: font_offsets.to_a,
239
+ file_size_bytes: file_size,
240
+ table_sharing: table_sharing,
241
+ )
242
+ end
243
+
244
+ private
245
+
246
+ # Calculate table sharing statistics
247
+ #
248
+ # Analyzes which tables are shared between fonts and calculates
249
+ # space savings from deduplication.
250
+ #
251
+ # @param io [IO] Open file handle
252
+ # @return [TableSharingInfo] Sharing statistics
253
+ def calculate_table_sharing(io)
254
+ require_relative "models/table_sharing_info"
255
+
256
+ font_class = self.class.font_class
257
+
258
+ # Extract all fonts
259
+ fonts = font_offsets.map do |offset|
260
+ font_class.from_collection(io, offset)
261
+ end
262
+
263
+ # Build table hash map (checksum -> size)
264
+ table_map = {}
265
+ total_table_size = 0
266
+
267
+ fonts.each do |font|
268
+ font.tables.each do |entry|
269
+ key = entry.checksum
270
+ size = entry.table_length
271
+ table_map[key] ||= size
272
+ total_table_size += size
273
+ end
274
+ end
275
+
276
+ # Count unique vs shared
277
+ unique_tables = table_map.size
278
+ total_tables = fonts.sum { |f| f.tables.length }
279
+ shared_tables = total_tables - unique_tables
280
+
281
+ # Calculate space saved
282
+ unique_size = table_map.values.sum
283
+ space_saved = total_table_size - unique_size
284
+
285
+ # Calculate sharing percentage
286
+ sharing_pct = total_tables.positive? ? (shared_tables.to_f / total_tables * 100).round(2) : 0.0
287
+
288
+ Models::TableSharingInfo.new(
289
+ shared_tables: shared_tables,
290
+ unique_tables: unique_tables,
291
+ sharing_percentage: sharing_pct,
292
+ space_saved_bytes: space_saved,
293
+ )
294
+ end
295
+ end
296
+ end