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,172 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "charstring_parser"
4
+ require_relative "charstring_builder"
5
+ require_relative "index_builder"
6
+
7
+ module Fontisan
8
+ module Tables
9
+ class Cff
10
+ # Rebuilds CharStrings INDEX with modified CharStrings
11
+ #
12
+ # CharStringRebuilder provides high-level interface for modifying
13
+ # CharStrings in a CFF font. It extracts all CharStrings from the source
14
+ # INDEX, allows modifications through a callback, and rebuilds the INDEX
15
+ # with updated CharString data.
16
+ #
17
+ # Use Cases:
18
+ # - Per-glyph hint injection
19
+ # - CharString optimization
20
+ # - Subroutine insertion
21
+ # - Any operation requiring CharString modification
22
+ #
23
+ # @example Inject hints into specific glyphs
24
+ # rebuilder = CharStringRebuilder.new(charstrings_index)
25
+ # rebuilder.modify_charstring(42) do |operations|
26
+ # # Insert hint operations at beginning
27
+ # hint_ops = [
28
+ # { type: :operator, name: :hstem, operands: [10, 20] }
29
+ # ]
30
+ # hint_ops + operations
31
+ # end
32
+ # new_index_data = rebuilder.rebuild
33
+ class CharStringRebuilder
34
+ # @return [CharstringsIndex] Source CharStrings INDEX
35
+ attr_reader :source_index
36
+
37
+ # @return [Hash] Modified CharString data by glyph index
38
+ attr_reader :modifications
39
+
40
+ # Initialize rebuilder with source CharStrings INDEX
41
+ #
42
+ # @param source_index [CharstringsIndex] Source CharStrings INDEX
43
+ # @param stem_count [Integer] Number of stem hints (for parsing hintmask)
44
+ def initialize(source_index, stem_count: 0)
45
+ @source_index = source_index
46
+ @stem_count = stem_count
47
+ @modifications = {}
48
+ end
49
+
50
+ # Modify a CharString by glyph index
51
+ #
52
+ # The block receives the parsed operations for the glyph and should
53
+ # return modified operations.
54
+ #
55
+ # @param glyph_index [Integer] Glyph index (0 = .notdef)
56
+ # @yield [operations] Block to modify operations
57
+ # @yieldparam operations [Array<Hash>] Parsed operations
58
+ # @yieldreturn [Array<Hash>] Modified operations
59
+ def modify_charstring(glyph_index)
60
+ # Get original CharString data
61
+ original_data = @source_index[glyph_index]
62
+ return unless original_data
63
+
64
+ # Parse to operations
65
+ parser = CharStringParser.new(original_data, stem_count: @stem_count)
66
+ operations = parser.parse
67
+
68
+ # Apply modification
69
+ modified_operations = yield(operations)
70
+
71
+ # Build new CharString
72
+ new_data = CharStringBuilder.build_from_operations(modified_operations)
73
+
74
+ # Store modification
75
+ @modifications[glyph_index] = new_data
76
+ end
77
+
78
+ # Rebuild CharStrings INDEX with modifications
79
+ #
80
+ # Creates new INDEX with modified CharStrings, keeping unmodified
81
+ # CharStrings unchanged.
82
+ #
83
+ # @return [String] Binary CharStrings INDEX data
84
+ def rebuild
85
+ # Collect all CharString data (modified and unmodified)
86
+ charstrings = []
87
+
88
+ (0...@source_index.count).each do |i|
89
+ charstrings << if @modifications.key?(i)
90
+ # Use modified CharString
91
+ @modifications[i]
92
+ else
93
+ # Use original CharString
94
+ @source_index[i]
95
+ end
96
+ end
97
+
98
+ # Build INDEX
99
+ IndexBuilder.build(charstrings)
100
+ end
101
+
102
+ # Batch modify multiple CharStrings
103
+ #
104
+ # More efficient than calling modify_charstring multiple times.
105
+ #
106
+ # @param glyph_indices [Array<Integer>] Glyph indices to modify
107
+ # @yield [glyph_index, operations] Block to modify each glyph
108
+ # @yieldparam glyph_index [Integer] Current glyph index
109
+ # @yieldparam operations [Array<Hash>] Parsed operations
110
+ # @yieldreturn [Array<Hash>] Modified operations
111
+ def batch_modify(glyph_indices)
112
+ glyph_indices.each do |glyph_index|
113
+ modify_charstring(glyph_index) do |operations|
114
+ yield(glyph_index, operations)
115
+ end
116
+ end
117
+ end
118
+
119
+ # Modify all CharStrings
120
+ #
121
+ # Applies the same modification to every glyph.
122
+ #
123
+ # @yield [glyph_index, operations] Block to modify each glyph
124
+ # @yieldparam glyph_index [Integer] Current glyph index
125
+ # @yieldparam operations [Array<Hash>] Parsed operations
126
+ # @yieldreturn [Array<Hash>] Modified operations
127
+ def modify_all
128
+ (0...@source_index.count).each do |i|
129
+ modify_charstring(i) do |operations|
130
+ yield(i, operations)
131
+ end
132
+ end
133
+ end
134
+
135
+ # Get CharString data (modified or original)
136
+ #
137
+ # @param glyph_index [Integer] Glyph index
138
+ # @return [String] CharString binary data
139
+ def charstring_data(glyph_index)
140
+ @modifications[glyph_index] || @source_index[glyph_index]
141
+ end
142
+
143
+ # Check if glyph has been modified
144
+ #
145
+ # @param glyph_index [Integer] Glyph index
146
+ # @return [Boolean] True if modified
147
+ def modified?(glyph_index)
148
+ @modifications.key?(glyph_index)
149
+ end
150
+
151
+ # Get count of modified glyphs
152
+ #
153
+ # @return [Integer] Number of modified glyphs
154
+ def modification_count
155
+ @modifications.size
156
+ end
157
+
158
+ # Clear all modifications
159
+ def clear_modifications
160
+ @modifications.clear
161
+ end
162
+
163
+ # Update stem count (needed for hintmask parsing)
164
+ #
165
+ # @param count [Integer] Number of stem hints
166
+ def stem_count=(count)
167
+ @stem_count = count
168
+ end
169
+ end
170
+ end
171
+ end
172
+ end
@@ -52,6 +52,7 @@ module Fontisan
52
52
  charstring_type: [12, 6],
