fontisan 0.4.37 → 0.4.38

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 615c64e2005d769c01c4b7a5cd153630b42f5230c55a0242f892aaec65618320
4
- data.tar.gz: deca6b51aba40a45b2150290fe5ef0bd94b935f4fd09c54da54e65bf8cde3194
3
+ metadata.gz: aebdd37610f8ce36210375f348bd5a0f1aea12d0a12c951005d018e64cac290c
4
+ data.tar.gz: 33e81566249885ae646e91e727abedff605619e6deb30f742fcf1dff570cd641
5
5
  SHA512:
6
- metadata.gz: dc66734c722fd5fc959d80c95f41b701ecb7679926e85d6bbf09f9aab84c5ea96eb94f558ee241f40b88c999f21a811d9d45a306f35ec7b5cc61b188c053851e
7
- data.tar.gz: 6ceebcf686badd81a1d934f8f2d03b607f29522bef598e85cd363c33d16a94f9df6d3fd048ebce38dfce103e5dc55efb7df6cbd55016220e564e4ca833490dd8
6
+ metadata.gz: 8e43a54dc2421202b7d780e10e37fb4b363c835d261257677728dd99602f6df0190414c1ed59c0d5c331e8b977d75326961f05b5cea2f7c32431c9cee39c094b
7
+ data.tar.gz: cce7fd930ab3d5da138c0f4756468b0494f21b7dcf0b515df5e38498770493a399fa10d862c5e2948efc35dfcae84c8a937ad5d8209ed1eddd4a033491934c37
@@ -11,7 +11,7 @@ Each file is `NN-short-name.md` where `NN` is the priority order.
11
11
  - [x] ~~[15 — CFF/CFF2 subsetter strategy](15-cff-cff2-subsetter-strategy.md)~~ ✓ Done — CFF via UFO round-trip, CFF2 via standalone INDEX filter with VStore preservation
12
12
 
13
13
  ### P1 — High-value features
14
- - [x] ~~[03 — `fontisan audit` command (identity+style+features lens)](03-fontisan-audit-command.md)~~ ✓ Done (Phase 1: identity, style, coverage, layout, provenance; Phase 2 axes: table directory, glyph names, cmap, OTS, collection integrity; Phase 3 axes: variable-font, hinting, WOFF2, format-round-trip; Phase 3 axis 14 (full OT conformance): cross-table conformance + per-table checks for head, hhea, maxp, OS/2, name, post, kern; CLI `fontisan audit --validate` with profiles: default (17 checks), structural, ots, layout, variable, hinting, web, spec, per_table)
14
+ - [x] ~~[03 — `fontisan audit` command (identity+style+features lens)](03-fontisan-audit-command.md)~~ ✓ Done (Phase 1: identity, style, coverage, layout, provenance; Phase 2 axes: table directory, glyph names, cmap, OTS, collection integrity; Phase 3 axes: variable-font, hinting, WOFF2, format-round-trip; Phase 3 axis 14 (full OT conformance): cross-table conformance + per-table checks for head, hhea, maxp, OS/2, name, post, kern, CFF/CFF2, glyf, GSUB/GPOS layout; CLI `fontisan audit --validate` with 20 checks across 9 profiles; CI exit codes via `--fail-on-error`; profile discovery via `--list-profiles`; audit summary model with error/warning/info counts)
15
15
  - [x] ~~[04 — UFO composite glyph encoding](04-ufo-composite-glyph-encoding.md)~~ ✓ Done
16
16
  - [x] ~~[05 — OTF compiler real CFF charstrings](05-otf-compiler-real-cff.md)~~ ✓ Already working (discovered in PR #117 — Cff.build produces real Type 2 charstrings; stale TODO marker removed)
17
17
 
@@ -18,21 +18,22 @@ module Fontisan
18
18
  class CheckRegistry
19
19
  # Per-table OT conformance checks (axis 14 — full OT spec coverage).
20
20
  OT_TABLE_CHECKS = %i[ot_head ot_hhea ot_maxp ot_os2 ot_name ot_post
21
- ot_kern].freeze
21
+ ot_kern ot_cff ot_glyf ot_layout].freeze
22
22
 
