fontisan 0.2.3 → 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 (43) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop_todo.yml +92 -40
  3. data/README.adoc +262 -3
  4. data/Rakefile +20 -7
  5. data/lib/fontisan/commands/base_command.rb +2 -19
  6. data/lib/fontisan/commands/convert_command.rb +16 -13
  7. data/lib/fontisan/commands/info_command.rb +88 -0
  8. data/lib/fontisan/config/conversion_matrix.yml +58 -20
  9. data/lib/fontisan/converters/outline_converter.rb +6 -3
  10. data/lib/fontisan/converters/svg_generator.rb +45 -0
  11. data/lib/fontisan/converters/woff2_encoder.rb +106 -13
  12. data/lib/fontisan/models/bitmap_glyph.rb +123 -0
  13. data/lib/fontisan/models/bitmap_strike.rb +94 -0
  14. data/lib/fontisan/models/color_glyph.rb +57 -0
  15. data/lib/fontisan/models/color_layer.rb +53 -0
  16. data/lib/fontisan/models/color_palette.rb +60 -0
  17. data/lib/fontisan/models/font_info.rb +26 -0
  18. data/lib/fontisan/models/svg_glyph.rb +89 -0
  19. data/lib/fontisan/open_type_font.rb +6 -0
  20. data/lib/fontisan/optimizers/charstring_rewriter.rb +19 -8
  21. data/lib/fontisan/optimizers/pattern_analyzer.rb +4 -2
  22. data/lib/fontisan/optimizers/subroutine_builder.rb +6 -5
  23. data/lib/fontisan/optimizers/subroutine_optimizer.rb +5 -2
  24. data/lib/fontisan/pipeline/output_writer.rb +2 -2
  25. data/lib/fontisan/tables/cbdt.rb +169 -0
  26. data/lib/fontisan/tables/cblc.rb +290 -0
  27. data/lib/fontisan/tables/cff.rb +6 -12
  28. data/lib/fontisan/tables/colr.rb +291 -0
  29. data/lib/fontisan/tables/cpal.rb +281 -0
  30. data/lib/fontisan/tables/glyf/glyph_builder.rb +5 -1
  31. data/lib/fontisan/tables/sbix.rb +379 -0
  32. data/lib/fontisan/tables/svg.rb +301 -0
  33. data/lib/fontisan/true_type_font.rb +6 -0
  34. data/lib/fontisan/validation/woff2_header_validator.rb +278 -0
  35. data/lib/fontisan/validation/woff2_table_validator.rb +270 -0
  36. data/lib/fontisan/validation/woff2_validator.rb +248 -0
  37. data/lib/fontisan/version.rb +1 -1
  38. data/lib/fontisan/woff2/directory.rb +40 -11
  39. data/lib/fontisan/woff2/table_transformer.rb +506 -73
  40. data/lib/fontisan/woff2_font.rb +29 -9
  41. data/lib/fontisan/woff_font.rb +17 -4
  42. data/lib/fontisan.rb +12 -0
  43. metadata +17 -2
@@ -58,30 +58,13 @@ module Fontisan
58
58
  # Load the font using FontLoader.
59
59
  #
60
60
  # Uses FontLoader for automatic format detection and loading.
61
- # Returns either TrueTypeFont or OpenTypeFont depending on file format.
61
+ # Returns TrueTypeFont, OpenTypeFont, WoffFont, or Woff2Font depending on file format.
62
62
  #
63
- # @return [TrueTypeFont, OpenTypeFont] The loaded font
63
+ # @return [TrueTypeFont, OpenTypeFont, WoffFont, Woff2Font] The loaded font
64
64
  # @raise [Errno::ENOENT] if file does not exist
65
- # @raise [UnsupportedFormatError] for WOFF/WOFF2 or other unsupported formats
66
65
  # @raise [InvalidFontError] for corrupted or unknown formats
67
66
  # @raise [Error] for other loading failures
68
67
  def load_font
69
- # BaseCommand is for inspection - reject compressed formats first
70
- # Check file signature before attempting to load
71
- File.open(@font_path, "rb") do |io|
72
- signature = io.read(4)
73
-
74
- if signature == "wOFF"
75
- raise UnsupportedFormatError,
76
- "Unsupported font format: WOFF files must be decompressed first. " \
77
- "Use ConvertCommand to convert WOFF to TTF/OTF."
78
- elsif signature == "wOF2"
79
- raise UnsupportedFormatError,
80
- "Unsupported font format: WOFF2 files must be decompressed first. " \
81
- "Use ConvertCommand to convert WOFF2 to TTF/OTF."
82
- end
83
- end
84
-
85
68
  # Brief mode uses metadata loading for 5x faster parsing