53
53
  font_matrix: [12, 7],
54
54
  stroke_width: [12, 8],
55
+ font_bbox: 5,
55
56
  synthetic_base: [12, 20],
56
57
  postscript: [12, 21],
57
58
  base_font_name: [12, 22],
@@ -60,6 +61,20 @@ module Fontisan
60
61
  subrs: 19,
61
62
  default_width_x: 20,
62
63
  nominal_width_x: 21,
64
+ # Hint-related Private DICT operators
65
+ blue_values: 6,
66
+ other_blues: 7,
67
+ family_blues: 8,
68
+ family_other_blues: 9,
69
+ std_hw: 10,
70
+ std_vw: 11,
71
+ stem_snap_h: [12, 12],
72
+ stem_snap_v: [12, 13],
73
+ blue_scale: [12, 9],
74
+ blue_shift: [12, 10],
75
+ blue_fuzz: [12, 11],
76
+ force_bold: [12, 14],
77
+ language_group: [12, 17],
63
78
  }.freeze
64
79
 
65
80
  # Build DICT structure from hash
@@ -78,7 +93,10 @@ module Fontisan
78
93
  dict_hash.each do |operator_name, value|
79
94
  # Get operator bytes
80
95
  operator_bytes = operator_for_name(operator_name)
81
- raise ArgumentError, "Unknown operator: #{operator_name}" unless operator_bytes
96
+ unless operator_bytes
97
+ raise ArgumentError,
98
+ "Unknown operator: #{operator_name}"
99
+ end
82
100
 
83
101
  # Write operands (value can be single value or array)
84
102
  if value.is_a?(Array)
