fontisan 0.1.0 → 0.2.0

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.
Files changed (185) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop_todo.yml +529 -65
  3. data/Gemfile +1 -0
  4. data/LICENSE +5 -1
  5. data/README.adoc +1301 -275
  6. data/Rakefile +27 -2
  7. data/benchmark/variation_quick_bench.rb +47 -0
  8. data/docs/EXTRACT_TTC_MIGRATION.md +549 -0
  9. data/fontisan.gemspec +4 -1
  10. data/lib/fontisan/binary/base_record.rb +22 -1
  11. data/lib/fontisan/cli.rb +309 -0
  12. data/lib/fontisan/collection/builder.rb +260 -0
  13. data/lib/fontisan/collection/offset_calculator.rb +227 -0
  14. data/lib/fontisan/collection/table_analyzer.rb +204 -0
  15. data/lib/fontisan/collection/table_deduplicator.rb +241 -0
  16. data/lib/fontisan/collection/writer.rb +306 -0
  17. data/lib/fontisan/commands/base_command.rb +8 -1
  18. data/lib/fontisan/commands/convert_command.rb +291 -0
  19. data/lib/fontisan/commands/export_command.rb +161 -0
  20. data/lib/fontisan/commands/info_command.rb +40 -6
  21. data/lib/fontisan/commands/instance_command.rb +295 -0
  22. data/lib/fontisan/commands/ls_command.rb +113 -0
  23. data/lib/fontisan/commands/pack_command.rb +241 -0
  24. data/lib/fontisan/commands/subset_command.rb +245 -0
  25. data/lib/fontisan/commands/unpack_command.rb +338 -0
  26. data/lib/fontisan/commands/validate_command.rb +178 -0
  27. data/lib/fontisan/commands/variable_command.rb +30 -1
  28. data/lib/fontisan/config/collection_settings.yml +56 -0
  29. data/lib/fontisan/config/conversion_matrix.yml +212 -0
  30. data/lib/fontisan/config/export_settings.yml +66 -0
  31. data/lib/fontisan/config/subset_profiles.yml +100 -0
  32. data/lib/fontisan/config/svg_settings.yml +60 -0
  33. data/lib/fontisan/config/validation_rules.yml +149 -0
  34. data/lib/fontisan/config/variable_settings.yml +99 -0
  35. data/lib/fontisan/config/woff2_settings.yml +77 -0
  36. data/lib/fontisan/constants.rb +69 -0
  37. data/lib/fontisan/converters/conversion_strategy.rb +96 -0
  38. data/lib/fontisan/converters/format_converter.rb +259 -0
  39. data/lib/fontisan/converters/outline_converter.rb +936 -0
  40. data/lib/fontisan/converters/svg_generator.rb +244 -0
  41. data/lib/fontisan/converters/table_copier.rb +117 -0
  42. data/lib/fontisan/converters/woff2_encoder.rb +416 -0
  43. data/lib/fontisan/converters/woff_writer.rb +391 -0
  44. data/lib/fontisan/error.rb +203 -0
  45. data/lib/fontisan/export/exporter.rb +262 -0
  46. data/lib/fontisan/export/table_serializer.rb +255 -0
  47. data/lib/fontisan/export/transformers/font_to_ttx.rb +172 -0
  48. data/lib/fontisan/export/transformers/head_transformer.rb +96 -0
  49. data/lib/fontisan/export/transformers/hhea_transformer.rb +59 -0
  50. data/lib/fontisan/export/transformers/maxp_transformer.rb +63 -0
  51. data/lib/fontisan/export/transformers/name_transformer.rb +63 -0
  52. data/lib/fontisan/export/transformers/os2_transformer.rb +121 -0
  53. data/lib/fontisan/export/transformers/post_transformer.rb +51 -0
  54. data/lib/fontisan/export/ttx_generator.rb +527 -0
  55. data/lib/fontisan/export/ttx_parser.rb +300 -0
  56. data/lib/fontisan/font_loader.rb +121 -12
  57. data/lib/fontisan/font_writer.rb +301 -0
  58. data/lib/fontisan/formatters/text_formatter.rb +102 -0
  59. data/lib/fontisan/glyph_accessor.rb +503 -0
  60. data/lib/fontisan/hints/hint_converter.rb +177 -0
  61. data/lib/fontisan/hints/postscript_hint_applier.rb +185 -0
  62. data/lib/fontisan/hints/postscript_hint_extractor.rb +254 -0
  63. data/lib/fontisan/hints/truetype_hint_applier.rb +71 -0
  64. data/lib/fontisan/hints/truetype_hint_extractor.rb +162 -0
  65. data/lib/fontisan/loading_modes.rb +113 -0
  66. data/lib/fontisan/metrics_calculator.rb +277 -0
  67. data/lib/fontisan/models/collection_font_summary.rb +52 -0
  68. data/lib/fontisan/models/collection_info.rb +76 -0
  69. data/lib/fontisan/models/collection_list_info.rb +37 -0
  70. data/lib/fontisan/models/font_export.rb +158 -0
  71. data/lib/fontisan/models/font_summary.rb +48 -0
  72. data/lib/fontisan/models/glyph_outline.rb +343 -0
  73. data/lib/fontisan/models/hint.rb +233 -0
  74. data/lib/fontisan/models/outline.rb +664 -0
  75. data/lib/fontisan/models/table_sharing_info.rb +40 -0
  76. data/lib/fontisan/models/ttx/glyph_order.rb +31 -0
  77. data/lib/fontisan/models/ttx/tables/binary_table.rb +67 -0
  78. data/lib/fontisan/models/ttx/tables/head_table.rb +74 -0
  79. data/lib/fontisan/models/ttx/tables/hhea_table.rb +74 -0
  80. data/lib/fontisan/models/ttx/tables/maxp_table.rb +55 -0
  81. data/lib/fontisan/models/ttx/tables/name_table.rb +45 -0
  82. data/lib/fontisan/models/ttx/tables/os2_table.rb +157 -0
  83. data/lib/fontisan/models/ttx/tables/post_table.rb +50 -0
  84. data/lib/fontisan/models/ttx/ttfont.rb +49 -0
  85. data/lib/fontisan/models/validation_report.rb +203 -0
  86. data/lib/fontisan/open_type_collection.rb +156 -2
  87. data/lib/fontisan/open_type_font.rb +296 -10
  88. data/lib/fontisan/optimizers/charstring_rewriter.rb +161 -0
  89. data/lib/fontisan/optimizers/pattern_analyzer.rb +308 -0
  90. data/lib/fontisan/optimizers/stack_tracker.rb +246 -0
  91. data/lib/fontisan/optimizers/subroutine_builder.rb +134 -0
  92. data/lib/fontisan/optimizers/subroutine_generator.rb +207 -0
  93. data/lib/fontisan/optimizers/subroutine_optimizer.rb +107 -0
  94. data/lib/fontisan/outline_extractor.rb +423 -0
  95. data/lib/fontisan/subset/builder.rb +268 -0
  96. data/lib/fontisan/subset/glyph_mapping.rb +215 -0
  97. data/lib/fontisan/subset/options.rb +142 -0
  98. data/lib/fontisan/subset/profile.rb +152 -0
  99. data/lib/fontisan/subset/table_subsetter.rb +461 -0
  100. data/lib/fontisan/svg/font_face_generator.rb +278 -0
  101. data/lib/fontisan/svg/font_generator.rb +264 -0
  102. data/lib/fontisan/svg/glyph_generator.rb +168 -0
  103. data/lib/fontisan/svg/view_box_calculator.rb +137 -0
  104. data/lib/fontisan/tables/cff/cff_glyph.rb +176 -0
  105. data/lib/fontisan/tables/cff/charset.rb +282 -0
  106. data/lib/fontisan/tables/cff/charstring.rb +905 -0
  107. data/lib/fontisan/tables/cff/charstring_builder.rb +322 -0
  108. data/lib/fontisan/tables/cff/charstrings_index.rb +162 -0
  109. data/lib/fontisan/tables/cff/dict.rb +351 -0
  110. data/lib/fontisan/tables/cff/dict_builder.rb +242 -0
  111. data/lib/fontisan/tables/cff/encoding.rb +274 -0
  112. data/lib/fontisan/tables/cff/header.rb +102 -0
  113. data/lib/fontisan/tables/cff/index.rb +237 -0
  114. data/lib/fontisan/tables/cff/index_builder.rb +170 -0
  115. data/lib/fontisan/tables/cff/private_dict.rb +284 -0
  116. data/lib/fontisan/tables/cff/top_dict.rb +236 -0
  117. data/lib/fontisan/tables/cff.rb +487 -0
  118. data/lib/fontisan/tables/cff2/blend_operator.rb +240 -0
  119. data/lib/fontisan/tables/cff2/charstring_parser.rb +591 -0
  120. data/lib/fontisan/tables/cff2/operand_stack.rb +232 -0
  121. data/lib/fontisan/tables/cff2.rb +341 -0
  122. data/lib/fontisan/tables/cvar.rb +242 -0
  123. data/lib/fontisan/tables/fvar.rb +2 -2
  124. data/lib/fontisan/tables/glyf/compound_glyph.rb +483 -0
  125. data/lib/fontisan/tables/glyf/compound_glyph_resolver.rb +136 -0
  126. data/lib/fontisan/tables/glyf/curve_converter.rb +343 -0
  127. data/lib/fontisan/tables/glyf/glyph_builder.rb +450 -0
  128. data/lib/fontisan/tables/glyf/simple_glyph.rb +382 -0
  129. data/lib/fontisan/tables/glyf.rb +235 -0
  130. data/lib/fontisan/tables/gvar.rb +270 -0
  131. data/lib/fontisan/tables/hhea.rb +124 -0
  132. data/lib/fontisan/tables/hmtx.rb +287 -0
  133. data/lib/fontisan/tables/hvar.rb +191 -0
  134. data/lib/fontisan/tables/loca.rb +322 -0
  135. data/lib/fontisan/tables/maxp.rb +192 -0
  136. data/lib/fontisan/tables/mvar.rb +185 -0
  137. data/lib/fontisan/tables/name.rb +99 -30
  138. data/lib/fontisan/tables/variation_common.rb +346 -0
  139. data/lib/fontisan/tables/vvar.rb +234 -0
  140. data/lib/fontisan/true_type_collection.rb +156 -2
  141. data/lib/fontisan/true_type_font.rb +297 -11
  142. data/lib/fontisan/utilities/brotli_wrapper.rb +159 -0
  143. data/lib/fontisan/utilities/checksum_calculator.rb +18 -0
  144. data/lib/fontisan/utils/thread_pool.rb +134 -0
  145. data/lib/fontisan/validation/checksum_validator.rb +170 -0
  146. data/lib/fontisan/validation/consistency_validator.rb +197 -0
  147. data/lib/fontisan/validation/structure_validator.rb +198 -0
  148. data/lib/fontisan/validation/table_validator.rb +158 -0
  149. data/lib/fontisan/validation/validator.rb +152 -0
  150. data/lib/fontisan/variable/axis_normalizer.rb +215 -0
  151. data/lib/fontisan/variable/delta_applicator.rb +313 -0
  152. data/lib/fontisan/variable/glyph_delta_processor.rb +218 -0
  153. data/lib/fontisan/variable/instancer.rb +344 -0
  154. data/lib/fontisan/variable/metric_delta_processor.rb +282 -0
  155. data/lib/fontisan/variable/region_matcher.rb +208 -0
  156. data/lib/fontisan/variable/static_font_builder.rb +213 -0
  157. data/lib/fontisan/variable/table_updater.rb +219 -0
  158. data/lib/fontisan/variation/blend_applier.rb +199 -0
  159. data/lib/fontisan/variation/cache.rb +298 -0
  160. data/lib/fontisan/variation/cache_key_builder.rb +162 -0
  161. data/lib/fontisan/variation/converter.rb +268 -0
  162. data/lib/fontisan/variation/data_extractor.rb +86 -0
  163. data/lib/fontisan/variation/delta_applier.rb +266 -0
  164. data/lib/fontisan/variation/delta_parser.rb +228 -0
  165. data/lib/fontisan/variation/inspector.rb +275 -0
  166. data/lib/fontisan/variation/instance_generator.rb +273 -0
  167. data/lib/fontisan/variation/interpolator.rb +231 -0
  168. data/lib/fontisan/variation/metrics_adjuster.rb +318 -0
  169. data/lib/fontisan/variation/optimizer.rb +418 -0
  170. data/lib/fontisan/variation/parallel_generator.rb +150 -0
  171. data/lib/fontisan/variation/region_matcher.rb +221 -0
  172. data/lib/fontisan/variation/subsetter.rb +463 -0
  173. data/lib/fontisan/variation/table_accessor.rb +105 -0
  174. data/lib/fontisan/variation/validator.rb +345 -0
  175. data/lib/fontisan/variation/variation_context.rb +211 -0
  176. data/lib/fontisan/version.rb +1 -1
  177. data/lib/fontisan/woff2/directory.rb +257 -0
  178. data/lib/fontisan/woff2/header.rb +101 -0
  179. data/lib/fontisan/woff2/table_transformer.rb +163 -0
  180. data/lib/fontisan/woff2_font.rb +712 -0
  181. data/lib/fontisan/woff_font.rb +483 -0
  182. data/lib/fontisan.rb +120 -0
  183. data/scripts/compare_stack_aware.rb +187 -0
  184. data/scripts/measure_optimization.rb +141 -0
  185. metadata +205 -4
