fontisan 0.2.3 → 0.2.5
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 +221 -49
- data/README.adoc +519 -5
- data/Rakefile +20 -7
- data/lib/fontisan/cli.rb +67 -6
- 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/commands/validate_command.rb +107 -151
- 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 +84 -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/models/validation_report.rb +227 -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/pipeline/transformation_pipeline.rb +4 -8
- 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/cmap.rb +82 -2
- 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/glyf.rb +118 -0
- data/lib/fontisan/tables/head.rb +60 -0
- data/lib/fontisan/tables/hhea.rb +74 -0
- data/lib/fontisan/tables/maxp.rb +60 -0
- data/lib/fontisan/tables/name.rb +76 -0
- data/lib/fontisan/tables/os2.rb +113 -0
- data/lib/fontisan/tables/post.rb +57 -0
- 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/validators/basic_validator.rb +85 -0
- data/lib/fontisan/validators/font_book_validator.rb +130 -0
- data/lib/fontisan/validators/opentype_validator.rb +112 -0
- data/lib/fontisan/validators/profile_loader.rb +139 -0
- data/lib/fontisan/validators/validator.rb +484 -0
- data/lib/fontisan/validators/web_font_validator.rb +102 -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 +90 -6
- metadata +20 -9
- data/lib/fontisan/config/validation_rules.yml +0 -149
- data/lib/fontisan/validation/checksum_validator.rb +0 -170
- data/lib/fontisan/validation/consistency_validator.rb +0 -197
- data/lib/fontisan/validation/structure_validator.rb +0 -198
- data/lib/fontisan/validation/table_validator.rb +0 -158
- data/lib/fontisan/validation/validator.rb +0 -152
- data/lib/fontisan/validation/variable_font_validator.rb +0 -218
|
@@ -46,6 +46,34 @@ module Fontisan
|
|
|
46
46
|
end
|
|
47
47
|
end
|
|
48
48
|
|
|
49
|
+
# Individual check result from DSL-based validation
|
|
50
|
+
class CheckResult < Lutaml::Model::Serializable
|
|
51
|
+
attribute :check_id, :string
|
|
52
|
+
attribute :passed, :boolean
|
|
53
|
+
attribute :severity, :string
|
|
54
|
+
attribute :messages, :string, collection: true, default: -> { [] }
|
|
55
|
+
attribute :table, :string
|
|
56
|
+
attribute :field, :string
|
|
57
|
+
|
|
58
|
+
yaml do
|
|
59
|
+
map "check_id", to: :check_id
|
|
60
|
+
map "passed", to: :passed
|
|
61
|
+
map "severity", to: :severity
|
|
62
|
+
map "messages", to: :messages
|
|
63
|
+
map "table", to: :table
|
|
64
|
+
map "field", to: :field
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
json do
|
|
68
|
+
map "check_id", to: :check_id
|
|
69
|
+
map "passed", to: :passed
|
|
70
|
+
map "severity", to: :severity
|
|
71
|
+
map "messages", to: :messages
|
|
72
|
+
map "table", to: :table
|
|
73
|
+
map "field", to: :field
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
|
|
49
77
|
# Validation summary counts
|
|
50
78
|
class Summary < Lutaml::Model::Serializable
|
|
51
79
|
attribute :errors, :integer, default: -> { 0 }
|
|
@@ -69,12 +97,22 @@ module Fontisan
|
|
|
69
97
|
attribute :valid, :boolean
|
|
70
98
|
attribute :issues, Issue, collection: true, default: -> { [] }
|
|
71
99
|
attribute :summary, Summary, default: -> { Summary.new }
|
|
100
|
+
attribute :profile, :string
|
|
101
|
+
attribute :status, :string
|
|
102
|
+
attribute :use_case, :string
|
|
103
|
+
attribute :checks_performed, :string, collection: true, default: -> { [] }
|
|
104
|
+
attribute :check_results, CheckResult, collection: true, default: -> { [] }
|
|
72
105
|
|
|
73
106
|
yaml do
|
|
74
107
|
map "font_path", to: :font_path
|
|
75
108
|
map "valid", to: :valid
|
|
76
109
|
map "summary", to: :summary
|
|
77
110
|
map "issues", to: :issues
|
|
111
|
+
map "profile", to: :profile
|
|
112
|
+
map "status", to: :status
|
|
113
|
+
map "use_case", to: :use_case
|
|
114
|
+
map "checks_performed", to: :checks_performed
|
|
115
|
+
map "check_results", to: :check_results
|
|
78
116
|
end
|
|
79
117
|
|
|
80
118
|
json do
|
|
@@ -82,6 +120,11 @@ module Fontisan
|
|
|
82
120
|
map "valid", to: :valid
|
|
83
121
|
map "summary", to: :summary
|
|
84
122
|
map "issues", to: :issues
|
|
123
|
+
map "profile", to: :profile
|
|
124
|
+
map "status", to: :status
|
|
125
|
+
map "use_case", to: :use_case
|
|
126
|
+
map "checks_performed", to: :checks_performed
|
|
127
|
+
map "check_results", to: :check_results
|
|
85
128
|
end
|
|
86
129
|
|
|
87
130
|
# Add an error to the report
|
|
@@ -198,6 +241,190 @@ module Fontisan
|
|
|
198
241
|
|
|
199
242
|
lines.join("\n")
|
|
200
243
|
end
|
|
244
|
+
|
|
245
|
+
# Check if font passed validation (alias for valid)
|
|
246
|
+
#
|
|
247
|
+
# @return [Boolean] true if font passed validation
|
|
248
|
+
def passed?
|
|
249
|
+
valid
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
# Check if font is valid (alias for valid attribute)
|
|
253
|
+
#
|
|
254
|
+
# @return [Boolean] true if font is valid
|
|
255
|
+
def valid?
|
|
256
|
+
valid
|
|
257
|
+
end
|
|
258
|
+
|
|
259
|
+
# Get result for a specific check by ID
|
|
260
|
+
#
|
|
261
|
+
# @param check_id [Symbol, String] The check identifier
|
|
262
|
+
# @return [CheckResult, nil] The check result or nil if not found
|
|
263
|
+
def result_of(check_id)
|
|
264
|
+
check_results.find { |cr| cr.check_id == check_id.to_s }
|
|
265
|
+
end
|
|
266
|
+
|
|
267
|
+
# Get all passed checks
|
|
268
|
+
#
|
|
269
|
+
# @return [Array<CheckResult>] Array of passed checks
|
|
270
|
+
def passed_checks
|
|
271
|
+
check_results.select(&:passed)
|
|
272
|
+
end
|
|
273
|
+
|
|
274
|
+
# Get all failed checks
|
|
275
|
+
#
|
|
276
|
+
# @return [Array<CheckResult>] Array of failed checks
|
|
277
|
+
def failed_checks
|
|
278
|
+
check_results.reject(&:passed)
|
|
279
|
+
end
|
|
280
|
+
|
|
281
|
+
# Severity filtering methods
|
|
282
|
+
|
|
283
|
+
# Get issues by severity level
|
|
284
|
+
#
|
|
285
|
+
# @param severity [Symbol, String] Severity level
|
|
286
|
+
# @return [Array<Issue>] Array of issues with the specified severity
|
|
287
|
+
def issues_by_severity(severity)
|
|
288
|
+
issues.select { |issue| issue.severity == severity.to_s }
|
|
289
|
+
end
|
|
290
|
+
|
|
291
|
+
# Get fatal error issues
|
|
292
|
+
#
|
|
293
|
+
# @return [Array<Issue>] Array of fatal error issues
|
|
294
|
+
def fatal_errors
|
|
295
|
+
issues_by_severity(:fatal)
|
|
296
|
+
end
|
|
297
|
+
|
|
298
|
+
# Get error issues only
|
|
299
|
+
#
|
|
300
|
+
# @return [Array<Issue>] Array of error issues
|
|
301
|
+
def errors_only
|
|
302
|
+
issues_by_severity(:error)
|
|
303
|
+
end
|
|
304
|
+
|
|
305
|
+
# Get warning issues only
|
|
306
|
+
#
|
|
307
|
+
# @return [Array<Issue>] Array of warning issues
|
|
308
|
+
def warnings_only
|
|
309
|
+
issues_by_severity(:warning)
|
|
310
|
+
end
|
|
311
|
+
|
|
312
|
+
# Get info issues only
|
|
313
|
+
#
|
|
314
|
+
# @return [Array<Issue>] Array of info issues
|
|
315
|
+
def info_only
|
|
316
|
+
issues_by_severity(:info)
|
|
317
|
+
end
|
|
318
|
+
|
|
319
|
+
# Category filtering methods
|
|
320
|
+
|
|
321
|
+
# Get issues by category
|
|
322
|
+
#
|
|
323
|
+
# @param category [String] Category name
|
|
324
|
+
# @return [Array<Issue>] Array of issues in the specified category
|
|
325
|
+
def issues_by_category(category)
|
|
326
|
+
issues.select { |issue| issue.category == category.to_s }
|
|
327
|
+
end
|
|
328
|
+
|
|
329
|
+
# Get check results for a specific table
|
|
330
|
+
#
|
|
331
|
+
# @param table_tag [String] Table tag (e.g., 'name', 'head')
|
|
332
|
+
# @return [Array<CheckResult>] Array of check results for the table
|
|
333
|
+
def table_issues(table_tag)
|
|
334
|
+
check_results.select { |cr| cr.table == table_tag.to_s }
|
|
335
|
+
end
|
|
336
|
+
|
|
337
|
+
# Get check results for a specific field in a table
|
|
338
|
+
#
|
|
339
|
+
# @param table_tag [String] Table tag
|
|
340
|
+
# @param field_name [String, Symbol] Field name
|
|
341
|
+
# @return [Array<CheckResult>] Array of check results for the field
|
|
342
|
+
def field_issues(table_tag, field_name)
|
|
343
|
+
check_results.select { |cr| cr.table == table_tag.to_s && cr.field == field_name.to_s }
|
|
344
|
+
end
|
|
345
|
+
|
|
346
|
+
# Check filtering methods
|
|
347
|
+
|
|
348
|
+
# Get checks by status
|
|
349
|
+
#
|
|
350
|
+
# @param passed [Boolean] true for passed checks, false for failed checks
|
|
351
|
+
# @return [Array<CheckResult>] Array of checks with the specified status
|
|
352
|
+
def checks_by_status(passed:)
|
|
353
|
+
check_results.select { |cr| cr.passed == passed }
|
|
354
|
+
end
|
|
355
|
+
|
|
356
|
+
# Get IDs of failed checks
|
|
357
|
+
#
|
|
358
|
+
# @return [Array<String>] Array of failed check IDs
|
|
359
|
+
def failed_check_ids
|
|
360
|
+
failed_checks.map(&:check_id)
|
|
361
|
+
end
|
|
362
|
+
|
|
363
|
+
# Get IDs of passed checks
|
|
364
|
+
#
|
|
365
|
+
# @return [Array<String>] Array of passed check IDs
|
|
366
|
+
def passed_check_ids
|
|
367
|
+
passed_checks.map(&:check_id)
|
|
368
|
+
end
|
|
369
|
+
|
|
370
|
+
# Statistics methods
|
|
371
|
+
|
|
372
|
+
# Calculate failure rate as percentage
|
|
373
|
+
#
|
|
374
|
+
# @return [Float] Failure rate (0.0 to 1.0)
|
|
375
|
+
def failure_rate
|
|
376
|
+
return 0.0 if check_results.empty?
|
|
377
|
+
failed_checks.length.to_f / check_results.length
|
|
378
|
+
end
|
|
379
|
+
|
|
380
|
+
# Calculate pass rate as percentage
|
|
381
|
+
#
|
|
382
|
+
# @return [Float] Pass rate (0.0 to 1.0)
|
|
383
|
+
def pass_rate
|
|
384
|
+
1.0 - failure_rate
|
|
385
|
+
end
|
|
386
|
+
|
|
387
|
+
# Get severity distribution
|
|
388
|
+
#
|
|
389
|
+
# @return [Hash] Hash with :errors, :warnings, :info counts
|
|
390
|
+
def severity_distribution
|
|
391
|
+
{
|
|
392
|
+
errors: summary.errors,
|
|
393
|
+
warnings: summary.warnings,
|
|
394
|
+
info: summary.info,
|
|
395
|
+
}
|
|
396
|
+
end
|
|
397
|
+
|
|
398
|
+
# Export format methods
|
|
399
|
+
|
|
400
|
+
# Generate full detailed text report
|
|
401
|
+
#
|
|
402
|
+
# @return [String] Detailed text report
|
|
403
|
+
def to_text_report
|
|
404
|
+
text_summary
|
|
405
|
+
end
|
|
406
|
+
|
|
407
|
+
# Generate brief summary
|
|
408
|
+
#
|
|
409
|
+
# @return [String] Brief summary string
|
|
410
|
+
def to_summary
|
|
411
|
+
"#{summary.errors} errors, #{summary.warnings} warnings, #{summary.info} info"
|
|
412
|
+
end
|
|
413
|
+
|
|
414
|
+
# Generate tabular format for CLI
|
|
415
|
+
#
|
|
416
|
+
# @return [String] Tabular format output
|
|
417
|
+
def to_table_format
|
|
418
|
+
lines = []
|
|
419
|
+
lines << "CHECK_ID | STATUS | SEVERITY | TABLE"
|
|
420
|
+
lines << "-" * 60
|
|
421
|
+
check_results.each do |cr|
|
|
422
|
+
status = cr.passed ? "PASS" : "FAIL"
|
|
423
|
+
table = cr.table || "N/A"
|
|
424
|
+
lines << "#{cr.check_id} | #{status} | #{cr.severity} | #{table}"
|
|
425
|
+
end
|
|
426
|
+
lines.join("\n")
|
|
427
|
+
end
|
|
201
428
|
end
|
|
202
429
|
end
|
|
203
430
|
end
|
|
@@ -512,6 +512,12 @@ module Fontisan
|
|
|
512
512
|
Constants::GPOS_TAG => Tables::Gpos,
|
|
513
513
|
Constants::GLYF_TAG => Tables::Glyf,
|
|
514
514
|
Constants::LOCA_TAG => Tables::Loca,
|
|
515
|
+
"SVG " => Tables::Svg,
|
|
516
|
+
"COLR" => Tables::Colr,
|
|
517
|
+
"CPAL" => Tables::Cpal,
|
|
518
|
+
"CBDT" => Tables::Cbdt,
|
|
519
|
+
"CBLC" => Tables::Cblc,
|
|
520
|
+
"sbix" => Tables::Sbix,
|
|
515
521
|
}[tag]
|
|
516
522
|
end
|
|
517
523
|
|
|
@@ -30,12 +30,13 @@ module Fontisan
|
|
|
30
30
|
#
|
|
31
31
|
# @param charstring [String] original CharString bytes
|
|
32
32
|
# @param patterns [Array<Pattern>] patterns to replace in this CharString
|
|
33
|
+
# @param glyph_id [Integer, nil] glyph ID being rewritten (for position filtering)
|
|
33
34
|
# @return [String] rewritten CharString with subroutine calls
|
|
34
|
-
def rewrite(charstring, patterns)
|
|
35
|
+
def rewrite(charstring, patterns, glyph_id = nil)
|
|
35
36
|
return charstring if patterns.empty?
|
|
36
37
|
|
|
37
38
|
# Build list of all replacements: [position, pattern]
|
|
38
|
-
replacements = build_replacement_list(charstring, patterns)
|
|
39
|
+
replacements = build_replacement_list(charstring, patterns, glyph_id)
|
|
39
40
|
|
|
40
41
|
# Remove overlapping replacements
|
|
41
42
|
replacements = remove_overlaps(replacements)
|
|
@@ -120,16 +121,26 @@ module Fontisan
|
|
|
120
121
|
# Build list of all pattern replacements with their positions
|
|
121
122
|
# @param charstring [String] CharString being rewritten
|
|
122
123
|
# @param patterns [Array<Pattern>] patterns to find
|
|
124
|
+
# @param glyph_id [Integer, nil] glyph ID being rewritten (for position filtering)
|
|
123
125
|
# @return [Array<Array>] array of [position, pattern] pairs
|
|
124
|
-
def build_replacement_list(charstring, patterns)
|
|
126
|
+
def build_replacement_list(charstring, patterns, glyph_id = nil)
|
|
125
127
|
replacements = []
|
|
126
128
|
|
|
127
129
|
patterns.each do |pattern|
|
|
128
|
-
|
|
129
|
-
|
|
130
|
+
if glyph_id && pattern.respond_to?(:positions) && pattern.positions.is_a?(Hash)
|
|
131
|
+
# Use exact positions from pattern analysis for this glyph
|
|
132
|
+
glyph_positions = pattern.positions[glyph_id] || []
|
|
130
133
|
|
|
131
|
-
|
|
132
|
-
|
|
134
|
+
glyph_positions.each do |position|
|
|
135
|
+
replacements << [position, pattern]
|
|
136
|
+
end
|
|
137
|
+
else
|
|
138
|
+
# Fallback for backward compatibility (unit tests without glyph_id)
|
|
139
|
+
positions = find_pattern_positions(charstring, pattern)
|
|
140
|
+
|
|
141
|
+
positions.each do |position|
|
|
142
|
+
replacements << [position, pattern]
|
|
143
|
+
end
|
|
133
144
|
end
|
|
134
145
|
end
|
|
135
146
|
|
|
@@ -140,7 +151,7 @@ module Fontisan
|
|
|
140
151
|
# @param charstring [String] CharString to search
|
|
141
152
|
# @param pattern [Pattern] pattern to find
|
|
142
153
|
# @return [Array<Integer>] array of start positions
|
|
143
|
-
def find_pattern_positions(charstring, pattern)
|
|
154
|
+
def find_pattern_positions(charstring, pattern, glyph_id = nil)
|
|
144
155
|
positions = []
|
|
145
156
|
offset = 0
|
|
146
157
|
|
|
@@ -160,7 +160,9 @@ module Fontisan
|
|
|
160
160
|
charstrings.length
|
|
161
161
|
end
|
|
162
162
|
|
|
163
|
-
|
|
163
|
+
# Use deterministic selection instead of random sampling
|
|
164
|
+
# Sort keys first to ensure consistent ordering across platforms
|
|
165
|
+
sampled_glyphs = charstrings.keys.sort.take(sample_size)
|
|
164
166
|
|
|
165
167
|
# NEW: Pre-compute boundaries for sampled glyphs
|
|
166
168
|
# Check if boundaries are useful (more than just start position)
|
|
@@ -249,7 +251,7 @@ module Fontisan
|
|
|
249
251
|
# Build positions hash
|
|
250
252
|
positions = {}
|
|
251
253
|
by_glyph.each do |glyph_id, glyph_occurrences|
|
|
252
|
-
positions[glyph_id] = glyph_occurrences.map(&:last)
|
|
254
|
+
positions[glyph_id] = glyph_occurrences.map(&:last).uniq
|
|
253
255
|
end
|
|
254
256
|
|
|
255
257
|
@patterns[bytes] = Pattern.new(
|
|
@@ -95,22 +95,23 @@ module Fontisan
|
|
|
95
95
|
# @return [String] encoded bytes
|
|
96
96
|
def encode_integer(num)
|
|
97
97
|
# Range 1: -107 to 107 (single byte)
|
|
98
|
+
# CFF spec: byte value = 139 + number
|
|
98
99
|
if num >= -107 && num <= 107
|
|
99
|
-
return [
|
|
100
|
+
return [139 + num].pack("C")
|
|
100
101
|
end
|
|
101
102
|
|
|
102
103
|
# Range 2: 108 to 1131 (two bytes)
|
|
103
104
|
if num >= 108 && num <= 1131
|
|
104
105
|
b0 = 247 + ((num - 108) >> 8)
|
|
105
106
|
b1 = (num - 108) & 0xff
|
|
106
|
-
return [b0, b1].pack("
|
|
107
|
+
return [b0, b1].pack("C*")
|
|
107
108
|
end
|
|
108
109
|
|
|
109
110
|
# Range 3: -1131 to -108 (two bytes)
|
|
110
111
|
if num >= -1131 && num <= -108
|
|
111
112
|
b0 = 251 - ((num + 108) >> 8)
|
|
112
113
|
b1 = -(num + 108) & 0xff
|
|
113
|
-
return [b0, b1].pack("
|
|
114
|
+
return [b0, b1].pack("C*")
|
|
114
115
|
end
|
|
115
116
|
|
|
116
117
|
# Range 4: -32768 to 32767 (three bytes)
|
|
@@ -118,7 +119,7 @@ module Fontisan
|
|
|
118
119
|
b0 = 29
|
|
119
120
|
b1 = (num >> 8) & 0xff
|
|
120
121
|
b2 = num & 0xff
|
|
121
|
-
return [b0, b1, b2].pack("
|
|
122
|
+
return [b0, b1, b2].pack("C*")
|
|
122
123
|
end
|
|
123
124
|
|
|
124
125
|
# Range 5: Larger numbers (five bytes)
|
|
@@ -127,7 +128,7 @@ module Fontisan
|
|
|
127
128
|
b2 = (num >> 16) & 0xff
|
|
128
129
|
b3 = (num >> 8) & 0xff
|
|
129
130
|
b4 = num & 0xff
|
|
130
|
-
[b0, b1, b2, b3, b4].pack("
|
|
131
|
+
[b0, b1, b2, b3, b4].pack("C*")
|
|
131
132
|
end
|
|
132
133
|
end
|
|
133
134
|
end
|
|
@@ -30,7 +30,9 @@ module Fontisan
|
|
|
30
30
|
# @return [Array<Pattern>] selected patterns
|
|
31
31
|
def optimize_selection
|
|
32
32
|
selected = []
|
|
33
|
-
|
|
33
|
+
# Sort by savings (descending), then by length (descending), then by min glyph ID,
|
|
34
|
+
# then by byte values for complete determinism across platforms
|
|
35
|
+
remaining = @patterns.sort_by { |p| [-p.savings, -p.length, p.glyphs.min, p.bytes.bytes] }
|
|
34
36
|
|
|
35
37
|
remaining.each do |pattern|
|
|
36
38
|
break if selected.length >= @max_subrs
|
|
@@ -50,7 +52,8 @@ module Fontisan
|
|
|
50
52
|
# @return [Array<Pattern>] ordered subroutines
|
|
51
53
|
def optimize_ordering(subroutines)
|
|
52
54
|
# Higher frequency = lower ID (shorter encoding)
|
|
53
|
-
|
|
55
|
+
# Use same comprehensive sort keys as optimize_selection for consistency
|
|
56
|
+
subroutines.sort_by { |subr| [-subr.frequency, -subr.length, subr.glyphs.min, subr.bytes.bytes] }
|
|
54
57
|
end
|
|
55
58
|
|
|
56
59
|
# Check if nesting would be beneficial
|
|
@@ -96,9 +96,9 @@ module Fontisan
|
|
|
96
96
|
|
|
97
97
|
writer = Converters::WoffWriter.new
|
|
98
98
|
font = build_font_from_tables(tables)
|
|
99
|
-
|
|
99
|
+
woff_data = writer.convert(font, @options)
|
|
100
100
|
|
|
101
|
-
File.binwrite(@output_path,
|
|
101
|
+
File.binwrite(@output_path, woff_data)
|
|
102
102
|
end
|
|
103
103
|
|
|
104
104
|
# Write WOFF2 format
|
|
@@ -263,16 +263,12 @@ module Fontisan
|
|
|
263
263
|
def validate_output
|
|
264
264
|
return unless File.exist?(@output_path)
|
|
265
265
|
|
|
266
|
-
|
|
266
|
+
# Use new validation framework with production profile
|
|
267
|
+
report = Fontisan.validate(@output_path, profile: :production)
|
|
267
268
|
|
|
268
|
-
|
|
269
|
-
font = FontLoader.load(@output_path, mode: :full)
|
|
270
|
-
validator = Validation::Validator.new
|
|
271
|
-
result = validator.validate(font, @output_path)
|
|
269
|
+
return if report.valid?
|
|
272
270
|
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
error_messages = result.errors.map(&:message).join(", ")
|
|
271
|
+
error_messages = report.errors.map(&:message).join(", ")
|
|
276
272
|
raise Error, "Output validation failed: #{error_messages}"
|
|
277
273
|
end
|
|
278
274
|
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "stringio"
|
|
4
|
+
require_relative "../binary/base_record"
|
|
5
|
+
|
|
6
|
+
module Fontisan
|
|
7
|
+
module Tables
|
|
8
|
+
# CBDT (Color Bitmap Data) table parser
|
|
9
|
+
#
|
|
10
|
+
# The CBDT table contains the actual bitmap data for color glyphs. It works
|
|
11
|
+
# together with the CBLC table which provides the location information for
|
|
12
|
+
# finding bitmaps in this table.
|
|
13
|
+
#
|
|
14
|
+
# CBDT Table Structure:
|
|
15
|
+
# ```
|
|
16
|
+
# CBDT Table = Header (8 bytes)
|
|
17
|
+
# + Bitmap Data (variable length)
|
|
18
|
+
# ```
|
|
19
|
+
#
|
|
20
|
+
# Header (8 bytes):
|
|
21
|
+
# - majorVersion (uint16): Major version (2 or 3)
|
|
22
|
+
# - minorVersion (uint16): Minor version (0)
|
|
23
|
+
# - reserved (uint32): Reserved, set to 0
|
|
24
|
+
#
|
|
25
|
+
# The bitmap data format depends on the index subtable format in CBLC.
|
|
26
|
+
# Common formats include:
|
|
27
|
+
# - Format 17: Small metrics, PNG data
|
|
28
|
+
# - Format 18: Big metrics, PNG data
|
|
29
|
+
# - Format 19: Metrics in CBLC, PNG data
|
|
30
|
+
#
|
|
31
|
+
# This parser provides low-level access to bitmap data. For proper bitmap
|
|
32
|
+
# extraction, use together with CBLC table which contains the index.
|
|
33
|
+
#
|
|
34
|
+
# Reference: OpenType CBDT specification
|
|
35
|
+
# https://docs.microsoft.com/en-us/typography/opentype/spec/cbdt
|
|
36
|
+
#
|
|
37
|
+
# @example Reading a CBDT table
|
|
38
|
+
# data = font.table_data['CBDT']
|
|
39
|
+
# cbdt = Fontisan::Tables::Cbdt.read(data)
|
|
40
|
+
# bitmap_data = cbdt.bitmap_data_at(offset, length)
|
|
41
|
+
class Cbdt < Binary::BaseRecord
|
|
42
|
+
# OpenType table tag for CBDT
|
|
43
|
+
TAG = "CBDT"
|
|
44
|
+
|
|
45
|
+
# Supported CBDT versions
|
|
46
|
+
VERSION_2_0 = 0x0002_0000
|
|
47
|
+
VERSION_3_0 = 0x0003_0000
|
|
48
|
+
|
|
49
|
+
# @return [Integer] Major version (2 or 3)
|
|
50
|
+
attr_reader :major_version
|
|
51
|
+
|
|
52
|
+
# @return [Integer] Minor version (0)
|
|
53
|
+
attr_reader :minor_version
|
|
54
|
+
|
|
55
|
+
# @return [String] Raw binary data for the entire CBDT table
|
|
56
|
+
attr_reader :raw_data
|
|
57
|
+
|
|
58
|
+
# Override read to parse CBDT structure
|
|
59
|
+
#
|
|
60
|
+
# @param io [IO, String] Binary data to read
|
|
61
|
+
# @return [Cbdt] Parsed CBDT table
|
|
62
|
+
def self.read(io)
|
|
63
|
+
cbdt = new
|
|
64
|
+
return cbdt if io.nil?
|
|
65
|
+
|
|
66
|
+
data = io.is_a?(String) ? io : io.read
|
|
67
|
+
cbdt.parse!(data)
|
|
68
|
+
cbdt
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# Parse the CBDT table structure
|
|
72
|
+
#
|
|
73
|
+
# @param data [String] Binary data for the CBDT table
|
|
74
|
+
# @raise [CorruptedTableError] If CBDT structure is invalid
|
|
75
|
+
def parse!(data)
|
|
76
|
+
@raw_data = data
|
|
77
|
+
io = StringIO.new(data)
|
|
78
|
+
|
|
79
|
+
# Parse CBDT header (8 bytes)
|
|
80
|
+
parse_header(io)
|
|
81
|
+
validate_header!
|
|
82
|
+
rescue StandardError => e
|
|
83
|
+
raise CorruptedTableError, "Failed to parse CBDT table: #{e.message}"
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# Get bitmap data at specific offset and length
|
|
87
|
+
#
|
|
88
|
+
# Used together with CBLC index to extract bitmap data.
|
|
89
|
+
#
|
|
90
|
+
# @param offset [Integer] Offset from start of table
|
|
91
|
+
# @param length [Integer] Length of bitmap data
|
|
92
|
+
# @return [String, nil] Binary bitmap data or nil
|
|
93
|
+
def bitmap_data_at(offset, length)
|
|
94
|
+
return nil if offset.nil? || length.nil?
|
|
95
|
+
return nil if offset.negative? || length.negative?
|
|
96
|
+
return nil if offset + length > raw_data.length
|
|
97
|
+
|
|
98
|
+
raw_data[offset, length]
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# Get combined version number
|
|
102
|
+
#
|
|
103
|
+
# @return [Integer] Combined version (e.g., 0x00020000 for v2.0)
|
|
104
|
+
def version
|
|
105
|
+
return nil if major_version.nil? || minor_version.nil?
|
|
106
|
+
|
|
107
|
+
(major_version << 16) | minor_version
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# Get table data size
|
|
111
|
+
#
|
|
112
|
+
# @return [Integer] Size of CBDT table in bytes
|
|
113
|
+
def data_size
|
|
114
|
+
raw_data&.length || 0
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# Check if offset is valid for this table
|
|
118
|
+
#
|
|
119
|
+
# @param offset [Integer] Offset to check
|
|
120
|
+
# @return [Boolean] True if offset is within table bounds
|
|
121
|
+
def valid_offset?(offset)
|
|
122
|
+
return false if offset.nil? || offset.negative?
|
|
123
|
+
return false if raw_data.nil?
|
|
124
|
+
|
|
125
|
+
offset < raw_data.length
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
# Validate the CBDT table structure
|
|
129
|
+
#
|
|
130
|
+
# @return [Boolean] True if valid
|
|
131
|
+
def valid?
|
|
132
|
+
return false if major_version.nil? || minor_version.nil?
|
|
133
|
+
return false unless [2, 3].include?(major_version)
|
|
134
|
+
return false unless minor_version.zero?
|
|
135
|
+
return false unless raw_data
|
|
136
|
+
|
|
137
|
+
true
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
private
|
|
141
|
+
|
|
142
|
+
# Parse CBDT header (8 bytes)
|
|
143
|
+
#
|
|
144
|
+
# @param io [StringIO] Input stream
|
|
145
|
+
def parse_header(io)
|
|
146
|
+
@major_version = io.read(2).unpack1("n")
|
|
147
|
+
@minor_version = io.read(2).unpack1("n")
|
|
148
|
+
@reserved = io.read(4).unpack1("N")
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
# Validate header values
|
|
152
|
+
#
|
|
153
|
+
# @raise [CorruptedTableError] If validation fails
|
|
154
|
+
def validate_header!
|
|
155
|
+
unless [2, 3].include?(major_version)
|
|
156
|
+
raise CorruptedTableError,
|
|
157
|
+
"Unsupported CBDT major version: #{major_version} " \
|
|
158
|
+
"(only versions 2 and 3 supported)"
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
unless minor_version.zero?
|
|
162
|
+
raise CorruptedTableError,
|
|
163
|
+
"Unsupported CBDT minor version: #{minor_version} " \
|
|
164
|
+
"(only version 0 supported)"
|
|
165
|
+
end
|
|
166
|
+
end
|
|
167
|
+
end
|
|
168
|
+
end
|
|
169
|
+
end
|