@intentic/fileq 1.318.0 → 1.320.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 (84) hide show
  1. package/NOTICE +4 -4
  2. package/README.md +16 -10
  3. package/dist/app.d.ts.map +1 -1
  4. package/dist/app.js +4 -6
  5. package/dist/app.js.map +1 -1
  6. package/dist/commands/derive.command.js +1 -1
  7. package/dist/commands/derive.command.js.map +1 -1
  8. package/dist/commands/read.command.d.ts.map +1 -1
  9. package/dist/commands/read.command.js +26 -45
  10. package/dist/commands/read.command.js.map +1 -1
  11. package/dist/lib/check/{docx.d.ts → check-docx.d.ts} +1 -1
  12. package/dist/lib/check/check-docx.d.ts.map +1 -0
  13. package/dist/lib/check/{docx.js → check-docx.js} +46 -14
  14. package/dist/lib/check/check-docx.js.map +1 -0
  15. package/dist/lib/check/{pdf.d.ts → check-pdf.d.ts} +1 -1
  16. package/dist/lib/check/check-pdf.d.ts.map +1 -0
  17. package/dist/lib/check/{pdf.js → check-pdf.js} +4 -2
  18. package/dist/lib/check/check-pdf.js.map +1 -0
  19. package/dist/lib/check/{pptx.d.ts → check-pptx.d.ts} +1 -1
  20. package/dist/lib/check/check-pptx.d.ts.map +1 -0
  21. package/dist/lib/check/{pptx.js → check-pptx.js} +18 -5
  22. package/dist/lib/check/check-pptx.js.map +1 -0
  23. package/dist/lib/check/{xlsx.d.ts → check-xlsx.d.ts} +1 -1
  24. package/dist/lib/check/check-xlsx.d.ts.map +1 -0
  25. package/dist/lib/check/{xlsx.js → check-xlsx.js} +17 -6
  26. package/dist/lib/check/check-xlsx.js.map +1 -0
  27. package/dist/lib/check/check.js +4 -4
  28. package/dist/lib/check/check.js.map +1 -1
  29. package/dist/lib/content-cache.d.ts +12 -0
  30. package/dist/lib/content-cache.d.ts.map +1 -0
  31. package/dist/lib/content-cache.js +33 -0
  32. package/dist/lib/content-cache.js.map +1 -0
  33. package/dist/lib/derive.d.ts +10 -0
  34. package/dist/lib/derive.d.ts.map +1 -1
  35. package/dist/lib/derive.js +40 -4
  36. package/dist/lib/derive.js.map +1 -1
  37. package/dist/lib/sidecar.d.ts +11 -2
  38. package/dist/lib/sidecar.d.ts.map +1 -1
  39. package/dist/lib/sidecar.js +28 -16
  40. package/dist/lib/sidecar.js.map +1 -1
  41. package/dist/skill.d.ts.map +1 -1
  42. package/dist/skill.js +8 -7
  43. package/dist/skill.js.map +1 -1
  44. package/package.json +7 -7
  45. package/src/app.ts +4 -6
  46. package/src/cli.integration.test.ts +52 -29
  47. package/src/commands/check.command.ts +1 -1
  48. package/src/commands/derive.command.ts +1 -1
  49. package/src/commands/read.command.ts +30 -52
  50. package/src/lib/archives.ts +1 -1
  51. package/src/lib/check/{docx.ts → check-docx.ts} +112 -27
  52. package/src/lib/check/{pdf.ts → check-pdf.ts} +23 -4
  53. package/src/lib/check/{pptx.ts → check-pptx.ts} +48 -11
  54. package/src/lib/check/{xlsx.ts → check-xlsx.ts} +20 -6
  55. package/src/lib/check/check.test.ts +99 -27
  56. package/src/lib/check/check.ts +4 -4
  57. package/src/lib/content-cache.ts +58 -0
  58. package/src/lib/derive.integration.test.ts +198 -0
  59. package/src/lib/derive.ts +69 -7
  60. package/src/lib/derivers/archive.ts +1 -1
  61. package/src/lib/derivers/load-cost.test.ts +1 -1
  62. package/src/lib/derivers/pdf.ts +1 -1
  63. package/src/lib/derivers/xlsx.ts +1 -1
  64. package/src/lib/sidecar.ts +42 -22
  65. package/src/render.integration.test.ts +80 -56
  66. package/src/skill.ts +8 -7
  67. package/dist/commands/sweep.command.d.ts +0 -3
  68. package/dist/commands/sweep.command.d.ts.map +0 -1
  69. package/dist/commands/sweep.command.js +0 -41
  70. package/dist/commands/sweep.command.js.map +0 -1
  71. package/dist/lib/check/docx.d.ts.map +0 -1
  72. package/dist/lib/check/docx.js.map +0 -1
  73. package/dist/lib/check/pdf.d.ts.map +0 -1
  74. package/dist/lib/check/pdf.js.map +0 -1
  75. package/dist/lib/check/pptx.d.ts.map +0 -1
  76. package/dist/lib/check/pptx.js.map +0 -1
  77. package/dist/lib/check/xlsx.d.ts.map +0 -1
  78. package/dist/lib/check/xlsx.js.map +0 -1
  79. package/dist/lib/sweep.d.ts +0 -7
  80. package/dist/lib/sweep.d.ts.map +0 -1
  81. package/dist/lib/sweep.js +0 -56
  82. package/dist/lib/sweep.js.map +0 -1
  83. package/src/commands/sweep.command.ts +0 -48
  84. package/src/lib/sweep.ts +0 -72