@@ -0,0 +1,483 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bindata"
4
+ require "zlib"
5
+ require_relative "constants"
6
+ require_relative "utilities/checksum_calculator"
7
+
8
+ module Fontisan
9
+ # WOFF Header structure
10
+ class WoffHeader < BinData::Record
11
+ endian :big
12
+ uint32 :signature # 0x774F4646 'wOFF'
13
+ uint32 :flavor # sfnt version (0x00010000 for TTF, 'OTTO' for CFF)
14
+ uint32 :woff_length # Total size of WOFF file
15
+ uint16 :num_tables # Number of entries in directory
16
+ uint16 :reserved # Reserved, must be zero
17
+ uint32 :total_sfnt_size # Total size needed for uncompressed font
18
+ uint16 :major_version # Major version of WOFF file
19
+ uint16 :minor_version # Minor version of WOFF file
20
+ uint32 :meta_offset # Offset to metadata block
21
+ uint32 :meta_length # Length of compressed metadata block
22
+ uint32 :meta_orig_length # Length of uncompressed metadata block
23
+ uint32 :priv_offset # Offset to private data block
24
+ uint32 :priv_length # Length of private data block
25
+ end
26
+
27
+ # WOFF Table Directory Entry structure
28
+ class WoffTableDirectoryEntry < BinData::Record
29
+ endian :big
30
+ string :tag, length: 4 # Table identifier
31
+ uint32 :offset # Offset to compressed table data
32
+ uint32 :comp_length # Length of compressed data
33
+ uint32 :orig_length # Length of uncompressed data
34
+ uint32 :orig_checksum # Checksum of uncompressed table
35
+ end
36
+
37
+ # Web Open Font Format (WOFF) font domain object
38
+ #
39
+ # Represents a WOFF font file that uses zlib compression for table data.
40
+ # WOFF is a simple wrapper format for TTF/OTF fonts with compression.
41
+ #
42
+ # According to the WOFF specification (https://www.w3.org/TR/WOFF/):
43
+ # - Tables are individually compressed using zlib
44
+ # - Optional metadata block (compressed XML)
45
+ # - Optional private data block
46
+ #
47
+ # @example Reading a WOFF font
48
+ # woff = WoffFont.from_file("font.woff")
49
+ # puts woff.header.num_tables
50
+ # name_table = woff.table("name")
51
+ # puts name_table.english_name(Tables::Name::FAMILY)
52
+ #
53
+ # @example Converting to TTF/OTF
54
+ # woff = WoffFont.from_file("font.woff")
55
+ # woff.to_ttf("output.ttf") # if TrueType flavored
56
+ # woff.to_otf("output.otf") # if CFF flavored
57
+ class WoffFont < BinData::Record
58
+ endian :big
59
+
60
+ woff_header :header
61
+ array :table_entries, type: :woff_table_directory_entry, initial_length: lambda {
62
+ header.num_tables
63
+ }
64
+
65
+ # Table data storage (decompressed on demand)
66
+ attr_accessor :decompressed_tables
67
+ attr_accessor :compressed_table_data
68
+
69
+ # Parsed table instances cache
70
+ attr_accessor :parsed_tables
71
+
72
+ # File IO handle for lazy table decompression
73
+ attr_accessor :io_source
74
+
75
+ # WOFF signature constant
76
+ WOFF_SIGNATURE = 0x774F4646 # 'wOFF'
77
+
78
+ # Read WOFF font from a file
79
+ #
80
+ # @param path [String] Path to the WOFF file
81
+ # @return [WoffFont] A new instance
82
+ # @raise [ArgumentError] if path is nil or empty
83
+ # @raise [Errno::ENOENT] if file does not exist
84
+ # @raise [InvalidFontError] if file format is invalid
85
+ def self.from_file(path)
86
+ if path.nil? || path.to_s.empty?
87
+ raise ArgumentError,
88
+ "path cannot be nil or empty"
89
+ end
90
+ raise Errno::ENOENT, "File not found: #{path}" unless File.exist?(path)
91
+
92
+ File.open(path, "rb") do |io|
93
+ font = read(io)
94
+ font.validate_signature!
95
+ font.initialize_storage
96
+ font.io_source = io
97
+ font.read_compressed_table_data(io)
98
+ font
99
+ end
100
+ rescue BinData::ValidityError, EOFError => e
101
+ Kernel.raise(::Fontisan::InvalidFontError,
102
+ "Invalid WOFF file: #{e.message}")
103
+ end
104
+
105
+ # Initialize storage hashes
106
+ #
107
+ # @return [void]
108
+ def initialize_storage
109
+ @decompressed_tables = {}
110
+ @compressed_table_data = {}
111
+ @parsed_tables = {}
112
+ end
113
+
114
+ # Validate WOFF signature
115
+ #
116
+ # @raise [InvalidFontError] if signature is invalid
117
+ # @return [void]
118
+ def validate_signature!
119
+ signature_value = header.signature.to_i
120
+ unless signature_value == WOFF_SIGNATURE
121
+ Kernel.raise(::Fontisan::InvalidFontError,
122
+ "Invalid WOFF signature: expected 0x#{WOFF_SIGNATURE.to_s(16)}, " \
123
+ "got 0x#{signature_value.to_s(16)}")
124
+ end
125
+ end
126
+
127
+ # Read compressed table data for all tables
128
+ #
129
+ # Tables are decompressed on-demand for efficiency
130
+ #
131
+ # @param io [IO] Open file handle
132
+ # @return [void]
133
+ def read_compressed_table_data(io)
134
+ @compressed_table_data = {}
135
+ table_entries.each do |entry|
136
+ io.seek(entry.offset)
137
+ # Force UTF-8 encoding on tag for hash key consistency
138
+ tag_key = entry.tag.dup.force_encoding("UTF-8")
139
+ @compressed_table_data[tag_key] = io.read(entry.comp_length)
140
+ end
141
+ end
142
+
143
+ # Check if font is TrueType flavored
144
+ #
145
+ # @return [Boolean] true if TrueType, false if CFF
146
+ def truetype?
147
+ [Constants::SFNT_VERSION_TRUETYPE, 0x00010000].include?(header.flavor)
148
+ end
149
+
150
+ # Check if font is CFF flavored (OpenType with CFF outlines)
151
+ #
152
+ # @return [Boolean] true if CFF, false if TrueType
153
+ def cff?
154
+ [Constants::SFNT_VERSION_OTTO, 0x4F54544F].include?(header.flavor) # 'OTTO'
155
+ end
156
+
157
+ # Get decompressed table data
158
+ #
159
+ # Decompresses table data on first access and caches result
160
+ #
161
+ # @param tag [String] The table tag
162
+ # @return [String, nil] Decompressed table data or nil if not found
163
+ def table_data(tag)
164
+ return @decompressed_tables[tag] if @decompressed_tables.key?(tag)
165
+
166
+ compressed_data = @compressed_table_data[tag]
167
+ return nil unless compressed_data
168
+
169
+ entry = find_table_entry(tag)
170
+ return nil unless entry
171
+
172
+ # Decompress if compressed (comp_length != orig_length)
173
+ @decompressed_tables[tag] = if entry.comp_length == entry.orig_length
174
+ # Table is not compressed
175
+ compressed_data
176
+ else
177
+ # Decompress using zlib
178
+ Zlib::Inflate.inflate(compressed_data)
179
+ end
180
+
181
+ # Verify decompressed size matches expected
182
+ if @decompressed_tables[tag].bytesize != entry.orig_length
183
+ Kernel.raise(::Fontisan::InvalidFontError,
184
+ "Decompressed table '#{tag}' size mismatch: " \
185
+ "expected #{entry.orig_length}, got #{@decompressed_tables[tag].bytesize}")
186
+ end
187
+
188
+ @decompressed_tables[tag]
189
+ end
190
+
191
+ # Check if font has a specific table
192
+ #
193
+ # @param tag [String] The table tag to check for
194
+ # @return [Boolean] true if table exists, false otherwise
195
+ def has_table?(tag)
196
+ table_entries.any? { |entry| entry.tag == tag }
197
+ end
198
+
199
+ # Find a table entry by tag
200
+ #
201
+ # @param tag [String] The table tag to find
202
+ # @return [WoffTableDirectoryEntry, nil] The table entry or nil
203
+ def find_table_entry(tag)
204
+ table_entries.find { |entry| entry.tag == tag }
205
+ end
206
+
207
+ # Get list of all table tags
208
+ #
209
+ # @return [Array<String>] Array of table tag strings
210
+ def table_names
211
+ table_entries.map(&:tag)
212
+ end
213
+
214
+ # Get parsed table instance
215
+ #
216
+ # This method decompresses and parses the raw table data into a
217
+ # structured table object and caches the result for subsequent calls.
218
+ #
219
+ # @param tag [String] The table tag to retrieve
220
+ # @return [Tables::*, nil] Parsed table object or nil if not found
221
+ def table(tag)
222
+ @parsed_tables[tag] ||= parse_table(tag)
223
+ end
224
+
225
+ # Get units per em from head table
226
+ #
227
+ # @return [Integer, nil] Units per em value
228
+ def units_per_em
229
+ head = table(Constants::HEAD_TAG)
230
+ head&.units_per_em
231
+ end
232
+
233
+ # Get WOFF metadata if present
234
+ #
235
+ # WOFF metadata is optional compressed XML describing the font
236
+ #
237
+ # @return [String, nil] Decompressed metadata XML or nil
238
+ def metadata
239
+ return nil if header.meta_length.zero?
240
+ return @metadata if defined?(@metadata)
241
+
242
+ File.open(io_source.path, "rb") do |io|
243
+ io.seek(header.meta_offset)
244
+ compressed_meta = io.read(header.meta_length)
245
+ @metadata = Zlib::Inflate.inflate(compressed_meta)
246
+
247
+ # Verify decompressed size
248
+ if @metadata.bytesize != header.meta_orig_length
249
+ Kernel.raise(::Fontisan::InvalidFontError,
250
+ "Metadata size mismatch: expected #{header.meta_orig_length}, " \
251
+ "got #{@metadata.bytesize}")
252
+ end
253
+
254
+ @metadata
255
+ end
256
+ rescue StandardError => e
257
+ warn "Failed to decompress WOFF metadata: #{e.message}"
258
+ @metadata = nil
259
+ end
260
+
261
+ # Get WOFF private data if present
262
+ #
263
+ # WOFF private data is optional application-specific data
264
+ #
265
+ # @return [String, nil] Private data or nil
266
+ def private_data
267
+ return nil if header.priv_length.zero?
268
+ return @private_data if defined?(@private_data)
269
+
270
+ File.open(io_source.path, "rb") do |io|
271
+ io.seek(header.priv_offset)
272
+ @private_data = io.read(header.priv_length)
273
+ end
274
+ rescue StandardError => e
275
+ warn "Failed to read WOFF private data: #{e.message}"
276
+ @private_data = nil
277
+ end
278
+
279
+ # Convert WOFF to TTF format
280
+ #
281
+ # Decompresses all tables and reconstructs a standard TTF file
282
+ #
283
+ # @param output_path [String] Path where TTF file will be written
284
+ # @return [Integer] Number of bytes written
285
+ # @raise [InvalidFontError] if font is not TrueType flavored
286
+ def to_ttf(output_path)
287
+ unless truetype?
288
+ Kernel.raise(::Fontisan::InvalidFontError,
289
+ "Cannot convert to TTF: font is CFF flavored (use to_otf)")
290
+ end
291
+
292
+ build_sfnt_font(output_path, Constants::SFNT_VERSION_TRUETYPE)
293
+ end
294
+
295
+ # Convert WOFF to OTF format
296
+ #
297
+ # Decompresses all tables and reconstructs a standard OTF file
298
+ #
299
+ # @param output_path [String] Path where OTF file will be written
300
+ # @return [Integer] Number of bytes written
301
+ # @raise [InvalidFontError] if font is not CFF flavored
302
+ def to_otf(output_path)
303
+ unless cff?
304
+ Kernel.raise(::Fontisan::InvalidFontError,
305
+ "Cannot convert to OTF: font is TrueType flavored (use to_ttf)")
306
+ end
307
+
308
+ build_sfnt_font(output_path, Constants::SFNT_VERSION_OTTO)
309
+ end
310
+
311
+ # Validate format correctness
312
+ #
313
+ # @return [Boolean] true if the WOFF format is valid, false otherwise
314
+ def valid?
315
+ return false unless header
316
+ return false unless header.signature == WOFF_SIGNATURE
317
+ return false unless table_entries.respond_to?(:length)
318
+ return false if table_entries.length != header.num_tables
319
+ return false unless has_table?(Constants::HEAD_TAG)
320
+
321
+ true
322
+ end
323
+
324
+ private
325
+
326
+ # Parse a table from decompressed data
327
+ #
328
+ # @param tag [String] The table tag to parse
329
+ # @return [Tables::*, nil] Parsed table object or nil
330
+ def parse_table(tag)
331
+ raw_data = table_data(tag)
332
+ return nil unless raw_data
333
+
334
+ table_class = table_class_for(tag)
335
+ return nil unless table_class
336
+
337
+ table_class.read(raw_data)
338
+ end
339
+
340
+ # Map table tag to parser class
341
+ #
342
+ # @param tag [String] The table tag
343
+ # @return [Class, nil] Table parser class or nil
344
+ def table_class_for(tag)
345
+ {
346
+ Constants::HEAD_TAG => Tables::Head,
347
+ Constants::HHEA_TAG => Tables::Hhea,
348
+ Constants::HMTX_TAG => Tables::Hmtx,
349
+ Constants::MAXP_TAG => Tables::Maxp,
350
+ Constants::NAME_TAG => Tables::Name,
351
+ Constants::OS2_TAG => Tables::Os2,
352
+ Constants::POST_TAG => Tables::Post,
353
+ Constants::CMAP_TAG => Tables::Cmap,
354
+ Constants::FVAR_TAG => Tables::Fvar,
355
+ Constants::GSUB_TAG => Tables::Gsub,
356
+ Constants::GPOS_TAG => Tables::Gpos,
357
+ }[tag]
358
+ end
359
+
360
+ # Build an SFNT font file (TTF or OTF) from decompressed WOFF data
361
+ #
362
+ # @param output_path [String] Path where font will be written
363
+ # @param sfnt_version [Integer] SFNT version (0x00010000 for TTF, 0x4F54544F for OTF)
364
+ # @return [Integer] Number of bytes written
365
+ def build_sfnt_font(output_path, sfnt_version)
366
+ File.open(output_path, "wb") do |io|
367
+ # Decompress all tables
368
+ decompressed_tables = {}
369
+ table_entries.each do |entry|
370
+ tag = entry.tag.dup.force_encoding("UTF-8")
371
+ decompressed_tables[tag] = table_data(tag)
372
+ end
373
+
374
+ # Calculate offset table fields
375
+ num_tables = table_entries.length
376
+ search_range, entry_selector, range_shift = calculate_offset_table_fields(num_tables)
377
+
378
+ # Write offset table
379
+ io.write([sfnt_version].pack("N"))
380
+ io.write([num_tables].pack("n"))
381
+ io.write([search_range].pack("n"))
382
+ io.write([entry_selector].pack("n"))
383
+ io.write([range_shift].pack("n"))
384
+
385
+ # Calculate table offsets
386
+ offset = 12 + (num_tables * 16) # Header + directory
387
+ table_records = []
388
+
389
+ table_entries.each do |entry|
390
+ tag = entry.tag.dup.force_encoding("UTF-8")
391
+ data = decompressed_tables[tag]
392
+ length = data.bytesize
393
+
394
+ # Calculate checksum
395
+ checksum = Utilities::ChecksumCalculator.calculate_table_checksum(data)
396
+
397
+ table_records << {
398
+ tag: entry.tag,
399
+ checksum: checksum,
400
+ offset: offset,
401
+ length: length,
402
+ data: data,
403
+ }
404
+
405
+ # Update offset for next table (with padding)
406
+ offset += length
407
+ padding = (Constants::TABLE_ALIGNMENT - (length % Constants::TABLE_ALIGNMENT)) %
408
+ Constants::TABLE_ALIGNMENT
409
+ offset += padding
410
+ end
411
+
412
+ # Write table directory
413
+ table_records.each do |record|
414
+ io.write(record[:tag])
415
+ io.write([record[:checksum]].pack("N"))
416
+ io.write([record[:offset]].pack("N"))
417
+ io.write([record[:length]].pack("N"))
418
+
419
+ # Write table data
420
+ io.write(record[:data])
421
+
422
+ # Add padding
423
+ padding = (Constants::TABLE_ALIGNMENT - (record[:length] % Constants::TABLE_ALIGNMENT)) %
424
+ Constants::TABLE_ALIGNMENT
425
+ io.write("\x00" * padding) if padding.positive?
426
+ end
427
+
428
+ io.pos
429
+ end
430
+
431
+ # Update checksum adjustment in head table
432
+ update_checksum_adjustment_in_file(output_path)
433
+
434
+ File.size(output_path)
435
+ end
436
+
437
+ # Calculate offset table fields
438
+ #
439
+ # @param num_tables [Integer] Number of tables
440
+ # @return [Array<Integer>] [searchRange, entrySelector, rangeShift]
441
+ def calculate_offset_table_fields(num_tables)
442
+ entry_selector = (Math.log(num_tables) / Math.log(2)).floor
443
+ search_range = (2**entry_selector) * 16
444
+ range_shift = num_tables * 16 - search_range
445
+ [search_range, entry_selector, range_shift]
446
+ end
447
+
448
+ # Update checksumAdjustment field in head table
449
+ #
450
+ # @param path [String] Path to the font file
451
+ # @return [void]
452
+ def update_checksum_adjustment_in_file(path)
453
+ # Calculate file checksum
454
+ checksum = Utilities::ChecksumCalculator.calculate_file_checksum(path)
455
+
456
+ # Calculate adjustment
457
+ adjustment = Utilities::ChecksumCalculator.calculate_adjustment(checksum)
458
+
459
+ # Find head table position in output file
460
+ File.open(path, "rb") do |io|
461
+ io.seek(4) # Skip sfnt_version
462
+ num_tables = io.read(2).unpack1("n")
463
+ io.seek(12) # Start of table directory
464
+
465
+ num_tables.times do
466
+ tag = io.read(4)
467
+ io.read(4) # checksum
468
+ offset = io.read(4).unpack1("N")
469
+ io.read(4) # length
470
+
471
+ if tag == Constants::HEAD_TAG
472
+ # Write adjustment to head table (offset 8 within head table)
473
+ File.open(path, "r+b") do |write_io|
474
+ write_io.seek(offset + 8)
475
+ write_io.write([adjustment].pack("N"))
476
+ end
477
+ break
478
+ end
479
+ end
480
+ end
481
+ end
482
+ end
483
+ end
data/lib/fontisan.rb CHANGED
@@ -1,7 +1,42 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ # _____
4
+ # _____
5
+ # _____
6
+ # | | <-----------------------| | font body
7
+ # | |(font) | |
8
+ # | |header | |
9
+ # | |
10
+ # \ \ \ \--> body data
11
+ # | | | |
12
+ # |_| |_|
13
+ # ^
14
+ # |
15
+ # ... meta data opposite the table headers
16
+ # data (instance variable bytes)
17
+ # ...
18
+ # Coupling rules:
19
+ # - a trueType font is composed of only one header and all 15
20
+ # - a head table has one of only two appearance... base and non zero top bit (mac format.k)
21
+ # - The most critical tables are
22
+ # - head # header table
23
+ # - hmtx # metrics array
24
+ # - post # glyph names
25
+ # - cmap # unicode mappings
26
+ # - /LOCA # glyph offsets
27
+ # - glyf # glyph outlines
28
+ # Without these you wouldn't be able to decode the font.
29
+ # - the only two required tables are head and cmap
30
+
3
31
  require "logger"
