fontisan 0.2.7 → 0.2.9
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.yml +103 -0
- data/.rubocop_todo.yml +65 -361
- data/CHANGELOG.md +116 -0
- data/Gemfile +1 -1
- data/README.adoc +106 -27
- data/Rakefile +12 -7
- data/benchmark/variation_quick_bench.rb +1 -1
- data/docs/APPLE_LEGACY_FONTS.adoc +173 -0
- data/docs/COLLECTION_VALIDATION.adoc +143 -0
- data/docs/COLOR_FONTS.adoc +127 -0
- data/docs/DOCUMENTATION_SUMMARY.md +141 -0
- data/docs/FONT_HINTING.adoc +9 -1
- data/docs/VALIDATION.adoc +254 -0
- data/docs/WOFF_WOFF2_FORMATS.adoc +94 -0
- data/lib/fontisan/cli.rb +45 -13
- data/lib/fontisan/collection/dfont_builder.rb +2 -1
- data/lib/fontisan/commands/convert_command.rb +2 -4
- data/lib/fontisan/commands/info_command.rb +3 -3
- data/lib/fontisan/commands/pack_command.rb +2 -1
- data/lib/fontisan/commands/validate_command.rb +157 -6
- data/lib/fontisan/converters/collection_converter.rb +22 -13
- data/lib/fontisan/converters/svg_generator.rb +2 -1
- data/lib/fontisan/converters/woff2_encoder.rb +6 -6
- data/lib/fontisan/converters/woff_writer.rb +3 -1
- data/lib/fontisan/font_loader.rb +7 -6
- data/lib/fontisan/formatters/text_formatter.rb +18 -14
- data/lib/fontisan/hints/hint_converter.rb +1 -1
- data/lib/fontisan/hints/hint_validator.rb +13 -10
- data/lib/fontisan/hints/truetype_instruction_analyzer.rb +15 -8
- data/lib/fontisan/hints/truetype_instruction_generator.rb +1 -1
- data/lib/fontisan/models/collection_validation_report.rb +104 -0
- data/lib/fontisan/models/font_report.rb +24 -0
- data/lib/fontisan/models/validation_report.rb +7 -2
- data/lib/fontisan/open_type_font.rb +18 -425
- data/lib/fontisan/optimizers/charstring_rewriter.rb +1 -1
- data/lib/fontisan/optimizers/subroutine_optimizer.rb +6 -2
- data/lib/fontisan/sfnt_font.rb +699 -0
- data/lib/fontisan/sfnt_table.rb +264 -0
- data/lib/fontisan/subset/glyph_mapping.rb +2 -0
- data/lib/fontisan/subset/table_subsetter.rb +2 -2
- data/lib/fontisan/tables/cblc.rb +8 -4
- data/lib/fontisan/tables/cff/index.rb +2 -0
- data/lib/fontisan/tables/cff.rb +6 -3
- data/lib/fontisan/tables/cff2/private_dict_blend_handler.rb +1 -1
- data/lib/fontisan/tables/cff2.rb +1 -1
- data/lib/fontisan/tables/cmap.rb +5 -5
- data/lib/fontisan/tables/cmap_table.rb +231 -0
- data/lib/fontisan/tables/glyf.rb +8 -10
- data/lib/fontisan/tables/glyf_table.rb +255 -0
- data/lib/fontisan/tables/head.rb +3 -3
- data/lib/fontisan/tables/head_table.rb +111 -0
- data/lib/fontisan/tables/hhea.rb +4 -4
- data/lib/fontisan/tables/hhea_table.rb +255 -0
- data/lib/fontisan/tables/hmtx_table.rb +191 -0
- data/lib/fontisan/tables/loca_table.rb +212 -0
- data/lib/fontisan/tables/maxp.rb +2 -2
- data/lib/fontisan/tables/maxp_table.rb +258 -0
- data/lib/fontisan/tables/name.rb +1 -1
- data/lib/fontisan/tables/name_table.rb +176 -0
- data/lib/fontisan/tables/os2.rb +8 -8
- data/lib/fontisan/tables/os2_table.rb +329 -0
- data/lib/fontisan/tables/post.rb +2 -2
- data/lib/fontisan/tables/post_table.rb +183 -0
- data/lib/fontisan/tables/sbix.rb +5 -4
- data/lib/fontisan/true_type_font.rb +12 -464
- data/lib/fontisan/utilities/checksum_calculator.rb +0 -44
- data/lib/fontisan/validation/collection_validator.rb +4 -2
- data/lib/fontisan/validators/basic_validator.rb +11 -21
- data/lib/fontisan/validators/font_book_validator.rb +29 -50
- data/lib/fontisan/validators/opentype_validator.rb +24 -28
- data/lib/fontisan/validators/validator.rb +87 -66
- data/lib/fontisan/validators/web_font_validator.rb +16 -21
- data/lib/fontisan/version.rb +1 -1
- data/lib/fontisan/woff2/glyf_transformer.rb +31 -8
- data/lib/fontisan/woff2/hmtx_transformer.rb +2 -1
- data/lib/fontisan/woff2/table_transformer.rb +4 -2
- data/lib/fontisan/woff2_font.rb +4 -2
- data/lib/fontisan/woff_font.rb +46 -30
- data/lib/fontisan.rb +2 -2
- data/scripts/compare_stack_aware.rb +1 -1
- data/scripts/measure_optimization.rb +1 -2
- metadata +23 -2
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "font_report"
|
|
4
|
+
require_relative "validation_report"
|
|
5
|
+
require "lutaml/model"
|
|
6
|
+
|
|
7
|
+
module Fontisan
|
|
8
|
+
module Models
|
|
9
|
+
# CollectionValidationReport aggregates validation results for all fonts
|
|
10
|
+
# in a TTC/OTC/dfont collection.
|
|
11
|
+
#
|
|
12
|
+
# Provides collection-level summary statistics and per-font validation
|
|
13
|
+
# details with clear formatting.
|
|
14
|
+
class CollectionValidationReport < Lutaml::Model::Serializable
|
|
15
|
+
attribute :collection_path, :string
|
|
16
|
+
attribute :collection_type, :string
|
|
17
|
+
attribute :num_fonts, :integer
|
|
18
|
+
attribute :font_reports, FontReport, collection: true,
|
|
19
|
+
initialize_empty: true
|
|
20
|
+
attribute :valid, :boolean, default: -> { true }
|
|
21
|
+
|
|
22
|
+
key_value do
|
|
23
|
+
map "collection_path", to: :collection_path
|
|
24
|
+
map "collection_type", to: :collection_type
|
|
25
|
+
map "num_fonts", to: :num_fonts
|
|
26
|
+
map "font_reports", to: :font_reports
|
|
27
|
+
map "valid", to: :valid
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Add a font report to the collection
|
|
31
|
+
#
|
|
32
|
+
# @param font_report [FontReport] The font report to add
|
|
33
|
+
# @return [void]
|
|
34
|
+
def add_font_report(font_report)
|
|
35
|
+
font_reports << font_report
|
|
36
|
+
# Mark that we're no longer using the default value
|
|
37
|
+
value_set_for(:font_reports)
|
|
38
|
+
# Update overall validity
|
|
39
|
+
self.valid = valid && font_report.report.valid?
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Get overall validation status for the collection
|
|
43
|
+
#
|
|
44
|
+
# @return [String] "valid", "invalid", or "valid_with_warnings"
|
|
45
|
+
def overall_status
|
|
46
|
+
return "invalid" unless font_reports.all? { |fr| fr.report.valid? }
|
|
47
|
+
return "valid_with_warnings" if font_reports.any? do |fr|
|
|
48
|
+
fr.report.has_warnings?
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
"valid"
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Generate text summary with collection header and per-font sections
|
|
55
|
+
#
|
|
56
|
+
# @return [String] Formatted validation report
|
|
57
|
+
def text_summary
|
|
58
|
+
lines = []
|
|
59
|
+
lines << "Collection: #{collection_path}"
|
|
60
|
+
lines << "Type: #{collection_type}"
|
|
61
|
+
lines << "Fonts: #{num_fonts}"
|
|
62
|
+
lines << ""
|
|
63
|
+
lines << "Summary:"
|
|
64
|
+
lines << " Total Errors: #{total_errors}"
|
|
65
|
+
lines << " Total Warnings: #{total_warnings}"
|
|
66
|
+
lines << " Total Info: #{total_info}"
|
|
67
|
+
|
|
68
|
+
if font_reports.any?
|
|
69
|
+
lines << ""
|
|
70
|
+
font_reports.each do |font_report|
|
|
71
|
+
lines << "=== Font #{font_report.font_index}: #{font_report.font_name} ==="
|
|
72
|
+
# Indent each line of the font's report
|
|
73
|
+
font_lines = font_report.report.text_summary.split("\n")
|
|
74
|
+
lines.concat(font_lines)
|
|
75
|
+
lines << "" unless font_report == font_reports.last
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
lines.join("\n")
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# Calculate total errors across all fonts
|
|
83
|
+
#
|
|
84
|
+
# @return [Integer] Total error count
|
|
85
|
+
def total_errors
|
|
86
|
+
font_reports.sum { |fr| fr.report.summary.errors }
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# Calculate total warnings across all fonts
|
|
90
|
+
#
|
|
91
|
+
# @return [Integer] Total warning count
|
|
92
|
+
def total_warnings
|
|
93
|
+
font_reports.sum { |fr| fr.report.summary.warnings }
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# Calculate total info messages across all fonts
|
|
97
|
+
#
|
|
98
|
+
# @return [Integer] Total info count
|
|
99
|
+
def total_info
|
|
100
|
+
font_reports.sum { |fr| fr.report.summary.info }
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
end
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "validation_report"
|
|
4
|
+
require "lutaml/model"
|
|
5
|
+
|
|
6
|
+
module Fontisan
|
|
7
|
+
module Models
|
|
8
|
+
# FontReport wraps a single font's validation report with collection context
|
|
9
|
+
#
|
|
10
|
+
# Used within CollectionValidationReport to associate validation results
|
|
11
|
+
# with a specific font index and name in the collection.
|
|
12
|
+
class FontReport < Lutaml::Model::Serializable
|
|
13
|
+
attribute :font_index, :integer
|
|
14
|
+
attribute :font_name, :string
|
|
15
|
+
attribute :report, ValidationReport
|
|
16
|
+
|
|
17
|
+
key_value do
|
|
18
|
+
map "font_index", to: :font_index
|
|
19
|
+
map "font_name", to: :font_name
|
|
20
|
+
map "report", to: :report
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
@@ -101,7 +101,9 @@ module Fontisan
|
|
|
101
101
|
attribute :status, :string
|
|
102
102
|
attribute :use_case, :string
|
|
103
103
|
attribute :checks_performed, :string, collection: true, default: -> { [] }
|
|
104
|
-
attribute :check_results, CheckResult, collection: true, default: -> {
|
|
104
|
+
attribute :check_results, CheckResult, collection: true, default: -> {
|
|
105
|
+
[]
|
|
106
|
+
}
|
|
105
107
|
|
|
106
108
|
yaml do
|
|
107
109
|
map "font_path", to: :font_path
|
|
@@ -340,7 +342,9 @@ module Fontisan
|
|
|
340
342
|
# @param field_name [String, Symbol] Field name
|
|
341
343
|
# @return [Array<CheckResult>] Array of check results for the field
|
|
342
344
|
def field_issues(table_tag, field_name)
|
|
343
|
-
check_results.select
|
|
345
|
+
check_results.select do |cr|
|
|
346
|
+
cr.table == table_tag.to_s && cr.field == field_name.to_s
|
|
347
|
+
end
|
|
344
348
|
end
|
|
345
349
|
|
|
346
350
|
# Check filtering methods
|
|
@@ -374,6 +378,7 @@ module Fontisan
|
|
|
374
378
|
# @return [Float] Failure rate (0.0 to 1.0)
|
|
375
379
|
def failure_rate
|
|
376
380
|
return 0.0 if check_results.empty?
|
|
381
|
+
|
|
377
382
|
failed_checks.length.to_f / check_results.length
|
|
378
383
|
end
|
|
379
384
|
|
|
@@ -1,15 +1,13 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
require_relative "constants"
|
|
5
|
-
require_relative "loading_modes"
|
|
6
|
-
require_relative "utilities/checksum_calculator"
|
|
3
|
+
require_relative "sfnt_font"
|
|
7
4
|
|
|
8
5
|
module Fontisan
|
|
9
|
-
# OpenType Font domain object
|
|
6
|
+
# OpenType Font domain object
|
|
10
7
|
#
|
|
11
|
-
# Represents
|
|
12
|
-
#
|
|
8
|
+
# Represents an OpenType Font file (CFF outlines). Inherits all shared
|
|
9
|
+
# SFNT functionality from SfntFont and adds OpenType-specific behavior
|
|
10
|
+
# including page-aligned lazy loading for optimal performance.
|
|
13
11
|
#
|
|
14
12
|
# @example Reading and analyzing a font
|
|
15
13
|
# otf = OpenTypeFont.from_file("font.otf")
|
|
@@ -24,29 +22,10 @@ module Fontisan
|
|
|
24
22
|
#
|
|
25
23
|
# @example Writing a font
|
|
26
24
|
# otf.to_file("output.otf")
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
array :tables, type: :table_directory, initial_length: lambda {
|
|
32
|
-
header.num_tables
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
# Table data is stored separately since it's at variable offsets
|
|
36
|
-
attr_accessor :table_data
|
|
37
|
-
|
|
38
|
-
# Parsed table instances cache
|
|
39
|
-
attr_accessor :parsed_tables
|
|
40
|
-
|
|
41
|
-
# Loading mode for this font (:metadata or :full)
|
|
42
|
-
attr_accessor :loading_mode
|
|
43
|
-
|
|
44
|
-
# IO source for lazy loading
|
|
45
|
-
attr_accessor :io_source
|
|
46
|
-
|
|
47
|
-
# Whether lazy loading is enabled
|
|
48
|
-
attr_accessor :lazy_load_enabled
|
|
49
|
-
|
|
25
|
+
#
|
|
26
|
+
# @example Reading from TTC collection
|
|
27
|
+
# otf = OpenTypeFont.from_collection(io, offset)
|
|
28
|
+
class OpenTypeFont < SfntFont
|
|
50
29
|
# Page cache for lazy loading (maps page_start_offset => page_data)
|
|
51
30
|
attr_accessor :page_cache
|
|
52
31
|
|
|
@@ -57,7 +36,7 @@ module Fontisan
|
|
|
57
36
|
#
|
|
58
37
|
# @param path [String] Path to the OTF file
|
|
59
38
|
# @param mode [Symbol] Loading mode (:metadata or :full, default: :full)
|
|
60
|
-
# @param lazy [Boolean] If true, load tables on demand (default: false
|
|
39
|
+
# @param lazy [Boolean] If true, load tables on demand (default: false)
|
|
61
40
|
# @return [OpenTypeFont] A new instance
|
|
62
41
|
# @raise [ArgumentError] if path is nil or empty, or if mode is invalid
|
|
63
42
|
# @raise [Errno::ENOENT] if file does not exist
|
|
@@ -93,167 +72,23 @@ module Fontisan
|
|
|
93
72
|
raise "Invalid OTF file: #{e.message}"
|
|
94
73
|
end
|
|
95
74
|
|
|
96
|
-
# Read OpenType Font from collection at specific offset
|
|
97
|
-
#
|
|
98
|
-
# @param io [IO] Open file handle
|
|
99
|
-
# @param offset [Integer] Byte offset to the font
|
|
100
|
-
# @param mode [Symbol] Loading mode (:metadata or :full, default: :full)
|
|
101
|
-
# @return [OpenTypeFont] A new instance
|
|
102
|
-
def self.from_collection(io, offset, mode: LoadingModes::FULL)
|
|
103
|
-
LoadingModes.validate_mode!(mode)
|
|
104
|
-
|
|
105
|
-
io.seek(offset)
|
|
106
|
-
font = read(io)
|
|
107
|
-
font.initialize_storage
|
|
108
|
-
font.loading_mode = mode
|
|
109
|
-
font.read_table_data(io)
|
|
110
|
-
font
|
|
111
|
-
end
|
|
112
|
-
|
|
113
75
|
# Initialize storage hashes
|
|
114
76
|
#
|
|
77
|
+
# Extends base class to add page_cache for lazy loading.
|
|
78
|
+
#
|
|
115
79
|
# @return [void]
|
|
116
80
|
def initialize_storage
|
|
117
|
-
|
|
118
|
-
@parsed_tables = {}
|
|
119
|
-
@loading_mode = LoadingModes::FULL
|
|
120
|
-
@lazy_load_enabled = false
|
|
121
|
-
@io_source = nil
|
|
81
|
+
super
|
|
122
82
|
@page_cache = {}
|
|
123
83
|
end
|
|
124
84
|
|
|
125
|
-
# Read table data for all tables
|
|
126
|
-
#
|
|
127
|
-
# In metadata mode, only reads metadata tables. In full mode, reads all tables.
|
|
128
|
-
# In lazy load mode, doesn't read data upfront.
|
|
129
|
-
#
|
|
130
|
-
# @param io [IO] Open file handle
|
|
131
|
-
# @return [void]
|
|
132
|
-
def read_table_data(io)
|
|
133
|
-
@table_data = {}
|
|
134
|
-
|
|
135
|
-
if @lazy_load_enabled
|
|
136
|
-
# Don't read data, just keep IO reference
|
|
137
|
-
@io_source = io
|
|
138
|
-
return
|
|
139
|
-
end
|
|
140
|
-
|
|
141
|
-
if @loading_mode == LoadingModes::METADATA
|
|
142
|
-
# Only read metadata tables for performance
|
|
143
|
-
# Use page-aware batched reading to maximize filesystem prefetching
|
|
144
|
-
read_metadata_tables_batched(io)
|
|
145
|
-
else
|
|
146
|
-
# Read all tables
|
|
147
|
-
tables.each do |entry|
|
|
148
|
-
io.seek(entry.offset)
|
|
149
|
-
# Force UTF-8 encoding on tag for hash key consistency
|
|
150
|
-
tag_key = entry.tag.dup.force_encoding("UTF-8")
|
|
151
|
-
@table_data[tag_key] = io.read(entry.table_length)
|
|
152
|
-
end
|
|
153
|
-
end
|
|
154
|
-
end
|
|
155
|
-
|
|
156
|
-
# Read metadata tables using page-aware batching
|
|
157
|
-
#
|
|
158
|
-
# Groups adjacent tables within page boundaries and reads them together
|
|
159
|
-
# to maximize filesystem prefetching and minimize random seeks.
|
|
160
|
-
#
|
|
161
|
-
# @param io [IO] Open file handle
|
|
162
|
-
# @return [void]
|
|
163
|
-
def read_metadata_tables_batched(io)
|
|
164
|
-
# Typical filesystem page size (4KB is common, but 8KB gives better prefetch window)
|
|
165
|
-
page_threshold = 8192
|
|
166
|
-
|
|
167
|
-
# Get metadata tables sorted by offset for sequential access
|
|
168
|
-
metadata_entries = tables.select { |entry| LoadingModes::METADATA_TABLES_SET.include?(entry.tag) }
|
|
169
|
-
metadata_entries.sort_by!(&:offset)
|
|
170
|
-
|
|
171
|
-
return if metadata_entries.empty?
|
|
172
|
-
|
|
173
|
-
# Group adjacent tables within page threshold for batched reading
|
|
174
|
-
i = 0
|
|
175
|
-
while i < metadata_entries.size
|
|
176
|
-
batch_start = metadata_entries[i]
|
|
177
|
-
batch_end = batch_start
|
|
178
|
-
batch_entries = [batch_start]
|
|
179
|
-
|
|
180
|
-
# Extend batch while next table is within page threshold
|
|
181
|
-
j = i + 1
|
|
182
|
-
while j < metadata_entries.size
|
|
183
|
-
next_entry = metadata_entries[j]
|
|
184
|
-
gap = next_entry.offset - (batch_end.offset + batch_end.table_length)
|
|
185
|
-
|
|
186
|
-
# If gap is small (within page threshold), include in batch
|
|
187
|
-
if gap <= page_threshold
|
|
188
|
-
batch_end = next_entry
|
|
189
|
-
batch_entries << next_entry
|
|
190
|
-
j += 1
|
|
191
|
-
else
|
|
192
|
-
break
|
|
193
|
-
end
|
|
194
|
-
end
|
|
195
|
-
|
|
196
|
-
# Read batch
|
|
197
|
-
if batch_entries.size == 1
|
|
198
|
-
# Single table, read normally
|
|
199
|
-
io.seek(batch_start.offset)
|
|
200
|
-
tag_key = batch_start.tag.dup.force_encoding("UTF-8")
|
|
201
|
-
@table_data[tag_key] = io.read(batch_start.table_length)
|
|
202
|
-
else
|
|
203
|
-
# Multiple tables, read contiguous segment
|
|
204
|
-
batch_offset = batch_start.offset
|
|
205
|
-
batch_length = (batch_end.offset + batch_end.table_length) - batch_start.offset
|
|
206
|
-
|
|
207
|
-
io.seek(batch_offset)
|
|
208
|
-
batch_data = io.read(batch_length)
|
|
209
|
-
|
|
210
|
-
# Extract individual tables from batch
|
|
211
|
-
batch_entries.each do |entry|
|
|
212
|
-
relative_offset = entry.offset - batch_offset
|
|
213
|
-
tag_key = entry.tag.dup.force_encoding("UTF-8")
|
|
214
|
-
@table_data[tag_key] =
|
|
215
|
-
batch_data[relative_offset, entry.table_length]
|
|
216
|
-
end
|
|
217
|
-
end
|
|
218
|
-
|
|
219
|
-
i = j
|
|
220
|
-
end
|
|
221
|
-
end
|
|
222
|
-
|
|
223
|
-
# Write OpenType Font to a file
|
|
224
|
-
#
|
|
225
|
-
# Writes the complete OTF structure to disk, including proper checksum
|
|
226
|
-
# calculation and table alignment.
|
|
227
|
-
#
|
|
228
|
-
# @param path [String] Path where the OTF file will be written
|
|
229
|
-
# @return [Integer] Number of bytes written
|
|
230
|
-
# @raise [IOError] if writing fails
|
|
231
|
-
def to_file(path)
|
|
232
|
-
File.open(path, "wb") do |io|
|
|
233
|
-
# Write header and tables (directory)
|
|
234
|
-
write_structure(io)
|
|
235
|
-
|
|
236
|
-
# Write table data with updated offsets
|
|
237
|
-
write_table_data_with_offsets(io)
|
|
238
|
-
|
|
239
|
-
io.pos
|
|
240
|
-
end
|
|
241
|
-
|
|
242
|
-
# Update checksum adjustment in head table
|
|
243
|
-
update_checksum_adjustment_in_file(path) if head_table
|
|
244
|
-
|
|
245
|
-
File.size(path)
|
|
246
|
-
end
|
|
247
|
-
|
|
248
85
|
# Validate format correctness
|
|
249
86
|
#
|
|
87
|
+
# Extends base class to check for CFF table (OpenType-specific).
|
|
88
|
+
#
|
|
250
89
|
# @return [Boolean] true if the OTF format is valid, false otherwise
|
|
251
90
|
def valid?
|
|
252
|
-
return false unless
|
|
253
|
-
return false unless tables.respond_to?(:length)
|
|
254
|
-
return false unless @table_data.is_a?(Hash)
|
|
255
|
-
return false if tables.length != header.num_tables
|
|
256
|
-
return false unless head_table
|
|
91
|
+
return false unless super
|
|
257
92
|
return false unless has_table?(Constants::CFF_TAG)
|
|
258
93
|
|
|
259
94
|
true
|
|
@@ -273,158 +108,6 @@ module Fontisan
|
|
|
273
108
|
true
|
|
274
109
|
end
|
|
275
110
|
|
|
276
|
-
# Check if font has a specific table
|
|
277
|
-
#
|
|
278
|
-
# @param tag [String] The table tag to check for
|
|
279
|
-
# @return [Boolean] true if table exists, false otherwise
|
|
280
|
-
def has_table?(tag)
|
|
281
|
-
tables.any? { |entry| entry.tag == tag }
|
|
282
|
-
end
|
|
283
|
-
|
|
284
|
-
# Check if a table is available in the current loading mode
|
|
285
|
-
#
|
|
286
|
-
# @param tag [String] The table tag to check
|
|
287
|
-
# @return [Boolean] true if table is available in current mode
|
|
288
|
-
def table_available?(tag)
|
|
289
|
-
return false unless has_table?(tag)
|
|
290
|
-
|
|
291
|
-
LoadingModes.table_allowed?(@loading_mode, tag)
|
|
292
|
-
end
|
|
293
|
-
|
|
294
|
-
# Find a table entry by tag
|
|
295
|
-
#
|
|
296
|
-
# @param tag [String] The table tag to find
|
|
297
|
-
# @return [TableDirectory, nil] The table entry or nil
|
|
298
|
-
def find_table_entry(tag)
|
|
299
|
-
tables.find { |entry| entry.tag == tag }
|
|
300
|
-
end
|
|
301
|
-
|
|
302
|
-
# Get the head table entry
|
|
303
|
-
#
|
|
304
|
-
# @return [TableDirectory, nil] The head table entry or nil
|
|
305
|
-
def head_table
|
|
306
|
-
find_table_entry(Constants::HEAD_TAG)
|
|
307
|
-
end
|
|
308
|
-
|
|
309
|
-
# Get list of all table tags
|
|
310
|
-
#
|
|
311
|
-
# @return [Array<String>] Array of table tag strings
|
|
312
|
-
def table_names
|
|
313
|
-
tables.map(&:tag)
|
|
314
|
-
end
|
|
315
|
-
|
|
316
|
-
# Get parsed table instance
|
|
317
|
-
#
|
|
318
|
-
# This method parses the raw table data into a structured table object
|
|
319
|
-
# and caches the result for subsequent calls. Enforces mode restrictions.
|
|
320
|
-
#
|
|
321
|
-
# @param tag [String] The table tag to retrieve
|
|
322
|
-
# @return [Tables::*, nil] Parsed table object or nil if not found
|
|
323
|
-
# @raise [ArgumentError] if table is not available in current loading mode
|
|
324
|
-
def table(tag)
|
|
325
|
-
# Check mode restrictions
|
|
326
|
-
unless table_available?(tag)
|
|
327
|
-
if has_table?(tag)
|
|
328
|
-
raise ArgumentError,
|
|
329
|
-
"Table '#{tag}' is not available in #{@loading_mode} mode. " \
|
|
330
|
-
"Available tables: #{LoadingModes.tables_for(@loading_mode).inspect}"
|
|
331
|
-
else
|
|
332
|
-
return nil
|
|
333
|
-
end
|
|
334
|
-
end
|
|
335
|
-
|
|
336
|
-
return @parsed_tables[tag] if @parsed_tables.key?(tag)
|
|
337
|
-
|
|
338
|
-
# Lazy load table data if enabled
|
|
339
|
-
if @lazy_load_enabled && !@table_data.key?(tag)
|
|
340
|
-
load_table_data(tag)
|
|
341
|
-
end
|
|
342
|
-
|
|
343
|
-
@parsed_tables[tag] ||= parse_table(tag)
|
|
344
|
-
end
|
|
345
|
-
|
|
346
|
-
# Get units per em from head table
|
|
347
|
-
#
|
|
348
|
-
# @return [Integer, nil] Units per em value
|
|
349
|
-
def units_per_em
|
|
350
|
-
head = table(Constants::HEAD_TAG)
|
|
351
|
-
head&.units_per_em
|
|
352
|
-
end
|
|
353
|
-
|
|
354
|
-
# Convenience methods for accessing common name table fields
|
|
355
|
-
# These are particularly useful in minimal mode
|
|
356
|
-
|
|
357
|
-
# Get font family name
|
|
358
|
-
#
|
|
359
|
-
# @return [String, nil] Family name or nil if not found
|
|
360
|
-
def family_name
|
|
361
|
-
name_table = table(Constants::NAME_TAG)
|
|
362
|
-
name_table&.english_name(Tables::Name::FAMILY)
|
|
363
|
-
end
|
|
364
|
-
|
|
365
|
-
# Get font subfamily name (e.g., Regular, Bold, Italic)
|
|
366
|
-
#
|
|
367
|
-
# @return [String, nil] Subfamily name or nil if not found
|
|
368
|
-
def subfamily_name
|
|
369
|
-
name_table = table(Constants::NAME_TAG)
|
|
370
|
-
name_table&.english_name(Tables::Name::SUBFAMILY)
|
|
371
|
-
end
|
|
372
|
-
|
|
373
|
-
# Get full font name
|
|
374
|
-
#
|
|
375
|
-
# @return [String, nil] Full name or nil if not found
|
|
376
|
-
def full_name
|
|
377
|
-
name_table = table(Constants::NAME_TAG)
|
|
378
|
-
name_table&.english_name(Tables::Name::FULL_NAME)
|
|
379
|
-
end
|
|
380
|
-
|
|
381
|
-
# Get PostScript name
|
|
382
|
-
#
|
|
383
|
-
# @return [String, nil] PostScript name or nil if not found
|
|
384
|
-
def post_script_name
|
|
385
|
-
name_table = table(Constants::NAME_TAG)
|
|
386
|
-
name_table&.english_name(Tables::Name::POSTSCRIPT_NAME)
|
|
387
|
-
end
|
|
388
|
-
|
|
389
|
-
# Get preferred family name
|
|
390
|
-
#
|
|
391
|
-
# @return [String, nil] Preferred family name or nil if not found
|
|
392
|
-
def preferred_family_name
|
|
393
|
-
name_table = table(Constants::NAME_TAG)
|
|
394
|
-
name_table&.english_name(Tables::Name::PREFERRED_FAMILY)
|
|
395
|
-
end
|
|
396
|
-
|
|
397
|
-
# Get preferred subfamily name
|
|
398
|
-
#
|
|
399
|
-
# @return [String, nil] Preferred subfamily name or nil if not found
|
|
400
|
-
def preferred_subfamily_name
|
|
401
|
-
name_table = table(Constants::NAME_TAG)
|
|
402
|
-
name_table&.english_name(Tables::Name::PREFERRED_SUBFAMILY)
|
|
403
|
-
end
|
|
404
|
-
|
|
405
|
-
# Close the IO source (for lazy loading)
|
|
406
|
-
#
|
|
407
|
-
# @return [void]
|
|
408
|
-
def close
|
|
409
|
-
@io_source&.close
|
|
410
|
-
@io_source = nil
|
|
411
|
-
end
|
|
412
|
-
|
|
413
|
-
# Setup finalizer for cleanup
|
|
414
|
-
#
|
|
415
|
-
# @return [void]
|
|
416
|
-
def setup_finalizer
|
|
417
|
-
ObjectSpace.define_finalizer(self, self.class.finalize(@io_source))
|
|
418
|
-
end
|
|
419
|
-
|
|
420
|
-
# Finalizer proc for closing IO
|
|
421
|
-
#
|
|
422
|
-
# @param io [IO] The IO object to close
|
|
423
|
-
# @return [Proc] The finalizer proc
|
|
424
|
-
def self.finalize(io)
|
|
425
|
-
proc { io&.close }
|
|
426
|
-
end
|
|
427
|
-
|
|
428
111
|
private
|
|
429
112
|
|
|
430
113
|
# Load a single table's data on demand
|
|
@@ -478,22 +161,10 @@ module Fontisan
|
|
|
478
161
|
@table_data[tag_key] = table_data_parts.join
|
|
479
162
|
end
|
|
480
163
|
|
|
481
|
-
# Parse a table from raw data
|
|
482
|
-
#
|
|
483
|
-
# @param tag [String] The table tag to parse
|
|
484
|
-
# @return [Tables::*, nil] Parsed table object or nil
|
|
485
|
-
def parse_table(tag)
|
|
486
|
-
raw_data = @table_data[tag]
|
|
487
|
-
return nil unless raw_data
|
|
488
|
-
|
|
489
|
-
table_class = table_class_for(tag)
|
|
490
|
-
return nil unless table_class
|
|
491
|
-
|
|
492
|
-
table_class.read(raw_data)
|
|
493
|
-
end
|
|
494
|
-
|
|
495
164
|
# Map table tag to parser class
|
|
496
165
|
#
|
|
166
|
+
# OpenType-specific mapping includes CFF table.
|
|
167
|
+
#
|
|
497
168
|
# @param tag [String] The table tag
|
|
498
169
|
# @return [Class, nil] Table parser class or nil
|
|
499
170
|
def table_class_for(tag)
|
|
@@ -520,83 +191,5 @@ module Fontisan
|
|
|
520
191
|
"sbix" => Tables::Sbix,
|
|
521
192
|
}[tag]
|
|
522
193
|
end
|
|
523
|
-
|
|
524
|
-
# Write the structure (header + table directory) to IO
|
|
525
|
-
#
|
|
526
|
-
# @param io [IO] Open file handle
|
|
527
|
-
# @return [void]
|
|
528
|
-
def write_structure(io)
|
|
529
|
-
# Write header
|
|
530
|
-
header.write(io)
|
|
531
|
-
|
|
532
|
-
# Write table directory with placeholder offsets
|
|
533
|
-
tables.each do |entry|
|
|
534
|
-
io.write(entry.tag)
|
|
535
|
-
io.write([entry.checksum].pack("N"))
|
|
536
|
-
io.write([0].pack("N")) # Placeholder offset
|
|
537
|
-
io.write([entry.table_length].pack("N"))
|
|
538
|
-
end
|
|
539
|
-
end
|
|
540
|
-
|
|
541
|
-
# Write table data and update offsets in directory
|
|
542
|
-
#
|
|
543
|
-
# @param io [IO] Open file handle
|
|
544
|
-
# @return [void]
|
|
545
|
-
def write_table_data_with_offsets(io)
|
|
546
|
-
tables.each_with_index do |entry, index|
|
|
547
|
-
# Record current position
|
|
548
|
-
current_position = io.pos
|
|
549
|
-
|
|
550
|
-
# Write table data
|
|
551
|
-
data = @table_data[entry.tag]
|
|
552
|
-
raise IOError, "Missing table data for tag '#{entry.tag}'" if data.nil?
|
|
553
|
-
|
|
554
|
-
io.write(data)
|
|
555
|
-
|
|
556
|
-
# Add padding to align to 4-byte boundary
|
|
557
|
-
padding = (Constants::TABLE_ALIGNMENT - (io.pos % Constants::TABLE_ALIGNMENT)) % Constants::TABLE_ALIGNMENT
|
|
558
|
-
io.write("\x00" * padding) if padding.positive?
|
|
559
|
-
|
|
560
|
-
# Zero out checksumAdjustment field in head table
|
|
561
|
-
if entry.tag == Constants::HEAD_TAG
|
|
562
|
-
current_pos = io.pos
|
|
563
|
-
io.seek(current_position + 8)
|
|
564
|
-
io.write([0].pack("N"))
|
|
565
|
-
io.seek(current_pos)
|
|
566
|
-
end
|
|
567
|
-
|
|
568
|
-
# Update offset in table directory
|
|
569
|
-
# Table directory starts at byte 12, each entry is 16 bytes
|
|
570
|
-
# Offset field is at byte 8 within each entry
|
|
571
|
-
directory_offset_position = 12 + (index * 16) + 8
|
|
572
|
-
current_pos = io.pos
|
|
573
|
-
io.seek(directory_offset_position)
|
|
574
|
-
io.write([current_position].pack("N"))
|
|
575
|
-
io.seek(current_pos)
|
|
576
|
-
end
|
|
577
|
-
end
|
|
578
|
-
|
|
579
|
-
# Update checksumAdjustment field in head table
|
|
580
|
-
#
|
|
581
|
-
# @param path [String] Path to the OTF file
|
|
582
|
-
# @return [void]
|
|
583
|
-
def update_checksum_adjustment_in_file(path)
|
|
584
|
-
# Use tempfile-based checksum calculation for Windows compatibility
|
|
585
|
-
# This keeps the tempfile alive until we're done with the checksum
|
|
586
|
-
File.open(path, "r+b") do |io|
|
|
587
|
-
checksum, _tmpfile = Utilities::ChecksumCalculator.calculate_checksum_from_io_with_tempfile(io)
|
|
588
|
-
|
|
589
|
-
# Calculate adjustment
|
|
590
|
-
adjustment = Utilities::ChecksumCalculator.calculate_adjustment(checksum)
|
|
591
|
-
|
|
592
|
-
# Find head table position
|
|
593
|
-
head_entry = head_table
|
|
594
|
-
return unless head_entry
|
|
595
|
-
|
|
596
|
-
# Write adjustment to head table (offset 8 within head table)
|
|
597
|
-
io.seek(head_entry.offset + 8)
|
|
598
|
-
io.write([adjustment].pack("N"))
|
|
599
|
-
end
|
|
600
|
-
end
|
|
601
194
|
end
|
|
602
195
|
end
|
|
@@ -151,7 +151,7 @@ module Fontisan
|
|
|
151
151
|
# @param charstring [String] CharString to search
|
|
152
152
|
# @param pattern [Pattern] pattern to find
|
|
153
153
|
# @return [Array<Integer>] array of start positions
|
|
154
|
-
def find_pattern_positions(charstring, pattern,
|
|
154
|
+
def find_pattern_positions(charstring, pattern, _glyph_id = nil)
|
|
155
155
|
positions = []
|
|
156
156
|
offset = 0
|
|
157
157
|
|
|
@@ -32,7 +32,9 @@ module Fontisan
|
|
|
32
32
|
selected = []
|
|
33
33
|
# Sort by savings (descending), then by length (descending), then by min glyph ID,
|
|
34
34
|
# then by byte values for complete determinism across platforms
|
|
35
|
-
remaining = @patterns.sort_by
|
|
35
|
+
remaining = @patterns.sort_by do |p|
|
|
36
|
+
[-p.savings, -p.length, p.glyphs.min, p.bytes.bytes]
|
|
37
|
+
end
|
|
36
38
|
|
|
37
39
|
remaining.each do |pattern|
|
|
38
40
|
break if selected.length >= @max_subrs
|
|
@@ -53,7 +55,9 @@ module Fontisan
|
|
|
53
55
|
def optimize_ordering(subroutines)
|
|
54
56
|
# Higher frequency = lower ID (shorter encoding)
|
|
55
57
|
# Use same comprehensive sort keys as optimize_selection for consistency
|
|
56
|
-
subroutines.sort_by
|
|
58
|
+
subroutines.sort_by do |subr|
|
|
59
|
+
[-subr.frequency, -subr.length, subr.glyphs.min, subr.bytes.bytes]
|
|
60
|
+
end
|
|
57
61
|
end
|
|
58
62
|
|
|
59
63
|
# Check if nesting would be beneficial
|