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.
@@ -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
@@ -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"