32
+ require "bindata"
33
+ require "zlib"
34
+ require "stringio"
4
35
  require "lutaml/model"
36
+ require "lutaml/model/xml_adapter/nokogiri_adapter"
37
+
38
+ # Configure lutaml-model to use Nokogiri adapter for XML serialization
39
+ Lutaml::Model::Config.xml_adapter = Lutaml::Model::Xml::NokogiriAdapter
5
40
 
6
41
  # Core
7
42
  require_relative "fontisan/version"
@@ -14,11 +49,23 @@ require_relative "fontisan/binary/base_record"
14
49
 
15
50
  # Table parsers
16
51
  require_relative "fontisan/tables/head"
52
+ require_relative "fontisan/tables/hhea"
53
+ require_relative "fontisan/tables/hmtx"
54
+ require_relative "fontisan/tables/maxp"
55
+ require_relative "fontisan/tables/loca"
56
+ require_relative "fontisan/tables/glyf"
17
57
  require_relative "fontisan/tables/name"
18
58
  require_relative "fontisan/tables/os2"
19
59
  require_relative "fontisan/tables/post"
20
60
  require_relative "fontisan/tables/cmap"
21
61
  require_relative "fontisan/tables/fvar"
62
+ require_relative "fontisan/tables/variation_common"
63
+ require_relative "fontisan/tables/hvar"
64
+ require_relative "fontisan/tables/vvar"
65
+ require_relative "fontisan/tables/mvar"
66
+ require_relative "fontisan/tables/gvar"
67
+ require_relative "fontisan/tables/cvar"
68
+ require_relative "fontisan/tables/cff"
22
69
  require_relative "fontisan/tables/layout_common"
