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,77 @@
1
+ # WOFF2 Encoding Configuration
2
+ #
3
+ # This file defines settings for WOFF2 font encoding, including
4
+ # Brotli compression parameters and table transformation options.
5
+ #
6
+ # Reference: https://www.w3.org/TR/WOFF2/
7
+
8
+ # Brotli compression settings
9
+ brotli:
10
+ # Compression quality level (0-11)
11
+ # Higher values give better compression but slower encoding
12
+ # Quality 11 is recommended for production web fonts
13
+ quality: 11
14
+
15
+ # Compression mode
16
+ # :font - Optimized for font data (recommended)
17
+ # :text - Optimized for text data
18
+ # :generic - General purpose compression
19
+ mode: font
20
+
21
+ # Table transformation settings
22
+ transformations:
23
+ # Enable table transformations for better compression
24
+ # For Phase 2 Milestone 2.1: disabled (architecture in place)
25
+ # Future milestones will enable these for optimization
26
+ enabled: false
27
+
28
+ # Transform glyf and loca tables
29
+ # Combines glyf and loca into single stream with delta encoding
30
+ # Typical compression improvement: 20-30%
31
+ glyf_loca: false
32
+
33
+ # Transform hmtx table
34
+ # Compact representation of horizontal metrics
35
+ # Typical compression improvement: 10-20%
36
+ hmtx: false
37
+
38
+ # Metadata settings
39
+ metadata:
40
+ # Include font metadata in WOFF2 file
41
+ include: false
42
+
43
+ # Metadata compression
44
+ compress: true
45
+
46
+ # Private data settings
47
+ private_data:
48
+ # Include private data block
49
+ include: false
50
+
51
+ # Validation settings
52
+ validation:
53
+ # Verify checksums after encoding
54
+ verify_checksums: true
55
+
56
+ # Validate WOFF2 structure
57
+ validate_structure: true
58
+
59
+ # Optimization settings
60
+ optimization:
61
+ # Reorder tables for optimal compression
62
+ # Tables are sorted by tag for consistency
63
+ reorder_tables: true
64
+
65
+ # Remove unnecessary padding
66
+ minimize_padding: true
67
+
68
+ # Logging settings
69
+ logging:
70
+ # Log compression ratios
71
+ log_compression: true
72
+
73
+ # Log table sizes
74
+ log_table_sizes: false
75
+
76
+ # Verbose output
77
+ verbose: false
@@ -30,6 +30,15 @@ module Fontisan
30
30
  # the checksum adjustment field.
31
31
  HEAD_TAG = "head"
32
32
 
33
+ # Hhea table tag identifier (Horizontal Header)
34
+ HHEA_TAG = "hhea"
35
+
36
+ # Hmtx table tag identifier (Horizontal Metrics)
37
+ HMTX_TAG = "hmtx"
38
+
39
+ # Maxp table tag identifier (Maximum Profile)
40
+ MAXP_TAG = "maxp"
41
+
33
42
  # Name table tag identifier
34
43
  NAME_TAG = "name"
35
44
 
@@ -60,6 +69,34 @@ module Fontisan
60
69
  # Fvar table tag identifier (Font Variations)
61
70
  FVAR_TAG = "fvar"
62
71
 
72
+ # Gvar table tag identifier (Glyph Variations for TrueType)
73
+ GVAR_TAG = "gvar"
74
+
75
+ # HVAR table tag identifier (Horizontal Metrics Variations)
76
+ HVAR_TAG = "HVAR"
77
+
78
+ # MVAR table tag identifier (Metrics Variations)
79
+ MVAR_TAG = "MVAR"
80
+
81
+ # VVAR table tag identifier (Vertical Metrics Variations)
82
+ VVAR_TAG = "VVAR"
83
+
84
+ # Cvar table tag identifier (CVT Variations)
85
+ CVAR_TAG = "cvar"
86
+
87
+ # CFF2 table tag identifier (CFF version 2 with variations)
88
+ CFF2_TAG = "CFF2"
89
+
90
+ # TrueType hinting tables
91
+ # Font Program table (TrueType bytecode executed once at font load)
92
+ FPGM_TAG = "fpgm"
93
+
94
+ # Control Value Program table (TrueType bytecode for initialization)
95
+ PREP_TAG = "prep"
96
+
97
+ # Control Value Table (metrics used by TrueType hinting)
98
+ CVT_TAG = "cvt "
99
+
63
100
  # Magic number used for font file checksum adjustment calculation.
