fontisan 0.2.8 → 0.2.10

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.
@@ -1,42 +1,17 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "bindata"
4
- require_relative "constants"
5
- require_relative "loading_modes"
6
- require_relative "utilities/checksum_calculator"
3
+ require_relative "sfnt_font"
7
4
 
8
5
  module Fontisan
9
- # TTF Offset Table structure
10
- class OffsetTable < BinData::Record
11
- endian :big
12
- uint32 :sfnt_version
13
- uint16 :num_tables
14
- uint16 :search_range
15
- uint16 :entry_selector
16
- uint16 :range_shift
17
- end
18
-
19
- # TTF Table Directory Entry structure
20
- class TableDirectory < BinData::Record
21
- endian :big
22
- string :tag, length: 4
23
- uint32 :checksum
24
- uint32 :offset
25
- uint32 :table_length
26
- end
27
-
28
- # TrueType Font domain object using BinData
29
- #
30
- # Represents a complete TrueType Font file using BinData's declarative
31
- # DSL for binary structure definition. The structure definition IS the
32
- # documentation, and BinData handles all low-level reading/writing.
6
+ # TrueType Font domain object
33
7
  #
34
- # Extended from ExtractTTC to support table parsing for analysis.
8
+ # Represents a TrueType Font file (glyf outlines). Inherits all shared
9
+ # SFNT functionality from SfntFont and adds TrueType-specific behavior.
35
10
  #
36
11
  # @example Reading and analyzing a font
37
12
  # ttf = TrueTypeFont.from_file("font.ttf")
38
13
  # puts ttf.header.num_tables # => 14
39
- # name_table = ttf.table("name") # Fontisan extension
14
+ # name_table = ttf.table("name")
40
15
  # puts name_table.english_name(Tables::Name::FAMILY)
41
16
  #
42
17
  # @example Loading with metadata mode
@@ -46,34 +21,15 @@ module Fontisan
46
21
  #
47
22
  # @example Writing a font
48
23
  # ttf.to_file("output.ttf")
49
- class TrueTypeFont < BinData::Record
50
- endian :big
51
-
52
- offset_table :header
53
- array :tables, type: :table_directory, initial_length: lambda {
54
- header.num_tables
55
- }
56
-
57
- # Table data is stored separately since it's at variable offsets
58
- attr_accessor :table_data
59
-
60
- # Parsed table instances cache (Fontisan extension)
61
- attr_accessor :parsed_tables
62
-
63
- # Loading mode for this font (:metadata or :full)
64
- attr_accessor :loading_mode
65
-
66
- # IO source for lazy loading
67
- attr_accessor :io_source
68
-
69
- # Whether lazy loading is enabled
70
- attr_accessor :lazy_load_enabled
71
-
24
+ #
25
+ # @example Reading from TTC collection
26
+ # ttf = TrueTypeFont.from_ttc(io, offset)
27
+ class TrueTypeFont < SfntFont
72
28
  # Read TrueType Font from a file
73
29
  #
74
30
  # @param path [String] Path to the TTF file
75
31
  # @param mode [Symbol] Loading mode (:metadata or :full, default: :full)
76
- # @param lazy [Boolean] If true, load tables on demand (default: false for eager loading)
32
+ # @param lazy [Boolean] If true, load tables on demand (default: false)
77
33
  # @return [TrueTypeFont] A new instance
78
34
  # @raise [ArgumentError] if path is nil or empty, or if mode is invalid
79
35
  # @raise [Errno::ENOENT] if file does not exist
@@ -127,153 +83,6 @@ module Fontisan
127
83
  font
128
84
  end
129
85
 
