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.
@@ -0,0 +1,94 @@
1
+ = WOFF and WOFF2 Font Format Support
2
+
3
+ == General
4
+
5
+ Fontisan provides comprehensive support for Web Open Font Format (WOFF) and WOFF2,
6
+ including reading, writing, and conversion between these and other font formats.
7
+
8
+ WOFF is a wrapper around SFNT fonts (TTF/OTF) using zlib compression, while WOFF2
9
+ uses Brotli compression for better compression ratios and table transformations.
10
+
11
+ === Display WOFF/WOFF2 font information
12
+
13
+ Show metadata specific to WOFF/WOFF2 fonts including version, compression details,
14
+ and original font flavor.
15
+
16
+ [source,shell]
17
+ ----
18
+ $ fontisan info FONT.woff [--format FORMAT]
19
+ ----
20
+
21
+ Where,
22
+
23
+ `FONT.woff`:: Path to the WOFF or WOFF2 file
24
+ `FORMAT`:: Output format: `text` (default), `json`, or `yaml`
25
+
26
+ .Major version from WOFF/WOFF2 header
27
+ [example]
28
+ ====
29
+ [source,shell]
30
+ ----
31
+ $ fontisan info /path/to/your/font.woff
32
+
33
+ Font type: WOFF (TrueType)
34
+ Family: Example Font
35
+ WOFF Major Version: 1
36
+ WOFF Minor Version: 0
37
+ WOFF Metadata: 0 entries
38
+ WOFF Private Data: 0 bytes
39
+ Original Font Flavor: TrueType
40
+ ----
41
+ ====
42
+
43
+ === WOFF2 validation
44
+
45
+ Fontisan validates WOFF2 fonts according to the specification, checking:
46
+
47
+ * Required table presence and order
48
+ * Table transformation flags (glyf, loca transformations)
49
+ * Checksum validation for transformed tables
50
+ * Compression format validation
51
+
52
+ .Validation example
53
+ [example]
54
+ ====
55
+ [source,shell]
56
+ ----
57
+ $ fontisan validate font.woff2 -t web
58
+
59
+ CHECK_ID | STATUS | SEVERITY
60
+ --------------------------------------------------------------
61
+ woff2_required_tables | PASS | error
62
+ woff2_table_transformations | PASS | error
63
+ woff2_checksum_validation | PASS | error
64
+ ----
65
+ ====
66
+
67
+ === Convert fonts to WOFF/WOFF2
68
+
69
+ Convert TTF/OTF fonts to web-optimized formats.
70
+
71
+ [source,shell]
72
+ ----
73
+ $ fontisan convert INPUT.ttf --to woff --output output.woff
74
+ $ fontisan convert INPUT.ttf --to woff2 --output output.woff2
75
+ ----
76
+
77
+ .Conversion with optimization
78
+ [example]
79
+ ====
80
+ [source,shell]
81
+ ----
82
+ $ fontisan convert font.ttf --to woff2 --output font.woff2
83
+
84
+ Converting font.ttf to WOFF2...
85
+ Input: 245.8 KB (TTF)
86
+ Output: 89.2 KB (WOFF2)
87
+ Ratio: 63.7% reduction
88
+
89
+ Compression details:
90
+ Tables: 12
91
+ Brotli compression: Applied
92
+ Transformed tables: glyf, loca
93
+ ----
94
+ ====
@@ -1,15 +1,13 @@
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
- # OpenType Font domain object using BinData
6
+ # OpenType Font domain object
10
7
  #
11
- # Represents a complete OpenType Font file (CFF outlines) using BinData's declarative
12
- # DSL for binary structure definition. Parallel to TrueTypeFont but for CFF format.
8
+ # Represents an OpenType Font file (CFF outlines). Inherits all shared
9
+ # SFNT functionality from SfntFont and adds OpenType-specific behavior
10
+ # including page-aligned lazy loading for optimal performance.
13
11
  #
14
12
  # @example Reading and analyzing a font
15
13
  # otf = OpenTypeFont.from_file("font.otf")
@@ -24,29 +22,10 @@ module Fontisan
24
22
  #
25
23
  # @example Writing a font
26
24
  # otf.to_file("output.otf")
