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,300 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "nokogiri"
4
+
5
+ module Fontisan
6
+ module Export
7
+ # TtxParser parses TTX XML format to font data
8
+ #
9
+ # Parses fonttools-compatible TTX XML files and reconstructs
10
+ # font data that can be written back to binary formats.
11
+ #
12
+ # @example Parsing TTX file
13
+ # parser = TtxParser.new
14
+ # font_data = parser.parse(File.read("font.ttx"))
15
+ # # Use font_data to rebuild binary font
16
+ class TtxParser
17
+ # Parse TTX XML content
18
+ #
19
+ # @param ttx_xml [String] TTX XML content
20
+ # @return [Hash] Parsed font data structure
21
+ def parse(ttx_xml)
22
+ doc = Nokogiri::XML(ttx_xml)
23
+ ttfont = doc.at_xpath("/ttFont")
24
+ raise "No ttFont root element found" unless ttfont
25
+
26
+ {
27
+ sfnt_version: parse_sfnt_version(ttfont["sfntVersion"]),
28
+ glyph_order: parse_glyph_order(ttfont),
29
+ tables: parse_tables(ttfont),
30
+ }
31
+ end
32
+
33
+ private
34
+
35
+ # Parse SFNT version
36
+ #
37
+ # @param version_str [String] Version string
38
+ # @return [Integer] Version integer
39
+ def parse_sfnt_version(version_str)
40
+ # Handle format like "\x00\x01\x00\x00" or "0x00010000"
41
+ if version_str.start_with?("\\x")
42
+ # Parse escaped hex bytes
43
+ bytes = version_str.scan(/\\x([0-9a-f]{2})/i).flatten
44
+ bytes.map { |b| b.to_i(16) }.pack("C*").unpack1("N")
45
+ elsif version_str.start_with?("0x")
46
+ version_str.to_i(16)
47
+ else
48
+ version_str.to_i
49
+ end
50
+ end
51
+
52
+ # Parse GlyphOrder section
53
+ #
54
+ # @param ttfont [Nokogiri::XML::Element] Root element
55
+ # @return [Array<Hash>] Array of glyph entries
56
+ def parse_glyph_order(ttfont)
57
+ glyph_order = ttfont.at_xpath("GlyphOrder")
58
+ return [] unless glyph_order
59
+
60
+ glyph_order.xpath("GlyphID").map do |glyph_id|
61
+ {
62
+ id: glyph_id["id"].to_i,
63
+ name: glyph_id["name"],
64
+ }
65
+ end
66
+ end
67
+
68
+ # Parse all tables
69
+ #
70
+ # @param ttfont [Nokogiri::XML::Element] Root element
71
+ # @return [Hash] Hash of table data by tag
72
+ def parse_tables(ttfont)
73
+ tables = {}
74
+
75
+ # Parse specific tables
76
+ parse_head_table(ttfont, tables)
77
+ parse_hhea_table(ttfont, tables)
78
+ parse_maxp_table(ttfont, tables)
79
+ parse_name_table(ttfont, tables)
80
+ parse_os2_table(ttfont, tables)
81
+ parse_post_table(ttfont, tables)
82
+
83
+ # Parse any remaining binary tables
84
+ parse_binary_tables(ttfont, tables)
85
+
86
+ tables
87
+ end
88
+
89
+ # Parse head table
90
+ #
91
+ # @param ttfont [Nokogiri::XML::Element] Root element
92
+ # @param tables [Hash] Tables hash
93
+ # @return [void]
94
+ def parse_head_table(ttfont, tables)
95
+ head = ttfont.at_xpath("head")
96
+ return unless head
97
+
98
+ tables["head"] = {
99
+ table_version: parse_fixed(head.at_xpath("tableVersion")&.[]("value")),
100
+ font_revision: parse_fixed(head.at_xpath("fontRevision")&.[]("value")),
101
+ checksum_adjustment: parse_hex(head.at_xpath("checkSumAdjustment")&.[]("value")),
102
+ magic_number: parse_hex(head.at_xpath("magicNumber")&.[]("value")),
103
+ flags: head.at_xpath("flags")&.[]("value").to_i,
104
+ units_per_em: head.at_xpath("unitsPerEm")&.[]("value").to_i,
105
+ created: parse_timestamp(head.at_xpath("created")&.[]("value")),
106
+ modified: parse_timestamp(head.at_xpath("modified")&.[]("value")),
107
+ x_min: head.at_xpath("xMin")&.[]("value").to_i,
108
+ y_min: head.at_xpath("yMin")&.[]("value").to_i,
109
+ x_max: head.at_xpath("xMax")&.[]("value").to_i,
110
+ y_max: head.at_xpath("yMax")&.[]("value").to_i,
111
+ mac_style: parse_binary_flags(head.at_xpath("macStyle")&.[]("value")),
112
+ lowest_rec_ppem: head.at_xpath("lowestRecPPEM")&.[]("value").to_i,
113
+ font_direction_hint: head.at_xpath("fontDirectionHint")&.[]("value").to_i,
114
+ index_to_loc_format: head.at_xpath("indexToLocFormat")&.[]("value").to_i,
115
+ glyph_data_format: head.at_xpath("glyphDataFormat")&.[]("value").to_i,
116
+ }
117
+ end
118
+
119
+ # Parse hhea table
120
+ #
121
+ # @param ttfont [Nokogiri::XML::Element] Root element
122
+ # @param tables [Hash] Tables hash
123
+ # @return [void]
124
+ def parse_hhea_table(ttfont, tables)
125
+ hhea = ttfont.at_xpath("hhea")
126
+ return unless hhea
127
+
128
+ tables["hhea"] = {
129
+ table_version: parse_hex(hhea.at_xpath("tableVersion")&.[]("value")),
130
+ ascent: hhea.at_xpath("ascent")&.[]("value").to_i,
131
+ descent: hhea.at_xpath("descent")&.[]("value").to_i,
132
+ line_gap: hhea.at_xpath("lineGap")&.[]("value").to_i,
133
+ advance_width_max: hhea.at_xpath("advanceWidthMax")&.[]("value").to_i,
134
+ min_left_side_bearing: hhea.at_xpath("minLeftSideBearing")&.[]("value").to_i,
135
+ min_right_side_bearing: hhea.at_xpath("minRightSideBearing")&.[]("value").to_i,
136
+ x_max_extent: hhea.at_xpath("xMaxExtent")&.[]("value").to_i,
137
+ caret_slope_rise: hhea.at_xpath("caretSlopeRise")&.[]("value").to_i,
138
+ caret_slope_run: hhea.at_xpath("caretSlopeRun")&.[]("value").to_i,
139
+ caret_offset: hhea.at_xpath("caretOffset")&.[]("value").to_i,
140
+ metric_data_format: hhea.at_xpath("metricDataFormat")&.[]("value").to_i,
141
+ number_of_h_metrics: hhea.at_xpath("numberOfHMetrics")&.[]("value").to_i,
142
+ }
143
+ end
144
+
145
+ # Parse maxp table
146
+ #
147
+ # @param ttfont [Nokogiri::XML::Element] Root element
148
+ # @param tables [Hash] Tables hash
149
+ # @return [void]
150
+ def parse_maxp_table(ttfont, tables)
151
+ maxp = ttfont.at_xpath("maxp")
152
+ return unless maxp
153
+
154
+ tables["maxp"] = {
155
+ table_version: parse_hex(maxp.at_xpath("tableVersion")&.[]("value")),
156
+ num_glyphs: maxp.at_xpath("numGlyphs")&.[]("value").to_i,
157
+ }
158
+
159
+ # Version 1.0 fields
160
+ if tables["maxp"][:table_version] >= 0x00010000
161
+ tables["maxp"].merge!({
162
+ max_points: maxp.at_xpath("maxPoints")&.[]("value")&.to_i,
163
+ max_contours: maxp.at_xpath("maxContours")&.[]("value")&.to_i,
164
+ max_composite_points: maxp.at_xpath("maxCompositePoints")&.[]("value")&.to_i,
165
+ max_composite_contours: maxp.at_xpath("maxCompositeContours")&.[]("value")&.to_i,
166
+ })
167
+ end
168
+ end
169
+
170
+ # Parse name table
171
+ #
172
+ # @param ttfont [Nokogiri::XML::Element] Root element
173
+ # @param tables [Hash] Tables hash
174
+ # @return [void]
175
+ def parse_name_table(ttfont, tables)
176
+ name_elem = ttfont.at_xpath("name")
177
+ return unless name_elem
178
+
179
+ name_records = name_elem.xpath("namerecord").map do |record|
180
+ {
181
+ name_id: record["nameID"].to_i,
182
+ platform_id: record["platformID"].to_i,
183
+ encoding_id: record["platEncID"].to_i,
184
+ language_id: parse_hex(record["langID"]),
185
+ string: record.text,
186
+ }
187
+ end
188
+
189
+ tables["name"] = { name_records: name_records }
190
+ end
191
+
192
+ # Parse OS/2 table (stub)
193
+ #
194
+ # @param ttfont [Nokogiri::XML::Element] Root element
195
+ # @param tables [Hash] Tables hash
196
+ # @return [void]
197
+ def parse_os2_table(ttfont, tables)
198
+ os2 = ttfont.at_xpath("OS/2")
199
+ return unless os2
200
+
201
+ # Basic OS/2 parsing - can be expanded
202
+ tables["OS/2"] = { parsed: false }
203
+ end
204
+
205
+ # Parse post table (stub)
206
+ #
207
+ # @param ttfont [Nokogiri::XML::Element] Root element
208
+ # @param tables [Hash] Tables hash
209
+ # @return [void]
210
+ def parse_post_table(ttfont, tables)
211
+ post = ttfont.at_xpath("post")
212
+ return unless post
213
+
214
+ tables["post"] = {
215
+ format_type: parse_fixed(post.at_xpath("formatType")&.[]("value")),
216
+ italic_angle: parse_fixed(post.at_xpath("italicAngle")&.[]("value")),
217
+ underline_position: post.at_xpath("underlinePosition")&.[]("value").to_i,
218
+ underline_thickness: post.at_xpath("underlineThickness")&.[]("value").to_i,
219
+ is_fixed_pitch: post.at_xpath("isFixedPitch")&.[]("value").to_i,
220
+ }
221
+ end
222
+
223
+ # Parse binary tables (fallback)
224
+ #
225
+ # @param ttfont [Nokogiri::XML::Element] Root element
226
+ # @param tables [Hash] Tables hash
227
+ # @return [void]
228
+ def parse_binary_tables(ttfont, tables)
229
+ # Find all table elements not already parsed
230
+ ttfont.children.each do |elem|
231
+ next unless elem.element?
232
+ next if elem.name == "GlyphOrder"
233
+ next if tables.key?(elem.name)
234
+
235
+ hexdata = elem.at_xpath("hexdata")
236
+ if hexdata
237
+ tables[elem.name] = {
238
+ binary: parse_hex_data(hexdata.text),
239
+ }
240
+ end
241
+ end
242
+ end
243
+
244
+ # Parse fixed-point number
245
+ #
246
+ # @param value_str [String] Fixed-point string
247
+ # @return [Integer] Fixed-point integer (16.16)
248
+ def parse_fixed(value_str)
249
+ return 0 unless value_str
250
+
251
+ (value_str.to_f * 65536).to_i
252
+ end
253
+
254
+ # Parse hex value
255
+ #
256
+ # @param value_str [String] Hex string
257
+ # @return [Integer] Integer value
258
+ def parse_hex(value_str)
259
+ return 0 unless value_str
260
+
261
+ value_str.to_i(16)
262
+ end
263
+
264
+ # Parse binary flags
265
+ #
266
+ # @param flags_str [String] Binary string with spaces
267
+ # @return [Integer] Integer value
268
+ def parse_binary_flags(flags_str)
269
+ return 0 unless flags_str
270
+
271
+ flags_str.gsub(/\s+/, "").to_i(2)
272
+ end
273
+
274
+ # Parse timestamp
275
+ #
276
+ # @param timestamp_str [String] Timestamp string
277
+ # @return [Integer] Mac timestamp
278
+ def parse_timestamp(timestamp_str)
279
+ return 0 unless timestamp_str
280
+
281
+ begin
282
+ time = Time.strptime(timestamp_str, "%a %b %e %H:%M:%S %Y")
283
+ mac_epoch = Time.utc(1904, 1, 1)
284
+ (time - mac_epoch).to_i
285
+ rescue StandardError
286
+ 0
287
+ end
288
+ end
289
+
290
+ # Parse hex data
291
+ #
292
+ # @param hex_str [String] Hex string with newlines
293
+ # @return [String] Binary data
294
+ def parse_hex_data(hex_str)
295
+ hex_clean = hex_str.gsub(/\s+/, "")
296
+ [hex_clean].pack("H*")
297
+ end
298
+ end
299
+ end
300
+ end
@@ -1,10 +1,13 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "constants"
4
+ require_relative "loading_modes"
4
5
  require_relative "true_type_font"
