@intentic/fileq 1.317.0 → 1.319.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 +39 -12
- package/dist/app.d.ts.map +1 -1
- package/dist/app.js +19 -7
- 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/derive.command.js +1 -1
- package/dist/commands/derive.command.js.map +1 -1
- package/dist/commands/read.command.d.ts.map +1 -1
- package/dist/commands/read.command.js +26 -45
- package/dist/commands/read.command.js.map +1 -1
- 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/content-cache.d.ts +12 -0
- package/dist/lib/content-cache.d.ts.map +1 -0
- package/dist/lib/content-cache.js +33 -0
- package/dist/lib/content-cache.js.map +1 -0
- package/dist/lib/derive.d.ts +10 -0
- package/dist/lib/derive.d.ts.map +1 -1
- package/dist/lib/derive.js +37 -4
- package/dist/lib/derive.js.map +1 -1
- 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/lib/sidecar.d.ts +11 -2
- package/dist/lib/sidecar.d.ts.map +1 -1
- package/dist/lib/sidecar.js +28 -16
- package/dist/lib/sidecar.js.map +1 -1
- 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 +32 -8
- 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 +21 -8
- package/src/app.ts +19 -7
- package/src/cli.integration.test.ts +115 -30
- package/src/commands/check.command.ts +97 -0
- package/src/commands/derive.command.ts +1 -1
- package/src/commands/read.command.ts +30 -52
- package/src/commands/render.command.ts +80 -0
- package/src/lib/archives.ts +1 -1
- 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/content-cache.ts +58 -0
- package/src/lib/derive.integration.test.ts +198 -0
- package/src/lib/derive.ts +66 -7
- package/src/lib/derivers/archive.ts +1 -1
- package/src/lib/derivers/image.ts +2 -1
- package/src/lib/derivers/load-cost.test.ts +1 -1
- package/src/lib/derivers/pdf.ts +1 -1
- package/src/lib/derivers/xlsx.ts +1 -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/lib/sidecar.ts +42 -22
- package/src/render.integration.test.ts +217 -0
- package/src/skill-authoring.ts +27 -0
- package/src/skill.ts +36 -8
- package/src/testing.ts +287 -0
- package/dist/commands/sweep.command.d.ts +0 -3
- package/dist/commands/sweep.command.d.ts.map +0 -1
- package/dist/commands/sweep.command.js +0 -41
- package/dist/commands/sweep.command.js.map +0 -1
- 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/lib/sweep.d.ts +0 -7
- package/dist/lib/sweep.d.ts.map +0 -1
- package/dist/lib/sweep.js +0 -56
- package/dist/lib/sweep.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
- package/src/commands/sweep.command.ts +0 -48
- package/src/lib/sweep.ts +0 -72
|
@@ -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
|
+
};
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { join } from "node:path";
|
|
2
|
+
import { toolHome } from "@intentic/agent-cli/env";
|
|
3
|
+
import type { DerivedDoc } from "./derivers/deriver.js";
|
|
4
|
+
import { DERIVED_DIR, ignoreShadowsInGit, isFresh, parseSidecarFront, readSidecar, sidecarBody, writeDerived, type DerivedEntry } from "./sidecar.js";
|
|
5
|
+
|
|
6
|
+
// Renderings keyed by what they are a function of, the source's sha256 and the deriver stamp, never by its path: the
|
|
7
|
+
// same bytes read again (the same file, a copy or a move, a download, a git blob) answer from here without a deriver.
|
|
8
|
+
// Kept in the workspace's derived tree when there is a workspace, so its .gitignore covers them, else in the per-user
|
|
9
|
+
// cache. A cache that cannot be read or written costs the speed-up, never the read.
|
|
10
|
+
|
|
11
|
+
export const contentCacheDir = (workspaceRoot: string | undefined): string =>
|
|
12
|
+
workspaceRoot === undefined ? join(toolHome("fileq"), "by-hash") : join(workspaceRoot, DERIVED_DIR, ".by-hash");
|
|
13
|
+
|
|
14
|
+
// The stamp made safe for a file name (`docx v2` → `docx-v2`); a hit is checked against the entry's own front matter.
|
|
15
|
+
export const contentCachePathFor = (workspaceRoot: string | undefined, sourceSha: string, deriverStamp: string): string =>
|
|
16
|
+
join(contentCacheDir(workspaceRoot), `${sourceSha}.${deriverStamp.replaceAll(/[^A-Za-z0-9.+-]/g, "-")}.md`);
|
|
17
|
+
|
|
18
|
+
export interface CachedRendering {
|
|
19
|
+
readonly path: string;
|
|
20
|
+
readonly doc: DerivedDoc;
|
|
21
|
+
readonly derivedAt: Date;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** The stored rendering of these bytes by this deriver; undefined when there is none, or none that still matches. */
|
|
25
|
+
export const readCachedRendering = async (
|
|
26
|
+
workspaceRoot: string | undefined,
|
|
27
|
+
sourceSha: string,
|
|
28
|
+
deriverStamp: string,
|
|
29
|
+
): Promise<CachedRendering | undefined> => {
|
|
30
|
+
const path = contentCachePathFor(workspaceRoot, sourceSha, deriverStamp);
|
|
31
|
+
const content = await readSidecar(path);
|
|
32
|
+
if (content === undefined || !isFresh(content, sourceSha, deriverStamp)) {
|
|
33
|
+
return undefined;
|
|
34
|
+
}
|
|
35
|
+
const front = parseSidecarFront(content);
|
|
36
|
+
const derivedAt = new Date(front.derivedAt ?? Number.NaN);
|
|
37
|
+
if (Number.isNaN(derivedAt.getTime())) {
|
|
38
|
+
return undefined;
|
|
39
|
+
}
|
|
40
|
+
const body = sidecarBody(content);
|
|
41
|
+
// writeDerived ends a non-empty body with one newline of its own.
|
|
42
|
+
const markdown = body.endsWith("\n") ? body.slice(0, -1) : body;
|
|
43
|
+
return { path, doc: { markdown, title: front.title, notes: [...front.notes] }, derivedAt };
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
/** Stores a rendering under its content key; answers the entry's path, or undefined when it could not be written. */
|
|
47
|
+
export const cacheRendering = async (workspaceRoot: string | undefined, entry: Omit<DerivedEntry, "source">): Promise<string | undefined> => {
|
|
48
|
+
try {
|
|
49
|
+
const written = await writeDerived(contentCachePathFor(workspaceRoot, entry.sourceSha, entry.deriverStamp), entry);
|
|
50
|
+
if (workspaceRoot !== undefined) {
|
|
51
|
+
await ignoreShadowsInGit(workspaceRoot);
|
|
52
|
+
}
|
|
53
|
+
return written.path;
|
|
54
|
+
} catch {
|
|
55
|
+
// allow(silent-catch): an unwritable cache costs the next read its speed-up, never this read its rendering.
|
|
56
|
+
return undefined;
|
|
57
|
+
}
|
|
58
|
+
};
|