@intentic/fileq 1.317.0 → 1.318.0
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.
- package/NOTICE +208 -0
- package/README.md +24 -3
- package/dist/app.d.ts.map +1 -1
- package/dist/app.js +15 -1
- package/dist/app.js.map +1 -1
- package/dist/commands/check.command.d.ts +3 -0
- package/dist/commands/check.command.d.ts.map +1 -0
- package/dist/commands/check.command.js +80 -0
- package/dist/commands/check.command.js.map +1 -0
- package/dist/commands/render.command.d.ts +3 -0
- package/dist/commands/render.command.d.ts.map +1 -0
- package/dist/commands/render.command.js +67 -0
- package/dist/commands/render.command.js.map +1 -0
- package/dist/lib/check/check.d.ts +15 -0
- package/dist/lib/check/check.d.ts.map +1 -0
- package/dist/lib/check/check.js +35 -0
- package/dist/lib/check/check.js.map +1 -0
- package/dist/lib/check/docx.d.ts +3 -0
- package/dist/lib/check/docx.d.ts.map +1 -0
- package/dist/lib/check/docx.js +329 -0
- package/dist/lib/check/docx.js.map +1 -0
- package/dist/lib/check/finding.d.ts +20 -0
- package/dist/lib/check/finding.d.ts.map +1 -0
- package/dist/lib/check/finding.js +27 -0
- package/dist/lib/check/finding.js.map +1 -0
- package/dist/lib/check/package.d.ts +25 -0
- package/dist/lib/check/package.d.ts.map +1 -0
- package/dist/lib/check/package.js +139 -0
- package/dist/lib/check/package.js.map +1 -0
- package/dist/lib/check/pdf.d.ts +3 -0
- package/dist/lib/check/pdf.d.ts.map +1 -0
- package/dist/lib/check/pdf.js +160 -0
- package/dist/lib/check/pdf.js.map +1 -0
- package/dist/lib/check/pptx.d.ts +3 -0
- package/dist/lib/check/pptx.d.ts.map +1 -0
- package/dist/lib/check/pptx.js +497 -0
- package/dist/lib/check/pptx.js.map +1 -0
- package/dist/lib/check/text-fit.d.ts +18 -0
- package/dist/lib/check/text-fit.d.ts.map +1 -0
- package/dist/lib/check/text-fit.js +69 -0
- package/dist/lib/check/text-fit.js.map +1 -0
- package/dist/lib/check/xlsx.d.ts +3 -0
- package/dist/lib/check/xlsx.d.ts.map +1 -0
- package/dist/lib/check/xlsx.js +131 -0
- package/dist/lib/check/xlsx.js.map +1 -0
- package/dist/lib/check/xml-tree.d.ts +15 -0
- package/dist/lib/check/xml-tree.d.ts.map +1 -0
- package/dist/lib/check/xml-tree.js +131 -0
- package/dist/lib/check/xml-tree.js.map +1 -0
- package/dist/lib/derivers/image.d.ts.map +1 -1
- package/dist/lib/derivers/image.js.map +1 -1
- package/dist/lib/formats.d.ts +1 -0
- package/dist/lib/formats.d.ts.map +1 -1
- package/dist/lib/formats.js +1 -0
- package/dist/lib/formats.js.map +1 -1
- package/dist/lib/render/convert.d.ts +11 -0
- package/dist/lib/render/convert.d.ts.map +1 -0
- package/dist/lib/render/convert.js +107 -0
- package/dist/lib/render/convert.js.map +1 -0
- package/dist/lib/render/pages.d.ts +11 -0
- package/dist/lib/render/pages.d.ts.map +1 -0
- package/dist/lib/render/pages.js +43 -0
- package/dist/lib/render/pages.js.map +1 -0
- package/dist/lib/render/render.d.ts +29 -0
- package/dist/lib/render/render.d.ts.map +1 -0
- package/dist/lib/render/render.js +142 -0
- package/dist/lib/render/render.js.map +1 -0
- package/dist/skill-authoring.d.ts +2 -0
- package/dist/skill-authoring.d.ts.map +1 -0
- package/dist/skill-authoring.js +20 -0
- package/dist/skill-authoring.js.map +1 -0
- package/dist/skill.d.ts.map +1 -1
- package/dist/skill.js +24 -1
- package/dist/skill.js.map +1 -1
- package/dist/testing.d.ts +67 -0
- package/dist/testing.d.ts.map +1 -1
- package/dist/testing.js +151 -0
- package/dist/testing.js.map +1 -1
- package/package.json +20 -7
- package/src/app.ts +15 -1
- package/src/cli.integration.test.ts +63 -1
- package/src/commands/check.command.ts +97 -0
- package/src/commands/render.command.ts +80 -0
- package/src/lib/check/check.test.ts +446 -0
- package/src/lib/check/check.ts +49 -0
- package/src/lib/check/docx.ts +434 -0
- package/src/lib/check/finding.ts +63 -0
- package/src/lib/check/package.ts +199 -0
- package/src/lib/check/pdf.ts +228 -0
- package/src/lib/check/pptx.ts +641 -0
- package/src/lib/check/text-fit.ts +105 -0
- package/src/lib/check/xlsx.ts +163 -0
- package/src/lib/check/xml-tree.ts +186 -0
- package/src/lib/derivers/image.ts +2 -1
- package/src/lib/formats.ts +6 -0
- package/src/lib/render/convert.ts +133 -0
- package/src/lib/render/pages.test.ts +38 -0
- package/src/lib/render/pages.ts +58 -0
- package/src/lib/render/render.ts +198 -0
- package/src/render.integration.test.ts +217 -0
- package/src/skill-authoring.ts +27 -0
- package/src/skill.ts +28 -1
- package/src/testing.ts +287 -0
- package/dist/lib/flags.d.ts +0 -2
- package/dist/lib/flags.d.ts.map +0 -1
- package/dist/lib/flags.js +0 -8
- package/dist/lib/flags.js.map +0 -1
- package/dist/version.d.ts +0 -2
- package/dist/version.d.ts.map +0 -1
- package/dist/version.js +0 -3
- package/dist/version.js.map +0 -1
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
import { posix } from "node:path";
|
|
2
|
+
import { unzipSync, type UnzipFileInfo } from "fflate";
|
|
3
|
+
import { error, type Finding } from "./finding.js";
|
|
4
|
+
import { childrenNamed, parseXml, type XmlElement } from "./xml-tree.js";
|
|
5
|
+
|
|
6
|
+
// An OOXML package (docx, pptx, xlsx) opened for checking: the zip's names, its XML parts parsed on demand, and the
|
|
7
|
+
// relationships that tie them together. A missing target or content type is the damage Office meets with "found a
|
|
8
|
+
// problem with content… repair?", so both are read off here once for every format.
|
|
9
|
+
// Only XML parts are inflated up front, each under a cap: a check must not be the thing a zip bomb takes down, and the
|
|
10
|
+
// media it needs (an image's pixel size) is inflated by name later.
|
|
11
|
+
|
|
12
|
+
const MAX_XML_PART_BYTES = 64 * 1024 * 1024;
|
|
13
|
+
const MAX_XML_TOTAL_BYTES = 256 * 1024 * 1024;
|
|
14
|
+
|
|
15
|
+
export interface Relationship {
|
|
16
|
+
readonly id: string;
|
|
17
|
+
/** The relationship type's last segment: `image`, `slide`, `hyperlink`, `slideLayout`. */
|
|
18
|
+
readonly kind: string;
|
|
19
|
+
/** A part name for an internal target (`ppt/media/image1.png`), the raw target for an external one. */
|
|
20
|
+
readonly target: string;
|
|
21
|
+
readonly external: boolean;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface OoxmlPackage {
|
|
25
|
+
readonly names: ReadonlySet<string>;
|
|
26
|
+
/** Parts left unread because they are over the cap, for the report's notes. */
|
|
27
|
+
readonly skipped: readonly string[];
|
|
28
|
+
readonly has: (part: string) => boolean;
|
|
29
|
+
/** A part's parsed tree; undefined when absent or skipped. */
|
|
30
|
+
readonly xml: (part: string) => XmlElement | undefined;
|
|
31
|
+
/** A part's text; undefined when absent or skipped. */
|
|
32
|
+
readonly text: (part: string) => string | undefined;
|
|
33
|
+
/** The relationships of a part, by id (`""` for the package's own). */
|
|
34
|
+
readonly rels: (part: string) => ReadonlyMap<string, Relationship>;
|
|
35
|
+
/** Inflates the named binary parts (media), each under the part cap. */
|
|
36
|
+
readonly binaries: (parts: readonly string[]) => Readonly<Record<string, Uint8Array>>;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export class NotAPackage extends Error {}
|
|
40
|
+
|
|
41
|
+
const isXmlPart = (name: string): boolean => name.endsWith(".xml") || name.endsWith(".rels") || name.endsWith(".vml");
|
|
42
|
+
|
|
43
|
+
export const openPackage = (bytes: Uint8Array): OoxmlPackage => {
|
|
44
|
+
const names = new Set<string>();
|
|
45
|
+
const skipped: string[] = [];
|
|
46
|
+
let budget = MAX_XML_TOTAL_BYTES;
|
|
47
|
+
let entries: Record<string, Uint8Array>;
|
|
48
|
+
try {
|
|
49
|
+
entries = unzipSync(bytes, {
|
|
50
|
+
filter: (file: UnzipFileInfo) => {
|
|
51
|
+
names.add(file.name);
|
|
52
|
+
if (!isXmlPart(file.name)) {
|
|
53
|
+
return false;
|
|
54
|
+
}
|
|
55
|
+
if (file.originalSize > MAX_XML_PART_BYTES || file.originalSize > budget) {
|
|
56
|
+
skipped.push(file.name);
|
|
57
|
+
return false;
|
|
58
|
+
}
|
|
59
|
+
budget -= file.originalSize;
|
|
60
|
+
return true;
|
|
61
|
+
},
|
|
62
|
+
});
|
|
63
|
+
} catch (cause) {
|
|
64
|
+
throw new NotAPackage(`not a readable zip package (${cause instanceof Error ? cause.message : String(cause)})`);
|
|
65
|
+
}
|
|
66
|
+
const decoder = new TextDecoder();
|
|
67
|
+
const texts = new Map<string, string>();
|
|
68
|
+
const trees = new Map<string, XmlElement>();
|
|
69
|
+
const relsCache = new Map<string, ReadonlyMap<string, Relationship>>();
|
|
70
|
+
const text = (part: string): string | undefined => {
|
|
71
|
+
const cached = texts.get(part);
|
|
72
|
+
if (cached !== undefined) {
|
|
73
|
+
return cached;
|
|
74
|
+
}
|
|
75
|
+
const raw = entries[part];
|
|
76
|
+
if (raw === undefined) {
|
|
77
|
+
return undefined;
|
|
78
|
+
}
|
|
79
|
+
const decoded = decoder.decode(raw);
|
|
80
|
+
texts.set(part, decoded);
|
|
81
|
+
return decoded;
|
|
82
|
+
};
|
|
83
|
+
const xml = (part: string): XmlElement | undefined => {
|
|
84
|
+
const cached = trees.get(part);
|
|
85
|
+
if (cached !== undefined) {
|
|
86
|
+
return cached;
|
|
87
|
+
}
|
|
88
|
+
const source = text(part);
|
|
89
|
+
if (source === undefined) {
|
|
90
|
+
return undefined;
|
|
91
|
+
}
|
|
92
|
+
const tree = parseXml(source);
|
|
93
|
+
trees.set(part, tree);
|
|
94
|
+
return tree;
|
|
95
|
+
};
|
|
96
|
+
const has = (part: string): boolean => names.has(part);
|
|
97
|
+
const rels = (part: string): ReadonlyMap<string, Relationship> => {
|
|
98
|
+
const cached = relsCache.get(part);
|
|
99
|
+
if (cached !== undefined) {
|
|
100
|
+
return cached;
|
|
101
|
+
}
|
|
102
|
+
const parsed = new Map<string, Relationship>();
|
|
103
|
+
const tree = xml(relsPartOf(part));
|
|
104
|
+
for (const relationship of tree === undefined ? [] : childrenNamed(tree, "rel:Relationship")) {
|
|
105
|
+
const id = relationship.attrs["Id"];
|
|
106
|
+
const rawTarget = relationship.attrs["Target"];
|
|
107
|
+
if (id === undefined || rawTarget === undefined) {
|
|
108
|
+
continue;
|
|
109
|
+
}
|
|
110
|
+
const external = relationship.attrs["TargetMode"] === "External";
|
|
111
|
+
parsed.set(id, {
|
|
112
|
+
id,
|
|
113
|
+
kind: (relationship.attrs["Type"] ?? "").split("/").at(-1) ?? "",
|
|
114
|
+
target: external ? rawTarget : resolveTarget(names, part, rawTarget),
|
|
115
|
+
external,
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
relsCache.set(part, parsed);
|
|
119
|
+
return parsed;
|
|
120
|
+
};
|
|
121
|
+
const binaries = (parts: readonly string[]): Readonly<Record<string, Uint8Array>> => {
|
|
122
|
+
const wanted = new Set(parts);
|
|
123
|
+
if (wanted.size === 0) {
|
|
124
|
+
return {};
|
|
125
|
+
}
|
|
126
|
+
return unzipSync(bytes, { filter: (file) => wanted.has(file.name) && file.originalSize <= MAX_XML_PART_BYTES });
|
|
127
|
+
};
|
|
128
|
+
return { names, skipped, has, xml, text, rels, binaries };
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
/** The package's main part, as its root relationships name it; `fallback` when they do not. */
|
|
132
|
+
export const mainPartOf = (pkg: OoxmlPackage, fallback: string): string =>
|
|
133
|
+
[...pkg.rels("").values()].find((relationship) => relationship.kind === "officeDocument" && !relationship.external)?.target ?? fallback;
|
|
134
|
+
|
|
135
|
+
/** Where a part's relationships live: `ppt/slides/slide1.xml` → `ppt/slides/_rels/slide1.xml.rels`. */
|
|
136
|
+
export const relsPartOf = (part: string): string => (part === "" ? "_rels/.rels" : `${posix.dirname(part)}/_rels/${posix.basename(part)}.rels`.replace(/^\.\//, ""));
|
|
137
|
+
|
|
138
|
+
// A target is relative to its source part's folder, or absolute from the package root; percent-encoding is how a
|
|
139
|
+
// writer spells a space, and the zip may hold either spelling.
|
|
140
|
+
const resolveTarget = (names: ReadonlySet<string>, source: string, target: string): string => {
|
|
141
|
+
const base = target.startsWith("/") ? target.slice(1) : posix.normalize(posix.join(posix.dirname(source), target)).replace(/^\.\//, "");
|
|
142
|
+
if (names.has(base)) {
|
|
143
|
+
return base;
|
|
144
|
+
}
|
|
145
|
+
try {
|
|
146
|
+
return decodeURIComponent(base);
|
|
147
|
+
} catch {
|
|
148
|
+
return base;
|
|
149
|
+
}
|
|
150
|
+
};
|
|
151
|
+
|
|
152
|
+
const DAMAGED = "Office will offer to repair the file, and may drop what it cannot place";
|
|
153
|
+
|
|
154
|
+
/** Internal relationships of `part` whose target is not in the package, as errors. */
|
|
155
|
+
export const missingTargetFindings = (pkg: OoxmlPackage, part: string, where: string, ignore: ReadonlySet<string> = new Set()): Finding[] =>
|
|
156
|
+
[...pkg.rels(part).values()]
|
|
157
|
+
.filter((relationship) => !relationship.external && !ignore.has(relationship.id) && !pkg.has(relationship.target))
|
|
158
|
+
.map((relationship) =>
|
|
159
|
+
error(
|
|
160
|
+
"missing-part",
|
|
161
|
+
where,
|
|
162
|
+
`${relationship.kind} ${relationship.id} points to ${relationship.target}, which is not in the file: ${DAMAGED}`,
|
|
163
|
+
),
|
|
164
|
+
);
|
|
165
|
+
|
|
166
|
+
// A part's extension as [Content_Types].xml keys it: `_rels/.rels` is "rels", where path.extname would say "".
|
|
167
|
+
const extensionOf = (name: string): string => {
|
|
168
|
+
const base = posix.basename(name);
|
|
169
|
+
const dot = base.lastIndexOf(".");
|
|
170
|
+
return dot === -1 ? "" : base.slice(dot + 1).toLowerCase();
|
|
171
|
+
};
|
|
172
|
+
|
|
173
|
+
/** Package-level damage: no content type for a part, or no main part where the package root says there is one. */
|
|
174
|
+
export const packageFindings = (pkg: OoxmlPackage, mainPart: string): Finding[] => {
|
|
175
|
+
const findings: Finding[] = [];
|
|
176
|
+
const types = pkg.xml("[Content_Types].xml");
|
|
177
|
+
if (types === undefined) {
|
|
178
|
+
return [error("missing-part", "", `the file has no [Content_Types].xml: ${DAMAGED}`)];
|
|
179
|
+
}
|
|
180
|
+
if (!pkg.has(mainPart)) {
|
|
181
|
+
findings.push(error("missing-part", "", `the file has no ${mainPart}, so nothing can open it as this format`));
|
|
182
|
+
}
|
|
183
|
+
const defaults = new Set(childrenNamed(types, "Default").map((entry) => (entry.attrs["Extension"] ?? "").toLowerCase()));
|
|
184
|
+
const overrides = new Set(childrenNamed(types, "Override").map((entry) => (entry.attrs["PartName"] ?? "").replace(/^\//, "")));
|
|
185
|
+
const untyped = [...pkg.names].filter(
|
|
186
|
+
(name) => !name.endsWith("/") && name !== "[Content_Types].xml" && !overrides.has(name) && !defaults.has(extensionOf(name)),
|
|
187
|
+
);
|
|
188
|
+
if (untyped.length > 0) {
|
|
189
|
+
const shown = untyped.slice(0, 3).join(", ");
|
|
190
|
+
findings.push(
|
|
191
|
+
error(
|
|
192
|
+
"content-type",
|
|
193
|
+
"",
|
|
194
|
+
`no content type for ${untyped.length === 1 ? "part" : `${untyped.length} parts`} ${shown}${untyped.length > 3 ? ", …" : ""}: add a Default for the extension in [Content_Types].xml; ${DAMAGED}`,
|
|
195
|
+
),
|
|
196
|
+
);
|
|
197
|
+
}
|
|
198
|
+
return findings;
|
|
199
|
+
};
|
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
// Structural checks over a PDF: blank pages, text set in fonts the file does not carry, internal links to nowhere, a
|
|
2
|
+
// page size that changes partway, and a document with no text layer at all. Read through pdf.js (unpdf), which the
|
|
3
|
+
// deriver already loads: a page's operator list says whether anything is drawn on it and in which fonts.
|
|
4
|
+
//
|
|
5
|
+
// The blank-page and unembedded-font rules are adapted from SurfSense's PDF artifact verification
|
|
6
|
+
// (surfsense_backend/app/artifacts/verification/formats/pdf.py, Copyright (c) SurfSense, Apache-2.0,
|
|
7
|
+
// https://github.com/MODSetter/SurfSense; see this package's NOTICE). Changed here: a page is blank only when nothing
|
|
8
|
+
// at all is drawn on it (a cover image with no text is not), and the fourteen standard PDF fonts, which every reader
|
|
9
|
+
// carries, are never reported as missing.
|
|
10
|
+
import { getDocumentProxy, getResolvedPDFJS } from "unpdf";
|
|
11
|
+
import { error, plural, warning, type CheckReport, type Finding } from "./finding.js";
|
|
12
|
+
|
|
13
|
+
// Past this, the rest of a long PDF is left unchecked and the notes say so.
|
|
14
|
+
const MAX_PAGES = 500;
|
|
15
|
+
const POINTS_PER_INCH = 72;
|
|
16
|
+
|
|
17
|
+
const STANDARD_FONTS = new Set([
|
|
18
|
+
"Helvetica",
|
|
19
|
+
"Helvetica-Bold",
|
|
20
|
+
"Helvetica-Oblique",
|
|
21
|
+
"Helvetica-BoldOblique",
|
|
22
|
+
"Times-Roman",
|
|
23
|
+
"Times-Bold",
|
|
24
|
+
"Times-Italic",
|
|
25
|
+
"Times-BoldItalic",
|
|
26
|
+
"Courier",
|
|
27
|
+
"Courier-Bold",
|
|
28
|
+
"Courier-Oblique",
|
|
29
|
+
"Courier-BoldOblique",
|
|
30
|
+
"Symbol",
|
|
31
|
+
"ZapfDingbats",
|
|
32
|
+
]);
|
|
33
|
+
|
|
34
|
+
type Operators = Awaited<ReturnType<typeof getResolvedPDFJS>>["OPS"];
|
|
35
|
+
|
|
36
|
+
// The operator codes that draw: text, and paths and images. pdf.js is resolved when a PDF is checked, never at load:
|
|
37
|
+
// every fileq run imports this module, and pdf.js is the largest thing it could pull in.
|
|
38
|
+
interface Painters {
|
|
39
|
+
readonly setFont: number;
|
|
40
|
+
readonly constructPath: number;
|
|
41
|
+
readonly text: ReadonlySet<number>;
|
|
42
|
+
readonly paint: ReadonlySet<number>;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const paintersOf = (OPS: Operators): Painters => ({
|
|
46
|
+
setFont: OPS.setFont,
|
|
47
|
+
constructPath: OPS.constructPath,
|
|
48
|
+
text: new Set([OPS.showText, OPS.showSpacedText, OPS.nextLineShowText, OPS.nextLineSetSpacingShowText]),
|
|
49
|
+
paint: new Set([
|
|
50
|
+
OPS.stroke,
|
|
51
|
+
OPS.closeStroke,
|
|
52
|
+
OPS.fill,
|
|
53
|
+
OPS.eoFill,
|
|
54
|
+
OPS.fillStroke,
|
|
55
|
+
OPS.eoFillStroke,
|
|
56
|
+
OPS.closeFillStroke,
|
|
57
|
+
OPS.closeEOFillStroke,
|
|
58
|
+
OPS.shadingFill,
|
|
59
|
+
OPS.rawFillPath,
|
|
60
|
+
OPS.paintImageXObject,
|
|
61
|
+
OPS.paintInlineImageXObject,
|
|
62
|
+
OPS.paintInlineImageXObjectGroup,
|
|
63
|
+
OPS.paintImageXObjectRepeat,
|
|
64
|
+
OPS.paintImageMaskXObject,
|
|
65
|
+
OPS.paintImageMaskXObjectGroup,
|
|
66
|
+
OPS.paintImageMaskXObjectRepeat,
|
|
67
|
+
OPS.paintSolidColorImageMask,
|
|
68
|
+
]),
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
type PdfDocument = Awaited<ReturnType<typeof getDocumentProxy>>;
|
|
72
|
+
type PdfPage = Awaited<ReturnType<PdfDocument["getPage"]>>;
|
|
73
|
+
|
|
74
|
+
interface PageScan {
|
|
75
|
+
readonly characters: number;
|
|
76
|
+
readonly painted: boolean;
|
|
77
|
+
/** Base names of the fonts that drew visible text and are not in the file. */
|
|
78
|
+
readonly missingFonts: readonly string[];
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// What this module reads of pdf.js's untyped operator arguments, fonts and annotations, declared once at the boundary.
|
|
82
|
+
// A glyph run is glyph objects with numbers (spacing adjustments) between them.
|
|
83
|
+
interface Glyph {
|
|
84
|
+
readonly unicode?: string;
|
|
85
|
+
}
|
|
86
|
+
type Operand = string | number | null | readonly (Glyph | number)[];
|
|
87
|
+
interface PdfFont {
|
|
88
|
+
readonly name?: string;
|
|
89
|
+
/** pdf.js found no font program in the file and fell back to a system font. */
|
|
90
|
+
readonly missingFile?: boolean;
|
|
91
|
+
}
|
|
92
|
+
interface PdfAnnotation {
|
|
93
|
+
readonly subtype?: string;
|
|
94
|
+
/** A named destination, an explicit one (an array), or none. */
|
|
95
|
+
readonly dest?: string | readonly Operand[] | null;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const isGlyph = (item: Glyph | number): item is Glyph => typeof item !== "number";
|
|
99
|
+
|
|
100
|
+
const visibleCharacters = (run: readonly (Glyph | number)[]): number => run.filter((item) => isGlyph(item) && /\S/.test(item.unicode ?? "")).length;
|
|
101
|
+
|
|
102
|
+
interface UsedFont {
|
|
103
|
+
/** The base font name, a subset's six-letter prefix removed. */
|
|
104
|
+
readonly name: string;
|
|
105
|
+
readonly missing: boolean;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const fontOf = (page: PdfPage, id: string | undefined): UsedFont | undefined => {
|
|
109
|
+
if (id === undefined) {
|
|
110
|
+
return undefined;
|
|
111
|
+
}
|
|
112
|
+
let font: PdfFont | undefined;
|
|
113
|
+
try {
|
|
114
|
+
font = page.commonObjs.get(id);
|
|
115
|
+
} catch {
|
|
116
|
+
// allow(silent-catch): pdf.js throws for a font it never resolved, and a font never loaded drew nothing.
|
|
117
|
+
return undefined;
|
|
118
|
+
}
|
|
119
|
+
return { name: (font?.name ?? "").replace(/^[A-Z]{6}\+/, ""), missing: font?.missingFile === true };
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
const scanPage = async (page: PdfPage, ops: Painters): Promise<PageScan> => {
|
|
123
|
+
const list = await page.getOperatorList();
|
|
124
|
+
const operands: readonly (readonly Operand[] | null)[] = list.argsArray;
|
|
125
|
+
let characters = 0;
|
|
126
|
+
let painted = false;
|
|
127
|
+
let currentFont: string | undefined;
|
|
128
|
+
const missing = new Set<string>();
|
|
129
|
+
list.fnArray.forEach((op, index) => {
|
|
130
|
+
const first = operands[index]?.[0];
|
|
131
|
+
if (op === ops.setFont) {
|
|
132
|
+
currentFont = first === undefined || first === null ? undefined : String(first);
|
|
133
|
+
} else if (ops.text.has(op)) {
|
|
134
|
+
const count = Array.isArray(first) ? visibleCharacters(first) : 0;
|
|
135
|
+
characters += count;
|
|
136
|
+
const font = count > 0 ? fontOf(page, currentFont) : undefined;
|
|
137
|
+
if (font?.missing === true && font.name !== "" && !STANDARD_FONTS.has(font.name)) {
|
|
138
|
+
missing.add(font.name);
|
|
139
|
+
}
|
|
140
|
+
} else if (ops.paint.has(op) || (op === ops.constructPath && ops.paint.has(Number(first)))) {
|
|
141
|
+
painted = true;
|
|
142
|
+
}
|
|
143
|
+
});
|
|
144
|
+
return { characters, painted: painted || characters > 0, missingFonts: [...missing] };
|
|
145
|
+
};
|
|
146
|
+
|
|
147
|
+
// A link whose named destination the file does not define goes nowhere when clicked.
|
|
148
|
+
const linkFindings = async (pdf: PdfDocument, page: PdfPage, number: number): Promise<Finding[]> => {
|
|
149
|
+
const annotations: readonly PdfAnnotation[] = await page.getAnnotations();
|
|
150
|
+
const findings: Finding[] = [];
|
|
151
|
+
for (const annotation of annotations) {
|
|
152
|
+
const dest = annotation.dest;
|
|
153
|
+
if (annotation.subtype !== "Link" || dest === undefined || dest === null || Array.isArray(dest)) {
|
|
154
|
+
continue;
|
|
155
|
+
}
|
|
156
|
+
const named = String(dest);
|
|
157
|
+
// allow(silent-catch): pdf.js throws where the file's destination tree is damaged, and then the link goes nowhere too.
|
|
158
|
+
const target = await pdf.getDestination(named).catch(() => null);
|
|
159
|
+
if (target === null) {
|
|
160
|
+
findings.push(error("broken-link", `page ${number}`, `a link goes to "${named}", a destination the file does not define, so clicking it does nothing`));
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
return findings;
|
|
164
|
+
};
|
|
165
|
+
|
|
166
|
+
const sizeLabel = (width: number, height: number): string =>
|
|
167
|
+
`${Number((width / POINTS_PER_INCH).toFixed(2))} × ${Number((height / POINTS_PER_INCH).toFixed(2))} in`;
|
|
168
|
+
|
|
169
|
+
const pageList = (numbers: readonly number[]): string => (numbers.length > 6 ? `${numbers.slice(0, 6).join(", ")}, …` : numbers.join(", "));
|
|
170
|
+
|
|
171
|
+
const openPdf = async (bytes: Uint8Array): Promise<PdfDocument | CheckReport> => {
|
|
172
|
+
try {
|
|
173
|
+
// Errors only: pdf.js otherwise narrates its recovery of a damaged file on the console, into the report.
|
|
174
|
+
return await getDocumentProxy(bytes, { verbosity: 0 });
|
|
175
|
+
} catch (cause) {
|
|
176
|
+
const reason = cause instanceof Error && cause.name === "PasswordException" ? "it is password-protected, so nothing past the password can be checked" : `it is not a readable PDF (${cause instanceof Error ? cause.message : String(cause)})`;
|
|
177
|
+
return { format: "pdf", findings: [error("unreadable", "", reason)], notes: [] };
|
|
178
|
+
}
|
|
179
|
+
};
|
|
180
|
+
|
|
181
|
+
export const checkPdf = async (bytes: Uint8Array): Promise<CheckReport> => {
|
|
182
|
+
const pdf = await openPdf(bytes);
|
|
183
|
+
if ("findings" in pdf) {
|
|
184
|
+
return pdf;
|
|
185
|
+
}
|
|
186
|
+
const ops = paintersOf((await getResolvedPDFJS()).OPS);
|
|
187
|
+
const findings: Finding[] = [];
|
|
188
|
+
const notes: string[] = [];
|
|
189
|
+
const total = pdf.numPages;
|
|
190
|
+
if (total > MAX_PAGES) {
|
|
191
|
+
notes.push(`checked the first ${MAX_PAGES} of ${total} pages`);
|
|
192
|
+
}
|
|
193
|
+
const sizes = new Map<string, number[]>();
|
|
194
|
+
const unembedded = new Map<string, number[]>();
|
|
195
|
+
let characters = 0;
|
|
196
|
+
for (let number = 1; number <= Math.min(total, MAX_PAGES); number += 1) {
|
|
197
|
+
const page = await pdf.getPage(number);
|
|
198
|
+
const scan = await scanPage(page, ops);
|
|
199
|
+
characters += scan.characters;
|
|
200
|
+
if (!scan.painted) {
|
|
201
|
+
findings.push(error("blank-page", `page ${number}`, "the page is blank: nothing is drawn on it"));
|
|
202
|
+
}
|
|
203
|
+
for (const font of scan.missingFonts) {
|
|
204
|
+
unembedded.set(font, [...(unembedded.get(font) ?? []), number]);
|
|
205
|
+
}
|
|
206
|
+
const [x0 = 0, y0 = 0, x1 = 0, y1 = 0] = page.view;
|
|
207
|
+
const turned = page.rotate % 180 !== 0;
|
|
208
|
+
const label = turned ? sizeLabel(y1 - y0, x1 - x0) : sizeLabel(x1 - x0, y1 - y0);
|
|
209
|
+
sizes.set(label, [...(sizes.get(label) ?? []), number]);
|
|
210
|
+
findings.push(...(await linkFindings(pdf, page, number)));
|
|
211
|
+
}
|
|
212
|
+
for (const [font, pages] of unembedded) {
|
|
213
|
+
findings.push(
|
|
214
|
+
warning("font-not-embedded", `page ${pageList(pages)}`, `text is set in ${font}, which the file does not carry: a reader without that font substitutes another, and lines can break differently; embed it`),
|
|
215
|
+
);
|
|
216
|
+
}
|
|
217
|
+
if (sizes.size > 1) {
|
|
218
|
+
const [common] = [...sizes.entries()].toSorted((a, b) => b[1].length - a[1].length);
|
|
219
|
+
const odd = [...sizes.entries()].filter(([label]) => label !== common?.[0]);
|
|
220
|
+
for (const [label, pages] of odd) {
|
|
221
|
+
findings.push(warning("page-size", `page ${pageList(pages)}`, `${plural(pages.length, "page is", "pages are")} ${label}, the rest ${common?.[0] ?? "another size"}`));
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
if (characters === 0 && total > 0) {
|
|
225
|
+
findings.push(warning("no-text-layer", "", "no page has a text layer, so its words cannot be searched, copied or read aloud"));
|
|
226
|
+
}
|
|
227
|
+
return { format: "pdf", extent: plural(total, "page"), findings, notes };
|
|
228
|
+
};
|