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,356 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "stringio"
4
+
5
+ module Fontisan
6
+ module Tables
7
+ class Cff
8
+ # Type 2 CharString builder/encoder
9
+ #
10
+ # [`CharStringBuilder`](lib/fontisan/tables/cff/charstring_builder.rb)
11
+ # encodes glyph outlines into Type 2 CharString binary format. It takes
12
+ # high-level outline commands and produces the stack-based CharString
13
+ # operators used in CFF fonts.
14
+ #
15
+ # Type 2 CharString encoding:
16
+ # - Numbers are encoded in various compact formats
17
+ # - Operators are single or two-byte commands
18
+ # - All coordinates are relative (dx, dy format)
19
+ # - Current point tracking for relative calculations
20
+ #
21
+ # Operator optimization:
22
+ # - Use specialized operators (hlineto, vlineto) when possible
23
+ # - Merge sequential operators of same type
24
+ # - Minimize operator bytes
25
+ #
26
+ # Reference: Adobe Type 2 CharString Format
27
+ # https://adobe-type-tools.github.io/font-tech-notes/pdfs/5177.Type2.pdf
28
+ #
29
+ # @example Building a CharString from outline
30
+ # builder = Fontisan::Tables::Cff::CharStringBuilder.new
31
+ # charstring_data = builder.build(outline, width: 500)
32
+ class CharStringBuilder
33
+ # Type 2 CharString operators (opposite of parser)
34
+ OPERATORS = {
35
+ hstem: 1,
36
+ vstem: 3,
37
+ vmoveto: 4,
38
+ rlineto: 5,
39
+ hlineto: 6,
40
+ vlineto: 7,
41
+ rrcurveto: 8,
42
+ callsubr: 10,
43
+ return: 11,
44
+ endchar: 14,
45
+ hstemhm: 18,
46
+ hintmask: 19,
47
+ cntrmask: 20,
48
+ rmoveto: 21,
49
+ hmoveto: 22,
50
+ vstemhm: 23,
51
+ rcurveline: 24,
52
+ rlinecurve: 25,
53
+ vvcurveto: 26,
54
+ hhcurveto: 27,
55
+ shortint: 28,
56
+ callgsubr: 29,
57
+ vhcurveto: 30,
58
+ hvcurveto: 31,
59
+ }.freeze
60
+
61
+ # Two-byte operators (12 prefix)
62
+ TWO_BYTE_OPERATORS = {
63
+ and: [12, 3],
64
+ or: [12, 4],
65
+ not: [12, 5],
66
+ abs: [12, 9],
67
+ add: [12, 10],
68
+ sub: [12, 11],
69
+ div: [12, 12],
70
+ neg: [12, 14],
71
+ eq: [12, 15],
72
+ drop: [12, 18],
73
+ put: [12, 20],
74
+ get: [12, 21],
75
+ ifelse: [12, 22],
76
+ random: [12, 23],
77
+ mul: [12, 24],
78
+ sqrt: [12, 26],
79
+ dup: [12, 27],
80
+ exch: [12, 28],
81
+ index: [12, 29],
82
+ roll: [12, 30],
83
+ hflex: [12, 34],
84
+ flex: [12, 35],
85
+ hflex1: [12, 36],
86
+ flex1: [12, 37],
87
+ }.freeze
88
+
89
+ # Build a CharString from an outline
90
+ #
91
+ # @param outline [Models::Outline] Universal outline object
92
+ # @param width [Integer, nil] Glyph width (optional)
93
+ # @return [String] Binary CharString data
94
+ def build(outline, width: nil)
95
+ @output = StringIO.new("".b)
96
+ @current_x = 0.0
97
+ @current_y = 0.0
98
+ @first_move = true
99
+
100
+ # Convert outline to CFF commands
101
+ commands = outline.to_cff_commands
102
+
103
+ # Encode width if provided (before first move)
104
+ if width && !commands.empty?
105
+ # Width is encoded as first operator before first move
106
+ # For now, we'll add it before the first moveto
107
+ encode_width(width)
108
+ end
109
+
110
+ # Encode each command
111
+ commands.each do |cmd|
112
+ encode_command(cmd)
113
+ end
114
+
115
+ # End character
116
+ write_operator(:endchar)
117
+
118
+ @output.string
119
+ end
120
+
121
+ # Build a CharString from operation list
122
+ #
123
+ # This method takes operations from CharStringParser and encodes them
124
+ # back to binary CharString format. Useful for CharString modification.
125
+ #
126
+ # @param operations [Array<Hash>] Array of operation hashes from parser
127
+ # @return [String] Binary CharString data
128
+ def self.build_from_operations(operations)
129
+ new.build_from_operations(operations)
130
+ end
131
+
132
+ # Instance method for building from operations
133
+ #
134
+ # @param operations [Array<Hash>] Array of operation hashes
135
+ # @return [String] Binary CharString data
136
+ def build_from_operations(operations)
137
+ @output = StringIO.new("".b)
138
+
139
+ operations.each do |op|
140
+ # Write operands
141
+ op[:operands].each { |operand| write_number(operand) }
142
+
143
+ # Write operator
144
+ write_operator(op[:name])
145
+
146
+ # Write hint data if present (for hintmask/cntrmask)
147
+ if op[:hint_data]
148
+ @output.write(op[:hint_data])
149
+ end
150
+ end
151
+
152
+ @output.string
153
+ end
154
+
155
+ # Build an empty CharString (for .notdef or empty glyphs)
156
+ #
157
+ # @param width [Integer, nil] Glyph width
158
+ # @return [String] Binary CharString data
159
+ def build_empty(width: nil)
160
+ @output = StringIO.new("".b)
161
+
162
+ # Encode width if provided
163
+ encode_width(width) if width
164
+
165
+ # Just endchar for empty glyph
166
+ write_operator(:endchar)
167
+
168
+ @output.string
169
+ end
170
+
171
+ private
172
+
173
+ # Encode a width value
174
+ #
175
+ # Width is encoded as a delta from nominal width
176
+ # For simplicity, we encode as-is (assuming nominal width is 0)
177
+ #
178
+ # @param width [Integer] Width value
179
+ def encode_width(width)
180
+ write_number(width)
181
+ end
182
+
183
+ # Encode a single command
184
+ #
185
+ # @param cmd [Hash] Command hash with :type and coordinates
186
+ def encode_command(cmd)
187
+ case cmd[:type]
188
+ when :move_to
189
+ encode_moveto(cmd)
190
+ when :line_to
191
+ encode_lineto(cmd)
192
+ when :curve_to
193
+ encode_curveto(cmd)
194
+ end
195
+ end
196
+
197
+ # Encode a moveto command
198
+ #
199
+ # Uses rmoveto (relative move) with dx, dy
200
+ # For first move, can optimize to hmoveto/vmoveto if one delta is 0
201
+ #
202
+ # @param cmd [Hash] Command with :x, :y
203
+ def encode_moveto(cmd)
204
+ dx = cmd[:x] - @current_x
205
+ dy = cmd[:y] - @current_y
206
+
207
+ if @first_move
208
+ # First move - can optimize
209
+ if dx.zero?
210
+ write_number(dy.round)
211
+ write_operator(:vmoveto)
212
+ elsif dy.zero?
213
+ write_number(dx.round)
214
+ write_operator(:hmoveto)
215
+ else
216
+ write_number(dx.round)
217
+ write_number(dy.round)
218
+ write_operator(:rmoveto)
219
+ end
220
+ @first_move = false
221
+ else
222
+ # Subsequent moves
223
+ write_number(dx.round)
224
+ write_number(dy.round)
225
+ write_operator(:rmoveto)
226
+ end
227
+
228
+ @current_x = cmd[:x]
229
+ @current_y = cmd[:y]
230
+ end
231
+
232
+ # Encode a lineto command
233
+ #
234
+ # Uses rlineto with dx, dy
235
+ # Could optimize with hlineto/vlineto for horizontal/vertical lines
236
+ #
237
+ # @param cmd [Hash] Command with :x, :y
238
+ def encode_lineto(cmd)
239
+ dx = cmd[:x] - @current_x
240
+ dy = cmd[:y] - @current_y
241
+
242
+ # Simple encoding - could optimize for h/v lines
243
+ write_number(dx.round)
244
+ write_number(dy.round)
245
+ write_operator(:rlineto)
246
+
247
+ @current_x = cmd[:x]
248
+ @current_y = cmd[:y]
249
+ end
250
+
251
+ # Encode a curveto command (cubic Bézier)
252
+ #
253
+ # Uses rrcurveto with 6 relative coordinates:
254
+ # dx1 dy1 dx2 dy2 dx3 dy3
255
+ #
256
+ # @param cmd [Hash] Command with :x1, :y1, :x2, :y2, :x, :y
257
+ def encode_curveto(cmd)
258
+ # Calculate relative coordinates for each control point
259
+ dx1 = cmd[:x1] - @current_x
260
+ dy1 = cmd[:y1] - @current_y
261
+
262
+ dx2 = cmd[:x2] - cmd[:x1]
263
+ dy2 = cmd[:y2] - cmd[:y1]
264
+
265
+ dx3 = cmd[:x] - cmd[:x2]
266
+ dy3 = cmd[:y] - cmd[:y2]
267
+
268
+ # Write operands
269
+ write_number(dx1.round)
270
+ write_number(dy1.round)
271
+ write_number(dx2.round)
272
+ write_number(dy2.round)
273
+ write_number(dx3.round)
274
+ write_number(dy3.round)
275
+
276
+ # Write operator
277
+ write_operator(:rrcurveto)
278
+
279
+ @current_x = cmd[:x]
280
+ @current_y = cmd[:y]
281
+ end
282
+
283
+ # Write a number to the CharString
284
+ #
285
+ # Numbers are encoded in various formats based on their range:
286
+ # - -107 to +107: Single byte (32-246)
287
+ # - -1131 to +1131: Two bytes (247-254 + byte)
288
+ # - -32768 to +32767: Three bytes (28 + 2 bytes)
289
+ # - Otherwise: Five bytes (255 + 4 bytes as 16.16 fixed)
290
+ #
291
+ # @param value [Integer, Float] Number to encode
292
+ def write_number(value)
293
+ # Convert float to integer if it's effectively an integer
294
+ value = value.round if value.is_a?(Float) && value == value.round
295
+
296
+ if value.is_a?(Float)
297
+ # Real number - use 5-byte format (16.16 fixed point)
298
+ write_real(value)
299
+ elsif value >= -107 && value <= 107
300
+ # Single byte format: 32-246 represents -107 to +107
301
+ @output.putc(value + 139)
302
+ elsif value >= 108 && value <= 1131
303
+ # Positive two-byte format: 247-250
304
+ adjusted = value - 108
305
+ b0 = 247 + (adjusted / 256)
306
+ b1 = adjusted % 256
307
+ @output.putc(b0)
308
+ @output.putc(b1)
309
+ elsif value >= -1131 && value <= -108
310
+ # Negative two-byte format: 251-254
311
+ adjusted = -value - 108
312
+ b0 = 251 + (adjusted / 256)
313
+ b1 = adjusted % 256
314
+ @output.putc(b0)
315
+ @output.putc(b1)
316
+ elsif value >= -32768 && value <= 32767
317
+ # Three-byte signed integer
318
+ @output.putc(28)
319
+ @output.write([value].pack("s>")) # Signed 16-bit big-endian
320
+ else
321
+ # Five-byte signed integer (stored as 16.16 fixed point)
322
+ write_real(value.to_f)
323
+ end
324
+ end
325
+
326
+ # Write a real number (5-byte format)
327
+ #
328
+ # @param value [Float] Real number
329
+ def write_real(value)
330
+ # Convert to 16.16 fixed point
331
+ fixed = (value * 65536.0).round
332
+
333
+ @output.putc(255)
334
+ @output.write([fixed].pack("l>")) # Signed 32-bit big-endian
335
+ end
336
+
337
+ # Write an operator to the CharString
338
+ #
339
+ # @param operator [Symbol] Operator name
340
+ def write_operator(operator)
341
+ if OPERATORS.key?(operator)
342
+ # Single-byte operator
343
+ @output.putc(OPERATORS[operator])
344
+ elsif TWO_BYTE_OPERATORS.key?(operator)
345
+ # Two-byte operator
346
+ bytes = TWO_BYTE_OPERATORS[operator]
347
+ @output.putc(bytes[0])
348
+ @output.putc(bytes[1])
349
+ else
350
+ raise ArgumentError, "Unknown operator: #{operator}"
351
+ end
352
+ end
353
+ end
354
+ end
355
+ end
356
+ end
@@ -0,0 +1,237 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "stringio"
4
+
5
+ module Fontisan
6
+ module Tables
7
+ class Cff
8
+ # CharString parser that converts binary CharString data to operation list
9
+ #
10
+ # Unlike [`CharString`](lib/fontisan/tables/cff/charstring.rb) which
11
+ # interprets and executes CharStrings for rendering, CharStringParser
12
+ # parses CharStrings into a list of operations that can be modified and
13
+ # rebuilt. This enables CharString manipulation for hint injection,
14
+ # subroutine optimization, and other transformations.
15
+ #
16
+ # Operation Format:
17
+ # ```ruby
18
+ # {
19
+ # type: :operator,
20
+ # name: :rmoveto,
21
+ # operands: [100, 200]
22
+ # }
23
+ # ```
24
+ #
25
+ # Reference: Adobe Type 2 CharString Format
26
+ # https://adobe-type-tools.github.io/font-tech-notes/pdfs/5177.Type2.pdf
27
+ #
28
+ # @example Parse a CharString
29
+ # parser = CharStringParser.new(charstring_data)
30
+ # operations = parser.parse
31
+ # operations.each { |op| puts "#{op[:name]} #{op[:operands]}" }
32
+ class CharStringParser
33
+ # Type 2 CharString operators (from CharString class)
34
+ OPERATORS = {
35
+ # Path construction operators
36
+ 1 => :hstem,
37
+ 3 => :vstem,
38
+ 4 => :vmoveto,
39
+ 5 => :rlineto,
40
+ 6 => :hlineto,
41
+ 7 => :vlineto,
42
+ 8 => :rrcurveto,
43
+ 10 => :callsubr,
44
+ 11 => :return,
45
+ 14 => :endchar,
46
+ 18 => :hstemhm,
47
+ 19 => :hintmask,
48
+ 20 => :cntrmask,
49
+ 21 => :rmoveto,
50
+ 22 => :hmoveto,
51
+ 23 => :vstemhm,
52
+ 24 => :rcurveline,
53
+ 25 => :rlinecurve,
54
+ 26 => :vvcurveto,
55
+ 27 => :hhcurveto,
56
+ 28 => :shortint,
57
+ 29 => :callgsubr,
58
+ 30 => :vhcurveto,
59
+ 31 => :hvcurveto,
60
+ # 12 prefix for two-byte operators
61
+ [12, 3] => :and,
62
+ [12, 4] => :or,
63
+ [12, 5] => :not,
64
+ [12, 9] => :abs,
65
+ [12, 10] => :add,
66
+ [12, 11] => :sub,
67
+ [12, 12] => :div,
68
+ [12, 14] => :neg,
69
+ [12, 15] => :eq,
70
+ [12, 18] => :drop,
71
+ [12, 20] => :put,
72
+ [12, 21] => :get,
73
+ [12, 22] => :ifelse,
74
+ [12, 23] => :random,
75
+ [12, 24] => :mul,
76
+ [12, 26] => :sqrt,
77
+ [12, 27] => :dup,
78
+ [12, 28] => :exch,
79
+ [12, 29] => :index,
80
+ [12, 30] => :roll,
81
+ [12, 34] => :hflex,
82
+ [12, 35] => :flex,
83
+ [12, 36] => :hflex1,
84
+ [12, 37] => :flex1,
85
+ }.freeze
86
+
87
+ # Operators that require hint mask bytes
88
+ HINTMASK_OPERATORS = %i[hintmask cntrmask].freeze
89
+
90
+ # @return [String] Binary CharString data
91
+ attr_reader :data
92
+
93
+ # @return [Array<Hash>] Parsed operations
94
+ attr_reader :operations
95
+
96
+ # Initialize parser with CharString data
97
+ #
98
+ # @param data [String] Binary CharString data
99
+ # @param stem_count [Integer] Number of stem hints (for hintmask)
100
+ def initialize(data, stem_count: 0)
101
+ @data = data
102
+ @stem_count = stem_count
103
+ @operations = []
104
+ end
105
+
106
+ # Parse CharString to operation list
107
+ #
108
+ # @return [Array<Hash>] Array of operation hashes
109
+ def parse
110
+ @operations = []
111
+ io = StringIO.new(@data)
112
+ operand_stack = []
113
+
114
+ until io.eof?
115
+ byte = io.getbyte
116
+
117
+ if operator_byte?(byte)
118
+ # Operator byte - read operator and create operation
119
+ operator = read_operator(io, byte)
120
+
121
+ # Read hint mask data if needed
122
+ hint_data = nil
123
+ if HINTMASK_OPERATORS.include?(operator)
124
+ hint_bytes = (@stem_count + 7) / 8
125
+ hint_data = io.read(hint_bytes) if hint_bytes.positive?
126
+ end
127
+
128
+ # Create operation
129
+ @operations << {
130
+ type: :operator,
131
+ name: operator,
132
+ operands: operand_stack.dup,
133
+ hint_data: hint_data
134
+ }
135
+
136
+ # Clear operand stack
137
+ operand_stack.clear
138
+ else
139
+ # Operand byte - read number and push to stack
140
+ io.pos -= 1
141
+ number = read_number(io)
142
+ operand_stack << number
143
+ end
144
+ end
145
+
146
+ @operations
147
+ rescue StandardError => e
148
+ raise CorruptedTableError,
149
+ "Failed to parse CharString: #{e.message}"
150
+ end
151
+
152
+ # Update stem count (needed for hintmask operations)
153
+ #
154
+ # @param count [Integer] Number of stem hints
155
+ def stem_count=(count)
156
+ @stem_count = count
157
+ end
158
+
159
+ private
160
+
161
+ # Check if byte is an operator
162
+ #
163
+ # @param byte [Integer] Byte value
164
+ # @return [Boolean] True if operator byte
165
+ def operator_byte?(byte)
166
+ (byte <= 31 && byte != 28) # Operators are 0-31 except 28 (shortint)
167
+ end
168
+
169
+ # Read operator from CharString
170
+ #
171
+ # @param io [StringIO] Input stream
172
+ # @param first_byte [Integer] First operator byte
173
+ # @return [Symbol] Operator name
174
+ def read_operator(io, first_byte)
175
+ if first_byte == 12
176
+ # Two-byte operator
177
+ second_byte = io.getbyte
178
+ raise CorruptedTableError, "Unexpected end of CharString" if
179
+ second_byte.nil?
180
+
181
+ operator_key = [first_byte, second_byte]
182
+ OPERATORS[operator_key] || :unknown
183
+ else
184
+ # Single-byte operator
185
+ OPERATORS[first_byte] || :unknown
186
+ end
187
+ end
188
+
189
+ # Read a number (integer or real) from CharString
190
+ #
191
+ # @param io [StringIO] Input stream
192
+ # @return [Integer, Float] The number value
193
+ def read_number(io)
194
+ byte = io.getbyte
195
+ raise CorruptedTableError, "Unexpected end of CharString" if
196
+ byte.nil?
197
+
198
+ case byte
199
+ when 28
200
+ # 3-byte signed integer (16-bit)
201
+ b1 = io.getbyte
202
+ b2 = io.getbyte
203
+ raise CorruptedTableError, "Unexpected end of CharString reading shortint" if
204
+ b1.nil? || b2.nil?
205
+ value = (b1 << 8) | b2
206
+ value > 0x7FFF ? value - 0x10000 : value
207
+ when 32..246
208
+ # Small integer: -107 to +107
209
+ byte - 139
210
+ when 247..250
211
+ # Positive 2-byte integer: +108 to +1131
212
+ b2 = io.getbyte
213
+ raise CorruptedTableError, "Unexpected end of CharString reading positive integer" if
214
+ b2.nil?
215
+ (byte - 247) * 256 + b2 + 108
216
+ when 251..254
217
+ # Negative 2-byte integer: -108 to -1131
218
+ b2 = io.getbyte
219
+ raise CorruptedTableError, "Unexpected end of CharString reading negative integer" if
220
+ b2.nil?
221
+ -(byte - 251) * 256 - b2 - 108
222
+ when 255
223
+ # 5-byte signed integer (32-bit) as fixed-point 16.16
224
+ bytes = io.read(4)
225
+ raise CorruptedTableError, "Unexpected end of CharString reading fixed-point" if
226
+ bytes.nil? || bytes.length < 4
227
+ value = bytes.unpack1("l>") # Signed 32-bit big-endian
228
+ value / 65536.0 # Convert to float
229
+ else
230
+ raise CorruptedTableError,
231
+ "Invalid CharString number byte: #{byte}"
232
+ end
233
+ end
234
+ end
235
+ end
236
+ end
237
+ end