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
@@ -6,7 +6,9 @@ require_relative "../woff2/directory"
6
6
  require_relative "../woff2/table_transformer"
7
7
  require_relative "../utilities/brotli_wrapper"
8
8
  require_relative "../utilities/checksum_calculator"
9
+ require_relative "../validation/woff2_validator"
9
10
  require "yaml"
11
+ require "stringio"
10
12
 
11
13
  module Fontisan
12
14
  module Converters
@@ -24,6 +26,7 @@ module Fontisan
24
26
  # 5. Compress all tables with single Brotli stream
25
27
  # 6. Build WOFF2 header and table directory
26
28
  # 7. Assemble complete WOFF2 binary
29
+ # 8. (Optional) Validate encoded WOFF2
27
30
  #
28
31
  # For Phase 2 Milestone 2.1:
29
32
  # - Basic WOFF2 structure generation
@@ -33,8 +36,13 @@ module Fontisan
33
36
  #
34
37
  # @example Convert TTF to WOFF2
35
38
  # encoder = Woff2Encoder.new
36
- # woff2_binary = encoder.convert(font)
37
- # File.binwrite('font.woff2', woff2_binary)
39
+ # result = encoder.convert(font)
40
+ # File.binwrite('font.woff2', result[:woff2_binary])
41
+ #
42
+ # @example Convert with validation
43
+ # encoder = Woff2Encoder.new
44
+ # result = encoder.convert(font, validate: true)
45
+ # puts result[:validation_report].text_summary if result[:validation_report]
38
46
  class Woff2Encoder
39
47
  include ConversionStrategy
40
48
 
@@ -57,7 +65,9 @@ module Fontisan
57
65
  # @param options [Hash] Conversion options
58
66
  # @option options [Integer] :quality Brotli quality (0-11)
59
67
  # @option options [Boolean] :transform_tables Apply table transformations
60
- # @return [Hash] Hash with :woff2_binary key containing WOFF2 binary
68
+ # @option options [Boolean] :validate Run validation after encoding
69
+ # @option options [Symbol] :validation_level Validation level (:strict, :standard, :lenient)
70
+ # @return [Hash] Hash with :woff2_binary and optional :validation_report keys
61
71
  # @raise [Error] If encoding fails
62
72
  def convert(font, options = {})
63
73
  validate(font, :woff2)
@@ -97,8 +107,16 @@ module Fontisan
97
107
  # Assemble WOFF2 binary
98
108
  woff2_binary = assemble_woff2(header, entries, compressed_data)
99
109
 
100
- # Return in special format for ConvertCommand to handle
101
- { woff2_binary: woff2_binary }
110
+ # Prepare result
111
+ result = { woff2_binary: woff2_binary }
112
+
113
+ # Optional validation
114
+ if options[:validate]
115
+ validation_report = validate_encoding(woff2_binary, options)
116
+ result[:validation_report] = validation_report
117
+ end
118
+
119
+ result
102
120
  end
103
121
 
104
122
  # Get list of supported conversions
@@ -144,6 +162,67 @@ module Fontisan
144
162
 
145
163
  private
146
164
 
165
+ # Validate encoded WOFF2 binary
166
+ #
167
+ # @param woff2_binary [String] Encoded WOFF2 data
168
+ # @param options [Hash] Validation options
169
+ # @return [Models::ValidationReport] Validation report
170
+ def validate_encoding(woff2_binary, options)
171
+ # Load the encoded WOFF2 from memory
172
+ io = StringIO.new(woff2_binary)
173
+ woff2_font = Woff2Font.from_file_io(io, "encoded.woff2")
174
+
175
+ # Run validation
176
+ validation_level = options[:validation_level] || :standard
177
+ validator = Validation::Woff2Validator.new(level: validation_level)
178
+ validator.validate(woff2_font, "encoded.woff2")
179
+ rescue StandardError => e
180
+ # If validation fails, create a report with the error
181
+ report = Models::ValidationReport.new(
182
+ font_path: "encoded.woff2",
183
+ valid: false,
184
+ )
185
+ report.add_error("woff2_validation", "Validation failed: #{e.message}", nil)
186
+ report
187
+ end
188
+
189
+ # Helper method to load WOFF2 from StringIO
190
+ #
191
+ # This is added to Woff2Font to support in-memory validation
192
+ module Woff2FontMemoryLoader
193
+ def self.from_file_io(io, path_for_report)
194
+ io.rewind
195
+
196
+ woff2 = Woff2Font.new
197
+ woff2.io_source = Woff2Font::IOSource.new(path_for_report)
198
+
199
+ # Read header
200
+ woff2.header = Woff2::Woff2Header.read(io)
201
+
202
+ # Validate signature
203
+ unless woff2.header.signature == Woff2::Woff2Header::SIGNATURE
204
+ raise InvalidFontError,
205
+ "Invalid WOFF2 signature: expected 0x#{Woff2::Woff2Header::SIGNATURE.to_s(16)}, " \
206
+ "got 0x#{woff2.header.signature.to_i.to_s(16)}"
207
+ end
208
+
209
+ # Read table directory
210
+ woff2.table_entries = Woff2Font.read_table_directory_from_io(io, woff2.header)
211
+
212
+ # Decompress tables
213
+ woff2.decompressed_tables = Woff2Font.decompress_tables(io, woff2.header,
214
+ woff2.table_entries)
215
+
216
+ # Apply transformations
217
+ Woff2Font.apply_transformations!(woff2.table_entries, woff2.decompressed_tables)
218
+
219
+ woff2
220
+ end
221
+ end
222
+
223
+ # Extend Woff2Font with in-memory loading
224
+ Woff2Font.singleton_class.prepend(Woff2FontMemoryLoader)
225
+
147
226
  # Load configuration from YAML file
