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,310 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require_relative "../models/hint"
5
+
6
+ module Fontisan
7
+ module Hints
8
+ # Converts hints between TrueType and PostScript formats
9
+ #
10
+ # This converter handles bidirectional conversion of rendering hints,
11
+ # translating between TrueType instruction-based hinting and PostScript
12
+ # operator-based hinting while preserving intent where possible.
13
+ #
14
+ # **Conversion Strategy:**
15
+ #
16
+ # - TrueType → PostScript: Extract semantic meaning from instructions
17
+ # and convert to corresponding PostScript operators
18
+ # - PostScript → TrueType: Analyze hint operators and generate
19
+ # equivalent TrueType instructions
20
+ #
21
+ # @example Convert TrueType hints to PostScript
22
+ # converter = HintConverter.new
23
+ # ps_hints = converter.to_postscript(tt_hints)
24
+ #
25
+ # @example Convert PostScript hints to TrueType
26
+ # converter = HintConverter.new
27
+ # tt_hints = converter.to_truetype(ps_hints)
28
+ class HintConverter
29
+ # Convert hints to PostScript format
30
+ #
31
+ # @param hints [Array<Hint>] Source hints (any format)
32
+ # @return [Array<Hint>] Hints in PostScript format
33
+ def to_postscript(hints)
34
+ return [] if hints.nil? || hints.empty?
35
+
36
+ hints.map do |hint|
37
+ next hint if hint.source_format == :postscript
38
+
39
+ convert_hint_to_postscript(hint)
40
+ end.compact
41
+ end
42
+
43
+ # Convert hints to TrueType format
44
+ #
45
+ # @param hints [Array<Hint>] Source hints (any format)
46
+ # @return [Array<Hint>] Hints in TrueType format
47
+ def to_truetype(hints)
48
+ return [] if hints.nil? || hints.empty?
49
+
50
+ hints.map do |hint|
51
+ next hint if hint.source_format == :truetype
52
+
53
+ convert_hint_to_truetype(hint)
54
+ end.compact
55
+ end
56
+
57
+ # Optimize hint set by removing redundant hints
58
+ #
59
+ # @param hints [Array<Hint>] Hints to optimize
60
+ # @return [Array<Hint>] Optimized hints
61
+ def optimize(hints)
62
+ return [] if hints.nil? || hints.empty?
63
+
64
+ # Remove duplicate hints
65
+ unique_hints = hints.uniq { |h| [h.type, h.data] }
66
+
67
+ # Remove conflicting hints (keep first)
68
+ remove_conflicts(unique_hints)
69
+ end
70
+
71
+ # Convert entire HintSet between formats
72
+ #
73
+ # @param hint_set [Models::HintSet] Source hint set
74
+ # @param target_format [Symbol] Target format (:truetype or :postscript)
75
+ # @return [Models::HintSet] Converted hint set
76
+ def convert_hint_set(hint_set, target_format)
77
+ return hint_set if hint_set.format == target_format.to_s
78
+
79
+ result = Models::HintSet.new(format: target_format.to_s)
80
+
81
+ case target_format
82
+ when :postscript
83
+ # Convert font-level TT → PS
84
+ if hint_set.font_program || hint_set.control_value_program ||
85
+ hint_set.control_values&.any?
86
+ ps_dict = convert_tt_programs_to_ps_dict(
87
+ hint_set.font_program,
88
+ hint_set.control_value_program,
89
+ hint_set.control_values
90
+ )
91
+ result.private_dict_hints = ps_dict.to_json
92
+ end
93
+
94
+ # Convert per-glyph hints
95
+ hint_set.hinted_glyph_ids.each do |glyph_id|
96
+ glyph_hints = hint_set.get_glyph_hints(glyph_id)
97
+ ps_hints = to_postscript(glyph_hints)
98
+ result.add_glyph_hints(glyph_id, ps_hints) unless ps_hints.empty?
99
+ end
100
+
101
+ when :truetype
102
+ # Convert font-level PS → TT
103
+ if hint_set.private_dict_hints && hint_set.private_dict_hints != "{}"
104
+ tt_programs = convert_ps_dict_to_tt_programs(
105
+ JSON.parse(hint_set.private_dict_hints)
106
+ )
107
+ result.font_program = tt_programs[:fpgm]
108
+ result.control_value_program = tt_programs[:prep]
109
+ result.control_values = tt_programs[:cvt]
110
+ end
111
+
112
+ # Convert per-glyph hints
113
+ hint_set.hinted_glyph_ids.each do |glyph_id|
114
+ glyph_hints = hint_set.get_glyph_hints(glyph_id)
115
+ tt_hints = to_truetype(glyph_hints)
116
+ result.add_glyph_hints(glyph_id, tt_hints) unless tt_hints.empty?
117
+ end
118
+ end
119
+
120
+ result.has_hints = !result.empty?
121
+ result
122
+ end
123
+
124
+ private
125
+
126
+ # Convert a single hint to PostScript format
127
+ #
128
+ # @param hint [Hint] Source hint
129
+ # @return [Hint, nil] Converted hint or nil if incompatible
130
+ def convert_hint_to_postscript(hint)
131
+ return nil unless hint.compatible_with?(:postscript)
132
+
133
+ # Get PostScript representation from hint
134
+ ps_data = hint.to_postscript
135
+
136
+ # Create new hint with PostScript format
137
+ Models::Hint.new(
138
+ type: hint.type,
139
+ data: ps_data,
140
+ source_format: :postscript
141
+ )
142
+ rescue StandardError => e
143
+ warn "Failed to convert hint to PostScript: #{e.message}"
144
+ nil
145
+ end
146
+
147
+ # Convert a single hint to TrueType format
148
+ #
149
+ # @param hint [Hint] Source hint
150
+ # @return [Hint, nil] Converted hint or nil if incompatible
151
+ def convert_hint_to_truetype(hint)
152
+ return nil unless hint.compatible_with?(:truetype)
153
+
154
+ # Get TrueType representation from hint
155
+ tt_instructions = hint.to_truetype
156
+
157
+ # Create new hint with TrueType format
158
+ Models::Hint.new(
159
+ type: hint.type,
160
+ data: { instructions: tt_instructions },
161
+ source_format: :truetype
162
+ )
163
+ rescue StandardError => e
164
+ warn "Failed to convert hint to TrueType: #{e.message}"
165
+ nil
166
+ end
167
+
168
+ # Remove conflicting hints from set
169
+ #
170
+ # @param hints [Array<Hint>] Hints to check
171
+ # @return [Array<Hint>] Non-conflicting hints
172
+ def remove_conflicts(hints)
173
+ non_conflicting = []
174
+
175
+ hints.each do |hint|
176
+ # Check if this hint conflicts with any already selected
177
+ conflicts = non_conflicting.any? do |existing|
178
+ hints_conflict?(hint, existing)
179
+ end
180
+
181
+ non_conflicting << hint unless conflicts
182
+ end
183
+
184
+ non_conflicting
185
+ end
186
+
187
+ # Check if two hints conflict
188
+ #
189
+ # @param hint1 [Hint] First hint
190
+ # @param hint2 [Hint] Second hint
191
+ # @return [Boolean] True if hints conflict
192
+ def hints_conflict?(hint1, hint2)
193
+ # Hints of different types don't conflict
194
+ return false if hint1.type != hint2.type
195
+
196
+ case hint1.type
197
+ when :stem
198
+ # Stem hints conflict if they overlap
199
+ stems_overlap?(hint1.data, hint2.data)
200
+ when :interpolate
201
+ # Multiple interpolation hints on same axis conflict
202
+ hint1.data[:axis] == hint2.data[:axis]
203
+ else
204
+ # Other hint types don't conflict
205
+ false
206
+ end
207
+ end
208
+
209
+ # Check if two stem hints overlap
210
+ #
211
+ # @param stem1 [Hash] First stem data
212
+ # @param stem2 [Hash] Second stem data
213
+ # @return [Boolean] True if stems overlap
214
+ def stems_overlap?(stem1, stem2)
215
+ # Must be same orientation to conflict
216
+ return false if stem1[:orientation] != stem2[:orientation]
217
+
218
+ pos1 = stem1[:position] || 0
219
+ width1 = stem1[:width] || 0
220
+ pos2 = stem2[:position] || 0
221
+ width2 = stem2[:width] || 0
222
+
223
+ # Check if ranges overlap
224
+ end1 = pos1 + width1
225
+ end2 = pos2 + width2
226
+
227
+ pos1 < end2 && pos2 < end1
228
+ end
229
+
230
+ # Convert TrueType font programs to PostScript Private dict
231
+ #
232
+ # Analyzes TrueType fpgm, prep, and cvt to extract semantic intent
233
+ # and generate corresponding PostScript hint parameters.
234
+ #
235
+ # @param fpgm [String] Font program bytecode
236
+ # @param prep [String] Control value program bytecode
237
+ # @param cvt [Array<Integer>] Control values
238
+ # @return [Hash] PostScript Private dict hint parameters
239
+ def convert_tt_programs_to_ps_dict(fpgm, prep, cvt)
240
+ hints = {}
241
+
242
+ # Extract stem widths from cvt if present
243
+ # CVT values typically contain standard widths
244
+ if cvt && !cvt.empty?
245
+ # First CVT value often represents standard horizontal stem
246
+ hints[:std_hw] = cvt[0].abs if cvt.length > 0
247
+ # Second CVT value often represents standard vertical stem
248
+ hints[:std_vw] = cvt[1].abs if cvt.length > 1
249
+ end
250
+
251
+ # Analyze control value program for alignment zones
252
+ # TrueType doesn't have exact Blue zones, so we use defaults
253
+ # These are standard values that work for most Latin fonts
254
+ hints[:blue_values] = [-20, 0, 706, 726]
255
+
256
+ # Optional: Add other_blues for descenders if we detect them
257
+ # This would require analyzing prep program, which is complex
258
+ # For now, use conservative defaults
259
+
260
+ hints
261
+ rescue StandardError => e
262
+ warn "Error converting TT programs to PS dict: #{e.message}"
263
+ {}
264
+ end
265
+
266
+ # Convert PostScript Private dict to TrueType font programs
267
+ #
268
+ # Generates TrueType control values and programs from PostScript
269
+ # hint parameters.
270
+ #
271
+ # @param ps_dict [Hash] PostScript Private dict parameters
272
+ # @return [Hash] TrueType programs ({ fpgm:, prep:, cvt: })
273
+ def convert_ps_dict_to_tt_programs(ps_dict)
274
+ # Handle both string and symbol keys from JSON
275
+ ps_dict = ps_dict.transform_keys(&:to_sym) if ps_dict.keys.first.is_a?(String)
276
+
277
+ # Generate control values from PS parameters
278
+ cvt = []
279
+
280
+ # Add standard stem widths as CVT values
281
+ cvt << ps_dict[:std_hw] if ps_dict[:std_hw]
282
+ cvt << ps_dict[:std_vw] if ps_dict[:std_vw]
283
+
284
+ # Add stem snap values if present
285
+ if ps_dict[:stem_snap_h]&.is_a?(Array)
286
+ cvt.concat(ps_dict[:stem_snap_h])
287
+ end
288
+ if ps_dict[:stem_snap_v]&.is_a?(Array)
289
+ cvt.concat(ps_dict[:stem_snap_v])
290
+ end
291
+
292
+ # Remove duplicates and sort
293
+ cvt = cvt.uniq.sort
294
+
295
+ # Generate basic prep program (empty for converted fonts)
296
+ # A real implementation would generate instructions to set up CVT
297
+ prep = ""
298
+
299
+ # fpgm typically empty for converted fonts
300
+ # Functions would need to be synthesized from scratch
301
+ fpgm = ""
302
+
303
+ { fpgm: fpgm, prep: prep, cvt: cvt }
304
+ rescue StandardError => e
305
+ warn "Error converting PS dict to TT programs: #{e.message}"
306
+ { fpgm: "", prep: "", cvt: [] }
307
+ end
308
+ end
309
+ end
310
+ end
@@ -0,0 +1,266 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../models/hint"
4
+ require "json"
5
+
6
+ module Fontisan
7
+ module Hints
8
+ # Applies rendering hints to PostScript/CFF font tables
9
+ #
10
+ # This applier validates and applies PostScript hint data to CFF fonts by
11
+ # rebuilding the entire CFF table structure with updated Private DICT parameters.
12
+ #
13
+ # **Status**: Fully Operational (Phase 10A Complete)
14
+ #
15
+ # **PostScript Hint Parameters (Private DICT)**:
16
+ #
17
+ # - blue_values: Alignment zones for overshoot suppression
18
+ # - other_blues: Additional alignment zones
19
+ # - std_hw: Standard horizontal stem width
20
+ # - std_vw: Standard vertical stem width
21
+ # - stem_snap_h: Horizontal stem snap widths
22
+ # - stem_snap_v: Vertical stem snap widths
23
+ # - blue_scale, blue_shift, blue_fuzz: Overshoot parameters
24
+ # - force_bold: Force bold flag
25
+ # - language_group: Language group (0=Latin, 1=CJK)
26
+ #
27
+ # @example Apply PostScript hints
28
+ # applier = PostScriptHintApplier.new
29
+ # tables = { "CFF " => cff_table }
30
+ # hint_set = HintSet.new(format: "postscript", private_dict_hints: hints_json)
31
+ # result = applier.apply(hint_set, tables)
32
+ class PostScriptHintApplier
33
+ # Apply PostScript hints to font tables
34
+ #
35
+ # Validates hint data and rebuilds CFF table with updated Private DICT.
36
+ # Supports arbitrary Private DICT size changes through full table reconstruction.
37
+ # Also supports per-glyph hints injected directly into CharStrings.
38
+ #
39
+ # @param hint_set [HintSet] Hint data to apply
40
+ # @param tables [Hash] Font tables (must include "CFF " or "CFF2 ")
41
+ # @return [Hash] Updated font tables with hints applied
42
+ def apply(hint_set, tables)
43
+ return tables if hint_set.nil? || hint_set.empty?
44
+ return tables unless hint_set.format == "postscript"
45
+
46
+ if cff2_table?(tables)
47
+ apply_cff2_hints(hint_set, tables)
48
+ elsif cff_table?(tables)
49
+ apply_cff_hints(hint_set, tables)
50
+ else
51
+ tables
52
+ end
53
+ end
54
+
55
+ private
56
+
57
+ # Check if tables contain CFF2 table
58
+ #
59
+ # @param tables [Hash] Font tables
60
+ # @return [Boolean] True if CFF2 table present
61
+ def cff2_table?(tables)
62
+ tables.key?("CFF2") || tables.key?("CFF2 ")
63
+ end
64
+
65
+ # Check if tables contain CFF table
66
+ #
67
+ # @param tables [Hash] Font tables
68
+ # @return [Boolean] True if CFF table present
69
+ def cff_table?(tables)
70
+ tables.key?("CFF ")
71
+ end
72
+
73
+ # Apply hints to CFF2 variable font
74
+ #
75
+ # @param hint_set [HintSet] Hint set with font-level and per-glyph hints
76
+ # @param tables [Hash] Font tables
77
+ # @return [Hash] Updated tables
78
+ def apply_cff2_hints(hint_set, tables)
79
+ # Load CFF2 table
80
+ cff2_data = tables["CFF2"] || tables["CFF2 "]
81
+
82
+ begin
83
+ require_relative "../tables/cff2/table_reader"
84
+ require_relative "../tables/cff2/table_builder"
85
+
86
+ reader = Tables::Cff2::TableReader.new(cff2_data)
87
+
88
+ # Validate CFF2 version
89
+ reader.read_header
90
+ unless reader.header[:major_version] == 2
91
+ warn "Invalid CFF2 table version: #{reader.header[:major_version]}"
92
+ return tables
93
+ end
94
+
95
+ # Build with hints
96
+ builder = Tables::Cff2::TableBuilder.new(reader, hint_set)
97
+ modified_table = builder.build
98
+
99
+ # Update tables
100
+ table_key = tables.key?("CFF2") ? "CFF2" : "CFF2 "
101
+ tables[table_key] = modified_table
102
+
103
+ tables
104
+ rescue StandardError => e
105
+ warn "Error applying CFF2 hints: #{e.message}"
106
+ tables
107
+ end
108
+ end
109
+
110
+ # Apply hints to CFF font
111
+ #
112
+ # @param hint_set [HintSet] Hint set with font-level and per-glyph hints
113
+ # @param tables [Hash] Font tables
114
+ # @return [Hash] Updated tables
115
+ def apply_cff_hints(hint_set, tables)
116
+ return tables unless tables["CFF "]
117
+
118
+ # Validate hint parameters (Private DICT)
119
+ hint_params = parse_hint_parameters(hint_set)
120
+
121
+ # Check if we have per-glyph hints
122
+ has_per_glyph_hints = hint_set.hinted_glyph_count.positive?
123
+
124
+ # If neither font-level nor per-glyph hints, return unchanged
125
+ return tables unless hint_params || has_per_glyph_hints
126
+
127
+ # Validate font-level parameters if present
128
+ if hint_params && !valid_hint_parameters?(hint_params)
129
+ return tables
130
+ end
131
+
132
+ # Apply hints (both font-level and per-glyph)
133
+ begin
134
+ require_relative "../tables/cff/table_builder"
135
+ require_relative "../tables/cff/charstring_rebuilder"
136
+ require_relative "../tables/cff/hint_operation_injector"
137
+ require_relative "../tables/cff"
138
+
139
+ # Parse CFF binary data into Cff object if needed
140
+ cff_data = tables["CFF "]
141
+ cff_table = if cff_data.is_a?(Tables::Cff)
142
+ cff_data
143
+ else
144
+ Tables::Cff.read(cff_data)
145
+ end
146
+
147
+ # Prepare per-glyph hint data if present
148
+ per_glyph_hints = if has_per_glyph_hints
149
+ extract_per_glyph_hints(hint_set)
150
+ else
151
+ nil
152
+ end
153
+
154
+ new_cff_data = Tables::Cff::TableBuilder.rebuild(
155
+ cff_table,
156
+ private_dict_hints: hint_params,
157
+ per_glyph_hints: per_glyph_hints
158
+ )
159
+
160
+ tables["CFF "] = new_cff_data
161
+ tables
162
+ rescue StandardError => e
163
+ warn "Failed to apply PostScript hints: #{e.message}"
164
+ tables
165
+ end
166
+ end
167
+
168
+ # Parse hint parameters from HintSet
169
+ #
170
+ # @param hint_set [HintSet] Hint set with Private dict hints
171
+ # @return [Hash, nil] Parsed hint parameters, or nil if invalid
172
+ def parse_hint_parameters(hint_set)
173
+ return nil unless hint_set.private_dict_hints
174
+ return nil if hint_set.private_dict_hints == "{}"
175
+
176
+ JSON.parse(hint_set.private_dict_hints)
177
+ rescue JSON::ParserError => e
178
+ warn "Failed to parse Private dict hints: #{e.message}"
179
+ nil
180
+ end
181
+
182
+ # Validate hint parameters against CFF specification limits
183
+ #
184
+ # @param params [Hash] Hint parameters
185
+ # @return [Boolean] True if all parameters are valid
186
+ def valid_hint_parameters?(params)
187
+ # Validate blue values (must be pairs, max 7 pairs = 14 values)
188
+ if params["blue_values"] || params[:blue_values]
189
+ values = params["blue_values"] || params[:blue_values]
190
+ return false unless values.is_a?(Array)
191
+ return false if values.length > 14 # Max 7 pairs
192
+ return false if values.length.odd? # Must be pairs
193
+ end
194
+
195
+ # Validate other_blues (max 5 pairs = 10 values)
196
+ if params["other_blues"] || params[:other_blues]
197
+ values = params["other_blues"] || params[:other_blues]
198
+ return false unless values.is_a?(Array)
199
+ return false if values.length > 10
200
+ return false if values.length.odd?
201
+ end
202
+
203
+ # Validate stem widths (single values)
204
+ if params["std_hw"] || params[:std_hw]
205
+ value = params["std_hw"] || params[:std_hw]
206
+ return false unless value.is_a?(Numeric)
207
+ return false if value.negative?
208
+ end
209
+
210
+ if params["std_vw"] || params[:std_vw]
211
+ value = params["std_vw"] || params[:std_vw]
212
+ return false unless value.is_a?(Numeric)
213
+ return false if value.negative?
214
+ end
215
+
216
+ # Validate stem snaps (arrays, max 12 values each)
217
+ %w[stem_snap_h stem_snap_v].each do |key|
218
+ next unless params[key] || params[key.to_sym]
219
+
220
+ values = params[key] || params[key.to_sym]
221
+ return false unless values.is_a?(Array)
222
+ return false if values.length > 12
223
+ end
224
+
225
+ # Validate blue_scale (should be positive)
226
+ if params["blue_scale"] || params[:blue_scale]
227
+ value = params["blue_scale"] || params[:blue_scale]
228
+ return false unless value.is_a?(Numeric)
229
+ return false if value <= 0
230
+ end
231
+
232
+ # Validate language_group (0 or 1 only)
233
+ if params["language_group"] || params[:language_group]
234
+ value = params["language_group"] || params[:language_group]
235
+ return false unless [0, 1].include?(value)
236
+ end
237
+
238
+ true
239
+ end
240
+
241
+ # Extract specific hint parameter with symbol/string key support
242
+ #
243
+ # @param params [Hash] Hint parameters
244
+ # @param key [String] Parameter name
245
+ # @return [Object, nil] Parameter value
246
+ def extract_param(params, key)
247
+ params[key] || params[key.to_sym]
248
+ end
249
+
250
+ # Extract per-glyph hint data from HintSet
251
+ #
252
+ # @param hint_set [HintSet] Hint set with per-glyph hints
253
+ # @return [Hash] Hash mapping glyph_id => Array<Hint>
254
+ def extract_per_glyph_hints(hint_set)
255
+ per_glyph = {}
256
+
257
+ hint_set.hinted_glyph_ids.each do |glyph_id|
258
+ hints = hint_set.get_glyph_hints(glyph_id)
259
+ per_glyph[glyph_id.to_i] = hints unless hints.empty?
260
+ end
261
+
262
+ per_glyph
263
+ end
264
+ end
265
+ end
266
+ end