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,487 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "stringio"
4
+ require_relative "../binary/base_record"
5
+
6
+ module Fontisan
7
+ module Tables
8
+ # CFF (Compact Font Format) table parser
9
+ #
10
+ # The CFF table contains PostScript-based glyph outline data for OpenType
11
+ # fonts with CFF outlines (as opposed to TrueType glyf/loca outlines).
12
+ # CFF is identified by the 'OTTO' signature in the font's sfnt version.
13
+ #
14
+ # CFF Table Structure:
15
+ # ```
16
+ # CFF Table = Header
17
+ # + Name INDEX
18
+ # + Top DICT INDEX
19
+ # + String INDEX
20
+ # + Global Subr INDEX
21
+ # + [Encodings]
22
+ # + [Charsets]
23
+ # + [FDSelect]
24
+ # + [CharStrings INDEX]
25
+ # + [Font DICT INDEX]
26
+ # + [Private DICT]
27
+ # + [Local Subr INDEX]
28
+ # ```
29
+ #
30
+ # This implementation focuses on the foundational structures (Header and
31
+ # INDEX) which are used throughout CFF. Additional structures like DICT,
32
+ # CharStrings, Charset, and Encoding require separate implementations.
33
+ #
34
+ # Reference: Adobe CFF specification
35
+ # https://adobe-type-tools.github.io/font-tech-notes/pdfs/5176.CFF.pdf
36
+ #
37
+ # Reference: docs/ttfunk-feature-analysis.md lines 2607-2648
38
+ #
39
+ # @example Reading a CFF table
40
+ # data = font.table_data['CFF ']
41
+ # cff = Fontisan::Tables::Cff.read(data)
42
+ # puts cff.font_count # => 1
43
+ # puts cff.header.version # => "1.0"
44
+ class Cff < Binary::BaseRecord
45
+ # OpenType table tag for CFF
46
+ TAG = "CFF "
47
+
48
+ # @return [Cff::Header] CFF header structure
49
+ attr_reader :header
50
+
51
+ # @return [Cff::Index] Name INDEX containing font names
52
+ attr_reader :name_index
53
+
54
+ # @return [Cff::Index] Top DICT INDEX containing font-level data
55
+ attr_reader :top_dict_index
56
+
57
+ # @return [Array<TopDict>] Parsed Top DICT objects
58
+ attr_reader :top_dicts
59
+
60
+ # @return [Cff::Index] String INDEX containing string data
61
+ attr_reader :string_index
62
+
63
+ # @return [Cff::Index] Global Subr INDEX containing global subroutines
64
+ attr_reader :global_subr_index
65
+
66
+ # @return [String] Raw binary data for the entire CFF table
67
+ attr_reader :raw_data
68
+
69
+ # Override read to parse CFF structure
70
+ #
71
+ # @param io [IO, String] Binary data to read
72
+ # @return [Cff] Parsed CFF table
73
+ def self.read(io)
74
+ cff = new
75
+ return cff if io.nil?
76
+
77
+ data = io.is_a?(String) ? io : io.read
78
+ cff.parse!(data)
79
+ cff
80
+ end
81
+
82
+ # Parse the CFF table structure
83
+ #
84
+ # This parses the foundational CFF structures: Header, Name INDEX,
85
+ # Top DICT INDEX, String INDEX, and Global Subr INDEX.
86
+ #
87
+ # Additional structures (CharStrings, Charset, Encoding, Private DICT)
88
+ # will be implemented in follow-up tasks.
89
+ #
90
+ # @param data [String] Binary data for the CFF table
91
+ # @raise [CorruptedTableError] If CFF structure is invalid
92
+ def parse!(data)
93
+ @raw_data = data
94
+ io = StringIO.new(data)
95
+
96
+ # Parse CFF Header (4 bytes minimum)
97
+ @header = Cff::Header.read(io)
98
+ @header.validate!
99
+
100
+ # Skip any additional header bytes beyond the standard 4
101
+ # (hdr_size can be larger for extensions)
102
+ if @header.hdr_size > 4
103
+ io.seek(@header.hdr_size)
104
+ end
105
+
106
+ # Parse Name INDEX
107
+ # Contains PostScript names of fonts in this CFF
108
+ # Typically just one name for single-font CFF
109
+ name_start = io.pos
110
+ @name_index = Cff::Index.new(io, start_offset: name_start)
111
+
112
+ # Validate that we have at least one font
113
+ if @name_index.count.zero?
114
+ raise CorruptedTableError, "CFF table must contain at least one font"
115
+ end
116
+
117
+ # Parse Top DICT INDEX
118
+ # Contains font-level DICTs with metadata and pointers
119
+ # Count should match name_index count (one DICT per font)
120
+ top_dict_start = io.pos
121
+ @top_dict_index = Cff::Index.new(io, start_offset: top_dict_start)
122
+
123
+ # Validate Top DICT count matches Name count
124
+ unless @top_dict_index.count == @name_index.count
125
+ raise CorruptedTableError,
126
+ "Top DICT count (#{@top_dict_index.count}) " \
127
+ "must match Name count (#{@name_index.count})"
128
+ end
129
+
130
+ # Parse String INDEX
131
+ # Contains additional string data beyond standard strings
132
+ # Standard strings (SIDs 0-390) are built-in
133
+ string_start = io.pos
134
+ @string_index = Cff::Index.new(io, start_offset: string_start)
135
+
136
+ # Parse Global Subr INDEX
137
+ # Contains subroutines used across all fonts in CFF
138
+ # Can be empty (count = 0)
139
+ global_subr_start = io.pos
140
+ @global_subr_index = Cff::Index.new(io, start_offset: global_subr_start)
141
+
142
+ # Parse Top DICTs
143
+ @top_dicts = []
144
+ @top_dict_index.each do |dict_data|
145
+ @top_dicts << TopDict.new(dict_data)
146
+ end
147
+
148
+ # Additional parsing will be added in follow-up tasks:
149
+ # - Charset parsing
150
+ # - Encoding parsing
151
+ # - CharStrings parsing
152
+ # - FDSelect parsing (for CIDFonts)
153
+ # - Private DICT parsing (requires Top DICT offsets)
154
+ rescue StandardError => e
155
+ raise CorruptedTableError, "Failed to parse CFF table: #{e.message}"
156
+ end
157
+
158
+ # Get the number of fonts in this CFF table
159
+ #
160
+ # Typically 1 for most OpenType fonts, but CFF supports multiple fonts
161
+ #
162
+ # @return [Integer] Number of fonts
163
+ def font_count
164
+ @name_index&.count || 0
165
+ end
166
+
167
+ # Get the PostScript name of a font by index
168
+ #
169
+ # @param index [Integer] Font index (0-based)
170
+ # @return [String, nil] PostScript font name, or nil if invalid index
171
+ def font_name(index = 0)
172
+ name_data = @name_index[index]
173
+ return nil unless name_data
174
+
175
+ # Font names in Name INDEX are ASCII strings
176
+ name_data.force_encoding("ASCII-8BIT")
177
+ end
178
+
179
+ # Get all font names in this CFF
180
+ #
181
+ # @return [Array<String>] Array of PostScript font names
182
+ def font_names
183
+ @name_index.to_a.map { |name| name.force_encoding("ASCII-8BIT") }
184
+ end
185
+
186
+ # Check if this is a CFF2 table (variable CFF)
187
+ #
188
+ # @return [Boolean] True if CFF version 2
189
+ def cff2?
190
+ @header&.cff2? || false
191
+ end
192
+
193
+ # Check if this is a standard CFF table (non-variable)
194
+ #
195
+ # @return [Boolean] True if CFF version 1
196
+ def cff?
197
+ @header&.cff? || false
198
+ end
199
+
200
+ # Get the CFF version string
201
+ #
202
+ # @return [String] Version in "major.minor" format
203
+ def version
204
+ @header&.version || "unknown"
205
+ end
206
+
207
+ # Get a string by String ID (SID)
208
+ #
209
+ # CFF has 391 predefined standard strings (SIDs 0-390).
210
+ # Additional strings are stored in the String INDEX.
211
+ #
212
+ # @param sid [Integer] String ID
213
+ # @return [String, nil] String data, or nil if invalid SID
214
+ def string_for_sid(sid)
215
+ # Standard strings (SIDs 0-390) are predefined
216
+ # See CFF spec Appendix A for the complete list
217
+ if sid <= 390
218
+ standard_string(sid)
219
+ else
220
+ # Custom strings start at SID 391
221
+ string_index_offset = sid - 391
222
+ string_data = @string_index[string_index_offset]
223
+ string_data&.force_encoding("ASCII-8BIT")
224
+ end
225
+ end
226
+
227
+ # Get count of custom strings (beyond standard strings)
228
+ #
229
+ # @return [Integer] Number of custom strings
230
+ def custom_string_count
231
+ @string_index&.count || 0
232
+ end
233
+
234
+ # Get count of global subroutines
235
+ #
236
+ # @return [Integer] Number of global subroutines
237
+ def global_subr_count
238
+ @global_subr_index&.count || 0
239
+ end
240
+
241
+ # Get the Top DICT for a specific font
242
+ #
243
+ # @param index [Integer] Font index (0-based)
244
+ # @return [TopDict, nil] Top DICT object, or nil if invalid index
245
+ def top_dict(index = 0)
246
+ @top_dicts&.[](index)
247
+ end
248
+
249
+ # Parse the Private DICT for a specific font
250
+ #
251
+ # The Private DICT location is specified in the Top DICT
252
+ #
253
+ # @param index [Integer] Font index (0-based)
254
+ # @return [PrivateDict, nil] Private DICT object, or nil if not present
255
+ def private_dict(index = 0)
256
+ top = top_dict(index)
257
+ return nil unless top
258
+
259
+ private_info = top.private
260
+ return nil unless private_info
261
+
262
+ size, offset = private_info
263
+ return nil if size <= 0 || offset.negative?
264
+
265
+ # Extract Private DICT data from raw CFF data
266
+ private_data = @raw_data[offset, size]
267
+ return nil unless private_data
268
+
269
+ PrivateDict.new(private_data)
270
+ rescue StandardError => e
271
+ warn "Failed to parse Private DICT: #{e.message}"
272
+ nil
273
+ end
274
+
275
+ # Get the Local Subr INDEX for a specific font
276
+ #
277
+ # Local subroutines are stored in the Private DICT area
278
+ #
279
+ # @param index [Integer] Font index (0-based)
280
+ # @return [Index, nil] Local Subr INDEX, or nil if not present
281
+ def local_subrs(index = 0)
282
+ priv_dict = private_dict(index)
283
+ return nil unless priv_dict
284
+
285
+ subrs_offset = priv_dict.subrs
286
+ return nil unless subrs_offset
287
+
288
+ top = top_dict(index)
289
+ return nil unless top
290
+
291
+ private_info = top.private
292
+ return nil unless private_info
293
+
294
+ _size, private_offset = private_info
295
+
296
+ # Local Subr offset is relative to Private DICT start
297
+ absolute_offset = private_offset + subrs_offset
298
+
299
+ io = StringIO.new(@raw_data)
300
+ io.seek(absolute_offset)
301
+ Index.new(io, start_offset: absolute_offset)
302
+ rescue StandardError => e
303
+ warn "Failed to parse Local Subr INDEX: #{e.message}"
304
+ nil
305
+ end
306
+
307
+ # Get the CharStrings INDEX for a specific font
308
+ #
309
+ # The CharStrings INDEX contains glyph outline programs
310
+ #
311
+ # @param index [Integer] Font index (0-based)
312
+ # @return [CharstringsIndex, nil] CharStrings INDEX, or nil if not
313
+ # present
314
+ def charstrings_index(index = 0)
315
+ top = top_dict(index)
316
+ return nil unless top
317
+
318
+ charstrings_offset = top.charstrings
319
+ return nil unless charstrings_offset
320
+
321
+ io = StringIO.new(@raw_data)
322
+ io.seek(charstrings_offset)
323
+ CharstringsIndex.new(io, start_offset: charstrings_offset)
324
+ rescue StandardError => e
325
+ warn "Failed to parse CharStrings INDEX: #{e.message}"
326
+ nil
327
+ end
328
+
329
+ # Get a CharString for a specific glyph
330
+ #
331
+ # This returns an interpreted CharString object with the glyph's
332
+ # outline data
333
+ #
334
+ # @param glyph_index [Integer] Glyph index (0-based, 0 is typically
335
+ # .notdef)
336
+ # @param font_index [Integer] Font index in CFF (default 0)
337
+ # @return [CharString, nil] Interpreted CharString, or nil if not found
338
+ #
339
+ # @example Getting a glyph's CharString
340
+ # cff = Fontisan::Tables::Cff.read(data)
341
+ # charstring = cff.charstring_for_glyph(42)
342
+ # puts charstring.width
343
+ # puts charstring.bounding_box
344
+ # charstring.to_commands.each { |cmd| puts cmd.inspect }
345
+ def charstring_for_glyph(glyph_index, font_index = 0)
346
+ charstrings = charstrings_index(font_index)
347
+ return nil unless charstrings
348
+
349
+ priv_dict = private_dict(font_index)
350
+ return nil unless priv_dict
351
+
352
+ local_subr_index = local_subrs(font_index)
353
+
354
+ charstrings.charstring_at(
355
+ glyph_index,
356
+ priv_dict,
357
+ @global_subr_index,
358
+ local_subr_index,
359
+ )
360
+ rescue StandardError => e
361
+ warn "Failed to get CharString for glyph #{glyph_index}: #{e.message}"
362
+ nil
363
+ end
364
+
365
+ # Get the number of glyphs in a font
366
+ #
367
+ # @param index [Integer] Font index (0-based)
368
+ # @return [Integer] Number of glyphs, or 0 if CharStrings not available
369
+ def glyph_count(index = 0)
370
+ charstrings = charstrings_index(index)
371
+ charstrings&.glyph_count || 0
372
+ end
373
+
374
+ # Validate the CFF table structure
375
+ #
376
+ # @return [Boolean] True if valid
377
+ def valid?
378
+ return false unless @header&.valid?
379
+ return false unless @name_index&.count&.positive?
380
+ return false unless @top_dict_index
381
+ return false unless @top_dict_index.count == @name_index.count
382
+ return false unless @string_index
383
+ return false unless @global_subr_index
384
+
385
+ true
386
+ end
387
+
388
+ private
389
+
390
+ # Get a standard CFF string by SID
391
+ #
392
+ # This is a placeholder that returns a generic string.
393
+ # A complete implementation would include all 391 standard strings
394
+ # from CFF spec Appendix A.
395
+ #
396
+ # TODO: Implement complete standard string table in follow-up task
397
+ #
398
+ # @param sid [Integer] String ID (0-390)
399
+ # @return [String] Standard string
400
+ def standard_string(sid)
401
+ # Placeholder implementation
402
+ # Full implementation should include all standard strings
403
+ # from CFF specification Appendix A
404
+ case sid
405
+ when 0 then ".notdef"
406
+ when 1 then "space"
407
+ when 2 then "exclam"
408
+ # ... (388 more standard strings)
409
+ else
410
+ ".notdef" # Fallback
411
+ end
412
+ end
413
+
414
+ # Get the Charset for a specific font
415
+ #
416
+ # Charset maps glyph IDs to glyph names via String IDs
417
+ #
418
+ # @param index [Integer] Font index (0-based)
419
+ # @return [Charset, nil] Charset object, or nil if not present
420
+ def charset(index = 0)
421
+ top = top_dict(index)
422
+ return nil unless top
423
+
424
+ charset_offset = top.charset
425
+ return nil unless charset_offset
426
+
427
+ # Handle predefined charsets
428
+ if charset_offset <= 2
429
+ num_glyphs = glyph_count(index)
430
+ return Charset.new(charset_offset, num_glyphs, self)
431
+ end
432
+
433
+ # Parse custom charset from offset
434
+ charset_data = @raw_data[charset_offset..]
435
+ return nil unless charset_data
436
+
437
+ num_glyphs = glyph_count(index)
438
+ Charset.new(charset_data, num_glyphs, self)
439
+ rescue StandardError => e
440
+ warn "Failed to parse Charset: #{e.message}"
441
+ nil
442
+ end
443
+
444
+ # Get the Encoding for a specific font
445
+ #
446
+ # Encoding maps character codes to glyph IDs
447
+ #
448
+ # @param index [Integer] Font index (0-based)
449
+ # @return [Encoding, nil] Encoding object, or nil if not present
450
+ def encoding(index = 0)
451
+ top = top_dict(index)
452
+ return nil unless top
453
+
454
+ encoding_offset = top.encoding
455
+ return nil unless encoding_offset
456
+
457
+ # Handle predefined encodings
458
+ if encoding_offset <= 1
459
+ num_glyphs = glyph_count(index)
460
+ return Encoding.new(encoding_offset, num_glyphs)
461
+ end
462
+
463
+ # Parse custom encoding from offset
464
+ encoding_data = @raw_data[encoding_offset..]
465
+ return nil unless encoding_data
466
+
467
+ num_glyphs = glyph_count(index)
468
+ Encoding.new(encoding_data, num_glyphs)
469
+ rescue StandardError => e
470
+ warn "Failed to parse Encoding: #{e.message}"
471
+ nil
472
+ end
473
+ end
474
+
475
+ # Load nested class definitions after the main class is defined
476
+ require_relative "cff/header"
477
+ require_relative "cff/index"
478
+ require_relative "cff/dict"
479
+ require_relative "cff/top_dict"
480
+ require_relative "cff/private_dict"
481
+ require_relative "cff/charstring"
482
+ require_relative "cff/charstrings_index"
483
+ require_relative "cff/charset"
484
+ require_relative "cff/encoding"
485
+ require_relative "cff/cff_glyph"
486
+ end
487
+ end