148
227
  #
149
228
  # @param path [String, nil] Path to config file
@@ -183,9 +262,9 @@ module Fontisan
183
262
  "mode" => "font",
184
263
  },
185
264
  "transformations" => {
186
- "enabled" => false, # Disabled for Milestone 2.1
187
- "glyf_loca" => false,
188
- "hmtx" => false,
265
+ "enabled" => true, # Enable transformations for better compression
266
+ "glyf_loca" => true,
267
+ "hmtx" => true,
189
268
  },
190
269
  "metadata" => {
191
270
  "include" => false,
@@ -255,11 +334,17 @@ module Fontisan
255
334
  # @return [Array<Woff2::Directory::Entry>] Table entries
256
335
  def build_table_entries(table_data, transformer, transform_enabled)
257
336
  entries = []
337
+ transformed_data = {}
258
338
 
259
339
  # Sort tables by tag for consistent output
260
340
  sorted_tags = table_data.keys.sort
261
341
 
262
342
  sorted_tags.each do |tag|
343
+ # Skip loca if we're transforming glyf (loca is combined with glyf)
344
+ if tag == "loca" && transform_enabled && transformer.transformable?("glyf")
345
+ next
346
+ end
347
+
263
348
  entry = Woff2::Directory::Entry.new
264
349
  entry.tag = tag
265
350
 
@@ -270,8 +355,10 @@ module Fontisan
270
355
  # Apply transformation if enabled and supported
271
356
  if transform_enabled && transformer.transformable?(tag)
272
357
  transformed = transformer.transform_table(tag)
273
- if transformed && transformed.bytesize < data.bytesize
358
+ if transformed&.bytesize&.positive? && transformed.bytesize < data.bytesize
359
+ # Transformation successful and reduces size
274
360
  entry.transform_length = transformed.bytesize
361
+ transformed_data[tag] = transformed
275
362
  end
276
363
  end
277
364
 
@@ -281,6 +368,9 @@ module Fontisan
281
368
  entries << entry
282
369
  end
283
370
 
371
+ # Store transformed data for compression
372
+ @transformed_data = transformed_data
373
+
284
374
  entries
285
375
  end
286
376
 
@@ -295,12 +385,15 @@ module Fontisan
295
385
  combined_data = String.new(encoding: Encoding::BINARY)
296
386
 
297
387
  entries.each do |entry|
298
- # Get table data
299
- data = table_data[entry.tag]
388
+ # Use transformed data if available, otherwise use original
389
+ data = if @transformed_data && @transformed_data[entry.tag]
390
+ @transformed_data[entry.tag]
391
+ else
392
+ table_data[entry.tag]
393
+ end
394
+
300
395
  next unless data
301
396
 
302
- # For this milestone, we don't have transformed data yet
303
- # Use original table data
304
397
  combined_data << data
305
398
  end
306
399
 
@@ -102,6 +102,37 @@ module Fontisan
102
102
  # without extracting individual fonts. Useful for inspecting collection
103
103
  # metadata and structure.
104
104
  #
105
+ # = Collection Format Understanding
106
+ #
107
+ # Both TTC (TrueType Collection) and OTC (OpenType Collection) files use
108
+ # the same "ttcf" signature. The distinction between TTC and OTC is NOT
109
+ # in the collection format itself, but in the fonts contained within:
110
+ #
111
+ # - TTC typically contains TrueType fonts (glyf outlines)
112
+ # - OTC typically contains OpenType fonts (CFF/CFF2 outlines)
113
+ # - Mixed collections are possible (both TTF and OTF in same collection)
114
+ #
115
+ # Each collection can contain multiple SFNT-format font files, with table
116
+ # deduplication to save space. Individual fonts within a collection are
117
+ # stored at different offsets within the file, each with their own table
118
+ # directory and data tables.
119
+ #
120
+ # = Detection Strategy
121
+ #
122
+ # This method scans ALL fonts in the collection to determine the collection
123
+ # type accurately:
124
+ #
125
+ # 1. Reads all font offsets from the collection header
126
+ # 2. Examines the sfnt_version of each font in the collection
127
+ # 3. Counts TrueType fonts (0x00010000 or 0x74727565 "true") vs OpenType fonts (0x4F54544F "OTTO")
128
+ # 4. If ANY font is OpenType (CFF), returns OpenTypeCollection
129
+ # 5. Only returns TrueTypeCollection if ALL fonts are TrueType
130
+ #
131
+ # This approach correctly handles:
132
+ # - Homogeneous collections (all TTF or all OTF)
133
+ # - Mixed collections (both TTF and OTF fonts) - uses OpenTypeCollection
134
+ # - Large collections with many fonts (like NotoSerifCJK.ttc with 35 fonts)
135
+ #
105
136
  # @param path [String] Path to the collection file
106
137
  # @return [TrueTypeCollection, OpenTypeCollection] The collection object
107
138
  # @raise [Errno::ENOENT] if file does not exist
@@ -121,23 +152,43 @@ module Fontisan
121
152
  "File is not a collection (TTC/OTC). Use FontLoader.load instead."
122
153
  end
123
154
 
124
- # Read first font offset to detect collection type
125
- io.seek(12) # Skip tag (4) + versions (4) + num_fonts (4)
126
- first_offset = io.read(4).unpack1("N")
155
+ # Read version and num_fonts
156
+ io.seek(8) # Skip tag (4) + version (4)
157
+ num_fonts = io.read(4).unpack1("N")
158
+
159
+ # Read all font offsets
160
+ font_offsets = num_fonts.times.map { io.read(4).unpack1("N") }
161
+
162
+ # Scan all fonts to determine collection type (not just first)
163
+ truetype_count = 0
164
+ opentype_count = 0
165
+
166
+ font_offsets.each do |offset|
167
+ io.rewind
168
+ io.seek(offset)
169
+ sfnt_version = io.read(4).unpack1("N")
170
+
171
+ case sfnt_version
172
+ when Constants::SFNT_VERSION_TRUETYPE, 0x74727565 # 0x74727565 = 'true'
173
+ truetype_count += 1
174
+ when Constants::SFNT_VERSION_OTTO
175
+ opentype_count += 1
176
+ else
177
+ raise InvalidFontError,
178
+ "Unknown font type in collection at offset #{offset} (sfnt version: 0x#{sfnt_version.to_s(16)})"
179
+ end
180
+ end
127
181
 
128
- # Peek at first font's sfnt_version
129
- io.seek(first_offset)
130
- sfnt_version = io.read(4).unpack1("N")
131
182
  io.rewind
132
183
 
133
- case sfnt_version
134
- when Constants::SFNT_VERSION_TRUETYPE
135
- TrueTypeCollection.from_file(path)
136
- when Constants::SFNT_VERSION_OTTO
184
+ # Determine collection type based on what fonts are inside
185
+ # If ANY font is OpenType, use OpenTypeCollection (more general format)
186
+ # Only use TrueTypeCollection if ALL fonts are TrueType
187
+ if opentype_count > 0
137
188
  OpenTypeCollection.from_file(path)
138
189
  else
139
- raise InvalidFontError,
140
- "Unknown font type in collection (sfnt version: 0x#{sfnt_version.to_s(16)})"
190
+ # All fonts are TrueType
191
+ TrueTypeCollection.from_file(path)
141
192
  end
142
193
  end
143
194
  end
@@ -167,6 +218,23 @@ module Fontisan
167
218
 
168
219
  # Load from a collection file (TTC or OTC)
169
220
  #
221
+ # This is the internal method that handles loading individual fonts from
222
+ # collection files. It reads the collection header to determine the type
223
+ # (TTC vs OTC) and extracts the requested font.
224
+ #
225
+ # = Collection Header Structure
226
+ #
227
+ # TTC/OTC files start with:
228
+ # - Bytes 0-3: "ttcf" tag (4 bytes)
229
+ # - Bytes 4-7: version (2 bytes major + 2 bytes minor)
230
+ # - Bytes 8-11: num_fonts (4 bytes, big-endian uint32)
231
+ # - Bytes 12+: font offset array (4 bytes per font, big-endian uint32)
232
+ #
233
+ # CRITICAL: The method seeks to position 8 (after tag and version) to read
234
+ # num_fonts, NOT position 12 which is where the offset array starts. This
235
+ # was a bug that caused "Unknown font type" errors when the first offset
236
+ # was misread as num_fonts.
237
+ #
170
238
  # @param io [IO] Open file handle
171
239
  # @param path [String] Path to the collection file
172
240
  # @param font_index [Integer] Index of font to extract
@@ -177,7 +245,7 @@ module Fontisan
177
245
  def self.load_from_collection(io, path, font_index,
178
246
  mode: LoadingModes::FULL, lazy: true)
179
247
  # Read collection header to get font offsets
180
- io.seek(12) # Skip tag (4) + major_version (2) + minor_version (2) + num_fonts marker (4)
248
+ io.seek(8) # Skip tag (4) + version (4)
181
249
  num_fonts = io.read(4).unpack1("N")
182
250
 
183
251
  if font_index >= num_fonts
@@ -185,26 +253,41 @@ mode: LoadingModes::FULL, lazy: true)
185
253
  "Font index #{font_index} out of range (collection has #{num_fonts} fonts)"
