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,207 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../../models/hint"
4
+
5
+ module Fontisan
6
+ module Tables
7
+ class Cff
8
+ # Injects hint operations into CharString operation lists
9
+ #
10
+ # HintOperationInjector converts abstract Hint objects into CFF CharString
11
+ # operations and injects them at the appropriate position. It handles:
12
+ # - Stem hints (hstem, vstem, hstemhm, vstemhm)
13
+ # - Hint masks (hintmask with mask data)
14
+ # - Counter masks (cntrmask with mask data)
15
+ # - Stack management (hints are stack-neutral)
16
+ #
17
+ # **Position Rules:**
18
+ # - Hints must appear BEFORE any path construction operators
19
+ # - Width (if present) comes first
20
+ # - Stem hints come before hintmask/cntrmask
21
+ # - Once path construction begins, no more hints allowed
22
+ #
23
+ # **Stack Neutrality:**
24
+ # - Hint operators consume their operands
25
+ # - They don't leave anything on the stack
26
+ # - Path construction starts with clean stack
27
+ #
28
+ # Reference: Type 2 CharString Format Section 4
29
+ # Adobe Technical Note #5177
30
+ #
31
+ # @example Inject hints into a glyph
32
+ # injector = HintOperationInjector.new
33
+ # hints = [
34
+ # Hint.new(type: :stem, data: { position: 100, width: 50, orientation: :horizontal })
35
+ # ]
36
+ # modified_ops = injector.inject(hints, original_operations)
37
+ class HintOperationInjector
38
+ # Initialize injector
39
+ def initialize
40
+ @stem_count = 0
41
+ end
42
+
43
+ # Inject hint operations into operation list
44
+ #
45
+ # @param hints [Array<Models::Hint>] Hints to inject
46
+ # @param operations [Array<Hash>] Original CharString operations
47
+ # @return [Array<Hash>] Modified operations with hints injected
48
+ def inject(hints, operations)
49
+ return operations if hints.nil? || hints.empty?
50
+
51
+ # Convert hints to operations
52
+ hint_ops = convert_hints_to_operations(hints)
53
+ return operations if hint_ops.empty?
54
+
55
+ # Find injection point (before first path operator)
56
+ inject_index = find_injection_point(operations)
57
+
58
+ # Insert hint operations
59
+ operations.dup.insert(inject_index, *hint_ops)
60
+ end
61
+
62
+ # Get stem count after injection (needed for hintmask)
63
+ #
64
+ # @return [Integer] Number of stem hints
65
+ attr_reader :stem_count
66
+
67
+ private
68
+
69
+ # Convert Hint objects to CharString operations
70
+ #
71
+ # @param hints [Array<Models::Hint>] Hints to convert
72
+ # @return [Array<Hash>] CharString operations
73
+ def convert_hints_to_operations(hints)
74
+ operations = []
75
+ @stem_count = 0
76
+
77
+ hints.each do |hint|
78
+ ops = hint_to_operations(hint)
79
+ operations.concat(ops)
80
+ end
81
+
82
+ operations
83
+ end
84
+
85
+ # Convert single Hint to operations
86
+ #
87
+ # @param hint [Models::Hint] Hint object
88
+ # @return [Array<Hash>] CharString operations
89
+ def hint_to_operations(hint)
90
+ ps_hint = hint.to_postscript
91
+ return [] if ps_hint.empty?
92
+
93
+ case ps_hint[:operator]
94
+ when :hstem, :vstem
95
+ stem_operation(ps_hint)
96
+ when :hstemhm, :vstemhm
97
+ stem_operation(ps_hint)
98
+ when :hintmask
99
+ hintmask_operation(ps_hint)
100
+ when :counter, :cntrmask
101
+ # :counter from Hint model maps to :cntrmask in CharStrings
102
+ cntrmask_operation(ps_hint)
103
+ else
104
+ []
105
+ end
106
+ end
107
+
108
+ # Create stem hint operation
109
+ #
110
+ # @param ps_hint [Hash] PostScript hint with :operator and :args
111
+ # @return [Array<Hash>] CharString operations
112
+ def stem_operation(ps_hint)
113
+ operator = ps_hint[:operator]
114
+ args = ps_hint[:args] || []
115
+
116
+ # Each pair of args is one stem
117
+ @stem_count += args.length / 2
118
+
119
+ [{
120
+ type: :operator,
121
+ name: operator,
122
+ operands: args,
123
+ hint_data: nil
124
+ }]
125
+ end
126
+
127
+ # Create hintmask operation
128
+ #
129
+ # @param ps_hint [Hash] PostScript hint with :operator and :args (mask)
130
+ # @return [Array<Hash>] CharString operations
131
+ def hintmask_operation(ps_hint)
132
+ mask_bytes = ps_hint[:args] || []
133
+
134
+ # Convert mask array to binary string
135
+ hint_data = if mask_bytes.is_a?(Array)
136
+ mask_bytes.pack("C*")
137
+ elsif mask_bytes.is_a?(String)
138
+ mask_bytes
139
+ else
140
+ ""
141
+ end
142
+
143
+ [{
144
+ type: :operator,
145
+ name: :hintmask,
146
+ operands: [],
147
+ hint_data: hint_data
148
+ }]
149
+ end
150
+
151
+ # Create cntrmask operation
152
+ #
153
+ # @param ps_hint [Hash] PostScript hint with :operator and :args (zones)
154
+ # @return [Array<Hash>] CharString operations
155
+ def cntrmask_operation(ps_hint)
156
+ zones = ps_hint[:args] || []
157
+
158
+ # Convert zones to binary string
159
+ hint_data = if zones.is_a?(Array)
160
+ zones.pack("C*")
161
+ elsif zones.is_a?(String)
162
+ zones
163
+ else
164
+ ""
165
+ end
166
+
167
+ [{
168
+ type: :operator,
169
+ name: :cntrmask,
170
+ operands: [],
171
+ hint_data: hint_data
172
+ }]
173
+ end
174
+
175
+ # Find injection point for hints
176
+ #
177
+ # Hints must go before first path construction operator.
178
+ # Path operators: moveto, lineto, curveto, etc.
179
+ #
180
+ # @param operations [Array<Hash>] CharString operations
181
+ # @return [Integer] Index to insert hints
182
+ def find_injection_point(operations)
183
+ # Path construction operators
184
+ path_operators = %i[
185
+ rmoveto hmoveto vmoveto
186
+ rlineto hlineto vlineto
187
+ rrcurveto rcurveline rlinecurve
188
+ vvcurveto hhcurveto vhcurveto hvcurveto
189
+ ]
190
+
191
+ # Find first path operator
192
+ operations.each_with_index do |op, index|
193
+ return index if path_operators.include?(op[:name])
194
+ end
195
+
196
+ # No path operators found - hints go before endchar
197
+ operations.each_with_index do |op, index|
198
+ return index if op[:name] == :endchar
199
+ end
200
+
201
+ # Empty or malformed - inject at start
202
+ 0
203
+ end
204
+ end
205
+ end
206
+ end
207
+ end
@@ -0,0 +1,237 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "stringio"
4
+ require_relative "../../binary/base_record"
5
+
6
+ module Fontisan
7
+ module Tables
8
+ class Cff
9
+ # CFF INDEX structure
10
+ #
11
+ # INDEX is a fundamental data structure used throughout CFF for storing
12
+ # arrays of variable-length data items. It's used for:
13
+ # - Name INDEX (font names)
14
+ # - String INDEX (string data)
15
+ # - Global Subr INDEX (global subroutines)
16
+ # - Local Subr INDEX (local subroutines)
17
+ # - CharStrings INDEX (glyph programs)
18
+ #
19
+ # Structure:
20
+ # - count (Card16): Number of objects stored in INDEX
21
+ # - offSize (OffSize): Size of offset values (1-4 bytes)
22
+ # - offset[count+1] (Offset): Array of offsets to data
23
+ # - data: The actual data bytes
24
+ #
25
+ # Offsets are relative to the byte before the data array. The first
26
+ # offset is always 1, not 0. The last offset points one byte past the
27
+ # end of the data.
28
+ #
29
+ # Reference: CFF specification section 5 "INDEX Data"
30
+ # https://adobe-type-tools.github.io/font-tech-notes/pdfs/5176.CFF.pdf
31
+ #
32
+ # @example Reading an INDEX
33
+ # index = Fontisan::Tables::Cff::Index.new(data)
34
+ # puts index.count # => 3
35
+ # puts index[0] # => first item data
36
+ # index.each { |item| puts item }
37
+ class Index
38
+ # @return [Integer] Number of items in the INDEX
39
+ attr_reader :count
40
+
41
+ # @return [Integer] Size of offset values (1-4 bytes)
42
+ attr_reader :off_size
43
+
44
+ # @return [Array<Integer>] Array of offsets (count + 1 elements)
45
+ attr_reader :offsets
46
+
47
+ # @return [String] Binary string containing all data
48
+ attr_reader :data
49
+
50
+ # Initialize an INDEX from binary data
51
+ #
52
+ # @param io [IO, StringIO, String] Binary data to parse
53
+ # @param start_offset [Integer] Starting byte offset in the data
54
+ def initialize(io, start_offset: 0)
55
+ @io = io.is_a?(String) ? StringIO.new(io) : io
56
+ @start_offset = start_offset
57
+ @io.seek(start_offset) if @io.respond_to?(:seek)
58
+
59
+ parse!
60
+ end
61
+
62
+ # Get the item at the specified index
63
+ #
64
+ # @param index [Integer] Zero-based index of item to retrieve
65
+ # @return [String, nil] Binary data for the item, or nil if out of bounds
66
+ def [](index)
67
+ return nil if index.negative? || index >= count
68
+ return "" if count.zero?
69
+
70
+ # Offsets are 1-based in the data array
71
+ start_pos = offsets[index] - 1
72
+ end_pos = offsets[index + 1] - 1
73
+ length = end_pos - start_pos
74
+
75
+ data[start_pos, length]
76
+ end
77
+
78
+ # Iterate over each item in the INDEX
79
+ #
80
+ # @yield [String] Binary data for each item
81
+ # @return [Enumerator] If no block given
82
+ def each
83
+ return enum_for(:each) unless block_given?
84
+
85
+ count.times do |i|
86
+ yield self[i]
87
+ end
88
+ end
89
+
90
+ # Get all items as an array
91
+ #
92
+ # @return [Array<String>] Array of binary data strings
93
+ def to_a
94
+ Array.new(count) { |i| self[i] }
95
+ end
96
+
97
+ # Check if the INDEX is empty
98
+ #
99
+ # @return [Boolean] True if count is 0
100
+ def empty?
101
+ count.zero?
102
+ end
103
+
104
+ # Get the size of a specific item
105
+ #
106
+ # @param index [Integer] Zero-based index of item
107
+ # @return [Integer, nil] Size in bytes, or nil if out of bounds
108
+ def item_size(index)
109
+ return nil if index.negative? || index >= count
110
+ return 0 if count.zero?
111
+
112
+ offsets[index + 1] - offsets[index]
113
+ end
114
+
115
+ # Calculate total size of the INDEX in bytes
116
+ #
117
+ # This includes the count, offSize, offset array, and data.
118
+ #
119
+ # @return [Integer] Total size in bytes
120
+ def total_size
121
+ return 2 if count.zero? # Just the count field
122
+
123
+ # count (2) + offSize (1) + offset array + data
124
+ 2 + 1 + ((count + 1) * off_size) + data.bytesize
125
+ end
126
+
127
+ private
128
+
129
+ # Parse the INDEX structure from the IO
130
+ def parse!
131
+ # Read count (Card16)
132
+ @count = read_uint16
133
+
134
+ # Empty INDEX has only count field
135
+ if @count.zero?
136
+ @off_size = 0
137
+ @offsets = []
138
+ @data = "".b
139
+ return
140
+ end
141
+
142
+ # Read offSize (OffSize)
143
+ @off_size = read_uint8
144
+
145
+ # Validate offSize
146
+ unless (1..4).cover?(@off_size)
147
+ raise CorruptedTableError,
148
+ "Invalid INDEX offSize: #{@off_size} (must be 1-4)"
149
+ end
150
+
151
+ # Read offset array (count + 1 offsets)
152
+ @offsets = Array.new(@count + 1) do
153
+ read_offset(@off_size)
154
+ end
155
+
156
+ # Validate offsets
157
+ validate_offsets!
158
+
159
+ # Read data section
160
+ # Size is (last offset - 1) since offsets are 1-based
161
+ data_size = @offsets.last - 1
162
+ @data = read_bytes(data_size)
163
+ end
164
+
165
+ # Read an unsigned 16-bit integer
166
+ #
167
+ # @return [Integer] The value
168
+ def read_uint16
169
+ bytes = read_bytes(2)
170
+ bytes.unpack1("n") # Big-endian unsigned 16-bit
171
+ end
172
+
173
+ # Read an unsigned 8-bit integer
174
+ #
175
+ # @return [Integer] The value
176
+ def read_uint8
177
+ read_bytes(1).unpack1("C")
178
+ end
179
+
180
+ # Read an offset value of specified size
181
+ #
182
+ # @param size [Integer] Number of bytes (1-4)
183
+ # @return [Integer] The offset value
184
+ def read_offset(size)
185
+ bytes = read_bytes(size)
186
+
187
+ case size
188
+ when 1
189
+ bytes.unpack1("C")
190
+ when 2
191
+ bytes.unpack1("n")
192
+ when 3
193
+ # 24-bit big-endian
194
+ bytes.unpack("C3").inject(0) { |sum, byte| (sum << 8) | byte }
195
+ when 4
196
+ bytes.unpack1("N")
197
+ else
198
+ raise ArgumentError, "Invalid offset size: #{size}"
199
+ end
200
+ end
201
+
202
+ # Read specified number of bytes from IO
203
+ #
204
+ # @param count [Integer] Number of bytes to read
205
+ # @return [String] Binary string
206
+ def read_bytes(count)
207
+ return "".b if count.zero?
208
+
209
+ bytes = @io.read(count)
210
+ if bytes.nil? || bytes.bytesize < count
211
+ raise CorruptedTableError,
212
+ "Unexpected end of INDEX data"
213
+ end
214
+
215
+ bytes
216
+ end
217
+
218
+ # Validate that offsets are in ascending order and within bounds
219
+ def validate_offsets!
220
+ # First offset must be 1
221
+ unless @offsets.first == 1
222
+ raise CorruptedTableError,
223
+ "Invalid INDEX: first offset must be 1, got #{@offsets.first}"
224
+ end
225
+
226
+ # Check ascending order
227
+ @offsets.each_cons(2) do |prev, curr|
228
+ if curr < prev
229
+ raise CorruptedTableError,
230
+ "Invalid INDEX: offsets are not in ascending order"
231
+ end
232
+ end
233
+ end
234
+ end
235
+ end
236
+ end
237
+ end
@@ -0,0 +1,170 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "stringio"
4
+
5
+ module Fontisan
6
+ module Tables
7
+ class Cff
8
+ # CFF INDEX structure builder
9
+ #
10
+ # [`IndexBuilder`](lib/fontisan/tables/cff/index_builder.rb) constructs
11
+ # binary INDEX structures from arrays of data items. INDEX is a fundamental
12
+ # CFF data structure used for storing arrays of variable-length data.
13
+ #
14
+ # The builder calculates optimal offset sizes, constructs the offset array,
15
+ # and produces compact binary output.
16
+ #
17
+ # Structure produced:
18
+ # - count (Card16): Number of items
19
+ # - offSize (OffSize): Size of offset values (1-4 bytes)
20
+ # - offset[count+1] (Offset): Array of offsets to data
21
+ # - data: Concatenated data bytes
22
+ #
23
+ # Offsets are 1-based (first offset is always 1). The last offset points
24
+ # one byte past the end of the data.
25
+ #
26
+ # Reference: CFF specification section 5 "INDEX Data"
27
+ # https://adobe-type-tools.github.io/font-tech-notes/pdfs/5176.CFF.pdf
28
+ #
29
+ # @example Building an INDEX
30
+ # items = ["data1".b, "data2".b, "data3".b]
31
+ # index_data = Fontisan::Tables::Cff::IndexBuilder.build(items)
32
+ class IndexBuilder
33
+ # Build INDEX structure from array of binary strings
34
+ #
35
+ # @param items [Array<String>] Array of binary data items
36
+ # @return [String] Binary INDEX data
37
+ # @raise [ArgumentError] If items is not an Array
38
+ def self.build(items)
39
+ validate_items!(items)
40
+
41
+ return build_empty_index if items.empty?
42
+
43
+ # Calculate total data size
44
+ data_size = items.sum(&:bytesize)
45
+
46
+ # Calculate optimal offset size (1-4 bytes)
47
+ # Last offset will be data_size + 1 (1-based)
48
+ off_size = calculate_off_size(data_size + 1)
49
+
50
+ # Build offset array (count + 1 offsets)
51
+ offsets = build_offsets(items, off_size)
52
+
53
+ # Concatenate all data
54
+ data = items.join
55
+
56
+ # Assemble INDEX structure
57
+ output = StringIO.new("".b)
58
+
59
+ # Write count (Card16)
60
+ output.write([items.length].pack("n"))
61
+
62
+ # Write offSize (OffSize)
63
+ output.putc(off_size)
64
+
65
+ # Write offset array
66
+ offsets.each do |offset|
67
+ write_offset(output, offset, off_size)
68
+ end
69
+
70
+ # Write data
71
+ output.write(data)
72
+
73
+ output.string
74
+ end
75
+
76
+ # Build an empty INDEX (count = 0)
77
+ #
78
+ # @return [String] Binary empty INDEX
79
+ def self.build_empty_index
80
+ # Empty INDEX has only count field (0)
81
+ [0].pack("n")
82
+ end
83
+ private_class_method :build_empty_index
84
+
85
+ # Validate items parameter
86
+ #
87
+ # @param items [Object] Items to validate
88
+ # @raise [ArgumentError] If items is invalid
89
+ def self.validate_items!(items)
90
+ raise ArgumentError, "items must be Array" unless items.is_a?(Array)
91
+
92
+ items.each_with_index do |item, i|
93
+ unless item.is_a?(String)
94
+ raise ArgumentError,
95
+ "item #{i} must be String, got: #{item.class}"
96
+ end
97
+ unless item.encoding == ::Encoding::BINARY
98
+ raise ArgumentError,
99
+ "item #{i} must have BINARY encoding, got: #{item.encoding}"
100
+ end
101
+ end
102
+ end
103
+ private_class_method :validate_items!
104
+
105
+ # Calculate optimal offset size for given maximum offset
106
+ #
107
+ # @param max_offset [Integer] Maximum offset value
108
+ # @return [Integer] Offset size (1-4 bytes)
109
+ def self.calculate_off_size(max_offset)
110
+ return 1 if max_offset <= 0xFF
111
+ return 2 if max_offset <= 0xFFFF
112
+ return 3 if max_offset <= 0xFFFFFF
113
+
114
+ 4
115
+ end
116
+ private_class_method :calculate_off_size
117
+
118
+ # Build offset array from items
119
+ #
120
+ # Offsets are 1-based. First offset is always 1.
121
+ # Each offset points to the start of its item in the data array.
122
+ # Last offset points one byte past the end of data.
123
+ #
124
+ # @param items [Array<String>] Array of data items
125
+ # @param off_size [Integer] Offset size (1-4 bytes)
126
+ # @return [Array<Integer>] Array of offsets (count + 1 elements)
127
+ def self.build_offsets(items, _off_size)
128
+ offsets = []
129
+ current_offset = 1 # 1-based
130
+
131
+ # First offset is always 1
132
+ offsets << current_offset
133
+
134
+ # Calculate offset for each item
135
+ items.each do |item|
136
+ current_offset += item.bytesize
137
+ offsets << current_offset
138
+ end
139
+
140
+ offsets
141
+ end
142
+ private_class_method :build_offsets
143
+
144
+ # Write an offset value of specified size
145
+ #
146
+ # @param io [StringIO] Output stream
147
+ # @param offset [Integer] Offset value to write
148
+ # @param size [Integer] Number of bytes (1-4)
149
+ def self.write_offset(io, offset, size)
150
+ case size
151
+ when 1
152
+ io.putc(offset & 0xFF)
153
+ when 2
154
+ io.write([offset].pack("n")) # Big-endian unsigned 16-bit
155
+ when 3
156
+ # 24-bit big-endian
157
+ io.putc((offset >> 16) & 0xFF)
158
+ io.putc((offset >> 8) & 0xFF)
159
+ io.putc(offset & 0xFF)
160
+ when 4
161
+ io.write([offset].pack("N")) # Big-endian unsigned 32-bit
162
+ else
163
+ raise ArgumentError, "Invalid offset size: #{size}"
164
+ end
165
+ end
166
+ private_class_method :write_offset
167
+ end
168
+ end
169
+ end
170
+ end
@@ -0,0 +1,70 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Fontisan
4
+ module Tables
5
+ class Cff
6
+ # Recalculates CFF table offsets after structure modifications
7
+ #
8
+ # When the Private DICT size changes (e.g., adding hint parameters),
9
+ # all offsets in the CFF table must be recalculated. This class
10
+ # computes new offsets based on section sizes.
11
+ #
12
+ # CFF Structure (sequential layout):
13
+ # - Header (fixed size)
14
+ # - Name INDEX
15
+ # - Top DICT INDEX (contains offsets to CharStrings and Private DICT)
16
+ # - String INDEX
17
+ # - Global Subr INDEX
18
+ # - CharStrings INDEX
19
+ # - Private DICT (variable size)
20
+ # - Local Subr INDEX (optional, within Private DICT)
21
+ #
22
+ # Key offsets to recalculate:
23
+ # - charstrings: Offset from CFF start to CharStrings INDEX
24
+ # - private: [size, offset] in Top DICT pointing to Private DICT
25
+ class OffsetRecalculator
26
+ # Calculate offsets for all CFF sections
27
+ #
28
+ # @param sections [Hash] Hash of section_name => binary_data
29
+ # @return [Hash] Hash of offset information
30
+ def self.calculate_offsets(sections)
31
+ offsets = {}
32
+ pos = 0
33
+
34
+ # Track position through CFF structure
35
+ pos += sections[:header].bytesize
36
+ pos += sections[:name_index].bytesize
37
+
38
+ # Top DICT INDEX starts here
39
+ offsets[:top_dict_start] = pos
40
+ pos += sections[:top_dict_index].bytesize
41
+
42
+ pos += sections[:string_index].bytesize
43
+ pos += sections[:global_subr_index].bytesize
44
+
45
+ # CharStrings INDEX offset (referenced in Top DICT)
46
+ offsets[:charstrings] = pos
47
+ pos += sections[:charstrings_index].bytesize
48
+
49
+ # Private DICT offset and size (referenced in Top DICT)
50
+ offsets[:private] = pos
51
+ offsets[:private_size] = sections[:private_dict].bytesize
52
+
53
+ offsets
54
+ end
55
+
56
+ # Update Top DICT with new offsets
57
+ #
58
+ # @param top_dict [Hash] Top DICT data
59
+ # @param offsets [Hash] Calculated offsets
60
+ # @return [Hash] Updated Top DICT
61
+ def self.update_top_dict(top_dict, offsets)
62
+ updated = top_dict.dup
63
+ updated[:charstrings] = offsets[:charstrings]
64
+ updated[:private] = [offsets[:private_size], offsets[:private]]
65
+ updated
66
+ end
67
+ end
68
+ end
69
+ end
70
+ end