86
69
  mode = @options[:brief] ? LoadingModes::METADATA : (@options[:mode] || LoadingModes::FULL)
87
70
 
@@ -51,22 +51,26 @@ module Fontisan
51
51
  # @option options [Boolean] :verbose Verbose output
52
52
  def initialize(font_path, options = {})
53
53
  super(font_path, options)
54
- @output_path = options[:output]
54
+
55
+ # Convert string keys to symbols for Thor compatibility
56
+ opts = options.transform_keys(&:to_sym)
57
+
58
+ @output_path = opts[:output]
55
59
 
56
60
  # Parse target format
57
- @target_format = parse_target_format(options[:to])
61
+ @target_format = parse_target_format(opts[:to])
58
62
 
59
63
  # Parse coordinates if string provided
60
- @coordinates = if options[:coordinates]
61
- parse_coordinates(options[:coordinates])
62
- elsif options[:instance_coordinates]
63
- options[:instance_coordinates]
64
+ @coordinates = if opts[:coordinates]
65
+ parse_coordinates(opts[:coordinates])
66
+ elsif opts[:instance_coordinates]
67
+ opts[:instance_coordinates]
64
68
  end
65
69
 
66
- @instance_index = options[:instance_index]
67
- @preserve_variation = options[:preserve_variation]
68
- @preserve_hints = options.fetch(:preserve_hints, false)
69
- @validate = !options[:no_validate]
70
+ @instance_index = opts[:instance_index]
71
+ @preserve_variation = opts[:preserve_variation]
72
+ @preserve_hints = opts.fetch(:preserve_hints, false)
73
+ @validate = !opts[:no_validate]
70
74
  end
71
75
 
72
76
  # Execute the conversion
@@ -193,14 +197,13 @@ module Fontisan
193
197
  when "svg"
194
198
  :svg
195
199
  when "woff"
196
- raise ArgumentError,
197
- "WOFF format conversion is not supported yet. Use woff2 instead."
200
+ :woff
198
201
  when "woff2"
199
202
  :woff2
200
203
  else
201
204
  raise ArgumentError,
202
205
  "Unknown target format: #{format}. " \
203
- "Supported: ttf, otf, svg, woff2"
206
+ "Supported: ttf, otf, svg, woff, woff2"
204
207
  end
205
208
  end
206
209
 
@@ -202,6 +202,9 @@ module Fontisan
202
202
  populate_from_name_table(info) if font.has_table?(Constants::NAME_TAG)
203
203
  populate_from_os2_table(info) if font.has_table?(Constants::OS2_TAG)
204
204
  populate_from_head_table(info) if font.has_table?(Constants::HEAD_TAG)
205
+ populate_color_info(info) if font.has_table?("COLR") && font.has_table?("CPAL")
206
+ populate_svg_info(info) if font.has_table?("SVG ")
207
+ populate_bitmap_info(info) if font.has_table?("CBLC") || font.has_table?("sbix")
205
208
  end
206
209
 
207
210
  # Populate FontInfo from the name table.
@@ -255,6 +258,91 @@ module Fontisan
255
258
  info.units_per_em = head_table.units_per_em
256
259
  end
257
260
 
