fontisan 0.1.0 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (214) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop_todo.yml +672 -69
  3. data/Gemfile +1 -0
  4. data/LICENSE +5 -1
  5. data/README.adoc +1477 -297
  6. data/Rakefile +63 -41
  7. data/benchmark/variation_quick_bench.rb +47 -0
  8. data/docs/EXTRACT_TTC_MIGRATION.md +549 -0
  9. data/fontisan.gemspec +4 -1
  10. data/lib/fontisan/binary/base_record.rb +22 -1
  11. data/lib/fontisan/cli.rb +364 -4
  12. data/lib/fontisan/collection/builder.rb +341 -0
  13. data/lib/fontisan/collection/offset_calculator.rb +227 -0
  14. data/lib/fontisan/collection/table_analyzer.rb +204 -0
  15. data/lib/fontisan/collection/table_deduplicator.rb +317 -0
  16. data/lib/fontisan/collection/writer.rb +306 -0
  17. data/lib/fontisan/commands/base_command.rb +24 -1
  18. data/lib/fontisan/commands/convert_command.rb +218 -0
  19. data/lib/fontisan/commands/export_command.rb +161 -0
  20. data/lib/fontisan/commands/info_command.rb +40 -6
  21. data/lib/fontisan/commands/instance_command.rb +286 -0
  22. data/lib/fontisan/commands/ls_command.rb +113 -0
  23. data/lib/fontisan/commands/pack_command.rb +241 -0
  24. data/lib/fontisan/commands/subset_command.rb +245 -0
  25. data/lib/fontisan/commands/unpack_command.rb +338 -0
  26. data/lib/fontisan/commands/validate_command.rb +203 -0
  27. data/lib/fontisan/commands/variable_command.rb +30 -1
  28. data/lib/fontisan/config/collection_settings.yml +56 -0
  29. data/lib/fontisan/config/conversion_matrix.yml +212 -0
  30. data/lib/fontisan/config/export_settings.yml +66 -0
  31. data/lib/fontisan/config/subset_profiles.yml +100 -0
  32. data/lib/fontisan/config/svg_settings.yml +60 -0
  33. data/lib/fontisan/config/validation_rules.yml +149 -0
  34. data/lib/fontisan/config/variable_settings.yml +99 -0
  35. data/lib/fontisan/config/woff2_settings.yml +77 -0
  36. data/lib/fontisan/constants.rb +79 -0
  37. data/lib/fontisan/converters/conversion_strategy.rb +96 -0
  38. data/lib/fontisan/converters/format_converter.rb +408 -0
  39. data/lib/fontisan/converters/outline_converter.rb +998 -0
  40. data/lib/fontisan/converters/svg_generator.rb +244 -0
  41. data/lib/fontisan/converters/table_copier.rb +117 -0
  42. data/lib/fontisan/converters/woff2_encoder.rb +416 -0
  43. data/lib/fontisan/converters/woff_writer.rb +391 -0
  44. data/lib/fontisan/error.rb +203 -0
  45. data/lib/fontisan/export/exporter.rb +262 -0
  46. data/lib/fontisan/export/table_serializer.rb +255 -0
  47. data/lib/fontisan/export/transformers/font_to_ttx.rb +172 -0
  48. data/lib/fontisan/export/transformers/head_transformer.rb +96 -0
  49. data/lib/fontisan/export/transformers/hhea_transformer.rb +59 -0
  50. data/lib/fontisan/export/transformers/maxp_transformer.rb +63 -0
  51. data/lib/fontisan/export/transformers/name_transformer.rb +63 -0
  52. data/lib/fontisan/export/transformers/os2_transformer.rb +121 -0
  53. data/lib/fontisan/export/transformers/post_transformer.rb +51 -0
  54. data/lib/fontisan/export/ttx_generator.rb +527 -0
  55. data/lib/fontisan/export/ttx_parser.rb +300 -0
  56. data/lib/fontisan/font_loader.rb +122 -15
  57. data/lib/fontisan/font_writer.rb +302 -0
  58. data/lib/fontisan/formatters/text_formatter.rb +102 -0
  59. data/lib/fontisan/glyph_accessor.rb +503 -0
  60. data/lib/fontisan/hints/hint_converter.rb +310 -0
  61. data/lib/fontisan/hints/postscript_hint_applier.rb +266 -0
  62. data/lib/fontisan/hints/postscript_hint_extractor.rb +354 -0
  63. data/lib/fontisan/hints/truetype_hint_applier.rb +117 -0
  64. data/lib/fontisan/hints/truetype_hint_extractor.rb +289 -0
  65. data/lib/fontisan/loading_modes.rb +115 -0
  66. data/lib/fontisan/metrics_calculator.rb +277 -0
  67. data/lib/fontisan/models/collection_font_summary.rb +52 -0
  68. data/lib/fontisan/models/collection_info.rb +76 -0
  69. data/lib/fontisan/models/collection_list_info.rb +37 -0
  70. data/lib/fontisan/models/font_export.rb +158 -0
  71. data/lib/fontisan/models/font_summary.rb +48 -0
  72. data/lib/fontisan/models/glyph_outline.rb +343 -0
  73. data/lib/fontisan/models/hint.rb +405 -0
  74. data/lib/fontisan/models/outline.rb +664 -0
  75. data/lib/fontisan/models/table_sharing_info.rb +40 -0
  76. data/lib/fontisan/models/ttx/glyph_order.rb +31 -0
  77. data/lib/fontisan/models/ttx/tables/binary_table.rb +67 -0
  78. data/lib/fontisan/models/ttx/tables/head_table.rb +74 -0
  79. data/lib/fontisan/models/ttx/tables/hhea_table.rb +74 -0
  80. data/lib/fontisan/models/ttx/tables/maxp_table.rb +55 -0
  81. data/lib/fontisan/models/ttx/tables/name_table.rb +45 -0
  82. data/lib/fontisan/models/ttx/tables/os2_table.rb +157 -0
  83. data/lib/fontisan/models/ttx/tables/post_table.rb +50 -0
  84. data/lib/fontisan/models/ttx/ttfont.rb +49 -0
  85. data/lib/fontisan/models/validation_report.rb +203 -0
  86. data/lib/fontisan/open_type_collection.rb +156 -2
  87. data/lib/fontisan/open_type_font.rb +321 -19
  88. data/lib/fontisan/open_type_font_extensions.rb +54 -0
  89. data/lib/fontisan/optimizers/charstring_rewriter.rb +161 -0
  90. data/lib/fontisan/optimizers/pattern_analyzer.rb +308 -0
  91. data/lib/fontisan/optimizers/stack_tracker.rb +246 -0
  92. data/lib/fontisan/optimizers/subroutine_builder.rb +134 -0
  93. data/lib/fontisan/optimizers/subroutine_generator.rb +207 -0
  94. data/lib/fontisan/optimizers/subroutine_optimizer.rb +107 -0
  95. data/lib/fontisan/outline_extractor.rb +423 -0
  96. data/lib/fontisan/pipeline/format_detector.rb +249 -0
  97. data/lib/fontisan/pipeline/output_writer.rb +154 -0
  98. data/lib/fontisan/pipeline/strategies/base_strategy.rb +75 -0
  99. data/lib/fontisan/pipeline/strategies/instance_strategy.rb +93 -0
  100. data/lib/fontisan/pipeline/strategies/named_strategy.rb +118 -0
  101. data/lib/fontisan/pipeline/strategies/preserve_strategy.rb +56 -0
  102. data/lib/fontisan/pipeline/transformation_pipeline.rb +411 -0
  103. data/lib/fontisan/pipeline/variation_resolver.rb +165 -0
  104. data/lib/fontisan/subset/builder.rb +268 -0
  105. data/lib/fontisan/subset/glyph_mapping.rb +215 -0
  106. data/lib/fontisan/subset/options.rb +142 -0
  107. data/lib/fontisan/subset/profile.rb +152 -0
  108. data/lib/fontisan/subset/table_subsetter.rb +461 -0
  109. data/lib/fontisan/svg/font_face_generator.rb +278 -0
  110. data/lib/fontisan/svg/font_generator.rb +264 -0
  111. data/lib/fontisan/svg/glyph_generator.rb +168 -0
  112. data/lib/fontisan/svg/view_box_calculator.rb +137 -0
  113. data/lib/fontisan/tables/cff/cff_glyph.rb +176 -0
  114. data/lib/fontisan/tables/cff/charset.rb +282 -0
  115. data/lib/fontisan/tables/cff/charstring.rb +934 -0
  116. data/lib/fontisan/tables/cff/charstring_builder.rb +356 -0
  117. data/lib/fontisan/tables/cff/charstring_parser.rb +237 -0
  118. data/lib/fontisan/tables/cff/charstring_rebuilder.rb +172 -0
  119. data/lib/fontisan/tables/cff/charstrings_index.rb +162 -0
  120. data/lib/fontisan/tables/cff/dict.rb +351 -0
  121. data/lib/fontisan/tables/cff/dict_builder.rb +257 -0
  122. data/lib/fontisan/tables/cff/encoding.rb +274 -0
  123. data/lib/fontisan/tables/cff/header.rb +102 -0
  124. data/lib/fontisan/tables/cff/hint_operation_injector.rb +207 -0
  125. data/lib/fontisan/tables/cff/index.rb +237 -0
  126. data/lib/fontisan/tables/cff/index_builder.rb +170 -0
  127. data/lib/fontisan/tables/cff/offset_recalculator.rb +70 -0
  128. data/lib/fontisan/tables/cff/private_dict.rb +284 -0
  129. data/lib/fontisan/tables/cff/private_dict_writer.rb +125 -0
  130. data/lib/fontisan/tables/cff/table_builder.rb +221 -0
  131. data/lib/fontisan/tables/cff/top_dict.rb +236 -0
  132. data/lib/fontisan/tables/cff.rb +489 -0
  133. data/lib/fontisan/tables/cff2/blend_operator.rb +240 -0
  134. data/lib/fontisan/tables/cff2/charstring_parser.rb +591 -0
  135. data/lib/fontisan/tables/cff2/operand_stack.rb +232 -0
  136. data/lib/fontisan/tables/cff2/private_dict_blend_handler.rb +246 -0
  137. data/lib/fontisan/tables/cff2/region_matcher.rb +200 -0
  138. data/lib/fontisan/tables/cff2/table_builder.rb +574 -0
  139. data/lib/fontisan/tables/cff2/table_reader.rb +419 -0
  140. data/lib/fontisan/tables/cff2/variation_data_extractor.rb +212 -0
  141. data/lib/fontisan/tables/cff2.rb +346 -0
  142. data/lib/fontisan/tables/cvar.rb +203 -0
  143. data/lib/fontisan/tables/fvar.rb +2 -2
  144. data/lib/fontisan/tables/glyf/compound_glyph.rb +483 -0
  145. data/lib/fontisan/tables/glyf/compound_glyph_resolver.rb +136 -0
  146. data/lib/fontisan/tables/glyf/curve_converter.rb +343 -0
  147. data/lib/fontisan/tables/glyf/glyph_builder.rb +450 -0
  148. data/lib/fontisan/tables/glyf/simple_glyph.rb +382 -0
  149. data/lib/fontisan/tables/glyf.rb +235 -0
  150. data/lib/fontisan/tables/gvar.rb +231 -0
  151. data/lib/fontisan/tables/hhea.rb +124 -0
  152. data/lib/fontisan/tables/hmtx.rb +287 -0
  153. data/lib/fontisan/tables/hvar.rb +191 -0
  154. data/lib/fontisan/tables/loca.rb +322 -0
  155. data/lib/fontisan/tables/maxp.rb +192 -0
  156. data/lib/fontisan/tables/mvar.rb +185 -0
  157. data/lib/fontisan/tables/name.rb +99 -30
  158. data/lib/fontisan/tables/variation_common.rb +346 -0
  159. data/lib/fontisan/tables/vvar.rb +234 -0
  160. data/lib/fontisan/true_type_collection.rb +156 -2
  161. data/lib/fontisan/true_type_font.rb +321 -20
  162. data/lib/fontisan/true_type_font_extensions.rb +54 -0
  163. data/lib/fontisan/utilities/brotli_wrapper.rb +159 -0
  164. data/lib/fontisan/utilities/checksum_calculator.rb +60 -0
  165. data/lib/fontisan/utils/thread_pool.rb +134 -0
  166. data/lib/fontisan/validation/checksum_validator.rb +170 -0
  167. data/lib/fontisan/validation/consistency_validator.rb +197 -0
  168. data/lib/fontisan/validation/structure_validator.rb +198 -0
  169. data/lib/fontisan/validation/table_validator.rb +158 -0
  170. data/lib/fontisan/validation/validator.rb +152 -0
  171. data/lib/fontisan/validation/variable_font_validator.rb +218 -0
  172. data/lib/fontisan/variable/axis_normalizer.rb +215 -0
  173. data/lib/fontisan/variable/delta_applicator.rb +313 -0
  174. data/lib/fontisan/variable/glyph_delta_processor.rb +218 -0
  175. data/lib/fontisan/variable/instancer.rb +344 -0
  176. data/lib/fontisan/variable/metric_delta_processor.rb +282 -0
  177. data/lib/fontisan/variable/region_matcher.rb +208 -0
  178. data/lib/fontisan/variable/static_font_builder.rb +213 -0
  179. data/lib/fontisan/variable/table_updater.rb +219 -0
  180. data/lib/fontisan/variation/blend_applier.rb +199 -0
  181. data/lib/fontisan/variation/cache.rb +298 -0
  182. data/lib/fontisan/variation/cache_key_builder.rb +162 -0
  183. data/lib/fontisan/variation/converter.rb +375 -0
  184. data/lib/fontisan/variation/data_extractor.rb +86 -0
  185. data/lib/fontisan/variation/delta_applier.rb +266 -0
  186. data/lib/fontisan/variation/delta_parser.rb +228 -0
  187. data/lib/fontisan/variation/inspector.rb +275 -0
  188. data/lib/fontisan/variation/instance_generator.rb +273 -0
  189. data/lib/fontisan/variation/instance_writer.rb +341 -0
  190. data/lib/fontisan/variation/interpolator.rb +231 -0
  191. data/lib/fontisan/variation/metrics_adjuster.rb +318 -0
  192. data/lib/fontisan/variation/optimizer.rb +418 -0
  193. data/lib/fontisan/variation/parallel_generator.rb +150 -0
  194. data/lib/fontisan/variation/region_matcher.rb +221 -0
  195. data/lib/fontisan/variation/subsetter.rb +463 -0
  196. data/lib/fontisan/variation/table_accessor.rb +105 -0
  197. data/lib/fontisan/variation/tuple_variation_header.rb +51 -0
  198. data/lib/fontisan/variation/validator.rb +345 -0
  199. data/lib/fontisan/variation/variable_svg_generator.rb +268 -0
  200. data/lib/fontisan/variation/variation_context.rb +211 -0
  201. data/lib/fontisan/variation/variation_preserver.rb +288 -0
  202. data/lib/fontisan/version.rb +1 -1
  203. data/lib/fontisan/version.rb.orig +9 -0
  204. data/lib/fontisan/woff2/directory.rb +257 -0
  205. data/lib/fontisan/woff2/glyf_transformer.rb +666 -0
  206. data/lib/fontisan/woff2/header.rb +101 -0
  207. data/lib/fontisan/woff2/hmtx_transformer.rb +164 -0
  208. data/lib/fontisan/woff2/table_transformer.rb +163 -0
  209. data/lib/fontisan/woff2_font.rb +717 -0
  210. data/lib/fontisan/woff_font.rb +488 -0
  211. data/lib/fontisan.rb +132 -0
  212. data/scripts/compare_stack_aware.rb +187 -0
  213. data/scripts/measure_optimization.rb +141 -0
  214. metadata +234 -4
