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
@@ -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
 
@@ -50,62 +50,80 @@ module Fontisan
50
50
  # Brief mode: load each font and populate brief info
51
51
  brief_info = Models::CollectionBriefInfo.new
52
52
  brief_info.collection_path = @font_path
53
+ brief_info.collection_type = collection.class.collection_format
54
+ brief_info.collection_version = collection.version_string
53
55
  brief_info.num_fonts = collection.num_fonts
54
- brief_info.fonts = []
55
-
56
- collection.num_fonts.times do |index|
57
- # Load individual font from collection
58
- font = FontLoader.load(@font_path, font_index: index, mode: LoadingModes::METADATA)
59
-
60
- # Populate brief info for this font
61
- info = Models::FontInfo.new
62
-
63
- # Font format and variable status
64
- info.font_format = case font
65
- when TrueTypeFont
66
- "truetype"
67
- when OpenTypeFont
68
- "cff"
69
- else
70
- "unknown"
71
- end
72
- info.is_variable = font.has_table?(Constants::FVAR_TAG)
73
-
74
- # Collection offset (only populated for fonts in collections)
75
- info.collection_offset = collection.font_offsets[index]
76
-
77
- # Essential names
78
- if font.has_table?(Constants::NAME_TAG)
79
- name_table = font.table(Constants::NAME_TAG)
80
- info.family_name = name_table.english_name(Tables::Name::FAMILY)
81
- info.subfamily_name = name_table.english_name(Tables::Name::SUBFAMILY)
82
- info.full_name = name_table.english_name(Tables::Name::FULL_NAME)
83
- info.postscript_name = name_table.english_name(Tables::Name::POSTSCRIPT_NAME)
84
- info.version = name_table.english_name(Tables::Name::VERSION)
85
- end
86
-
87
- # Essential metrics
88
- if font.has_table?(Constants::HEAD_TAG)
89
- head = font.table(Constants::HEAD_TAG)
90
- info.font_revision = head.font_revision
91
- info.units_per_em = head.units_per_em
92
- end
93
-
94
- # Vendor ID
95
- if font.has_table?(Constants::OS2_TAG)
96
- os2_table = font.table(Constants::OS2_TAG)
97
- info.vendor_id = os2_table.vendor_id
98
- end
99
-
100
- brief_info.fonts << info
101
- end
56
+ brief_info.fonts = load_collection_fonts(collection, @font_path)
102
57
 
103
58
  brief_info
104
59
  else
105
- # Full mode: show detailed sharing statistics
106
- collection.collection_info(io, @font_path)
60
+ # Full mode: show detailed sharing statistics AND font information
61
+ full_info = collection.collection_info(io, @font_path)
62
+
63
+ # Add font information to full mode
64
+ full_info.fonts = load_collection_fonts(collection, @font_path)
65
+
66
+ full_info
67
+ end
68
+ end
69
+ end
70
+
71
+ # Load font information for all fonts in a collection
72
+ #
73
+ # @param collection [TrueTypeCollection, OpenTypeCollection] The collection
74
+ # @param collection_path [String] Path to the collection file
75
+ # @return [Array<Models::FontInfo>] Array of font info objects
76
+ def load_collection_fonts(collection, collection_path)
77
+ fonts = []
78
+
79
+ collection.num_fonts.times do |index|
80
+ # Load individual font from collection
81
+ font = FontLoader.load(collection_path, font_index: index, mode: LoadingModes::METADATA)
82
+
83
+ # Populate font info
84
+ info = Models::FontInfo.new
85
+
86
+ # Font format and variable status
87
+ info.font_format = case font
88
+ when TrueTypeFont
89
+ "truetype"
90
+ when OpenTypeFont
91
+ "cff"
92
+ else
93
+ "unknown"
94
+ end
95
+ info.is_variable = font.has_table?(Constants::FVAR_TAG)
96
+
97
+ # Collection offset (only populated for fonts in collections)
98
+ info.collection_offset = collection.font_offsets[index]
99
+
100
+ # Essential names
101
+ if font.has_table?(Constants::NAME_TAG)
102
+ name_table = font.table(Constants::NAME_TAG)
103
+ info.family_name = name_table.english_name(Tables::Name::FAMILY)
104
+ info.subfamily_name = name_table.english_name(Tables::Name::SUBFAMILY)
105
+ info.full_name = name_table.english_name(Tables::Name::FULL_NAME)
106
+ info.postscript_name = name_table.english_name(Tables::Name::POSTSCRIPT_NAME)
107
+ info.version = name_table.english_name(Tables::Name::VERSION)
108
+ end
109
+
110
+ # Essential metrics
111
+ if font.has_table?(Constants::HEAD_TAG)
112
+ head = font.table(Constants::HEAD_TAG)
113
+ info.font_revision = head.font_revision
114
+ info.units_per_em = head.units_per_em
107
115
  end
116
+
117
+ # Vendor ID
118
+ if font.has_table?(Constants::OS2_TAG)
119
+ os2_table = font.table(Constants::OS2_TAG)
120
+ info.vendor_id = os2_table.vendor_id
121
+ end
122
+
123
+ fonts << info
108
124
  end
125
+
126
+ fonts
109
127
  end
110
128
 
111
129
  # Get individual font information
@@ -184,6 +202,9 @@ module Fontisan
184
202
  populate_from_name_table(info) if font.has_table?(Constants::NAME_TAG)
185
203
  populate_from_os2_table(info) if font.has_table?(Constants::OS2_TAG)
186
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")
187
208
  end
188
209
 
189
210
  # Populate FontInfo from the name table.
@@ -237,6 +258,91 @@ module Fontisan
237
258
  info.units_per_em = head_table.units_per_em
238
259
  end
239
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
+
240
346
  # Format OS/2 embedding permission flags into a human-readable string.
241
347
  #
242
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