261
+ # Populate FontInfo with color font information from COLR/CPAL tables
262
+ #
263
+ # @param info [Models::FontInfo] FontInfo instance to populate
264
+ def populate_color_info(info)
265
+ colr_table = font.table("COLR")
266
+ cpal_table = font.table("CPAL")
267
+
268
+ return unless colr_table && cpal_table
269
+
270
+ info.is_color_font = true
271
+ info.color_glyphs = colr_table.num_color_glyphs
272
+ info.color_palettes = cpal_table.num_palettes
273
+ info.colors_per_palette = cpal_table.num_palette_entries
274
+ rescue StandardError => e
275
+ warn "Failed to populate color font info: #{e.message}"
276
+ info.is_color_font = false
277
+ end
278
+
279
+ # Populate FontInfo with SVG table information
280
+ #
281
+ # @param info [Models::FontInfo] FontInfo instance to populate
282
+ def populate_svg_info(info)
283
+ svg_table = font.table("SVG ")
284
+
285
+ return unless svg_table
286
+
287
+ info.has_svg_table = true
288
+ info.svg_glyph_count = svg_table.glyph_ids_with_svg.length
289
+ rescue StandardError => e
290
+ warn "Failed to populate SVG info: #{e.message}"
291
+ info.has_svg_table = false
292
+ end
293
+
294
+ # Populate FontInfo with bitmap table information (CBDT/CBLC, sbix)
295
+ #
296
+ # @param info [Models::FontInfo] FontInfo instance to populate
297
+ def populate_bitmap_info(info)
298
+ bitmap_strikes = []
299
+ ppem_sizes = []
300
+ formats = []
301
+
302
+ # Check for CBDT/CBLC (Google format)
303
+ if font.has_table?("CBLC") && font.has_table?("CBDT")
304
+ cblc = font.table("CBLC")
305
+ info.has_bitmap_glyphs = true
306
+ ppem_sizes.concat(cblc.ppem_sizes)
307
+
308
+ cblc.strikes.each do |strike_rec|
309
+ bitmap_strikes << Models::BitmapStrike.new(
310
+ ppem: strike_rec.ppem,
311
+ start_glyph_id: strike_rec.start_glyph_index,
312
+ end_glyph_id: strike_rec.end_glyph_index,
313
+ bit_depth: strike_rec.bit_depth,
314
+ num_glyphs: strike_rec.glyph_range.size
315
+ )
316
+ end
317
+ formats << "PNG" # CBDT typically contains PNG data
318
+ end
319
+
320
+ # Check for sbix (Apple format)
321
+ if font.has_table?("sbix")
322
+ sbix = font.table("sbix")
323
+ info.has_bitmap_glyphs = true
324
+ ppem_sizes.concat(sbix.ppem_sizes)
325
+ formats.concat(sbix.supported_formats)
326
+
327
+ sbix.strikes.each do |strike|
328
+ bitmap_strikes << Models::BitmapStrike.new(
329
+ ppem: strike[:ppem],
330
+ start_glyph_id: 0,
331
+ end_glyph_id: strike[:num_glyphs] - 1,
332
+ bit_depth: 32, # sbix is typically 32-bit
333
+ num_glyphs: strike[:num_glyphs]
334
+ )
335
+ end
336
+ end
337
+
338
+ info.bitmap_strikes = bitmap_strikes unless bitmap_strikes.empty?
339
+ info.bitmap_ppem_sizes = ppem_sizes.uniq.sort
340
+ info.bitmap_formats = formats.uniq
341
+ rescue StandardError => e
342
+ warn "Failed to populate bitmap info: #{e.message}"
343
+ info.has_bitmap_glyphs = false
344
+ end
345
+
258
346
  # Format OS/2 embedding permission flags into a human-readable string.
259
347
  #
260
348
  # @param flags [Integer] OS/2 fsType flags
@@ -78,26 +78,34 @@ conversions:
78
78
  are architectural placeholders for future optimization.
79
79
 
80
80
  # Phase 2 conversions (Milestone 2.3) - WOFF
81
- # NOTE: Temporarily disabled until WOFF writer bugs are fixed
82
- # - from: ttf
83
- # to: woff
84
- # strategy: woff_writer
85
- # description: "Compress TrueType to WOFF format with zlib"
86
- # status: implemented
87
- # notes: >
88
- # WOFF 1.0 encoding with zlib compression. Supports optional metadata
89
- # and private data blocks. Individual table compression for optimal
90
- # file size reduction.
91
-
92
- # - from: otf
93
- # to: woff
94
- # strategy: woff_writer
95
- # description: "Compress OpenType/CFF to WOFF format with zlib"
96
- # status: implemented
97
- # notes: >
98
- # WOFF 1.0 encoding with zlib compression. Supports optional metadata
99
- # and private data blocks. Individual table compression for optimal
100
- # file size reduction.
81
+ - from: ttf
82
+ to: woff
83
+ strategy: woff_writer
84
+ description: "Compress TrueType to WOFF format with zlib"
85
+ status: implemented
86
+ notes: >
87
+ WOFF 1.0 encoding with zlib compression. Supports optional metadata
88
+ and private data blocks. Individual table compression for optimal
89
+ file size reduction.
90
+
91
+ - from: otf
92
+ to: woff
93
+ strategy: woff_writer
94
+ description: "Compress OpenType/CFF to WOFF format with zlib"
95
+ status: implemented
96
+ notes: >
97
+ WOFF 1.0 encoding with zlib compression. Supports optional metadata
98
+ and private data blocks. Individual table compression for optimal
99
+ file size reduction.
100
+
101
+ - from: woff
102
+ to: woff
103
+ strategy: woff_recompress
104
+ description: "Copy WOFF font with re-compression"
105
+ status: implemented
106
+ notes: >
107
+ Same-format copy operation. Decompresses and re-compresses WOFF data,
108
+ useful for normalizing compression or updating metadata.
101
109
 
102
110
  # Reverse WOFF conversions (WOFF → TTF/OTF)
103
111
  - from: woff
@@ -139,6 +147,36 @@ conversions:
139
147
  transformation. Note: SVG fonts are deprecated in favor of WOFF2, but
140
148
  useful for fallback, conversion, and inspection purposes.
141
149
 