64
101
  # This constant is used in conjunction with the file checksum to compute
65
102
  # the checksumAdjustment value stored in the 'head' table.
@@ -74,5 +111,47 @@ module Fontisan
74
111
  # All table data in TTF files must be aligned to 4-byte boundaries,
75
112
  # with padding added as necessary.
76
113
  TABLE_ALIGNMENT = 4
114
+
115
+ # Common font subfamily names for string interning
116
+ #
117
+ # These strings are frozen and reused to reduce memory allocations
118
+ # when parsing fonts with common subfamily names.
119
+ STRING_POOL = {
120
+ "Regular" => "Regular".freeze,
121
+ "Bold" => "Bold".freeze,
122
+ "Italic" => "Italic".freeze,
123
+ "Bold Italic" => "Bold Italic".freeze,
124
+ "BoldItalic" => "BoldItalic".freeze,
125
+ "Light" => "Light".freeze,
126
+ "Medium" => "Medium".freeze,
127
+ "Semibold" => "Semibold".freeze,
128
+ "SemiBold" => "SemiBold".freeze,
129
+ "Black" => "Black".freeze,
130
+ "Thin" => "Thin".freeze,
131
+ "ExtraLight" => "ExtraLight".freeze,
132
+ "Extra Light" => "Extra Light".freeze,
133
+ "ExtraBold" => "ExtraBold".freeze,
134
+ "Extra Bold" => "Extra Bold".freeze,
135
+ "Heavy" => "Heavy".freeze,
136
+ "Book" => "Book".freeze,
137
+ "Roman" => "Roman".freeze,
138
+ "Normal" => "Normal".freeze,
139
+ "Oblique" => "Oblique".freeze,
140
+ "Light Italic" => "Light Italic".freeze,
141
+ "Medium Italic" => "Medium Italic".freeze,
142
+ "Semibold Italic" => "Semibold Italic".freeze,
143
+ "Bold Oblique" => "Bold Oblique".freeze,
144
+ }.freeze
145
+
146
+ # Intern a string using the string pool
147
+ #
148
+ # If the string is in the pool, returns the pooled instance.
149
+ # Otherwise, freezes and returns the original string.
150
+ #
151
+ # @param str [String] The string to intern
152
+ # @return [String] The interned string
153
+ def self.intern_string(str)
154
+ STRING_POOL[str] || str.freeze
155
+ end
77
156
  end
78
157
  end