186
254
  end
187
255
 
188
- # Read first offset to detect collection type
189
- first_offset = io.read(4).unpack1("N")
256
+ # Read all font offsets
257
+ font_offsets = num_fonts.times.map { io.read(4).unpack1("N") }
258
+
259
+ # Scan all fonts to determine collection type (not just first)
260
+ truetype_count = 0
261
+ opentype_count = 0
262
+
263
+ font_offsets.each do |offset|
264
+ io.rewind
265
+ io.seek(offset)
266
+ sfnt_version = io.read(4).unpack1("N")
267
+
268
+ case sfnt_version
269
+ when Constants::SFNT_VERSION_TRUETYPE, 0x74727565 # 0x74727565 = 'true'
270
+ truetype_count += 1
271
+ when Constants::SFNT_VERSION_OTTO
272
+ opentype_count += 1
273
+ else
274
+ raise InvalidFontError,
275
+ "Unknown font type in collection at offset #{offset} (sfnt version: 0x#{sfnt_version.to_s(16)})"
276
+ end
277
+ end
190
278
 
191
- # Peek at first font's sfnt_version to determine TTC vs OTC
192
- io.seek(first_offset)
193
- sfnt_version = io.read(4).unpack1("N")
194
279
  io.rewind
195
280
 
196
- case sfnt_version
197
- when Constants::SFNT_VERSION_TRUETYPE
198
- # TrueType Collection
199
- ttc = TrueTypeCollection.from_file(path)
200
- File.open(path, "rb") { |f| ttc.font(font_index, f, mode: mode) }
201
- when Constants::SFNT_VERSION_OTTO
281
+ # If ANY font is OpenType, use OpenTypeCollection (more general format)
282
+ # Only use TrueTypeCollection if ALL fonts are TrueType
283
+ if opentype_count > 0
202
284
  # OpenType Collection
