fontisan 0.2.0 → 0.2.2

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 (99) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop_todo.yml +119 -308
  3. data/README.adoc +1525 -1323
  4. data/Rakefile +45 -47
  5. data/benchmark/variation_quick_bench.rb +4 -4
  6. data/docs/FONT_HINTING.adoc +562 -0
  7. data/docs/VARIABLE_FONT_OPERATIONS.adoc +599 -0
  8. data/lib/fontisan/cli.rb +92 -34
  9. data/lib/fontisan/collection/builder.rb +82 -0
  10. data/lib/fontisan/collection/offset_calculator.rb +2 -0
  11. data/lib/fontisan/collection/table_deduplicator.rb +76 -0
  12. data/lib/fontisan/commands/base_command.rb +21 -2
  13. data/lib/fontisan/commands/convert_command.rb +96 -165
  14. data/lib/fontisan/commands/info_command.rb +111 -5
  15. data/lib/fontisan/commands/instance_command.rb +77 -85
  16. data/lib/fontisan/commands/validate_command.rb +28 -0
  17. data/lib/fontisan/config/validation_rules.yml +1 -1
  18. data/lib/fontisan/constants.rb +34 -24
  19. data/lib/fontisan/converters/format_converter.rb +154 -1
  20. data/lib/fontisan/converters/outline_converter.rb +101 -34
  21. data/lib/fontisan/converters/woff_writer.rb +9 -4
  22. data/lib/fontisan/font_loader.rb +14 -9
  23. data/lib/fontisan/font_writer.rb +9 -6
  24. data/lib/fontisan/formatters/text_formatter.rb +45 -1
  25. data/lib/fontisan/hints/hint_converter.rb +131 -2
  26. data/lib/fontisan/hints/hint_validator.rb +284 -0
  27. data/lib/fontisan/hints/postscript_hint_applier.rb +219 -140
  28. data/lib/fontisan/hints/postscript_hint_extractor.rb +151 -16
  29. data/lib/fontisan/hints/truetype_hint_applier.rb +90 -44
  30. data/lib/fontisan/hints/truetype_hint_extractor.rb +134 -11
  31. data/lib/fontisan/hints/truetype_instruction_analyzer.rb +261 -0
  32. data/lib/fontisan/hints/truetype_instruction_generator.rb +266 -0
  33. data/lib/fontisan/loading_modes.rb +6 -4
  34. data/lib/fontisan/models/collection_brief_info.rb +31 -0
  35. data/lib/fontisan/models/font_info.rb +3 -30
  36. data/lib/fontisan/models/hint.rb +183 -12
  37. data/lib/fontisan/models/outline.rb +4 -1
  38. data/lib/fontisan/open_type_font.rb +28 -10
  39. data/lib/fontisan/open_type_font_extensions.rb +54 -0
  40. data/lib/fontisan/optimizers/pattern_analyzer.rb +2 -1
  41. data/lib/fontisan/optimizers/subroutine_generator.rb +1 -1
  42. data/lib/fontisan/pipeline/format_detector.rb +249 -0
  43. data/lib/fontisan/pipeline/output_writer.rb +159 -0
  44. data/lib/fontisan/pipeline/strategies/base_strategy.rb +75 -0
  45. data/lib/fontisan/pipeline/strategies/instance_strategy.rb +93 -0
  46. data/lib/fontisan/pipeline/strategies/named_strategy.rb +118 -0
  47. data/lib/fontisan/pipeline/strategies/preserve_strategy.rb +56 -0
  48. data/lib/fontisan/pipeline/transformation_pipeline.rb +416 -0
  49. data/lib/fontisan/pipeline/variation_resolver.rb +165 -0
  50. data/lib/fontisan/subset/table_subsetter.rb +5 -5
  51. data/lib/fontisan/tables/cff/charstring.rb +58 -3
  52. data/lib/fontisan/tables/cff/charstring_builder.rb +34 -0
  53. data/lib/fontisan/tables/cff/charstring_parser.rb +249 -0
  54. data/lib/fontisan/tables/cff/charstring_rebuilder.rb +172 -0
  55. data/lib/fontisan/tables/cff/dict_builder.rb +19 -1
  56. data/lib/fontisan/tables/cff/hint_operation_injector.rb +209 -0
  57. data/lib/fontisan/tables/cff/offset_recalculator.rb +70 -0
  58. data/lib/fontisan/tables/cff/private_dict_writer.rb +131 -0
  59. data/lib/fontisan/tables/cff/table_builder.rb +221 -0
  60. data/lib/fontisan/tables/cff.rb +2 -0
  61. data/lib/fontisan/tables/cff2/charstring_parser.rb +14 -8
  62. data/lib/fontisan/tables/cff2/private_dict_blend_handler.rb +247 -0
  63. data/lib/fontisan/tables/cff2/region_matcher.rb +200 -0
  64. data/lib/fontisan/tables/cff2/table_builder.rb +580 -0
  65. data/lib/fontisan/tables/cff2/table_reader.rb +421 -0
  66. data/lib/fontisan/tables/cff2/variation_data_extractor.rb +212 -0
  67. data/lib/fontisan/tables/cff2.rb +10 -5
  68. data/lib/fontisan/tables/cvar.rb +2 -41
  69. data/lib/fontisan/tables/glyf/compound_glyph_resolver.rb +2 -1
  70. data/lib/fontisan/tables/glyf/curve_converter.rb +10 -4
  71. data/lib/fontisan/tables/glyf/glyph_builder.rb +27 -10
  72. data/lib/fontisan/tables/gvar.rb +2 -41
  73. data/lib/fontisan/tables/name.rb +4 -4
  74. data/lib/fontisan/true_type_font.rb +27 -10
  75. data/lib/fontisan/true_type_font_extensions.rb +54 -0
  76. data/lib/fontisan/utilities/checksum_calculator.rb +42 -0
  77. data/lib/fontisan/validation/checksum_validator.rb +2 -2
  78. data/lib/fontisan/validation/table_validator.rb +1 -1
  79. data/lib/fontisan/validation/variable_font_validator.rb +218 -0
  80. data/lib/fontisan/variation/cache.rb +3 -1
  81. data/lib/fontisan/variation/converter.rb +121 -13
  82. data/lib/fontisan/variation/delta_applier.rb +2 -1
  83. data/lib/fontisan/variation/inspector.rb +2 -1
  84. data/lib/fontisan/variation/instance_generator.rb +2 -1
  85. data/lib/fontisan/variation/instance_writer.rb +341 -0
  86. data/lib/fontisan/variation/optimizer.rb +6 -3
  87. data/lib/fontisan/variation/subsetter.rb +32 -10
  88. data/lib/fontisan/variation/tuple_variation_header.rb +51 -0
  89. data/lib/fontisan/variation/variable_svg_generator.rb +268 -0
  90. data/lib/fontisan/variation/variation_preserver.rb +291 -0
  91. data/lib/fontisan/version.rb +1 -1
  92. data/lib/fontisan/version.rb.orig +9 -0
  93. data/lib/fontisan/woff2/glyf_transformer.rb +693 -0
  94. data/lib/fontisan/woff2/hmtx_transformer.rb +164 -0
  95. data/lib/fontisan/woff2_font.rb +489 -468
  96. data/lib/fontisan/woff_font.rb +16 -11
  97. data/lib/fontisan.rb +54 -2
  98. data/scripts/measure_optimization.rb +15 -7
  99. metadata +37 -2
