fontisan 0.4.32 → 0.4.34
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 +22 -213
- data/TODO.improvements/README.md +6 -6
- data/lib/fontisan/cli.rb +65 -0
- data/lib/fontisan/commands/audit_command.rb +257 -0
- data/lib/fontisan/commands.rb +1 -0
- data/lib/fontisan/models/audit_report.rb +143 -0
- data/lib/fontisan/models.rb +2 -0
- data/lib/fontisan/subset/table_strategy/cff2.rb +392 -0
- data/lib/fontisan/subset/table_strategy.rb +2 -0
- data/lib/fontisan/tables/cff/cff2_charstring_builder.rb +5 -2
- data/lib/fontisan/tables/cff2/fd_select.rb +74 -1
- data/lib/fontisan/tables/cff2/index.rb +155 -0
- data/lib/fontisan/tables/cff2/table_reader.rb +25 -26
- data/lib/fontisan/tables/cff2.rb +1 -0
- data/lib/fontisan/type1/charstrings.rb +34 -1
- data/lib/fontisan/type1/seac_expander.rb +68 -57
- data/lib/fontisan/ufo/compile/cff2.rb +161 -19
- data/lib/fontisan/ufo/compile/feature_compiler.rb +161 -0
- data/lib/fontisan/ufo/compile/gsub.rb +267 -0
- data/lib/fontisan/ufo/compile/variable_otf.rb +16 -7
- data/lib/fontisan/ufo/compile.rb +3 -1
- data/lib/fontisan/ufo/font.rb +3 -2
- data/lib/fontisan/ufo/image.rb +15 -2
- data/lib/fontisan/ufo/image_set.rb +82 -2
- data/lib/fontisan/ufo/reader.rb +8 -0
- data/lib/fontisan/version.rb +1 -1
- metadata +7 -1
|
@@ -235,25 +235,7 @@ module Fontisan
|
|
|
235
235
|
Cff::Index.new(@io, start_offset: offset)
|
|
236
236
|
end
|
|
237
237
|
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
# Read bytes safely with EOF checking
|
|
241
|
-
#
|
|
242
|
-
# @param bytes [Integer] Number of bytes to read
|
|
243
|
-
# @param description [String] Description for error messages
|
|
244
|
-
# @return [String] Binary data
|
|
245
|
-
# @raise [EOFError] If not enough bytes available
|
|
246
|
-
def read_safely(bytes, description)
|
|
247
|
-
data = @io.read(bytes)
|
|
248
|
-
if data.nil? || data.bytesize < bytes
|
|
249
|
-
raise EOFError,
|
|
250
|
-
"Unexpected EOF while reading #{description}"
|
|
251
|
-
end
|
|
252
|
-
|
|
253
|
-
data
|
|
254
|
-
end
|
|
255
|
-
|
|
256
|
-
# Parse DICT structure
|
|
238
|
+
# Parse a DICT structure (public: reused by the CFF2 subsetter).
|
|
257
239
|
#
|
|
258
240
|
# @param data [String] DICT binary data
|
|
259
241
|
# @return [Hash] Parsed operators and values
|
|
@@ -281,20 +263,37 @@ module Fontisan
|
|
|
281
263
|
dict
|
|
282
264
|
end
|
|
283
265
|
|
|
266
|
+
private
|
|
267
|
+
|
|
268
|
+
# Read bytes safely with EOF checking
|
|
269
|
+
#
|
|
270
|
+
# @param bytes [Integer] Number of bytes to read
|
|
271
|
+
# @param description [String] Description for error messages
|
|
272
|
+
# @return [String] Binary data
|
|
273
|
+
# @raise [EOFError] If not enough bytes available
|
|
274
|
+
def read_safely(bytes, description)
|
|
275
|
+
data = @io.read(bytes)
|
|
276
|
+
if data.nil? || data.bytesize < bytes
|
|
277
|
+
raise EOFError,
|
|
278
|
+
"Unexpected EOF while reading #{description}"
|
|
279
|
+
end
|
|
280
|
+
|
|
281
|
+
data
|
|
282
|
+
end
|
|
283
|
+
|
|
284
284
|
# Check if byte is an operator
|
|
285
285
|
#
|
|
286
|
-
# CFF2
|
|
286
|
+
# CFF/CFF2 DICT operator bytes are 0-21 (with 12 as the two-byte
|
|
287
|
+
# escape prefix) and 24 (vstore, CFF2-specific). Number encoding
|
|
288
|
+
# prefixes (28, 29, 30) and ranges (32-254) are NOT operators.
|
|
287
289
|
#
|
|
288
290
|
# @param byte [Integer] Byte value
|
|
289
291
|
# @return [Boolean] True if operator
|
|
290
292
|
def operator_byte?(byte)
|
|
291
|
-
|
|
292
|
-
return
|
|
293
|
-
|
|
294
|
-
# CFF2-specific operators
|
|
295
|
-
return true if byte == VSTORE_OPERATOR
|
|
293
|
+
return false if (28..30).cover?(byte) # number prefixes
|
|
294
|
+
return false if (32..254).cover?(byte) # number ranges
|
|
296
295
|
|
|
297
|
-
|
|
296
|
+
true # 0-27 (incl. 12 escape), 31, 255 — treat as operator
|
|
298
297
|
end
|
|
299
298
|
|
|
300
299
|
# Read DICT operator
|
data/lib/fontisan/tables/cff2.rb
CHANGED
|
@@ -26,6 +26,7 @@ module Fontisan
|
|
|
26
26
|
autoload :CharstringParser, "fontisan/tables/cff2/charstring_parser"
|
|
27
27
|
autoload :FdSelect, "fontisan/tables/cff2/fd_select"
|
|
28
28
|
autoload :Header, "fontisan/tables/cff2/header"
|
|
29
|
+
autoload :Index, "fontisan/tables/cff2/index"
|
|
29
30
|
autoload :IndexBuilder, "fontisan/tables/cff2/index_builder"
|
|
30
31
|
autoload :DictEncoder, "fontisan/tables/cff2/dict_encoder"
|
|
31
32
|
autoload :OperandStack, "fontisan/tables/cff2/operand_stack"
|
|
@@ -42,6 +42,39 @@ module Fontisan
|
|
|
42
42
|
def initialize(private_dict = nil)
|
|
43
43
|
@private_dict = private_dict || PrivateDict.new
|
|
44
44
|
@charstrings = {}
|
|
45
|
+
@encoding_override = nil
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Build a CharStrings from a pre-built glyph-name → charstring-bytecode
|
|
49
|
+
# hash. Useful for constructing test fixtures without parsing a full
|
|
50
|
+
# Type 1 font. Optionally accepts +encoding:+ to override the default
|
|
51
|
+
# Adobe Standard Encoding (char code → glyph name).
|
|
52
|
+
#
|
|
53
|
+
# @param charstrings_hash [Hash{String=>String}]
|
|
54
|
+
# @param encoding [Hash{Integer=>String}, nil]
|
|
55
|
+
# @param private_dict [PrivateDict, nil]
|
|
56
|
+
# @return [CharStrings]
|
|
57
|
+
def self.from_hash(charstrings_hash, encoding: nil, private_dict: nil)
|
|
58
|
+
cs = new(private_dict)
|
|
59
|
+
charstrings_hash.each { |name, data| cs.register(name, data) }
|
|
60
|
+
cs.set_encoding(encoding) if encoding
|
|
61
|
+
cs
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# Register (or replace) a glyph's charstring data.
|
|
65
|
+
#
|
|
66
|
+
# @param glyph_name [String]
|
|
67
|
+
# @param charstring_data [String] binary charstring bytecode
|
|
68
|
+
def register(glyph_name, charstring_data)
|
|
69
|
+
@charstrings[glyph_name] = charstring_data
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# Override the default Adobe Standard Encoding with a custom
|
|
73
|
+
# +char_code → glyph_name+ map.
|
|
74
|
+
#
|
|
75
|
+
# @param encoding [Hash{Integer=>String}]
|
|
76
|
+
def set_encoding(encoding)
|
|
77
|
+
@encoding_override = encoding
|
|
45
78
|
end
|
|
46
79
|
|
|
47
80
|
# Parse CharStrings dictionary from decrypted Type 1 font data
|
|
@@ -72,7 +105,7 @@ module Fontisan
|
|
|
72
105
|
#
|
|
73
106
|
# @return [Hash] Character code to glyph name mapping
|
|
74
107
|
def encoding
|
|
75
|
-
@encoding ||= build_standard_encoding
|
|
108
|
+
@encoding_override || (@encoding ||= build_standard_encoding)
|
|
76
109
|
end
|
|
77
110
|
|
|
78
111
|
# Iterate over all charstrings
|
|
@@ -2,6 +2,10 @@
|
|
|
2
2
|
|
|
3
3
|
module Fontisan
|
|
4
4
|
module Type1
|
|
5
|
+
# Error raised when a seac reference cannot be resolved (the referenced
|
|
6
|
+
# glyph is missing, or a cycle is detected in nested seac).
|
|
7
|
+
class SeacReferenceError < Fontisan::Error; end
|
|
8
|
+
|
|
5
9
|
# Expands Type 1 seac composite glyphs into base + accent outlines
|
|
6
10
|
#
|
|
7
11
|
# [`SeacExpander`](lib/fontisan/type1/seac_expander.rb) handles the
|
|
@@ -9,6 +13,10 @@ module Fontisan
|
|
|
9
13
|
# The seac operator combines two glyphs (a base character and an accent)
|
|
10
14
|
# with a positioning offset, which must be decomposed for CFF conversion.
|
|
11
15
|
#
|
|
16
|
+
# Supports nested seac: if a base or accent glyph is itself a seac
|
|
17
|
+
# composite, it is recursively expanded. Cycles are detected and raise
|
|
18
|
+
# {SeacReferenceError}.
|
|
19
|
+
#
|
|
12
20
|
# The seac operator format is:
|
|
13
21
|
# ```
|
|
14
22
|
# seac asb adx ady bchar achar
|
|
@@ -28,6 +36,8 @@ module Fontisan
|
|
|
28
36
|
#
|
|
29
37
|
# @see https://www.adobe.com/devnet/font/pdfs/Type1.pdf
|
|
30
38
|
class SeacExpander
|
|
39
|
+
MAX_DEPTH = 16
|
|
40
|
+
|
|
31
41
|
# @return [CharStrings] Type 1 CharStrings dictionary
|
|
32
42
|
attr_reader :charstrings
|
|
33
43
|
|
|
@@ -43,67 +53,15 @@ module Fontisan
|
|
|
43
53
|
@private_dict = private_dict
|
|
44
54
|
end
|
|
45
55
|
|
|
46
|
-
# Decompose a seac composite glyph into base + accent outlines
|
|
47
|
-
#
|
|
48
|
-
# This method:
|
|
49
|
-
# 1. Parses the seac operator to extract components
|
|
50
|
-
# 2. Gets CharStrings for base and accent glyphs
|
|
51
|
-
# 3. Parses both CharStrings into outline commands
|
|
52
|
-
# 4. Transforms the accent by (adx, ady) offset
|
|
53
|
-
# 5. Merges base and accent outlines into a single path
|
|
54
|
-
# 6. Returns the decomposed CharString data
|
|
56
|
+
# Decompose a seac composite glyph into base + accent outlines.
|
|
57
|
+
# Handles nested seac recursively.
|
|
55
58
|
#
|
|
56
59
|
# @param glyph_name [String] Name of the composite glyph to decompose
|
|
57
60
|
# @return [String, nil] Decomposed CharString bytecode, or nil if not a seac composite
|
|
58
|
-
# @raise [
|
|
59
|
-
#
|
|
60
|
-
# @example Decompose "Agrave" glyph
|
|
61
|
-
# expander.decompose("Agrave")
|
|
61
|
+
# @raise [SeacReferenceError] If base or accent glyphs are not found,
|
|
62
|
+
# or if a cycle is detected
|
|
62
63
|
def decompose(glyph_name)
|
|
63
|
-
|
|
64
|
-
return nil unless components
|
|
65
|
-
|
|
66
|
-
# Use the encoding map to lookup glyph names from character codes
|
|
67
|
-
base_glyph_name = @charstrings.encoding[components[:base]]
|
|
68
|
-
accent_glyph_name = @charstrings.encoding[components[:accent]]
|
|
69
|
-
|
|
70
|
-
if base_glyph_name.nil?
|
|
71
|
-
raise Fontisan::Error,
|
|
72
|
-
"Base glyph for char code #{components[:base]} not found"
|
|
73
|
-
end
|
|
74
|
-
|
|
75
|
-
if accent_glyph_name.nil?
|
|
76
|
-
raise Fontisan::Error,
|
|
77
|
-
"Accent glyph for char code #{components[:accent]} not found"
|
|
78
|
-
end
|
|
79
|
-
|
|
80
|
-
# Get CharStrings for base and accent
|
|
81
|
-
base_charstring = @charstrings[base_glyph_name]
|
|
82
|
-
accent_charstring = @charstrings[accent_glyph_name]
|
|
83
|
-
|
|
84
|
-
if base_charstring.nil?
|
|
85
|
-
raise Fontisan::Error,
|
|
86
|
-
"CharString not found for base glyph #{base_glyph_name}"
|
|
87
|
-
end
|
|
88
|
-
|
|
89
|
-
if accent_charstring.nil?
|
|
90
|
-
raise Fontisan::Error,
|
|
91
|
-
"CharString not found for accent glyph #{accent_glyph_name}"
|
|
92
|
-
end
|
|
93
|
-
|
|
94
|
-
# Parse both CharStrings into command sequences
|
|
95
|
-
base_commands = parse_charstring_to_commands(base_charstring)
|
|
96
|
-
accent_commands = parse_charstring_to_commands(accent_charstring)
|
|
97
|
-
|
|
98
|
-
# Transform accent by (adx, ady) offset
|
|
99
|
-
accent_commands = transform_commands(accent_commands, components[:adx],
|
|
100
|
-
components[:ady])
|
|
101
|
-
|
|
102
|
-
# Merge base and accent commands
|
|
103
|
-
merged_commands = merge_outline_commands(base_commands, accent_commands)
|
|
104
|
-
|
|
105
|
-
# Convert merged commands back to CharString bytecode
|
|
106
|
-
generate_charstring(merged_commands)
|
|
64
|
+
decompose_with_visited(glyph_name, Set.new)
|
|
107
65
|
end
|
|
108
66
|
|
|
109
67
|
# Check if a glyph is a seac composite
|
|
@@ -123,6 +81,59 @@ module Fontisan
|
|
|
123
81
|
|
|
124
82
|
private
|
|
125
83
|
|
|
84
|
+
# Recursive decomposition with cycle detection via +visited+ set.
|
|
85
|
+
def decompose_with_visited(glyph_name, visited)
|
|
86
|
+
return nil unless composite?(glyph_name)
|
|
87
|
+
|
|
88
|
+
if visited.include?(glyph_name)
|
|
89
|
+
raise SeacReferenceError,
|
|
90
|
+
"Cycle detected in seac references: #{visited.to_a.join(' → ')} → #{glyph_name}"
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
if visited.size >= MAX_DEPTH
|
|
94
|
+
raise SeacReferenceError,
|
|
95
|
+
"seac nesting depth exceeds #{MAX_DEPTH} at #{glyph_name}"
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
visited_next = visited.dup.add(glyph_name)
|
|
99
|
+
components = @charstrings.components_for(glyph_name)
|
|
100
|
+
|
|
101
|
+
base_name = resolve_char_code(components[:base], "base", glyph_name)
|
|
102
|
+
accent_name = resolve_char_code(components[:accent], "accent", glyph_name)
|
|
103
|
+
|
|
104
|
+
base_commands = resolve_glyph_commands(base_name, visited_next)
|
|
105
|
+
accent_commands = resolve_glyph_commands(accent_name, visited_next)
|
|
106
|
+
accent_commands = transform_commands(accent_commands, components[:adx],
|
|
107
|
+
components[:ady])
|
|
108
|
+
merged = merge_outline_commands(base_commands, accent_commands)
|
|
109
|
+
generate_charstring(merged)
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# Resolve a char code to a glyph name via the font's encoding.
|
|
113
|
+
def resolve_char_code(char_code, role, parent)
|
|
114
|
+
name = @charstrings.encoding[char_code]
|
|
115
|
+
return name if name
|
|
116
|
+
|
|
117
|
+
raise SeacReferenceError,
|
|
118
|
+
"#{role} glyph for char code #{char_code} (in #{parent}) not found in encoding"
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
# Get outline commands for a glyph, recursively expanding nested seac.
|
|
122
|
+
def resolve_glyph_commands(glyph_name, visited)
|
|
123
|
+
charstring = @charstrings[glyph_name]
|
|
124
|
+
if charstring.nil?
|
|
125
|
+
raise SeacReferenceError,
|
|
126
|
+
"CharString not found for glyph #{glyph_name}"
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
if composite?(glyph_name)
|
|
130
|
+
nested = decompose_with_visited(glyph_name, visited)
|
|
131
|
+
parse_charstring_to_commands(nested)
|
|
132
|
+
else
|
|
133
|
+
parse_charstring_to_commands(charstring)
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
|
|
126
137
|
# Parse Type 1 CharString into drawing commands
|
|
127
138
|
#
|
|
128
139
|
# Converts Type 1 CharString bytecode into a list of drawing commands
|
|
@@ -33,42 +33,143 @@ module Fontisan
|
|
|
33
33
|
|
|
34
34
|
# @param font [Fontisan::Ufo::Font]
|
|
35
35
|
# @param glyphs [Array<Fontisan::Ufo::Glyph>] in gid order
|
|
36
|
+
# @param masters [Array<Hash>, nil] variation masters for variable
|
|
37
|
+
# CFF2. Each hash: { font:, axes: }. When present, charstrings
|
|
38
|
+
# emit blend operators and a VariationStore is embedded.
|
|
39
|
+
# @param axis_count [Integer, nil] number of variation axes
|
|
36
40
|
# @param variation_store [String, nil] ItemVariationStore bytes
|
|
37
41
|
# for variable CFF2 fonts. When present, embedded between the
|
|
38
42
|
# GlobalSubr INDEX and CharStrings INDEX, and referenced from
|
|
39
43
|
# the Top DICT via operator 24.
|
|
40
44
|
# @return [String] CFF2 table bytes
|
|
41
|
-
def self.build(
|
|
45
|
+
def self.build(font, glyphs:, masters: nil, axis_count: nil,
|
|
46
|
+
variation_store: nil)
|
|
47
|
+
if masters&.any?
|
|
48
|
+
build_variable(font, glyphs, masters, axis_count)
|
|
49
|
+
else
|
|
50
|
+
build_static(font, glyphs, variation_store:)
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Build a static CFF2 table (no variation data).
|
|
55
|
+
def self.build_static(font, glyphs, variation_store:)
|
|
42
56
|
charstrings = glyphs.map { |g| charstring_for(g) }
|
|
43
57
|
global_subr_index = empty_global_subr_index
|
|
44
58
|
font_dict = build_font_dict(private_size: 0, private_offset: 0)
|
|
45
59
|
font_dict_index = Tables::Cff2::IndexBuilder.build([font_dict])
|
|
46
60
|
vs_bytes = variation_store&.b
|
|
47
61
|
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
variation_store_offset: vs_bytes ? 0 : nil
|
|
62
|
+
layout = converge_layout(
|
|
63
|
+
charstrings:, global_subr_index:, font_dict_index:,
|
|
64
|
+
variation_store: vs_bytes
|
|
52
65
|
)
|
|
53
|
-
layout
|
|
54
|
-
|
|
66
|
+
assemble(layout:)
|
|
67
|
+
end
|
|
55
68
|
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
69
|
+
# Build a variable CFF2 table with blend operators and a VStore.
|
|
70
|
+
#
|
|
71
|
+
# Each glyph gets a variable charstring with blend operators when
|
|
72
|
+
# master outlines are available for it. Glyphs without masters fall
|
|
73
|
+
# back to static charstrings. The VStore contains one region per
|
|
74
|
+
# master, each peaking at the master's axis location.
|
|
75
|
+
def self.build_variable(font, glyphs, masters, axis_count)
|
|
76
|
+
num_regions = masters.size
|
|
77
|
+
vstore = build_variation_store(masters, axis_count)
|
|
65
78
|
|
|
66
|
-
|
|
79
|
+
master_glyph_sets = masters.map do |m|
|
|
80
|
+
{ font: m[:font], by_name: m[:font].glyphs }
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
charstrings = glyphs.map.with_index do |glyph, _idx|
|
|
84
|
+
master_outlines = master_glyph_sets.filter_map do |mg|
|
|
85
|
+
mg[:by_name][glyph.name]&.to_outline
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
if master_outlines.size == num_regions && glyph.contours.any?
|
|
89
|
+
Tables::Cff::Cff2CharStringBuilder.build_variable(
|
|
90
|
+
glyph.to_outline,
|
|
91
|
+
master_outlines: master_outlines,
|
|
92
|
+
num_regions: num_regions,
|
|
93
|
+
width: glyph.width.to_i,
|
|
94
|
+
)
|
|
95
|
+
else
|
|
96
|
+
charstring_for(glyph)
|
|
97
|
+
end
|
|
67
98
|
end
|
|
68
99
|
|
|
100
|
+
global_subr_index = empty_global_subr_index
|
|
101
|
+
font_dict = build_font_dict(private_size: 0, private_offset: 0)
|
|
102
|
+
font_dict_index = Tables::Cff2::IndexBuilder.build([font_dict])
|
|
103
|
+
|
|
104
|
+
layout = converge_layout(
|
|
105
|
+
charstrings:, global_subr_index:, font_dict_index:,
|
|
106
|
+
variation_store: vstore
|
|
107
|
+
)
|
|
69
108
|
assemble(layout:)
|
|
70
109
|
end
|
|
71
110
|
|
|
111
|
+
# Build the ItemVariationStore for CFF2: one region per master,
|
|
112
|
+
# each peaking at the master's axis location.
|
|
113
|
+
def self.build_variation_store(masters, axis_count)
|
|
114
|
+
ac = axis_count || masters.first&.dig(:axes)&.size || 1
|
|
115
|
+
regions = masters.each_with_index.map do |_master, midx|
|
|
116
|
+
Array.new(ac) do |aidx|
|
|
117
|
+
if aidx == midx
|
|
118
|
+
{ start: -1.0, peak: 1.0, end: 1.0 }
|
|
119
|
+
else
|
|
120
|
+
{ start: -1.0, peak: 0.0, end: 1.0 }
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
region_list = serialize_region_list(regions, ac)
|
|
126
|
+
# One ItemVariationData with all regions referenced but zero items.
|
|
127
|
+
# The actual deltas live in charstrings (pushed via blend operands).
|
|
128
|
+
var_data = serialize_empty_variation_data(regions.size)
|
|
129
|
+
|
|
130
|
+
header_size = 8
|
|
131
|
+
offsets_array_size = 4
|
|
132
|
+
region_list_offset = header_size + offsets_array_size
|
|
133
|
+
var_data_offset = region_list_offset + region_list.bytesize
|
|
134
|
+
|
|
135
|
+
store = +""
|
|
136
|
+
store << [1].pack("n")
|
|
137
|
+
store << [region_list_offset].pack("N")
|
|
138
|
+
store << [1].pack("n")
|
|
139
|
+
store << [var_data_offset].pack("N")
|
|
140
|
+
store << region_list
|
|
141
|
+
store << var_data
|
|
142
|
+
store
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def self.serialize_region_list(regions, axis_count)
|
|
146
|
+
io = +""
|
|
147
|
+
io << [axis_count].pack("n")
|
|
148
|
+
io << [regions.size].pack("n")
|
|
149
|
+
regions.each do |region|
|
|
150
|
+
region.each do |coords|
|
|
151
|
+
io << [f2dot14(coords[:start]), f2dot14(coords[:peak]),
|
|
152
|
+
f2dot14(coords[:end])].pack("nnn")
|
|
153
|
+
end
|
|
154
|
+
end
|
|
155
|
+
io
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
# Serialize an ItemVariationData with all regions referenced but
|
|
159
|
+
# zero items. CFF2 charstrings push their own deltas via blend.
|
|
160
|
+
def self.serialize_empty_variation_data(region_count)
|
|
161
|
+
io = +""
|
|
162
|
+
io << [0].pack("n") # itemCount = 0
|
|
163
|
+
io << [region_count].pack("n") # shortDeltaCount
|
|
164
|
+
io << [region_count].pack("n") # regionIndexCount
|
|
165
|
+
region_count.times { |i| io << [i].pack("n") }
|
|
166
|
+
io
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
def self.f2dot14(value)
|
|
170
|
+
(value.to_f * 16384).to_i.clamp(-16384, 16384)
|
|
171
|
+
end
|
|
172
|
+
|
|
72
173
|
# ---------- charstring per-glyph ----------
|
|
73
174
|
|
|
74
175
|
def self.charstring_for(glyph)
|
|
@@ -148,6 +249,43 @@ module Fontisan
|
|
|
148
249
|
}
|
|
149
250
|
end
|
|
150
251
|
|
|
252
|
+
# Iteratively encode the Top DICT and recompute offsets until the
|
|
253
|
+
# layout stabilizes. After convergence, performs one final
|
|
254
|
+
# encode + compute pass so +layout[:top_dict]+ carries the
|
|
255
|
+
# converged offsets (not the stale initial placeholder).
|
|
256
|
+
def self.converge_layout(charstrings:, global_subr_index:,
|
|
257
|
+
font_dict_index:, variation_store: nil)
|
|
258
|
+
has_vs = !variation_store.nil?
|
|
259
|
+
top_dict = encode_top_dict(
|
|
260
|
+
charstrings_offset: 0, font_dict_index_offset: 0,
|
|
261
|
+
variation_store_offset: has_vs ? 0 : nil
|
|
262
|
+
)
|
|
263
|
+
layout = compute_layout(top_dict:, charstrings:, global_subr_index:,
|
|
264
|
+
font_dict_index:, variation_store:)
|
|
265
|
+
|
|
266
|
+
10.times do
|
|
267
|
+
top_dict = encode_top_dict(
|
|
268
|
+
charstrings_offset: layout[:charstrings_offset],
|
|
269
|
+
font_dict_index_offset: layout[:font_dict_index_offset],
|
|
270
|
+
variation_store_offset: layout[:variation_store_offset],
|
|
271
|
+
)
|
|
272
|
+
next_layout = compute_layout(top_dict:, charstrings:, global_subr_index:,
|
|
273
|
+
font_dict_index:, variation_store:)
|
|
274
|
+
break if same_offsets?(layout, next_layout)
|
|
275
|
+
|
|
276
|
+
layout = next_layout
|
|
277
|
+
end
|
|
278
|
+
|
|
279
|
+
# Final encode ensures layout[:top_dict] matches converged offsets
|
|
280
|
+
final_td = encode_top_dict(
|
|
281
|
+
charstrings_offset: layout[:charstrings_offset],
|
|
282
|
+
font_dict_index_offset: layout[:font_dict_index_offset],
|
|
283
|
+
variation_store_offset: layout[:variation_store_offset],
|
|
284
|
+
)
|
|
285
|
+
compute_layout(top_dict: final_td, charstrings:, global_subr_index:,
|
|
286
|
+
font_dict_index:, variation_store:)
|
|
287
|
+
end
|
|
288
|
+
|
|
151
289
|
def self.same_offsets?(a, b)
|
|
152
290
|
a[:charstrings_offset] == b[:charstrings_offset] &&
|
|
153
291
|
a[:font_dict_index_offset] == b[:font_dict_index_offset] &&
|
|
@@ -173,8 +311,12 @@ module Fontisan
|
|
|
173
311
|
|
|
174
312
|
private_class_method :charstring_for, :empty_charstring,
|
|
175
313
|
:encode_top_dict, :build_font_dict,
|
|
176
|
-
:compute_layout, :
|
|
177
|
-
:
|
|
314
|
+
:compute_layout, :converge_layout,
|
|
315
|
+
:same_offsets?,
|
|
316
|
+
:empty_global_subr_index, :assemble,
|
|
317
|
+
:build_static, :build_variable,
|
|
318
|
+
:build_variation_store, :serialize_region_list,
|
|
319
|
+
:serialize_empty_variation_data, :f2dot14
|
|
178
320
|
end
|
|
179
321
|
end
|
|
180
322
|
end
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Fontisan
|
|
4
|
+
module Ufo
|
|
5
|
+
module Compile
|
|
6
|
+
# Minimal Adobe FEA (features.fea) parser. Extracts the two most
|
|
7
|
+
# common feature constructs from UFO sources:
|
|
8
|
+
#
|
|
9
|
+
# feature liga { sub f i by fi; } liga; → LigatureSubst
|
|
10
|
+
# feature kern { pos A V -50; } kern; → PairPos
|
|
11
|
+
#
|
|
12
|
+
# Unsupported constructs are collected and surfaced via
|
|
13
|
+
# {ParsedFeatures#unsupported} so the caller can decide whether
|
|
14
|
+
# to warn or raise. This is the "limited fea subset" approach
|
|
15
|
+
# from TODO #10b — expand as real-world fonts demand.
|
|
16
|
+
#
|
|
17
|
+
# The parser is deliberately simple: it tokenizes on whitespace
|
|
18
|
+
# and semicolons, recognizes `feature <tag> { ... } <tag>;` blocks,
|
|
19
|
+
# and dispatches per-statement to registered rule handlers (OCP —
|
|
20
|
+
# new rule kinds are one handler addition).
|
|
21
|
+
#
|
|
22
|
+
# @example
|
|
23
|
+
# parsed = FeatureCompiler.parse(fea_text)
|
|
24
|
+
# parsed.ligatures_for("liga") # => [{sequence: ["f", "i"], result: "fi"}]
|
|
25
|
+
class FeatureCompiler
|
|
26
|
+
# @param text [String] features.fea content
|
|
27
|
+
# @return [ParsedFeatures]
|
|
28
|
+
def self.parse(text)
|
|
29
|
+
new.parse(text)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# @param text [String]
|
|
33
|
+
# @return [ParsedFeatures]
|
|
34
|
+
def parse(text)
|
|
35
|
+
@parsed = ParsedFeatures.new
|
|
36
|
+
tokenize_blocks(text || "")
|
|
37
|
+
@parsed
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
private
|
|
41
|
+
|
|
42
|
+
# Split text into `feature <tag> { body } <tag>;` blocks and
|
|
43
|
+
# pass each block body to the statement dispatcher.
|
|
44
|
+
def tokenize_blocks(text)
|
|
45
|
+
# Strip comments (# to end of line)
|
|
46
|
+
cleaned = text.gsub(%r{#[^\n]*}, "")
|
|
47
|
+
# Match feature blocks: feature TAG { ... } TAG;
|
|
48
|
+
cleaned.scan(/feature\s+([A-Za-z0-9]{4})\s*\{([^}]*)\}\s*\1\s*;/m) do |tag, body|
|
|
49
|
+
parse_body(tag, body)
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Parse statements within a feature block.
|
|
54
|
+
def parse_body(tag, body)
|
|
55
|
+
statements = body.split(";").map(&:strip).reject(&:empty?)
|
|
56
|
+
statements.each do |stmt|
|
|
57
|
+
tokens = stmt.split(/\s+/)
|
|
58
|
+
dispatch(tag, tokens, stmt)
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Dispatch a statement to the appropriate handler based on the
|
|
63
|
+
# leading keyword. Unknown statements are collected as unsupported.
|
|
64
|
+
def dispatch(tag, tokens, raw)
|
|
65
|
+
case tokens.first
|
|
66
|
+
when "sub" then handle_sub(tag, tokens)
|
|
67
|
+
when "pos" then handle_pos(tag, tokens)
|
|
68
|
+
else
|
|
69
|
+
@parsed.add_unsupported(tag, raw)
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# `sub A B C by ABC;` → ligature substitution
|
|
74
|
+
def handle_sub(tag, tokens)
|
|
75
|
+
# tokens: ["sub", "A", "B", ..., "by", "ABC"]
|
|
76
|
+
by_idx = tokens.index("by")
|
|
77
|
+
unless by_idx && by_idx > 1 && tokens.size > by_idx + 1
|
|
78
|
+
@parsed.add_unsupported(tag, tokens.join(" "))
|
|
79
|
+
return
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
sequence = tokens[1, by_idx - 1]
|
|
83
|
+
result = tokens[by_idx + 1]
|
|
84
|
+
return if sequence.nil? || sequence.empty? || result.nil?
|
|
85
|
+
|
|
86
|
+
if sequence.size == 1
|
|
87
|
+
# Single substitution: `sub A by A.alt;` — unsupported for now
|
|
88
|
+
@parsed.add_unsupported(tag, tokens.join(" "))
|
|
89
|
+
return
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
@parsed.add_ligature(tag, sequence: sequence, result: result)
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# `pos A B -50;` → pair positioning
|
|
96
|
+
def handle_pos(tag, tokens)
|
|
97
|
+
# tokens: ["pos", "A", "B", "-50"] or ["pos", "A", "B", "<", "0", "-50", "0", ">"]
|
|
98
|
+
return unless tokens.size >= 4
|
|
99
|
+
|
|
100
|
+
left = tokens[1]
|
|
101
|
+
right = tokens[2]
|
|
102
|
+
value = parse_pos_value(tokens[3])
|
|
103
|
+
return unless value
|
|
104
|
+
|
|
105
|
+
@parsed.add_pair(tag, left:, right:, value:)
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# Parse a positioning value: either a bare integer or the start
|
|
109
|
+
# of a ValueRecord (we only handle the bare integer case for now).
|
|
110
|
+
def parse_pos_value(token)
|
|
111
|
+
Integer(token)
|
|
112
|
+
rescue ArgumentError
|
|
113
|
+
nil
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# Result of parsing features.fea. Aggregates ligature and pair
|
|
118
|
+
# rules by feature tag, plus any unsupported statements.
|
|
119
|
+
class ParsedFeatures
|
|
120
|
+
attr_reader :ligatures, :pairs, :unsupported
|
|
121
|
+
|
|
122
|
+
def initialize
|
|
123
|
+
@ligatures = Hash.new { |h, k| h[k] = [] }
|
|
124
|
+
@pairs = Hash.new { |h, k| h[k] = [] }
|
|
125
|
+
@unsupported = Hash.new { |h, k| h[k] = [] }
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
# @param tag [String] feature tag
|
|
129
|
+
# @return [Array<Hash>] ligature rules: {sequence: [names], result: name}
|
|
130
|
+
def ligatures_for(tag)
|
|
131
|
+
@ligatures[tag] || []
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
# @param tag [String] feature tag
|
|
135
|
+
# @return [Array<Hash>] pair rules: {left:, right:, value:}
|
|
136
|
+
def pairs_for(tag)
|
|
137
|
+
@pairs[tag] || []
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
# @return [Boolean] true if no features were parsed
|
|
141
|
+
def empty?
|
|
142
|
+
@ligatures.empty? && @pairs.empty?
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
# ---- mutation (used by FeatureCompiler) ----
|
|
146
|
+
|
|
147
|
+
def add_ligature(tag, sequence:, result:)
|
|
148
|
+
@ligatures[tag] << { sequence: sequence, result: result }
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def add_pair(tag, left:, right:, value:)
|
|
152
|
+
@pairs[tag] << { left: left, right: right, value: value }
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
def add_unsupported(tag, statement)
|
|
156
|
+
@unsupported[tag] << statement
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
end
|