23
23
  PROFILES = {
24
24
  default: %i[table_directory glyph_names cmap ots_compatibility
25
25
  collection_integrity variable_font hinting woff2_validation
26
26
  format_round_trip opentype_conformance
27
- ot_head ot_hhea ot_maxp ot_os2 ot_name ot_post ot_kern],
27
+ ot_head ot_hhea ot_maxp ot_os2 ot_name ot_post ot_kern
28
+ ot_cff ot_glyf ot_layout],
28
29
  structural: %i[table_directory collection_integrity opentype_conformance],
29
30
  ots: %i[ots_compatibility],
30
- layout: %i[glyph_names cmap],
31
+ layout: %i[glyph_names cmap ot_layout],
31
32
  variable: %i[variable_font],
32
33
  hinting: %i[hinting],
33
34
  web: %i[ots_compatibility woff2_validation],
34
35
  spec: %i[opentype_conformance ot_head ot_hhea ot_maxp ot_os2
35
- ot_name ot_post ot_kern],
36
+ ot_name ot_post ot_kern ot_cff ot_glyf ot_layout],
36
37
  per_table: OT_TABLE_CHECKS,
37
38
  }.freeze
38
39
 
@@ -64,6 +65,9 @@ module Fontisan
64
65
  when :ot_name then Checks::NameTableCheck
65
66
  when :ot_post then Checks::PostCheck
66
67
  when :ot_kern then Checks::KernCheck
68
+ when :ot_cff then Checks::CffTableCheck
69
+ when :ot_glyf then Checks::GlyfTableCheck
70
+ when :ot_layout then Checks::LayoutTableCheck
67
71
  end
68
72
  end
69
73
  end
