@intentic/fileq 1.319.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.
- package/NOTICE +4 -4
- package/dist/lib/check/{docx.d.ts → check-docx.d.ts} +1 -1
- package/dist/lib/check/check-docx.d.ts.map +1 -0
- package/dist/lib/check/{docx.js → check-docx.js} +46 -14
- package/dist/lib/check/check-docx.js.map +1 -0
- package/dist/lib/check/{pdf.d.ts → check-pdf.d.ts} +1 -1
- package/dist/lib/check/check-pdf.d.ts.map +1 -0
- package/dist/lib/check/{pdf.js → check-pdf.js} +4 -2
- package/dist/lib/check/check-pdf.js.map +1 -0
- package/dist/lib/check/{pptx.d.ts → check-pptx.d.ts} +1 -1
- package/dist/lib/check/check-pptx.d.ts.map +1 -0
- package/dist/lib/check/{pptx.js → check-pptx.js} +18 -5
- package/dist/lib/check/check-pptx.js.map +1 -0
- package/dist/lib/check/{xlsx.d.ts → check-xlsx.d.ts} +1 -1
- package/dist/lib/check/check-xlsx.d.ts.map +1 -0
- package/dist/lib/check/{xlsx.js → check-xlsx.js} +17 -6
- package/dist/lib/check/check-xlsx.js.map +1 -0
- package/dist/lib/check/check.js +4 -4
- package/dist/lib/check/check.js.map +1 -1
- package/dist/lib/derive.d.ts.map +1 -1
- package/dist/lib/derive.js +4 -1
- package/dist/lib/derive.js.map +1 -1
- package/package.json +6 -6
- package/src/lib/check/{docx.ts → check-docx.ts} +112 -27
- package/src/lib/check/{pdf.ts → check-pdf.ts} +23 -4
- package/src/lib/check/{pptx.ts → check-pptx.ts} +48 -11
- package/src/lib/check/{xlsx.ts → check-xlsx.ts} +20 -6
- package/src/lib/check/check.test.ts +99 -27
- package/src/lib/check/check.ts +4 -4
- package/src/lib/derive.ts +4 -1
- package/src/render.integration.test.ts +80 -56
- package/dist/lib/check/docx.d.ts.map +0 -1
- package/dist/lib/check/docx.js.map +0 -1
- package/dist/lib/check/pdf.d.ts.map +0 -1
- package/dist/lib/check/pdf.js.map +0 -1
- package/dist/lib/check/pptx.d.ts.map +0 -1
- package/dist/lib/check/pptx.js.map +0 -1
- package/dist/lib/check/xlsx.d.ts.map +0 -1
- package/dist/lib/check/xlsx.js.map +0 -1
|
@@ -73,10 +73,12 @@ const headingLevels = (styles: XmlElement | undefined): ReadonlyMap<string, numb
|
|
|
73
73
|
const basedOn = childNamed(style, "w:basedOn")?.attrs["w:val"];
|
|
74
74
|
return basedOn === undefined ? undefined : levelOf(basedOn, depth + 1);
|
|
75
75
|
};
|
|
76
|
-
return new Map(
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
76
|
+
return new Map(
|
|
77
|
+
[...byId.keys()].flatMap((id) => {
|
|
78
|
+
const level = levelOf(id, 0);
|
|
79
|
+
return level === undefined ? [] : [[id, level] as const];
|
|
80
|
+
}),
|
|
81
|
+
);
|
|
80
82
|
};
|
|
81
83
|
|
|
82
84
|
const headingOf = (paragraph: XmlElement, levels: ReadonlyMap<string, number>): number | undefined => {
|
|
@@ -106,7 +108,12 @@ const locate = (paragraphs: readonly XmlElement[], levels: ReadonlyMap<string, n
|
|
|
106
108
|
const text = paragraphText(paragraph);
|
|
107
109
|
const heading = headingOf(paragraph, levels);
|
|
108
110
|
const number = index + 1;
|
|
109
|
-
const label =
|
|
111
|
+
const label =
|
|
112
|
+
text.trim() !== ""
|
|
113
|
+
? `paragraph ${number} · ${excerpt(text, 40)}`
|
|
114
|
+
: lastHeading === undefined
|
|
115
|
+
? `paragraph ${number}`
|
|
116
|
+
: `paragraph ${number} · under ${lastHeading}`;
|
|
110
117
|
located.push({ paragraph, number, text, heading });
|
|
111
118
|
whereOf.set(paragraph, label);
|
|
112
119
|
if (heading !== undefined && text.trim() !== "") {
|
|
@@ -151,7 +158,12 @@ const stepField = (node: XmlElement, open: OpenField[], fields: Field[], where:
|
|
|
151
158
|
current.separated = true;
|
|
152
159
|
} else if (type === "end") {
|
|
153
160
|
open.pop();
|
|
154
|
-
fields.push({
|
|
161
|
+
fields.push({
|
|
162
|
+
instruction: current.instruction.trim(),
|
|
163
|
+
result: current.result,
|
|
164
|
+
hasResult: current.separated && current.result.trim() !== "",
|
|
165
|
+
where: current.where,
|
|
166
|
+
});
|
|
155
167
|
}
|
|
156
168
|
};
|
|
157
169
|
|
|
@@ -195,19 +207,33 @@ const bookmarkArgument = (instruction: string): string | undefined => {
|
|
|
195
207
|
const fieldFindings = (fields: readonly Field[], bookmarks: ReadonlySet<string>, updatesOnOpen: boolean): Finding[] =>
|
|
196
208
|
fields.flatMap((field): Finding[] => {
|
|
197
209
|
const keyword = (field.instruction.split(/\s+/)[0] ?? "").toUpperCase();
|
|
198
|
-
if (field.result.trim().startsWith(
|
|
199
|
-
return [
|
|
210
|
+
if (field.result.trim().startsWith("Error!")) {
|
|
211
|
+
return [
|
|
212
|
+
error("field-error", field.where, `the ${keyword} field shows ${excerpt(field.result)}: fix what it points at, then update fields`),
|
|
213
|
+
];
|
|
200
214
|
}
|
|
201
215
|
const bookmark = bookmarkArgument(field.instruction);
|
|
202
216
|
if (bookmark !== undefined && !bookmarks.has(bookmark) && !BUILT_IN_ANCHORS.has(bookmark)) {
|
|
203
|
-
return [
|
|
217
|
+
return [
|
|
218
|
+
error(
|
|
219
|
+
"broken-reference",
|
|
220
|
+
field.where,
|
|
221
|
+
`the ${keyword} field points to bookmark "${bookmark}", which is not in the document: Word shows "Error! Reference source not found."`,
|
|
222
|
+
),
|
|
223
|
+
];
|
|
204
224
|
}
|
|
205
225
|
if (keyword === "MERGEFIELD") {
|
|
206
|
-
return [
|
|
226
|
+
return [
|
|
227
|
+
warning("merge-field", field.where, `a mail-merge field is left in (${field.instruction}): it prints its placeholder, not a value`),
|
|
228
|
+
];
|
|
207
229
|
}
|
|
208
230
|
if (keyword === "TOC" && !field.hasResult) {
|
|
209
|
-
const when = updatesOnOpen
|
|
210
|
-
|
|
231
|
+
const when = updatesOnOpen
|
|
232
|
+
? "Word offers to update fields when the file opens"
|
|
233
|
+
: "nothing fills it until someone updates fields (F9 in Word)";
|
|
234
|
+
return [
|
|
235
|
+
warning("empty-toc", field.where, `the table of contents has no entries saved: ${when}, and previews and LibreOffice show it empty`),
|
|
236
|
+
];
|
|
211
237
|
}
|
|
212
238
|
if (!field.hasResult && keyword !== "" && !COMPUTED_FIELDS.has(keyword) && keyword !== "TOC") {
|
|
213
239
|
return [warning("empty-field", field.where, `the ${keyword} field has no saved result, so it shows blank until fields are updated`)];
|
|
@@ -217,7 +243,13 @@ const fieldFindings = (fields: readonly Field[], bookmarks: ReadonlySet<string>,
|
|
|
217
243
|
|
|
218
244
|
// --- references and images -------------------------------------------------------------------------------------
|
|
219
245
|
|
|
220
|
-
const referenceFindings = (
|
|
246
|
+
const referenceFindings = (
|
|
247
|
+
context: DocContext,
|
|
248
|
+
part: string,
|
|
249
|
+
tree: XmlElement,
|
|
250
|
+
whereOfNode: (node: XmlElement) => string,
|
|
251
|
+
referenced: Set<string>,
|
|
252
|
+
): Finding[] => {
|
|
221
253
|
const rels = context.pkg.rels(part);
|
|
222
254
|
const findings: Finding[] = [];
|
|
223
255
|
for (const node of everyElement(tree)) {
|
|
@@ -244,10 +276,20 @@ const referenceFinding = (
|
|
|
244
276
|
): Finding | undefined => {
|
|
245
277
|
const isImage = node.name === "a:blip" || node.name === "v:imagedata";
|
|
246
278
|
if (relationship === undefined) {
|
|
247
|
-
return error(
|
|
279
|
+
return error(
|
|
280
|
+
isImage ? "missing-image" : "missing-part",
|
|
281
|
+
where,
|
|
282
|
+
`${isImage ? "an image" : node.name} refers to ${id}, which the document does not define: it shows as missing`,
|
|
283
|
+
);
|
|
248
284
|
}
|
|
249
285
|
if (relationship.external) {
|
|
250
|
-
return isImage
|
|
286
|
+
return isImage
|
|
287
|
+
? warning(
|
|
288
|
+
"linked-image",
|
|
289
|
+
where,
|
|
290
|
+
`an image is linked from outside the file (${relationship.target}), so it will not show on anyone else's machine; embed it`,
|
|
291
|
+
)
|
|
292
|
+
: undefined;
|
|
251
293
|
}
|
|
252
294
|
if (pkg.has(relationship.target)) {
|
|
253
295
|
return undefined;
|
|
@@ -290,7 +332,13 @@ const wideImageFindings = (context: DocContext, body: XmlElement, owner: Readonl
|
|
|
290
332
|
return [];
|
|
291
333
|
}
|
|
292
334
|
const where = paragraph === undefined ? "" : (context.whereOf.get(paragraph) ?? "");
|
|
293
|
-
return [
|
|
335
|
+
return [
|
|
336
|
+
warning(
|
|
337
|
+
"wide-image",
|
|
338
|
+
where,
|
|
339
|
+
`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`,
|
|
340
|
+
),
|
|
341
|
+
];
|
|
294
342
|
});
|
|
295
343
|
};
|
|
296
344
|
|
|
@@ -298,11 +346,17 @@ const wideImageFindings = (context: DocContext, body: XmlElement, owner: Readonl
|
|
|
298
346
|
|
|
299
347
|
const draftFindings = (context: DocContext, body: XmlElement): Finding[] => {
|
|
300
348
|
const findings: Finding[] = [];
|
|
301
|
-
const changes = everyElement(body).filter(
|
|
349
|
+
const changes = everyElement(body).filter(
|
|
350
|
+
(node) => ["w:ins", "w:del", "w:moveFrom", "w:moveTo"].includes(node.name) && node.children.some(isElement),
|
|
351
|
+
).length;
|
|
302
352
|
if (changes > 0) {
|
|
303
|
-
findings.push(
|
|
353
|
+
findings.push(
|
|
354
|
+
warning("tracked-changes", "", `${plural(changes, "tracked change")} still in the document: accept or reject them before it goes out`),
|
|
355
|
+
);
|
|
304
356
|
}
|
|
305
|
-
const commentsPart = [...context.pkg.rels(context.main).values()].find(
|
|
357
|
+
const commentsPart = [...context.pkg.rels(context.main).values()].find(
|
|
358
|
+
(relationship) => relationship.kind === "comments" && !relationship.external,
|
|
359
|
+
);
|
|
306
360
|
const comments = commentsPart === undefined ? undefined : context.pkg.xml(commentsPart.target);
|
|
307
361
|
const count = comments === undefined ? 0 : childrenNamed(comments, "w:comment").length;
|
|
308
362
|
if (count > 0) {
|
|
@@ -321,7 +375,13 @@ const paragraphFindings = (context: DocContext): Finding[] => {
|
|
|
321
375
|
const hasPicture = descendantsNamed(located.paragraph, "w:drawing").length > 0 || descendantsNamed(located.paragraph, "w:pict").length > 0;
|
|
322
376
|
if (located.heading !== undefined && located.text.trim() === "" && !hasPicture) {
|
|
323
377
|
const level = located.heading === 0 ? "title" : `heading ${located.heading}`;
|
|
324
|
-
findings.push(
|
|
378
|
+
findings.push(
|
|
379
|
+
warning(
|
|
380
|
+
"empty-heading",
|
|
381
|
+
where,
|
|
382
|
+
`empty ${level}: a blank line in the text and an empty entry in the navigation pane and any table of contents; delete it`,
|
|
383
|
+
),
|
|
384
|
+
);
|
|
325
385
|
}
|
|
326
386
|
if (TYPED_BULLET.test(located.text) && childAt(located.paragraph, "w:pPr", "w:numPr") === undefined) {
|
|
327
387
|
typedBullets += 1;
|
|
@@ -330,7 +390,11 @@ const paragraphFindings = (context: DocContext): Finding[] => {
|
|
|
330
390
|
}
|
|
331
391
|
if (firstBullet !== undefined) {
|
|
332
392
|
findings.push(
|
|
333
|
-
warning(
|
|
393
|
+
warning(
|
|
394
|
+
"typed-bullets",
|
|
395
|
+
firstBullet,
|
|
396
|
+
`${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`,
|
|
397
|
+
),
|
|
334
398
|
);
|
|
335
399
|
}
|
|
336
400
|
return findings;
|
|
@@ -343,7 +407,11 @@ const controlFindings = (body: XmlElement, whereOfNode: (node: XmlElement) => st
|
|
|
343
407
|
const prompt = textIn(childNamed(control, "w:sdtContent") ?? control, "w:t");
|
|
344
408
|
// A block-level control holds whole paragraphs; it is found by the first of them.
|
|
345
409
|
const where = whereOfNode(descendantsNamed(control, "w:p")[0] ?? control);
|
|
346
|
-
return error(
|
|
410
|
+
return error(
|
|
411
|
+
"placeholder-control",
|
|
412
|
+
where,
|
|
413
|
+
`a content control still shows its prompt ${excerpt(prompt)}: enter the value or remove the control`,
|
|
414
|
+
);
|
|
347
415
|
});
|
|
348
416
|
|
|
349
417
|
const linkFindings = (body: XmlElement, bookmarks: ReadonlySet<string>, whereOfNode: (node: XmlElement) => string): Finding[] =>
|
|
@@ -352,7 +420,13 @@ const linkFindings = (body: XmlElement, bookmarks: ReadonlySet<string>, whereOfN
|
|
|
352
420
|
if (anchor === undefined || bookmarks.has(anchor) || BUILT_IN_ANCHORS.has(anchor)) {
|
|
353
421
|
return [];
|
|
354
422
|
}
|
|
355
|
-
return [
|
|
423
|
+
return [
|
|
424
|
+
error(
|
|
425
|
+
"broken-link",
|
|
426
|
+
whereOfNode(link),
|
|
427
|
+
`the link ${excerpt(textIn(link, "w:t"))} points to bookmark "${anchor}", which is not in the document, so clicking it goes nowhere`,
|
|
428
|
+
),
|
|
429
|
+
];
|
|
356
430
|
});
|
|
357
431
|
|
|
358
432
|
// Headers, footers and notes: each part's own images and relationships, located by the part's name.
|
|
@@ -360,7 +434,10 @@ const storyFindings = (context: DocContext, stories: readonly { part: string; tr
|
|
|
360
434
|
stories.flatMap((story) => {
|
|
361
435
|
const label = story.part.replace(/^word\//, "").replace(/\.xml$/, "");
|
|
362
436
|
const referenced = new Set<string>();
|
|
363
|
-
return [
|
|
437
|
+
return [
|
|
438
|
+
...referenceFindings(context, story.part, story.tree, () => label, referenced),
|
|
439
|
+
...missingTargetFindings(context.pkg, story.part, label, referenced),
|
|
440
|
+
];
|
|
364
441
|
});
|
|
365
442
|
|
|
366
443
|
const openDocx = (bytes: Uint8Array): OoxmlPackage | CheckReport => {
|
|
@@ -368,7 +445,11 @@ const openDocx = (bytes: Uint8Array): OoxmlPackage | CheckReport => {
|
|
|
368
445
|
return openPackage(bytes);
|
|
369
446
|
} catch (cause) {
|
|
370
447
|
if (cause instanceof NotAPackage) {
|
|
371
|
-
return {
|
|
448
|
+
return {
|
|
449
|
+
format: "docx",
|
|
450
|
+
findings: [error("unreadable", "", `${cause.message}: this is not a Word file (or it is password-protected)`)],
|
|
451
|
+
notes: [],
|
|
452
|
+
};
|
|
372
453
|
}
|
|
373
454
|
throw cause;
|
|
374
455
|
}
|
|
@@ -403,7 +484,9 @@ export const checkDocx = (bytes: Uint8Array): CheckReport => {
|
|
|
403
484
|
return tree === undefined ? [] : [{ part: relationship.target, tree }];
|
|
404
485
|
});
|
|
405
486
|
const bookmarks = new Set(
|
|
406
|
-
[body, ...stories.map((story) => story.tree)].flatMap((tree) =>
|
|
487
|
+
[body, ...stories.map((story) => story.tree)].flatMap((tree) =>
|
|
488
|
+
descendantsNamed(tree, "w:bookmarkStart").flatMap((mark) => mark.attrs["w:name"] ?? []),
|
|
489
|
+
),
|
|
407
490
|
);
|
|
408
491
|
const context: DocContext = { pkg, main, paragraphs: located, whereOf, bookmarks };
|
|
409
492
|
if (located.every((item) => item.text.trim() === "") && descendantsNamed(body, "w:drawing").length === 0) {
|
|
@@ -424,7 +507,9 @@ export const checkDocx = (bytes: Uint8Array): CheckReport => {
|
|
|
424
507
|
);
|
|
425
508
|
// A field showing "Error!" or a control showing its prompt is already named for what it is; the same words read as
|
|
426
509
|
// leftover text would say it twice.
|
|
427
|
-
const explained = new Set(
|
|
510
|
+
const explained = new Set(
|
|
511
|
+
findings.filter((finding) => finding.rule === "field-error" || finding.rule === "placeholder-control").map((finding) => finding.where),
|
|
512
|
+
);
|
|
428
513
|
return {
|
|
429
514
|
format: "docx",
|
|
430
515
|
extent: plural(located.length, "paragraph"),
|
|
@@ -157,7 +157,13 @@ const linkFindings = async (pdf: PdfDocument, page: PdfPage, number: number): Pr
|
|
|
157
157
|
// allow(silent-catch): pdf.js throws where the file's destination tree is damaged, and then the link goes nowhere too.
|
|
158
158
|
const target = await pdf.getDestination(named).catch(() => null);
|
|
159
159
|
if (target === null) {
|
|
160
|
-
findings.push(
|
|
160
|
+
findings.push(
|
|
161
|
+
error(
|
|
162
|
+
"broken-link",
|
|
163
|
+
`page ${number}`,
|
|
164
|
+
`a link goes to "${named}", a destination the file does not define, so clicking it does nothing`,
|
|
165
|
+
),
|
|
166
|
+
);
|
|
161
167
|
}
|
|
162
168
|
}
|
|
163
169
|
return findings;
|
|
@@ -173,7 +179,10 @@ const openPdf = async (bytes: Uint8Array): Promise<PdfDocument | CheckReport> =>
|
|
|
173
179
|
// Errors only: pdf.js otherwise narrates its recovery of a damaged file on the console, into the report.
|
|
174
180
|
return await getDocumentProxy(bytes, { verbosity: 0 });
|
|
175
181
|
} catch (cause) {
|
|
176
|
-
const reason =
|
|
182
|
+
const reason =
|
|
183
|
+
cause instanceof Error && cause.name === "PasswordException"
|
|
184
|
+
? "it is password-protected, so nothing past the password can be checked"
|
|
185
|
+
: `it is not a readable PDF (${cause instanceof Error ? cause.message : String(cause)})`;
|
|
177
186
|
return { format: "pdf", findings: [error("unreadable", "", reason)], notes: [] };
|
|
178
187
|
}
|
|
179
188
|
};
|
|
@@ -211,14 +220,24 @@ export const checkPdf = async (bytes: Uint8Array): Promise<CheckReport> => {
|
|
|
211
220
|
}
|
|
212
221
|
for (const [font, pages] of unembedded) {
|
|
213
222
|
findings.push(
|
|
214
|
-
warning(
|
|
223
|
+
warning(
|
|
224
|
+
"font-not-embedded",
|
|
225
|
+
`page ${pageList(pages)}`,
|
|
226
|
+
`text is set in ${font}, which the file does not carry: a reader without that font substitutes another, and lines can break differently; embed it`,
|
|
227
|
+
),
|
|
215
228
|
);
|
|
216
229
|
}
|
|
217
230
|
if (sizes.size > 1) {
|
|
218
231
|
const [common] = [...sizes.entries()].toSorted((a, b) => b[1].length - a[1].length);
|
|
219
232
|
const odd = [...sizes.entries()].filter(([label]) => label !== common?.[0]);
|
|
220
233
|
for (const [label, pages] of odd) {
|
|
221
|
-
findings.push(
|
|
234
|
+
findings.push(
|
|
235
|
+
warning(
|
|
236
|
+
"page-size",
|
|
237
|
+
`page ${pageList(pages)}`,
|
|
238
|
+
`${plural(pages.length, "page is", "pages are")} ${label}, the rest ${common?.[0] ?? "another size"}`,
|
|
239
|
+
),
|
|
240
|
+
);
|
|
222
241
|
}
|
|
223
242
|
}
|
|
224
243
|
if (characters === 0 && total > 0) {
|
|
@@ -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 =>
|
|
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 =
|
|
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
|
-
? [
|
|
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(
|
|
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(
|
|
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 (
|
|
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 =
|
|
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(
|
|
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 {
|
|
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(
|
|
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 =>
|
|
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
|
|
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
|
|
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 {
|
|
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!")
|
|
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(
|
|
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.
|