fontisan 0.4.33 → 0.4.35
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 +19 -217
- data/TODO.improvements/README.md +4 -4
- data/lib/fontisan/audit/check.rb +47 -0
- data/lib/fontisan/audit/check_registry.rb +47 -0
- data/lib/fontisan/audit/checks/cmap_check.rb +249 -0
- data/lib/fontisan/audit/checks/collection_integrity_check.rb +102 -0
- data/lib/fontisan/audit/checks/glyph_name_check.rb +107 -0
- data/lib/fontisan/audit/checks/ots_compatibility_check.rb +191 -0
- data/lib/fontisan/audit/checks/table_directory_check.rb +157 -0
- data/lib/fontisan/audit/checks.rb +22 -0
- data/lib/fontisan/audit.rb +20 -0
- data/lib/fontisan/cli.rb +78 -0
- data/lib/fontisan/commands/audit_command.rb +291 -0
- data/lib/fontisan/commands.rb +1 -0
- data/lib/fontisan/models/audit_report.rb +148 -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
- data/lib/fontisan.rb +1 -0
- metadata +14 -1
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Fontisan
|
|
4
|
+
module Audit
|
|
5
|
+
module Checks
|
|
6
|
+
# Validates the SFNT table directory: checksums, 4-byte alignment,
|
|
7
|
+
# offset arithmetic, search-range/entry-selector/range-shift
|
|
8
|
+
# consistency.
|
|
9
|
+
#
|
|
10
|
+
# These checks are the foundation of all font validation — a
|
|
11
|
+
# corrupt table directory makes every downstream parse
|
|
12
|
+
# unreliable. Replaces the table-directory portion of MS Font
|
|
13
|
+
# Validator + ots-sanitize.
|
|
14
|
+
#
|
|
15
|
+
# @see https://learn.microsoft.com/en-us/typography/opentype/spec/otff#table-directory
|
|
16
|
+
class TableDirectoryCheck < Check
|
|
17
|
+
# @param font [SfntFont]
|
|
18
|
+
# @return [Array<Models::ValidationReport::Issue>]
|
|
19
|
+
def self.call(font)
|
|
20
|
+
issues = []
|
|
21
|
+
issues.concat(validate_search_fields(font))
|
|
22
|
+
issues.concat(validate_alignment(font))
|
|
23
|
+
issues.concat(validate_checksums(font))
|
|
24
|
+
issues.concat(validate_offsets(font))
|
|
25
|
+
issues
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def self.code
|
|
29
|
+
:table_directory
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# ---------- search range / entry selector / range shift ----------
|
|
33
|
+
|
|
34
|
+
def self.validate_search_fields(font)
|
|
35
|
+
num_tables = font.header.num_tables
|
|
36
|
+
expected_range = largest_power_of_two_le(num_tables) * 16
|
|
37
|
+
expected_selector = Math.log2(expected_range / 16).to_i
|
|
38
|
+
expected_shift = (num_tables * 16) - expected_range
|
|
39
|
+
|
|
40
|
+
issues = []
|
|
41
|
+
if font.header.search_range != expected_range
|
|
42
|
+
issues << issue(severity: :warning,
|
|
43
|
+
message: "searchRange is #{font.header.search_range} " \
|
|
44
|
+
"but should be #{expected_range} " \
|
|
45
|
+
"(numTables=#{num_tables})",
|
|
46
|
+
location: "header.searchRange")
|
|
47
|
+
end
|
|
48
|
+
if font.header.entry_selector != expected_selector
|
|
49
|
+
issues << issue(severity: :warning,
|
|
50
|
+
message: "entrySelector is #{font.header.entry_selector} " \
|
|
51
|
+
"but should be #{expected_selector}",
|
|
52
|
+
location: "header.entrySelector")
|
|
53
|
+
end
|
|
54
|
+
if font.header.range_shift != expected_shift
|
|
55
|
+
issues << issue(severity: :warning,
|
|
56
|
+
message: "rangeShift is #{font.header.range_shift} " \
|
|
57
|
+
"but should be #{expected_shift}",
|
|
58
|
+
location: "header.rangeShift")
|
|
59
|
+
end
|
|
60
|
+
issues
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def self.largest_power_of_two_le(n)
|
|
64
|
+
return 0 if n <= 0
|
|
65
|
+
|
|
66
|
+
power = 1
|
|
67
|
+
power *= 2 while power * 2 <= n
|
|
68
|
+
power
|
|
69
|
+
end
|
|
70
|
+
private_class_method :largest_power_of_two_le
|
|
71
|
+
|
|
72
|
+
# ---------- 4-byte alignment ----------
|
|
73
|
+
|
|
74
|
+
def self.validate_alignment(font)
|
|
75
|
+
font.tables.each_with_object([]) do |entry, issues|
|
|
76
|
+
next if (entry.offset % 4).zero?
|
|
77
|
+
|
|
78
|
+
issues << issue(severity: :warning,
|
|
79
|
+
message: "Table '#{readable_tag(entry.tag)}' at offset " \
|
|
80
|
+
"#{entry.offset} is not 4-byte aligned",
|
|
81
|
+
location: "tables.#{readable_tag(entry.tag)}.offset")
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# ---------- checksums ----------
|
|
86
|
+
|
|
87
|
+
# The 'head' table checksum has a special rule: its checkSumAdjustment
|
|
88
|
+
# field (offset 8) is set to make the whole-table checksum match the
|
|
89
|
+
# directory entry. We skip the head table's own checksum verification
|
|
90
|
+
# and instead verify the adjustment is present.
|
|
91
|
+
def self.validate_checksums(font)
|
|
92
|
+
font.tables.each_with_object([]) do |entry, issues|
|
|
93
|
+
tag = readable_tag(entry.tag)
|
|
94
|
+
raw = font.table_data[tag]
|
|
95
|
+
next unless raw
|
|
96
|
+
|
|
97
|
+
next if tag == "head" # checkSumAdjustment complicates this
|
|
98
|
+
|
|
99
|
+
actual = checksum(raw)
|
|
100
|
+
next if actual == entry.checksum
|
|
101
|
+
|
|
102
|
+
issues << issue(severity: :error,
|
|
103
|
+
message: "Table '#{tag}' checksum mismatch: " \
|
|
104
|
+
"directory=0x#{entry.checksum.to_s(16).upcase} " \
|
|
105
|
+
"computed=0x#{actual.to_s(16).upcase}",
|
|
106
|
+
location: "tables.#{tag}.checksum")
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# OpenType table checksum: sum of uint32 words, padded to 4 bytes
|
|
111
|
+
# with zeros if the table length isn't a multiple of 4.
|
|
112
|
+
def self.checksum(data)
|
|
113
|
+
remainder = data.bytesize % 4
|
|
114
|
+
padded_bytes = remainder.zero? ? data : data + "\x00".b * (4 - remainder)
|
|
115
|
+
padded_bytes.unpack("N*").sum & 0xFFFFFFFF
|
|
116
|
+
rescue StandardError
|
|
117
|
+
0
|
|
118
|
+
end
|
|
119
|
+
private_class_method :checksum
|
|
120
|
+
|
|
121
|
+
# ---------- offset arithmetic ----------
|
|
122
|
+
|
|
123
|
+
def self.validate_offsets(font)
|
|
124
|
+
dir_end = 12 + (font.header.num_tables * 16)
|
|
125
|
+
font.tables.each_with_object([]) do |entry, issues|
|
|
126
|
+
tag = readable_tag(entry.tag)
|
|
127
|
+
|
|
128
|
+
if entry.offset < dir_end
|
|
129
|
+
issues << issue(severity: :error,
|
|
130
|
+
message: "Table '#{tag}' offset #{entry.offset} " \
|
|
131
|
+
"overlaps the table directory " \
|
|
132
|
+
"(directory ends at #{dir_end})",
|
|
133
|
+
location: "tables.#{tag}.offset")
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
raw = font.table_data[tag]
|
|
137
|
+
if raw && raw.bytesize != entry.table_length
|
|
138
|
+
issues << issue(severity: :error,
|
|
139
|
+
message: "Table '#{tag}' length mismatch: " \
|
|
140
|
+
"directory says #{entry.table_length} " \
|
|
141
|
+
"but actual data is #{raw.bytesize} bytes",
|
|
142
|
+
location: "tables.#{tag}.table_length")
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
# BinData returns ASCII-8BIT tag strings; normalize for display.
|
|
148
|
+
def self.readable_tag(tag)
|
|
149
|
+
tag.dup.force_encoding("UTF-8")
|
|
150
|
+
rescue StandardError
|
|
151
|
+
tag.to_s
|
|
152
|
+
end
|
|
153
|
+
private_class_method :readable_tag
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
end
|
|
157
|
+
end
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "lutaml/model"
|
|
4
|
+
|
|
5
|
+
module Fontisan
|
|
6
|
+
module Audit
|
|
7
|
+
# Individual audit check implementations. Each is a standalone class
|
|
8
|
+
# under {Audit::Check} that implements `.call(font)` and `.code`.
|
|
9
|
+
#
|
|
10
|
+
# Adding a new check = one file + one autoload entry here. No edits
|
|
11
|
+
# to {CheckRegistry} or {AuditCommand} required (Open/Closed).
|
|
12
|
+
module Checks
|
|
13
|
+
autoload :TableDirectoryCheck, "fontisan/audit/checks/table_directory_check"
|
|
14
|
+
autoload :GlyphNameCheck, "fontisan/audit/checks/glyph_name_check"
|
|
15
|
+
autoload :CmapCheck, "fontisan/audit/checks/cmap_check"
|
|
16
|
+
autoload :OtsCompatibilityCheck,
|
|
17
|
+
"fontisan/audit/checks/ots_compatibility_check"
|
|
18
|
+
autoload :CollectionIntegrityCheck,
|
|
19
|
+
"fontisan/audit/checks/collection_integrity_check"
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Fontisan
|
|
4
|
+
# Audit-layer validation axes. Each check is a standalone module that
|
|
5
|
+
# produces structured issues when run against a loaded font. Checks
|
|
6
|
+
# are MECE by design: one concern per check (checksums, glyph names,
|
|
7
|
+
# cmap subtables, OTS compatibility, collection integrity, etc.).
|
|
8
|
+
#
|
|
9
|
+
# Checks are independent of the existing `Validators::*` framework
|
|
10
|
+
# (which uses a DSL and produces boolean pass/fail per check). The
|
|
11
|
+
# audit checks produce detailed `Models::ValidationReport::Issue`
|
|
12
|
+
# records so the consumer knows exactly what failed and where.
|
|
13
|
+
#
|
|
14
|
+
# @see Models::ValidationReport::Issue
|
|
15
|
+
module Audit
|
|
16
|
+
autoload :Check, "fontisan/audit/check"
|
|
17
|
+
autoload :CheckRegistry, "fontisan/audit/check_registry"
|
|
18
|
+
autoload :Checks, "fontisan/audit/checks"
|
|
19
|
+
end
|
|
20
|
+
end
|
data/lib/fontisan/cli.rb
CHANGED
|
@@ -31,6 +31,29 @@ module Fontisan
|
|
|
31
31
|
desc "stitch", "Stitch glyphs from multiple source fonts into one output"
|
|
32
32
|
subcommand "stitch", StitcherCli
|
|
33
33
|
|
|
34
|
+
desc "audit FONT_FILE", "Produce a structured audit report (identity, style, coverage, layout)"
|
|
35
|
+
option :output, type: :string,
|
|
36
|
+
desc: "Output file path. For collections, use a directory path."
|
|
37
|
+
option :no_codepoints, type: :boolean, default: false,
|
|
38
|
+
desc: "Omit the codepoint list from the report"
|
|
39
|
+
option :font_index, type: :numeric, default: nil,
|
|
40
|
+
desc: "Audit only the given face of a collection"
|
|
41
|
+
option :validate, type: :string, default: nil,
|
|
42
|
+
desc: "Run validation checks. Use 'true' for all, or a " \
|
|
43
|
+
"profile name: default, structural, ots, layout"
|
|
44
|
+
# Produce a structured font audit report (YAML or JSON).
|
|
45
|
+
def audit(font_file)
|
|
46
|
+
cmd_options = options.dup
|
|
47
|
+
cmd_options[:include_codepoints] = !options[:no_codepoints]
|
|
48
|
+
cmd_options[:validate] = parse_validate_option(options[:validate])
|
|
49
|
+
command = Commands::AuditCommand.new(font_file, cmd_options)
|
|
50
|
+
result = command.run
|
|
51
|
+
write_audit_result(result, options[:output])
|
|
52
|
+
rescue Errno::ENOENT
|
|
53
|
+
warn "File not found: #{font_file}" unless options[:quiet]
|
|
54
|
+
exit 1
|
|
55
|
+
end
|
|
56
|
+
|
|
34
57
|
desc "info PATH", "Display font information"
|
|
35
58
|
option :brief, type: :boolean, default: false,
|
|
36
59
|
desc: "Brief mode - only essential info (5x faster, uses metadata loading)",
|
|
@@ -732,6 +755,61 @@ module Fontisan
|
|
|
732
755
|
puts output unless options[:quiet]
|
|
733
756
|
end
|
|
734
757
|
|
|
758
|
+
# Write the audit result to stdout or a file. For a single report,
|
|
759
|
+
# the output path (if given) is the file path. For a collection,
|
|
760
|
+
# the output path is a directory; each face is written as
|
|
761
|
+
# `{font_index:02d}-{postscript_name}.yaml`.
|
|
762
|
+
def write_audit_result(result, output_path)
|
|
763
|
+
if result.is_a?(Array)
|
|
764
|
+
write_audit_collection(result, output_path)
|
|
765
|
+
else
|
|
766
|
+
write_audit_single(result, output_path)
|
|
767
|
+
end
|
|
768
|
+
end
|
|
769
|
+
|
|
770
|
+
def write_audit_single(report, output_path)
|
|
771
|
+
content = serialize_audit(report)
|
|
772
|
+
if output_path
|
|
773
|
+
FileUtils.mkpath(File.dirname(output_path)) unless File.dirname(output_path) == "."
|
|
774
|
+
File.write(output_path, content)
|
|
775
|
+
else
|
|
776
|
+
puts content unless options[:quiet]
|
|
777
|
+
end
|
|
778
|
+
end
|
|
779
|
+
|
|
780
|
+
def write_audit_collection(reports, output_path)
|
|
781
|
+
if output_path
|
|
782
|
+
FileUtils.mkpath(output_path)
|
|
783
|
+
reports.each do |report|
|
|
784
|
+
name = audit_face_filename(report)
|
|
785
|
+
File.write(File.join(output_path, name), serialize_audit(report))
|
|
786
|
+
end
|
|
787
|
+
say("Wrote #{reports.size} reports to #{output_path}") unless options[:quiet]
|
|
788
|
+
else
|
|
789
|
+
puts serialize_audit(reports) unless options[:quiet]
|
|
790
|
+
end
|
|
791
|
+
end
|
|
792
|
+
|
|
793
|
+
def serialize_audit(report)
|
|
794
|
+
options[:format] == "json" ? report.to_json : report.to_yaml
|
|
795
|
+
end
|
|
796
|
+
|
|
797
|
+
def audit_face_filename(report)
|
|
798
|
+
safe_name = report.postscript_name&.gsub(%r{[^A-Za-z0-9._-]}, "_") || "font"
|
|
799
|
+
suffix = options[:format] == "json" ? "json" : "yaml"
|
|
800
|
+
idx = report.font_index || 0
|
|
801
|
+
format("%<idx>02d-%<name>s.%<suffix>s", idx: idx, name: safe_name, suffix: suffix)
|
|
802
|
+
end
|
|
803
|
+
|
|
804
|
+
# The --validate option is either nil (no validation), "true" (run all
|
|
805
|
+
# checks), or a profile name (e.g. "ots", "structural").
|
|
806
|
+
def parse_validate_option(raw)
|
|
807
|
+
return nil if raw.nil? || raw.empty?
|
|
808
|
+
return true if raw == "true"
|
|
809
|
+
|
|
810
|
+
raw.to_sym
|
|
811
|
+
end
|
|
812
|
+
|
|
735
813
|
def serialize_report(report, format)
|
|
736
814
|
format == :json ? report.to_json : report.to_yaml
|
|
737
815
|
end
|
|
@@ -0,0 +1,291 @@
|
|
|
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
|
+
populate_validation(r, face) if validate?
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# ------------------------------------------------------------------
|
|
83
|
+
# Provenance
|
|
84
|
+
# ------------------------------------------------------------------
|
|
85
|
+
|
|
86
|
+
def populate_provenance(report, font_index:, num_fonts:)
|
|
87
|
+
report.generated_at = Time.now.utc.iso8601
|
|
88
|
+
report.fontisan_version = VERSION
|
|
89
|
+
report.source_file = File.basename(@font_path)
|
|
90
|
+
report.source_sha256 = Digest::SHA256.file(@font_path).hexdigest
|
|
91
|
+
report.source_format = detect_source_format
|
|
92
|
+
report.font_index = font_index
|
|
93
|
+
report.num_fonts_in_source = num_fonts
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def detect_source_format
|
|
97
|
+
fmt = FontLoader.detect_format(@font_path)
|
|
98
|
+
fmt ? fmt.to_s : "unknown"
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# ------------------------------------------------------------------
|
|
102
|
+
# Identity (name table)
|
|
103
|
+
# ------------------------------------------------------------------
|
|
104
|
+
|
|
105
|
+
def populate_identity(report, face)
|
|
106
|
+
return unless face.has_table?(Constants::NAME_TAG)
|
|
107
|
+
|
|
108
|
+
name_table = face.table(Constants::NAME_TAG)
|
|
109
|
+
report.family_name = name_table.english_name(Tables::Name::FAMILY)
|
|
110
|
+
report.subfamily_name = name_table.english_name(Tables::Name::SUBFAMILY)
|
|
111
|
+
report.full_name = name_table.english_name(Tables::Name::FULL_NAME)
|
|
112
|
+
report.postscript_name =
|
|
113
|
+
name_table.english_name(Tables::Name::POSTSCRIPT_NAME)
|
|
114
|
+
report.version = name_table.english_name(Tables::Name::VERSION)
|
|
115
|
+
|
|
116
|
+
return unless face.has_table?(Constants::HEAD_TAG)
|
|
117
|
+
|
|
118
|
+
report.font_revision = face.table(Constants::HEAD_TAG).font_revision
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
# ------------------------------------------------------------------
|
|
122
|
+
# Style (OS/2, head, fvar)
|
|
123
|
+
# ------------------------------------------------------------------
|
|
124
|
+
|
|
125
|
+
def populate_style(report, face)
|
|
126
|
+
report.is_variable = false
|
|
127
|
+
report.italic = false
|
|
128
|
+
report.bold = false
|
|
129
|
+
populate_os2_style(report, face) if face.has_table?(Constants::OS2_TAG)
|
|
130
|
+
populate_head_style(report, face) if face.has_table?(Constants::HEAD_TAG)
|
|
131
|
+
populate_fvar_style(report, face) if face.has_table?(Constants::FVAR_TAG)
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def populate_os2_style(report, face)
|
|
135
|
+
os2 = face.table(Constants::OS2_TAG)
|
|
136
|
+
report.weight_class = os2.us_weight_class
|
|
137
|
+
report.width_class = os2.us_width_class
|
|
138
|
+
report.italic = os2.fs_selection && (os2.fs_selection & 0x01).positive?
|
|
139
|
+
report.bold = os2.fs_selection && (os2.fs_selection & 0x20).positive?
|
|
140
|
+
report.panose = format_panose(os2.panose)
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def populate_head_style(report, face)
|
|
144
|
+
head = face.table(Constants::HEAD_TAG)
|
|
145
|
+
mac_style = head.mac_style || 0
|
|
146
|
+
# head macStyle overrides OS/2 fsSelection only when OS/2 didn't set it
|
|
147
|
+
report.italic = true if (mac_style & 0x02).positive?
|
|
148
|
+
report.bold = true if (mac_style & 0x01).positive?
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def populate_fvar_style(report, face)
|
|
152
|
+
report.is_variable = true
|
|
153
|
+
fvar = face.table(Constants::FVAR_TAG)
|
|
154
|
+
report.axes = fvar.axes.map do |axis|
|
|
155
|
+
Models::AuditAxis.new(
|
|
156
|
+
tag: axis.axis_tag,
|
|
157
|
+
min_value: axis.min_value,
|
|
158
|
+
default_value: axis.default_value,
|
|
159
|
+
max_value: axis.max_value,
|
|
160
|
+
name_id: axis.axis_name_id,
|
|
161
|
+
)
|
|
162
|
+
end
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
def format_panose(panose)
|
|
166
|
+
return nil unless panose
|
|
167
|
+
|
|
168
|
+
panose.to_a.join(" ")
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
# ------------------------------------------------------------------
|
|
172
|
+
# Coverage (cmap + glyph count)
|
|
173
|
+
# ------------------------------------------------------------------
|
|
174
|
+
|
|
175
|
+
def populate_coverage(report, face)
|
|
176
|
+
if face.has_table?(Constants::CMAP_TAG)
|
|
177
|
+
cmap = face.table(Constants::CMAP_TAG)
|
|
178
|
+
mappings = cmap.unicode_mappings || {}
|
|
179
|
+
report.total_codepoints = mappings.size
|
|
180
|
+
report.cmap_subtables = cmap.subtable_formats
|
|
181
|
+
report.codepoints = codepoint_list(mappings.keys) if include_codepoints?
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
report.total_glyphs = total_glyphs(face)
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
def codepoint_list(codepoints)
|
|
188
|
+
codepoints.sort.map { |cp| format_codepoint(cp) }
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
def format_codepoint(cp)
|
|
192
|
+
format("U+%04X", cp)
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
def total_glyphs(face)
|
|
196
|
+
return 0 unless face.has_table?(Constants::MAXP_TAG)
|
|
197
|
+
|
|
198
|
+
face.table(Constants::MAXP_TAG)&.num_glyphs || 0
|
|
199
|
+
rescue StandardError
|
|
200
|
+
0
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
def include_codepoints?
|
|
204
|
+
@options.fetch(:include_codepoints, true)
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
# ------------------------------------------------------------------
|
|
208
|
+
# OpenType layout (GSUB/GPOS)
|
|
209
|
+
# ------------------------------------------------------------------
|
|
210
|
+
|
|
211
|
+
def populate_layout(report, face)
|
|
212
|
+
report.opentype_scripts = collect_scripts(face)
|
|
213
|
+
report.features = collect_features(face)
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
def collect_scripts(face)
|
|
217
|
+
scripts = Set.new
|
|
218
|
+
%w[GSUB GPOS].each do |tag|
|
|
219
|
+
next unless face.has_table?(tag)
|
|
220
|
+
|
|
221
|
+
table = face.table(tag)
|
|
222
|
+
scripts.merge(table.scripts) if table
|
|
223
|
+
end
|
|
224
|
+
scripts.sort
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
def collect_features(face)
|
|
228
|
+
features = Set.new
|
|
229
|
+
%w[GSUB GPOS].each do |tag|
|
|
230
|
+
next unless face.has_table?(tag)
|
|
231
|
+
|
|
232
|
+
table = face.table(tag)
|
|
233
|
+
features.merge(collect_features_from_table(table)) if table
|
|
234
|
+
end
|
|
235
|
+
features.sort
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
def collect_features_from_table(layout_table)
|
|
239
|
+
scripts = begin
|
|
240
|
+
layout_table.scripts
|
|
241
|
+
rescue StandardError
|
|
242
|
+
[]
|
|
243
|
+
end
|
|
244
|
+
scripts.flat_map { |script| features_for_script(layout_table, script) }
|
|
245
|
+
rescue StandardError
|
|
246
|
+
[]
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
def features_for_script(layout_table, script)
|
|
250
|
+
layout_table.features(script_tag: script)
|
|
251
|
+
rescue StandardError
|
|
252
|
+
[]
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
# ------------------------------------------------------------------
|
|
256
|
+
# Validation (audit checks)
|
|
257
|
+
# ------------------------------------------------------------------
|
|
258
|
+
|
|
259
|
+
def populate_validation(report, face)
|
|
260
|
+
report.validation_issues = run_validation_checks(face)
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
def run_validation_checks(face)
|
|
264
|
+
checks = Audit::CheckRegistry.for(validation_profile)
|
|
265
|
+
checks.flat_map { |check| safe_run_check(check, face) }
|
|
266
|
+
end
|
|
267
|
+
|
|
268
|
+
def safe_run_check(check, face)
|
|
269
|
+
check.call(face)
|
|
270
|
+
rescue StandardError => e
|
|
271
|
+
[Models::ValidationReport::Issue.new(
|
|
272
|
+
severity: "fatal",
|
|
273
|
+
category: check.code.to_s,
|
|
274
|
+
message: "Check #{check.code} failed to execute: #{e.class}: #{e.message}",
|
|
275
|
+
location: nil,
|
|
276
|
+
)]
|
|
277
|
+
end
|
|
278
|
+
|
|
279
|
+
def validate?
|
|
280
|
+
@options.fetch(:validate, false)
|
|
281
|
+
end
|
|
282
|
+
|
|
283
|
+
def validation_profile
|
|
284
|
+
profile = @options[:validate]
|
|
285
|
+
return :default if profile == true
|
|
286
|
+
|
|
287
|
+
profile&.to_sym || :default
|
|
288
|
+
end
|
|
289
|
+
end
|
|
290
|
+
end
|
|
291
|
+
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"
|