fontisan 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (185) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop_todo.yml +529 -65
  3. data/Gemfile +1 -0
  4. data/LICENSE +5 -1
  5. data/README.adoc +1301 -275
  6. data/Rakefile +27 -2
  7. data/benchmark/variation_quick_bench.rb +47 -0
  8. data/docs/EXTRACT_TTC_MIGRATION.md +549 -0
  9. data/fontisan.gemspec +4 -1
  10. data/lib/fontisan/binary/base_record.rb +22 -1
  11. data/lib/fontisan/cli.rb +309 -0
  12. data/lib/fontisan/collection/builder.rb +260 -0
  13. data/lib/fontisan/collection/offset_calculator.rb +227 -0
  14. data/lib/fontisan/collection/table_analyzer.rb +204 -0
  15. data/lib/fontisan/collection/table_deduplicator.rb +241 -0
  16. data/lib/fontisan/collection/writer.rb +306 -0
  17. data/lib/fontisan/commands/base_command.rb +8 -1
  18. data/lib/fontisan/commands/convert_command.rb +291 -0
  19. data/lib/fontisan/commands/export_command.rb +161 -0
  20. data/lib/fontisan/commands/info_command.rb +40 -6
  21. data/lib/fontisan/commands/instance_command.rb +295 -0
  22. data/lib/fontisan/commands/ls_command.rb +113 -0
  23. data/lib/fontisan/commands/pack_command.rb +241 -0
  24. data/lib/fontisan/commands/subset_command.rb +245 -0
  25. data/lib/fontisan/commands/unpack_command.rb +338 -0
  26. data/lib/fontisan/commands/validate_command.rb +178 -0
  27. data/lib/fontisan/commands/variable_command.rb +30 -1
  28. data/lib/fontisan/config/collection_settings.yml +56 -0
  29. data/lib/fontisan/config/conversion_matrix.yml +212 -0
  30. data/lib/fontisan/config/export_settings.yml +66 -0
  31. data/lib/fontisan/config/subset_profiles.yml +100 -0
  32. data/lib/fontisan/config/svg_settings.yml +60 -0
  33. data/lib/fontisan/config/validation_rules.yml +149 -0
  34. data/lib/fontisan/config/variable_settings.yml +99 -0
  35. data/lib/fontisan/config/woff2_settings.yml +77 -0
  36. data/lib/fontisan/constants.rb +69 -0
  37. data/lib/fontisan/converters/conversion_strategy.rb +96 -0
  38. data/lib/fontisan/converters/format_converter.rb +259 -0
  39. data/lib/fontisan/converters/outline_converter.rb +936 -0
  40. data/lib/fontisan/converters/svg_generator.rb +244 -0
  41. data/lib/fontisan/converters/table_copier.rb +117 -0
  42. data/lib/fontisan/converters/woff2_encoder.rb +416 -0
  43. data/lib/fontisan/converters/woff_writer.rb +391 -0
  44. data/lib/fontisan/error.rb +203 -0
  45. data/lib/fontisan/export/exporter.rb +262 -0
  46. data/lib/fontisan/export/table_serializer.rb +255 -0
  47. data/lib/fontisan/export/transformers/font_to_ttx.rb +172 -0
  48. data/lib/fontisan/export/transformers/head_transformer.rb +96 -0
  49. data/lib/fontisan/export/transformers/hhea_transformer.rb +59 -0
  50. data/lib/fontisan/export/transformers/maxp_transformer.rb +63 -0
  51. data/lib/fontisan/export/transformers/name_transformer.rb +63 -0
  52. data/lib/fontisan/export/transformers/os2_transformer.rb +121 -0
  53. data/lib/fontisan/export/transformers/post_transformer.rb +51 -0
  54. data/lib/fontisan/export/ttx_generator.rb +527 -0
  55. data/lib/fontisan/export/ttx_parser.rb +300 -0
  56. data/lib/fontisan/font_loader.rb +121 -12
  57. data/lib/fontisan/font_writer.rb +301 -0
  58. data/lib/fontisan/formatters/text_formatter.rb +102 -0
  59. data/lib/fontisan/glyph_accessor.rb +503 -0
  60. data/lib/fontisan/hints/hint_converter.rb +177 -0
  61. data/lib/fontisan/hints/postscript_hint_applier.rb +185 -0
  62. data/lib/fontisan/hints/postscript_hint_extractor.rb +254 -0
  63. data/lib/fontisan/hints/truetype_hint_applier.rb +71 -0
  64. data/lib/fontisan/hints/truetype_hint_extractor.rb +162 -0
  65. data/lib/fontisan/loading_modes.rb +113 -0
  66. data/lib/fontisan/metrics_calculator.rb +277 -0
  67. data/lib/fontisan/models/collection_font_summary.rb +52 -0
  68. data/lib/fontisan/models/collection_info.rb +76 -0
  69. data/lib/fontisan/models/collection_list_info.rb +37 -0
  70. data/lib/fontisan/models/font_export.rb +158 -0
  71. data/lib/fontisan/models/font_summary.rb +48 -0
  72. data/lib/fontisan/models/glyph_outline.rb +343 -0
  73. data/lib/fontisan/models/hint.rb +233 -0
  74. data/lib/fontisan/models/outline.rb +664 -0
  75. data/lib/fontisan/models/table_sharing_info.rb +40 -0
  76. data/lib/fontisan/models/ttx/glyph_order.rb +31 -0
  77. data/lib/fontisan/models/ttx/tables/binary_table.rb +67 -0
  78. data/lib/fontisan/models/ttx/tables/head_table.rb +74 -0
  79. data/lib/fontisan/models/ttx/tables/hhea_table.rb +74 -0
  80. data/lib/fontisan/models/ttx/tables/maxp_table.rb +55 -0
  81. data/lib/fontisan/models/ttx/tables/name_table.rb +45 -0
  82. data/lib/fontisan/models/ttx/tables/os2_table.rb +157 -0
  83. data/lib/fontisan/models/ttx/tables/post_table.rb +50 -0
  84. data/lib/fontisan/models/ttx/ttfont.rb +49 -0
  85. data/lib/fontisan/models/validation_report.rb +203 -0
  86. data/lib/fontisan/open_type_collection.rb +156 -2
  87. data/lib/fontisan/open_type_font.rb +296 -10
  88. data/lib/fontisan/optimizers/charstring_rewriter.rb +161 -0
  89. data/lib/fontisan/optimizers/pattern_analyzer.rb +308 -0
  90. data/lib/fontisan/optimizers/stack_tracker.rb +246 -0
  91. data/lib/fontisan/optimizers/subroutine_builder.rb +134 -0
  92. data/lib/fontisan/optimizers/subroutine_generator.rb +207 -0
  93. data/lib/fontisan/optimizers/subroutine_optimizer.rb +107 -0
  94. data/lib/fontisan/outline_extractor.rb +423 -0
  95. data/lib/fontisan/subset/builder.rb +268 -0
  96. data/lib/fontisan/subset/glyph_mapping.rb +215 -0
  97. data/lib/fontisan/subset/options.rb +142 -0
  98. data/lib/fontisan/subset/profile.rb +152 -0
  99. data/lib/fontisan/subset/table_subsetter.rb +461 -0
  100. data/lib/fontisan/svg/font_face_generator.rb +278 -0
  101. data/lib/fontisan/svg/font_generator.rb +264 -0
  102. data/lib/fontisan/svg/glyph_generator.rb +168 -0
  103. data/lib/fontisan/svg/view_box_calculator.rb +137 -0
  104. data/lib/fontisan/tables/cff/cff_glyph.rb +176 -0
  105. data/lib/fontisan/tables/cff/charset.rb +282 -0
  106. data/lib/fontisan/tables/cff/charstring.rb +905 -0
  107. data/lib/fontisan/tables/cff/charstring_builder.rb +322 -0
  108. data/lib/fontisan/tables/cff/charstrings_index.rb +162 -0
  109. data/lib/fontisan/tables/cff/dict.rb +351 -0
  110. data/lib/fontisan/tables/cff/dict_builder.rb +242 -0
  111. data/lib/fontisan/tables/cff/encoding.rb +274 -0
  112. data/lib/fontisan/tables/cff/header.rb +102 -0
  113. data/lib/fontisan/tables/cff/index.rb +237 -0
  114. data/lib/fontisan/tables/cff/index_builder.rb +170 -0
  115. data/lib/fontisan/tables/cff/private_dict.rb +284 -0
  116. data/lib/fontisan/tables/cff/top_dict.rb +236 -0
  117. data/lib/fontisan/tables/cff.rb +487 -0
  118. data/lib/fontisan/tables/cff2/blend_operator.rb +240 -0
  119. data/lib/fontisan/tables/cff2/charstring_parser.rb +591 -0
  120. data/lib/fontisan/tables/cff2/operand_stack.rb +232 -0
  121. data/lib/fontisan/tables/cff2.rb +341 -0
  122. data/lib/fontisan/tables/cvar.rb +242 -0
  123. data/lib/fontisan/tables/fvar.rb +2 -2
  124. data/lib/fontisan/tables/glyf/compound_glyph.rb +483 -0
  125. data/lib/fontisan/tables/glyf/compound_glyph_resolver.rb +136 -0
  126. data/lib/fontisan/tables/glyf/curve_converter.rb +343 -0
  127. data/lib/fontisan/tables/glyf/glyph_builder.rb +450 -0
  128. data/lib/fontisan/tables/glyf/simple_glyph.rb +382 -0
  129. data/lib/fontisan/tables/glyf.rb +235 -0
  130. data/lib/fontisan/tables/gvar.rb +270 -0
  131. data/lib/fontisan/tables/hhea.rb +124 -0
  132. data/lib/fontisan/tables/hmtx.rb +287 -0
  133. data/lib/fontisan/tables/hvar.rb +191 -0
  134. data/lib/fontisan/tables/loca.rb +322 -0
  135. data/lib/fontisan/tables/maxp.rb +192 -0
  136. data/lib/fontisan/tables/mvar.rb +185 -0
  137. data/lib/fontisan/tables/name.rb +99 -30
  138. data/lib/fontisan/tables/variation_common.rb +346 -0
  139. data/lib/fontisan/tables/vvar.rb +234 -0
  140. data/lib/fontisan/true_type_collection.rb +156 -2
  141. data/lib/fontisan/true_type_font.rb +297 -11
  142. data/lib/fontisan/utilities/brotli_wrapper.rb +159 -0
  143. data/lib/fontisan/utilities/checksum_calculator.rb +18 -0
  144. data/lib/fontisan/utils/thread_pool.rb +134 -0
  145. data/lib/fontisan/validation/checksum_validator.rb +170 -0
  146. data/lib/fontisan/validation/consistency_validator.rb +197 -0
  147. data/lib/fontisan/validation/structure_validator.rb +198 -0
  148. data/lib/fontisan/validation/table_validator.rb +158 -0
  149. data/lib/fontisan/validation/validator.rb +152 -0
  150. data/lib/fontisan/variable/axis_normalizer.rb +215 -0
  151. data/lib/fontisan/variable/delta_applicator.rb +313 -0
  152. data/lib/fontisan/variable/glyph_delta_processor.rb +218 -0
  153. data/lib/fontisan/variable/instancer.rb +344 -0
  154. data/lib/fontisan/variable/metric_delta_processor.rb +282 -0
  155. data/lib/fontisan/variable/region_matcher.rb +208 -0
  156. data/lib/fontisan/variable/static_font_builder.rb +213 -0
  157. data/lib/fontisan/variable/table_updater.rb +219 -0
  158. data/lib/fontisan/variation/blend_applier.rb +199 -0
  159. data/lib/fontisan/variation/cache.rb +298 -0
  160. data/lib/fontisan/variation/cache_key_builder.rb +162 -0
  161. data/lib/fontisan/variation/converter.rb +268 -0
  162. data/lib/fontisan/variation/data_extractor.rb +86 -0
  163. data/lib/fontisan/variation/delta_applier.rb +266 -0
  164. data/lib/fontisan/variation/delta_parser.rb +228 -0
  165. data/lib/fontisan/variation/inspector.rb +275 -0
  166. data/lib/fontisan/variation/instance_generator.rb +273 -0
  167. data/lib/fontisan/variation/interpolator.rb +231 -0
  168. data/lib/fontisan/variation/metrics_adjuster.rb +318 -0
  169. data/lib/fontisan/variation/optimizer.rb +418 -0
  170. data/lib/fontisan/variation/parallel_generator.rb +150 -0
  171. data/lib/fontisan/variation/region_matcher.rb +221 -0
  172. data/lib/fontisan/variation/subsetter.rb +463 -0
  173. data/lib/fontisan/variation/table_accessor.rb +105 -0
  174. data/lib/fontisan/variation/validator.rb +345 -0
  175. data/lib/fontisan/variation/variation_context.rb +211 -0
  176. data/lib/fontisan/version.rb +1 -1
  177. data/lib/fontisan/woff2/directory.rb +257 -0
  178. data/lib/fontisan/woff2/header.rb +101 -0
  179. data/lib/fontisan/woff2/table_transformer.rb +163 -0
  180. data/lib/fontisan/woff2_font.rb +712 -0
  181. data/lib/fontisan/woff_font.rb +483 -0
  182. data/lib/fontisan.rb +120 -0
  183. data/scripts/compare_stack_aware.rb +187 -0
  184. data/scripts/measure_optimization.rb +141 -0
  185. metadata +205 -4