203
285
  otc = OpenTypeCollection.from_file(path)
204
286
  File.open(path, "rb") { |f| otc.font(font_index, f, mode: mode) }
205
287
  else
206
- raise InvalidFontError,
207
- "Unknown font type in collection (sfnt version: 0x#{sfnt_version.to_s(16)})"
288
+ # TrueType Collection (all fonts are TrueType)
289
+ ttc = TrueTypeCollection.from_file(path)
290
+ File.open(path, "rb") { |f| ttc.font(font_index, f, mode: mode) }
208
291
  end
209
292
  end
210
293
 
@@ -364,20 +364,36 @@ module Fontisan
364
364
  def format_collection_info(info)
365
365
  lines = []
366
366
 
367
- # Header section
368
- lines << "=== Collection Information ==="
369
- lines << ""
370
- lines << "File: #{info.collection_path}"
371
- lines << "Format: #{info.collection_format}"
367
+ # Header section with type and version (like brief mode)
368
+ lines << "Collection: #{info.collection_path}"
369
+
370
+ # Format collection type for display with OpenType version
371
+ if info.collection_format
372
+ collection_type_display = case info.collection_format
373
+ when "TTC"
374
+ "TrueType Collection (OpenType 1.4)"
375
+ when "OTC"
376
+ "OpenType Collection (OpenType 1.8)"
377
+ else
378
+ info.collection_format
379
+ end
380
+ lines << "Type: #{collection_type_display}"
381
+ end
382
+
383
+ lines << "Version: #{info.version_string}"
372
384
  lines << "Size: #{format_bytes(info.file_size_bytes)}"
