fontisan 0.4.34 → 0.4.36

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: 1c278ba38670bfc65e41805d14d6bc175ae451f0e5400e4b25036bf3ae6a84f6
4
- data.tar.gz: 59ec85b42b6879f589c13429e8e490a41eb32257530207647203c010ee461182
3
+ metadata.gz: 3957074ba3c679c22715fa1f1aabf4dbd3cc7334891f729d64988ec231237f3b
4
+ data.tar.gz: 18129617714b672947d8f39a88e7c06a7644e4ff12875c383321635a6e051377
5
5
  SHA512:
6
- metadata.gz: 17b9aeaeeb0589379b2ff65413aabb3db15c4d0dd323bd4891e4863a9dca2fdb7190fd795ebcd62fa16d6d570e7f7bb50c740b9947c0b42a5e673380e0164cd2
7
- data.tar.gz: c9b35dd44d43fc634b1bf07aa84d2bd99527e4695636424cbf46aca757615b09d6dc27f3512e701ab8676d1111f51f980dd97d0d0fd82c47f4177592f9b94c95
6
+ metadata.gz: 3315d7c7f3cd824dadcf4ecddc63319a5661ac13ebf8383dfab8f2eda3fffd2713a1a039df922d58073cc0b99615d0d608de5641f67528e48f2aa70678e5b8f1
7
+ data.tar.gz: 1bd98a99dc9902aeae6e5ceb3c40f16d01c7a61c16ae2c94f1c97f212602de2675602ab736545869640500dc49ac5f4bef6302bf3b43c34593071e94b513c448
data/.rubocop.yml CHANGED
@@ -116,7 +116,19 @@ Style/BitwisePredicate:
116
116
  Enabled: false
117
117
 
118
118
  # Allow short math-conventional parameter names in font geometry code.
119
- # Point#x / Point#y are domain-standard coordinate names; renaming to
120
- # x_coord/y_coord adds noise without aiding readers.
119
+ # Font geometry / binary parsing uses single-letter variables by domain
120
+ # convention: affine matrix (a,b,c,d,e,f), loop indices (i,j,k,m,n),
121
+ # coordinates (x,y,z), deltas (dx,dy). Renaming to 3+ chars adds noise
122
+ # without aiding readers familiar with the math.
121
123
  Naming/MethodParameterName:
122
- AllowedNames: ["x", "y", "cp", "gid", "n"]
124
+ AllowedNames: ["a", "b", "c", "d", "e", "f", "i", "j", "k", "m", "n",
125
+ "p", "r", "s", "t", "u", "v", "w", "x", "y", "z",
126
+ "cp", "gid", "dx", "dy", "fd", "io", "id"]
127
+
128
+ # Allow spec-format identifiers that match OpenType spec terminology:
129
+ # cmap format4, format6, format12, format14 are the official names.
130
+ # Renaming to format_4 diverges from the spec and makes docs harder
131
+ # to cross-reference.
132
+ Naming/VariableNumber:
133
+ AllowedIdentifiers: ["format0", "format4", "format6", "format12", "format14",
134
+ "version1", "version2", "table1", "table2"]
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 04:55:54 UTC using RuboCop version 1.86.1.
3
+ # on 2026-07-11 08:52:22 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,20 +32,13 @@ Layout/IndentationWidth:
32
32
  - 'lib/fontisan/commands/audit_command.rb'
33
33
  - 'lib/fontisan/ufo/compile/glyf_loca.rb'
34
34
 
35
- # Offense count: 1969
35
+ # Offense count: 2053
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
39
39
  Layout/LineLength:
40
40
  Enabled: false
41
41
 
42
- # Offense count: 2
43
- # This cop supports safe autocorrection (--autocorrect).
44
- # Configuration parameters: AllowInHeredoc.
45
- Layout/TrailingWhitespace:
46
- Exclude:
47
- - 'lib/fontisan/commands/audit_command.rb'
48
-
49
42
  # Offense count: 2
50
43
  Lint/CopDirectiveSyntax:
51
44
  Exclude:
@@ -118,7 +111,7 @@ Lint/UselessConstantScoping:
118
111
  - 'lib/fontisan/conversion_options.rb'
119
112
  - 'lib/fontisan/type1/charstrings.rb'
120
113
 
121
- # Offense count: 752
114
+ # Offense count: 766
122
115
  # Configuration parameters: AllowedMethods, AllowedPatterns, CountRepeatedAttributes, Max.
123
116
  Metrics/AbcSize:
124
117
  Enabled: false
