fontisan 0.4.36 → 0.4.37

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: 3957074ba3c679c22715fa1f1aabf4dbd3cc7334891f729d64988ec231237f3b
4
- data.tar.gz: 18129617714b672947d8f39a88e7c06a7644e4ff12875c383321635a6e051377
3
+ metadata.gz: 615c64e2005d769c01c4b7a5cd153630b42f5230c55a0242f892aaec65618320
4
+ data.tar.gz: deca6b51aba40a45b2150290fe5ef0bd94b935f4fd09c54da54e65bf8cde3194
5
5
  SHA512:
6
- metadata.gz: 3315d7c7f3cd824dadcf4ecddc63319a5661ac13ebf8383dfab8f2eda3fffd2713a1a039df922d58073cc0b99615d0d608de5641f67528e48f2aa70678e5b8f1
7
- data.tar.gz: 1bd98a99dc9902aeae6e5ceb3c40f16d01c7a61c16ae2c94f1c97f212602de2675602ab736545869640500dc49ac5f4bef6302bf3b43c34593071e94b513c448
6
+ metadata.gz: dc66734c722fd5fc959d80c95f41b701ecb7679926e85d6bbf09f9aab84c5ea96eb94f558ee241f40b88c999f21a811d9d45a306f35ec7b5cc61b188c053851e
7
+ data.tar.gz: 6ceebcf686badd81a1d934f8f2d03b607f29522bef598e85cd363c33d16a94f9df6d3fd048ebce38dfce103e5dc55efb7df6cbd55016220e564e4ca833490dd8
@@ -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, OpenType conformance foundation; CLI `fontisan audit --validate` with profiles: default, structural, ots, layout, variable, hinting, web, spec)
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)
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
 
@@ -16,17 +16,24 @@ module Fontisan
16
16
  # @example Run a specific profile
17
17
  # CheckRegistry.for(:ots) # => [OtsCompatibilityCheck]
18
18
  class CheckRegistry
19
+ # Per-table OT conformance checks (axis 14 — full OT spec coverage).
20
+ OT_TABLE_CHECKS = %i[ot_head ot_hhea ot_maxp ot_os2 ot_name ot_post
21
+ ot_kern].freeze
22
+
19
23
  PROFILES = {
20
24
  default: %i[table_directory glyph_names cmap ots_compatibility
21
25
  collection_integrity variable_font hinting woff2_validation
22
- format_round_trip opentype_conformance],
26
+ format_round_trip opentype_conformance
27
+ ot_head ot_hhea ot_maxp ot_os2 ot_name ot_post ot_kern],
23
28
  structural: %i[table_directory collection_integrity opentype_conformance],
24
29
  ots: %i[ots_compatibility],
25
30
  layout: %i[glyph_names cmap],
26
31
  variable: %i[variable_font],
27
32
  hinting: %i[hinting],
28
33
  web: %i[ots_compatibility woff2_validation],
29
- spec: %i[opentype_conformance],
34
+ spec: %i[opentype_conformance ot_head ot_hhea ot_maxp ot_os2
35
+ ot_name ot_post ot_kern],
36
+ per_table: OT_TABLE_CHECKS,
30
37
  }.freeze
31
38
 
32
39
  # @param profile [Symbol]
@@ -50,6 +57,13 @@ module Fontisan
50
57
  when :woff2_validation then Checks::Woff2ValidationCheck
51
58
  when :format_round_trip then Checks::FormatRoundTripCheck
52
59
  when :opentype_conformance then Checks::OpenTypeConformanceCheck
60
+ when :ot_head then Checks::HeadCheck
61
+ when :ot_hhea then Checks::HheaCheck
62
+ when :ot_maxp then Checks::MaxpCheck
63
+ when :ot_os2 then Checks::Os2Check
64
+ when :ot_name then Checks::NameTableCheck
65
+ when :ot_post then Checks::PostCheck
66
+ when :ot_kern then Checks::KernCheck
53
67
  end