23
70
  require_relative "fontisan/tables/gsub"
24
71
  require_relative "fontisan/tables/gpos"
@@ -28,27 +75,95 @@ require_relative "fontisan/true_type_font"
28
75
  require_relative "fontisan/open_type_font"
29
76
  require_relative "fontisan/true_type_collection"
30
77
  require_relative "fontisan/open_type_collection"
78
+ require_relative "fontisan/woff_font"
79
+ require_relative "fontisan/woff2_font"
31
80
 
32
81
  # Font loading
33
82
  require_relative "fontisan/font_loader"
34
83
 
35
84
  # Utilities
85
+ require_relative "fontisan/metrics_calculator"
86
+ require_relative "fontisan/glyph_accessor"
87
+ require_relative "fontisan/outline_extractor"
36
88
  require_relative "fontisan/utilities/checksum_calculator"
89
+ require_relative "fontisan/font_writer"
37
90
 
38
91
  # Information models (Lutaml::Model)
39
92
  require_relative "fontisan/models/font_info"
40
93
  require_relative "fontisan/models/table_info"
41
94
  require_relative "fontisan/models/glyph_info"
95
+ require_relative "fontisan/models/glyph_outline"
42
96
  require_relative "fontisan/models/unicode_mappings"
43
97
  require_relative "fontisan/models/variable_font_info"