130
- # Initialize storage hashes (Fontisan extension)
131
- #
132
- # @return [void]
133
- def initialize_storage
134
- @table_data = {}
135
- @parsed_tables = {}
136
- @loading_mode = LoadingModes::FULL
137
- @lazy_load_enabled = false
138
- @io_source = nil
139
- end
140
-
141
- # Read table data for all tables
142
- #
143
- # In metadata mode, only reads metadata tables. In full mode, reads all tables.
144
- # In lazy load mode, doesn't read data upfront.
145
- #
146
- # @param io [IO] Open file handle
147
- # @return [void]
148
- def read_table_data(io)
149
- @table_data = {}
150
-
151
- if @lazy_load_enabled
152
- # Don't read data, just keep IO reference
153
- @io_source = io
154
- return
155
- end
156
-
157
- if @loading_mode == LoadingModes::METADATA
158
- # Only read metadata tables for performance
159
- # Use page-aware batched reading to maximize filesystem prefetching
160
- read_metadata_tables_batched(io)
161
- else
162
- # Read all tables
163
- tables.each do |entry|
164
- io.seek(entry.offset)
165
- # Force UTF-8 encoding on tag for hash key consistency
166
- tag_key = entry.tag.dup.force_encoding("UTF-8")
167
- @table_data[tag_key] = io.read(entry.table_length)
168
- end
169
- end
170
- end
171
-
172
- # Read metadata tables using page-aware batching
173
- #
174
- # Groups adjacent tables within page boundaries and reads them together
175
- # to maximize filesystem prefetching and minimize random seeks.
176
- #
177
- # @param io [IO] Open file handle
178
- # @return [void]
179
- def read_metadata_tables_batched(io)
180
- # Typical filesystem page size (4KB is common, but 8KB gives better prefetch window)
181
- page_threshold = 8192
182
-
183
- # Get metadata tables sorted by offset for sequential access
184
- metadata_entries = tables.select { |entry| LoadingModes::METADATA_TABLES_SET.include?(entry.tag) }
185
- metadata_entries.sort_by!(&:offset)
186
-
187
- return if metadata_entries.empty?
188
-
189
- # Group adjacent tables within page threshold for batched reading
190
- i = 0
191
- while i < metadata_entries.size
192
- batch_start = metadata_entries[i]
193
- batch_end = batch_start
194
- batch_entries = [batch_start]
195
-
196
- # Extend batch while next table is within page threshold
197
- j = i + 1
198
- while j < metadata_entries.size
199
- next_entry = metadata_entries[j]
200
- gap = next_entry.offset - (batch_end.offset + batch_end.table_length)
201
-
202
- # If gap is small (within page threshold), include in batch
203
- if gap <= page_threshold
204
- batch_end = next_entry
205
- batch_entries << next_entry
206
- j += 1
207
- else
208
- break
209
- end
210
- end
211
-
212
- # Read batch
213
- if batch_entries.size == 1
214
- # Single table, read normally
215
- io.seek(batch_start.offset)
216
- tag_key = batch_start.tag.dup.force_encoding("UTF-8")
217
- @table_data[tag_key] = io.read(batch_start.table_length)
218
- else
219
- # Multiple tables, read contiguous segment
220
- batch_offset = batch_start.offset
221
- batch_length = (batch_end.offset + batch_end.table_length) - batch_start.offset
222
-
223
- io.seek(batch_offset)
224
- batch_data = io.read(batch_length)
225
-
226
- # Extract individual tables from batch
227
- batch_entries.each do |entry|
228
- relative_offset = entry.offset - batch_offset
229
- tag_key = entry.tag.dup.force_encoding("UTF-8")
230
- @table_data[tag_key] =
231
- batch_data[relative_offset, entry.table_length]
232
- end
233
- end
234
-
235
- i = j
236
- end
237
- end
238
-
239
- # Write TrueType Font to a file
240
- #
241
- # Writes the complete TTF structure to disk, including proper checksum
242
- # calculation and table alignment.
243
- #
244
- # @param path [String] Path where the TTF file will be written
245
- # @return [Integer] Number of bytes written
246
- # @raise [IOError] if writing fails
247
- def to_file(path)
248
- File.open(path, "wb") do |io|
249
- # Write header and tables (directory)
250
- write_structure(io)
251
-
252
- # Write table data with updated offsets
253
- write_table_data_with_offsets(io)
254
-
255
- io.pos
256
- end
257
-
258
- # Update checksum adjustment in head table
259
- update_checksum_adjustment_in_file(path) if head_table
260
-
261
- File.size(path)
262
- end
263
-
264
- # Validate format correctness
265
- #
266
- # @return [Boolean] true if the TTF format is valid, false otherwise
267
- def valid?
268
- return false unless header
269
- return false unless tables.respond_to?(:length)
270
- return false unless @table_data.is_a?(Hash)
271
- return false if tables.length != header.num_tables
272
- return false unless head_table
273
-
274
- true
275
- end
276
-
277
86
  # Check if font is TrueType flavored
278
87
  #
279
88
  # @return [Boolean] true for TrueType fonts
@@ -288,194 +97,11 @@ module Fontisan
288
97
  false
289
98
  end
290
99
 
