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,134 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Fontisan
4
+ module Utils
5
+ # Simple thread pool implementation
6
+ #
7
+ # Manages a fixed number of worker threads for parallel job execution.
8
+ # Jobs are queued and processed by available workers.
9
+ #
10
+ # @example Basic usage
11
+ # pool = ThreadPool.new(4)
12
+ # future = pool.schedule { expensive_computation }
13
+ # result = future.value
14
+ # pool.shutdown
15
+ class ThreadPool
16
+ # Initialize thread pool
17
+ #
18
+ # @param size [Integer] Number of worker threads
19
+ def initialize(size)
20
+ @size = size
21
+ @queue = Queue.new
22
+ @threads = []
23
+ @shutdown = false
24
+
25
+ # Start worker threads
26
+ @size.times do
27
+ @threads << Thread.new { worker_loop }
28
+ end
29
+ end
30
+
31
+ # Schedule a job for execution
32
+ #
33
+ # @yield Job to execute
34
+ # @return [Future] Future object to retrieve result
35
+ def schedule(&block)
36
+ future = Future.new
37
+ @queue << { block: block, future: future }
38
+ future
39
+ end
40
+
41
+ # Shutdown thread pool
42
+ #
43
+ # Waits for all queued jobs to complete and stops workers.
44
+ def shutdown
45
+ @shutdown = true
46
+
47
+ # Signal all threads to stop
48
+ @size.times { @queue << :stop }
49
+
50
+ # Wait for all threads to finish
51
+ @threads.each(&:join)
52
+ end
53
+
54
+ private
55
+
56
+ # Worker thread main loop
57
+ def worker_loop
58
+ loop do
59
+ job = @queue.pop
60
+ break if job == :stop
61
+
62
+ begin
63
+ result = job[:block].call
64
+ job[:future].set_value(result)
65
+ rescue StandardError => e
66
+ job[:future].set_error(e)
67
+ end
68
+ end
69
+ end
70
+ end
71
+
72
+ # Future object for async result retrieval
73
+ #
74
+ # Represents a computation that will complete in the future.
75
+ # Provides blocking access to the eventual result or error.
76
+ #
77
+ # @example Basic usage
78
+ # future = Future.new
79
+ # Thread.new { future.set_value(42) }
80
+ # result = future.value # Blocks until value is set
81
+ class Future
82
+ def initialize
83
+ @mutex = Mutex.new
84
+ @condition = ConditionVariable.new
85
+ @completed = false
86
+ @value = nil
87
+ @error = nil
88
+ end
89
+
90
+ # Set the computed value
91
+ #
92
+ # @param value [Object] Result value
93
+ def set_value(value)
94
+ @mutex.synchronize do
95
+ @value = value
96
+ @completed = true
97
+ @condition.signal
98
+ end
99
+ end
100
+
101
+ # Set an error
102
+ #
103
+ # @param error [Exception] Error that occurred
104
+ def set_error(error)
105
+ @mutex.synchronize do
106
+ @error = error
107
+ @completed = true
108
+ @condition.signal
109
+ end
110
+ end
111
+
112
+ # Get the value, blocking until available
113
+ #
114
+ # @return [Object] Computed value
115
+ # @raise [Exception] If computation failed
116
+ def value
117
+ @mutex.synchronize do
118
+ @condition.wait(@mutex) until @completed
119
+
120
+ raise @error if @error
121
+
122
+ @value
123
+ end
124
+ end
125
+
126
+ # Check if computation is complete
127
+ #
128
+ # @return [Boolean] True if complete
129
+ def completed?
130
+ @mutex.synchronize { @completed }
131
+ end
132
+ end
133
+ end
134
+ end
@@ -0,0 +1,170 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../utilities/checksum_calculator"
4
+
5
+ module Fontisan
6
+ module Validation
7
+ # ChecksumValidator validates font file and table checksums
8
+ #
9
+ # This validator checks that the head table checksum adjustment is correct
10
+ # and validates individual table checksums to ensure file integrity.
11
+ #
12
+ # Single Responsibility: Checksum validation and file integrity
13
+ #
14
+ # @example Validating checksums
15
+ # validator = ChecksumValidator.new(rules)
16
+ # issues = validator.validate(font, font_path)
17
+ class ChecksumValidator
18
+ # Initialize checksum validator
19
+ #
20
+ # @param rules [Hash] Validation rules configuration
21
+ def initialize(rules)
22
+ @rules = rules
23
+ @checksum_config = rules["checksum_validation"] || {}
24
+ end
25
+
26
+ # Validate font checksums
27
+ #
28
+ # @param font [TrueTypeFont, OpenTypeFont] The font to validate
29
+ # @param font_path [String] Path to the font file
30
+ # @return [Array<Hash>] Array of validation issues
31
+ def validate(font, font_path)
32
+ issues = []
33
+
34
+ # Check head table checksum adjustment if enabled
35
+ if should_check?("check_head_checksum_adjustment")
36
+ issues.concat(check_head_checksum_adjustment(font, font_path))
37
+ end
38
+
39
+ # Check individual table checksums if enabled
40
+ if should_check?("check_table_checksums")
41
+ issues.concat(check_table_checksums(font))
42
+ end
43
+
44
+ issues
45
+ end
46
+
47
+ private
48
+
49
+ # Check if a validation should be performed
50
+ #
51
+ # @param check_name [String] The check name
52
+ # @return [Boolean] true if check should be performed
53
+ def should_check?(check_name)
54
+ @rules.dig("validation_levels", "standard", check_name)
55
+ end
56
+
57
+ # Check head table checksum adjustment
58
+ #
59
+ # @param font [TrueTypeFont, OpenTypeFont] The font
60
+ # @param font_path [String] Path to the font file
61
+ # @return [Array<Hash>] Array of checksum issues
62
+ def check_head_checksum_adjustment(font, font_path)
63
+ issues = []
64
+
65
+ head_entry = font.head_table
66
+ return issues unless head_entry
67
+
68
+ # Calculate the checksum of the entire font file
69
+ begin
70
+ file_checksum = Utilities::ChecksumCalculator.calculate_file_checksum(font_path)
71
+ magic = @checksum_config["magic"] || Constants::CHECKSUM_ADJUSTMENT_MAGIC
72
+
73
+ # Read the actual checksum adjustment from head table
74
+ head_data = font.table_data[Constants::HEAD_TAG]
75
+ return issues unless head_data && head_data.bytesize >= 12
76
+
77
+ actual_adjustment = head_data.byteslice(8, 4).unpack1("N")
78
+
79
+ # The actual adjustment should be 0 when we calculate, since we zero it out
80
+ # So we need to check if the file checksum with zeroed adjustment equals magic
81
+ if file_checksum != magic
82
+ # Calculate what the adjustment should be
83
+ temp_checksum = (file_checksum - actual_adjustment) & 0xFFFFFFFF
84
+ correct_adjustment = (magic - temp_checksum) & 0xFFFFFFFF
85
+
86
+ if actual_adjustment != correct_adjustment
87
+ issues << {
88
+ severity: "error",
89
+ category: "checksum",
90
+ message: "Invalid head table checksum adjustment (expected: 0x#{correct_adjustment.to_s(16)}, got: 0x#{actual_adjustment.to_s(16)})",
91
+ location: "head table",
92
+ }
93
+ end
94
+ end
95
+ rescue StandardError => e
96
+ issues << {
97
+ severity: "error",
98
+ category: "checksum",
99
+ message: "Failed to validate head checksum adjustment: #{e.message}",
100
+ location: "head table",
101
+ }
102
+ end
103
+
104
+ issues
105
+ end
106
+
107
+ # Check individual table checksums
108
+ #
109
+ # @param font [TrueTypeFont, OpenTypeFont] The font
110
+ # @return [Array<Hash>] Array of table checksum issues
111
+ def check_table_checksums(font)
112
+ issues = []
113
+
114
+ skip_tables = @checksum_config["skip_tables"] || []
115
+
116
+ font.tables.each do |table_entry|
117
+ tag = table_entry.tag
118
+
119
+ # Skip tables that are exempt from checksum validation
120
+ next if skip_tables.include?(tag)
121
+
122
+ # Get table data
123
+ table_data = font.table_data[tag]
124
+ next unless table_data
125
+
126
+ # Calculate checksum for the table
127
+ calculated_checksum = calculate_table_checksum(table_data)
128
+ declared_checksum = table_entry.checksum
129
+
130
+ # Special handling for head table (checksum adjustment field should be 0)
131
+ if tag == Constants::HEAD_TAG
132
+ # Zero out checksum adjustment field for calculation
133
+ modified_data = table_data.dup
134
+ modified_data[8, 4] = "\x00\x00\x00\x00"
135
+ calculated_checksum = calculate_table_checksum(modified_data)
136
+ end
137
+
138
+ if calculated_checksum != declared_checksum
139
+ issues << {
140
+ severity: "warning",
141
+ category: "checksum",
142
+ message: "Table '#{tag}' checksum mismatch (expected: 0x#{declared_checksum.to_s(16)}, got: 0x#{calculated_checksum.to_s(16)})",
143
+ location: "#{tag} table",
144
+ }
145
+ end
146
+ end
147
+
148
+ issues
149
+ end
150
+
151
+ # Calculate checksum for table data
152
+ #
153
+ # @param data [String] The table data
154
+ # @return [Integer] The calculated checksum
155
+ def calculate_table_checksum(data)
156
+ sum = 0
157
+ # Pad to 4-byte boundary
158
+ padded_data = data + ("\x00" * ((4 - (data.bytesize % 4)) % 4))
159
+
160
+ # Sum all 32-bit values
161
+ (0...padded_data.bytesize).step(4) do |i|
162
+ value = padded_data.byteslice(i, 4).unpack1("N")
163
+ sum = (sum + value) & 0xFFFFFFFF
164
+ end
165
+
166
+ sum
167
+ end
168
+ end
169
+ end
170
+ end
@@ -0,0 +1,197 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Fontisan
4
+ module Validation
5
+ # ConsistencyValidator validates cross-table consistency
6
+ #
7
+ # This validator ensures that references between tables are valid,
8
+ # such as cmap glyph references, hmtx entry counts, and variable
9
+ # font table consistency.
10
+ #
11
+ # Single Responsibility: Cross-table data consistency validation
12
+ #
13
+ # @example Validating consistency
14
+ # validator = ConsistencyValidator.new(rules)
15
+ # issues = validator.validate(font)
16
+ class ConsistencyValidator
17
+ # Initialize consistency validator
18
+ #
19
+ # @param rules [Hash] Validation rules configuration
20
+ def initialize(rules)
21
+ @rules = rules
22
+ @consistency_config = rules["consistency_checks"] || {}
23
+ end
24
+
25
+ # Validate font consistency
26
+ #
27
+ # @param font [TrueTypeFont, OpenTypeFont] The font to validate
28
+ # @return [Array<Hash>] Array of validation issues
29
+ def validate(font)
30
+ issues = []
31
+
32
+ # Check hmtx consistency if enabled
33
+ issues.concat(check_hmtx_consistency(font)) if should_check?("check_hmtx_consistency")
34
+
35
+ # Check name table consistency if enabled
36
+ issues.concat(check_name_consistency(font)) if should_check?("check_name_consistency")
37
+
38
+ # Check variable font consistency if enabled
39
+ issues.concat(check_variable_consistency(font)) if should_check?("check_variable_consistency")
40
+
41
+ issues
42
+ end
43
+
44
+ private
45
+
46
+ # Check if a validation should be performed
47
+ #
48
+ # @param check_name [String] The check name
49
+ # @return [Boolean] true if check should be performed
50
+ def should_check?(check_name)
51
+ @rules.dig("validation_levels", "standard", check_name)
52
+ end
53
+
54
+ # Check hmtx entry count matches glyph count
55
+ #
56
+ # @param font [TrueTypeFont, OpenTypeFont] The font
57
+ # @return [Array<Hash>] Array of hmtx consistency issues
58
+ def check_hmtx_consistency(font)
59
+ issues = []
60
+
61
+ hmtx = font.table(Constants::HMTX_TAG)
62
+ maxp = font.table(Constants::MAXP_TAG)
63
+ hhea = font.table(Constants::HHEA_TAG)
64
+
65
+ return issues unless hmtx && maxp && hhea
66
+
67
+ glyph_count = maxp.num_glyphs
68
+ num_of_long_hor_metrics = hhea.number_of_h_metrics
69
+
70
+ # Verify the structure makes sense
71
+ if num_of_long_hor_metrics > glyph_count
72
+ issues << {
73
+ severity: "error",
74
+ category: "consistency",
75
+ message: "hhea number_of_h_metrics (#{num_of_long_hor_metrics}) exceeds glyph count (#{glyph_count})",
76
+ location: "hhea/hmtx tables",
77
+ }
78
+ end
79
+
80
+ if num_of_long_hor_metrics < 1
81
+ issues << {
82
+ severity: "error",
83
+ category: "consistency",
84
+ message: "hhea number_of_h_metrics is #{num_of_long_hor_metrics}, must be at least 1",
85
+ location: "hhea table",
86
+ }
87
+ end
88
+
89
+ issues
90
+ end
91
+
92
+ # Check name table for consistency issues
93
+ #
94
+ # @param font [TrueTypeFont, OpenTypeFont] The font
95
+ # @return [Array<Hash>] Array of name table issues
96
+ def check_name_consistency(font)
97
+ issues = []
98
+
99
+ name = font.table(Constants::NAME_TAG)
100
+ return issues unless name
101
+
102
+ # Check that required name IDs are present
103
+ required_name_ids = [
104
+ Tables::Name::FAMILY, # 1
105
+ Tables::Name::SUBFAMILY, # 2
106
+ Tables::Name::FULL_NAME, # 4
107
+ Tables::Name::VERSION, # 5
108
+ Tables::Name::POSTSCRIPT_NAME, # 6
109
+ ]
110
+
111
+ required_name_ids.each do |name_id|
112
+ has_entry = name.name_records.any? do |record|
113
+ record.name_id == name_id
114
+ end
115
+ unless has_entry
116
+ issues << {
117
+ severity: "warning",
118
+ category: "consistency",
119
+ message: "Missing recommended name ID #{name_id}",
120
+ location: "name table",
121
+ }
122
+ end
123
+ end
124
+
125
+ # Check for duplicate entries (same platform/encoding/language/nameID)
126
+ seen = {}
127
+ name.name_records.each do |record|
128
+ key = [record.platform_id, record.encoding_id, record.language_id,
129
+ record.name_id]
130
+ if seen[key]
131
+ issues << {
132
+ severity: "warning",
133
+ category: "consistency",
134
+ message: "Duplicate name record: platform=#{record.platform_id}, encoding=#{record.encoding_id}, language=#{record.language_id}, nameID=#{record.name_id}",
135
+ location: "name table",
136
+ }
137
+ end
138
+ seen[key] = true
139
+ end
140
+
141
+ issues
142
+ end
143
+
144
+ # Check variable font table consistency
145
+ #
146
+ # @param font [TrueTypeFont, OpenTypeFont] The font
147
+ # @return [Array<Hash>] Array of variable font issues
148
+ def check_variable_consistency(font)
149
+ issues = []
150
+
151
+ # Only check if this is a variable font
152
+ return issues unless font.has_table?(Constants::FVAR_TAG)
153
+
154
+ fvar = font.table(Constants::FVAR_TAG)
155
+ return issues unless fvar
156
+
157
+ axis_count = fvar.axes.length
158
+
159
+ # For TrueType variable fonts, check gvar consistency
160
+ if font.has_table?(Constants::GVAR_TAG)
161
+ gvar = font.table(Constants::GVAR_TAG)
162
+ gvar_axis_count = gvar.axis_count
163
+ if gvar_axis_count != axis_count
164
+ issues << {
165
+ severity: "error",
166
+ category: "consistency",
167
+ message: "fvar axis count (#{axis_count}) doesn't match gvar axis count (#{gvar_axis_count})",
168
+ location: "fvar/gvar tables",
169
+ }
170
+ end
171
+ end
172
+
173
+ # Check that recommended variation tables are present
174
+ unless font.has_table?(Constants::GVAR_TAG) || font.has_table?(Constants::CFF2_TAG)
175
+ issues << {
176
+ severity: "error",
177
+ category: "consistency",
178
+ message: "Variable font missing gvar (TrueType) or CFF2 (CFF) table",
179
+ location: "variable font",
180
+ }
181
+ end
182
+
183
+ # Check for recommended metrics variation tables
184
+ unless font.has_table?(Constants::HVAR_TAG)
185
+ issues << {
186
+ severity: "info",
187
+ category: "consistency",
188
+ message: "Variable font missing HVAR table (recommended for better rendering)",
189
+ location: nil,
190
+ }
191
+ end
192
+
193
+ issues
194
+ end
195
+ end
196
+ end
197
+ end
@@ -0,0 +1,198 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Fontisan
4
+ module Validation
5
+ # StructureValidator validates the structural integrity of fonts
6
+ #
7
+ # This validator checks the SFNT structure, table offsets, table ordering,
8
+ # and other structural properties that ensure the font file is well-formed.
9
+ #
10
+ # Single Responsibility: Font structure and SFNT format validation
11
+ #
12
+ # @example Validating structure
13
+ # validator = StructureValidator.new(rules)
14
+ # issues = validator.validate(font)
15
+ class StructureValidator
16
+ # Initialize structure validator
17
+ #
18
+ # @param rules [Hash] Validation rules configuration
19
+ def initialize(rules)
20
+ @rules = rules
21
+ @structure_config = rules["structure_validation"] || {}
22
+ end
23
+
24
+ # Validate font structure
25
+ #
26
+ # @param font [TrueTypeFont, OpenTypeFont] The font to validate
27
+ # @return [Array<Hash>] Array of validation issues
28
+ def validate(font)
29
+ issues = []
30
+
31
+ # Check glyph count consistency
32
+ issues.concat(check_glyph_consistency(font))
33
+
34
+ # Check table offsets
35
+ issues.concat(check_table_offsets(font)) if @rules.dig(
36
+ "validation_levels", "standard", "check_table_offsets"
37
+ )
38
+
39
+ # Check table ordering (optional optimization check)
40
+ issues.concat(check_table_ordering(font)) if @rules.dig(
41
+ "validation_levels", "standard", "check_table_ordering"
42
+ )
43
+
44
+ issues
45
+ end
46
+
47
+ private
48
+
49
+ # Check glyph count consistency across tables
50
+ #
51
+ # @param font [TrueTypeFont, OpenTypeFont] The font
52
+ # @return [Array<Hash>] Array of consistency issues
53
+ def check_glyph_consistency(font)
54
+ issues = []
55
+
56
+ # Get glyph count from maxp table
57
+ maxp = font.table(Constants::MAXP_TAG)
58
+ return issues unless maxp
59
+
60
+ expected_count = maxp.num_glyphs
61
+
62
+ # For TrueType fonts, check glyf table glyph count
63
+ if font.has_table?(Constants::GLYF_TAG)
64
+ glyf = font.table(Constants::GLYF_TAG)
65
+ actual_count = glyf.glyphs.length if glyf.respond_to?(:glyphs)
66
+
67
+ if actual_count && actual_count != expected_count
68
+ issues << {
69
+ severity: "error",
70
+ category: "structure",
71
+ message: "Glyph count mismatch: maxp=#{expected_count}, glyf=#{actual_count}",
72
+ location: "glyf table",
73
+ }
74
+ end
75
+ end
76
+
77
+ # Check glyph count bounds with safe defaults
78
+ min_glyph_count = @structure_config["min_glyph_count"] || 1
79
+ max_glyph_count = @structure_config["max_glyph_count"] || 65536
80
+
81
+ if expected_count < min_glyph_count
82
+ issues << {
83
+ severity: "error",
84
+ category: "structure",
85
+ message: "Glyph count (#{expected_count}) below minimum (#{min_glyph_count})",
86
+ location: "maxp table",
87
+ }
88
+ end
89
+
90
+ if expected_count > max_glyph_count
91
+ issues << {
92
+ severity: "error",
93
+ category: "structure",
94
+ message: "Glyph count (#{expected_count}) exceeds maximum (#{max_glyph_count})",
95
+ location: "maxp table",
96
+ }
97
+ end
98
+
99
+ issues
100
+ end
101
+
102
+ # Check that table offsets are valid
103
+ #
104
+ # @param font [TrueTypeFont, OpenTypeFont] The font
105
+ # @return [Array<Hash>] Array of offset issues
106
+ def check_table_offsets(font)
107
+ issues = []
108
+
109
+ min_offset = @structure_config["min_table_offset"] || 12
110
+ max_size = @structure_config["max_table_size"] || 104857600
111
+
112
+ font.tables.each do |table_entry|
113
+ tag = table_entry.tag
114
+ offset = table_entry.offset
115
+ length = table_entry.table_length
116
+
117
+ # Check minimum offset
118
+ if offset < min_offset
119
+ issues << {
120
+ severity: "error",
121
+ category: "structure",
122
+ message: "Table '#{tag}' has invalid offset: #{offset} (minimum: #{min_offset})",
123
+ location: "#{tag} table directory",
124
+ }
125
+ end
126
+
127
+ # Check for reasonable table size
128
+ if length > max_size
129
+ issues << {
130
+ severity: "warning",
131
+ category: "structure",
132
+ message: "Table '#{tag}' has unusually large size: #{length} bytes",
133
+ location: "#{tag} table",
134
+ }
135
+ end
136
+
137
+ # Check alignment (tables should be 4-byte aligned)
138
+ alignment = @structure_config["table_alignment"] || 4
139
+ if offset % alignment != 0
140
+ issues << {
141
+ severity: "warning",
142
+ category: "structure",
143
+ message: "Table '#{tag}' is not #{alignment}-byte aligned (offset: #{offset})",
144
+ location: "#{tag} table directory",
145
+ }
146
+ end
147
+ end
148
+
149
+ issues
150
+ end
151
+
152
+ # Check table ordering (optimization check, not critical)
153
+ #
154
+ # @param font [TrueTypeFont, OpenTypeFont] The font
155
+ # @return [Array<Hash>] Array of ordering issues
156
+ def check_table_ordering(font)
157
+ issues = []
158
+
159
+ # Recommended table order for optimal loading
160
+ recommended_order = [
161
+ Constants::HEAD_TAG,
162
+ Constants::HHEA_TAG,
163
+ Constants::MAXP_TAG,
164
+ Constants::OS2_TAG,
165
+ Constants::NAME_TAG,
166
+ Constants::CMAP_TAG,
167
+ Constants::POST_TAG,
168
+ Constants::GLYF_TAG,
169
+ Constants::LOCA_TAG,
170
+ Constants::HMTX_TAG,
171
+ ]
172
+
173
+ # Get actual table order
174
+ actual_order = font.table_names
175
+
176
+ # Check if critical tables are in recommended order
177
+ critical_tables = recommended_order.take(7) # head through post
178
+ actual_critical = actual_order.select do |tag|
179
+ critical_tables.include?(tag)
180
+ end
181
+ expected_critical = critical_tables.select do |tag|
182
+ actual_order.include?(tag)
183
+ end
184
+
185
+ if actual_critical != expected_critical
186
+ issues << {
187
+ severity: "info",
188
+ category: "structure",
189
+ message: "Tables not in optimal order for performance",
190
+ location: nil,
191
+ }
192
+ end
193
+
194
+ issues
195
+ end
196
+ end
197
+ end
198
+ end