@@ -0,0 +1,87 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Fontisan
4
+ module Audit
5
+ module Checks
6
+ # Validates CFF/CFF2 table structure: header version, Name INDEX
7
+ # presence (CFF1), Top DICT CharStrings offset, CharStrings count
8
+ # vs maxp.numGlyphs.
9
+ #
10
+ # @see https://learn.microsoft.com/en-us/typography/opentype/spec/cff
11
+ # @see https://learn.microsoft.com/en-us/typography/opentype/spec/cff2
12
+ class CffTableCheck < Check
13
+ def self.call(font)
14
+ if font.has_table?("CFF2")
15
+ validate_cff2(font)
16
+ elsif font.has_table?("CFF ")
17
+ validate_cff1(font)
18
+ else
19
+ []
20
+ end
21
+ end
22
+
23
+ def self.code
24
+ :ot_cff
25
+ end
26
+
27
+ # ---------- CFF1 ----------
28
+
29
+ def self.validate_cff1(font)
30
+ cff = font.table("CFF ")
31
+ return [] unless cff
32
+
33
+ issues = []
34
+ issues.concat(validate_cff1_name_index(cff))
35
+ issues.concat(validate_charstrings_count(cff, font, "CFF "))
36
+ issues
37
+ end
38
+ private_class_method :validate_cff1
39
+
40
+ def self.validate_cff1_name_index(cff)
41
+ count = cff.font_count.to_i
42
+ return [] if count.positive?
43
+
44
+ [issue(severity: :error,
45
+ message: "CFF Name INDEX is empty — must contain at least " \
46
+ "one font name",
47
+ location: "cff.name_index")]
48
+ end
49
+ private_class_method :validate_cff1_name_index
50
+
51
+ # ---------- CFF2 ----------
52
+
53
+ def self.validate_cff2(font)
54
+ cff2 = font.table("CFF2")
55
+ return [] unless cff2
56
+
57
+ validate_charstrings_count(cff2, font, "CFF2")
58
+ end
59
+ private_class_method :validate_cff2
60
+
61
+ # ---------- shared ----------
62
+
63
+ def self.validate_charstrings_count(cff, font, table_tag)
64
+ return [] unless font.has_table?("maxp")
65
+
66
+ cs_count = glyph_count_from_cff(cff)
67
+ maxp_count = font.table("maxp").num_glyphs.to_i
68
+ return [] if cs_count == maxp_count
69
+
70
+ [issue(severity: :error,
71
+ message: "#{table_tag} CharStrings count (#{cs_count}) does " \
72
+ "not match maxp.numGlyphs (#{maxp_count})",
73
+ location: "#{table_tag.downcase}.charstrings")]
74
+ end
75
+ private_class_method :validate_charstrings_count
76
+
77
+ def self.glyph_count_from_cff(cff)
78
+ cs_index = cff.charstrings_index(0)
79
+ cs_index&.count.to_i
80
+ rescue StandardError
81
+ 0
82
+ end
83
+ private_class_method :glyph_count_from_cff
84
+ end
85
+ end
86
+ end
87
+ end
@@ -0,0 +1,115 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Fontisan
4
+ module Audit
5
+ module Checks
6
+ # Validates the 'glyf' table: simple glyph contour integrity and
7
+ # compound glyph reference bounds. Only runs on TrueType fonts.
8
+ #
9
+ # @see https://learn.microsoft.com/en-us/typography/opentype/spec/glyf
10
+ class GlyfTableCheck < Check
11
+ MAX_COMPOUND_DEPTH = 16
12
+
13
+ def self.call(font)
14
+ return [] unless font.has_table?("glyf") && font.has_table?("loca")
15
+
16
+ issues = []
17
+ issues.concat(validate_glyph_counts(font))
18
+ issues
19
+ end
20
+
21
+ def self.code
22
+ :ot_glyf
23
+ end
24
+
25
+ # Verify loca offsets are monotonically non-decreasing and
26
+ # within the glyf table bounds. Discontinuities signal
27
+ # corruption that renderers may reject.
28
+ def self.validate_glyph_counts(font)
29
+ glyf = font.table("glyf")
30
+ return [] unless glyf
31
+
32
+ raw = font.table_data["glyf"]
33
+ return [] unless raw
34
+
35
+ issues = []
36
+ maxp = font.table("maxp")
37
+ num_glyphs = maxp&.num_glyphs.to_i
38
+ return [] if num_glyphs.zero?
39
+
40
+ num_glyphs.times do |gid|
41
+ issues.concat(validate_one_glyph(font, gid))
42
+ rescue StandardError
43
+ issues << issue(severity: :warning,
44
+ message: "glyf: failed to validate glyph at GID " \
45
+ "#{gid}",
46
+ location: "glyf.glyph.#{gid}")
47
+ end
48
+ issues
49
+ end
50
+ private_class_method :validate_glyph_counts
51
+
52
+ def self.validate_one_glyph(font, gid)
53
+ head = font.table("head")
54
+ loca = font.table("loca")
55
+ glyf = font.table("glyf")
56
+
57
+ raw = begin
58
+ glyf.glyph_for(gid, loca, head)
59
+ rescue StandardError
60
+ nil
61
+ end
62
+ return [] unless raw
63
+
64
+ if raw.simple?
65
+ validate_simple_glyph(raw, gid)
66
+ elsif raw.compound?
67
+ validate_compound_glyph(raw, gid, font)
68
+ else
69
+ []
70
+ end
71
+ end
72
+ private_class_method :validate_one_glyph
73
+
74
+ def self.validate_simple_glyph(raw, gid)
75
+ issues = []
76
+ end_pts = raw.end_pts_of_contours
77
+ return [] unless end_pts&.any?
78
+
79
+ # endPtsOfContours must be monotonically increasing
80
+ end_pts.each_cons(2) do |prev, curr|
81
+ next unless curr <= prev
82
+
83
+ issues << issue(severity: :error,
84
+ message: "glyf glyph #{gid}: contour endPoints " \
85
+ "are not monotonically increasing " \
86
+ "(#{prev} → #{curr})",
87
+ location: "glyf.glyph.#{gid}.contours")
88
+ break
89
+ end
90
+ issues
91
+ end
92
+ private_class_method :validate_simple_glyph
93
+
94
+ def self.validate_compound_glyph(raw, gid, font)
95
+ issues = []
96
+ maxp = font.table("maxp")
97
+ max_gid = maxp&.num_glyphs.to_i - 1
98
+
99
+ raw.components.each do |comp|
100
+ ref_gid = comp.glyph_index.to_i
101
+ next if ref_gid.between?(0, max_gid)
102
+
103
+ issues << issue(severity: :error,
104
+ message: "glyf compound glyph #{gid} references " \
105
+ "out-of-bounds GID #{ref_gid} " \
106
+ "(max: #{max_gid})",
107
+ location: "glyf.glyph.#{gid}.component.#{ref_gid}")
108
+ end
109
+ issues
110
+ end
111
+ private_class_method :validate_compound_glyph
112
+ end
113
+ end
114
+ end
115
+ end
@@ -0,0 +1,70 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Fontisan
4
+ module Audit
5
+ module Checks
6
+ # Validates GSUB/GPOS layout table structure: header version,
7
+ # ScriptList/FeatureList/LookupList offsets, and DFLT script
8
+ # recommendation. Only runs when layout tables are present.
9
+ #
10
+ # @see https://learn.microsoft.com/en-us/typography/opentype/spec/chapter2
11
+ class LayoutTableCheck < Check
12
+ def self.call(font)
13
+ issues = []
14
+ issues.concat(validate_layout_table(font, "GSUB"))
15
+ issues.concat(validate_layout_table(font, "GPOS"))
16
+ issues
17
+ end
18
+
19
+ def self.code
20
+ :ot_layout
21
+ end
22
+
23
+ def self.validate_layout_table(font, tag)
24
+ return [] unless font.has_table?(tag)
25
+
26
+ table = font.table(tag)
27
+ return [] unless table
28
+
29
+ issues = []
30
+ issues.concat(validate_scripts(table, tag))
31
+ issues.concat(validate_dflt_script(table, tag))
32
+ issues
33
+ end
34
+ private_class_method :validate_layout_table
35
+
36
+ def self.validate_scripts(table, tag)
37
+ scripts = begin
38
+ table.scripts
39
+ rescue StandardError
40
+ []
41
+ end
42
+ return [] if scripts.any?
43
+
44
+ [issue(severity: :warning,
45
+ message: "#{tag} table has no script records — layout " \
46
+ "rules won't apply without at least one script " \
47
+ "(DFLT recommended)",
48
+ location: "#{tag.downcase}.script_list")]
49
+ end
50
+ private_class_method :validate_scripts
51
+
52
+ def self.validate_dflt_script(table, tag)
53
+ scripts = begin
54
+ table.scripts
55
+ rescue StandardError
56
+ []
57
+ end
58
+ return [] if scripts.include?("DFLT")
59
+
60
+ [issue(severity: :info,
61
+ message: "#{tag} table has no 'DFLT' script — fonts " \
62
+ "without DFLT may not apply features to scripts " \
63
+ "not explicitly listed",
64
+ location: "#{tag.downcase}.script_list.DFLT")]
65
+ end
66
+ private_class_method :validate_dflt_script
67
+ end
68
+ end
69
+ end
70
+ end
@@ -32,6 +32,9 @@ module Fontisan
32
32
  autoload :NameTableCheck, "fontisan/audit/checks/name_table_check"