5
6
  require_relative "open_type_font"
6
7
  require_relative "true_type_collection"
7
8
  require_relative "open_type_collection"
9
+ require_relative "woff_font"
10
+ require_relative "woff2_font"
8
11
  require_relative "error"
9
12
 
10
13
  module Fontisan
@@ -19,35 +22,52 @@ module Fontisan
19
22
  # font = FontLoader.load("font.otf") # => OpenTypeFont
20
23
  # font = FontLoader.load("fonts.ttc") # => TrueTypeFont (first in collection)
21
24
  # font = FontLoader.load("fonts.ttc", font_index: 2) # => TrueTypeFont (third in collection)
25
+ #
26
+ # @example Loading modes
27
+ # font = FontLoader.load("font.ttf", mode: :metadata) # Load only metadata tables
28
+ # font = FontLoader.load("font.ttf", mode: :full) # Load all tables
29
+ #
30
+ # @example Lazy loading control
31
+ # font = FontLoader.load("font.ttf", lazy: true) # Tables loaded on-demand
32
+ # font = FontLoader.load("font.ttf", lazy: false) # All tables loaded upfront
22
33
  class FontLoader
23
34
  # Load a font from file with automatic format detection
24
35
  #
25
36
  # @param path [String] Path to the font file
26
37
  # @param font_index [Integer] Index of font in collection (0-based, default: 0)
