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,278 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Fontisan
4
+ module Svg
5
+ # Generates SVG font-face element with font metadata
6
+ #
7
+ # [`FontFaceGenerator`](lib/fontisan/svg/font_face_generator.rb) extracts
8
+ # font metadata from font tables and formats it as SVG font-face attributes.
9
+ # This includes font family, style, weight, units-per-em, ascent, descent,
10
+ # and other font-level metrics.
11
+ #
12
+ # Responsibilities:
13
+ # - Extract font metadata from name, head, hhea, OS/2 tables
14
+ # - Format metadata as SVG font-face attributes
15
+ # - Handle missing or invalid metadata gracefully
16
+ # - Provide sensible defaults
17
+ #
18
+ # This class separates metadata extraction from XML generation, following
19
+ # separation of concerns principle.
20
+ #
21
+ # @example Generate font-face attributes
22
+ # generator = FontFaceGenerator.new(font)
23
+ # attributes = generator.generate_attributes
24
+ # # => { font_family: "Arial", units_per_em: 1000, ... }
25
+ class FontFaceGenerator
26
+ # @return [TrueTypeFont, OpenTypeFont] Font instance
27
+ attr_reader :font
28
+
29
+ # Initialize generator with font
30
+ #
31
+ # @param font [TrueTypeFont, OpenTypeFont] Font to extract metadata from
32
+ # @raise [ArgumentError] If font is nil or invalid
33
+ def initialize(font)
34
+ raise ArgumentError, "Font cannot be nil" if font.nil?
35
+
36
+ unless font.respond_to?(:table)
37
+ raise ArgumentError, "Font must respond to :table method"
38
+ end
39
+
40
+ @font = font
41
+ end
42
+
43
+ # Generate font-face attributes
44
+ #
45
+ # Returns a hash of font-face attributes suitable for SVG rendering.
46
+ # All values are properly formatted for SVG.
47
+ #
48
+ # @return [Hash<Symbol, Object>] Font-face attributes
49
+ def generate_attributes
50
+ {
51
+ font_family: extract_font_family,
52
+ font_weight: extract_font_weight,
53
+ font_style: extract_font_style,
54
+ units_per_em: extract_units_per_em,
55
+ ascent: extract_ascent,
56
+ descent: extract_descent,
57
+ x_height: extract_x_height,
58
+ cap_height: extract_cap_height,
59
+ bbox: extract_bbox,
60
+ underline_position: extract_underline_position,
61
+ underline_thickness: extract_underline_thickness,
62
+ }
63
+ end
64
+
65
+ # Generate font-face element as XML string
66
+ #
67
+ # @param indent [String] Indentation string (default: " ")
68
+ # @return [String] XML font-face element
69
+ def generate_xml(indent: " ")
70
+ attrs = generate_attributes
71
+
72
+ # Build attribute string
73
+ attr_parts = []
74
+ attr_parts << "font-family=\"#{escape_xml(attrs[:font_family])}\""
75
+ attr_parts << "units-per-em=\"#{attrs[:units_per_em]}\""
76
+ attr_parts << "ascent=\"#{attrs[:ascent]}\""
77
+ attr_parts << "descent=\"#{attrs[:descent]}\""
78
+
79
+ # Optional attributes
80
+ attr_parts << "font-weight=\"#{attrs[:font_weight]}\"" if attrs[:font_weight]
81
+ attr_parts << "font-style=\"#{attrs[:font_style]}\"" if attrs[:font_style]
82
+ attr_parts << "x-height=\"#{attrs[:x_height]}\"" if attrs[:x_height]
83
+ attr_parts << "cap-height=\"#{attrs[:cap_height]}\"" if attrs[:cap_height]
84
+ attr_parts << "bbox=\"#{attrs[:bbox]}\"" if attrs[:bbox]
85
+ attr_parts << "underline-position=\"#{attrs[:underline_position]}\"" if attrs[:underline_position]
86
+ attr_parts << "underline-thickness=\"#{attrs[:underline_thickness]}\"" if attrs[:underline_thickness]
87
+
88
+ "#{indent}<font-face #{attr_parts.join(' ')}/>"
89
+ end
90
+
91
+ private
92
+
93
+ # Extract font family name from name table
94
+ #
95
+ # @return [String] Font family name
96
+ def extract_font_family
97
+ name_table = font.table("name")
98
+ return "Unknown" unless name_table
99
+
100
+ # Try to get font family name (name ID 1)
101
+ family_name = name_table.font_family.first
102
+ return family_name if family_name && !family_name.empty?
103
+
104
+ # Fallback to full font name (name ID 4)
105
+ full_name = name_table.font_name.first
106
+ return full_name if full_name && !full_name.empty?
107
+
108
+ "Unknown"
109
+ rescue StandardError
110
+ "Unknown"
111
+ end
112
+
113
+ # Extract font weight from OS/2 table
114
+ #
115
+ # @return [Integer, nil] Font weight (100-900) or nil
116
+ def extract_font_weight
117
+ os2 = font.table("OS/2")
118
+ return nil unless os2
119
+
120
+ weight = os2.weight_class
121
+ return nil unless weight&.positive?
122
+
123
+ weight
124
+ rescue StandardError
125
+ nil
126
+ end
127
+
128
+ # Extract font style from OS/2 or name table
129
+ #
130
+ # @return [String, nil] Font style ("normal", "italic", "oblique") or nil
131
+ def extract_font_style
132
+ os2 = font.table("OS/2")
133
+ if os2
134
+ # Check italic bit in fsSelection
135
+ fs_selection = os2.fs_selection
136
+ return "italic" if fs_selection && (fs_selection & 0x01) != 0
137
+ end
138
+
139
+ # Check name table for style
140
+ name_table = font.table("name")
141
+ if name_table
142
+ subfamily = name_table.font_subfamily.first
143
+ return "italic" if subfamily&.match?(/italic/i)
144
+ return "oblique" if subfamily&.match?(/oblique/i)
145
+ end
146
+
147
+ "normal"
148
+ rescue StandardError
149
+ "normal"
150
+ end
151
+
152
+ # Extract units per em from head table
153
+ #
154
+ # @return [Integer] Units per em (default: 1000)
155
+ def extract_units_per_em
156
+ head = font.table("head")
157
+ return 1000 unless head
158
+
159
+ units = head.units_per_em
160
+ return 1000 unless units
161
+
162
+ units.to_i
163
+ end
164
+
165
+ # Extract ascent from hhea table
166
+ #
167
+ # @return [Integer] Font ascent
168
+ def extract_ascent
169
+ hhea = font.table("hhea")
170
+ return 800 unless hhea
171
+
172
+ ascent = hhea.ascent
173
+ return 800 unless ascent
174
+
175
+ ascent.to_i
176
+ end
177
+
178
+ # Extract descent from hhea table
179
+ #
180
+ # @return [Integer] Font descent (typically negative)
181
+ def extract_descent
182
+ hhea = font.table("hhea")
183
+ return -200 unless hhea
184
+
185
+ descent = hhea.descent
186
+ return -200 unless descent
187
+
188
+ descent.to_i
189
+ end
190
+
191
+ # Extract x-height from OS/2 table
192
+ #
193
+ # @return [Integer, nil] X-height or nil
194
+ def extract_x_height
195
+ os2 = font.table("OS/2")
196
+ return nil unless os2
197
+
198
+ x_height = os2.x_height
199
+ return nil unless x_height&.positive?
200
+
201
+ x_height
202
+ rescue StandardError
203
+ nil
204
+ end
205
+
206
+ # Extract cap-height from OS/2 table
207
+ #
208
+ # @return [Integer, nil] Cap height or nil
209
+ def extract_cap_height
210
+ os2 = font.table("OS/2")
211
+ return nil unless os2
212
+
213
+ cap_height = os2.cap_height
214
+ return nil unless cap_height&.positive?
215
+
216
+ cap_height
217
+ rescue StandardError
218
+ nil
219
+ end
220
+
221
+ # Extract bounding box from head table
222
+ #
223
+ # @return [String, nil] Bounding box "xMin yMin xMax yMax" or nil
224
+ def extract_bbox
225
+ head = font.table("head")
226
+ return nil unless head
227
+
228
+ # SVG font bbox format: "xMin yMin xMax yMax"
229
+ "#{head.x_min} #{head.y_min} #{head.x_max} #{head.y_max}"
230
+ rescue StandardError
231
+ nil
232
+ end
233
+
234
+ # Extract underline position from post table
235
+ #
236
+ # @return [Integer, nil] Underline position or nil
237
+ def extract_underline_position
238
+ post = font.table("post")
239
+ return nil unless post
240
+
241
+ position = post.underline_position
242
+ return nil unless position
243
+
244
+ position
245
+ rescue StandardError
246
+ nil
247
+ end
248
+
249
+ # Extract underline thickness from post table
250
+ #
251
+ # @return [Integer, nil] Underline thickness or nil
252
+ def extract_underline_thickness
253
+ post = font.table("post")
254
+ return nil unless post
255
+
256
+ thickness = post.underline_thickness
257
+ return nil unless thickness&.positive?
258
+
259
+ thickness
260
+ rescue StandardError
261
+ nil
262
+ end
263
+
264
+ # Escape XML special characters
265
+ #
266
+ # @param text [String] Text to escape
267
+ # @return [String] Escaped text
268
+ def escape_xml(text)
269
+ text.to_s
270
+ .gsub("&", "&amp;")
271
+ .gsub("<", "&lt;")
272
+ .gsub(">", "&gt;")
273
+ .gsub("\"", "&quot;")
274
+ .gsub("'", "&apos;")
275
+ end
276
+ end
277
+ end
278
+ end
@@ -0,0 +1,264 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "font_face_generator"
4
+ require_relative "glyph_generator"
5
+ require_relative "view_box_calculator"
6
+
7
+ module Fontisan
8
+ module Svg
9
+ # Generates complete SVG font XML structure
10
+ #
11
+ # [`FontGenerator`](lib/fontisan/svg/font_generator.rb) orchestrates all
12
+ # SVG font generation components to produce a complete SVG font document.
13
+ # It coordinates FontFaceGenerator, GlyphGenerator, and ViewBoxCalculator
14
+ # to build valid SVG font XML.
15
+ #
16
+ # Responsibilities:
17
+ # - Generate complete SVG font XML structure
18
+ # - Coordinate sub-generators (font-face, glyphs)
19
+ # - Create proper XML namespaces and structure
20
+ # - Handle font ID and default advance width
21
+ # - Format XML with proper indentation
22
+ #
23
+ # This is the main orchestrator for SVG font generation, following the
24
+ # single responsibility principle by delegating specific tasks to
25
+ # specialized generators.
26
+ #
27
+ # @example Generate complete SVG font
28
+ # generator = FontGenerator.new(font, glyph_data)
29
+ # svg_xml = generator.generate
30
+ # File.write("font.svg", svg_xml)
31
+ class FontGenerator
32
+ # @return [TrueTypeFont, OpenTypeFont] Font instance
33
+ attr_reader :font
34
+
35
+ # @return [Hash] Glyph data map (glyph_id => {outline, unicode, name, advance})
36
+ attr_reader :glyph_data
37
+
38
+ # @return [Hash] Generation options
39
+ attr_reader :options
40
+
41
+ # Initialize generator
42
+ #
43
+ # @param font [TrueTypeFont, OpenTypeFont] Font to generate SVG for
44
+ # @param glyph_data [Hash] Glyph data map
45
+ # @param options [Hash] Generation options
46
+ # @option options [Boolean] :pretty_print Pretty print XML (default: true)
47
+ # @option options [String] :font_id Font ID for SVG (default: from font name)
48
+ # @option options [Integer] :default_advance Default advance width (default: 500)
49
+ # @raise [ArgumentError] If font or glyph_data is invalid
50
+ def initialize(font, glyph_data, options = {})
51
+ validate_parameters!(font, glyph_data)
52
+
53
+ @font = font
54
+ @glyph_data = glyph_data
55
+ @options = default_options.merge(options)
56
+ end
57
+
58
+ # Generate complete SVG font XML
59
+ #
60
+ # Creates a complete SVG document with embedded font definition.
61
+ # The structure follows SVG 1.1 font specification.
62
+ #
63
+ # @return [String] Complete SVG font XML
64
+ def generate
65
+ parts = []
66
+ parts << xml_declaration
67
+ parts << svg_opening_tag
68
+ parts << " <defs>"
69
+ parts << generate_font_element
70
+ parts << " </defs>"
71
+ parts << svg_closing_tag
72
+
73
+ parts.join("\n")
74
+ end
75
+
76
+ private
77
+
78
+ # Validate initialization parameters
79
+ #
80
+ # @param font [Object] Font to validate
81
+ # @param glyph_data [Object] Glyph data to validate
82
+ # @raise [ArgumentError] If validation fails
83
+ def validate_parameters!(font, glyph_data)
84
+ raise ArgumentError, "Font cannot be nil" if font.nil?
85
+
86
+ unless font.respond_to?(:table)
87
+ raise ArgumentError, "Font must respond to :table method"
88
+ end
89
+
90
+ unless glyph_data.is_a?(Hash)
91
+ raise ArgumentError,
92
+ "glyph_data must be a Hash, got: #{glyph_data.class}"
93
+ end
94
+ end
95
+
96
+ # Get default options
97
+ #
98
+ # @return [Hash] Default options
99
+ def default_options
100
+ {
101
+ pretty_print: true,
102
+ font_id: nil,
103
+ default_advance: 500,
104
+ }
105
+ end
106
+
107
+ # Generate XML declaration
108
+ #
109
+ # @return [String] XML declaration
110
+ def xml_declaration
111
+ '<?xml version="1.0" encoding="UTF-8"?>'
112
+ end
113
+
114
+ # Generate SVG opening tag
115
+ #
116
+ # @return [String] SVG opening tag with namespaces
117
+ def svg_opening_tag
118
+ '<svg xmlns="http://www.w3.org/2000/svg">'
119
+ end
120
+
121
+ # Generate SVG closing tag
122
+ #
123
+ # @return [String] SVG closing tag
124
+ def svg_closing_tag
125
+ "</svg>"
126
+ end
127
+
128
+ # Generate font element with all glyphs
129
+ #
130
+ # @return [String] Complete font element
131
+ def generate_font_element
132
+ parts = []
133
+
134
+ # Font opening tag
135
+ font_id = options[:font_id] || extract_font_id
136
+ default_advance = options[:default_advance]
137
+ parts << " <font id=\"#{escape_xml(font_id)}\" horiz-adv-x=\"#{default_advance}\">"
138
+
139
+ # Font-face element
140
+ parts << generate_font_face
141
+
142
+ # Missing glyph
143
+ parts << generate_missing_glyph
144
+
145
+ # All glyphs
146
+ parts << generate_glyphs
147
+
148
+ # Font closing tag
149
+ parts << " </font>"
150
+
151
+ parts.join("\n")
152
+ end
153
+
154
+ # Generate font-face element
155
+ #
156
+ # @return [String] Font-face XML
157
+ def generate_font_face
158
+ face_generator = FontFaceGenerator.new(font)
159
+ face_generator.generate_xml(indent: " ")
160
+ end
161
+
162
+ # Generate missing-glyph element
163
+ #
164
+ # @return [String] Missing-glyph XML
165
+ def generate_missing_glyph
166
+ calculator = create_calculator
167
+ glyph_generator = GlyphGenerator.new(calculator)
168
+ glyph_generator.generate_missing_glyph(
169
+ advance_width: options[:default_advance],
170
+ indent: " ",
171
+ )
172
+ end
173
+
174
+ # Generate all glyph elements
175
+ #
176
+ # @return [String] All glyph XML elements
177
+ def generate_glyphs
178
+ calculator = create_calculator
179
+ glyph_generator = GlyphGenerator.new(calculator)
180
+
181
+ glyph_xmls = glyph_data.map do |_glyph_id, data|
182
+ next unless data[:outline]
183
+
184
+ glyph_generator.generate_glyph_xml(
185
+ data[:outline],
186
+ unicode: data[:unicode],
187
+ glyph_name: data[:name],
188
+ advance_width: data[:advance],
189
+ indent: " ",
190
+ )
191
+ end
192
+
193
+ glyph_xmls.compact.join("\n")
194
+ end
195
+
196
+ # Create ViewBoxCalculator
197
+ #
198
+ # @return [ViewBoxCalculator] Calculator instance
199
+ def create_calculator
200
+ head = font.table("head")
201
+ hhea = font.table("hhea")
202
+
203
+ units_per_em = head&.units_per_em&.to_i || 1000
204
+ ascent = hhea&.ascent&.to_i || 800
205
+ descent = hhea&.descent&.to_i || -200
206
+
207
+ ViewBoxCalculator.new(
208
+ units_per_em: units_per_em,
209
+ ascent: ascent,
210
+ descent: descent,
211
+ )
212
+ end
213
+
214
+ # Extract font ID from font name
215
+ #
216
+ # @return [String] Font ID
217
+ def extract_font_id
218
+ name_table = font.table("name")
219
+ return "Font" unless name_table
220
+
221
+ # Try PostScript name first (name ID 6)
222
+ ps_name = name_table.postscript_name.first
223
+ return sanitize_font_id(ps_name) if ps_name && !ps_name.empty?
224
+
225
+ # Fallback to font family (name ID 1)
226
+ family_name = name_table.font_family.first
227
+ return sanitize_font_id(family_name) if family_name && !family_name.empty?
228
+
229
+ "Font"
230
+ rescue StandardError
231
+ "Font"
232
+ end
233
+
234
+ # Sanitize font ID for XML
235
+ #
236
+ # @param name [String] Font name
237
+ # @return [String] Sanitized ID
238
+ def sanitize_font_id(name)
239
+ # Remove invalid XML ID characters
240
+ # XML IDs must start with letter or underscore
241
+ # Can contain letters, digits, hyphens, underscores, periods
242
+ sanitized = name.gsub(/[^a-zA-Z0-9\-_.]/, "_")
243
+
244
+ # Ensure starts with letter or underscore
245
+ sanitized = "_#{sanitized}" if /\A[^a-zA-Z_]/.match?(sanitized)
246
+
247
+ sanitized
248
+ end
249
+
250
+ # Escape XML special characters
251
+ #
252
+ # @param text [String] Text to escape
253
+ # @return [String] Escaped text
254
+ def escape_xml(text)
255
+ text.to_s
256
+ .gsub("&", "&amp;")
257
+ .gsub("<", "&lt;")
258
+ .gsub(">", "&gt;")
259
+ .gsub("\"", "&quot;")
260
+ .gsub("'", "&apos;")
261
+ end
262
+ end
263
+ end
264
+ end
@@ -0,0 +1,168 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "view_box_calculator"
4
+
5
+ module Fontisan
6
+ module Svg
7
+ # Generates SVG glyph elements from glyph outlines
8
+ #
9
+ # [`GlyphGenerator`](lib/fontisan/svg/glyph_generator.rb) converts
10
+ # [`GlyphOutline`](lib/fontisan/models/glyph_outline.rb) objects to SVG
11
+ # `<glyph>` elements with proper path data and coordinate transformations.
12
+ #
13
+ # Responsibilities:
14
+ # - Transform glyph outline to SVG path with Y-axis flip
15
+ # - Generate SVG glyph element with attributes
16
+ # - Handle unicode and glyph name mapping
17
+ # - Calculate horizontal advance width
18
+ # - Format path data with proper precision
19
+ #
20
+ # This class uses ViewBoxCalculator for coordinate transformations and
21
+ # GlyphOutline's to_svg_path method for path generation.
22
+ #
23
+ # @example Generate SVG glyph element
24
+ # generator = GlyphGenerator.new(calculator)
25
+ # xml = generator.generate_glyph_xml(outline, unicode: "A", advance_width: 600)
26
+ class GlyphGenerator
27
+ # @return [ViewBoxCalculator] Coordinate calculator
28
+ attr_reader :calculator
29
+
30
+ # Initialize generator with calculator
31
+ #
32
+ # @param calculator [ViewBoxCalculator] Coordinate transformation calculator
33
+ # @raise [ArgumentError] If calculator is nil
34
+ def initialize(calculator)
35
+ raise ArgumentError, "Calculator cannot be nil" if calculator.nil?
36
+
37
+ @calculator = calculator
38
+ end
39
+
40
+ # Generate SVG glyph element
41
+ #
42
+ # @param outline [Models::GlyphOutline] Glyph outline
43
+ # @param unicode [String, nil] Unicode character
44
+ # @param glyph_name [String, nil] Glyph name
45
+ # @param advance_width [Integer] Horizontal advance width
46
+ # @param indent [String] Indentation string
47
+ # @return [String] XML glyph element
48
+ def generate_glyph_xml(outline, unicode: nil, glyph_name: nil,
49
+ advance_width: 0, indent: " ")
50
+ # Build attribute parts
51
+ attr_parts = []
52
+
53
+ attr_parts << "unicode=\"#{escape_xml(unicode)}\"" if unicode
54
+ attr_parts << "glyph-name=\"#{escape_xml(glyph_name)}\"" if glyph_name
55
+ attr_parts << "horiz-adv-x=\"#{advance_width}\"" if advance_width&.positive?
56
+
57
+ # Generate SVG path with Y-axis transformation
58
+ path_data = generate_svg_path(outline)
59
+ attr_parts << "d=\"#{path_data}\"" if path_data && !path_data.empty?
60
+
61
+ "#{indent}<glyph #{attr_parts.join(' ')}/>"
62
+ end
63
+
64
+ # Generate missing-glyph element
65
+ #
66
+ # @param advance_width [Integer] Default advance width
67
+ # @param indent [String] Indentation string
68
+ # @return [String] XML missing-glyph element
69
+ def generate_missing_glyph(advance_width: 500, indent: " ")
70
+ "#{indent}<missing-glyph horiz-adv-x=\"#{advance_width}\"/>"
71
+ end
72
+
73
+ # Generate SVG path data with coordinate transformation
74
+ #
75
+ # Transforms the glyph outline from font space to SVG space by flipping
76
+ # the Y-axis. Font coordinates use Y-up (ascender positive), while SVG
77
+ # uses Y-down (origin at top).
78
+ #
79
+ # @param outline [Models::GlyphOutline] Glyph outline
80
+ # @return [String] SVG path data
81
+ def generate_svg_path(outline)
82
+ return "" if outline.empty?
83
+
84
+ path_parts = outline.contours.map do |contour|
85
+ build_transformed_contour_path(contour)
86
+ end
87
+
88
+ path_parts.join(" ")
89
+ end
90
+
91
+ private
92
+
93
+ # Build SVG path for a contour with Y-axis transformation
94
+ #
95
+ # @param contour [Array<Hash>] Array of point hashes
96
+ # @return [String] SVG path string for this contour
97
+ def build_transformed_contour_path(contour)
98
+ return "" if contour.empty?
99
+
100
+ parts = []
101
+ i = 0
102
+
103
+ # Move to first point (with Y-axis flip)
104
+ first = contour[i]
105
+ svg_y = calculator.transform_y(first[:y])
106
+ parts << "M #{first[:x]} #{svg_y}"
107
+ i += 1
108
+
109
+ # Process remaining points
110
+ while i < contour.length
111
+ point = contour[i]
112
+
113
+ if point[:on_curve]
114
+ # Line to on-curve point (with Y-axis flip)
115
+ svg_y = calculator.transform_y(point[:y])
116
+ parts << "L #{point[:x]} #{svg_y}"
117
+ i += 1
118
+ else
119
+ # Off-curve point - quadratic curve control point
120
+ control = point
121
+ control_svg_y = calculator.transform_y(control[:y])
122
+ i += 1
123
+
124
+ if i < contour.length && !contour[i][:on_curve]
125
+ # Two consecutive off-curve points
126
+ # Implied on-curve point at midpoint
127
+ next_control = contour[i]
128
+ implied_x = (control[:x] + next_control[:x]) / 2.0
129
+ implied_y = (control[:y] + next_control[:y]) / 2.0
130
+ implied_svg_y = calculator.transform_y(implied_y)
131
+ parts << "Q #{control[:x]} #{control_svg_y} #{implied_x} #{implied_svg_y}"
132
+ elsif i < contour.length
133
+ # Next point is on-curve - end of quadratic curve
134
+ end_point = contour[i]
135
+ end_svg_y = calculator.transform_y(end_point[:y])
136
+ parts << "Q #{control[:x]} #{control_svg_y} #{end_point[:x]} #{end_svg_y}"
137
+ i += 1
138
+ else
139
+ # Off-curve point is last - curves back to first point
140
+ first_svg_y = calculator.transform_y(first[:y])
141
+ parts << "Q #{control[:x]} #{control_svg_y} #{first[:x]} #{first_svg_y}"
142
+ end
143
+ end
144
+ end
145
+
146
+ # Close path
147
+ parts << "Z"
148
+
149
+ parts.join(" ")
150
+ end
151
+
152
+ # Escape XML special characters
153
+ #
154
+ # @param text [String, nil] Text to escape
155
+ # @return [String] Escaped text
156
+ def escape_xml(text)
157
+ return "" if text.nil?
158
+
159
+ text.to_s
160
+ .gsub("&", "&amp;")
161
+ .gsub("<", "&lt;")
162
+ .gsub(">", "&gt;")
163
+ .gsub("\"", "&quot;")
164
+ .gsub("'", "&apos;")
165
+ end
166
+ end
167
+ end
168
+ end