27
- class OpenTypeFont < BinData::Record
28
- endian :big
29
-
30
- offset_table :header
31
- array :tables, type: :table_directory, initial_length: lambda {
32
- header.num_tables
33
- }
34
-
35
- # Table data is stored separately since it's at variable offsets
36
- attr_accessor :table_data
37
-
38
- # Parsed table instances cache
39
- attr_accessor :parsed_tables
40
-
41
- # Loading mode for this font (:metadata or :full)
42
- attr_accessor :loading_mode
43
-
44
- # IO source for lazy loading
45
- attr_accessor :io_source
46
-
47
- # Whether lazy loading is enabled
48
- attr_accessor :lazy_load_enabled
49
-
25
+ #
26
+ # @example Reading from TTC collection
27
+ # otf = OpenTypeFont.from_collection(io, offset)
28
+ class OpenTypeFont < SfntFont
50
29
  # Page cache for lazy loading (maps page_start_offset => page_data)
51
30
  attr_accessor :page_cache
52
31
 
@@ -57,7 +36,7 @@ module Fontisan
57
36
  #
58
37
  # @param path [String] Path to the OTF file
59
38
  # @param mode [Symbol] Loading mode (:metadata or :full, default: :full)
60
- # @param lazy [Boolean] If true, load tables on demand (default: false for eager loading)
39
+ # @param lazy [Boolean] If true, load tables on demand (default: false)
61
40
  # @return [OpenTypeFont] A new instance
62
41
  # @raise [ArgumentError] if path is nil or empty, or if mode is invalid
63
42
  # @raise [Errno::ENOENT] if file does not exist
@@ -93,167 +72,23 @@ module Fontisan
93
72
  raise "Invalid OTF file: #{e.message}"
94
73
  end
95
74
 
96
- # Read OpenType Font from collection at specific offset
97
- #
98
- # @param io [IO] Open file handle
99
- # @param offset [Integer] Byte offset to the font
100
- # @param mode [Symbol] Loading mode (:metadata or :full, default: :full)
101
- # @return [OpenTypeFont] A new instance
102
- def self.from_collection(io, offset, mode: LoadingModes::FULL)
103
- LoadingModes.validate_mode!(mode)
104
-
105
- io.seek(offset)
106
- font = read(io)
107
- font.initialize_storage
108
- font.loading_mode = mode
109
- font.read_table_data(io)
110
- font
111
- end
112
-
113
75
  # Initialize storage hashes
114
76
  #
77
+ # Extends base class to add page_cache for lazy loading.
78
+ #
115
79
  # @return [void]
116
80
  def initialize_storage
117
- @table_data = {}
118
- @parsed_tables = {}
119
- @loading_mode = LoadingModes::FULL
120
- @lazy_load_enabled = false
121
- @io_source = nil
81
+ super
122
82
  @page_cache = {}
123
83
  end
124
84
 
