fontisan 0.1.0 → 0.2.1
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 +672 -69
- data/Gemfile +1 -0
- data/LICENSE +5 -1
- data/README.adoc +1477 -297
- data/Rakefile +63 -41
- data/benchmark/variation_quick_bench.rb +47 -0
- data/docs/EXTRACT_TTC_MIGRATION.md +549 -0
- data/fontisan.gemspec +4 -1
- data/lib/fontisan/binary/base_record.rb +22 -1
- data/lib/fontisan/cli.rb +364 -4
- data/lib/fontisan/collection/builder.rb +341 -0
- data/lib/fontisan/collection/offset_calculator.rb +227 -0
- data/lib/fontisan/collection/table_analyzer.rb +204 -0
- data/lib/fontisan/collection/table_deduplicator.rb +317 -0
- data/lib/fontisan/collection/writer.rb +306 -0
- data/lib/fontisan/commands/base_command.rb +24 -1
- data/lib/fontisan/commands/convert_command.rb +218 -0
- data/lib/fontisan/commands/export_command.rb +161 -0
- data/lib/fontisan/commands/info_command.rb +40 -6
- data/lib/fontisan/commands/instance_command.rb +286 -0
- data/lib/fontisan/commands/ls_command.rb +113 -0
- data/lib/fontisan/commands/pack_command.rb +241 -0
- data/lib/fontisan/commands/subset_command.rb +245 -0
- data/lib/fontisan/commands/unpack_command.rb +338 -0
- data/lib/fontisan/commands/validate_command.rb +203 -0
- data/lib/fontisan/commands/variable_command.rb +30 -1
- data/lib/fontisan/config/collection_settings.yml +56 -0
- data/lib/fontisan/config/conversion_matrix.yml +212 -0
- data/lib/fontisan/config/export_settings.yml +66 -0
- data/lib/fontisan/config/subset_profiles.yml +100 -0
- data/lib/fontisan/config/svg_settings.yml +60 -0
- data/lib/fontisan/config/validation_rules.yml +149 -0
- data/lib/fontisan/config/variable_settings.yml +99 -0
- data/lib/fontisan/config/woff2_settings.yml +77 -0
- data/lib/fontisan/constants.rb +79 -0
- data/lib/fontisan/converters/conversion_strategy.rb +96 -0
- data/lib/fontisan/converters/format_converter.rb +408 -0
- data/lib/fontisan/converters/outline_converter.rb +998 -0
- data/lib/fontisan/converters/svg_generator.rb +244 -0
- data/lib/fontisan/converters/table_copier.rb +117 -0
- data/lib/fontisan/converters/woff2_encoder.rb +416 -0
- data/lib/fontisan/converters/woff_writer.rb +391 -0
- data/lib/fontisan/error.rb +203 -0
- data/lib/fontisan/export/exporter.rb +262 -0
- data/lib/fontisan/export/table_serializer.rb +255 -0
- data/lib/fontisan/export/transformers/font_to_ttx.rb +172 -0
- data/lib/fontisan/export/transformers/head_transformer.rb +96 -0
- data/lib/fontisan/export/transformers/hhea_transformer.rb +59 -0
- data/lib/fontisan/export/transformers/maxp_transformer.rb +63 -0
- data/lib/fontisan/export/transformers/name_transformer.rb +63 -0
- data/lib/fontisan/export/transformers/os2_transformer.rb +121 -0
- data/lib/fontisan/export/transformers/post_transformer.rb +51 -0
- data/lib/fontisan/export/ttx_generator.rb +527 -0
- data/lib/fontisan/export/ttx_parser.rb +300 -0
- data/lib/fontisan/font_loader.rb +122 -15
- data/lib/fontisan/font_writer.rb +302 -0
- data/lib/fontisan/formatters/text_formatter.rb +102 -0
- data/lib/fontisan/glyph_accessor.rb +503 -0
- data/lib/fontisan/hints/hint_converter.rb +310 -0
- data/lib/fontisan/hints/postscript_hint_applier.rb +266 -0
- data/lib/fontisan/hints/postscript_hint_extractor.rb +354 -0
- data/lib/fontisan/hints/truetype_hint_applier.rb +117 -0
- data/lib/fontisan/hints/truetype_hint_extractor.rb +289 -0
- data/lib/fontisan/loading_modes.rb +115 -0
- data/lib/fontisan/metrics_calculator.rb +277 -0
- data/lib/fontisan/models/collection_font_summary.rb +52 -0
- data/lib/fontisan/models/collection_info.rb +76 -0
- data/lib/fontisan/models/collection_list_info.rb +37 -0
- data/lib/fontisan/models/font_export.rb +158 -0
- data/lib/fontisan/models/font_summary.rb +48 -0
- data/lib/fontisan/models/glyph_outline.rb +343 -0
- data/lib/fontisan/models/hint.rb +405 -0
- data/lib/fontisan/models/outline.rb +664 -0
- data/lib/fontisan/models/table_sharing_info.rb +40 -0
- data/lib/fontisan/models/ttx/glyph_order.rb +31 -0
- data/lib/fontisan/models/ttx/tables/binary_table.rb +67 -0
- data/lib/fontisan/models/ttx/tables/head_table.rb +74 -0
- data/lib/fontisan/models/ttx/tables/hhea_table.rb +74 -0
- data/lib/fontisan/models/ttx/tables/maxp_table.rb +55 -0
- data/lib/fontisan/models/ttx/tables/name_table.rb +45 -0
- data/lib/fontisan/models/ttx/tables/os2_table.rb +157 -0
- data/lib/fontisan/models/ttx/tables/post_table.rb +50 -0
- data/lib/fontisan/models/ttx/ttfont.rb +49 -0
- data/lib/fontisan/models/validation_report.rb +203 -0
- data/lib/fontisan/open_type_collection.rb +156 -2
- data/lib/fontisan/open_type_font.rb +321 -19
- data/lib/fontisan/open_type_font_extensions.rb +54 -0
- data/lib/fontisan/optimizers/charstring_rewriter.rb +161 -0
- data/lib/fontisan/optimizers/pattern_analyzer.rb +308 -0
- data/lib/fontisan/optimizers/stack_tracker.rb +246 -0
- data/lib/fontisan/optimizers/subroutine_builder.rb +134 -0
- data/lib/fontisan/optimizers/subroutine_generator.rb +207 -0
- data/lib/fontisan/optimizers/subroutine_optimizer.rb +107 -0
- data/lib/fontisan/outline_extractor.rb +423 -0
- data/lib/fontisan/pipeline/format_detector.rb +249 -0
- data/lib/fontisan/pipeline/output_writer.rb +154 -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 +411 -0
- data/lib/fontisan/pipeline/variation_resolver.rb +165 -0
- data/lib/fontisan/subset/builder.rb +268 -0
- data/lib/fontisan/subset/glyph_mapping.rb +215 -0
- data/lib/fontisan/subset/options.rb +142 -0
- data/lib/fontisan/subset/profile.rb +152 -0
- data/lib/fontisan/subset/table_subsetter.rb +461 -0
- data/lib/fontisan/svg/font_face_generator.rb +278 -0
- data/lib/fontisan/svg/font_generator.rb +264 -0
- data/lib/fontisan/svg/glyph_generator.rb +168 -0
- data/lib/fontisan/svg/view_box_calculator.rb +137 -0
- data/lib/fontisan/tables/cff/cff_glyph.rb +176 -0
- data/lib/fontisan/tables/cff/charset.rb +282 -0
- data/lib/fontisan/tables/cff/charstring.rb +934 -0
- data/lib/fontisan/tables/cff/charstring_builder.rb +356 -0
- data/lib/fontisan/tables/cff/charstring_parser.rb +237 -0
- data/lib/fontisan/tables/cff/charstring_rebuilder.rb +172 -0
- data/lib/fontisan/tables/cff/charstrings_index.rb +162 -0
- data/lib/fontisan/tables/cff/dict.rb +351 -0
- data/lib/fontisan/tables/cff/dict_builder.rb +257 -0
- data/lib/fontisan/tables/cff/encoding.rb +274 -0
- data/lib/fontisan/tables/cff/header.rb +102 -0
- data/lib/fontisan/tables/cff/hint_operation_injector.rb +207 -0
- data/lib/fontisan/tables/cff/index.rb +237 -0
- data/lib/fontisan/tables/cff/index_builder.rb +170 -0
- data/lib/fontisan/tables/cff/offset_recalculator.rb +70 -0
- data/lib/fontisan/tables/cff/private_dict.rb +284 -0
- data/lib/fontisan/tables/cff/private_dict_writer.rb +125 -0
- data/lib/fontisan/tables/cff/table_builder.rb +221 -0
- data/lib/fontisan/tables/cff/top_dict.rb +236 -0
- data/lib/fontisan/tables/cff.rb +489 -0
- data/lib/fontisan/tables/cff2/blend_operator.rb +240 -0
- data/lib/fontisan/tables/cff2/charstring_parser.rb +591 -0
- data/lib/fontisan/tables/cff2/operand_stack.rb +232 -0
- data/lib/fontisan/tables/cff2/private_dict_blend_handler.rb +246 -0
- data/lib/fontisan/tables/cff2/region_matcher.rb +200 -0
- data/lib/fontisan/tables/cff2/table_builder.rb +574 -0
- data/lib/fontisan/tables/cff2/table_reader.rb +419 -0
- data/lib/fontisan/tables/cff2/variation_data_extractor.rb +212 -0
- data/lib/fontisan/tables/cff2.rb +346 -0
- data/lib/fontisan/tables/cvar.rb +203 -0
- data/lib/fontisan/tables/fvar.rb +2 -2
- data/lib/fontisan/tables/glyf/compound_glyph.rb +483 -0
- data/lib/fontisan/tables/glyf/compound_glyph_resolver.rb +136 -0
- data/lib/fontisan/tables/glyf/curve_converter.rb +343 -0
- data/lib/fontisan/tables/glyf/glyph_builder.rb +450 -0
- data/lib/fontisan/tables/glyf/simple_glyph.rb +382 -0
- data/lib/fontisan/tables/glyf.rb +235 -0
- data/lib/fontisan/tables/gvar.rb +231 -0
- data/lib/fontisan/tables/hhea.rb +124 -0
- data/lib/fontisan/tables/hmtx.rb +287 -0
- data/lib/fontisan/tables/hvar.rb +191 -0
- data/lib/fontisan/tables/loca.rb +322 -0
- data/lib/fontisan/tables/maxp.rb +192 -0
- data/lib/fontisan/tables/mvar.rb +185 -0
- data/lib/fontisan/tables/name.rb +99 -30
- data/lib/fontisan/tables/variation_common.rb +346 -0
- data/lib/fontisan/tables/vvar.rb +234 -0
- data/lib/fontisan/true_type_collection.rb +156 -2
- data/lib/fontisan/true_type_font.rb +321 -20
- data/lib/fontisan/true_type_font_extensions.rb +54 -0
- data/lib/fontisan/utilities/brotli_wrapper.rb +159 -0
- data/lib/fontisan/utilities/checksum_calculator.rb +60 -0
- data/lib/fontisan/utils/thread_pool.rb +134 -0
- data/lib/fontisan/validation/checksum_validator.rb +170 -0
- data/lib/fontisan/validation/consistency_validator.rb +197 -0
- data/lib/fontisan/validation/structure_validator.rb +198 -0
- data/lib/fontisan/validation/table_validator.rb +158 -0
- data/lib/fontisan/validation/validator.rb +152 -0
- data/lib/fontisan/validation/variable_font_validator.rb +218 -0
- data/lib/fontisan/variable/axis_normalizer.rb +215 -0
- data/lib/fontisan/variable/delta_applicator.rb +313 -0
- data/lib/fontisan/variable/glyph_delta_processor.rb +218 -0
- data/lib/fontisan/variable/instancer.rb +344 -0
- data/lib/fontisan/variable/metric_delta_processor.rb +282 -0
- data/lib/fontisan/variable/region_matcher.rb +208 -0
- data/lib/fontisan/variable/static_font_builder.rb +213 -0
- data/lib/fontisan/variable/table_updater.rb +219 -0
- data/lib/fontisan/variation/blend_applier.rb +199 -0
- data/lib/fontisan/variation/cache.rb +298 -0
- data/lib/fontisan/variation/cache_key_builder.rb +162 -0
- data/lib/fontisan/variation/converter.rb +375 -0
- data/lib/fontisan/variation/data_extractor.rb +86 -0
- data/lib/fontisan/variation/delta_applier.rb +266 -0
- data/lib/fontisan/variation/delta_parser.rb +228 -0
- data/lib/fontisan/variation/inspector.rb +275 -0
- data/lib/fontisan/variation/instance_generator.rb +273 -0
- data/lib/fontisan/variation/instance_writer.rb +341 -0
- data/lib/fontisan/variation/interpolator.rb +231 -0
- data/lib/fontisan/variation/metrics_adjuster.rb +318 -0
- data/lib/fontisan/variation/optimizer.rb +418 -0
- data/lib/fontisan/variation/parallel_generator.rb +150 -0
- data/lib/fontisan/variation/region_matcher.rb +221 -0
- data/lib/fontisan/variation/subsetter.rb +463 -0
- data/lib/fontisan/variation/table_accessor.rb +105 -0
- data/lib/fontisan/variation/tuple_variation_header.rb +51 -0
- data/lib/fontisan/variation/validator.rb +345 -0
- data/lib/fontisan/variation/variable_svg_generator.rb +268 -0
- data/lib/fontisan/variation/variation_context.rb +211 -0
- data/lib/fontisan/variation/variation_preserver.rb +288 -0
- data/lib/fontisan/version.rb +1 -1
- data/lib/fontisan/version.rb.orig +9 -0
- data/lib/fontisan/woff2/directory.rb +257 -0
- data/lib/fontisan/woff2/glyf_transformer.rb +666 -0
- data/lib/fontisan/woff2/header.rb +101 -0
- data/lib/fontisan/woff2/hmtx_transformer.rb +164 -0
- data/lib/fontisan/woff2/table_transformer.rb +163 -0
- data/lib/fontisan/woff2_font.rb +717 -0
- data/lib/fontisan/woff_font.rb +488 -0
- data/lib/fontisan.rb +132 -0
- data/scripts/compare_stack_aware.rb +187 -0
- data/scripts/measure_optimization.rb +141 -0
- metadata +234 -4
|
@@ -0,0 +1,207 @@
|
|
|
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
|
+
# Find first path operator
|
|
192
|
+
operations.each_with_index do |op, index|
|
|
193
|
+
return index if path_operators.include?(op[:name])
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
# No path operators found - hints go before endchar
|
|
197
|
+
operations.each_with_index do |op, index|
|
|
198
|
+
return index if op[:name] == :endchar
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
# Empty or malformed - inject at start
|
|
202
|
+
0
|
|
203
|
+
end
|
|
204
|
+
end
|
|
205
|
+
end
|
|
206
|
+
end
|
|
207
|
+
end
|
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "stringio"
|
|
4
|
+
require_relative "../../binary/base_record"
|
|
5
|
+
|
|
6
|
+
module Fontisan
|
|
7
|
+
module Tables
|
|
8
|
+
class Cff
|
|
9
|
+
# CFF INDEX structure
|
|
10
|
+
#
|
|
11
|
+
# INDEX is a fundamental data structure used throughout CFF for storing
|
|
12
|
+
# arrays of variable-length data items. It's used for:
|
|
13
|
+
# - Name INDEX (font names)
|
|
14
|
+
# - String INDEX (string data)
|
|
15
|
+
# - Global Subr INDEX (global subroutines)
|
|
16
|
+
# - Local Subr INDEX (local subroutines)
|
|
17
|
+
# - CharStrings INDEX (glyph programs)
|
|
18
|
+
#
|
|
19
|
+
# Structure:
|
|
20
|
+
# - count (Card16): Number of objects stored in INDEX
|
|
21
|
+
# - offSize (OffSize): Size of offset values (1-4 bytes)
|
|
22
|
+
# - offset[count+1] (Offset): Array of offsets to data
|
|
23
|
+
# - data: The actual data bytes
|
|
24
|
+
#
|
|
25
|
+
# Offsets are relative to the byte before the data array. The first
|
|
26
|
+
# offset is always 1, not 0. The last offset points one byte past the
|
|
27
|
+
# end of the data.
|
|
28
|
+
#
|
|
29
|
+
# Reference: CFF specification section 5 "INDEX Data"
|
|
30
|
+
# https://adobe-type-tools.github.io/font-tech-notes/pdfs/5176.CFF.pdf
|
|
31
|
+
#
|
|
32
|
+
# @example Reading an INDEX
|
|
33
|
+
# index = Fontisan::Tables::Cff::Index.new(data)
|
|
34
|
+
# puts index.count # => 3
|
|
35
|
+
# puts index[0] # => first item data
|
|
36
|
+
# index.each { |item| puts item }
|
|
37
|
+
class Index
|
|
38
|
+
# @return [Integer] Number of items in the INDEX
|
|
39
|
+
attr_reader :count
|
|
40
|
+
|
|
41
|
+
# @return [Integer] Size of offset values (1-4 bytes)
|
|
42
|
+
attr_reader :off_size
|
|
43
|
+
|
|
44
|
+
# @return [Array<Integer>] Array of offsets (count + 1 elements)
|
|
45
|
+
attr_reader :offsets
|
|
46
|
+
|
|
47
|
+
# @return [String] Binary string containing all data
|
|
48
|
+
attr_reader :data
|
|
49
|
+
|
|
50
|
+
# Initialize an INDEX from binary data
|
|
51
|
+
#
|
|
52
|
+
# @param io [IO, StringIO, String] Binary data to parse
|
|
53
|
+
# @param start_offset [Integer] Starting byte offset in the data
|
|
54
|
+
def initialize(io, start_offset: 0)
|
|
55
|
+
@io = io.is_a?(String) ? StringIO.new(io) : io
|
|
56
|
+
@start_offset = start_offset
|
|
57
|
+
@io.seek(start_offset) if @io.respond_to?(:seek)
|
|
58
|
+
|
|
59
|
+
parse!
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Get the item at the specified index
|
|
63
|
+
#
|
|
64
|
+
# @param index [Integer] Zero-based index of item to retrieve
|
|
65
|
+
# @return [String, nil] Binary data for the item, or nil if out of bounds
|
|
66
|
+
def [](index)
|
|
67
|
+
return nil if index.negative? || index >= count
|
|
68
|
+
return "" if count.zero?
|
|
69
|
+
|
|
70
|
+
# Offsets are 1-based in the data array
|
|
71
|
+
start_pos = offsets[index] - 1
|
|
72
|
+
end_pos = offsets[index + 1] - 1
|
|
73
|
+
length = end_pos - start_pos
|
|
74
|
+
|
|
75
|
+
data[start_pos, length]
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# Iterate over each item in the INDEX
|
|
79
|
+
#
|
|
80
|
+
# @yield [String] Binary data for each item
|
|
81
|
+
# @return [Enumerator] If no block given
|
|
82
|
+
def each
|
|
83
|
+
return enum_for(:each) unless block_given?
|
|
84
|
+
|
|
85
|
+
count.times do |i|
|
|
86
|
+
yield self[i]
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# Get all items as an array
|
|
91
|
+
#
|
|
92
|
+
# @return [Array<String>] Array of binary data strings
|
|
93
|
+
def to_a
|
|
94
|
+
Array.new(count) { |i| self[i] }
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# Check if the INDEX is empty
|
|
98
|
+
#
|
|
99
|
+
# @return [Boolean] True if count is 0
|
|
100
|
+
def empty?
|
|
101
|
+
count.zero?
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# Get the size of a specific item
|
|
105
|
+
#
|
|
106
|
+
# @param index [Integer] Zero-based index of item
|
|
107
|
+
# @return [Integer, nil] Size in bytes, or nil if out of bounds
|
|
108
|
+
def item_size(index)
|
|
109
|
+
return nil if index.negative? || index >= count
|
|
110
|
+
return 0 if count.zero?
|
|
111
|
+
|
|
112
|
+
offsets[index + 1] - offsets[index]
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# Calculate total size of the INDEX in bytes
|
|
116
|
+
#
|
|
117
|
+
# This includes the count, offSize, offset array, and data.
|
|
118
|
+
#
|
|
119
|
+
# @return [Integer] Total size in bytes
|
|
120
|
+
def total_size
|
|
121
|
+
return 2 if count.zero? # Just the count field
|
|
122
|
+
|
|
123
|
+
# count (2) + offSize (1) + offset array + data
|
|
124
|
+
2 + 1 + ((count + 1) * off_size) + data.bytesize
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
private
|
|
128
|
+
|
|
129
|
+
# Parse the INDEX structure from the IO
|
|
130
|
+
def parse!
|
|
131
|
+
# Read count (Card16)
|
|
132
|
+
@count = read_uint16
|
|
133
|
+
|
|
134
|
+
# Empty INDEX has only count field
|
|
135
|
+
if @count.zero?
|
|
136
|
+
@off_size = 0
|
|
137
|
+
@offsets = []
|
|
138
|
+
@data = "".b
|
|
139
|
+
return
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
# Read offSize (OffSize)
|
|
143
|
+
@off_size = read_uint8
|
|
144
|
+
|
|
145
|
+
# Validate offSize
|
|
146
|
+
unless (1..4).cover?(@off_size)
|
|
147
|
+
raise CorruptedTableError,
|
|
148
|
+
"Invalid INDEX offSize: #{@off_size} (must be 1-4)"
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
# Read offset array (count + 1 offsets)
|
|
152
|
+
@offsets = Array.new(@count + 1) do
|
|
153
|
+
read_offset(@off_size)
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
# Validate offsets
|
|
157
|
+
validate_offsets!
|
|
158
|
+
|
|
159
|
+
# Read data section
|
|
160
|
+
# Size is (last offset - 1) since offsets are 1-based
|
|
161
|
+
data_size = @offsets.last - 1
|
|
162
|
+
@data = read_bytes(data_size)
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
# Read an unsigned 16-bit integer
|
|
166
|
+
#
|
|
167
|
+
# @return [Integer] The value
|
|
168
|
+
def read_uint16
|
|
169
|
+
bytes = read_bytes(2)
|
|
170
|
+
bytes.unpack1("n") # Big-endian unsigned 16-bit
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
# Read an unsigned 8-bit integer
|
|
174
|
+
#
|
|
175
|
+
# @return [Integer] The value
|
|
176
|
+
def read_uint8
|
|
177
|
+
read_bytes(1).unpack1("C")
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
# Read an offset value of specified size
|
|
181
|
+
#
|
|
182
|
+
# @param size [Integer] Number of bytes (1-4)
|
|
183
|
+
# @return [Integer] The offset value
|
|
184
|
+
def read_offset(size)
|
|
185
|
+
bytes = read_bytes(size)
|
|
186
|
+
|
|
187
|
+
case size
|
|
188
|
+
when 1
|
|
189
|
+
bytes.unpack1("C")
|
|
190
|
+
when 2
|
|
191
|
+
bytes.unpack1("n")
|
|
192
|
+
when 3
|
|
193
|
+
# 24-bit big-endian
|
|
194
|
+
bytes.unpack("C3").inject(0) { |sum, byte| (sum << 8) | byte }
|
|
195
|
+
when 4
|
|
196
|
+
bytes.unpack1("N")
|
|
197
|
+
else
|
|
198
|
+
raise ArgumentError, "Invalid offset size: #{size}"
|
|
199
|
+
end
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
# Read specified number of bytes from IO
|
|
203
|
+
#
|
|
204
|
+
# @param count [Integer] Number of bytes to read
|
|
205
|
+
# @return [String] Binary string
|
|
206
|
+
def read_bytes(count)
|
|
207
|
+
return "".b if count.zero?
|
|
208
|
+
|
|
209
|
+
bytes = @io.read(count)
|
|
210
|
+
if bytes.nil? || bytes.bytesize < count
|
|
211
|
+
raise CorruptedTableError,
|
|
212
|
+
"Unexpected end of INDEX data"
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
bytes
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
# Validate that offsets are in ascending order and within bounds
|
|
219
|
+
def validate_offsets!
|
|
220
|
+
# First offset must be 1
|
|
221
|
+
unless @offsets.first == 1
|
|
222
|
+
raise CorruptedTableError,
|
|
223
|
+
"Invalid INDEX: first offset must be 1, got #{@offsets.first}"
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
# Check ascending order
|
|
227
|
+
@offsets.each_cons(2) do |prev, curr|
|
|
228
|
+
if curr < prev
|
|
229
|
+
raise CorruptedTableError,
|
|
230
|
+
"Invalid INDEX: offsets are not in ascending order"
|
|
231
|
+
end
|
|
232
|
+
end
|
|
233
|
+
end
|
|
234
|
+
end
|
|
235
|
+
end
|
|
236
|
+
end
|
|
237
|
+
end
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "stringio"
|
|
4
|
+
|
|
5
|
+
module Fontisan
|
|
6
|
+
module Tables
|
|
7
|
+
class Cff
|
|
8
|
+
# CFF INDEX structure builder
|
|
9
|
+
#
|
|
10
|
+
# [`IndexBuilder`](lib/fontisan/tables/cff/index_builder.rb) constructs
|
|
11
|
+
# binary INDEX structures from arrays of data items. INDEX is a fundamental
|
|
12
|
+
# CFF data structure used for storing arrays of variable-length data.
|
|
13
|
+
#
|
|
14
|
+
# The builder calculates optimal offset sizes, constructs the offset array,
|
|
15
|
+
# and produces compact binary output.
|
|
16
|
+
#
|
|
17
|
+
# Structure produced:
|
|
18
|
+
# - count (Card16): Number of items
|
|
19
|
+
# - offSize (OffSize): Size of offset values (1-4 bytes)
|
|
20
|
+
# - offset[count+1] (Offset): Array of offsets to data
|
|
21
|
+
# - data: Concatenated data bytes
|
|
22
|
+
#
|
|
23
|
+
# Offsets are 1-based (first offset is always 1). The last offset points
|
|
24
|
+
# one byte past the end of the data.
|
|
25
|
+
#
|
|
26
|
+
# Reference: CFF specification section 5 "INDEX Data"
|
|
27
|
+
# https://adobe-type-tools.github.io/font-tech-notes/pdfs/5176.CFF.pdf
|
|
28
|
+
#
|
|
29
|
+
# @example Building an INDEX
|
|
30
|
+
# items = ["data1".b, "data2".b, "data3".b]
|
|
31
|
+
# index_data = Fontisan::Tables::Cff::IndexBuilder.build(items)
|
|
32
|
+
class IndexBuilder
|
|
33
|
+
# Build INDEX structure from array of binary strings
|
|
34
|
+
#
|
|
35
|
+
# @param items [Array<String>] Array of binary data items
|
|
36
|
+
# @return [String] Binary INDEX data
|
|
37
|
+
# @raise [ArgumentError] If items is not an Array
|
|
38
|
+
def self.build(items)
|
|
39
|
+
validate_items!(items)
|
|
40
|
+
|
|
41
|
+
return build_empty_index if items.empty?
|
|
42
|
+
|
|
43
|
+
# Calculate total data size
|
|
44
|
+
data_size = items.sum(&:bytesize)
|
|
45
|
+
|
|
46
|
+
# Calculate optimal offset size (1-4 bytes)
|
|
47
|
+
# Last offset will be data_size + 1 (1-based)
|
|
48
|
+
off_size = calculate_off_size(data_size + 1)
|
|
49
|
+
|
|
50
|
+
# Build offset array (count + 1 offsets)
|
|
51
|
+
offsets = build_offsets(items, off_size)
|
|
52
|
+
|
|
53
|
+
# Concatenate all data
|
|
54
|
+
data = items.join
|
|
55
|
+
|
|
56
|
+
# Assemble INDEX structure
|
|
57
|
+
output = StringIO.new("".b)
|
|
58
|
+
|
|
59
|
+
# Write count (Card16)
|
|
60
|
+
output.write([items.length].pack("n"))
|
|
61
|
+
|
|
62
|
+
# Write offSize (OffSize)
|
|
63
|
+
output.putc(off_size)
|
|
64
|
+
|
|
65
|
+
# Write offset array
|
|
66
|
+
offsets.each do |offset|
|
|
67
|
+
write_offset(output, offset, off_size)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Write data
|
|
71
|
+
output.write(data)
|
|
72
|
+
|
|
73
|
+
output.string
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# Build an empty INDEX (count = 0)
|
|
77
|
+
#
|
|
78
|
+
# @return [String] Binary empty INDEX
|
|
79
|
+
def self.build_empty_index
|
|
80
|
+
# Empty INDEX has only count field (0)
|
|
81
|
+
[0].pack("n")
|
|
82
|
+
end
|
|
83
|
+
private_class_method :build_empty_index
|
|
84
|
+
|
|
85
|
+
# Validate items parameter
|
|
86
|
+
#
|
|
87
|
+
# @param items [Object] Items to validate
|
|
88
|
+
# @raise [ArgumentError] If items is invalid
|
|
89
|
+
def self.validate_items!(items)
|
|
90
|
+
raise ArgumentError, "items must be Array" unless items.is_a?(Array)
|
|
91
|
+
|
|
92
|
+
items.each_with_index do |item, i|
|
|
93
|
+
unless item.is_a?(String)
|
|
94
|
+
raise ArgumentError,
|
|
95
|
+
"item #{i} must be String, got: #{item.class}"
|
|
96
|
+
end
|
|
97
|
+
unless item.encoding == ::Encoding::BINARY
|
|
98
|
+
raise ArgumentError,
|
|
99
|
+
"item #{i} must have BINARY encoding, got: #{item.encoding}"
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
private_class_method :validate_items!
|
|
104
|
+
|
|
105
|
+
# Calculate optimal offset size for given maximum offset
|
|
106
|
+
#
|
|
107
|
+
# @param max_offset [Integer] Maximum offset value
|
|
108
|
+
# @return [Integer] Offset size (1-4 bytes)
|
|
109
|
+
def self.calculate_off_size(max_offset)
|
|
110
|
+
return 1 if max_offset <= 0xFF
|
|
111
|
+
return 2 if max_offset <= 0xFFFF
|
|
112
|
+
return 3 if max_offset <= 0xFFFFFF
|
|
113
|
+
|
|
114
|
+
4
|
|
115
|
+
end
|
|
116
|
+
private_class_method :calculate_off_size
|
|
117
|
+
|
|
118
|
+
# Build offset array from items
|
|
119
|
+
#
|
|
120
|
+
# Offsets are 1-based. First offset is always 1.
|
|
121
|
+
# Each offset points to the start of its item in the data array.
|
|
122
|
+
# Last offset points one byte past the end of data.
|
|
123
|
+
#
|
|
124
|
+
# @param items [Array<String>] Array of data items
|
|
125
|
+
# @param off_size [Integer] Offset size (1-4 bytes)
|
|
126
|
+
# @return [Array<Integer>] Array of offsets (count + 1 elements)
|
|
127
|
+
def self.build_offsets(items, _off_size)
|
|
128
|
+
offsets = []
|
|
129
|
+
current_offset = 1 # 1-based
|
|
130
|
+
|
|
131
|
+
# First offset is always 1
|
|
132
|
+
offsets << current_offset
|
|
133
|
+
|
|
134
|
+
# Calculate offset for each item
|
|
135
|
+
items.each do |item|
|
|
136
|
+
current_offset += item.bytesize
|
|
137
|
+
offsets << current_offset
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
offsets
|
|
141
|
+
end
|
|
142
|
+
private_class_method :build_offsets
|
|
143
|
+
|
|
144
|
+
# Write an offset value of specified size
|
|
145
|
+
#
|
|
146
|
+
# @param io [StringIO] Output stream
|
|
147
|
+
# @param offset [Integer] Offset value to write
|
|
148
|
+
# @param size [Integer] Number of bytes (1-4)
|
|
149
|
+
def self.write_offset(io, offset, size)
|
|
150
|
+
case size
|
|
151
|
+
when 1
|
|
152
|
+
io.putc(offset & 0xFF)
|
|
153
|
+
when 2
|
|
154
|
+
io.write([offset].pack("n")) # Big-endian unsigned 16-bit
|
|
155
|
+
when 3
|
|
156
|
+
# 24-bit big-endian
|
|
157
|
+
io.putc((offset >> 16) & 0xFF)
|
|
158
|
+
io.putc((offset >> 8) & 0xFF)
|
|
159
|
+
io.putc(offset & 0xFF)
|
|
160
|
+
when 4
|
|
161
|
+
io.write([offset].pack("N")) # Big-endian unsigned 32-bit
|
|
162
|
+
else
|
|
163
|
+
raise ArgumentError, "Invalid offset size: #{size}"
|
|
164
|
+
end
|
|
165
|
+
end
|
|
166
|
+
private_class_method :write_offset
|
|
167
|
+
end
|
|
168
|
+
end
|
|
169
|
+
end
|
|
170
|
+
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
|