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
|
@@ -1,70 +1,116 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require_relative "../models/hint"
|
|
4
|
-
|
|
5
3
|
module Fontisan
|
|
6
4
|
module Hints
|
|
7
|
-
# Applies rendering hints to TrueType
|
|
5
|
+
# Applies rendering hints to TrueType font tables
|
|
6
|
+
#
|
|
7
|
+
# This applier writes TrueType hint data into font-level tables:
|
|
8
|
+
# - fpgm (Font Program) - bytecode executed once at font initialization
|
|
9
|
+
# - prep (Control Value Program) - bytecode for glyph preparation
|
|
10
|
+
# - cvt (Control Values) - array of 16-bit values for hinting metrics
|
|
8
11
|
#
|
|
9
|
-
#
|
|
10
|
-
#
|
|
11
|
-
# instruction sequencing and maintains compatibility with TrueType
|
|
12
|
-
# instruction execution model.
|
|
12
|
+
# The applier ensures proper table structure with correct checksums
|
|
13
|
+
# and does not corrupt the font if hint application fails.
|
|
13
14
|
#
|
|
14
|
-
# @example Apply hints
|
|
15
|
+
# @example Apply hints from a HintSet
|
|
15
16
|
# applier = TrueTypeHintApplier.new
|
|
16
|
-
#
|
|
17
|
+
# tables = {}
|
|
18
|
+
# updated_tables = applier.apply(hint_set, tables)
|
|
17
19
|
class TrueTypeHintApplier
|
|
18
|
-
# Apply hints to
|
|
20
|
+
# Apply TrueType hints to font tables
|
|
19
21
|
#
|
|
20
|
-
# @param
|
|
21
|
-
# @param
|
|
22
|
-
# @return [
|
|
23
|
-
def apply(
|
|
24
|
-
return
|
|
25
|
-
return
|
|
22
|
+
# @param hint_set [HintSet] Hint data to apply
|
|
23
|
+
# @param tables [Hash] Font tables to update
|
|
24
|
+
# @return [Hash] Updated font tables
|
|
25
|
+
def apply(hint_set, tables)
|
|
26
|
+
return tables if hint_set.nil? || hint_set.empty?
|
|
27
|
+
return tables unless hint_set.format == "truetype"
|
|
26
28
|
|
|
27
|
-
#
|
|
28
|
-
|
|
29
|
+
# Write fpgm table if present
|
|
30
|
+
if hint_set.font_program && !hint_set.font_program.empty?
|
|
31
|
+
tables["fpgm"] = build_fpgm_table(hint_set.font_program)
|
|
32
|
+
end
|
|
29
33
|
|
|
30
|
-
#
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
# 3. Update glyph instruction data
|
|
34
|
+
# Write prep table if present
|
|
35
|
+
if hint_set.control_value_program && !hint_set.control_value_program.empty?
|
|
36
|
+
tables["prep"] = build_prep_table(hint_set.control_value_program)
|
|
37
|
+
end
|
|
35
38
|
|
|
36
|
-
#
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
39
|
+
# Write cvt table if present
|
|
40
|
+
if hint_set.control_values && !hint_set.control_values.empty?
|
|
41
|
+
tables["cvt "] = build_cvt_table(hint_set.control_values)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Future enhancement: Apply per-glyph hints to glyf table
|
|
45
|
+
# For now, font-level tables only
|
|
46
|
+
|
|
47
|
+
tables
|
|
40
48
|
end
|
|
41
49
|
|
|
42
50
|
private
|
|
43
51
|
|
|
44
|
-
# Build
|
|
52
|
+
# Build fpgm (Font Program) table
|
|
45
53
|
#
|
|
46
|
-
# @param
|
|
47
|
-
# @return [
|
|
48
|
-
def
|
|
49
|
-
|
|
54
|
+
# @param program_data [String] Raw bytecode
|
|
55
|
+
# @return [Hash] Table structure with tag, data, and checksum
|
|
56
|
+
def build_fpgm_table(program_data)
|
|
57
|
+
{
|
|
58
|
+
tag: "fpgm",
|
|
59
|
+
data: program_data,
|
|
60
|
+
checksum: calculate_checksum(program_data),
|
|
61
|
+
}
|
|
62
|
+
end
|
|
50
63
|
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
64
|
+
# Build prep (Control Value Program) table
|
|
65
|
+
#
|
|
66
|
+
# @param program_data [String] Raw bytecode
|
|
67
|
+
# @return [Hash] Table structure with tag, data, and checksum
|
|
68
|
+
def build_prep_table(program_data)
|
|
69
|
+
{
|
|
70
|
+
tag: "prep",
|
|
71
|
+
data: program_data,
|
|
72
|
+
checksum: calculate_checksum(program_data),
|
|
73
|
+
}
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# Build cvt (Control Values) table
|
|
77
|
+
#
|
|
78
|
+
# CVT values are 16-bit signed integers (FWORD) in big-endian format.
|
|
79
|
+
# Each value represents a design-space coordinate used for hinting.
|
|
80
|
+
#
|
|
81
|
+
# @param control_values [Array<Integer>] Array of 16-bit signed values
|
|
82
|
+
# @return [Hash] Table structure with tag, data, and checksum
|
|
83
|
+
def build_cvt_table(control_values)
|
|
84
|
+
# Pack as 16-bit big-endian signed integers (s> = signed big-endian)
|
|
85
|
+
data = control_values.pack("s>*")
|
|
55
86
|
|
|
56
|
-
|
|
87
|
+
{
|
|
88
|
+
tag: "cvt ",
|
|
89
|
+
data: data,
|
|
90
|
+
checksum: calculate_checksum(data),
|
|
91
|
+
}
|
|
57
92
|
end
|
|
58
93
|
|
|
59
|
-
#
|
|
94
|
+
# Calculate OpenType table checksum
|
|
95
|
+
#
|
|
96
|
+
# OpenType spec requires tables to be checksummed as 32-bit unsigned
|
|
97
|
+
# integers in big-endian format. The table is padded to a multiple of
|
|
98
|
+
# 4 bytes with zeros before checksum calculation.
|
|
60
99
|
#
|
|
61
|
-
# @param
|
|
62
|
-
# @return [
|
|
63
|
-
def
|
|
64
|
-
|
|
100
|
+
# @param data [String] Table data
|
|
101
|
+
# @return [Integer] 32-bit checksum
|
|
102
|
+
def calculate_checksum(data)
|
|
103
|
+
# Pad to 4-byte boundary with zeros
|
|
104
|
+
padding_needed = (4 - data.length % 4) % 4
|
|
105
|
+
padded = data + ("\x00" * padding_needed)
|
|
106
|
+
|
|
107
|
+
# Sum as 32-bit unsigned integers in big-endian
|
|
108
|
+
checksum = 0
|
|
109
|
+
(0...padded.length).step(4) do |i|
|
|
110
|
+
checksum = (checksum + padded[i, 4].unpack1("N")) & 0xFFFFFFFF
|
|
111
|
+
end
|
|
65
112
|
|
|
66
|
-
|
|
67
|
-
instructions.all? { |byte| byte >= 0 && byte <= 255 }
|
|
113
|
+
checksum
|
|
68
114
|
end
|
|
69
115
|
end
|
|
70
116
|
end
|
|
@@ -29,12 +29,36 @@ module Fontisan
|
|
|
29
29
|
MDRP_MIN_RND_BLACK = 0xC0
|
|
30
30
|
IUP_Y = 0x30
|
|
31
31
|
IUP_X = 0x31
|
|
32
|
-
SHP = [0x32, 0x33]
|
|
32
|
+
SHP = [0x32, 0x33].freeze
|
|
33
33
|
ALIGNRP = 0x3C
|
|
34
34
|
DELTAP1 = 0x5D
|
|
35
35
|
DELTAP2 = 0x71
|
|
36
36
|
DELTAP3 = 0x72
|
|
37
37
|
|
|
38
|
+
# Extract complete hint data from TrueType font
|
|
39
|
+
#
|
|
40
|
+
# This extracts both font-level hints (fpgm, prep, cvt tables) and
|
|
41
|
+
# per-glyph hints from glyph instructions.
|
|
42
|
+
#
|
|
43
|
+
# @param font [TrueTypeFont] TrueType font object
|
|
44
|
+
# @return [Models::HintSet] Complete hint set
|
|
45
|
+
def extract_from_font(font)
|
|
46
|
+
hint_set = Models::HintSet.new(format: "truetype")
|
|
47
|
+
|
|
48
|
+
# Extract font-level programs
|
|
49
|
+
hint_set.font_program = extract_font_program(font)
|
|
50
|
+
hint_set.control_value_program = extract_control_value_program(font)
|
|
51
|
+
hint_set.control_values = extract_control_values(font)
|
|
52
|
+
|
|
53
|
+
# Extract per-glyph hints
|
|
54
|
+
extract_glyph_hints(font, hint_set)
|
|
55
|
+
|
|
56
|
+
# Update metadata
|
|
57
|
+
hint_set.has_hints = !hint_set.empty?
|
|
58
|
+
|
|
59
|
+
hint_set
|
|
60
|
+
end
|
|
61
|
+
|
|
38
62
|
# Extract hints from TrueType glyph
|
|
39
63
|
#
|
|
40
64
|
# @param glyph [Glyph] TrueType glyph with instructions
|
|
@@ -80,7 +104,7 @@ module Fontisan
|
|
|
80
104
|
hints << Models::Hint.new(
|
|
81
105
|
type: :interpolate,
|
|
82
106
|
data: { axis: opcode == IUP_Y ? :y : :x },
|
|
83
|
-
source_format: :truetype
|
|
107
|
+
source_format: :truetype,
|
|
84
108
|
)
|
|
85
109
|
i += 1
|
|
86
110
|
|
|
@@ -89,7 +113,7 @@ module Fontisan
|
|
|
89
113
|
hints << Models::Hint.new(
|
|
90
114
|
type: :shift,
|
|
91
115
|
data: { instructions: [opcode] },
|
|
92
|
-
source_format: :truetype
|
|
116
|
+
source_format: :truetype,
|
|
93
117
|
)
|
|
94
118
|
i += 1
|
|
95
119
|
|
|
@@ -98,7 +122,7 @@ module Fontisan
|
|
|
98
122
|
hints << Models::Hint.new(
|
|
99
123
|
type: :align,
|
|
100
124
|
data: {},
|
|
101
|
-
source_format: :truetype
|
|
125
|
+
source_format: :truetype,
|
|
102
126
|
)
|
|
103
127
|
i += 1
|
|
104
128
|
|
|
@@ -113,9 +137,9 @@ module Fontisan
|
|
|
113
137
|
type: :delta,
|
|
114
138
|
data: {
|
|
115
139
|
instructions: [opcode] + [count] + delta_data,
|
|
116
|
-
count: count
|
|
140
|
+
count: count,
|
|
117
141
|
},
|
|
118
|
-
source_format: :truetype
|
|
142
|
+
source_format: :truetype,
|
|
119
143
|
)
|
|
120
144
|
i += count * 2 + 1
|
|
121
145
|
end
|
|
@@ -141,7 +165,7 @@ module Fontisan
|
|
|
141
165
|
|
|
142
166
|
# Check if next instruction is MDRP (stem width)
|
|
143
167
|
has_width = index + 1 < bytes.length &&
|
|
144
|
-
|
|
168
|
+
bytes[index + 1] == MDRP_MIN_RND_BLACK
|
|
145
169
|
|
|
146
170
|
if has_width
|
|
147
171
|
Models::Hint.new(
|
|
@@ -149,14 +173,113 @@ module Fontisan
|
|
|
149
173
|
data: {
|
|
150
174
|
position: 0, # Would be extracted from graphics state
|
|
151
175
|
width: 0, # Would be calculated from MDRP
|
|
152
|
-
orientation: :vertical # Inferred from instruction context
|
|
176
|
+
orientation: :vertical, # Inferred from instruction context
|
|
153
177
|
},
|
|
154
|
-
source_format: :truetype
|
|
178
|
+
source_format: :truetype,
|
|
155
179
|
)
|
|
156
|
-
else
|
|
157
|
-
nil
|
|
158
180
|
end
|
|
159
181
|
end
|
|
182
|
+
|
|
183
|
+
# Extract font program (fpgm table)
|
|
184
|
+
#
|
|
185
|
+
# @param font [TrueTypeFont] TrueType font
|
|
186
|
+
# @return [String] Font program bytecode (binary string)
|
|
187
|
+
def extract_font_program(font)
|
|
188
|
+
return "" unless font.has_table?("fpgm")
|
|
189
|
+
|
|
190
|
+
font_program_data = font.instance_variable_get(:@table_data)["fpgm"]
|
|
191
|
+
return "" unless font_program_data
|
|
192
|
+
|
|
193
|
+
# Return as binary string
|
|
194
|
+
font_program_data.force_encoding("ASCII-8BIT")
|
|
195
|
+
rescue StandardError => e
|
|
196
|
+
warn "Failed to extract font program: #{e.message}"
|
|
197
|
+
""
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
# Extract control value program (prep table)
|
|
201
|
+
#
|
|
202
|
+
# @param font [TrueTypeFont] TrueType font
|
|
203
|
+
# @return [String] Control value program bytecode (binary string)
|
|
204
|
+
def extract_control_value_program(font)
|
|
205
|
+
return "" unless font.has_table?("prep")
|
|
206
|
+
|
|
207
|
+
prep_data = font.instance_variable_get(:@table_data)["prep"]
|
|
208
|
+
return "" unless prep_data
|
|
209
|
+
|
|
210
|
+
# Return as binary string
|
|
211
|
+
prep_data.force_encoding("ASCII-8BIT")
|
|
212
|
+
rescue StandardError => e
|
|
213
|
+
warn "Failed to extract control value program: #{e.message}"
|
|
214
|
+
""
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
# Extract control values (cvt table)
|
|
218
|
+
#
|
|
219
|
+
# @param font [TrueTypeFont] TrueType font
|
|
220
|
+
# @return [Array<Integer>] Control values
|
|
221
|
+
def extract_control_values(font)
|
|
222
|
+
return [] unless font.has_table?("cvt ")
|
|
223
|
+
|
|
224
|
+
cvt_data = font.instance_variable_get(:@table_data)["cvt "]
|
|
225
|
+
return [] unless cvt_data
|
|
226
|
+
|
|
227
|
+
# CVT table is an array of 16-bit signed integers (FWord values)
|
|
228
|
+
values = []
|
|
229
|
+
io = StringIO.new(cvt_data)
|
|
230
|
+
while !io.eof?
|
|
231
|
+
# Read 16-bit big-endian signed integer
|
|
232
|
+
bytes = io.read(2)
|
|
233
|
+
break unless bytes&.length == 2
|
|
234
|
+
|
|
235
|
+
value = bytes.unpack1("n") # Unsigned short
|
|
236
|
+
# Convert to signed
|
|
237
|
+
value = value - 65536 if value > 32767
|
|
238
|
+
values << value
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
values
|
|
242
|
+
rescue StandardError => e
|
|
243
|
+
warn "Failed to extract control values: #{e.message}"
|
|
244
|
+
[]
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
# Extract per-glyph hints from glyf table
|
|
248
|
+
#
|
|
249
|
+
# @param font [TrueTypeFont] TrueType font
|
|
250
|
+
# @param hint_set [Models::HintSet] Hint set to populate
|
|
251
|
+
# @return [void]
|
|
252
|
+
def extract_glyph_hints(font, hint_set)
|
|
253
|
+
return unless font.has_table?("glyf")
|
|
254
|
+
|
|
255
|
+
glyf_table = font.table("glyf")
|
|
256
|
+
return unless glyf_table
|
|
257
|
+
|
|
258
|
+
# Get number of glyphs from maxp table
|
|
259
|
+
maxp_table = font.table("maxp")
|
|
260
|
+
return unless maxp_table
|
|
261
|
+
|
|
262
|
+
num_glyphs = maxp_table.num_glyphs
|
|
263
|
+
|
|
264
|
+
# Iterate through all glyphs
|
|
265
|
+
(0...num_glyphs).each do |glyph_id|
|
|
266
|
+
glyph = glyf_table.glyph_for(glyph_id)
|
|
267
|
+
next unless glyph
|
|
268
|
+
next if glyph.number_of_contours <= 0 # Skip compound glyphs and empty glyphs
|
|
269
|
+
|
|
270
|
+
# Extract hints from simple glyph instructions
|
|
271
|
+
hints = extract(glyph)
|
|
272
|
+
next if hints.empty?
|
|
273
|
+
|
|
274
|
+
# Store glyph hints
|
|
275
|
+
hint_set.add_glyph_hints(glyph_id, hints)
|
|
276
|
+
rescue StandardError
|
|
277
|
+
# Skip glyphs that fail to parse
|
|
278
|
+
next
|
|
279
|
+
end
|
|
280
|
+
rescue StandardError => e
|
|
281
|
+
warn "Failed to extract glyph hints: #{e.message}"
|
|
282
|
+
end
|
|
160
283
|
end
|
|
161
284
|
end
|
|
162
285
|
end
|
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Fontisan
|
|
4
|
+
module Hints
|
|
5
|
+
# Analyzes TrueType bytecode instructions to extract hint parameters
|
|
6
|
+
#
|
|
7
|
+
# This analyzer parses fpgm (Font Program) and prep (Control Value Program)
|
|
8
|
+
# bytecode to extract semantic hint information that can be converted to
|
|
9
|
+
# PostScript Private dict parameters.
|
|
10
|
+
#
|
|
11
|
+
# **Key Extracted Parameters:**
|
|
12
|
+
#
|
|
13
|
+
# - Blue zones (alignment zones for baseline, x-height, cap-height)
|
|
14
|
+
# - Stem widths (from CVT setup in prep)
|
|
15
|
+
# - Delta base and shift values
|
|
16
|
+
# - Twilight zone setup
|
|
17
|
+
#
|
|
18
|
+
# @example Analyze prep program
|
|
19
|
+
# analyzer = TrueTypeInstructionAnalyzer.new
|
|
20
|
+
# params = analyzer.analyze_prep(prep_bytecode, cvt_values)
|
|
21
|
+
class TrueTypeInstructionAnalyzer
|
|
22
|
+
# TrueType instruction opcodes relevant for hint extraction
|
|
23
|
+
NPUSHB = 0x40 # Push N bytes
|
|
24
|
+
NPUSHW = 0x41 # Push N words
|
|
25
|
+
PUSHB = (0xB0..0xB7).to_a # Push 1-8 bytes
|
|
26
|
+
PUSHW = (0xB8..0xBF).to_a # Push 1-8 words
|
|
27
|
+
SVTCA_Y = 0x00 # Set freedom and projection vectors to Y-axis
|
|
28
|
+
SVTCA_X = 0x01 # Set freedom and projection vectors to X-axis
|
|
29
|
+
RCVT = 0x45 # Read CVT
|
|
30
|
+
WCVTP = 0x44 # Write CVT (in Pixels)
|
|
31
|
+
WCVTF = 0x70 # Write CVT (in FUnits)
|
|
32
|
+
MDAP = [0x2E, 0x2F].freeze # Move Direct Absolute Point
|
|
33
|
+
SCVTCI = 0x1D # Set Control Value Table Cut In
|
|
34
|
+
SSWCI = 0x1E # Set Single Width Cut In
|
|
35
|
+
SSW = 0x1F # Set Single Width
|
|
36
|
+
|
|
37
|
+
# Analyze prep program to extract hint parameters
|
|
38
|
+
#
|
|
39
|
+
# @param prep [String] Control value program bytecode
|
|
40
|
+
# @param cvt [Array<Integer>] Control values
|
|
41
|
+
# @return [Hash] Extracted hint parameters
|
|
42
|
+
def analyze_prep(prep, cvt = [])
|
|
43
|
+
return {} if prep.nil? && (cvt.nil? || cvt.empty?)
|
|
44
|
+
|
|
45
|
+
params = {}
|
|
46
|
+
|
|
47
|
+
# Parse prep bytecode if present
|
|
48
|
+
if prep && !prep.empty?
|
|
49
|
+
bytes = prep.bytes
|
|
50
|
+
stack = []
|
|
51
|
+
i = 0
|
|
52
|
+
|
|
53
|
+
while i < bytes.length
|
|
54
|
+
opcode = bytes[i]
|
|
55
|
+
|
|
56
|
+
case opcode
|
|
57
|
+
when NPUSHB
|
|
58
|
+
# Push N bytes
|
|
59
|
+
i += 1
|
|
60
|
+
count = bytes[i]
|
|
61
|
+
i += 1
|
|
62
|
+
count.times do
|
|
63
|
+
stack.push(bytes[i])
|
|
64
|
+
i += 1
|
|
65
|
+
end
|
|
66
|
+
next
|
|
67
|
+
|
|
68
|
+
when NPUSHW
|
|
69
|
+
# Push N words (16-bit values)
|
|
70
|
+
i += 1
|
|
71
|
+
count = bytes[i]
|
|
72
|
+
i += 1
|
|
73
|
+
count.times do
|
|
74
|
+
value = (bytes[i] << 8) | bytes[i + 1]
|
|
75
|
+
# Convert to signed
|
|
76
|
+
value = value - 65536 if value > 32767
|
|
77
|
+
stack.push(value)
|
|
78
|
+
i += 2
|
|
79
|
+
end
|
|
80
|
+
next
|
|
81
|
+
|
|
82
|
+
when *PUSHB
|
|
83
|
+
# Push 1-8 bytes
|
|
84
|
+
count = opcode - 0xB0 + 1
|
|
85
|
+
i += 1
|
|
86
|
+
count.times do
|
|
87
|
+
stack.push(bytes[i])
|
|
88
|
+
i += 1
|
|
89
|
+
end
|
|
90
|
+
next
|
|
91
|
+
|
|
92
|
+
when *PUSHW
|
|
93
|
+
# Push 1-8 words
|
|
94
|
+
count = opcode - 0xB8 + 1
|
|
95
|
+
i += 1
|
|
96
|
+
count.times do
|
|
97
|
+
value = (bytes[i] << 8) | bytes[i + 1]
|
|
98
|
+
value = value - 65536 if value > 32767
|
|
99
|
+
stack.push(value)
|
|
100
|
+
i += 2
|
|
101
|
+
end
|
|
102
|
+
next
|
|
103
|
+
|
|
104
|
+
when WCVTP, WCVTF
|
|
105
|
+
# Write to CVT - this shows which CVT indices are being set up
|
|
106
|
+
# Pattern: value cvt_index WCVTP (stack top to bottom)
|
|
107
|
+
if stack.length >= 2
|
|
108
|
+
value = stack.pop
|
|
109
|
+
cvt_index = stack.pop
|
|
110
|
+
# Track CVT modifications (useful for understanding setup)
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
when SSW
|
|
114
|
+
# Set Single Width - used for stem width control
|
|
115
|
+
if stack.length >= 1
|
|
116
|
+
width = stack.pop
|
|
117
|
+
params[:single_width] = width unless params[:single_width]
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
when SSWCI
|
|
121
|
+
# Set Single Width Cut In
|
|
122
|
+
if stack.length >= 1
|
|
123
|
+
params[:single_width_cut_in] = stack.pop
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
when SCVTCI
|
|
127
|
+
# Set CVT Cut In
|
|
128
|
+
if stack.length >= 1
|
|
129
|
+
params[:cvt_cut_in] = stack.pop
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
i += 1
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
# Extract blue zones from CVT analysis (always do this if CVT is present)
|
|
138
|
+
if cvt && !cvt.empty?
|
|
139
|
+
params.merge!(extract_blue_zones_from_cvt(cvt))
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
params
|
|
143
|
+
rescue StandardError => e
|
|
144
|
+
warn "Error analyzing prep program: #{e.message}"
|
|
145
|
+
{}
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
# Analyze Font Program (fpgm) for complexity indicators
|
|
149
|
+
#
|
|
150
|
+
# The fpgm contains font-level function definitions. While we don't
|
|
151
|
+
# fully decompile it, we can extract useful metadata about hint complexity.
|
|
152
|
+
#
|
|
153
|
+
# @param fpgm [String] Binary fpgm data
|
|
154
|
+
# @return [Hash] Analysis results with complexity indicators
|
|
155
|
+
def analyze_fpgm(fpgm)
|
|
156
|
+
return {} if fpgm.nil? || fpgm.empty?
|
|
157
|
+
|
|
158
|
+
size = fpgm.bytesize
|
|
159
|
+
|
|
160
|
+
# Estimate complexity based on size
|
|
161
|
+
complexity = if size < 100
|
|
162
|
+
:simple
|
|
163
|
+
elsif size < 200
|
|
164
|
+
:moderate
|
|
165
|
+
else
|
|
166
|
+
:complex
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
{
|
|
170
|
+
fpgm_size: size,
|
|
171
|
+
has_functions: size > 0,
|
|
172
|
+
complexity: complexity,
|
|
173
|
+
}
|
|
174
|
+
rescue StandardError
|
|
175
|
+
# Return empty hash on any error
|
|
176
|
+
{}
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
# Extract blue zones from CVT values using heuristics
|
|
180
|
+
#
|
|
181
|
+
# Blue zones in PostScript define alignment constraints for
|
|
182
|
+
# baseline, x-height, cap-height, ascender, and descender.
|
|
183
|
+
# TrueType doesn't have explicit blue zones, but we can derive
|
|
184
|
+
# them from CVT values using common patterns.
|
|
185
|
+
#
|
|
186
|
+
# Heuristics:
|
|
187
|
+
# - Negative values near -250 to -200: Descender zones
|
|
188
|
+
# - Values near 0: Baseline zones
|
|
189
|
+
# - Values near 500-550: X-height zones
|
|
190
|
+
# - Values near 700-750: Cap-height zones
|
|
191
|
+
# - For large UPM (>2000): Scale thresholds proportionally
|
|
192
|
+
#
|
|
193
|
+
# @param cvt [Array<Integer>] Control Value Table entries
|
|
194
|
+
# @return [Hash] Extracted blue zone parameters
|
|
195
|
+
def extract_blue_zones_from_cvt(cvt)
|
|
196
|
+
return {} if cvt.nil? || cvt.empty?
|
|
197
|
+
|
|
198
|
+
zones = {}
|
|
199
|
+
|
|
200
|
+
# Detect scale from maximum absolute value
|
|
201
|
+
max_value = cvt.map(&:abs).max
|
|
202
|
+
scale_factor = max_value > 1000 ? (max_value / 1000.0) : 1.0
|
|
203
|
+
|
|
204
|
+
# Scaled thresholds
|
|
205
|
+
descender_min = (-300 * scale_factor).to_i
|
|
206
|
+
descender_max = (-150 * scale_factor).to_i
|
|
207
|
+
baseline_range = (50 * scale_factor).to_i
|
|
208
|
+
xheight_min = (450 * scale_factor).to_i
|
|
209
|
+
xheight_max = (600 * scale_factor).to_i
|
|
210
|
+
capheight_min = (650 * scale_factor).to_i
|
|
211
|
+
capheight_max = (1500 * scale_factor).to_i # Wider range for large UPM
|
|
212
|
+
|
|
213
|
+
# Group CVT values by typical alignment zones
|
|
214
|
+
descender_values = cvt.select { |v| v < descender_max && v > descender_min }
|
|
215
|
+
baseline_values = cvt.select { |v| v >= -baseline_range && v <= baseline_range }
|
|
216
|
+
xheight_values = cvt.select { |v| v >= xheight_min && v <= xheight_max }
|
|
217
|
+
capheight_values = cvt.select { |v| v >= capheight_min && v <= capheight_max }
|
|
218
|
+
|
|
219
|
+
# Build blue_values (baseline and top zones)
|
|
220
|
+
blue_values = []
|
|
221
|
+
|
|
222
|
+
# Add baseline zone if detected
|
|
223
|
+
if baseline_values.any?
|
|
224
|
+
min_baseline = baseline_values.min
|
|
225
|
+
max_baseline = baseline_values.max
|
|
226
|
+
blue_values << min_baseline << max_baseline
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
# Add cap-height zone if detected (or any top zone for large UPM)
|
|
230
|
+
if capheight_values.any?
|
|
231
|
+
min_cap = capheight_values.min
|
|
232
|
+
max_cap = capheight_values.max
|
|
233
|
+
blue_values << min_cap << max_cap
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
zones[:blue_values] = blue_values unless blue_values.empty?
|
|
237
|
+
|
|
238
|
+
# Build other_blues (descender zones)
|
|
239
|
+
if descender_values.any?
|
|
240
|
+
min_desc = descender_values.min
|
|
241
|
+
max_desc = descender_values.max
|
|
242
|
+
zones[:other_blues] = [min_desc, max_desc]
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
zones
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
private
|
|
249
|
+
|
|
250
|
+
# Estimate complexity of bytecode program
|
|
251
|
+
#
|
|
252
|
+
# @param bytes [Array<Integer>] Bytecode
|
|
253
|
+
# @return [Symbol] Complexity level (:simple, :moderate, :complex)
|
|
254
|
+
def estimate_complexity(bytes)
|
|
255
|
+
return :simple if bytes.length < 50
|
|
256
|
+
return :moderate if bytes.length < 200
|
|
257
|
+
:complex
|
|
258
|
+
end
|
|
259
|
+
end
|
|
260
|
+
end
|
|
261
|
+
end
|