125
- # Read table data for all tables
126
- #
127
- # In metadata mode, only reads metadata tables. In full mode, reads all tables.
128
- # In lazy load mode, doesn't read data upfront.
129
- #
130
- # @param io [IO] Open file handle
131
- # @return [void]
132
- def read_table_data(io)
133
- @table_data = {}
134
-
135
- if @lazy_load_enabled
136
- # Don't read data, just keep IO reference
137
- @io_source = io
138
- return
139
- end
140
-
141
- if @loading_mode == LoadingModes::METADATA
142
- # Only read metadata tables for performance
143
- # Use page-aware batched reading to maximize filesystem prefetching
144
- read_metadata_tables_batched(io)
145
- else
146
- # Read all tables
147
- tables.each do |entry|
148
- io.seek(entry.offset)
149
- # Force UTF-8 encoding on tag for hash key consistency
150
- tag_key = entry.tag.dup.force_encoding("UTF-8")
151
- @table_data[tag_key] = io.read(entry.table_length)
152
- end
153
- end
154
- end
155
-
156
- # Read metadata tables using page-aware batching
157
- #
158
- # Groups adjacent tables within page boundaries and reads them together
159
- # to maximize filesystem prefetching and minimize random seeks.
160
- #
161
- # @param io [IO] Open file handle
162
- # @return [void]
163
- def read_metadata_tables_batched(io)
164
- # Typical filesystem page size (4KB is common, but 8KB gives better prefetch window)
165
- page_threshold = 8192
166
-
167
- # Get metadata tables sorted by offset for sequential access
168
- metadata_entries = tables.select { |entry| LoadingModes::METADATA_TABLES_SET.include?(entry.tag) }
169
- metadata_entries.sort_by!(&:offset)
170
-
171
- return if metadata_entries.empty?
172
-
173
- # Group adjacent tables within page threshold for batched reading
174
- i = 0
175
- while i < metadata_entries.size
176
- batch_start = metadata_entries[i]
177
- batch_end = batch_start
178
- batch_entries = [batch_start]
179
-
180
- # Extend batch while next table is within page threshold
181
- j = i + 1
182
- while j < metadata_entries.size
183
- next_entry = metadata_entries[j]
184
- gap = next_entry.offset - (batch_end.offset + batch_end.table_length)
185
-
186
- # If gap is small (within page threshold), include in batch
187
- if gap <= page_threshold
188
- batch_end = next_entry
189
- batch_entries << next_entry
190
- j += 1
191
- else
192
- break
193
- end
194
- end
195
-
196
- # Read batch
197
- if batch_entries.size == 1
198
- # Single table, read normally
199
- io.seek(batch_start.offset)
200
- tag_key = batch_start.tag.dup.force_encoding("UTF-8")
201
- @table_data[tag_key] = io.read(batch_start.table_length)
202
- else
203
- # Multiple tables, read contiguous segment
204
- batch_offset = batch_start.offset
205
- batch_length = (batch_end.offset + batch_end.table_length) - batch_start.offset
206
-
207
- io.seek(batch_offset)
208
- batch_data = io.read(batch_length)
209
-
210
- # Extract individual tables from batch
211
- batch_entries.each do |entry|
212
- relative_offset = entry.offset - batch_offset
213
- tag_key = entry.tag.dup.force_encoding("UTF-8")
214
- @table_data[tag_key] =
215
- batch_data[relative_offset, entry.table_length]
216
- end
217
- end
218
-
219
- i = j
220
- end
221
- end
222
-
223
- # Write OpenType Font to a file
224
- #
225
- # Writes the complete OTF structure to disk, including proper checksum
226
- # calculation and table alignment.
227
- #
228
- # @param path [String] Path where the OTF file will be written
229
- # @return [Integer] Number of bytes written
230
- # @raise [IOError] if writing fails
231
- def to_file(path)
232
- File.open(path, "wb") do |io|
233
- # Write header and tables (directory)
234
- write_structure(io)
235
-
236
- # Write table data with updated offsets
237
- write_table_data_with_offsets(io)
238
-
239
- io.pos
240
- end
241
-
242
- # Update checksum adjustment in head table
243
- update_checksum_adjustment_in_file(path) if head_table
244
-
245
- File.size(path)
246
- end
247
-
248
85
  # Validate format correctness
249
86
  #
87
+ # Extends base class to check for CFF table (OpenType-specific).
88
+ #
250
89
  # @return [Boolean] true if the OTF format is valid, false otherwise
251
90
  def valid?
252
- return false unless header
253
- return false unless tables.respond_to?(:length)
254
- return false unless @table_data.is_a?(Hash)
255
- return false if tables.length != header.num_tables
256
- return false unless head_table
91
+ return false unless super
257
92
  return false unless has_table?(Constants::CFF_TAG)
258
93
 
259
94
  true
@@ -273,158 +108,6 @@ module Fontisan
273
108
  true
274
109
  end
275
110
 