54
68
  end
55
69
  end
@@ -0,0 +1,96 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Fontisan
4
+ module Audit
5
+ module Checks
6
+ # Validates the 'head' table per the OpenType spec.
7
+ #
8
+ # @see https://learn.microsoft.com/en-us/typography/opentype/spec/head
9
+ class HeadCheck < Check
10
+ HEAD_MAGIC = 0x5F0F3CF5
11
+ MIN_UPM = 16
12
+ MAX_UPM = 16_384
13
+ MAC_STYLE_DEFINED_BITS = 0x00FF
14
+ MAC_STYLE_RESERVED_BITS = 0xFF00
15
+
16
+ def self.call(font)
17
+ return [] unless font.has_table?("head")
18
+
19
+ head = font.table("head")
20
+ issues = []
21
+ issues.concat(validate_magic(head))
22
+ issues.concat(validate_upm(head))
23
+ issues.concat(validate_loc_format(head))
24
+ issues.concat(validate_dates(head))
25
+ issues.concat(validate_mac_style(head))
26
+ issues
27
+ end
28
+
29
+ def self.code
30
+ :ot_head
31
+ end
32
+
33
+ def self.validate_magic(head)
34
+ return [] if head.magic_number == HEAD_MAGIC
35
+
36
+ [issue(severity: :error,
37
+ message: "head.magicNumber is 0x#{head.magic_number.to_i.to_s(16)} " \
38
+ "but must be 0x#{HEAD_MAGIC.to_s(16)}",
39
+ location: "head.magic_number")]
40
+ end
41
+ private_class_method :validate_magic
42
+
43
+ def self.validate_upm(head)
44
+ upm = head.units_per_em.to_i
45
+ return [] if upm.between?(MIN_UPM, MAX_UPM)
46
+
47
+ [issue(severity: :error,
48
+ message: "head.unitsPerEm is #{upm} but must be between " \
49
+ "#{MIN_UPM} and #{MAX_UPM}",
50
+ location: "head.units_per_em")]
51
+ end
52
+ private_class_method :validate_upm
53
+
54
+ def self.validate_loc_format(head)
55
+ fmt = head.index_to_loc_format.to_i
56
+ return [] if [0, 1].include?(fmt)
57
+
58
+ [issue(severity: :error,
59
+ message: "head.indexToLocFormat is #{fmt} but must be 0 " \
60
+ "(short) or 1 (long)",
61
+ location: "head.index_to_loc_format")]
62
+ end
63
+ private_class_method :validate_loc_format
64
+
65
+ def self.validate_dates(head)
66
+ issues = []
67
+ if head.created_raw.to_i.zero?
68
+ issues << issue(severity: :info,
69
+ message: "head.created is 0 — creation date not set",
70
+ location: "head.created")
71
+ end
72
+ if head.modified_raw.to_i.zero?
73
+ issues << issue(severity: :info,
74
+ message: "head.modified is 0 — modification date not set",
75
+ location: "head.modified")
76
+ end
77
+ issues
78
+ end
79
+ private_class_method :validate_dates
80
+
81
+ def self.validate_mac_style(head)
82
+ style = head.mac_style.to_i
83
+ issues = []
84
+ unless (style & MAC_STYLE_RESERVED_BITS).zero?
85
+ issues << issue(severity: :warning,
86
+ message: "head.macStyle uses reserved bits " \
87
+ "(0x#{style.to_s(16)}, bits 8-15 must be 0)",
88
+ location: "head.mac_style")
89
+ end
90
+ issues
91
+ end
92
+ private_class_method :validate_mac_style
93
+ end
94
+ end
95
+ end
96
+ end
@@ -0,0 +1,68 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Fontisan
4
+ module Audit
5
+ module Checks
6
+ # Validates the 'hhea' table per the OpenType spec.
7
+ #
8
+ # @see https://learn.microsoft.com/en-us/typography/opentype/spec/hhea
9
+ class HheaCheck < Check
10
+ def self.call(font)
11
+ return [] unless font.has_table?("hhea")
12
+
13
+ hhea = font.table("hhea")
14
+ issues = []
15
+ issues.concat(validate_ascent(hhea))
16
+ issues.concat(validate_descent(hhea))
17
+ issues.concat(validate_line_gap(hhea))
18
+ issues.concat(validate_advance_width_max(hhea))
19
+ issues
20
+ end
21
+
22
+ def self.code
23
+ :ot_hhea
24
+ end
25
+
26
+ def self.validate_ascent(hhea)
27
+ return [] if hhea.ascent.to_i.positive?
28
+
29
+ [issue(severity: :warning,
30
+ message: "hhea.ascent is #{hhea.ascent} but should be positive " \
31
+ "(ascender typically rises above the baseline)",
32
+ location: "hhea.ascent")]
33
+ end
34
+ private_class_method :validate_ascent
35
+
36
+ def self.validate_descent(hhea)
37
+ return [] if hhea.descent.to_i <= 0
38
+
39
+ [issue(severity: :warning,
40
+ message: "hhea.descent is #{hhea.descent} but should be ≤ 0 " \
41
+ "(descender typically falls below the baseline)",
42
+ location: "hhea.descent")]
43
+ end
44
+ private_class_method :validate_descent
45
+
46
+ def self.validate_line_gap(hhea)
47
+ return [] if hhea.line_gap.to_i >= 0
48
+
49
+ [issue(severity: :info,
50
+ message: "hhea.lineGap is #{hhea.line_gap} — negative line " \
51
+ "gap is unusual but not spec-prohibited",
52
+ location: "hhea.line_gap")]
53
+ end
54
+ private_class_method :validate_line_gap
55
+
56
+ def self.validate_advance_width_max(hhea)
57
+ return [] unless hhea.advance_width_max.to_i <= 0
58
+
59
+ [issue(severity: :warning,
60
+ message: "hhea.advanceWidthMax is #{hhea.advance_width_max} " \
61
+ "but should be positive (the widest glyph advance)",
62
+ location: "hhea.advance_width_max")]
63
+ end
64
+ private_class_method :validate_advance_width_max
65
+ end
66
+ end
67
+ end
68
+ end
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Fontisan
4
+ module Audit
5
+ module Checks
6
+ # Validates the 'kern' table if present. The kern table is
7
+ # deprecated by the OpenType spec — GPOS is the replacement.
8
+ # Many legacy fonts still ship kern; this check warns about
9
+ # deprecation and validates basic structure.
10
+ #
11
+ # @see https://learn.microsoft.com/en-us/typography/opentype/spec/kern
12
+ class KernCheck < Check
13
+ def self.call(font)
14
+ return [] unless font.has_table?("kern")
15
+
16
+ issues = [deprecation_warning]
17
+ issues.concat(validate_gpos_presence(font))
18
+ issues
19
+ end
20
+
21
+ def self.code
22
+ :ot_kern
23
+ end
24
+
25
+ def self.deprecation_warning
26
+ issue(severity: :warning,
27
+ message: "kern table is present but deprecated by the " \
28
+ "OpenType spec — use GPOS instead. Some modern " \
29
+ "renderers (HarfBuzz, Core Text) ignore kern",
30
+ location: "tables.kern")
31
+ end
32
+ private_class_method :deprecation_warning
33
+
34
+ def self.validate_gpos_presence(font)
35
+ return [] if font.has_table?("GPOS")
36
+
37
+ [issue(severity: :warning,
38
+ message: "kern table present but no GPOS table — " \
39
+ "kerning data should be migrated to a GPOS " \
40
+ "'kern' feature for full renderer support",
41
+ location: "tables.GPOS")]
42
+ end
43
+ private_class_method :validate_gpos_presence
44
+ end
45
+ end
46
+ end
47
+ end
@@ -0,0 +1,85 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Fontisan
4
+ module Audit
5
+ module Checks
6
+ # Validates the 'maxp' table per the OpenType spec.
7
+ #
8
+ # @see https://learn.microsoft.com/en-us/typography/opentype/spec/maxp
9
+ class MaxpCheck < Check
10
+ MAX_GLYPHS = 0xFFFF
11
+ MAX_STACK_ELEMENTS = 1000
12
+
13
+ def self.call(font)
14
+ return [] unless font.has_table?("maxp")
15
+
16
+ maxp = font.table("maxp")
17
+ issues = []
18
+ issues.concat(validate_version(maxp, font))
19
+ issues.concat(validate_num_glyphs(maxp))
20
+ issues.concat(validate_hint_capacity(maxp))
21
+ issues
22
+ end
23
+
24
+ def self.code
25
+ :ot_maxp
26
+ end
27
+
28
+ def self.validate_version(maxp, font)
29
+ version_raw = maxp.version_raw
30
+ is_truetype = font.has_table?("glyf")
31
+ tt_version = 0x00010000 # 1.0 as Fixed 16.16
32
+ cff_version = 0x00005000 # 0.5 as Fixed 16.16
33
+ if is_truetype && version_raw != tt_version
34
+ [issue(severity: :error,
35
+ message: "maxp.version is 0x#{version_raw.to_s(16)} but must " \
36
+ "be 1.0 (0x#{tt_version.to_s(16)}) for TrueType fonts",
37
+ location: "maxp.version")]
38
+ elsif !is_truetype && version_raw != cff_version
39
+ [issue(severity: :warning,
40
+ message: "maxp.version is 0x#{version_raw.to_s(16)} but " \
41
+ "should be 0.5 (0x#{cff_version.to_s(16)}) for " \
42
+ "CFF-based fonts",
43
+ location: "maxp.version")]
44
+ else
45
+ []
46
+ end
47
+ end
48
+ private_class_method :validate_version
49
+
50
+ def self.validate_num_glyphs(maxp)
51
+ n = maxp.num_glyphs.to_i
52
+ return [] if n.between?(1, MAX_GLYPHS)
53
+
54
+ [issue(severity: :error,
55
+ message: "maxp.numGlyphs is #{n} but must be between 1 and " \
56
+ "#{MAX_GLYPHS}",
57
+ location: "maxp.num_glyphs")]
58
+ end
59
+ private_class_method :validate_num_glyphs
60
+
61
+ def self.validate_hint_capacity(maxp)
62
+ return [] unless maxp.version_1_0?
63
+
64
+ issues = []
65
+ zones = maxp.max_zones.to_i
66
+ unless zones.between?(1, 2)
67
+ issues << issue(severity: :warning,
68
+ message: "maxp.maxZones is #{zones} but must be 1 or 2 " \
69
+ "per the OpenType spec",
70
+ location: "maxp.max_zones")
71
+ end
72
+ stack = maxp.max_stack_elements.to_i
73
+ if stack > MAX_STACK_ELEMENTS
74
+ issues << issue(severity: :warning,
75
+ message: "maxp.maxStackElements is #{stack} — " \
76
+ "excessively large values waste interpreter memory",
77
+ location: "maxp.max_stack_elements")
78
+ end
79
+ issues
80
+ end
81
+ private_class_method :validate_hint_capacity
82
+ end
83
+ end
84
+ end
85
+ end
@@ -0,0 +1,78 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Fontisan
4
+ module Audit
5
+ module Checks
6
+ # Validates the 'name' table per the OpenType spec.
7
+ #
8
+ # Covers: required nameIDs, PostScript name character validity,
9
+ # version string format. Glyph-name rules are in GlyphNameCheck.
10
+ #
11
+ # @see https://learn.microsoft.com/en-us/typography/opentype/spec/name
12
+ class NameTableCheck < Check
13
+ REQUIRED_NAME_IDS = {
14
+ 1 => "Family",
15
+ 2 => "Subfamily",
16
+ 4 => "Full",
17
+ 6 => "PostScript",
18
+ }.freeze
19
+ PS_NAME_PATTERN = /\A[A-Za-z][A-Za-z0-9._-]*\z/
20
+
21
+ def self.call(font)
22
+ return [] unless font.has_table?("name")
23
+
24
+ name = font.table("name")
25
+ issues = []
26
+ issues.concat(validate_required_ids(name))
27
+ issues.concat(validate_ps_name(name))
28
+ issues.concat(validate_version_string(name))
29
+ issues
30
+ end
31
+
32
+ def self.code
33
+ :ot_name
34
+ end
35
+
36
+ def self.validate_required_ids(name)
37
+ REQUIRED_NAME_IDS.each_with_object([]) do |(id, label), issues|
38
+ val = name.english_name(id).to_s
39
+ next unless val.empty?
40
+
41
+ issues << issue(severity: :error,
42
+ message: "Required name ID #{id} (#{label}) is " \
43
+ "missing from the name table",
44
+ location: "name.nameID.#{id}")
45
+ end
46
+ end
47
+ private_class_method :validate_required_ids
48
+
49
+ def self.validate_ps_name(name)
50
+ ps = name.english_name(6).to_s
51
+ return [] if ps.empty? || ps.match?(PS_NAME_PATTERN)
52
+
53
+ [issue(severity: :warning,
54
+ message: "PostScript name '#{ps}' contains characters outside " \
55
+ "the OT spec grammar (must start with a letter, then " \
56
+ "A–Z a–z 0–9 . - _)",
57
+ location: "name.nameID.6")]
58
+ end
59
+ private_class_method :validate_ps_name
60
+
61
+ def self.validate_version_string(name)
62
+ ver = name.english_name(5).to_s
63
+ return [] if ver.empty?
64
+
65
+ unless ver.match?(/\AVersion\s/i)
66
+ return [issue(severity: :info,
67
+ message: "name ID 5 (version) is '#{ver}' but should " \
68
+ "start with 'Version ' per OT spec convention",
69
+ location: "name.nameID.5")]
70
+ end
71
+
72
+ []
73
+ end
74
+ private_class_method :validate_version_string
75
+ end
76
+ end
77
+ end
78
+ end
@@ -3,44 +3,25 @@
3
3
  module Fontisan
