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
@@ -0,0 +1,301 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "stringio"
4
+ require "zlib"
5
+ require_relative "../binary/base_record"
6
+
7
+ module Fontisan
8
+ module Tables
9
+ # SVG (Scalable Vector Graphics) table parser
10
+ #
11
+ # The SVG table contains embedded SVG documents for glyphs, typically used
12
+ # for color emoji or graphic elements. Each document can cover a range of
13
+ # glyph IDs and may be compressed with gzip.
14
+ #
15
+ # SVG Table Structure:
16
+ # ```
17
+ # SVG Table = Header (10 bytes)
18
+ # + Document Index
19
+ # + SVG Documents
20
+ # ```
21
+ #
22
+ # Header (10 bytes):
23
+ # - version (uint16): Table version (0)
24
+ # - svgDocumentListOffset (uint32): Offset to SVG Document Index
25
+ # - reserved (uint32): Reserved, set to 0
26
+ #
27
+ # Document Index:
28
+ # - numEntries (uint16): Number of SVG Document Index Entries
29
+ # - entries[numEntries]: Array of SVG Document Index Entries
30
+ #
31
+ # SVG Document Index Entry (12 bytes):
32
+ # - startGlyphID (uint16): First glyph ID
33
+ # - endGlyphID (uint16): Last glyph ID (inclusive)
34
+ # - svgDocOffset (uint32): Offset to SVG document
35
+ # - svgDocLength (uint32): Length of SVG document
36
+ #
37
+ # SVG documents may be compressed with gzip (identified by magic bytes 0x1f 0x8b).
38
+ #
39
+ # Reference: OpenType SVG specification
40
+ # https://docs.microsoft.com/en-us/typography/opentype/spec/svg
41
+ #
42
+ # @example Reading an SVG table
43
+ # data = font.table_data['SVG ']
44
+ # svg = Fontisan::Tables::Svg.read(data)
45
+ # svg_content = svg.svg_for_glyph(42)
46
+ # puts "Glyph 42 SVG: #{svg_content}"
47
+ class Svg < Binary::BaseRecord
48
+ # OpenType table tag for SVG (note: includes trailing space)
49
+ TAG = "SVG "
50
+
51
+ # SVG Document Index Entry structure
52
+ #
53
+ # Each entry associates a glyph range with an SVG document.
54
+ # Structure (12 bytes): start_glyph_id, end_glyph_id, svg_doc_offset, svg_doc_length
55
+ class SvgDocumentRecord < Binary::BaseRecord
56
+ endian :big
57
+ uint16 :start_glyph_id
58
+ uint16 :end_glyph_id
59
+ uint32 :svg_doc_offset
60
+ uint32 :svg_doc_length
61
+
62
+ # Check if this record includes a specific glyph ID
63
+ #
64
+ # @param glyph_id [Integer] Glyph ID to check
65
+ # @return [Boolean] True if glyph is in range
66
+ def includes_glyph?(glyph_id)
67
+ glyph_id >= start_glyph_id && glyph_id <= end_glyph_id
68
+ end
69
+
70
+ # Get the glyph range for this record
71
+ #
72
+ # @return [Range] Range of glyph IDs
73
+ def glyph_range
74
+ start_glyph_id..end_glyph_id
75
+ end
76
+ end
77
+
78
+ # @return [Integer] SVG table version (0)
79
+ attr_reader :version
80
+
81
+ # @return [Integer] Offset to SVG Document Index
82
+ attr_reader :svg_document_list_offset
83
+
84
+ # @return [Integer] Number of SVG document entries
85
+ attr_reader :num_entries
86
+
87
+ # @return [Array<SvgDocumentRecord>] Parsed document records
88
+ attr_reader :document_records
89
+
90
+ # @return [String] Raw binary data for the entire SVG table
91
+ attr_reader :raw_data
92
+
93
+ # Override read to parse SVG structure
94
+ #
95
+ # @param io [IO, String] Binary data to read
96
+ # @return [Svg] Parsed SVG table
97
+ def self.read(io)
98
+ svg = new
99
+ return svg if io.nil?
100
+
101
+ data = io.is_a?(String) ? io : io.read
102
+ svg.parse!(data)
103
+ svg
104
+ end
105
+
106
+ # Parse the SVG table structure
107
+ #
108
+ # @param data [String] Binary data for the SVG table
109
+ # @raise [CorruptedTableError] If SVG structure is invalid
110
+ def parse!(data)
111
+ @raw_data = data
112
+ io = StringIO.new(data)
113
+
114
+ # Parse SVG header (10 bytes)
115
+ parse_header(io)
116
+ validate_header!
117
+
118
+ # Parse document index
119
+ parse_document_index(io)
120
+ rescue StandardError => e
121
+ raise CorruptedTableError, "Failed to parse SVG table: #{e.message}"
122
+ end
123
+
124
+ # Get SVG document for a specific glyph ID
125
+ #
126
+ # Returns the SVG XML content for the specified glyph.
127
+ # Automatically decompresses gzipped content.
128
+ # Returns nil if glyph has no SVG data.
129
+ #
130
+ # @param glyph_id [Integer] Glyph ID to look up
131
+ # @return [String, nil] SVG XML content or nil
132
+ def svg_for_glyph(glyph_id)
133
+ record = find_document_record(glyph_id)
134
+ return nil unless record
135
+
136
+ extract_svg_document(record)
137
+ end
138
+
139
+ # Check if glyph has SVG data
140
+ #
141
+ # @param glyph_id [Integer] Glyph ID to check
142
+ # @return [Boolean] True if glyph has SVG
143
+ def has_svg_for_glyph?(glyph_id)
144
+ !find_document_record(glyph_id).nil?
145
+ end
146
+
147
+ # Get all glyph IDs that have SVG data
148
+ #
149
+ # @return [Array<Integer>] Array of glyph IDs with SVG
150
+ def glyph_ids_with_svg
151
+ document_records.flat_map do |record|
152
+ record.glyph_range.to_a
153
+ end
154
+ end
155
+
156
+ # Get the number of SVG documents in this table
157
+ #
158
+ # @return [Integer] Number of SVG documents
159
+ def num_svg_documents
160
+ num_entries
161
+ end
162
+
163
+ # Validate the SVG table structure
164
+ #
165
+ # @return [Boolean] True if valid
166
+ def valid?
167
+ return false if version.nil?
168
+ return false if version != 0 # Only version 0 supported
169
+ return false if num_entries.nil? || num_entries.negative?
170
+ return false unless document_records
171
+
172
+ true
173
+ end
174
+
175
+ private
176
+
177
+ # Parse SVG header (10 bytes)
178
+ #
179
+ # @param io [StringIO] Input stream
180
+ def parse_header(io)
181
+ @version = io.read(2).unpack1("n")
182
+ @svg_document_list_offset = io.read(4).unpack1("N")
183
+ @reserved = io.read(4).unpack1("N")
184
+ end
185
+
186
+ # Validate header values
187
+ #
188
+ # @raise [CorruptedTableError] If validation fails
189
+ def validate_header!
190
+ unless version.zero?
191
+ raise CorruptedTableError,
192
+ "Unsupported SVG version: #{version} (only version 0 supported)"
193
+ end
194
+
195
+ if svg_document_list_offset > raw_data.length
196
+ raise CorruptedTableError,
197
+ "Invalid svgDocumentListOffset: #{svg_document_list_offset}"
198
+ end
199
+ end
200
+
201
+ # Parse document index
202
+ #
203
+ # @param io [StringIO] Input stream
204
+ def parse_document_index(io)
205
+ # Seek to document index
206
+ io.seek(svg_document_list_offset)
207
+
208
+ # Check if there's enough data to read num_entries
209
+ return if io.eof?
210
+
211
+ # Parse number of entries
212
+ num_entries_data = io.read(2)
213
+ return if num_entries_data.nil? || num_entries_data.length < 2
214
+
215
+ @num_entries = num_entries_data.unpack1("n")
216
+ @document_records = []
217
+
218
+ return if num_entries.zero?
219
+
220
+ # Parse each document record (12 bytes each)
221
+ num_entries.times do
222
+ record_data = io.read(12)
223
+ record = SvgDocumentRecord.read(record_data)
224
+ @document_records << record
225
+ end
226
+ end
227
+
228
+ # Find document record for a specific glyph ID
229
+ #
230
+ # Uses binary search since document records should be sorted by glyph ID.
231
+ #
232
+ # @param glyph_id [Integer] Glyph ID to find
233
+ # @return [SvgDocumentRecord, nil] Document record or nil if not found
234
+ def find_document_record(glyph_id)
235
+ # Binary search through document records
236
+ left = 0
237
+ right = document_records.length - 1
238
+
239
+ while left <= right
240
+ mid = (left + right) / 2
241
+ record = document_records[mid]
242
+
243
+ if record.includes_glyph?(glyph_id)
244
+ return record
245
+ elsif glyph_id < record.start_glyph_id
246
+ right = mid - 1
247
+ else
248
+ left = mid + 1
249
+ end
250
+ end
251
+
252
+ nil
253
+ end
254
+
255
+ # Extract SVG document from record
256
+ #
257
+ # Calculates absolute offset and extracts SVG data.
258
+ # Automatically decompresses gzipped content.
259
+ #
260
+ # @param record [SvgDocumentRecord] Document record
261
+ # @return [String] SVG XML content
262
+ def extract_svg_document(record)
263
+ # Calculate absolute offset
264
+ # Offset is relative to start of SVG Document List
265
+ # Document List = numEntries (2 bytes) + entries array + documents
266
+ documents_offset = svg_document_list_offset + 2 + (num_entries * 12)
267
+ absolute_offset = documents_offset + record.svg_doc_offset
268
+
269
+ # Extract SVG data
270
+ svg_data = raw_data[absolute_offset, record.svg_doc_length]
271
+
272
+ # Check if compressed (gzip magic bytes: 0x1f 0x8b)
273
+ if gzipped?(svg_data)
274
+ decompress_gzip(svg_data)
275
+ else
276
+ svg_data
277
+ end
278
+ end
279
+
280
+ # Check if data is gzipped
281
+ #
282
+ # @param data [String] Binary data
283
+ # @return [Boolean] True if gzipped
284
+ def gzipped?(data)
285
+ return false if data.nil? || data.length < 2
286
+
287
+ data[0..1].unpack("C*") == [0x1f, 0x8b]
288
+ end
289
+
290
+ # Decompress gzipped data
291
+ #
292
+ # @param data [String] Gzipped binary data
293
+ # @return [String] Decompressed data
294
+ def decompress_gzip(data)
295
+ Zlib::GzipReader.new(StringIO.new(data)).read
296
+ rescue Zlib::Error => e
297
+ raise CorruptedTableError, "Failed to decompress SVG data: #{e.message}"
298
+ end
299
+ end
300
+ end
301
+ end
@@ -1,14 +1,12 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "bindata"
4
- require_relative "constants"
3
+ require_relative "base_collection"
5
4
 