291
- # Check if font has a specific table
292
- #
293
- # @param tag [String] The table tag to check for
294
- # @return [Boolean] true if table exists, false otherwise
295
- def has_table?(tag)
296
- tables.any? { |entry| entry.tag == tag }
297
- end
298
-
299
- # Check if a table is available in the current loading mode
300
- #
301
- # @param tag [String] The table tag to check
302
- # @return [Boolean] true if table is available in current mode
303
- def table_available?(tag)
304
- return false unless has_table?(tag)
305
-
306
- LoadingModes.table_allowed?(@loading_mode, tag)
307
- end
308
-
309
- # Find a table entry by tag
310
- #
311
- # @param tag [String] The table tag to find
312
- # @return [TableDirectory, nil] The table entry or nil
313
- def find_table_entry(tag)
314
- tables.find { |entry| entry.tag == tag }
315
- end
316
-
317
- # Get the head table entry
318
- #
319
- # @return [TableDirectory, nil] The head table entry or nil
320
- def head_table
321
- find_table_entry(Constants::HEAD_TAG)
322
- end
323
-
324
- # Get list of all table tags (Fontisan extension)
325
- #
326
- # @return [Array<String>] Array of table tag strings
327
- def table_names
328
- tables.map(&:tag)
329
- end
330
-
331
- # Get parsed table instance (Fontisan extension)
332
- #
333
- # This method parses the raw table data into a structured table object
334
- # and caches the result for subsequent calls. Enforces mode restrictions.
335
- #
336
- # @param tag [String] The table tag to retrieve
337
- # @return [Tables::*, nil] Parsed table object or nil if not found
338
- # @raise [ArgumentError] if table is not available in current loading mode
339
- def table(tag)
340
- # Check mode restrictions
341
- unless table_available?(tag)
342
- if has_table?(tag)
343
- raise ArgumentError,
344
- "Table '#{tag}' is not available in #{@loading_mode} mode. " \
345
- "Available tables: #{LoadingModes.tables_for(@loading_mode).inspect}"
346
- else
347
- return nil
348
- end
349
- end
350
-
351
- return @parsed_tables[tag] if @parsed_tables.key?(tag)
352
-
353
- # Lazy load table data if enabled
354
- if @lazy_load_enabled && !@table_data.key?(tag)
355
- load_table_data(tag)
356
- end
357
-
358
- @parsed_tables[tag] ||= parse_table(tag)
359
- end
360
-
361
- # Get units per em from head table (Fontisan extension)
362
- #
363
- # @return [Integer, nil] Units per em value
364
- def units_per_em
365
- head = table(Constants::HEAD_TAG)
366
- head&.units_per_em
367
- end
368
-
369
- # Convenience methods for accessing common name table fields
370
- # These are particularly useful in minimal mode
371
-
372
- # Get font family name
373
- #
374
- # @return [String, nil] Family name or nil if not found
375
- def family_name
376
- name_table = table(Constants::NAME_TAG)
377
- name_table&.english_name(Tables::Name::FAMILY)
378
- end
379
-
380
- # Get font subfamily name (e.g., Regular, Bold, Italic)
381
- #
382
- # @return [String, nil] Subfamily name or nil if not found
383
- def subfamily_name
384
- name_table = table(Constants::NAME_TAG)
385
- name_table&.english_name(Tables::Name::SUBFAMILY)
386
- end
387
-
388
- # Get full font name
389
- #
390
- # @return [String, nil] Full name or nil if not found
391
- def full_name
392
- name_table = table(Constants::NAME_TAG)
393
- name_table&.english_name(Tables::Name::FULL_NAME)
394
- end
395
-
396
- # Get PostScript name
397
- #
398
- # @return [String, nil] PostScript name or nil if not found
399
- def post_script_name
400
- name_table = table(Constants::NAME_TAG)
401
- name_table&.english_name(Tables::Name::POSTSCRIPT_NAME)
402
- end
403
-
404
- # Get preferred family name
405
- #
406
- # @return [String, nil] Preferred family name or nil if not found
407
- def preferred_family_name
408
- name_table = table(Constants::NAME_TAG)
409
- name_table&.english_name(Tables::Name::PREFERRED_FAMILY)
410
- end
411
-
412
- # Get preferred subfamily name
413
- #
414
- # @return [String, nil] Preferred subfamily name or nil if not found
415
- def preferred_subfamily_name
416
- name_table = table(Constants::NAME_TAG)
417
- name_table&.english_name(Tables::Name::PREFERRED_SUBFAMILY)
418
- end
419
-
420
- # Close the IO source (for lazy loading)
421
- #
422
- # @return [void]
423
- def close
424
- @io_source&.close
425
- @io_source = nil
426
- end
427
-
428
- # Setup finalizer for cleanup
429
- #
430
- # @return [void]
431
- def setup_finalizer
432
- ObjectSpace.define_finalizer(self, self.class.finalize(@io_source))
433
- end
434
-
435
- # Finalizer proc for closing IO
436
- #
437
- # @param io [IO] The IO object to close
438
- # @return [Proc] The finalizer proc
439
- def self.finalize(io)
440
- proc { io&.close }
441
- end
442
-
443
100
  private