4
4
  module Audit
5
5
  module Checks
6
- # Foundational OpenType spec conformance checks. Covers the
7
- # most commonly-violated "MUST" and "SHOULD" rules from the OT
8
- # spec that other check domains don't already cover.
6
+ # Foundational cross-table OpenType spec conformance checks.
7
+ # Per-table rules live in their own check modules
8
+ # ({HeadCheck}, {HheaCheck}, {Os2Check}, {NameTableCheck},
9
+ # {PostCheck}, {KernCheck}) to keep each concern MECE.
9
10
  #
10
- # This check is intentionally a growing foundation new OT spec
11
- # rules are added incrementally as real-world fonts surface them.
12
- # Full OT conformance is a long-running effort that tracks the
13
- # spec's evolution (axis 14 per TODO #03).
14
- #
15
- # Current checks:
11
+ # This check owns the rules that span multiple tables:
16
12
  #
17
13
  # - Required tables for each sfnt flavor (TTF vs CFF vs variable)
18
- # - head.indexToLocFormat consistency with loca table size
19
- # - name table must have at least family (1), subfamily (2),
20
- # full (4), PostScript (6) name IDs
21
- # - OS/2 fsSelection must not use reserved bits
14
+ # - head.indexToLocFormat consistency with loca table byte size
22
15
  # - hhea.numberOfHMetrics must be ≤ maxp.numGlyphs