27
- # @return [TrueTypeFont, OpenTypeFont] The loaded font object
38
+ # @param mode [Symbol] Loading mode (:metadata or :full, default: from ENV or :full)
39
+ # @param lazy [Boolean] If true, load tables on demand (default: false for eager loading)
40
+ # @return [TrueTypeFont, OpenTypeFont, WoffFont, Woff2Font] The loaded font object
28
41
  # @raise [Errno::ENOENT] if file does not exist
29
- # @raise [UnsupportedFormatError] for WOFF/WOFF2 or other unsupported formats
42
+ # @raise [UnsupportedFormatError] for unsupported formats
30
43
  # @raise [InvalidFontError] for corrupted or unknown formats
31
- def self.load(path, font_index: 0)
44
+ def self.load(path, font_index: 0, mode: nil, lazy: nil)
32
45
  raise Errno::ENOENT, "File not found: #{path}" unless File.exist?(path)
33
46
 
47
+ # Resolve mode and lazy parameters with environment variables
48
+ resolved_mode = mode || env_mode || LoadingModes::FULL
49
+ resolved_lazy = lazy.nil? ? (env_lazy.nil? ? false : env_lazy) : lazy
50
+
51
+ # Validate mode
52
+ LoadingModes.validate_mode!(resolved_mode)
53
+
34
54
  File.open(path, "rb") do |io|