33
33
  autoload :PostCheck, "fontisan/audit/checks/post_check"
34
34
  autoload :KernCheck, "fontisan/audit/checks/kern_check"
35
+ autoload :CffTableCheck, "fontisan/audit/checks/cff_table_check"
36
+ autoload :GlyfTableCheck, "fontisan/audit/checks/glyf_table_check"
37
+ autoload :LayoutTableCheck, "fontisan/audit/checks/layout_table_check"
35
38
  end
36
39
  end
37
40
  end
data/lib/fontisan/cli.rb CHANGED
@@ -40,15 +40,26 @@ module Fontisan
40
40
  desc: "Audit only the given face of a collection"
41
41
  option :validate, type: :string, default: nil,
42
42
  desc: "Run validation checks. Use 'true' for all, or a " \
43
- "profile name: default, structural, ots, layout"
43
+ "profile name: default, structural, ots, layout, " \
44
+ "variable, hinting, web, spec, per_table"
45
+ option :list_profiles, type: :boolean, default: false,
46
+ desc: "List available validation profiles and exit"
47
+ option :fail_on_error, type: :boolean, default: true,
48
+ desc: "Exit with code 1 if validation finds errors " \
49
+ "(only applies with --validate)"
44
50
  # Produce a structured font audit report (YAML or JSON).
