fontisan 0.2.5 → 0.2.6

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.
@@ -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,25 @@ module Fontisan
92
94
 
93
95
  File.open(path, "rb") do |io|
94
96
  signature = io.read(4)
95
- signature == Constants::TTC_TAG
97
+ io.rewind
98
+
99
+ # Check for TTC/OTC signature
100
+ return true if signature == Constants::TTC_TAG
101
+
102
+ # Check for multi-font dfont (suitcase) - only if it's actually a dfont
103
+ if signature == Constants::DFONT_RESOURCE_HEADER
104
+ require_relative "parsers/dfont_parser"
105
+ # Verify it's a valid dfont and has multiple fonts
106
+ return Parsers::DfontParser.dfont?(io) && Parsers::DfontParser.sfnt_count(io) > 1
107
+ end
108
+
109
+ false
96
110
  end
97
111
  end
98
112
 
99
113
  # Load a collection object without extracting fonts
100
114
  #
101
- # Returns the collection object (TrueTypeCollection or OpenTypeCollection)
115
+ # Returns the collection object (TrueTypeCollection, OpenTypeCollection, or DfontCollection)
102
116
  # without extracting individual fonts. Useful for inspecting collection
103
117
  # metadata and structure.
104
118
  #
@@ -112,6 +126,10 @@ module Fontisan
112
126
  # - OTC typically contains OpenType fonts (CFF/CFF2 outlines)
113
127
  # - Mixed collections are possible (both TTF and OTF in same collection)
114
128
  #
129
+ # dfont (Data Fork Font) is an Apple-specific format that contains Mac
130
+ # font suitcase resources. It can contain multiple SFNT fonts (TrueType
131
+ # or OpenType).
132
+ #
115
133
  # Each collection can contain multiple SFNT-format font files, with table
116
134
  # deduplication to save space. Individual fonts within a collection are
117
135
  # stored at different offsets within the file, each with their own table
@@ -128,13 +146,16 @@ module Fontisan
128
146
  # 4. If ANY font is OpenType (CFF), returns OpenTypeCollection
129
147
  # 5. Only returns TrueTypeCollection if ALL fonts are TrueType
130
148
  #
149
+ # For dfont files, returns DfontCollection.
150
+ #
131
151
  # This approach correctly handles:
132
152
  # - Homogeneous collections (all TTF or all OTF)
133
153
  # - Mixed collections (both TTF and OTF fonts) - uses OpenTypeCollection
134
154
  # - Large collections with many fonts (like NotoSerifCJK.ttc with 35 fonts)
155
+ # - dfont suitcases (Apple-specific)
135
156
  #
136
157
  # @param path [String] Path to the collection file
137
- # @return [TrueTypeCollection, OpenTypeCollection] The collection object
158
+ # @return [TrueTypeCollection, OpenTypeCollection, DfontCollection] The collection object
138
159
  # @raise [Errno::ENOENT] if file does not exist
139
160
  # @raise [InvalidFontError] if file is not a collection or type cannot be determined
140
161
  #
@@ -146,10 +167,18 @@ module Fontisan
146
167
 
147
168
  File.open(path, "rb") do |io|
148
169
  signature = io.read(4)
170
+ io.rewind
171
+
172
+ # Check for dfont
173
+ if signature == Constants::DFONT_RESOURCE_HEADER || dfont_signature?(io)
174
+ require_relative "dfont_collection"
175
+ return DfontCollection.from_file(path)
176
+ end
149
177
 
178
+ # Check for TTC/OTC
150
179
  unless signature == Constants::TTC_TAG
151
180
  raise InvalidFontError,
152
- "File is not a collection (TTC/OTC). Use FontLoader.load instead."
181
+ "File is not a collection (TTC/OTC/dfont). Use FontLoader.load instead."
153
182
  end
154
183
 
155
184
  # Read version and num_fonts
@@ -291,6 +320,50 @@ mode: LoadingModes::FULL, lazy: true)
291
320
  end
292
321
  end
293
322
 
323
+ # Extract and load font from dfont resource fork
324
+ #
325
+ # @param io [IO] Open file handle
326
+ # @param path [String] Path to dfont file
327
+ # @param font_index [Integer] Font index in suitcase
328
+ # @param mode [Symbol] Loading mode
329
+ # @param lazy [Boolean] Lazy loading flag
330
+ # @return [TrueTypeFont, OpenTypeFont] Loaded font
331
+ # @api private
332
+ def self.extract_and_load_dfont(io, path, font_index, mode, lazy)
333
+ require_relative "parsers/dfont_parser"
334
+
335
+ # Extract SFNT data from resource fork
336
+ sfnt_data = Parsers::DfontParser.extract_sfnt(io, index: font_index)
337
+
338
+ # Create StringIO with SFNT data
339
+ sfnt_io = StringIO.new(sfnt_data)
340
+
341
+ # Detect SFNT signature
342
+ signature = sfnt_io.read(4)
343
+ sfnt_io.rewind
344
+
345
+ # Read and setup font based on signature
346
+ case signature
347
+ when pack_uint32(Constants::SFNT_VERSION_TRUETYPE), "true"
348
+ font = TrueTypeFont.read(sfnt_io)
349
+ font.initialize_storage
350
+ font.loading_mode = mode
351
+ font.lazy_load_enabled = lazy
352
+ font.read_table_data(sfnt_io) unless lazy
353
+ font
354
+ when "OTTO"
355
+ font = OpenTypeFont.read(sfnt_io)
356
+ font.initialize_storage
357
+ font.loading_mode = mode
358
+ font.lazy_load_enabled = lazy
359
+ font.read_table_data(sfnt_io) unless lazy
360
+ font
361
+ else
362
+ raise InvalidFontError,
363
+ "Invalid SFNT data in dfont resource (signature: #{signature.inspect})"
364
+ end
365
+ end
366
+
294
367
  # Pack uint32 value to big-endian bytes
295
368
  #
296
369
  # @param value [Integer] The uint32 value
@@ -301,6 +374,18 @@ mode: LoadingModes::FULL, lazy: true)
301
374
  end
302
375
 
303
376
  private_class_method :load_from_collection, :pack_uint32, :env_mode,
304
- :env_lazy
377
+ :env_lazy, :extract_and_load_dfont
378
+
379
+ # Check if file has dfont signature
380
+ #
381
+ # @param io [IO] Open file handle
382
+ # @return [Boolean] true if dfont
383
+ # @api private
384
+ def self.dfont_signature?(io)
385
+ require_relative "parsers/dfont_parser"
386
+ Parsers::DfontParser.dfont?(io)
387
+ end
388
+
389
+ private_class_method :dfont_signature?
305
390
  end
306
391
  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
- # Keep file handle open for lazy loading
105
- font.io_source = File.open(path, "rb")
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 page-aligned reads and caches pages to ensure lazy loading
454
- # performance is not slower than eager loading.
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
- # Use page-aligned reading with caching
465
- table_start = entry.offset
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] = table_data_parts.join
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)