fontisan 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (185) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop_todo.yml +529 -65
  3. data/Gemfile +1 -0
  4. data/LICENSE +5 -1
  5. data/README.adoc +1301 -275
  6. data/Rakefile +27 -2
  7. data/benchmark/variation_quick_bench.rb +47 -0
  8. data/docs/EXTRACT_TTC_MIGRATION.md +549 -0
  9. data/fontisan.gemspec +4 -1
  10. data/lib/fontisan/binary/base_record.rb +22 -1
  11. data/lib/fontisan/cli.rb +309 -0
  12. data/lib/fontisan/collection/builder.rb +260 -0
  13. data/lib/fontisan/collection/offset_calculator.rb +227 -0
  14. data/lib/fontisan/collection/table_analyzer.rb +204 -0
  15. data/lib/fontisan/collection/table_deduplicator.rb +241 -0
  16. data/lib/fontisan/collection/writer.rb +306 -0
  17. data/lib/fontisan/commands/base_command.rb +8 -1
  18. data/lib/fontisan/commands/convert_command.rb +291 -0
  19. data/lib/fontisan/commands/export_command.rb +161 -0
  20. data/lib/fontisan/commands/info_command.rb +40 -6
  21. data/lib/fontisan/commands/instance_command.rb +295 -0
  22. data/lib/fontisan/commands/ls_command.rb +113 -0
  23. data/lib/fontisan/commands/pack_command.rb +241 -0
  24. data/lib/fontisan/commands/subset_command.rb +245 -0
  25. data/lib/fontisan/commands/unpack_command.rb +338 -0
  26. data/lib/fontisan/commands/validate_command.rb +178 -0
  27. data/lib/fontisan/commands/variable_command.rb +30 -1
  28. data/lib/fontisan/config/collection_settings.yml +56 -0
  29. data/lib/fontisan/config/conversion_matrix.yml +212 -0
  30. data/lib/fontisan/config/export_settings.yml +66 -0
  31. data/lib/fontisan/config/subset_profiles.yml +100 -0
  32. data/lib/fontisan/config/svg_settings.yml +60 -0
  33. data/lib/fontisan/config/validation_rules.yml +149 -0
  34. data/lib/fontisan/config/variable_settings.yml +99 -0
  35. data/lib/fontisan/config/woff2_settings.yml +77 -0
  36. data/lib/fontisan/constants.rb +69 -0
  37. data/lib/fontisan/converters/conversion_strategy.rb +96 -0
  38. data/lib/fontisan/converters/format_converter.rb +259 -0
  39. data/lib/fontisan/converters/outline_converter.rb +936 -0
  40. data/lib/fontisan/converters/svg_generator.rb +244 -0
  41. data/lib/fontisan/converters/table_copier.rb +117 -0
  42. data/lib/fontisan/converters/woff2_encoder.rb +416 -0
  43. data/lib/fontisan/converters/woff_writer.rb +391 -0
  44. data/lib/fontisan/error.rb +203 -0
  45. data/lib/fontisan/export/exporter.rb +262 -0
  46. data/lib/fontisan/export/table_serializer.rb +255 -0
  47. data/lib/fontisan/export/transformers/font_to_ttx.rb +172 -0
  48. data/lib/fontisan/export/transformers/head_transformer.rb +96 -0
  49. data/lib/fontisan/export/transformers/hhea_transformer.rb +59 -0
  50. data/lib/fontisan/export/transformers/maxp_transformer.rb +63 -0
  51. data/lib/fontisan/export/transformers/name_transformer.rb +63 -0
  52. data/lib/fontisan/export/transformers/os2_transformer.rb +121 -0
  53. data/lib/fontisan/export/transformers/post_transformer.rb +51 -0
  54. data/lib/fontisan/export/ttx_generator.rb +527 -0
  55. data/lib/fontisan/export/ttx_parser.rb +300 -0
  56. data/lib/fontisan/font_loader.rb +121 -12
  57. data/lib/fontisan/font_writer.rb +301 -0
  58. data/lib/fontisan/formatters/text_formatter.rb +102 -0
  59. data/lib/fontisan/glyph_accessor.rb +503 -0
  60. data/lib/fontisan/hints/hint_converter.rb +177 -0
  61. data/lib/fontisan/hints/postscript_hint_applier.rb +185 -0
  62. data/lib/fontisan/hints/postscript_hint_extractor.rb +254 -0
  63. data/lib/fontisan/hints/truetype_hint_applier.rb +71 -0
  64. data/lib/fontisan/hints/truetype_hint_extractor.rb +162 -0
  65. data/lib/fontisan/loading_modes.rb +113 -0
  66. data/lib/fontisan/metrics_calculator.rb +277 -0
  67. data/lib/fontisan/models/collection_font_summary.rb +52 -0
  68. data/lib/fontisan/models/collection_info.rb +76 -0
  69. data/lib/fontisan/models/collection_list_info.rb +37 -0
  70. data/lib/fontisan/models/font_export.rb +158 -0
  71. data/lib/fontisan/models/font_summary.rb +48 -0
  72. data/lib/fontisan/models/glyph_outline.rb +343 -0
  73. data/lib/fontisan/models/hint.rb +233 -0
  74. data/lib/fontisan/models/outline.rb +664 -0
  75. data/lib/fontisan/models/table_sharing_info.rb +40 -0
  76. data/lib/fontisan/models/ttx/glyph_order.rb +31 -0
  77. data/lib/fontisan/models/ttx/tables/binary_table.rb +67 -0
  78. data/lib/fontisan/models/ttx/tables/head_table.rb +74 -0
  79. data/lib/fontisan/models/ttx/tables/hhea_table.rb +74 -0
  80. data/lib/fontisan/models/ttx/tables/maxp_table.rb +55 -0
  81. data/lib/fontisan/models/ttx/tables/name_table.rb +45 -0
  82. data/lib/fontisan/models/ttx/tables/os2_table.rb +157 -0
  83. data/lib/fontisan/models/ttx/tables/post_table.rb +50 -0
  84. data/lib/fontisan/models/ttx/ttfont.rb +49 -0
  85. data/lib/fontisan/models/validation_report.rb +203 -0
  86. data/lib/fontisan/open_type_collection.rb +156 -2
  87. data/lib/fontisan/open_type_font.rb +296 -10
  88. data/lib/fontisan/optimizers/charstring_rewriter.rb +161 -0
  89. data/lib/fontisan/optimizers/pattern_analyzer.rb +308 -0
  90. data/lib/fontisan/optimizers/stack_tracker.rb +246 -0
  91. data/lib/fontisan/optimizers/subroutine_builder.rb +134 -0
  92. data/lib/fontisan/optimizers/subroutine_generator.rb +207 -0
  93. data/lib/fontisan/optimizers/subroutine_optimizer.rb +107 -0
  94. data/lib/fontisan/outline_extractor.rb +423 -0
  95. data/lib/fontisan/subset/builder.rb +268 -0
  96. data/lib/fontisan/subset/glyph_mapping.rb +215 -0
  97. data/lib/fontisan/subset/options.rb +142 -0
  98. data/lib/fontisan/subset/profile.rb +152 -0
  99. data/lib/fontisan/subset/table_subsetter.rb +461 -0
  100. data/lib/fontisan/svg/font_face_generator.rb +278 -0
  101. data/lib/fontisan/svg/font_generator.rb +264 -0
  102. data/lib/fontisan/svg/glyph_generator.rb +168 -0
  103. data/lib/fontisan/svg/view_box_calculator.rb +137 -0
  104. data/lib/fontisan/tables/cff/cff_glyph.rb +176 -0
  105. data/lib/fontisan/tables/cff/charset.rb +282 -0
  106. data/lib/fontisan/tables/cff/charstring.rb +905 -0
  107. data/lib/fontisan/tables/cff/charstring_builder.rb +322 -0
  108. data/lib/fontisan/tables/cff/charstrings_index.rb +162 -0
  109. data/lib/fontisan/tables/cff/dict.rb +351 -0
  110. data/lib/fontisan/tables/cff/dict_builder.rb +242 -0
  111. data/lib/fontisan/tables/cff/encoding.rb +274 -0
  112. data/lib/fontisan/tables/cff/header.rb +102 -0
  113. data/lib/fontisan/tables/cff/index.rb +237 -0
  114. data/lib/fontisan/tables/cff/index_builder.rb +170 -0
  115. data/lib/fontisan/tables/cff/private_dict.rb +284 -0
  116. data/lib/fontisan/tables/cff/top_dict.rb +236 -0
  117. data/lib/fontisan/tables/cff.rb +487 -0
  118. data/lib/fontisan/tables/cff2/blend_operator.rb +240 -0
  119. data/lib/fontisan/tables/cff2/charstring_parser.rb +591 -0
  120. data/lib/fontisan/tables/cff2/operand_stack.rb +232 -0
  121. data/lib/fontisan/tables/cff2.rb +341 -0
  122. data/lib/fontisan/tables/cvar.rb +242 -0
  123. data/lib/fontisan/tables/fvar.rb +2 -2
  124. data/lib/fontisan/tables/glyf/compound_glyph.rb +483 -0
  125. data/lib/fontisan/tables/glyf/compound_glyph_resolver.rb +136 -0
  126. data/lib/fontisan/tables/glyf/curve_converter.rb +343 -0
  127. data/lib/fontisan/tables/glyf/glyph_builder.rb +450 -0
  128. data/lib/fontisan/tables/glyf/simple_glyph.rb +382 -0
  129. data/lib/fontisan/tables/glyf.rb +235 -0
  130. data/lib/fontisan/tables/gvar.rb +270 -0
  131. data/lib/fontisan/tables/hhea.rb +124 -0
  132. data/lib/fontisan/tables/hmtx.rb +287 -0
  133. data/lib/fontisan/tables/hvar.rb +191 -0
  134. data/lib/fontisan/tables/loca.rb +322 -0
  135. data/lib/fontisan/tables/maxp.rb +192 -0
  136. data/lib/fontisan/tables/mvar.rb +185 -0
  137. data/lib/fontisan/tables/name.rb +99 -30
  138. data/lib/fontisan/tables/variation_common.rb +346 -0
  139. data/lib/fontisan/tables/vvar.rb +234 -0
  140. data/lib/fontisan/true_type_collection.rb +156 -2
  141. data/lib/fontisan/true_type_font.rb +297 -11
  142. data/lib/fontisan/utilities/brotli_wrapper.rb +159 -0
  143. data/lib/fontisan/utilities/checksum_calculator.rb +18 -0
  144. data/lib/fontisan/utils/thread_pool.rb +134 -0
  145. data/lib/fontisan/validation/checksum_validator.rb +170 -0
  146. data/lib/fontisan/validation/consistency_validator.rb +197 -0
  147. data/lib/fontisan/validation/structure_validator.rb +198 -0
  148. data/lib/fontisan/validation/table_validator.rb +158 -0
  149. data/lib/fontisan/validation/validator.rb +152 -0
  150. data/lib/fontisan/variable/axis_normalizer.rb +215 -0
  151. data/lib/fontisan/variable/delta_applicator.rb +313 -0
  152. data/lib/fontisan/variable/glyph_delta_processor.rb +218 -0
  153. data/lib/fontisan/variable/instancer.rb +344 -0
  154. data/lib/fontisan/variable/metric_delta_processor.rb +282 -0
  155. data/lib/fontisan/variable/region_matcher.rb +208 -0
  156. data/lib/fontisan/variable/static_font_builder.rb +213 -0
  157. data/lib/fontisan/variable/table_updater.rb +219 -0
  158. data/lib/fontisan/variation/blend_applier.rb +199 -0
  159. data/lib/fontisan/variation/cache.rb +298 -0
  160. data/lib/fontisan/variation/cache_key_builder.rb +162 -0
  161. data/lib/fontisan/variation/converter.rb +268 -0
  162. data/lib/fontisan/variation/data_extractor.rb +86 -0
  163. data/lib/fontisan/variation/delta_applier.rb +266 -0
  164. data/lib/fontisan/variation/delta_parser.rb +228 -0
  165. data/lib/fontisan/variation/inspector.rb +275 -0
  166. data/lib/fontisan/variation/instance_generator.rb +273 -0
  167. data/lib/fontisan/variation/interpolator.rb +231 -0
  168. data/lib/fontisan/variation/metrics_adjuster.rb +318 -0
  169. data/lib/fontisan/variation/optimizer.rb +418 -0
  170. data/lib/fontisan/variation/parallel_generator.rb +150 -0
  171. data/lib/fontisan/variation/region_matcher.rb +221 -0
  172. data/lib/fontisan/variation/subsetter.rb +463 -0
  173. data/lib/fontisan/variation/table_accessor.rb +105 -0
  174. data/lib/fontisan/variation/validator.rb +345 -0
  175. data/lib/fontisan/variation/variation_context.rb +211 -0
  176. data/lib/fontisan/version.rb +1 -1
  177. data/lib/fontisan/woff2/directory.rb +257 -0
  178. data/lib/fontisan/woff2/header.rb +101 -0
  179. data/lib/fontisan/woff2/table_transformer.rb +163 -0
  180. data/lib/fontisan/woff2_font.rb +712 -0
  181. data/lib/fontisan/woff_font.rb +483 -0
  182. data/lib/fontisan.rb +120 -0
  183. data/scripts/compare_stack_aware.rb +187 -0
  184. data/scripts/measure_optimization.rb +141 -0
  185. metadata +205 -4