276
- # Check if font has a specific table
277
- #
278
- # @param tag [String] The table tag to check for
279
- # @return [Boolean] true if table exists, false otherwise
280
- def has_table?(tag)
281
- tables.any? { |entry| entry.tag == tag }
282
- end
283
-
284
- # Check if a table is available in the current loading mode
285
- #
286
- # @param tag [String] The table tag to check
287
- # @return [Boolean] true if table is available in current mode
288
- def table_available?(tag)
289
- return false unless has_table?(tag)
290
-
291
- LoadingModes.table_allowed?(@loading_mode, tag)
292
- end
293
-
294
- # Find a table entry by tag
295
- #
296
- # @param tag [String] The table tag to find
297
- # @return [TableDirectory, nil] The table entry or nil
298
- def find_table_entry(tag)
299
- tables.find { |entry| entry.tag == tag }
300
- end
301
-
302
- # Get the head table entry
303
- #
304
- # @return [TableDirectory, nil] The head table entry or nil
305
- def head_table
306
- find_table_entry(Constants::HEAD_TAG)
307
- end
308
-
309
- # Get list of all table tags
310
- #
311
- # @return [Array<String>] Array of table tag strings
312
- def table_names
313
- tables.map(&:tag)
314
- end
315
-
316
- # Get parsed table instance
317
- #
318
- # This method parses the raw table data into a structured table object
319
- # and caches the result for subsequent calls. Enforces mode restrictions.
320
- #
321
- # @param tag [String] The table tag to retrieve
322
- # @return [Tables::*, nil] Parsed table object or nil if not found
323
- # @raise [ArgumentError] if table is not available in current loading mode
324
- def table(tag)
325
- # Check mode restrictions
326
- unless table_available?(tag)
327
- if has_table?(tag)
328
- raise ArgumentError,
329
- "Table '#{tag}' is not available in #{@loading_mode} mode. " \
330
- "Available tables: #{LoadingModes.tables_for(@loading_mode).inspect}"
331
- else
332
- return nil
333
- end
334
- end
335
-
336
- return @parsed_tables[tag] if @parsed_tables.key?(tag)
337
-
338
- # Lazy load table data if enabled
339
- if @lazy_load_enabled && !@table_data.key?(tag)
340
- load_table_data(tag)
341
- end
342
-
343
- @parsed_tables[tag] ||= parse_table(tag)
344
- end
345
-
346
- # Get units per em from head table
347
- #
348
- # @return [Integer, nil] Units per em value
349
- def units_per_em
350
- head = table(Constants::HEAD_TAG)
351
- head&.units_per_em
352
- end
353
-
354
- # Convenience methods for accessing common name table fields
355
- # These are particularly useful in minimal mode
356
-
357
- # Get font family name
358
- #
359
- # @return [String, nil] Family name or nil if not found
360
- def family_name
361
- name_table = table(Constants::NAME_TAG)
362
- name_table&.english_name(Tables::Name::FAMILY)
363
- end
364
-
365
- # Get font subfamily name (e.g., Regular, Bold, Italic)
366
- #
367
- # @return [String, nil] Subfamily name or nil if not found
368
- def subfamily_name
369
- name_table = table(Constants::NAME_TAG)
370
- name_table&.english_name(Tables::Name::SUBFAMILY)
371
- end
372
-
373
- # Get full font name
374
- #
375
- # @return [String, nil] Full name or nil if not found
376
- def full_name
377
- name_table = table(Constants::NAME_TAG)
378
- name_table&.english_name(Tables::Name::FULL_NAME)
379
- end
380
-
381
- # Get PostScript name
382
- #
383
- # @return [String, nil] PostScript name or nil if not found
384
- def post_script_name
385
- name_table = table(Constants::NAME_TAG)
386
- name_table&.english_name(Tables::Name::POSTSCRIPT_NAME)
387
- end
388
-
389
- # Get preferred family name
390
- #
391
- # @return [String, nil] Preferred family name or nil if not found
392
- def preferred_family_name
393
- name_table = table(Constants::NAME_TAG)
394
- name_table&.english_name(Tables::Name::PREFERRED_FAMILY)
395
- end
396
-
397
- # Get preferred subfamily name
398
- #
399
- # @return [String, nil] Preferred subfamily name or nil if not found
400
- def preferred_subfamily_name
401
- name_table = table(Constants::NAME_TAG)
402
- name_table&.english_name(Tables::Name::PREFERRED_SUBFAMILY)
403
- end
404
-
405
- # Close the IO source (for lazy loading)
406
- #
407
- # @return [void]
408
- def close
409
- @io_source&.close
410
- @io_source = nil
411
- end
412
-
413
- # Setup finalizer for cleanup
414
- #
415
- # @return [void]
416
- def setup_finalizer
417
- ObjectSpace.define_finalizer(self, self.class.finalize(@io_source))
418
- end
419
-
420
- # Finalizer proc for closing IO
421
- #
422
- # @param io [IO] The IO object to close
423
- # @return [Proc] The finalizer proc
424
- def self.finalize(io)
425
- proc { io&.close }
426
- end
427
-
428
111
  private
