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.
- checksums.yaml +4 -4
- data/.rubocop_todo.yml +94 -48
- data/README.adoc +293 -3
- data/Rakefile +20 -7
- data/lib/fontisan/base_collection.rb +296 -0
- 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 +156 -50
- 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/font_loader.rb +109 -26
- data/lib/fontisan/formatters/text_formatter.rb +72 -19
- data/lib/fontisan/models/bitmap_glyph.rb +123 -0
- data/lib/fontisan/models/bitmap_strike.rb +94 -0
- data/lib/fontisan/models/collection_brief_info.rb +6 -0
- data/lib/fontisan/models/collection_info.rb +6 -1
- 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_collection.rb +17 -220
- 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_collection.rb +29 -113
- 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 +18 -2
|
@@ -1,13 +1,12 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
require_relative "constants"
|
|
3
|
+
require_relative "base_collection"
|
|
5
4
|
|
|
6
5
|
module Fontisan
|
|
7
|
-
# OpenType Collection domain object
|
|
6
|
+
# OpenType Collection domain object
|
|
8
7
|
#
|
|
9
|
-
# Represents a complete OpenType Collection file (OTC)
|
|
10
|
-
#
|
|
8
|
+
# Represents a complete OpenType Collection file (OTC). Inherits all shared
|
|
9
|
+
# functionality from BaseCollection and implements OTC-specific behavior.
|
|
11
10
|
#
|
|
12
11
|
# @example Reading and extracting fonts
|
|
13
12
|
# File.open("fonts.otc", "rb") do |io|
|
|
@@ -15,39 +14,26 @@ module Fontisan
|
|
|
15
14
|
# puts otc.num_fonts # => 4
|
|
16
15
|
# fonts = otc.extract_fonts(io) # => [OpenTypeFont, OpenTypeFont, ...]
|
|
17
16
|
# end
|
|
18
|
-
class OpenTypeCollection <
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
string :tag, length: 4, assert: "ttcf"
|
|
22
|
-
uint16 :major_version
|
|
23
|
-
uint16 :minor_version
|
|
24
|
-
uint32 :num_fonts
|
|
25
|
-
array :font_offsets, type: :uint32, initial_length: :num_fonts
|
|
26
|
-
|
|
27
|
-
# Read OpenType Collection from a file
|
|
17
|
+
class OpenTypeCollection < BaseCollection
|
|
18
|
+
# Get the font class for OpenType collections
|
|
28
19
|
#
|
|
29
|
-
# @
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
def self.from_file(path)
|
|
35
|
-
if path.nil? || path.to_s.empty?
|
|
36
|
-
raise ArgumentError,
|
|
37
|
-
"path cannot be nil or empty"
|
|
38
|
-
end
|
|
39
|
-
raise Errno::ENOENT, "File not found: #{path}" unless File.exist?(path)
|
|
20
|
+
# @return [Class] OpenTypeFont class
|
|
21
|
+
def self.font_class
|
|
22
|
+
require_relative "open_type_font"
|
|
23
|
+
OpenTypeFont
|
|
24
|
+
end
|
|
40
25
|
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
26
|
+
# Get the collection format identifier
|
|
27
|
+
#
|
|
28
|
+
# @return [String] "OTC" for OpenType Collection
|
|
29
|
+
def self.collection_format
|
|
30
|
+
"OTC"
|
|
46
31
|
end
|
|
47
32
|
|
|
48
33
|
# Extract fonts as OpenTypeFont objects
|
|
49
34
|
#
|
|
50
35
|
# Reads each font from the OTC file and returns them as OpenTypeFont objects.
|
|
36
|
+
# This method uses the from_collection method.
|
|
51
37
|
#
|
|
52
38
|
# @param io [IO] Open file handle to read fonts from
|
|
53
39
|
# @return [Array<OpenTypeFont>] Array of font objects
|
|
@@ -58,194 +44,5 @@ module Fontisan
|
|
|
58
44
|
OpenTypeFont.from_collection(io, offset)
|
|
59
45
|
end
|
|
60
46
|
end
|
|
61
|
-
|
|
62
|
-
# Get a single font from the collection
|
|
63
|
-
#
|
|
64
|
-
# @param index [Integer] Index of the font (0-based)
|
|
65
|
-
# @param io [IO] Open file handle
|
|
66
|
-
# @param mode [Symbol] Loading mode (:metadata or :full, default: :full)
|
|
67
|
-
# @return [OpenTypeFont, nil] Font object or nil if index out of range
|
|
68
|
-
def font(index, io, mode: LoadingModes::FULL)
|
|
69
|
-
return nil if index >= num_fonts
|
|
70
|
-
|
|
71
|
-
require_relative "open_type_font"
|
|
72
|
-
OpenTypeFont.from_collection(io, font_offsets[index], mode: mode)
|
|
73
|
-
end
|
|
74
|
-
|
|
75
|
-
# Get font count
|
|
76
|
-
#
|
|
77
|
-
# @return [Integer] Number of fonts in collection
|
|
78
|
-
def font_count
|
|
79
|
-
num_fonts
|
|
80
|
-
end
|
|
81
|
-
|
|
82
|
-
# Validate format correctness
|
|
83
|
-
#
|
|
84
|
-
# @return [Boolean] true if the format is valid, false otherwise
|
|
85
|
-
def valid?
|
|
86
|
-
tag == Constants::TTC_TAG && num_fonts.positive? && font_offsets.length == num_fonts
|
|
87
|
-
rescue StandardError
|
|
88
|
-
false
|
|
89
|
-
end
|
|
90
|
-
|
|
91
|
-
# Get the OTC version as a single integer
|
|
92
|
-
#
|
|
93
|
-
# @return [Integer] Version number (e.g., 0x00010000 for version 1.0)
|
|
94
|
-
def version
|
|
95
|
-
(major_version << 16) | minor_version
|
|
96
|
-
end
|
|
97
|
-
|
|
98
|
-
# List all fonts in the collection with basic metadata
|
|
99
|
-
#
|
|
100
|
-
# Returns a CollectionListInfo model containing summaries of all fonts.
|
|
101
|
-
# This is the API method used by the `ls` command for collections.
|
|
102
|
-
#
|
|
103
|
-
# @param io [IO] Open file handle to read fonts from
|
|
104
|
-
# @return [CollectionListInfo] List of fonts with metadata
|
|
105
|
-
#
|
|
106
|
-
# @example List fonts in collection
|
|
107
|
-
# File.open("fonts.otc", "rb") do |io|
|
|
108
|
-
# otc = OpenTypeCollection.read(io)
|
|
109
|
-
# list = otc.list_fonts(io)
|
|
110
|
-
# list.fonts.each { |f| puts "#{f.index}: #{f.family_name}" }
|
|
111
|
-
# end
|
|
112
|
-
def list_fonts(io)
|
|
113
|
-
require_relative "models/collection_list_info"
|
|
114
|
-
require_relative "models/collection_font_summary"
|
|
115
|
-
require_relative "open_type_font"
|
|
116
|
-
require_relative "tables/name"
|
|
117
|
-
|
|
118
|
-
fonts = font_offsets.map.with_index do |offset, index|
|
|
119
|
-
font = OpenTypeFont.from_collection(io, offset)
|
|
120
|
-
|
|
121
|
-
# Extract basic font info
|
|
122
|
-
name_table = font.table("name")
|
|
123
|
-
post_table = font.table("post")
|
|
124
|
-
|
|
125
|
-
family_name = name_table&.english_name(Tables::Name::FAMILY) || "Unknown"
|
|
126
|
-
subfamily_name = name_table&.english_name(Tables::Name::SUBFAMILY) || "Regular"
|
|
127
|
-
postscript_name = name_table&.english_name(Tables::Name::POSTSCRIPT_NAME) || "Unknown"
|
|
128
|
-
|
|
129
|
-
# Determine font format
|
|
130
|
-
sfnt = font.header.sfnt_version
|
|
131
|
-
font_format = case sfnt
|
|
132
|
-
when 0x00010000, 0x74727565 # 0x74727565 = 'true'
|
|
133
|
-
"TrueType"
|
|
134
|
-
when 0x4F54544F # 'OTTO'
|
|
135
|
-
"OpenType"
|
|
136
|
-
else
|
|
137
|
-
"Unknown"
|
|
138
|
-
end
|
|
139
|
-
|
|
140
|
-
num_glyphs = post_table&.glyph_names&.length || 0
|
|
141
|
-
num_tables = font.table_names.length
|
|
142
|
-
|
|
143
|
-
Models::CollectionFontSummary.new(
|
|
144
|
-
index: index,
|
|
145
|
-
family_name: family_name,
|
|
146
|
-
subfamily_name: subfamily_name,
|
|
147
|
-
postscript_name: postscript_name,
|
|
148
|
-
font_format: font_format,
|
|
149
|
-
num_glyphs: num_glyphs,
|
|
150
|
-
num_tables: num_tables,
|
|
151
|
-
)
|
|
152
|
-
end
|
|
153
|
-
|
|
154
|
-
Models::CollectionListInfo.new(
|
|
155
|
-
collection_path: nil, # Will be set by command
|
|
156
|
-
num_fonts: num_fonts,
|
|
157
|
-
fonts: fonts,
|
|
158
|
-
)
|
|
159
|
-
end
|
|
160
|
-
|
|
161
|
-
# Get comprehensive collection metadata
|
|
162
|
-
#
|
|
163
|
-
# Returns a CollectionInfo model with header information, offsets,
|
|
164
|
-
# and table sharing statistics.
|
|
165
|
-
# This is the API method used by the `info` command for collections.
|
|
166
|
-
#
|
|
167
|
-
# @param io [IO] Open file handle to read fonts from
|
|
168
|
-
# @param path [String] Collection file path (for file size)
|
|
169
|
-
# @return [CollectionInfo] Collection metadata
|
|
170
|
-
#
|
|
171
|
-
# @example Get collection info
|
|
172
|
-
# File.open("fonts.otc", "rb") do |io|
|
|
173
|
-
# otc = OpenTypeCollection.read(io)
|
|
174
|
-
# info = otc.collection_info(io, "fonts.otc")
|
|
175
|
-
# puts "Version: #{info.version_string}"
|
|
176
|
-
# end
|
|
177
|
-
def collection_info(io, path)
|
|
178
|
-
require_relative "models/collection_info"
|
|
179
|
-
require_relative "models/table_sharing_info"
|
|
180
|
-
|
|
181
|
-
# Calculate table sharing statistics
|
|
182
|
-
table_sharing = calculate_table_sharing(io)
|
|
183
|
-
|
|
184
|
-
# Get file size
|
|
185
|
-
file_size = path ? File.size(path) : 0
|
|
186
|
-
|
|
187
|
-
Models::CollectionInfo.new(
|
|
188
|
-
collection_path: path,
|
|
189
|
-
collection_format: "OTC",
|
|
190
|
-
ttc_tag: tag,
|
|
191
|
-
major_version: major_version,
|
|
192
|
-
minor_version: minor_version,
|
|
193
|
-
num_fonts: num_fonts,
|
|
194
|
-
font_offsets: font_offsets.to_a,
|
|
195
|
-
file_size_bytes: file_size,
|
|
196
|
-
table_sharing: table_sharing,
|
|
197
|
-
)
|
|
198
|
-
end
|
|
199
|
-
|
|
200
|
-
private
|
|
201
|
-
|
|
202
|
-
# Calculate table sharing statistics
|
|
203
|
-
#
|
|
204
|
-
# Analyzes which tables are shared between fonts and calculates
|
|
205
|
-
# space savings from deduplication.
|
|
206
|
-
#
|
|
207
|
-
# @param io [IO] Open file handle
|
|
208
|
-
# @return [TableSharingInfo] Sharing statistics
|
|
209
|
-
def calculate_table_sharing(io)
|
|
210
|
-
require_relative "models/table_sharing_info"
|
|
211
|
-
require_relative "open_type_font"
|
|
212
|
-
|
|
213
|
-
# Extract all fonts
|
|
214
|
-
fonts = font_offsets.map do |offset|
|
|
215
|
-
OpenTypeFont.from_collection(io, offset)
|
|
216
|
-
end
|
|
217
|
-
|
|
218
|
-
# Build table hash map (checksum -> size)
|
|
219
|
-
table_map = {}
|
|
220
|
-
total_table_size = 0
|
|
221
|
-
|
|
222
|
-
fonts.each do |font|
|
|
223
|
-
font.tables.each do |entry|
|
|
224
|
-
key = entry.checksum
|
|
225
|
-
size = entry.table_length
|
|
226
|
-
table_map[key] ||= size
|
|
227
|
-
total_table_size += size
|
|
228
|
-
end
|
|
229
|
-
end
|
|
230
|
-
|
|
231
|
-
# Count unique vs shared
|
|
232
|
-
unique_tables = table_map.size
|
|
233
|
-
total_tables = fonts.sum { |f| f.tables.length }
|
|
234
|
-
shared_tables = total_tables - unique_tables
|
|
235
|
-
|
|
236
|
-
# Calculate space saved
|
|
237
|
-
unique_size = table_map.values.sum
|
|
238
|
-
space_saved = total_table_size - unique_size
|
|
239
|
-
|
|
240
|
-
# Calculate sharing percentage
|
|
241
|
-
sharing_pct = total_tables.positive? ? (shared_tables.to_f / total_tables * 100).round(2) : 0.0
|
|
242
|
-
|
|
243
|
-
Models::TableSharingInfo.new(
|
|
244
|
-
shared_tables: shared_tables,
|
|
245
|
-
unique_tables: unique_tables,
|
|
246
|
-
sharing_percentage: sharing_pct,
|
|
247
|
-
space_saved_bytes: space_saved,
|
|
248
|
-
)
|
|
249
|
-
end
|
|
250
47
|
end
|
|
251
48
|
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
|
|
@@ -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
|