@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.
- package/NOTICE +208 -0
- package/README.md +24 -3
- package/dist/app.d.ts.map +1 -1
- package/dist/app.js +15 -1
- package/dist/app.js.map +1 -1
- package/dist/commands/check.command.d.ts +3 -0
- package/dist/commands/check.command.d.ts.map +1 -0
- package/dist/commands/check.command.js +80 -0
- package/dist/commands/check.command.js.map +1 -0
- package/dist/commands/render.command.d.ts +3 -0
- package/dist/commands/render.command.d.ts.map +1 -0
- package/dist/commands/render.command.js +67 -0
- package/dist/commands/render.command.js.map +1 -0
- package/dist/lib/check/check.d.ts +15 -0
- package/dist/lib/check/check.d.ts.map +1 -0
- package/dist/lib/check/check.js +35 -0
- package/dist/lib/check/check.js.map +1 -0
- package/dist/lib/check/docx.d.ts +3 -0
- package/dist/lib/check/docx.d.ts.map +1 -0
- package/dist/lib/check/docx.js +329 -0
- package/dist/lib/check/docx.js.map +1 -0
- package/dist/lib/check/finding.d.ts +20 -0
- package/dist/lib/check/finding.d.ts.map +1 -0
- package/dist/lib/check/finding.js +27 -0
- package/dist/lib/check/finding.js.map +1 -0
- package/dist/lib/check/package.d.ts +25 -0
- package/dist/lib/check/package.d.ts.map +1 -0
- package/dist/lib/check/package.js +139 -0
- package/dist/lib/check/package.js.map +1 -0
- package/dist/lib/check/pdf.d.ts +3 -0
- package/dist/lib/check/pdf.d.ts.map +1 -0
- package/dist/lib/check/pdf.js +160 -0
- package/dist/lib/check/pdf.js.map +1 -0
- package/dist/lib/check/pptx.d.ts +3 -0
- package/dist/lib/check/pptx.d.ts.map +1 -0
- package/dist/lib/check/pptx.js +497 -0
- package/dist/lib/check/pptx.js.map +1 -0
- package/dist/lib/check/text-fit.d.ts +18 -0
- package/dist/lib/check/text-fit.d.ts.map +1 -0
- package/dist/lib/check/text-fit.js +69 -0
- package/dist/lib/check/text-fit.js.map +1 -0
- package/dist/lib/check/xlsx.d.ts +3 -0
- package/dist/lib/check/xlsx.d.ts.map +1 -0
- package/dist/lib/check/xlsx.js +131 -0
- package/dist/lib/check/xlsx.js.map +1 -0
- package/dist/lib/check/xml-tree.d.ts +15 -0
- package/dist/lib/check/xml-tree.d.ts.map +1 -0
- package/dist/lib/check/xml-tree.js +131 -0
- package/dist/lib/check/xml-tree.js.map +1 -0
- package/dist/lib/derivers/image.d.ts.map +1 -1
- package/dist/lib/derivers/image.js.map +1 -1
- package/dist/lib/formats.d.ts +1 -0
- package/dist/lib/formats.d.ts.map +1 -1
- package/dist/lib/formats.js +1 -0
- package/dist/lib/formats.js.map +1 -1
- package/dist/lib/render/convert.d.ts +11 -0
- package/dist/lib/render/convert.d.ts.map +1 -0
- package/dist/lib/render/convert.js +107 -0
- package/dist/lib/render/convert.js.map +1 -0
- package/dist/lib/render/pages.d.ts +11 -0
- package/dist/lib/render/pages.d.ts.map +1 -0
- package/dist/lib/render/pages.js +43 -0
- package/dist/lib/render/pages.js.map +1 -0
- package/dist/lib/render/render.d.ts +29 -0
- package/dist/lib/render/render.d.ts.map +1 -0
- package/dist/lib/render/render.js +142 -0
- package/dist/lib/render/render.js.map +1 -0
- package/dist/skill-authoring.d.ts +2 -0
- package/dist/skill-authoring.d.ts.map +1 -0
- package/dist/skill-authoring.js +20 -0
- package/dist/skill-authoring.js.map +1 -0
- package/dist/skill.d.ts.map +1 -1
- package/dist/skill.js +24 -1
- package/dist/skill.js.map +1 -1
- package/dist/testing.d.ts +67 -0
- package/dist/testing.d.ts.map +1 -1
- package/dist/testing.js +151 -0
- package/dist/testing.js.map +1 -1
- package/package.json +20 -7
- package/src/app.ts +15 -1
- package/src/cli.integration.test.ts +63 -1
- package/src/commands/check.command.ts +97 -0
- package/src/commands/render.command.ts +80 -0
- package/src/lib/check/check.test.ts +446 -0
- package/src/lib/check/check.ts +49 -0
- package/src/lib/check/docx.ts +434 -0
- package/src/lib/check/finding.ts +63 -0
- package/src/lib/check/package.ts +199 -0
- package/src/lib/check/pdf.ts +228 -0
- package/src/lib/check/pptx.ts +641 -0
- package/src/lib/check/text-fit.ts +105 -0
- package/src/lib/check/xlsx.ts +163 -0
- package/src/lib/check/xml-tree.ts +186 -0
- package/src/lib/derivers/image.ts +2 -1
- package/src/lib/formats.ts +6 -0
- package/src/lib/render/convert.ts +133 -0
- package/src/lib/render/pages.test.ts +38 -0
- package/src/lib/render/pages.ts +58 -0
- package/src/lib/render/render.ts +198 -0
- package/src/render.integration.test.ts +217 -0
- package/src/skill-authoring.ts +27 -0
- package/src/skill.ts +28 -1
- package/src/testing.ts +287 -0
- package/dist/lib/flags.d.ts +0 -2
- package/dist/lib/flags.d.ts.map +0 -1
- package/dist/lib/flags.js +0 -8
- package/dist/lib/flags.js.map +0 -1
- package/dist/version.d.ts +0 -2
- package/dist/version.d.ts.map +0 -1
- package/dist/version.js +0 -3
- package/dist/version.js.map +0 -1
|
@@ -0,0 +1,641 @@
|
|
|
1
|
+
// Structural checks over a PowerPoint deck: what a person opening it would meet, read off the package without
|
|
2
|
+
// rendering it. Slide numbers are the deck's own order (presentation.xml's slide list), never the part file names,
|
|
3
|
+
// which a reordered deck no longer matches.
|
|
4
|
+
//
|
|
5
|
+
// The geometry, crop and missing-media rules are adapted from SurfSense's PPTX artifact verification
|
|
6
|
+
// (surfsense_backend/app/artifacts/verification/formats/pptx.py, Copyright (c) SurfSense, Apache-2.0,
|
|
7
|
+
// https://github.com/MODSetter/SurfSense; see this package's NOTICE). Changed here: a shape partly off the slide is a
|
|
8
|
+
// warning rather than ignored, a hidden or empty slide is a warning rather than a blocker, and placeholder geometry is
|
|
9
|
+
// inherited from the layout and master before it is judged. The text-fit, placeholder, stretch and leftover-text checks
|
|
10
|
+
// are this package's own.
|
|
11
|
+
import { imageSize } from "image-size";
|
|
12
|
+
import { error, inches, leftoverText, plural, warning, type CheckReport, type Finding } from "./finding.js";
|
|
13
|
+
import { missingTargetFindings, NotAPackage, openPackage, packageFindings, type OoxmlPackage } from "./package.js";
|
|
14
|
+
import { estimateText, type Paragraph } from "./text-fit.js";
|
|
15
|
+
import { childAt, childNamed, childrenNamed, everyElement, intAttr, isElement, textIn, type XmlElement } from "./xml-tree.js";
|
|
16
|
+
|
|
17
|
+
const PRESENTATION = "ppt/presentation.xml";
|
|
18
|
+
const EMU_PER_PT = 12_700;
|
|
19
|
+
const DEFAULT_SIZE_HUNDREDTHS = 1800;
|
|
20
|
+
// bodyPr's default insets: 0.1 in left and right, 0.05 in top and bottom.
|
|
21
|
+
const DEFAULT_INSET_X = 91_440;
|
|
22
|
+
const DEFAULT_INSET_Y = 45_720;
|
|
23
|
+
// An estimate has to miss by this much before it is worth a warning; the glyph widths are averages.
|
|
24
|
+
const OVERFLOW_MARGIN = 1.15;
|
|
25
|
+
const CROP_WHOLE = 100_000;
|
|
26
|
+
|
|
27
|
+
const DRAWABLE = new Set(["p:sp", "p:pic", "p:graphicFrame", "p:cxnSp", "p:grpSp", "p:contentPart"]);
|
|
28
|
+
|
|
29
|
+
// Placeholders that sit empty on purpose or are filled by the application (date, footer, slide number).
|
|
30
|
+
const SELF_FILLING = new Set(["dt", "ftr", "sldNum", "hdr", "sldImg"]);
|
|
31
|
+
|
|
32
|
+
const PROMPTS = new Map<string, readonly [string, string]>([
|
|
33
|
+
["title", ["title", "Click to add title"]],
|
|
34
|
+
["ctrTitle", ["title", "Click to add title"]],
|
|
35
|
+
["subTitle", ["subtitle", "Click to add subtitle"]],
|
|
36
|
+
["pic", ["picture", "Click icon to add picture"]],
|
|
37
|
+
["chart", ["chart", "Click icon to add chart"]],
|
|
38
|
+
["tbl", ["table", "Click icon to add table"]],
|
|
39
|
+
["media", ["media", "Click icon to add media"]],
|
|
40
|
+
]);
|
|
41
|
+
const promptOf = (type: string): readonly [string, string] => PROMPTS.get(type) ?? ["text", "Click to add text"];
|
|
42
|
+
|
|
43
|
+
interface Box {
|
|
44
|
+
readonly x: number;
|
|
45
|
+
readonly y: number;
|
|
46
|
+
readonly cx: number;
|
|
47
|
+
readonly cy: number;
|
|
48
|
+
readonly rot: number;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
interface Placeholder {
|
|
52
|
+
readonly type: string;
|
|
53
|
+
readonly idx: string | undefined;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Everything a slide's shapes inherit from: the layout and master placeholders, the master's text styles, and the
|
|
57
|
+
// deck's default text style for shapes that are not placeholders.
|
|
58
|
+
interface Inheritance {
|
|
59
|
+
readonly layout: XmlElement | undefined;
|
|
60
|
+
readonly master: XmlElement | undefined;
|
|
61
|
+
readonly defaultTextStyle: XmlElement | undefined;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
interface SlideContext {
|
|
65
|
+
readonly pkg: OoxmlPackage;
|
|
66
|
+
readonly number: number;
|
|
67
|
+
readonly part: string;
|
|
68
|
+
readonly width: number;
|
|
69
|
+
readonly height: number;
|
|
70
|
+
readonly inheritance: Inheritance;
|
|
71
|
+
readonly pictures: PictureUse[];
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
interface PictureUse {
|
|
75
|
+
readonly where: string;
|
|
76
|
+
readonly media: string;
|
|
77
|
+
readonly cx: number;
|
|
78
|
+
readonly cy: number;
|
|
79
|
+
readonly crop: { readonly l: number; readonly t: number; readonly r: number; readonly b: number };
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const KINDS = new Map([
|
|
83
|
+
["p:sp", "shape"],
|
|
84
|
+
["p:pic", "picture"],
|
|
85
|
+
["p:graphicFrame", "frame"],
|
|
86
|
+
["p:grpSp", "group"],
|
|
87
|
+
["p:cxnSp", "connector"],
|
|
88
|
+
]);
|
|
89
|
+
|
|
90
|
+
// What PowerPoint's selection pane calls it: its name, or for a nameless one its kind and id.
|
|
91
|
+
const drawingName = (drawing: XmlElement): string => {
|
|
92
|
+
const nv = drawing.children.find((child): child is XmlElement => isElement(child) && child.name.startsWith("p:nv"));
|
|
93
|
+
const properties = childAt(nv, "p:cNvPr");
|
|
94
|
+
const name = properties?.attrs["name"] ?? "";
|
|
95
|
+
return name.trim() === "" ? `unnamed ${KINDS.get(drawing.name) ?? "drawing"} #${properties?.attrs["id"] ?? "?"}` : `"${name}"`;
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
// A shape wrapped for newer features sits in mc:AlternateContent; its first choice is the shape PowerPoint shows.
|
|
99
|
+
const drawableChildren = (tree: XmlElement): XmlElement[] =>
|
|
100
|
+
tree.children.flatMap((child): XmlElement[] => {
|
|
101
|
+
if (!isElement(child)) {
|
|
102
|
+
return [];
|
|
103
|
+
}
|
|
104
|
+
if (child.name === "mc:AlternateContent") {
|
|
105
|
+
const choice = childNamed(child, "mc:Choice") ?? childNamed(child, "mc:Fallback");
|
|
106
|
+
return choice === undefined ? [] : drawableChildren(choice);
|
|
107
|
+
}
|
|
108
|
+
return DRAWABLE.has(child.name) ? [child] : [];
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
const placeholderOf = (drawing: XmlElement): Placeholder | undefined => {
|
|
112
|
+
const nv = drawing.children.find((child): child is XmlElement => isElement(child) && child.name.startsWith("p:nv"));
|
|
113
|
+
const ph = childAt(nv, "p:nvPr", "p:ph");
|
|
114
|
+
return ph === undefined ? undefined : { type: ph.attrs["type"] ?? "obj", idx: ph.attrs["idx"] };
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
// The master names only the broad kinds; a layout's centred title or subtitle inherits from the master's title or body.
|
|
118
|
+
const masterType = (type: string): string => {
|
|
119
|
+
if (type === "ctrTitle") {
|
|
120
|
+
return "title";
|
|
121
|
+
}
|
|
122
|
+
return type === "subTitle" || type === "obj" ? "body" : type;
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
const findPlaceholder = (tree: XmlElement | undefined, wanted: Placeholder, onMaster: boolean): XmlElement | undefined => {
|
|
126
|
+
const spTree = childAt(tree, "p:cSld", "p:spTree");
|
|
127
|
+
if (spTree === undefined) {
|
|
128
|
+
return undefined;
|
|
129
|
+
}
|
|
130
|
+
const candidates = drawableChildren(spTree).flatMap((drawing) => {
|
|
131
|
+
const ph = placeholderOf(drawing);
|
|
132
|
+
return ph === undefined ? [] : [{ drawing, ph }];
|
|
133
|
+
});
|
|
134
|
+
if (!onMaster && wanted.idx !== undefined) {
|
|
135
|
+
const byIdx = candidates.find(({ ph }) => ph.idx === wanted.idx);
|
|
136
|
+
if (byIdx !== undefined) {
|
|
137
|
+
return byIdx.drawing;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
const type = onMaster ? masterType(wanted.type) : wanted.type;
|
|
141
|
+
return candidates.find(({ ph }) => (onMaster ? masterType(ph.type) : ph.type) === type)?.drawing;
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
// The shape itself, then the layout placeholder it fills, then the master's: the order PowerPoint inherits in.
|
|
145
|
+
const inheritanceChain = (drawing: XmlElement, inheritance: Inheritance): XmlElement[] => {
|
|
146
|
+
const ph = placeholderOf(drawing);
|
|
147
|
+
if (ph === undefined) {
|
|
148
|
+
return [drawing];
|
|
149
|
+
}
|
|
150
|
+
const layoutDrawing = findPlaceholder(inheritance.layout, ph, false);
|
|
151
|
+
const layoutPh = layoutDrawing === undefined ? undefined : placeholderOf(layoutDrawing);
|
|
152
|
+
const masterDrawing = findPlaceholder(inheritance.master, layoutPh ?? ph, true);
|
|
153
|
+
return [drawing, layoutDrawing, masterDrawing].filter((item): item is XmlElement => item !== undefined);
|
|
154
|
+
};
|
|
155
|
+
|
|
156
|
+
const ownTransform = (drawing: XmlElement): XmlElement | undefined => {
|
|
157
|
+
switch (drawing.name) {
|
|
158
|
+
case "p:graphicFrame":
|
|
159
|
+
return childNamed(drawing, "p:xfrm");
|
|
160
|
+
case "p:grpSp":
|
|
161
|
+
return childAt(drawing, "p:grpSpPr", "a:xfrm");
|
|
162
|
+
default:
|
|
163
|
+
return childAt(drawing, "p:spPr", "a:xfrm");
|
|
164
|
+
}
|
|
165
|
+
};
|
|
166
|
+
|
|
167
|
+
const boxOf = (chain: readonly XmlElement[]): Box | undefined => {
|
|
168
|
+
for (const drawing of chain) {
|
|
169
|
+
const xfrm = ownTransform(drawing);
|
|
170
|
+
const off = childAt(xfrm, "a:off");
|
|
171
|
+
const ext = childAt(xfrm, "a:ext");
|
|
172
|
+
if (off === undefined || ext === undefined) {
|
|
173
|
+
continue;
|
|
174
|
+
}
|
|
175
|
+
const [x, y, cx, cy] = [intAttr(off, "x"), intAttr(off, "y"), intAttr(ext, "cx"), intAttr(ext, "cy")];
|
|
176
|
+
if (x === undefined || y === undefined || cx === undefined || cy === undefined) {
|
|
177
|
+
return undefined;
|
|
178
|
+
}
|
|
179
|
+
return { x, y, cx, cy, rot: intAttr(xfrm, "rot") ?? 0 };
|
|
180
|
+
}
|
|
181
|
+
return undefined;
|
|
182
|
+
};
|
|
183
|
+
|
|
184
|
+
// The axis-aligned box a rotated shape covers, about its centre.
|
|
185
|
+
interface Covered {
|
|
186
|
+
readonly left: number;
|
|
187
|
+
readonly top: number;
|
|
188
|
+
readonly right: number;
|
|
189
|
+
readonly bottom: number;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
const coveredBox = (box: Box): Covered => {
|
|
193
|
+
const angle = ((box.rot / 60_000) * Math.PI) / 180;
|
|
194
|
+
const width = Math.abs(box.cx * Math.cos(angle)) + Math.abs(box.cy * Math.sin(angle));
|
|
195
|
+
const height = Math.abs(box.cx * Math.sin(angle)) + Math.abs(box.cy * Math.cos(angle));
|
|
196
|
+
const centreX = box.x + box.cx / 2;
|
|
197
|
+
const centreY = box.y + box.cy / 2;
|
|
198
|
+
return { left: centreX - width / 2, top: centreY - height / 2, right: centreX + width / 2, bottom: centreY + height / 2 };
|
|
199
|
+
};
|
|
200
|
+
|
|
201
|
+
const geometryFindings = (drawing: XmlElement, box: Box, where: string, width: number, height: number): Finding[] => {
|
|
202
|
+
if (box.cx < 0 || box.cy < 0) {
|
|
203
|
+
return [error("bad-geometry", where, "has a negative width or height, which PowerPoint cannot draw")];
|
|
204
|
+
}
|
|
205
|
+
const connector = drawing.name === "p:cxnSp";
|
|
206
|
+
if ((box.cx === 0 && box.cy === 0) || (!connector && (box.cx === 0 || box.cy === 0))) {
|
|
207
|
+
return [warning("bad-geometry", where, "has no area, so it cannot be seen; size it or delete it")];
|
|
208
|
+
}
|
|
209
|
+
const covered = coveredBox(box);
|
|
210
|
+
if (covered.right <= 0 || covered.bottom <= 0 || covered.left >= width || covered.top >= height) {
|
|
211
|
+
return [error("off-slide", where, "sits entirely off the slide, so no one will see it; move it onto the slide or delete it")];
|
|
212
|
+
}
|
|
213
|
+
// Bleeding past an edge is sometimes the design (a full-bleed photo), so it is reported, not failed; a sliver
|
|
214
|
+
// under 1% of the slide is rounding.
|
|
215
|
+
const edges = [
|
|
216
|
+
["left", -covered.left, width],
|
|
217
|
+
["top", -covered.top, height],
|
|
218
|
+
["right", covered.right - width, width],
|
|
219
|
+
["bottom", covered.bottom - height, height],
|
|
220
|
+
] as const;
|
|
221
|
+
return edges
|
|
222
|
+
.filter(([, past, span]) => past > span * 0.01)
|
|
223
|
+
.map(([edge, past]) => warning("off-slide", where, `runs ${inches(past)} past the ${edge} edge of the slide, where it is cut off`));
|
|
224
|
+
};
|
|
225
|
+
|
|
226
|
+
// --- text ------------------------------------------------------------------------------------------------------
|
|
227
|
+
|
|
228
|
+
// The first value a list of candidates yields, in inheritance order.
|
|
229
|
+
const firstOf = <T>(candidates: readonly (() => T | undefined)[]): T | undefined => {
|
|
230
|
+
for (const candidate of candidates) {
|
|
231
|
+
const value = candidate();
|
|
232
|
+
if (value !== undefined) {
|
|
233
|
+
return value;
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
return undefined;
|
|
237
|
+
};
|
|
238
|
+
|
|
239
|
+
const masterStyleFor = (master: XmlElement | undefined, placeholder: Placeholder): XmlElement | undefined =>
|
|
240
|
+
childAt(master, "p:txStyles", masterType(placeholder.type) === "title" ? "p:titleStyle" : "p:bodyStyle");
|
|
241
|
+
|
|
242
|
+
interface TextStyle {
|
|
243
|
+
readonly listStyles: readonly (XmlElement | undefined)[];
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
const textStyleOf = (drawing: XmlElement, chain: readonly XmlElement[], inheritance: Inheritance): TextStyle => {
|
|
247
|
+
const placeholder = placeholderOf(drawing);
|
|
248
|
+
const own = chain.map((link) => childAt(link, "p:txBody", "a:lstStyle"));
|
|
249
|
+
const tail = placeholder === undefined ? [inheritance.defaultTextStyle] : [masterStyleFor(inheritance.master, placeholder)];
|
|
250
|
+
return { listStyles: [...own, ...tail] };
|
|
251
|
+
};
|
|
252
|
+
|
|
253
|
+
const hundredthsAt = (element: XmlElement | undefined, path: readonly string[], attribute: string): number | undefined => intAttr(childAt(element, ...path), attribute);
|
|
254
|
+
|
|
255
|
+
const paragraphsOf = (txBody: XmlElement, style: TextStyle, fontScale: number, spacingReduction: number): Paragraph[] =>
|
|
256
|
+
childrenNamed(txBody, "a:p").map((p) => {
|
|
257
|
+
const pPr = childNamed(p, "a:pPr");
|
|
258
|
+
const level = (intAttr(pPr, "lvl") ?? 0) + 1;
|
|
259
|
+
const runs = p.children.filter((child): child is XmlElement => isElement(child) && (child.name === "a:r" || child.name === "a:fld"));
|
|
260
|
+
const text = p.children
|
|
261
|
+
.map((child) => {
|
|
262
|
+
if (!isElement(child)) {
|
|
263
|
+
return "";
|
|
264
|
+
}
|
|
265
|
+
if (child.name === "a:br") {
|
|
266
|
+
return "\n";
|
|
267
|
+
}
|
|
268
|
+
return child.name === "a:r" || child.name === "a:fld" ? textIn(child, "a:t") : "";
|
|
269
|
+
})
|
|
270
|
+
.join("");
|
|
271
|
+
const inherited = (attribute: string): number | undefined =>
|
|
272
|
+
firstOf([
|
|
273
|
+
() => intAttr(childAt(pPr, "a:defRPr"), attribute),
|
|
274
|
+
...style.listStyles.map((listStyle) => () => intAttr(childAt(listStyle, `a:lvl${level}pPr`, "a:defRPr"), attribute)),
|
|
275
|
+
]);
|
|
276
|
+
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 bold = runs.some((run) => childNamed(run, "a:rPr")?.attrs["b"] === "1") || (runs.length > 0 && inherited("b") === 1);
|
|
279
|
+
const spacing = (name: string): number | undefined =>
|
|
280
|
+
firstOf([
|
|
281
|
+
() => hundredthsAt(pPr, [name, "a:spcPts"], "val"),
|
|
282
|
+
...style.listStyles.map((listStyle) => () => hundredthsAt(listStyle, [`a:lvl${level}pPr`, name, "a:spcPts"], "val")),
|
|
283
|
+
]);
|
|
284
|
+
const lineSpacingPct = firstOf([
|
|
285
|
+
() => hundredthsAt(pPr, ["a:lnSpc", "a:spcPct"], "val"),
|
|
286
|
+
...style.listStyles.map((listStyle) => () => hundredthsAt(listStyle, [`a:lvl${level}pPr`, "a:lnSpc", "a:spcPct"], "val")),
|
|
287
|
+
]);
|
|
288
|
+
const lineHeightPts = hundredthsAt(pPr, ["a:lnSpc", "a:spcPts"], "val");
|
|
289
|
+
const sizePt = (size / 100) * fontScale;
|
|
290
|
+
return {
|
|
291
|
+
text,
|
|
292
|
+
sizePt,
|
|
293
|
+
bold,
|
|
294
|
+
lineSpacing: ((lineSpacingPct ?? 100_000) / 100_000) * (1 - spacingReduction),
|
|
295
|
+
lineHeightPt: lineHeightPts === undefined ? undefined : lineHeightPts / 100,
|
|
296
|
+
spaceBeforePt: (spacing("a:spcBef") ?? 0) / 100,
|
|
297
|
+
spaceAfterPt: (spacing("a:spcAft") ?? 0) / 100,
|
|
298
|
+
};
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
type Autofit = "none" | "shrink" | "grow";
|
|
302
|
+
|
|
303
|
+
const bodyPropsOf = (chain: readonly XmlElement[]) => {
|
|
304
|
+
const bodies = chain.map((link) => childAt(link, "p:txBody", "a:bodyPr")).filter((body): body is XmlElement => body !== undefined);
|
|
305
|
+
const attribute = (name: string): string | undefined => bodies.map((body) => body.attrs[name]).find((value) => value !== undefined);
|
|
306
|
+
const inset = (name: string, fallback: number): number => {
|
|
307
|
+
const raw = attribute(name);
|
|
308
|
+
return raw !== undefined && /^\d+$/.test(raw) ? Number(raw) : fallback;
|
|
309
|
+
};
|
|
310
|
+
const fitElement = bodies
|
|
311
|
+
.map((body) => childNamed(body, "a:normAutofit") ?? childNamed(body, "a:spAutoFit") ?? childNamed(body, "a:noAutofit"))
|
|
312
|
+
.find((fit) => fit !== undefined);
|
|
313
|
+
const autofit: Autofit = fitElement?.name === "a:normAutofit" ? "shrink" : fitElement?.name === "a:spAutoFit" ? "grow" : "none";
|
|
314
|
+
return {
|
|
315
|
+
left: inset("lIns", DEFAULT_INSET_X),
|
|
316
|
+
right: inset("rIns", DEFAULT_INSET_X),
|
|
317
|
+
top: inset("tIns", DEFAULT_INSET_Y),
|
|
318
|
+
bottom: inset("bIns", DEFAULT_INSET_Y),
|
|
319
|
+
wrap: attribute("wrap") !== "none",
|
|
320
|
+
vertical: (attribute("vert") ?? "horz") !== "horz",
|
|
321
|
+
autofit,
|
|
322
|
+
fontScale: (intAttr(fitElement, "fontScale") ?? 100_000) / 100_000,
|
|
323
|
+
spacingReduction: (intAttr(fitElement, "lnSpcReduction") ?? 0) / 100_000,
|
|
324
|
+
};
|
|
325
|
+
};
|
|
326
|
+
|
|
327
|
+
const overflowFindings = (drawing: XmlElement, chain: readonly XmlElement[], box: Box, where: string, context: SlideContext): Finding[] => {
|
|
328
|
+
const txBody = childNamed(drawing, "p:txBody");
|
|
329
|
+
if (txBody === undefined || textIn(txBody, "a:t").trim() === "") {
|
|
330
|
+
return [];
|
|
331
|
+
}
|
|
332
|
+
const body = bodyPropsOf(chain);
|
|
333
|
+
if (body.vertical || box.rot !== 0) {
|
|
334
|
+
return [];
|
|
335
|
+
}
|
|
336
|
+
const paragraphs = paragraphsOf(txBody, textStyleOf(drawing, chain, context.inheritance), body.fontScale, body.spacingReduction);
|
|
337
|
+
const innerWidth = (box.cx - body.left - body.right) / EMU_PER_PT;
|
|
338
|
+
const innerHeight = (box.cy - body.top - body.bottom) / EMU_PER_PT;
|
|
339
|
+
if (innerWidth <= 0) {
|
|
340
|
+
return [];
|
|
341
|
+
}
|
|
342
|
+
const estimate = estimateText(paragraphs, innerWidth, body.wrap);
|
|
343
|
+
const look = `(an estimate: look with \`fileq render --pages ${context.number}\`)`;
|
|
344
|
+
if (!body.wrap && estimate.widestPt > innerWidth * OVERFLOW_MARGIN) {
|
|
345
|
+
return [warning("text-overflow", where, `text is set not to wrap and probably runs past the box's right edge ${look}`)];
|
|
346
|
+
}
|
|
347
|
+
const needed = estimate.heightPt * EMU_PER_PT + body.top + body.bottom;
|
|
348
|
+
if (body.autofit === "grow") {
|
|
349
|
+
// The box grows to fit its text, so the text cannot overflow the box, only the slide.
|
|
350
|
+
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}`)]
|
|
352
|
+
: [];
|
|
353
|
+
}
|
|
354
|
+
if (estimate.heightPt <= innerHeight * OVERFLOW_MARGIN) {
|
|
355
|
+
return [];
|
|
356
|
+
}
|
|
357
|
+
const shrink =
|
|
358
|
+
body.autofit === "shrink"
|
|
359
|
+
? "; shrink-on-overflow is on, so LibreOffice shrinks it but PowerPoint shows it overflowing until the text is edited: shorten it or enlarge the box"
|
|
360
|
+
: "; shorten it, enlarge the box or split the slide";
|
|
361
|
+
const offSlide = box.y + needed > context.height * 1.02 ? ", past the bottom of the slide" : "";
|
|
362
|
+
return [
|
|
363
|
+
warning(
|
|
364
|
+
"text-overflow",
|
|
365
|
+
where,
|
|
366
|
+
`text probably overflows its box${offSlide}: about ${plural(estimate.lines, "line")} of ${estimate.sizePt} pt need ${inches(needed)}, the box is ${inches(box.cy)} tall${shrink} ${look}`,
|
|
367
|
+
),
|
|
368
|
+
];
|
|
369
|
+
};
|
|
370
|
+
|
|
371
|
+
// A table's rows grow to fit their text in PowerPoint, so a long table runs off the slide rather than overflowing a cell.
|
|
372
|
+
const tableFindings = (frame: XmlElement, box: Box, where: string, context: SlideContext): Finding[] => {
|
|
373
|
+
const table = childAt(frame, "a:graphic", "a:graphicData", "a:tbl");
|
|
374
|
+
if (table === undefined) {
|
|
375
|
+
return [];
|
|
376
|
+
}
|
|
377
|
+
const grid = childAt(table, "a:tblGrid");
|
|
378
|
+
const columns = (grid === undefined ? [] : childrenNamed(grid, "a:gridCol")).map((column) => intAttr(column, "w") ?? 0);
|
|
379
|
+
let total = 0;
|
|
380
|
+
for (const row of childrenNamed(table, "a:tr")) {
|
|
381
|
+
let rowHeight = intAttr(row, "h") ?? 0;
|
|
382
|
+
let column = 0;
|
|
383
|
+
for (const cell of childrenNamed(row, "a:tc")) {
|
|
384
|
+
const span = intAttr(cell, "gridSpan") ?? 1;
|
|
385
|
+
const cellWidth = columns.slice(column, column + span).reduce((sum, width) => sum + width, 0);
|
|
386
|
+
column += span;
|
|
387
|
+
const txBody = childNamed(cell, "a:txBody");
|
|
388
|
+
if (txBody === undefined || cell.attrs["hMerge"] === "1" || cell.attrs["vMerge"] === "1") {
|
|
389
|
+
continue;
|
|
390
|
+
}
|
|
391
|
+
const tcPr = childNamed(cell, "a:tcPr");
|
|
392
|
+
const marginX = (intAttr(tcPr, "marL") ?? DEFAULT_INSET_X) + (intAttr(tcPr, "marR") ?? DEFAULT_INSET_X);
|
|
393
|
+
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);
|
|
395
|
+
rowHeight = Math.max(rowHeight, estimate.heightPt * EMU_PER_PT + marginY);
|
|
396
|
+
}
|
|
397
|
+
total += rowHeight;
|
|
398
|
+
}
|
|
399
|
+
if (box.y + total <= context.height * 1.05) {
|
|
400
|
+
return [];
|
|
401
|
+
}
|
|
402
|
+
return [
|
|
403
|
+
warning(
|
|
404
|
+
"table-overflow",
|
|
405
|
+
where,
|
|
406
|
+
`the table's rows probably need about ${inches(total)}, and it starts ${inches(box.y)} down a ${inches(context.height)} slide, so its last rows run off the bottom; split it across slides or cut rows (an estimate: look with \`fileq render --pages ${context.number}\`)`,
|
|
407
|
+
),
|
|
408
|
+
];
|
|
409
|
+
};
|
|
410
|
+
|
|
411
|
+
// --- references ------------------------------------------------------------------------------------------------
|
|
412
|
+
|
|
413
|
+
// One `r:` attribute's relationship, judged: undefined when it resolves to something that is there.
|
|
414
|
+
const referenceFinding = (node: XmlElement, id: string, where: string, context: SlideContext): Finding | undefined => {
|
|
415
|
+
const relationship = context.pkg.rels(context.part).get(id);
|
|
416
|
+
const isImage = node.name === "a:blip";
|
|
417
|
+
const rule = isImage ? "missing-image" : "missing-part";
|
|
418
|
+
if (relationship === undefined) {
|
|
419
|
+
const what = isImage ? "the picture shows as a broken image" : "the reference is broken";
|
|
420
|
+
return error(rule, where, `${isImage ? "image" : node.name} refers to ${id}, which the slide does not define: ${what}`);
|
|
421
|
+
}
|
|
422
|
+
if (relationship.external) {
|
|
423
|
+
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`)
|
|
425
|
+
: undefined;
|
|
426
|
+
}
|
|
427
|
+
if (context.pkg.has(relationship.target)) {
|
|
428
|
+
return undefined;
|
|
429
|
+
}
|
|
430
|
+
return isImage
|
|
431
|
+
? error(rule, where, `image ${relationship.target} is not in the file, so it shows as a broken image`)
|
|
432
|
+
: error(rule, where, `${relationship.kind} ${relationship.target} is not in the file, so it is missing`);
|
|
433
|
+
};
|
|
434
|
+
|
|
435
|
+
const referenceFindings = (element: XmlElement, where: string, context: SlideContext, referenced: Set<string>): Finding[] =>
|
|
436
|
+
everyElement(element).flatMap((node) =>
|
|
437
|
+
Object.entries(node.attrs).flatMap(([attribute, id]) => {
|
|
438
|
+
if (!attribute.startsWith("r:") || id === "") {
|
|
439
|
+
return [];
|
|
440
|
+
}
|
|
441
|
+
referenced.add(id);
|
|
442
|
+
return referenceFinding(node, id, where, context) ?? [];
|
|
443
|
+
}),
|
|
444
|
+
);
|
|
445
|
+
|
|
446
|
+
const pictureFindings = (picture: XmlElement, box: Box | undefined, where: string, context: SlideContext): Finding[] => {
|
|
447
|
+
const blipFill = childNamed(picture, "p:blipFill");
|
|
448
|
+
const crop = childAt(blipFill, "a:srcRect");
|
|
449
|
+
const sides = { l: intAttr(crop, "l") ?? 0, t: intAttr(crop, "t") ?? 0, r: intAttr(crop, "r") ?? 0, b: intAttr(crop, "b") ?? 0 };
|
|
450
|
+
if (sides.l + sides.r >= CROP_WHOLE || sides.t + sides.b >= CROP_WHOLE) {
|
|
451
|
+
return [error("bad-crop", where, "the picture is cropped away entirely, so nothing of it shows; fix the crop")];
|
|
452
|
+
}
|
|
453
|
+
const embed = childAt(blipFill, "a:blip")?.attrs["r:embed"];
|
|
454
|
+
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) {
|
|
456
|
+
context.pictures.push({ where, media: relationship.target, cx: box.cx, cy: box.cy, crop: sides });
|
|
457
|
+
}
|
|
458
|
+
return [];
|
|
459
|
+
};
|
|
460
|
+
|
|
461
|
+
// A picture drawn at a shape other than its own proportions is stretched; the most common way to get there is setting
|
|
462
|
+
// both width and height instead of deriving one from the other.
|
|
463
|
+
const stretchFindings = (pkg: OoxmlPackage, pictures: readonly PictureUse[]): Finding[] => {
|
|
464
|
+
const media = pkg.binaries([...new Set(pictures.map((picture) => picture.media))]);
|
|
465
|
+
return pictures.flatMap((picture) => {
|
|
466
|
+
const bytes = media[picture.media];
|
|
467
|
+
if (bytes === undefined) {
|
|
468
|
+
return [];
|
|
469
|
+
}
|
|
470
|
+
let size: { width?: number; height?: number };
|
|
471
|
+
try {
|
|
472
|
+
size = imageSize(bytes);
|
|
473
|
+
} catch {
|
|
474
|
+
// allow(silent-catch): a format image-size cannot read (EMF, a damaged file) has no proportions to compare.
|
|
475
|
+
return [];
|
|
476
|
+
}
|
|
477
|
+
if (size.width === undefined || size.height === undefined || size.width === 0 || size.height === 0) {
|
|
478
|
+
return [];
|
|
479
|
+
}
|
|
480
|
+
const shownWidth = size.width * (1 - (picture.crop.l + picture.crop.r) / CROP_WHOLE);
|
|
481
|
+
const shownHeight = size.height * (1 - (picture.crop.t + picture.crop.b) / CROP_WHOLE);
|
|
482
|
+
const imageRatio = shownWidth / shownHeight;
|
|
483
|
+
const boxRatio = picture.cx / picture.cy;
|
|
484
|
+
const off = boxRatio / imageRatio;
|
|
485
|
+
if (Math.abs(off - 1) <= 0.05) {
|
|
486
|
+
return [];
|
|
487
|
+
}
|
|
488
|
+
return [
|
|
489
|
+
warning(
|
|
490
|
+
"stretched-image",
|
|
491
|
+
picture.where,
|
|
492
|
+
`the picture is ${off > 1 ? "stretched wide" : "squeezed narrow"}: its box is ${boxRatio.toFixed(2)}:1, the image ${imageRatio.toFixed(2)}:1; set one side from the other, or crop instead`,
|
|
493
|
+
),
|
|
494
|
+
];
|
|
495
|
+
});
|
|
496
|
+
};
|
|
497
|
+
|
|
498
|
+
// --- one slide -------------------------------------------------------------------------------------------------
|
|
499
|
+
|
|
500
|
+
const drawingFindings = (drawing: XmlElement, context: SlideContext, grouped: boolean, referenced: Set<string>): Finding[] => {
|
|
501
|
+
const name = drawingName(drawing);
|
|
502
|
+
const where = `slide ${context.number} · ${name}`;
|
|
503
|
+
const findings: Finding[] = [...referenceFindings(drawing, where, context, referenced)];
|
|
504
|
+
const chain = inheritanceChain(drawing, context.inheritance);
|
|
505
|
+
// A grouped shape's offsets are in its group's own coordinate space; only the group itself is placed on the slide.
|
|
506
|
+
const box = grouped ? undefined : boxOf(chain);
|
|
507
|
+
if (box !== undefined) {
|
|
508
|
+
findings.push(...geometryFindings(drawing, box, where, context.width, context.height));
|
|
509
|
+
}
|
|
510
|
+
switch (drawing.name) {
|
|
511
|
+
case "p:grpSp":
|
|
512
|
+
for (const child of drawableChildren(drawing)) {
|
|
513
|
+
findings.push(...drawingFindings(child, context, true, referenced));
|
|
514
|
+
}
|
|
515
|
+
break;
|
|
516
|
+
case "p:pic":
|
|
517
|
+
findings.push(...pictureFindings(drawing, box, where, context));
|
|
518
|
+
break;
|
|
519
|
+
case "p:graphicFrame":
|
|
520
|
+
findings.push(...leftoverText(textIn(drawing, "a:t"), where));
|
|
521
|
+
if (box !== undefined) {
|
|
522
|
+
findings.push(...tableFindings(drawing, box, where, context));
|
|
523
|
+
}
|
|
524
|
+
break;
|
|
525
|
+
case "p:sp": {
|
|
526
|
+
const text = childrenNamed(childNamed(drawing, "p:txBody") ?? drawing, "a:p")
|
|
527
|
+
.map((p) => textIn(p, "a:t"))
|
|
528
|
+
.join("\n");
|
|
529
|
+
findings.push(...leftoverText(text, where));
|
|
530
|
+
const placeholder = placeholderOf(drawing);
|
|
531
|
+
const filledWithPicture = childAt(drawing, "p:spPr", "a:blipFill") !== undefined;
|
|
532
|
+
if (placeholder !== undefined && !SELF_FILLING.has(placeholder.type) && text.trim() === "" && !filledWithPicture) {
|
|
533
|
+
const [label, prompt] = promptOf(placeholder.type);
|
|
534
|
+
findings.push(
|
|
535
|
+
warning(
|
|
536
|
+
"empty-placeholder",
|
|
537
|
+
where,
|
|
538
|
+
`empty ${label} placeholder: invisible in the slide show, but it reads "${prompt}" to anyone who edits the deck; fill it or delete it`,
|
|
539
|
+
),
|
|
540
|
+
);
|
|
541
|
+
}
|
|
542
|
+
if (box !== undefined) {
|
|
543
|
+
findings.push(...overflowFindings(drawing, chain, box, where, context));
|
|
544
|
+
}
|
|
545
|
+
break;
|
|
546
|
+
}
|
|
547
|
+
default:
|
|
548
|
+
break;
|
|
549
|
+
}
|
|
550
|
+
return findings;
|
|
551
|
+
};
|
|
552
|
+
|
|
553
|
+
const layoutsOf = (pkg: OoxmlPackage, slidePart: string): Pick<Inheritance, "layout" | "master"> => {
|
|
554
|
+
const layoutRel = [...pkg.rels(slidePart).values()].find((relationship) => relationship.kind === "slideLayout" && !relationship.external);
|
|
555
|
+
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");
|
|
557
|
+
return { layout, master: masterRel === undefined ? undefined : pkg.xml(masterRel.target) };
|
|
558
|
+
};
|
|
559
|
+
|
|
560
|
+
const slideFindings = (context: SlideContext, tree: XmlElement): Finding[] => {
|
|
561
|
+
const findings: Finding[] = [];
|
|
562
|
+
const slideWhere = `slide ${context.number}`;
|
|
563
|
+
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"));
|
|
565
|
+
}
|
|
566
|
+
const referenced = new Set<string>();
|
|
567
|
+
const background = childAt(tree, "p:cSld", "p:bg");
|
|
568
|
+
if (background !== undefined) {
|
|
569
|
+
findings.push(...referenceFindings(background, `${slideWhere} · background`, context, referenced));
|
|
570
|
+
}
|
|
571
|
+
const spTree = childAt(tree, "p:cSld", "p:spTree");
|
|
572
|
+
const drawings = spTree === undefined ? [] : drawableChildren(spTree);
|
|
573
|
+
if (drawings.length === 0) {
|
|
574
|
+
findings.push(warning("empty-slide", slideWhere, "the slide has nothing on it of its own, only what its layout draws"));
|
|
575
|
+
}
|
|
576
|
+
for (const drawing of drawings) {
|
|
577
|
+
findings.push(...drawingFindings(drawing, context, false, referenced));
|
|
578
|
+
}
|
|
579
|
+
findings.push(...missingTargetFindings(context.pkg, context.part, slideWhere, referenced));
|
|
580
|
+
return findings;
|
|
581
|
+
};
|
|
582
|
+
|
|
583
|
+
// --- the deck --------------------------------------------------------------------------------------------------
|
|
584
|
+
|
|
585
|
+
export const checkPptx = (bytes: Uint8Array): CheckReport => {
|
|
586
|
+
let pkg: OoxmlPackage;
|
|
587
|
+
try {
|
|
588
|
+
pkg = openPackage(bytes);
|
|
589
|
+
} catch (cause) {
|
|
590
|
+
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: [] };
|
|
592
|
+
}
|
|
593
|
+
throw cause;
|
|
594
|
+
}
|
|
595
|
+
const findings = packageFindings(pkg, PRESENTATION);
|
|
596
|
+
const notes = pkg.skipped.map((part) => `not checked: ${part} is too large to read`);
|
|
597
|
+
const presentation = pkg.xml(PRESENTATION);
|
|
598
|
+
if (presentation === undefined) {
|
|
599
|
+
return { format: "pptx", findings, notes };
|
|
600
|
+
}
|
|
601
|
+
const size = childNamed(presentation, "p:sldSz");
|
|
602
|
+
const width = intAttr(size, "cx") ?? 0;
|
|
603
|
+
const height = intAttr(size, "cy") ?? 0;
|
|
604
|
+
if (width <= 0 || height <= 0) {
|
|
605
|
+
findings.push(error("no-slide-size", "", "the presentation sets no slide size (p:sldSz), so nothing can lay it out"));
|
|
606
|
+
return { format: "pptx", findings, notes };
|
|
607
|
+
}
|
|
608
|
+
const slideList = childNamed(presentation, "p:sldIdLst");
|
|
609
|
+
const slideIds = slideList === undefined ? [] : childrenNamed(slideList, "p:sldId");
|
|
610
|
+
if (slideIds.length === 0) {
|
|
611
|
+
findings.push(error("no-slides", "", "the presentation has no slides"));
|
|
612
|
+
}
|
|
613
|
+
const rels = pkg.rels(PRESENTATION);
|
|
614
|
+
const defaultTextStyle = childNamed(presentation, "p:defaultTextStyle");
|
|
615
|
+
const pictures: PictureUse[] = [];
|
|
616
|
+
slideIds.forEach((slideId, index) => {
|
|
617
|
+
const number = index + 1;
|
|
618
|
+
const relationship = rels.get(slideId.attrs["r:id"] ?? "");
|
|
619
|
+
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"));
|
|
621
|
+
return;
|
|
622
|
+
}
|
|
623
|
+
const tree = pkg.xml(relationship.target);
|
|
624
|
+
if (tree === undefined) {
|
|
625
|
+
return; // too large to read, named in the notes
|
|
626
|
+
}
|
|
627
|
+
const context: SlideContext = {
|
|
628
|
+
pkg,
|
|
629
|
+
number,
|
|
630
|
+
part: relationship.target,
|
|
631
|
+
width,
|
|
632
|
+
height,
|
|
633
|
+
inheritance: { ...layoutsOf(pkg, relationship.target), defaultTextStyle },
|
|
634
|
+
pictures,
|
|
635
|
+
};
|
|
636
|
+
findings.push(...slideFindings(context, tree));
|
|
637
|
+
});
|
|
638
|
+
findings.push(...stretchFindings(pkg, pictures));
|
|
639
|
+
return { format: "pptx", extent: plural(slideIds.length, "slide"), findings, notes };
|
|
640
|
+
};
|
|
641
|
+
|