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
|
@@ -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
|
|
data/lib/fontisan/font_loader.rb
CHANGED
|
@@ -102,6 +102,37 @@ module Fontisan
|
|
|
102
102
|
# without extracting individual fonts. Useful for inspecting collection
|
|
103
103
|
# metadata and structure.
|
|
104
104
|
#
|
|
105
|
+
# = Collection Format Understanding
|
|
106
|
+
#
|
|
107
|
+
# Both TTC (TrueType Collection) and OTC (OpenType Collection) files use
|
|
108
|
+
# the same "ttcf" signature. The distinction between TTC and OTC is NOT
|
|
109
|
+
# in the collection format itself, but in the fonts contained within:
|
|
110
|
+
#
|
|
111
|
+
# - TTC typically contains TrueType fonts (glyf outlines)
|
|
112
|
+
# - OTC typically contains OpenType fonts (CFF/CFF2 outlines)
|
|
113
|
+
# - Mixed collections are possible (both TTF and OTF in same collection)
|
|
114
|
+
#
|
|
115
|
+
# Each collection can contain multiple SFNT-format font files, with table
|
|
116
|
+
# deduplication to save space. Individual fonts within a collection are
|
|
117
|
+
# stored at different offsets within the file, each with their own table
|
|
118
|
+
# directory and data tables.
|
|
119
|
+
#
|
|
120
|
+
# = Detection Strategy
|
|
121
|
+
#
|
|
122
|
+
# This method scans ALL fonts in the collection to determine the collection
|
|
123
|
+
# type accurately:
|
|
124
|
+
#
|
|
125
|
+
# 1. Reads all font offsets from the collection header
|
|
126
|
+
# 2. Examines the sfnt_version of each font in the collection
|
|
127
|
+
# 3. Counts TrueType fonts (0x00010000 or 0x74727565 "true") vs OpenType fonts (0x4F54544F "OTTO")
|
|
128
|
+
# 4. If ANY font is OpenType (CFF), returns OpenTypeCollection
|
|
129
|
+
# 5. Only returns TrueTypeCollection if ALL fonts are TrueType
|
|
130
|
+
#
|
|
131
|
+
# This approach correctly handles:
|
|
132
|
+
# - Homogeneous collections (all TTF or all OTF)
|
|
133
|
+
# - Mixed collections (both TTF and OTF fonts) - uses OpenTypeCollection
|
|
134
|
+
# - Large collections with many fonts (like NotoSerifCJK.ttc with 35 fonts)
|
|
135
|
+
#
|
|
105
136
|
# @param path [String] Path to the collection file
|
|
106
137
|
# @return [TrueTypeCollection, OpenTypeCollection] The collection object
|
|
107
138
|
# @raise [Errno::ENOENT] if file does not exist
|
|
@@ -121,23 +152,43 @@ module Fontisan
|
|
|
121
152
|
"File is not a collection (TTC/OTC). Use FontLoader.load instead."
|
|
122
153
|
end
|
|
123
154
|
|
|
124
|
-
# Read
|
|
125
|
-
io.seek(
|
|
126
|
-
|
|
155
|
+
# Read version and num_fonts
|
|
156
|
+
io.seek(8) # Skip tag (4) + version (4)
|
|
157
|
+
num_fonts = io.read(4).unpack1("N")
|
|
158
|
+
|
|
159
|
+
# Read all font offsets
|
|
160
|
+
font_offsets = num_fonts.times.map { io.read(4).unpack1("N") }
|
|
161
|
+
|
|
162
|
+
# Scan all fonts to determine collection type (not just first)
|
|
163
|
+
truetype_count = 0
|
|
164
|
+
opentype_count = 0
|
|
165
|
+
|
|
166
|
+
font_offsets.each do |offset|
|
|
167
|
+
io.rewind
|
|
168
|
+
io.seek(offset)
|
|
169
|
+
sfnt_version = io.read(4).unpack1("N")
|
|
170
|
+
|
|
171
|
+
case sfnt_version
|
|
172
|
+
when Constants::SFNT_VERSION_TRUETYPE, 0x74727565 # 0x74727565 = 'true'
|
|
173
|
+
truetype_count += 1
|
|
174
|
+
when Constants::SFNT_VERSION_OTTO
|
|
175
|
+
opentype_count += 1
|
|
176
|
+
else
|
|
177
|
+
raise InvalidFontError,
|
|
178
|
+
"Unknown font type in collection at offset #{offset} (sfnt version: 0x#{sfnt_version.to_s(16)})"
|
|
179
|
+
end
|
|
180
|
+
end
|
|
127
181
|
|
|
128
|
-
# Peek at first font's sfnt_version
|
|
129
|
-
io.seek(first_offset)
|
|
130
|
-
sfnt_version = io.read(4).unpack1("N")
|
|
131
182
|
io.rewind
|
|
132
183
|
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
184
|
+
# Determine collection type based on what fonts are inside
|
|
185
|
+
# If ANY font is OpenType, use OpenTypeCollection (more general format)
|
|
186
|
+
# Only use TrueTypeCollection if ALL fonts are TrueType
|
|
187
|
+
if opentype_count > 0
|
|
137
188
|
OpenTypeCollection.from_file(path)
|
|
138
189
|
else
|
|
139
|
-
|
|
140
|
-
|
|
190
|
+
# All fonts are TrueType
|
|
191
|
+
TrueTypeCollection.from_file(path)
|
|
141
192
|
end
|
|
142
193
|
end
|
|
143
194
|
end
|
|
@@ -167,6 +218,23 @@ module Fontisan
|
|
|
167
218
|
|
|
168
219
|
# Load from a collection file (TTC or OTC)
|
|
169
220
|
#
|
|
221
|
+
# This is the internal method that handles loading individual fonts from
|
|
222
|
+
# collection files. It reads the collection header to determine the type
|
|
223
|
+
# (TTC vs OTC) and extracts the requested font.
|
|
224
|
+
#
|
|
225
|
+
# = Collection Header Structure
|
|
226
|
+
#
|
|
227
|
+
# TTC/OTC files start with:
|
|
228
|
+
# - Bytes 0-3: "ttcf" tag (4 bytes)
|
|
229
|
+
# - Bytes 4-7: version (2 bytes major + 2 bytes minor)
|
|
230
|
+
# - Bytes 8-11: num_fonts (4 bytes, big-endian uint32)
|
|
231
|
+
# - Bytes 12+: font offset array (4 bytes per font, big-endian uint32)
|
|
232
|
+
#
|
|
233
|
+
# CRITICAL: The method seeks to position 8 (after tag and version) to read
|
|
234
|
+
# num_fonts, NOT position 12 which is where the offset array starts. This
|
|
235
|
+
# was a bug that caused "Unknown font type" errors when the first offset
|
|
236
|
+
# was misread as num_fonts.
|
|
237
|
+
#
|
|
170
238
|
# @param io [IO] Open file handle
|
|
171
239
|
# @param path [String] Path to the collection file
|
|
172
240
|
# @param font_index [Integer] Index of font to extract
|
|
@@ -177,7 +245,7 @@ module Fontisan
|
|
|
177
245
|
def self.load_from_collection(io, path, font_index,
|
|
178
246
|
mode: LoadingModes::FULL, lazy: true)
|
|
179
247
|
# Read collection header to get font offsets
|
|
180
|
-
io.seek(
|
|
248
|
+
io.seek(8) # Skip tag (4) + version (4)
|
|
181
249
|
num_fonts = io.read(4).unpack1("N")
|
|
182
250
|
|
|
183
251
|
if font_index >= num_fonts
|
|
@@ -185,26 +253,41 @@ mode: LoadingModes::FULL, lazy: true)
|
|
|
185
253
|
"Font index #{font_index} out of range (collection has #{num_fonts} fonts)"
|
|
186
254
|
end
|
|
187
255
|
|
|
188
|
-
# Read
|
|
189
|
-
|
|
256
|
+
# Read all font offsets
|
|
257
|
+
font_offsets = num_fonts.times.map { io.read(4).unpack1("N") }
|
|
258
|
+
|
|
259
|
+
# Scan all fonts to determine collection type (not just first)
|
|
260
|
+
truetype_count = 0
|
|
261
|
+
opentype_count = 0
|
|
262
|
+
|
|
263
|
+
font_offsets.each do |offset|
|
|
264
|
+
io.rewind
|
|
265
|
+
io.seek(offset)
|
|
266
|
+
sfnt_version = io.read(4).unpack1("N")
|
|
267
|
+
|
|
268
|
+
case sfnt_version
|
|
269
|
+
when Constants::SFNT_VERSION_TRUETYPE, 0x74727565 # 0x74727565 = 'true'
|
|
270
|
+
truetype_count += 1
|
|
271
|
+
when Constants::SFNT_VERSION_OTTO
|
|
272
|
+
opentype_count += 1
|
|
273
|
+
else
|
|
274
|
+
raise InvalidFontError,
|
|
275
|
+
"Unknown font type in collection at offset #{offset} (sfnt version: 0x#{sfnt_version.to_s(16)})"
|
|
276
|
+
end
|
|
277
|
+
end
|
|
190
278
|
|
|
191
|
-
# Peek at first font's sfnt_version to determine TTC vs OTC
|
|
192
|
-
io.seek(first_offset)
|
|
193
|
-
sfnt_version = io.read(4).unpack1("N")
|
|
194
279
|
io.rewind
|
|
195
280
|
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
ttc = TrueTypeCollection.from_file(path)
|
|
200
|
-
File.open(path, "rb") { |f| ttc.font(font_index, f, mode: mode) }
|
|
201
|
-
when Constants::SFNT_VERSION_OTTO
|
|
281
|
+
# If ANY font is OpenType, use OpenTypeCollection (more general format)
|
|
282
|
+
# Only use TrueTypeCollection if ALL fonts are TrueType
|
|
283
|
+
if opentype_count > 0
|
|
202
284
|
# OpenType Collection
|
|
203
285
|
otc = OpenTypeCollection.from_file(path)
|
|
204
286
|
File.open(path, "rb") { |f| otc.font(font_index, f, mode: mode) }
|
|
205
287
|
else
|
|
206
|
-
|
|
207
|
-
|
|
288
|
+
# TrueType Collection (all fonts are TrueType)
|
|
289
|
+
ttc = TrueTypeCollection.from_file(path)
|
|
290
|
+
File.open(path, "rb") { |f| ttc.font(font_index, f, mode: mode) }
|
|
208
291
|
end
|
|
209
292
|
end
|
|
210
293
|
|
|
@@ -364,20 +364,36 @@ module Fontisan
|
|
|
364
364
|
def format_collection_info(info)
|
|
365
365
|
lines = []
|
|
366
366
|
|
|
367
|
-
# Header section
|
|
368
|
-
lines << "
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
367
|
+
# Header section with type and version (like brief mode)
|
|
368
|
+
lines << "Collection: #{info.collection_path}"
|
|
369
|
+
|
|
370
|
+
# Format collection type for display with OpenType version
|
|
371
|
+
if info.collection_format
|
|
372
|
+
collection_type_display = case info.collection_format
|
|
373
|
+
when "TTC"
|
|
374
|
+
"TrueType Collection (OpenType 1.4)"
|
|
375
|
+
when "OTC"
|
|
376
|
+
"OpenType Collection (OpenType 1.8)"
|
|
377
|
+
else
|
|
378
|
+
info.collection_format
|
|
379
|
+
end
|
|
380
|
+
lines << "Type: #{collection_type_display}"
|
|
381
|
+
end
|
|
382
|
+
|
|
383
|
+
lines << "Version: #{info.version_string}"
|
|
372
384
|
lines << "Size: #{format_bytes(info.file_size_bytes)}"
|
|
385
|
+
lines << "Fonts: #{info.num_fonts}"
|
|
373
386
|
lines << ""
|
|
374
387
|
|
|
375
|
-
#
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
388
|
+
# Table sharing statistics
|
|
389
|
+
if info.table_sharing
|
|
390
|
+
lines << "=== Table Sharing ==="
|
|
391
|
+
lines << "Shared tables: #{info.table_sharing.shared_tables}"
|
|
392
|
+
lines << "Unique tables: #{info.table_sharing.unique_tables}"
|
|
393
|
+
lines << "Sharing: #{format_float(info.table_sharing.sharing_percentage)}%"
|
|
394
|
+
lines << "Space saved: #{format_bytes(info.table_sharing.space_saved_bytes)}"
|
|
395
|
+
lines << ""
|
|
396
|
+
end
|
|
381
397
|
|
|
382
398
|
# Font offsets
|
|
383
399
|
lines << "=== Font Offsets ==="
|
|
@@ -387,13 +403,35 @@ module Fontisan
|
|
|
387
403
|
end
|
|
388
404
|
lines << ""
|
|
389
405
|
|
|
390
|
-
#
|
|
391
|
-
if info.
|
|
392
|
-
lines << "===
|
|
393
|
-
lines << "
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
406
|
+
# Individual font information (like brief mode)
|
|
407
|
+
if info.fonts && !info.fonts.empty?
|
|
408
|
+
lines << "=== Fonts ==="
|
|
409
|
+
lines << ""
|
|
410
|
+
|
|
411
|
+
info.fonts.each_with_index do |font_info, index|
|
|
412
|
+
# Show font index with offset
|
|
413
|
+
if font_info.collection_offset
|
|
414
|
+
lines << "Font #{index} (offset: #{font_info.collection_offset}):"
|
|
415
|
+
else
|
|
416
|
+
lines << "Font #{index}:"
|
|
417
|
+
end
|
|
418
|
+
lines << ""
|
|
419
|
+
|
|
420
|
+
# Format each font using same structure as brief mode
|
|
421
|
+
font_type_display = format_font_type_display(font_info.font_format, font_info.is_variable)
|
|
422
|
+
add_line(lines, "Font type", font_type_display)
|
|
423
|
+
add_line(lines, "Family", font_info.family_name)
|
|
424
|
+
add_line(lines, "Subfamily", font_info.subfamily_name)
|
|
425
|
+
add_line(lines, "Full name", font_info.full_name)
|
|
426
|
+
add_line(lines, "PostScript name", font_info.postscript_name)
|
|
427
|
+
add_line(lines, "Version", font_info.version)
|
|
428
|
+
add_line(lines, "Vendor ID", font_info.vendor_id)
|
|
429
|
+
add_line(lines, "Font revision", format_float(font_info.font_revision))
|
|
430
|
+
add_line(lines, "Units per em", font_info.units_per_em)
|
|
431
|
+
|
|
432
|
+
# Blank line between fonts (except after last)
|
|
433
|
+
lines << "" unless index == info.num_fonts - 1
|
|
434
|
+
end
|
|
397
435
|
end
|
|
398
436
|
|
|
399
437
|
lines.join("\n")
|
|
@@ -406,8 +444,23 @@ module Fontisan
|
|
|
406
444
|
def format_collection_brief_info(info)
|
|
407
445
|
lines = []
|
|
408
446
|
|
|
409
|
-
# Collection header
|
|
447
|
+
# Collection header with type and version
|
|
410
448
|
lines << "Collection: #{info.collection_path}"
|
|
449
|
+
|
|
450
|
+
# Format collection type for display with OpenType version
|
|
451
|
+
if info.collection_type
|
|
452
|
+
collection_type_display = case info.collection_type
|
|
453
|
+
when "TTC"
|
|
454
|
+
"TrueType Collection (OpenType 1.4)"
|
|
455
|
+
when "OTC"
|
|
456
|
+
"OpenType Collection (OpenType 1.8)"
|
|
457
|
+
else
|
|
458
|
+
info.collection_type
|
|
459
|
+
end
|
|
460
|
+
lines << "Type: #{collection_type_display}"
|
|
461
|
+
end
|
|
462
|
+
|
|
463
|
+
lines << "Version: #{info.collection_version}" if info.collection_version
|
|
411
464
|
lines << "Fonts: #{info.num_fonts}"
|
|
412
465
|
lines << ""
|
|
413
466
|
|
|
@@ -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
|