23
- # - post table version must be valid
24
- # - cmap must have at least one Unicode subtable (already
25
- # covered by CmapCheck — skipped here to avoid duplicate issues)
26
16
  #
27
17
  # @see https://learn.microsoft.com/en-us/typography/opentype/spec/otff
28
18
  class OpenTypeConformanceCheck < Check
29
- REQUIRED_NAME_IDS = {
30
- 1 => "Family",
31
- 2 => "Subfamily",
32
- 4 => "Full",
33
- 6 => "PostScript",
34
- }.freeze
35
-
36
19
  # @param font [SfntFont]
37
20
  # @return [Array<Models::ValidationReport::Issue>]
38
21
  def self.call(font)
39
22
  issues = []
40
23
  issues.concat(validate_required_tables(font))
41
24
  issues.concat(validate_loca_format(font))
42
- issues.concat(validate_name_coverage(font))
43
- issues.concat(validate_os2_fsselection(font))
44
25
  issues.concat(validate_hhea_metrics(font))
45
26
  issues
46
27
  end
@@ -103,41 +84,6 @@ module Fontisan
103
84
  end
104
85
  private_class_method :validate_loca_format
105
86
 
106
- # ---------- name table coverage ----------
107
-
108
- def self.validate_name_coverage(font)
109
- return [] unless font.has_table?("name")
110
-
111
- name = font.table("name")
112
- REQUIRED_NAME_IDS.each_with_object([]) do |(id, label), issues|
113
- val = name.english_name(id).to_s
114
- next unless val.empty?
115
-
116
- issues << issue(severity: :error,
117
- message: "Required name ID #{id} (#{label}) is " \
118
- "missing from the name table",
119
- location: "name.nameID.#{id}")
120
- end
121
- end
122
- private_class_method :validate_name_coverage
123
-
124
- # ---------- OS/2 fsSelection reserved bits ----------
125
-
126
- def self.validate_os2_fsselection(font)
127
- return [] unless font.has_table?("OS/2")
128
-
129
- os2 = font.table("OS/2")
130
- fs = os2.fs_selection.to_i
131
- reserved_mask = 0xFC00 # bits 10-15 are reserved
132
- return [] if (fs & reserved_mask).zero?
133
-
134
- [issue(severity: :warning,
135
- message: "OS/2 fsSelection uses reserved bits " \
136
- "(value 0x#{fs.to_s(16)}, bits 10-15 must be 0)",
137
- location: "os2.fs_selection")]
138
- end
139
- private_class_method :validate_os2_fsselection
140
-
141
87
  # ---------- hhea.numberOfHMetrics ----------
