fontisan 0.4.37 → 0.4.39

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: 42aba87092f4f48daf4e44f57dbbfcd948af16d90ef11fd145c3290d159eb674
4
+ data.tar.gz: 53a94a2f8914a6303e39dcb61a9db7cb411cb71322d2b400b595ff1010d6deca
5
5
  SHA512:
6
- metadata.gz: dc66734c722fd5fc959d80c95f41b701ecb7679926e85d6bbf09f9aab84c5ea96eb94f558ee241f40b88c999f21a811d9d45a306f35ec7b5cc61b188c053851e
7
- data.tar.gz: 6ceebcf686badd81a1d934f8f2d03b607f29522bef598e85cd363c33d16a94f9df6d3fd048ebce38dfce103e5dc55efb7df6cbd55016220e564e4ca833490dd8
6
+ metadata.gz: f0ee7602cfe91bbf0232ffb727d1743dc1d1b4eaacdc4290c8cfb07134b158be363c8d1c9a9ac15a41946e51c82a85eb443135a0f1469d83d4eb29d3b26e8142
7
+ data.tar.gz: 4f3419c11a81e62b556d1eb3f7e3cdec7bfabf36b4bf00fe9da9ff5fba4ede066266d8aad3bd79a7a6c4a2210098676753b3c2eabbdfc4a993a7ed7440ef64b0
data/.rubocop.yml CHANGED
@@ -131,4 +131,11 @@ Naming/MethodParameterName:
131
131
  # to cross-reference.
132
132
  Naming/VariableNumber:
133
133
  AllowedIdentifiers: ["format0", "format4", "format6", "format12", "format14",
134
- "version1", "version2", "table1", "table2"]
134
+ "version1", "version2", "table1", "table2",
135
+ "os2", "type1", "scale_to_1000", "scale_10", "scale_01",
136
+ "canonical_id_1", "canonical_id_2",
137
+ "strike_offset_1", "strike_offset_2", "strike_offset_3",
138
+ "plane_0", "to_signed_16", "strike1",
139
+ "range_8", "range_16", "encoding_id_2",
140
+ "subtable_1", "subtable_2", "b1", "c2",
141
+ "x0", "y0", "x1", "y1", "x2", "y2", "x3", "y3"]
data/.rubocop_todo.yml CHANGED
@@ -1,6 +1,6 @@
1
1
  # This configuration was generated by
2
2
  # `rubocop --auto-gen-config`
3
- # on 2026-07-11 08:52:22 UTC using RuboCop version 1.86.1.
3
+ # on 2026-07-11 14:35:05 UTC using RuboCop version 1.86.1.
4
4
  # The point is for the user to remove these configuration records
5
5
  # one by one as the offenses are removed from the code base.
6
6
  # Note that changes in the inspected code, or installation of new
@@ -32,7 +32,7 @@ Layout/IndentationWidth:
32
32
  - 'lib/fontisan/commands/audit_command.rb'
33
33
  - 'lib/fontisan/ufo/compile/glyf_loca.rb'
34
34
 
35
- # Offense count: 2053
35
+ # Offense count: 2094
36
36
  # This cop supports safe autocorrection (--autocorrect).
37
37
  # Configuration parameters: Max, AllowHeredoc, AllowURI, AllowQualifiedName, URISchemes, AllowRBSInlineAnnotation, AllowCopDirectives, AllowedPatterns, SplitStrings.
38
38
  # URISchemes: http, https
@@ -111,7 +111,7 @@ Lint/UselessConstantScoping:
111
111
  - 'lib/fontisan/conversion_options.rb'
112
112
  - 'lib/fontisan/type1/charstrings.rb'
113
113
 
114
- # Offense count: 766
114
+ # Offense count: 771
115
115
  # Configuration parameters: AllowedMethods, AllowedPatterns, CountRepeatedAttributes, Max.
116
116
  Metrics/AbcSize:
117
117
  Enabled: false
@@ -134,12 +134,12 @@ Metrics/CollectionLiteralLength:
134
134
  - 'lib/fontisan/type1/agl.rb'
135
135
  - 'lib/fontisan/type1/encodings.rb'
136
136
 