444
101
 
445
- # Load a single table's data on demand
446
- #
447
- # Uses direct seek-and-read for minimal overhead. This ensures lazy loading
448
- # performance is comparable to eager loading when accessing all tables.
102
+ # Map table tag to parser class
449
103
  #
450
- # @param tag [String] The table tag to load
451
- # @return [void]
452
- def load_table_data(tag)
453
- return unless @io_source
454
-
455
- entry = find_table_entry(tag)
456
- return nil unless entry
457
-
458
- # Direct seek and read - same as eager loading but on-demand
459
- @io_source.seek(entry.offset)
460
- tag_key = tag.dup.force_encoding("UTF-8")
461
- @table_data[tag_key] = @io_source.read(entry.table_length)
462
- end
463
-
464
- # Parse a table from raw data (Fontisan extension)
465
- #
466
- # @param tag [String] The table tag to parse
467
- # @return [Tables::*, nil] Parsed table object or nil
468
- def parse_table(tag)
469
- raw_data = @table_data[tag]
470
- return nil unless raw_data
471
-
472
- table_class = table_class_for(tag)
473
- return nil unless table_class
474
-
475
- table_class.read(raw_data)
476
- end
477
-
478
- # Map table tag to parser class (Fontisan extension)
104
+ # TrueType-specific mapping includes glyf/loca tables.
479
105
  #
480
106
  # @param tag [String] The table tag
481
107
  # @return [Class, nil] Table parser class or nil
@@ -502,82 +128,5 @@ module Fontisan
502
128
  "sbix" => Tables::Sbix,
503
129
  }[tag]
504
130
  end
505
-
506
- # Write the structure (header + table directory) to IO
507
- #
508
- # @param io [IO] Open file handle
509
- # @return [void]
510
- def write_structure(io)
511
- # Write header
512
- header.write(io)
513
-
514
- # Write table directory with placeholder offsets
515
- tables.each do |entry|
516
- io.write(entry.tag)
517
- io.write([entry.checksum].pack("N"))
518
- io.write([0].pack("N")) # Placeholder offset
519
- io.write([entry.table_length].pack("N"))
520
- end
521
- end
522
-
523
- # Write table data and update offsets in directory
524
- #
525
- # @param io [IO] Open file handle
526
- # @return [void]
527
- def write_table_data_with_offsets(io)
528
- tables.each_with_index do |entry, index|
529
- # Record current position
530
- current_position = io.pos
531
-
532
- # Write table data
533
- data = @table_data[entry.tag]
534
- raise IOError, "Missing table data for tag '#{entry.tag}'" if data.nil?
535
-
536
- io.write(data)
537
-
538
- # Add padding to align to 4-byte boundary
539
- padding = (Constants::TABLE_ALIGNMENT - (io.pos % Constants::TABLE_ALIGNMENT)) % Constants::TABLE_ALIGNMENT
540
- io.write("\x00" * padding) if padding.positive?
541
-
542
- # Zero out checksumAdjustment field in head table
543
- if entry.tag == Constants::HEAD_TAG
544
- current_pos = io.pos
545
- io.seek(current_position + 8)
546
- io.write([0].pack("N"))
547
- io.seek(current_pos)
548
- end
549
-
550
- # Update offset in table directory
551
- # Table directory starts at byte 12, each entry is 16 bytes
552
- # Offset field is at byte 8 within each entry
553
- directory_offset_position = 12 + (index * 16) + 8
554
- current_pos = io.pos
555
- io.seek(directory_offset_position)
556
- io.write([current_position].pack("N")) # Offset is now known
557
- io.seek(current_pos)
558
- end
559
- end
560
-
561
- # Update checksumAdjustment field in head table
562
- #
563
- # @param path [String] Path to the TTF file
564
- # @return [void]
565
- def update_checksum_adjustment_in_file(path)
566
- File.open(path, "r+b") do |io|
567
- # Calculate checksum directly from IO to avoid Windows Tempfile issues
568
- checksum = Utilities::ChecksumCalculator.calculate_checksum_from_io(io)
569
-
570
- # Calculate adjustment
571
- adjustment = Utilities::ChecksumCalculator.calculate_adjustment(checksum)
572
-
573
- # Find head table position
574
- head_entry = head_table
575
- return unless head_entry
576
-
577
- # Write adjustment to head table (offset 8 within head table)
578
- io.seek(head_entry.offset + 8)
579
- io.write([adjustment].pack("N"))
580
- end
581
- end
582
131
  end
