fontisan 0.4.33 → 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 +4 -4
- 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/type1/charstrings.rb +34 -1
- data/lib/fontisan/type1/seac_expander.rb +68 -57
- data/lib/fontisan/ufo/compile/feature_compiler.rb +161 -0
- data/lib/fontisan/ufo/compile/gsub.rb +267 -0
- 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 +5 -1
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "digest"
|
|
4
|
+
require "time"
|
|
5
|
+
|
|
6
|
+
module Fontisan
|
|
7
|
+
module Commands
|
|
8
|
+
# Produces a structured {Models::AuditReport} for a single font or
|
|
9
|
+
# an array of reports for every face in a collection.
|
|
10
|
+
#
|
|
11
|
+
# The audit report is MECE with ucode: fontisan owns the font-identity
|
|
12
|
+
# axis (name table, style metadata, OpenType layout); ucode owns the
|
|
13
|
+
# Unicode-coverage axis (UCD parsing, block/script aggregation). The
|
|
14
|
+
# audit command emits the raw codepoint list so a consumer can run
|
|
15
|
+
# ucode separately without re-reading the font.
|
|
16
|
+
#
|
|
17
|
+
# Delegates data extraction to existing sub-commands and table readers
|
|
18
|
+
# — no table re-parsing happens here. This keeps the audit command a
|
|
19
|
+
# thin orchestration layer (DRY, single source of truth).
|
|
20
|
+
#
|
|
21
|
+
# @example Single font
|
|
22
|
+
# cmd = AuditCommand.new("Inter.ttf", include_codepoints: true)
|
|
23
|
+
# cmd.run # => Models::AuditReport
|
|
24
|
+
#
|
|
25
|
+
# @example Collection
|
|
26
|
+
# cmd = AuditCommand.new("Inter.ttc")
|
|
27
|
+
# cmd.run # => Array<Models::AuditReport>
|
|
28
|
+
class AuditCommand < BaseCommand
|
|
29
|
+
# @return [Models::AuditReport, Array<Models::AuditReport>]
|
|
30
|
+
def run
|
|
31
|
+
if FontLoader.collection?(@font_path)
|
|
32
|
+
audit_collection
|
|
33
|
+
else
|
|
34
|
+
audit_single_font(font_index: nil, num_fonts: 1)
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
private
|
|
39
|
+
|
|
40
|
+
# ------------------------------------------------------------------
|
|
41
|
+
# Collection dispatch
|
|
42
|
+
# ------------------------------------------------------------------
|
|
43
|
+
|
|
44
|
+
def audit_collection
|
|
45
|
+
collection = FontLoader.load_collection(@font_path)
|
|
46
|
+
num = collection.num_fonts
|
|
47
|
+
Array.new(num) do |idx|
|
|
48
|
+
face = load_face(idx)
|
|
49
|
+
build_report(face, font_index: idx, num_fonts: num)
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def audit_single_font(font_index:, num_fonts:)
|
|
54
|
+
build_report(font, font_index: font_index, num_fonts: num_fonts)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Reload a single face from a collection by index, with full tables
|
|
58
|
+
# so the audit is complete.
|
|
59
|
+
def load_face(idx)
|
|
60
|
+
FontLoader.load(@font_path, font_index: idx, mode: LoadingModes::FULL)
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# ------------------------------------------------------------------
|
|
64
|
+
# Report assembly
|
|
65
|
+
# ------------------------------------------------------------------
|
|
66
|
+
|
|
67
|
+
# @param face [SfntFont] loaded font
|
|
68
|
+
# @param font_index [Integer, nil]
|
|
69
|
+
# @param num_fonts [Integer]
|
|
70
|
+
# @return [Models::AuditReport]
|
|
71
|
+
def build_report(face, font_index:, num_fonts:)
|
|
72
|
+
Models::AuditReport.new.tap do |r|
|
|
73
|
+
populate_provenance(r, font_index:, num_fonts:)
|
|
74
|
+
populate_identity(r, face)
|
|
75
|
+
populate_style(r, face)
|
|
76
|
+
populate_coverage(r, face)
|
|
77
|
+
populate_layout(r, face)
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# ------------------------------------------------------------------
|
|
82
|
+
# Provenance
|
|
83
|
+
# ------------------------------------------------------------------
|
|
84
|
+
|
|
85
|
+
def populate_provenance(report, font_index:, num_fonts:)
|
|
86
|
+
report.generated_at = Time.now.utc.iso8601
|
|
87
|
+
report.fontisan_version = VERSION
|
|
88
|
+
report.source_file = File.basename(@font_path)
|
|
89
|
+
report.source_sha256 = Digest::SHA256.file(@font_path).hexdigest
|
|
90
|
+
report.source_format = detect_source_format
|
|
91
|
+
report.font_index = font_index
|
|
92
|
+
report.num_fonts_in_source = num_fonts
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def detect_source_format
|
|
96
|
+
fmt = FontLoader.detect_format(@font_path)
|
|
97
|
+
fmt ? fmt.to_s : "unknown"
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# ------------------------------------------------------------------
|
|
101
|
+
# Identity (name table)
|
|
102
|
+
# ------------------------------------------------------------------
|
|
103
|
+
|
|
104
|
+
def populate_identity(report, face)
|
|
105
|
+
return unless face.has_table?(Constants::NAME_TAG)
|
|
106
|
+
|
|
107
|
+
name_table = face.table(Constants::NAME_TAG)
|
|
108
|
+
report.family_name = name_table.english_name(Tables::Name::FAMILY)
|
|
109
|
+
report.subfamily_name = name_table.english_name(Tables::Name::SUBFAMILY)
|
|
110
|
+
report.full_name = name_table.english_name(Tables::Name::FULL_NAME)
|
|
111
|
+
report.postscript_name =
|
|
112
|
+
name_table.english_name(Tables::Name::POSTSCRIPT_NAME)
|
|
113
|
+
report.version = name_table.english_name(Tables::Name::VERSION)
|
|
114
|
+
|
|
115
|
+
return unless face.has_table?(Constants::HEAD_TAG)
|
|
116
|
+
|
|
117
|
+
report.font_revision = face.table(Constants::HEAD_TAG).font_revision
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# ------------------------------------------------------------------
|
|
121
|
+
# Style (OS/2, head, fvar)
|
|
122
|
+
# ------------------------------------------------------------------
|
|
123
|
+
|
|
124
|
+
def populate_style(report, face)
|
|
125
|
+
report.is_variable = false
|
|
126
|
+
report.italic = false
|
|
127
|
+
report.bold = false
|
|
128
|
+
populate_os2_style(report, face) if face.has_table?(Constants::OS2_TAG)
|
|
129
|
+
populate_head_style(report, face) if face.has_table?(Constants::HEAD_TAG)
|
|
130
|
+
populate_fvar_style(report, face) if face.has_table?(Constants::FVAR_TAG)
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def populate_os2_style(report, face)
|
|
134
|
+
os2 = face.table(Constants::OS2_TAG)
|
|
135
|
+
report.weight_class = os2.us_weight_class
|
|
136
|
+
report.width_class = os2.us_width_class
|
|
137
|
+
report.italic = os2.fs_selection && (os2.fs_selection & 0x01).positive?
|
|
138
|
+
report.bold = os2.fs_selection && (os2.fs_selection & 0x20).positive?
|
|
139
|
+
report.panose = format_panose(os2.panose)
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def populate_head_style(report, face)
|
|
143
|
+
head = face.table(Constants::HEAD_TAG)
|
|
144
|
+
mac_style = head.mac_style || 0
|
|
145
|
+
# head macStyle overrides OS/2 fsSelection only when OS/2 didn't set it
|
|
146
|
+
report.italic = true if (mac_style & 0x02).positive?
|
|
147
|
+
report.bold = true if (mac_style & 0x01).positive?
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
def populate_fvar_style(report, face)
|
|
151
|
+
report.is_variable = true
|
|
152
|
+
fvar = face.table(Constants::FVAR_TAG)
|
|
153
|
+
report.axes = fvar.axes.map do |axis|
|
|
154
|
+
Models::AuditAxis.new(
|
|
155
|
+
tag: axis.axis_tag,
|
|
156
|
+
min_value: axis.min_value,
|
|
157
|
+
default_value: axis.default_value,
|
|
158
|
+
max_value: axis.max_value,
|
|
159
|
+
name_id: axis.axis_name_id,
|
|
160
|
+
)
|
|
161
|
+
end
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
def format_panose(panose)
|
|
165
|
+
return nil unless panose
|
|
166
|
+
|
|
167
|
+
panose.to_a.join(" ")
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
# ------------------------------------------------------------------
|
|
171
|
+
# Coverage (cmap + glyph count)
|
|
172
|
+
# ------------------------------------------------------------------
|
|
173
|
+
|
|
174
|
+
def populate_coverage(report, face)
|
|
175
|
+
if face.has_table?(Constants::CMAP_TAG)
|
|
176
|
+
cmap = face.table(Constants::CMAP_TAG)
|
|
177
|
+
mappings = cmap.unicode_mappings || {}
|
|
178
|
+
report.total_codepoints = mappings.size
|
|
179
|
+
report.cmap_subtables = cmap.subtable_formats
|
|
180
|
+
report.codepoints = codepoint_list(mappings.keys) if include_codepoints?
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
report.total_glyphs = total_glyphs(face)
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
def codepoint_list(codepoints)
|
|
187
|
+
codepoints.sort.map { |cp| format_codepoint(cp) }
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
def format_codepoint(cp)
|
|
191
|
+
format("U+%04X", cp)
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
def total_glyphs(face)
|
|
195
|
+
return 0 unless face.has_table?(Constants::MAXP_TAG)
|
|
196
|
+
|
|
197
|
+
face.table(Constants::MAXP_TAG)&.num_glyphs || 0
|
|
198
|
+
rescue StandardError
|
|
199
|
+
0
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
def include_codepoints?
|
|
203
|
+
@options.fetch(:include_codepoints, true)
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
# ------------------------------------------------------------------
|
|
207
|
+
# OpenType layout (GSUB/GPOS)
|
|
208
|
+
# ------------------------------------------------------------------
|
|
209
|
+
|
|
210
|
+
def populate_layout(report, face)
|
|
211
|
+
report.opentype_scripts = collect_scripts(face)
|
|
212
|
+
report.features = collect_features(face)
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
def collect_scripts(face)
|
|
216
|
+
scripts = Set.new
|
|
217
|
+
%w[GSUB GPOS].each do |tag|
|
|
218
|
+
next unless face.has_table?(tag)
|
|
219
|
+
|
|
220
|
+
table = face.table(tag)
|
|
221
|
+
scripts.merge(table.scripts) if table
|
|
222
|
+
end
|
|
223
|
+
scripts.sort
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
def collect_features(face)
|
|
227
|
+
features = Set.new
|
|
228
|
+
%w[GSUB GPOS].each do |tag|
|
|
229
|
+
next unless face.has_table?(tag)
|
|
230
|
+
|
|
231
|
+
table = face.table(tag)
|
|
232
|
+
features.merge(collect_features_from_table(table)) if table
|
|
233
|
+
end
|
|
234
|
+
features.sort
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
def collect_features_from_table(layout_table)
|
|
238
|
+
scripts = begin
|
|
239
|
+
layout_table.scripts
|
|
240
|
+
rescue StandardError
|
|
241
|
+
[]
|
|
242
|
+
end
|
|
243
|
+
scripts.flat_map { |script| features_for_script(layout_table, script) }
|
|
244
|
+
rescue StandardError
|
|
245
|
+
[]
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
def features_for_script(layout_table, script)
|
|
249
|
+
|
|
250
|
+
layout_table.features(script_tag: script)
|
|
251
|
+
rescue StandardError
|
|
252
|
+
[]
|
|
253
|
+
|
|
254
|
+
end
|
|
255
|
+
end
|
|
256
|
+
end
|
|
257
|
+
end
|
data/lib/fontisan/commands.rb
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
module Fontisan
|
|
6
6
|
module Commands
|
|
7
7
|
autoload :BaseCommand, "fontisan/commands/base_command"
|
|
8
|
+
autoload :AuditCommand, "fontisan/commands/audit_command"
|
|
8
9
|
autoload :ConvertCommand, "fontisan/commands/convert_command"
|
|
9
10
|
autoload :DumpTableCommand, "fontisan/commands/dump_table_command"
|
|
10
11
|
autoload :ExportCommand, "fontisan/commands/export_command"
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "lutaml/model"
|
|
4
|
+
|
|
5
|
+
module Fontisan
|
|
6
|
+
module Models
|
|
7
|
+
# Variable-font axis descriptor for the audit report.
|
|
8
|
+
class AuditAxis < Lutaml::Model::Serializable
|
|
9
|
+
attribute :tag, :string
|
|
10
|
+
attribute :min_value, :float
|
|
11
|
+
attribute :default_value, :float
|
|
12
|
+
attribute :max_value, :float
|
|
13
|
+
attribute :name_id, :integer
|
|
14
|
+
|
|
15
|
+
json do
|
|
16
|
+
map "tag", to: :tag
|
|
17
|
+
map "min_value", to: :min_value
|
|
18
|
+
map "default_value", to: :default_value
|
|
19
|
+
map "max_value", to: :max_value
|
|
20
|
+
map "name_id", to: :name_id
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
yaml do
|
|
24
|
+
map "tag", to: :tag
|
|
25
|
+
map "min_value", to: :min_value
|
|
26
|
+
map "default_value", to: :default_value
|
|
27
|
+
map "max_value", to: :max_value
|
|
28
|
+
map "name_id", to: :name_id
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Structured font audit report covering identity, style, coverage,
|
|
33
|
+
# and OpenType layout facts. Designed for archival use — each report
|
|
34
|
+
# is self-describing with provenance (generated_at, fontisan_version,
|
|
35
|
+
# source_sha256).
|
|
36
|
+
#
|
|
37
|
+
# The report is a value object: it captures what the font FILE
|
|
38
|
+
# declares, not what any external index says about it.
|
|
39
|
+
class AuditReport < Lutaml::Model::Serializable
|
|
40
|
+
# Provenance
|
|
41
|
+
attribute :generated_at, :string
|
|
42
|
+
attribute :fontisan_version, :string
|
|
43
|
+
attribute :source_file, :string
|
|
44
|
+
attribute :source_sha256, :string
|
|
45
|
+
attribute :source_format, :string
|
|
46
|
+
attribute :font_index, :integer
|
|
47
|
+
attribute :num_fonts_in_source, :integer
|
|
48
|
+
|
|
49
|
+
# Identity
|
|
50
|
+
attribute :family_name, :string
|
|
51
|
+
attribute :subfamily_name, :string
|
|
52
|
+
attribute :full_name, :string
|
|
53
|
+
attribute :postscript_name, :string
|
|
54
|
+
attribute :version, :string
|
|
55
|
+
attribute :font_revision, :float
|
|
56
|
+
|
|
57
|
+
# Style
|
|
58
|
+
attribute :weight_class, :integer
|
|
59
|
+
attribute :width_class, :integer
|
|
60
|
+
attribute :italic, Lutaml::Model::Type::Boolean
|
|
61
|
+
attribute :bold, Lutaml::Model::Type::Boolean
|
|
62
|
+
attribute :panose, :string
|
|
63
|
+
attribute :is_variable, Lutaml::Model::Type::Boolean
|
|
64
|
+
attribute :axes, AuditAxis, collection: true
|
|
65
|
+
|
|
66
|
+
# Coverage
|
|
67
|
+
attribute :total_codepoints, :integer
|
|
68
|
+
attribute :total_glyphs, :integer
|
|
69
|
+
attribute :cmap_subtables, :integer, collection: true
|
|
70
|
+
attribute :codepoints, :string, collection: true
|
|
71
|
+
|
|
72
|
+
# OpenType layout
|
|
73
|
+
attribute :opentype_scripts, :string, collection: true
|
|
74
|
+
attribute :features, :string, collection: true
|
|
75
|
+
|
|
76
|
+
json do
|
|
77
|
+
map "generated_at", to: :generated_at
|
|
78
|
+
map "fontisan_version", to: :fontisan_version
|
|
79
|
+
map "source_file", to: :source_file
|
|
80
|
+
map "source_sha256", to: :source_sha256
|
|
81
|
+
map "source_format", to: :source_format
|
|
82
|
+
map "font_index", to: :font_index
|
|
83
|
+
map "num_fonts_in_source", to: :num_fonts_in_source
|
|
84
|
+
|
|
85
|
+
map "family_name", to: :family_name
|
|
86
|
+
map "subfamily_name", to: :subfamily_name
|
|
87
|
+
map "full_name", to: :full_name
|
|
88
|
+
map "postscript_name", to: :postscript_name
|
|
89
|
+
map "version", to: :version
|
|
90
|
+
map "font_revision", to: :font_revision
|
|
91
|
+
|
|
92
|
+
map "weight_class", to: :weight_class
|
|
93
|
+
map "width_class", to: :width_class
|
|
94
|
+
map "italic", to: :italic
|
|
95
|
+
map "bold", to: :bold
|
|
96
|
+
map "panose", to: :panose
|
|
97
|
+
map "is_variable", to: :is_variable
|
|
98
|
+
map "axes", to: :axes
|
|
99
|
+
|
|
100
|
+
map "total_codepoints", to: :total_codepoints
|
|
101
|
+
map "total_glyphs", to: :total_glyphs
|
|
102
|
+
map "cmap_subtables", to: :cmap_subtables
|
|
103
|
+
map "codepoints", to: :codepoints
|
|
104
|
+
|
|
105
|
+
map "opentype_scripts", to: :opentype_scripts
|
|
106
|
+
map "features", to: :features
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
yaml do
|
|
110
|
+
map "generated_at", to: :generated_at
|
|
111
|
+
map "fontisan_version", to: :fontisan_version
|
|
112
|
+
map "source_file", to: :source_file
|
|
113
|
+
map "source_sha256", to: :source_sha256
|
|
114
|
+
map "source_format", to: :source_format
|
|
115
|
+
map "font_index", to: :font_index
|
|
116
|
+
map "num_fonts_in_source", to: :num_fonts_in_source
|
|
117
|
+
|
|
118
|
+
map "family_name", to: :family_name
|
|
119
|
+
map "subfamily_name", to: :subfamily_name
|
|
120
|
+
map "full_name", to: :full_name
|
|
121
|
+
map "postscript_name", to: :postscript_name
|
|
122
|
+
map "version", to: :version
|
|
123
|
+
map "font_revision", to: :font_revision
|
|
124
|
+
|
|
125
|
+
map "weight_class", to: :weight_class
|
|
126
|
+
map "width_class", to: :width_class
|
|
127
|
+
map "italic", to: :italic
|
|
128
|
+
map "bold", to: :bold
|
|
129
|
+
map "panose", to: :panose
|
|
130
|
+
map "is_variable", to: :is_variable
|
|
131
|
+
map "axes", to: :axes
|
|
132
|
+
|
|
133
|
+
map "total_codepoints", to: :total_codepoints
|
|
134
|
+
map "total_glyphs", to: :total_glyphs
|
|
135
|
+
map "cmap_subtables", to: :cmap_subtables
|
|
136
|
+
map "codepoints", to: :codepoints
|
|
137
|
+
|
|
138
|
+
map "opentype_scripts", to: :opentype_scripts
|
|
139
|
+
map "features", to: :features
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
end
|
data/lib/fontisan/models.rb
CHANGED
|
@@ -6,6 +6,8 @@ module Fontisan
|
|
|
6
6
|
module Models
|
|
7
7
|
autoload :AllScriptsFeaturesInfo,
|
|
8
8
|
"fontisan/models/all_scripts_features_info"
|
|
9
|
+
autoload :AuditAxis, "fontisan/models/audit_report"
|
|
10
|
+
autoload :AuditReport, "fontisan/models/audit_report"
|
|
9
11
|
autoload :AxisInfo, "fontisan/models/variable_font_info"
|
|
10
12
|
autoload :BitmapGlyph, "fontisan/models/bitmap_glyph"
|
|
11
13
|
autoload :BitmapStrike, "fontisan/models/bitmap_strike"
|
|
@@ -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
|