44
98
  require_relative "fontisan/models/optical_size_info"
45
99
  require_relative "fontisan/models/scripts_info"
46
100
  require_relative "fontisan/models/features_info"
47
101
  require_relative "fontisan/models/all_scripts_features_info"
102
+ require_relative "fontisan/models/validation_report"
103
+ require_relative "fontisan/models/font_export"
104
+ require_relative "fontisan/models/collection_font_summary"
105
+ require_relative "fontisan/models/collection_info"
106
+ require_relative "fontisan/models/collection_list_info"
107
+ require_relative "fontisan/models/font_summary"
108
+ require_relative "fontisan/models/table_sharing_info"
109
+
110
+ # Export infrastructure
111
+ require_relative "fontisan/export/table_serializer"
112
+ require_relative "fontisan/export/ttx_generator"
113
+ require_relative "fontisan/export/ttx_parser"
114
+ require_relative "fontisan/export/exporter"
115
+
116
+ # Validation infrastructure
117
+ require_relative "fontisan/validation/table_validator"
118
+ require_relative "fontisan/validation/structure_validator"
119
+ require_relative "fontisan/validation/consistency_validator"
120
+ require_relative "fontisan/validation/checksum_validator"
121
+ require_relative "fontisan/validation/validator"
122
+
123
+ # Subsetting infrastructure
124
+ require_relative "fontisan/subset/options"
125
+ require_relative "fontisan/subset/profile"
126
+ require_relative "fontisan/subset/glyph_mapping"
127
+ require_relative "fontisan/subset/table_subsetter"
128
+ require_relative "fontisan/subset/builder"
129
+
130
+ # Collection infrastructure
131
+ require_relative "fontisan/collection/table_analyzer"
132
+ require_relative "fontisan/collection/table_deduplicator"
133
+ require_relative "fontisan/collection/offset_calculator"
134
+ require_relative "fontisan/collection/writer"
135
+ require_relative "fontisan/collection/builder"
136
+
137
+ # Format conversion infrastructure
138
+ require_relative "fontisan/converters/conversion_strategy"
139
+ require_relative "fontisan/converters/table_copier"
140
+ require_relative "fontisan/converters/outline_converter"
141
+ require_relative "fontisan/converters/format_converter"
142
+
143
+ # Variation infrastructure
144
+ require_relative "fontisan/variation/interpolator"
145
+ require_relative "fontisan/variation/region_matcher"
146
+ require_relative "fontisan/variation/data_extractor"
147
+ require_relative "fontisan/variation/instance_generator"
148
+ require_relative "fontisan/variation/interpolator"
149
+ require_relative "fontisan/variation/region_matcher"
150
+ require_relative "fontisan/variation/metrics_adjuster"
151
+ require_relative "fontisan/variation/converter"
152
+ require_relative "fontisan/variation/delta_parser"
153
+ require_relative "fontisan/variation/delta_applier"
154
+ require_relative "fontisan/variation/blend_applier"
155
+
156
+ # Optimization infrastructure
157
+ require_relative "fontisan/optimizers/pattern_analyzer"
158
+ require_relative "fontisan/optimizers/subroutine_builder"
159
+ require_relative "fontisan/optimizers/charstring_rewriter"
160
+ require_relative "fontisan/optimizers/subroutine_optimizer"
161
+ require_relative "fontisan/optimizers/subroutine_generator"
48
162
 
49
163
  # Commands
50
164
  require_relative "fontisan/commands/base_command"
51
165
  require_relative "fontisan/commands/info_command"
166
+ require_relative "fontisan/commands/ls_command"
52
167
  require_relative "fontisan/commands/tables_command"
53
168
  require_relative "fontisan/commands/glyphs_command"
54
169
  require_relative "fontisan/commands/unicode_command"
@@ -57,6 +172,11 @@ require_relative "fontisan/commands/optical_size_command"
57
172
  require_relative "fontisan/commands/scripts_command"
58
173
  require_relative "fontisan/commands/features_command"
59
174
  require_relative "fontisan/commands/dump_table_command"
175
+ require_relative "fontisan/commands/subset_command"
176
+ require_relative "fontisan/commands/convert_command"
177
+ require_relative "fontisan/commands/pack_command"
178
+ require_relative "fontisan/commands/unpack_command"
179
+ require_relative "fontisan/commands/validate_command"
60
180
 
61
181
  # Formatters
62
182
  require_relative "fontisan/formatters/text_formatter"