@@ -0,0 +1,262 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../models/font_export"
4
+ require_relative "table_serializer"
5
+ require_relative "ttx_generator"
6
+ require_relative "transformers/font_to_ttx"
7
+ require_relative "../utilities/checksum_calculator"
8
+
9
+ module Fontisan
10
+ module Export
11
+ # Exporter orchestrates font export to YAML/JSON/TTX
12
+ #
13
+ # Main entry point for exporting fonts to debugging formats.
14
+ # Handles table extraction, serialization, and metadata generation.
15
+ #
16
+ # @example Exporting a font to YAML
17
+ # exporter = Exporter.new(font, "font.ttf")
18
+ # export = exporter.export(format: :yaml)
19
+ # File.write("font.yaml", export.to_yaml)
20
+ #
21
+ # @example Exporting to TTX format
22
+ # exporter = Exporter.new(font, "font.ttf")
23
+ # ttx_xml = exporter.to_ttx
24
+ # File.write("font.ttx", ttx_xml)
25
+ #
26
+ # @example Selective table export
27
+ # export = exporter.export(tables: ["head", "name", "cmap"])
28
+ class Exporter
29
+ # Initialize exporter
30
+ #
31
+ # @param font [TrueTypeFont, OpenTypeFont] The font to export
32
+ # @param source_path [String] Path to source font file
33
+ # @param options [Hash] Export options
34
+ # @option options [Symbol] :binary_format Format for binary data (:hex or :base64)
35
+ def initialize(font, source_path, options = {})
36
+ @font = font
37
+ @source_path = source_path
38
+ @binary_format = options.fetch(:binary_format, :hex)
39
+ @serializer = TableSerializer.new(binary_format: @binary_format)
40
+ end
41
+
42
+ # Export font to FontExport model
43
+ #
44
+ # @param options [Hash] Export options
45
+ # @option options [Array<String>] :tables Specific tables to export (default: all)
46
+ # @option options [Symbol] :format Output format (:yaml, :json, or :ttx)
47
+ # @return [Models::FontExport, String] The export model or TTX XML string
48
+ def export(options = {})
49
+ format = options[:format] || :yaml
50
+
51
+ if format == :ttx
52
+ to_ttx(options)
53
+ else
54
+ export_to_model(options)
55
+ end
56
+ end
57
+
58
+ # Export font and return as YAML string
59
+ #
60
+ # @param options [Hash] Export options
61
+ # @return [String] YAML representation
62
+ def to_yaml(options = {})
63
+ export_model = export_to_model(options)
64
+ export_model.to_yaml
65
+ end
66
+
67
+ # Export font and return as JSON string
68
+ #
69
+ # @param options [Hash] Export options
70
+ # @return [String] JSON representation
71
+ def to_json(options = {})
72
+ export_model = export_to_model(options)
73
+ export_model.to_json
74
+ end
75
+
76
+ # Export font and return as TTX XML string
77
+ #
78
+ # Uses model-based architecture with FontToTtx transformer
79
+ # and lutaml-model serialization.
80
+ #
81
+ # @param options [Hash] Export options
82
+ # @option options [Array<String>] :tables Specific tables to export
83
+ # @option options [Boolean] :pretty Pretty-print XML (default: true)
84
+ # @option options [Integer] :indent Indentation spaces (default: 2)
85
+ # @return [String] TTX XML representation
86
+ def to_ttx(options = {})
87
+ # Use new model-based architecture
88
+ transformer = Transformers::FontToTtx.new(@font)
89
+ ttx_model = transformer.transform(options)
90
+
91
+ # Let lutaml-model handle XML serialization
92
+ ttx_model.to_xml(
93
+ pretty: options.fetch(:pretty, true),
94
+ indent: options.fetch(:indent, 2),
95
+ )
96
+ end
97
+
98
+ private
99
+
100
+ # Export to FontExport model
101
+ #
102
+ # @param options [Hash] Export options
103
+ # @return [Models::FontExport] The export model
104
+ def export_to_model(options = {})
105
+ table_list = options[:tables] || :all
106
+
107
+ export_model = Models::FontExport.new
108
+ export_model.metadata = build_metadata
109
+ export_model.header = build_header
110
+
111
+ tables_to_export = select_tables(table_list)
112
+ tables_to_export.each do |tag|
113
+ export_table(export_model, tag)
114
+ end
115
+
116
+ export_model
117
+ end
118
+
119
+ # Build export metadata
120
+ #
121
+ # @return [Models::FontExport::Metadata]
122
+ def build_metadata
123
+ Models::FontExport::Metadata.new.tap do |meta|
124
+ meta.source_file = @source_path
125
+ meta.export_date = Time.now.utc.iso8601
126
+ meta.exporter_version = Fontisan::VERSION
127
+ meta.font_format = detect_font_format
128
+ end
129
+ end
130
+
131
+ # Build font header information
132
+ #
133
+ # @return [Models::FontExport::Header]
134
+ def build_header
135
+ Models::FontExport::Header.new.tap do |header|
136
+ header.sfnt_version = format_sfnt_version(@font.header.sfnt_version.to_i)
137
+ header.num_tables = @font.tables.size
138
+ header.search_range = @font.header.search_range.to_i
139
+ header.entry_selector = @font.header.entry_selector.to_i
140
+ header.range_shift = @font.header.range_shift.to_i
141
+ end
142
+ end
143
+
144
+ # Select tables to export
145
+ #
146
+ # @param table_list [Symbol, Array<String>] :all or list of table tags
147
+ # @return [Array<String>] Table tags to export
148
+ def select_tables(table_list)
149
+ if table_list == :all
150
+ @font.table_names
151
+ else
152
+ available = @font.table_names
153
+ requested = Array(table_list).map(&:to_s)
154
+ requested.select { |tag| available.include?(tag) }
155
+ end
156
+ end
157
+
158
+ # Export a single table
159
+ #
160
+ # @param export_model [Models::FontExport] The export model
161
+ # @param tag [String] The table tag
162
+ # @return [void]
163
+ def export_table(export_model, tag)
164
+ table = @font.table(tag)
165
+ return unless table
166
+
167
+ checksum = calculate_table_checksum(tag)
168
+ serialized = @serializer.serialize(table, tag)
169
+
170
+ export_model.add_table(
171
+ tag: tag,
172
+ checksum: format_checksum(checksum),
173
+ parsed: serialized[:parsed],
174
+ data: serialized[:data],
175
+ fields: serialized[:fields],
176
+ )
177
+ rescue StandardError => e
178
+ # If serialization fails, store as binary
179
+ export_binary_fallback(export_model, tag, e)
180
+ end
181
+
182
+ # Export table as binary fallback on error
183
+ #
184
+ # @param export_model [Models::FontExport] The export model
185
+ # @param tag [String] The table tag
186
+ # @param error [StandardError] The error that occurred
187
+ # @return [void]
188
+ def export_binary_fallback(export_model, tag, error)
189
+ table = @font.table(tag)
190
+ binary_data = table.respond_to?(:to_binary_s) ? table.to_binary_s : ""
191
+ checksum = calculate_table_checksum(tag)
192
+
193
+ export_model.add_table(
194
+ tag: tag,
195
+ checksum: format_checksum(checksum),
196
+ parsed: false,
197
+ data: @serializer.send(:encode_binary, binary_data),
198
+ fields: { error: error.message }.to_json,
199
+ )
200
+ end
201
+
202
+ # Calculate table checksum
203
+ #
204
+ # @param tag [String] Table tag
205
+ # @return [Integer] Checksum value
206
+ def calculate_table_checksum(tag)
207
+ table_entry = @font.tables.find { |entry| entry.tag == tag }
208
+ return 0 unless table_entry
209
+
210
+ if table_entry.respond_to?(:checksum)
211
+ table_entry.checksum.to_i
212
+ else
213
+ # Calculate from binary data
214
+ table = @font.table(tag)
215
+ data = table.respond_to?(:to_binary_s) ? table.to_binary_s : ""
216
+ Utilities::ChecksumCalculator.calculate(data)
217
+ end
218
+ end
219
+
220
+ # Format checksum as hex string
221
+ #
222
+ # @param checksum [Integer] Checksum value
223
+ # @return [String] Hex string (e.g., "0x12345678")
224
+ def format_checksum(checksum)
225
+ "0x#{checksum.to_s(16).upcase.rjust(8, '0')}"
226
+ end
227
+
228
+ # Format SFNT version
229
+ #
230
+ # @param version [Integer] SFNT version
231
+ # @return [String] Formatted version
232
+ def format_sfnt_version(version)
233
+ case version
234
+ when 0x00010000
235
+ "0x00010000 (TrueType)"
236
+ when 0x4F54544F # 'OTTO'
237
+ "0x4F54544F (OpenType/CFF)"
238
+ else
239
+ "0x#{version.to_s(16).upcase}"
240
+ end
241
+ end
242
+
243
+ # Detect font format
244
+ #
245
+ # @return [String] Font format name
246
+ def detect_font_format
247
+ case @font.class.name
248
+ when /TrueType/
249
+ "TrueType"
250
+ when /OpenType/
251
+ "OpenType"
252
+ when /Woff2/
253
+ "WOFF2"
254
+ when /Woff/
255
+ "WOFF"
256
+ else
257
+ "Unknown"
258
+ end
259
+ end
260
+ end
261
+ end
262
+ end
@@ -0,0 +1,255 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "base64"
4
+ require "json"
5
+
6
+ module Fontisan
7
+ module Export
8
+ # TableSerializer handles serialization of individual font tables
9
+ #
10
+ # Uses strategy pattern to serialize different table types:
11
+ # - Fully parsed tables: Use Lutaml::Model serialization
12
+ # - Binary tables: Encode as hex or base64
13
+ # - Special tables: Custom serialization logic
14
+ #
15
+ # @example Serializing a parsed table
16
+ # serializer = TableSerializer.new(binary_format: :hex)
17
+ # data = serializer.serialize(head_table, "head")
18
+ #
19
+ # @example Serializing binary table
20
+ # data = serializer.serialize_binary(raw_data, "DSIG")
21
+ class TableSerializer
22
+ # Tables that have full Lutaml::Model parsing support
23
+ FULLY_PARSED_TABLES = %w[
24
+ head hhea maxp post OS/2 name
25
+ fvar HVAR VVAR MVAR cvar gvar
26
+ ].freeze
27
+
28
+ # Tables that should be stored as binary
29
+ BINARY_ONLY_TABLES = %w[
30
+ cvt fpgm prep gasp DSIG GDEF GPOS GSUB
31
+ ].freeze
32
+
33
+ # Initialize table serializer
34
+ #
35
+ # @param binary_format [Symbol] Format for binary data (:hex or :base64)
36
+ def initialize(binary_format: :hex)
37
+ @binary_format = binary_format
38
+ validate_binary_format!
39
+ end
40
+
41
+ # Serialize a table to exportable format
42
+ #
43
+ # @param table [Object] The table object
44
+ # @param tag [String] The table tag
45
+ # @return [Hash] Serialized table data
46
+ def serialize(table, tag)
47
+ if fully_parsed?(tag)
48
+ serialize_parsed(table, tag)
49
+ elsif binary_only?(tag)
50
+ serialize_binary(table.to_binary_s, tag)
51
+ else
52
+ serialize_mixed(table, tag)
53
+ end
54
+ end
55
+
56
+ # Serialize a parsed table
57
+ #
58
+ # @param table [Object] The table object with Lutaml::Model
59
+ # @param tag [String] The table tag
60
+ # @return [Hash] Serialized data with parsed flag
61
+ def serialize_parsed(table, tag)
62
+ fields = extract_fields(table)
63
+ {
64
+ tag: tag,
65
+ parsed: true,
66
+ fields: fields.to_json,
67
+ data: nil,
68
+ }
69
+ end
70
+
71
+ # Serialize a binary-only table
72
+ #
73
+ # @param data [String] Binary data
74
+ # @param tag [String] The table tag
75
+ # @return [Hash] Serialized data with binary content
76
+ def serialize_binary(data, tag)
77
+ encoded = encode_binary(data)
78
+ {
79
+ tag: tag,
80
+ parsed: false,
81
+ data: encoded,
82
+ fields: nil,
83
+ }
84
+ end
85
+
86
+ # Serialize tables with mixed content (summary + binary)
87
+ #
88
+ # @param table [Object] The table object
89
+ # @param tag [String] The table tag
90
+ # @return [Hash] Serialized data with both fields and binary
91
+ def serialize_mixed(table, tag)
92
+ summary = create_summary(table, tag)
93
+ binary = table.respond_to?(:to_binary_s) ? table.to_binary_s : ""
94
+
95
+ {
96
+ tag: tag,
97
+ parsed: true,
98
+ fields: summary.to_json,
99
+ data: encode_binary(binary),
100
+ }
101
+ end
102
+
103
+ private
104
+
105
+ # Check if table is fully parsed
106
+ #
107
+ # @param tag [String] Table tag
108
+ # @return [Boolean]
109
+ def fully_parsed?(tag)
110
+ FULLY_PARSED_TABLES.include?(tag)
111
+ end
112
+
113
+ # Check if table is binary-only
114
+ #
115
+ # @param tag [String] Table tag
116
+ # @return [Boolean]
117
+ def binary_only?(tag)
118
+ BINARY_ONLY_TABLES.include?(tag)
119
+ end
120
+
121
+ # Extract fields from a parsed table
122
+ #
123
+ # @param table [Object] The table object
124
+ # @return [Hash] Field names and values
125
+ def extract_fields(table)
126
+ fields = {}
127
+
128
+ # Get all instance variables
129
+ table.instance_variables.each do |var|
130
+ name = var.to_s.delete("@")
131
+ value = table.instance_variable_get(var)
132
+ fields[name] = serialize_value(value)
133
+ end
134
+
135
+ fields
136
+ end
137
+
138
+ # Serialize individual field value
139
+ #
140
+ # @param value [Object] The value to serialize
141
+ # @return [Object] Serialized value
142
+ def serialize_value(value)
143
+ case value
144
+ when Integer, Float, String, TrueClass, FalseClass, NilClass
145
+ value
146
+ when Array
147
+ value.map { |v| serialize_value(v) }
148
+ when Hash
149
+ value.transform_values { |v| serialize_value(v) }
150
+ when Time
151
+ value.iso8601
152
+ else
153
+ # For complex objects, try to extract fields
154
+ if value.respond_to?(:instance_variables)
155
+ extract_fields(value)
156
+ else
157
+ value.to_s
158
+ end
159
+ end
160
+ end
161
+
162
+ # Create summary for mixed-content tables
163
+ #
164
+ # @param table [Object] The table object
165
+ # @param tag [String] Table tag
166
+ # @return [Hash] Summary information
167
+ def create_summary(table, tag)
168
+ case tag
169
+ when "glyf"
170
+ create_glyf_summary(table)
171
+ when "loca"
172
+ create_loca_summary(table)
173
+ when "cmap"
174
+ create_cmap_summary(table)
175
+ when "CFF"
176
+ create_cff_summary(table)
177
+ else
178
+ { type: "binary", size: table.to_binary_s.bytesize }
179
+ end
180
+ end
181
+
182
+ # Create glyf table summary
183
+ #
184
+ # @param table [Object] glyf table
185
+ # @return [Hash] Summary
186
+ def create_glyf_summary(table)
187
+ {
188
+ type: "glyf",
189
+ num_glyphs: table.respond_to?(:glyphs) ? table.glyphs.length : 0,
190
+ note: "Outline data stored as binary",
191
+ }
192
+ end
193
+
194
+ # Create loca table summary
195
+ #
196
+ # @param table [Object] loca table
197
+ # @return [Hash] Summary
198
+ def create_loca_summary(table)
199
+ {
200
+ type: "loca",
201
+ num_offsets: table.respond_to?(:offsets) ? table.offsets.length : 0,
202
+ format: table.respond_to?(:format) ? table.format : nil,
203
+ }
204
+ end
205
+
206
+ # Create cmap table summary
207
+ #
208
+ # @param table [Object] cmap table
209
+ # @return [Hash] Summary
210
+ def create_cmap_summary(table)
211
+ {
212
+ type: "cmap",
213
+ version: table.respond_to?(:version) ? table.version : 0,
214
+ note: "Character mappings stored as binary",
215
+ }
216
+ end
217
+
218
+ # Create CFF table summary
219
+ #
220
+ # @param table [Object] CFF table
221
+ # @return [Hash] Summary
222
+ def create_cff_summary(_table)
223
+ {
224
+ type: "CFF",
225
+ note: "CharString data stored as binary",
226
+ }
227
+ end
228
+
229
+ # Encode binary data based on format
230
+ #
231
+ # @param data [String] Binary data
232
+ # @return [String] Encoded data
233
+ def encode_binary(data)
234
+ case @binary_format
235
+ when :hex
236
+ data.unpack1("H*")
237
+ when :base64
238
+ Base64.strict_encode64(data)
239
+ end
240
+ end
241
+
242
+ # Validate binary format option
243
+ #
244
+ # @raise [ArgumentError] if format is invalid
245
+ def validate_binary_format!
246
+ valid_formats = %i[hex base64]
247
+ return if valid_formats.include?(@binary_format)
248
+
249
+ raise ArgumentError,
250
+ "Invalid binary format: #{@binary_format}. " \
251
+ "Must be one of: #{valid_formats.join(', ')}"
252
+ end
253
+ end
254
+ end
255
+ end
@@ -0,0 +1,172 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../../models/ttx/ttfont"
4
+ require_relative "../../models/ttx/glyph_order"
5
+ require_relative "head_transformer"
6
+ require_relative "name_transformer"
7
+ require_relative "os2_transformer"
8
+ require_relative "post_transformer"
9
+ require_relative "hhea_transformer"
10
+ require_relative "maxp_transformer"
11
+
12
+ module Fontisan
13
+ module Export
14
+ module Transformers
15
+ # FontToTtx orchestrates font to TTX transformation
16
+ #
17
+ # Main transformer that coordinates conversion of a complete
18
+ # font to TTX format using individual table transformers.
19
+ # Follows model-to-model transformation principles with
20
+ # clean separation of concerns.
21
+ class FontToTtx
22
+ # Initialize transformer
23
+ #
24
+ # @param font [TrueTypeFont, OpenTypeFont] Source font
25
+ def initialize(font)
26
+ @font = font
27
+ end
28
+
29
+ # Transform font to TTX model
30
+ #
31
+ # @param options [Hash] Transformation options
32
+ # @option options [Array<String>] :tables Specific tables to include
33
+ # @return [Models::Ttx::TtFont] Complete TTX model
34
+ def transform(options = {})
35
+ table_list = options[:tables] || :all
36
+
37
+ Models::Ttx::TtFont.new.tap do |ttx|
38
+ ttx.sfnt_version = format_sfnt_version(@font.header.sfnt_version.to_i)
39
+ ttx.ttlib_version = "4.0"
40
+ ttx.glyph_order = build_glyph_order
41
+
42
+ # Transform specific tables
43
+ tables_to_transform = select_tables(table_list)
44
+ tables_to_transform.each do |tag|
45
+ transform_table(ttx, tag)
46
+ end
47
+ end
48
+ end
49
+
50
+ private
51
+
52
+ # Build glyph order model
53
+ #
54
+ # @return [Models::Ttx::GlyphOrder] Glyph order model
55
+ def build_glyph_order
56
+ Models::Ttx::GlyphOrder.new.tap do |glyph_order|
57
+ glyph_order.glyph_ids = build_glyph_ids
58
+ end
59
+ end
60
+
61
+ # Build glyph ID entries
62
+ #
63
+ # @return [Array<Models::Ttx::GlyphId>] Glyph ID models
64
+ def build_glyph_ids
65
+ Array.new(glyph_count) do |glyph_id|
66
+ Models::Ttx::GlyphId.new.tap do |gid|
67
+ gid.id = glyph_id
68
+ gid.name = get_glyph_name(glyph_id)
69
+ end
70
+ end
71
+ end
72
+
73
+ # Transform individual table
74
+ #
75
+ # @param ttx [Models::Ttx::TtFont] TTX model being built
76
+ # @param tag [String] Table tag
77
+ # @return [void]
78
+ def transform_table(ttx, tag)
79
+ table = @font.table(tag)
80
+ return unless table
81
+
82
+ case tag
83
+ when "head"
84
+ ttx.head_table = HeadTransformer.transform(table)
85
+ when "hhea"
86
+ ttx.hhea_table = HheaTransformer.transform(table)
87
+ when "maxp"
88
+ ttx.maxp_table = MaxpTransformer.transform(table)
89
+ when "name"
90
+ ttx.name_table = NameTransformer.transform(table)
91
+ when "OS/2"
92
+ ttx.os2_table = Os2Transformer.transform(table)
93
+ when "post"
94
+ ttx.post_table = PostTransformer.transform(table)
95
+ else
96
+ # Fallback to binary table
97
+ binary_table = transform_binary_table(tag, table)
98
+ ttx.binary_tables ||= []
99
+ ttx.binary_tables << binary_table if binary_table
100
+ end
101
+ rescue StandardError => e
102
+ # On error, fall back to binary representation
103
+ warn "Error transforming #{tag}: #{e.message}"
104
+ binary_table = transform_binary_table(tag, table)
105
+ ttx.binary_tables ||= []
106
+ ttx.binary_tables << binary_table if binary_table
107
+ end
108
+
109
+ # Transform table to binary representation
110
+ #
111
+ # @param tag [String] Table tag
112
+ # @param table [Object] Table object
113
+ # @return [Models::Ttx::Tables::BinaryTable, nil] Binary table model
114
+ def transform_binary_table(tag, table)
115
+ binary_data = table.respond_to?(:to_binary_s) ? table.to_binary_s : ""
116
+ return nil if binary_data.empty?
117
+
118
+ Models::Ttx::Tables::BinaryTable.new.tap do |bin_table|
119
+ bin_table.tag = tag
120
+ bin_table.hexdata = binary_data
121
+ end
122
+ end
123
+
124
+ # Select tables to transform
125
+ #
126
+ # @param table_list [Symbol, Array<String>] :all or list of tags
127
+ # @return [Array<String>] Table tags to transform
128
+ def select_tables(table_list)
129
+ if table_list == :all
130
+ @font.table_names
131
+ else
132
+ available = @font.table_names
133
+ requested = Array(table_list).map(&:to_s)
134
+ requested.select { |tag| available.include?(tag) }
135
+ end
136
+ end
137
+
138
+ # Get number of glyphs
139
+ #
140
+ # @return [Integer] Number of glyphs
141
+ def glyph_count
142
+ maxp = @font.table("maxp")
143
+ maxp ? maxp.num_glyphs.to_i : 0
144
+ end
145
+
146
+ # Get glyph name by ID
147
+ #
148
+ # @param glyph_id [Integer] Glyph ID
149
+ # @return [String] Glyph name
150
+ def get_glyph_name(glyph_id)
151
+ post = @font.table("post")
152
+ if post.respond_to?(:glyph_names) && post.glyph_names
153
+ post.glyph_names[glyph_id] || ".notdef"
154
+ elsif glyph_id.zero?
155
+ ".notdef"
156
+ else
157
+ "glyph#{glyph_id.to_s.rjust(5, '0')}"
158
+ end
159
+ end
160
+
161
+ # Format SFNT version
162
+ #
163
+ # @param version [Integer] SFNT version
164
+ # @return [String] Formatted version as escaped bytes
165
+ def format_sfnt_version(version)
166
+ bytes = [version].pack("N").bytes
167
+ "\\x#{bytes.map { |b| b.to_s(16).rjust(2, '0') }.join('\\x')}"
168
+ end
169
+ end
170
+ end
171
+ end
172
+ end