45
- def audit(font_file)
51
+ def audit(font_file = nil)
52
+ return list_audit_profiles if options[:list_profiles]
53
+
54
+ raise Thor::Error, "FONT_FILE is required" if font_file.nil?
55
+
46
56
  cmd_options = options.dup
47
57
  cmd_options[:include_codepoints] = !options[:no_codepoints]
48
58
  cmd_options[:validate] = parse_validate_option(options[:validate])
49
59
  command = Commands::AuditCommand.new(font_file, cmd_options)
50
60
  result = command.run
51
61
  write_audit_result(result, options[:output])
62
+ exit_on_validation_errors(result) if options[:validate] && options[:fail_on_error]
52
63
  rescue Errno::ENOENT
53
64
  warn "File not found: #{font_file}" unless options[:quiet]
54
65
  exit 1
@@ -810,6 +821,34 @@ module Fontisan
810
821
  raw.to_sym
811
822
  end
812
823
 
824
+ # Print available validation profiles with their check counts.
825
+ def list_audit_profiles
826
+ puts "Available validation profiles for --validate:"
827
+ puts ""
828
+ Audit::CheckRegistry::PROFILES.each do |name, codes|
829
+ puts " #{name.to_s.ljust(15)} #{codes.size} checks"
830
+ end
831
+ puts ""
832
+ puts "Usage: fontisan audit font.ttf --validate <profile>"
833
+ puts " fontisan audit font.ttf --validate true (all checks)"
834
+ end
835
+
836
+ # Exit with code 1 if the audit found error-level issues.
837
+ # For collections, aggregates across all faces.
838
+ def exit_on_validation_errors(result)
839
+ issues = if result.is_a?(Array)
840
+ result.flat_map(&:validation_issues)
841
+ else
842
+ result.validation_issues || []
843
+ end
844
+ has_errors = issues.any? { |i| %w[error fatal].include?(i.severity) }
845
+ return unless has_errors
846
+
847
+ error_count = issues.count { |i| %w[error fatal].include?(i.severity) }
848
+ warn "Audit found #{error_count} error(s) — exiting with code 1"
849
+ exit 1
850
+ end
851
+
813
852
  def serialize_report(report, format)
814
853
  format == :json ? report.to_json : report.to_yaml
815
854
  end
@@ -257,7 +257,9 @@ module Fontisan
257
257
  # ------------------------------------------------------------------
258
258
 
259
259
  def populate_validation(report, face)
260
- report.validation_issues = run_validation_checks(face)
260
+ issues = run_validation_checks(face)
261
+ report.validation_issues = issues
262
+ report.validation_summary = Models::AuditSummary.from_issues(issues)
261
263
  end
262
264
 
263
265
  def run_validation_checks(face)
@@ -4,6 +4,51 @@ require "lutaml/model"
4
4
 
5
5
  module Fontisan
6
6
  module Models
7
+ # Summary of validation issues by severity. Used by CI pipelines
8
+ # to decide pass/fail and by humans to gauge font health at a
9
+ # glance.
10
+ class AuditSummary < Lutaml::Model::Serializable
11
+ attribute :error_count, :integer, default: 0
12
+ attribute :warning_count, :integer, default: 0
13
+ attribute :info_count, :integer, default: 0
14
+ attribute :fatal_count, :integer, default: 0
15
+ attribute :total, :integer, default: 0
16
+ attribute :passed, Lutaml::Model::Type::Boolean, default: true
17
+
18
+ json do
19
+ map "error_count", to: :error_count
20
+ map "warning_count", to: :warning_count
21
+ map "info_count", to: :info_count
22
+ map "fatal_count", to: :fatal_count
23
+ map "total", to: :total
24
+ map "passed", to: :passed
25
+ end
26
+
27
+ yaml do
28
+ map "error_count", to: :error_count
29
+ map "warning_count", to: :warning_count
30
+ map "info_count", to: :info_count
31
+ map "fatal_count", to: :fatal_count
32
+ map "total", to: :total
33
+ map "passed", to: :passed
34
+ end
35
+
36
+ # Build a summary from an array of ValidationReport::Issue records.
37
+ # @param issues [Array<ValidationReport::Issue>]
38
+ # @return [AuditSummary]
39
+ def self.from_issues(issues)
40
+ counts = issues.group_by(&:severity).transform_values(&:size)
41
+ new(
42
+ error_count: counts["error"].to_i,
43
+ warning_count: counts["warning"].to_i,
44
+ info_count: counts["info"].to_i,
45
+ fatal_count: counts["fatal"].to_i,
46
+ total: issues.size,
47
+ passed: counts["error"].to_i.zero? && counts["fatal"].to_i.zero?,
48
+ )
49
+ end
50
+ end
51
+
7
52
  # Variable-font axis descriptor for the audit report.
