@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.
Files changed (111) hide show
  1. package/NOTICE +208 -0
  2. package/README.md +24 -3
  3. package/dist/app.d.ts.map +1 -1
  4. package/dist/app.js +15 -1
  5. package/dist/app.js.map +1 -1
  6. package/dist/commands/check.command.d.ts +3 -0
  7. package/dist/commands/check.command.d.ts.map +1 -0
  8. package/dist/commands/check.command.js +80 -0
  9. package/dist/commands/check.command.js.map +1 -0
  10. package/dist/commands/render.command.d.ts +3 -0
  11. package/dist/commands/render.command.d.ts.map +1 -0
  12. package/dist/commands/render.command.js +67 -0
  13. package/dist/commands/render.command.js.map +1 -0
  14. package/dist/lib/check/check.d.ts +15 -0
  15. package/dist/lib/check/check.d.ts.map +1 -0
  16. package/dist/lib/check/check.js +35 -0
  17. package/dist/lib/check/check.js.map +1 -0
  18. package/dist/lib/check/docx.d.ts +3 -0
  19. package/dist/lib/check/docx.d.ts.map +1 -0
  20. package/dist/lib/check/docx.js +329 -0
  21. package/dist/lib/check/docx.js.map +1 -0
  22. package/dist/lib/check/finding.d.ts +20 -0
  23. package/dist/lib/check/finding.d.ts.map +1 -0
  24. package/dist/lib/check/finding.js +27 -0
  25. package/dist/lib/check/finding.js.map +1 -0
  26. package/dist/lib/check/package.d.ts +25 -0
  27. package/dist/lib/check/package.d.ts.map +1 -0
  28. package/dist/lib/check/package.js +139 -0
  29. package/dist/lib/check/package.js.map +1 -0
  30. package/dist/lib/check/pdf.d.ts +3 -0
  31. package/dist/lib/check/pdf.d.ts.map +1 -0
  32. package/dist/lib/check/pdf.js +160 -0
  33. package/dist/lib/check/pdf.js.map +1 -0
  34. package/dist/lib/check/pptx.d.ts +3 -0
  35. package/dist/lib/check/pptx.d.ts.map +1 -0
  36. package/dist/lib/check/pptx.js +497 -0
  37. package/dist/lib/check/pptx.js.map +1 -0
  38. package/dist/lib/check/text-fit.d.ts +18 -0
  39. package/dist/lib/check/text-fit.d.ts.map +1 -0
  40. package/dist/lib/check/text-fit.js +69 -0
  41. package/dist/lib/check/text-fit.js.map +1 -0
  42. package/dist/lib/check/xlsx.d.ts +3 -0
  43. package/dist/lib/check/xlsx.d.ts.map +1 -0
  44. package/dist/lib/check/xlsx.js +131 -0
  45. package/dist/lib/check/xlsx.js.map +1 -0
  46. package/dist/lib/check/xml-tree.d.ts +15 -0
  47. package/dist/lib/check/xml-tree.d.ts.map +1 -0
  48. package/dist/lib/check/xml-tree.js +131 -0
  49. package/dist/lib/check/xml-tree.js.map +1 -0
  50. package/dist/lib/derivers/image.d.ts.map +1 -1
  51. package/dist/lib/derivers/image.js.map +1 -1
  52. package/dist/lib/formats.d.ts +1 -0
  53. package/dist/lib/formats.d.ts.map +1 -1
  54. package/dist/lib/formats.js +1 -0
  55. package/dist/lib/formats.js.map +1 -1
  56. package/dist/lib/render/convert.d.ts +11 -0
  57. package/dist/lib/render/convert.d.ts.map +1 -0
  58. package/dist/lib/render/convert.js +107 -0
  59. package/dist/lib/render/convert.js.map +1 -0
  60. package/dist/lib/render/pages.d.ts +11 -0
  61. package/dist/lib/render/pages.d.ts.map +1 -0
  62. package/dist/lib/render/pages.js +43 -0
  63. package/dist/lib/render/pages.js.map +1 -0
  64. package/dist/lib/render/render.d.ts +29 -0
  65. package/dist/lib/render/render.d.ts.map +1 -0
  66. package/dist/lib/render/render.js +142 -0
  67. package/dist/lib/render/render.js.map +1 -0
  68. package/dist/skill-authoring.d.ts +2 -0
  69. package/dist/skill-authoring.d.ts.map +1 -0
  70. package/dist/skill-authoring.js +20 -0
  71. package/dist/skill-authoring.js.map +1 -0
  72. package/dist/skill.d.ts.map +1 -1
  73. package/dist/skill.js +24 -1
  74. package/dist/skill.js.map +1 -1
  75. package/dist/testing.d.ts +67 -0
  76. package/dist/testing.d.ts.map +1 -1
  77. package/dist/testing.js +151 -0
  78. package/dist/testing.js.map +1 -1
  79. package/package.json +20 -7
  80. package/src/app.ts +15 -1
  81. package/src/cli.integration.test.ts +63 -1
  82. package/src/commands/check.command.ts +97 -0
  83. package/src/commands/render.command.ts +80 -0
  84. package/src/lib/check/check.test.ts +446 -0
  85. package/src/lib/check/check.ts +49 -0
  86. package/src/lib/check/docx.ts +434 -0
  87. package/src/lib/check/finding.ts +63 -0
  88. package/src/lib/check/package.ts +199 -0
  89. package/src/lib/check/pdf.ts +228 -0
  90. package/src/lib/check/pptx.ts +641 -0
  91. package/src/lib/check/text-fit.ts +105 -0
  92. package/src/lib/check/xlsx.ts +163 -0
  93. package/src/lib/check/xml-tree.ts +186 -0
  94. package/src/lib/derivers/image.ts +2 -1
  95. package/src/lib/formats.ts +6 -0
  96. package/src/lib/render/convert.ts +133 -0
  97. package/src/lib/render/pages.test.ts +38 -0
  98. package/src/lib/render/pages.ts +58 -0
  99. package/src/lib/render/render.ts +198 -0
  100. package/src/render.integration.test.ts +217 -0
  101. package/src/skill-authoring.ts +27 -0
  102. package/src/skill.ts +28 -1
  103. package/src/testing.ts +287 -0
  104. package/dist/lib/flags.d.ts +0 -2
  105. package/dist/lib/flags.d.ts.map +0 -1
  106. package/dist/lib/flags.js +0 -8
  107. package/dist/lib/flags.js.map +0 -1
  108. package/dist/version.d.ts +0 -2
  109. package/dist/version.d.ts.map +0 -1
  110. package/dist/version.js +0 -3
  111. package/dist/version.js.map +0 -1