35
55
  signature = io.read(4)
36
56
  io.rewind
37
57
 
38
58
  case signature
39
59
  when Constants::TTC_TAG
40
- load_from_collection(io, path, font_index)
60
+ load_from_collection(io, path, font_index, mode: resolved_mode, lazy: resolved_lazy)
41
61
  when pack_uint32(Constants::SFNT_VERSION_TRUETYPE)
42
- TrueTypeFont.from_file(path)
62
+ TrueTypeFont.from_file(path, mode: resolved_mode, lazy: resolved_lazy)
43
63
  when "OTTO"
44
- OpenTypeFont.from_file(path)
64
+ OpenTypeFont.from_file(path, mode: resolved_mode, lazy: resolved_lazy)
45
65
  when "wOFF"
46
66
  raise UnsupportedFormatError,
47
- "Unsupported font format: WOFF. Fontisan currently supports TTF, OTF, TTC, and OTC files."
67
+ "Unsupported font format: WOFF. Please convert to TTF/OTF first."
48
68
  when "wOF2"
49
69
  raise UnsupportedFormatError,
50
- "Unsupported font format: WOFF2. Fontisan currently supports TTF, OTF, TTC, and OTC files."
70
+ "Unsupported font format: WOFF2. Please convert to TTF/OTF first."
51
71
  else
