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.
- checksums.yaml +4 -4
- data/.rubocop_todo.yml +92 -40
- data/README.adoc +262 -3
- data/Rakefile +20 -7
- data/lib/fontisan/commands/base_command.rb +2 -19
- data/lib/fontisan/commands/convert_command.rb +16 -13
- data/lib/fontisan/commands/info_command.rb +88 -0
- data/lib/fontisan/config/conversion_matrix.yml +58 -20
- data/lib/fontisan/converters/outline_converter.rb +6 -3
- data/lib/fontisan/converters/svg_generator.rb +45 -0
- data/lib/fontisan/converters/woff2_encoder.rb +106 -13
- data/lib/fontisan/models/bitmap_glyph.rb +123 -0
- data/lib/fontisan/models/bitmap_strike.rb +94 -0
- data/lib/fontisan/models/color_glyph.rb +57 -0
- data/lib/fontisan/models/color_layer.rb +53 -0
- data/lib/fontisan/models/color_palette.rb +60 -0
- data/lib/fontisan/models/font_info.rb +26 -0
- data/lib/fontisan/models/svg_glyph.rb +89 -0
- data/lib/fontisan/open_type_font.rb +6 -0
- data/lib/fontisan/optimizers/charstring_rewriter.rb +19 -8
- data/lib/fontisan/optimizers/pattern_analyzer.rb +4 -2
- data/lib/fontisan/optimizers/subroutine_builder.rb +6 -5
- data/lib/fontisan/optimizers/subroutine_optimizer.rb +5 -2
- data/lib/fontisan/pipeline/output_writer.rb +2 -2
- data/lib/fontisan/tables/cbdt.rb +169 -0
- data/lib/fontisan/tables/cblc.rb +290 -0
- data/lib/fontisan/tables/cff.rb +6 -12
- data/lib/fontisan/tables/colr.rb +291 -0
- data/lib/fontisan/tables/cpal.rb +281 -0
- data/lib/fontisan/tables/glyf/glyph_builder.rb +5 -1
- data/lib/fontisan/tables/sbix.rb +379 -0
- data/lib/fontisan/tables/svg.rb +301 -0
- data/lib/fontisan/true_type_font.rb +6 -0
- data/lib/fontisan/validation/woff2_header_validator.rb +278 -0
- data/lib/fontisan/validation/woff2_table_validator.rb +270 -0
- data/lib/fontisan/validation/woff2_validator.rb +248 -0
- data/lib/fontisan/version.rb +1 -1
- data/lib/fontisan/woff2/directory.rb +40 -11
- data/lib/fontisan/woff2/table_transformer.rb +506 -73
- data/lib/fontisan/woff2_font.rb +29 -9
- data/lib/fontisan/woff_font.rb +17 -4
- data/lib/fontisan.rb +12 -0
- 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
|
|
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
|
-
|
|
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(
|
|
61
|
+
@target_format = parse_target_format(opts[:to])
|
|
58
62
|
|
|
59
63
|
# Parse coordinates if string provided
|
|
60
|
-
@coordinates = if
|
|
61
|
-
parse_coordinates(
|
|
62
|
-
elsif
|
|
63
|
-
|
|
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 =
|
|
67
|
-
@preserve_variation =
|
|
68
|
-
@preserve_hints =
|
|
69
|
-
@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
|
-
|
|
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
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
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
|
-
#
|
|
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
|
-
#
|
|
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
|
-
#
|
|
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
|
-
# @
|
|
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
|
-
#
|
|
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" =>
|
|
187
|
-
"glyf_loca" =>
|
|
188
|
-
"hmtx" =>
|
|
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
|
-
#
|
|
299
|
-
data =
|
|
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
|