429
112
 
430
113
  # Load a single table's data on demand
@@ -478,22 +161,10 @@ module Fontisan
478
161
  @table_data[tag_key] = table_data_parts.join
479
162
  end
480
163
 
481
- # Parse a table from raw data
482
- #
483
- # @param tag [String] The table tag to parse
484
- # @return [Tables::*, nil] Parsed table object or nil
485
- def parse_table(tag)
486
- raw_data = @table_data[tag]
487
- return nil unless raw_data
488
-
489
- table_class = table_class_for(tag)
490
- return nil unless table_class
491
-
492
- table_class.read(raw_data)
493
- end
494
-
495
164
  # Map table tag to parser class
496
165
  #
166
+ # OpenType-specific mapping includes CFF table.
167
+ #
497
168
  # @param tag [String] The table tag
498
169
  # @return [Class, nil] Table parser class or nil
499
170
  def table_class_for(tag)
@@ -520,82 +191,5 @@ module Fontisan
520
191
  "sbix" => Tables::Sbix,
521
192
  }[tag]
522
193
  end
523
-
524
- # Write the structure (header + table directory) to IO
525
- #
526
- # @param io [IO] Open file handle
527
- # @return [void]
528
- def write_structure(io)
529
- # Write header
530
- header.write(io)
531
-
532
- # Write table directory with placeholder offsets
533
- tables.each do |entry|
534
- io.write(entry.tag)
535
- io.write([entry.checksum].pack("N"))
536
- io.write([0].pack("N")) # Placeholder offset
537
- io.write([entry.table_length].pack("N"))
538
- end
539
- end
540
-
541
- # Write table data and update offsets in directory
542
- #
543
- # @param io [IO] Open file handle
544
- # @return [void]
545
- def write_table_data_with_offsets(io)
546
- tables.each_with_index do |entry, index|
547
- # Record current position
548
- current_position = io.pos
549
-
550
- # Write table data
551
- data = @table_data[entry.tag]
552
- raise IOError, "Missing table data for tag '#{entry.tag}'" if data.nil?
553
-
554
- io.write(data)
555
-
556
- # Add padding to align to 4-byte boundary
557
- padding = (Constants::TABLE_ALIGNMENT - (io.pos % Constants::TABLE_ALIGNMENT)) % Constants::TABLE_ALIGNMENT
558
- io.write("\x00" * padding) if padding.positive?
559
-
560
- # Zero out checksumAdjustment field in head table
561
- if entry.tag == Constants::HEAD_TAG
562
- current_pos = io.pos
563
- io.seek(current_position + 8)
564
- io.write([0].pack("N"))
565
- io.seek(current_pos)
566
- end
567
-
568
- # Update offset in table directory
569
- # Table directory starts at byte 12, each entry is 16 bytes
570
- # Offset field is at byte 8 within each entry
571
- directory_offset_position = 12 + (index * 16) + 8
572
- current_pos = io.pos
573
- io.seek(directory_offset_position)
574
- io.write([current_position].pack("N"))
575
- io.seek(current_pos)
576
- end
577
- end
578
-
579
- # Update checksumAdjustment field in head table
580
- #
581
- # @param path [String] Path to the OTF file
582
- # @return [void]
583
- def update_checksum_adjustment_in_file(path)
584
- File.open(path, "r+b") do |io|
585
- # Calculate checksum directly from IO to avoid Windows Tempfile issues
586
- checksum = Utilities::ChecksumCalculator.calculate_checksum_from_io(io)
587
-
588
- # Calculate adjustment
589
- adjustment = Utilities::ChecksumCalculator.calculate_adjustment(checksum)
590
-
591
- # Find head table position
592
- head_entry = head_table
593
- return unless head_entry
594
-
595
- # Write adjustment to head table (offset 8 within head table)
596
- io.seek(head_entry.offset + 8)
597
- io.write([adjustment].pack("N"))
598
- end
599
- end
600
194
  end
601
195
  end