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,274 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "stringio"
4
+ require_relative "../../binary/base_record"
5
+
6
+ module Fontisan
7
+ module Tables
8
+ class Cff
9
+ # CFF Encoding structure
10
+ #
11
+ # Encoding maps character codes to glyph IDs (GIDs).
12
+ # GID 0 (.notdef) is not encoded.
13
+ #
14
+ # Three formats:
15
+ # - Format 0: Array of codes (one per glyph)
16
+ # - Format 1: Ranges of consecutive codes
17
+ # - Format 0/1 with supplement: Format 0 or 1 with additional mappings
18
+ #
19
+ # Predefined encodings:
20
+ # - 0: Standard encoding (Adobe standard character set)
21
+ # - 1: Expert encoding (Adobe expert character set)
22
+ #
23
+ # Reference: CFF specification section 14 "Encodings"
24
+ # https://adobe-type-tools.github.io/font-tech-notes/pdfs/5176.CFF.pdf
25
+ #
26
+ # @example Reading an Encoding
27
+ # encoding = Fontisan::Tables::Cff::Encoding.new(data, num_glyphs)
28
+ # puts encoding.glyph_id(65) # => GID for char code 65 ('A')
29
+ # puts encoding.char_code(5) # => char code for GID 5
30
+ class Encoding
31
+ # Predefined encoding identifiers
32
+ PREDEFINED = {
33
+ 0 => :standard,
34
+ 1 => :expert,
35
+ }.freeze
36
+
37
+ # Format mask to extract format type
38
+ FORMAT_MASK = 0x7F
39
+
40
+ # @return [Integer] Encoding format (0 or 1)
41
+ attr_reader :format_type
42
+
43
+ # @return [Hash<Integer, Integer>] Map from character code to GID
44
+ attr_reader :code_to_gid
45
+
46
+ # @return [Hash<Integer, Integer>] Map from GID to character code
47
+ attr_reader :gid_to_code
48
+
49
+ # Initialize an Encoding
50
+ #
51
+ # @param data [String, Integer] Binary data or predefined encoding ID
52
+ # @param num_glyphs [Integer] Number of glyphs in the font
53
+ def initialize(data, num_glyphs)
54
+ @num_glyphs = num_glyphs
55
+ @code_to_gid = {}
56
+ @gid_to_code = {}
57
+
58
+ # GID 0 (.notdef) is always at code 0
59
+ @code_to_gid[0] = 0
60
+ @gid_to_code[0] = 0
61
+
62
+ if data.is_a?(Integer) && PREDEFINED.key?(data)
63
+ load_predefined_encoding(data)
64
+ else
65
+ @data = data
66
+ parse!
67
+ end
68
+ end
69
+
70
+ # Get GID for a character code
71
+ #
72
+ # @param code [Integer] Character code (0-255)
73
+ # @return [Integer, nil] Glyph ID or nil if not mapped
74
+ def glyph_id(code)
75
+ @code_to_gid[code]
76
+ end
77
+
78
+ # Get character code for a GID
79
+ #
80
+ # @param gid [Integer] Glyph ID
81
+ # @return [Integer, nil] Character code or nil if not mapped
82
+ def char_code(gid)
83
+ @gid_to_code[gid]
84
+ end
85
+
86
+ # Get the format symbol
87
+ #
88
+ # @return [Symbol] Format identifier (:array, :range, or :predefined)
89
+ def format
90
+ return :predefined unless @format_type
91
+
92
+ @format_type.zero? ? :array : :range
93
+ end
94
+
95
+ # Check if encoding has supplement
96
+ #
97
+ # @return [Boolean] True if encoding has supplemental mappings
98
+ def has_supplement?
99
+ @has_supplement || false
100
+ end
101
+
102
+ private
103
+
104
+ # Parse the Encoding from binary data
105
+ def parse!
106
+ io = StringIO.new(@data)
107
+ format_byte = read_uint8(io)
108
+
109
+ # Extract format (lower 7 bits) and supplement flag (bit 7)
110
+ @format_type = format_byte & FORMAT_MASK
111
+ @has_supplement = (format_byte & 0x80) != 0
112
+
113
+ case @format_type
114
+ when 0
115
+ parse_format_0(io)
116
+ when 1
117
+ parse_format_1(io)
118
+ else
119
+ raise CorruptedTableError,
120
+ "Invalid Encoding format: #{@format_type}"
121
+ end
122
+
123
+ # Parse supplemental encoding if present
124
+ parse_supplement(io) if @has_supplement
125
+ rescue StandardError => e
126
+ raise CorruptedTableError,
127
+ "Failed to parse Encoding: #{e.message}"
128
+ end
129
+
130
+ # Parse Format 0: Array of codes
131
+ #
132
+ # Format 0 directly lists character codes for each glyph (except
133
+ # .notdef)
134
+ #
135
+ # @param io [StringIO] Input stream positioned after format byte
136
+ def parse_format_0(io)
137
+ n_codes = read_uint8(io)
138
+
139
+ # Read one code per glyph (GIDs start at 1, skipping .notdef)
140
+ n_codes.times do |i|
141
+ code = read_uint8(io)
142
+ gid = i + 1 # GID 0 is .notdef, so start at 1
143
+
144
+ @code_to_gid[code] = gid
145
+ @gid_to_code[gid] = code
146
+ end
147
+ end
148
+
149
+ # Parse Format 1: Ranges of codes
150
+ #
151
+ # Format 1 uses ranges: first code, nLeft (number of consecutive codes)
152
+ #
153
+ # @param io [StringIO] Input stream positioned after format byte
154
+ def parse_format_1(io)
155
+ n_ranges = read_uint8(io)
156
+ gid = 1 # Start at GID 1 (skip .notdef at 0)
157
+
158
+ n_ranges.times do
159
+ first_code = read_uint8(io)
160
+ n_left = read_uint8(io)
161
+
162
+ # Map the range of codes
163
+ (n_left + 1).times do |i|
164
+ code = first_code + i
165
+ @code_to_gid[code] = gid
166
+ @gid_to_code[gid] = code
167
+ gid += 1
168
+ end
169
+ end
170
+ end
171
+
172
+ # Parse supplemental encoding
173
+ #
174
+ # Supplemental encoding provides additional code-to-GID mappings
175
+ #
176
+ # @param io [StringIO] Input stream positioned after main encoding data
177
+ def parse_supplement(io)
178
+ n_sups = read_uint8(io)
179
+
180
+ n_sups.times do
181
+ read_uint8(io)
182
+ read_uint16(io)
183
+
184
+ # Find GID for this SID (requires charset lookup)
185
+ # For now, we'll store the code mapping
186
+ # A full implementation would need charset access to resolve SID to
187
+ # GID
188
+ # This is typically used when the charset has glyphs not in the
189
+ # standard encoding
190
+ end
191
+ end
192
+
193
+ # Load a predefined encoding
194
+ #
195
+ # @param encoding_id [Integer] Predefined encoding ID (0 or 1)
196
+ def load_predefined_encoding(encoding_id)
197
+ @format_type = nil # Predefined encodings don't have a format
198
+
199
+ case encoding_id
200
+ when 0
201
+ load_standard_encoding
202
+ when 1
203
+ load_expert_encoding
204
+ end
205
+ end
206
+
207
+ # Load Standard encoding
208
+ #
209
+ # Adobe Standard Encoding is the default encoding for Type 1 fonts
210
+ # It maps common Latin characters to specific codes
211
+ def load_standard_encoding
212
+ # Standard encoding for common characters (codes 0-255)
213
+ # This is a simplified version - a full implementation would include
214
+ # all 256 standard encoding mappings from the CFF specification
215
+ # Appendix B
216
+
217
+ # Common ASCII mappings (basic Latin)
218
+ gid = 1
219
+ (32..126).each do |code|
220
+ @code_to_gid[code] = gid
221
+ @gid_to_code[gid] = code
222
+ gid += 1
223
+ break if gid >= @num_glyphs
224
+ end
225
+ end
226
+
227
+ # Load Expert encoding
228
+ #
229
+ # Adobe Expert Encoding is used for expert fonts with special
230
+ # characters like small caps, old-style figures, ligatures, etc.
231
+ def load_expert_encoding
232
+ # Expert encoding for special characters
233
+ # This is a simplified version - a full implementation would include
234
+ # all expert encoding mappings from the CFF specification Appendix C
235
+
236
+ # Map some common expert characters
237
+ gid = 1
238
+ expert_codes = [32, 33, 34, 35, 36, 37, 38, 39, 40, 41, 42, 43, 44,
239
+ 45, 46, 47]
240
+ expert_codes.each do |code|
241
+ @code_to_gid[code] = gid if gid < @num_glyphs
242
+ @gid_to_code[gid] = code if gid < @num_glyphs
243
+ gid += 1
244
+ break if gid >= @num_glyphs
245
+ end
246
+ end
247
+
248
+ # Read an unsigned 8-bit integer
249
+ #
250
+ # @param io [StringIO] Input stream
251
+ # @return [Integer] The value
252
+ def read_uint8(io)
253
+ byte = io.read(1)
254
+ raise CorruptedTableError, "Unexpected end of Encoding data" if
255
+ byte.nil?
256
+
257
+ byte.unpack1("C")
258
+ end
259
+
260
+ # Read an unsigned 16-bit integer (big-endian)
261
+ #
262
+ # @param io [StringIO] Input stream
263
+ # @return [Integer] The value
264
+ def read_uint16(io)
265
+ bytes = io.read(2)
266
+ raise CorruptedTableError, "Unexpected end of Encoding data" if
267
+ bytes.nil? || bytes.bytesize < 2
268
+
269
+ bytes.unpack1("n") # Big-endian unsigned 16-bit
270
+ end
271
+ end
272
+ end
273
+ end
274
+ end
@@ -0,0 +1,102 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../../binary/base_record"
4
+
5
+ module Fontisan
6
+ module Tables
7
+ class Cff
8
+ # CFF Header structure
9
+ #
10
+ # The CFF header appears at the beginning of the CFF table and contains
11
+ # basic version and structural information about the CFF data.
12
+ #
13
+ # Structure (4 bytes minimum):
14
+ # - uint8: major version (always 1 for CFF, 2 for CFF2)
15
+ # - uint8: minor version (always 0)
16
+ # - uint8: hdr_size (header size in bytes, typically 4)
17
+ # - uint8: off_size (offset size used throughout CFF, 1-4 bytes)
18
+ #
19
+ # Reference: CFF specification section 4 "Header"
20
+ # https://adobe-type-tools.github.io/font-tech-notes/pdfs/5176.CFF.pdf
21
+ #
22
+ # @example Reading a CFF header
23
+ # data = File.binread("font.otf", 4, cff_offset)
24
+ # header = Fontisan::Tables::Cff::Header.read(data)
25
+ # puts header.major # => 1
26
+ # puts header.minor # => 0
27
+ # puts header.off_size # => 4
28
+ class Header < Binary::BaseRecord
29
+ # Major version number (1 for CFF, 2 for CFF2)
30
+ uint8 :major
31
+
32
+ # Minor version number (always 0)
33
+ uint8 :minor
34
+
35
+ # Header size in bytes (typically 4, but can be larger for extensions)
36
+ uint8 :hdr_size
37
+
38
+ # Offset size used throughout the CFF table
39
+ # Valid values are 1, 2, 3, or 4 bytes
40
+ #
41
+ # This determines how offsets are encoded in INDEX structures and
42
+ # other parts of the CFF table.
43
+ uint8 :off_size
44
+
45
+ # Check if this is a valid CFF version 1.0 header
46
+ #
47
+ # @return [Boolean] True if major version is 1 and minor is 0
48
+ def cff?
49
+ major == 1 && minor.zero?
50
+ end
51
+
52
+ # Check if this is a CFF2 header (variable CFF fonts)
53
+ #
54
+ # @return [Boolean] True if major version is 2
55
+ def cff2?
56
+ major == 2
57
+ end
58
+
59
+ # Get the version as a string
60
+ #
61
+ # @return [String] Version in "major.minor" format
62
+ def version
63
+ "#{major}.#{minor}"
64
+ end
65
+
66
+ # Validate that the header has correct values
67
+ #
68
+ # @return [Boolean] True if header is valid
69
+ def valid?
70
+ # Major version must be 1 or 2
71
+ return false unless [1, 2].include?(major)
72
+
73
+ # Minor version must be 0
74
+ return false unless minor.zero?
75
+
76
+ # Header size must be at least 4 bytes
77
+ return false unless hdr_size >= 4
78
+
79
+ # Offset size must be between 1 and 4
80
+ return false unless (1..4).cover?(off_size)
81
+
82
+ true
83
+ end
84
+
85
+ # Validate header and raise error if invalid
86
+ #
87
+ # @raise [Fontisan::CorruptedTableError] If header is invalid
88
+ def validate!
89
+ return if valid?
90
+
91
+ message = "Invalid CFF header: " \
92
+ "version=#{version}, " \
93
+ "hdr_size=#{hdr_size}, " \
94
+ "off_size=#{off_size}"
95
+ error = Fontisan::CorruptedTableError.new(message)
96
+ error.set_backtrace(caller)
97
+ Kernel.raise(error)
98
+ end
99
+ end
100
+ end
101
+ end
102
+ end
@@ -0,0 +1,237 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "stringio"
4
+ require_relative "../../binary/base_record"
5
+
6
+ module Fontisan
7
+ module Tables
8
+ class Cff
9
+ # CFF INDEX structure
10
+ #
11
+ # INDEX is a fundamental data structure used throughout CFF for storing
12
+ # arrays of variable-length data items. It's used for:
13
+ # - Name INDEX (font names)
14
+ # - String INDEX (string data)
15
+ # - Global Subr INDEX (global subroutines)
16
+ # - Local Subr INDEX (local subroutines)
17
+ # - CharStrings INDEX (glyph programs)
18
+ #
19
+ # Structure:
20
+ # - count (Card16): Number of objects stored in INDEX
21
+ # - offSize (OffSize): Size of offset values (1-4 bytes)
22
+ # - offset[count+1] (Offset): Array of offsets to data
23
+ # - data: The actual data bytes
24
+ #
25
+ # Offsets are relative to the byte before the data array. The first
26
+ # offset is always 1, not 0. The last offset points one byte past the
27
+ # end of the data.
28
+ #
29
+ # Reference: CFF specification section 5 "INDEX Data"
30
+ # https://adobe-type-tools.github.io/font-tech-notes/pdfs/5176.CFF.pdf
31
+ #
32
+ # @example Reading an INDEX
33
+ # index = Fontisan::Tables::Cff::Index.new(data)
34
+ # puts index.count # => 3
35
+ # puts index[0] # => first item data
36
+ # index.each { |item| puts item }
37
+ class Index
38
+ # @return [Integer] Number of items in the INDEX
39
+ attr_reader :count
40
+
41
+ # @return [Integer] Size of offset values (1-4 bytes)
42
+ attr_reader :off_size
43
+
44
+ # @return [Array<Integer>] Array of offsets (count + 1 elements)
45
+ attr_reader :offsets
46
+
47
+ # @return [String] Binary string containing all data
48
+ attr_reader :data
49
+
50
+ # Initialize an INDEX from binary data
51
+ #
52
+ # @param io [IO, StringIO, String] Binary data to parse
53
+ # @param start_offset [Integer] Starting byte offset in the data
54
+ def initialize(io, start_offset: 0)
55
+ @io = io.is_a?(String) ? StringIO.new(io) : io
56
+ @start_offset = start_offset
57
+ @io.seek(start_offset) if @io.respond_to?(:seek)
58
+
59
+ parse!
60
+ end
61
+
62
+ # Get the item at the specified index
63
+ #
64
+ # @param index [Integer] Zero-based index of item to retrieve
65
+ # @return [String, nil] Binary data for the item, or nil if out of bounds
66
+ def [](index)
67
+ return nil if index.negative? || index >= count
68
+ return "" if count.zero?
69
+
70
+ # Offsets are 1-based in the data array
71
+ start_pos = offsets[index] - 1
72
+ end_pos = offsets[index + 1] - 1
73
+ length = end_pos - start_pos
74
+
75
+ data[start_pos, length]
76
+ end
77
+
78
+ # Iterate over each item in the INDEX
79
+ #
80
+ # @yield [String] Binary data for each item
81
+ # @return [Enumerator] If no block given
82
+ def each
83
+ return enum_for(:each) unless block_given?
84
+
85
+ count.times do |i|
86
+ yield self[i]
87
+ end
88
+ end
89
+
90
+ # Get all items as an array
91
+ #
92
+ # @return [Array<String>] Array of binary data strings
93
+ def to_a
94
+ Array.new(count) { |i| self[i] }
95
+ end
96
+
97
+ # Check if the INDEX is empty
98
+ #
99
+ # @return [Boolean] True if count is 0
100
+ def empty?
101
+ count.zero?
102
+ end
103
+
104
+ # Get the size of a specific item
105
+ #
106
+ # @param index [Integer] Zero-based index of item
107
+ # @return [Integer, nil] Size in bytes, or nil if out of bounds
108
+ def item_size(index)
109
+ return nil if index.negative? || index >= count
110
+ return 0 if count.zero?
111
+
112
+ offsets[index + 1] - offsets[index]
113
+ end
114
+
115
+ # Calculate total size of the INDEX in bytes
116
+ #
117
+ # This includes the count, offSize, offset array, and data.
118
+ #
119
+ # @return [Integer] Total size in bytes
120
+ def total_size
121
+ return 2 if count.zero? # Just the count field
122
+
123
+ # count (2) + offSize (1) + offset array + data
124
+ 2 + 1 + ((count + 1) * off_size) + data.bytesize
125
+ end
126
+
127
+ private
128
+
129
+ # Parse the INDEX structure from the IO
130
+ def parse!
131
+ # Read count (Card16)
132
+ @count = read_uint16
133
+
134
+ # Empty INDEX has only count field
135
+ if @count.zero?
136
+ @off_size = 0
137
+ @offsets = []
138
+ @data = "".b
139
+ return
140
+ end
141
+
142
+ # Read offSize (OffSize)
143
+ @off_size = read_uint8
144
+
145
+ # Validate offSize
146
+ unless (1..4).cover?(@off_size)
147
+ raise CorruptedTableError,
148
+ "Invalid INDEX offSize: #{@off_size} (must be 1-4)"
149
+ end
150
+
151
+ # Read offset array (count + 1 offsets)
152
+ @offsets = Array.new(@count + 1) do
153
+ read_offset(@off_size)
154
+ end
155
+
156
+ # Validate offsets
157
+ validate_offsets!
158
+
159
+ # Read data section
160
+ # Size is (last offset - 1) since offsets are 1-based
161
+ data_size = @offsets.last - 1
162
+ @data = read_bytes(data_size)
163
+ end
164
+
165
+ # Read an unsigned 16-bit integer
166
+ #
167
+ # @return [Integer] The value
168
+ def read_uint16
169
+ bytes = read_bytes(2)
170
+ bytes.unpack1("n") # Big-endian unsigned 16-bit
171
+ end
172
+
173
+ # Read an unsigned 8-bit integer
174
+ #
175
+ # @return [Integer] The value
176
+ def read_uint8
177
+ read_bytes(1).unpack1("C")
178
+ end
179
+
180
+ # Read an offset value of specified size
181
+ #
182
+ # @param size [Integer] Number of bytes (1-4)
183
+ # @return [Integer] The offset value
184
+ def read_offset(size)
185
+ bytes = read_bytes(size)
186
+
187
+ case size
188
+ when 1
189
+ bytes.unpack1("C")
190
+ when 2
191
+ bytes.unpack1("n")
192
+ when 3
193
+ # 24-bit big-endian
194
+ bytes.unpack("C3").inject(0) { |sum, byte| (sum << 8) | byte }
195
+ when 4
196
+ bytes.unpack1("N")
197
+ else
198
+ raise ArgumentError, "Invalid offset size: #{size}"
199
+ end
200
+ end
201
+
202
+ # Read specified number of bytes from IO
203
+ #
204
+ # @param count [Integer] Number of bytes to read
205
+ # @return [String] Binary string
206
+ def read_bytes(count)
207
+ return "".b if count.zero?
208
+
209
+ bytes = @io.read(count)
210
+ if bytes.nil? || bytes.bytesize < count
211
+ raise CorruptedTableError,
212
+ "Unexpected end of INDEX data"
213
+ end
214
+
215
+ bytes
216
+ end
217
+
218
+ # Validate that offsets are in ascending order and within bounds
219
+ def validate_offsets!
220
+ # First offset must be 1
221
+ unless @offsets.first == 1
222
+ raise CorruptedTableError,
223
+ "Invalid INDEX: first offset must be 1, got #{@offsets.first}"
224
+ end
225
+
226
+ # Check ascending order
227
+ @offsets.each_cons(2) do |prev, curr|
228
+ if curr < prev
229
+ raise CorruptedTableError,
230
+ "Invalid INDEX: offsets are not in ascending order"
231
+ end
232
+ end
233
+ end
234
+ end
235
+ end
236
+ end
237
+ end