@@ -0,0 +1,434 @@
1
+ // Structural checks over a Word document: broken cross-references and links, fields that show an error or nothing,
2
+ // missing or linked images, empty headings, and what a draft leaves behind (tracked changes, comments, a content
3
+ // control still showing its prompt, template text). Word has no pages in the file, so a location is a paragraph: its
4
+ // number in reading order, with its own words or the heading it sits under to find it by.
5
+ //
6
+ // The literal-bullet rule is adapted from SurfSense's DOCX artifact verification
7
+ // (surfsense_backend/app/artifacts/verification/formats/docx.py, Copyright (c) SurfSense, Apache-2.0,
8
+ // https://github.com/MODSetter/SurfSense; see this package's NOTICE), reported here once per document as a warning.
9
+ import { error, excerpt, inches, leftoverText, plural, warning, type CheckReport, type Finding } from "./finding.js";
10
+ import { mainPartOf, missingTargetFindings, NotAPackage, openPackage, packageFindings, type OoxmlPackage, type Relationship } from "./package.js";
11
+ import { childAt, childNamed, childrenNamed, descendantsNamed, everyElement, intAttr, isElement, textIn, type XmlElement } from "./xml-tree.js";
12
+
13
+ const DEFAULT_MAIN = "word/document.xml";
14
+ const EMU_PER_TWIP = 635;
15
+ // Built-in anchors Word resolves without a bookmark.
16
+ const BUILT_IN_ANCHORS = new Set(["_top"]);
17
+ // Fields every viewer computes as it lays the page out; an empty saved result is normal for them.
18
+ const COMPUTED_FIELDS = new Set(["PAGE", "NUMPAGES", "SECTIONPAGES", "DATE", "TIME", "PRINTDATE", "SAVEDATE", "CREATEDATE"]);
19
+ const CROSS_REFERENCES = new Set(["REF", "PAGEREF", "NOTEREF"]);
20
+ const TYPED_BULLET = /^\s*[•◦▪‣∙·]\s/;
21
+
22
+ interface Located {
23
+ readonly paragraph: XmlElement;
24
+ readonly number: number;
25
+ readonly text: string;
26
+ readonly heading: number | undefined;
27
+ }
28
+
29
+ interface Field {
30
+ readonly instruction: string;
31
+ readonly result: string;
32
+ readonly hasResult: boolean;
33
+ readonly where: string;
34
+ }
35
+
36
+ interface DocContext {
37
+ readonly pkg: OoxmlPackage;
38
+ readonly main: string;
39
+ readonly paragraphs: readonly Located[];
40
+ readonly whereOf: ReadonlyMap<XmlElement, string>;
41
+ readonly bookmarks: ReadonlySet<string>;
42
+ }
43
+
44
+ // --- styles ----------------------------------------------------------------------------------------------------
45
+
46
+ // Heading level by style id, from styles.xml: a built-in "heading N" or "Title" by its (always English) style name,
47
+ // or any style carrying an outline level, following basedOn so a custom style on top of a heading still counts.
48
+ const headingLevels = (styles: XmlElement | undefined): ReadonlyMap<string, number> => {
49
+ const byId = new Map<string, XmlElement>();
50
+ for (const style of styles === undefined ? [] : childrenNamed(styles, "w:style")) {
51
+ const id = style.attrs["w:styleId"];
52
+ if (id !== undefined && style.attrs["w:type"] === "paragraph") {
53
+ byId.set(id, style);
54
+ }
55
+ }
56
+ const levelOf = (id: string, depth: number): number | undefined => {
57
+ const style = byId.get(id);
58
+ if (style === undefined || depth > 8) {
59
+ return undefined;
60
+ }
61
+ const name = (childNamed(style, "w:name")?.attrs["w:val"] ?? "").toLowerCase();
62
+ const numbered = /^heading (\d)$/.exec(name);
63
+ if (numbered !== null) {
64
+ return Number(numbered[1]);
65
+ }
66
+ if (name === "title") {
67
+ return 0;
68
+ }
69
+ const outline = intAttr(childAt(style, "w:pPr", "w:outlineLvl"), "w:val");
70
+ if (outline !== undefined && outline < 9) {
71
+ return outline + 1;
72
+ }
73
+ const basedOn = childNamed(style, "w:basedOn")?.attrs["w:val"];
74
+ return basedOn === undefined ? undefined : levelOf(basedOn, depth + 1);
75
+ };
76
+ return new Map([...byId.keys()].flatMap((id) => {
77
+ const level = levelOf(id, 0);
78
+ return level === undefined ? [] : [[id, level] as const];
79
+ }));
80
+ };
81
+
82
+ const headingOf = (paragraph: XmlElement, levels: ReadonlyMap<string, number>): number | undefined => {
83
+ const pPr = childNamed(paragraph, "w:pPr");
84
+ const outline = intAttr(childAt(pPr, "w:outlineLvl"), "w:val");
85
+ if (outline !== undefined && outline < 9) {
86
+ return outline + 1;
87
+ }
88
+ const style = childAt(pPr, "w:pStyle")?.attrs["w:val"];
89
+ return style === undefined ? undefined : levels.get(style);
90
+ };
91
+
92
+ // --- locating --------------------------------------------------------------------------------------------------
93
+
94
+ const paragraphText = (paragraph: XmlElement): string => textIn(paragraph, "w:t");
95
+
96
+ interface Locations {
97
+ readonly located: Located[];
98
+ readonly whereOf: Map<XmlElement, string>;
99
+ }
100
+
101
+ const locate = (paragraphs: readonly XmlElement[], levels: ReadonlyMap<string, number>): Locations => {
102
+ const located: Located[] = [];
103
+ const whereOf = new Map<XmlElement, string>();
104
+ let lastHeading: string | undefined;
105
+ paragraphs.forEach((paragraph, index) => {
106
+ const text = paragraphText(paragraph);
107
+ const heading = headingOf(paragraph, levels);
108
+ const number = index + 1;
109
+ const label = text.trim() !== "" ? `paragraph ${number} · ${excerpt(text, 40)}` : lastHeading === undefined ? `paragraph ${number}` : `paragraph ${number} · under ${lastHeading}`;
110
+ located.push({ paragraph, number, text, heading });
111
+ whereOf.set(paragraph, label);
112
+ if (heading !== undefined && text.trim() !== "") {
113
+ lastHeading = excerpt(text, 40);
114
+ }
115
+ });
116
+ return { located, whereOf };
117
+ };
118
+
119
+ // The paragraph an element sits in, by walking every paragraph's subtree once.
120
+ const paragraphIndex = (paragraphs: readonly XmlElement[]): Map<XmlElement, XmlElement> => {
121
+ const owner = new Map<XmlElement, XmlElement>();
122
+ for (const paragraph of paragraphs) {
123
+ for (const node of everyElement(paragraph)) {
124
+ owner.set(node, paragraph);
125
+ }
126
+ }
127
+ return owner;
128
+ };
129
+
130
+ // --- fields ----------------------------------------------------------------------------------------------------
131
+
132
+ interface OpenField {
133
+ instruction: string;
134
+ result: string;
135
+ separated: boolean;
136
+ readonly where: string;
137
+ }
138
+
139
+ // One fldChar moves the field state machine: begin opens a field, separate starts its saved result, end closes it.
140
+ const stepField = (node: XmlElement, open: OpenField[], fields: Field[], where: string): void => {
141
+ const type = node.attrs["w:fldCharType"];
142
+ const current = open.at(-1);
143
+ if (type === "begin") {
144
+ open.push({ instruction: "", result: "", separated: false, where });
145
+ return;
146
+ }
147
+ if (current === undefined) {
148
+ return;
149
+ }
150
+ if (type === "separate") {
151
+ current.separated = true;
152
+ } else if (type === "end") {
153
+ open.pop();
154
+ fields.push({ instruction: current.instruction.trim(), result: current.result, hasResult: current.separated && current.result.trim() !== "", where: current.where });
155
+ }
156
+ };
157
+
158
+ // Complex fields span runs: begin, instruction text, separate, the saved result, end; they nest. Simple fields carry
159
+ // the instruction as an attribute and the result as their content.
160
+ const fieldsIn = (body: XmlElement, whereOfNode: (node: XmlElement) => string): Field[] => {
161
+ const fields: Field[] = [];
162
+ const open: OpenField[] = [];
163
+ for (const node of everyElement(body)) {
164
+ if (node.name === "w:fldSimple") {
165
+ const result = textIn(node, "w:t");
166
+ fields.push({ instruction: (node.attrs["w:instr"] ?? "").trim(), result, hasResult: result.trim() !== "", where: whereOfNode(node) });
167
+ } else if (node.name === "w:fldChar") {
168
+ stepField(node, open, fields, whereOfNode(node));
169
+ } else {
170
+ const current = open.at(-1);
171
+ const text = node.children.filter((child): child is string => !isElement(child)).join("");
172
+ if (current !== undefined && node.name === "w:instrText" && !current.separated) {
173
+ current.instruction += text;
174
+ } else if (current !== undefined && node.name === "w:t" && current.separated) {
175
+ current.result += text;
176
+ }
177
+ }
178
+ }
179
+ return fields;
180
+ };
181
+
182
+ const bookmarkArgument = (instruction: string): string | undefined => {
183
+ const tokens = [...instruction.matchAll(/"[^"]*"|\S+/g)].map((match) => match[0]);
184
+ const keyword = (tokens[0] ?? "").toUpperCase();
185
+ if (CROSS_REFERENCES.has(keyword)) {
186
+ return tokens[1]?.replaceAll('"', "");
187
+ }
188
+ if (keyword === "HYPERLINK") {
189
+ const at = tokens.indexOf("\\l");
190
+ return at === -1 ? undefined : tokens[at + 1]?.replaceAll('"', "");
191
+ }
192
+ return undefined;
193
+ };
194
+
195
+ const fieldFindings = (fields: readonly Field[], bookmarks: ReadonlySet<string>, updatesOnOpen: boolean): Finding[] =>
196
+ fields.flatMap((field): Finding[] => {
197
+ const keyword = (field.instruction.split(/\s+/)[0] ?? "").toUpperCase();
198
+ if (field.result.trim().startsWith('Error!')) {
199
+ return [error("field-error", field.where, `the ${keyword} field shows ${excerpt(field.result)}: fix what it points at, then update fields`)];
200
+ }
201
+ const bookmark = bookmarkArgument(field.instruction);
202
+ if (bookmark !== undefined && !bookmarks.has(bookmark) && !BUILT_IN_ANCHORS.has(bookmark)) {
203
+ return [error("broken-reference", field.where, `the ${keyword} field points to bookmark "${bookmark}", which is not in the document: Word shows "Error! Reference source not found."`)];
204
+ }
205
+ if (keyword === "MERGEFIELD") {
206
+ return [warning("merge-field", field.where, `a mail-merge field is left in (${field.instruction}): it prints its placeholder, not a value`)];
207
+ }
208
+ if (keyword === "TOC" && !field.hasResult) {
209
+ const when = updatesOnOpen ? "Word offers to update fields when the file opens" : "nothing fills it until someone updates fields (F9 in Word)";
210
+ return [warning("empty-toc", field.where, `the table of contents has no entries saved: ${when}, and previews and LibreOffice show it empty`)];
211
+ }
212
+ if (!field.hasResult && keyword !== "" && !COMPUTED_FIELDS.has(keyword) && keyword !== "TOC") {
213
+ return [warning("empty-field", field.where, `the ${keyword} field has no saved result, so it shows blank until fields are updated`)];
214
+ }
215
+ return [];
216
+ });
217
+
218
+ // --- references and images -------------------------------------------------------------------------------------
219
+
220
+ const referenceFindings = (context: DocContext, part: string, tree: XmlElement, whereOfNode: (node: XmlElement) => string, referenced: Set<string>): Finding[] => {
221
+ const rels = context.pkg.rels(part);
222
+ const findings: Finding[] = [];
223
+ for (const node of everyElement(tree)) {
224
+ for (const [attribute, id] of Object.entries(node.attrs)) {
225
+ if (!attribute.startsWith("r:") || id === "") {
226
+ continue;
227
+ }
228
+ referenced.add(id);
229
+ const finding = referenceFinding(context.pkg, node, rels.get(id), id, whereOfNode(node));
230
+ if (finding !== undefined) {
231
+ findings.push(finding);
232
+ }
233
+ }
234
+ }
235
+ return findings;
236
+ };
237
+
238
+ const referenceFinding = (
239
+ pkg: OoxmlPackage,
240
+ node: XmlElement,
241
+ relationship: Relationship | undefined,
242
+ id: string,
243
+ where: string,
244
+ ): Finding | undefined => {
245
+ const isImage = node.name === "a:blip" || node.name === "v:imagedata";
246
+ if (relationship === undefined) {
247
+ return error(isImage ? "missing-image" : "missing-part", where, `${isImage ? "an image" : node.name} refers to ${id}, which the document does not define: it shows as missing`);
248
+ }
249
+ if (relationship.external) {
250
+ return isImage ? warning("linked-image", where, `an image is linked from outside the file (${relationship.target}), so it will not show on anyone else's machine; embed it`) : undefined;
251
+ }
252
+ if (pkg.has(relationship.target)) {
253
+ return undefined;
254
+ }
255
+ return isImage
256
+ ? error("missing-image", where, `image ${relationship.target} is not in the file, so it shows as a broken picture`)
257
+ : error("missing-part", where, `${relationship.kind} ${relationship.target} is not in the file`);
258
+ };
259
+
260
+ // The text column of each section, in reading order: a paragraph's own sectPr closes a section, the body's closes the last.
261
+ const textWidths = (paragraphs: readonly XmlElement[], body: XmlElement): { endsAt: number; width: number | undefined }[] => {
262
+ const widthOf = (sectPr: XmlElement | undefined): number | undefined => {
263
+ const page = intAttr(childAt(sectPr, "w:pgSz"), "w:w");
264
+ const margins = childAt(sectPr, "w:pgMar");
265
+ if (page === undefined) {
266
+ return undefined;
267
+ }
268
+ const columns = Math.max(1, intAttr(childAt(sectPr, "w:cols"), "w:num") ?? 1);
269
+ const text = page - (intAttr(margins, "w:left") ?? 0) - (intAttr(margins, "w:right") ?? 0) - (intAttr(margins, "w:gutter") ?? 0);
270
+ return (text / columns) * EMU_PER_TWIP;
271
+ };
272
+ const sections = paragraphs.flatMap((paragraph, index) => {
273
+ const sectPr = childAt(paragraph, "w:pPr", "w:sectPr");
274
+ return sectPr === undefined ? [] : [{ endsAt: index, width: widthOf(sectPr) }];
275
+ });
276
+ sections.push({ endsAt: paragraphs.length, width: widthOf(childNamed(body, "w:sectPr")) });
277
+ return sections;
278
+ };
279
+
280
+ const wideImageFindings = (context: DocContext, body: XmlElement, owner: ReadonlyMap<XmlElement, XmlElement>): Finding[] => {
281
+ const all = context.paragraphs.map((located) => located.paragraph);
282
+ const order = new Map(all.map((paragraph, index) => [paragraph, index]));
283
+ const sections = textWidths(all, body);
284
+ return descendantsNamed(body, "wp:inline").flatMap((inline): Finding[] => {
285
+ const cx = intAttr(childNamed(inline, "wp:extent"), "cx");
286
+ const paragraph = owner.get(inline);
287
+ const index = paragraph === undefined ? undefined : order.get(paragraph);
288
+ const width = index === undefined ? undefined : sections.find((section) => section.endsAt >= index)?.width;
289
+ if (cx === undefined || width === undefined || width <= 0 || cx <= width * 1.02) {
290
+ return [];
291
+ }
292
+ const where = paragraph === undefined ? "" : (context.whereOf.get(paragraph) ?? "");
293
+ return [warning("wide-image", where, `a picture is ${inches(cx)} wide in a ${inches(width)} text column, so it runs into the margin or is cut off; scale it to fit`)];
294
+ });
295
+ };
296
+
297
+ // --- the document ----------------------------------------------------------------------------------------------
298
+
299
+ const draftFindings = (context: DocContext, body: XmlElement): Finding[] => {
300
+ const findings: Finding[] = [];
301
+ const changes = everyElement(body).filter((node) => ["w:ins", "w:del", "w:moveFrom", "w:moveTo"].includes(node.name) && node.children.some(isElement)).length;
302
+ if (changes > 0) {
303
+ findings.push(warning("tracked-changes", "", `${plural(changes, "tracked change")} still in the document: accept or reject them before it goes out`));
304
+ }
305
+ const commentsPart = [...context.pkg.rels(context.main).values()].find((relationship) => relationship.kind === "comments" && !relationship.external);
306
+ const comments = commentsPart === undefined ? undefined : context.pkg.xml(commentsPart.target);
307
+ const count = comments === undefined ? 0 : childrenNamed(comments, "w:comment").length;
308
+ if (count > 0) {
309
+ findings.push(warning("comments", "", `${plural(count, "comment")} still in the document: resolve or delete them before it goes out`));
310
+ }
311
+ return findings;
312
+ };
313
+
314
+ const paragraphFindings = (context: DocContext): Finding[] => {
315
+ const findings: Finding[] = [];
316
+ let typedBullets = 0;
317
+ let firstBullet: string | undefined;
318
+ for (const located of context.paragraphs) {
319
+ const where = context.whereOf.get(located.paragraph) ?? `paragraph ${located.number}`;
320
+ findings.push(...leftoverText(located.text, where));
321
+ const hasPicture = descendantsNamed(located.paragraph, "w:drawing").length > 0 || descendantsNamed(located.paragraph, "w:pict").length > 0;
322
+ if (located.heading !== undefined && located.text.trim() === "" && !hasPicture) {
323
+ const level = located.heading === 0 ? "title" : `heading ${located.heading}`;
324
+ findings.push(warning("empty-heading", where, `empty ${level}: a blank line in the text and an empty entry in the navigation pane and any table of contents; delete it`));
325
+ }
326
+ if (TYPED_BULLET.test(located.text) && childAt(located.paragraph, "w:pPr", "w:numPr") === undefined) {
327
+ typedBullets += 1;
328
+ firstBullet ??= where;
329
+ }
330
+ }
331
+ if (firstBullet !== undefined) {
332
+ findings.push(
333
+ warning("typed-bullets", firstBullet, `${plural(typedBullets, "paragraph")} ${typedBullets === 1 ? "starts" : "start"} with a typed bullet character instead of a list style, so the list will not indent or renumber; use a list style`),
334
+ );
335
+ }
336
+ return findings;
337
+ };
338
+
339
+ const controlFindings = (body: XmlElement, whereOfNode: (node: XmlElement) => string): Finding[] =>
340
+ descendantsNamed(body, "w:sdt")
341
+ .filter((control) => childAt(control, "w:sdtPr", "w:showingPlcHdr") !== undefined)
342
+ .map((control) => {
343
+ const prompt = textIn(childNamed(control, "w:sdtContent") ?? control, "w:t");
344
+ // A block-level control holds whole paragraphs; it is found by the first of them.
345
+ const where = whereOfNode(descendantsNamed(control, "w:p")[0] ?? control);
346
+ return error("placeholder-control", where, `a content control still shows its prompt ${excerpt(prompt)}: enter the value or remove the control`);
347
+ });
348
+
349
+ const linkFindings = (body: XmlElement, bookmarks: ReadonlySet<string>, whereOfNode: (node: XmlElement) => string): Finding[] =>
350
+ descendantsNamed(body, "w:hyperlink").flatMap((link) => {
351
+ const anchor = link.attrs["w:anchor"];
352
+ if (anchor === undefined || bookmarks.has(anchor) || BUILT_IN_ANCHORS.has(anchor)) {
353
+ return [];
354
+ }
355
+ return [error("broken-link", whereOfNode(link), `the link ${excerpt(textIn(link, "w:t"))} points to bookmark "${anchor}", which is not in the document, so clicking it goes nowhere`)];
356
+ });
357
+
358
+ // Headers, footers and notes: each part's own images and relationships, located by the part's name.
359
+ const storyFindings = (context: DocContext, stories: readonly { part: string; tree: XmlElement }[]): Finding[] =>
360
+ stories.flatMap((story) => {
361
+ const label = story.part.replace(/^word\//, "").replace(/\.xml$/, "");
362
+ const referenced = new Set<string>();
363
+ return [...referenceFindings(context, story.part, story.tree, () => label, referenced), ...missingTargetFindings(context.pkg, story.part, label, referenced)];
364
+ });
365
+
366
+ const openDocx = (bytes: Uint8Array): OoxmlPackage | CheckReport => {
367
+ try {
368
+ return openPackage(bytes);
369
+ } catch (cause) {
370
+ if (cause instanceof NotAPackage) {
371
+ return { format: "docx", findings: [error("unreadable", "", `${cause.message}: this is not a Word file (or it is password-protected)`)], notes: [] };
372
+ }
373
+ throw cause;
374
+ }
375
+ };
376
+
377
+ export const checkDocx = (bytes: Uint8Array): CheckReport => {
378
+ const pkg = openDocx(bytes);
379
+ if ("findings" in pkg) {
380
+ return pkg;
381
+ }
382
+ const main = mainPartOf(pkg, DEFAULT_MAIN);
383
+ const findings = packageFindings(pkg, main);
384
+ const notes = pkg.skipped.map((part) => `not checked: ${part} is too large to read`);
385
+ const body = childAt(pkg.xml(main), "w:body");
386
+ if (body === undefined) {
387
+ return { format: "docx", findings, notes };
388
+ }
389
+ const mainRels = [...pkg.rels(main).values()].filter((relationship) => !relationship.external);
390
+ const partOfKind = (kind: string): XmlElement | undefined => {
391
+ const relationship = mainRels.find((candidate) => candidate.kind === kind);
392
+ return relationship === undefined ? undefined : pkg.xml(relationship.target);
393
+ };
394
+ const all = descendantsNamed(body, "w:p");
395
+ const { located, whereOf } = locate(all, headingLevels(partOfKind("styles")));
396
+ const owner = paragraphIndex(all);
397
+ const whereOfNode = (node: XmlElement): string => whereOf.get(owner.get(node) ?? node) ?? "";
398
+ // Bookmarks can sit in any story: the body, headers and footers, footnotes.
399
+ const stories = mainRels
400
+ .filter((relationship) => ["header", "footer", "footnotes", "endnotes"].includes(relationship.kind))
401
+ .flatMap((relationship) => {
402
+ const tree = pkg.xml(relationship.target);
403
+ return tree === undefined ? [] : [{ part: relationship.target, tree }];
404
+ });
405
+ const bookmarks = new Set(
406
+ [body, ...stories.map((story) => story.tree)].flatMap((tree) => descendantsNamed(tree, "w:bookmarkStart").flatMap((mark) => mark.attrs["w:name"] ?? [])),
407
+ );
408
+ const context: DocContext = { pkg, main, paragraphs: located, whereOf, bookmarks };
409
+ if (located.every((item) => item.text.trim() === "") && descendantsNamed(body, "w:drawing").length === 0) {
410
+ findings.push(warning("no-text", "", "the document has no text"));
411
+ }
412
+ const updateFields = childAt(partOfKind("settings"), "w:updateFields");
413
+ const referenced = new Set<string>();
414
+ findings.push(
415
+ ...linkFindings(body, bookmarks, whereOfNode),
416
+ ...fieldFindings(fieldsIn(body, whereOfNode), bookmarks, updateFields !== undefined && updateFields.attrs["w:val"] !== "false"),
417
+ ...referenceFindings(context, main, body, whereOfNode, referenced),
418
+ ...storyFindings(context, stories),
419
+ ...missingTargetFindings(pkg, main, "", referenced),
420
+ ...controlFindings(body, whereOfNode),
421
+ ...paragraphFindings(context),
422
+ ...wideImageFindings(context, body, owner),
423
+ ...draftFindings(context, body),
424
+ );
425
+ // A field showing "Error!" or a control showing its prompt is already named for what it is; the same words read as
426
+ // leftover text would say it twice.
427
+ const explained = new Set(findings.filter((finding) => finding.rule === "field-error" || finding.rule === "placeholder-control").map((finding) => finding.where));
428
+ return {
429
+ format: "docx",
430
+ extent: plural(located.length, "paragraph"),
431
+ findings: findings.filter((finding) => finding.rule !== "leftover-text" || !explained.has(finding.where)),
432
+ notes,
433
+ };
434
+ };
@@ -0,0 +1,63 @@
1
+ // What `fileq check` reports, and the rules every format shares.
2
+ // An ERROR is a fact read off the file's structure that a reader will meet: a missing image, a formula showing #REF!,
3
+ // a link to a bookmark that is not there. A WARNING is either a fact whose harm depends on the reader (a tracked change,
4
+ // an empty placeholder) or an estimate (text overflowing its box, measured with average glyph widths, not the font).
5
+ // An estimate is never an error: the exit code must be something an agent can act on without second-guessing it.
6
+
7
+ export type Severity = "error" | "warning";
8
+
9
+ export interface Finding {
10
+ readonly severity: Severity;
11
+ /** A stable name for the rule, for grouping and for --json readers (`missing-image`, `formula-error`). */
12
+ readonly rule: string;
13
+ /** Where it is, in the reader's units: `slide 3 · "Title 1"`, `Sheet1!B4`, `page 7`; empty for the whole file. */
14
+ readonly where: string;
15
+ readonly message: string;
16
+ }
17
+
18
+ export interface CheckReport {
19
+ readonly format: string;
20
+ /** The document's size in its own units (`12 slides`, `3 sheets`, `8 pages`); undefined when it could not be read. */
21
+ readonly extent?: string | undefined;
22
+ readonly findings: Finding[];
23
+ /** What was not checked and why (a part too large, a cap reached). */
24
+ readonly notes: string[];
25
+ }
26
+
27
+ export const error = (rule: string, where: string, message: string): Finding => ({ severity: "error", rule, where, message });
28
+ export const warning = (rule: string, where: string, message: string): Finding => ({ severity: "warning", rule, where, message });
29
+
30
+ export const plural = (count: number, noun: string, nouns = `${noun}s`): string => `${count} ${count === 1 ? noun : nouns}`;
31
+
32
+ /** A text excerpt for a message: one line, quoted, cut at `max` characters. */
33
+ export const excerpt = (text: string, max = 60): string => {
34
+ const line = text.replaceAll(/\s+/g, " ").trim();
35
+ return `"${line.length > max ? `${line.slice(0, max - 1)}…` : line}"`;
36
+ };
37
+
38
+ // Text an authoring tool or a template put there for a person to replace. The first group is never a document's own
39
+ // words: Office's placeholder prompts and filler Latin. The second usually is not, but a schedule can say TBD.
40
+ const LEFTOVER_CERTAIN: readonly RegExp[] = [
41
+ /\bClick (?:or tap )?(?:here )?(?:to|icon to) (?:add|edit|enter|insert)\b[^.\n]*/i,
42
+ /\bLorem ipsum\b/i,
43
+ /\bError! (?:Reference source not found|Bookmark not defined|No table of contents entries found|Not a valid)[^.\n]*/,
44
+ ];
45
+ const LEFTOVER_LIKELY: readonly RegExp[] = [/\b(?:TODO|FIXME|TBD|XXX)\b/, /\{\{[^{}\n]{1,40}\}\}/, /\[(?:insert|add|your|placeholder)\b[^\]\n]{0,40}\]/i];
46
+
47
+ /** Leftover template or editor text in `text`, as findings at `where`; at most one per rule, the first match quoted. */
48
+ export const leftoverText = (text: string, where: string): Finding[] => {
49
+ const certain = LEFTOVER_CERTAIN.map((pattern) => pattern.exec(text)?.[0]).find((match) => match !== undefined);
50
+ if (certain !== undefined) {
51
+ return [error("leftover-text", where, `leftover template text ${excerpt(certain)}: replace it with the real content or delete it`)];
52
+ }
53
+ const likely = LEFTOVER_LIKELY.map((pattern) => pattern.exec(text)?.[0]).find((match) => match !== undefined);
54
+ if (likely !== undefined) {
55
+ return [warning("leftover-text", where, `looks like a note left for later: ${excerpt(likely)}`)];
56
+ }
57
+ return [];
58
+ };
59
+
60
+ const EMU_PER_INCH = 914_400;
61
+
62
+ /** EMUs as inches for a message: `1.5 in`. */
63
+ export const inches = (emu: number): string => `${Number((emu / EMU_PER_INCH).toFixed(2))} in`;