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.
- checksums.yaml +4 -4
- data/.rubocop_todo.yml +119 -308
- data/README.adoc +1525 -1323
- data/Rakefile +45 -47
- data/benchmark/variation_quick_bench.rb +4 -4
- data/docs/FONT_HINTING.adoc +562 -0
- data/docs/VARIABLE_FONT_OPERATIONS.adoc +599 -0
- data/lib/fontisan/cli.rb +92 -34
- data/lib/fontisan/collection/builder.rb +82 -0
- data/lib/fontisan/collection/offset_calculator.rb +2 -0
- data/lib/fontisan/collection/table_deduplicator.rb +76 -0
- data/lib/fontisan/commands/base_command.rb +21 -2
- data/lib/fontisan/commands/convert_command.rb +96 -165
- data/lib/fontisan/commands/info_command.rb +111 -5
- data/lib/fontisan/commands/instance_command.rb +77 -85
- data/lib/fontisan/commands/validate_command.rb +28 -0
- data/lib/fontisan/config/validation_rules.yml +1 -1
- data/lib/fontisan/constants.rb +34 -24
- data/lib/fontisan/converters/format_converter.rb +154 -1
- data/lib/fontisan/converters/outline_converter.rb +101 -34
- data/lib/fontisan/converters/woff_writer.rb +9 -4
- data/lib/fontisan/font_loader.rb +14 -9
- data/lib/fontisan/font_writer.rb +9 -6
- data/lib/fontisan/formatters/text_formatter.rb +45 -1
- data/lib/fontisan/hints/hint_converter.rb +131 -2
- data/lib/fontisan/hints/hint_validator.rb +284 -0
- data/lib/fontisan/hints/postscript_hint_applier.rb +219 -140
- data/lib/fontisan/hints/postscript_hint_extractor.rb +151 -16
- data/lib/fontisan/hints/truetype_hint_applier.rb +90 -44
- data/lib/fontisan/hints/truetype_hint_extractor.rb +134 -11
- data/lib/fontisan/hints/truetype_instruction_analyzer.rb +261 -0
- data/lib/fontisan/hints/truetype_instruction_generator.rb +266 -0
- data/lib/fontisan/loading_modes.rb +6 -4
- data/lib/fontisan/models/collection_brief_info.rb +31 -0
- data/lib/fontisan/models/font_info.rb +3 -30
- data/lib/fontisan/models/hint.rb +183 -12
- data/lib/fontisan/models/outline.rb +4 -1
- data/lib/fontisan/open_type_font.rb +28 -10
- data/lib/fontisan/open_type_font_extensions.rb +54 -0
- data/lib/fontisan/optimizers/pattern_analyzer.rb +2 -1
- data/lib/fontisan/optimizers/subroutine_generator.rb +1 -1
- data/lib/fontisan/pipeline/format_detector.rb +249 -0
- data/lib/fontisan/pipeline/output_writer.rb +159 -0
- data/lib/fontisan/pipeline/strategies/base_strategy.rb +75 -0
- data/lib/fontisan/pipeline/strategies/instance_strategy.rb +93 -0
- data/lib/fontisan/pipeline/strategies/named_strategy.rb +118 -0
- data/lib/fontisan/pipeline/strategies/preserve_strategy.rb +56 -0
- data/lib/fontisan/pipeline/transformation_pipeline.rb +416 -0
- data/lib/fontisan/pipeline/variation_resolver.rb +165 -0
- data/lib/fontisan/subset/table_subsetter.rb +5 -5
- data/lib/fontisan/tables/cff/charstring.rb +58 -3
- data/lib/fontisan/tables/cff/charstring_builder.rb +34 -0
- data/lib/fontisan/tables/cff/charstring_parser.rb +249 -0
- data/lib/fontisan/tables/cff/charstring_rebuilder.rb +172 -0
- data/lib/fontisan/tables/cff/dict_builder.rb +19 -1
- data/lib/fontisan/tables/cff/hint_operation_injector.rb +209 -0
- data/lib/fontisan/tables/cff/offset_recalculator.rb +70 -0
- data/lib/fontisan/tables/cff/private_dict_writer.rb +131 -0
- data/lib/fontisan/tables/cff/table_builder.rb +221 -0
- data/lib/fontisan/tables/cff.rb +2 -0
- data/lib/fontisan/tables/cff2/charstring_parser.rb +14 -8
- data/lib/fontisan/tables/cff2/private_dict_blend_handler.rb +247 -0
- data/lib/fontisan/tables/cff2/region_matcher.rb +200 -0
- data/lib/fontisan/tables/cff2/table_builder.rb +580 -0
- data/lib/fontisan/tables/cff2/table_reader.rb +421 -0
- data/lib/fontisan/tables/cff2/variation_data_extractor.rb +212 -0
- data/lib/fontisan/tables/cff2.rb +10 -5
- data/lib/fontisan/tables/cvar.rb +2 -41
- data/lib/fontisan/tables/glyf/compound_glyph_resolver.rb +2 -1
- data/lib/fontisan/tables/glyf/curve_converter.rb +10 -4
- data/lib/fontisan/tables/glyf/glyph_builder.rb +27 -10
- data/lib/fontisan/tables/gvar.rb +2 -41
- data/lib/fontisan/tables/name.rb +4 -4
- data/lib/fontisan/true_type_font.rb +27 -10
- data/lib/fontisan/true_type_font_extensions.rb +54 -0
- data/lib/fontisan/utilities/checksum_calculator.rb +42 -0
- data/lib/fontisan/validation/checksum_validator.rb +2 -2
- data/lib/fontisan/validation/table_validator.rb +1 -1
- data/lib/fontisan/validation/variable_font_validator.rb +218 -0
- data/lib/fontisan/variation/cache.rb +3 -1
- data/lib/fontisan/variation/converter.rb +121 -13
- data/lib/fontisan/variation/delta_applier.rb +2 -1
- data/lib/fontisan/variation/inspector.rb +2 -1
- data/lib/fontisan/variation/instance_generator.rb +2 -1
- data/lib/fontisan/variation/instance_writer.rb +341 -0
- data/lib/fontisan/variation/optimizer.rb +6 -3
- data/lib/fontisan/variation/subsetter.rb +32 -10
- data/lib/fontisan/variation/tuple_variation_header.rb +51 -0
- data/lib/fontisan/variation/variable_svg_generator.rb +268 -0
- data/lib/fontisan/variation/variation_preserver.rb +291 -0
- data/lib/fontisan/version.rb +1 -1
- data/lib/fontisan/version.rb.orig +9 -0
- data/lib/fontisan/woff2/glyf_transformer.rb +693 -0
- data/lib/fontisan/woff2/hmtx_transformer.rb +164 -0
- data/lib/fontisan/woff2_font.rb +489 -468
- data/lib/fontisan/woff_font.rb +16 -11
- data/lib/fontisan.rb +54 -2
- data/scripts/measure_optimization.rb +15 -7
- metadata +37 -2
|
@@ -0,0 +1,580 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../cff/table_builder"
|
|
4
|
+
require_relative "table_reader"
|
|
5
|
+
require_relative "private_dict_blend_handler"
|
|
6
|
+
require_relative "../cff/charstring_parser"
|
|
7
|
+
require_relative "../cff/charstring_builder"
|
|
8
|
+
require_relative "../cff/hint_operation_injector"
|
|
9
|
+
require_relative "../cff/dict_builder"
|
|
10
|
+
require "stringio"
|
|
11
|
+
|
|
12
|
+
module Fontisan
|
|
13
|
+
module Tables
|
|
14
|
+
class Cff2
|
|
15
|
+
# Rebuilds CFF2 table with modifications while preserving variation data
|
|
16
|
+
#
|
|
17
|
+
# CFF2TableBuilder extends CFF TableBuilder to handle CFF2-specific
|
|
18
|
+
# structures including Variable Store and blend operators in CharStrings.
|
|
19
|
+
# It preserves variation data while applying hints to variable fonts.
|
|
20
|
+
#
|
|
21
|
+
# Key Principles:
|
|
22
|
+
# - Variable Store is read-only and preserved unchanged
|
|
23
|
+
# - Blend operators in CharStrings are maintained
|
|
24
|
+
# - Blend in Private DICT is preserved
|
|
25
|
+
# - Reuses Phase 1+2 infrastructure for CharString modification
|
|
26
|
+
#
|
|
27
|
+
# Reference: Adobe Technical Note #5177 (CFF2)
|
|
28
|
+
#
|
|
29
|
+
# @example Rebuild CFF2 with hints
|
|
30
|
+
# reader = CFF2TableReader.new(cff2_data)
|
|
31
|
+
# builder = CFF2TableBuilder.new(reader, hint_set)
|
|
32
|
+
# new_cff2 = builder.build
|
|
33
|
+
class TableBuilder < Tables::Cff::TableBuilder
|
|
34
|
+
# CFF2-specific operators not supported by CFF DictBuilder
|
|
35
|
+
INVALID_CFF_KEYS = [24].freeze # operator 24 = vstore (CFF2 only)
|
|
36
|
+
|
|
37
|
+
# @return [CFF2TableReader] CFF2 table reader
|
|
38
|
+
attr_reader :reader
|
|
39
|
+
|
|
40
|
+
# @return [Hash, nil] Variable Store data
|
|
41
|
+
attr_reader :variable_store
|
|
42
|
+
|
|
43
|
+
# @return [Integer] Number of variation axes
|
|
44
|
+
attr_reader :num_axes
|
|
45
|
+
|
|
46
|
+
# Initialize builder with CFF2 table reader and hint set
|
|
47
|
+
#
|
|
48
|
+
# @param reader [CFF2TableReader] CFF2 table reader
|
|
49
|
+
# @param hint_set [Object] Hint set with font-level and per-glyph hints
|
|
50
|
+
def initialize(reader, hint_set = nil)
|
|
51
|
+
@reader = reader
|
|
52
|
+
@hint_set = hint_set
|
|
53
|
+
|
|
54
|
+
# Read CFF2 structures
|
|
55
|
+
@reader.read_header
|
|
56
|
+
@reader.read_top_dict
|
|
57
|
+
@variable_store = @reader.read_variable_store
|
|
58
|
+
|
|
59
|
+
# Determine number of axes from Variable Store
|
|
60
|
+
@num_axes = extract_num_axes
|
|
61
|
+
|
|
62
|
+
# Don't call super - CFF2 has different structure
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# Build CFF2 table with hints applied
|
|
66
|
+
#
|
|
67
|
+
# @return [String] Binary CFF2 table data
|
|
68
|
+
def build
|
|
69
|
+
# Check if we need to modify anything
|
|
70
|
+
return @reader.data unless should_modify?
|
|
71
|
+
|
|
72
|
+
# Extract and modify sections
|
|
73
|
+
header_data = extract_header
|
|
74
|
+
top_dict_hash = @reader.top_dict
|
|
75
|
+
charstrings_data = extract_and_modify_charstrings
|
|
76
|
+
private_dict_data = extract_and_modify_private_dict
|
|
77
|
+
vstore_data = extract_variable_store
|
|
78
|
+
|
|
79
|
+
# Rebuild CFF2 table
|
|
80
|
+
rebuild_cff2_table(
|
|
81
|
+
header: header_data,
|
|
82
|
+
top_dict: top_dict_hash,
|
|
83
|
+
charstrings: charstrings_data,
|
|
84
|
+
private_dict: private_dict_data,
|
|
85
|
+
vstore: vstore_data,
|
|
86
|
+
)
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# Check if table has variation data
|
|
90
|
+
#
|
|
91
|
+
# @return [Boolean] True if Variable Store present
|
|
92
|
+
def variable?
|
|
93
|
+
!@variable_store.nil?
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
private
|
|
97
|
+
|
|
98
|
+
# Extract number of variation axes from Variable Store
|
|
99
|
+
#
|
|
100
|
+
# @return [Integer] Number of axes
|
|
101
|
+
def extract_num_axes
|
|
102
|
+
return 0 unless @variable_store
|
|
103
|
+
|
|
104
|
+
# Get from first region's axis count
|
|
105
|
+
regions = @variable_store[:regions]
|
|
106
|
+
return 0 if regions.nil? || regions.empty?
|
|
107
|
+
|
|
108
|
+
regions.first[:axis_count] || 0
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# Extract CharStrings offset from Top DICT
|
|
112
|
+
#
|
|
113
|
+
# CFF2 Top DICT operator 17 contains CharStrings offset.
|
|
114
|
+
#
|
|
115
|
+
# @return [Integer] CharStrings offset
|
|
116
|
+
def extract_charstrings_offset
|
|
117
|
+
top_dict = @reader.top_dict
|
|
118
|
+
return nil unless top_dict
|
|
119
|
+
|
|
120
|
+
# Operator 17 = CharStrings offset
|
|
121
|
+
top_dict[17]
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
# Modify CharStrings with per-glyph hints
|
|
125
|
+
#
|
|
126
|
+
# Uses Phase 1 CharStringRebuilder and Phase 2 HintOperationInjector
|
|
127
|
+
# to inject hints while preserving blend operators.
|
|
128
|
+
#
|
|
129
|
+
# @param charstrings_index [CharstringsIndex] Source CharStrings INDEX
|
|
130
|
+
# @return [String] Modified CharStrings INDEX binary data
|
|
131
|
+
def modify_charstrings(charstrings_index)
|
|
132
|
+
return nil unless @hint_set
|
|
133
|
+
|
|
134
|
+
# Get hinted glyph IDs from HintSet
|
|
135
|
+
hinted_glyph_ids = @hint_set.hinted_glyph_ids
|
|
136
|
+
return nil if hinted_glyph_ids.empty?
|
|
137
|
+
|
|
138
|
+
# Create rebuilder with stem count
|
|
139
|
+
stem_count = calculate_stem_count
|
|
140
|
+
rebuilder = Cff::CharStringRebuilder.new(charstrings_index,
|
|
141
|
+
stem_count: stem_count)
|
|
142
|
+
|
|
143
|
+
# Modify each glyph with hints
|
|
144
|
+
hinted_glyph_ids.each do |glyph_id|
|
|
145
|
+
# Get hints for this glyph
|
|
146
|
+
hints = @hint_set.get_glyph_hints(glyph_id)
|
|
147
|
+
next if hints.nil? || hints.empty?
|
|
148
|
+
|
|
149
|
+
# Convert glyph_id to integer if it's a string
|
|
150
|
+
glyph_index = glyph_id.to_i
|
|
151
|
+
|
|
152
|
+
rebuilder.modify_charstring(glyph_index) do |operations|
|
|
153
|
+
# Inject hints while preserving blend operators
|
|
154
|
+
injector = Cff::HintOperationInjector.new
|
|
155
|
+
injector.inject(hints, operations)
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
# Rebuild CharStrings INDEX
|
|
160
|
+
rebuilder.rebuild
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
# Calculate stem count from font-level hints
|
|
164
|
+
#
|
|
165
|
+
# Stem count is needed for hintmask/cntrmask parsing.
|
|
166
|
+
# Extracted from blue values and stem snap arrays.
|
|
167
|
+
#
|
|
168
|
+
# @return [Integer] Total stem count (hstem + vstem)
|
|
169
|
+
def calculate_stem_count
|
|
170
|
+
return 0 unless @hint_set
|
|
171
|
+
|
|
172
|
+
# Get font-level hints (from private_dict_hints JSON)
|
|
173
|
+
return 0 unless @hint_set.respond_to?(:private_dict_hints)
|
|
174
|
+
|
|
175
|
+
begin
|
|
176
|
+
font_hints = JSON.parse(@hint_set.private_dict_hints || "{}")
|
|
177
|
+
rescue JSON::ParserError
|
|
178
|
+
return 0
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
return 0 if font_hints.nil? || font_hints.empty?
|
|
182
|
+
|
|
183
|
+
# Count stems from blue zones (hstem)
|
|
184
|
+
hstem_count = 0
|
|
185
|
+
blue_values = font_hints["blue_values"] || font_hints[:blue_values]
|
|
186
|
+
if blue_values.is_a?(Array)
|
|
187
|
+
hstem_count = blue_values.size / 2
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
# Count stems from stem snap (vstem)
|
|
191
|
+
vstem_count = 0
|
|
192
|
+
stem_snap_h = font_hints["stem_snap_h"] || font_hints[:stem_snap_h]
|
|
193
|
+
if stem_snap_h.is_a?(Array)
|
|
194
|
+
vstem_count = stem_snap_h.size
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
hstem_count + vstem_count
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
# Check if font-level hints are present
|
|
201
|
+
#
|
|
202
|
+
# @return [Boolean] True if private_dict_hints are present
|
|
203
|
+
def has_font_level_hints?
|
|
204
|
+
return false unless @hint_set.respond_to?(:private_dict_hints)
|
|
205
|
+
|
|
206
|
+
hints = JSON.parse(@hint_set.private_dict_hints || "{}")
|
|
207
|
+
!hints.empty?
|
|
208
|
+
rescue JSON::ParserError
|
|
209
|
+
false
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
# Modify Private DICT with font-level hints
|
|
213
|
+
#
|
|
214
|
+
# Handles variable hint values using PrivateDictBlendHandler
|
|
215
|
+
# while preserving existing blend operators.
|
|
216
|
+
#
|
|
217
|
+
# @return [Hash, nil] Modified Private DICT data
|
|
218
|
+
def modify_private_dict
|
|
219
|
+
# Read original Private DICT
|
|
220
|
+
private_dict_info = extract_private_dict_info
|
|
221
|
+
return nil unless private_dict_info
|
|
222
|
+
|
|
223
|
+
size, offset = private_dict_info
|
|
224
|
+
private_dict = @reader.read_private_dict(size, offset)
|
|
225
|
+
|
|
226
|
+
# Create handler
|
|
227
|
+
handler = PrivateDictBlendHandler.new(private_dict)
|
|
228
|
+
|
|
229
|
+
# Get font-level hints
|
|
230
|
+
font_hints = JSON.parse(@hint_set.private_dict_hints)
|
|
231
|
+
|
|
232
|
+
# Rebuild with hints (preserving blend)
|
|
233
|
+
handler.rebuild_with_hints(font_hints, num_axes: @num_axes)
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
# Extract Private DICT information from Top DICT
|
|
237
|
+
#
|
|
238
|
+
# @return [Array<Integer>, nil] [size, offset] or nil if not present
|
|
239
|
+
def extract_private_dict_info
|
|
240
|
+
# Extract from Top DICT (operator 18)
|
|
241
|
+
private_info = @reader.top_dict[18]
|
|
242
|
+
return nil unless private_info
|
|
243
|
+
|
|
244
|
+
# Format: [size, offset]
|
|
245
|
+
private_info
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
# Preserve Variable Store unchanged
|
|
249
|
+
#
|
|
250
|
+
# Variable Store is read-only for hint application.
|
|
251
|
+
# We simply copy it to output without modification.
|
|
252
|
+
#
|
|
253
|
+
# @return [Hash, nil] Variable Store data
|
|
254
|
+
def preserve_variable_store
|
|
255
|
+
@variable_store
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
# Check if modification is needed
|
|
259
|
+
#
|
|
260
|
+
# @return [Boolean] True if hints should be applied
|
|
261
|
+
def should_modify?
|
|
262
|
+
return false unless @hint_set
|
|
263
|
+
|
|
264
|
+
has_per_glyph = !@hint_set.hinted_glyph_ids.empty?
|
|
265
|
+
has_font_level = has_font_level_hints?
|
|
266
|
+
|
|
267
|
+
has_per_glyph || has_font_level
|
|
268
|
+
end
|
|
269
|
+
|
|
270
|
+
# Extract CFF2 header bytes
|
|
271
|
+
#
|
|
272
|
+
# @return [String] Binary header data
|
|
273
|
+
def extract_header
|
|
274
|
+
header_size = @reader.header[:header_size]
|
|
275
|
+
@reader.data[0, header_size]
|
|
276
|
+
end
|
|
277
|
+
|
|
278
|
+
# Extract and optionally modify CharStrings
|
|
279
|
+
#
|
|
280
|
+
# @return [String] CharStrings INDEX binary data
|
|
281
|
+
def extract_and_modify_charstrings
|
|
282
|
+
charstrings_offset = extract_charstrings_offset
|
|
283
|
+
return nil unless charstrings_offset
|
|
284
|
+
|
|
285
|
+
charstrings_index = @reader.read_charstrings(charstrings_offset)
|
|
286
|
+
|
|
287
|
+
if @hint_set && !@hint_set.hinted_glyph_ids.empty?
|
|
288
|
+
modify_charstrings(charstrings_index)
|
|
289
|
+
else
|
|
290
|
+
# Return original CharStrings as binary
|
|
291
|
+
extract_charstrings_binary(charstrings_offset)
|
|
292
|
+
end
|
|
293
|
+
end
|
|
294
|
+
|
|
295
|
+
# Extract CharStrings INDEX as binary
|
|
296
|
+
#
|
|
297
|
+
# @param offset [Integer] CharStrings offset in table
|
|
298
|
+
# @return [String] Binary CharStrings INDEX data
|
|
299
|
+
def extract_charstrings_binary(offset)
|
|
300
|
+
io = StringIO.new(@reader.data)
|
|
301
|
+
io.seek(offset)
|
|
302
|
+
|
|
303
|
+
# Read INDEX structure: count (2 bytes)
|
|
304
|
+
count = io.read(2).unpack1("n")
|
|
305
|
+
return [0].pack("n") if count.zero?
|
|
306
|
+
|
|
307
|
+
# Read offSize (1 byte)
|
|
308
|
+
off_size = io.read(1).unpack1("C")
|
|
309
|
+
|
|
310
|
+
# Calculate INDEX size
|
|
311
|
+
# count + offSize + (count+1)*offSize + data_size
|
|
312
|
+
offset_array_size = (count + 1) * off_size
|
|
313
|
+
|
|
314
|
+
# Read offset array to get data size
|
|
315
|
+
offsets = []
|
|
316
|
+
(count + 1).times do
|
|
317
|
+
offset_bytes = io.read(off_size)
|
|
318
|
+
case off_size
|
|
319
|
+
when 1
|
|
320
|
+
offsets << offset_bytes.unpack1("C")
|
|
321
|
+
when 2
|
|
322
|
+
offsets << offset_bytes.unpack1("n")
|
|
323
|
+
when 3
|
|
324
|
+
offsets << (offset_bytes.bytes[0] << 16 | offset_bytes.bytes[1] << 8 | offset_bytes.bytes[2])
|
|
325
|
+
when 4
|
|
326
|
+
offsets << offset_bytes.unpack1("N")
|
|
327
|
+
end
|
|
328
|
+
end
|
|
329
|
+
|
|
330
|
+
data_size = offsets.last - 1 # Offsets are 1-based
|
|
331
|
+
|
|
332
|
+
# Calculate total INDEX size
|
|
333
|
+
index_size = 2 + 1 + offset_array_size + data_size
|
|
334
|
+
|
|
335
|
+
# Reset and extract full INDEX
|
|
336
|
+
io.seek(offset)
|
|
337
|
+
io.read(index_size)
|
|
338
|
+
end
|
|
339
|
+
|
|
340
|
+
# Extract and optionally modify Private DICT
|
|
341
|
+
#
|
|
342
|
+
# @return [String, nil] Binary Private DICT data
|
|
343
|
+
def extract_and_modify_private_dict
|
|
344
|
+
if @hint_set && has_font_level_hints?
|
|
345
|
+
# Modify and serialize
|
|
346
|
+
modified_dict = modify_private_dict
|
|
347
|
+
return nil unless modified_dict
|
|
348
|
+
|
|
349
|
+
serialize_private_dict(modified_dict)
|
|
350
|
+
else
|
|
351
|
+
# Return original Private DICT
|
|
352
|
+
private_dict_info = extract_private_dict_info
|
|
353
|
+
return nil unless private_dict_info
|
|
354
|
+
|
|
355
|
+
size, offset = private_dict_info
|
|
356
|
+
@reader.data[offset, size]
|
|
357
|
+
end
|
|
358
|
+
end
|
|
359
|
+
|
|
360
|
+
# Extract Variable Store as binary (unchanged)
|
|
361
|
+
#
|
|
362
|
+
# @return [String, nil] Binary Variable Store data
|
|
363
|
+
def extract_variable_store
|
|
364
|
+
return nil unless @variable_store
|
|
365
|
+
|
|
366
|
+
vstore_offset = @reader.top_dict[24] # operator 24 = vstore
|
|
367
|
+
return nil unless vstore_offset
|
|
368
|
+
|
|
369
|
+
# Extract Variable Store bytes unchanged
|
|
370
|
+
# For simplicity, extract from vstore_offset to end of table
|
|
371
|
+
# In production, we'd parse structure to get exact size
|
|
372
|
+
@reader.data[vstore_offset..]
|
|
373
|
+
end
|
|
374
|
+
|
|
375
|
+
# Rebuild complete CFF2 table
|
|
376
|
+
#
|
|
377
|
+
# @param header [String] CFF2 header
|
|
378
|
+
# @param top_dict [Hash] Top DICT hash
|
|
379
|
+
# @param charstrings [String] CharStrings INDEX
|
|
380
|
+
# @param private_dict [String, nil] Private DICT
|
|
381
|
+
# @param vstore [String, nil] Variable Store
|
|
382
|
+
# @return [String] Complete CFF2 table binary
|
|
383
|
+
def rebuild_cff2_table(header:, top_dict:, charstrings:, private_dict:,
|
|
384
|
+
vstore:)
|
|
385
|
+
output = StringIO.new("".b)
|
|
386
|
+
|
|
387
|
+
# 1. Write Header
|
|
388
|
+
output.write(header)
|
|
389
|
+
|
|
390
|
+
# 2. Calculate offsets for all sections
|
|
391
|
+
offsets = calculate_cff2_offsets(
|
|
392
|
+
header_size: header.size,
|
|
393
|
+
charstrings: charstrings,
|
|
394
|
+
private_dict: private_dict,
|
|
395
|
+
vstore: vstore,
|
|
396
|
+
)
|
|
397
|
+
|
|
398
|
+
# 3. Build Top DICT with updated offsets
|
|
399
|
+
updated_top_dict = update_top_dict_offsets(top_dict, offsets)
|
|
400
|
+
top_dict_binary = serialize_top_dict(updated_top_dict)
|
|
401
|
+
|
|
402
|
+
# Write Top DICT
|
|
403
|
+
output.write(top_dict_binary)
|
|
404
|
+
|
|
405
|
+
# 4. Write CharStrings
|
|
406
|
+
output.write(charstrings) if charstrings
|
|
407
|
+
|
|
408
|
+
# 5. Write Private DICT
|
|
409
|
+
output.write(private_dict) if private_dict
|
|
410
|
+
|
|
411
|
+
# 6. Write Variable Store (UNCHANGED)
|
|
412
|
+
output.write(vstore) if vstore
|
|
413
|
+
|
|
414
|
+
output.string
|
|
415
|
+
end
|
|
416
|
+
|
|
417
|
+
# Calculate offsets for CFF2 sections
|
|
418
|
+
#
|
|
419
|
+
# @param header_size [Integer] Header size
|
|
420
|
+
# @param charstrings [String] CharStrings data
|
|
421
|
+
# @param private_dict [String, nil] Private DICT data
|
|
422
|
+
# @param vstore [String, nil] Variable Store data
|
|
423
|
+
# @return [Hash] Section offsets
|
|
424
|
+
def calculate_cff2_offsets(header_size:, charstrings:, private_dict:,
|
|
425
|
+
vstore:)
|
|
426
|
+
# Start after header
|
|
427
|
+
offset = header_size
|
|
428
|
+
|
|
429
|
+
# Top DICT offset (immediately after header)
|
|
430
|
+
top_dict_offset = offset
|
|
431
|
+
|
|
432
|
+
# Estimate Top DICT size (will be recalculated)
|
|
433
|
+
# For now, use original Top DICT size from reader
|
|
434
|
+
top_dict_size = estimate_top_dict_size
|
|
435
|
+
|
|
436
|
+
offset += top_dict_size
|
|
437
|
+
|
|
438
|
+
# CharStrings offset
|
|
439
|
+
charstrings_offset = offset
|
|
440
|
+
offset += charstrings&.size || 0
|
|
441
|
+
|
|
442
|
+
# Private DICT offset
|
|
443
|
+
private_dict_offset = offset
|
|
444
|
+
private_dict_size = private_dict&.size || 0
|
|
445
|
+
offset += private_dict_size
|
|
446
|
+
|
|
447
|
+
# Variable Store offset
|
|
448
|
+
vstore_offset = vstore ? offset : nil
|
|
449
|
+
|
|
450
|
+
{
|
|
451
|
+
top_dict: top_dict_offset,
|
|
452
|
+
charstrings: charstrings_offset,
|
|
453
|
+
private_dict: private_dict_offset,
|
|
454
|
+
private_dict_size: private_dict_size,
|
|
455
|
+
vstore: vstore_offset,
|
|
456
|
+
}
|
|
457
|
+
end
|
|
458
|
+
|
|
459
|
+
# Estimate Top DICT size
|
|
460
|
+
#
|
|
461
|
+
# @return [Integer] Estimated size
|
|
462
|
+
def estimate_top_dict_size
|
|
463
|
+
# Use original Top DICT size from reader as estimate
|
|
464
|
+
# In CFF2, Top DICT size is in header
|
|
465
|
+
top_dict_length = @reader.header[:top_dict_length]
|
|
466
|
+
top_dict_length || 50 # Default estimate
|
|
467
|
+
end
|
|
468
|
+
|
|
469
|
+
# Update Top DICT with new offsets
|
|
470
|
+
#
|
|
471
|
+
# @param top_dict [Hash] Original Top DICT
|
|
472
|
+
# @param offsets [Hash] Calculated offsets
|
|
473
|
+
# @return [Hash] Updated Top DICT
|
|
474
|
+
def update_top_dict_offsets(top_dict, offsets)
|
|
475
|
+
updated = top_dict.dup
|
|
476
|
+
|
|
477
|
+
# Update CharStrings offset (operator 17)
|
|
478
|
+
updated[17] = offsets[:charstrings]
|
|
479
|
+
|
|
480
|
+
# Update Private DICT [size, offset] (operator 18)
|
|
481
|
+
if offsets[:private_dict_size]&.positive?
|
|
482
|
+
updated[18] = [offsets[:private_dict_size], offsets[:private_dict]]
|
|
483
|
+
end
|
|
484
|
+
|
|
485
|
+
# Update Variable Store offset (operator 24)
|
|
486
|
+
updated[24] = offsets[:vstore] if offsets[:vstore]
|
|
487
|
+
|
|
488
|
+
updated
|
|
489
|
+
end
|
|
490
|
+
|
|
491
|
+
# Serialize Top DICT to binary
|
|
492
|
+
#
|
|
493
|
+
# @param dict [Hash] Top DICT hash with integer operator keys
|
|
494
|
+
# @return [String] Binary DICT data
|
|
495
|
+
def serialize_top_dict(dict)
|
|
496
|
+
require_relative "../cff/dict_builder"
|
|
497
|
+
|
|
498
|
+
# Convert integer operator keys to symbol keys for DictBuilder
|
|
499
|
+
symbol_dict = convert_operators_to_symbols(dict)
|
|
500
|
+
Cff::DictBuilder.build(symbol_dict)
|
|
501
|
+
end
|
|
502
|
+
|
|
503
|
+
# Serialize Private DICT to binary
|
|
504
|
+
#
|
|
505
|
+
# @param dict [Hash] Private DICT hash
|
|
506
|
+
# @return [String] Binary DICT data
|
|
507
|
+
def serialize_private_dict(dict)
|
|
508
|
+
require_relative "../cff/dict_builder"
|
|
509
|
+
|
|
510
|
+
# Convert integer operator keys to symbol keys for DictBuilder
|
|
511
|
+
symbol_dict = convert_operators_to_symbols(dict)
|
|
512
|
+
Cff::DictBuilder.build(symbol_dict)
|
|
513
|
+
end
|
|
514
|
+
|
|
515
|
+
# Convert integer operator keys to symbol keys
|
|
516
|
+
#
|
|
517
|
+
# @param dict [Hash] Dictionary with integer or string keys
|
|
518
|
+
# @return [Hash] Dictionary with symbol keys
|
|
519
|
+
def convert_operators_to_symbols(dict)
|
|
520
|
+
# Operator mapping: integer => symbol
|
|
521
|
+
operator_map = {
|
|
522
|
+
0 => :version,
|
|
523
|
+
1 => :notice,
|
|
524
|
+
2 => :full_name,
|
|
525
|
+
3 => :family_name,
|
|
526
|
+
4 => :weight,
|
|
527
|
+
5 => :font_bbox,
|
|
528
|
+
6 => :blue_values,
|
|
529
|
+
7 => :other_blues,
|
|
530
|
+
8 => :family_blues,
|
|
531
|
+
9 => :family_other_blues,
|
|
532
|
+
10 => :std_hw,
|
|
533
|
+
11 => :std_vw,
|
|
534
|
+
15 => :charset,
|
|
535
|
+
16 => :encoding,
|
|
536
|
+
17 => :charstrings,
|
|
537
|
+
18 => :private,
|
|
538
|
+
19 => :subrs,
|
|
539
|
+
20 => :default_width_x,
|
|
540
|
+
21 => :nominal_width_x,
|
|
541
|
+
# Note: operator 24 (vstore) is CFF2-specific and handled separately
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
result = {}
|
|
545
|
+
dict.each do |key, value|
|
|
546
|
+
# Skip vstore (operator 24) - CFF2 specific, not in CFF DictBuilder
|
|
547
|
+
next if INVALID_CFF_KEYS.include?(key)
|
|
548
|
+
|
|
549
|
+
# Convert string keys to symbols for DictBuilder
|
|
550
|
+
symbol_key = if key.is_a?(String)
|
|
551
|
+
key.to_sym
|
|
552
|
+
elsif key.is_a?(Integer)
|
|
553
|
+
operator_map[key] || key
|
|
554
|
+
else
|
|
555
|
+
key
|
|
556
|
+
end
|
|
557
|
+
|
|
558
|
+
result[symbol_key] = value
|
|
559
|
+
end
|
|
560
|
+
result
|
|
561
|
+
end
|
|
562
|
+
|
|
563
|
+
# Validate CFF2 structure
|
|
564
|
+
#
|
|
565
|
+
# @return [Array<String>] Validation errors (empty if valid)
|
|
566
|
+
def validate
|
|
567
|
+
errors = []
|
|
568
|
+
|
|
569
|
+
errors << "Not a valid CFF2 table" unless @reader.header[:major_version] == 2
|
|
570
|
+
|
|
571
|
+
if variable? && @num_axes.zero?
|
|
572
|
+
errors << "CFF2 has Variable Store but no axes defined"
|
|
573
|
+
end
|
|
574
|
+
|
|
575
|
+
errors
|
|
576
|
+
end
|
|
577
|
+
end
|
|
578
|
+
end
|
|
579
|
+
end
|
|
580
|
+
end
|