583
132
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Fontisan
4
- VERSION = "0.2.8"
4
+ VERSION = "0.2.10"
5
5
  end
@@ -438,12 +438,13 @@ module Fontisan
438
438
  io.write("\x00" * padding) if padding.positive?
439
439
  end
440
440
 
441
+ # Update checksum adjustment in head table BEFORE closing file
442
+ # This avoids Windows file locking issues when Tempfiles are used
443
+ update_checksum_adjustment_in_io(io)
444
+
441
445
  io.pos
442
446
  end
443
447
 
444
- # Update checksum adjustment in head table
445
- update_checksum_adjustment_in_file(output_path)
446
-
447
448
  File.size(output_path)
448
449
  end
449
450
 
@@ -458,43 +459,58 @@ module Fontisan
458
459
  [search_range, entry_selector, range_shift]
459
460
  end
460
461
 
461
- # Update checksumAdjustment field in head table
462
+ # Update checksumAdjustment field in head table using an open IO object
462
463
  #
463
- # @param path [String] Path to the font file
464
+ # @param io [IO] Open IO object positioned at start of file
464
465
  # @return [void]
465
- def update_checksum_adjustment_in_file(path)
466
- # Find head table position in output file
466
+ def update_checksum_adjustment_in_io(io)
467
+ # Save current position
468
+ current_pos = io.pos
469
+
470
+ # Find head table position in the file
467
471
  head_offset = nil
468
- File.open(path, "rb") do |io|
469
- io.seek(4) # Skip sfnt_version
470
- num_tables = io.read(2).unpack1("n")
471
- io.seek(12) # Start of table directory
472
-
473
- num_tables.times do
474
- tag = io.read(4)
475
- io.read(4) # checksum
476
- offset = io.read(4).unpack1("N")
477
- io.read(4) # length
478
-
479
- if tag == Constants::HEAD_TAG
480
- head_offset = offset
481
- break
482
- end
472
+ io.seek(4) # Skip sfnt_version
473
+ num_tables = io.read(2).unpack1("n")
474
+ io.seek(12) # Start of table directory
475
+
476
+ num_tables.times do
477
+ tag = io.read(4)
478
+ io.read(4) # checksum
479
+ offset = io.read(4).unpack1("N")
480
+ io.read(4) # length
481
+
482
+ if tag == Constants::HEAD_TAG
483
+ head_offset = offset
484
+ break
483
485
  end
484
486
  end
485
487
 
486
488
  return unless head_offset
487
489
 
490
+ # Rewind to calculate checksum from the beginning
491
+ io.rewind
492
+
488
493
  # Calculate checksum directly from IO to avoid Windows Tempfile issues
489
- File.open(path, "r+b") do |io|
490
- checksum = Utilities::ChecksumCalculator.calculate_checksum_from_io(io)
494
+ checksum = Utilities::ChecksumCalculator.calculate_checksum_from_io(io)
491
495
 
492
- # Calculate adjustment
493
- adjustment = Utilities::ChecksumCalculator.calculate_adjustment(checksum)
496
+ # Calculate adjustment
497
+ adjustment = Utilities::ChecksumCalculator.calculate_adjustment(checksum)
494
498
 
495
- # Write adjustment to head table (offset 8 within head table)
496
- io.seek(head_offset + 8)
497
- io.write([adjustment].pack("N"))
499
+ # Write adjustment to head table (offset 8 within head table)
500
+ io.seek(head_offset + 8)
501
+ io.write([adjustment].pack("N"))
502
+
503
+ # Restore original position
504
+ io.seek(current_pos)
505
+ end
506
+
507
+ # Update checksumAdjustment field in head table
508
+ #
509
+ # @param path [String] Path to the font file
510
+ # @return [void]
511
+ def update_checksum_adjustment_in_file(path)
512
+ File.open(path, "r+b") do |io|
513
+ update_checksum_adjustment_in_io(io)
498
514
  end
499
515
  end
500
516
  end