137
- # Offense count: 383
137
+ # Offense count: 388
138
138
  # Configuration parameters: AllowedMethods, AllowedPatterns, Max.
139
139
  Metrics/CyclomaticComplexity:
140
140
  Enabled: false
141
141
 
142
- # Offense count: 1045
142
+ # Offense count: 1059
143
143
  # Configuration parameters: CountComments, CountAsOne, AllowedMethods, AllowedPatterns.
144
144
  Metrics/MethodLength:
145
145
  Max: 135
@@ -150,7 +150,7 @@ Metrics/ParameterLists:
150
150
  Max: 39
151
151
  MaxOptionalParameters: 4
152
152
 
153
- # Offense count: 294
153
+ # Offense count: 297
154
154
  # Configuration parameters: AllowedMethods, AllowedPatterns, Max.
155
155
  Metrics/PerceivedComplexity:
156
156
  Enabled: false
@@ -210,7 +210,7 @@ Naming/VariableName:
210
210
  - 'lib/fontisan/type1/pfm_generator.rb'
211
211
  - 'lib/fontisan/type1/pfm_parser.rb'
212
212
 
213
- # Offense count: 124
213
+ # Offense count: 55
214
214
  # Configuration parameters: EnforcedStyle, CheckMethodNames, CheckSymbols, AllowedIdentifiers, AllowedPatterns.
215
215
  # SupportedStyles: snake_case, normalcase, non_integer
216
216
  # AllowedIdentifiers: TLS1_1, TLS1_2, capture3, iso8601, rfc1123_date, rfc822, rfc2822, rfc3339, x86_64
@@ -233,7 +233,7 @@ RSpec/AnyInstance:
233
233
  RSpec/ContextWording:
234
234
  Enabled: false
235
235
 
236
- # Offense count: 42
236
+ # Offense count: 44
237
237
  # Configuration parameters: IgnoredMetadata.
238
238
  RSpec/DescribeClass:
239
239
  Enabled: false
@@ -249,7 +249,7 @@ RSpec/DescribeMethod:
249
249
  - 'spec/fontisan/type1/seac_expander_spec.rb'
250
250
  - 'spec/fontisan/type1_real_fonts_spec.rb'
251
251
 
252
- # Offense count: 1729
252
+ # Offense count: 1736
253
253
  # Configuration parameters: CountAsOne.
254
254
  RSpec/ExampleLength:
255
255
  Max: 66
@@ -327,7 +327,7 @@ RSpec/MultipleDescribes:
327
327
  - 'spec/fontisan/utils/thread_pool_spec.rb'
328
328
  - 'spec/fontisan/variation/cache_spec.rb'
329
329
 
330
- # Offense count: 2153
330
+ # Offense count: 2168
331
331
  RSpec/MultipleExpectations:
332
332
  Max: 19
333
333
 
@@ -359,7 +359,7 @@ RSpec/RepeatedExample:
359
359
  - 'spec/fontisan/metrics_calculator_spec.rb'
360
360
  - 'spec/fontisan/tables/os2_spec.rb'
361
361
 
362
- # Offense count: 51
362
+ # Offense count: 53
363
363
  # Configuration parameters: CustomTransform, IgnoreMethods, IgnoreMetadata, InflectorPath, EnforcedInflector.
364
364
  # SupportedInflectors: default, active_support
365
365
  RSpec/SpecFilePathFormat:
@@ -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
 
@@ -26,7 +26,7 @@ Each file is `NN-short-name.md` where `NN` is the priority order.
26
26
  ### P3 — Code-quality cleanup
27
27
  - [x] ~~[13 — Split `OctokitFetcher` out of `fixture_downloader.rb`](13-split-octokit-fetcher.md)~~ ✓ Done (v0.4.24)
28
28
  - [x] ~~[12 — `cbdt_fixture.rb` full BinData conversion](12-cbdt-fixture-bindata-conversion.md)~~ ✓ Done (critical tables converted; remaining os2/name/hmtx/cmap verified correct, BinData conversion is diminishing returns)