385
+ lines << "Fonts: #{info.num_fonts}"
373
386
  lines << ""
374
387
 
375
- # Header details
376
- lines << "=== Header ==="
377
- lines << "Tag: #{info.ttc_tag}"
378
- lines << "Version: #{info.version_string} (#{info.version_hex})"
379
- lines << "Number of fonts: #{info.num_fonts}"
380
- lines << ""
388
+ # Table sharing statistics
389
+ if info.table_sharing
390
+ lines << "=== Table Sharing ==="
391
+ lines << "Shared tables: #{info.table_sharing.shared_tables}"
392
+ lines << "Unique tables: #{info.table_sharing.unique_tables}"
393
+ lines << "Sharing: #{format_float(info.table_sharing.sharing_percentage)}%"
394
+ lines << "Space saved: #{format_bytes(info.table_sharing.space_saved_bytes)}"
395
+ lines << ""
396
+ end
381
397
 
382
398
  # Font offsets
383
399
  lines << "=== Font Offsets ==="
@@ -387,13 +403,35 @@ module Fontisan
387
403
  end
388
404
  lines << ""
389
405
 
390
- # Table sharing statistics
391
- if info.table_sharing
392
- lines << "=== Table Sharing ==="
393
- lines << "Shared tables: #{info.table_sharing.shared_tables}"
394
- lines << "Unique tables: #{info.table_sharing.unique_tables}"
395
- lines << "Sharing: #{format_float(info.table_sharing.sharing_percentage)}%"
396
- lines << "Space saved: #{format_bytes(info.table_sharing.space_saved_bytes)}"
406
+ # Individual font information (like brief mode)
407
+ if info.fonts && !info.fonts.empty?
408
+ lines << "=== Fonts ==="
409
+ lines << ""
410
+
411
+ info.fonts.each_with_index do |font_info, index|
412
+ # Show font index with offset
413
+ if font_info.collection_offset
414
+ lines << "Font #{index} (offset: #{font_info.collection_offset}):"
415
+ else
416
+ lines << "Font #{index}:"
417
+ end
418
+ lines << ""
419
+
420
+ # Format each font using same structure as brief mode
421
+ font_type_display = format_font_type_display(font_info.font_format, font_info.is_variable)
422
+ add_line(lines, "Font type", font_type_display)
423
+ add_line(lines, "Family", font_info.family_name)
424
+ add_line(lines, "Subfamily", font_info.subfamily_name)
425
+ add_line(lines, "Full name", font_info.full_name)
426
+ add_line(lines, "PostScript name", font_info.postscript_name)
427
+ add_line(lines, "Version", font_info.version)
428
+ add_line(lines, "Vendor ID", font_info.vendor_id)
429
+ add_line(lines, "Font revision", format_float(font_info.font_revision))
430
+ add_line(lines, "Units per em", font_info.units_per_em)
431
+
432
+ # Blank line between fonts (except after last)
433
+ lines << "" unless index == info.num_fonts - 1
434
+ end
397
435
  end
398
436
 
399
437
  lines.join("\n")
@@ -406,8 +444,23 @@ module Fontisan
406
444
  def format_collection_brief_info(info)
407
445
  lines = []
408
446
 
409
- # Collection header
447
+ # Collection header with type and version
410
448
  lines << "Collection: #{info.collection_path}"
449
+
450
+ # Format collection type for display with OpenType version
451
+ if info.collection_type
452
+ collection_type_display = case info.collection_type
453
+ when "TTC"
454
+ "TrueType Collection (OpenType 1.4)"
455
+ when "OTC"
456
+ "OpenType Collection (OpenType 1.8)"
457
+ else
458
+ info.collection_type
459
+ end
460
+ lines << "Type: #{collection_type_display}"
461
+ end
462
+
463
+ lines << "Version: #{info.collection_version}" if info.collection_version
411
464
  lines << "Fonts: #{info.num_fonts}"
