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,208 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "yaml"
4
+
5
+ module Fontisan
6
+ module Variable
7
+ # Calculates region scalars for variation regions
8
+ #
9
+ # Given normalized coordinates and variation regions, computes scalar values
10
+ # (0.0 to 1.0) that determine how much each region contributes to the final
11
+ # delta values. The algorithm follows the OpenType specification for region
12
+ # matching.
13
+ #
14
+ # A region is defined by start, peak, and end coordinates for each axis:
15
+ # - If coordinate is outside [start, end], scalar is 0.0
16
+ # - If coordinate is at peak, scalar contribution for that axis is 1.0
17
+ # - Otherwise, scalar is linearly interpolated
18
+ # - Final scalar is the product of all axis contributions
19
+ #
20
+ # @example Calculate region scalars
21
+ # matcher = RegionMatcher.new(variation_region_list)
22
+ # scalars = matcher.match({ "wght" => 0.5 })
23
+ # # => [0.5, 1.0, 0.0, ...]
24
+ class RegionMatcher
25
+ # @return [Hash] Configuration settings
26
+ attr_reader :config
27
+
28
+ # @return [Array<Array<Hash>>] Variation regions
29
+ attr_reader :regions
30
+
31
+ # Initialize the region matcher
32
+ #
33
+ # @param variation_region_list [VariationCommon::VariationRegionList] Region list
34
+ # @param axis_tags [Array<String>] Axis tags in order
35
+ # @param config [Hash] Optional configuration overrides
36
+ def initialize(variation_region_list, axis_tags, config = {})
37
+ @variation_region_list = variation_region_list
38
+ @axis_tags = axis_tags
39
+ @config = load_config.merge(config)
40
+ @regions = build_regions
41
+ @scalar_cache = {} if @config.dig(:region_matching, :cache_scalars)
42
+ end
43
+
44
+ # Calculate scalars for all regions
45
+ #
46
+ # @param normalized_coords [Hash<String, Float>] Normalized coordinates
47
+ # @return [Array<Float>] Scalar for each region (0.0 to 1.0)
48
+ def match(normalized_coords)
49
+ # Check cache if enabled
50
+ if @config.dig(:region_matching, :cache_scalars)
51
+ cache_key = cache_key_for(normalized_coords)
52
+ return @scalar_cache[cache_key] if @scalar_cache.key?(cache_key)
53
+ end
54
+
55
+ scalars = @regions.map do |region|
56
+ calculate_region_scalar(region, normalized_coords)
57
+ end
58
+
59
+ # Cache result if enabled
60
+ if @config.dig(:region_matching, :cache_scalars)
61
+ @scalar_cache[cache_key_for(normalized_coords)] = scalars
62
+ end
63
+
64
+ scalars
65
+ end
66
+
67
+ # Calculate scalar for a specific region
68
+ #
69
+ # @param region_index [Integer] Region index
70
+ # @param normalized_coords [Hash<String, Float>] Normalized coordinates
71
+ # @return [Float] Region scalar (0.0 to 1.0)
72
+ def match_region(region_index, normalized_coords)
73
+ return 0.0 if region_index >= @regions.length
74
+
75
+ region = @regions[region_index]
76
+ calculate_region_scalar(region, normalized_coords)
77
+ end
78
+
79
+ # Get number of regions
80
+ #
81
+ # @return [Integer] Region count
82
+ def region_count
83
+ @regions.length
84
+ end
85
+
86
+ # Clear scalar cache
87
+ def clear_cache
88
+ @scalar_cache&.clear
89
+ end
90
+
91
+ private
92
+
93
+ # Load configuration from YAML file
94
+ #
95
+ # @return [Hash] Configuration hash
96
+ def load_config
97
+ config_path = File.join(__dir__, "..", "config",
98
+ "variable_settings.yml")
99
+ YAML.load_file(config_path)
100
+ rescue StandardError
101
+ # Return default config
102
+ {
103
+ region_matching: {
104
+ algorithm: "standard",
105
+ multi_axis: true,
106
+ cache_scalars: true,
107
+ },
108
+ delta_application: {
109
+ min_scalar_threshold: 0.0001,
110
+ },
111
+ }
112
+ end
113
+
114
+ # Build region information from variation region list
115
+ #
116
+ # @return [Array<Array<Hash>>] Array of regions with axis coordinates
117
+ def build_regions
118
+ return [] unless @variation_region_list
119
+
120
+ @variation_region_list.regions.map do |region_coords|
121
+ # Map axis coordinates to hash
122
+ region_coords.each_with_index.map do |coord, axis_index|
123
+ {
124
+ axis_tag: @axis_tags[axis_index],
125
+ start: coord.start,
126
+ peak: coord.peak,
127
+ end: coord.end_value,
128
+ }
129
+ end
130
+ end
131
+ end
132
+
133
+ # Calculate scalar for a region
134
+ #
135
+ # @param region [Array<Hash>] Region axis coordinates
136
+ # @param normalized_coords [Hash<String, Float>] Normalized coordinates
137
+ # @return [Float] Region scalar (0.0 to 1.0)
138
+ def calculate_region_scalar(region, normalized_coords)
139
+ # Start with scalar of 1.0
140
+ scalar = 1.0
141
+
142
+ # Process each axis in the region
143
+ region.each do |axis_coord|
144
+ axis_tag = axis_coord[:axis_tag]
145
+ coord = normalized_coords[axis_tag] || normalized_coords[axis_tag.to_sym] || 0.0
146
+
147
+ # Calculate contribution for this axis
148
+ axis_scalar = calculate_axis_scalar(
149
+ coord,
150
+ axis_coord[:start],
151
+ axis_coord[:peak],
152
+ axis_coord[:end],
153
+ )
154
+
155
+ # Multiply into total scalar
156
+ scalar *= axis_scalar
157
+
158
+ # Early exit if scalar becomes 0
159
+ break if scalar.zero?
160
+ end
161
+
162
+ # Apply minimum threshold if configured
163
+ threshold = @config.dig(:delta_application,
164
+ :min_scalar_threshold) || 0.0
165
+ scalar < threshold ? 0.0 : scalar
166
+ end
167
+
168
+ # Calculate scalar contribution for a single axis
169
+ #
170
+ # @param coord [Float] Normalized coordinate
171
+ # @param start [Float] Region start
172
+ # @param peak [Float] Region peak
173
+ # @param end_coord [Float] Region end
174
+ # @return [Float] Axis scalar (0.0 to 1.0)
175
+ def calculate_axis_scalar(coord, start, peak, end_coord)
176
+ # Outside region range: no contribution
177
+ return 0.0 if coord < start || coord > end_coord
178
+
179
+ # At peak: full contribution
180
+ return 1.0 if (coord - peak).abs < Float::EPSILON
181
+
182
+ # Between start and peak
183
+ if coord < peak
184
+ range = peak - start
185
+ return 1.0 if range.abs < Float::EPSILON
186
+
187
+ return (coord - start) / range
188
+ end
189
+
190
+ # Between peak and end
191
+ range = end_coord - peak
192
+ return 1.0 if range.abs < Float::EPSILON
193
+
194
+ (end_coord - coord) / range
195
+ end
196
+
197
+ # Generate cache key for coordinates
198
+ #
199
+ # @param normalized_coords [Hash] Normalized coordinates
200
+ # @return [String] Cache key
201
+ def cache_key_for(normalized_coords)
202
+ # Sort by axis tag for consistent keys
203
+ sorted_coords = normalized_coords.sort_by { |tag, _| tag.to_s }
204
+ sorted_coords.map { |tag, value| "#{tag}:#{value}" }.join("|")
205
+ end
206
+ end
207
+ end
208
+ end
@@ -0,0 +1,213 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "table_updater"
4
+ require_relative "../font_writer"
5
+
6
+ module Fontisan
7
+ module Variable
8
+ # Builds static font instances from variable font data
9
+ #
10
+ # This class takes a variable font and applied variation data,
11
+ # then constructs a complete static font by:
12
+ # 1. Copying all non-variation tables unchanged
13
+ # 2. Removing variation-specific tables (fvar, gvar, HVAR, etc.)
14
+ # 3. Updating metric tables (hmtx, hhea) with varied values
15
+ # 4. Updating head table's modified timestamp
16
+ # 5. Writing the complete static font binary
17
+ #
18
+ # The result is a valid static font at the specified instance point.
19
+ #
20
+ # @example Build static font
21
+ # builder = StaticFontBuilder.new(font)
22
+ # static_binary = builder.build(varied_metrics, font_metrics)
23
+ class StaticFontBuilder
24
+ # Tables to remove from static font (variation-specific)
25
+ VARIATION_TABLES = %w[fvar avar gvar cvar HVAR VVAR MVAR STAT].freeze
26
+
27
+ # @return [TableUpdater] Table updater instance
28
+ attr_reader :table_updater
29
+
30
+ # Initialize the builder
31
+ #
32
+ # @param font [TrueTypeFont, OpenTypeFont] Variable font object
33
+ def initialize(font)
34
+ @font = font
35
+ @table_updater = TableUpdater.new
36
+ end
37
+
38
+ # Build static font from varied data
39
+ #
40
+ # @param varied_metrics [Hash<Integer, Hash>] Varied metrics by glyph ID
41
+ # { glyph_id => { advance_width: 500, lsb: 50 } }
42
+ # @param font_metrics [Hash] Varied font-level metrics
43
+ # { ascent: 2048, descent: -512, line_gap: 0 }
44
+ # @param options [Hash] Build options
45
+ # @option options [Boolean] :update_modified Update head modified timestamp
46
+ # @return [String] Complete static font binary
47
+ def build(varied_metrics = {}, font_metrics = {}, options = {})
48
+ # Collect tables for static font
49
+ tables = collect_tables(varied_metrics, font_metrics, options)
50
+
51
+ # Detect sfnt version
52
+ sfnt_version = detect_sfnt_version(tables)
53
+
54
+ # Write font using FontWriter
55
+ FontWriter.write_font(tables, sfnt_version: sfnt_version)
56
+ end
57
+
58
+ # Build static font and write to file
59
+ #
60
+ # @param output_path [String] Output file path
61
+ # @param varied_metrics [Hash<Integer, Hash>] Varied metrics by glyph ID
62
+ # @param font_metrics [Hash] Varied font-level metrics
63
+ # @param options [Hash] Build options
64
+ # @return [Integer] Number of bytes written
65
+ def build_to_file(output_path, varied_metrics = {}, font_metrics = {},
66
+ options = {})
67
+ binary = build(varied_metrics, font_metrics, options)
68
+ File.binwrite(output_path, binary)
69
+ end
70
+
71
+ private
72
+
73
+ # Collect all tables for static font
74
+ #
75
+ # @param varied_metrics [Hash] Varied glyph metrics
76
+ # @param font_metrics [Hash] Varied font metrics
77
+ # @param options [Hash] Build options
78
+ # @return [Hash<String, String>] Map of table tag to binary data
79
+ def collect_tables(varied_metrics, font_metrics, options)
80
+ tables = {}
81
+
82
+ # Get all table tags from font
83
+ table_tags = @font.respond_to?(:tables) ? @font.tables.keys : []
84
+
85
+ table_tags.each do |tag|
86
+ # Skip variation tables
87
+ next if VARIATION_TABLES.include?(tag)
88
+
89
+ # Get original table data
90
+ original_data = @font.table_data(tag)
91
+ next if original_data.nil? || original_data.empty?
92
+
93
+ # Update specific tables with varied data
94
+ tables[tag] = case tag
95
+ when "hmtx"
96
+ update_hmtx_table(original_data, varied_metrics)
97
+ when "hhea"
98
+ update_hhea_table(original_data, font_metrics)
99
+ when "OS/2"
100
+ update_os2_table(original_data, font_metrics)
101
+ when "head"
102
+ update_head_table(original_data, options)
103
+ else
104
+ # Copy unchanged
105
+ original_data
106
+ end
107
+ end
108
+
109
+ tables
110
+ end
111
+
112
+ # Update hmtx table with varied metrics
113
+ #
114
+ # @param original_data [String] Original table data
115
+ # @param varied_metrics [Hash] Varied glyph metrics
116
+ # @return [String] Updated table data
117
+ def update_hmtx_table(original_data, varied_metrics)
118
+ return original_data if varied_metrics.empty?
119
+
120
+ # Get required context from other tables
121
+ hhea = load_table("hhea")
122
+ maxp = load_table("maxp")
123
+
124
+ return original_data unless hhea && maxp
125
+
126
+ num_h_metrics = hhea.number_of_h_metrics
127
+ num_glyphs = maxp.num_glyphs
128
+
129
+ @table_updater.update_hmtx(
130
+ original_data,
131
+ varied_metrics,
132
+ num_h_metrics,
133
+ num_glyphs,
134
+ )
135
+ end
136
+
137
+ # Update hhea table with varied metrics
138
+ #
139
+ # @param original_data [String] Original table data
140
+ # @param font_metrics [Hash] Varied font metrics
141
+ # @return [String] Updated table data
142
+ def update_hhea_table(original_data, font_metrics)
143
+ return original_data if font_metrics.empty?
144
+
145
+ # Extract hhea-specific metrics
146
+ hhea_metrics = {}
147
+ hhea_metrics[:ascent] = font_metrics["hasc"] if font_metrics["hasc"]
148
+ hhea_metrics[:descent] = font_metrics["hdsc"] if font_metrics["hdsc"]
149
+ hhea_metrics[:line_gap] = font_metrics["hlgp"] if font_metrics["hlgp"]
150
+
151
+ return original_data if hhea_metrics.empty?
152
+
153
+ @table_updater.update_hhea(original_data, hhea_metrics)
154
+ end
155
+
156
+ # Update OS/2 table with varied metrics
157
+ #
158
+ # @param original_data [String] Original table data
159
+ # @param font_metrics [Hash] Varied font metrics
160
+ # @return [String] Updated table data
161
+ def update_os2_table(original_data, font_metrics)
162
+ return original_data if font_metrics.empty?
163
+
164
+ @table_updater.update_os2(original_data, font_metrics)
165
+ end
166
+
167
+ # Update head table
168
+ #
169
+ # @param original_data [String] Original table data
170
+ # @param options [Hash] Build options
171
+ # @return [String] Updated table data
172
+ def update_head_table(original_data, options)
173
+ if options[:update_modified] == false
174
+ original_data
175
+ else
176
+ @table_updater.update_head_modified(original_data)
177
+ end
178
+ end
179
+
180
+ # Load a table from the font
181
+ #
182
+ # @param tag [String] Table tag
183
+ # @return [Object, nil] Parsed table or nil
184
+ def load_table(tag)
185
+ data = @font.table_data(tag)
186
+ return nil if data.nil? || data.empty?
187
+
188
+ table_class = case tag
189
+ when "hhea" then Tables::Hhea
190
+ when "maxp" then Tables::Maxp
191
+ when "head" then Tables::Head
192
+ else return nil
193
+ end
194
+
195
+ table_class.read(data)
196
+ rescue StandardError
197
+ nil
198
+ end
199
+
200
+ # Detect sfnt version from tables
201
+ #
202
+ # @param tables [Hash] Map of table tag to data
203
+ # @return [Integer] sfnt version
204
+ def detect_sfnt_version(tables)
205
+ if tables.key?("CFF ") || tables.key?("CFF2")
206
+ 0x4F54544F # 'OTTO' for OpenType/CFF
207
+ else
208
+ 0x00010000 # 1.0 for TrueType
209
+ end
210
+ end
211
+ end
212
+ end
213
+ end
@@ -0,0 +1,219 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "stringio"
4
+
5
+ module Fontisan
6
+ module Variable
7
+ # Updates font tables with applied variation deltas
8
+ #
9
+ # This class is responsible for taking original table data and applying
10
+ # calculated deltas to create updated tables for static font instances.
11
+ # It handles:
12
+ # - Updating hmtx with varied advance widths and sidebearings
13
+ # - Updating hhea with varied ascent/descent/line gap
14
+ # - Updating OS/2 with varied metrics
15
+ # - Updating head table's modified timestamp
16
+ #
17
+ # Each update method takes the original table and delta values,
18
+ # then reconstructs the table binary with updated values.
19
+ #
20
+ # @example Update hmtx table
21
+ # updater = TableUpdater.new
22
+ # new_hmtx = updater.update_hmtx(
23
+ # original_hmtx_data,
24
+ # varied_metrics,
25
+ # num_h_metrics,
26
+ # num_glyphs
27
+ # )
28
+ class TableUpdater
29
+ # Update hmtx table with varied metrics
30
+ #
31
+ # @param original_data [String] Original hmtx table binary
32
+ # @param varied_metrics [Hash<Integer, Hash>] Varied metrics by glyph ID
33
+ # { glyph_id => { advance_width: 500, lsb: 50 } }
34
+ # @param num_h_metrics [Integer] Number of hMetrics from hhea
35
+ # @param num_glyphs [Integer] Total glyphs from maxp
36
+ # @return [String] Updated hmtx table binary
37
+ def update_hmtx(original_data, varied_metrics, num_h_metrics, num_glyphs)
38
+ io = StringIO.new(original_data)
39
+ io.set_encoding(Encoding::BINARY)
40
+
41
+ # Parse original hMetrics
42
+ h_metrics = []
43
+ num_h_metrics.times do
44
+ advance_width = io.read(2)&.unpack1("n") || 0
45
+ lsb = io.read(2)&.unpack1("n") || 0
46
+ lsb = lsb >= 0x8000 ? lsb - 0x10000 : lsb # Convert to signed
47
+ h_metrics << { advance_width: advance_width, lsb: lsb }
48
+ end
49
+
50
+ # Parse additional LSBs
51
+ lsb_count = num_glyphs - num_h_metrics
52
+ left_side_bearings = []
53
+ lsb_count.times do
54
+ lsb = io.read(2)&.unpack1("n") || 0
55
+ lsb = lsb >= 0x8000 ? lsb - 0x10000 : lsb
56
+ left_side_bearings << lsb
57
+ end
58
+
59
+ # Apply varied metrics
60
+ varied_metrics.each do |glyph_id, metrics|
61
+ if glyph_id < num_h_metrics
62
+ if metrics[:advance_width]
63
+ h_metrics[glyph_id][:advance_width] =
64
+ metrics[:advance_width]
65
+ end
66
+ h_metrics[glyph_id][:lsb] = metrics[:lsb] if metrics[:lsb]
67
+ else
68
+ lsb_index = glyph_id - num_h_metrics
69
+ left_side_bearings[lsb_index] = metrics[:lsb] if metrics[:lsb]
70
+ end
71
+ end
72
+
73
+ # Build updated hmtx binary
74
+ output = String.new(encoding: Encoding::BINARY)
75
+
76
+ # Write hMetrics
77
+ h_metrics.each do |metric|
78
+ output << [metric[:advance_width]].pack("n")
79
+ # Convert signed LSB to unsigned for packing
80
+ lsb_unsigned = metric[:lsb].negative? ? metric[:lsb] + 0x10000 : metric[:lsb]
81
+ output << [lsb_unsigned].pack("n")
82
+ end
83
+
84
+ # Write additional LSBs
85
+ left_side_bearings.each do |lsb|
86
+ lsb_unsigned = lsb.negative? ? lsb + 0x10000 : lsb
87
+ output << [lsb_unsigned].pack("n")
88
+ end
89
+
90
+ output
91
+ end
92
+
93
+ # Update hhea table with varied metrics
94
+ #
95
+ # @param original_data [String] Original hhea table binary
96
+ # @param varied_metrics [Hash] Varied font metrics
97
+ # { ascent: 2048, descent: -512, line_gap: 0 }
98
+ # @return [String] Updated hhea table binary
99
+ def update_hhea(original_data, varied_metrics)
100
+ io = StringIO.new(original_data)
101
+ io.set_encoding(Encoding::BINARY)
102
+
103
+ # Read all fields
104
+ version = io.read(4)
105
+ ascent = io.read(2)&.unpack1("n") || 0
106
+ ascent = ascent >= 0x8000 ? ascent - 0x10000 : ascent
107
+ descent = io.read(2)&.unpack1("n") || 0
108
+ descent = descent >= 0x8000 ? descent - 0x10000 : descent
109
+ line_gap = io.read(2)&.unpack1("n") || 0
110
+ line_gap = line_gap >= 0x8000 ? line_gap - 0x10000 : line_gap
111
+
112
+ # Read remaining fields
113
+ rest = io.read
114
+
115
+ # Apply varied metrics
116
+ ascent = varied_metrics[:ascent] if varied_metrics[:ascent]
117
+ descent = varied_metrics[:descent] if varied_metrics[:descent]
118
+ line_gap = varied_metrics[:line_gap] if varied_metrics[:line_gap]
119
+
120
+ # Build updated hhea binary
121
+ output = String.new(encoding: Encoding::BINARY)
122
+ output << version
123
+
124
+ # Convert signed values to unsigned for packing
125
+ ascent_unsigned = ascent.negative? ? ascent + 0x10000 : ascent
126
+ descent_unsigned = descent.negative? ? descent + 0x10000 : descent
127
+ line_gap_unsigned = line_gap.negative? ? line_gap + 0x10000 : line_gap
128
+
129
+ output << [ascent_unsigned].pack("n")
130
+ output << [descent_unsigned].pack("n")
131
+ output << [line_gap_unsigned].pack("n")
132
+ output << rest
133
+
134
+ output
135
+ end
136
+
137
+ # Update OS/2 table with varied metrics
138
+ #
139
+ # @param original_data [String] Original OS/2 table binary
140
+ # @param varied_metrics [Hash] Varied font metrics from MVAR
141
+ # @return [String] Updated OS/2 table binary
142
+ def update_os2(original_data, varied_metrics)
143
+ return original_data if varied_metrics.empty?
144
+
145
+ io = StringIO.new(original_data)
146
+ io.set_encoding(Encoding::BINARY)
147
+
148
+ # Read version to determine table size
149
+ io.read(2)&.unpack1("n") || 0
150
+ io.rewind
151
+
152
+ # For simplicity, return original if no specific OS/2 metrics to update
153
+ # This would need to be expanded based on MVAR tags present
154
+ original_data
155
+ end
156
+
157
+ # Update head table's modified timestamp
158
+ #
159
+ # @param original_data [String] Original head table binary
160
+ # @param timestamp [Time] New modification time
161
+ # @return [String] Updated head table binary
162
+ def update_head_modified(original_data, timestamp = Time.now)
163
+ io = StringIO.new(original_data)
164
+ io.set_encoding(Encoding::BINARY)
165
+
166
+ # Read up to modified timestamp
167
+ header = io.read(28) # version through created timestamp
168
+ _old_modified = io.read(8) # Skip old modified timestamp
169
+ rest = io.read # Remaining data
170
+
171
+ # Convert Time to LONGDATETIME (seconds since 1904-01-01)
172
+ # Difference between 1904 and 1970 (Unix epoch) is 2082844800 seconds
173
+ longdatetime = timestamp.to_i + 2_082_844_800
174
+
175
+ # Build updated head binary
176
+ output = String.new(encoding: Encoding::BINARY)
177
+ output << header
178
+ output << [longdatetime].pack("q>") # 64-bit big-endian signed integer
179
+ output << rest
180
+
181
+ output
182
+ end
183
+
184
+ # Build updated table with varied values
185
+ #
186
+ # This is a generic helper for building updated table binaries
187
+ #
188
+ # @param original_data [String] Original table binary
189
+ # @param updates [Hash] Hash of offset => new_value pairs
190
+ # @return [String] Updated table binary
191
+ def apply_updates(original_data, updates)
192
+ data = original_data.dup
193
+
194
+ updates.each do |offset, value|
195
+ # Handle different value types
196
+ packed_value = case value
197
+ when Integer
198
+ if value >= -32768 && value <= 32767
199
+ # int16
200
+ unsigned = value.negative? ? value + 0x10000 : value
201
+ [unsigned].pack("n")
202
+ else
203
+ # int32
204
+ [value].pack("N")
205
+ end
206
+ when String
207
+ value
208
+ else
209
+ value.to_s
210
+ end
211
+
212
+ data[offset, packed_value.bytesize] = packed_value
213
+ end
214
+
215
+ data
216
+ end
217
+ end
218
+ end
219
+ end