fontisan 0.2.5 → 0.2.7
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 +41 -23
- data/README.adoc +230 -860
- data/lib/fontisan/base_collection.rb +5 -33
- data/lib/fontisan/cli.rb +27 -7
- data/lib/fontisan/collection/dfont_builder.rb +315 -0
- data/lib/fontisan/collection/shared_logic.rb +54 -0
- data/lib/fontisan/commands/convert_command.rb +118 -7
- data/lib/fontisan/commands/pack_command.rb +129 -22
- data/lib/fontisan/config/conversion_matrix.yml +175 -1
- data/lib/fontisan/constants.rb +8 -0
- data/lib/fontisan/converters/collection_converter.rb +438 -0
- data/lib/fontisan/dfont_collection.rb +269 -0
- data/lib/fontisan/font_loader.rb +90 -6
- data/lib/fontisan/parsers/dfont_parser.rb +192 -0
- data/lib/fontisan/true_type_font.rb +8 -46
- data/lib/fontisan/validation/collection_validator.rb +265 -0
- data/lib/fontisan/version.rb +1 -1
- metadata +8 -2
data/lib/fontisan/font_loader.rb
CHANGED
|
@@ -63,7 +63,7 @@ module Fontisan
|
|
|
63
63
|
when Constants::TTC_TAG
|
|
64
64
|
load_from_collection(io, path, font_index, mode: resolved_mode,
|
|
65
65
|
lazy: resolved_lazy)
|
|
66
|
-
when pack_uint32(Constants::SFNT_VERSION_TRUETYPE)
|
|
66
|
+
when pack_uint32(Constants::SFNT_VERSION_TRUETYPE), "true"
|
|
67
67
|
TrueTypeFont.from_file(path, mode: resolved_mode, lazy: resolved_lazy)
|
|
68
68
|
when "OTTO"
|
|
69
69
|
OpenTypeFont.from_file(path, mode: resolved_mode, lazy: resolved_lazy)
|
|
@@ -71,6 +71,8 @@ module Fontisan
|
|
|
71
71
|
WoffFont.from_file(path, mode: resolved_mode, lazy: resolved_lazy)
|
|
72
72
|
when "wOF2"
|
|
73
73
|
Woff2Font.from_file(path, mode: resolved_mode, lazy: resolved_lazy)
|
|
74
|
+
when Constants::DFONT_RESOURCE_HEADER
|
|
75
|
+
extract_and_load_dfont(io, path, font_index, resolved_mode, resolved_lazy)
|
|
74
76
|
else
|
|
75
77
|
raise InvalidFontError,
|
|
76
78
|
"Unknown font format. Expected TTF, OTF, TTC, OTC, WOFF, or WOFF2 file."
|
|
@@ -92,13 +94,24 @@ module Fontisan
|
|
|
92
94
|
|
|
93
95
|
File.open(path, "rb") do |io|
|
|
94
96
|
signature = io.read(4)
|
|
95
|
-
|
|
97
|
+
io.rewind
|
|
98
|
+
|
|
99
|
+
# Check for TTC/OTC signature
|
|
100
|
+
return true if signature == Constants::TTC_TAG
|
|
101
|
+
|
|
102
|
+
# Check for dfont - dfont is a collection format even if it contains only one font
|
|
103
|
+
if signature == Constants::DFONT_RESOURCE_HEADER
|
|
104
|
+
require_relative "parsers/dfont_parser"
|
|
105
|
+
return Parsers::DfontParser.dfont?(io)
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
false
|
|
96
109
|
end
|
|
97
110
|
end
|
|
98
111
|
|
|
99
112
|
# Load a collection object without extracting fonts
|
|
100
113
|
#
|
|
101
|
-
# Returns the collection object (TrueTypeCollection or
|
|
114
|
+
# Returns the collection object (TrueTypeCollection, OpenTypeCollection, or DfontCollection)
|
|
102
115
|
# without extracting individual fonts. Useful for inspecting collection
|
|
103
116
|
# metadata and structure.
|
|
104
117
|
#
|
|
@@ -112,6 +125,10 @@ module Fontisan
|
|
|
112
125
|
# - OTC typically contains OpenType fonts (CFF/CFF2 outlines)
|
|
113
126
|
# - Mixed collections are possible (both TTF and OTF in same collection)
|
|
114
127
|
#
|
|
128
|
+
# dfont (Data Fork Font) is an Apple-specific format that contains Mac
|
|
129
|
+
# font suitcase resources. It can contain multiple SFNT fonts (TrueType
|
|
130
|
+
# or OpenType).
|
|
131
|
+
#
|
|
115
132
|
# Each collection can contain multiple SFNT-format font files, with table
|
|
116
133
|
# deduplication to save space. Individual fonts within a collection are
|
|
117
134
|
# stored at different offsets within the file, each with their own table
|
|
@@ -128,13 +145,16 @@ module Fontisan
|
|
|
128
145
|
# 4. If ANY font is OpenType (CFF), returns OpenTypeCollection
|
|
129
146
|
# 5. Only returns TrueTypeCollection if ALL fonts are TrueType
|
|
130
147
|
#
|
|
148
|
+
# For dfont files, returns DfontCollection.
|
|
149
|
+
#
|
|
131
150
|
# This approach correctly handles:
|
|
132
151
|
# - Homogeneous collections (all TTF or all OTF)
|
|
133
152
|
# - Mixed collections (both TTF and OTF fonts) - uses OpenTypeCollection
|
|
134
153
|
# - Large collections with many fonts (like NotoSerifCJK.ttc with 35 fonts)
|
|
154
|
+
# - dfont suitcases (Apple-specific)
|
|
135
155
|
#
|
|
136
156
|
# @param path [String] Path to the collection file
|
|
137
|
-
# @return [TrueTypeCollection, OpenTypeCollection] The collection object
|
|
157
|
+
# @return [TrueTypeCollection, OpenTypeCollection, DfontCollection] The collection object
|
|
138
158
|
# @raise [Errno::ENOENT] if file does not exist
|
|
139
159
|
# @raise [InvalidFontError] if file is not a collection or type cannot be determined
|
|
140
160
|
#
|
|
@@ -146,10 +166,18 @@ module Fontisan
|
|
|
146
166
|
|
|
147
167
|
File.open(path, "rb") do |io|
|
|
148
168
|
signature = io.read(4)
|
|
169
|
+
io.rewind
|
|
170
|
+
|
|
171
|
+
# Check for dfont
|
|
172
|
+
if signature == Constants::DFONT_RESOURCE_HEADER || dfont_signature?(io)
|
|
173
|
+
require_relative "dfont_collection"
|
|
174
|
+
return DfontCollection.from_file(path)
|
|
175
|
+
end
|
|
149
176
|
|
|
177
|
+
# Check for TTC/OTC
|
|
150
178
|
unless signature == Constants::TTC_TAG
|
|
151
179
|
raise InvalidFontError,
|
|
152
|
-
"File is not a collection (TTC/OTC). Use FontLoader.load instead."
|
|
180
|
+
"File is not a collection (TTC/OTC/dfont). Use FontLoader.load instead."
|
|
153
181
|
end
|
|
154
182
|
|
|
155
183
|
# Read version and num_fonts
|
|
@@ -291,6 +319,50 @@ mode: LoadingModes::FULL, lazy: true)
|
|
|
291
319
|
end
|
|
292
320
|
end
|
|
293
321
|
|
|
322
|
+
# Extract and load font from dfont resource fork
|
|
323
|
+
#
|
|
324
|
+
# @param io [IO] Open file handle
|
|
325
|
+
# @param path [String] Path to dfont file
|
|
326
|
+
# @param font_index [Integer] Font index in suitcase
|
|
327
|
+
# @param mode [Symbol] Loading mode
|
|
328
|
+
# @param lazy [Boolean] Lazy loading flag
|
|
329
|
+
# @return [TrueTypeFont, OpenTypeFont] Loaded font
|
|
330
|
+
# @api private
|
|
331
|
+
def self.extract_and_load_dfont(io, path, font_index, mode, lazy)
|
|
332
|
+
require_relative "parsers/dfont_parser"
|
|
333
|
+
|
|
334
|
+
# Extract SFNT data from resource fork
|
|
335
|
+
sfnt_data = Parsers::DfontParser.extract_sfnt(io, index: font_index)
|
|
336
|
+
|
|
337
|
+
# Create StringIO with SFNT data
|
|
338
|
+
sfnt_io = StringIO.new(sfnt_data)
|
|
339
|
+
|
|
340
|
+
# Detect SFNT signature
|
|
341
|
+
signature = sfnt_io.read(4)
|
|
342
|
+
sfnt_io.rewind
|
|
343
|
+
|
|
344
|
+
# Read and setup font based on signature
|
|
345
|
+
case signature
|
|
346
|
+
when pack_uint32(Constants::SFNT_VERSION_TRUETYPE), "true"
|
|
347
|
+
font = TrueTypeFont.read(sfnt_io)
|
|
348
|
+
font.initialize_storage
|
|
349
|
+
font.loading_mode = mode
|
|
350
|
+
font.lazy_load_enabled = lazy
|
|
351
|
+
font.read_table_data(sfnt_io) unless lazy
|
|
352
|
+
font
|
|
353
|
+
when "OTTO"
|
|
354
|
+
font = OpenTypeFont.read(sfnt_io)
|
|
355
|
+
font.initialize_storage
|
|
356
|
+
font.loading_mode = mode
|
|
357
|
+
font.lazy_load_enabled = lazy
|
|
358
|
+
font.read_table_data(sfnt_io) unless lazy
|
|
359
|
+
font
|
|
360
|
+
else
|
|
361
|
+
raise InvalidFontError,
|
|
362
|
+
"Invalid SFNT data in dfont resource (signature: #{signature.inspect})"
|
|
363
|
+
end
|
|
364
|
+
end
|
|
365
|
+
|
|
294
366
|
# Pack uint32 value to big-endian bytes
|
|
295
367
|
#
|
|
296
368
|
# @param value [Integer] The uint32 value
|
|
@@ -301,6 +373,18 @@ mode: LoadingModes::FULL, lazy: true)
|
|
|
301
373
|
end
|
|
302
374
|
|
|
303
375
|
private_class_method :load_from_collection, :pack_uint32, :env_mode,
|
|
304
|
-
:env_lazy
|
|
376
|
+
:env_lazy, :extract_and_load_dfont
|
|
377
|
+
|
|
378
|
+
# Check if file has dfont signature
|
|
379
|
+
#
|
|
380
|
+
# @param io [IO] Open file handle
|
|
381
|
+
# @return [Boolean] true if dfont
|
|
382
|
+
# @api private
|
|
383
|
+
def self.dfont_signature?(io)
|
|
384
|
+
require_relative "parsers/dfont_parser"
|
|
385
|
+
Parsers::DfontParser.dfont?(io)
|
|
386
|
+
end
|
|
387
|
+
|
|
388
|
+
private_class_method :dfont_signature?
|
|
305
389
|
end
|
|
306
390
|
end
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "bindata"
|
|
4
|
+
require_relative "../error"
|
|
5
|
+
|
|
6
|
+
module Fontisan
|
|
7
|
+
module Parsers
|
|
8
|
+
# Parser for Apple dfont (Data Fork Font) resource fork format.
|
|
9
|
+
#
|
|
10
|
+
# dfont files store resource fork data in the data fork, containing
|
|
11
|
+
# TrueType or OpenType SFNT data embedded in a resource fork structure.
|
|
12
|
+
#
|
|
13
|
+
# @example Extract SFNT from dfont
|
|
14
|
+
# File.open("font.dfont", "rb") do |io|
|
|
15
|
+
# sfnt_data = DfontParser.extract_sfnt(io)
|
|
16
|
+
# # sfnt_data is raw SFNT binary
|
|
17
|
+
# end
|
|
18
|
+
class DfontParser
|
|
19
|
+
# Resource fork header structure (16 bytes)
|
|
20
|
+
class ResourceHeader < BinData::Record
|
|
21
|
+
endian :big
|
|
22
|
+
uint32 :resource_data_offset # Offset to resource data section
|
|
23
|
+
uint32 :resource_map_offset # Offset to resource map
|
|
24
|
+
uint32 :resource_data_length # Length of resource data
|
|
25
|
+
uint32 :resource_map_length # Length of resource map
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Resource type entry in type list
|
|
29
|
+
class ResourceType < BinData::Record
|
|
30
|
+
endian :big
|
|
31
|
+
string :type_code, length: 4 # Resource type (e.g., 'sfnt')
|
|
32
|
+
uint16 :resource_count_minus_1 # Number of resources - 1
|
|
33
|
+
uint16 :reference_list_offset # Offset to reference list
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Resource reference in reference list
|
|
37
|
+
class ResourceReference < BinData::Record
|
|
38
|
+
endian :big
|
|
39
|
+
uint16 :resource_id # Resource ID
|
|
40
|
+
int16 :name_offset # Offset to name in name list (-1 if none)
|
|
41
|
+
uint8 :attributes # Resource attributes
|
|
42
|
+
bit24 :data_offset # Offset to data (relative to resource data section)
|
|
43
|
+
uint32 :reserved # Reserved (handle to resource in memory)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Extract SFNT data from dfont file
|
|
47
|
+
#
|
|
48
|
+
# @param io [IO] Open file handle
|
|
49
|
+
# @param index [Integer] Font index for multi-font suitcases (default: 0)
|
|
50
|
+
# @return [String] Raw SFNT binary data
|
|
51
|
+
# @raise [InvalidFontError] if not valid dfont or index out of range
|
|
52
|
+
def self.extract_sfnt(io, index: 0)
|
|
53
|
+
header = parse_header(io)
|
|
54
|
+
sfnt_resources = find_sfnt_resources(io, header)
|
|
55
|
+
|
|
56
|
+
if sfnt_resources.empty?
|
|
57
|
+
raise InvalidFontError, "No sfnt resources found in dfont file"
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
if index >= sfnt_resources.length
|
|
61
|
+
raise InvalidFontError,
|
|
62
|
+
"Font index #{index} out of range (dfont has #{sfnt_resources.length} fonts)"
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
extract_resource_data(io, header, sfnt_resources[index])
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# Count number of sfnt resources (fonts) in dfont
|
|
69
|
+
#
|
|
70
|
+
# @param io [IO] Open file handle
|
|
71
|
+
# @return [Integer] Number of fonts
|
|
72
|
+
def self.sfnt_count(io)
|
|
73
|
+
header = parse_header(io)
|
|
74
|
+
sfnt_resources = find_sfnt_resources(io, header)
|
|
75
|
+
sfnt_resources.length
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# Check if file is valid dfont resource fork
|
|
79
|
+
#
|
|
80
|
+
# @param io [IO] Open file handle
|
|
81
|
+
# @return [Boolean]
|
|
82
|
+
def self.dfont?(io)
|
|
83
|
+
io.rewind
|
|
84
|
+
header_bytes = io.read(16)
|
|
85
|
+
io.rewind
|
|
86
|
+
|
|
87
|
+
return false if header_bytes.nil? || header_bytes.length < 16
|
|
88
|
+
|
|
89
|
+
# Basic sanity check on resource fork structure
|
|
90
|
+
data_offset = header_bytes[0..3].unpack1("N")
|
|
91
|
+
map_offset = header_bytes[4..7].unpack1("N")
|
|
92
|
+
|
|
93
|
+
data_offset.positive? && map_offset > data_offset
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# Parse resource fork header
|
|
97
|
+
#
|
|
98
|
+
# @param io [IO] Open file handle
|
|
99
|
+
# @return [ResourceHeader] Parsed header
|
|
100
|
+
# @raise [InvalidFontError] if header invalid
|
|
101
|
+
# @api private
|
|
102
|
+
def self.parse_header(io)
|
|
103
|
+
io.rewind
|
|
104
|
+
ResourceHeader.read(io)
|
|
105
|
+
rescue BinData::ValidityError => e
|
|
106
|
+
raise InvalidFontError, "Invalid dfont resource header: #{e.message}"
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# Find all sfnt resources in resource map
|
|
110
|
+
#
|
|
111
|
+
# @param io [IO] Open file handle
|
|
112
|
+
# @param header [ResourceHeader] Parsed header
|
|
113
|
+
# @return [Array<Hash>] Array of resource info hashes with :id and :offset
|
|
114
|
+
# @api private
|
|
115
|
+
def self.find_sfnt_resources(io, header)
|
|
116
|
+
# Seek to resource map
|
|
117
|
+
io.seek(header.resource_map_offset)
|
|
118
|
+
|
|
119
|
+
# Skip resource map header (22 bytes reserved + 4 bytes attributes + 2 bytes type list offset + 2 bytes name list offset)
|
|
120
|
+
# The actual layout is:
|
|
121
|
+
# - Bytes 0-15: Copy of resource header (16 bytes)
|
|
122
|
+
# - Bytes 16-19: Reserved for handle to next resource map (4 bytes)
|
|
123
|
+
# - Bytes 20-21: Reserved for file reference number (2 bytes)
|
|
124
|
+
# - Bytes 22-23: Resource file attributes (2 bytes)
|
|
125
|
+
# - Bytes 24-25: Offset to type list (2 bytes)
|
|
126
|
+
# - Bytes 26-27: Offset to name list (2 bytes)
|
|
127
|
+
io.seek(header.resource_map_offset + 24)
|
|
128
|
+
|
|
129
|
+
# Read type list offset (relative to start of resource map)
|
|
130
|
+
type_list_offset = io.read(2).unpack1("n")
|
|
131
|
+
|
|
132
|
+
# Seek to type list
|
|
133
|
+
io.seek(header.resource_map_offset + type_list_offset)
|
|
134
|
+
|
|
135
|
+
# Read number of types minus 1
|
|
136
|
+
type_count_minus_1 = io.read(2).unpack1("n")
|
|
137
|
+
type_count = type_count_minus_1 + 1
|
|
138
|
+
|
|
139
|
+
# Find 'sfnt' type in type list
|
|
140
|
+
sfnt_type = nil
|
|
141
|
+
type_count.times do
|
|
142
|
+
type_entry = ResourceType.read(io)
|
|
143
|
+
|
|
144
|
+
if type_entry.type_code == "sfnt"
|
|
145
|
+
sfnt_type = type_entry
|
|
146
|
+
break
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
return [] unless sfnt_type
|
|
151
|
+
|
|
152
|
+
# Read reference list for sfnt resources
|
|
153
|
+
reference_list_offset = header.resource_map_offset + type_list_offset + sfnt_type.reference_list_offset
|
|
154
|
+
io.seek(reference_list_offset)
|
|
155
|
+
|
|
156
|
+
resource_count = sfnt_type.resource_count_minus_1 + 1
|
|
157
|
+
resources = []
|
|
158
|
+
|
|
159
|
+
resource_count.times do
|
|
160
|
+
ref = ResourceReference.read(io)
|
|
161
|
+
resources << { id: ref.resource_id, offset: ref.data_offset }
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
resources
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
# Extract resource data at specific offset
|
|
168
|
+
#
|
|
169
|
+
# @param io [IO] Open file handle
|
|
170
|
+
# @param header [ResourceHeader] Parsed header
|
|
171
|
+
# @param resource_info [Hash] Resource info with :offset
|
|
172
|
+
# @return [String] Raw SFNT binary data
|
|
173
|
+
# @api private
|
|
174
|
+
def self.extract_resource_data(io, header, resource_info)
|
|
175
|
+
# Calculate absolute offset to resource data
|
|
176
|
+
# The offset in the reference is relative to the start of the resource data section
|
|
177
|
+
data_offset = header.resource_data_offset + resource_info[:offset]
|
|
178
|
+
|
|
179
|
+
io.seek(data_offset)
|
|
180
|
+
|
|
181
|
+
# Read data length (first 4 bytes of resource data)
|
|
182
|
+
data_length = io.read(4).unpack1("N")
|
|
183
|
+
|
|
184
|
+
# Read the actual data
|
|
185
|
+
io.read(data_length)
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
private_class_method :parse_header, :find_sfnt_resources,
|
|
189
|
+
:extract_resource_data
|
|
190
|
+
end
|
|
191
|
+
end
|
|
192
|
+
end
|
|
@@ -69,12 +69,6 @@ module Fontisan
|
|
|
69
69
|
# Whether lazy loading is enabled
|
|
70
70
|
attr_accessor :lazy_load_enabled
|
|
71
71
|
|
|
72
|
-
# Page cache for lazy loading (maps page_start_offset => page_data)
|
|
73
|
-
attr_accessor :page_cache
|
|
74
|
-
|
|
75
|
-
# Page size for lazy loading alignment (typical filesystem page size)
|
|
76
|
-
PAGE_SIZE = 4096
|
|
77
|
-
|
|
78
72
|
# Read TrueType Font from a file
|
|
79
73
|
#
|
|
80
74
|
# @param path [String] Path to the TTF file
|
|
@@ -101,8 +95,9 @@ module Fontisan
|
|
|
101
95
|
font.lazy_load_enabled = lazy
|
|
102
96
|
|
|
103
97
|
if lazy
|
|
104
|
-
#
|
|
105
|
-
|
|
98
|
+
# Reuse existing IO handle by duplicating it to prevent double file open
|
|
99
|
+
# The dup ensures the handle stays open after this block closes
|
|
100
|
+
font.io_source = io.dup
|
|
106
101
|
font.setup_finalizer
|
|
107
102
|
else
|
|
108
103
|
# Read tables upfront
|
|
@@ -141,7 +136,6 @@ module Fontisan
|
|
|
141
136
|
@loading_mode = LoadingModes::FULL
|
|
142
137
|
@lazy_load_enabled = false
|
|
143
138
|
@io_source = nil
|
|
144
|
-
@page_cache = {}
|
|
145
139
|
end
|
|
146
140
|
|
|
147
141
|
# Read table data for all tables
|
|
@@ -450,8 +444,8 @@ module Fontisan
|
|
|
450
444
|
|
|
451
445
|
# Load a single table's data on demand
|
|
452
446
|
#
|
|
453
|
-
# Uses
|
|
454
|
-
# performance is
|
|
447
|
+
# Uses direct seek-and-read for minimal overhead. This ensures lazy loading
|
|
448
|
+
# performance is comparable to eager loading when accessing all tables.
|
|
455
449
|
#
|
|
456
450
|
# @param tag [String] The table tag to load
|
|
457
451
|
# @return [void]
|
|
@@ -461,42 +455,10 @@ module Fontisan
|
|
|
461
455
|
entry = find_table_entry(tag)
|
|
462
456
|
return nil unless entry
|
|
463
457
|
|
|
464
|
-
#
|
|
465
|
-
|
|
466
|
-
table_end = entry.offset + entry.table_length
|
|
467
|
-
|
|
468
|
-
# Calculate page boundaries
|
|
469
|
-
page_start = (table_start / PAGE_SIZE) * PAGE_SIZE
|
|
470
|
-
page_end = ((table_end + PAGE_SIZE - 1) / PAGE_SIZE) * PAGE_SIZE
|
|
471
|
-
|
|
472
|
-
# Read all required pages (or use cached pages)
|
|
473
|
-
table_data_parts = []
|
|
474
|
-
current_page = page_start
|
|
475
|
-
|
|
476
|
-
while current_page < page_end
|
|
477
|
-
page_data = @page_cache[current_page]
|
|
478
|
-
|
|
479
|
-
unless page_data
|
|
480
|
-
# Read page from disk and cache it
|
|
481
|
-
@io_source.seek(current_page)
|
|
482
|
-
page_data = @io_source.read(PAGE_SIZE) || ""
|
|
483
|
-
@page_cache[current_page] = page_data
|
|
484
|
-
end
|
|
485
|
-
|
|
486
|
-
# Calculate which part of this page we need
|
|
487
|
-
chunk_start = [table_start - current_page, 0].max
|
|
488
|
-
chunk_end = [table_end - current_page, PAGE_SIZE].min
|
|
489
|
-
|
|
490
|
-
if chunk_end > chunk_start
|
|
491
|
-
table_data_parts << page_data[chunk_start...chunk_end]
|
|
492
|
-
end
|
|
493
|
-
|
|
494
|
-
current_page += PAGE_SIZE
|
|
495
|
-
end
|
|
496
|
-
|
|
497
|
-
# Combine parts and store
|
|
458
|
+
# Direct seek and read - same as eager loading but on-demand
|
|
459
|
+
@io_source.seek(entry.offset)
|
|
498
460
|
tag_key = tag.dup.force_encoding("UTF-8")
|
|
499
|
-
@table_data[tag_key] =
|
|
461
|
+
@table_data[tag_key] = @io_source.read(entry.table_length)
|
|
500
462
|
end
|
|
501
463
|
|
|
502
464
|
# Parse a table from raw data (Fontisan extension)
|
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../error"
|
|
4
|
+
|
|
5
|
+
module Fontisan
|
|
6
|
+
module Validation
|
|
7
|
+
# CollectionValidator validates font compatibility for collection formats
|
|
8
|
+
#
|
|
9
|
+
# Main responsibility: Enforce format-specific compatibility rules for
|
|
10
|
+
# TTC, OTC, and dfont collections according to OpenType spec and Apple standards.
|
|
11
|
+
#
|
|
12
|
+
# Rules:
|
|
13
|
+
# - TTC: TrueType fonts ONLY (per OpenType spec)
|
|
14
|
+
# - OTC: CFF fonts required, mixed TTF+OTF allowed (Fontisan extension)
|
|
15
|
+
# - dfont: Any SFNT fonts (TTF, OTF, or mixed)
|
|
16
|
+
# - All: Web fonts (WOFF/WOFF2) are NEVER allowed in collections
|
|
17
|
+
#
|
|
18
|
+
# @example Validate TTC compatibility
|
|
19
|
+
# validator = CollectionValidator.new
|
|
20
|
+
# validator.validate!([font1, font2], :ttc)
|
|
21
|
+
#
|
|
22
|
+
# @example Check compatibility without raising
|
|
23
|
+
# validator = CollectionValidator.new
|
|
24
|
+
# result = validator.compatible?([font1, font2], :otc)
|
|
25
|
+
class CollectionValidator
|
|
26
|
+
# Validate fonts are compatible with collection format
|
|
27
|
+
#
|
|
28
|
+
# @param fonts [Array<TrueTypeFont, OpenTypeFont>] Fonts to validate
|
|
29
|
+
# @param format [Symbol] Collection format (:ttc, :otc, or :dfont)
|
|
30
|
+
# @return [Boolean] true if valid
|
|
31
|
+
# @raise [Error] if validation fails
|
|
32
|
+
def validate!(fonts, format)
|
|
33
|
+
validate_not_empty!(fonts)
|
|
34
|
+
validate_format!(format)
|
|
35
|
+
|
|
36
|
+
case format
|
|
37
|
+
when :ttc
|
|
38
|
+
validate_ttc!(fonts)
|
|
39
|
+
when :otc
|
|
40
|
+
validate_otc!(fonts)
|
|
41
|
+
when :dfont
|
|
42
|
+
validate_dfont!(fonts)
|
|
43
|
+
else
|
|
44
|
+
raise Error, "Unknown collection format: #{format}"
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
true
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Check if fonts are compatible with format (without raising)
|
|
51
|
+
#
|
|
52
|
+
# @param fonts [Array] Fonts to check
|
|
53
|
+
# @param format [Symbol] Collection format
|
|
54
|
+
# @return [Boolean] true if compatible
|
|
55
|
+
def compatible?(fonts, format)
|
|
56
|
+
validate!(fonts, format)
|
|
57
|
+
true
|
|
58
|
+
rescue Error
|
|
59
|
+
false
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Get compatibility issues for fonts and format
|
|
63
|
+
#
|
|
64
|
+
# @param fonts [Array] Fonts to check
|
|
65
|
+
# @param format [Symbol] Collection format
|
|
66
|
+
# @return [Array<String>] Array of issue descriptions (empty if compatible)
|
|
67
|
+
def compatibility_issues(fonts, format)
|
|
68
|
+
issues = []
|
|
69
|
+
|
|
70
|
+
return ["Font array cannot be empty"] if fonts.nil? || fonts.empty?
|
|
71
|
+
return ["Invalid format: #{format}"] unless %i[ttc otc dfont].include?(format)
|
|
72
|
+
|
|
73
|
+
case format
|
|
74
|
+
when :ttc
|
|
75
|
+
issues.concat(ttc_issues(fonts))
|
|
76
|
+
when :otc
|
|
77
|
+
issues.concat(otc_issues(fonts))
|
|
78
|
+
when :dfont
|
|
79
|
+
issues.concat(dfont_issues(fonts))
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
issues
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
private
|
|
86
|
+
|
|
87
|
+
# Validate fonts array is not empty
|
|
88
|
+
#
|
|
89
|
+
# @param fonts [Array] Fonts
|
|
90
|
+
# @raise [ArgumentError] if empty or nil
|
|
91
|
+
def validate_not_empty!(fonts)
|
|
92
|
+
if fonts.nil? || fonts.empty?
|
|
93
|
+
raise ArgumentError, "Font array cannot be empty"
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# Validate format is supported
|
|
98
|
+
#
|
|
99
|
+
# @param format [Symbol] Format
|
|
100
|
+
# @raise [ArgumentError] if invalid
|
|
101
|
+
def validate_format!(format)
|
|
102
|
+
unless %i[ttc otc dfont].include?(format)
|
|
103
|
+
raise ArgumentError, "Invalid format: #{format}. Must be :ttc, :otc, or :dfont"
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# Validate TTC compatibility
|
|
108
|
+
#
|
|
109
|
+
# Per OpenType spec: TTC = TrueType outlines ONLY
|
|
110
|
+
# "CFF rasterizer does not currently support TTC files"
|
|
111
|
+
#
|
|
112
|
+
# @param fonts [Array] Fonts
|
|
113
|
+
# @raise [Error] if incompatible
|
|
114
|
+
def validate_ttc!(fonts)
|
|
115
|
+
fonts.each_with_index do |font, index|
|
|
116
|
+
# Check for web fonts
|
|
117
|
+
if web_font?(font)
|
|
118
|
+
raise Error,
|
|
119
|
+
"Font #{index} is a web font (WOFF/WOFF2). " \
|
|
120
|
+
"Web fonts cannot be packed into collections."
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
# Check for TrueType outline format
|
|
124
|
+
unless truetype_font?(font)
|
|
125
|
+
raise Error,
|
|
126
|
+
"Font #{index} is not TrueType. " \
|
|
127
|
+
"TTC requires TrueType fonts only (per OpenType spec)."
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
# Validate OTC compatibility
|
|
133
|
+
#
|
|
134
|
+
# Per OpenType 1.8: OTC for CFF collections
|
|
135
|
+
# Fontisan extension: Also allows mixed TTF+OTF for flexibility
|
|
136
|
+
#
|
|
137
|
+
# @param fonts [Array] Fonts
|
|
138
|
+
# @raise [Error] if incompatible
|
|
139
|
+
def validate_otc!(fonts)
|
|
140
|
+
has_cff = false
|
|
141
|
+
|
|
142
|
+
fonts.each_with_index do |font, index|
|
|
143
|
+
# Check for web fonts
|
|
144
|
+
if web_font?(font)
|
|
145
|
+
raise Error,
|
|
146
|
+
"Font #{index} is a web font (WOFF/WOFF2). " \
|
|
147
|
+
"Web fonts cannot be packed into collections."
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
# Track if any font has CFF
|
|
151
|
+
has_cff = true if cff_font?(font)
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
# OTC should have at least one CFF font
|
|
155
|
+
unless has_cff
|
|
156
|
+
raise Error,
|
|
157
|
+
"OTC requires at least one CFF/OpenType font. " \
|
|
158
|
+
"All fonts are TrueType - use TTC instead."
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
# Validate dfont compatibility
|
|
163
|
+
#
|
|
164
|
+
# Apple dfont suitcase: Any SFNT fonts OK (TTF, OTF, or mixed)
|
|
165
|
+
# dfont stores complete Mac resources (FOND, NFNT, sfnt)
|
|
166
|
+
#
|
|
167
|
+
# @param fonts [Array] Fonts
|
|
168
|
+
# @raise [Error] if incompatible
|
|
169
|
+
def validate_dfont!(fonts)
|
|
170
|
+
fonts.each_with_index do |font, index|
|
|
171
|
+
# Only check for web fonts - dfont accepts any SFNT
|
|
172
|
+
if web_font?(font)
|
|
173
|
+
raise Error,
|
|
174
|
+
"Font #{index} is a web font (WOFF/WOFF2). " \
|
|
175
|
+
"Web fonts cannot be packed into dfont."
|
|
176
|
+
end
|
|
177
|
+
end
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
# Get TTC compatibility issues
|
|
181
|
+
#
|
|
182
|
+
# @param fonts [Array] Fonts
|
|
183
|
+
# @return [Array<String>] Issues
|
|
184
|
+
def ttc_issues(fonts)
|
|
185
|
+
issues = []
|
|
186
|
+
|
|
187
|
+
fonts.each_with_index do |font, index|
|
|
188
|
+
if web_font?(font)
|
|
189
|
+
issues << "Font #{index} is WOFF/WOFF2 (not allowed in collections)"
|
|
190
|
+
elsif !truetype_font?(font)
|
|
191
|
+
issues << "Font #{index} is not TrueType (TTC requires TrueType only)"
|
|
192
|
+
end
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
issues
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
# Get OTC compatibility issues
|
|
199
|
+
#
|
|
200
|
+
# @param fonts [Array] Fonts
|
|
201
|
+
# @return [Array<String>] Issues
|
|
202
|
+
def otc_issues(fonts)
|
|
203
|
+
issues = []
|
|
204
|
+
has_cff = false
|
|
205
|
+
|
|
206
|
+
fonts.each_with_index do |font, index|
|
|
207
|
+
if web_font?(font)
|
|
208
|
+
issues << "Font #{index} is WOFF/WOFF2 (not allowed in collections)"
|
|
209
|
+
end
|
|
210
|
+
has_cff = true if cff_font?(font)
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
unless has_cff
|
|
214
|
+
issues << "OTC requires at least one CFF font (all fonts are TrueType)"
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
issues
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
# Get dfont compatibility issues
|
|
221
|
+
#
|
|
222
|
+
# @param fonts [Array] Fonts
|
|
223
|
+
# @return [Array<String>] Issues
|
|
224
|
+
def dfont_issues(fonts)
|
|
225
|
+
issues = []
|
|
226
|
+
|
|
227
|
+
fonts.each_with_index do |font, index|
|
|
228
|
+
if web_font?(font)
|
|
229
|
+
issues << "Font #{index} is WOFF/WOFF2 (not allowed in dfont)"
|
|
230
|
+
end
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
issues
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
# Check if font is a web font
|
|
237
|
+
#
|
|
238
|
+
# @param font [Object] Font object
|
|
239
|
+
# @return [Boolean] true if WOFF or WOFF2
|
|
240
|
+
def web_font?(font)
|
|
241
|
+
font.class.name.include?("Woff")
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
# Check if font is TrueType
|
|
245
|
+
#
|
|
246
|
+
# @param font [Object] Font object
|
|
247
|
+
# @return [Boolean] true if TrueType
|
|
248
|
+
def truetype_font?(font)
|
|
249
|
+
return false unless font.respond_to?(:has_table?)
|
|
250
|
+
|
|
251
|
+
font.has_table?("glyf")
|
|
252
|
+
end
|
|
253
|
+
|
|
254
|
+
# Check if font is CFF/OpenType
|
|
255
|
+
#
|
|
256
|
+
# @param font [Object] Font object
|
|
257
|
+
# @return [Boolean] true if CFF
|
|
258
|
+
def cff_font?(font)
|
|
259
|
+
return false unless font.respond_to?(:has_table?)
|
|
260
|
+
|
|
261
|
+
font.has_table?("CFF ") || font.has_table?("CFF2")
|
|
262
|
+
end
|
|
263
|
+
end
|
|
264
|
+
end
|
|
265
|
+
end
|