@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,105 @@
|
|
|
1
|
+
// Estimates how much room a run of text needs, from character classes rather than the font's own metrics. Good enough
|
|
2
|
+
// to say "about nine lines of 24 pt will not fit in a box 1.2 in tall", never good enough to say it for certain: the
|
|
3
|
+
// real answer depends on the font the viewer substitutes, which is why every finding built on this is a warning and
|
|
4
|
+
// points at `fileq render` for the look that settles it.
|
|
5
|
+
|
|
6
|
+
export interface Paragraph {
|
|
7
|
+
readonly text: string;
|
|
8
|
+
readonly sizePt: number;
|
|
9
|
+
readonly bold: boolean;
|
|
10
|
+
/** Multiple of single spacing (1 for single); ignored when `lineHeightPt` gives the height in points. */
|
|
11
|
+
readonly lineSpacing: number;
|
|
12
|
+
/** Exact line height in points when the paragraph sets one. */
|
|
13
|
+
readonly lineHeightPt?: number | undefined;
|
|
14
|
+
readonly spaceBeforePt: number;
|
|
15
|
+
readonly spaceAfterPt: number;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// Average advance of a character as a fraction of the font size, by class. Calibrated against Arial/Liberation Sans,
|
|
19
|
+
// where running English text averages about half an em per character.
|
|
20
|
+
const widthEm = (char: string): number => {
|
|
21
|
+
if (/\s/.test(char)) {
|
|
22
|
+
return 0.28;
|
|
23
|
+
}
|
|
24
|
+
if (/[\u1100-\u11ff\u2e80-\u9fff\uac00-\ud7af\uf900-\ufaff\uff00-\uffef]/.test(char)) {
|
|
25
|
+
return 1;
|
|
26
|
+
}
|
|
27
|
+
if (/[iljtfrI.,:;'!|()[\]]/.test(char)) {
|
|
28
|
+
return 0.3;
|
|
29
|
+
}
|
|
30
|
+
if (/[mwMW@%]/.test(char)) {
|
|
31
|
+
return 0.85;
|
|
32
|
+
}
|
|
33
|
+
if (/[A-Z]/.test(char)) {
|
|
34
|
+
return 0.66;
|
|
35
|
+
}
|
|
36
|
+
if (/\d/.test(char)) {
|
|
37
|
+
return 0.556;
|
|
38
|
+
}
|
|
39
|
+
if (/[a-z]/.test(char)) {
|
|
40
|
+
return 0.52;
|
|
41
|
+
}
|
|
42
|
+
return 0.6;
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
const BOLD_WIDENING = 1.07;
|
|
46
|
+
// Single spacing in PowerPoint and LibreOffice is about 1.2 times the font size.
|
|
47
|
+
const SINGLE_LINE = 1.2;
|
|
48
|
+
|
|
49
|
+
const textWidthPt = (text: string, sizePt: number, bold: boolean): number =>
|
|
50
|
+
[...text].reduce((sum, char) => sum + widthEm(char), 0) * sizePt * (bold ? BOLD_WIDENING : 1);
|
|
51
|
+
|
|
52
|
+
/** Lines one paragraph wraps to at `widthPt`, greedy by word, a word wider than the line broken where it runs out. */
|
|
53
|
+
export const wrappedLines = (paragraph: Paragraph, widthPt: number): number => {
|
|
54
|
+
let lines = 0;
|
|
55
|
+
for (const hardLine of paragraph.text.split("\n")) {
|
|
56
|
+
let used = 0;
|
|
57
|
+
let count = 1;
|
|
58
|
+
for (const word of hardLine.split(/(?<=\s)/)) {
|
|
59
|
+
const width = textWidthPt(word, paragraph.sizePt, paragraph.bold);
|
|
60
|
+
const visible = textWidthPt(word.trimEnd(), paragraph.sizePt, paragraph.bold);
|
|
61
|
+
if (used > 0 && used + visible > widthPt) {
|
|
62
|
+
count += 1;
|
|
63
|
+
used = 0;
|
|
64
|
+
}
|
|
65
|
+
if (used === 0 && visible > widthPt && widthPt > 0) {
|
|
66
|
+
count += Math.ceil(visible / widthPt) - 1;
|
|
67
|
+
used = visible % widthPt;
|
|
68
|
+
continue;
|
|
69
|
+
}
|
|
70
|
+
used += width;
|
|
71
|
+
}
|
|
72
|
+
lines += count;
|
|
73
|
+
}
|
|
74
|
+
return lines;
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
const lineHeightOf = (paragraph: Paragraph): number => paragraph.lineHeightPt ?? paragraph.sizePt * SINGLE_LINE * paragraph.lineSpacing;
|
|
78
|
+
|
|
79
|
+
export interface TextEstimate {
|
|
80
|
+
readonly heightPt: number;
|
|
81
|
+
readonly lines: number;
|
|
82
|
+
/** The widest unwrapped line, for a box that does not wrap. */
|
|
83
|
+
readonly widestPt: number;
|
|
84
|
+
/** The size most of the text is set in, for the message. */
|
|
85
|
+
readonly sizePt: number;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Height of the paragraphs set in a column `widthPt` wide; `wrap: false` keeps every hard line on one line. */
|
|
89
|
+
export const estimateText = (paragraphs: readonly Paragraph[], widthPt: number, wrap: boolean): TextEstimate => {
|
|
90
|
+
let heightPt = 0;
|
|
91
|
+
let lines = 0;
|
|
92
|
+
let widestPt = 0;
|
|
93
|
+
const weight = new Map<number, number>();
|
|
94
|
+
paragraphs.forEach((paragraph, index) => {
|
|
95
|
+
const count = wrap ? wrappedLines(paragraph, widthPt) : paragraph.text.split("\n").length;
|
|
96
|
+
lines += count;
|
|
97
|
+
heightPt += count * lineHeightOf(paragraph) + (index === 0 ? 0 : paragraph.spaceBeforePt) + (index === paragraphs.length - 1 ? 0 : paragraph.spaceAfterPt);
|
|
98
|
+
for (const hardLine of paragraph.text.split("\n")) {
|
|
99
|
+
widestPt = Math.max(widestPt, textWidthPt(hardLine, paragraph.sizePt, paragraph.bold));
|
|
100
|
+
}
|
|
101
|
+
weight.set(paragraph.sizePt, (weight.get(paragraph.sizePt) ?? 0) + Math.max(1, paragraph.text.length));
|
|
102
|
+
});
|
|
103
|
+
const sizePt = [...weight.entries()].toSorted((a, b) => b[1] - a[1])[0]?.[0] ?? 18;
|
|
104
|
+
return { heightPt, lines, widestPt, sizePt };
|
|
105
|
+
};
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
// Structural checks over an Excel workbook: cells whose saved value is an error (#REF!, #DIV/0!, #NAME?, …),
|
|
2
|
+
// formulas that point at a deleted range, formulas saved without a value, names that lead nowhere, and links to other
|
|
3
|
+
// workbooks. Worksheets are scanned as text, not built into a tree: a sheet can be tens of megabytes of cells.
|
|
4
|
+
//
|
|
5
|
+
// The error-value and missing-cached-value rules are adapted from SurfSense's XLSX artifact verification
|
|
6
|
+
// (surfsense_backend/app/artifacts/verification/formats/xlsx.py, Copyright (c) SurfSense, Apache-2.0,
|
|
7
|
+
// https://github.com/MODSetter/SurfSense; see this package's NOTICE). Changed here: each finding names its cell, and a
|
|
8
|
+
// formula with no saved value is a warning, since every spreadsheet application computes it on open.
|
|
9
|
+
import { decodeEntities } from "../xml.js";
|
|
10
|
+
import { error, excerpt, plural, warning, type CheckReport, type Finding } from "./finding.js";
|
|
11
|
+
import { mainPartOf, missingTargetFindings, NotAPackage, openPackage, packageFindings, type OoxmlPackage } from "./package.js";
|
|
12
|
+
import { childAt, childNamed, childrenNamed, isElement, type XmlElement } from "./xml-tree.js";
|
|
13
|
+
|
|
14
|
+
const DEFAULT_MAIN = "xl/workbook.xml";
|
|
15
|
+
// Past this many cells of one kind on one sheet, the rest are counted, not listed.
|
|
16
|
+
const LISTED_PER_SHEET = 10;
|
|
17
|
+
|
|
18
|
+
const CELL = /<((?:\w+:)?)c\b([^>]*?)(?:\/>|>([\s\S]*?)<\/\1c>)/g;
|
|
19
|
+
const FORMULA = /<((?:\w+:)?)f\b[^>]*?(?:\/>|>([\s\S]*?)<\/\1f>)/;
|
|
20
|
+
const VALUE = /<((?:\w+:)?)v>([\s\S]*?)<\/\1v>/;
|
|
21
|
+
const INLINE = /<((?:\w+:)?)is\b/;
|
|
22
|
+
|
|
23
|
+
const attributeIn = (attributes: string, name: string): string | undefined => new RegExp(`(?:^|\\s)${name}="([^"]*)"`).exec(attributes)?.[1];
|
|
24
|
+
|
|
25
|
+
// A sheet name as a formula would quote it: `Sheet1!B4`, `'Q3 plan'!B4`.
|
|
26
|
+
const cellRef = (sheet: string, ref: string): string => (/^[A-Za-z_][\w.]*$/.test(sheet) ? `${sheet}!${ref}` : `'${sheet.replaceAll("'", "''")}'!${ref}`);
|
|
27
|
+
|
|
28
|
+
interface SheetScan {
|
|
29
|
+
readonly errors: Finding[];
|
|
30
|
+
readonly uncached: string[];
|
|
31
|
+
readonly filled: number;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const scanSheet = (sheet: string, xml: string): SheetScan => {
|
|
35
|
+
const errors: Finding[] = [];
|
|
36
|
+
const uncached: string[] = [];
|
|
37
|
+
let listed = 0;
|
|
38
|
+
let unlisted = 0;
|
|
39
|
+
let filled = 0;
|
|
40
|
+
const report = (finding: Finding): void => {
|
|
41
|
+
if (listed < LISTED_PER_SHEET) {
|
|
42
|
+
errors.push(finding);
|
|
43
|
+
listed += 1;
|
|
44
|
+
} else {
|
|
45
|
+
unlisted += 1;
|
|
46
|
+
}
|
|
47
|
+
};
|
|
48
|
+
for (const match of xml.matchAll(CELL)) {
|
|
49
|
+
const attributes = match[2] ?? "";
|
|
50
|
+
const inner = match[3] ?? "";
|
|
51
|
+
const ref = cellRef(sheet, attributeIn(attributes, "r") ?? "?");
|
|
52
|
+
const formula = FORMULA.exec(inner);
|
|
53
|
+
const value = VALUE.exec(inner);
|
|
54
|
+
const formulaText = formula === null ? undefined : decodeEntities(formula[2] ?? "");
|
|
55
|
+
if (formula !== null || value !== null || INLINE.test(inner)) {
|
|
56
|
+
filled += 1;
|
|
57
|
+
}
|
|
58
|
+
const shown = formulaText === undefined || formulaText === "" ? "" : ` from =${formulaText}`;
|
|
59
|
+
if (attributeIn(attributes, "t") === "e") {
|
|
60
|
+
report(error("formula-error", ref, `shows ${decodeEntities(value?.[2] ?? "an error")}${shown}: fix the formula or its inputs`));
|
|
61
|
+
} else if (formulaText?.includes("#REF!") === true) {
|
|
62
|
+
report(error("formula-error", ref, `the formula =${formulaText} points at a deleted range (#REF!)`));
|
|
63
|
+
} else if (formula !== null && value === null) {
|
|
64
|
+
uncached.push(ref);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
if (unlisted > 0) {
|
|
68
|
+
errors.push(error("formula-error", sheet, `and ${plural(unlisted, "more cell")} with errors on this sheet`));
|
|
69
|
+
}
|
|
70
|
+
return { errors, uncached, filled };
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
const uncachedFinding = (sheet: string, cells: readonly string[], recalculates: boolean): Finding => {
|
|
74
|
+
const shown = cells.slice(0, 5).map((cell) => cell.slice(cell.lastIndexOf("!") + 1)).join(", ");
|
|
75
|
+
const more = cells.length > 5 ? ", …" : "";
|
|
76
|
+
const onOpen = recalculates ? "the workbook asks to be recalculated on open, so spreadsheet applications fill them in" : "Excel, LibreOffice and Google Sheets compute them on open";
|
|
77
|
+
return warning(
|
|
78
|
+
"formula-uncached",
|
|
79
|
+
sheet,
|
|
80
|
+
`${plural(cells.length, "formula")} saved with no value (${shown}${more}): ${onOpen}, but previews and anything that reads the file (pandas, fileq read) see blanks; write the value with the formula, or recalculate in LibreOffice`,
|
|
81
|
+
);
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
const openWorkbook = (bytes: Uint8Array): OoxmlPackage | CheckReport => {
|
|
85
|
+
try {
|
|
86
|
+
return openPackage(bytes);
|
|
87
|
+
} catch (cause) {
|
|
88
|
+
if (cause instanceof NotAPackage) {
|
|
89
|
+
return { format: "xlsx", findings: [error("unreadable", "", `${cause.message}: this is not an Excel file (or it is password-protected)`)], notes: [] };
|
|
90
|
+
}
|
|
91
|
+
throw cause;
|
|
92
|
+
}
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
const namesFindings = (workbook: XmlElement | undefined): Finding[] => {
|
|
96
|
+
const names = workbook === undefined ? undefined : childNamed(workbook, "x:definedNames");
|
|
97
|
+
return (names === undefined ? [] : childrenNamed(names, "x:definedName")).flatMap((name) => {
|
|
98
|
+
const target = name.children.filter((child): child is string => !isElement(child)).join("");
|
|
99
|
+
return target.includes("#REF!") ? [error("broken-name", "", `the defined name "${name.attrs["name"] ?? "?"}" refers to ${excerpt(target)}, a deleted range`)] : [];
|
|
100
|
+
});
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
const externalFindings = (pkg: OoxmlPackage): Finding[] => {
|
|
104
|
+
const links = [...pkg.names].filter((name) => /^xl\/externalLinks\/externalLink\d+\.xml$/.test(name));
|
|
105
|
+
return links.map((link) => {
|
|
106
|
+
const target = [...pkg.rels(link).values()].find((relationship) => relationship.external)?.target ?? "another file";
|
|
107
|
+
return warning("external-link", "", `the workbook takes values from ${target}; they go stale, or break, once the file is sent without it`);
|
|
108
|
+
});
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
interface SheetsResult {
|
|
112
|
+
readonly findings: Finding[];
|
|
113
|
+
readonly filled: number;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const sheetsFindings = (pkg: OoxmlPackage, main: string, sheets: readonly XmlElement[], recalculates: boolean): SheetsResult => {
|
|
117
|
+
const rels = pkg.rels(main);
|
|
118
|
+
const findings: Finding[] = [];
|
|
119
|
+
let filled = 0;
|
|
120
|
+
for (const sheet of sheets) {
|
|
121
|
+
const name = sheet.attrs["name"] ?? "?";
|
|
122
|
+
const relationship = rels.get(sheet.attrs["r:id"] ?? "");
|
|
123
|
+
if (relationship === undefined || relationship.external || !pkg.has(relationship.target)) {
|
|
124
|
+
findings.push(error("missing-part", name, "the sheet list names a sheet that is not in the file; Excel will offer to repair the workbook"));
|
|
125
|
+
continue;
|
|
126
|
+
}
|
|
127
|
+
// A chart sheet has no cells, and a sheet over the size cap is named in the notes.
|
|
128
|
+
const xml = relationship.kind === "worksheet" ? pkg.text(relationship.target) : undefined;
|
|
129
|
+
const scan = xml === undefined ? undefined : scanSheet(name, xml);
|
|
130
|
+
if (scan === undefined) {
|
|
131
|
+
continue;
|
|
132
|
+
}
|
|
133
|
+
filled += scan.filled;
|
|
134
|
+
findings.push(...scan.errors, ...(scan.uncached.length > 0 ? [uncachedFinding(name, scan.uncached, recalculates)] : []));
|
|
135
|
+
findings.push(...missingTargetFindings(pkg, relationship.target, name));
|
|
136
|
+
}
|
|
137
|
+
return { findings, filled };
|
|
138
|
+
};
|
|
139
|
+
|
|
140
|
+
export const checkXlsx = (bytes: Uint8Array): CheckReport => {
|
|
141
|
+
const pkg = openWorkbook(bytes);
|
|
142
|
+
if ("findings" in pkg) {
|
|
143
|
+
return pkg;
|
|
144
|
+
}
|
|
145
|
+
const main = mainPartOf(pkg, DEFAULT_MAIN);
|
|
146
|
+
const findings = packageFindings(pkg, main);
|
|
147
|
+
const notes = pkg.skipped.map((part) => `not checked: ${part} is too large to read`);
|
|
148
|
+
const workbook = pkg.xml(main);
|
|
149
|
+
const sheetList = childAt(workbook, "x:sheets");
|
|
150
|
+
const sheets = sheetList === undefined ? [] : childrenNamed(sheetList, "x:sheet");
|
|
151
|
+
if (workbook !== undefined && sheets.length === 0) {
|
|
152
|
+
findings.push(error("no-sheets", "", "the workbook has no sheets"));
|
|
153
|
+
}
|
|
154
|
+
const fullCalcOnLoad = childAt(workbook, "x:calcPr")?.attrs["fullCalcOnLoad"];
|
|
155
|
+
const scanned = sheetsFindings(pkg, main, sheets, fullCalcOnLoad === "1" || fullCalcOnLoad === "true");
|
|
156
|
+
findings.push(...scanned.findings);
|
|
157
|
+
if (sheets.length > 0 && scanned.filled === 0 && pkg.skipped.length === 0) {
|
|
158
|
+
findings.push(warning("no-data", "", "every sheet is empty"));
|
|
159
|
+
}
|
|
160
|
+
const listed = new Set(sheets.flatMap((sheet) => sheet.attrs["r:id"] ?? []));
|
|
161
|
+
findings.push(...namesFindings(workbook), ...externalFindings(pkg), ...missingTargetFindings(pkg, main, "", listed));
|
|
162
|
+
return { format: "xlsx", extent: plural(sheets.length, "sheet"), findings, notes };
|
|
163
|
+
};
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
import { decodeEntities } from "../xml.js";
|
|
2
|
+
|
|
3
|
+
// A small non-validating XML reader for the checks. The derivers get by with a regex over a part's text; a check needs
|
|
4
|
+
// the tree (a shape's OWN geometry rather than a grouped child's, a paragraph's own runs, a field's begin and end).
|
|
5
|
+
// Names come out under the specification's prefixes whatever the file declared: a part rewritten by a script that
|
|
6
|
+
// renamed `p:` to `ns0:` is still the same presentation, and a check that matched on the written prefix would pass it.
|
|
7
|
+
// No DTD, no entity expansion beyond the five predefined and numeric references: a part is data, never instructions.
|
|
8
|
+
|
|
9
|
+
export interface XmlElement {
|
|
10
|
+
/** Qualified name under the canonical prefix of its namespace (`p:sp`, `w:t`); the written one for an unknown namespace. */
|
|
11
|
+
readonly name: string;
|
|
12
|
+
/** Attributes by qualified name, canonical prefixes as above; unprefixed attributes keep their bare name. */
|
|
13
|
+
readonly attrs: Readonly<Record<string, string>>;
|
|
14
|
+
readonly children: (XmlElement | string)[];
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// Transitional and Strict OOXML name the same vocabulary under two URIs; both read as one prefix.
|
|
18
|
+
const CANONICAL = new Map<string, string>([
|
|
19
|
+
["http://schemas.openxmlformats.org/presentationml/2006/main", "p"],
|
|
20
|
+
["http://purl.oclc.org/ooxml/presentationml/main", "p"],
|
|
21
|
+
["http://schemas.openxmlformats.org/drawingml/2006/main", "a"],
|
|
22
|
+
["http://purl.oclc.org/ooxml/drawingml/main", "a"],
|
|
23
|
+
["http://schemas.openxmlformats.org/officeDocument/2006/relationships", "r"],
|
|
24
|
+
["http://purl.oclc.org/ooxml/officeDocument/relationships", "r"],
|
|
25
|
+
["http://schemas.openxmlformats.org/wordprocessingml/2006/main", "w"],
|
|
26
|
+
["http://purl.oclc.org/ooxml/wordprocessingml/main", "w"],
|
|
27
|
+
["http://schemas.openxmlformats.org/drawingml/2006/wordprocessingDrawing", "wp"],
|
|
28
|
+
["http://purl.oclc.org/ooxml/drawingml/wordprocessingDrawing", "wp"],
|
|
29
|
+
["http://schemas.openxmlformats.org/spreadsheetml/2006/main", "x"],
|
|
30
|
+
["http://purl.oclc.org/ooxml/spreadsheetml/main", "x"],
|
|
31
|
+
["http://schemas.openxmlformats.org/package/2006/relationships", "rel"],
|
|
32
|
+
["http://schemas.openxmlformats.org/markup-compatibility/2006", "mc"],
|
|
33
|
+
]);
|
|
34
|
+
|
|
35
|
+
const TOKEN =
|
|
36
|
+
/<!--[\s\S]*?-->|<!\[CDATA\[([\s\S]*?)\]\]>|<\?[\s\S]*?\?>|<!DOCTYPE[^>[]*(?:\[[\s\S]*?\])?\s*>|<\/([^\s>]+)\s*>|<([^\s/>!?]+)((?:\s+[^\s=/>]+\s*=\s*(?:"[^"]*"|'[^']*'))*)\s*(\/?)>/g;
|
|
37
|
+
const ATTRIBUTE = /([^\s=]+)\s*=\s*(?:"([^"]*)"|'([^']*)')/g;
|
|
38
|
+
|
|
39
|
+
type Scope = ReadonlyMap<string, string>;
|
|
40
|
+
|
|
41
|
+
const canonicalName = (written: string, scope: Scope, isAttribute: boolean): string => {
|
|
42
|
+
const colon = written.indexOf(":");
|
|
43
|
+
const prefix = colon === -1 ? "" : written.slice(0, colon);
|
|
44
|
+
const local = colon === -1 ? written : written.slice(colon + 1);
|
|
45
|
+
// An unprefixed attribute belongs to no namespace; an unprefixed element to the default one.
|
|
46
|
+
if (colon === -1 && isAttribute) {
|
|
47
|
+
return written;
|
|
48
|
+
}
|
|
49
|
+
const uri = scope.get(prefix);
|
|
50
|
+
const canonical = uri === undefined ? undefined : CANONICAL.get(uri);
|
|
51
|
+
if (canonical !== undefined) {
|
|
52
|
+
return `${canonical}:${local}`;
|
|
53
|
+
}
|
|
54
|
+
return written;
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
interface Frame {
|
|
58
|
+
readonly element: XmlElement;
|
|
59
|
+
/** The name as the file wrote it, which is what its closing tag repeats. */
|
|
60
|
+
readonly written: string;
|
|
61
|
+
readonly scope: Scope;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const isDeclaration = (name: string): boolean => name === "xmlns" || name.startsWith("xmlns:");
|
|
65
|
+
|
|
66
|
+
// An opening tag as an element under `parent`: its namespace declarations extend the parent's scope, and its names are
|
|
67
|
+
// canonicalised under the scope that results.
|
|
68
|
+
const openElement = (parent: Frame, written: string, attributeText: string): Frame => {
|
|
69
|
+
const pairs: [string, string][] = [...attributeText.matchAll(ATTRIBUTE)].map((attribute) => [attribute[1] ?? "", decodeEntities(attribute[2] ?? attribute[3] ?? "")]);
|
|
70
|
+
const declarations = pairs.filter(([name]) => isDeclaration(name));
|
|
71
|
+
const scope =
|
|
72
|
+
declarations.length === 0
|
|
73
|
+
? parent.scope
|
|
74
|
+
: new Map([...parent.scope, ...declarations.map(([name, uri]): [string, string] => [name === "xmlns" ? "" : name.slice(6), uri])]);
|
|
75
|
+
const attrs = Object.fromEntries(pairs.filter(([name]) => !isDeclaration(name)).map(([name, value]) => [canonicalName(name, scope, true), value]));
|
|
76
|
+
const element: XmlElement = { name: canonicalName(written, scope, false), attrs, children: [] };
|
|
77
|
+
parent.element.children.push(element);
|
|
78
|
+
return { element, written, scope };
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
// Whitespace between elements is layout; inside a text-bearing element (`a:t`, `w:t`) it is content.
|
|
82
|
+
const appendText = (frame: Frame | undefined, raw: string, decode: boolean): void => {
|
|
83
|
+
if (frame === undefined || raw === "") {
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
const textBearing = frame.element.name.endsWith(":t") || frame.element.name === "t";
|
|
87
|
+
if (raw.trim() === "" && !textBearing) {
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
frame.element.children.push(decode ? decodeEntities(raw) : raw);
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
/** The root element of `text`; an empty synthetic root when the text holds no element at all. */
|
|
94
|
+
export const parseXml = (text: string): XmlElement => {
|
|
95
|
+
const root: XmlElement = { name: "#document", attrs: {}, children: [] };
|
|
96
|
+
const stack: Frame[] = [{ element: root, written: "#document", scope: new Map() }];
|
|
97
|
+
let last = 0;
|
|
98
|
+
for (const match of text.matchAll(TOKEN)) {
|
|
99
|
+
appendText(stack.at(-1), text.slice(last, match.index), true);
|
|
100
|
+
last = match.index + match[0].length;
|
|
101
|
+
const [, cdata, closing, opening, attributeText, selfClosing] = match;
|
|
102
|
+
if (cdata !== undefined) {
|
|
103
|
+
appendText(stack.at(-1), cdata, false);
|
|
104
|
+
} else if (closing !== undefined) {
|
|
105
|
+
// Pops to the matching open tag; a stray close tag (a malformed part) is ignored rather than fatal.
|
|
106
|
+
const at = stack.findLastIndex((frame) => frame.written === closing);
|
|
107
|
+
stack.length = at > 0 ? at : stack.length;
|
|
108
|
+
} else if (opening !== undefined) {
|
|
109
|
+
const frame = openElement(stack.at(-1) ?? { element: root, written: "#document", scope: new Map() }, opening, attributeText ?? "");
|
|
110
|
+
if (selfClosing !== "/") {
|
|
111
|
+
stack.push(frame);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
// Anything else is a comment, a processing instruction or a doctype, none of which is content.
|
|
115
|
+
}
|
|
116
|
+
return root.children.find((child): child is XmlElement => typeof child !== "string") ?? root;
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
export const isElement = (node: XmlElement | string): node is XmlElement => typeof node !== "string";
|
|
120
|
+
|
|
121
|
+
/** Direct children named `name`. */
|
|
122
|
+
export const childrenNamed = (element: XmlElement, name: string): XmlElement[] =>
|
|
123
|
+
element.children.filter((child): child is XmlElement => isElement(child) && child.name === name);
|
|
124
|
+
|
|
125
|
+
/** The first direct child named `name`. */
|
|
126
|
+
export const childNamed = (element: XmlElement, name: string): XmlElement | undefined =>
|
|
127
|
+
element.children.find((child): child is XmlElement => isElement(child) && child.name === name);
|
|
128
|
+
|
|
129
|
+
/** The element at the end of a path of direct children (`childAt(sp, "p:nvSpPr", "p:nvPr", "p:ph")`). */
|
|
130
|
+
export const childAt = (element: XmlElement | undefined, ...path: readonly string[]): XmlElement | undefined => {
|
|
131
|
+
let current = element;
|
|
132
|
+
for (const name of path) {
|
|
133
|
+
if (current === undefined) {
|
|
134
|
+
return undefined;
|
|
135
|
+
}
|
|
136
|
+
current = childNamed(current, name);
|
|
137
|
+
}
|
|
138
|
+
return current;
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
/** Every descendant named `name`, in document order (the element itself excluded). */
|
|
142
|
+
export const descendantsNamed = (element: XmlElement, name: string): XmlElement[] => {
|
|
143
|
+
const found: XmlElement[] = [];
|
|
144
|
+
const visit = (node: XmlElement): void => {
|
|
145
|
+
for (const child of node.children) {
|
|
146
|
+
if (isElement(child)) {
|
|
147
|
+
if (child.name === name) {
|
|
148
|
+
found.push(child);
|
|
149
|
+
}
|
|
150
|
+
visit(child);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
};
|
|
154
|
+
visit(element);
|
|
155
|
+
return found;
|
|
156
|
+
};
|
|
157
|
+
|
|
158
|
+
/** Every element in the subtree, the root included, in document order. */
|
|
159
|
+
export const everyElement = (element: XmlElement): XmlElement[] => {
|
|
160
|
+
const found: XmlElement[] = [element];
|
|
161
|
+
const visit = (node: XmlElement): void => {
|
|
162
|
+
for (const child of node.children) {
|
|
163
|
+
if (isElement(child)) {
|
|
164
|
+
found.push(child);
|
|
165
|
+
visit(child);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
};
|
|
169
|
+
visit(element);
|
|
170
|
+
return found;
|
|
171
|
+
};
|
|
172
|
+
|
|
173
|
+
/** The text of every descendant element named `textName` (`a:t`, `w:t`), joined. */
|
|
174
|
+
export const textIn = (element: XmlElement, textName: string): string =>
|
|
175
|
+
descendantsNamed(element, textName)
|
|
176
|
+
.map((node) => node.children.filter((child): child is string => typeof child === "string").join(""))
|
|
177
|
+
.join("");
|
|
178
|
+
|
|
179
|
+
/** An integer attribute; undefined when absent or not a whole number. */
|
|
180
|
+
export const intAttr = (element: XmlElement | undefined, name: string): number | undefined => {
|
|
181
|
+
const raw = element?.attrs[name];
|
|
182
|
+
if (raw === undefined || !/^-?\d+$/.test(raw.trim())) {
|
|
183
|
+
return undefined;
|
|
184
|
+
}
|
|
185
|
+
return Number(raw);
|
|
186
|
+
};
|
|
@@ -5,7 +5,8 @@ import type { DerivedDoc, Deriver } from "./deriver.js";
|
|
|
5
5
|
|
|
6
6
|
// exifr is CommonJS behind a `main` field with no exports map, so a named `import { parse }` type-checks but throws at
|
|
7
7
|
// load; the default import is its module.exports under Node and under a bundler alike (the Claude Code plugin bundles
|
|
8
|
-
// fileq, and a `createRequire` call is the one form a bundle cannot
|
|
8
|
+
// fileq, and so does the desktop app's compiled file server, and a `createRequire` call is the one form a bundle cannot
|
|
9
|
+
// follow: there it failed every derive with "Cannot find module 'exifr'").
|
|
9
10
|
// oxlint-disable-next-line import/no-named-as-default-member -- the named import this rule asks for is the one that throws at load.
|
|
10
11
|
const { parse: parseExif } = exifr;
|
|
11
12
|
|
package/src/lib/formats.ts
CHANGED
|
@@ -126,6 +126,12 @@ export const detectFormat = async (absPath: string): Promise<Format | undefined>
|
|
|
126
126
|
return byExtension;
|
|
127
127
|
};
|
|
128
128
|
|
|
129
|
+
/**
|
|
130
|
+
* detectFormat, then the extension when magic names something fileq does not read: a password-protected docx is an
|
|
131
|
+
* encrypted container, not a zip, and a command that checks or draws documents still owes it its format's answer.
|
|
132
|
+
*/
|
|
133
|
+
export const claimedFormat = async (absPath: string): Promise<Format | undefined> => (await detectFormat(absPath)) ?? EXTENSION_FORMAT[extname(absPath).toLowerCase()];
|
|
134
|
+
|
|
129
135
|
// Formats whose text diff is already the better reading: a textconv over them would replace a line diff with a
|
|
130
136
|
// rendering of the same text.
|
|
131
137
|
const TEXT_UNDERNEATH: ReadonlySet<Format> = new Set(["html"]);
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import { readdir } from "node:fs/promises";
|
|
3
|
+
import { basename, extname, join } from "node:path";
|
|
4
|
+
import { promisify } from "node:util";
|
|
5
|
+
import { errorMessage, isMissing } from "@intentic/base/errors";
|
|
6
|
+
import { onPath } from "../tools.js";
|
|
7
|
+
import { runsOf } from "./pages.js";
|
|
8
|
+
|
|
9
|
+
// The two external steps of `fileq render`: an office document to PDF through LibreOffice, and PDF pages to PNG
|
|
10
|
+
// through poppler's pdftoppm or MuPDF's mutool. fileq carries no renderer of its own: a faithful one is LibreOffice-sized,
|
|
11
|
+
// and a missing tool is named with what installs it rather than papered over with a worse picture.
|
|
12
|
+
|
|
13
|
+
const run = promisify(execFile);
|
|
14
|
+
|
|
15
|
+
// A first conversion also builds LibreOffice's profile, which is most of its time; a long deck takes longer still.
|
|
16
|
+
const CONVERT_TIMEOUT_MS = 180_000;
|
|
17
|
+
const RASTER_TIMEOUT_MS = 120_000;
|
|
18
|
+
|
|
19
|
+
export class ToolMissing extends Error {}
|
|
20
|
+
export class ConvertFailed extends Error {}
|
|
21
|
+
|
|
22
|
+
export type Rasterizer = "pdftoppm" | "mutool";
|
|
23
|
+
|
|
24
|
+
/** A folder's entries; none when it does not exist (yet, or because a tool never wrote it). */
|
|
25
|
+
export const listDir = async (dir: string): Promise<string[]> => {
|
|
26
|
+
try {
|
|
27
|
+
return await readdir(dir);
|
|
28
|
+
} catch (cause) {
|
|
29
|
+
if (isMissing(cause)) {
|
|
30
|
+
return [];
|
|
31
|
+
}
|
|
32
|
+
throw cause;
|
|
33
|
+
}
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
// In an Intentic sandbox (its `environment` command is on PATH) the tools are the opt-in `office` image pack, which an
|
|
37
|
+
// agent asks the owner for by name; anywhere else they are ordinary packages.
|
|
38
|
+
const OFFICE_PACK = 'ask the owner for the office image pack (LibreOffice and poppler) with `environment propose office --pack --why "to look at the document I made"`: it arrives with the next rebuild; until then say the document was checked but not seen';
|
|
39
|
+
|
|
40
|
+
const inSandbox = (): boolean => onPath("environment");
|
|
41
|
+
|
|
42
|
+
/** The rasterizer on PATH, poppler's first; throws ToolMissing with what to install when there is none. */
|
|
43
|
+
export const rasterizer = (): Rasterizer => {
|
|
44
|
+
if (onPath("pdftoppm")) {
|
|
45
|
+
return "pdftoppm";
|
|
46
|
+
}
|
|
47
|
+
if (onPath("mutool")) {
|
|
48
|
+
return "mutool";
|
|
49
|
+
}
|
|
50
|
+
const remedy = inSandbox() ? OFFICE_PACK : "install one (Debian/Ubuntu: apt-get install poppler-utils)";
|
|
51
|
+
throw new ToolMissing(`rendering pages needs pdftoppm (poppler-utils) or mutool (mupdf-tools) on PATH, and neither is here: ${remedy}`);
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
/** LibreOffice's binary on PATH; throws ToolMissing with what to install when there is none. */
|
|
55
|
+
export const office = (format: string): string => {
|
|
56
|
+
for (const name of ["soffice", "libreoffice"]) {
|
|
57
|
+
if (onPath(name)) {
|
|
58
|
+
return name;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
const remedy = inSandbox()
|
|
62
|
+
? OFFICE_PACK
|
|
63
|
+
: "install it (Debian/Ubuntu: apt-get install libreoffice-impress-nogui libreoffice-writer-nogui libreoffice-calc-nogui), or export the file to PDF yourself and render that";
|
|
64
|
+
throw new ToolMissing(`rendering a ${format} needs LibreOffice (soffice) on PATH to lay it out, and it is not here: ${remedy}`);
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
// Impress leaves hidden slides out of a PDF by default, which would shift every later page off its slide number.
|
|
68
|
+
const IMPRESS_PDF = 'pdf:impress_pdf_Export:{"ExportHiddenSlides":{"type":"boolean","value":"true"}}';
|
|
69
|
+
const pdfFilter = (format: string): string => (format === "pptx" || format === "odp" ? IMPRESS_PDF : "pdf");
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Converts an office document to PDF in `workDir` and answers the PDF's path. Each run gets its own LibreOffice
|
|
73
|
+
* profile, so two conversions at once never meet over a lock file, and none of them touches the user's own profile.
|
|
74
|
+
*/
|
|
75
|
+
export const toPdf = async (binary: string, absPath: string, format: string, workDir: string): Promise<string> => {
|
|
76
|
+
const outDir = join(workDir, "pdf");
|
|
77
|
+
const args = [
|
|
78
|
+
"--headless",
|
|
79
|
+
"--norestore",
|
|
80
|
+
"--nologo",
|
|
81
|
+
"--nolockcheck",
|
|
82
|
+
`-env:UserInstallation=file://${join(workDir, "profile")}`,
|
|
83
|
+
"--convert-to",
|
|
84
|
+
pdfFilter(format),
|
|
85
|
+
"--outdir",
|
|
86
|
+
outDir,
|
|
87
|
+
absPath,
|
|
88
|
+
];
|
|
89
|
+
let output = "";
|
|
90
|
+
try {
|
|
91
|
+
const result = await run(binary, args, { timeout: CONVERT_TIMEOUT_MS, maxBuffer: 4 * 1024 * 1024 });
|
|
92
|
+
output = `${result.stdout}${result.stderr}`;
|
|
93
|
+
} catch (cause) {
|
|
94
|
+
throw new ConvertFailed(`LibreOffice could not convert it: ${errorMessage(cause).split("\n").slice(0, 3).join(" ")}`);
|
|
95
|
+
}
|
|
96
|
+
// LibreOffice names the PDF after the source and exits 0 even when it wrote nothing, so the file is the answer.
|
|
97
|
+
const expected = `${basename(absPath, extname(absPath))}.pdf`;
|
|
98
|
+
const written = await listDir(outDir);
|
|
99
|
+
if (!written.includes(expected)) {
|
|
100
|
+
const said = output.trim().split("\n").slice(-2).join(" ");
|
|
101
|
+
throw new ConvertFailed(`LibreOffice wrote no PDF${said === "" ? "" : ` (it said: ${said})`}; the file may be damaged or password-protected`);
|
|
102
|
+
}
|
|
103
|
+
return join(outDir, expected);
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Rasterizes the given pages of `pdf` into `outDir`, each fitted into a `size`-pixel square, and answers each page's
|
|
108
|
+
* PNG path. The rasterizers name their files differently; the page number is read back off the name either way.
|
|
109
|
+
*/
|
|
110
|
+
export const toPngs = async (tool: Rasterizer, pdf: string, pages: readonly number[], size: number, outDir: string): Promise<Map<number, string>> => {
|
|
111
|
+
const prefix = join(outDir, "p");
|
|
112
|
+
try {
|
|
113
|
+
if (tool === "pdftoppm") {
|
|
114
|
+
for (const [first, last] of runsOf(pages)) {
|
|
115
|
+
await run("pdftoppm", ["-png", "-scale-to", String(size), "-f", String(first), "-l", String(last), pdf, prefix], { timeout: RASTER_TIMEOUT_MS });
|
|
116
|
+
}
|
|
117
|
+
} else {
|
|
118
|
+
await run("mutool", ["draw", "-q", "-F", "png", "-w", String(size), "-h", String(size), "-o", `${prefix}-%d.png`, pdf, pages.join(",")], {
|
|
119
|
+
timeout: RASTER_TIMEOUT_MS,
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
} catch (cause) {
|
|
123
|
+
throw new ConvertFailed(`${tool} could not draw the pages: ${errorMessage(cause).split("\n").slice(0, 3).join(" ")}`);
|
|
124
|
+
}
|
|
125
|
+
const drawn = new Map<number, string>();
|
|
126
|
+
for (const name of await readdir(outDir)) {
|
|
127
|
+
const match = /^p-0*(\d+)\.png$/.exec(name);
|
|
128
|
+
if (match !== null) {
|
|
129
|
+
drawn.set(Number(match[1]), join(outDir, name));
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
return drawn;
|
|
133
|
+
};
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { BadPages, describePages, pagesWithin, parsePages, runsOf } from "./pages.js";
|
|
2
|
+
|
|
3
|
+
describe("parsePages", () => {
|
|
4
|
+
test("a page, a range, an open range and a list", () => {
|
|
5
|
+
expect(parsePages("3")).toEqual([{ from: 3, to: 3 }]);
|
|
6
|
+
expect(parsePages("1-3")).toEqual([{ from: 1, to: 3 }]);
|
|
7
|
+
expect(parsePages("7-")).toEqual([{ from: 7, to: undefined }]);
|
|
8
|
+
expect(parsePages("2, 5,7-9")).toEqual([
|
|
9
|
+
{ from: 2, to: 2 },
|
|
10
|
+
{ from: 5, to: 5 },
|
|
11
|
+
{ from: 7, to: 9 },
|
|
12
|
+
]);
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
test("page 0, a backwards range and words are refused with the reason", () => {
|
|
16
|
+
expect(() => parsePages("0")).toThrow(BadPages);
|
|
17
|
+
expect(() => parsePages("5-2")).toThrow("pages count from 1, and a range runs forward");
|
|
18
|
+
expect(() => parsePages("first")).toThrow('--pages takes numbers and ranges like 1-3,5,7-; "first" is neither');
|
|
19
|
+
});
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
describe("pagesWithin", () => {
|
|
23
|
+
test("ranges are clipped to the document, merged, sorted, each page once", () => {
|
|
24
|
+
expect(pagesWithin(parsePages("4-,2,3-5"), 6)).toEqual([2, 3, 4, 5, 6]);
|
|
25
|
+
expect(pagesWithin(parsePages("8-10"), 6)).toEqual([]);
|
|
26
|
+
});
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
describe("runsOf and describePages", () => {
|
|
30
|
+
test("consecutive pages form one run, for the rasterizer and for the message", () => {
|
|
31
|
+
expect(runsOf([1, 2, 3, 5, 7, 8])).toEqual([
|
|
32
|
+
[1, 3],
|
|
33
|
+
[5, 5],
|
|
34
|
+
[7, 8],
|
|
35
|
+
]);
|
|
36
|
+
expect(describePages([1, 2, 3, 5, 7, 8])).toBe("1-3, 5, 7-8");
|
|
37
|
+
});
|
|
38
|
+
});
|