52
72
  raise InvalidFontError,
53
73
  "Unknown font format. Expected TTF, OTF, TTC, or OTC file."
@@ -55,14 +75,103 @@ module Fontisan
55
75
  end
56
76
  end
57
77
 
78
+ # Check if a file is a collection (TTC or OTC)
79
+ #
80
+ # @param path [String] Path to the font file
81
+ # @return [Boolean] true if file is a TTC/OTC collection
82
+ # @raise [Errno::ENOENT] if file does not exist
83
+ #
84
+ # @example Check if file is collection
85
+ # FontLoader.collection?("fonts.ttc") # => true
86
+ # FontLoader.collection?("font.ttf") # => false
87
+ def self.collection?(path)
88
+ raise Errno::ENOENT, "File not found: #{path}" unless File.exist?(path)
89
+
90
+ File.open(path, "rb") do |io|
91
+ signature = io.read(4)
92
+ signature == Constants::TTC_TAG
93
+ end
94
+ end
95
+
96
+ # Load a collection object without extracting fonts
97
+ #
98
+ # Returns the collection object (TrueTypeCollection or OpenTypeCollection)
99
+ # without extracting individual fonts. Useful for inspecting collection
100
+ # metadata and structure.
101
+ #
102
+ # @param path [String] Path to the collection file
103
+ # @return [TrueTypeCollection, OpenTypeCollection] The collection object
104
+ # @raise [Errno::ENOENT] if file does not exist
105
+ # @raise [InvalidFontError] if file is not a collection or type cannot be determined
106
+ #
107
+ # @example Load collection for inspection
108
+ # collection = FontLoader.load_collection("fonts.ttc")
109
+ # puts "Collection has #{collection.num_fonts} fonts"
110
+ def self.load_collection(path)
111
+ raise Errno::ENOENT, "File not found: #{path}" unless File.exist?(path)
112
+
113
+ File.open(path, "rb") do |io|
114
+ signature = io.read(4)
115
+
116
+ unless signature == Constants::TTC_TAG
117
+ raise InvalidFontError,
118
+ "File is not a collection (TTC/OTC). Use FontLoader.load instead."
119
+ end
120
+
121
+ # Read first font offset to detect collection type
122
+ io.seek(12) # Skip tag (4) + versions (4) + num_fonts (4)
123
+ first_offset = io.read(4).unpack1("N")
124
+
125
+ # Peek at first font's sfnt_version
126
+ io.seek(first_offset)
127
+ sfnt_version = io.read(4).unpack1("N")
128
+ io.rewind
129
+
130
+ case sfnt_version
131
+ when Constants::SFNT_VERSION_TRUETYPE
132
+ TrueTypeCollection.from_file(path)
133
+ when Constants::SFNT_VERSION_OTTO
134
+ OpenTypeCollection.from_file(path)
135
+ else
136
+ raise InvalidFontError,
137
+ "Unknown font type in collection (sfnt version: 0x#{sfnt_version.to_s(16)})"
138
+ end
139
+ end
140
+ end
141
+
142
+ # Get mode from environment variable
143
+ #
144
+ # @return [Symbol, nil] Mode from FONTISAN_MODE or nil
145
+ # @api private
146
+ def self.env_mode
147
+ env_value = ENV["FONTISAN_MODE"]
148
+ return nil unless env_value
149
+
150
+ mode = env_value.to_sym
151
+ LoadingModes.valid_mode?(mode) ? mode : nil
152
+ end
153
+
154
+ # Get lazy setting from environment variable
155
+ #
156
+ # @return [Boolean, nil] Lazy setting from FONTISAN_LAZY or nil if not set
157
+ # @api private
158
+ def self.env_lazy
159
+ env_value = ENV["FONTISAN_LAZY"]
160
+ return nil unless env_value
161
+
162
+ env_value.downcase == "true"
163
+ end
164
+
58
165
  # Load from a collection file (TTC or OTC)