@@ -141,12 +134,12 @@ Metrics/CollectionLiteralLength:
141
134
  - 'lib/fontisan/type1/agl.rb'
142
135
  - 'lib/fontisan/type1/encodings.rb'
143
136
 
144
- # Offense count: 375
137
+ # Offense count: 383
145
138
  # Configuration parameters: AllowedMethods, AllowedPatterns, Max.
146
139
  Metrics/CyclomaticComplexity:
147
140
  Enabled: false
148
141
 
149
- # Offense count: 1019
142
+ # Offense count: 1045
150
143
  # Configuration parameters: CountComments, CountAsOne, AllowedMethods, AllowedPatterns.
151
144
  Metrics/MethodLength:
152
145
  Max: 135
@@ -157,16 +150,28 @@ Metrics/ParameterLists:
157
150
  Max: 39
158
151
  MaxOptionalParameters: 4
159
152
 
160
- # Offense count: 289
153
+ # Offense count: 294
161
154
  # Configuration parameters: AllowedMethods, AllowedPatterns, Max.
162
155
  Metrics/PerceivedComplexity:
163
156
  Enabled: false
164
157
 
165
- # Offense count: 209
158
+ # Offense count: 34
166
159
  # Configuration parameters: MinNameLength, AllowNamesEndingInNumbers, AllowedNames, ForbiddenNames.
167
160
  # AllowedNames: as, at, by, cc, db, id, if, in, io, ip, of, on, os, pp, to
168
161
  Naming/MethodParameterName:
169
- Enabled: false
162
+ Exclude:
163
+ - 'lib/fontisan.rb'
164
+ - 'lib/fontisan/conversion_options.rb'
165
+ - 'lib/fontisan/optimizers/subroutine_optimizer.rb'
166
+ - 'lib/fontisan/svg_to_glyf/geometry/affine_transform.rb'
167
+ - 'lib/fontisan/svg_to_glyf/geometry/transform_parser.rb'
168
+ - 'lib/fontisan/type1/ttf_to_type1_converter.rb'
169
+ - 'lib/fontisan/ufo/compile/filters/cubic_to_quadratic.rb'
170
+ - 'lib/fontisan/ufo/convert.rb'
171
+ - 'lib/fontisan/variation/optimizer.rb'
172
+ - 'lib/fontisan/woff2/glyf_loca_reconstruct.rb'
173
+ - 'lib/fontisan/woff2/glyf_loca_transform.rb'
174
+ - 'spec/fontisan/subset/table_strategy/cff2_spec.rb'
170
175
 
171
176
  # Offense count: 13
172
177
  # Configuration parameters: Mode, AllowedMethods, AllowedPatterns, AllowBangMethods, WaywardPredicates.
@@ -228,7 +233,7 @@ RSpec/AnyInstance:
228
233
  RSpec/ContextWording:
229
234
  Enabled: false
230
235
 
231
- # Offense count: 40
236
+ # Offense count: 42
232
237
  # Configuration parameters: IgnoredMetadata.
233
238
  RSpec/DescribeClass:
234
239
  Enabled: false
@@ -244,7 +249,7 @@ RSpec/DescribeMethod:
244
249
  - 'spec/fontisan/type1/seac_expander_spec.rb'
245
250
  - 'spec/fontisan/type1_real_fonts_spec.rb'
246
251
 
247
- # Offense count: 1726
252
+ # Offense count: 1729
248
253
  # Configuration parameters: CountAsOne.
249
254
  RSpec/ExampleLength:
250
255
  Max: 66
@@ -322,7 +327,7 @@ RSpec/MultipleDescribes:
322
327
  - 'spec/fontisan/utils/thread_pool_spec.rb'
323
328
  - 'spec/fontisan/variation/cache_spec.rb'
324
329
 
325
- # Offense count: 2149
330
+ # Offense count: 2153
326
331
  RSpec/MultipleExpectations:
327
332
  Max: 19
328
333
 