29
- - [x] ~~[14 — Rubocop baseline chip (per-namespace)](14-rubocop-baseline-chip.md)~~ ✓ Partially done — baseline reduced from 619 to 428 lines; all namespaces pass `rubocop` with 0 offenses; remaining baseline offenses are non-autocorrectable manual fixes (~7h total per TODO estimate)
29
+ - [x] ~~[14 — Rubocop baseline chip (per-namespace)](14-rubocop-baseline-chip.md)~~ ✓ Partially done — all auto-correctable offenses fixed; all namespaces pass `rubocop` with 0 new offenses; Naming cops configured for font-domain conventions; remaining ~9800 baseline offenses are structural Metrics (MethodLength, AbcSize, LineLength) explicitly out of scope per TODO ("Refactoring architectural patterns — separate refactors"); estimated ~7h manual refactoring effort
30
30
 
31
31
  ## Convention
32
32
 
@@ -18,21 +18,23 @@ 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
- format_round_trip opentype_conformance
27
- ot_head ot_hhea ot_maxp ot_os2 ot_name ot_post ot_kern],
26
+ format_round_trip opentype_conformance naming_consistency
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 naming_consistency],
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
37
+ naming_consistency],
36
38
  per_table: OT_TABLE_CHECKS,
37
39
  }.freeze
38
40
 
@@ -57,6 +59,7 @@ module Fontisan
57
59
  when :woff2_validation then Checks::Woff2ValidationCheck
58
60
  when :format_round_trip then Checks::FormatRoundTripCheck
59
61
  when :opentype_conformance then Checks::OpenTypeConformanceCheck
62
+ when :naming_consistency then Checks::NamingConsistencyCheck
60
63
  when :ot_head then Checks::HeadCheck
61
64
  when :ot_hhea then Checks::HheaCheck
62
65
  when :ot_maxp then Checks::MaxpCheck
@@ -64,6 +67,9 @@ module Fontisan
64
67
  when :ot_name then Checks::NameTableCheck
65
68
  when :ot_post then Checks::PostCheck
66
69
  when :ot_kern then Checks::KernCheck
70
+ when :ot_cff then Checks::CffTableCheck
71
+ when :ot_glyf then Checks::GlyfTableCheck
72
+ when :ot_layout then Checks::LayoutTableCheck
67
73
  end
68
74
  end
69
75
  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