59
166
  #
60
167
  # @param io [IO] Open file handle
61
168
  # @param path [String] Path to the collection file
62
169
  # @param font_index [Integer] Index of font to extract
170
+ # @param mode [Symbol] Loading mode (:metadata or :full)
171
+ # @param lazy [Boolean] If true, load tables on demand
63
172
  # @return [TrueTypeFont, OpenTypeFont] The loaded font object
64
173
  # @raise [InvalidFontError] if collection type cannot be determined
65
- def self.load_from_collection(io, path, font_index)
174
+ def self.load_from_collection(io, path, font_index, mode: LoadingModes::FULL, lazy: true)
66
175
  # Read collection header to get font offsets
67
176
  io.seek(12) # Skip tag (4) + major_version (2) + minor_version (2) + num_fonts marker (4)
68
177
  num_fonts = io.read(4).unpack1("N")
@@ -84,11 +193,11 @@ module Fontisan
84
193
  when Constants::SFNT_VERSION_TRUETYPE
85
194
  # TrueType Collection
86
195
  ttc = TrueTypeCollection.from_file(path)
87
- File.open(path, "rb") { |f| ttc.font(font_index, f) }
196
+ File.open(path, "rb") { |f| ttc.font(font_index, f, mode: mode) }
88
197
  when Constants::SFNT_VERSION_OTTO
89
198
  # OpenType Collection
90
199
  otc = OpenTypeCollection.from_file(path)
91
- File.open(path, "rb") { |f| otc.font(font_index, f) }
200
+ File.open(path, "rb") { |f| otc.font(font_index, f, mode: mode) }
92
201
  else
93
202
  raise InvalidFontError,
94
203
  "Unknown font type in collection (sfnt version: 0x#{sfnt_version.to_s(16)})"
@@ -104,6 +213,6 @@ module Fontisan
104
213
  [value].pack("N")
105
214
  end
106
215
 
107
- private_class_method :load_from_collection, :pack_uint32
216
+ private_class_method :load_from_collection, :pack_uint32, :env_mode, :env_lazy
108
217
  end
109
218
  end