8
53
  class AuditAxis < Lutaml::Model::Serializable
9
54
  attribute :tag, :string
@@ -75,6 +120,7 @@ module Fontisan
75
120
 
76
121
  # Validation (populated only with --validate)
77
122
  attribute :validation_issues, Models::ValidationReport::Issue, collection: true
123
+ attribute :validation_summary, AuditSummary
78
124
 
79
125
  json do
80
126
  map "generated_at", to: :generated_at
@@ -108,6 +154,7 @@ module Fontisan
108
154
  map "opentype_scripts", to: :opentype_scripts
109
155
  map "features", to: :features
110
156
  map "validation_issues", to: :validation_issues
157
+ map "validation_summary", to: :validation_summary
111
158
  end
112
159
 
113
160
  yaml do
@@ -142,6 +189,7 @@ module Fontisan
142
189
  map "opentype_scripts", to: :opentype_scripts
143
190
  map "features", to: :features
144
191
  map "validation_issues", to: :validation_issues
192
+ map "validation_summary", to: :validation_summary
145
193
  end
146
194
  end
147
195
  end
@@ -8,6 +8,7 @@ module Fontisan
8
8
  "fontisan/models/all_scripts_features_info"
9
9
  autoload :AuditAxis, "fontisan/models/audit_report"
10
10
  autoload :AuditReport, "fontisan/models/audit_report"
11
+ autoload :AuditSummary, "fontisan/models/audit_report"
11
12
  autoload :AxisInfo, "fontisan/models/variable_font_info"
12
13
  autoload :BitmapGlyph, "fontisan/models/bitmap_glyph"
13
14
  autoload :BitmapStrike, "fontisan/models/bitmap_strike"
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Fontisan
4
- VERSION = "0.4.37"
4
+ VERSION = "0.4.38"
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: fontisan
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.4.37
4
+ version: 0.4.38
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ribose Inc.
@@ -297,14 +297,17 @@ files:
297
297
  - lib/fontisan/audit/check.rb
298
298
  - lib/fontisan/audit/check_registry.rb
299
299
  - lib/fontisan/audit/checks.rb
300
+ - lib/fontisan/audit/checks/cff_table_check.rb
300
301
  - lib/fontisan/audit/checks/cmap_check.rb
301
302
  - lib/fontisan/audit/checks/collection_integrity_check.rb
302
303
  - lib/fontisan/audit/checks/format_round_trip_check.rb
304
+ - lib/fontisan/audit/checks/glyf_table_check.rb
303
305
  - lib/fontisan/audit/checks/glyph_name_check.rb
304
306
  - lib/fontisan/audit/checks/head_check.rb
305
307
  - lib/fontisan/audit/checks/hhea_check.rb
306
308
  - lib/fontisan/audit/checks/hinting_check.rb
307
309
  - lib/fontisan/audit/checks/kern_check.rb
310
+ - lib/fontisan/audit/checks/layout_table_check.rb
308
311
  - lib/fontisan/audit/checks/maxp_check.rb
309
312
  - lib/fontisan/audit/checks/name_table_check.rb
310
313
  - lib/fontisan/audit/checks/opentype_conformance_check.rb