150
+ # WOFF2 decompression (reverse conversions)
151
+ - from: woff2
152
+ to: ttf
153
+ strategy: woff2_decoder
154
+ description: "Decompress WOFF2 to TrueType format"
155
+ status: implemented
156
+ notes: >
157
+ WOFF2 decompression with Brotli. Reverses table transformations
158
+ (glyf/loca, hmtx) if present. Preserves all font data and metadata.
159
+ Automatically detects font flavor from WOFF2 header.
160
+
161
+ - from: woff2
162
+ to: otf
163
+ strategy: woff2_decoder
164
+ description: "Decompress WOFF2 to OpenType/CFF format"
165
+ status: implemented
166
+ notes: >
167
+ WOFF2 decompression with Brotli. Reverses table transformations
168
+ if present. Preserves all font data and metadata. Automatically
169
+ detects font flavor from WOFF2 header.
170
+
171
+ - from: woff2
172
+ to: woff2
173
+ strategy: woff2_recompress
174
+ description: "Copy WOFF2 font with re-compression"
175
+ status: implemented
176
+ notes: >
177
+ Same-format copy operation. Decompresses and re-compresses WOFF2 data
178
+ with Brotli, useful for normalizing compression or updating metadata.
179
+
142
180
  # Conversion compatibility matrix
143
181
  #
144
182
  # This section documents which source features are preserved in conversions.
@@ -415,11 +415,14 @@ module Fontisan
415
415
  # If we have local subroutines, add Subrs offset
416
416
  # Subrs offset is relative to Private DICT start
417
417
  if local_subrs.any?
418
- # Calculate size of Private DICT itself to know where Subrs starts
418
+ # Add a placeholder Subrs offset first to get accurate size
419
+ private_dict_hash[:subrs] = 0
420
+
421
+ # Calculate size of Private DICT with Subrs entry
419
422
  temp_private_dict_data = Tables::Cff::DictBuilder.build(private_dict_hash)
420
423
  subrs_offset = temp_private_dict_data.bytesize
421
424
 
422
- # Add Subrs offset to DICT
425
+ # Update with actual Subrs offset
423
426
  private_dict_hash[:subrs] = subrs_offset
424
427
  end
425
428
 
@@ -729,7 +732,7 @@ module Fontisan
729
732
  if glyph_patterns.empty?
730
733
  charstring
731
734
  else
732
- rewriter.rewrite(charstring, glyph_patterns)
735
+ rewriter.rewrite(charstring, glyph_patterns, glyph_id)
733
736
  end
734
737
  end
735
738
 
@@ -99,6 +99,51 @@ module Fontisan
99
99
 
100
100
  private
101
101
 
102
+ # Generate SVG for a color glyph from COLR/CPAL tables
103
+ #
104
+ # @param glyph_id [Integer] Glyph ID
105
+ # @param colr_table [Tables::Colr] COLR table
106
+ # @param cpal_table [Tables::Cpal] CPAL table
107
+ # @param extractor [OutlineExtractor] Outline extractor for layer glyphs
108
+ # @param palette_index [Integer] Palette index to use (default: 0)
109
+ # @return [String, nil] SVG path data with color layers, or nil if not color glyph
110
+ def generate_color_glyph(glyph_id, colr_table, cpal_table, extractor, palette_index: 0)
111
+ # Get layers for this glyph
112
+ layers = colr_table.layers_for_glyph(glyph_id)
113
+ return nil if layers.empty?
114
+
115
+ # Get palette colors
116
+ palette = cpal_table.palette(palette_index)
117
+ return nil unless palette
118
+
119
+ # Generate SVG for each layer
120
+ svg_paths = []
121
+ layers.each do |layer|
122
+ # Get outline for layer glyph
123
+ outline = extractor.extract(layer.glyph_id)
124
+ next unless outline
125
+
126
+ # Get color for this layer
127
+ color = if layer.uses_foreground_color?
128
+ "currentColor" # Use CSS currentColor for foreground
129
+ else
130
+ palette[layer.palette_index] || "#000000FF"
131
+ end
132
+
133
+ # Convert outline to SVG path
134
+ path_data = outline.to_svg_path
135
+ next if path_data.empty?
136
+
137
+ # Create colored path element
138
+ svg_paths << %(<path d="#{path_data}" fill="#{color}" />)
139
+ end
140
+
141
+ svg_paths.empty? ? nil : svg_paths.join("\n")
142
+ rescue StandardError => e
143
+ warn "Failed to generate color glyph #{glyph_id}: #{e.message}"
144
+ nil
145
+ end
146
+
102
147
  # Extract glyph data from font
103
148
  #
104
149
  # @param font [TrueTypeFont, OpenTypeFont] Source font
@@ -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
 
@@ -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