@@ -0,0 +1,209 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../../models/hint"
4
+
5
+ module Fontisan
6
+ module Tables
7
+ class Cff
8
+ # Injects hint operations into CharString operation lists
9
+ #
10
+ # HintOperationInjector converts abstract Hint objects into CFF CharString
11
+ # operations and injects them at the appropriate position. It handles:
12
+ # - Stem hints (hstem, vstem, hstemhm, vstemhm)
13
+ # - Hint masks (hintmask with mask data)
14
+ # - Counter masks (cntrmask with mask data)
15
+ # - Stack management (hints are stack-neutral)
16
+ #
17
+ # **Position Rules:**
18
+ # - Hints must appear BEFORE any path construction operators
19
+ # - Width (if present) comes first
20
+ # - Stem hints come before hintmask/cntrmask
21
+ # - Once path construction begins, no more hints allowed
22
+ #
23
+ # **Stack Neutrality:**
24
+ # - Hint operators consume their operands
25
+ # - They don't leave anything on the stack
26
+ # - Path construction starts with clean stack
27
+ #
28
+ # Reference: Type 2 CharString Format Section 4
29
+ # Adobe Technical Note #5177
30
+ #
31
+ # @example Inject hints into a glyph
32
+ # injector = HintOperationInjector.new
33
+ # hints = [
34
+ # Hint.new(type: :stem, data: { position: 100, width: 50, orientation: :horizontal })
35
+ # ]
36
+ # modified_ops = injector.inject(hints, original_operations)
37
+ class HintOperationInjector
38
+ # Initialize injector
39
+ def initialize
40
+ @stem_count = 0
41
+ end
42
+
43
+ # Inject hint operations into operation list
44
+ #
45
+ # @param hints [Array<Models::Hint>] Hints to inject
46
+ # @param operations [Array<Hash>] Original CharString operations
47
+ # @return [Array<Hash>] Modified operations with hints injected
48
+ def inject(hints, operations)
49
+ return operations if hints.nil? || hints.empty?
50
+
51
+ # Convert hints to operations
52
+ hint_ops = convert_hints_to_operations(hints)
53
+ return operations if hint_ops.empty?
54
+
55
+ # Find injection point (before first path operator)
56
+ inject_index = find_injection_point(operations)
57
+
58
+ # Insert hint operations
59
+ operations.dup.insert(inject_index, *hint_ops)
60
+ end
61
+
62
+ # Get stem count after injection (needed for hintmask)
63
+ #
64
+ # @return [Integer] Number of stem hints
65
+ attr_reader :stem_count
66
+
67
+ private
68
+
69
+ # Convert Hint objects to CharString operations
70
+ #
71
+ # @param hints [Array<Models::Hint>] Hints to convert
72
+ # @return [Array<Hash>] CharString operations
73
+ def convert_hints_to_operations(hints)
74
+ operations = []
75
+ @stem_count = 0
76
+
77
+ hints.each do |hint|
78
+ ops = hint_to_operations(hint)
79
+ operations.concat(ops)
80
+ end
81
+
82
+ operations
83
+ end
84
+
85
+ # Convert single Hint to operations
86
+ #
87
+ # @param hint [Models::Hint] Hint object
88
+ # @return [Array<Hash>] CharString operations
89
+ def hint_to_operations(hint)
90
+ ps_hint = hint.to_postscript
91
+ return [] if ps_hint.empty?
92
+
93
+ case ps_hint[:operator]
94
+ when :hstem, :vstem
95
+ stem_operation(ps_hint)
96
+ when :hstemhm, :vstemhm
97
+ stem_operation(ps_hint)
98
+ when :hintmask
99
+ hintmask_operation(ps_hint)
100
+ when :counter, :cntrmask
101
+ # :counter from Hint model maps to :cntrmask in CharStrings
102
+ cntrmask_operation(ps_hint)
103
+ else
104
+ []
105
+ end
106
+ end
107
+
108
+ # Create stem hint operation
109
+ #
110
+ # @param ps_hint [Hash] PostScript hint with :operator and :args
111
+ # @return [Array<Hash>] CharString operations
112
+ def stem_operation(ps_hint)
113
+ operator = ps_hint[:operator]
114
+ args = ps_hint[:args] || []
115
+
116
+ # Each pair of args is one stem
117
+ @stem_count += args.length / 2
118
+
119
+ [{
120
+ type: :operator,
121
+ name: operator,
122
+ operands: args,
123
+ hint_data: nil,
124
+ }]
125
+ end
126
+
127
+ # Create hintmask operation
128
+ #
129
+ # @param ps_hint [Hash] PostScript hint with :operator and :args (mask)
130
+ # @return [Array<Hash>] CharString operations
131
+ def hintmask_operation(ps_hint)
132
+ mask_bytes = ps_hint[:args] || []
133
+
134
+ # Convert mask array to binary string
135
+ hint_data = if mask_bytes.is_a?(Array)
136
+ mask_bytes.pack("C*")
137
+ elsif mask_bytes.is_a?(String)
138
+ mask_bytes
139
+ else
140
+ ""
141
+ end
142
+
143
+ [{
144
+ type: :operator,
145
+ name: :hintmask,
146
+ operands: [],
147
+ hint_data: hint_data,
148
+ }]
149
+ end
150
+
151
+ # Create cntrmask operation
152
+ #
153
+ # @param ps_hint [Hash] PostScript hint with :operator and :args (zones)
154
+ # @return [Array<Hash>] CharString operations
155
+ def cntrmask_operation(ps_hint)
156
+ zones = ps_hint[:args] || []
157
+
158
+ # Convert zones to binary string
159
+ hint_data = if zones.is_a?(Array)
160
+ zones.pack("C*")
161
+ elsif zones.is_a?(String)
162
+ zones
163
+ else
164
+ ""
165
+ end
166
+
167
+ [{
168
+ type: :operator,
169
+ name: :cntrmask,
170
+ operands: [],
171
+ hint_data: hint_data,
172
+ }]
173
+ end
174
+
175
+ # Find injection point for hints
176
+ #
177
+ # Hints must go before first path construction operator.
178
+ # Path operators: moveto, lineto, curveto, etc.
179
+ #
180
+ # @param operations [Array<Hash>] CharString operations
181
+ # @return [Integer] Index to insert hints
182
+ def find_injection_point(operations)
183
+ # Path construction operators
184
+ path_operators = %i[
185
+ rmoveto hmoveto vmoveto
186
+ rlineto hlineto vlineto
187
+ rrcurveto rcurveline rlinecurve
188
+ vvcurveto hhcurveto vhcurveto hvcurveto
189
+ ]
190
+
191
+ # rubocop:disable Style/CombinableLoops
192
+ # Find first path operator
193
+ operations.each_with_index do |op, index|
194
+ return index if path_operators.include?(op[:name])
195
+ end
196
+
197
+ # No path operators found - hints go before endchar
198
+ operations.each_with_index do |op, index|
199
+ return index if op[:name] == :endchar
200
+ end
201
+ # rubocop:enable Style/CombinableLoops
202
+
203
+ # Empty or malformed - inject at start
204
+ 0
205
+ end
206
+ end
207
+ end
208
+ end
209
+ end
@@ -0,0 +1,70 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Fontisan
4
+ module Tables
5
+ class Cff
6
+ # Recalculates CFF table offsets after structure modifications
7
+ #
8
+ # When the Private DICT size changes (e.g., adding hint parameters),
9
+ # all offsets in the CFF table must be recalculated. This class
10
+ # computes new offsets based on section sizes.
11
+ #
12
+ # CFF Structure (sequential layout):
13
+ # - Header (fixed size)
14
+ # - Name INDEX
15
+ # - Top DICT INDEX (contains offsets to CharStrings and Private DICT)
16
+ # - String INDEX
17
+ # - Global Subr INDEX
18
+ # - CharStrings INDEX
19
+ # - Private DICT (variable size)
20
+ # - Local Subr INDEX (optional, within Private DICT)
21
+ #
22
+ # Key offsets to recalculate:
23
+ # - charstrings: Offset from CFF start to CharStrings INDEX
24
+ # - private: [size, offset] in Top DICT pointing to Private DICT
25
+ class OffsetRecalculator
26
+ # Calculate offsets for all CFF sections
27
+ #
28
+ # @param sections [Hash] Hash of section_name => binary_data
29
+ # @return [Hash] Hash of offset information
30
+ def self.calculate_offsets(sections)
31
+ offsets = {}
32
+ pos = 0
33
+
34
+ # Track position through CFF structure
35
+ pos += sections[:header].bytesize
36
+ pos += sections[:name_index].bytesize
37
+
38
+ # Top DICT INDEX starts here
39
+ offsets[:top_dict_start] = pos
40
+ pos += sections[:top_dict_index].bytesize
41
+
42
+ pos += sections[:string_index].bytesize
43
+ pos += sections[:global_subr_index].bytesize
44
+
45
+ # CharStrings INDEX offset (referenced in Top DICT)
46
+ offsets[:charstrings] = pos
47
+ pos += sections[:charstrings_index].bytesize
48
+
49
+ # Private DICT offset and size (referenced in Top DICT)
50
+ offsets[:private] = pos
51
+ offsets[:private_size] = sections[:private_dict].bytesize
52
+
53
+ offsets
54
+ end
55
+
56
+ # Update Top DICT with new offsets
57
+ #
58
+ # @param top_dict [Hash] Top DICT data
59
+ # @param offsets [Hash] Calculated offsets
60
+ # @return [Hash] Updated Top DICT
61
+ def self.update_top_dict(top_dict, offsets)
62
+ updated = top_dict.dup
63
+ updated[:charstrings] = offsets[:charstrings]
64
+ updated[:private] = [offsets[:private_size], offsets[:private]]
65
+ updated
66
+ end
67
+ end
68
+ end
69
+ end
70
+ end
@@ -0,0 +1,131 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "dict_builder"
4
+
5
+ module Fontisan
6
+ module Tables
7
+ class Cff
8
+ # Builds CFF Private DICT with hint parameters
9
+ #
10
+ # Private DICT contains font-level hint information used for rendering quality.
11
+ # This writer validates hint parameters against CFF spec limits and serializes
12
+ # them into binary DICT format.
13
+ #
14
+ # Supported hint parameters:
15
+ # - blue_values: Alignment zones (max 14 values, pairs)
16
+ # - other_blues: Additional zones (max 10 values, pairs)
17
+ # - family_blues: Family alignment zones (max 14 values, pairs)
18
+ # - family_other_blues: Family zones (max 10 values, pairs)
19
+ # - std_hw: Standard horizontal stem width
20
+ # - std_vw: Standard vertical stem width
21
+ # - stem_snap_h: Horizontal stem snap widths (max 12)
22
+ # - stem_snap_v: Vertical stem snap widths (max 12)
23
+ # - blue_scale, blue_shift, blue_fuzz: Overshoot parameters
24
+ # - force_bold: Bold flag
25
+ # - language_group: 0=Latin, 1=CJK
26
+ class PrivateDictWriter
27
+ # CFF specification limits for hint parameters
28
+ HINT_LIMITS = {
29
+ blue_values: { max: 14, pairs: true },
30
+ other_blues: { max: 10, pairs: true },
31
+ family_blues: { max: 14, pairs: true },
32
+ family_other_blues: { max: 10, pairs: true },
33
+ stem_snap_h: { max: 12 },
34
+ stem_snap_v: { max: 12 },
35
+ }.freeze
36
+
37
+ # Initialize writer with optional source Private DICT
38
+ #
39
+ # @param source_dict [PrivateDict, nil] Source to copy non-hint params from
40
+ def initialize(source_dict = nil)
41
+ @params = {}
42
+ parse_source(source_dict) if source_dict
43
+ end
44
+
45
+ # Update hint parameters
46
+ #
47
+ # @param hint_params [Hash] Hint parameters to add/update
48
+ # @raise [ArgumentError] If parameters are invalid
49
+ def update_hints(hint_params)
50
+ validate!(hint_params)
51
+ @params.merge!(hint_params.transform_keys(&:to_sym))
52
+ end
53
+
54
+ # Serialize to binary DICT format
55
+ #
56
+ # @return [String] Binary DICT data
57
+ def serialize
58
+ DictBuilder.build(@params)
59
+ end
60
+
61
+ # Get serialized size in bytes
62
+ #
63
+ # @return [Integer] Size in bytes
64
+ def size
65
+ serialize.bytesize
66
+ end
67
+
68
+ private
69
+
70
+ # Parse non-hint parameters from source Private DICT
71
+ #
72
+ # @param source_dict [PrivateDict] Source dictionary
73
+ def parse_source(source_dict)
74
+ return unless source_dict.respond_to?(:to_h)
75
+
76
+ # Extract only non-hint params (subrs, widths)
77
+ @params = source_dict.to_h.select do |k, _|
78
+ %i[subrs default_width_x nominal_width_x].include?(k)
79
+ end
80
+ end
81
+
82
+ # Validate hint parameters against CFF spec
83
+ #
84
+ # @param params [Hash] Hint parameters
85
+ # @raise [ArgumentError] If validation fails
86
+ def validate!(params)
87
+ params.each do |key, value|
88
+ k = key.to_sym
89
+ validate_hint_param(k, value)
90
+ end
91
+ end
92
+
93
+ # Validate individual hint parameter
94
+ #
95
+ # @param key [Symbol] Parameter name
96
+ # @param value [Object] Parameter value
97
+ # @raise [ArgumentError] If validation fails
98
+ def validate_hint_param(key, value)
99
+ # Check array limits
100
+ if HINT_LIMITS[key]
101
+ raise ArgumentError, "#{key} invalid" unless value.is_a?(Array)
102
+
103
+ if value.length > HINT_LIMITS[key][:max]
104
+ raise ArgumentError,
105
+ "#{key} too long"
106
+ end
107
+ if HINT_LIMITS[key][:pairs] && value.length.odd?
108
+ raise ArgumentError, "#{key} must be pairs"
109
+ end
110
+ end
111
+
112
+ # Check value-specific constraints
113
+ case key
114
+ when :std_hw, :std_vw
115
+ raise ArgumentError, "#{key} negative" if value.negative?
116
+ when :blue_scale
117
+ raise ArgumentError, "#{key} not positive" if value <= 0
118
+ when :blue_shift, :blue_fuzz
119
+ raise ArgumentError, "#{key} invalid" unless value.is_a?(Numeric)
120
+ when :force_bold
121
+ raise ArgumentError, "#{key} must be 0 or 1" unless [0,
122
+ 1].include?(value)
123
+ when :language_group
124
+ raise ArgumentError, "#{key} must be 0 or 1" unless [0,
125
+ 1].include?(value)
126
+ end
127
+ end
128
+ end
129
+ end
130
+ end
131
+ end