@@ -0,0 +1,96 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Fontisan
4
+ module Converters
5
+ # Interface module for font format conversion strategies
6
+ #
7
+ # [`ConversionStrategy`](lib/fontisan/converters/conversion_strategy.rb)
8
+ # defines the contract that all conversion strategy classes must implement.
9
+ # This follows the Strategy pattern to enable polymorphic handling of
10
+ # different conversion types (TTF→OTF, OTF→TTF, same-format copying).
11
+ #
12
+ # Each strategy must implement:
13
+ # - convert(font, options) - Perform the actual conversion
14
+ # - supported_conversions - Return array of [source, target] format pairs
15
+ # - validate(font, target_format) - Validate conversion is possible
16
+ #
17
+ # Strategies are selected by [`FormatConverter`](lib/fontisan/converters/format_converter.rb)
18
+ # based on source and target formats.
19
+ #
20
+ # @example Implementing a strategy
21
+ # class MyStrategy
22
+ # include Fontisan::Converters::ConversionStrategy
23
+ #
24
+ # def convert(font, options = {})
25
+ # # Perform conversion
26
+ # tables = {...}
27
+ # tables
28
+ # end
29
+ #
30
+ # def supported_conversions
31
+ # [[:ttf, :otf], [:otf, :ttf]]
32
+ # end
33
+ #
34
+ # def validate(font, target_format)
35
+ # # Validate font can be converted
36
+ # raise Error unless valid
37
+ # end
38
+ # end
39
+ module ConversionStrategy
40
+ # Convert font to target format
41
+ #
42
+ # This method must return a hash of table tags to binary data,
43
+ # which will be assembled into a complete font by FontWriter.
44
+ #
45
+ # @param font [TrueTypeFont, OpenTypeFont] Source font
46
+ # @param options [Hash] Conversion options
47
+ # @return [Hash<String, String>] Map of table tags to binary data
48
+ # @raise [NotImplementedError] If not implemented by strategy
49
+ def convert(font, options = {})
50
+ raise NotImplementedError,
51
+ "#{self.class.name} must implement convert(font, options)"
52
+ end
53
+
54
+ # Get list of supported conversions
55
+ #
56
+ # Returns an array of [source_format, target_format] pairs that
57
+ # this strategy can handle.
58
+ #
59
+ # @return [Array<Array<Symbol>>] Supported conversion pairs
60
+ # @raise [NotImplementedError] If not implemented by strategy
61
+ #
62
+ # @example
63
+ # strategy.supported_conversions
64
+ # # => [[:ttf, :otf], [:otf, :ttf]]
65
+ def supported_conversions
66
+ raise NotImplementedError,
67
+ "#{self.class.name} must implement supported_conversions"
68
+ end
69
+
70
+ # Validate that conversion is possible
71
+ #
72
+ # Checks if the given font can be converted to the target format.
73
+ # Should raise an error with a clear message if conversion is not
74
+ # possible.
75
+ #
76
+ # @param font [TrueTypeFont, OpenTypeFont] Font to validate
77
+ # @param target_format [Symbol] Target format (:ttf, :otf, etc.)
78
+ # @return [Boolean] True if valid
79
+ # @raise [Error] If conversion is not possible
80
+ # @raise [NotImplementedError] If not implemented by strategy
81
+ def validate(font, target_format)
82
+ raise NotImplementedError,
83
+ "#{self.class.name} must implement validate(font, target_format)"
84
+ end
85
+
86
+ # Check if strategy supports a conversion
87
+ #
88
+ # @param source_format [Symbol] Source format
89
+ # @param target_format [Symbol] Target format
90
+ # @return [Boolean] True if supported
91
+ def supports?(source_format, target_format)
92
+ supported_conversions.include?([source_format, target_format])
93
+ end
94
+ end
95
+ end
96
+ end
@@ -0,0 +1,408 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "conversion_strategy"
4
+ require_relative "table_copier"
5
+ require_relative "outline_converter"
6
+ require_relative "woff_writer"
7
+ require_relative "woff2_encoder"
8
+ require_relative "svg_generator"
9
+ require "yaml"
10
+
11
+ module Fontisan
12
+ module Converters
13
+ # Main orchestrator for font format conversions
14
+ #
15
+ # [`FormatConverter`](lib/fontisan/converters/format_converter.rb) is the
16
+ # primary entry point for all format conversion operations. It:
17
+ # - Selects appropriate conversion strategy based on source/target formats
18
+ # - Validates conversions against the conversion matrix
19
+ # - Delegates actual conversion to strategy implementations
20
+ # - Provides clean error messages for unsupported conversions
21
+ #
22
+ # The converter uses a strategy pattern with pluggable strategies for
23
+ # different conversion types:
24
+ # - OutlineConverter: TTF ↔ OTF conversions
25
+ # - TableCopier: Same-format operations
26
+ # - Woff2Encoder: TTF/OTF → WOFF2 compression
27
+ # - SvgGenerator: TTF/OTF → SVG font generation
28
+ #
29
+ # Supported conversions are defined in the conversion matrix configuration
30
+ # file, making it easy to extend without modifying code.
31
+ #
32
+ # @example Converting TTF to OTF
33
+ # converter = Fontisan::Converters::FormatConverter.new
34
+ # tables = converter.convert(font, :otf)
35
+ # FontWriter.write_to_file(tables, 'output.otf',
36
+ # sfnt_version: 0x4F54544F)
37
+ #
38
+ # @example Same-format copy
39
+ # converter = Fontisan::Converters::FormatConverter.new
40
+ # tables = converter.convert(font, :ttf) # TTF to TTF
41
+ # FontWriter.write_to_file(tables, 'copy.ttf')
42
+ class FormatConverter
43
+ # @return [Hash] Conversion matrix loaded from config
44
+ attr_reader :conversion_matrix
45
+
46
+ # @return [Array] Available conversion strategies
47
+ attr_reader :strategies
48
+
49
+ # Initialize converter with strategies
50
+ #
51
+ # @param conversion_matrix_path [String, nil] Path to conversion matrix
52
+ # config. If nil, uses default.
53
+ def initialize(conversion_matrix_path: nil)
54
+ @strategies = [
55
+ TableCopier.new,
56
+ OutlineConverter.new,
57
+ WoffWriter.new,
58
+ Woff2Encoder.new,
59
+ SvgGenerator.new,
60
+ ]
61
+
62
+ load_conversion_matrix(conversion_matrix_path)
63
+ end
64
+
65
+ # Convert font to target format
66
+ #
67
+ # This is the main entry point for format conversion. It:
68
+ # 1. Detects source format from font
69
+ # 2. Validates conversion is supported
70
+ # 3. Selects appropriate strategy
71
+ # 4. Delegates conversion to strategy
72
+ #
73
+ # @param font [TrueTypeFont, OpenTypeFont] Source font
74
+ # @param target_format [Symbol] Target format (:ttf, :otf, :woff2, :svg)
75
+ # @param options [Hash] Additional conversion options
76
+ # @option options [Boolean] :preserve_variation Preserve variation data
77
+ # (default: true)
78
+ # @option options [Boolean] :preserve_hints Preserve rendering hints
79
+ # (default: false)
80
+ # @option options [Hash] :instance_coordinates Coordinates for variable→SVG
81
+ # @option options [Integer] :instance_index Named instance index for variable→SVG
82
+ # @return [Hash<String, String>] Map of table tags to binary data
83
+ # @raise [ArgumentError] If parameters are invalid
84
+ # @raise [Error] If conversion is not supported
85
+ #
86
+ # @example
87
+ # tables = converter.convert(font, :otf)
88
+ #
89
+ # @example Variable font to SVG at specific weight
90
+ # result = converter.convert(variable_font, :svg, instance_coordinates: { "wght" => 700.0 })
91
+ #
92
+ # @example Convert with hint preservation
93
+ # tables = converter.convert(font, :otf, preserve_hints: true)
94
+ def convert(font, target_format, options = {})
95
+ validate_parameters!(font, target_format)
96
+
97
+ source_format = detect_format(font)
98
+ validate_conversion_supported!(source_format, target_format)
99
+
100
+ # Special case: Variable font to SVG
101
+ if variable_font?(font) && target_format == :svg
102
+ return convert_variable_to_svg(font, options)
103
+ end
104
+
105
+ strategy = select_strategy(source_format, target_format)
106
+ tables = strategy.convert(font, options.merge(target_format: target_format))
107
+
108
+ # Preserve variation data if requested and font is variable
109
+ if options.fetch(:preserve_variation, true) && variable_font?(font)
110
+ tables = preserve_variation_data(
111
+ font,
112
+ tables,
113
+ source_format,
114
+ target_format,
115
+ options,
116
+ )
117
+ end
118
+
119
+ tables
120
+ end
121
+
122
+ # Check if a conversion is supported
123
+ #
124
+ # @param source_format [Symbol] Source format
125
+ # @param target_format [Symbol] Target format
126
+ # @return [Boolean] True if conversion is supported
127
+ def supported?(source_format, target_format)
128
+ return false unless conversion_matrix
129
+
130
+ conversions = conversion_matrix["conversions"]
131
+ return false unless conversions
132
+
133
+ conversions.any? do |conv|
134
+ conv["from"] == source_format.to_s &&
135
+ conv["to"] == target_format.to_s
136
+ end
137
+ end
138
+
139
+ # Get list of supported target formats for a source format
140
+ #
141
+ # @param source_format [Symbol] Source format
142
+ # @return [Array<Symbol>] Supported target formats
143
+ def supported_targets(source_format)
144
+ return [] unless conversion_matrix
145
+
146
+ conversions = conversion_matrix["conversions"]
147
+ return [] unless conversions
148
+
149
+ conversions
150
+ .select { |conv| conv["from"] == source_format.to_s }
151
+ .map { |conv| conv["to"].to_sym }
152
+ end
153
+
154
+ # Get all supported conversions
155
+ #
156
+ # @return [Array<Hash>] Array of conversion hashes with :from and :to
157
+ def all_conversions
158
+ return [] unless conversion_matrix
159
+
160
+ conversions = conversion_matrix["conversions"]
161
+ return [] unless conversions
162
+
163
+ conversions.map do |conv|
164
+ { from: conv["from"].to_sym, to: conv["to"].to_sym }
165
+ end
166
+ end
167
+
168
+ private
169
+
170
+ # Convert variable font to SVG at specific coordinates
171
+ #
172
+ # @param font [TrueTypeFont, OpenTypeFont] Variable font
173
+ # @param options [Hash] Conversion options
174
+ # @option options [Hash] :instance_coordinates Design space coordinates
175
+ # @option options [Integer] :instance_index Named instance index
176
+ # @return [Hash] Hash with :svg_xml key
177
+ def convert_variable_to_svg(font, options = {})
178
+ require_relative "../variation/variable_svg_generator"
179
+
180
+ coordinates = options[:instance_coordinates] || {}
181
+ generator = Variation::VariableSvgGenerator.new(font, coordinates)
182
+
183
+ # Use named instance if specified
184
+ if options[:instance_index]
185
+ generator.generate_named_instance(options[:instance_index], options)
186
+ else
187
+ generator.generate(options)
188
+ end
189
+ end
190
+
191
+ # Check if font is a variable font
192
+ #
193
+ # @param font [TrueTypeFont, OpenTypeFont] Font to check
194
+ # @return [Boolean] True if font has fvar table
195
+ def variable_font?(font)
196
+ font.has_table?("fvar")
197
+ end
198
+
199
+ # Preserve variation data from source to target
200
+ #
201
+ # @param font [TrueTypeFont, OpenTypeFont] Source font
202
+ # @param tables [Hash<String, String>] Target tables
203
+ # @param source_format [Symbol] Source format
204
+ # @param target_format [Symbol] Target format
205
+ # @param options [Hash] Preservation options
206
+ # @return [Hash<String, String>] Tables with variation preserved
207
+ def preserve_variation_data(font, tables, source_format, target_format, options)
208
+ # Case 1: Compatible formats (same outline format) - just copy tables
209
+ if compatible_variation_formats?(source_format, target_format)
210
+ require_relative "../variation/variation_preserver"
211
+ Variation::VariationPreserver.preserve(font, tables, options)
212
+
213
+ # Case 2: Different outline formats - convert variation data
214
+ elsif convertible_variation_formats?(source_format, target_format)
215
+ convert_variation_data(font, tables, source_format, target_format, options)
216
+
217
+ # Case 3: Unsupported conversion
218
+ else
219
+ if options[:preserve_variation]
220
+ raise Fontisan::Error,
221
+ "Cannot preserve variation data for " \
222
+ "#{source_format} → #{target_format}"
223
+ end
224
+ tables
225
+ end
226
+ end
227
+
228
+ # Check if formats have compatible variation (same outline format)
229
+ #
230
+ # @param source [Symbol] Source format
231
+ # @param target [Symbol] Target format
232
+ # @return [Boolean] True if compatible
233
+ def compatible_variation_formats?(source, target)
234
+ # Same format (copy operation)
235
+ return true if source == target
236
+
237
+ # Same outline format (just packaging change)
238
+ (source == :ttf && target == :woff) ||
239
+ (source == :otf && target == :woff) ||
240
+ (source == :woff && target == :ttf) ||
241
+ (source == :woff && target == :otf) ||
242
+ (source == :ttf && target == :woff2) ||
243
+ (source == :otf && target == :woff2)
244
+ end
245
+
246
+ # Check if formats allow variation conversion (different outline formats)
247
+ #
248
+ # @param source [Symbol] Source format
249
+ # @param target [Symbol] Target format
250
+ # @return [Boolean] True if convertible
251
+ def convertible_variation_formats?(source, target)
252
+ # Different outline formats (need variation conversion)
253
+ (source == :ttf && target == :otf) ||
254
+ (source == :otf && target == :ttf)
255
+ end
256
+
257
+ # Convert variation data between outline formats
258
+ #
259
+ # This is a placeholder for full TTF↔OTF variation conversion.
260
+ # Full implementation would:
261
+ # 1. Use Variation::Converter to convert gvar ↔ CFF2 blend
262
+ # 2. Build appropriate variation tables for target format
263
+ # 3. Preserve common tables (fvar, avar, STAT, metrics)
264
+ #
265
+ # @param font [TrueTypeFont, OpenTypeFont] Source font
266
+ # @param tables [Hash<String, String>] Target tables
267
+ # @param source_format [Symbol] Source format
268
+ # @param target_format [Symbol] Target format
269
+ # @param options [Hash] Conversion options
270
+ # @return [Hash<String, String>] Tables with converted variation
271
+ def convert_variation_data(font, tables, source_format, target_format, _options)
272
+ require_relative "../variation/variation_preserver"
273
+ require_relative "../variation/converter"
274
+
275
+ # For now, just preserve common tables and warn about conversion
276
+ warn "WARNING: Full variation conversion (#{source_format} → " \
277
+ "#{target_format}) not yet implemented. " \
278
+ "Preserving common variation tables only."
279
+
280
+ # Preserve common tables (fvar, avar, STAT) but not format-specific
281
+ Variation::VariationPreserver.preserve(
282
+ font,
283
+ tables,
284
+ preserve_format_specific: false,
285
+ preserve_metrics: true,
286
+ )
287
+ end
288
+
289
+ # Load conversion matrix from YAML config
290
+ #
291
+ # @param path [String, nil] Path to config file
292
+ def load_conversion_matrix(path)
293
+ config_path = path || default_conversion_matrix_path
294
+
295
+ @conversion_matrix = if File.exist?(config_path)
296
+ YAML.load_file(config_path)
297
+ else
298
+ # Use default inline matrix if file doesn't exist
299
+ default_conversion_matrix
300
+ end
301
+ rescue StandardError => e
302
+ warn "Failed to load conversion matrix: #{e.message}"
303
+ @conversion_matrix = default_conversion_matrix
304
+ end
305
+
306
+ # Get default conversion matrix path
307
+ #
308
+ # @return [String] Path to conversion matrix config
309
+ def default_conversion_matrix_path
310
+ File.join(
311
+ __dir__,
312
+ "..",
313
+ "config",
314
+ "conversion_matrix.yml",
315
+ )
316
+ end
317
+
318
+ # Get default conversion matrix (fallback)
319
+ #
320
+ # @return [Hash] Default conversion matrix
321
+ def default_conversion_matrix
322
+ {
323
+ "conversions" => [
324
+ { "from" => "ttf", "to" => "ttf" },
325
+ { "from" => "otf", "to" => "otf" },
326
+ { "from" => "ttf", "to" => "otf" },
327
+ { "from" => "otf", "to" => "ttf" },
328
+ ],
329
+ }
330
+ end
331
+
332
+ # Validate conversion parameters
333
+ #
334
+ # @param font [Object] Font object
335
+ # @param target_format [Symbol] Target format
336
+ # @raise [ArgumentError] If parameters are invalid
337
+ def validate_parameters!(font, target_format)
338
+ raise ArgumentError, "Font cannot be nil" if font.nil?
339
+
340
+ unless font.respond_to?(:table)
341
+ raise ArgumentError, "Font must respond to :table method"
342
+ end
343
+
344
+ unless target_format.is_a?(Symbol)
345
+ raise ArgumentError,
346
+ "target_format must be a Symbol, got: #{target_format.class}"
347
+ end
348
+ end
349
+
350
+ # Validate conversion is supported
351
+ #
352
+ # @param source_format [Symbol] Source format
353
+ # @param target_format [Symbol] Target format
354
+ # @raise [Error] If conversion is not supported
355
+ def validate_conversion_supported!(source_format, target_format)
356
+ unless supported?(source_format, target_format)
357
+ available = supported_targets(source_format)
358
+ message = "Conversion from #{source_format} to #{target_format} " \
359
+ "is not supported."
360
+ message += if available.any?
361
+ " Available targets for #{source_format}: " \
362
+ "#{available.join(', ')}"
363
+ else
364
+ " No conversions available from #{source_format}."
365
+ end
366
+ raise Fontisan::Error, message
367
+ end
368
+ end
369
+
370
+ # Select conversion strategy
371
+ #
372
+ # @param source_format [Symbol] Source format
373
+ # @param target_format [Symbol] Target format
374
+ # @return [ConversionStrategy] Selected strategy
375
+ # @raise [Error] If no strategy supports the conversion
376
+ def select_strategy(source_format, target_format)
377
+ strategy = strategies.find do |s|
378
+ s.supports?(source_format, target_format)
379
+ end
380
+
381
+ unless strategy
382
+ raise Fontisan::Error,
383
+ "No strategy available for #{source_format} → #{target_format}"
384
+ end
385
+
386
+ strategy
387
+ end
388
+
389
+ # Detect font format from tables
390
+ #
391
+ # @param font [TrueTypeFont, OpenTypeFont] Font to detect
392
+ # @return [Symbol] Format (:ttf or :otf)
393
+ # @raise [Error] If format cannot be detected
394
+ def detect_format(font)
395
+ # Check for CFF/CFF2 tables (OpenType/CFF)
396
+ if font.has_table?("CFF ") || font.has_table?("CFF2")
397
+ :otf
398
+ # Check for glyf table (TrueType)
399
+ elsif font.has_table?("glyf")
400
+ :ttf
401
+ else
402
+ raise Fontisan::Error,
403
+ "Cannot detect font format: missing both CFF and glyf tables"
404
+ end
405
+ end
406
+ end
407
+ end
408
+ end