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.
@@ -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
- 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 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 OpenTypeCollection)
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
- # 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)
@@ -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