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,483 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Fontisan
4
+ module Tables
5
+ # Represents a compound TrueType glyph composed of other glyphs
6
+ #
7
+ # A compound glyph is built by referencing other glyphs (components)
8
+ # and applying transformations to them. Each component references
9
+ # another glyph by ID and specifies positioning and optional scaling,
10
+ # rotation, or affine transformation.
11
+ #
12
+ # The glyph structure consists of:
13
+ # - Header: numberOfContours (-1), xMin, yMin, xMax, yMax (10 bytes)
14
+ # - Components: array of component descriptions (variable length)
15
+ # - Instructions: optional TrueType hinting instructions
16
+ #
17
+ # Each component has:
18
+ # - flags (uint16): component flags
19
+ # - glyphIndex (uint16): referenced glyph ID
20
+ # - arguments: positioning (arg1, arg2) - interpretation depends on flags
21
+ # - transformation: optional scale/rotation/affine matrix
22
+ #
23
+ # Component flags (16-bit) indicate:
24
+ # - Bit 0 (0x0001): ARG_1_AND_2_ARE_WORDS - arguments are 16-bit
25
+ # - Bit 1 (0x0002): ARGS_ARE_XY_VALUES - arguments are x,y offsets
26
+ # - Bit 2 (0x0004): ROUND_XY_TO_GRID - round x,y to grid
27
+ # - Bit 3 (0x0008): WE_HAVE_A_SCALE - uniform scale follows
28
+ # - Bit 5 (0x0020): MORE_COMPONENTS - more components follow
29
+ # - Bit 6 (0x0040): WE_HAVE_AN_X_AND_Y_SCALE - separate x,y scale
30
+ # - Bit 7 (0x0080): WE_HAVE_A_TWO_BY_TWO - 2x2 affine matrix
31
+ # - Bit 8 (0x0100): WE_HAVE_INSTRUCTIONS - instructions follow components
32
+ # - Bit 9 (0x0200): USE_MY_METRICS - use this component's metrics
33
+ # - Bit 10 (0x0400): OVERLAP_COMPOUND - component outlines overlap
34
+ # - Bit 11 (0x0800): SCALED_COMPONENT_OFFSET - scale offset values
35
+ # - Bit 12 (0x1000): UNSCALED_COMPONENT_OFFSET - don't scale offsets
36
+ #
37
+ # Reference: OpenType specification, glyf table - Compound Glyph Description
38
+ # https://docs.microsoft.com/en-us/typography/opentype/spec/glyf#compound-glyph-description
39
+ class CompoundGlyph
40
+ # Component flag constants
41
+ ARG_1_AND_2_ARE_WORDS = 0x0001
42
+ ARGS_ARE_XY_VALUES = 0x0002
43
+ ROUND_XY_TO_GRID = 0x0004
44
+ WE_HAVE_A_SCALE = 0x0008
45
+ MORE_COMPONENTS = 0x0020
46
+ WE_HAVE_AN_X_AND_Y_SCALE = 0x0040
47
+ WE_HAVE_A_TWO_BY_TWO = 0x0080
48
+ WE_HAVE_INSTRUCTIONS = 0x0100
49
+ USE_MY_METRICS = 0x0200
50
+ OVERLAP_COMPOUND = 0x0400
51
+ SCALED_COMPONENT_OFFSET = 0x0800
52
+ UNSCALED_COMPONENT_OFFSET = 0x1000
53
+
54
+ # Component data structure
55
+ Component = Struct.new(
56
+ :flags,
57
+ :glyph_index,
58
+ :arg1,
59
+ :arg2,
60
+ :scale_x,
61
+ :scale_y,
62
+ :scale_01,
63
+ :scale_10,
64
+ keyword_init: true,
65
+ ) do
66
+ # Check if arguments are x,y offsets (vs point numbers)
67
+ def args_are_xy?
68
+ (flags & ARGS_ARE_XY_VALUES) != 0
69
+ end
70
+
71
+ # Check if using this component's metrics
72
+ def use_my_metrics?
73
+ (flags & USE_MY_METRICS) != 0
74
+ end
75
+
76
+ # Check if component has uniform scale
77
+ def has_scale?
78
+ (flags & WE_HAVE_A_SCALE) != 0
79
+ end
80
+
81
+ # Check if component has separate x,y scale
82
+ def has_xy_scale?
83
+ (flags & WE_HAVE_AN_X_AND_Y_SCALE) != 0
84
+ end
85
+
86
+ # Check if component has 2x2 transformation matrix
87
+ def has_2x2?
88
+ (flags & WE_HAVE_A_TWO_BY_TWO) != 0
89
+ end
90
+
91
+ # Check if component overlaps with others
92
+ def overlap?
93
+ (flags & OVERLAP_COMPOUND) != 0
94
+ end
95
+
96
+ # Get transformation matrix as array [a, b, c, d, e, f]
97
+ # representing affine transformation: x' = a*x + c*y + e, y' = b*x + d*y + f
98
+ #
99
+ # @return [Array<Float>] Transformation matrix [a, b, c, d, e, f]
100
+ def transformation_matrix
101
+ if has_2x2?
102
+ [scale_x, scale_01, scale_10, scale_y, arg1, arg2]
103
+ elsif has_xy_scale?
104
+ [scale_x, 0.0, 0.0, scale_y, arg1, arg2]
105
+ elsif has_scale?
106
+ [scale_x, 0.0, 0.0, scale_x, arg1, arg2]
107
+ else
108
+ [1.0, 0.0, 0.0, 1.0, arg1, arg2]
109
+ end
110
+ end
111
+ end
112
+
113
+ # Glyph header fields
114
+ attr_reader :glyph_id
115
+ attr_reader :x_min, :y_min, :x_max, :y_max, :instruction_length,
116
+ :instructions
117
+
118
+ # Compound glyph data
119
+ attr_reader :components
120
+
121
+ # Parse compound glyph data
122
+ #
123
+ # @param data [String] Binary glyph data
124
+ # @param glyph_id [Integer] Glyph ID for error reporting
125
+ # @return [CompoundGlyph] Parsed compound glyph
126
+ # @raise [Fontisan::CorruptedTableError] If data is insufficient or invalid
127
+ def self.parse(data, glyph_id)
128
+ glyph = new(glyph_id)
129
+ glyph.parse_data(data)
130
+ glyph
131
+ end
132
+
133
+ # Initialize a new compound glyph
134
+ #
135
+ # @param glyph_id [Integer] Glyph ID
136
+ def initialize(glyph_id)
137
+ @glyph_id = glyph_id
138
+ @components = []
139
+ end
140
+
141
+ # Parse glyph data
142
+ #
143
+ # @param data [String] Binary glyph data
144
+ # @raise [Fontisan::CorruptedTableError] If parsing fails
145
+ def parse_data(data)
146
+ io = StringIO.new(data)
147
+ io.set_encoding(Encoding::BINARY)
148
+
149
+ parse_header(io)
150
+ parse_components(io)
151
+ parse_instructions(io) if has_instructions?
152
+
153
+ validate_parsed_data!
154
+ end
155
+
156
+ # Check if this is a simple glyph
157
+ #
158
+ # @return [Boolean] Always false for CompoundGlyph
159
+ def simple?
160
+ false
161
+ end
162
+
163
+ # Check if this is a compound glyph
164
+ #
165
+ # @return [Boolean] Always true for CompoundGlyph
166
+ def compound?
167
+ true
168
+ end
169
+
170
+ # Check if glyph has no components
171
+ #
172
+ # @return [Boolean] True if no components
173
+ def empty?
174
+ components.empty?
175
+ end
176
+
177
+ # Get bounding box as array
178
+ #
179
+ # @return [Array<Integer>] Bounding box [xMin, yMin, xMax, yMax]
180
+ def bounding_box
181
+ [x_min, y_min, x_max, y_max]
182
+ end
183
+
184
+ # Get all component glyph IDs (for dependency tracking)
185
+ #
186
+ # This method returns the glyph IDs of all components that make up
187
+ # this compound glyph. This is essential for subsetting operations,
188
+ # where all dependent glyphs must be included.
189
+ #
190
+ # @return [Array<Integer>] Array of component glyph IDs
191
+ #
192
+ # @example Getting component dependencies
193
+ # glyph = glyf.glyph_for(100, loca, head)
194
+ # if glyph.compound?
195
+ # deps = glyph.component_glyph_ids
196
+ # puts "Glyph 100 depends on: #{deps.join(', ')}"
197
+ # end
198
+ def component_glyph_ids
199
+ components.map(&:glyph_index)
200
+ end
201
+
202
+ # Check if glyph uses a specific component
203
+ #
204
+ # @param glyph_id [Integer] Glyph ID to check
205
+ # @return [Boolean] True if glyph uses this component
206
+ def uses_component?(glyph_id)
207
+ component_glyph_ids.include?(glyph_id)
208
+ end
209
+
210
+ # Get number of components
211
+ #
212
+ # @return [Integer] Component count
213
+ def num_components
214
+ components.length
215
+ end
216
+
217
+ # Check if any component uses metrics from referenced glyph
218
+ #
219
+ # @return [Boolean] True if any component has USE_MY_METRICS flag
220
+ def uses_component_metrics?
221
+ components.any?(&:use_my_metrics?)
222
+ end
223
+
224
+ # Get the component that provides metrics (if any)
225
+ #
226
+ # @return [Component, nil] Component with USE_MY_METRICS flag, or nil
227
+ def metrics_component
228
+ components.find(&:use_my_metrics?)
229
+ end
230
+
231
+ private
232
+
233
+ # Parse glyph header (10 bytes)
234
+ #
235
+ # @param io [StringIO] Input stream
236
+ # @raise [Fontisan::CorruptedTableError] If insufficient data
237
+ def parse_header(io)
238
+ header = io.read(10)
239
+ if header.nil? || header.length < 10
240
+ raise Fontisan::CorruptedTableError,
241
+ "Insufficient header data for compound glyph #{glyph_id}"
242
+ end
243
+
244
+ values = header.unpack("n5")
245
+ num_contours = to_signed_16(values[0])
246
+ @x_min = to_signed_16(values[1])
247
+ @y_min = to_signed_16(values[2])
248
+ @x_max = to_signed_16(values[3])
249
+ @y_max = to_signed_16(values[4])
250
+
251
+ if num_contours != -1
252
+ raise Fontisan::CorruptedTableError,
253
+ "Compound glyph #{glyph_id} must have numberOfContours = -1, got #{num_contours}"
254
+ end
255
+ end
256
+
257
+ # Parse all components
258
+ #
259
+ # @param io [StringIO] Input stream
260
+ # @raise [Fontisan::CorruptedTableError] If insufficient data
261
+ def parse_components(io)
262
+ loop do
263
+ component = parse_component(io)
264
+ @components << component
265
+
266
+ # Check if more components follow
267
+ break unless (component.flags & MORE_COMPONENTS) != 0
268
+ end
269
+ end
270
+
271
+ # Parse a single component
272
+ #
273
+ # @param io [StringIO] Input stream
274
+ # @return [Component] Parsed component
275
+ # @raise [Fontisan::CorruptedTableError] If insufficient data
276
+ def parse_component(io)
277
+ # Read flags and glyph index
278
+ header = io.read(4)
279
+ if header.nil? || header.length < 4
280
+ raise Fontisan::CorruptedTableError,
281
+ "Insufficient component header data for compound glyph #{glyph_id}"
282
+ end
283
+
284
+ flags, glyph_index = header.unpack("n2")
285
+
286
+ # Parse arguments (position or point indices)
287
+ arg1, arg2 = parse_component_arguments(io, flags)
288
+
289
+ # Parse transformation (scale, rotation, or 2x2 matrix)
290
+ scale_x, scale_y, scale_01, scale_10 = parse_component_transformation(
291
+ io, flags
292
+ )
293
+
294
+ Component.new(
295
+ flags: flags,
296
+ glyph_index: glyph_index,
297
+ arg1: arg1,
298
+ arg2: arg2,
299
+ scale_x: scale_x,
300
+ scale_y: scale_y,
301
+ scale_01: scale_01,
302
+ scale_10: scale_10,
303
+ )
304
+ end
305
+
306
+ # Parse component arguments (arg1, arg2)
307
+ #
308
+ # Arguments can be:
309
+ # - 8-bit or 16-bit depending on ARG_1_AND_2_ARE_WORDS flag
310
+ # - Interpreted as x,y offsets if ARGS_ARE_XY_VALUES is set
311
+ # - Otherwise interpreted as point indices for alignment
312
+ #
313
+ # @param io [StringIO] Input stream
314
+ # @param flags [Integer] Component flags
315
+ # @return [Array<Integer>] [arg1, arg2]
316
+ # @raise [Fontisan::CorruptedTableError] If insufficient data
317
+ def parse_component_arguments(io, flags)
318
+ if (flags & ARG_1_AND_2_ARE_WORDS).zero?
319
+ # 8-bit signed arguments
320
+ data = io.read(2)
321
+ if data.nil? || data.length < 2
322
+ raise Fontisan::CorruptedTableError,
323
+ "Insufficient argument data for compound glyph #{glyph_id}"
324
+ end
325
+
326
+ values = data.unpack("C2")
327
+ [to_signed_8(values[0]), to_signed_8(values[1])]
328
+ else
329
+ # 16-bit signed arguments
330
+ data = io.read(4)
331
+ if data.nil? || data.length < 4
332
+ raise Fontisan::CorruptedTableError,
333
+ "Insufficient argument data for compound glyph #{glyph_id}"
334
+ end
335
+
336
+ values = data.unpack("n2")
337
+ [to_signed_16(values[0]), to_signed_16(values[1])]
338
+ end
339
+ end
340
+
341
+ # Parse component transformation
342
+ #
343
+ # Transformation can be:
344
+ # - Uniform scale (1 value)
345
+ # - Separate x,y scale (2 values)
346
+ # - 2x2 affine matrix (4 values)
347
+ # - None (identity transformation)
348
+ #
349
+ # @param io [StringIO] Input stream
350
+ # @param flags [Integer] Component flags
351
+ # @return [Array<Float>] [scale_x, scale_y, scale_01, scale_10]
352
+ # @raise [Fontisan::CorruptedTableError] If insufficient data
353
+ def parse_component_transformation(io, flags)
354
+ if (flags & WE_HAVE_A_TWO_BY_TWO) != 0
355
+ # 2x2 transformation matrix (4 F2DOT14 values)
356
+ data = io.read(8)
357
+ if data.nil? || data.length < 8
358
+ raise Fontisan::CorruptedTableError,
359
+ "Insufficient 2x2 matrix data for compound glyph #{glyph_id}"
360
+ end
361
+
362
+ values = data.unpack("n4").map { |v| f2dot14_to_float(v) }
363
+ [values[0], values[3], values[1], values[2]] # [xscale, yscale, scale01, scale10]
364
+ elsif (flags & WE_HAVE_AN_X_AND_Y_SCALE) != 0
365
+ # Separate x and y scale (2 F2DOT14 values)
366
+ data = io.read(4)
367
+ if data.nil? || data.length < 4
368
+ raise Fontisan::CorruptedTableError,
369
+ "Insufficient x,y scale data for compound glyph #{glyph_id}"
370
+ end
371
+
372
+ values = data.unpack("n2").map { |v| f2dot14_to_float(v) }
373
+ [values[0], values[1], 0.0, 0.0]
374
+ elsif (flags & WE_HAVE_A_SCALE) != 0
375
+ # Uniform scale (1 F2DOT14 value)
376
+ data = io.read(2)
377
+ if data.nil? || data.length < 2
378
+ raise Fontisan::CorruptedTableError,
379
+ "Insufficient scale data for compound glyph #{glyph_id}"
380
+ end
381
+
382
+ scale = f2dot14_to_float(data.unpack1("n"))
383
+ [scale, scale, 0.0, 0.0]
384
+ else
385
+ # No transformation (identity)
386
+ [1.0, 1.0, 0.0, 0.0]
387
+ end
388
+ end
389
+
390
+ # Parse instructions if present
391
+ #
392
+ # @param io [StringIO] Input stream
393
+ # @raise [Fontisan::CorruptedTableError] If insufficient data
394
+ def parse_instructions(io)
395
+ length_data = io.read(2)
396
+ if length_data.nil? || length_data.length < 2
397
+ raise Fontisan::CorruptedTableError,
398
+ "Insufficient instruction length data for compound glyph #{glyph_id}"
399
+ end
400
+
401
+ @instruction_length = length_data.unpack1("n")
402
+
403
+ if @instruction_length.positive?
404
+ @instructions = io.read(@instruction_length)
405
+ if @instructions.nil? || @instructions.length < @instruction_length
406
+ raise Fontisan::CorruptedTableError,
407
+ "Insufficient instruction data for compound glyph #{glyph_id}"
408
+ end
409
+ else
410
+ @instructions = "".b
411
+ end
412
+ end
413
+
414
+ # Check if any component has instructions flag set
415
+ #
416
+ # @return [Boolean] True if instructions should be present
417
+ def has_instructions?
418
+ components.any? { |c| (c.flags & WE_HAVE_INSTRUCTIONS) != 0 }
419
+ end
420
+
421
+ # Validate parsed data consistency
422
+ #
423
+ # @raise [Fontisan::CorruptedTableError] If validation fails
424
+ def validate_parsed_data!
425
+ if components.empty?
426
+ raise Fontisan::CorruptedTableError,
427
+ "Compound glyph #{glyph_id} has no components"
428
+ end
429
+
430
+ # Check for duplicate USE_MY_METRICS flags
431
+ metrics_components = components.select(&:use_my_metrics?)
432
+ if metrics_components.length > 1
433
+ raise Fontisan::CorruptedTableError,
434
+ "Compound glyph #{glyph_id} has multiple components with USE_MY_METRICS flag"
435
+ end
436
+
437
+ # Validate component glyph indices
438
+ components.each_with_index do |component, i|
439
+ if component.glyph_index.nil? || component.glyph_index.negative?
440
+ raise Fontisan::CorruptedTableError,
441
+ "Invalid glyph index in component #{i} of compound glyph #{glyph_id}"
442
+ end
443
+
444
+ # Check for circular reference (component referencing self)
445
+ if component.glyph_index == glyph_id
446
+ raise Fontisan::CorruptedTableError,
447
+ "Circular reference: compound glyph #{glyph_id} references itself"
448
+ end
449
+ end
450
+ end
451
+
452
+ # Convert unsigned 16-bit value to signed
453
+ #
454
+ # @param value [Integer] Unsigned 16-bit value
455
+ # @return [Integer] Signed 16-bit value
456
+ def to_signed_16(value)
457
+ value > 0x7FFF ? value - 0x10000 : value
458
+ end
459
+
460
+ # Convert unsigned 8-bit value to signed
461
+ #
462
+ # @param value [Integer] Unsigned 8-bit value
463
+ # @return [Integer] Signed 8-bit value
464
+ def to_signed_8(value)
465
+ value > 0x7F ? value - 0x100 : value
466
+ end
467
+
468
+ # Convert F2DOT14 fixed-point to float
469
+ #
470
+ # F2DOT14 is a signed 2.14 fixed-point number:
471
+ # - 2 bits for integer part (including sign)
472
+ # - 14 bits for fractional part
473
+ # Range: -2.0 to ~1.99993896484375
474
+ #
475
+ # @param value [Integer] Unsigned 16-bit F2DOT14 value
476
+ # @return [Float] Float value
477
+ def f2dot14_to_float(value)
478
+ signed = to_signed_16(value)
479
+ signed / 16_384.0 # 2^14 = 16384
480
+ end
481
+ end
482
+ end
483
+ end
@@ -0,0 +1,136 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../../models/outline"
4
+
5
+ module Fontisan
6
+ module Tables
7
+ # Resolves compound glyphs into simple outlines
8
+ #
9
+ # [`CompoundGlyphResolver`](lib/fontisan/tables/glyf/compound_glyph_resolver.rb)
10
+ # handles the recursive resolution of compound (composite) glyphs in TrueType fonts.
11
+ # Compound glyphs are composed of references to other glyphs with transformation
12
+ # matrices applied. This resolver:
13
+ #
14
+ # - Recursively resolves component glyphs (which may themselves be compound)
15
+ # - Applies transformation matrices to each component
16
+ # - Merges all components into a single simple outline
17
+ # - Handles nested compound glyphs (compound glyphs referencing other compounds)
18
+ # - Detects and prevents circular references
19
+ #
20
+ # **Transformation Process:**
21
+ #
22
+ # Each component has a transformation matrix [a, b, c, d, e, f] representing:
23
+ # x' = a*x + c*y + e
24
+ # y' = b*x + d*y + f
25
+ #
26
+ # Where:
27
+ # - a, d: Scale factors for x and y
28
+ # - b, c: Rotation/skew components
29
+ # - e, f: Translation offsets (x, y)
30
+ #
31
+ # **Resolution Strategy:**
32
+ #
33
+ # 1. Start with compound glyph
34
+ # 2. For each component:
35
+ # a. Get component glyph (may be simple or compound)
36
+ # b. If compound, recursively resolve it first
37
+ # c. Apply component's transformation matrix
38
+ # d. Merge into result outline
39
+ # 3. Return merged simple outline
40
+ #
41
+ # @example Resolving a compound glyph
42
+ # resolver = CompoundGlyphResolver.new(glyf_table, loca_table, head_table)
43
+ # outline = resolver.resolve(compound_glyph)
44
+ #
45
+ # @example With circular reference detection
46
+ # visited = Set.new
47
+ # outline = resolver.resolve(compound_glyph, visited)
48
+ class CompoundGlyphResolver
49
+ # Maximum recursion depth to prevent infinite loops
50
+ MAX_DEPTH = 32
51
+
52
+ # @return [Glyf] The glyf table
53
+ attr_reader :glyf
54
+
55
+ # @return [Loca] The loca table
56
+ attr_reader :loca
57
+
58
+ # @return [Head] The head table
59
+ attr_reader :head
60
+
61
+ # Initialize resolver with required tables
62
+ #
63
+ # @param glyf [Glyf] Glyf table
64
+ # @param loca [Loca] Loca table
65
+ # @param head [Head] Head table
66
+ def initialize(glyf, loca, head)
67
+ @glyf = glyf
68
+ @loca = loca
69
+ @head = head
70
+ end
71
+
72
+ # Resolve a compound glyph into a simple outline
73
+ #
74
+ # @param compound_glyph [CompoundGlyph] Compound glyph to resolve
75
+ # @param visited [Set<Integer>] Set of visited glyph IDs (for circular ref detection)
76
+ # @param depth [Integer] Current recursion depth
77
+ # @return [Outline] Resolved simple outline
78
+ # @raise [Error] If circular reference detected or max depth exceeded
79
+ def resolve(compound_glyph, visited = Set.new, depth = 0)
80
+ # Check recursion depth
81
+ if depth > MAX_DEPTH
82
+ raise Fontisan::Error,
83
+ "Maximum recursion depth (#{MAX_DEPTH}) exceeded resolving compound glyph #{compound_glyph.glyph_id}"
84
+ end
85
+
86
+ # Check for circular reference
87
+ if visited.include?(compound_glyph.glyph_id)
88
+ raise Fontisan::Error,
89
+ "Circular reference detected in compound glyph #{compound_glyph.glyph_id}"
90
+ end
91
+
92
+ # Mark as visited
93
+ visited = visited.dup.add(compound_glyph.glyph_id)
94
+
95
+ # Start with empty merged outline
96
+ merged_outline = Models::Outline.new(
97
+ glyph_id: compound_glyph.glyph_id,
98
+ commands: [],
99
+ bbox: {
100
+ x_min: compound_glyph.x_min,
101
+ y_min: compound_glyph.y_min,
102
+ x_max: compound_glyph.x_max,
103
+ y_max: compound_glyph.y_max,
104
+ },
105
+ )
106
+
107
+ # Resolve each component
108
+ compound_glyph.components.each do |component|
109
+ # Get component glyph
110
+ component_glyph = glyf.glyph_for(component.glyph_index, loca, head)
111
+
112
+ # Skip empty components
113
+ next if component_glyph.nil? || component_glyph.empty?
114
+
115
+ # Get component outline (recursively if compound)
116
+ component_outline = if component_glyph.compound?
117
+ # Recursively resolve compound component
118
+ resolve(component_glyph, visited, depth + 1)
119
+ else
120
+ # Convert simple glyph to outline
121
+ Models::Outline.from_truetype(component_glyph, component.glyph_index)
122
+ end
123
+
124
+ # Apply transformation matrix
125
+ matrix = component.transformation_matrix
126
+ transformed_outline = component_outline.transform(matrix)
127
+
128
+ # Merge into result
129
+ merged_outline.merge!(transformed_outline)
130
+ end
131
+
132
+ merged_outline
133
+ end
134
+ end
135
+ end
136
+ end