@@ -250,7 +250,8 @@ const textStyleOf = (drawing: XmlElement, chain: readonly XmlElement[], inherita
250
250
  return { listStyles: [...own, ...tail] };
251
251
  };
252
252
 
253
- const hundredthsAt = (element: XmlElement | undefined, path: readonly string[], attribute: string): number | undefined => intAttr(childAt(element, ...path), attribute);
253
+ const hundredthsAt = (element: XmlElement | undefined, path: readonly string[], attribute: string): number | undefined =>
254
+ intAttr(childAt(element, ...path), attribute);
254
255
 
255
256
  const paragraphsOf = (txBody: XmlElement, style: TextStyle, fontScale: number, spacingReduction: number): Paragraph[] =>
256
257
  childrenNamed(txBody, "a:p").map((p) => {
@@ -274,7 +275,10 @@ const paragraphsOf = (txBody: XmlElement, style: TextStyle, fontScale: number, s
274
275
  ...style.listStyles.map((listStyle) => () => intAttr(childAt(listStyle, `a:lvl${level}pPr`, "a:defRPr"), attribute)),
275
276
  ]);
276
277
  const runSizes = runs.flatMap((run) => intAttr(childNamed(run, "a:rPr"), "sz") ?? []);
277
- const size = runSizes.length > 0 ? Math.max(...runSizes) : (intAttr(childNamed(p, "a:endParaRPr"), "sz") ?? inherited("sz") ?? DEFAULT_SIZE_HUNDREDTHS);
278
+ const size =
279
+ runSizes.length > 0
280
+ ? Math.max(...runSizes)
281
+ : (intAttr(childNamed(p, "a:endParaRPr"), "sz") ?? inherited("sz") ?? DEFAULT_SIZE_HUNDREDTHS);
278
282
  const bold = runs.some((run) => childNamed(run, "a:rPr")?.attrs["b"] === "1") || (runs.length > 0 && inherited("b") === 1);
279
283
  const spacing = (name: string): number | undefined =>
280
284
  firstOf([
@@ -348,7 +352,13 @@ const overflowFindings = (drawing: XmlElement, chain: readonly XmlElement[], box
348
352
  if (body.autofit === "grow") {
349
353
  // The box grows to fit its text, so the text cannot overflow the box, only the slide.
350
354
  return box.y + needed > context.height * 1.02
351
- ? [warning("text-overflow", where, `the box grows to fit its text, and about ${plural(estimate.lines, "line")} of ${estimate.sizePt} pt take it past the bottom of the slide ${look}`)]
355
+ ? [
356
+ warning(
357
+ "text-overflow",
358
+ where,
359
+ `the box grows to fit its text, and about ${plural(estimate.lines, "line")} of ${estimate.sizePt} pt take it past the bottom of the slide ${look}`,
360
+ ),
361
+ ]
352
362
  : [];
353
363
  }
354
364
  if (estimate.heightPt <= innerHeight * OVERFLOW_MARGIN) {
@@ -391,7 +401,11 @@ const tableFindings = (frame: XmlElement, box: Box, where: string, context: Slid
391
401
  const tcPr = childNamed(cell, "a:tcPr");
392
402
  const marginX = (intAttr(tcPr, "marL") ?? DEFAULT_INSET_X) + (intAttr(tcPr, "marR") ?? DEFAULT_INSET_X);
393
403
  const marginY = (intAttr(tcPr, "marT") ?? DEFAULT_INSET_Y) + (intAttr(tcPr, "marB") ?? DEFAULT_INSET_Y);
394
- const estimate = estimateText(paragraphsOf(txBody, { listStyles: [childNamed(txBody, "a:lstStyle")] }, 1, 0), (cellWidth - marginX) / EMU_PER_PT, true);
404
+ const estimate = estimateText(
405
+ paragraphsOf(txBody, { listStyles: [childNamed(txBody, "a:lstStyle")] }, 1, 0),
406
+ (cellWidth - marginX) / EMU_PER_PT,
407
+ true,
408
+ );
395
409
  rowHeight = Math.max(rowHeight, estimate.heightPt * EMU_PER_PT + marginY);
396
410
  }
397
411
  total += rowHeight;
@@ -421,7 +435,11 @@ const referenceFinding = (node: XmlElement, id: string, where: string, context:
421
435
  }
422
436
  if (relationship.external) {
423
437
  return isImage
424
- ? warning("linked-image", where, `image is linked from outside the file (${relationship.target}), so it will not show on anyone else's machine; embed it`)
438
+ ? warning(
439
+ "linked-image",
440
+ where,
441
+ `image is linked from outside the file (${relationship.target}), so it will not show on anyone else's machine; embed it`,
442
+ )
425
443
  : undefined;
426
444
  }
427
445
  if (context.pkg.has(relationship.target)) {
@@ -452,7 +470,14 @@ const pictureFindings = (picture: XmlElement, box: Box | undefined, where: strin
452
470
  }
453
471
  const embed = childAt(blipFill, "a:blip")?.attrs["r:embed"];
454
472
  const relationship = embed === undefined ? undefined : context.pkg.rels(context.part).get(embed);
455
- if (box !== undefined && relationship !== undefined && !relationship.external && context.pkg.has(relationship.target) && box.cx > 0 && box.cy > 0) {
473
+ if (
474
+ box !== undefined &&
475
+ relationship !== undefined &&
476
+ !relationship.external &&
477
+ context.pkg.has(relationship.target) &&
478
+ box.cx > 0 &&
479
+ box.cy > 0
480
+ ) {
456
481
  context.pictures.push({ where, media: relationship.target, cx: box.cx, cy: box.cy, crop: sides });
457
482
  }
458
483
  return [];
@@ -553,7 +578,8 @@ const drawingFindings = (drawing: XmlElement, context: SlideContext, grouped: bo
553
578
  const layoutsOf = (pkg: OoxmlPackage, slidePart: string): Pick<Inheritance, "layout" | "master"> => {
554
579
  const layoutRel = [...pkg.rels(slidePart).values()].find((relationship) => relationship.kind === "slideLayout" && !relationship.external);
555
580
  const layout = layoutRel === undefined ? undefined : pkg.xml(layoutRel.target);
556
- const masterRel = layoutRel === undefined ? undefined : [...pkg.rels(layoutRel.target).values()].find((relationship) => relationship.kind === "slideMaster");
581
+ const masterRel =
582
+ layoutRel === undefined ? undefined : [...pkg.rels(layoutRel.target).values()].find((relationship) => relationship.kind === "slideMaster");
557
583
  return { layout, master: masterRel === undefined ? undefined : pkg.xml(masterRel.target) };
558
584
  };
559
585
 
@@ -561,7 +587,9 @@ const slideFindings = (context: SlideContext, tree: XmlElement): Finding[] => {
561
587
  const findings: Finding[] = [];
562
588
  const slideWhere = `slide ${context.number}`;
563
589
  if (tree.attrs["show"] === "0") {
564
- findings.push(warning("hidden-slide", slideWhere, "the slide is hidden, so the slide show skips it; delete it if it is not meant to be delivered"));
590
+ findings.push(
591
+ warning("hidden-slide", slideWhere, "the slide is hidden, so the slide show skips it; delete it if it is not meant to be delivered"),
592
+ );
565
593
  }
566
594
  const referenced = new Set<string>();
567
595
  const background = childAt(tree, "p:cSld", "p:bg");
@@ -588,7 +616,11 @@ export const checkPptx = (bytes: Uint8Array): CheckReport => {
588
616
  pkg = openPackage(bytes);
589
617
  } catch (cause) {
590
618
  if (cause instanceof NotAPackage) {
591
- return { format: "pptx", findings: [error("unreadable", "", `${cause.message}: this is not a PowerPoint file (or it is password-protected)`)], notes: [] };
619
+ return {
620
+ format: "pptx",
621
+ findings: [error("unreadable", "", `${cause.message}: this is not a PowerPoint file (or it is password-protected)`)],
622
+ notes: [],
623
+ };
592
624
  }
593
625
  throw cause;
594
626
  }
@@ -617,7 +649,13 @@ export const checkPptx = (bytes: Uint8Array): CheckReport => {
617
649
  const number = index + 1;
618
650
  const relationship = rels.get(slideId.attrs["r:id"] ?? "");
619
651
  if (relationship === undefined || relationship.external || !pkg.has(relationship.target)) {
620
- findings.push(error("missing-part", `slide ${number}`, "the slide list names a slide that is not in the file; PowerPoint will offer to repair the deck"));
652
+ findings.push(
653
+ error(
654
+ "missing-part",
655
+ `slide ${number}`,
656
+ "the slide list names a slide that is not in the file; PowerPoint will offer to repair the deck",
657
+ ),
658
+ );
621
659
  return;
622
660
  }
623
661
  const tree = pkg.xml(relationship.target);
@@ -638,4 +676,3 @@ export const checkPptx = (bytes: Uint8Array): CheckReport => {
638
676
  findings.push(...stretchFindings(pkg, pictures));
639
677
  return { format: "pptx", extent: plural(slideIds.length, "slide"), findings, notes };
640
678
  };
641
-
@@ -23,7 +23,8 @@ const INLINE = /<((?:\w+:)?)is\b/;
23
23
  const attributeIn = (attributes: string, name: string): string | undefined => new RegExp(`(?:^|\\s)${name}="([^"]*)"`).exec(attributes)?.[1];
24
24
 
25
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}`);
26
+ const cellRef = (sheet: string, ref: string): string =>
27
+ /^[A-Za-z_][\w.]*$/.test(sheet) ? `${sheet}!${ref}` : `'${sheet.replaceAll("'", "''")}'!${ref}`;
27
28
 
28
29
  interface SheetScan {
29
30
  readonly errors: Finding[];
@@ -71,9 +72,14 @@ const scanSheet = (sheet: string, xml: string): SheetScan => {
71
72
  };
72
73
 
73
74
  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 shown = cells
76
+ .slice(0, 5)
77
+ .map((cell) => cell.slice(cell.lastIndexOf("!") + 1))
78
+ .join(", ");
75
79
  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";
80
+ const onOpen = recalculates
81
+ ? "the workbook asks to be recalculated on open, so spreadsheet applications fill them in"
82
+ : "Excel, LibreOffice and Google Sheets compute them on open";
77
83
  return warning(
78
84
  "formula-uncached",
79
85
  sheet,
@@ -86,7 +92,11 @@ const openWorkbook = (bytes: Uint8Array): OoxmlPackage | CheckReport => {
86
92
  return openPackage(bytes);
87
93
  } catch (cause) {
88
94
  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: [] };
95
+ return {
96
+ format: "xlsx",
97
+ findings: [error("unreadable", "", `${cause.message}: this is not an Excel file (or it is password-protected)`)],
98
+ notes: [],
99
+ };
90
100
  }
91
101
  throw cause;
92
102
  }
@@ -96,7 +106,9 @@ const namesFindings = (workbook: XmlElement | undefined): Finding[] => {
96
106
  const names = workbook === undefined ? undefined : childNamed(workbook, "x:definedNames");
97
107
  return (names === undefined ? [] : childrenNamed(names, "x:definedName")).flatMap((name) => {
98
108
  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`)] : [];
109
+ return target.includes("#REF!")
110
+ ? [error("broken-name", "", `the defined name "${name.attrs["name"] ?? "?"}" refers to ${excerpt(target)}, a deleted range`)]
111
+ : [];
100
112
  });
101
113
  };
102
114
 
@@ -121,7 +133,9 @@ const sheetsFindings = (pkg: OoxmlPackage, main: string, sheets: readonly XmlEle
121
133
  const name = sheet.attrs["name"] ?? "?";
122
134
  const relationship = rels.get(sheet.attrs["r:id"] ?? "");
123
135
  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"));
136
+ findings.push(
137
+ error("missing-part", name, "the sheet list names a sheet that is not in the file; Excel will offer to repair the workbook"),
138
+ );
125
139
  continue;
126
140
  }
127
141
  // A chart sheet has no cells, and a sheet over the size cap is named in the notes.
@@ -1,11 +1,24 @@
1
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";
2
+ import {
3
+ deckBytes,
4
+ field,
5
+ inlinePicture,
6
+ para,
7
+ pdfBytes,
8
+ pdfPagesBytes,
9
+ pictureFrame,
10
+ pngOfSize,
11
+ tableFrame,
12
+ textBox,
13
+ wordBytes,
14
+ workbookBytes,
15
+ } from "../../testing.js";
16
+ import { checkDocx } from "./check-docx.js";
4
17
  import { leftoverText, type CheckReport } from "./finding.js";
5
- import { checkPdf } from "./pdf.js";
6
- import { checkPptx } from "./pptx.js";
18
+ import { checkPdf } from "./check-pdf.js";
19
+ import { checkPptx } from "./check-pptx.js";
7
20
  import { estimateText, wrappedLines, type Paragraph } from "./text-fit.js";
8
- import { checkXlsx } from "./xlsx.js";
21
+ import { checkXlsx } from "./check-xlsx.js";
9
22
  import { parseXml } from "./xml-tree.js";
10
23
 
11
24
  // Every checker against a document built with one known defect, and against a clean one, which must come back with
@@ -52,7 +65,9 @@ describe("pptx", () => {
52
65
  test("a drawing wholly off the slide is an error, one bleeding past an edge a warning with how far", () => {
53
66
  const report = checkPptx(
54
67
  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"])] }],
68
+ slides: [
69
+ { drawings: [textBox("Gone", { x: 14, y: 1, w: 2, h: 1 }, ["off"]), textBox("Bleed", { x: 12, y: 1, w: 3, h: 1 }, ["partly"])] },
70
+ ],
56
71
  }),
57
72
  );
58
73
  expect(found(report)).toEqual([
@@ -100,7 +115,10 @@ describe("pptx", () => {
100
115
  deckBytes({
101
116
  slides: [
102
117
  {
103
- drawings: [pictureFrame("Gone", { x: 1, y: 1, w: 4, h: 3 }, "rId2"), pictureFrame("Undefined", { x: 6, y: 1, w: 4, h: 3 }, "rId9")],
118
+ drawings: [
119
+ pictureFrame("Gone", { x: 1, y: 1, w: 4, h: 3 }, "rId2"),
120
+ pictureFrame("Undefined", { x: 6, y: 1, w: 4, h: 3 }, "rId9"),
121
+ ],
104
122
  rels: { rId2: { type: "image", target: "../media/missing.png" } },
105
123
  },
106
124
  ],
@@ -116,7 +134,12 @@ describe("pptx", () => {
116
134
  test("an image linked from outside the file is a warning naming where it points", () => {
117
135
  const report = checkPptx(
118
136
  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 } } }],
137
+ slides: [
138
+ {
139
+ drawings: [pictureFrame("Logo", { x: 1, y: 1, w: 4, h: 3 }, "rId2")],
140
+ rels: { rId2: { type: "image", target: "file:///C:/Users/me/logo.png", external: true } },
141
+ },
142
+ ],
120
143
  }),
121
144
  );
122
145
  expect(found(report)).toEqual([["warning", "linked-image", 'slide 1 · "Logo"']]);
@@ -128,7 +151,10 @@ describe("pptx", () => {
128
151
  deckBytes({
129
152
  slides: [
130
153
  {
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 })],
154
+ drawings: [
155
+ pictureFrame("Wide", { x: 1, y: 1, w: 4, h: 1 }, "rId2"),
156
+ pictureFrame("Cropped", { x: 6, y: 1, w: 4, h: 3 }, "rId2", { l: 60_000, r: 40_000 }),
157
+ ],
132
158
  rels: { rId2: IMAGE },
133
159
  },
134
160
  ],
@@ -147,7 +173,9 @@ describe("pptx", () => {
147
173
  test("a picture cropped to its box's proportions is not stretched", () => {
148
174
  const report = checkPptx(
149
175
  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 } }],
176
+ slides: [
177
+ { drawings: [pictureFrame("Banner", { x: 1, y: 1, w: 4, h: 1.5 }, "rId2", { t: 25_000, b: 25_000 })], rels: { rId2: IMAGE } },
178
+ ],
151
179
  media: { "image1.png": pngOfSize(400, 300) },
152
180
  }),
153
181
  );
@@ -155,7 +183,8 @@ describe("pptx", () => {
155
183
  });
156
184
 
157
185
  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.";
186
+ const long =
187
+ "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
188
  const report = checkPptx(
160
189
  deckBytes({
161
190
  slides: [
@@ -177,7 +206,10 @@ describe("pptx", () => {
177
206
  {
178
207
  drawings: [
179
208
  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" }),
209
+ textBox("Grows", { x: 1, y: 3, w: 3, h: 0.5 }, ["A sentence long enough to need three or four lines here."], {
210
+ size: 18,
211
+ autofit: "grow",
212
+ }),
181
213
  ],
182
214
  },
183
215
  ],
@@ -187,9 +219,14 @@ describe("pptx", () => {
187
219
  });
188
220
 
189
221
  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");
222
+ const long =
223
+ "A paragraph much too long for its box, which PowerPoint only shrinks once somebody edits the text and LibreOffice shrinks at once.";
224
+ const report = checkPptx(
225
+ deckBytes({ slides: [{ drawings: [textBox("Body", { x: 1, y: 1, w: 3, h: 0.6 }, [long], { size: 24, autofit: "shrink" })] }] }),
226
+ );
227
+ expect(messageOf(report, "text-overflow")).toContain(
228
+ "shrink-on-overflow is on, so LibreOffice shrinks it but PowerPoint shows it overflowing until the text is edited",
229
+ );
193
230
  });
194
231
 
195
232
  test("a table whose rows run past the bottom of the slide is a warning", () => {
@@ -233,7 +270,14 @@ describe("docx", () => {
233
270
  test("a well-made document has nothing to report", () => {
234
271
  const report = checkDocx(
235
272
  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")],
273
+ body: [
274
+ para("Report", "berschrift1"),
275
+ para("The body of the report."),
276
+ '<w:p><w:bookmarkStart w:id="0" w:name="results"/><w:bookmarkEnd w:id="0"/></w:p>',
277
+ '<w:p><w:hyperlink w:anchor="results"><w:r><w:t>see results</w:t></w:r></w:hyperlink></w:p>',
278
+ inlinePicture("rId5", 6, 3),
279
+ field("PAGE", "1"),
280
+ ],
237
281
  rels: { rId5: DOC_IMAGE },
238
282
  media: { "image1.png": pngOfSize(600, 300) },
239
283
  }),
@@ -243,13 +287,21 @@ describe("docx", () => {
243
287
  });
244
288
 
245
289
  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>'] }));
290
+ const report = checkDocx(
291
+ wordBytes({
292
+ 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>'],
293
+ }),
294
+ );
247
295
  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');
296
+ expect(messageOf(report, "broken-link")).toBe(
297
+ 'the link "see section" points to bookmark "nowhere", which is not in the document, so clicking it goes nowhere',
298
+ );
249
299
  });
250
300
 
251
301
  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.")] }));
302
+ const report = checkDocx(
303
+ wordBytes({ body: [field("PAGEREF _Ref999 \\h", "3"), field("REF _Ref123 \\h", "Error! Reference source not found.")] }),
304
+ );
253
305
  expect(found(report)).toEqual([
254
306
  ["error", "broken-reference", 'paragraph 1 · "3"'],
255
307
  ["error", "field-error", 'paragraph 2 · "Error! Reference source not found."'],
@@ -281,7 +333,9 @@ describe("docx", () => {
281
333
  ["error", "missing-image", "paragraph 2"],
282
334
  ["warning", "wide-image", "paragraph 1"],
283
335
  ]);
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");
336
+ expect(messageOf(report, "wide-image")).toBe(
337
+ "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",
338
+ );
285
339
  });
286
340
 
287
341
  test("what a draft leaves behind: tracked changes, comments, a control showing its prompt, filler text", () => {
@@ -289,7 +343,7 @@ describe("docx", () => {
289
343
  wordBytes({
290
344
  body: [
291
345
  '<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>',
346
+ "<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
347
  para("Lorem ipsum dolor sit amet."),
294
348
  ],
295
349
  comments: ["check this figure"],
@@ -316,7 +370,9 @@ const cell = (ref: string, inner: string, type?: string): string => `<c r="${ref
316
370
 
317
371
  describe("xlsx", () => {
318
372
  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>` }]));
373
+ const report = checkXlsx(
374
+ workbookBytes([{ name: "Sheet1", rows: `<row r="1">${cell("A1", "<v>1</v>")}${cell("B1", "<f>A1*2</f><v>2</v>")}</row>` }]),
375
+ );
320
376
  expect(report.extent).toBe("1 sheet");
321
377
  expect(report.findings).toEqual([]);
322
378
  });
@@ -336,7 +392,9 @@ describe("xlsx", () => {
336
392
  const rows = `<row r="1">${cell("A1", "<v>1</v>")}${cell("B1", "<f>A1+1</f>")}${cell("C1", "<f>A1+2</f>")}</row>`;
337
393
  const plain = checkXlsx(workbookBytes([{ name: "Data", rows }]));
338
394
  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");
395
+ expect(messageOf(plain, "formula-uncached")).toContain(
396
+ "2 formulas saved with no value (B1, C1): Excel, LibreOffice and Google Sheets compute them on open",
397
+ );
340
398
  const flagged = checkXlsx(workbookBytes([{ name: "Data", rows }], { calcPr: '<calcPr fullCalcOnLoad="1"/>' }));
341
399
  expect(messageOf(flagged, "formula-uncached")).toContain("the workbook asks to be recalculated on open");
342
400
  });
@@ -365,12 +423,19 @@ describe("pdf", () => {
365
423
  });
366
424
 
367
425
  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" }]));
426
+ const report = await checkPdf(
427
+ pdfPagesBytes([{ content: "BT /F1 12 Tf 72 720 Td (Cover) Tj ET" }, { content: "" }, { content: "0 0 1 rg 10 10 100 100 re f" }]),
428
+ );
369
429
  expect(found(report)).toEqual([["error", "blank-page", "page 2"]]);
370
430
  });
371
431
 
372
432
  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" }]));
433
+ const report = await checkPdf(
434
+ pdfPagesBytes([
435
+ { content: "BT /F1 12 Tf 72 720 Td (Hello) Tj ET", font: "Calibri" },
436
+ { content: "BT /F1 12 Tf 72 720 Td (Again) Tj ET", font: "Calibri" },
437
+ ]),
438
+ );
374
439
  expect(found(report)).toEqual([["warning", "font-not-embedded", "page 1, 2"]]);
375
440
  expect(messageOf(report, "font-not-embedded")).toContain("text is set in Calibri, which the file does not carry");
376
441
  });
@@ -384,7 +449,12 @@ describe("pdf", () => {
384
449
 
385
450
  test("a link to a named destination the file does not define is an error", async () => {
386
451
  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) >>]" }]),
452
+ pdfPagesBytes([
453
+ {
454
+ content: "BT /F1 12 Tf 72 720 Td (See results) Tj ET",
455
+ extra: "/Annots [<< /Type /Annot /Subtype /Link /Rect [72 700 200 730] /Dest (results) >>]",
456
+ },
457
+ ]),
388
458
  );
389
459
  expect(found(report)).toEqual([["error", "broken-link", "page 1"]]);
390
460
  });
@@ -422,7 +492,9 @@ describe("xml tree", () => {
422
492
  });
423
493
 
424
494
  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>');
495
+ const tree = parseXml(
496
+ '<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>',
497
+ );
426
498
  expect(tree.children).toEqual([
427
499
  { name: "w:t", attrs: {}, children: [" A & B "] },
428
500
  { name: "w:t", attrs: {}, children: ["<raw>"] },
@@ -1,11 +1,11 @@
1
1
  import { readFile, stat } from "node:fs/promises";
2
2
  import { errorMessage, isMissing } from "@intentic/base/errors";
3
3
  import { claimedFormat, type Format } from "../formats.js";
4
- import { checkDocx } from "./docx.js";
4
+ import { checkDocx } from "./check-docx.js";
5
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";
6
+ import { checkPdf } from "./check-pdf.js";
7
+ import { checkPptx } from "./check-pptx.js";
8
+ import { checkXlsx } from "./check-xlsx.js";
9
9
 
10
10
  // `fileq check`'s routing: which formats have a checker, and the one read of the file every checker works from.
11
11
 
@@ -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
+ };