6
5
  module Fontisan
7
- # TrueType Collection domain object using BinData
6
+ # TrueType Collection domain object
8
7
  #
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.
8
+ # Represents a complete TrueType Collection file. Inherits all shared
9
+ # functionality from BaseCollection and implements TTC-specific behavior.
12
10
  #
13
11
  # @example Reading and extracting fonts
14
12
  # File.open("Helvetica.ttc", "rb") do |io|
@@ -16,51 +14,25 @@ module Fontisan
16
14
  # puts ttc.num_fonts # => 6
17
15
  # fonts = ttc.extract_fonts(io) # => [TrueTypeFont, TrueTypeFont, ...]
18
16
  # 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
17
+ class TrueTypeCollection < BaseCollection
18
+ # Get the font class for TrueType collections
29
19
  #
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}"
20
+ # @return [Class] TrueTypeFont class
21
+ def self.font_class
22
+ require_relative "true_type_font"
23
+ TrueTypeFont
47
24
  end
48
25
 
49
- # Extract fonts as TrueTypeFont objects
26
+ # Get the collection format identifier
50
27
  #
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
28
+ # @return [String] "TTC" for TrueType Collection
29
+ def self.collection_format
30
+ "TTC"
61
31
  end
62
32
 
63
- # Get a single font from the collection (Fontisan extension)
33
+ # Get a single font from the collection
34
+ #
35
+ # Overrides BaseCollection to use TrueType-specific from_ttc method.
64
36
  #