142
88
 
143
89
  def self.validate_hhea_metrics(font)
@@ -0,0 +1,117 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Fontisan
4
+ module Audit
5
+ module Checks
6
+ # Validates the 'OS/2' table per the OpenType spec.
7
+ #
8
+ # @see https://learn.microsoft.com/en-us/typography/opentype/spec/os2
9
+ class Os2Check < Check
10
+ FS_SELECTION_RESERVED = 0xFC00
11
+
12
+ def self.call(font)
13
+ return [] unless font.has_table?("OS/2")
14
+
15
+ os2 = font.table("OS/2")
16
+ issues = []
17
+ issues.concat(validate_weight_class(os2))
18
+ issues.concat(validate_width_class(os2))
19
+ issues.concat(validate_fs_selection(os2))
20
+ issues.concat(validate_typo_metrics(os2))
21
+ issues.concat(validate_win_metrics(os2))
22
+ issues.concat(validate_panose(os2))
23
+ issues
24
+ end
25
+
26
+ def self.code
27
+ :ot_os2
28
+ end
29
+
30
+ def self.validate_weight_class(os2)
31
+ w = os2.us_weight_class.to_i
32
+ return [] if w.between?(1, 1000)
33
+
34
+ [issue(severity: :error,
35
+ message: "OS/2.usWeightClass is #{w} but must be between " \
36
+ "1 and 1000",
37
+ location: "os2.us_weight_class")]
38
+ end
39
+ private_class_method :validate_weight_class
40
+
41
+ def self.validate_width_class(os2)
42
+ w = os2.us_width_class.to_i
43
+ return [] if w.between?(1, 9)
44
+
45
+ [issue(severity: :error,
46
+ message: "OS/2.usWidthClass is #{w} but must be between 1 " \
47
+ "and 9",
48
+ location: "os2.us_width_class")]
49
+ end
50
+ private_class_method :validate_width_class
51
+
52
+ def self.validate_fs_selection(os2)
53
+ fs = os2.fs_selection.to_i
54
+ return [] if (fs & FS_SELECTION_RESERVED).zero?
55
+
56
+ [issue(severity: :warning,
57
+ message: "OS/2.fsSelection uses reserved bits " \
58
+ "(0x#{fs.to_s(16)}, bits 10-15 must be 0)",
59
+ location: "os2.fs_selection")]
60
+ end
61
+ private_class_method :validate_fs_selection
62
+
63
+ def self.validate_typo_metrics(os2)
64
+ issues = []
65
+ asc = os2.s_typo_ascender.to_i
66
+ desc = os2.s_typo_descender.to_i
67
+ if asc <= 0
68
+ issues << issue(severity: :warning,
69
+ message: "OS/2.sTypoAscender is #{asc} but should " \
70
+ "be positive",
71
+ location: "os2.s_typo_ascender")
72
+ end
73
+ if desc.positive?
74
+ issues << issue(severity: :warning,
75
+ message: "OS/2.sTypoDescender is #{desc} but should " \
76
+ "be ≤ 0",
77
+ location: "os2.s_typo_descender")
78
+ end
79
+ issues
80
+ end
81
+ private_class_method :validate_typo_metrics
82
+
83
+ def self.validate_win_metrics(os2)
84
+ issues = []
85
+ if os2.us_win_ascent.to_i <= 0
86
+ issues << issue(severity: :warning,
87
+ message: "OS/2.usWinAscent is #{os2.us_win_ascent} " \
88
+ "but should be positive",
89
+ location: "os2.us_win_ascent")
90
+ end
91
+ if os2.us_win_descent.to_i <= 0
92
+ issues << issue(severity: :warning,
93
+ message: "OS/2.usWinDescent is #{os2.us_win_descent} " \
94
+ "but should be positive",
95
+ location: "os2.us_win_descent")
96
+ end
97
+ issues
98
+ end
99
+ private_class_method :validate_win_metrics
100
+
101
+ def self.validate_panose(os2)
102
+ panose = os2.panose
103
+ return [] unless panose
104
+
105
+ all_zero = panose.to_a.all?(&:zero?)
106
+ return [] unless all_zero
107
+
108
+ [issue(severity: :info,
109
+ message: "OS/2.panose is all zeros — PANOSE classification " \
110
+ "helps font matching in some applications",
111
+ location: "os2.panose")]
112
+ end
113
+ private_class_method :validate_panose
114
+ end
115
+ end
116
+ end
117
+ end
@@ -0,0 +1,75 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Fontisan
4
+ module Audit
5
+ module Checks
6
+ # Validates the 'post' table per the OpenType spec.
7
+ #
8
+ # @see https://learn.microsoft.com/en-us/typography/opentype/spec/post
9
+ class PostCheck < Check
10
+ VALID_VERSIONS = [1.0, 2.0, 2.5, 3.0, 4.0].freeze
11
+
12
+ def self.call(font)
13
+ return [] unless font.has_table?("post")
14
+
15
+ post = font.table("post")
16
+ head = font.has_table?("head") ? font.table("head") : nil
17
+ issues = []
18
+ issues.concat(validate_version(post))
19
+ issues.concat(validate_italic_angle(post, head))
20
+ issues.concat(validate_is_fixed_pitch(post))
21
+ issues
22
+ end
23
+
24
+ def self.code
25
+ :ot_post
26
+ end
27
+
28
+ def self.validate_version(post)
29
+ version = post.version.to_f
30
+ return [] if VALID_VERSIONS.include?(version)
31
+
32
+ [issue(severity: :error,
33
+ message: "post.version is #{version} but must be one of " \
34
+ "#{VALID_VERSIONS.join(', ')}",
35
+ location: "post.version")]
36
+ end
37
+ private_class_method :validate_version
38
+
39
+ def self.validate_italic_angle(post, head)
40
+ angle = post.italic_angle.to_f
41
+ issues = []
42
+ if head&.mac_style
43
+ is_italic = (head.mac_style.to_i & 0x02).positive?
44
+ if is_italic && angle >= 0
45
+ issues << issue(severity: :warning,
46
+ message: "post.italicAngle is #{angle} but " \
47
+ "head.macStyle indicates italic " \
48
+ "(angle should be negative)",
49
+ location: "post.italic_angle")
50
+ elsif !is_italic && angle != 0
51
+ issues << issue(severity: :info,
52
+ message: "post.italicAngle is #{angle} but " \
53
+ "head.macStyle does not indicate italic " \
54
+ "(angle should be 0)",
55
+ location: "post.italic_angle")
56
+ end
57
+ end
58
+ issues
59
+ end
60
+ private_class_method :validate_italic_angle
61
+
62
+ def self.validate_is_fixed_pitch(post)
63
+ val = post.is_fixed_pitch.to_i
64
+ return [] if [0, 1].include?(val)
65
+
66
+ [issue(severity: :warning,
67
+ message: "post.isFixedPitch is #{val} but must be 0 " \
68
+ "(proportional) or 1 (monospace)",
69
+ location: "post.is_fixed_pitch")]
70
+ end
71
+ private_class_method :validate_is_fixed_pitch
72
+ end
73
+ end
74
+ end
75
+ end
@@ -25,6 +25,13 @@ module Fontisan
25
25
  "fontisan/audit/checks/format_round_trip_check"