@@ -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, OpenType layout, provenance; CLI `fontisan audit` with YAML/JSON output)
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)
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
 
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "lutaml/model"
4
+
5
+ module Fontisan
6
+ module Audit
7
+ # Abstract base for all audit checks. A check is a stateless
8
+ # function that examines a loaded font and returns an Array of
9
+ # {Models::ValidationReport::Issue} records. An empty array means
10
+ # the font passed the check.
11
+ #
12
+ # Subclasses MUST override {.call} and {.code}.
13
+ #
14
+ # Checks are intentionally stateless + side-effect free so they
15
+ # can be composed, run in parallel, and tested in isolation.
16
+ class Check
17
+ # Run the check against +font+.
18
+ #
19
+ # @param font [SfntFont, BaseCollection]
20
+ # @return [Array<Models::ValidationReport::Issue>]
21
+ def self.call(font)
22
+ raise NotImplementedError,
23
+ "#{name} must override .call(font) -> Array<Issue>"
24
+ end
25
+
26
+ # Unique short identifier for this check (e.g. :table_checksum).
27
+ # Used as the issue +category+ for programmatic filtering.
28
+ #
29
+ # @return [Symbol]
30
+ def self.code
31
+ raise NotImplementedError,
32
+ "#{name} must override .code -> Symbol"
33
+ end
34
+
35
+ # Build an issue. Centralizes the category so callers don't repeat it.
36
+ # @return [Models::ValidationReport::Issue]
37
+ def self.issue(severity:, message:, location: nil)
38
+ Models::ValidationReport::Issue.new(
39
+ severity: severity.to_s,
40
+ category: code.to_s,
41
+ message: message,
42
+ location: location,
43
+ )
44
+ end
45
+ end
46
+ end
47
+ end
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Fontisan
4
+ module Audit
5
+ # Central registry of audit checks. A profile is a named subset of
6
+ # checks (e.g. +:ots+, +:structural+). The +:default+ profile runs
7
+ # every registered check.
8
+ #
9
+ # Profiles let callers opt into specific validation axes without
10
+ # running everything — e.g. +fontisan audit font.ttf --validate ots+
11
+ # runs only the OTS compatibility check.
12
+ #
13
+ # @example Run all checks
14
+ # CheckRegistry.for(:default) # => [TableDirectoryCheck, ...]
15
+ #
16
+ # @example Run a specific profile
17
+ # CheckRegistry.for(:ots) # => [OtsCompatibilityCheck]
18
+ class CheckRegistry
19
+ PROFILES = {
20
+ default: %i[table_directory glyph_names cmap ots_compatibility
21
+ collection_integrity variable_font hinting woff2_validation
22
+ format_round_trip opentype_conformance],
23
+ structural: %i[table_directory collection_integrity opentype_conformance],
24
+ ots: %i[ots_compatibility],
25
+ layout: %i[glyph_names cmap],
26
+ variable: %i[variable_font],
27
+ hinting: %i[hinting],
28
+ web: %i[ots_compatibility woff2_validation],
29
+ spec: %i[opentype_conformance],
30
+ }.freeze
31
+
32
+ # @param profile [Symbol]
33
+ # @return [Array<Class>] check classes for the profile
34
+ def self.for(profile)
35
+ codes = PROFILES[profile] || PROFILES[:default]
36
+ codes.filter_map { |code| check_for(code) }
37
+ end
38
+
39
+ # @param code [Symbol]
40
+ # @return [Class, nil] the check class
41
+ def self.check_for(code)
42
+ case code
43
+ when :table_directory then Checks::TableDirectoryCheck
44
+ when :glyph_names then Checks::GlyphNameCheck
45
+ when :cmap then Checks::CmapCheck
46
+ when :ots_compatibility then Checks::OtsCompatibilityCheck
47
+ when :collection_integrity then Checks::CollectionIntegrityCheck
48
+ when :variable_font then Checks::VariableFontCheck
49
+ when :hinting then Checks::HintingCheck
50
+ when :woff2_validation then Checks::Woff2ValidationCheck
51
+ when :format_round_trip then Checks::FormatRoundTripCheck
52
+ when :opentype_conformance then Checks::OpenTypeConformanceCheck
53
+ end
54
+ end
55
+ end
56
+ end
57
+ end
@@ -0,0 +1,249 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "stringio"
4
+
5
+ module Fontisan
6
+ module Audit
7
+ module Checks
8
+ # Validates cmap subtable invariants per the OpenType spec:
9
+ #
10
+ # - At least one Unicode-encoded subtable is present
11
+ # - Format 4: segCountX2 must equal segCount × 2
12
+ # - Format 4: the last segment must have endCode 0xFFFF
13
+ # - Format 4: reservedPad must be 0
14
+ # - Format 12: groups must be sorted and non-overlapping
15
+ # - Format 12: each group startCharCode ≤ endCharCode
16
+ # - Subtable offsets must be within the cmap table bounds
17
+ #
18
+ # Catches the most common real-world cmap bugs that renderers and
19
+ # text shapers reject silently.
20
+ #
21
+ # @see https://learn.microsoft.com/en-us/typography/opentype/spec/cmap
22
+ class CmapCheck < Check
23
+ # @param font [SfntFont]
24
+ # @return [Array<Models::ValidationReport::Issue>]
25
+ def self.call(font)
26
+ return [] unless font.has_table?("cmap")
27
+
28
+ raw = font.table_data["cmap"]
29
+ return [] unless raw
30
+
31
+ issues = []
32
+ issues.concat(validate_header(raw))
33
+ return issues unless issues.empty?
34
+
35
+ records = read_encoding_records(raw)
36
+ issues.concat(validate_unicode_presence(records))
37
+ issues.concat(validate_subtables(raw, records))
38
+ issues
39
+ end
40
+
41
+ def self.code
42
+ :cmap
43
+ end
44
+
45
+ # ---------- header ----------
46
+
47
+ def self.validate_header(raw)
48
+ issues = []
49
+ version, num_tables = raw.unpack("nn")
50
+ if version != 0
51
+ issues << issue(severity: :error,
52
+ message: "cmap version is #{version} but must be 0",
53
+ location: "cmap.version")
54
+ end
55
+ if num_tables.zero?
56
+ issues << issue(severity: :error,
57
+ message: "cmap has no encoding records",
58
+ location: "cmap.numTables")
59
+ end
60
+ issues
61
+ end
62
+ private_class_method :validate_header
63
+
64
+ # ---------- encoding records ----------
65
+
66
+ def self.read_encoding_records(raw)
67
+ num_tables = raw.unpack1("x2n")
68
+ offset = 4
69
+ Array.new(num_tables) do |i|
70
+ break if offset + 8 > raw.bytesize
71
+
72
+ platform_id, encoding_id, subtable_offset = raw[offset, 8].unpack("nnN")
73
+ offset += 8
74
+ {
75
+ index: i,
76
+ platform_id: platform_id,
77
+ encoding_id: encoding_id,
78
+ subtable_offset: subtable_offset,
79
+ }
80
+ end
81
+ end
82
+ private_class_method :read_encoding_records
83
+
84
+ def self.validate_unicode_presence(records)
85
+ has_unicode = records.any? do |r|
86
+ unicode_platform?(r[:platform_id], r[:encoding_id])
87
+ end
88
+ return [] if has_unicode
89
+
90
+ [issue(severity: :warning,
91
+ message: "cmap has no Unicode-encoded subtable " \
92
+ "(expected platform 0 or 3 with encoding 1 or 10)",
93
+ location: "cmap.encoding_records")]
94
+ end
95
+ private_class_method :validate_unicode_presence
96
+
97
+ def self.unicode_platform?(platform_id, encoding_id)
98
+ platform_id.zero? ||
99
+ (platform_id == 3 && [1, 10].include?(encoding_id))
100
+ end
101
+ private_class_method :unicode_platform?
102
+
103
+ # ---------- per-subtable validation ----------
104
+
105
+ def self.validate_subtables(raw, records)
106
+ records.flat_map { |r| validate_one_subtable(raw, r) }
107
+ end
108
+ private_class_method :validate_subtables
109
+
110
+ def self.validate_one_subtable(raw, record)
111
+ off = record[:subtable_offset]
112
+ if off + 2 > raw.bytesize
113
+ return [issue(severity: :error,
114
+ message: "cmap subtable at record #{record[:index]} " \
115
+ "has offset #{off} beyond the table end",
116
+ location: "cmap.encoding_records.#{record[:index]}")]
117
+ end
118
+
119
+ format = raw[off, 2].unpack1("n")
120
+ case format
121
+ when 0 then validate_format0(raw, off, record)
122
+ when 4 then validate_format4(raw, off, record)
123
+ when 6 then validate_format6(raw, off, record)
124
+ when 12 then validate_format12(raw, off, record)
125
+ when 14 then [] # format 14 (Unicode Variation Sequences) — skip for now
126
+ else
127
+ [issue(severity: :warning,
128
+ message: "cmap subtable at record #{record[:index]} uses " \
129
+ "unknown format #{format}",
130
+ location: "cmap.encoding_records.#{record[:index]}")]
131
+ end
132
+ end
133
+ private_class_method :validate_one_subtable
134
+
135
+ # Format 4 validation: segCountX2 consistency, sentinel, reservedPad.
136
+ def self.validate_format4(raw, off, record)
137
+ issues = []
138
+ return issues unless off + 14 <= raw.bytesize
139
+
140
+ _format, _, _lang, seg_count_x2, _search, _sel, _shift,
141
+ = raw[off, 16].unpack("nnnnnnnn")
142
+ seg_count = seg_count_x2 / 2
143
+
144
+ if seg_count_x2.odd? || seg_count_x2 != seg_count * 2
145
+ issues << issue(severity: :error,
146
+ message: "cmap format 4 (record #{record[:index]}): " \
147
+ "segCountX2=#{seg_count_x2} is inconsistent " \
148
+ "with segCount=#{seg_count}",
149
+ location: "cmap.encoding_records.#{record[:index]}")
150
+ end
151
+
152
+ # Read the last endCode (the sentinel segment)
153
+ end_codes_start = off + 14
154
+ if seg_count.positive? && end_codes_start + (seg_count * 2) <= raw.bytesize
155
+ last_end = raw[end_codes_start + ((seg_count - 1) * 2), 2].unpack1("n")
156
+ if last_end != 0xFFFF
157
+ issues << issue(severity: :warning,
158
+ message: "cmap format 4 (record #{record[:index]}): " \
159
+ "last segment endCode is 0x#{last_end.to_s(16)} " \
160
+ "but should be 0xFFFF (sentinel)",
161
+ location: "cmap.encoding_records.#{record[:index]}")
162
+ end
163
+ end
164
+
165
+ # reservedPad check
166
+ reserved_pad_off = end_codes_start + (seg_count * 2)
167
+ if reserved_pad_off + 2 <= raw.bytesize
168
+ reserved_pad = raw[reserved_pad_off, 2].unpack1("n")
169
+ unless reserved_pad.zero?
170
+ issues << issue(severity: :error,
171
+ message: "cmap format 4 (record #{record[:index]}): " \
172
+ "reservedPad is #{reserved_pad} but must be 0",
173
+ location: "cmap.encoding_records.#{record[:index]}")
174
+ end
175
+ end
176
+
177
+ issues
178
+ end
179
+ private_class_method :validate_format4
180
+
181
+ # Format 12 validation: groups sorted, non-overlapping, ordered.
182
+ def self.validate_format12(raw, off, record)
183
+ issues = []
184
+ return issues unless off + 16 <= raw.bytesize
185
+
186
+ _format, _reserved, _length, _lang, num_groups = raw[off, 16].unpack("nnNnN")
187
+ groups_start = off + 16
188
+ return issues if num_groups.zero?
189
+ return issues unless groups_start + (num_groups * 12) <= raw.bytesize
190
+
191
+ prev_end = nil
192
+ num_groups.times do |g|
193
+ entry_off = groups_start + (g * 12)
194
+ start_cp, end_cp, _gid = raw[entry_off, 12].unpack("NNN")
195
+
196
+ if start_cp > end_cp
197
+ issues << issue(severity: :error,
198
+ message: "cmap format 12 (record #{record[:index]}): " \
199
+ "group #{g} has startCharCode 0x#{start_cp.to_s(16)} " \
200
+ "> endCharCode 0x#{end_cp.to_s(16)}",
201
+ location: "cmap.encoding_records.#{record[:index]}")
202
+ end
203
+
204
+ if prev_end && start_cp <= prev_end
205
+ issues << issue(severity: :error,
206
+ message: "cmap format 12 (record #{record[:index]}): " \
207
+ "group #{g} overlaps or is out of order " \
208
+ "(startCharCode 0x#{start_cp.to_s(16)} ≤ " \
209
+ "previous endCharCode 0x#{prev_end.to_s(16)})",
210
+ location: "cmap.encoding_records.#{record[:index]}")
211
+ end
212
+ prev_end = end_cp
213
+ end
214
+ issues
215
+ end
216
+ private_class_method :validate_format12
217
+
218
+ def self.validate_format0(raw, off, record)
219
+ # Format 0: 262 bytes (header 6 + 256-byte glyphIdArray)
220
+ if off + 262 > raw.bytesize
221
+ [issue(severity: :error,
222
+ message: "cmap format 0 (record #{record[:index]}): " \
223
+ "subtable is truncated (need 262 bytes)",
224
+ location: "cmap.encoding_records.#{record[:index]}")]
225
+ else
226
+ []
227
+ end
228
+ end
229
+ private_class_method :validate_format0
230
+
231
+ def self.validate_format6(raw, off, record)
232
+ return [] unless off + 10 <= raw.bytesize
233
+
234
+ _fmt, _len, _lang, first, count = raw[off, 10].unpack("nnnnn")
235
+ needed = off + 10 + (count * 2)
236
+ if needed > raw.bytesize
237
+ [issue(severity: :error,
238
+ message: "cmap format 6 (record #{record[:index]}): " \
239
+ "subtable truncated (first=#{first}, count=#{count})",
240
+ location: "cmap.encoding_records.#{record[:index]}")]
241
+ else
242
+ []
243
+ end
244
+ end
245
+ private_class_method :validate_format6
246
+ end
247
+ end
248
+ end
249
+ end
@@ -0,0 +1,102 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Fontisan
4
+ module Audit
5
+ module Checks
6
+ # Validates TrueType/OpenType Collection (TTC/OTC) structural
7
+ # integrity: TTC header, per-face SFNT offsets, shared-table
8
+ # deduplication. TTC is undertooled — CJK and Apple workflows
9
+ # ship fonts as collections and no widely-available validator
10
+ # covers the collection-specific invariants.
11
+ #
12
+ # Checks:
13
+ #
14
+ # - TTC tag must be 'ttcf'
15
+ # - header.majorVersion must be 1 or 2
16
+ # - numFonts must be positive and match the offset array length
17
+ # - every face offset must be > 0 and within the file
18
+ # - face offsets must not overlap the TTC header
19
+ # - (v2 only) DSIG tag/length must be null or point inside the file
20
+ #
21
+ # @see https://learn.microsoft.com/en-us/typography/opentype/spec/otff#ttc-header
22
+ class CollectionIntegrityCheck < Check
23
+ TTC_TAG = "ttcf"
24
+ VALID_MAJOR_VERSIONS = [1, 2].freeze
25
+
26
+ # @param font [BaseCollection, SfntFont] a collection or single font
27
+ # @return [Array<Models::ValidationReport::Issue>]
28
+ def self.call(font)
29
+ return [] unless collection?(font)
30
+
31
+ issues = []
32
+ issues.concat(validate_header(font))
33
+ issues.concat(validate_face_offsets(font))
34
+ issues
35
+ end
36
+
37
+ def self.code
38
+ :collection_integrity
39
+ end
40
+
41
+ def self.collection?(font)
42
+ font.is_a?(BaseCollection)
43
+ end
44
+ private_class_method :collection?
45
+
46
+ def self.validate_header(collection)
47
+ issues = []
48
+ tag = collection.tag.to_s
49
+
50
+ if tag != TTC_TAG
51
+ issues << issue(severity: :error,
52
+ message: "Collection tag is '#{tag}' but must be '#{TTC_TAG}'",
53
+ location: "ttc_header.tag")
54
+ end
55
+
56
+ version = collection.major_version.to_i
57
+ unless VALID_MAJOR_VERSIONS.include?(version)
58
+ issues << issue(severity: :error,
59
+ message: "Collection major version is #{version} " \
60
+ "but must be one of #{VALID_MAJOR_VERSIONS.join(', ')}",
61
+ location: "ttc_header.majorVersion")
62
+ end
63
+
64
+ num_fonts = collection.num_fonts.to_i
65
+ if num_fonts <= 0
66
+ issues << issue(severity: :error,
67
+ message: "Collection numFonts is #{num_fonts} " \
68
+ "but must be positive",
69
+ location: "ttc_header.numFonts")
70
+ end
71
+
72
+ if collection.font_offsets.length != num_fonts
73
+ issues << issue(severity: :error,
74
+ message: "Collection has #{num_fonts} fonts but " \
75
+ "#{collection.font_offsets.length} offsets " \
76
+ "(should match)",
77
+ location: "ttc_header.font_offsets")
78
+ end
79
+
80
+ issues
81
+ end
82
+ private_class_method :validate_header
83
+
84
+ def self.validate_face_offsets(collection)
85
+ header_end = 12 + (collection.num_fonts.to_i * 4) +
86
+ (collection.major_version.to_i == 2 ? 8 : 0)
87
+ collection.font_offsets.each_with_index.with_object([]) do |(off, idx), issues|
88
+ off_val = off.to_i
89
+ if off_val < header_end
90
+ issues << issue(severity: :error,
91
+ message: "Face #{idx} SFNT offset #{off_val} " \
92
+ "overlaps the TTC header " \
93
+ "(header ends at #{header_end})",
94
+ location: "ttc_header.font_offsets.#{idx}")
95
+ end
96
+ end
97
+ end
98
+ private_class_method :validate_face_offsets
99
+ end
100
+ end
101
+ end
102
+ end