@@ -0,0 +1,102 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Fontisan
4
+ module Audit
5
+ module Checks
6
+ # Validates name-table consistency across name IDs. Catches
7
+ # common real-world bugs where font metadata disagrees with
8
+ # itself:
9
+ #
10
+ # - PostScript name should start with the Family name (minus
11
+ # spaces) — e.g. Family "Source Sans" → PS "SourceSans-Regular"
12
+ # - Subfamily should be a recognized style ("Regular", "Bold",
13
+ # "Italic", "Bold Italic") for non-variable fonts
14
+ # - Full name should be Family + " " + Subfamily
15
+ # - Version string should start with "Version "
16
+ #
17
+ # These rules catch typo-level errors that confuse font pickers,
18
+ # package managers, and PDF embedding workflows.
19
+ #
20
+ # @see https://learn.microsoft.com/en-us/typography/opentype/spec/name
21
+ class NamingConsistencyCheck < Check
22
+ RECOGNIZED_SUBFAMILIES = %w[
23
+ Regular Italic Bold BoldItalic Thin Light Medium SemiBold Black
24
+ Condensed Extended Oblique Book Normal
25
+ ].freeze
26
+
27
+ def self.call(font)
28
+ return [] unless font.has_table?("name")
29
+
30
+ name = font.table("name")
31
+ issues = []
32
+ issues.concat(check_ps_name_prefix(name))
33
+ issues.concat(check_full_name_composition(name))
34
+ issues.concat(check_subfamily_style(name, font))
35
+ issues
36
+ end
37
+
38
+ def self.code
39
+ :naming_consistency
40
+ end
41
+
42
+ # PostScript name should start with the family name (spaces
43
+ # removed). E.g. Family "Source Sans 3" → PS "SourceSans3-Regular".
44
+ def self.check_ps_name_prefix(name)
45
+ family = name.english_name(Tables::Name::FAMILY).to_s
46
+ ps = name.english_name(Tables::Name::POSTSCRIPT_NAME).to_s
47
+ return [] if family.empty? || ps.empty?
48
+
49
+ family_compact = family.delete(" ")
50
+ return [] if ps.start_with?(family_compact)
51
+
52
+ [issue(severity: :warning,
53
+ message: "PostScript name '#{ps}' does not start with the " \
54
+ "family name '#{family}' (expected prefix: '#{family_compact}')",
55
+ location: "name.consistency.ps_prefix")]
56
+ end
57
+ private_class_method :check_ps_name_prefix
58
+
59
+ # Full name should be Family + " " + Subfamily.
60
+ def self.check_full_name_composition(name)
61
+ family = name.english_name(Tables::Name::FAMILY).to_s
62
+ subfamily = name.english_name(Tables::Name::SUBFAMILY).to_s
63
+ full = name.english_name(Tables::Name::FULL_NAME).to_s
64
+ return [] if family.empty? || subfamily.empty? || full.empty?
65
+
66
+ expected = "#{family} #{subfamily}"
67
+ return [] if full == expected
68
+
69
+ [issue(severity: :info,
70
+ message: "Full name '#{full}' doesn't match " \
71
+ "Family + Subfamily '#{expected}'",
72
+ location: "name.consistency.full_name")]
73
+ end
74
+ private_class_method :check_full_name_composition
75
+
76
+ # Subfamily should be a recognized style name for non-variable
77
+ # fonts. Variable fonts use arbitrary subfamily names.
78
+ def self.check_subfamily_style(name, font)
79
+ return [] if font.has_table?("fvar") # variable fonts use custom names
80
+
81
+ subfamily = name.english_name(Tables::Name::SUBFAMILY).to_s
82
+ return [] if subfamily.empty?
83
+
84
+ return [] if recognized_subfamily?(subfamily)
85
+
86
+ [issue(severity: :info,
87
+ message: "Subfamily '#{subfamily}' is not a standard style " \
88
+ "name — font pickers may not categorize it correctly",
89
+ location: "name.consistency.subfamily")]
90
+ end
91
+ private_class_method :check_subfamily_style
92
+
93
+ def self.recognized_subfamily?(subfamily)
94
+ RECOGNIZED_SUBFAMILIES.any? do |recognized|
95
+ subfamily.casecmp?(recognized) || subfamily.casecmp?(recognized.delete('"'))
96
+ end
97
+ end
98
+ private_class_method :recognized_subfamily?
99
+ end
100
+ end
101
+ end
102
+ end
@@ -32,6 +32,11 @@ 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"
38
+ autoload :NamingConsistencyCheck,
39
+ "fontisan/audit/checks/naming_consistency_check"
35
40
  end
36
41
  end
37
42
  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
@@ -791,7 +802,95 @@ module Fontisan
791
802
  end
792
803
 
793
804
  def serialize_audit(report)
