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 +4 -4
- data/.rubocop.yml +15 -3
- data/.rubocop_todo.yml +23 -18
- data/TODO.improvements/README.md +1 -1
- data/lib/fontisan/audit/check.rb +47 -0
- data/lib/fontisan/audit/check_registry.rb +57 -0
- data/lib/fontisan/audit/checks/cmap_check.rb +249 -0
- data/lib/fontisan/audit/checks/collection_integrity_check.rb +102 -0
- data/lib/fontisan/audit/checks/format_round_trip_check.rb +150 -0
- data/lib/fontisan/audit/checks/glyph_name_check.rb +107 -0
- data/lib/fontisan/audit/checks/hinting_check.rb +163 -0
- data/lib/fontisan/audit/checks/opentype_conformance_check.rb +159 -0
- data/lib/fontisan/audit/checks/ots_compatibility_check.rb +191 -0
- data/lib/fontisan/audit/checks/table_directory_check.rb +157 -0
- data/lib/fontisan/audit/checks/variable_font_check.rb +151 -0
- data/lib/fontisan/audit/checks/woff2_validation_check.rb +140 -0
- data/lib/fontisan/audit/checks.rb +30 -0
- data/lib/fontisan/audit.rb +20 -0
- data/lib/fontisan/cli.rb +13 -0
- data/lib/fontisan/commands/audit_command.rb +36 -2
- data/lib/fontisan/models/audit_report.rb +5 -0
- data/lib/fontisan/version.rb +1 -1
- data/lib/fontisan.rb +1 -0
- metadata +15 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 3957074ba3c679c22715fa1f1aabf4dbd3cc7334891f729d64988ec231237f3b
|
|
4
|
+
data.tar.gz: 18129617714b672947d8f39a88e7c06a7644e4ff12875c383321635a6e051377
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
-
#
|
|
120
|
-
#
|
|
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: ["
|
|
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
|
|
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:
|
|
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:
|
|
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:
|
|
137
|
+
# Offense count: 383
|
|
145
138
|
# Configuration parameters: AllowedMethods, AllowedPatterns, Max.
|
|
146
139
|
Metrics/CyclomaticComplexity:
|
|
147
140
|
Enabled: false
|
|
148
141
|
|
|
149
|
-
# Offense count:
|
|
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:
|
|
153
|
+
# Offense count: 294
|
|
161
154
|
# Configuration parameters: AllowedMethods, AllowedPatterns, Max.
|
|
162
155
|
Metrics/PerceivedComplexity:
|
|
163
156
|
Enabled: false
|
|
164
157
|
|
|
165
|
-
# Offense count:
|
|
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
|
-
|
|
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:
|
|
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:
|
|
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:
|
|
330
|
+
# Offense count: 2153
|
|
326
331
|
RSpec/MultipleExpectations:
|
|
327
332
|
Max: 19
|
|
328
333
|
|
data/TODO.improvements/README.md
CHANGED
|
@@ -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,
|
|
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
|