fontisan 0.1.0 → 0.2.1

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 (214) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop_todo.yml +672 -69
  3. data/Gemfile +1 -0
  4. data/LICENSE +5 -1
  5. data/README.adoc +1477 -297
  6. data/Rakefile +63 -41
  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 +364 -4
  12. data/lib/fontisan/collection/builder.rb +341 -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 +317 -0
  16. data/lib/fontisan/collection/writer.rb +306 -0
  17. data/lib/fontisan/commands/base_command.rb +24 -1
  18. data/lib/fontisan/commands/convert_command.rb +218 -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 +286 -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 +203 -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 +79 -0
  37. data/lib/fontisan/converters/conversion_strategy.rb +96 -0
  38. data/lib/fontisan/converters/format_converter.rb +408 -0
  39. data/lib/fontisan/converters/outline_converter.rb +998 -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 +122 -15
  57. data/lib/fontisan/font_writer.rb +302 -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 +310 -0
  61. data/lib/fontisan/hints/postscript_hint_applier.rb +266 -0
  62. data/lib/fontisan/hints/postscript_hint_extractor.rb +354 -0
  63. data/lib/fontisan/hints/truetype_hint_applier.rb +117 -0
  64. data/lib/fontisan/hints/truetype_hint_extractor.rb +289 -0
  65. data/lib/fontisan/loading_modes.rb +115 -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 +405 -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 +321 -19
  88. data/lib/fontisan/open_type_font_extensions.rb +54 -0
  89. data/lib/fontisan/optimizers/charstring_rewriter.rb +161 -0
  90. data/lib/fontisan/optimizers/pattern_analyzer.rb +308 -0
  91. data/lib/fontisan/optimizers/stack_tracker.rb +246 -0
  92. data/lib/fontisan/optimizers/subroutine_builder.rb +134 -0
  93. data/lib/fontisan/optimizers/subroutine_generator.rb +207 -0
  94. data/lib/fontisan/optimizers/subroutine_optimizer.rb +107 -0
  95. data/lib/fontisan/outline_extractor.rb +423 -0
  96. data/lib/fontisan/pipeline/format_detector.rb +249 -0
  97. data/lib/fontisan/pipeline/output_writer.rb +154 -0
  98. data/lib/fontisan/pipeline/strategies/base_strategy.rb +75 -0
  99. data/lib/fontisan/pipeline/strategies/instance_strategy.rb +93 -0
  100. data/lib/fontisan/pipeline/strategies/named_strategy.rb +118 -0
  101. data/lib/fontisan/pipeline/strategies/preserve_strategy.rb +56 -0
  102. data/lib/fontisan/pipeline/transformation_pipeline.rb +411 -0
  103. data/lib/fontisan/pipeline/variation_resolver.rb +165 -0
  104. data/lib/fontisan/subset/builder.rb +268 -0
  105. data/lib/fontisan/subset/glyph_mapping.rb +215 -0
  106. data/lib/fontisan/subset/options.rb +142 -0
  107. data/lib/fontisan/subset/profile.rb +152 -0
  108. data/lib/fontisan/subset/table_subsetter.rb +461 -0
  109. data/lib/fontisan/svg/font_face_generator.rb +278 -0
  110. data/lib/fontisan/svg/font_generator.rb +264 -0
  111. data/lib/fontisan/svg/glyph_generator.rb +168 -0
  112. data/lib/fontisan/svg/view_box_calculator.rb +137 -0
  113. data/lib/fontisan/tables/cff/cff_glyph.rb +176 -0
  114. data/lib/fontisan/tables/cff/charset.rb +282 -0
  115. data/lib/fontisan/tables/cff/charstring.rb +934 -0
  116. data/lib/fontisan/tables/cff/charstring_builder.rb +356 -0
  117. data/lib/fontisan/tables/cff/charstring_parser.rb +237 -0
  118. data/lib/fontisan/tables/cff/charstring_rebuilder.rb +172 -0
  119. data/lib/fontisan/tables/cff/charstrings_index.rb +162 -0
  120. data/lib/fontisan/tables/cff/dict.rb +351 -0
  121. data/lib/fontisan/tables/cff/dict_builder.rb +257 -0
  122. data/lib/fontisan/tables/cff/encoding.rb +274 -0
  123. data/lib/fontisan/tables/cff/header.rb +102 -0
  124. data/lib/fontisan/tables/cff/hint_operation_injector.rb +207 -0
  125. data/lib/fontisan/tables/cff/index.rb +237 -0
  126. data/lib/fontisan/tables/cff/index_builder.rb +170 -0
  127. data/lib/fontisan/tables/cff/offset_recalculator.rb +70 -0
  128. data/lib/fontisan/tables/cff/private_dict.rb +284 -0
  129. data/lib/fontisan/tables/cff/private_dict_writer.rb +125 -0
  130. data/lib/fontisan/tables/cff/table_builder.rb +221 -0
  131. data/lib/fontisan/tables/cff/top_dict.rb +236 -0
  132. data/lib/fontisan/tables/cff.rb +489 -0
  133. data/lib/fontisan/tables/cff2/blend_operator.rb +240 -0
  134. data/lib/fontisan/tables/cff2/charstring_parser.rb +591 -0
  135. data/lib/fontisan/tables/cff2/operand_stack.rb +232 -0
  136. data/lib/fontisan/tables/cff2/private_dict_blend_handler.rb +246 -0
  137. data/lib/fontisan/tables/cff2/region_matcher.rb +200 -0
  138. data/lib/fontisan/tables/cff2/table_builder.rb +574 -0
  139. data/lib/fontisan/tables/cff2/table_reader.rb +419 -0
  140. data/lib/fontisan/tables/cff2/variation_data_extractor.rb +212 -0
  141. data/lib/fontisan/tables/cff2.rb +346 -0
  142. data/lib/fontisan/tables/cvar.rb +203 -0
  143. data/lib/fontisan/tables/fvar.rb +2 -2
  144. data/lib/fontisan/tables/glyf/compound_glyph.rb +483 -0
  145. data/lib/fontisan/tables/glyf/compound_glyph_resolver.rb +136 -0
  146. data/lib/fontisan/tables/glyf/curve_converter.rb +343 -0
  147. data/lib/fontisan/tables/glyf/glyph_builder.rb +450 -0
  148. data/lib/fontisan/tables/glyf/simple_glyph.rb +382 -0
  149. data/lib/fontisan/tables/glyf.rb +235 -0
  150. data/lib/fontisan/tables/gvar.rb +231 -0
  151. data/lib/fontisan/tables/hhea.rb +124 -0
  152. data/lib/fontisan/tables/hmtx.rb +287 -0
  153. data/lib/fontisan/tables/hvar.rb +191 -0
  154. data/lib/fontisan/tables/loca.rb +322 -0
  155. data/lib/fontisan/tables/maxp.rb +192 -0
  156. data/lib/fontisan/tables/mvar.rb +185 -0
  157. data/lib/fontisan/tables/name.rb +99 -30
  158. data/lib/fontisan/tables/variation_common.rb +346 -0
  159. data/lib/fontisan/tables/vvar.rb +234 -0
  160. data/lib/fontisan/true_type_collection.rb +156 -2
  161. data/lib/fontisan/true_type_font.rb +321 -20
  162. data/lib/fontisan/true_type_font_extensions.rb +54 -0
  163. data/lib/fontisan/utilities/brotli_wrapper.rb +159 -0
  164. data/lib/fontisan/utilities/checksum_calculator.rb +60 -0
  165. data/lib/fontisan/utils/thread_pool.rb +134 -0
  166. data/lib/fontisan/validation/checksum_validator.rb +170 -0
  167. data/lib/fontisan/validation/consistency_validator.rb +197 -0
  168. data/lib/fontisan/validation/structure_validator.rb +198 -0
  169. data/lib/fontisan/validation/table_validator.rb +158 -0
  170. data/lib/fontisan/validation/validator.rb +152 -0
  171. data/lib/fontisan/validation/variable_font_validator.rb +218 -0
  172. data/lib/fontisan/variable/axis_normalizer.rb +215 -0
  173. data/lib/fontisan/variable/delta_applicator.rb +313 -0
  174. data/lib/fontisan/variable/glyph_delta_processor.rb +218 -0
  175. data/lib/fontisan/variable/instancer.rb +344 -0
  176. data/lib/fontisan/variable/metric_delta_processor.rb +282 -0
  177. data/lib/fontisan/variable/region_matcher.rb +208 -0
  178. data/lib/fontisan/variable/static_font_builder.rb +213 -0
  179. data/lib/fontisan/variable/table_updater.rb +219 -0
  180. data/lib/fontisan/variation/blend_applier.rb +199 -0
  181. data/lib/fontisan/variation/cache.rb +298 -0
  182. data/lib/fontisan/variation/cache_key_builder.rb +162 -0
  183. data/lib/fontisan/variation/converter.rb +375 -0
  184. data/lib/fontisan/variation/data_extractor.rb +86 -0
  185. data/lib/fontisan/variation/delta_applier.rb +266 -0
  186. data/lib/fontisan/variation/delta_parser.rb +228 -0
  187. data/lib/fontisan/variation/inspector.rb +275 -0
  188. data/lib/fontisan/variation/instance_generator.rb +273 -0
  189. data/lib/fontisan/variation/instance_writer.rb +341 -0
  190. data/lib/fontisan/variation/interpolator.rb +231 -0
  191. data/lib/fontisan/variation/metrics_adjuster.rb +318 -0
  192. data/lib/fontisan/variation/optimizer.rb +418 -0
  193. data/lib/fontisan/variation/parallel_generator.rb +150 -0
  194. data/lib/fontisan/variation/region_matcher.rb +221 -0
  195. data/lib/fontisan/variation/subsetter.rb +463 -0
  196. data/lib/fontisan/variation/table_accessor.rb +105 -0
  197. data/lib/fontisan/variation/tuple_variation_header.rb +51 -0
  198. data/lib/fontisan/variation/validator.rb +345 -0
  199. data/lib/fontisan/variation/variable_svg_generator.rb +268 -0
  200. data/lib/fontisan/variation/variation_context.rb +211 -0
  201. data/lib/fontisan/variation/variation_preserver.rb +288 -0
  202. data/lib/fontisan/version.rb +1 -1
  203. data/lib/fontisan/version.rb.orig +9 -0
  204. data/lib/fontisan/woff2/directory.rb +257 -0
  205. data/lib/fontisan/woff2/glyf_transformer.rb +666 -0
  206. data/lib/fontisan/woff2/header.rb +101 -0
  207. data/lib/fontisan/woff2/hmtx_transformer.rb +164 -0
  208. data/lib/fontisan/woff2/table_transformer.rb +163 -0
  209. data/lib/fontisan/woff2_font.rb +717 -0
  210. data/lib/fontisan/woff_font.rb +488 -0
  211. data/lib/fontisan.rb +132 -0
  212. data/scripts/compare_stack_aware.rb +187 -0
  213. data/scripts/measure_optimization.rb +141 -0
  214. metadata +234 -4
