extract_ttc 0.3.6 → 0.3.7

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 (40) hide show
  1. checksums.yaml +4 -4
  2. data/.gitignore +0 -4
  3. data/.rubocop.yml +7 -9
  4. data/.rubocop_todo.yml +135 -0
  5. data/Gemfile +6 -6
  6. data/README.adoc +856 -55
  7. data/Rakefile +7 -101
  8. data/exe/extract_ttc +7 -0
  9. data/extract_ttc.gemspec +3 -4
  10. data/lib/extract_ttc/cli.rb +47 -0
  11. data/lib/extract_ttc/commands/extract.rb +88 -0
  12. data/lib/extract_ttc/commands/info.rb +112 -0
  13. data/lib/extract_ttc/commands/list.rb +60 -0
  14. data/lib/extract_ttc/configuration.rb +126 -0
  15. data/lib/extract_ttc/constants.rb +42 -0
  16. data/lib/extract_ttc/models/extraction_result.rb +56 -0
  17. data/lib/extract_ttc/models/validation_result.rb +53 -0
  18. data/lib/extract_ttc/true_type_collection.rb +79 -0
  19. data/lib/extract_ttc/true_type_font.rb +239 -0
  20. data/lib/extract_ttc/utilities/checksum_calculator.rb +89 -0
  21. data/lib/extract_ttc/utilities/output_path_generator.rb +100 -0
  22. data/lib/extract_ttc/version.rb +1 -1
  23. data/lib/extract_ttc.rb +83 -55
  24. data/sig/extract_ttc/configuration.rbs +19 -0
  25. data/sig/extract_ttc/constants.rbs +17 -0
  26. data/sig/extract_ttc/models/extraction_result.rbs +19 -0
  27. data/sig/extract_ttc/models/font_data.rbs +17 -0
  28. data/sig/extract_ttc/models/table_directory_entry.rbs +15 -0
  29. data/sig/extract_ttc/models/true_type_collection_header.rbs +15 -0
  30. data/sig/extract_ttc/models/true_type_font_offset_table.rbs +17 -0
  31. data/sig/extract_ttc/models/validation_result.rbs +17 -0
  32. data/sig/extract_ttc/utilities/checksum_calculator.rbs +13 -0
  33. data/sig/extract_ttc/utilities/output_path_generator.rbs +11 -0
  34. data/sig/extract_ttc/validators/true_type_collection_validator.rbs +9 -0
  35. data/sig/extract_ttc.rbs +20 -0
  36. metadata +44 -28
  37. data/ext/stripttc/LICENSE +0 -31
  38. data/ext/stripttc/dummy.c +0 -2
  39. data/ext/stripttc/extconf.rb +0 -5
  40. data/ext/stripttc/stripttc.c +0 -187
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ExtractTtc
4
+ module Models
5
+ # Represents the result of a validation operation
6
+ #
7
+ # This model encapsulates the outcome of validating files, headers, or other
8
+ # data structures, including whether the validation passed and any error
9
+ # messages generated during validation.
10
+ #
11
+ # This is an immutable value object with methods to query validity status.
12
+ class ValidationResult
13
+ attr_reader :valid, :errors
14
+
15
+ # Initialize a new validation result
16
+ #
17
+ # @param valid [Boolean] Whether the validation passed
18
+ # @param errors [Array<String>] Array of error messages (empty if valid)
19
+ def initialize(valid: true, errors: [])
20
+ @valid = valid
21
+ @errors = errors.freeze
22
+ end
23
+
24
+ # Check if the validation passed
25
+ #
26
+ # @return [Boolean] true if valid, false otherwise
27
+ def valid?
28
+ @valid
29
+ end
30
+
31
+ # Check if the validation failed
32
+ #
33
+ # @return [Boolean] true if invalid, false otherwise
34
+ def invalid?
35
+ !@valid
36
+ end
37
+
38
+ # Add an error message to the result
39
+ #
40
+ # This creates a new ValidationResult with the error added, as the object
41
+ # is immutable.
42
+ #
43
+ # @param message [String] The error message to add
44
+ # @return [ValidationResult] A new result object with the error added
45
+ def add_error(message)
46
+ self.class.new(
47
+ valid: false,
48
+ errors: @errors.dup << message,
49
+ )
50
+ end
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,79 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bindata"
4
+ require_relative "constants"
5
+
6
+ module ExtractTtc
7
+ # TrueType Collection domain object using BinData
8
+ #
9
+ # Represents a complete TrueType Collection file using BinData's declarative
10
+ # DSL for binary structure definition. The structure definition IS the
11
+ # documentation, and BinData handles all low-level reading/writing.
12
+ #
13
+ # @example Reading and extracting fonts
14
+ # File.open("Helvetica.ttc", "rb") do |io|
15
+ # ttc = TrueTypeCollection.read(io)
16
+ # puts ttc.num_fonts # => 6
17
+ # fonts = ttc.extract_fonts(io) # => [TrueTypeFont, TrueTypeFont, ...]
18
+ # end
19
+ class TrueTypeCollection < BinData::Record
20
+ endian :big
21
+
22
+ string :tag, length: 4, assert: "ttcf"
23
+ uint16 :major_version
24
+ uint16 :minor_version
25
+ uint32 :num_fonts
26
+ array :font_offsets, type: :uint32, initial_length: :num_fonts
27
+
28
+ # Read TrueType Collection from a file
29
+ #
30
+ # @param path [String] Path to the TTC file
31
+ # @return [TrueTypeCollection] A new instance
32
+ # @raise [ArgumentError] if path is nil or empty
33
+ # @raise [Errno::ENOENT] if file does not exist
34
+ # @raise [RuntimeError] if file format is invalid
35
+ def self.from_file(path)
36
+ if path.nil? || path.to_s.empty?
37
+ raise ArgumentError,
38
+ "path cannot be nil or empty"
39
+ end
40
+ raise Errno::ENOENT, "File not found: #{path}" unless File.exist?(path)
41
+
42
+ File.open(path, "rb") { |io| read(io) }
43
+ rescue BinData::ValidityError => e
44
+ raise "Invalid TTC file: #{e.message}"
45
+ rescue EOFError => e
46
+ raise "Invalid TTC file: unexpected end of file - #{e.message}"
47
+ end
48
+
49
+ # Extract fonts as TrueTypeFont objects
50
+ #
51
+ # Reads each font from the TTC file and returns them as TrueTypeFont objects.
52
+ #
53
+ # @param io [IO] Open file handle to read fonts from
54
+ # @return [Array<TrueTypeFont>] Array of font objects
55
+ def extract_fonts(io)
56
+ require_relative "true_type_font"
57
+
58
+ font_offsets.map do |offset|
59
+ TrueTypeFont.from_ttc(io, offset)
60
+ end
61
+ end
62
+
63
+ # Validate format correctness
64
+ #
65
+ # @return [Boolean] true if the format is valid, false otherwise
66
+ def valid?
67
+ tag == Constants::TTC_TAG && num_fonts.positive? && font_offsets.length == num_fonts
68
+ rescue StandardError
69
+ false
70
+ end
71
+
72
+ # Get the TTC version as a single integer
73
+ #
74
+ # @return [Integer] Version number (e.g., 0x00010000 for version 1.0)
75
+ def version
76
+ (major_version << 16) | minor_version
77
+ end
78
+ end
79
+ end
@@ -0,0 +1,239 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bindata"
4
+ require_relative "constants"
5
+ require_relative "utilities/checksum_calculator"
6
+
7
+ module ExtractTtc
8
+ # TTF Offset Table structure
9
+ class OffsetTable < BinData::Record
10
+ endian :big
11
+ uint32 :sfnt_version
12
+ uint16 :num_tables
13
+ uint16 :search_range
14
+ uint16 :entry_selector
15
+ uint16 :range_shift
16
+ end
17
+
18
+ # TTF Table Directory Entry structure
19
+ class TableDirectory < BinData::Record
20
+ endian :big
21
+ string :tag, length: 4
22
+ uint32 :checksum
23
+ uint32 :offset
24
+ uint32 :table_length
25
+ end
26
+
27
+ # TrueType Font domain object using BinData
28
+ #
29
+ # Represents a complete TrueType Font file using BinData's declarative
30
+ # DSL for binary structure definition. The structure definition IS the
31
+ # documentation, and BinData handles all low-level reading/writing.
32
+ #
33
+ # @example Writing a font
34
+ # ttc = TrueTypeCollection.from_file("Helvetica.ttc")
35
+ # fonts = ttc.extract_fonts
36
+ # fonts[0].to_file("Helvetica_0.ttf")
37
+ #
38
+ # @example Reading a font
39
+ # ttf = TrueTypeFont.from_file("font.ttf")
40
+ # puts ttf.header.num_tables # => 14
41
+ class TrueTypeFont < BinData::Record
42
+ endian :big
43
+
44
+ offset_table :header
45
+ array :tables, type: :table_directory, initial_length: -> {
46
+ header.num_tables
47
+ }
48
+
49
+ # Table data is stored separately since it's at variable offsets
50
+ attr_accessor :table_data
51
+
52
+ # Read TrueType Font from a file
53
+ #
54
+ # @param path [String] Path to the TTF file
55
+ # @return [TrueTypeFont] A new instance
56
+ # @raise [ArgumentError] if path is nil or empty
57
+ # @raise [Errno::ENOENT] if file does not exist
58
+ # @raise [RuntimeError] if file format is invalid
59
+ def self.from_file(path)
60
+ if path.nil? || path.to_s.empty?
61
+ raise ArgumentError,
62
+ "path cannot be nil or empty"
63
+ end
64
+ raise Errno::ENOENT, "File not found: #{path}" unless File.exist?(path)
65
+
66
+ File.open(path, "rb") do |io|
67
+ font = read(io)
68
+ font.read_table_data(io)
69
+ font
70
+ end
71
+ rescue BinData::ValidityError, EOFError => e
72
+ raise "Invalid TTF file: #{e.message}"
73
+ end
74
+
75
+ # Read TrueType Font from TTC at specific offset
76
+ #
77
+ # @param io [IO] Open file handle
78
+ # @param offset [Integer] Byte offset to the font
79
+ # @return [TrueTypeFont] A new instance
80
+ def self.from_ttc(io, offset)
81
+ io.seek(offset)
82
+ font = read(io)
83
+ font.read_table_data(io)
84
+ font
85
+ end
86
+
87
+ # Read table data for all tables
88
+ #
89
+ # @param io [IO] Open file handle
90
+ # @return [void]
91
+ def read_table_data(io)
92
+ @table_data = {}
93
+ tables.each do |entry|
94
+ io.seek(entry.offset)
95
+ @table_data[entry.tag] = io.read(entry.table_length)
96
+ end
97
+ end
98
+
99
+ # Write TrueType Font to a file
100
+ #
101
+ # Writes the complete TTF structure to disk, including proper checksum
102
+ # calculation and table alignment.
103
+ #
104
+ # @param path [String] Path where the TTF file will be written
105
+ # @return [Integer] Number of bytes written
106
+ # @raise [IOError] if writing fails
107
+ def to_file(path)
108
+ File.open(path, "wb") do |io|
109
+ # Write header and tables (directory)
110
+ write_structure(io)
111
+
112
+ # Write table data with updated offsets
113
+ write_table_data_with_offsets(io)
114
+
115
+ io.pos
116
+ end
117
+
118
+ # Update checksum adjustment in head table
119
+ update_checksum_adjustment_in_file(path) if head_table
120
+
121
+ File.size(path)
122
+ end
123
+
124
+ # Validate format correctness
125
+ #
126
+ # @return [Boolean] true if the TTF format is valid, false otherwise
127
+ def valid?
128
+ return false unless header
129
+ return false unless tables.respond_to?(:length)
130
+ return false unless @table_data.is_a?(Hash)
131
+ return false if tables.length != header.num_tables
132
+ return false unless head_table
133
+
134
+ true
135
+ end
136
+
137
+ # Check if font has a specific table
138
+ #
139
+ # @param tag [String] The table tag to check for
140
+ # @return [Boolean] true if table exists, false otherwise
141
+ def has_table?(tag)
142
+ tables.any? { |entry| entry.tag == tag }
143
+ end
144
+
145
+ # Find a table entry by tag
146
+ #
147
+ # @param tag [String] The table tag to find
148
+ # @return [TableDirectory, nil] The table entry or nil
149
+ def find_table_entry(tag)
150
+ tables.find { |entry| entry.tag == tag }
151
+ end
152
+
153
+ # Get the head table entry
154
+ #
155
+ # @return [TableDirectory, nil] The head table entry or nil
156
+ def head_table
157
+ find_table_entry(Constants::HEAD_TAG)
158
+ end
159
+
160
+ private
161
+
162
+ # Write the structure (header + table directory) to IO
163
+ #
164
+ # @param io [IO] Open file handle
165
+ # @return [void]
166
+ def write_structure(io)
167
+ # Write header
168
+ header.write(io)
169
+
170
+ # Write table directory with placeholder offsets
171
+ tables.each do |entry|
172
+ io.write(entry.tag)
173
+ io.write([entry.checksum].pack("N"))
174
+ io.write([0].pack("N")) # Placeholder offset
175
+ io.write([entry.table_length].pack("N"))
176
+ end
177
+ end
178
+
179
+ # Write table data and update offsets in directory
180
+ #
181
+ # @param io [IO] Open file handle
182
+ # @return [void]
183
+ def write_table_data_with_offsets(io)
184
+ tables.each_with_index do |entry, index|
185
+ # Record current position
186
+ current_position = io.pos
187
+
188
+ # Write table data
189
+ data = @table_data[entry.tag]
190
+ raise IOError, "Missing table data for tag '#{entry.tag}'" if data.nil?
191
+
192
+ io.write(data)
193
+
194
+ # Add padding to align to 4-byte boundary
195
+ padding = (Constants::TABLE_ALIGNMENT - (io.pos % Constants::TABLE_ALIGNMENT)) % Constants::TABLE_ALIGNMENT
196
+ io.write("\x00" * padding) if padding.positive?
197
+
198
+ # Zero out checksumAdjustment field in head table
199
+ if entry.tag == Constants::HEAD_TAG
200
+ current_pos = io.pos
201
+ io.seek(current_position + 8)
202
+ io.write([0].pack("N"))
203
+ io.seek(current_pos)
204
+ end
205
+
206
+ # Update offset in table directory
207
+ # Table directory starts at byte 12, each entry is 16 bytes
208
+ # Offset field is at byte 8 within each entry
209
+ directory_offset_position = 12 + (index * 16) + 8
210
+ current_pos = io.pos
211
+ io.seek(directory_offset_position)
212
+ io.write([current_position].pack("N"))
213
+ io.seek(current_pos)
214
+ end
215
+ end
216
+
217
+ # Update checksumAdjustment field in head table
218
+ #
219
+ # @param path [String] Path to the TTF file
220
+ # @return [void]
221
+ def update_checksum_adjustment_in_file(path)
222
+ # Calculate file checksum
223
+ checksum = Utilities::ChecksumCalculator.calculate_file_checksum(path)
224
+
225
+ # Calculate adjustment
226
+ adjustment = Utilities::ChecksumCalculator.calculate_adjustment(checksum)
227
+
228
+ # Find head table position
229
+ head_entry = head_table
230
+ return unless head_entry
231
+
232
+ # Write adjustment to head table (offset 8 within head table)
233
+ File.open(path, "r+b") do |io|
234
+ io.seek(head_entry.offset + 8)
235
+ io.write([adjustment].pack("N"))
236
+ end
237
+ end
238
+ end
239
+ end
@@ -0,0 +1,89 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../constants"
4
+
5
+ module ExtractTtc
6
+ module Utilities
7
+ # ChecksumCalculator provides stateless utility methods for calculating font file checksums.
8
+ #
9
+ # This class implements the TrueType/OpenType checksum algorithm which sums all uint32
10
+ # values in a file. The checksum is used to verify file integrity and calculate the
11
+ # checksumAdjustment value stored in the 'head' table.
12
+ #
13
+ # @example Calculate file checksum
14
+ # checksum = ChecksumCalculator.calculate_file_checksum("font.ttf")
15
+ # # => 2842116234
16
+ #
17
+ # @example Calculate checksum adjustment
18
+ # adjustment = ChecksumCalculator.calculate_adjustment(checksum)
19
+ # # => 1452851062
20
+ class ChecksumCalculator
21
+ # Calculate the checksum of an entire font file.
22
+ #
23
+ # The checksum is calculated by summing all uint32 (4-byte) values in the file.
24
+ # Files that are not multiples of 4 bytes are padded with zeros. The sum is
25
+ # masked to 32 bits to prevent overflow.
26
+ #
27
+ # @param file_path [String] path to the font file
28
+ # @return [Integer] the calculated uint32 checksum
29
+ # @raise [Errno::ENOENT] if the file does not exist
30
+ # @raise [Errno::EACCES] if the file cannot be read
31
+ #
32
+ # @example
33
+ # checksum = ChecksumCalculator.calculate_file_checksum("font.ttf")
34
+ # # => 2842116234
35
+ def self.calculate_file_checksum(file_path)
36
+ File.open(file_path, "rb") do |file|
37
+ calculate_checksum_from_io(file)
38
+ end
39
+ end
40
+
41
+ # Calculate the checksum adjustment value for the 'head' table.
42
+ #
43
+ # The checksum adjustment is stored at offset 8 in the 'head' table and is
44
+ # calculated as: CHECKSUM_ADJUSTMENT_MAGIC - file_checksum.
45
+ # This value ensures that the checksum of the entire font file equals the
46
+ # magic number.
47
+ #
48
+ # @param file_checksum [Integer] the calculated file checksum
49
+ # @return [Integer] the checksum adjustment value to write to the 'head' table
50
+ #
51
+ # @example
52
+ # adjustment = ChecksumCalculator.calculate_adjustment(2842116234)
53
+ # # => 1452851062
54
+ def self.calculate_adjustment(file_checksum)
55
+ (Constants::CHECKSUM_ADJUSTMENT_MAGIC - file_checksum) & 0xFFFFFFFF
56
+ end
57
+
58
+ # Calculate checksum from an IO object.
59
+ #
60
+ # Reads the IO stream in 4-byte chunks and calculates the uint32 checksum.
61
+ # This is the core checksum algorithm implementation.
62
+ #
63
+ # @param io [IO] the IO object to read from
64
+ # @return [Integer] the calculated uint32 checksum
65
+ # @api private
66
+ def self.calculate_checksum_from_io(io)
67
+ io.rewind
68
+ sum = 0
69
+
70
+ until io.eof?
71
+ # Read 4 bytes at a time
72
+ bytes = io.read(4)
73
+ break if bytes.nil? || bytes.empty?
74
+
75
+ # Pad with zeros if less than 4 bytes
76
+ bytes += "\0" * (4 - bytes.length) if bytes.length < 4
77
+
78
+ # Convert to uint32 (network byte order, big-endian)
79
+ value = bytes.unpack1("N")
80
+ sum = (sum + value) & 0xFFFFFFFF
81
+ end
82
+
83
+ sum
84
+ end
85
+
86
+ private_class_method :calculate_checksum_from_io
87
+ end
88
+ end
89
+ end
@@ -0,0 +1,100 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ExtractTtc
4
+ module Utilities
5
+ # OutputPathGenerator provides stateless utility methods for generating output file paths.
6
+ #
7
+ # This class generates standardized output paths for extracted TTF files from TTC files,
8
+ # using a consistent naming convention with zero-padded indices.
9
+ #
10
+ # @example Generate output path with defaults
11
+ # path = OutputPathGenerator.generate("Helvetica.ttc", 0)
12
+ # # => "Helvetica_00.ttf"
13
+ #
14
+ # @example Generate output path with custom directory
15
+ # path = OutputPathGenerator.generate("Helvetica.ttc", 5, output_dir: "/tmp/fonts")
16
+ # # => "/tmp/fonts/Helvetica_05.ttf"
17
+ class OutputPathGenerator
18
+ # Default format string for zero-padded font indices.
19
+ # Produces two-digit indices (00, 01, 02, etc.)
20
+ DEFAULT_INDEX_FORMAT = "%02d"
21
+
22
+ # Generate an output TTF file path for an extracted font.
23
+ #
24
+ # The output path is constructed from the input file's basename, a zero-padded
25
+ # font index, and an optional output directory. The resulting filename follows
26
+ # the pattern: "basename_XX.ttf" where XX is the zero-padded index.
27
+ #
28
+ # @param input_path [String] path to the input TTC file
29
+ # @param font_index [Integer] zero-based index of the font being extracted
30
+ # @param output_dir [String, nil] optional output directory (defaults to current directory ".")
31
+ # @return [String] the generated output file path
32
+ # @raise [ArgumentError] if font_index is negative
33
+ #
34
+ # @example Generate path in current directory
35
+ # OutputPathGenerator.generate("fonts/Helvetica.ttc", 0)
36
+ # # => "Helvetica_00.ttf"
37
+ #
38
+ # @example Generate path in specific directory
39
+ # OutputPathGenerator.generate("Helvetica.ttc", 3, output_dir: "/tmp")
40
+ # # => "/tmp/Helvetica_03.ttf"
41
+ #
42
+ # @example High font index
43
+ # OutputPathGenerator.generate("Font.ttc", 15)
44
+ # # => "Font_15.ttf"
45
+ def self.generate(input_path, font_index, output_dir: nil)
46
+ if font_index.negative?
47
+ raise ArgumentError,
48
+ "font_index must be non-negative"
49
+ end
50
+
51
+ basename = File.basename(input_path, ".*")
52
+ formatted_index = sprintf(DEFAULT_INDEX_FORMAT, font_index)
53
+ filename = "#{basename}_#{formatted_index}.ttf"
54
+
55
+ if output_dir.nil? || output_dir.empty? || output_dir == "."
56
+ filename
57
+ else
58
+ File.join(output_dir, filename)
59
+ end
60
+ end
61
+
62
+ # Generate output path with a custom index format.
63
+ #
64
+ # Allows specifying a custom sprintf format string for the index padding,
65
+ # enabling different padding widths or styles.
66
+ #
67
+ # @param input_path [String] path to the input TTC file
68
+ # @param font_index [Integer] zero-based index of the font being extracted
69
+ # @param index_format [String] sprintf format string for index formatting
70
+ # @param output_dir [String, nil] optional output directory
71
+ # @return [String] the generated output file path
72
+ # @raise [ArgumentError] if font_index is negative
73
+ #
74
+ # @example Three-digit padding
75
+ # OutputPathGenerator.generate_with_format("Font.ttc", 5, "%03d")
76
+ # # => "Font_005.ttf"
77
+ #
78
+ # @example No padding
79
+ # OutputPathGenerator.generate_with_format("Font.ttc", 5, "%d")
80
+ # # => "Font_5.ttf"
81
+ def self.generate_with_format(input_path, font_index, index_format,
82
+ output_dir: nil)
83
+ if font_index.negative?
84
+ raise ArgumentError,
85
+ "font_index must be non-negative"
86
+ end
87
+
88
+ basename = File.basename(input_path, ".*")
89
+ formatted_index = sprintf(index_format, font_index)
90
+ filename = "#{basename}_#{formatted_index}.ttf"
91
+
92
+ if output_dir.nil? || output_dir.empty?
93
+ filename
94
+ else
95
+ File.join(output_dir, filename)
96
+ end
97
+ end
98
+ end
99
+ end
100
+ end
@@ -1,3 +1,3 @@
1
1
  module ExtractTtc
2
- VERSION = "0.3.6".freeze
2
+ VERSION = "0.3.7".freeze
3
3
  end