794
- options[:format] == "json" ? report.to_json : report.to_yaml
805
+ case options[:format]
806
+ when "json" then report.to_json
807
+ when "text", nil then format_audit_as_text(report)
808
+ else report.to_yaml
809
+ end
810
+ end
811
+
812
+ # Format an AuditReport as human-readable text for terminal use.
813
+ # Groups fields into sections: provenance, identity, style,
814
+ # coverage, layout, validation summary.
815
+ def format_audit_as_text(report)
816
+ lines = []
817
+ lines << "=== Font Audit Report ==="
818
+ lines.concat(format_audit_provenance(report))
819
+ lines.concat(format_audit_identity(report))
820
+ lines.concat(format_audit_style(report))
821
+ lines.concat(format_audit_coverage(report))
822
+ lines.concat(format_audit_layout(report))
823
+ lines.concat(format_audit_validation(report))
824
+ lines.join("\n")
825
+ end
826
+
827
+ def format_audit_provenance(report)
828
+ lines = []
829
+ lines << " Source: #{report.source_file}"
830
+ lines << " Format: #{report.source_format}"
831
+ lines << " SHA256: #{report.source_sha256[0, 16]}..." if report.source_sha256
832
+ lines << " Fontisan: #{report.fontisan_version}" if report.fontisan_version
833
+ lines << ""
834
+ end
835
+
836
+ def format_audit_identity(report)
837
+ lines = ["Identity:"]
838
+ lines << " Family: #{report.family_name}"
839
+ lines << " Subfamily: #{report.subfamily_name}"
840
+ lines << " Full: #{report.full_name}"
841
+ lines << " PostScript: #{report.postscript_name}"
842
+ lines << " Version: #{report.version}"
843
+ lines << ""
844
+ end
845
+
846
+ def format_audit_style(report)
847
+ lines = ["Style:"]
848
+ lines << " Weight: #{report.weight_class}"
849
+ lines << " Width: #{report.width_class}"
850
+ lines << " Italic: #{report.italic}"
851
+ lines << " Bold: #{report.bold}"
852
+ lines << " Variable: #{report.is_variable}"
853
+ report.axes&.each do |axis|
854
+ lines << " #{axis.tag}: #{axis.min_value}–#{axis.max_value} " \
855
+ "(default #{axis.default_value})"
856
+ end
857
+ lines << ""
858
+ end
859
+
860
+ def format_audit_coverage(report)
861
+ lines = ["Coverage:"]
862
+ lines << " Glyphs: #{report.total_glyphs}"
863
+ lines << " Codepoints: #{report.total_codepoints}"
864
+ lines << " cmap subtables: #{report.cmap_subtables&.join(', ')}" if report.cmap_subtables&.any?
865
+ lines << ""
866
+ end
867
+
868
+ def format_audit_layout(report)
869
+ scripts = report.opentype_scripts
870
+ features = report.features
871
+ return [] unless scripts&.any? || features&.any?
872
+
873
+ lines = ["Layout:"]
874
+ lines << " Scripts: #{scripts&.join(', ')}" if scripts&.any?
875
+ lines << " Features: #{features&.join(', ')}" if features&.any?
876
+ lines << ""
877
+ end
878
+
879
+ def format_audit_validation(report)
880
+ summary = report.validation_summary
881
+ return [] unless summary
882
+
883
+ lines = ["Validation:"]
884
+ lines << " Errors: #{summary.error_count}"
885
+ lines << " Warnings: #{summary.warning_count}"
886
+ lines << " Info: #{summary.info_count}"
887
+ result = summary.passed ? "PASSED" : "FAILED"
888
+ lines << " Result: #{result}"
889
+ lines << ""
890
+ report.validation_issues&.each do |issue|
891
+ lines << " [#{issue.severity.upcase}] #{issue.category}: #{issue.message}"
892
+ end
893
+ lines
795
894
  end
796
895
 
797
896
  def audit_face_filename(report)
@@ -810,6 +909,34 @@ module Fontisan
810
909
  raw.to_sym
811
910
  end
812
911
 
912
+ # Print available validation profiles with their check counts.
913
+ def list_audit_profiles
914
+ puts "Available validation profiles for --validate:"
915
+ puts ""
916
+ Audit::CheckRegistry::PROFILES.each do |name, codes|
917
+ puts " #{name.to_s.ljust(15)} #{codes.size} checks"
918
+ end
919
+ puts ""
920
+ puts "Usage: fontisan audit font.ttf --validate <profile>"
921
+ puts " fontisan audit font.ttf --validate true (all checks)"
922
+ end
923
+
924
+ # Exit with code 1 if the audit found error-level issues.
925
+ # For collections, aggregates across all faces.
926
+ def exit_on_validation_errors(result)
927
+ issues = if result.is_a?(Array)
928
+ result.flat_map(&:validation_issues)
929
+ else
930
+ result.validation_issues || []
931
+ end
932
+ has_errors = issues.any? { |i| %w[error fatal].include?(i.severity) }
933
+ return unless has_errors
934
+
935
+ error_count = issues.count { |i| %w[error fatal].include?(i.severity) }
936
+ warn "Audit found #{error_count} error(s) — exiting with code 1"
937
+ exit 1
938
+ end
939
+
813
940
  def serialize_report(report, format)
814
941
  format == :json ? report.to_json : report.to_yaml
815
942
  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.39"
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.39
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ribose Inc.
@@ -297,16 +297,20 @@ 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
313
+ - lib/fontisan/audit/checks/naming_consistency_check.rb
310
314
  - lib/fontisan/audit/checks/opentype_conformance_check.rb
311
315
  - lib/fontisan/audit/checks/os2_check.rb
312
316
  - lib/fontisan/audit/checks/ots_compatibility_check.rb