65
37
  # @param index [Integer] Index of the font (0-based)
66
38
  # @param io [IO] Open file handle
@@ -73,43 +45,27 @@ module Fontisan
73
45
  TrueTypeFont.from_ttc(io, font_offsets[index], mode: mode)
74
46
  end
75
47
 
76
- # Get font count (Fontisan extension)
48
+ # Extract fonts as TrueTypeFont objects
77
49
  #
78
- # @return [Integer] Number of fonts in collection
79
- def font_count
80
- num_fonts
81
- end
82
-
83
- # Validate format correctness
50
+ # Reads each font from the TTC file and returns them as TrueTypeFont objects.
51
+ # This method uses the TTC-specific from_ttc method.
84
52
  #
85
- # @return [Boolean] true if the format is valid, false otherwise
86
- def valid?
87
- tag == Constants::TTC_TAG && num_fonts.positive? && font_offsets.length == num_fonts
88
- rescue StandardError
89
- false
90
- end
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"
91
57
 
92
- # Get the TTC version as a single integer
93
- #
94
- # @return [Integer] Version number (e.g., 0x00010000 for version 1.0)
95
- def version
96
- (major_version << 16) | minor_version
58
+ font_offsets.map do |offset|
59
+ TrueTypeFont.from_ttc(io, offset)
60
+ end
97
61
  end