@@ -0,0 +1,351 @@
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 DICT (Dictionary) structure parser
10
+ #
11
+ # DICTs in CFF use a compact operand-operator format similar to PostScript.
12
+ # Operands are pushed onto a stack, then an operator consumes them.
13
+ #
14
+ # Operand Encoding:
15
+ # - 32-247: Small integers (values -107 to +107)
16
+ # - 28: 3-byte signed integer follows
17
+ # - 29: 5-byte signed integer follows
18
+ # - 30: Real number (nibble-encoded)
19
+ # - 247-254: 2-byte signed integers
20
+ # - 255: Reserved
21
+ # - 0-21, 22-27: Operators (single or two-byte)
22
+ #
23
+ # Reference: CFF specification section 4 "DICT Data"
24
+ # https://adobe-type-tools.github.io/font-tech-notes/pdfs/5176.CFF.pdf
25
+ #
26
+ # @example Parsing a DICT
27
+ # data = top_dict_index[0]
28
+ # dict = Fontisan::Tables::Cff::Dict.new(data)
29
+ # puts dict[:charset] # => offset to charset
30
+ # puts dict[:version] # => version SID
31
+ class Dict
32
+ # Common DICT operators shared across Top DICT and Private DICT
33
+ #
34
+ # Key: operator byte(s), Value: operator name symbol
35
+ OPERATORS = {
36
+ 0 => :version,
37
+ 1 => :notice,
38
+ 2 => :full_name,
39
+ 3 => :family_name,
40
+ 4 => :weight,
41
+ [12, 0] => :copyright,
42
+ [12, 1] => :is_fixed_pitch,
43
+ [12, 2] => :italic_angle,
44
+ [12, 3] => :underline_position,
45
+ [12, 4] => :underline_thickness,
46
+ [12, 5] => :paint_type,
47
+ [12, 6] => :charstring_type,
48
+ [12, 7] => :font_matrix,
49
+ [12, 8] => :stroke_width,
50
+ [12, 20] => :synthetic_base,
51
+ [12, 21] => :postscript,
52
+ [12, 22] => :base_font_name,
53
+ [12, 23] => :base_font_blend,
54
+ }.freeze
55
+
56
+ # @return [Hash] Parsed dictionary as key-value pairs
57
+ attr_reader :dict
58
+
59
+ # @return [String] Raw binary data of the DICT
60
+ attr_reader :data
61
+
62
+ # Initialize and parse a DICT from binary data
63
+ #
64
+ # @param data [String, IO, StringIO] Binary DICT data
65
+ def initialize(data)
66
+ @data = data.is_a?(String) ? data : data.read
67
+ @dict = {}
68
+ @io = StringIO.new(@data)
69
+ parse!
70
+ end
71
+
72
+ # Get a value from the dictionary by operator name
73
+ #
74
+ # @param key [Symbol] Operator name (e.g., :charset, :encoding)
75
+ # @return [Object, nil] Value for the operator, or nil if not present
76
+ def [](key)
77
+ @dict[key]
78
+ end
79
+
80
+ # Set a value in the dictionary
81
+ #
82
+ # @param key [Symbol] Operator name
83
+ # @param value [Object] Value to set
84
+ def []=(key, value)
85
+ @dict[key] = value
86
+ end
87
+
88
+ # Check if the dictionary contains a specific operator
89
+ #
90
+ # @param key [Symbol] Operator name
91
+ # @return [Boolean] True if operator is present
92
+ def has_key?(key)
93
+ @dict.key?(key)
94
+ end
95
+
96
+ # Get all operator names in this DICT
97
+ #
98
+ # @return [Array<Symbol>] Array of operator names
99
+ def keys
100
+ @dict.keys
101
+ end
102
+
103
+ # Get all values in this DICT
104
+ #
105
+ # @return [Array<Object>] Array of values
106
+ def values
107
+ @dict.values
108
+ end
109
+
110
+ # Convert DICT to Hash
111
+ #
112
+ # @return [Hash] Dictionary as hash
113
+ def to_h
114
+ @dict.dup
115
+ end
116
+
117
+ # Number of entries in the DICT
118
+ #
119
+ # @return [Integer] Entry count
120
+ def size
121
+ @dict.size
122
+ end
123
+
124
+ # Check if DICT is empty
125
+ #
126
+ # @return [Boolean] True if no entries
127
+ def empty?
128
+ @dict.empty?
129
+ end
130
+
131
+ private
132
+
133
+ # Parse the DICT structure
134
+ #
135
+ # DICTs use a stack-based format:
136
+ # 1. Read operands and push onto operand stack
137
+ # 2. When operator is encountered, pop operands and process
138
+ # 3. Store result in dictionary
139
+ def parse!
140
+ operand_stack = []
141
+
142
+ until @io.eof?
143
+ byte = read_byte
144
+
145
+ if operator?(byte)
146
+ # Process operator with current operand stack
147
+ operator = read_operator(byte)
148
+ process_operator(operator, operand_stack)
149
+ operand_stack.clear
150
+ else
151
+ # Read operand and push onto stack
152
+ @io.pos -= 1 # Unread the byte
153
+ operand = read_operand
154
+ operand_stack << operand
155
+ end
156
+ end
157
+ end
158
+
159
+ # Check if a byte is an operator
160
+ #
161
+ # @param byte [Integer] Byte value
162
+ # @return [Boolean] True if operator byte
163
+ def operator?(byte)
164
+ # Operators are 0-21 or escape (12) followed by another byte
165
+ byte <= 21 || byte == 12
166
+ end
167
+
168
+ # Read an operator (single or two-byte)
169
+ #
170
+ # @param first_byte [Integer] First operator byte
171
+ # @return [Integer, Array<Integer>] Operator identifier
172
+ def read_operator(first_byte)
173
+ if first_byte == 12
174
+ # Two-byte operator (escape operator)
175
+ second_byte = read_byte
176
+ [first_byte, second_byte]
177
+ else
178
+ # Single-byte operator
179
+ first_byte
180
+ end
181
+ end
182
+
183
+ # Process an operator with its operands
184
+ #
185
+ # @param operator [Integer, Array<Integer>] Operator identifier
186
+ # @param operands [Array] Operand stack
187
+ def process_operator(operator, operands)
188
+ operator_name = operator_name_for(operator)
189
+ return unless operator_name
190
+
191
+ # Store the operand(s) in the dictionary
192
+ # Most operators take a single operand, some take arrays
193
+ value = operands.size == 1 ? operands.first : operands.dup
194
+ @dict[operator_name] = value
195
+ end
196
+
197
+ # Get the operator name for an operator byte(s)
198
+ #
199
+ # @param operator [Integer, Array<Integer>] Operator identifier
200
+ # @return [Symbol, nil] Operator name or nil if unknown
201
+ def operator_name_for(operator)
202
+ # Check in the OPERATORS table (common operators)
203
+ self.class::OPERATORS[operator] || derived_operators[operator]
204
+ end
205
+
206
+ # Get derived class-specific operators
207
+ #
208
+ # Subclasses override this to add their specific operators
209
+ #
210
+ # @return [Hash] Additional operators for this DICT type
211
+ def derived_operators
212
+ {}
213
+ end
214
+
215
+ # Read a single operand from the DICT data
216
+ #
217
+ # Operands can be:
218
+ # - Small integers (1 byte: 32-246 or 247-254 with next byte)
219
+ # - Medium integers (3 bytes: 28 + 2 bytes)
220
+ # - Large integers (5 bytes: 29 + 4 bytes)
221
+ # - Real numbers (30 + nibble-encoded decimal)
222
+ #
223
+ # @return [Integer, Float] The operand value
224
+ def read_operand
225
+ byte = read_byte
226
+
227
+ case byte
228
+ when 28
229
+ # 3-byte signed integer
230
+ read_int16
231
+ when 29
232
+ # 5-byte signed integer
233
+ read_int32
234
+ when 30
235
+ # Real number (nibble-encoded)
236
+ read_real
237
+ when 32..246
238
+ # Small integer: -107 to +107
239
+ byte - 139
240
+ when 247..250
241
+ # Positive 2-byte integer
242
+ second_byte = read_byte
243
+ (byte - 247) * 256 + second_byte + 108
244
+ when 251..254
245
+ # Negative 2-byte integer
246
+ second_byte = read_byte
247
+ -(byte - 251) * 256 - second_byte - 108
248
+ else
249
+ raise CorruptedTableError,
250
+ "Invalid DICT operand byte: #{byte}"
251
+ end
252
+ end
253
+
254
+ # Read a 16-bit signed integer (big-endian)
255
+ #
256
+ # @return [Integer] Signed 16-bit value
257
+ def read_int16
258
+ bytes = @io.read(2)
259
+ if bytes.nil? || bytes.bytesize < 2
260
+ raise CorruptedTableError,
261
+ "Unexpected end of DICT"
262
+ end
263
+
264
+ value = bytes.unpack1("n") # Unsigned 16-bit big-endian
265
+ # Convert to signed
266
+ value > 0x7FFF ? value - 0x10000 : value
267
+ end
268
+
269
+ # Read a 32-bit signed integer (big-endian)
270
+ #
271
+ # @return [Integer] Signed 32-bit value
272
+ def read_int32
273
+ bytes = @io.read(4)
274
+ if bytes.nil? || bytes.bytesize < 4
275
+ raise CorruptedTableError,
276
+ "Unexpected end of DICT"
277
+ end
278
+
279
+ value = bytes.unpack1("N") # Unsigned 32-bit big-endian
280
+ # Convert to signed
281
+ value > 0x7FFFFFFF ? value - 0x100000000 : value
282
+ end
283
+
284
+ # Read a real number (nibble-encoded)
285
+ #
286
+ # Real numbers in CFF are encoded as a sequence of nibbles (4-bit values)
287
+ # where each nibble represents a digit or special character.
288
+ #
289
+ # Nibble values:
290
+ # - 0-9: Decimal digits
291
+ # - a (10): Decimal point
292
+ # - b (11): Positive exponent (E)
293
+ # - c (12): Negative exponent (E-)
294
+ # - d (13): Reserved
295
+ # - e (14): Minus sign
296
+ # - f (15): End of number
297
+ #
298
+ # @return [Float] The decoded real number
299
+ def read_real
300
+ nibbles = []
301
+
302
+ loop do
303
+ byte = read_byte
304
+ high_nibble = (byte >> 4) & 0x0F
305
+ low_nibble = byte & 0x0F
306
+
307
+ break if high_nibble == 0xF
308
+
309
+ nibbles << high_nibble
310
+
311
+ break if low_nibble == 0xF
312
+
313
+ nibbles << low_nibble
314
+ end
315
+
316
+ # Convert nibbles to string representation
317
+ str = +""
318
+ nibbles.each do |nibble|
319
+ case nibble
320
+ when 0..9
321
+ str << nibble.to_s
322
+ when 0xa # Decimal point
323
+ str << "."
324
+ when 0xb # Positive exponent (E)
325
+ str << "e"
326
+ when 0xc # Negative exponent (E-)
327
+ str << "e-"
328
+ when 0xe # Minus sign
329
+ str << "-"
330
+ when 0xd, 0xf # Reserved or end marker
331
+ # Skip
332
+ end
333
+ end
334
+
335
+ # Convert to float
336
+ str.to_f
337
+ end
338
+
339
+ # Read a single byte from the IO
340
+ #
341
+ # @return [Integer] Byte value (0-255)
342
+ def read_byte
343
+ byte = @io.getbyte
344
+ raise CorruptedTableError, "Unexpected end of DICT" if byte.nil?
345
+
346
+ byte
347
+ end
348
+ end
349
+ end
350
+ end
351
+ end
@@ -0,0 +1,242 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "stringio"
4
+
5
+ module Fontisan
6
+ module Tables
7
+ class Cff
8
+ # CFF DICT (Dictionary) structure builder
9
+ #
10
+ # [`DictBuilder`](lib/fontisan/tables/cff/dict_builder.rb) constructs
11
+ # binary DICT structures from hash representations. DICTs in CFF use a
12
+ # compact operand-operator format similar to PostScript.
13
+ #
14
+ # The builder encodes operands in various compact formats and writes
15
+ # operators according to the CFF specification.
16
+ #
17
+ # Operand Encoding:
18
+ # - Small integers (-107 to +107): Single byte (32-246)
19
+ # - Medium integers (108 to 1131): Two bytes (247-250 + byte)
20
+ # - Medium integers (-1131 to -108): Two bytes (251-254 + byte)
21
+ # - Larger integers: Three bytes (28 + 2 bytes) or five bytes (29 + 4 bytes)
22
+ # - Real numbers: Nibble-encoded (30 + nibbles + 0xF terminator)
23
+ #
24
+ # Operators:
25
+ # - Single-byte: 0-21
26
+ # - Two-byte: 12 followed by second byte
27
+ #
28
+ # Reference: CFF specification section 4 "DICT Data"
29
+ # https://adobe-type-tools.github.io/font-tech-notes/pdfs/5176.CFF.pdf
30
+ #
31
+ # @example Building a DICT
32
+ # dict_hash = { version: 391, notice: 392, charset: 0 }
33
+ # dict_data = Fontisan::Tables::Cff::DictBuilder.build(dict_hash)
34
+ class DictBuilder
35
+ # Operator mapping (name => byte(s))
36
+ OPERATORS = {
37
+ version: 0,
38
+ notice: 1,
39
+ full_name: 2,
40
+ family_name: 3,
41
+ weight: 4,
42
+ charset: 15,
43
+ encoding: 16,
44
+ charstrings: 17,
45
+ private: 18,
46
+ copyright: [12, 0],
47
+ is_fixed_pitch: [12, 1],
48
+ italic_angle: [12, 2],
49
+ underline_position: [12, 3],
50
+ underline_thickness: [12, 4],
51
+ paint_type: [12, 5],
52
+ charstring_type: [12, 6],
53
+ font_matrix: [12, 7],
54
+ stroke_width: [12, 8],
55
+ synthetic_base: [12, 20],
56
+ postscript: [12, 21],
57
+ base_font_name: [12, 22],
58
+ base_font_blend: [12, 23],
59
+ # Private DICT operators
60
+ subrs: 19,
61
+ default_width_x: 20,
62
+ nominal_width_x: 21,
63
+ }.freeze
64
+
65
+ # Build DICT structure from hash
66
+ #
67
+ # @param dict_hash [Hash] Hash of operator => value pairs
68
+ # @return [String] Binary DICT data
69
+ # @raise [ArgumentError] If dict_hash is invalid
70
+ def self.build(dict_hash)
71
+ validate_dict!(dict_hash)
72
+
73
+ return "".b if dict_hash.empty?
74
+
75
+ output = StringIO.new("".b)
76
+
77
+ # Encode each operator with its operands
78
+ dict_hash.each do |operator_name, value|
79
+ # Get operator bytes
80
+ operator_bytes = operator_for_name(operator_name)
81
+ raise ArgumentError, "Unknown operator: #{operator_name}" unless operator_bytes
82
+
83
+ # Write operands (value can be single value or array)
84
+ if value.is_a?(Array)
85
+ value.each { |v| write_operand(output, v) }
86
+ else
87
+ write_operand(output, value)
88
+ end
89
+
90
+ # Write operator
91
+ write_operator(output, operator_bytes)
92
+ end
93
+
94
+ output.string
95
+ end
96
+
97
+ # Validate dict parameter
98
+ #
99
+ # @param dict_hash [Object] Dictionary to validate
100
+ # @raise [ArgumentError] If dict_hash is invalid
101
+ def self.validate_dict!(dict_hash)
102
+ unless dict_hash.is_a?(Hash)
103
+ raise ArgumentError,
104
+ "dict_hash must be Hash, got: #{dict_hash.class}"
105
+ end
106
+ end
107
+ private_class_method :validate_dict!
108
+
109
+ # Get operator bytes for operator name
110
+ #
111
+ # @param operator_name [Symbol] Operator name
112
+ # @return [Integer, Array<Integer>, nil] Operator byte(s) or nil
113
+ def self.operator_for_name(operator_name)
114
+ OPERATORS[operator_name]
115
+ end
116
+ private_class_method :operator_for_name
117
+
118
+ # Write an operand value to output
119
+ #
120
+ # @param io [StringIO] Output stream
121
+ # @param value [Integer, Float] Operand value
122
+ def self.write_operand(io, value)
123
+ if value.is_a?(Float)
124
+ write_real(io, value)
125
+ else
126
+ write_integer(io, value)
127
+ end
128
+ end
129
+ private_class_method :write_operand
130
+
131
+ # Write an integer operand
132
+ #
133
+ # @param io [StringIO] Output stream
134
+ # @param value [Integer] Integer value
135
+ def self.write_integer(io, value)
136
+ if value >= -107 && value <= 107
137
+ # Single byte: 32-246 represents -107 to +107
138
+ io.putc(value + 139)
139
+ elsif value >= 108 && value <= 1131
140
+ # Positive two-byte: 247-250
141
+ adjusted = value - 108
142
+ b0 = 247 + (adjusted / 256)
143
+ b1 = adjusted % 256
144
+ io.putc(b0)
145
+ io.putc(b1)
146
+ elsif value >= -1131 && value <= -108
147
+ # Negative two-byte: 251-254
148
+ adjusted = -value - 108
149
+ b0 = 251 + (adjusted / 256)
150
+ b1 = adjusted % 256
151
+ io.putc(b0)
152
+ io.putc(b1)
153
+ elsif value >= -32768 && value <= 32767
154
+ # Three-byte signed 16-bit
155
+ io.putc(28)
156
+ io.write([value].pack("s>")) # Signed 16-bit big-endian
157
+ else
158
+ # Five-byte signed 32-bit
159
+ io.putc(29)
160
+ io.write([value].pack("l>")) # Signed 32-bit big-endian
161
+ end
162
+ end
163
+ private_class_method :write_integer
164
+
165
+ # Write a real number operand
166
+ #
167
+ # Real numbers are encoded using nibbles (4-bit values).
168
+ # Each nibble represents a digit or special character.
169
+ #
170
+ # Nibble values:
171
+ # - 0-9: Decimal digits
172
+ # - a (10): Decimal point
173
+ # - b (11): Positive exponent (E)
174
+ # - c (12): Negative exponent (E-)
175
+ # - e (14): Minus sign
176
+ # - f (15): End of number
177
+ #
178
+ # @param io [StringIO] Output stream
179
+ # @param value [Float] Real number value
180
+ def self.write_real(io, value)
181
+ io.putc(30) # Real number marker
182
+
183
+ # Convert to string representation
184
+ str = value.to_s
185
+
186
+ # Handle special cases
187
+ str = "0" if str == "0.0"
188
+
189
+ # Convert string to nibbles
190
+ nibbles = []
191
+
192
+ str.each_char do |char|
193
+ case char
194
+ when "0".."9"
195
+ nibbles << char.to_i
196
+ when "."
197
+ nibbles << 0xa
198
+ when "-"
199
+ nibbles << 0xe
200
+ when "e", "E"
201
+ # Check if next char is minus
202
+ nibbles << 0xb # Default to positive exponent
203
+ end
204
+ end
205
+
206
+ # Handle negative exponent
207
+ if str.include?("e-") || str.include?("E-")
208
+ # Replace last 0xb with 0xc
209
+ exp_index = nibbles.rindex(0xb)
210
+ nibbles[exp_index] = 0xc if exp_index
211
+ end
212
+
213
+ # Add terminator
214
+ nibbles << 0xf
215
+
216
+ # Pack nibbles into bytes
217
+ nibbles.each_slice(2) do |high, low|
218
+ low ||= 0xf # Pad with terminator if odd number
219
+ byte = (high << 4) | low
220
+ io.putc(byte)
221
+ end
222
+ end
223
+ private_class_method :write_real
224
+
225
+ # Write an operator to output
226
+ #
227
+ # @param io [StringIO] Output stream
228
+ # @param operator_bytes [Integer, Array<Integer>] Operator byte(s)
229
+ def self.write_operator(io, operator_bytes)
230
+ if operator_bytes.is_a?(Array)
231
+ # Two-byte operator
232
+ operator_bytes.each { |byte| io.putc(byte) }
233
+ else
234
+ # Single-byte operator
235
+ io.putc(operator_bytes)
236
+ end
237
+ end
238
+ private_class_method :write_operator
239
+ end
240
+ end
241
+ end
242
+ end