@@ -0,0 +1,998 @@
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
+ # Convert and apply hints if preservation is enabled
157
+ if @preserve_hints && hints_per_glyph.any?
158
+ # Extract font-level hints separately
159
+ hint_set = extract_ttf_hint_set(font)
160
+
161
+ unless hint_set.empty?
162
+ # Convert TrueType hints to PostScript format
163
+ converter = Hints::HintConverter.new
164
+ ps_hint_set = converter.convert_hint_set(hint_set, :postscript)
165
+
166
+ # Apply PostScript hints (validation mode - CFF modification pending)
167
+ applier = Hints::PostScriptHintApplier.new
168
+ tables = applier.apply(ps_hint_set, tables)
169
+ end
170
+ end
171
+
172
+ tables
173
+ end
174
+
175
+ # Convert OpenType/CFF font to TrueType
176
+ #
177
+ # @param font [OpenTypeFont] Source font
178
+ # @return [Hash<String, String>] Target tables
179
+ def convert_otf_to_ttf(font)
180
+ # Extract all glyphs from CFF table
181
+ outlines = extract_cff_outlines(font)
182
+
183
+ # Extract hints if preservation is enabled
184
+ hints_per_glyph = @preserve_hints ? extract_cff_hints(font) : {}
185
+
186
+ # Build glyf and loca tables
187
+ glyf_data, loca_data, loca_format = build_glyf_loca_tables(outlines, hints_per_glyph)
188
+
189
+ # Copy all tables except CFF
190
+ tables = copy_tables(font, ["CFF ", "CFF2"])
191
+
192
+ # Add glyf and loca tables
193
+ tables["glyf"] = glyf_data
194
+ tables["loca"] = loca_data
195
+
196
+ # Update maxp table for TrueType
197
+ tables["maxp"] = update_maxp_for_truetype(font, outlines, loca_format)
198
+
199
+ # Update head table for TrueType
200
+ tables["head"] = update_head_for_truetype(font, loca_format)
201
+
202
+ # Convert and apply hints if preservation is enabled
203
+ if @preserve_hints && hints_per_glyph.any?
204
+ # Extract font-level hints separately
205
+ hint_set = extract_cff_hint_set(font)
206
+
207
+ unless hint_set.empty?
208
+ # Convert PostScript hints to TrueType format
209
+ converter = Hints::HintConverter.new
210
+ tt_hint_set = converter.convert_hint_set(hint_set, :truetype)
211
+
212
+ # Apply TrueType hints (writes fpgm/prep/cvt tables)
213
+ applier = Hints::TrueTypeHintApplier.new
214
+ tables = applier.apply(tt_hint_set, tables)
215
+ end
216
+ end
217
+
218
+ tables
219
+ end
220
+
221
+ # Convert TrueType font to OpenType/CFF
222
+ #
223
+ # @return [Hash<String, String>] Target tables
224
+ def ttf_to_otf
225
+ raise Fontisan::Error, "No font loaded" unless @font
226
+
227
+ convert_ttf_to_otf(@font)
228
+ end
229
+
230
+ # Convert OpenType/CFF font to TrueType
231
+ #
232
+ # @return [Hash<String, String>] Target tables
233
+ def otf_to_ttf
234
+ raise Fontisan::Error, "No font loaded" unless @font
235
+
236
+ convert_otf_to_ttf(@font)
237
+ end
238
+
239
+ # Get supported conversions
240
+ #
241
+ # @return [Array<Array<Symbol>>] Supported conversion pairs
242
+ def supported_conversions
243
+ [
244
+ %i[ttf otf],
245
+ %i[otf ttf],
246
+ %i[cff2 ttf],
247
+ %i[ttf cff2],
248
+ ]
249
+ end
250
+
251
+ # Validate font for conversion
252
+ #
253
+ # @param font [TrueTypeFont, OpenTypeFont] Font to validate
254
+ # @param target_format [Symbol] Target format
255
+ # @return [Boolean] True if valid
256
+ # @raise [ArgumentError] If font is invalid
257
+ # @raise [Error] If conversion is not supported
258
+ def validate(font, target_format)
259
+ raise ArgumentError, "Font cannot be nil" if font.nil?
260
+
261
+ unless font.respond_to?(:tables)
262
+ raise ArgumentError, "Font must respond to :tables"
263
+ end
264
+
265
+ unless font.respond_to?(:table)
266
+ raise ArgumentError, "Font must respond to :table"
267
+ end
268
+
269
+ source_format = detect_format(font)
270
+ unless supports?(source_format, target_format)
271
+ raise Fontisan::Error,
272
+ "Conversion #{source_format} → #{target_format} not supported"
273
+ end
274
+
275
+ # Check that source font has required tables
276
+ validate_source_tables(font, source_format)
277
+
278
+ true
279
+ end
280
+
281
+ # Extract outlines from TrueType font
282
+ #
283
+ # @param font [TrueTypeFont] Source font
284
+ # @return [Array<Outline>] Array of outline objects
285
+ def extract_ttf_outlines(font)
286
+ # Get required tables
287
+ head = font.table("head")
288
+ maxp = font.table("maxp")
289
+ loca = font.table("loca")
290
+ glyf = font.table("glyf")
291
+
292
+ # Parse loca with context
293
+ loca.parse_with_context(head.index_to_loc_format, maxp.num_glyphs)
294
+
295
+ # Create resolver for compound glyphs
296
+ resolver = Tables::CompoundGlyphResolver.new(glyf, loca, head)
297
+
298
+ # Extract all glyphs
299
+ outlines = []
300
+ maxp.num_glyphs.times do |glyph_id|
301
+ glyph = glyf.glyph_for(glyph_id, loca, head)
302
+
303
+ outlines << if glyph.nil? || glyph.empty?
304
+ # Empty glyph - create empty outline
305
+ Models::Outline.new(
306
+ glyph_id: glyph_id,
307
+ commands: [],
308
+ bbox: { x_min: 0, y_min: 0, x_max: 0, y_max: 0 },
309
+ )
310
+ elsif glyph.simple?
311
+ # Convert simple glyph to outline
312
+ Models::Outline.from_truetype(glyph, glyph_id)
313
+ else
314
+ # Compound glyph - resolve to simple outline
315
+ resolver.resolve(glyph)
316
+ end
317
+ end
318
+
319
+ outlines
320
+ end
321
+
322
+ # Extract outlines from CFF font
323
+ #
324
+ # @param font [OpenTypeFont] Source font
325
+ # @return [Array<Outline>] Array of outline objects
326
+ def extract_cff_outlines(font)
327
+ # Get CFF table
328
+ cff = font.table("CFF ")
329
+ raise Fontisan::Error, "CFF table not found" unless cff
330
+
331
+ # Get number of glyphs
332
+ num_glyphs = cff.glyph_count
333
+
334
+ # Extract all glyphs
335
+ outlines = []
336
+ num_glyphs.times do |glyph_id|
337
+ charstring = cff.charstring_for_glyph(glyph_id)
338
+
339
+ outlines << if charstring.nil? || charstring.path.empty?
340
+ # Empty glyph
341
+ Models::Outline.new(
342
+ glyph_id: glyph_id,
343
+ commands: [],
344
+ bbox: { x_min: 0, y_min: 0, x_max: 0, y_max: 0 },
345
+ )
346
+ else
347
+ # Convert CharString to outline
348
+ Models::Outline.from_cff(charstring, glyph_id)
349
+ end
350
+ end
351
+
352
+ outlines
353
+ end
354
+
355
+ # Build CFF table from outlines
356
+ #
357
+ # @param outlines [Array<Outline>] Glyph outlines
358
+ # @param font [TrueTypeFont] Source font (for metadata)
359
+ # @return [String] CFF table binary data
360
+ def build_cff_table(outlines, font, hints_per_glyph)
361
+ # Build CharStrings INDEX from outlines
362
+ begin
363
+ charstrings = outlines.map do |outline|
364
+ builder = Tables::Cff::CharStringBuilder.new
365
+ if outline.empty?
366
+ builder.build_empty
367
+ else
368
+ builder.build(outline)
369
+ end
370
+ end
371
+ rescue StandardError => e
372
+ raise Fontisan::Error, "Failed to build CharStrings: #{e.message}"
373
+ end
374
+
375
+ # Apply subroutine optimization if enabled
376
+ local_subrs = []
377
+
378
+ if @optimize_cff
379
+ begin
380
+ charstrings, local_subrs = optimize_charstrings(charstrings)
381
+ rescue StandardError => e
382
+ # If optimization fails, fall back to unoptimized CharStrings
383
+ warn "CFF optimization failed: #{e.message}, using unoptimized CharStrings"
384
+ local_subrs = []
385
+ end
386
+ end
387
+
388
+ # Build font metadata
389
+ begin
390
+ font_name = extract_font_name(font)
391
+ rescue StandardError => e
392
+ raise Fontisan::Error, "Failed to extract font name: #{e.message}"
393
+ end
394
+
395
+ # Build all INDEXes
396
+ begin
397
+ header_size = 4
398
+ name_index_data = Tables::Cff::IndexBuilder.build([font_name])
399
+ string_index_data = Tables::Cff::IndexBuilder.build([]) # Empty strings
400
+ global_subr_index_data = Tables::Cff::IndexBuilder.build([]) # Empty global subrs
401
+ charstrings_index_data = Tables::Cff::IndexBuilder.build(charstrings)
402
+ local_subrs_index_data = Tables::Cff::IndexBuilder.build(local_subrs)
403
+ rescue StandardError => e
404
+ raise Fontisan::Error, "Failed to build CFF indexes: #{e.message}"
405
+ end
406
+
407
+ # Build Private DICT with Subrs offset if we have local subroutines
408
+ begin
409
+ private_dict_hash = {
410
+ default_width_x: 1000,
411
+ nominal_width_x: 0,
412
+ }
413
+
414
+ # If we have local subroutines, add Subrs offset
415
+ # Subrs offset is relative to Private DICT start
416
+ if local_subrs.any?
417
+ # Calculate size of Private DICT itself to know where Subrs starts
418
+ temp_private_dict_data = Tables::Cff::DictBuilder.build(private_dict_hash)
419
+ subrs_offset = temp_private_dict_data.bytesize
420
+
421
+ # Add Subrs offset to DICT
422
+ private_dict_hash[:subrs] = subrs_offset
423
+ end
424
+
425
+ # Build final Private DICT
426
+ private_dict_data = Tables::Cff::DictBuilder.build(private_dict_hash)
427
+ private_dict_size = private_dict_data.bytesize
428
+ rescue StandardError => e
429
+ raise Fontisan::Error, "Failed to build Private DICT: #{e.message}"
430
+ end
431
+
432
+ # Calculate offsets with iterative refinement
433
+ begin
434
+ # Initial pass
435
+ top_dict_index_start = header_size + name_index_data.bytesize
436
+ string_index_start = top_dict_index_start + 100 # Approximate
437
+ global_subr_index_start = string_index_start + string_index_data.bytesize
438
+ charstrings_offset = global_subr_index_start + global_subr_index_data.bytesize
439
+
440
+ # Build Top DICT
441
+ top_dict_hash = {
442
+ charset: 0,
443
+ encoding: 0,
444
+ charstrings: charstrings_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
+
449
+ # Recalculate with actual Top DICT size
450
+ string_index_start = top_dict_index_start + top_dict_index_data.bytesize
451
+ global_subr_index_start = string_index_start + string_index_data.bytesize
452
+ charstrings_offset = global_subr_index_start + global_subr_index_data.bytesize
453
+ private_dict_offset = charstrings_offset + charstrings_index_data.bytesize
454
+
455
+ # Update Top DICT with Private DICT info
456
+ top_dict_hash = {
457
+ charset: 0,
458
+ encoding: 0,
459
+ charstrings: charstrings_offset,
460
+ private: [private_dict_size, private_dict_offset],
461
+ }
462
+ top_dict_data = Tables::Cff::DictBuilder.build(top_dict_hash)
463
+ top_dict_index_data = Tables::Cff::IndexBuilder.build([top_dict_data])
464
+
465
+ # Final recalculation
466
+ string_index_start = top_dict_index_start + top_dict_index_data.bytesize
467
+ global_subr_index_start = string_index_start + string_index_data.bytesize
468
+ charstrings_offset = global_subr_index_start + global_subr_index_data.bytesize
469
+ private_dict_offset = charstrings_offset + charstrings_index_data.bytesize
470
+
471
+ # Final Top DICT
472
+ top_dict_hash = {
473
+ charset: 0,
474
+ encoding: 0,
475
+ charstrings: charstrings_offset,
476
+ private: [private_dict_size, private_dict_offset],
477
+ }
478
+ top_dict_data = Tables::Cff::DictBuilder.build(top_dict_hash)
479
+ top_dict_index_data = Tables::Cff::IndexBuilder.build([top_dict_data])
480
+ rescue StandardError => e
481
+ raise Fontisan::Error, "Failed to calculate CFF table offsets: #{e.message}"
482
+ end
483
+
484
+ # Build CFF Header
485
+ begin
486
+ header = [
487
+ 1, # major version
488
+ 0, # minor version
489
+ 4, # header size
490
+ 4, # offSize (will be in INDEX)
491
+ ].pack("C4")
492
+ rescue StandardError => e
493
+ raise Fontisan::Error, "Failed to build CFF header: #{e.message}"
494
+ end
495
+
496
+ # Assemble complete CFF table
497
+ begin
498
+ header +
499
+ name_index_data +
500
+ top_dict_index_data +
501
+ string_index_data +
502
+ global_subr_index_data +
503
+ charstrings_index_data +
504
+ private_dict_data +
505
+ local_subrs_index_data
506
+ rescue StandardError => e
507
+ raise Fontisan::Error, "Failed to assemble CFF table: #{e.message}"
508
+ end
509
+ end
510
+
511
+ # Build glyf and loca tables from outlines
512
+ #
513
+ # @param outlines [Array<Outline>] Glyph outlines
514
+ # @return [Array<String, String, Integer>] [glyf_data, loca_data, loca_format]
515
+ def build_glyf_loca_tables(outlines, hints_per_glyph)
516
+ glyf_data = "".b
517
+ offsets = []
518
+
519
+ # Build each glyph
520
+ outlines.each do |outline|
521
+ offsets << glyf_data.bytesize
522
+
523
+ if outline.empty?
524
+ # Empty glyph - no data
525
+ next
526
+ end
527
+
528
+ # Build glyph data using GlyphBuilder class method
529
+ glyph_data = Fontisan::Tables::GlyphBuilder.build_simple_glyph(outline)
530
+ glyf_data << glyph_data
531
+
532
+ # Add padding to 4-byte boundary
533
+ padding = (4 - (glyf_data.bytesize % 4)) % 4
534
+ glyf_data << ("\x00" * padding) if padding.positive?
535
+ end
536
+
537
+ # Add final offset
538
+ offsets << glyf_data.bytesize
539
+
540
+ # Build loca table
541
+ # Determine format based on max offset
542
+ max_offset = offsets.max
543
+ if max_offset <= 0x1FFFE
544
+ # Short format (offsets / 2)
545
+ loca_format = 0
546
+ loca_data = offsets.map { |off| off / 2 }.pack("n*")
547
+ else
548
+ # Long format
549
+ loca_format = 1
550
+ loca_data = offsets.pack("N*")
551
+ end
552
+
553
+ [glyf_data, loca_data, loca_format]
554
+ end
555
+
556
+ # Copy non-outline tables from source to target
557
+ #
558
+ # @param font [TrueTypeFont, OpenTypeFont] Source font
559
+ # @param exclude_tags [Array<String>] Tags to exclude
560
+ # @return [Hash<String, String>] Copied tables
561
+ def copy_tables(font, exclude_tags = [])
562
+ tables = {}
563
+
564
+ font.table_data.each do |tag, data|
565
+ next if exclude_tags.include?(tag)
566
+
567
+ tables[tag] = data if data
568
+ end
569
+
570
+ tables
571
+ end
572
+
573
+ # Update maxp table for CFF format
574
+ #
575
+ # @param font [TrueTypeFont] Source font
576
+ # @param num_glyphs [Integer] Number of glyphs
577
+ # @return [String] Updated maxp table binary data
578
+ def update_maxp_for_cff(_font, num_glyphs)
579
+ # CFF uses maxp version 0.5 (0x00005000)
580
+ # Structure: version (4 bytes) + numGlyphs (2 bytes)
581
+ [Tables::Maxp::VERSION_0_5, num_glyphs].pack("Nn")
582
+ end
583
+
584
+ # Update maxp table for TrueType format
585
+ #
586
+ # @param font [OpenTypeFont] Source font
587
+ # @param outlines [Array<Outline>] Glyph outlines
588
+ # @param loca_format [Integer] Loca format (0 or 1)
589
+ # @return [String] Updated maxp table binary data
590
+ def update_maxp_for_truetype(font, outlines, _loca_format)
591
+ # Get source maxp
592
+ font.table("maxp")
593
+ num_glyphs = outlines.length
594
+
595
+ # Calculate statistics from outlines
596
+ max_points = 0
597
+ max_contours = 0
598
+
599
+ outlines.each do |outline|
600
+ next if outline.empty?
601
+
602
+ contours = outline.to_truetype_contours
603
+ max_contours = [max_contours, contours.length].max
604
+
605
+ contours.each do |contour|
606
+ max_points = [max_points, contour.length].max
607
+ end
608
+ end
609
+
610
+ # Build maxp v1.0 table
611
+ # We'll use conservative defaults for instruction-related fields
612
+ [
613
+ Tables::Maxp::VERSION_1_0, # version
614
+ num_glyphs, # numGlyphs
615
+ max_points, # maxPoints
616
+ max_contours, # maxContours
617
+ 0, # maxCompositePoints
618
+ 0, # maxCompositeContours
619
+ 2, # maxZones
620
+ 0, # maxTwilightPoints
621
+ 0, # maxStorage
622
+ 0, # maxFunctionDefs
623
+ 0, # maxInstructionDefs
624
+ 0, # maxStackElements
625
+ 0, # maxSizeOfInstructions
626
+ 0, # maxComponentElements
627
+ 0, # maxComponentDepth
628
+ ].pack("Nnnnnnnnnnnnnnn")
629
+ end
630
+
631
+ # Update head table for CFF format
632
+ #
633
+ # @param font [TrueTypeFont] Source font
634
+ # @return [String] Updated head table binary data
635
+ def update_head_for_cff(font)
636
+ font.table("head")
637
+ head_data = font.table_data["head"].dup
638
+
639
+ # For CFF fonts, indexToLocFormat is not relevant
640
+ # but we'll set it to 0 for consistency
641
+ # indexToLocFormat is at offset 50 (2 bytes)
642
+ head_data[50, 2] = [0].pack("n")
643
+
644
+ head_data
645
+ end
646
+
647
+ # Update head table for TrueType format
648
+ #
649
+ # @param font [OpenTypeFont] Source font
650
+ # @param loca_format [Integer] Loca format (0=short, 1=long)
651
+ # @return [String] Updated head table binary data
652
+ def update_head_for_truetype(font, loca_format)
653
+ font.table("head")
654
+ head_data = font.table_data["head"].dup
655
+
656
+ # Set indexToLocFormat at offset 50 (2 bytes)
657
+ head_data[50, 2] = [loca_format].pack("n")
658
+
659
+ head_data
660
+ end
661
+
662
+ # Extract font name from name table
663
+ #
664
+ # @param font [TrueTypeFont, OpenTypeFont] Font
665
+ # @return [String] Font name
666
+ def extract_font_name(font)
667
+ name_table = font.table("name")
668
+ if name_table
669
+ font_name = name_table.english_name(Tables::Name::FAMILY)
670
+ return font_name.dup.force_encoding("ASCII-8BIT") if font_name
671
+ end
672
+
673
+ "UnnamedFont"
674
+ end
675
+
676
+ # Optimize CharStrings using subroutine extraction
677
+ #
678
+ # @param charstrings [Array<String>] Original CharString bytes
679
+ # @return [Array<Array<String>, Array<String>>] [optimized_charstrings, local_subrs]
680
+ def optimize_charstrings(charstrings)
681
+ # Convert to hash format expected by PatternAnalyzer
682
+ charstrings_hash = {}
683
+ charstrings.each_with_index do |cs, index|
684
+ charstrings_hash[index] = cs
685
+ end
686
+
687
+ # Analyze patterns
688
+ analyzer = Optimizers::PatternAnalyzer.new(
689
+ min_length: 10,
690
+ stack_aware: true
691
+ )
692
+ patterns = analyzer.analyze(charstrings_hash)
693
+
694
+ # Return original if no patterns found
695
+ return [charstrings, []] if patterns.empty?
696
+
697
+ # Optimize selection
698
+ optimizer = Optimizers::SubroutineOptimizer.new(patterns, max_subrs: 65_535)
699
+ selected_patterns = optimizer.optimize_selection
700
+
701
+ # Optimize ordering
702
+ selected_patterns = optimizer.optimize_ordering(selected_patterns)
703
+
704
+ # Return original if no patterns selected
705
+ return [charstrings, []] if selected_patterns.empty?
706
+
707
+ # Build subroutines
708
+ builder = Optimizers::SubroutineBuilder.new(selected_patterns, type: :local)
709
+ local_subrs = builder.build
710
+
711
+ # Build subroutine map
712
+ subroutine_map = {}
713
+ selected_patterns.each_with_index do |pattern, index|
714
+ subroutine_map[pattern.bytes] = index
715
+ end
716
+
717
+ # Rewrite CharStrings
718
+ rewriter = Optimizers::CharstringRewriter.new(subroutine_map, builder)
719
+ optimized_charstrings = charstrings.map.with_index do |charstring, glyph_id|
720
+ # Find patterns for this glyph
721
+ glyph_patterns = selected_patterns.select { |p| p.glyphs.include?(glyph_id) }
722
+
723
+ if glyph_patterns.empty?
724
+ charstring
725
+ else
726
+ rewriter.rewrite(charstring, glyph_patterns)
727
+ end
728
+ end
729
+
730
+ [optimized_charstrings, local_subrs]
731
+ rescue StandardError => e
732
+ # If optimization fails for any reason, return original CharStrings
733
+ warn "Optimization warning: #{e.message}"
734
+ [charstrings, []]
735
+ end
736
+
737
+ # Generate static instance from variable font
738
+ #
739
+ # @param font [TrueTypeFont, OpenTypeFont] Variable font
740
+ # @param source_format [Symbol] Source format
741
+ # @param target_format [Symbol] Target format
742
+ # @return [Hash<String, String>] Static font tables
743
+ def generate_static_instance(font, source_format, target_format)
744
+ # Generate instance at specified coordinates
745
+ fvar = font.table("fvar")
746
+ axes = fvar ? fvar.axes : []
747
+
748
+ generator = Variation::InstanceGenerator.new(font, @instance_coordinates)
749
+ instance_tables = generator.generate
750
+
751
+ # If target format differs from source, convert outlines
752
+ if source_format != target_format
753
+ # Create temporary font with instance tables
754
+ temp_font = font.class.new
755
+ temp_font.instance_variable_set(:@table_data, instance_tables)
756
+
757
+ # Convert outline format
758
+ case [source_format, target_format]
759
+ when %i[ttf otf]
760
+ convert_ttf_to_otf(temp_font, @options)
761
+ when %i[otf ttf], %i[cff2 ttf]
762
+ convert_otf_to_ttf(temp_font)
763
+ else
764
+ instance_tables
765
+ end
766
+ else
767
+ instance_tables
768
+ end
769
+ end
770
+
771
+ # Convert variation data during outline conversion
772
+ #
773
+ # @param font [TrueTypeFont, OpenTypeFont] Source font
774
+ # @param target_format [Symbol] Target format
775
+ # @return [Hash, nil] Converted variation data or nil
776
+ def convert_variations(font, target_format)
777
+ return nil unless @preserve_variations
778
+ return nil unless variable_font?(font)
779
+
780
+ fvar = font.table("fvar")
781
+ return nil unless fvar
782
+
783
+ axes = fvar.axes
784
+ converter = Variation::Converter.new(font, axes)
785
+
786
+ # Get glyph count
787
+ maxp = font.table("maxp")
788
+ return nil unless maxp
789
+
790
+ glyph_count = maxp.num_glyphs
791
+
792
+ # Convert variation data for each glyph
793
+ variation_data = {}
794
+ glyph_count.times do |glyph_id|
795
+ source_format = detect_format(font)
796
+
797
+ data = case [source_format, target_format]
798
+ when %i[ttf otf], %i[ttf cff2]
799
+ # gvar → blend
800
+ converter.gvar_to_blend(glyph_id)
801
+ when %i[otf ttf], %i[cff2 ttf]
802
+ # blend → gvar
803
+ converter.blend_to_gvar(glyph_id)
804
+ else
805
+ nil
806
+ end
807
+
808
+ variation_data[glyph_id] = data if data
809
+ end
810
+
811
+ variation_data.empty? ? nil : variation_data
812
+ end
813
+
814
+ # Detect font format from tables
815
+ #
816
+ # @param font [TrueTypeFont, OpenTypeFont] Font to detect
817
+ # @return [Symbol] Format (:ttf, :otf, or :cff2)
818
+ # @raise [Error] If format cannot be detected
819
+ def detect_format(font)
820
+ # Check for CFF2 table first (OpenType variable fonts with CFF2 outlines)
821
+ if font.has_table?("CFF2")
822
+ :cff2
823
+ # Check for CFF table (OpenType/CFF)
824
+ elsif font.has_table?("CFF ")
825
+ :otf
826
+ # Check for glyf table (TrueType)
827
+ elsif font.has_table?("glyf")
828
+ :ttf
829
+ else
830
+ raise Fontisan::Error,
831
+ "Cannot detect font format: missing outline tables (CFF2, CFF, or glyf)"
832
+ end
833
+ end
834
+
835
+ # Detect target format as opposite of source
836
+ #
837
+ # @param font [TrueTypeFont, OpenTypeFont] Source font
838
+ # @return [Symbol] Target format
839
+ def detect_target_format(font)
840
+ source = detect_format(font)
841
+ case source
842
+ when :ttf
843
+ :otf
844
+ when :cff2
845
+ :ttf
846
+ else
847
+ :ttf
848
+ end
849
+ end
850
+
851
+ # Validate source font has required tables
852
+ #
853
+ # @param font [TrueTypeFont, OpenTypeFont] Font to validate
854
+ # @param format [Symbol] Font format
855
+ # @raise [Error] If required tables are missing
856
+ def validate_source_tables(font, format)
857
+ case format
858
+ when :ttf
859
+ unless font.has_table?("glyf") && font.has_table?("loca")
860
+ raise Fontisan::MissingTableError,
861
+ "TrueType font missing required glyf or loca table"
862
+ end
863
+ # Also verify tables can actually be loaded
864
+ unless font.table("glyf") && font.table("loca")
865
+ raise Fontisan::MissingTableError,
866
+ "TrueType font missing required glyf or loca table"
867
+ end
868
+ when :cff2
869
+ unless font.has_table?("CFF2")
870
+ raise Fontisan::MissingTableError,
871
+ "CFF2 font missing required CFF2 table"
872
+ end
873
+ unless font.table("CFF2")
874
+ raise Fontisan::MissingTableError,
875
+ "CFF2 font missing required CFF2 table"
876
+ end
877
+ when :otf
878
+ unless font.has_table?("CFF ") || font.has_table?("CFF2")
879
+ raise Fontisan::MissingTableError,
880
+ "OpenType font missing required CFF or CFF2 table"
881
+ end
882
+ # Verify at least one can be loaded
883
+ unless font.table("CFF ") || font.table("CFF2")
884
+ raise Fontisan::MissingTableError,
885
+ "OpenType font missing required CFF or CFF2 table"
886
+ end
887
+ end
888
+
889
+ # Common required tables
890
+ %w[head hhea maxp].each do |tag|
891
+ unless font.has_table?(tag)
892
+ raise Fontisan::MissingTableError,
893
+ "Font missing required #{tag} table"
894
+ end
895
+ # Verify table can actually be loaded
896
+ unless font.table(tag)
897
+ raise Fontisan::MissingTableError,
898
+ "Font missing required #{tag} table"
899
+ end
900
+ end
901
+ end
902
+
903
+ # Extract hints from TrueType font
904
+ #
905
+ # @param font [TrueTypeFont] Source font
906
+ # @return [Hash<Integer, Array<Hint>>] Map of glyph ID to hints
907
+ def extract_ttf_hints(font)
908
+ hints_per_glyph = {}
909
+ extractor = Hints::TrueTypeHintExtractor.new
910
+
911
+ # Get required tables
912
+ head = font.table("head")
913
+ maxp = font.table("maxp")
914
+ loca = font.table("loca")
915
+ glyf = font.table("glyf")
916
+
917
+ # Parse loca with context
918
+ loca.parse_with_context(head.index_to_loc_format, maxp.num_glyphs)
919
+
920
+ # Extract hints from each glyph
921
+ maxp.num_glyphs.times do |glyph_id|
922
+ glyph = glyf.glyph_for(glyph_id, loca, head)
923
+ next if glyph.nil? || glyph.empty?
924
+
925
+ hints = extractor.extract(glyph)
926
+ hints_per_glyph[glyph_id] = hints if hints.any?
927
+ end
928
+
929
+ hints_per_glyph
930
+ rescue StandardError => e
931
+ warn "Failed to extract TrueType hints: #{e.message}"
932
+ {}
933
+ end
934
+
935
+ # Extract hints from CFF font
936
+ #
937
+ # @param font [OpenTypeFont] Source font
938
+ # @return [Hash<Integer, Array<Hint>>] Map of glyph ID to hints
939
+ def extract_cff_hints(font)
940
+ hints_per_glyph = {}
941
+ extractor = Hints::PostScriptHintExtractor.new
942
+
943
+ # Get CFF table
944
+ cff = font.table("CFF ")
945
+ return {} unless cff
946
+
947
+ # Get number of glyphs
948
+ num_glyphs = cff.glyph_count
949
+
950
+ # Extract hints from each CharString
951
+ num_glyphs.times do |glyph_id|
952
+ charstring = cff.charstring_for_glyph(glyph_id)
953
+ next if charstring.nil?
954
+
955
+ hints = extractor.extract(charstring)
956
+ hints_per_glyph[glyph_id] = hints if hints.any?
957
+ end
958
+
959
+ hints_per_glyph
960
+ rescue StandardError => e
961
+ warn "Failed to extract CFF hints: #{e.message}"
962
+ {}
963
+ end
964
+
965
+ # Extract complete TrueType hint set from font
966
+ #
967
+ # @param font [TrueTypeFont] Source font
968
+ # @return [HintSet] Complete hint set
969
+ def extract_ttf_hint_set(font)
970
+ extractor = Hints::TrueTypeHintExtractor.new
971
+ extractor.extract_from_font(font)
972
+ rescue StandardError => e
973
+ warn "Failed to extract TrueType hint set: #{e.message}"
974
+ Models::HintSet.new(format: :truetype)
975
+ end
976
+
977
+ # Extract complete PostScript hint set from font
978
+ #
979
+ # @param font [OpenTypeFont] Source font
980
+ # @return [HintSet] Complete hint set
981
+ def extract_cff_hint_set(font)
982
+ extractor = Hints::PostScriptHintExtractor.new
983
+ extractor.extract_from_font(font)
984
+ rescue StandardError => e
985
+ warn "Failed to extract PostScript hint set: #{e.message}"
986
+ Models::HintSet.new(format: :postscript)
987
+ end
988
+
989
+ # Check if font is a variable font
990
+ #
991
+ # @param font [TrueTypeFont, OpenTypeFont] Font to check
992
+ # @return [Boolean] True if font has variation tables
993
+ def variable_font?(font)
994
+ font.has_table?("fvar")
995
+ end
996
+ end
997
+ end
998
+ end