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,503 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Fontisan
4
+ # High-level utility class for unified glyph access across font formats
5
+ #
6
+ # [`GlyphAccessor`](lib/fontisan/glyph_accessor.rb) provides a clean, unified
7
+ # interface for accessing glyphs regardless of the underlying font format
8
+ # (TrueType with glyf table or OpenType with CFF table).
9
+ #
10
+ # This class automatically detects the font format and delegates to the
11
+ # appropriate table parser, abstracting away the complexity of different
12
+ # glyph storage mechanisms.
13
+ #
14
+ # Key features:
15
+ # - Unified glyph access by ID, Unicode character, or PostScript name
16
+ # - Automatic format detection (TrueType glyf vs CFF)
17
+ # - Metrics retrieval (advance width, left sidebearing)
18
+ # - Glyph closure calculation for subsetting (tracks composite dependencies)
19
+ # - Validation of glyph IDs and character mappings
20
+ #
21
+ # @example Basic usage
22
+ # font = Fontisan::TrueTypeFont.from_file('font.ttf')
23
+ # accessor = Fontisan::GlyphAccessor.new(font)
24
+ #
25
+ # # Access glyph by ID
26
+ # glyph = accessor.glyph_for_id(42)
27
+ # puts glyph.class # => SimpleGlyph or CompoundGlyph
28
+ #
29
+ # # Access glyph by Unicode character
30
+ # glyph_a = accessor.glyph_for_char(0x0041) # 'A'
31
+ #
32
+ # # Get metrics
33
+ # metrics = accessor.metrics_for_id(42)
34
+ # puts "Width: #{metrics[:advance_width]}, LSB: #{metrics[:lsb]}"
35
+ #
36
+ # @example Subsetting workflow with closure
37
+ # # Calculate all glyphs needed (including composite dependencies)
38
+ # base_glyphs = [0, 1, 65, 66, 67] # .notdef, A, B, C
39
+ # all_glyphs = accessor.closure_for(base_glyphs)
40
+ # puts "Total glyphs needed: #{all_glyphs.size}"
41
+ #
42
+ # Reference: [`docs/ttfunk-feature-analysis.md:541-575`](docs/ttfunk-feature-analysis.md:541)
43
+ class GlyphAccessor
44
+ # Font instance this accessor operates on
45
+ # @return [TrueTypeFont, OpenTypeFont]
46
+ attr_reader :font
47
+
48
+ # Initialize a new glyph accessor
49
+ #
50
+ # @param font [TrueTypeFont, OpenTypeFont] Font instance to access
51
+ # @raise [ArgumentError] If font is nil or doesn't respond to table method
52
+ def initialize(font)
53
+ raise ArgumentError, "Font cannot be nil" if font.nil?
54
+
55
+ unless font.respond_to?(:table)
56
+ raise ArgumentError, "Font must respond to :table method"
57
+ end
58
+
59
+ @font = font
60
+ @glyph_cache = {}
61
+ @closure_cache = {}
62
+ end
63
+
64
+ # Get glyph object for a glyph ID
65
+ #
66
+ # Returns the appropriate glyph object based on the font format:
67
+ # - TrueType fonts: [`SimpleGlyph`](lib/fontisan/tables/glyf/simple_glyph.rb)
68
+ # or [`CompoundGlyph`](lib/fontisan/tables/glyf/compound_glyph.rb)
69
+ # - CFF fonts: [`CFFGlyph`](lib/fontisan/tables/cff/cff_glyph.rb)
70
+ #
71
+ # @param glyph_id [Integer] Glyph ID (0-based, 0 is .notdef)
72
+ # @return [SimpleGlyph, CompoundGlyph, CFFGlyph, nil] Glyph object or nil
73
+ # if glyph is empty or invalid
74
+ # @raise [ArgumentError] If glyph_id is invalid
75
+ # @raise [Fontisan::MissingTableError] If required tables are missing
76
+ #
77
+ # @example Get a glyph
78
+ # glyph = accessor.glyph_for_id(65)
79
+ # if glyph
80
+ # puts "Bounding box: #{glyph.bounding_box}"
81
+ # puts "Type: #{glyph.simple? ? 'simple' : 'compound'}"
82
+ # end
83
+ def glyph_for_id(glyph_id)
84
+ validate_glyph_id!(glyph_id)
85
+
86
+ return @glyph_cache[glyph_id] if @glyph_cache.key?(glyph_id)
87
+
88
+ glyph = if truetype?
89
+ truetype_glyph(glyph_id)
90
+ elsif cff?
91
+ cff_glyph(glyph_id)
92
+ else
93
+ raise Fontisan::MissingTableError,
94
+ "Font has neither glyf nor CFF table"
95
+ end
96
+
97
+ @glyph_cache[glyph_id] = glyph
98
+ end
99
+
100
+ # Get glyph object for a Unicode character code
101
+ #
102
+ # Uses the cmap table to map the character code to a glyph ID,
103
+ # then retrieves the corresponding glyph.
104
+ #
105
+ # @param char_code [Integer] Unicode character code (e.g., 0x0041 for 'A')
106
+ # @return [SimpleGlyph, CompoundGlyph, nil] Glyph object or nil if
107
+ # character is not mapped
108
+ # @raise [Fontisan::MissingTableError] If cmap table is missing
109
+ #
110
+ # @example Get glyph for 'A'
111
+ # glyph_a = accessor.glyph_for_char(0x0041)
112
+ # glyph_a = accessor.glyph_for_char('A'.ord) # Equivalent
113
+ def glyph_for_char(char_code)
114
+ glyph_id = char_to_glyph_id(char_code)
115
+ return nil unless glyph_id
116
+
117
+ glyph_for_id(glyph_id)
118
+ end
119
+
120
+ # Get glyph object for a PostScript glyph name
121
+ #
122
+ # Uses the post table (if available) to map the glyph name to a glyph ID.
123
+ # This method is primarily useful for fonts with post table version 2.0.
124
+ #
125
+ # @param glyph_name [String] PostScript glyph name (e.g., "A", "Aacute")
126
+ # @return [SimpleGlyph, CompoundGlyph, nil] Glyph object or nil if
127
+ # name is not found
128
+ # @raise [Fontisan::MissingTableError] If post table is missing or
129
+ # unsupported version
130
+ #
131
+ # @example Get glyph by name
132
+ # glyph = accessor.glyph_for_name("A")
133
+ # glyph = accessor.glyph_for_name("Aacute")
134
+ def glyph_for_name(glyph_name)
135
+ glyph_id = name_to_glyph_id(glyph_name)
136
+ return nil unless glyph_id
137
+
138
+ glyph_for_id(glyph_id)
139
+ end
140
+
141
+ # Get horizontal metrics for a glyph ID
142
+ #
143
+ # Returns a hash with advance width and left sidebearing in font units.
144
+ #
145
+ # @param glyph_id [Integer] Glyph ID
146
+ # @return [Hash{Symbol => Integer}, nil] Hash with :advance_width and
147
+ # :lsb keys, or nil if glyph is invalid
148
+ # @raise [Fontisan::MissingTableError] If hmtx table is missing or not parsed
149
+ #
150
+ # @example Get metrics
151
+ # metrics = accessor.metrics_for_id(65) # 'A'
152
+ # puts "Advance width: #{metrics[:advance_width]} FUnits"
153
+ # puts "Left sidebearing: #{metrics[:lsb]} FUnits"
154
+ def metrics_for_id(glyph_id)
155
+ validate_glyph_id!(glyph_id)
156
+
157
+ hmtx = font.table("hmtx")
158
+ raise_missing_table!("hmtx") unless hmtx
159
+
160
+ unless hmtx.parsed?
161
+ # Auto-parse if not already parsed
162
+ parse_hmtx_with_context!(hmtx)
163
+ end
164
+
165
+ hmtx.metric_for(glyph_id)
166
+ end
167
+
168
+ # Get horizontal metrics for a Unicode character
169
+ #
170
+ # @param char_code [Integer] Unicode character code
171
+ # @return [Hash{Symbol => Integer}, nil] Metrics hash or nil if not mapped
172
+ # @raise [Fontisan::MissingTableError] If required tables are missing
173
+ #
174
+ # @example Get metrics for 'A'
175
+ # metrics = accessor.metrics_for_char(0x0041)
176
+ def metrics_for_char(char_code)
177
+ glyph_id = char_to_glyph_id(char_code)
178
+ return nil unless glyph_id
179
+
180
+ metrics_for_id(glyph_id)
181
+ end
182
+
183
+ # Get outline for glyph by ID
184
+ #
185
+ # Extracts the complete outline data for a glyph, including all contours,
186
+ # points, and bounding box information. The outline can be converted to
187
+ # SVG paths or drawing commands for rendering.
188
+ #
189
+ # This method uses [`OutlineExtractor`](lib/fontisan/outline_extractor.rb)
190
+ # to handle both TrueType (glyf) and CFF outline formats transparently.
191
+ # For compound glyphs, it recursively resolves component dependencies.
192
+ #
193
+ # @param glyph_id [Integer] Glyph ID (0-based, 0 is .notdef)
194
+ # @return [Models::GlyphOutline, nil] Outline object or nil if glyph is
195
+ # empty or invalid
196
+ # @raise [ArgumentError] If glyph_id is invalid
197
+ # @raise [Fontisan::MissingTableError] If required tables are missing
198
+ #
199
+ # @example Get outline for a glyph
200
+ # outline = accessor.outline_for_id(65) # 'A'
201
+ # if outline
202
+ # puts "Contours: #{outline.contour_count}"
203
+ # puts "Points: #{outline.point_count}"
204
+ # puts "SVG: #{outline.to_svg_path}"
205
+ # end
206
+ def outline_for_id(glyph_id)
207
+ extractor = OutlineExtractor.new(@font)
208
+ extractor.extract(glyph_id)
209
+ end
210
+
211
+ # Get outline for Unicode codepoint
212
+ #
213
+ # Maps a Unicode codepoint to a glyph ID via the cmap table, then
214
+ # extracts the outline for that glyph.
215
+ #
216
+ # @param codepoint [Integer] Unicode codepoint (e.g., 0x0041 for 'A')
217
+ # @return [Models::GlyphOutline, nil] Outline object or nil if character
218
+ # is not mapped or glyph is empty
219
+ # @raise [Fontisan::MissingTableError] If required tables are missing
220
+ #
221
+ # @example Get outline for 'A'
222
+ # outline = accessor.outline_for_codepoint(0x0041)
223
+ # svg_path = outline.to_svg_path if outline
224
+ def outline_for_codepoint(codepoint)
225
+ glyph_id = char_to_glyph_id(codepoint)
226
+ return nil unless glyph_id
227
+
228
+ outline_for_id(glyph_id)
229
+ end
230
+
231
+ # Get outline for character
232
+ #
233
+ # Convenience method that takes a character string and extracts its
234
+ # outline. The character is converted to its Unicode codepoint first.
235
+ #
236
+ # @param char [String] Single character (e.g., 'A', '中', '😀')
237
+ # @return [Models::GlyphOutline, nil] Outline object or nil if character
238
+ # is not mapped or glyph is empty
239
+ # @raise [ArgumentError] If char is not a single character
240
+ # @raise [Fontisan::MissingTableError] If required tables are missing
241
+ #
242
+ # @example Get outline for 'A'
243
+ # outline = accessor.outline_for_char('A')
244
+ # commands = outline.to_commands if outline
245
+ #
246
+ # @example Handle multi-codepoint characters
247
+ # outline = accessor.outline_for_char('A') # Works
248
+ # outline = accessor.outline_for_char('AB') # ArgumentError
249
+ def outline_for_char(char)
250
+ unless char.is_a?(String) && char.length == 1
251
+ raise ArgumentError,
252
+ "char must be a single character String, got: #{char.inspect}"
253
+ end
254
+
255
+ outline_for_codepoint(char.ord)
256
+ end
257
+
258
+ # Check if a glyph ID exists and is valid
259
+ #
260
+ # @param glyph_id [Integer] Glyph ID to check
261
+ # @return [Boolean] True if glyph ID is valid
262
+ def glyph_exists?(glyph_id)
263
+ return false if glyph_id.nil? || glyph_id.negative?
264
+
265
+ maxp = font.table("maxp")
266
+ return false unless maxp
267
+
268
+ glyph_id < maxp.num_glyphs
269
+ end
270
+
271
+ # Check if a Unicode character is mapped in the font
272
+ #
273
+ # @param char_code [Integer] Unicode character code
274
+ # @return [Boolean] True if character has a glyph mapping
275
+ def has_glyph_for_char?(char_code)
276
+ !char_to_glyph_id(char_code).nil?
277
+ end
278
+
279
+ # Check if font uses TrueType outlines (glyf table)
280
+ #
281
+ # @return [Boolean] True if font has glyf table
282
+ def truetype?
283
+ font.table("glyf") != nil
284
+ end
285
+
286
+ # Check if font uses CFF outlines (CFF table)
287
+ #
288
+ # @return [Boolean] True if font has CFF table
289
+ def cff?
290
+ font.table("CFF ") != nil
291
+ end
292
+
293
+ # Calculate glyph closure for subsetting
294
+ #
295
+ # This method recursively tracks all glyphs needed for a given set of
296
+ # glyph IDs, including component glyphs referenced by compound glyphs.
297
+ # This is essential for font subsetting to ensure all required glyphs
298
+ # are included.
299
+ #
300
+ # The closure always includes glyph 0 (.notdef) as required by the
301
+ # OpenType specification.
302
+ #
303
+ # @param glyph_ids [Array<Integer>] Base set of glyph IDs
304
+ # @return [Set<Integer>] Complete set of glyph IDs needed (including
305
+ # composite dependencies)
306
+ # @raise [ArgumentError] If glyph_ids is not an array
307
+ #
308
+ # @example Calculate closure for subsetting
309
+ # # Want to subset to just "ABC"
310
+ # base_glyphs = [65, 66, 67] # Assuming these are glyph IDs for A, B, C
311
+ # all_needed = accessor.closure_for(base_glyphs)
312
+ # # all_needed includes base glyphs + any composite dependencies + .notdef
313
+ #
314
+ # @example Closure with composite glyphs
315
+ # # If 'Ä' (glyph 100) is composite referencing 'A' (glyph 65) and
316
+ # # dieresis (glyph 200)
317
+ # closure = accessor.closure_for([100])
318
+ # # Returns: [0, 100, 65, 200] (includes .notdef, Ä, A, dieresis)
319
+ def closure_for(glyph_ids)
320
+ unless glyph_ids.is_a?(Array)
321
+ raise ArgumentError, "glyph_ids must be an Array"
322
+ end
323
+
324
+ # Start with provided glyphs plus .notdef
325
+ result = Set.new([0])
326
+ glyph_ids.each { |id| result.add(id) if glyph_exists?(id) }
327
+
328
+ # CFF fonts have no composite glyphs, so return early
329
+ return result if cff?
330
+
331
+ # Recursively collect composite dependencies (TrueType only)
332
+ to_process = result.to_a.dup
333
+ processed = Set.new
334
+
335
+ while (glyph_id = to_process.shift)
336
+ next if processed.include?(glyph_id)
337
+
338
+ processed.add(glyph_id)
339
+
340
+ # Get glyph and check if it's compound
341
+ glyph = glyph_for_id(glyph_id)
342
+ next unless glyph
343
+ next unless glyph.respond_to?(:compound?) && glyph.compound?
344
+
345
+ # Add component glyph IDs
346
+ if glyph.respond_to?(:components)
347
+ glyph.components.each do |component|
348
+ component_id = component[:glyph_index]
349
+ next unless glyph_exists?(component_id)
350
+
351
+ unless result.include?(component_id)
352
+ result.add(component_id)
353
+ to_process << component_id
354
+ end
355
+ end
356
+ end
357
+ end
358
+
359
+ result
360
+ end
361
+
362
+ # Clear internal caches to free memory
363
+ #
364
+ # Useful for long-running processes that access many glyphs.
365
+ #
366
+ # @return [void]
367
+ def clear_cache
368
+ @glyph_cache.clear
369
+ @closure_cache.clear
370
+
371
+ # Also clear glyf table cache if present
372
+ glyf = font.table("glyf")
373
+ glyf&.clear_cache if glyf.respond_to?(:clear_cache)
374
+ end
375
+
376
+ private
377
+
378
+ # Validate a glyph ID
379
+ #
380
+ # @param glyph_id [Integer] Glyph ID to validate
381
+ # @raise [ArgumentError] If glyph ID is invalid
382
+ def validate_glyph_id!(glyph_id)
383
+ if glyph_id.nil?
384
+ raise ArgumentError, "glyph_id cannot be nil"
385
+ end
386
+
387
+ if glyph_id.negative?
388
+ raise ArgumentError, "glyph_id must be >= 0, got: #{glyph_id}"
389
+ end
390
+
391
+ unless glyph_exists?(glyph_id)
392
+ maxp = font.table("maxp")
393
+ num_glyphs = maxp ? maxp.num_glyphs : "unknown"
394
+ raise ArgumentError,
395
+ "glyph_id #{glyph_id} exceeds number of glyphs (#{num_glyphs})"
396
+ end
397
+ end
398
+
399
+ # Get TrueType glyph from glyf table
400
+ #
401
+ # @param glyph_id [Integer] Glyph ID
402
+ # @return [SimpleGlyph, CompoundGlyph, nil] Glyph object
403
+ def truetype_glyph(glyph_id)
404
+ glyf = font.table("glyf")
405
+ raise_missing_table!("glyf") unless glyf
406
+
407
+ loca = font.table("loca")
408
+ raise_missing_table!("loca") unless loca
409
+
410
+ head = font.table("head")
411
+ raise_missing_table!("head") unless head
412
+
413
+ # Ensure loca is parsed
414
+ unless loca.parsed?
415
+ parse_loca_with_context!(loca, head)
416
+ end
417
+
418
+ glyf.glyph_for(glyph_id, loca, head)
419
+ end
420
+
421
+ # Get CFF glyph from CFF table
422
+ #
423
+ # @param glyph_id [Integer] Glyph ID
424
+ # @return [CFFGlyph, nil] CFF glyph object or nil if empty
425
+ def cff_glyph(glyph_id)
426
+ cff = font.table(Constants::CFF_TAG)
427
+ raise_missing_table!(Constants::CFF_TAG) unless cff
428
+
429
+ # Get CharString for glyph
430
+ charstring = cff.charstring_for_glyph(glyph_id)
431
+ return nil unless charstring
432
+
433
+ # Get Charset and Encoding
434
+ charset = cff.charset
435
+ encoding = cff.encoding
436
+
437
+ # Wrap in CFFGlyph class
438
+ Tables::Cff::CFFGlyph.new(glyph_id, charstring, charset, encoding)
439
+ rescue StandardError => e
440
+ warn "Failed to get CFF glyph #{glyph_id}: #{e.message}"
441
+ nil
442
+ end
443
+
444
+ # Map character code to glyph ID
445
+ #
446
+ # @param char_code [Integer] Unicode character code
447
+ # @return [Integer, nil] Glyph ID or nil if not mapped
448
+ def char_to_glyph_id(char_code)
449
+ cmap = font.table("cmap")
450
+ raise_missing_table!("cmap") unless cmap
451
+
452
+ cmap.unicode_mappings[char_code]
453
+ end
454
+
455
+ # Map glyph name to glyph ID
456
+ #
457
+ # @param glyph_name [String] PostScript glyph name
458
+ # @return [Integer, nil] Glyph ID or nil if not found
459
+ def name_to_glyph_id(glyph_name)
460
+ post = font.table("post")
461
+ raise_missing_table!("post") unless post
462
+
463
+ # post.glyph_names returns array of names indexed by glyph ID
464
+ names = post.glyph_names
465
+ return nil if names.empty?
466
+
467
+ names.index(glyph_name)
468
+ end
469
+
470
+ # Parse loca table with context
471
+ #
472
+ # @param loca [Loca] Loca table instance
473
+ # @param head [Head] Head table instance
474
+ def parse_loca_with_context!(loca, head)
475
+ maxp = font.table("maxp")
476
+ raise_missing_table!("maxp") unless maxp
477
+
478
+ loca.parse_with_context(head.index_to_loc_format, maxp.num_glyphs)
479
+ end
480
+
481
+ # Parse hmtx table with context
482
+ #
483
+ # @param hmtx [Hmtx] Hmtx table instance
484
+ def parse_hmtx_with_context!(hmtx)
485
+ hhea = font.table("hhea")
486
+ raise_missing_table!("hhea") unless hhea
487
+
488
+ maxp = font.table("maxp")
489
+ raise_missing_table!("maxp") unless maxp
490
+
491
+ hmtx.parse_with_context(hhea.number_of_h_metrics, maxp.num_glyphs)
492
+ end
493
+
494
+ # Raise MissingTableError
495
+ #
496
+ # @param table_tag [String] Table tag
497
+ # @raise [Fontisan::MissingTableError]
498
+ def raise_missing_table!(table_tag)
499
+ raise Fontisan::MissingTableError,
500
+ "Required table '#{table_tag}' not found in font"
501
+ end
502
+ end
503
+ end
@@ -0,0 +1,177 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../models/hint"
4
+
5
+ module Fontisan
6
+ module Hints
7
+ # Converts hints between TrueType and PostScript formats
8
+ #
9
+ # This converter handles bidirectional conversion of rendering hints,
10
+ # translating between TrueType instruction-based hinting and PostScript
11
+ # operator-based hinting while preserving intent where possible.
12
+ #
13
+ # **Conversion Strategy:**
14
+ #
15
+ # - TrueType → PostScript: Extract semantic meaning from instructions
16
+ # and convert to corresponding PostScript operators
17
+ # - PostScript → TrueType: Analyze hint operators and generate
18
+ # equivalent TrueType instructions
19
+ #
20
+ # @example Convert TrueType hints to PostScript
21
+ # converter = HintConverter.new
22
+ # ps_hints = converter.to_postscript(tt_hints)
23
+ #
24
+ # @example Convert PostScript hints to TrueType
25
+ # converter = HintConverter.new
26
+ # tt_hints = converter.to_truetype(ps_hints)
27
+ class HintConverter
28
+ # Convert hints to PostScript format
29
+ #
30
+ # @param hints [Array<Hint>] Source hints (any format)
31
+ # @return [Array<Hint>] Hints in PostScript format
32
+ def to_postscript(hints)
33
+ return [] if hints.nil? || hints.empty?
34
+
35
+ hints.map do |hint|
36
+ next hint if hint.source_format == :postscript
37
+
38
+ convert_hint_to_postscript(hint)
39
+ end.compact
40
+ end
41
+
42
+ # Convert hints to TrueType format
43
+ #
44
+ # @param hints [Array<Hint>] Source hints (any format)
45
+ # @return [Array<Hint>] Hints in TrueType format
46
+ def to_truetype(hints)
47
+ return [] if hints.nil? || hints.empty?
48
+
49
+ hints.map do |hint|
50
+ next hint if hint.source_format == :truetype
51
+
52
+ convert_hint_to_truetype(hint)
53
+ end.compact
54
+ end
55
+
56
+ # Optimize hint set by removing redundant hints
57
+ #
58
+ # @param hints [Array<Hint>] Hints to optimize
59
+ # @return [Array<Hint>] Optimized hints
60
+ def optimize(hints)
61
+ return [] if hints.nil? || hints.empty?
62
+
63
+ # Remove duplicate hints
64
+ unique_hints = hints.uniq { |h| [h.type, h.data] }
65
+
66
+ # Remove conflicting hints (keep first)
67
+ remove_conflicts(unique_hints)
68
+ end
69
+
70
+ private
71
+
72
+ # Convert a single hint to PostScript format
73
+ #
74
+ # @param hint [Hint] Source hint
75
+ # @return [Hint, nil] Converted hint or nil if incompatible
76
+ def convert_hint_to_postscript(hint)
77
+ return nil unless hint.compatible_with?(:postscript)
78
+
79
+ # Get PostScript representation from hint
80
+ ps_data = hint.to_postscript
81
+
82
+ # Create new hint with PostScript format
83
+ Models::Hint.new(
84
+ type: hint.type,
85
+ data: ps_data,
86
+ source_format: :postscript
87
+ )
88
+ rescue StandardError => e
89
+ warn "Failed to convert hint to PostScript: #{e.message}"
90
+ nil
91
+ end
92
+
93
+ # Convert a single hint to TrueType format
94
+ #
95
+ # @param hint [Hint] Source hint
96
+ # @return [Hint, nil] Converted hint or nil if incompatible
97
+ def convert_hint_to_truetype(hint)
98
+ return nil unless hint.compatible_with?(:truetype)
99
+
100
+ # Get TrueType representation from hint
101
+ tt_instructions = hint.to_truetype
102
+
103
+ # Create new hint with TrueType format
104
+ Models::Hint.new(
105
+ type: hint.type,
106
+ data: { instructions: tt_instructions },
107
+ source_format: :truetype
108
+ )
109
+ rescue StandardError => e
110
+ warn "Failed to convert hint to TrueType: #{e.message}"
111
+ nil
112
+ end
113
+
114
+ # Remove conflicting hints from set
115
+ #
116
+ # @param hints [Array<Hint>] Hints to check
117
+ # @return [Array<Hint>] Non-conflicting hints
118
+ def remove_conflicts(hints)
119
+ non_conflicting = []
120
+
121
+ hints.each do |hint|
122
+ # Check if this hint conflicts with any already selected
123
+ conflicts = non_conflicting.any? do |existing|
124
+ hints_conflict?(hint, existing)
125
+ end
126
+
127
+ non_conflicting << hint unless conflicts
128
+ end
129
+
130
+ non_conflicting
131
+ end
132
+
133
+ # Check if two hints conflict
134
+ #
135
+ # @param hint1 [Hint] First hint
136
+ # @param hint2 [Hint] Second hint
137
+ # @return [Boolean] True if hints conflict
138
+ def hints_conflict?(hint1, hint2)
139
+ # Hints of different types don't conflict
140
+ return false if hint1.type != hint2.type
141
+
142
+ case hint1.type
143
+ when :stem
144
+ # Stem hints conflict if they overlap
145
+ stems_overlap?(hint1.data, hint2.data)
146
+ when :interpolate
147
+ # Multiple interpolation hints on same axis conflict
148
+ hint1.data[:axis] == hint2.data[:axis]
149
+ else
150
+ # Other hint types don't conflict
151
+ false
152
+ end
153
+ end
154
+
155
+ # Check if two stem hints overlap
156
+ #
157
+ # @param stem1 [Hash] First stem data
158
+ # @param stem2 [Hash] Second stem data
159
+ # @return [Boolean] True if stems overlap
160
+ def stems_overlap?(stem1, stem2)
161
+ # Must be same orientation to conflict
162
+ return false if stem1[:orientation] != stem2[:orientation]
163
+
164
+ pos1 = stem1[:position] || 0
165
+ width1 = stem1[:width] || 0
166
+ pos2 = stem2[:position] || 0
167
+ width2 = stem2[:width] || 0
168
+
169
+ # Check if ranges overlap
170
+ end1 = pos1 + width1
171
+ end2 = pos2 + width2
172
+
173
+ pos1 < end2 && pos2 < end1
174
+ end
175
+ end
176
+ end
177
+ end