98
62
 
99
63
  # List all fonts in the collection with basic metadata
100
64
  #
101
- # Returns a CollectionListInfo model containing summaries of all fonts.
102
- # This is the API method used by the `ls` command for collections.
65
+ # Overrides BaseCollection to use TrueType-specific from_ttc method.
103
66
  #
104
67
  # @param io [IO] Open file handle to read fonts from
105
68
  # @return [CollectionListInfo] List of fonts with metadata
106
- #
107
- # @example List fonts in collection
108
- # File.open("fonts.ttc", "rb") do |io|
109
- # ttc = TrueTypeCollection.read(io)
110
- # list = ttc.list_fonts(io)
111
- # list.fonts.each { |f| puts "#{f.index}: #{f.family_name}" }
112
- # end
113
69
  def list_fonts(io)
114
70
  require_relative "models/collection_list_info"
115
71
  require_relative "models/collection_font_summary"
@@ -159,51 +115,11 @@ module Fontisan
159
115
  )
160
116
  end
161
117
 
162
- # Get comprehensive collection metadata
163
- #
164
- # Returns a CollectionInfo model with header information, offsets,
165
- # and table sharing statistics.
166
- # This is the API method used by the `info` command for collections.
167
- #
168
- # @param io [IO] Open file handle to read fonts from
169
- # @param path [String] Collection file path (for file size)
170
- # @return [CollectionInfo] Collection metadata
171
- #
172
- # @example Get collection info
173
- # File.open("fonts.ttc", "rb") do |io|
174
- # ttc = TrueTypeCollection.read(io)
175
- # info = ttc.collection_info(io, "fonts.ttc")
176
- # puts "Version: #{info.version_string}"
177
- # end
178
- def collection_info(io, path)
179
- require_relative "models/collection_info"
180
- require_relative "models/table_sharing_info"
181
-
182
- # Calculate table sharing statistics
183
- table_sharing = calculate_table_sharing(io)
184
-
185
- # Get file size
186
- file_size = path ? File.size(path) : 0
187
-
188
- Models::CollectionInfo.new(
189
- collection_path: path,
190
- collection_format: "TTC",
191
- ttc_tag: tag,
192
- major_version: major_version,
193
- minor_version: minor_version,
194
- num_fonts: num_fonts,
195
- font_offsets: font_offsets.to_a,
196
- file_size_bytes: file_size,
197
- table_sharing: table_sharing,
198
- )
199
- end
200
-
201
118
  private
202
119
 
203
120
  # Calculate table sharing statistics
204
121
  #
205
- # Analyzes which tables are shared between fonts and calculates
206
- # space savings from deduplication.
122
+ # Overrides BaseCollection to use TrueType-specific from_ttc method.
207
123
  #
208
124
  # @param io [IO] Open file handle
209
125
  # @return [TableSharingInfo] Sharing statistics
@@ -532,6 +532,12 @@ module Fontisan
532
532
  Constants::GPOS_TAG => Tables::Gpos,
533
533
  Constants::GLYF_TAG => Tables::Glyf,
534
534
  Constants::LOCA_TAG => Tables::Loca,
535
+ "SVG " => Tables::Svg,
536
+ "COLR" => Tables::Colr,
537
+ "CPAL" => Tables::Cpal,
538
+ "CBDT" => Tables::Cbdt,
539
+ "CBLC" => Tables::Cblc,
540
+ "sbix" => Tables::Sbix,
535
541
  }[tag]
536
542
  end
537
543