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,936 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "conversion_strategy"
4
+ require_relative "../outline_extractor"
5
+ require_relative "../models/outline"
6
+ require_relative "../tables/cff/charstring_builder"
7
+ require_relative "../tables/cff/index_builder"
8
+ require_relative "../tables/cff/dict_builder"
9
+ require_relative "../tables/glyf/glyph_builder"
10
+ require_relative "../tables/glyf/compound_glyph_resolver"
11
+ require_relative "../optimizers/pattern_analyzer"
12
+ require_relative "../optimizers/subroutine_optimizer"
13
+ require_relative "../optimizers/subroutine_builder"
14
+ require_relative "../optimizers/charstring_rewriter"
15
+ require_relative "../hints/truetype_hint_extractor"
16
+ require_relative "../hints/postscript_hint_extractor"
17
+ require_relative "../hints/hint_converter"
18
+ require_relative "../hints/truetype_hint_applier"
19
+ require_relative "../hints/postscript_hint_applier"
20
+ require_relative "../tables/cff2"
21
+ require_relative "../variation/data_extractor"
22
+ require_relative "../variation/instance_generator"
23
+ require_relative "../variation/converter"
24
+
25
+ module Fontisan
26
+ module Converters
27
+ # Strategy for converting between TTF and OTF outline formats
28
+ #
29
+ # [`OutlineConverter`](lib/fontisan/converters/outline_converter.rb)
30
+ # handles conversion between TrueType (glyf/loca) and CFF outline formats.
31
+ # This involves:
32
+ # - Extracting glyph outlines from source format
33
+ # - Converting to universal [`Outline`](lib/fontisan/models/outline.rb) model
34
+ # - Building target format tables using specialized builders
35
+ # - Updating related tables (maxp, head)
36
+ # - Preserving all other font tables
37
+ # - Optionally preserving rendering hints
38
+ #
39
+ # **Conversion Details:**
40
+ #
41
+ # TTF → OTF:
42
+ # - Extract glyphs from glyf/loca tables
43
+ # - Convert TrueType quadratic curves to universal format
44
+ # - Build complete CFF table with CharStrings INDEX
45
+ # - Remove glyf/loca tables
46
+ # - Update maxp to version 0.5 (CFF format)
47
+ # - Update head table (clear indexToLocFormat)
48
+ #
49
+ # OTF → TTF:
50
+ # - Extract CharStrings from CFF table
51
+ # - Convert CFF cubic curves to universal format
52
+ # - Build glyf and loca tables
53
+ # - Remove CFF table
54
+ # - Update maxp to version 1.0 (TrueType format)
55
+ # - Update head table (set indexToLocFormat)
56
+ #
57
+ # @example Converting TTF to OTF
58
+ # converter = Fontisan::Converters::OutlineConverter.new
59
+ # otf_font = converter.convert(ttf_font, target_format: :otf)
60
+ #
61
+ # @example Converting OTF to TTF
62
+ # converter = Fontisan::Converters::OutlineConverter.new
63
+ # ttf_font = converter.convert(otf_font, target_format: :ttf)
64
+ #
65
+ # @example Converting with hint preservation
66
+ # converter = Fontisan::Converters::OutlineConverter.new
67
+ # otf_font = converter.convert(ttf_font, target_format: :otf, preserve_hints: true)
68
+ class OutlineConverter
69
+ include ConversionStrategy
70
+
71
+ # Supported outline formats
72
+ SUPPORTED_FORMATS = %i[ttf otf cff2].freeze
73
+
74
+ # @return [TrueTypeFont, OpenTypeFont] Source font
75
+ attr_reader :font
76
+
77
+ # Initialize converter
78
+ def initialize
79
+ @font = nil
80
+ end
81
+
82
+ # Convert font between TTF and OTF formats
83
+ #
84
+ # @param font [TrueTypeFont, OpenTypeFont] Source font
85
+ # @param options [Hash] Conversion options
86
+ # @option options [Symbol] :target_format Target format (:ttf or :otf)
87
+ # @option options [Boolean] :optimize_cff Enable CFF subroutine optimization (default: false)
88
+ # @option options [Boolean] :preserve_hints Preserve rendering hints (default: false)
89
+ # @option options [Boolean] :preserve_variations Keep variation data during conversion (default: true)
90
+ # @option options [Boolean] :generate_instance Generate static instance instead of variable font (default: false)
91
+ # @option options [Hash] :instance_coordinates Axis coordinates for instance generation (default: {})
92
+ # @return [Hash<String, String>] Map of table tags to binary data
93
+ def convert(font, options = {})
94
+ @font = font
95
+ @options = options
96
+ @optimize_cff = options.fetch(:optimize_cff, false)
97
+ @preserve_hints = options.fetch(:preserve_hints, false)
98
+ @preserve_variations = options.fetch(:preserve_variations, true)
99
+ @generate_instance = options.fetch(:generate_instance, false)
100
+ @instance_coordinates = options.fetch(:instance_coordinates, {})
101
+ target_format = options[:target_format] ||
102
+ detect_target_format(font)
103
+ validate(font, target_format)
104
+
105
+ source_format = detect_format(font)
106
+
107
+ # Check if we should generate a static instance instead
108
+ if @generate_instance && variable_font?(font)
109
+ return generate_static_instance(font, source_format, target_format)
110
+ end
111
+
112
+ case [source_format, target_format]
113
+ when %i[ttf otf]
114
+ convert_ttf_to_otf(font, options)
115
+ when %i[otf ttf]
116
+ convert_otf_to_ttf(font)
117
+ when %i[cff2 ttf]
118
+ # CFF2 to TTF - treat CFF2 similar to OTF for now
119
+ convert_otf_to_ttf(font)
120
+ when %i[ttf cff2]
121
+ # TTF to CFF2 - for variable fonts
122
+ convert_ttf_to_otf(font, options)
123
+ else
124
+ raise Fontisan::Error,
125
+ "Unsupported conversion: #{source_format} → #{target_format}"
126
+ end
127
+ end
128
+
129
+ # Convert TrueType font to OpenType/CFF
130
+ #
131
+ # @param font [TrueTypeFont] Source font
132
+ # @param options [Hash] Conversion options (currently unused)
133
+ # @return [Hash<String, String>] Target tables
134
+ def convert_ttf_to_otf(font, options = {})
135
+ # Extract all glyphs from glyf table
136
+ outlines = extract_ttf_outlines(font)
137
+
138
+ # Extract hints if preservation is enabled
139
+ hints_per_glyph = @preserve_hints ? extract_ttf_hints(font) : {}
140
+
141
+ # Build CFF table from outlines and hints
142
+ cff_data = build_cff_table(outlines, font, hints_per_glyph)
143
+
144
+ # Copy all tables except glyf/loca
145
+ tables = copy_tables(font, %w[glyf loca])
146
+
147
+ # Add CFF table
148
+ tables["CFF "] = cff_data
149
+
150
+ # Update maxp table for CFF
151
+ tables["maxp"] = update_maxp_for_cff(font, outlines.length)
152
+
153
+ # Update head table for CFF
154
+ tables["head"] = update_head_for_cff(font)
155
+
156
+ tables
157
+ end
158
+
159
+ # Convert OpenType/CFF font to TrueType
160
+ #
161
+ # @param font [OpenTypeFont] Source font
162
+ # @return [Hash<String, String>] Target tables
163
+ def convert_otf_to_ttf(font)
164
+ # Extract all glyphs from CFF table
165
+ outlines = extract_cff_outlines(font)
166
+
167
+ # Extract hints if preservation is enabled
168
+ hints_per_glyph = @preserve_hints ? extract_cff_hints(font) : {}
169
+
170
+ # Build glyf and loca tables
171
+ glyf_data, loca_data, loca_format = build_glyf_loca_tables(outlines, hints_per_glyph)
172
+
173
+ # Copy all tables except CFF
174
+ tables = copy_tables(font, ["CFF ", "CFF2"])
175
+
176
+ # Add glyf and loca tables
177
+ tables["glyf"] = glyf_data
178
+ tables["loca"] = loca_data
179
+
180
+ # Update maxp table for TrueType
181
+ tables["maxp"] = update_maxp_for_truetype(font, outlines, loca_format)
182
+
183
+ # Update head table for TrueType
184
+ tables["head"] = update_head_for_truetype(font, loca_format)
185
+
186
+ tables
187
+ end
188
+
189
+ # Convert TrueType font to OpenType/CFF
190
+ #
191
+ # @return [Hash<String, String>] Target tables
192
+ def ttf_to_otf
193
+ raise Fontisan::Error, "No font loaded" unless @font
194
+
195
+ convert_ttf_to_otf(@font)
196
+ end
197
+
198
+ # Convert OpenType/CFF font to TrueType
199
+ #
200
+ # @return [Hash<String, String>] Target tables
201
+ def otf_to_ttf
202
+ raise Fontisan::Error, "No font loaded" unless @font
203
+
204
+ convert_otf_to_ttf(@font)
205
+ end
206
+
207
+ # Get supported conversions
208
+ #
209
+ # @return [Array<Array<Symbol>>] Supported conversion pairs
210
+ def supported_conversions
211
+ [
212
+ %i[ttf otf],
213
+ %i[otf ttf],
214
+ %i[cff2 ttf],
215
+ %i[ttf cff2],
216
+ ]
217
+ end
218
+
219
+ # Validate font for conversion
220
+ #
221
+ # @param font [TrueTypeFont, OpenTypeFont] Font to validate
222
+ # @param target_format [Symbol] Target format
223
+ # @return [Boolean] True if valid
224
+ # @raise [ArgumentError] If font is invalid
225
+ # @raise [Error] If conversion is not supported
226
+ def validate(font, target_format)
227
+ raise ArgumentError, "Font cannot be nil" if font.nil?
228
+
229
+ unless font.respond_to?(:tables)
230
+ raise ArgumentError, "Font must respond to :tables"
231
+ end
232
+
233
+ unless font.respond_to?(:table)
234
+ raise ArgumentError, "Font must respond to :table"
235
+ end
236
+
237
+ source_format = detect_format(font)
238
+ unless supports?(source_format, target_format)
239
+ raise Fontisan::Error,
240
+ "Conversion #{source_format} → #{target_format} not supported"
241
+ end
242
+
243
+ # Check that source font has required tables
244
+ validate_source_tables(font, source_format)
245
+
246
+ true
247
+ end
248
+
249
+ # Extract outlines from TrueType font
250
+ #
251
+ # @param font [TrueTypeFont] Source font
252
+ # @return [Array<Outline>] Array of outline objects
253
+ def extract_ttf_outlines(font)
254
+ # Get required tables
255
+ head = font.table("head")
256
+ maxp = font.table("maxp")
257
+ loca = font.table("loca")
258
+ glyf = font.table("glyf")
259
+
260
+ # Parse loca with context
261
+ loca.parse_with_context(head.index_to_loc_format, maxp.num_glyphs)
262
+
263
+ # Create resolver for compound glyphs
264
+ resolver = Tables::CompoundGlyphResolver.new(glyf, loca, head)
265
+
266
+ # Extract all glyphs
267
+ outlines = []
268
+ maxp.num_glyphs.times do |glyph_id|
269
+ glyph = glyf.glyph_for(glyph_id, loca, head)
270
+
271
+ outlines << if glyph.nil? || glyph.empty?
272
+ # Empty glyph - create empty outline
273
+ Models::Outline.new(
274
+ glyph_id: glyph_id,
275
+ commands: [],
276
+ bbox: { x_min: 0, y_min: 0, x_max: 0, y_max: 0 },
277
+ )
278
+ elsif glyph.simple?
279
+ # Convert simple glyph to outline
280
+ Models::Outline.from_truetype(glyph, glyph_id)
281
+ else
282
+ # Compound glyph - resolve to simple outline
283
+ resolver.resolve(glyph)
284
+ end
285
+ end
286
+
287
+ outlines
288
+ end
289
+
290
+ # Extract outlines from CFF font
291
+ #
292
+ # @param font [OpenTypeFont] Source font
293
+ # @return [Array<Outline>] Array of outline objects
294
+ def extract_cff_outlines(font)
295
+ # Get CFF table
296
+ cff = font.table("CFF ")
297
+ raise Fontisan::Error, "CFF table not found" unless cff
298
+
299
+ # Get number of glyphs
300
+ num_glyphs = cff.glyph_count
301
+
302
+ # Extract all glyphs
303
+ outlines = []
304
+ num_glyphs.times do |glyph_id|
305
+ charstring = cff.charstring_for_glyph(glyph_id)
306
+
307
+ outlines << if charstring.nil? || charstring.path.empty?
308
+ # Empty glyph
309
+ Models::Outline.new(
310
+ glyph_id: glyph_id,
311
+ commands: [],
312
+ bbox: { x_min: 0, y_min: 0, x_max: 0, y_max: 0 },
313
+ )
314
+ else
315
+ # Convert CharString to outline
316
+ Models::Outline.from_cff(charstring, glyph_id)
317
+ end
318
+ end
319
+
320
+ outlines
321
+ end
322
+
323
+ # Build CFF table from outlines
324
+ #
325
+ # @param outlines [Array<Outline>] Glyph outlines
326
+ # @param font [TrueTypeFont] Source font (for metadata)
327
+ # @return [String] CFF table binary data
328
+ def build_cff_table(outlines, font, hints_per_glyph)
329
+ # Build CharStrings INDEX from outlines
330
+ begin
331
+ charstrings = outlines.map do |outline|
332
+ builder = Tables::Cff::CharStringBuilder.new
333
+ if outline.empty?
334
+ builder.build_empty
335
+ else
336
+ builder.build(outline)
337
+ end
338
+ end
339
+ rescue StandardError => e
340
+ raise Fontisan::Error, "Failed to build CharStrings: #{e.message}"
341
+ end
342
+
343
+ # Apply subroutine optimization if enabled
344
+ local_subrs = []
345
+
346
+ if @optimize_cff
347
+ begin
348
+ charstrings, local_subrs = optimize_charstrings(charstrings)
349
+ rescue StandardError => e
350
+ # If optimization fails, fall back to unoptimized CharStrings
351
+ warn "CFF optimization failed: #{e.message}, using unoptimized CharStrings"
352
+ local_subrs = []
353
+ end
354
+ end
355
+
356
+ # Build font metadata
357
+ begin
358
+ font_name = extract_font_name(font)
359
+ rescue StandardError => e
360
+ raise Fontisan::Error, "Failed to extract font name: #{e.message}"
361
+ end
362
+
363
+ # Build all INDEXes
364
+ begin
365
+ header_size = 4
366
+ name_index_data = Tables::Cff::IndexBuilder.build([font_name])
367
+ string_index_data = Tables::Cff::IndexBuilder.build([]) # Empty strings
368
+ global_subr_index_data = Tables::Cff::IndexBuilder.build([]) # Empty global subrs
369
+ charstrings_index_data = Tables::Cff::IndexBuilder.build(charstrings)
370
+ local_subrs_index_data = Tables::Cff::IndexBuilder.build(local_subrs)
371
+ rescue StandardError => e
372
+ raise Fontisan::Error, "Failed to build CFF indexes: #{e.message}"
373
+ end
374
+
375
+ # Build Private DICT with Subrs offset if we have local subroutines
376
+ begin
377
+ private_dict_hash = {
378
+ default_width_x: 1000,
379
+ nominal_width_x: 0,
380
+ }
381
+
382
+ # If we have local subroutines, add Subrs offset
383
+ # Subrs offset is relative to Private DICT start
384
+ if local_subrs.any?
385
+ # Calculate size of Private DICT itself to know where Subrs starts
386
+ temp_private_dict_data = Tables::Cff::DictBuilder.build(private_dict_hash)
387
+ subrs_offset = temp_private_dict_data.bytesize
388
+
389
+ # Add Subrs offset to DICT
390
+ private_dict_hash[:subrs] = subrs_offset
391
+ end
392
+
393
+ # Build final Private DICT
394
+ private_dict_data = Tables::Cff::DictBuilder.build(private_dict_hash)
395
+ private_dict_size = private_dict_data.bytesize
396
+ rescue StandardError => e
397
+ raise Fontisan::Error, "Failed to build Private DICT: #{e.message}"
398
+ end
399
+
400
+ # Calculate offsets with iterative refinement
401
+ begin
402
+ # Initial pass
403
+ top_dict_index_start = header_size + name_index_data.bytesize
404
+ string_index_start = top_dict_index_start + 100 # Approximate
405
+ global_subr_index_start = string_index_start + string_index_data.bytesize
406
+ charstrings_offset = global_subr_index_start + global_subr_index_data.bytesize
407
+
408
+ # Build Top DICT
409
+ top_dict_hash = {
410
+ charset: 0,
411
+ encoding: 0,
412
+ charstrings: charstrings_offset,
413
+ }
414
+ top_dict_data = Tables::Cff::DictBuilder.build(top_dict_hash)
415
+ top_dict_index_data = Tables::Cff::IndexBuilder.build([top_dict_data])
416
+
417
+ # Recalculate with actual Top DICT size
418
+ string_index_start = top_dict_index_start + top_dict_index_data.bytesize
419
+ global_subr_index_start = string_index_start + string_index_data.bytesize
420
+ charstrings_offset = global_subr_index_start + global_subr_index_data.bytesize
421
+ private_dict_offset = charstrings_offset + charstrings_index_data.bytesize
422
+
423
+ # Update Top DICT with Private DICT info
424
+ top_dict_hash = {
425
+ charset: 0,
426
+ encoding: 0,
427
+ charstrings: charstrings_offset,
428
+ private: [private_dict_size, private_dict_offset],
429
+ }
430
+ top_dict_data = Tables::Cff::DictBuilder.build(top_dict_hash)
431
+ top_dict_index_data = Tables::Cff::IndexBuilder.build([top_dict_data])
432
+
433
+ # Final recalculation
434
+ string_index_start = top_dict_index_start + top_dict_index_data.bytesize
435
+ global_subr_index_start = string_index_start + string_index_data.bytesize
436
+ charstrings_offset = global_subr_index_start + global_subr_index_data.bytesize
437
+ private_dict_offset = charstrings_offset + charstrings_index_data.bytesize
438
+
439
+ # Final Top DICT
440
+ top_dict_hash = {
441
+ charset: 0,
442
+ encoding: 0,
443
+ charstrings: charstrings_offset,
444
+ private: [private_dict_size, private_dict_offset],
445
+ }
446
+ top_dict_data = Tables::Cff::DictBuilder.build(top_dict_hash)
447
+ top_dict_index_data = Tables::Cff::IndexBuilder.build([top_dict_data])
448
+ rescue StandardError => e
449
+ raise Fontisan::Error, "Failed to calculate CFF table offsets: #{e.message}"
450
+ end
451
+
452
+ # Build CFF Header
453
+ begin
454
+ header = [
455
+ 1, # major version
456
+ 0, # minor version
457
+ 4, # header size
458
+ 4, # offSize (will be in INDEX)
459
+ ].pack("C4")
460
+ rescue StandardError => e
461
+ raise Fontisan::Error, "Failed to build CFF header: #{e.message}"
462
+ end
463
+
464
+ # Assemble complete CFF table
465
+ begin
466
+ header +
467
+ name_index_data +
468
+ top_dict_index_data +
469
+ string_index_data +
470
+ global_subr_index_data +
471
+ charstrings_index_data +
472
+ private_dict_data +
473
+ local_subrs_index_data
474
+ rescue StandardError => e
475
+ raise Fontisan::Error, "Failed to assemble CFF table: #{e.message}"
476
+ end
477
+ end
478
+
479
+ # Build glyf and loca tables from outlines
480
+ #
481
+ # @param outlines [Array<Outline>] Glyph outlines
482
+ # @return [Array<String, String, Integer>] [glyf_data, loca_data, loca_format]
483
+ def build_glyf_loca_tables(outlines, hints_per_glyph)
484
+ glyf_data = "".b
485
+ offsets = []
486
+
487
+ # Build each glyph
488
+ outlines.each do |outline|
489
+ offsets << glyf_data.bytesize
490
+
491
+ if outline.empty?
492
+ # Empty glyph - no data
493
+ next
494
+ end
495
+
496
+ # Convert outline to TrueType contours
497
+ contours = outline.to_truetype_contours
498
+
499
+ # Build glyph data
500
+ builder = Tables::Glyf::GlyphBuilder.new(
501
+ contours: contours,
502
+ x_min: outline.bbox[:x_min],
503
+ y_min: outline.bbox[:y_min],
504
+ x_max: outline.bbox[:x_max],
505
+ y_max: outline.bbox[:y_max],
506
+ )
507
+
508
+ glyph_data = builder.build
509
+ glyf_data << glyph_data
510
+
511
+ # Add padding to 4-byte boundary
512
+ padding = (4 - (glyf_data.bytesize % 4)) % 4
513
+ glyf_data << ("\x00" * padding) if padding.positive?
514
+ end
515
+
516
+ # Add final offset
517
+ offsets << glyf_data.bytesize
518
+
519
+ # Build loca table
520
+ # Determine format based on max offset
521
+ max_offset = offsets.max
522
+ if max_offset <= 0x1FFFE
523
+ # Short format (offsets / 2)
524
+ loca_format = 0
525
+ loca_data = offsets.map { |off| off / 2 }.pack("n*")
526
+ else
527
+ # Long format
528
+ loca_format = 1
529
+ loca_data = offsets.pack("N*")
530
+ end
531
+
532
+ [glyf_data, loca_data, loca_format]
533
+ end
534
+
535
+ # Copy non-outline tables from source to target
536
+ #
537
+ # @param font [TrueTypeFont, OpenTypeFont] Source font
538
+ # @param exclude_tags [Array<String>] Tags to exclude
539
+ # @return [Hash<String, String>] Copied tables
540
+ def copy_tables(font, exclude_tags = [])
541
+ tables = {}
542
+
543
+ font.table_data.each do |tag, data|
544
+ next if exclude_tags.include?(tag)
545
+
546
+ tables[tag] = data if data
547
+ end
548
+
549
+ tables
550
+ end
551
+
552
+ # Update maxp table for CFF format
553
+ #
554
+ # @param font [TrueTypeFont] Source font
555
+ # @param num_glyphs [Integer] Number of glyphs
556
+ # @return [String] Updated maxp table binary data
557
+ def update_maxp_for_cff(_font, num_glyphs)
558
+ # CFF uses maxp version 0.5 (0x00005000)
559
+ # Structure: version (4 bytes) + numGlyphs (2 bytes)
560
+ [Tables::Maxp::VERSION_0_5, num_glyphs].pack("Nn")
561
+ end
562
+
563
+ # Update maxp table for TrueType format
564
+ #
565
+ # @param font [OpenTypeFont] Source font
566
+ # @param outlines [Array<Outline>] Glyph outlines
567
+ # @param loca_format [Integer] Loca format (0 or 1)
568
+ # @return [String] Updated maxp table binary data
569
+ def update_maxp_for_truetype(font, outlines, _loca_format)
570
+ # Get source maxp
571
+ font.table("maxp")
572
+ num_glyphs = outlines.length
573
+
574
+ # Calculate statistics from outlines
575
+ max_points = 0
576
+ max_contours = 0
577
+
578
+ outlines.each do |outline|
579
+ next if outline.empty?
580
+
581
+ contours = outline.to_truetype_contours
582
+ max_contours = [max_contours, contours.length].max
583
+
584
+ contours.each do |contour|
585
+ max_points = [max_points, contour.length].max
586
+ end
587
+ end
588
+
589
+ # Build maxp v1.0 table
590
+ # We'll use conservative defaults for instruction-related fields
591
+ [
592
+ Tables::Maxp::VERSION_1_0, # version
593
+ num_glyphs, # numGlyphs
594
+ max_points, # maxPoints
595
+ max_contours, # maxContours
596
+ 0, # maxCompositePoints
597
+ 0, # maxCompositeContours
598
+ 2, # maxZones
599
+ 0, # maxTwilightPoints
600
+ 0, # maxStorage
601
+ 0, # maxFunctionDefs
602
+ 0, # maxInstructionDefs
603
+ 0, # maxStackElements
604
+ 0, # maxSizeOfInstructions
605
+ 0, # maxComponentElements
606
+ 0, # maxComponentDepth
607
+ ].pack("Nnnnnnnnnnnnnnn")
608
+ end
609
+
610
+ # Update head table for CFF format
611
+ #
612
+ # @param font [TrueTypeFont] Source font
613
+ # @return [String] Updated head table binary data
614
+ def update_head_for_cff(font)
615
+ font.table("head")
616
+ head_data = font.table_data["head"].dup
617
+
618
+ # For CFF fonts, indexToLocFormat is not relevant
619
+ # but we'll set it to 0 for consistency
620
+ # indexToLocFormat is at offset 50 (2 bytes)
621
+ head_data[50, 2] = [0].pack("n")
622
+
623
+ head_data
624
+ end
625
+
626
+ # Update head table for TrueType format
627
+ #
628
+ # @param font [OpenTypeFont] Source font
629
+ # @param loca_format [Integer] Loca format (0=short, 1=long)
630
+ # @return [String] Updated head table binary data
631
+ def update_head_for_truetype(font, loca_format)
632
+ font.table("head")
633
+ head_data = font.table_data["head"].dup
634
+
635
+ # Set indexToLocFormat at offset 50 (2 bytes)
636
+ head_data[50, 2] = [loca_format].pack("n")
637
+
638
+ head_data
639
+ end
640
+
641
+ # Extract font name from name table
642
+ #
643
+ # @param font [TrueTypeFont, OpenTypeFont] Font
644
+ # @return [String] Font name
645
+ def extract_font_name(font)
646
+ name_table = font.table("name")
647
+ if name_table
648
+ font_name = name_table.english_name(Tables::Name::FAMILY)
649
+ return font_name.dup.force_encoding("ASCII-8BIT") if font_name
650
+ end
651
+
652
+ "UnnamedFont"
653
+ end
654
+
655
+ # Optimize CharStrings using subroutine extraction
656
+ #
657
+ # @param charstrings [Array<String>] Original CharString bytes
658
+ # @return [Array<Array<String>, Array<String>>] [optimized_charstrings, local_subrs]
659
+ def optimize_charstrings(charstrings)
660
+ # Convert to hash format expected by PatternAnalyzer
661
+ charstrings_hash = {}
662
+ charstrings.each_with_index do |cs, index|
663
+ charstrings_hash[index] = cs
664
+ end
665
+
666
+ # Analyze patterns
667
+ analyzer = Optimizers::PatternAnalyzer.new(
668
+ min_length: 10,
669
+ stack_aware: true
670
+ )
671
+ patterns = analyzer.analyze(charstrings_hash)
672
+
673
+ # Return original if no patterns found
674
+ return [charstrings, []] if patterns.empty?
675
+
676
+ # Optimize selection
677
+ optimizer = Optimizers::SubroutineOptimizer.new(patterns, max_subrs: 65_535)
678
+ selected_patterns = optimizer.optimize_selection
679
+
680
+ # Optimize ordering
681
+ selected_patterns = optimizer.optimize_ordering(selected_patterns)
682
+
683
+ # Return original if no patterns selected
684
+ return [charstrings, []] if selected_patterns.empty?
685
+
686
+ # Build subroutines
687
+ builder = Optimizers::SubroutineBuilder.new(selected_patterns, type: :local)
688
+ local_subrs = builder.build
689
+
690
+ # Build subroutine map
691
+ subroutine_map = {}
692
+ selected_patterns.each_with_index do |pattern, index|
693
+ subroutine_map[pattern.bytes] = index
694
+ end
695
+
696
+ # Rewrite CharStrings
697
+ rewriter = Optimizers::CharstringRewriter.new(subroutine_map, builder)
698
+ optimized_charstrings = charstrings.map.with_index do |charstring, glyph_id|
699
+ # Find patterns for this glyph
700
+ glyph_patterns = selected_patterns.select { |p| p.glyphs.include?(glyph_id) }
701
+
702
+ if glyph_patterns.empty?
703
+ charstring
704
+ else
705
+ rewriter.rewrite(charstring, glyph_patterns)
706
+ end
707
+ end
708
+
709
+ [optimized_charstrings, local_subrs]
710
+ rescue StandardError => e
711
+ # If optimization fails for any reason, return original CharStrings
712
+ warn "Optimization warning: #{e.message}"
713
+ [charstrings, []]
714
+ end
715
+
716
+ # Generate static instance from variable font
717
+ #
718
+ # @param font [TrueTypeFont, OpenTypeFont] Variable font
719
+ # @param source_format [Symbol] Source format
720
+ # @param target_format [Symbol] Target format
721
+ # @return [Hash<String, String>] Static font tables
722
+ def generate_static_instance(font, source_format, target_format)
723
+ # Generate instance at specified coordinates
724
+ fvar = font.table("fvar")
725
+ axes = fvar ? fvar.axes : []
726
+
727
+ generator = Variation::InstanceGenerator.new(font, @instance_coordinates)
728
+ instance_tables = generator.generate
729
+
730
+ # If target format differs from source, convert outlines
731
+ if source_format != target_format
732
+ # Create temporary font with instance tables
733
+ temp_font = font.class.new
734
+ temp_font.instance_variable_set(:@table_data, instance_tables)
735
+
736
+ # Convert outline format
737
+ case [source_format, target_format]
738
+ when %i[ttf otf]
739
+ convert_ttf_to_otf(temp_font, @options)
740
+ when %i[otf ttf], %i[cff2 ttf]
741
+ convert_otf_to_ttf(temp_font)
742
+ else
743
+ instance_tables
744
+ end
745
+ else
746
+ instance_tables
747
+ end
748
+ end
749
+
750
+ # Convert variation data during outline conversion
751
+ #
752
+ # @param font [TrueTypeFont, OpenTypeFont] Source font
753
+ # @param target_format [Symbol] Target format
754
+ # @return [Hash, nil] Converted variation data or nil
755
+ def convert_variations(font, target_format)
756
+ return nil unless @preserve_variations
757
+ return nil unless variable_font?(font)
758
+
759
+ fvar = font.table("fvar")
760
+ return nil unless fvar
761
+
762
+ axes = fvar.axes
763
+ converter = Variation::Converter.new(font, axes)
764
+
765
+ # Get glyph count
766
+ maxp = font.table("maxp")
767
+ return nil unless maxp
768
+
769
+ glyph_count = maxp.num_glyphs
770
+
771
+ # Convert variation data for each glyph
772
+ variation_data = {}
773
+ glyph_count.times do |glyph_id|
774
+ source_format = detect_format(font)
775
+
776
+ data = case [source_format, target_format]
777
+ when %i[ttf otf], %i[ttf cff2]
778
+ # gvar → blend
779
+ converter.gvar_to_blend(glyph_id)
780
+ when %i[otf ttf], %i[cff2 ttf]
781
+ # blend → gvar
782
+ converter.blend_to_gvar(glyph_id)
783
+ else
784
+ nil
785
+ end
786
+
787
+ variation_data[glyph_id] = data if data
788
+ end
789
+
790
+ variation_data.empty? ? nil : variation_data
791
+ end
792
+
793
+ # Detect font format from tables
794
+ #
795
+ # @param font [TrueTypeFont, OpenTypeFont] Font to detect
796
+ # @return [Symbol] Format (:ttf, :otf, or :cff2)
797
+ # @raise [Error] If format cannot be detected
798
+ def detect_format(font)
799
+ # Check for CFF2 table first (OpenType variable fonts with CFF2 outlines)
800
+ if font.has_table?("CFF2")
801
+ :cff2
802
+ # Check for CFF table (OpenType/CFF)
803
+ elsif font.has_table?("CFF ")
804
+ :otf
805
+ # Check for glyf table (TrueType)
806
+ elsif font.has_table?("glyf")
807
+ :ttf
808
+ else
809
+ raise Fontisan::Error,
810
+ "Cannot detect font format: missing outline tables (CFF2, CFF, or glyf)"
811
+ end
812
+ end
813
+
814
+ # Detect target format as opposite of source
815
+ #
816
+ # @param font [TrueTypeFont, OpenTypeFont] Source font
817
+ # @return [Symbol] Target format
818
+ def detect_target_format(font)
819
+ source = detect_format(font)
820
+ case source
821
+ when :ttf
822
+ :otf
823
+ when :cff2
824
+ :ttf
825
+ else
826
+ :ttf
827
+ end
828
+ end
829
+
830
+ # Validate source font has required tables
831
+ #
832
+ # @param font [TrueTypeFont, OpenTypeFont] Font to validate
833
+ # @param format [Symbol] Font format
834
+ # @raise [Error] If required tables are missing
835
+ def validate_source_tables(font, format)
836
+ case format
837
+ when :ttf
838
+ unless font.has_table?("glyf") && font.has_table?("loca") &&
839
+ font.table("glyf") && font.table("loca")
840
+ raise Fontisan::MissingTableError,
841
+ "TrueType font missing required glyf or loca table"
842
+ end
843
+ when :cff2
844
+ unless font.has_table?("CFF2") && font.table("CFF2")
845
+ raise Fontisan::MissingTableError,
846
+ "CFF2 font missing required CFF2 table"
847
+ end
848
+ when :otf
849
+ unless (font.has_table?("CFF ") && font.table("CFF ")) ||
850
+ (font.has_table?("CFF2") && font.table("CFF2"))
851
+ raise Fontisan::MissingTableError,
852
+ "OpenType font missing required CFF or CFF2 table"
853
+ end
854
+ end
855
+
856
+ # Common required tables
857
+ %w[head hhea maxp].each do |tag|
858
+ unless font.table(tag)
859
+ raise Fontisan::MissingTableError,
860
+ "Font missing required #{tag} table"
861
+ end
862
+ end
863
+ end
864
+
865
+ # Extract hints from TrueType font
866
+ #
867
+ # @param font [TrueTypeFont] Source font
868
+ # @return [Hash<Integer, Array<Hint>>] Map of glyph ID to hints
869
+ def extract_ttf_hints(font)
870
+ hints_per_glyph = {}
871
+ extractor = Hints::TrueTypeHintExtractor.new
872
+
873
+ # Get required tables
874
+ head = font.table("head")
875
+ maxp = font.table("maxp")
876
+ loca = font.table("loca")
877
+ glyf = font.table("glyf")
878
+
879
+ # Parse loca with context
880
+ loca.parse_with_context(head.index_to_loc_format, maxp.num_glyphs)
881
+
882
+ # Extract hints from each glyph
883
+ maxp.num_glyphs.times do |glyph_id|
884
+ glyph = glyf.glyph_for(glyph_id, loca, head)
885
+ next if glyph.nil? || glyph.empty?
886
+
887
+ hints = extractor.extract(glyph)
888
+ hints_per_glyph[glyph_id] = hints if hints.any?
889
+ end
890
+
891
+ hints_per_glyph
892
+ rescue StandardError => e
893
+ warn "Failed to extract TrueType hints: #{e.message}"
894
+ {}
895
+ end
896
+
897
+ # Extract hints from CFF font
898
+ #
899
+ # @param font [OpenTypeFont] Source font
900
+ # @return [Hash<Integer, Array<Hint>>] Map of glyph ID to hints
901
+ def extract_cff_hints(font)
902
+ hints_per_glyph = {}
903
+ extractor = Hints::PostScriptHintExtractor.new
904
+
905
+ # Get CFF table
906
+ cff = font.table("CFF ")
907
+ return {} unless cff
908
+
909
+ # Get number of glyphs
910
+ num_glyphs = cff.glyph_count
911
+
912
+ # Extract hints from each CharString
913
+ num_glyphs.times do |glyph_id|
914
+ charstring = cff.charstring_for_glyph(glyph_id)
915
+ next if charstring.nil?
916
+
917
+ hints = extractor.extract(charstring)
918
+ hints_per_glyph[glyph_id] = hints if hints.any?
919
+ end
920
+
921
+ hints_per_glyph
922
+ rescue StandardError => e
923
+ warn "Failed to extract CFF hints: #{e.message}"
924
+ {}
925
+ end
926
+
927
+ # Check if font is a variable font
928
+ #
929
+ # @param font [TrueTypeFont, OpenTypeFont] Font to check
930
+ # @return [Boolean] True if font has variation tables
931
+ def variable_font?(font)
932
+ font.has_table?("fvar")
933
+ end
934
+ end
935
+ end
936
+ end