@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,446 @@
1
+ import { strToU8 } from "fflate";
2
+ import { deckBytes, field, inlinePicture, para, pdfBytes, pdfPagesBytes, pictureFrame, pngOfSize, tableFrame, textBox, wordBytes, workbookBytes } from "../../testing.js";
3
+ import { checkDocx } from "./docx.js";
4
+ import { leftoverText, type CheckReport } from "./finding.js";
5
+ import { checkPdf } from "./pdf.js";
6
+ import { checkPptx } from "./pptx.js";
7
+ import { estimateText, wrappedLines, type Paragraph } from "./text-fit.js";
8
+ import { checkXlsx } from "./xlsx.js";
9
+ import { parseXml } from "./xml-tree.js";
10
+
11
+ // Every checker against a document built with one known defect, and against a clean one, which must come back with
12
+ // nothing: a lint that cries wolf on a well-made deck teaches the agent to ignore it.
13
+
14
+ const found = (report: CheckReport): [string, string, string][] => report.findings.map((finding) => [finding.severity, finding.rule, finding.where]);
15
+ const messageOf = (report: CheckReport, rule: string): string => report.findings.find((finding) => finding.rule === rule)?.message ?? "";
16
+
17
+ const IMAGE = { type: "image", target: "../media/image1.png" };
18
+
19
+ describe("pptx", () => {
20
+ test("a well-made deck has nothing to report, placeholders placed by the layout included", () => {
21
+ const report = checkPptx(
22
+ deckBytes({
23
+ slides: [
24
+ {
25
+ drawings: [
26
+ textBox("Title 1", undefined, ["Quarterly results"], { ph: { type: "title" } }),
27
+ textBox("Content Placeholder 2", undefined, ["Revenue up 4%", "Costs flat"], { ph: { idx: 1 } }),
28
+ pictureFrame("Picture 3", { x: 8, y: 2, w: 4, h: 3 }, "rId2"),
29
+ ],
30
+ rels: { rId2: IMAGE },
31
+ },
32
+ ],
33
+ media: { "image1.png": pngOfSize(400, 300) },
34
+ }),
35
+ );
36
+ expect(report.extent).toBe("1 slide");
37
+ expect(report.findings).toEqual([]);
38
+ });
39
+
40
+ test("slides are numbered in the deck's order, not by their part files", () => {
41
+ const report = checkPptx(
42
+ deckBytes({
43
+ slides: [
44
+ { drawings: [textBox("Opening", { x: 1, y: 1, w: 4, h: 1 }, ["Hello"])], partNumber: 7 },
45
+ { drawings: [textBox("Stray", { x: 20, y: 1, w: 2, h: 1 }, ["gone"])], partNumber: 2 },
46
+ ],
47
+ }),
48
+ );
49
+ expect(found(report)).toEqual([["error", "off-slide", 'slide 2 · "Stray"']]);
50
+ });
51
+
52
+ test("a drawing wholly off the slide is an error, one bleeding past an edge a warning with how far", () => {
53
+ const report = checkPptx(
54
+ deckBytes({
55
+ slides: [{ drawings: [textBox("Gone", { x: 14, y: 1, w: 2, h: 1 }, ["off"]), textBox("Bleed", { x: 12, y: 1, w: 3, h: 1 }, ["partly"])] }],
56
+ }),
57
+ );
58
+ expect(found(report)).toEqual([
59
+ ["error", "off-slide", 'slide 1 · "Gone"'],
60
+ ["warning", "off-slide", 'slide 1 · "Bleed"'],
61
+ ]);
62
+ expect(messageOf(report, "off-slide")).toBe("sits entirely off the slide, so no one will see it; move it onto the slide or delete it");
63
+ expect(report.findings[1]?.message).toBe("runs 1.67 in past the right edge of the slide, where it is cut off");
64
+ });
65
+
66
+ test("a sliver past the edge under 1% of the slide is rounding, not a finding", () => {
67
+ const report = checkPptx(deckBytes({ slides: [{ drawings: [textBox("Full bleed", { x: 0, y: 0, w: 13.4, h: 7.5 }, ["Cover"])] }] }));
68
+ expect(report.findings).toEqual([]);
69
+ });
70
+
71
+ test("Office's own prompt left as text is an error; a TODO is a warning", () => {
72
+ const report = checkPptx(
73
+ deckBytes({
74
+ slides: [
75
+ {
76
+ drawings: [
77
+ textBox("Title 1", undefined, ["Click to add title"], { ph: { type: "title" } }),
78
+ textBox("Notes", { x: 1, y: 3, w: 6, h: 1 }, ["TODO: numbers from finance"]),
79
+ ],
80
+ },
81
+ ],
82
+ }),
83
+ );
84
+ expect(found(report)).toEqual([
85
+ ["error", "leftover-text", 'slide 1 · "Title 1"'],
86
+ ["warning", "leftover-text", 'slide 1 · "Notes"'],
87
+ ]);
88
+ });
89
+
90
+ test("an empty placeholder is a warning naming the prompt an editor sees", () => {
91
+ const report = checkPptx(deckBytes({ slides: [{ drawings: [textBox("Title 1", undefined, [], { ph: { type: "title" } })] }] }));
92
+ expect(found(report)).toEqual([["warning", "empty-placeholder", 'slide 1 · "Title 1"']]);
93
+ expect(messageOf(report, "empty-placeholder")).toBe(
94
+ 'empty title placeholder: invisible in the slide show, but it reads "Click to add title" to anyone who edits the deck; fill it or delete it',
95
+ );
96
+ });
97
+
98
+ test("a picture whose image is not in the file, or whose relationship is not defined, is an error", () => {
99
+ const report = checkPptx(
100
+ deckBytes({
101
+ slides: [
102
+ {
103
+ drawings: [pictureFrame("Gone", { x: 1, y: 1, w: 4, h: 3 }, "rId2"), pictureFrame("Undefined", { x: 6, y: 1, w: 4, h: 3 }, "rId9")],
104
+ rels: { rId2: { type: "image", target: "../media/missing.png" } },
105
+ },
106
+ ],
107
+ }),
108
+ );
109
+ expect(found(report)).toEqual([
110
+ ["error", "missing-image", 'slide 1 · "Gone"'],
111
+ ["error", "missing-image", 'slide 1 · "Undefined"'],
112
+ ]);
113
+ expect(report.findings[0]?.message).toBe("image ppt/media/missing.png is not in the file, so it shows as a broken image");
114
+ });
115
+
116
+ test("an image linked from outside the file is a warning naming where it points", () => {
117
+ const report = checkPptx(
118
+ deckBytes({
119
+ slides: [{ drawings: [pictureFrame("Logo", { x: 1, y: 1, w: 4, h: 3 }, "rId2")], rels: { rId2: { type: "image", target: "file:///C:/Users/me/logo.png", external: true } } }],
120
+ }),
121
+ );
122
+ expect(found(report)).toEqual([["warning", "linked-image", 'slide 1 · "Logo"']]);
123
+ expect(messageOf(report, "linked-image")).toContain("file:///C:/Users/me/logo.png");
124
+ });
125
+
126
+ test("a stretched picture is a warning; a crop that removes it all is an error", () => {
127
+ const report = checkPptx(
128
+ deckBytes({
129
+ slides: [
130
+ {
131
+ drawings: [pictureFrame("Wide", { x: 1, y: 1, w: 4, h: 1 }, "rId2"), pictureFrame("Cropped", { x: 6, y: 1, w: 4, h: 3 }, "rId2", { l: 60_000, r: 40_000 })],
132
+ rels: { rId2: IMAGE },
133
+ },
134
+ ],
135
+ media: { "image1.png": pngOfSize(400, 300) },
136
+ }),
137
+ );
138
+ expect(found(report)).toEqual([
139
+ ["error", "bad-crop", 'slide 1 · "Cropped"'],
140
+ ["warning", "stretched-image", 'slide 1 · "Wide"'],
141
+ ]);
142
+ expect(messageOf(report, "stretched-image")).toBe(
143
+ "the picture is stretched wide: its box is 4.00:1, the image 1.33:1; set one side from the other, or crop instead",
144
+ );
145
+ });
146
+
147
+ test("a picture cropped to its box's proportions is not stretched", () => {
148
+ const report = checkPptx(
149
+ deckBytes({
150
+ slides: [{ drawings: [pictureFrame("Banner", { x: 1, y: 1, w: 4, h: 1.5 }, "rId2", { t: 25_000, b: 25_000 })], rels: { rId2: IMAGE } }],
151
+ media: { "image1.png": pngOfSize(400, 300) },
152
+ }),
153
+ );
154
+ expect(report.findings).toEqual([]);
155
+ });
156
+
157
+ test("text that cannot fit its box is a warning with the estimate, pointing at render", () => {
158
+ const long = "This is a very long sentence that will surely not fit in a small box of three inches at twenty four points, and it goes on and on.";
159
+ const report = checkPptx(
160
+ deckBytes({
161
+ slides: [
162
+ { drawings: [textBox("Opening", { x: 1, y: 1, w: 4, h: 1 }, ["Hello"])] },
163
+ { drawings: [textBox("Callout", { x: 1, y: 1, w: 3, h: 0.6 }, [long], { size: 24 })] },
164
+ ],
165
+ }),
166
+ );
167
+ expect(found(report)).toEqual([["warning", "text-overflow", 'slide 2 · "Callout"']]);
168
+ expect(messageOf(report, "text-overflow")).toBe(
169
+ "text probably overflows its box: about 7 lines of 24 pt need 2.9 in, the box is 0.6 in tall; shorten it, enlarge the box or split the slide (an estimate: look with `fileq render --pages 2`)",
170
+ );
171
+ });
172
+
173
+ test("text that fits, and a box that grows to fit its text on the slide, say nothing", () => {
174
+ const report = checkPptx(
175
+ deckBytes({
176
+ slides: [
177
+ {
178
+ drawings: [
179
+ textBox("Fits", { x: 1, y: 1, w: 6, h: 1.2 }, ["Two lines of", "comfortable text"], { size: 24 }),
180
+ textBox("Grows", { x: 1, y: 3, w: 3, h: 0.5 }, ["A sentence long enough to need three or four lines here."], { size: 18, autofit: "grow" }),
181
+ ],
182
+ },
183
+ ],
184
+ }),
185
+ );
186
+ expect(report.findings).toEqual([]);
187
+ });
188
+
189
+ test("shrink-on-overflow says PowerPoint shows it overflowing until edited", () => {
190
+ const long = "A paragraph much too long for its box, which PowerPoint only shrinks once somebody edits the text and LibreOffice shrinks at once.";
191
+ const report = checkPptx(deckBytes({ slides: [{ drawings: [textBox("Body", { x: 1, y: 1, w: 3, h: 0.6 }, [long], { size: 24, autofit: "shrink" })] }] }));
192
+ expect(messageOf(report, "text-overflow")).toContain("shrink-on-overflow is on, so LibreOffice shrinks it but PowerPoint shows it overflowing until the text is edited");
193
+ });
194
+
195
+ test("a table whose rows run past the bottom of the slide is a warning", () => {
196
+ const rows = Array.from({ length: 16 }, (_, index) => [`Region ${index}`, "A value", "Another"]);
197
+ const report = checkPptx(deckBytes({ slides: [{ drawings: [tableFrame("Table 1", { x: 0.5, y: 1.5, w: 12, h: 3 }, rows, 0.4)] }] }));
198
+ expect(found(report)).toEqual([["warning", "table-overflow", 'slide 1 · "Table 1"']]);
199
+ });
200
+
201
+ test("a hidden slide is a warning", () => {
202
+ const report = checkPptx(deckBytes({ slides: [{ drawings: [textBox("Backup", { x: 1, y: 1, w: 4, h: 1 }, ["Spare"])], hidden: true }] }));
203
+ expect(found(report)).toEqual([["warning", "hidden-slide", "slide 1"]]);
204
+ });
205
+
206
+ test("a part with no content type is damage Office repairs", () => {
207
+ const report = checkPptx(
208
+ deckBytes({
209
+ slides: [{ drawings: [pictureFrame("Photo", { x: 1, y: 1, w: 4, h: 3 }, "rId2")], rels: { rId2: IMAGE } }],
210
+ media: { "image1.png": pngOfSize(400, 300) },
211
+ untypedMedia: true,
212
+ }),
213
+ );
214
+ expect(found(report)).toEqual([["error", "content-type", ""]]);
215
+ expect(messageOf(report, "content-type")).toContain("no content type for part ppt/media/image1.png");
216
+ });
217
+
218
+ test("a file that is not a zip is one error, not a crash", () => {
219
+ const report = checkPptx(strToU8("not a deck"));
220
+ expect(found(report)).toEqual([["error", "unreadable", ""]]);
221
+ });
222
+
223
+ test("a nameless drawing is named by its kind and id", () => {
224
+ const nameless = textBox("", { x: 20, y: 1, w: 2, h: 1 }, ["far"]);
225
+ const report = checkPptx(deckBytes({ slides: [{ drawings: [nameless] }] }));
226
+ expect(report.findings[0]?.where).toMatch(/^slide 1 · unnamed shape #\d+$/);
227
+ });
228
+ });
229
+
230
+ const DOC_IMAGE = { type: "image", target: "media/image1.png" };
231
+
232
+ describe("docx", () => {
233
+ test("a well-made document has nothing to report", () => {
234
+ const report = checkDocx(
235
+ wordBytes({
236
+ body: [para("Report", "berschrift1"), para("The body of the report."), '<w:p><w:bookmarkStart w:id="0" w:name="results"/><w:bookmarkEnd w:id="0"/></w:p>', '<w:p><w:hyperlink w:anchor="results"><w:r><w:t>see results</w:t></w:r></w:hyperlink></w:p>', inlinePicture("rId5", 6, 3), field("PAGE", "1")],
237
+ rels: { rId5: DOC_IMAGE },
238
+ media: { "image1.png": pngOfSize(600, 300) },
239
+ }),
240
+ );
241
+ expect(report.extent).toBe("6 paragraphs");
242
+ expect(report.findings).toEqual([]);
243
+ });
244
+
245
+ test("a link to a bookmark that is not there is an error, located by its paragraph", () => {
246
+ const report = checkDocx(wordBytes({ body: [para("Intro", "berschrift1"), '<w:p><w:hyperlink w:anchor="nowhere"><w:r><w:t>see section</w:t></w:r></w:hyperlink></w:p>'] }));
247
+ expect(found(report)).toEqual([["error", "broken-link", 'paragraph 2 · "see section"']]);
248
+ expect(messageOf(report, "broken-link")).toBe('the link "see section" points to bookmark "nowhere", which is not in the document, so clicking it goes nowhere');
249
+ });
250
+
251
+ test("a cross-reference to a missing bookmark, and a field showing Word's error, are errors said once", () => {
252
+ const report = checkDocx(wordBytes({ body: [field("PAGEREF _Ref999 \\h", "3"), field("REF _Ref123 \\h", "Error! Reference source not found.")] }));
253
+ expect(found(report)).toEqual([
254
+ ["error", "broken-reference", 'paragraph 1 · "3"'],
255
+ ["error", "field-error", 'paragraph 2 · "Error! Reference source not found."'],
256
+ ]);
257
+ });
258
+
259
+ test("a table of contents with no saved entries is a warning", () => {
260
+ const report = checkDocx(wordBytes({ body: [para("Contents"), field('TOC \\o "1-3" \\h', undefined), para("Body", "berschrift1")] }));
261
+ expect(found(report)).toEqual([["warning", "empty-toc", "paragraph 2"]]);
262
+ });
263
+
264
+ test("an empty heading is found through the style's name, whatever its id, and through basedOn", () => {
265
+ const report = checkDocx(wordBytes({ body: [para("Results", "berschrift1"), para("", "berschrift2"), para("", "Callout"), para("Text.")] }));
266
+ expect(found(report)).toEqual([
267
+ ["warning", "empty-heading", 'paragraph 2 · under "Results"'],
268
+ ["warning", "empty-heading", 'paragraph 3 · under "Results"'],
269
+ ]);
270
+ });
271
+
272
+ test("a missing image is an error, one wider than the text column a warning", () => {
273
+ const report = checkDocx(
274
+ wordBytes({
275
+ body: [inlinePicture("rId5", 9, 3), inlinePicture("rId6", 2, 2)],
276
+ rels: { rId5: DOC_IMAGE, rId6: { type: "image", target: "media/missing.png" } },
277
+ media: { "image1.png": pngOfSize(900, 300) },
278
+ }),
279
+ );
280
+ expect(found(report)).toEqual([
281
+ ["error", "missing-image", "paragraph 2"],
282
+ ["warning", "wide-image", "paragraph 1"],
283
+ ]);
284
+ expect(messageOf(report, "wide-image")).toBe("a picture is 9 in wide in a 6.5 in text column, so it runs into the margin or is cut off; scale it to fit");
285
+ });
286
+
287
+ test("what a draft leaves behind: tracked changes, comments, a control showing its prompt, filler text", () => {
288
+ const report = checkDocx(
289
+ wordBytes({
290
+ body: [
291
+ '<w:p><w:ins w:id="1" w:author="a"><w:r><w:t>new words</w:t></w:r></w:ins></w:p>',
292
+ '<w:sdt><w:sdtPr><w:showingPlcHdr/></w:sdtPr><w:sdtContent><w:p><w:r><w:t>Click or tap here to enter text.</w:t></w:r></w:p></w:sdtContent></w:sdt>',
293
+ para("Lorem ipsum dolor sit amet."),
294
+ ],
295
+ comments: ["check this figure"],
296
+ }),
297
+ );
298
+ expect(found(report)).toEqual([
299
+ ["error", "placeholder-control", 'paragraph 2 · "Click or tap here to enter text."'],
300
+ ["error", "leftover-text", 'paragraph 3 · "Lorem ipsum dolor sit amet."'],
301
+ ["warning", "tracked-changes", ""],
302
+ ["warning", "comments", ""],
303
+ ]);
304
+ });
305
+
306
+ test("typed bullets are one warning with their count, at the first", () => {
307
+ const report = checkDocx(wordBytes({ body: [para("• first"), para("• second"), para("Plain.")] }));
308
+ expect(found(report)).toEqual([["warning", "typed-bullets", 'paragraph 1 · "• first"']]);
309
+ expect(messageOf(report, "typed-bullets")).toBe(
310
+ "2 paragraphs start with a typed bullet character instead of a list style, so the list will not indent or renumber; use a list style",
311
+ );
312
+ });
313
+ });
314
+
315
+ const cell = (ref: string, inner: string, type?: string): string => `<c r="${ref}"${type === undefined ? "" : ` t="${type}"`}>${inner}</c>`;
316
+
317
+ describe("xlsx", () => {
318
+ test("a workbook of values and computed formulas has nothing to report", () => {
319
+ const report = checkXlsx(workbookBytes([{ name: "Sheet1", rows: `<row r="1">${cell("A1", "<v>1</v>")}${cell("B1", "<f>A1*2</f><v>2</v>")}</row>` }]));
320
+ expect(report.extent).toBe("1 sheet");
321
+ expect(report.findings).toEqual([]);
322
+ });
323
+
324
+ test("an error value is an error at its cell with its formula; a sheet name with a space is quoted", () => {
325
+ const report = checkXlsx(workbookBytes([{ name: "Q3 plan", rows: `<row r="4">${cell("C4", "<f>A4/B4</f><v>#DIV/0!</v>", "e")}</row>` }]));
326
+ expect(found(report)).toEqual([["error", "formula-error", "'Q3 plan'!C4"]]);
327
+ expect(messageOf(report, "formula-error")).toBe("shows #DIV/0! from =A4/B4: fix the formula or its inputs");
328
+ });
329
+
330
+ test("a formula pointing at a deleted range is an error even with a value saved", () => {
331
+ const report = checkXlsx(workbookBytes([{ name: "Data", rows: `<row r="1">${cell("A1", "<f>SUM(#REF!)</f><v>0</v>")}</row>` }]));
332
+ expect(found(report)).toEqual([["error", "formula-error", "Data!A1"]]);
333
+ });
334
+
335
+ test("formulas saved without a value are one warning per sheet, and say whether the workbook recalculates on open", () => {
336
+ const rows = `<row r="1">${cell("A1", "<v>1</v>")}${cell("B1", "<f>A1+1</f>")}${cell("C1", "<f>A1+2</f>")}</row>`;
337
+ const plain = checkXlsx(workbookBytes([{ name: "Data", rows }]));
338
+ expect(found(plain)).toEqual([["warning", "formula-uncached", "Data"]]);
339
+ expect(messageOf(plain, "formula-uncached")).toContain("2 formulas saved with no value (B1, C1): Excel, LibreOffice and Google Sheets compute them on open");
340
+ const flagged = checkXlsx(workbookBytes([{ name: "Data", rows }], { calcPr: '<calcPr fullCalcOnLoad="1"/>' }));
341
+ expect(messageOf(flagged, "formula-uncached")).toContain("the workbook asks to be recalculated on open");
342
+ });
343
+
344
+ test("past ten error cells on a sheet, the rest are counted", () => {
345
+ const rows = `<row r="1">${Array.from({ length: 13 }, (_, index) => cell(`A${index + 1}`, "<v>#N/A</v>", "e")).join("")}</row>`;
346
+ const report = checkXlsx(workbookBytes([{ name: "Data", rows }]));
347
+ expect(report.findings).toHaveLength(11);
348
+ expect(report.findings.at(-1)?.message).toBe("and 3 more cells with errors on this sheet");
349
+ });
350
+
351
+ test("a defined name pointing at a deleted range is an error; an empty workbook a warning", () => {
352
+ const report = checkXlsx(workbookBytes([{ name: "Data", rows: "" }], { definedNames: '<definedName name="Total">#REF!$A$1</definedName>' }));
353
+ expect(found(report)).toEqual([
354
+ ["warning", "no-data", ""],
355
+ ["error", "broken-name", ""],
356
+ ]);
357
+ });
358
+ });
359
+
360
+ describe("pdf", () => {
361
+ test("a page of text in a standard font has nothing to report", async () => {
362
+ const report = await checkPdf(pdfBytes("Hello world, this is a page."));
363
+ expect(report.extent).toBe("1 page");
364
+ expect(report.findings).toEqual([]);
365
+ });
366
+
367
+ test("a blank page is an error; a page with only a drawing is not blank", async () => {
368
+ const report = await checkPdf(pdfPagesBytes([{ content: "BT /F1 12 Tf 72 720 Td (Cover) Tj ET" }, { content: "" }, { content: "0 0 1 rg 10 10 100 100 re f" }]));
369
+ expect(found(report)).toEqual([["error", "blank-page", "page 2"]]);
370
+ });
371
+
372
+ test("text in a font the file does not carry is a warning; the fourteen standard fonts are not", async () => {
373
+ const report = await checkPdf(pdfPagesBytes([{ content: "BT /F1 12 Tf 72 720 Td (Hello) Tj ET", font: "Calibri" }, { content: "BT /F1 12 Tf 72 720 Td (Again) Tj ET", font: "Calibri" }]));
374
+ expect(found(report)).toEqual([["warning", "font-not-embedded", "page 1, 2"]]);
375
+ expect(messageOf(report, "font-not-embedded")).toContain("text is set in Calibri, which the file does not carry");
376
+ });
377
+
378
+ test("a page of another size is a warning naming both sizes", async () => {
379
+ const text = "BT /F1 12 Tf 72 500 Td (Page) Tj ET";
380
+ const report = await checkPdf(pdfPagesBytes([{ content: text }, { content: text, size: [792, 612] }, { content: text }]));
381
+ expect(found(report)).toEqual([["warning", "page-size", "page 2"]]);
382
+ expect(messageOf(report, "page-size")).toBe("1 page is 11 × 8.5 in, the rest 8.5 × 11 in");
383
+ });
384
+
385
+ test("a link to a named destination the file does not define is an error", async () => {
386
+ const report = await checkPdf(
387
+ pdfPagesBytes([{ content: "BT /F1 12 Tf 72 720 Td (See results) Tj ET", extra: "/Annots [<< /Type /Annot /Subtype /Link /Rect [72 700 200 730] /Dest (results) >>]" }]),
388
+ );
389
+ expect(found(report)).toEqual([["error", "broken-link", "page 1"]]);
390
+ });
391
+
392
+ test("a document with no text layer at all is a warning", async () => {
393
+ const report = await checkPdf(pdfPagesBytes([{ content: "0 0 1 rg 10 10 100 100 re f" }]));
394
+ expect(found(report)).toEqual([["warning", "no-text-layer", ""]]);
395
+ });
396
+
397
+ test("bytes that are not a PDF are one error", async () => {
398
+ const report = await checkPdf(strToU8("not a pdf"));
399
+ expect(found(report)).toEqual([["error", "unreadable", ""]]);
400
+ });
401
+ });
402
+
403
+ describe("leftover text", () => {
404
+ test("the first certain match wins, quoted; a likely one is a warning; ordinary words are nothing", () => {
405
+ expect(leftoverText("Click to add subtitle", "x").map((finding) => [finding.severity, finding.message])).toEqual([
406
+ ["error", 'leftover template text "Click to add subtitle": replace it with the real content or delete it'],
407
+ ]);
408
+ expect(leftoverText("Venue: TBD", "x").map((finding) => finding.severity)).toEqual(["warning"]);
409
+ expect(leftoverText("Dear {{first_name}},", "x").map((finding) => finding.severity)).toEqual(["warning"]);
410
+ expect(leftoverText("Click the button to add a row.", "x")).toEqual([]);
411
+ expect(leftoverText("A todo list for the team", "x")).toEqual([]);
412
+ });
413
+ });
414
+
415
+ describe("xml tree", () => {
416
+ test("a part written under other prefixes reads under the specification's", () => {
417
+ const tree = parseXml(
418
+ '<ns0:sld xmlns:ns0="http://schemas.openxmlformats.org/presentationml/2006/main" xmlns:ns1="http://schemas.openxmlformats.org/officeDocument/2006/relationships"><ns0:cSld ns1:id="rId1"/></ns0:sld>',
419
+ );
420
+ expect(tree.name).toBe("p:sld");
421
+ expect(tree.children).toEqual([{ name: "p:cSld", attrs: { "r:id": "rId1" }, children: [] }]);
422
+ });
423
+
424
+ test("entities decode, CDATA is text, whitespace is kept only inside a text element", () => {
425
+ const tree = parseXml('<w:p xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">\n <w:t> A &amp; B </w:t><w:t><![CDATA[<raw>]]></w:t>\n</w:p>');
426
+ expect(tree.children).toEqual([
427
+ { name: "w:t", attrs: {}, children: [" A & B "] },
428
+ { name: "w:t", attrs: {}, children: ["<raw>"] },
429
+ ]);
430
+ });
431
+ });
432
+
433
+ describe("text fit", () => {
434
+ const paragraph = (text: string, sizePt = 18): Paragraph => ({ text, sizePt, bold: false, lineSpacing: 1, spaceBeforePt: 0, spaceAfterPt: 0 });
435
+
436
+ test("a line that fits is one line; a hard break is another; a word wider than the column breaks", () => {
437
+ expect(wrappedLines(paragraph("short"), 200)).toBe(1);
438
+ expect(wrappedLines(paragraph("one\ntwo"), 200)).toBe(2);
439
+ expect(wrappedLines(paragraph("x".repeat(40), 10), 100)).toBe(3);
440
+ });
441
+
442
+ test("height is lines times 1.2 of the size, plus the spacing between paragraphs", () => {
443
+ const estimate = estimateText([{ ...paragraph("a", 20), spaceAfterPt: 6 }, paragraph("b", 20)], 500, true);
444
+ expect(estimate).toEqual({ heightPt: 54, lines: 2, widestPt: 10.4, sizePt: 20 });
445
+ });
446
+ });
@@ -0,0 +1,49 @@
1
+ import { readFile, stat } from "node:fs/promises";
2
+ import { errorMessage, isMissing } from "@intentic/base/errors";
3
+ import { claimedFormat, type Format } from "../formats.js";
4
+ import { checkDocx } from "./docx.js";
5
+ import type { CheckReport } from "./finding.js";
6
+ import { checkPdf } from "./pdf.js";
7
+ import { checkPptx } from "./pptx.js";
8
+ import { checkXlsx } from "./xlsx.js";
9
+
10
+ // `fileq check`'s routing: which formats have a checker, and the one read of the file every checker works from.
11
+
12
+ type Checker = (bytes: Uint8Array) => CheckReport | Promise<CheckReport>;
13
+
14
+ const CHECKERS = new Map<Format, Checker>([
15
+ ["docx", checkDocx],
16
+ ["pptx", checkPptx],
17
+ ["xlsx", checkXlsx],
18
+ ["pdf", checkPdf],
19
+ ]);
20
+
21
+ export const CHECKABLE: readonly Format[] = [...CHECKERS.keys()];
22
+
23
+ // A document is read whole into memory to be checked; past this it is data to process, not a deliverable to proof.
24
+ const MAX_CHECK_BYTES = 200 * 1024 * 1024;
25
+
26
+ export type CheckOutcome =
27
+ | { readonly kind: "checked"; readonly report: CheckReport }
28
+ | { readonly kind: "unsupported"; readonly format: Format | undefined }
29
+ | { readonly kind: "unreadable"; readonly reason: string };
30
+
31
+ /** Checks one file, routed by its claimed format (magic first, then the extension). */
32
+ export const checkFile = async (absPath: string): Promise<CheckOutcome> => {
33
+ let size: number;
34
+ try {
35
+ size = (await stat(absPath)).size;
36
+ } catch (cause) {
37
+ return { kind: "unreadable", reason: isMissing(cause) ? "no such file" : errorMessage(cause) };
38
+ }
39
+ if (size > MAX_CHECK_BYTES) {
40
+ return { kind: "unreadable", reason: `${Math.round(size / 1024 / 1024)} MB is over the ${MAX_CHECK_BYTES / 1024 / 1024} MB a check reads` };
41
+ }
42
+ const format = await claimedFormat(absPath);
43
+ const checker = format === undefined ? undefined : CHECKERS.get(format);
44
+ if (checker === undefined) {
45
+ return { kind: "unsupported", format };
46
+ }
47
+ const report = await checker(new Uint8Array(await readFile(absPath)));
48
+ return { kind: "checked", report };
49
+ };