@@ -0,0 +1,574 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../cff/table_builder"
4
+ require_relative "table_reader"
5
+ require_relative "private_dict_blend_handler"
6
+ require_relative "../cff/charstring_parser"
7
+ require_relative "../cff/charstring_builder"
8
+ require_relative "../cff/hint_operation_injector"
9
+ require_relative "../cff/dict_builder"
10
+ require "stringio"
11
+
12
+ module Fontisan
13
+ module Tables
14
+ class Cff2
15
+ # Rebuilds CFF2 table with modifications while preserving variation data
16
+ #
17
+ # CFF2TableBuilder extends CFF TableBuilder to handle CFF2-specific
18
+ # structures including Variable Store and blend operators in CharStrings.
19
+ # It preserves variation data while applying hints to variable fonts.
20
+ #
21
+ # Key Principles:
22
+ # - Variable Store is read-only and preserved unchanged
23
+ # - Blend operators in CharStrings are maintained
24
+ # - Blend in Private DICT is preserved
25
+ # - Reuses Phase 1+2 infrastructure for CharString modification
26
+ #
27
+ # Reference: Adobe Technical Note #5177 (CFF2)
28
+ #
29
+ # @example Rebuild CFF2 with hints
30
+ # reader = CFF2TableReader.new(cff2_data)
31
+ # builder = CFF2TableBuilder.new(reader, hint_set)
32
+ # new_cff2 = builder.build
33
+ class TableBuilder < Tables::Cff::TableBuilder
34
+ # @return [CFF2TableReader] CFF2 table reader
35
+ attr_reader :reader
36
+
37
+ # @return [Hash, nil] Variable Store data
38
+ attr_reader :variable_store
39
+
40
+ # @return [Integer] Number of variation axes
41
+ attr_reader :num_axes
42
+
43
+ # Initialize builder with CFF2 table reader and hint set
44
+ #
45
+ # @param reader [CFF2TableReader] CFF2 table reader
46
+ # @param hint_set [Object] Hint set with font-level and per-glyph hints
47
+ def initialize(reader, hint_set = nil)
48
+ @reader = reader
49
+ @hint_set = hint_set
50
+
51
+ # Read CFF2 structures
52
+ @reader.read_header
53
+ @reader.read_top_dict
54
+ @variable_store = @reader.read_variable_store
55
+
56
+ # Determine number of axes from Variable Store
57
+ @num_axes = extract_num_axes
58
+
59
+ # Don't call super - CFF2 has different structure
60
+ end
61
+
62
+ # Build CFF2 table with hints applied
63
+ #
64
+ # @return [String] Binary CFF2 table data
65
+ def build
66
+ # Check if we need to modify anything
67
+ return @reader.data unless should_modify?
68
+
69
+ # Extract and modify sections
70
+ header_data = extract_header
71
+ top_dict_hash = @reader.top_dict
72
+ charstrings_data = extract_and_modify_charstrings
73
+ private_dict_data = extract_and_modify_private_dict
74
+ vstore_data = extract_variable_store
75
+
76
+ # Rebuild CFF2 table
77
+ rebuild_cff2_table(
78
+ header: header_data,
79
+ top_dict: top_dict_hash,
80
+ charstrings: charstrings_data,
81
+ private_dict: private_dict_data,
82
+ vstore: vstore_data
83
+ )
84
+ end
85
+
86
+ # Check if table has variation data
87
+ #
88
+ # @return [Boolean] True if Variable Store present
89
+ def variable?
90
+ !@variable_store.nil?
91
+ end
92
+
93
+ private
94
+
95
+ # Extract number of variation axes from Variable Store
96
+ #
97
+ # @return [Integer] Number of axes
98
+ def extract_num_axes
99
+ return 0 unless @variable_store
100
+
101
+ # Get from first region's axis count
102
+ regions = @variable_store[:regions]
103
+ return 0 if regions.nil? || regions.empty?
104
+
105
+ regions.first[:axis_count] || 0
106
+ end
107
+
108
+ # Extract CharStrings offset from Top DICT
109
+ #
110
+ # CFF2 Top DICT operator 17 contains CharStrings offset.
111
+ #
112
+ # @return [Integer] CharStrings offset
113
+ def extract_charstrings_offset
114
+ top_dict = @reader.top_dict
115
+ return nil unless top_dict
116
+
117
+ # Operator 17 = CharStrings offset
118
+ top_dict[17]
119
+ end
120
+
121
+ # Modify CharStrings with per-glyph hints
122
+ #
123
+ # Uses Phase 1 CharStringRebuilder and Phase 2 HintOperationInjector
124
+ # to inject hints while preserving blend operators.
125
+ #
126
+ # @param charstrings_index [CharstringsIndex] Source CharStrings INDEX
127
+ # @return [String] Modified CharStrings INDEX binary data
128
+ def modify_charstrings(charstrings_index)
129
+ return nil unless @hint_set
130
+
131
+ # Get hinted glyph IDs from HintSet
132
+ hinted_glyph_ids = @hint_set.hinted_glyph_ids
133
+ return nil if hinted_glyph_ids.empty?
134
+
135
+ # Create rebuilder with stem count
136
+ stem_count = calculate_stem_count
137
+ rebuilder = Cff::CharStringRebuilder.new(charstrings_index, stem_count: stem_count)
138
+
139
+ # Modify each glyph with hints
140
+ hinted_glyph_ids.each do |glyph_id|
141
+ # Get hints for this glyph
142
+ hints = @hint_set.get_glyph_hints(glyph_id)
143
+ next if hints.nil? || hints.empty?
144
+
145
+ # Convert glyph_id to integer if it's a string
146
+ glyph_index = glyph_id.to_i
147
+
148
+ rebuilder.modify_charstring(glyph_index) do |operations|
149
+ # Inject hints while preserving blend operators
150
+ injector = Cff::HintOperationInjector.new
151
+ injector.inject(hints, operations)
152
+ end
153
+ end
154
+
155
+ # Rebuild CharStrings INDEX
156
+ rebuilder.rebuild
157
+ end
158
+
159
+ # Calculate stem count from font-level hints
160
+ #
161
+ # Stem count is needed for hintmask/cntrmask parsing.
162
+ # Extracted from blue values and stem snap arrays.
163
+ #
164
+ # @return [Integer] Total stem count (hstem + vstem)
165
+ def calculate_stem_count
166
+ return 0 unless @hint_set
167
+
168
+ # Get font-level hints (from private_dict_hints JSON)
169
+ return 0 unless @hint_set.respond_to?(:private_dict_hints)
170
+
171
+ begin
172
+ font_hints = JSON.parse(@hint_set.private_dict_hints || "{}")
173
+ rescue JSON::ParserError
174
+ return 0
175
+ end
176
+
177
+ return 0 if font_hints.nil? || font_hints.empty?
178
+
179
+ # Count stems from blue zones (hstem)
180
+ hstem_count = 0
181
+ blue_values = font_hints["blue_values"] || font_hints[:blue_values]
182
+ if blue_values && blue_values.is_a?(Array)
183
+ hstem_count = blue_values.size / 2
184
+ end
185
+
186
+ # Count stems from stem snap (vstem)
187
+ vstem_count = 0
188
+ stem_snap_h = font_hints["stem_snap_h"] || font_hints[:stem_snap_h]
189
+ if stem_snap_h && stem_snap_h.is_a?(Array)
190
+ vstem_count = stem_snap_h.size
191
+ end
192
+
193
+ hstem_count + vstem_count
194
+ end
195
+
196
+ # Check if font-level hints are present
197
+ #
198
+ # @return [Boolean] True if private_dict_hints are present
199
+ def has_font_level_hints?
200
+ return false unless @hint_set.respond_to?(:private_dict_hints)
201
+
202
+ hints = JSON.parse(@hint_set.private_dict_hints || "{}")
203
+ !hints.empty?
204
+ rescue JSON::ParserError
205
+ false
206
+ end
207
+
208
+ # Modify Private DICT with font-level hints
209
+ #
210
+ # Handles variable hint values using PrivateDictBlendHandler
211
+ # while preserving existing blend operators.
212
+ #
213
+ # @return [Hash, nil] Modified Private DICT data
214
+ def modify_private_dict
215
+ # Read original Private DICT
216
+ private_dict_info = extract_private_dict_info
217
+ return nil unless private_dict_info
218
+
219
+ size, offset = private_dict_info
220
+ private_dict = @reader.read_private_dict(size, offset)
221
+
222
+ # Create handler
223
+ handler = PrivateDictBlendHandler.new(private_dict)
224
+
225
+ # Get font-level hints
226
+ font_hints = JSON.parse(@hint_set.private_dict_hints)
227
+
228
+ # Rebuild with hints (preserving blend)
229
+ handler.rebuild_with_hints(font_hints, num_axes: @num_axes)
230
+ end
231
+
232
+ # Extract Private DICT information from Top DICT
233
+ #
234
+ # @return [Array<Integer>, nil] [size, offset] or nil if not present
235
+ def extract_private_dict_info
236
+ # Extract from Top DICT (operator 18)
237
+ private_info = @reader.top_dict[18]
238
+ return nil unless private_info
239
+
240
+ # Format: [size, offset]
241
+ private_info
242
+ end
243
+
244
+ # Preserve Variable Store unchanged
245
+ #
246
+ # Variable Store is read-only for hint application.
247
+ # We simply copy it to output without modification.
248
+ #
249
+ # @return [Hash, nil] Variable Store data
250
+ def preserve_variable_store
251
+ @variable_store
252
+ end
253
+
254
+ # Check if modification is needed
255
+ #
256
+ # @return [Boolean] True if hints should be applied
257
+ def should_modify?
258
+ return false unless @hint_set
259
+
260
+ has_per_glyph = !@hint_set.hinted_glyph_ids.empty?
261
+ has_font_level = has_font_level_hints?
262
+
263
+ has_per_glyph || has_font_level
264
+ end
265
+
266
+ # Extract CFF2 header bytes
267
+ #
268
+ # @return [String] Binary header data
269
+ def extract_header
270
+ header_size = @reader.header[:header_size]
271
+ @reader.data[0, header_size]
272
+ end
273
+
274
+ # Extract and optionally modify CharStrings
275
+ #
276
+ # @return [String] CharStrings INDEX binary data
277
+ def extract_and_modify_charstrings
278
+ charstrings_offset = extract_charstrings_offset
279
+ return nil unless charstrings_offset
280
+
281
+ charstrings_index = @reader.read_charstrings(charstrings_offset)
282
+
283
+ if @hint_set && !@hint_set.hinted_glyph_ids.empty?
284
+ modify_charstrings(charstrings_index)
285
+ else
286
+ # Return original CharStrings as binary
287
+ extract_charstrings_binary(charstrings_offset)
288
+ end
289
+ end
290
+
291
+ # Extract CharStrings INDEX as binary
292
+ #
293
+ # @param offset [Integer] CharStrings offset in table
294
+ # @return [String] Binary CharStrings INDEX data
295
+ def extract_charstrings_binary(offset)
296
+ io = StringIO.new(@reader.data)
297
+ io.seek(offset)
298
+
299
+ # Read INDEX structure: count (2 bytes)
300
+ count = io.read(2).unpack1("n")
301
+ return [0].pack("n") if count.zero?
302
+
303
+ # Read offSize (1 byte)
304
+ off_size = io.read(1).unpack1("C")
305
+
306
+ # Calculate INDEX size
307
+ # count + offSize + (count+1)*offSize + data_size
308
+ offset_array_size = (count + 1) * off_size
309
+
310
+ # Read offset array to get data size
311
+ offsets = []
312
+ (count + 1).times do
313
+ offset_bytes = io.read(off_size)
314
+ case off_size
315
+ when 1
316
+ offsets << offset_bytes.unpack1("C")
317
+ when 2
318
+ offsets << offset_bytes.unpack1("n")
319
+ when 3
320
+ offsets << (offset_bytes.bytes[0] << 16 | offset_bytes.bytes[1] << 8 | offset_bytes.bytes[2])
321
+ when 4
322
+ offsets << offset_bytes.unpack1("N")
323
+ end
324
+ end
325
+
326
+ data_size = offsets.last - 1 # Offsets are 1-based
327
+
328
+ # Calculate total INDEX size
329
+ index_size = 2 + 1 + offset_array_size + data_size
330
+
331
+ # Reset and extract full INDEX
332
+ io.seek(offset)
333
+ io.read(index_size)
334
+ end
335
+
336
+ # Extract and optionally modify Private DICT
337
+ #
338
+ # @return [String, nil] Binary Private DICT data
339
+ def extract_and_modify_private_dict
340
+ if @hint_set && has_font_level_hints?
341
+ # Modify and serialize
342
+ modified_dict = modify_private_dict
343
+ return nil unless modified_dict
344
+
345
+ serialize_private_dict(modified_dict)
346
+ else
347
+ # Return original Private DICT
348
+ private_dict_info = extract_private_dict_info
349
+ return nil unless private_dict_info
350
+
351
+ size, offset = private_dict_info
352
+ @reader.data[offset, size]
353
+ end
354
+ end
355
+
356
+ # Extract Variable Store as binary (unchanged)
357
+ #
358
+ # @return [String, nil] Binary Variable Store data
359
+ def extract_variable_store
360
+ return nil unless @variable_store
361
+
362
+ vstore_offset = @reader.top_dict[24] # operator 24 = vstore
363
+ return nil unless vstore_offset
364
+
365
+ # Extract Variable Store bytes unchanged
366
+ # For simplicity, extract from vstore_offset to end of table
367
+ # In production, we'd parse structure to get exact size
368
+ @reader.data[vstore_offset..-1]
369
+ end
370
+
371
+ # Rebuild complete CFF2 table
372
+ #
373
+ # @param header [String] CFF2 header
374
+ # @param top_dict [Hash] Top DICT hash
375
+ # @param charstrings [String] CharStrings INDEX
376
+ # @param private_dict [String, nil] Private DICT
377
+ # @param vstore [String, nil] Variable Store
378
+ # @return [String] Complete CFF2 table binary
379
+ def rebuild_cff2_table(header:, top_dict:, charstrings:, private_dict:, vstore:)
380
+ output = StringIO.new("".b)
381
+
382
+ # 1. Write Header
383
+ output.write(header)
384
+
385
+ # 2. Calculate offsets for all sections
386
+ offsets = calculate_cff2_offsets(
387
+ header_size: header.size,
388
+ charstrings: charstrings,
389
+ private_dict: private_dict,
390
+ vstore: vstore
391
+ )
392
+
393
+ # 3. Build Top DICT with updated offsets
394
+ updated_top_dict = update_top_dict_offsets(top_dict, offsets)
395
+ top_dict_binary = serialize_top_dict(updated_top_dict)
396
+
397
+ # Write Top DICT
398
+ output.write(top_dict_binary)
399
+
400
+ # 4. Write CharStrings
401
+ output.write(charstrings) if charstrings
402
+
403
+ # 5. Write Private DICT
404
+ output.write(private_dict) if private_dict
405
+
406
+ # 6. Write Variable Store (UNCHANGED)
407
+ output.write(vstore) if vstore
408
+
409
+ output.string
410
+ end
411
+
412
+ # Calculate offsets for CFF2 sections
413
+ #
414
+ # @param header_size [Integer] Header size
415
+ # @param charstrings [String] CharStrings data
416
+ # @param private_dict [String, nil] Private DICT data
417
+ # @param vstore [String, nil] Variable Store data
418
+ # @return [Hash] Section offsets
419
+ def calculate_cff2_offsets(header_size:, charstrings:, private_dict:, vstore:)
420
+ # Start after header
421
+ offset = header_size
422
+
423
+ # Top DICT offset (immediately after header)
424
+ top_dict_offset = offset
425
+
426
+ # Estimate Top DICT size (will be recalculated)
427
+ # For now, use original Top DICT size from reader
428
+ top_dict_size = estimate_top_dict_size
429
+
430
+ offset += top_dict_size
431
+
432
+ # CharStrings offset
433
+ charstrings_offset = offset
434
+ offset += charstrings&.size || 0
435
+
436
+ # Private DICT offset
437
+ private_dict_offset = offset
438
+ private_dict_size = private_dict&.size || 0
439
+ offset += private_dict_size
440
+
441
+ # Variable Store offset
442
+ vstore_offset = vstore ? offset : nil
443
+
444
+ {
445
+ top_dict: top_dict_offset,
446
+ charstrings: charstrings_offset,
447
+ private_dict: private_dict_offset,
448
+ private_dict_size: private_dict_size,
449
+ vstore: vstore_offset
450
+ }
451
+ end
452
+
453
+ # Estimate Top DICT size
454
+ #
455
+ # @return [Integer] Estimated size
456
+ def estimate_top_dict_size
457
+ # Use original Top DICT size from reader as estimate
458
+ # In CFF2, Top DICT size is in header
459
+ top_dict_length = @reader.header[:top_dict_length]
460
+ top_dict_length || 50 # Default estimate
461
+ end
462
+
463
+ # Update Top DICT with new offsets
464
+ #
465
+ # @param top_dict [Hash] Original Top DICT
466
+ # @param offsets [Hash] Calculated offsets
467
+ # @return [Hash] Updated Top DICT
468
+ def update_top_dict_offsets(top_dict, offsets)
469
+ updated = top_dict.dup
470
+
471
+ # Update CharStrings offset (operator 17)
472
+ updated[17] = offsets[:charstrings]
473
+
474
+ # Update Private DICT [size, offset] (operator 18)
475
+ if offsets[:private_dict_size]&.positive?
476
+ updated[18] = [offsets[:private_dict_size], offsets[:private_dict]]
477
+ end
478
+
479
+ # Update Variable Store offset (operator 24)
480
+ updated[24] = offsets[:vstore] if offsets[:vstore]
481
+
482
+ updated
483
+ end
484
+
485
+ # Serialize Top DICT to binary
486
+ #
487
+ # @param dict [Hash] Top DICT hash with integer operator keys
488
+ # @return [String] Binary DICT data
489
+ def serialize_top_dict(dict)
490
+ require_relative "../cff/dict_builder"
491
+
492
+ # Convert integer operator keys to symbol keys for DictBuilder
493
+ symbol_dict = convert_operators_to_symbols(dict)
494
+ Cff::DictBuilder.build(symbol_dict)
495
+ end
496
+
497
+ # Serialize Private DICT to binary
498
+ #
499
+ # @param dict [Hash] Private DICT hash
500
+ # @return [String] Binary DICT data
501
+ def serialize_private_dict(dict)
502
+ require_relative "../cff/dict_builder"
503
+
504
+ # Convert integer operator keys to symbol keys for DictBuilder
505
+ symbol_dict = convert_operators_to_symbols(dict)
506
+ Cff::DictBuilder.build(symbol_dict)
507
+ end
508
+
509
+ # Convert integer operator keys to symbol keys
510
+ #
511
+ # @param dict [Hash] Dictionary with integer or string keys
512
+ # @return [Hash] Dictionary with symbol keys
513
+ def convert_operators_to_symbols(dict)
514
+ # Operator mapping: integer => symbol
515
+ operator_map = {
516
+ 0 => :version,
517
+ 1 => :notice,
518
+ 2 => :full_name,
519
+ 3 => :family_name,
520
+ 4 => :weight,
521
+ 5 => :font_bbox,
522
+ 6 => :blue_values,
523
+ 7 => :other_blues,
524
+ 8 => :family_blues,
525
+ 9 => :family_other_blues,
526
+ 10 => :std_hw,
527
+ 11 => :std_vw,
528
+ 15 => :charset,
529
+ 16 => :encoding,
530
+ 17 => :charstrings,
531
+ 18 => :private,
532
+ 19 => :subrs,
533
+ 20 => :default_width_x,
534
+ 21 => :nominal_width_x
535
+ # Note: operator 24 (vstore) is CFF2-specific and handled separately
536
+ }
537
+
538
+ result = {}
539
+ dict.each do |key, value|
540
+ # Skip vstore (operator 24) - CFF2 specific, not in CFF DictBuilder
541
+ next if key == 24 || key == :vstore
542
+
543
+ # Convert string keys to symbols for DictBuilder
544
+ if key.is_a?(String)
545
+ symbol_key = key.to_sym
546
+ elsif key.is_a?(Integer)
547
+ symbol_key = operator_map[key] || key
548
+ else
549
+ symbol_key = key
550
+ end
551
+
552
+ result[symbol_key] = value
553
+ end
554
+ result
555
+ end
556
+
557
+ # Validate CFF2 structure
558
+ #
559
+ # @return [Array<String>] Validation errors (empty if valid)
560
+ def validate
561
+ errors = []
562
+
563
+ errors << "Not a valid CFF2 table" unless @reader.header[:major_version] == 2
564
+
565
+ if variable? && @num_axes.zero?
566
+ errors << "CFF2 has Variable Store but no axes defined"
567
+ end
568
+
569
+ errors
570
+ end
571
+ end
572
+ end
573
+ end
574
+ end