412
465
  lines << ""
413
466
 
@@ -0,0 +1,123 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "lutaml/model"
4
+
5
+ module Fontisan
6
+ module Models
7
+ # Bitmap glyph representation model
8
+ #
9
+ # Represents a bitmap glyph from the CBDT/CBLC tables. Each glyph contains
10
+ # bitmap image data at a specific ppem size.
11
+ #
12
+ # This model uses lutaml-model for structured serialization to YAML/JSON/XML.
13
+ #
14
+ # @example Creating a bitmap glyph
15
+ # glyph = BitmapGlyph.new
16
+ # glyph.glyph_id = 42
17
+ # glyph.ppem = 16
18
+ # glyph.format = "PNG"
19
+ # glyph.width = 16
20
+ # glyph.height = 16
21
+ # glyph.data_size = 256
22
+ #
23
+ # @example Serializing to JSON
24
+ # json = glyph.to_json
25
+ # # {
26
+ # # "glyph_id": 42,
27
+ # # "ppem": 16,
28
+ # # "format": "PNG",
29
+ # # "width": 16,
30
+ # # "height": 16,
31
+ # # "data_size": 256
32
+ # # }
33
+ class BitmapGlyph < Lutaml::Model::Serializable
34
+ # @!attribute glyph_id
35
+ # @return [Integer] Glyph ID
36
+ attribute :glyph_id, :integer
37
+
38
+ # @!attribute ppem
39
+ # @return [Integer] Pixels per em for this bitmap
40
+ attribute :ppem, :integer
41
+
42
+ # @!attribute format
43
+ # @return [String] Bitmap format (e.g., "PNG", "JPEG", "TIFF")
44
+ attribute :format, :string
45
+
46
+ # @!attribute width
47
+ # @return [Integer] Bitmap width in pixels
48
+ attribute :width, :integer
49
+
50
+ # @!attribute height
51
+ # @return [Integer] Bitmap height in pixels
52
+ attribute :height, :integer
53
+
54
+ # @!attribute bit_depth
55
+ # @return [Integer] Bit depth (1, 2, 4, 8, 32)
56
+ attribute :bit_depth, :integer
57
+
58
+ # @!attribute data_size
59
+ # @return [Integer] Size of bitmap data in bytes
60
+ attribute :data_size, :integer
61
+
62
+ # @!attribute data_offset
63
+ # @return [Integer] Offset to bitmap data in CBDT table
64
+ attribute :data_offset, :integer
65
+
66
+ # Check if this is a PNG bitmap
67
+ #
68
+ # @return [Boolean] True if format is PNG
69
+ def png?
70
+ format&.upcase == "PNG"
71
+ end
72
+
73
+ # Check if this is a JPEG bitmap
74
+ #
75
+ # @return [Boolean] True if format is JPEG
76
+ def jpeg?
77
+ format&.upcase == "JPEG"
78
+ end
79
+
80
+ # Check if this is a TIFF bitmap
81
+ #
82
+ # @return [Boolean] True if format is TIFF
83
+ def tiff?
84
+ format&.upcase == "TIFF"
85
+ end
86
+
87
+ # Check if this is a color bitmap (32-bit)
88
+ #
89
+ # @return [Boolean] True if 32-bit color
90
+ def color?
91
+ bit_depth == 32
92
+ end
93
+
94
+ # Check if this is a monochrome bitmap (1-bit)
95
+ #
96
+ # @return [Boolean] True if 1-bit monochrome
97
+ def monochrome?
98
+ bit_depth == 1
99
+ end
100
+
101
+ # Get the color depth description
102
+ #
103
+ # @return [String] Human-readable color depth
104
+ def color_depth
105
+ case bit_depth
106
+ when 1 then "1-bit (monochrome)"
107
+ when 2 then "2-bit (4 colors)"
108
+ when 4 then "4-bit (16 colors)"
109
+ when 8 then "8-bit (256 colors)"
110
+ when 32 then "32-bit (full color with alpha)"
111
+ else "#{bit_depth}-bit"
112
+ end
113
+ end
114
+
115
+ # Get bitmap dimensions as string
116
+ #
117
+ # @return [String] Dimensions in "WxH" format
118
+ def dimensions
119
+ "#{width}x#{height}"
120
+ end
121
+ end
122
+ end
123
+ end