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,191 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../binary/base_record"
4
+ require_relative "variation_common"
5
+
6
+ module Fontisan
7
+ module Tables
8
+ # Parser for the 'HVAR' (Horizontal Metrics Variations) table
9
+ #
10
+ # The HVAR table provides variation data for horizontal metrics including:
11
+ # - Advance width variations
12
+ # - Left side bearing (LSB) variations
13
+ #
14
+ # This table uses the ItemVariationStore structure to efficiently store
15
+ # delta values for different regions in the design space.
16
+ #
17
+ # Reference: OpenType specification, HVAR table
18
+ #
19
+ # @example Reading an HVAR table
20
+ # data = font.table_data("HVAR")
21
+ # hvar = Fontisan::Tables::Hvar.read(data)
22
+ # advance_deltas = hvar.advance_width_deltas(glyph_id, coordinates)
23
+ class Hvar < Binary::BaseRecord
24
+ uint16 :major_version
25
+ uint16 :minor_version
26
+ uint32 :item_variation_store_offset
27
+ uint32 :advance_width_mapping_offset
28
+ uint32 :lsb_mapping_offset
29
+ uint32 :rsb_mapping_offset
30
+
31
+ # Get version as a float
32
+ #
33
+ # @return [Float] Version number (e.g., 1.0)
34
+ def version
35
+ major_version + (minor_version / 10.0)
36
+ end
37
+
38
+ # Parse the item variation store
39
+ #
40
+ # @return [VariationCommon::ItemVariationStore, nil] Variation store
41
+ def item_variation_store
42
+ return @item_variation_store if defined?(@item_variation_store)
43
+ return @item_variation_store = nil if item_variation_store_offset.zero?
44
+
45
+ data = raw_data
46
+ offset = item_variation_store_offset
47
+
48
+ return @item_variation_store = nil if offset >= data.bytesize
49
+
50
+ store_data = data.byteslice(offset..-1)
51
+ @item_variation_store = VariationCommon::ItemVariationStore.read(store_data)
52
+ rescue StandardError => e
53
+ warn "Failed to parse HVAR item variation store: #{e.message}"
54
+ @item_variation_store = nil
55
+ end
56
+
57
+ # Parse advance width mapping
58
+ #
59
+ # @return [VariationCommon::DeltaSetIndexMap, nil] Advance width map
60
+ def advance_width_mapping
61
+ return @advance_width_mapping if defined?(@advance_width_mapping)
62
+ return @advance_width_mapping = nil if advance_width_mapping_offset.zero?
63
+
64
+ data = raw_data
65
+ offset = advance_width_mapping_offset
66
+
67
+ return @advance_width_mapping = nil if offset >= data.bytesize
68
+
69
+ map_data = data.byteslice(offset..-1)
70
+ @advance_width_mapping = VariationCommon::DeltaSetIndexMap.read(map_data)
71
+ rescue StandardError => e
72
+ warn "Failed to parse HVAR advance width mapping: #{e.message}"
73
+ @advance_width_mapping = nil
74
+ end
75
+
76
+ # Parse LSB (left side bearing) mapping
77
+ #
78
+ # @return [VariationCommon::DeltaSetIndexMap, nil] LSB map
79
+ def lsb_mapping
80
+ return @lsb_mapping if defined?(@lsb_mapping)
81
+ return @lsb_mapping = nil if lsb_mapping_offset.zero?
82
+
83
+ data = raw_data
84
+ offset = lsb_mapping_offset
85
+
86
+ return @lsb_mapping = nil if offset >= data.bytesize
87
+
88
+ map_data = data.byteslice(offset..-1)
89
+ @lsb_mapping = VariationCommon::DeltaSetIndexMap.read(map_data)
90
+ rescue StandardError => e
91
+ warn "Failed to parse HVAR LSB mapping: #{e.message}"
92
+ @lsb_mapping = nil
93
+ end
94
+
95
+ # Parse RSB (right side bearing) mapping
96
+ #
97
+ # @return [VariationCommon::DeltaSetIndexMap, nil] RSB map
98
+ def rsb_mapping
99
+ return @rsb_mapping if defined?(@rsb_mapping)
100
+ return @rsb_mapping = nil if rsb_mapping_offset.zero?
101
+
102
+ data = raw_data
103
+ offset = rsb_mapping_offset
104
+
105
+ return @rsb_mapping = nil if offset >= data.bytesize
106
+
107
+ map_data = data.byteslice(offset..-1)
108
+ @rsb_mapping = VariationCommon::DeltaSetIndexMap.read(map_data)
109
+ rescue StandardError => e
110
+ warn "Failed to parse HVAR RSB mapping: #{e.message}"
111
+ @rsb_mapping = nil
112
+ end
113
+
114
+ # Get advance width delta set for a glyph
115
+ #
116
+ # @param glyph_id [Integer] Glyph ID
117
+ # @return [Array<Integer>, nil] Delta values or nil
118
+ def advance_width_delta_set(glyph_id)
119
+ return nil unless item_variation_store
120
+
121
+ # If no mapping, use glyph_id directly
122
+ if advance_width_mapping.nil?
123
+ return item_variation_store.delta_set(0, glyph_id)
124
+ end
125
+
126
+ # Use mapping to get delta set indices
127
+ map_data = advance_width_mapping.map_data
128
+ return nil if glyph_id >= map_data.length
129
+
130
+ delta_index = map_data[glyph_id]
131
+ outer_index = (delta_index >> 16) & 0xFFFF
132
+ inner_index = delta_index & 0xFFFF
133
+
134
+ item_variation_store.delta_set(outer_index, inner_index)
135
+ end
136
+
137
+ # Get LSB delta set for a glyph
138
+ #
139
+ # @param glyph_id [Integer] Glyph ID
140
+ # @return [Array<Integer>, nil] Delta values or nil
141
+ def lsb_delta_set(glyph_id)
142
+ return nil unless item_variation_store
143
+
144
+ # If no mapping, use glyph_id directly
145
+ if lsb_mapping.nil?
146
+ return item_variation_store.delta_set(0, glyph_id)
147
+ end
148
+
149
+ # Use mapping to get delta set indices
150
+ map_data = lsb_mapping.map_data
151
+ return nil if glyph_id >= map_data.length
152
+
153
+ delta_index = map_data[glyph_id]
154
+ outer_index = (delta_index >> 16) & 0xFFFF
155
+ inner_index = delta_index & 0xFFFF
156
+
157
+ item_variation_store.delta_set(outer_index, inner_index)
158
+ end
159
+
160
+ # Get RSB delta set for a glyph
161
+ #
162
+ # @param glyph_id [Integer] Glyph ID
163
+ # @return [Array<Integer>, nil] Delta values or nil
164
+ def rsb_delta_set(glyph_id)
165
+ return nil unless item_variation_store
166
+
167
+ # If no mapping, use glyph_id directly
168
+ if rsb_mapping.nil?
169
+ return item_variation_store.delta_set(0, glyph_id)
170
+ end
171
+
172
+ # Use mapping to get delta set indices
173
+ map_data = rsb_mapping.map_data
174
+ return nil if glyph_id >= map_data.length
175
+
176
+ delta_index = map_data[glyph_id]
177
+ outer_index = (delta_index >> 16) & 0xFFFF
178
+ inner_index = delta_index & 0xFFFF
179
+
180
+ item_variation_store.delta_set(outer_index, inner_index)
181
+ end
182
+
183
+ # Check if table is valid
184
+ #
185
+ # @return [Boolean] True if valid
186
+ def valid?
187
+ major_version == 1 && minor_version.zero?
188
+ end
189
+ end
190
+ end
191
+ end
@@ -0,0 +1,322 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../binary/base_record"
4
+
5
+ module Fontisan
6
+ module Tables
7
+ # Parser for the 'loca' (Index to Location) table
8
+ #
9
+ # The loca table provides offsets to glyph data in the glyf table.
10
+ # Each glyph has an entry in this table indicating where its data
11
+ # begins in the glyf table. An additional entry marks the end of the
12
+ # last glyph's data.
13
+ #
14
+ # The table has two formats:
15
+ # - Short format (0): uint16 offsets divided by 2 (actual offset = value × 2)
16
+ # - Long format (1): uint32 offsets used as-is
17
+ #
18
+ # The format is determined by head.indexToLocFormat:
19
+ # - 0 = short format (uint16, multiply by 2)
20
+ # - 1 = long format (uint32, use as-is)
21
+ #
22
+ # The table always contains (numGlyphs + 1) offsets, where the last
23
+ # offset marks the end of the last glyph's data in the glyf table.
24
+ #
25
+ # The table is context-dependent and requires:
26
+ # - indexToLocFormat from head table (format selection)
27
+ # - numGlyphs from maxp table (number of offsets to read)
28
+ #
29
+ # Reference: OpenType specification, loca table
30
+ # https://docs.microsoft.com/en-us/typography/opentype/spec/loca
31
+ #
32
+ # @example Parsing loca with context
33
+ # # Get required tables first
34
+ # head = font.table('head')
35
+ # maxp = font.table('maxp')
36
+ #
37
+ # # Parse loca with context
38
+ # data = font.read_table_data('loca')
39
+ # loca = Fontisan::Tables::Loca.read(data)
40
+ # loca.parse_with_context(head.index_to_loc_format, maxp.num_glyphs)
41
+ #
42
+ # # Get offset for a glyph
43
+ # offset = loca.offset_for(42)
44
+ # size = loca.size_of(42)
45
+ # is_empty = loca.empty?(42)
46
+ class Loca < Binary::BaseRecord
47
+ # Short format constant (from head.indexToLocFormat)
48
+ FORMAT_SHORT = 0
49
+
50
+ # Long format constant (from head.indexToLocFormat)
51
+ FORMAT_LONG = 1
52
+
53
+ # Store the raw data for deferred parsing
54
+ attr_accessor :raw_data
55
+
56
+ # Parsed offsets array
57
+ # @return [Array<Integer>] Array of glyph offsets in glyf table
58
+ attr_reader :offsets
59
+
60
+ # Format of the loca table (0 = short, 1 = long)
61
+ # @return [Integer] Format indicator
62
+ attr_reader :format
63
+
64
+ # Total number of glyphs from maxp table
65
+ # @return [Integer] Total glyph count
66
+ attr_reader :num_glyphs
67
+
68
+ # Override read to capture raw data
69
+ #
70
+ # @param io [IO, String] Input data
71
+ # @return [Loca] Parsed table instance
72
+ def self.read(io)
73
+ instance = new
74
+
75
+ # Handle nil or empty data gracefully
76
+ instance.raw_data = if io.nil?
77
+ "".b
78
+ elsif io.is_a?(String)
79
+ io
80
+ else
81
+ io.read || "".b
82
+ end
83
+
84
+ instance
85
+ end
86
+
87
+ # Parse the table with font context
88
+ #
89
+ # This method must be called after reading the table data, providing
90
+ # the indexToLocFormat from head and numGlyphs from maxp.
91
+ #
92
+ # @param index_to_loc_format [Integer] Format (0 = short, 1 = long) from head table
93
+ # @param num_glyphs [Integer] Total number of glyphs from maxp table
94
+ # @raise [ArgumentError] If context parameters are invalid
95
+ # @raise [Fontisan::CorruptedTableError] If table data is insufficient
96
+ def parse_with_context(index_to_loc_format, num_glyphs)
97
+ validate_context_params(index_to_loc_format, num_glyphs)
98
+
99
+ @format = index_to_loc_format
100
+ @num_glyphs = num_glyphs
101
+
102
+ io = StringIO.new(raw_data)
103
+ io.set_encoding(Encoding::BINARY)
104
+
105
+ # Number of offsets is numGlyphs + 1 (extra offset marks end of last glyph)
106
+ offset_count = num_glyphs + 1
107
+
108
+ @offsets = if short_format?
109
+ parse_short_offsets(io, offset_count)
110
+ else
111
+ parse_long_offsets(io, offset_count)
112
+ end
113
+
114
+ validate_parsed_data!(io, offset_count)
115
+ end
116
+
117
+ # Get the offset for a specific glyph ID in the glyf table
118
+ #
119
+ # @param glyph_id [Integer] Glyph ID (0-based)
120
+ # @return [Integer, nil] Byte offset in glyf table, or nil if invalid ID
121
+ # @raise [RuntimeError] If table has not been parsed with context
122
+ #
123
+ # @example Getting glyph offset
124
+ # offset = loca.offset_for(0) # .notdef glyph offset
125
+ def offset_for(glyph_id)
126
+ raise "Table not parsed. Call parse_with_context first." unless @offsets
127
+
128
+ return nil if glyph_id >= num_glyphs || glyph_id.negative?
129
+
130
+ offsets[glyph_id]
131
+ end
132
+
133
+ # Calculate the size of glyph data for a specific glyph ID
134
+ #
135
+ # The size is calculated as the difference between consecutive offsets:
136
+ # size = offsets[glyph_id + 1] - offsets[glyph_id]
137
+ #
138
+ # A size of 0 indicates an empty glyph (no outline data).
139
+ #
140
+ # @param glyph_id [Integer] Glyph ID (0-based)
141
+ # @return [Integer, nil] Size in bytes, or nil if invalid ID
142
+ # @raise [RuntimeError] If table has not been parsed with context
143
+ #
144
+ # @example Calculating glyph size
145
+ # size = loca.size_of(42) # Size of glyph 42 in bytes
146
+ def size_of(glyph_id)
147
+ raise "Table not parsed. Call parse_with_context first." unless @offsets
148
+
149
+ return nil if glyph_id >= num_glyphs || glyph_id.negative?
150
+
151
+ offsets[glyph_id + 1] - offsets[glyph_id]
152
+ end
153
+
154
+ # Check if a glyph has no outline data
155
+ #
156
+ # A glyph is empty when its size is 0, which occurs when consecutive
157
+ # offsets are equal. Empty glyphs are used for space characters and
158
+ # other non-visible glyphs.
159
+ #
160
+ # @param glyph_id [Integer] Glyph ID (0-based)
161
+ # @return [Boolean, nil] True if empty, false if has data, nil if invalid ID
162
+ # @raise [RuntimeError] If table has not been parsed with context
163
+ #
164
+ # @example Checking if glyph is empty
165
+ # is_empty = loca.empty?(32) # Check if space character is empty
166
+ def empty?(glyph_id)
167
+ size = size_of(glyph_id)
168
+ size&.zero?
169
+ end
170
+
171
+ # Check if the table has been parsed with context
172
+ #
173
+ # @return [Boolean] True if parsed, false otherwise
174
+ def parsed?
175
+ !@offsets.nil?
176
+ end
177
+
178
+ # Check if using short format (format 0)
179
+ #
180
+ # @return [Boolean] True if short format, false otherwise
181
+ def short_format?
182
+ format == FORMAT_SHORT
183
+ end
184
+
185
+ # Check if using long format (format 1)
186
+ #
187
+ # @return [Boolean] True if long format, false otherwise
188
+ def long_format?
189
+ format == FORMAT_LONG
190
+ end
191
+
192
+ # Get the expected size for this table
193
+ #
194
+ # @return [Integer, nil] Expected size in bytes, or nil if not parsed
195
+ def expected_size
196
+ return nil unless parsed?
197
+
198
+ offset_count = num_glyphs + 1
199
+ if short_format?
200
+ offset_count * 2 # uint16
201
+ else
202
+ offset_count * 4 # uint32
203
+ end
204
+ end
205
+
206
+ private
207
+
208
+ # Validate context parameters
209
+ #
210
+ # @param format [Integer] Format indicator
211
+ # @param num_glyphs [Integer] Total glyphs
212
+ # @raise [ArgumentError] If parameters are invalid
213
+ def validate_context_params(format, num_glyphs)
214
+ if format.nil? || (format != FORMAT_SHORT && format != FORMAT_LONG)
215
+ raise ArgumentError,
216
+ "indexToLocFormat must be 0 (short) or 1 (long), " \
217
+ "got: #{format.inspect}"
218
+ end
219
+
220
+ if num_glyphs.nil? || num_glyphs < 1
221
+ raise ArgumentError,
222
+ "numGlyphs must be >= 1, got: #{num_glyphs.inspect}"
223
+ end
224
+ end
225
+
226
+ # Parse short format offsets (uint16, multiply by 2)
227
+ #
228
+ # @param io [StringIO] Input stream
229
+ # @param count [Integer] Number of offsets to parse
230
+ # @return [Array<Integer>] Array of offsets
231
+ # @raise [Fontisan::CorruptedTableError] If insufficient data
232
+ def parse_short_offsets(io, count)
233
+ offsets = []
234
+ count.times do |i|
235
+ value = read_uint16(io)
236
+
237
+ if value.nil?
238
+ raise Fontisan::CorruptedTableError,
239
+ "Insufficient data for short offset at index #{i}"
240
+ end
241
+
242
+ # Short offsets are divided by 2, so multiply to get actual offset
243
+ offsets << (value * 2)
244
+ end
245
+ offsets
246
+ end
247
+
248
+ # Parse long format offsets (uint32, use as-is)
249
+ #
250
+ # @param io [StringIO] Input stream
251
+ # @param count [Integer] Number of offsets to parse
252
+ # @return [Array<Integer>] Array of offsets
253
+ # @raise [Fontisan::CorruptedTableError] If insufficient data
254
+ def parse_long_offsets(io, count)
255
+ offsets = []
256
+ count.times do |i|
257
+ value = read_uint32(io)
258
+
259
+ if value.nil?
260
+ raise Fontisan::CorruptedTableError,
261
+ "Insufficient data for long offset at index #{i}"
262
+ end
263
+
264
+ offsets << value
265
+ end
266
+ offsets
267
+ end
268
+
269
+ # Validate that all expected data was parsed
270
+ #
271
+ # @param io [StringIO] Input stream
272
+ # @param offset_count [Integer] Expected number of offsets
273
+ # @raise [Fontisan::CorruptedTableError] If data validation fails
274
+ def validate_parsed_data!(io, offset_count)
275
+ # Check that we parsed the expected number of offsets
276
+ if offsets.length != offset_count
277
+ raise Fontisan::CorruptedTableError,
278
+ "Expected #{offset_count} offsets, got #{offsets.length}"
279
+ end
280
+
281
+ # Check that offsets are monotonically increasing
282
+ offsets.each_cons(2).with_index do |(prev, curr), i|
283
+ if curr < prev
284
+ raise Fontisan::CorruptedTableError,
285
+ "Offsets are not monotonically increasing: " \
286
+ "offset[#{i}]=#{prev}, offset[#{i + 1}]=#{curr}"
287
+ end
288
+ end
289
+
290
+ # Check for unexpected remaining data
291
+ remaining = io.read
292
+ if remaining && !remaining.empty? && remaining.length > 3
293
+ # Some fonts may have padding, only warn if significant
294
+ warn "Warning: loca table has #{remaining.length} unexpected " \
295
+ "bytes after parsing"
296
+ end
297
+ end
298
+
299
+ # Read unsigned 16-bit integer
300
+ #
301
+ # @param io [StringIO] Input stream
302
+ # @return [Integer, nil] Value or nil if insufficient data
303
+ def read_uint16(io)
304
+ data = io.read(2)
305
+ return nil if data.nil? || data.length < 2
306
+
307
+ data.unpack1("n") # Big-endian unsigned 16-bit
308
+ end
309
+
310
+ # Read unsigned 32-bit integer
311
+ #
312
+ # @param io [StringIO] Input stream
313
+ # @return [Integer, nil] Value or nil if insufficient data
314
+ def read_uint32(io)
315
+ data = io.read(4)
316
+ return nil if data.nil? || data.length < 4
317
+
318
+ data.unpack1("N") # Big-endian unsigned 32-bit
319
+ end
320
+ end
321
+ end
322
+ end
@@ -0,0 +1,192 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../binary/base_record"
4
+
5
+ module Fontisan
6
+ module Tables
7
+ # BinData structure for the 'maxp' (Maximum Profile) table
8
+ #
9
+ # The maxp table contains memory and complexity limits for the font.
10
+ # It provides the number of glyphs and various maximum values needed
11
+ # for font rendering and processing.
12
+ #
13
+ # The table has two versions:
14
+ # - Version 0.5 (0x00005000): CFF fonts - only version and numGlyphs
15
+ # - Version 1.0 (0x00010000): TrueType fonts - includes additional fields
16
+ #
17
+ # Version 1.0 fields provide information about:
18
+ # - Glyph outline complexity (points, contours)
19
+ # - Composite glyph structure
20
+ # - TrueType instruction limitations
21
+ #
22
+ # Reference: OpenType specification, maxp table
23
+ # https://docs.microsoft.com/en-us/typography/opentype/spec/maxp
24
+ #
25
+ # @example Reading a maxp table
26
+ # data = File.binread("font.ttf", size, maxp_offset)
27
+ # maxp = Fontisan::Tables::Maxp.read(data)
28
+ # puts maxp.num_glyphs # => 512
29
+ # puts maxp.version # => 1.0 or 0.5
30
+ # puts maxp.truetype? # => true or false
31
+ class Maxp < Binary::BaseRecord
32
+ # Version 0.5 constant (CFF fonts)
33
+ VERSION_0_5 = 0x00005000
34
+
35
+ # Version 1.0 constant (TrueType fonts)
36
+ VERSION_1_0 = 0x00010000
37
+
38
+ # Minimum table size for version 0.5 (4 + 2 = 6 bytes)
39
+ TABLE_SIZE_V0_5 = 6
40
+
41
+ # Full table size for version 1.0 (4 + 2 + 13×2 = 32 bytes)
42
+ TABLE_SIZE_V1_0 = 32
43
+
44
+ # Version as 16.16 fixed-point (stored as int32)
45
+ # 0x00010000 for version 1.0 (TrueType)
46
+ # 0x00005000 for version 0.5 (CFF)
47
+ int32 :version_raw
48
+
49
+ # Total number of glyphs in the font
50
+ # Must be >= 1 (at minimum, .notdef must be present)
51
+ uint16 :num_glyphs
52
+
53
+ # The following fields are only present in version 1.0 (TrueType fonts)
54
+
55
+ # Maximum points in a non-composite glyph
56
+ uint16 :max_points, onlyif: -> { version_raw == VERSION_1_0 }
57
+
58
+ # Maximum contours in a non-composite glyph
59
+ uint16 :max_contours, onlyif: -> { version_raw == VERSION_1_0 }
60
+
61
+ # Maximum points in a composite glyph
62
+ uint16 :max_composite_points, onlyif: -> { version_raw == VERSION_1_0 }
63
+
64
+ # Maximum contours in a composite glyph
65
+ uint16 :max_composite_contours, onlyif: -> { version_raw == VERSION_1_0 }
66
+
67
+ # Maximum zones (1 or 2, depending on instructions)
68
+ # 1 = no twilight zone, 2 = twilight zone present
69
+ uint16 :max_zones, onlyif: -> { version_raw == VERSION_1_0 }
70
+
71
+ # Maximum points used in twilight zone (Z0)
72
+ uint16 :max_twilight_points, onlyif: -> { version_raw == VERSION_1_0 }
73
+
74
+ # Maximum storage area locations
75
+ uint16 :max_storage, onlyif: -> { version_raw == VERSION_1_0 }
76
+
77
+ # Maximum function definitions
78
+ uint16 :max_function_defs, onlyif: -> { version_raw == VERSION_1_0 }
79
+
80
+ # Maximum instruction definitions
81
+ uint16 :max_instruction_defs, onlyif: -> { version_raw == VERSION_1_0 }
82
+
83
+ # Maximum stack depth
84
+ uint16 :max_stack_elements, onlyif: -> { version_raw == VERSION_1_0 }
85
+
86
+ # Maximum byte count for glyph instructions
87
+ uint16 :max_size_of_instructions, onlyif: -> {
88
+ version_raw == VERSION_1_0
89
+ }
90
+
91
+ # Maximum component elements in a composite glyph
92
+ uint16 :max_component_elements, onlyif: -> { version_raw == VERSION_1_0 }
93
+
94
+ # Maximum levels of recursion in composite glyphs
95
+ # 0 if font has no composite glyphs
96
+ uint16 :max_component_depth, onlyif: -> { version_raw == VERSION_1_0 }
97
+
98
+ # Convert version from fixed-point to float
99
+ #
100
+ # Version 0.5 (0x00005000) is a special case, not standard 16.16 fixed-point
101
+ #
102
+ # @return [Float] Version number (1.0 or 0.5)
103
+ def version
104
+ case version_raw
105
+ when VERSION_0_5
106
+ 0.5
107
+ when VERSION_1_0
108
+ 1.0
109
+ else
110
+ fixed_to_float(version_raw)
111
+ end
112
+ end
113
+
114
+ # Check if this is version 1.0 (TrueType)
115
+ #
116
+ # @return [Boolean] True if version 1.0, false otherwise
117
+ def version_1_0?
118
+ version_raw == VERSION_1_0
119
+ end
120
+
121
+ # Check if this is version 0.5 (CFF)
122
+ #
123
+ # @return [Boolean] True if version 0.5, false otherwise
124
+ def version_0_5?
125
+ version_raw == VERSION_0_5
126
+ end
127
+
128
+ # Check if this is a TrueType font (alias for version_1_0?)
129
+ #
130
+ # @return [Boolean] True if TrueType font, false otherwise
131
+ def truetype?
132
+ version_1_0?
133
+ end
134
+
135
+ # Check if this is a CFF font (alias for version_0_5?)
136
+ #
137
+ # @return [Boolean] True if CFF font, false otherwise
138
+ def cff?
139
+ version_0_5?
140
+ end
141
+
142
+ # Check if the table is valid
143
+ #
144
+ # @return [Boolean] True if valid, false otherwise
145
+ def valid?
146
+ # Version must be either 0.5 or 1.0
147
+ return false unless version_0_5? || version_1_0?
148
+
149
+ # Number of glyphs must be at least 1
150
+ return false unless num_glyphs >= 1
151
+
152
+ # For version 1.0, maxZones must be 1 or 2
153
+ if version_1_0? && max_zones && !(max_zones >= 1 && max_zones <= 2)
154
+ return false
155
+ end
156
+
157
+ true
158
+ end
159
+
160
+ # Validate the table and raise error if invalid
161
+ #
162
+ # @raise [Fontisan::CorruptedTableError] If table is invalid
163
+ def validate_structure!
164
+ unless version_0_5? || version_1_0?
165
+ raise Fontisan::CorruptedTableError,
166
+ "Invalid maxp version: expected 0x00005000 (0.5) or " \
167
+ "0x00010000 (1.0), got 0x#{version_raw.to_s(16).upcase}"
168
+ end
169
+
170
+ unless num_glyphs >= 1
171
+ raise Fontisan::CorruptedTableError,
172
+ "Invalid number of glyphs: must be >= 1, got #{num_glyphs}"
173
+ end
174
+
175
+ if version_1_0? && max_zones && (max_zones < 1 || max_zones > 2)
176
+ raise Fontisan::CorruptedTableError,
177
+ "Invalid maxZones: must be 1 or 2, got #{max_zones}"
178
+ end
179
+ end
180
+
181
+ # Alias for compatibility
182
+ alias validate! validate_structure!
183
+
184
+ # Get the expected table size based on version
185
+ #
186
+ # @return [Integer] Expected size in bytes
187
+ def expected_size
188
+ version_1_0? ? TABLE_SIZE_V1_0 : TABLE_SIZE_V0_5
189
+ end
190
+ end
191
+ end
192
+ end