26
26
  autoload :OpenTypeConformanceCheck,
27
27
  "fontisan/audit/checks/opentype_conformance_check"
28
+ autoload :HeadCheck, "fontisan/audit/checks/head_check"
29
+ autoload :HheaCheck, "fontisan/audit/checks/hhea_check"
30
+ autoload :MaxpCheck, "fontisan/audit/checks/maxp_check"
31
+ autoload :Os2Check, "fontisan/audit/checks/os2_check"
32
+ autoload :NameTableCheck, "fontisan/audit/checks/name_table_check"
33
+ autoload :PostCheck, "fontisan/audit/checks/post_check"
34
+ autoload :KernCheck, "fontisan/audit/checks/kern_check"
28
35
  end
29
36
  end
30
37
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Fontisan
4
- VERSION = "0.4.36"
4
+ VERSION = "0.4.37"
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.36
4
+ version: 0.4.37
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ribose Inc.
@@ -301,9 +301,16 @@ files:
301
301
  - lib/fontisan/audit/checks/collection_integrity_check.rb
302
302
  - lib/fontisan/audit/checks/format_round_trip_check.rb
303
303
  - lib/fontisan/audit/checks/glyph_name_check.rb
304
+ - lib/fontisan/audit/checks/head_check.rb
305
+ - lib/fontisan/audit/checks/hhea_check.rb
304
306
  - lib/fontisan/audit/checks/hinting_check.rb
307
+ - lib/fontisan/audit/checks/kern_check.rb
308
+ - lib/fontisan/audit/checks/maxp_check.rb
309
+ - lib/fontisan/audit/checks/name_table_check.rb
305
310
  - lib/fontisan/audit/checks/opentype_conformance_check.rb
311
+ - lib/fontisan/audit/checks/os2_check.rb
306
312
  - lib/fontisan/audit/checks/ots_compatibility_check.rb
313
+ - lib/fontisan/audit/checks/post_check.rb
307
314
  - lib/fontisan/audit/checks/table_directory_check.rb
308
315
  - lib/fontisan/audit/checks/variable_font_check.rb
309
316
  - lib/fontisan/audit/checks/woff2_validation_check.rb