@@ -0,0 +1,200 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Fontisan
4
+ module Tables
5
+ class Cff2
6
+ # Region matcher for calculating variation scalars
7
+ #
8
+ # Maps design space coordinates to region scalars based on
9
+ # the Variable Store region definitions. Each region defines
10
+ # a range (start, peak, end) for each variation axis.
11
+ #
12
+ # Scalar Calculation:
13
+ # - If coordinate is at peak: scalar = 1.0
14
+ # - If coordinate is between start and peak: linear interpolation
15
+ # - If coordinate is between peak and end: linear interpolation
16
+ # - If coordinate is outside [start, end]: scalar = 0.0
17
+ #
18
+ # Reference: OpenType Font Variations Overview
19
+ # Reference: Adobe Technical Note #5177 (CFF2)
20
+ #
21
+ # @example Calculating scalars
22
+ # matcher = RegionMatcher.new(regions)
23
+ # scalars = matcher.calculate_scalars({ "wght" => 0.5, "wdth" => 0.3 })
24
+ class RegionMatcher
25
+ # @return [Array<Hash>] Regions from Variable Store
26
+ attr_reader :regions
27
+
28
+ # Initialize matcher with regions
29
+ #
30
+ # @param regions [Array<Hash>] Region definitions from Variable Store
31
+ def initialize(regions)
32
+ @regions = regions
33
+ end
34
+
35
+ # Calculate scalars for all regions at given coordinates
36
+ #
37
+ # Coordinates are normalized values in the range [-1.0, 1.0]
38
+ # where 0.0 represents the default/regular style.
39
+ #
40
+ # @param coordinates [Array<Float>] Normalized coordinates per axis
41
+ # @return [Array<Float>] Scalars for each region
42
+ def calculate_scalars(coordinates)
43
+ @regions.map do |region|
44
+ calculate_region_scalar(region, coordinates)
45
+ end
46
+ end
47
+
48
+ # Calculate scalar for a single region
49
+ #
50
+ # The scalar is the product of scalars for all axes in the region.
51
+ # If any axis has scalar 0.0, the entire region scalar is 0.0.
52
+ #
53
+ # @param region [Hash] Region definition
54
+ # @param coordinates [Array<Float>] Normalized coordinates per axis
55
+ # @return [Float] Scalar for the region (0.0 to 1.0)
56
+ def calculate_region_scalar(region, coordinates)
57
+ axes = region[:axes]
58
+
59
+ # Multiply scalars for all axes
60
+ scalar = 1.0
61
+ axes.each_with_index do |axis, i|
62
+ coord = coordinates[i] || 0.0
63
+ axis_scalar = calculate_axis_scalar(axis, coord)
64
+ scalar *= axis_scalar
65
+
66
+ # Early exit if any axis is out of range
67
+ return 0.0 if axis_scalar.zero?
68
+ end
69
+
70
+ scalar
71
+ end
72
+
73
+ # Calculate scalar for a single axis
74
+ #
75
+ # @param axis [Hash] Axis definition with :start_coord, :peak_coord, :end_coord
76
+ # @param coordinate [Float] Normalized coordinate for this axis
77
+ # @return [Float] Scalar for this axis (0.0 to 1.0)
78
+ def calculate_axis_scalar(axis, coordinate)
79
+ start_coord = axis[:start_coord]
80
+ peak_coord = axis[:peak_coord]
81
+ end_coord = axis[:end_coord]
82
+
83
+ # Outside the region
84
+ return 0.0 if coordinate < start_coord || coordinate > end_coord
85
+
86
+ # At or beyond peak
87
+ return 1.0 if coordinate == peak_coord
88
+
89
+ # Between start and peak
90
+ if coordinate < peak_coord
91
+ # Linear interpolation: (coord - start) / (peak - start)
92
+ range = peak_coord - start_coord
93
+ return 1.0 if range.zero? # Avoid division by zero
94
+
95
+ (coordinate - start_coord) / range
96
+ else
97
+ # Between peak and end
98
+ # Linear interpolation: (end - coord) / (end - peak)
99
+ range = end_coord - peak_coord
100
+ return 1.0 if range.zero? # Avoid division by zero
101
+
102
+ (end_coord - coordinate) / range
103
+ end
104
+ end
105
+
106
+ # Check if coordinates are within any region
107
+ #
108
+ # @param coordinates [Array<Float>] Normalized coordinates
109
+ # @return [Boolean] True if coordinates activate any region
110
+ def coordinates_active?(coordinates)
111
+ scalars = calculate_scalars(coordinates)
112
+ scalars.any?(&:positive?)
113
+ end
114
+
115
+ # Get active regions for coordinates
116
+ #
117
+ # Returns indices of regions that have non-zero scalars
118
+ #
119
+ # @param coordinates [Array<Float>] Normalized coordinates
120
+ # @return [Array<Integer>] Indices of active regions
121
+ def active_regions(coordinates)
122
+ scalars = calculate_scalars(coordinates)
123
+ scalars.each_with_index.select { |scalar, _| scalar.positive? }
124
+ .map(&:last)
125
+ end
126
+
127
+ # Get scalar for specific region index
128
+ #
129
+ # @param region_index [Integer] Region index
130
+ # @param coordinates [Array<Float>] Normalized coordinates
131
+ # @return [Float, nil] Scalar for the region, or nil if index invalid
132
+ def scalar_for_region(region_index, coordinates)
133
+ return nil if region_index >= @regions.size
134
+
135
+ region = @regions[region_index]
136
+ calculate_region_scalar(region, coordinates)
137
+ end
138
+
139
+ # Validate region structure
140
+ #
141
+ # @return [Array<String>] Array of validation errors (empty if valid)
142
+ def validate
143
+ errors = []
144
+
145
+ @regions.each_with_index do |region, i|
146
+ axes = region[:axes]
147
+ unless axes.is_a?(Array)
148
+ errors << "Region #{i} has invalid axes (not an array)"
149
+ next
150
+ end
151
+
152
+ axes.each_with_index do |axis, j|
153
+ unless axis.is_a?(Hash)
154
+ errors << "Region #{i}, axis #{j} is not a hash"
155
+ next
156
+ end
157
+
158
+ # Check required keys
159
+ %i[start_coord peak_coord end_coord].each do |key|
160
+ unless axis.key?(key)
161
+ errors << "Region #{i}, axis #{j} missing #{key}"
162
+ end
163
+ end
164
+
165
+ # Validate coordinate ordering
166
+ if axis[:start_coord] && axis[:peak_coord] && axis[:end_coord]
167
+ start = axis[:start_coord]
168
+ peak = axis[:peak_coord]
169
+ ending = axis[:end_coord]
170
+
171
+ unless start <= peak && peak <= ending
172
+ errors << "Region #{i}, axis #{j} has invalid ordering: " \
173
+ "#{start} > #{peak} > #{ending}"
174
+ end
175
+ end
176
+ end
177
+ end
178
+
179
+ errors
180
+ end
181
+
182
+ # Get number of axes from first region
183
+ #
184
+ # @return [Integer] Number of axes
185
+ def axis_count
186
+ return 0 if @regions.empty?
187
+
188
+ @regions.first[:axis_count] || @regions.first[:axes]&.size || 0
189
+ end
190
+
191
+ # Check if matcher has regions
192
+ #
193
+ # @return [Boolean] True if regions are present
194
+ def has_regions?
195
+ !@regions.empty?
196
+ end
197
+ end
198
+ end
199
+ end
200
+ end