@pptx-studio/validate 0.1.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/CHANGELOG.md +23 -0
- package/LICENSE +202 -0
- package/NOTICE +43 -0
- package/README.md +131 -0
- package/dist/index.d.ts +707 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +2131 -0
- package/dist/index.js.map +1 -0
- package/package.json +56 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,2131 @@
|
|
|
1
|
+
import { NS, attribute, attributeNamespaceOf, attributeValue, childElements, childRanks, descendantElements, elementKey, isSchemaNamespace, namespaceOf, outOfOrderChildren, parseXml, sourceOf, textContent } from "@pptx-studio/xml";
|
|
2
|
+
import { CONTENT_TYPE, CONTENT_TYPES_PART, FONT_DATA_CONTENT_TYPE, FONT_DATA_EXTENSION, PartStore, REL_TYPE, checkPartNameCollisions, isContentTypesStreamName, isRelationshipPartName, isValidRelationshipId, normalizePartName, partExtension, readZip, resolveRelativeTarget, sourcePartNameForRels, validatePartName } from "@pptx-studio/opc";
|
|
3
|
+
//#region src/errors.ts
|
|
4
|
+
/**
|
|
5
|
+
* The single error type this package throws.
|
|
6
|
+
*
|
|
7
|
+
* Same invariant as `@pptx-studio/opc` and `@pptx-studio/xml`: **every failure
|
|
8
|
+
* path throws a `ValidateError` and nothing else.**
|
|
9
|
+
*
|
|
10
|
+
* There is a second thing worth saying here, because this package is the one
|
|
11
|
+
* where the distinction matters most. A *finding* is not an error. A finding is
|
|
12
|
+
* the normal output of this package - it says a rule was broken, names the part
|
|
13
|
+
* and the element, and is data a caller can render, sort or ignore. An error is
|
|
14
|
+
* thrown only when validation itself could not be carried out, or when a caller
|
|
15
|
+
* asked for bytes and the report says they must not have them.
|
|
16
|
+
*
|
|
17
|
+
* Getting that split wrong is how a validator becomes something people disable.
|
|
18
|
+
* If every broken rule were an exception, the only way to see the second
|
|
19
|
+
* problem would be to fix the first, and a caller with a deck that breaks nine
|
|
20
|
+
* rules would learn about them one export at a time.
|
|
21
|
+
*/
|
|
22
|
+
/**
|
|
23
|
+
* Machine-readable failure reasons.
|
|
24
|
+
*
|
|
25
|
+
* Deliberately few. Almost everything this package has to say is a finding.
|
|
26
|
+
*/
|
|
27
|
+
const VALIDATE_ERROR_CODES = [
|
|
28
|
+
"ERR_VALIDATION_FAILED",
|
|
29
|
+
"ERR_UNVALIDATABLE",
|
|
30
|
+
"ERR_UNKNOWN_RULE"
|
|
31
|
+
];
|
|
32
|
+
var ValidateError = class extends Error {
|
|
33
|
+
code;
|
|
34
|
+
detail;
|
|
35
|
+
constructor(code, message, detail = {}) {
|
|
36
|
+
super(message);
|
|
37
|
+
this.name = "ValidateError";
|
|
38
|
+
this.code = code;
|
|
39
|
+
this.detail = detail;
|
|
40
|
+
}
|
|
41
|
+
};
|
|
42
|
+
function isValidateError(value) {
|
|
43
|
+
return value instanceof ValidateError;
|
|
44
|
+
}
|
|
45
|
+
//#endregion
|
|
46
|
+
//#region src/rules/rules.ts
|
|
47
|
+
const RULES = [
|
|
48
|
+
{
|
|
49
|
+
id: "V001",
|
|
50
|
+
category: "package",
|
|
51
|
+
severity: "fatal",
|
|
52
|
+
evidence: "both",
|
|
53
|
+
title: "every part resolves to a content type",
|
|
54
|
+
why: "OPC has no default type. A part whose extension matches no `Default` and which carries no `Override` has no type at all, and a consumer has no way to know what it is holding. PowerPoint refuses such a package outright."
|
|
55
|
+
},
|
|
56
|
+
{
|
|
57
|
+
id: "V002",
|
|
58
|
+
category: "package",
|
|
59
|
+
severity: "fatal",
|
|
60
|
+
evidence: "both",
|
|
61
|
+
title: "the content-type map is internally consistent",
|
|
62
|
+
why: "Every `Override` names a part that exists; no extension is declared `Default` twice; no part is `Override`-ed twice. And the one Office omission that is famous for it: whenever any `.fntdata` part exists there must be a `<Default Extension=\"fntdata\" ContentType=\"application/x-fontdata\"/>`. Leaving it out is the canonical \"PowerPoint found a problem with content\" bug and it is what sub-phase 8.7 exists to get right."
|
|
63
|
+
},
|
|
64
|
+
{
|
|
65
|
+
id: "V003",
|
|
66
|
+
category: "package",
|
|
67
|
+
severity: "fatal",
|
|
68
|
+
evidence: "schema",
|
|
69
|
+
title: "the archive carries no directory entries and no ZIP64 record",
|
|
70
|
+
why: "A directory entry is not a part - no OPC part name may end in a slash - so writing one puts a thing in the package that nothing can name. ZIP64 is refused for a narrower reason: PowerPoint reads it, but our writer emits ZIP32 only, so a ZIP64 record in something we produced means a length field went somewhere it should not have. Both are about what *we* hand over; a package that arrives with either is reported and not refused."
|
|
71
|
+
},
|
|
72
|
+
{
|
|
73
|
+
id: "V004",
|
|
74
|
+
category: "package",
|
|
75
|
+
severity: "fatal",
|
|
76
|
+
evidence: "both",
|
|
77
|
+
title: "part names are ASCII, well formed, and free of pointless percent-escapes",
|
|
78
|
+
why: "The OPC grammar, plus the one thing it does not say. RFC 3986 §6.2.2.2 normalisation is enforced by PowerPoint rather than merely recommended: a percent-escape of an *unreserved* character is a whole-package refusal with `0x808D1005`. Measured nine for nine on one-name packages - `%2D`, `%41`, `%5F` and `%7E` refused; `%20`, `%23`, `%24`, `%2C` and `%3A` open. `@pptx-studio/opc` reports this as a warning (`M1.8`) because it must open what it is given; here it is fatal, because we are about to write it."
|
|
79
|
+
},
|
|
80
|
+
{
|
|
81
|
+
id: "V005",
|
|
82
|
+
category: "package",
|
|
83
|
+
severity: "fatal",
|
|
84
|
+
evidence: "both",
|
|
85
|
+
title: "there is exactly one main presentation part, of a matching content type",
|
|
86
|
+
why: "`_rels/.rels` must carry exactly one `officeDocument` relationship and it must resolve to a part whose content type is one of the four PresentationML main-part types. And the pairing PowerPoint enforces outside the schema: it checks the *file extension* against that content type and refuses the pair when they disagree, which is why a macro-enabled deck has to be written `.pptm`. `a32-macros` is the corpus deck that measured it."
|
|
87
|
+
},
|
|
88
|
+
{
|
|
89
|
+
id: "V006",
|
|
90
|
+
category: "relationships",
|
|
91
|
+
severity: "fatal",
|
|
92
|
+
evidence: "both",
|
|
93
|
+
title: "every relationship reference resolves in its own part’s `.rels`",
|
|
94
|
+
why: "`r:id`, `r:embed`, `r:link`, `r:pict`, `r:dm`, `r:lo`, `r:qs`, `r:cs` and every other attribute in the relationship namespace. Relationship ids are `xsd:ID` scoped to one `.rels` part, so the lookup is always against the referring part’s own collection and never against a global registry - a global one is not a shortcut, it is a bug. A dangling reference is fatal where an orphan relationship is harmless, which is the asymmetry that decides which direction media garbage collection may be aggressive in."
|
|
95
|
+
},
|
|
96
|
+
{
|
|
97
|
+
id: "V007",
|
|
98
|
+
category: "relationships",
|
|
99
|
+
severity: "fatal",
|
|
100
|
+
evidence: "schema",
|
|
101
|
+
title: "relationship ids are unique within a `.rels` and match the `xsd:ID` grammar",
|
|
102
|
+
why: "`Relationship/@Id` is `xsd:ID`: an XML Name, unique in its document. Two relationships sharing an id in one `.rels` makes every reference to it ambiguous, and which one a consumer picks is not defined anywhere."
|
|
103
|
+
},
|
|
104
|
+
{
|
|
105
|
+
id: "V008",
|
|
106
|
+
category: "relationships",
|
|
107
|
+
severity: "fatal",
|
|
108
|
+
evidence: "both",
|
|
109
|
+
title: "internal targets resolve inside the package, relative to the source part’s folder",
|
|
110
|
+
why: "A target is resolved against the folder of the part that *owns the `.rels`*, not against the package root and not against the `.rels` part’s own folder. Getting that wrong moves every relative target one directory up and is the classic reason a rebuilt deck loses all its images at once. `TargetMode=\"External\"` is passed through untouched and not resolved at all."
|
|
111
|
+
},
|
|
112
|
+
{
|
|
113
|
+
id: "V009",
|
|
114
|
+
category: "relationships",
|
|
115
|
+
severity: "fatal",
|
|
116
|
+
evidence: "measured",
|
|
117
|
+
title: "the relationship edges a part must have, and the ones it must not",
|
|
118
|
+
why: "Four findings, none of them in any schema, all found by bisection. (1) A slide needs **exactly one** `slideLayout` relationship resolving to a layout part - zero, two, one pointing at a master and one pointing at a missing part were each built as a single change and each refused; the binding lives only in the rels part, which is why \"change layout\" is a relationship rewrite. (2) A layout needs exactly one `slideMaster`. (3) A `cx:chartSpace` part with no `.rels` of its own is a whole-package refusal - it needs a `chartStyle` and a `chartColorStyle`, where the classic `c:chartSpace` needs neither. (4) A media part may not be both a media object’s target and a transition’s `p:snd`; two copies of the same bytes open, one shared copy does not."
|
|
119
|
+
},
|
|
120
|
+
{
|
|
121
|
+
id: "V010",
|
|
122
|
+
category: "order",
|
|
123
|
+
severity: "fatal",
|
|
124
|
+
evidence: "both",
|
|
125
|
+
title: "children appear in schema-sequence order",
|
|
126
|
+
why: "OOXML complex types are `xsd:sequence` almost everywhere, and PowerPoint enforces it. Microsoft’s own Open XML SDK shipped a regression that merely swapped two elements in `slideMaster1.xml` and PowerPoint refused the file. The ranks come from `@pptx-studio/xml`’s table, generated from the ECMA-376 Transitional schemas and checked against 194 148 elements PowerPoint wrote. This rule also covers the two the plan calls out by name, because the table already ranks them: `a:rPr` before `a:t`, and `a:endParaRPr` last in `a:p`."
|
|
127
|
+
},
|
|
128
|
+
{
|
|
129
|
+
id: "V011",
|
|
130
|
+
category: "order",
|
|
131
|
+
severity: "fatal",
|
|
132
|
+
evidence: "schema",
|
|
133
|
+
title: "`extLst` is last among its siblings, and every `a:ext` carries a `@uri`",
|
|
134
|
+
why: "Its own rule rather than a case of `V010`, because it has to hold in the places the generated table cannot rank - extension markup, `xsd:any` wildcards, and the vocabularies outside the four schemas we generate from. `extLst` is always last in every type that has one, and an `a:ext` without a `@uri` is an extension nobody can identify, which makes it unpreservable: the whole `extLst` contract is \"carry it through untouched, keyed by uri\"."
|
|
135
|
+
},
|
|
136
|
+
{
|
|
137
|
+
id: "V012",
|
|
138
|
+
category: "order",
|
|
139
|
+
severity: "fatal",
|
|
140
|
+
evidence: "both",
|
|
141
|
+
title: "no child appears in a parent whose content model has no place for it",
|
|
142
|
+
why: "Ordering catches a child in the wrong *position*; this catches one in the wrong *parent*, which the ordering rule cannot see because an unrankable child is skipped there rather than reported. The case that made it a rule is measured: `a:ahXY` or `a:cxn` as a direct child of `a:custGeom`, without its `a:ahLst`/`a:cxnLst` wrapper, is a whole-package refusal. Only parents the table knows and whose model has no `xsd:any` are checked, and markup in a namespace the table does not cover is left alone."
|
|
143
|
+
},
|
|
144
|
+
{
|
|
145
|
+
id: "V013",
|
|
146
|
+
category: "required",
|
|
147
|
+
severity: "fatal",
|
|
148
|
+
evidence: "schema",
|
|
149
|
+
title: "`p:presentation` carries `p:notesSz`",
|
|
150
|
+
why: "The asymmetry that catches people: in `CT_Presentation`, `p:sldSz` is `[0..1]` and `p:notesSz` is `[1..1]`. A generator that treats the two the same way, or that omits both because the deck has no notes, writes a presentation part that is missing a required child."
|
|
151
|
+
},
|
|
152
|
+
{
|
|
153
|
+
id: "V014",
|
|
154
|
+
category: "required",
|
|
155
|
+
severity: "fatal",
|
|
156
|
+
evidence: "schema",
|
|
157
|
+
title: "`p:clrMap` carries all twelve attributes",
|
|
158
|
+
why: "`CT_ColorMapping` has twelve attributes - `bg1`, `tx1`, `bg2`, `tx2`, `accent1`…`accent6`, `hlink`, `folHlink` - and every one is required. There is no default and no partial map: eleven of twelve is not a map with one slot missing, it is an invalid element. This is also the element that decides what `bg1` and `tx1` resolve to, so a wrong one is a deck that renders in the wrong colours everywhere at once."
|
|
159
|
+
},
|
|
160
|
+
{
|
|
161
|
+
id: "V015",
|
|
162
|
+
category: "required",
|
|
163
|
+
severity: "fatal",
|
|
164
|
+
evidence: "schema",
|
|
165
|
+
title: "`p:spTree` begins with `p:nvGrpSpPr` then `p:grpSpPr`",
|
|
166
|
+
why: "A shape tree is a group shape, and `CT_GroupShape` requires both of them first, in that order, before any child shape. An empty slide still has them. This is separate from `V010` because the failure is a missing required child rather than a misordered optional one, and the two need different messages: \"add this\" against \"move this\"."
|
|
167
|
+
},
|
|
168
|
+
{
|
|
169
|
+
id: "V016",
|
|
170
|
+
category: "required",
|
|
171
|
+
severity: "fatal",
|
|
172
|
+
evidence: "schema",
|
|
173
|
+
title: "every text body has `a:bodyPr` and at least one `a:p`",
|
|
174
|
+
why: "`CT_TextBody` is `bodyPr` then optional `lstStyle` then `a:p` at `[1..unbounded]`. A shape whose text was deleted down to nothing is the way this gets broken in an editor: the last paragraph goes, and what is left is a `p:txBody` with no `a:p` in it. An empty paragraph is not the same as no paragraph, and `a:endParaRPr` is where its height comes from."
|
|
175
|
+
},
|
|
176
|
+
{
|
|
177
|
+
id: "V017",
|
|
178
|
+
category: "required",
|
|
179
|
+
severity: "fatal",
|
|
180
|
+
evidence: "schema",
|
|
181
|
+
title: "`p:graphicFrame` carries `p:xfrm` and `a:graphic`",
|
|
182
|
+
why: "And note the namespace: it is `p:xfrm`, PresentationML, not the `a:xfrm` that every other shape uses. A graphic frame has no placeholder-inherited geometry path, so this is also the reason sub-phase 7.4 gives a rebound chart or table a *copy* of the target layout’s transform instead of deleting its own the way it does for every other shape - delete it there and the frame collapses to zero size."
|
|
183
|
+
},
|
|
184
|
+
{
|
|
185
|
+
id: "V018",
|
|
186
|
+
category: "ids",
|
|
187
|
+
severity: "fatal",
|
|
188
|
+
evidence: "schema",
|
|
189
|
+
title: "`p:sldId/@id` is 256…2147483647 and unique",
|
|
190
|
+
why: "`ST_SlideId` is not zero-based and not unbounded. The floor of 256 is the part people get wrong, because nothing about a first slide suggests its id should start there. Four id spaces exist in a presentation and they are not one allocator; this is the first of them."
|
|
191
|
+
},
|
|
192
|
+
{
|
|
193
|
+
id: "V019",
|
|
194
|
+
category: "ids",
|
|
195
|
+
severity: "fatal",
|
|
196
|
+
evidence: "both",
|
|
197
|
+
title: "master and layout ids are ≥ 2147483648 and unique **across both lists**",
|
|
198
|
+
why: "`ST_SlideMasterId` and `ST_SlideLayoutId` are both \"2147483648 and up\" and no schema says they may not overlap. PowerPoint allocates them from one running counter - master, its layouts, next master, its layouts - and refuses a package that does not. Found by bisection when `a12-masters` would not open: two counters are indistinguishable from correct with one master and collide on the second, so **every** two-master package was refused."
|
|
199
|
+
},
|
|
200
|
+
{
|
|
201
|
+
id: "V020",
|
|
202
|
+
category: "ids",
|
|
203
|
+
severity: "fatal",
|
|
204
|
+
evidence: "both",
|
|
205
|
+
title: "`p:cNvPr/@id` is unique within its part and ≤ 2147483647",
|
|
206
|
+
why: "Unique *within a part*, and free to repeat across parts - a shape id is not a document-wide identity and treating it as one is how a duplicate-slide implementation ends up renumbering things it should not. The range is measured rather than read: `ST_DrawingElementId` is `xsd:unsignedInt`, but every value from 0 to 2147483647 opens, 2147483648…4294967294 are whole-package refusals, and 4294967295 opens - PowerPoint reads the attribute as a signed 32-bit integer and keeps `0xFFFFFFFF` as a sentinel it renumbers away on save. `a39-large-ids` is the corpus deck."
|
|
207
|
+
},
|
|
208
|
+
{
|
|
209
|
+
id: "V021",
|
|
210
|
+
category: "ids",
|
|
211
|
+
severity: "warning",
|
|
212
|
+
evidence: "schema",
|
|
213
|
+
title: "a slide placeholder’s `(type, idx)` has a counterpart in its layout",
|
|
214
|
+
why: "A `p:ph` that matches nothing in the layout inherits nothing: no geometry, no text style, no prompt. PowerPoint opens the file and draws the shape at whatever it can work out, which is why this is a warning and not fatal - the deck is not refused, it is silently wrong. Matching follows the five tiers sub-phase 7.1 hardens, and the asymmetry that matters is checked here too: slide→layout matches on `(type, idx)` where layout→master matches on **type only**."
|
|
215
|
+
},
|
|
216
|
+
{
|
|
217
|
+
id: "V022",
|
|
218
|
+
category: "refused",
|
|
219
|
+
severity: "fatal",
|
|
220
|
+
evidence: "measured",
|
|
221
|
+
title: "`p:ph/@type` is not `hdr` or `sldImg` outside a notes or handout part",
|
|
222
|
+
why: "A whole-package refusal, on a slide layout and on a slide alike, either one alone with no other change. The other seven content types were built as one-type packages in the same bisection and every one opens: `obj`, `chart`, `tbl`, `clipArt`, `dgm`, `media`, `pic`. Nothing in the schema says so - `CT_Placeholder` is one complex type shared by masters, layouts, slides, notes slides and handout masters, and `ST_PlaceholderType` is one enumeration holding all sixteen values. The restriction is real and unwritten: those two belong to the notes and handout families."
|
|
223
|
+
},
|
|
224
|
+
{
|
|
225
|
+
id: "V023",
|
|
226
|
+
category: "refused",
|
|
227
|
+
severity: "fatal",
|
|
228
|
+
evidence: "measured",
|
|
229
|
+
title: "every geometry guide named is a guide that is defined",
|
|
230
|
+
why: "A whole-package refusal, not a repair and not a dropped shape. It applies both to an `a:gd` formula naming an adjust value no `a:avLst` defines and to an `a:pt` coordinate naming a guide no `a:gdLst` defines. `ST_GeomGuideName` is an unconstrained token, so this is schema-legal markup refused for a reason no schema states - and it is the rule that sub-phase 2.5 needs, because dragging an adjust handle is exactly the operation that can write a formula referring to a guide that is no longer there."
|
|
231
|
+
},
|
|
232
|
+
{
|
|
233
|
+
id: "V024",
|
|
234
|
+
category: "refused",
|
|
235
|
+
severity: "fatal",
|
|
236
|
+
evidence: "both",
|
|
237
|
+
title: "`c:tx` holds `c:strRef` or `c:v`, never `c:strLit`",
|
|
238
|
+
why: "`CT_SerTx` is a choice of exactly those two. The trap is that `c:cat` and `c:val`, two elements away in the same series, both accept the literal forms - so the markup looks like something that ought to work, and it is a whole-package refusal."
|
|
239
|
+
},
|
|
240
|
+
{
|
|
241
|
+
id: "V025",
|
|
242
|
+
category: "refused",
|
|
243
|
+
severity: "fatal",
|
|
244
|
+
evidence: "measured",
|
|
245
|
+
title: "no `p:control`",
|
|
246
|
+
why: "A whole-package refusal in all eight forms tried: bare, name-only, with and without `r:id`, with a `p:pic` preview, with an ActiveX part and its `.bin`, and inside a macro-enabled package. An empty `p:controls` is accepted, which places the refusal precisely on the child element. We do not author ActiveX controls and never will; the rule exists so that a file that has one is not handed back with it re-serialised into a form PowerPoint likes less than the original."
|
|
247
|
+
},
|
|
248
|
+
{
|
|
249
|
+
id: "V026",
|
|
250
|
+
category: "refused",
|
|
251
|
+
severity: "fatal",
|
|
252
|
+
evidence: "measured",
|
|
253
|
+
title: "a `cs:chartStyle` carries all thirty-one of its entries",
|
|
254
|
+
why: "Four entries refused; thirty-one opened. And the shape of the finding is the interesting half: a chart with **no** chart-style relationship at all is fine, so this is not \"the part is required\" - it is \"if the part exists it must be complete\". A subset is worse than an absence, which is the opposite of what a partial-styling model would predict."
|
|
255
|
+
},
|
|
256
|
+
{
|
|
257
|
+
id: "V027",
|
|
258
|
+
category: "preservation",
|
|
259
|
+
severity: "fatal",
|
|
260
|
+
evidence: "schema",
|
|
261
|
+
needsBaseline: true,
|
|
262
|
+
title: "a part nobody edited comes back out byte-for-byte",
|
|
263
|
+
why: "The load-bearing one, and the reason the architecture is what it is. Preserving charts, SmartArt, animations, OLE and macros is unachievable in any design where the writer has to *understand* a feature to emit it, so preservation is the default state rather than a feature - and this rule is where that stops being an intention. `ppt/embeddings/*.bin` is called out inside it: those are OLE2/CFB compound files and any rewrite of one is a guaranteed repair prompt."
|
|
264
|
+
},
|
|
265
|
+
{
|
|
266
|
+
id: "V028",
|
|
267
|
+
category: "preservation",
|
|
268
|
+
severity: "fatal",
|
|
269
|
+
evidence: "schema",
|
|
270
|
+
needsBaseline: true,
|
|
271
|
+
title: "no `mc:AlternateContent` branch and no `extLst` was rebuilt",
|
|
272
|
+
why: "Two things we hold opaque, for one reason: we do not know what is in them. An `mc:AlternateContent` branch we do not understand must arrive and leave identical, because `mc:Choice/@Requires` names a *prefix* and rewriting the branch is how ignorable extension markup becomes a hard error. An `extLst` is an ordered list keyed by `@uri` and never a typed model that is rebuilt from fields - `a34-extlst` measured that PowerPoint carries an unknown `a:ext/@uri` through untouched, so anything we drop there is something the file would otherwise have kept forever."
|
|
273
|
+
},
|
|
274
|
+
{
|
|
275
|
+
id: "V029",
|
|
276
|
+
category: "preservation",
|
|
277
|
+
severity: "fatal",
|
|
278
|
+
evidence: "both",
|
|
279
|
+
needsBaseline: true,
|
|
280
|
+
title: "text and field identity survive: `xml:space`, `a:fld/@id`, cached field text",
|
|
281
|
+
why: "Three small things that are each silently destructive. An `a:t` with leading or trailing whitespace needs `xml:space=\"preserve\"` if it was written with it, because dropping the attribute changes what the text *is*. `a:fld/@id` is a required `ST_Guid` and regenerating one can make PowerPoint repair the file. And a field’s cached `a:t` is the only thing that renders when we cannot evaluate the field ourselves, so discarding it turns a date placeholder into an empty box on every consumer that is not PowerPoint."
|
|
282
|
+
}
|
|
283
|
+
];
|
|
284
|
+
const BY_ID = new Map(RULES.map((rule) => [rule.id, rule]));
|
|
285
|
+
function ruleById(id) {
|
|
286
|
+
return BY_ID.get(id);
|
|
287
|
+
}
|
|
288
|
+
const RULE_IDS = RULES.map((rule) => rule.id);
|
|
289
|
+
/** Rules that need the package as it was opened. See `Rule.needsBaseline`. */
|
|
290
|
+
const BASELINE_RULES = RULES.filter((rule) => "needsBaseline" in rule).map((rule) => rule.id);
|
|
291
|
+
//#endregion
|
|
292
|
+
//#region src/report/report.ts
|
|
293
|
+
/** Fatal, then warning; then by part; then by offset; then by rule. */
|
|
294
|
+
function compareFindings(a, b) {
|
|
295
|
+
if (a.severity !== b.severity) return a.severity === "fatal" ? -1 : 1;
|
|
296
|
+
if (a.where.part !== b.where.part) return a.where.part.localeCompare(b.where.part);
|
|
297
|
+
const offsetA = a.where.offset ?? -1;
|
|
298
|
+
const offsetB = b.where.offset ?? -1;
|
|
299
|
+
if (offsetA !== offsetB) return offsetA - offsetB;
|
|
300
|
+
return a.rule.localeCompare(b.rule);
|
|
301
|
+
}
|
|
302
|
+
/**
|
|
303
|
+
* The key two reports are differenced on.
|
|
304
|
+
*
|
|
305
|
+
* Includes the message, because the message carries the instance - the id that
|
|
306
|
+
* collided, the guide that was not defined. Two findings of the same rule at
|
|
307
|
+
* the same place with different values are different findings, and treating
|
|
308
|
+
* them as one would let an introduced defect hide behind an inherited one.
|
|
309
|
+
*/
|
|
310
|
+
function findingKey(finding) {
|
|
311
|
+
return [
|
|
312
|
+
finding.rule,
|
|
313
|
+
finding.where.part,
|
|
314
|
+
finding.where.xpath ?? "",
|
|
315
|
+
finding.message
|
|
316
|
+
].join("\0");
|
|
317
|
+
}
|
|
318
|
+
function buildReport(input) {
|
|
319
|
+
const findings = [...input.findings].sort(compareFindings);
|
|
320
|
+
const blocking = findings.filter((finding) => finding.severity === "fatal" && finding.origin !== "inherited").length;
|
|
321
|
+
return {
|
|
322
|
+
findings,
|
|
323
|
+
checked: input.checked,
|
|
324
|
+
skipped: input.skipped,
|
|
325
|
+
problems: input.problems,
|
|
326
|
+
blocking,
|
|
327
|
+
ok: blocking === 0
|
|
328
|
+
};
|
|
329
|
+
}
|
|
330
|
+
function isReport(value) {
|
|
331
|
+
return typeof value === "object" && value !== null && Array.isArray(value.findings) && typeof value.blocking === "number";
|
|
332
|
+
}
|
|
333
|
+
/**
|
|
334
|
+
* A report as text.
|
|
335
|
+
*
|
|
336
|
+
* Grouped by part rather than by rule, because the question a reader has is
|
|
337
|
+
* "what is wrong with this file", and a file is a set of parts. Grouping by
|
|
338
|
+
* rule is the right shape for the corpus gate and the wrong one here.
|
|
339
|
+
*/
|
|
340
|
+
function formatReport(report, options = {}) {
|
|
341
|
+
const showInherited = options.inherited ?? true;
|
|
342
|
+
const showWarnings = options.warnings ?? true;
|
|
343
|
+
const shown = report.findings.filter((finding) => (showInherited || finding.origin !== "inherited") && (showWarnings || finding.severity === "fatal"));
|
|
344
|
+
const lines = [];
|
|
345
|
+
if (shown.length === 0) lines.push(report.findings.length === 0 ? "no findings" : "no findings shown");
|
|
346
|
+
else {
|
|
347
|
+
const byPart = /* @__PURE__ */ new Map();
|
|
348
|
+
for (const finding of shown) {
|
|
349
|
+
const bucket = byPart.get(finding.where.part);
|
|
350
|
+
if (bucket) bucket.push(finding);
|
|
351
|
+
else byPart.set(finding.where.part, [finding]);
|
|
352
|
+
}
|
|
353
|
+
for (const [part, findings] of byPart) {
|
|
354
|
+
lines.push("");
|
|
355
|
+
lines.push(" " + (part === "/" ? "(package)" : part));
|
|
356
|
+
for (const finding of findings) {
|
|
357
|
+
const tag = finding.severity === "fatal" ? finding.origin === "inherited" ? "fatal (already there)" : "fatal" : "warning";
|
|
358
|
+
lines.push(" " + finding.rule + " " + tag);
|
|
359
|
+
if (finding.where.xpath !== null) lines.push(" " + finding.where.xpath);
|
|
360
|
+
lines.push(" " + finding.message);
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
for (const problem of report.problems) {
|
|
365
|
+
lines.push("");
|
|
366
|
+
lines.push(" " + problem.part + " could not be read: " + problem.message);
|
|
367
|
+
}
|
|
368
|
+
for (const skipped of report.skipped) {
|
|
369
|
+
lines.push("");
|
|
370
|
+
lines.push(" " + skipped.rule + " did not run: " + skipped.why);
|
|
371
|
+
}
|
|
372
|
+
if (options.explain === true) {
|
|
373
|
+
const seen = /* @__PURE__ */ new Set();
|
|
374
|
+
for (const finding of shown) {
|
|
375
|
+
if (seen.has(finding.rule)) continue;
|
|
376
|
+
seen.add(finding.rule);
|
|
377
|
+
const rule = ruleById(finding.rule);
|
|
378
|
+
if (rule === void 0) continue;
|
|
379
|
+
lines.push("");
|
|
380
|
+
lines.push(" " + rule.id + " - " + rule.title);
|
|
381
|
+
lines.push(" " + rule.why);
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
lines.push("");
|
|
385
|
+
lines.push("validate: " + String(report.checked.length) + " rule(s), " + String(report.findings.length) + " finding(s), " + String(report.blocking) + " blocking");
|
|
386
|
+
return lines.join("\n");
|
|
387
|
+
}
|
|
388
|
+
//#endregion
|
|
389
|
+
//#region src/report/location.ts
|
|
390
|
+
/** A location that names a part and nothing inside it. */
|
|
391
|
+
function partLocation(part) {
|
|
392
|
+
return {
|
|
393
|
+
part,
|
|
394
|
+
xpath: null,
|
|
395
|
+
offset: null
|
|
396
|
+
};
|
|
397
|
+
}
|
|
398
|
+
/** The location of the package itself: the archive, the content types, the graph. */
|
|
399
|
+
const PACKAGE_LOCATION = {
|
|
400
|
+
part: "/",
|
|
401
|
+
xpath: null,
|
|
402
|
+
offset: null
|
|
403
|
+
};
|
|
404
|
+
/** 1-based position among same-named element siblings, or `null` if it is alone. */
|
|
405
|
+
function positionAmongSiblings(element) {
|
|
406
|
+
const parent = element.parent;
|
|
407
|
+
if (parent === void 0) return null;
|
|
408
|
+
let position = 0;
|
|
409
|
+
let seen = 0;
|
|
410
|
+
for (const child of parent.children) {
|
|
411
|
+
if (child.type !== "element" || child.qname !== element.qname) continue;
|
|
412
|
+
seen++;
|
|
413
|
+
if (child === element) position = seen;
|
|
414
|
+
}
|
|
415
|
+
return seen > 1 ? position : null;
|
|
416
|
+
}
|
|
417
|
+
/**
|
|
418
|
+
* The XPath of an element, from the document root.
|
|
419
|
+
*
|
|
420
|
+
* Never throws and never returns an empty string: a detached element - one
|
|
421
|
+
* whose `parent` chain does not reach a root - still gets its own name, so a
|
|
422
|
+
* finding built from a node the caller synthesised is degraded rather than
|
|
423
|
+
* lost.
|
|
424
|
+
*/
|
|
425
|
+
function xpathOf(element) {
|
|
426
|
+
const steps = [];
|
|
427
|
+
for (let node = element; node !== void 0; node = node.parent) {
|
|
428
|
+
const position = positionAmongSiblings(node);
|
|
429
|
+
steps.push(position === null ? node.qname : node.qname + "[" + String(position) + "]");
|
|
430
|
+
}
|
|
431
|
+
steps.reverse();
|
|
432
|
+
return "/" + steps.join("/");
|
|
433
|
+
}
|
|
434
|
+
/** The XPath of an attribute: its owner's path, then `/@qname`. */
|
|
435
|
+
function xpathOfAttribute(owner, attr) {
|
|
436
|
+
const qname = typeof attr === "string" ? attr : attr.qname;
|
|
437
|
+
return xpathOf(owner) + "/@" + qname;
|
|
438
|
+
}
|
|
439
|
+
/** A location for an element inside a named part. */
|
|
440
|
+
function elementLocation(part, element) {
|
|
441
|
+
return {
|
|
442
|
+
part,
|
|
443
|
+
xpath: xpathOf(element),
|
|
444
|
+
offset: element.start
|
|
445
|
+
};
|
|
446
|
+
}
|
|
447
|
+
/**
|
|
448
|
+
* A location for one attribute of an element.
|
|
449
|
+
*
|
|
450
|
+
* The offset is the attribute's own start when the element carries it, and the
|
|
451
|
+
* element's start when it does not - which is the case that matters, because a
|
|
452
|
+
* *missing* required attribute is a finding and it has to point somewhere.
|
|
453
|
+
*/
|
|
454
|
+
function attributeLocation(part, element, qname) {
|
|
455
|
+
const attr = attribute(element, qname);
|
|
456
|
+
return {
|
|
457
|
+
part,
|
|
458
|
+
xpath: xpathOfAttribute(element, qname),
|
|
459
|
+
offset: attr === void 0 ? element.start : attr.start
|
|
460
|
+
};
|
|
461
|
+
}
|
|
462
|
+
/**
|
|
463
|
+
* `line:column` for an offset, 1-based, or `null`.
|
|
464
|
+
*
|
|
465
|
+
* Not part of `Location`, because computing it means scanning the source from
|
|
466
|
+
* the beginning and a report with three hundred findings would scan it three
|
|
467
|
+
* hundred times. Callers that render a report for a human ask for it once, at
|
|
468
|
+
* the point of rendering, for the findings they are about to show.
|
|
469
|
+
*/
|
|
470
|
+
function lineColumn(source, offset) {
|
|
471
|
+
let line = 1;
|
|
472
|
+
let lineStart = 0;
|
|
473
|
+
const limit = Math.min(offset, source.length);
|
|
474
|
+
for (let i = 0; i < limit; i++) if (source.charCodeAt(i) === 10) {
|
|
475
|
+
line++;
|
|
476
|
+
lineStart = i + 1;
|
|
477
|
+
}
|
|
478
|
+
return {
|
|
479
|
+
line,
|
|
480
|
+
column: limit - lineStart + 1
|
|
481
|
+
};
|
|
482
|
+
}
|
|
483
|
+
/** Document order, for sorting findings within a part. */
|
|
484
|
+
function inDocumentOrder(a, b) {
|
|
485
|
+
return a.start - b.start;
|
|
486
|
+
}
|
|
487
|
+
/** The root element's qname, for the handful of rules that dispatch on it. */
|
|
488
|
+
function rootName(document) {
|
|
489
|
+
return document.root.qname;
|
|
490
|
+
}
|
|
491
|
+
//#endregion
|
|
492
|
+
//#region src/context.ts
|
|
493
|
+
/** Content types whose parts are XML, beyond everything ending in `+xml`. */
|
|
494
|
+
const XML_CONTENT_TYPES = /* @__PURE__ */ new Set(["application/xml", "text/xml"]);
|
|
495
|
+
function isXmlContentType(contentType) {
|
|
496
|
+
if (contentType === void 0) return false;
|
|
497
|
+
const base = contentType.split(";")[0].trim().toLowerCase();
|
|
498
|
+
return base.endsWith("+xml") || XML_CONTENT_TYPES.has(base);
|
|
499
|
+
}
|
|
500
|
+
var RuntimeContext = class {
|
|
501
|
+
store;
|
|
502
|
+
bytes;
|
|
503
|
+
archive;
|
|
504
|
+
baseline;
|
|
505
|
+
findings = [];
|
|
506
|
+
problems = [];
|
|
507
|
+
#parts = null;
|
|
508
|
+
#documents = /* @__PURE__ */ new Map();
|
|
509
|
+
#baselineDocuments = /* @__PURE__ */ new Map();
|
|
510
|
+
constructor(input) {
|
|
511
|
+
this.store = input.store;
|
|
512
|
+
this.bytes = input.bytes ?? null;
|
|
513
|
+
this.archive = input.archive ?? null;
|
|
514
|
+
this.baseline = input.baseline ?? null;
|
|
515
|
+
}
|
|
516
|
+
parts() {
|
|
517
|
+
this.#parts ??= this.store.partNames;
|
|
518
|
+
return this.#parts;
|
|
519
|
+
}
|
|
520
|
+
read(part) {
|
|
521
|
+
if (!this.store.has(part)) return null;
|
|
522
|
+
try {
|
|
523
|
+
return this.store.read(part);
|
|
524
|
+
} catch (error) {
|
|
525
|
+
this.problem(part, describe(error));
|
|
526
|
+
return null;
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
contentType(part) {
|
|
530
|
+
return this.store.contentTypeOf(part);
|
|
531
|
+
}
|
|
532
|
+
document(part) {
|
|
533
|
+
const key = normalizePartName(part);
|
|
534
|
+
const cached = this.#documents.get(key);
|
|
535
|
+
if (cached !== void 0) return cached;
|
|
536
|
+
const parsed = this.#parse(part, this.read(part));
|
|
537
|
+
this.#documents.set(key, parsed);
|
|
538
|
+
return parsed;
|
|
539
|
+
}
|
|
540
|
+
baselineDocument(part) {
|
|
541
|
+
if (this.baseline === null) return null;
|
|
542
|
+
const key = normalizePartName(part);
|
|
543
|
+
const cached = this.#baselineDocuments.get(key);
|
|
544
|
+
if (cached !== void 0) return cached;
|
|
545
|
+
const parsed = this.#parse(part, this.baselineRead(part), true);
|
|
546
|
+
this.#baselineDocuments.set(key, parsed);
|
|
547
|
+
return parsed;
|
|
548
|
+
}
|
|
549
|
+
#parse(part, bytes, quiet = false) {
|
|
550
|
+
if (bytes === null) return null;
|
|
551
|
+
if (!isRelationshipPartName(part) && !isXmlContentType(this.contentType(part))) return null;
|
|
552
|
+
try {
|
|
553
|
+
return parseXml(bytes);
|
|
554
|
+
} catch (error) {
|
|
555
|
+
if (!quiet) this.problem(part, describe(error));
|
|
556
|
+
return null;
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
#contentTypesDocument;
|
|
560
|
+
contentTypesDocument() {
|
|
561
|
+
if (this.#contentTypesDocument !== void 0) return this.#contentTypesDocument;
|
|
562
|
+
this.#contentTypesDocument = null;
|
|
563
|
+
const archive = this.archive;
|
|
564
|
+
if (archive !== null) {
|
|
565
|
+
const entry = archive.entries.find((e) => isContentTypesStreamName(e.name));
|
|
566
|
+
if (entry !== void 0) try {
|
|
567
|
+
this.#contentTypesDocument = parseXml(archive.read(entry));
|
|
568
|
+
} catch (error) {
|
|
569
|
+
this.problem(CONTENT_TYPES_PART, describe(error));
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
return this.#contentTypesDocument;
|
|
573
|
+
}
|
|
574
|
+
baselineRead(part) {
|
|
575
|
+
if (this.baseline === null || !this.baseline.has(part)) return null;
|
|
576
|
+
try {
|
|
577
|
+
return this.baseline.read(part);
|
|
578
|
+
} catch {
|
|
579
|
+
return null;
|
|
580
|
+
}
|
|
581
|
+
}
|
|
582
|
+
edited(part) {
|
|
583
|
+
return this.store.info(part)?.fromArchive === false;
|
|
584
|
+
}
|
|
585
|
+
add(rule, where, message) {
|
|
586
|
+
const definition = ruleById(rule);
|
|
587
|
+
if (definition === void 0) return;
|
|
588
|
+
this.findings.push({
|
|
589
|
+
rule,
|
|
590
|
+
severity: definition.severity,
|
|
591
|
+
category: definition.category,
|
|
592
|
+
where,
|
|
593
|
+
message,
|
|
594
|
+
origin: "unknown"
|
|
595
|
+
});
|
|
596
|
+
}
|
|
597
|
+
problem(part, message) {
|
|
598
|
+
if (!this.problems.some((p) => p.part === part && p.message === message)) this.problems.push({
|
|
599
|
+
part,
|
|
600
|
+
message
|
|
601
|
+
});
|
|
602
|
+
}
|
|
603
|
+
};
|
|
604
|
+
function describe(error) {
|
|
605
|
+
if (error instanceof Error) {
|
|
606
|
+
const code = error.code;
|
|
607
|
+
return typeof code === "string" ? code + ": " + error.message : error.message;
|
|
608
|
+
}
|
|
609
|
+
return String(error);
|
|
610
|
+
}
|
|
611
|
+
function createContext(input) {
|
|
612
|
+
return new RuntimeContext(input);
|
|
613
|
+
}
|
|
614
|
+
//#endregion
|
|
615
|
+
//#region src/rels.ts
|
|
616
|
+
/**
|
|
617
|
+
* Every relationship part in the package, parsed.
|
|
618
|
+
*
|
|
619
|
+
* A `.rels` whose root is not `Relationships` in the OPC namespace is skipped
|
|
620
|
+
* and recorded as a problem, because there is nothing sensible to say about it
|
|
621
|
+
* rule by rule and saying it once is better than saying it four times.
|
|
622
|
+
*/
|
|
623
|
+
const CACHE = /* @__PURE__ */ new WeakMap();
|
|
624
|
+
function readRelsParts(ctx) {
|
|
625
|
+
const cached = CACHE.get(ctx);
|
|
626
|
+
if (cached !== void 0) return cached;
|
|
627
|
+
const parts = scanRelsParts(ctx);
|
|
628
|
+
CACHE.set(ctx, parts);
|
|
629
|
+
return parts;
|
|
630
|
+
}
|
|
631
|
+
/**
|
|
632
|
+
* Memoised above, because five of the twenty-nine rules want this list and each
|
|
633
|
+
* would otherwise re-walk every `.rels` part in the package. The parsed trees
|
|
634
|
+
* are already cached on the context; what is saved here is the element scan and
|
|
635
|
+
* the allocation, which on a three-hundred-slide deck is five passes over a
|
|
636
|
+
* thousand relationship parts for one answer that cannot have changed.
|
|
637
|
+
*
|
|
638
|
+
* Keyed by context rather than by store, so nothing survives one validation run
|
|
639
|
+
* into the next.
|
|
640
|
+
*/
|
|
641
|
+
function scanRelsParts(ctx) {
|
|
642
|
+
const out = [];
|
|
643
|
+
for (const partName of ctx.parts()) {
|
|
644
|
+
if (!isRelationshipPartName(partName)) continue;
|
|
645
|
+
const document = ctx.document(partName);
|
|
646
|
+
if (document === null) continue;
|
|
647
|
+
const root = document.root;
|
|
648
|
+
if (root.local !== "Relationships") {
|
|
649
|
+
ctx.problem(partName, "root element is <" + root.qname + ">, not <Relationships>");
|
|
650
|
+
continue;
|
|
651
|
+
}
|
|
652
|
+
let source;
|
|
653
|
+
try {
|
|
654
|
+
source = partName === "/_rels/.rels" ? "/" : sourcePartNameForRels(partName);
|
|
655
|
+
} catch {
|
|
656
|
+
ctx.problem(partName, "is not a well-formed relationship part name");
|
|
657
|
+
continue;
|
|
658
|
+
}
|
|
659
|
+
const relationships = [];
|
|
660
|
+
for (const child of childElements(root)) {
|
|
661
|
+
if (child.local !== "Relationship") continue;
|
|
662
|
+
relationships.push({
|
|
663
|
+
id: attributeValue(child, "Id") ?? "",
|
|
664
|
+
type: attributeValue(child, "Type") ?? "",
|
|
665
|
+
target: attributeValue(child, "Target") ?? "",
|
|
666
|
+
targetMode: attributeValue(child, "TargetMode") ?? "Internal",
|
|
667
|
+
element: child
|
|
668
|
+
});
|
|
669
|
+
}
|
|
670
|
+
out.push({
|
|
671
|
+
partName,
|
|
672
|
+
source,
|
|
673
|
+
relationships,
|
|
674
|
+
root
|
|
675
|
+
});
|
|
676
|
+
}
|
|
677
|
+
return out;
|
|
678
|
+
}
|
|
679
|
+
/**
|
|
680
|
+
* Resolve an internal target against the folder of the part that owns the
|
|
681
|
+
* `.rels` - never against the package root, and never against the `.rels`
|
|
682
|
+
* part's own folder.
|
|
683
|
+
*
|
|
684
|
+
* The distinction is the whole of rule `V008`. `_rels/` is a directory in the
|
|
685
|
+
* archive but not in the resolution model: a `Target="../media/image1.png"` in
|
|
686
|
+
* `/ppt/slides/_rels/slide1.xml.rels` resolves against `/ppt/slides/`, giving
|
|
687
|
+
* `/ppt/media/image1.png`. Resolving it against `/ppt/slides/_rels/` instead
|
|
688
|
+
* yields `/ppt/slides/media/image1.png`, which is one directory too deep and is
|
|
689
|
+
* why a rebuilt deck loses every image at once rather than one of them.
|
|
690
|
+
*/
|
|
691
|
+
function resolveTarget(source, target) {
|
|
692
|
+
try {
|
|
693
|
+
return {
|
|
694
|
+
part: resolveRelativeTarget(source, target),
|
|
695
|
+
failure: null
|
|
696
|
+
};
|
|
697
|
+
} catch (error) {
|
|
698
|
+
return {
|
|
699
|
+
part: null,
|
|
700
|
+
failure: error instanceof Error ? error.message : String(error)
|
|
701
|
+
};
|
|
702
|
+
}
|
|
703
|
+
}
|
|
704
|
+
/** `.rels` parts keyed by the **source** part they describe, normalised. */
|
|
705
|
+
function bySource(parts) {
|
|
706
|
+
const map = /* @__PURE__ */ new Map();
|
|
707
|
+
for (const part of parts) map.set(part.source === "/" ? "/" : normalizePartName(part.source), part);
|
|
708
|
+
return map;
|
|
709
|
+
}
|
|
710
|
+
//#endregion
|
|
711
|
+
//#region src/rules/package.ts
|
|
712
|
+
/**
|
|
713
|
+
* `V001` … `V005`: the container.
|
|
714
|
+
*
|
|
715
|
+
* These five run before any markup is looked at, and they are the ones whose
|
|
716
|
+
* failure mode is "PowerPoint declines to open the file at all" rather than
|
|
717
|
+
* "the slide renders wrong". The package layer has no partial credit: a part
|
|
718
|
+
* with no content type is not a part with an unknown type, it is a stream
|
|
719
|
+
* nothing is allowed to interpret.
|
|
720
|
+
*/
|
|
721
|
+
/** Every part resolves to a content type. */
|
|
722
|
+
function v001ContentTypeCoverage(ctx) {
|
|
723
|
+
for (const part of ctx.parts()) {
|
|
724
|
+
if (ctx.contentType(part) !== void 0) continue;
|
|
725
|
+
const extension = partExtension(part);
|
|
726
|
+
ctx.add("V001", partLocation(part), "no content type: nothing in [Content_Types].xml covers it. Add an Override for the part, " + (extension === "" ? "or give it an extension a Default covers." : "or a Default for the \"" + extension + "\" extension."));
|
|
727
|
+
}
|
|
728
|
+
}
|
|
729
|
+
/**
|
|
730
|
+
* The content-type map holds together.
|
|
731
|
+
*
|
|
732
|
+
* Four separate things, one rule, because they are one question - "does this
|
|
733
|
+
* map say exactly one thing about every part" - and a caller who has to
|
|
734
|
+
* remember four rule ids to know whether their content types are sound is a
|
|
735
|
+
* caller who will remember three.
|
|
736
|
+
*/
|
|
737
|
+
function v002ContentTypeMap(ctx) {
|
|
738
|
+
const document = ctx.contentTypesDocument();
|
|
739
|
+
if (document === null) {
|
|
740
|
+
if (ctx.archive === null) ctx.problem(CONTENT_TYPES_PART, "not available: only a PartStore was supplied, so V002 checked the parsed map and not the markup. A duplicate <Default> or <Override> is visible only in the markup.");
|
|
741
|
+
else ctx.add("V002", partLocation(CONTENT_TYPES_PART), "the content-type stream is missing or would not parse.");
|
|
742
|
+
} else {
|
|
743
|
+
const seenDefault = /* @__PURE__ */ new Map();
|
|
744
|
+
const seenOverride = /* @__PURE__ */ new Map();
|
|
745
|
+
for (const child of childElements(document.root)) if (child.local === "Default") {
|
|
746
|
+
const extension = (attributeValue(child, "Extension") ?? "").toLowerCase();
|
|
747
|
+
const count = (seenDefault.get(extension) ?? 0) + 1;
|
|
748
|
+
seenDefault.set(extension, count);
|
|
749
|
+
if (count === 2) ctx.add("V002", elementLocation(CONTENT_TYPES_PART, child), "the \"" + extension + "\" extension has two <Default> entries. PowerPoint refuses a duplicate even when the two agree.");
|
|
750
|
+
} else if (child.local === "Override") {
|
|
751
|
+
const partName = attributeValue(child, "PartName") ?? "";
|
|
752
|
+
const key = partName.toLowerCase();
|
|
753
|
+
const count = (seenOverride.get(key) ?? 0) + 1;
|
|
754
|
+
seenOverride.set(key, count);
|
|
755
|
+
if (count === 2) ctx.add("V002", elementLocation(CONTENT_TYPES_PART, child), partName + " has two <Override> entries.");
|
|
756
|
+
if (partName !== "" && !ctx.store.has(partName)) ctx.add("V002", elementLocation(CONTENT_TYPES_PART, child), "an <Override> names " + partName + ", which is not in the package.");
|
|
757
|
+
}
|
|
758
|
+
}
|
|
759
|
+
const fontParts = ctx.parts().filter((part) => partExtension(part).toLowerCase() === FONT_DATA_EXTENSION);
|
|
760
|
+
if (fontParts.length > 0) {
|
|
761
|
+
const declared = ctx.store.contentTypes.defaults.find((entry) => entry.extension.toLowerCase() === FONT_DATA_EXTENSION);
|
|
762
|
+
if (declared === void 0) ctx.add("V002", partLocation(CONTENT_TYPES_PART), "the package has " + String(fontParts.length) + " .fntdata part(s) and no <Default Extension=\"fntdata\" ContentType=\"" + FONT_DATA_CONTENT_TYPE + "\"/>. PowerPoint reports the file as having a problem with its content.");
|
|
763
|
+
else if (declared.contentType.toLowerCase() !== FONT_DATA_CONTENT_TYPE.toLowerCase()) ctx.add("V002", partLocation(CONTENT_TYPES_PART), "the fntdata Default is typed \"" + declared.contentType + "\"; embedded fonts are \"" + FONT_DATA_CONTENT_TYPE + "\".");
|
|
764
|
+
}
|
|
765
|
+
}
|
|
766
|
+
/** No directory entries, no ZIP64 record. */
|
|
767
|
+
function v003ArchiveShape(ctx) {
|
|
768
|
+
const archive = ctx.archive;
|
|
769
|
+
if (archive === null) return;
|
|
770
|
+
for (const entry of archive.entries) {
|
|
771
|
+
if (!entry.isDirectory) continue;
|
|
772
|
+
ctx.add("V003", PACKAGE_LOCATION, "the archive carries a directory entry, \"" + entry.name + "\". No OPC part name may end in a slash, so nothing in the package can name it.");
|
|
773
|
+
}
|
|
774
|
+
if (ctx.bytes !== null && hasZip64Locator(ctx.bytes)) ctx.add("V003", PACKAGE_LOCATION, "the archive carries a ZIP64 end-of-central-directory locator. Our writer emits ZIP32 only, so one in a package we produced means a size or offset field overflowed.");
|
|
775
|
+
}
|
|
776
|
+
/**
|
|
777
|
+
* The ZIP64 locator signature, searched backwards from the end.
|
|
778
|
+
*
|
|
779
|
+
* The locator sits immediately before the end-of-central-directory record,
|
|
780
|
+
* which is within 64 KB + 22 bytes of the end once a comment is allowed for.
|
|
781
|
+
* Same computation `@pptx-studio/census` makes; duplicated rather than shared
|
|
782
|
+
* because `census` is a sibling at layer 1 and depending on it to answer one
|
|
783
|
+
* boolean would put a whole feature scanner in the export path.
|
|
784
|
+
*/
|
|
785
|
+
function hasZip64Locator(bytes) {
|
|
786
|
+
if (bytes.byteLength < 20) return false;
|
|
787
|
+
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
|
788
|
+
const from = Math.max(0, bytes.byteLength - 65536 - 40);
|
|
789
|
+
for (let at = bytes.byteLength - 20; at >= from; at--) if (view.getUint32(at, true) === 117853008) return true;
|
|
790
|
+
return false;
|
|
791
|
+
}
|
|
792
|
+
/**
|
|
793
|
+
* Part names, with `M1.8` raised from warning to fatal.
|
|
794
|
+
*
|
|
795
|
+
* `@pptx-studio/opc` rates a percent-escape of an unreserved character a
|
|
796
|
+
* warning, and that is right *there*: the package layer has to open what it is
|
|
797
|
+
* given, and a file that arrives with `%5F` in a part name is a file the user
|
|
798
|
+
* still wants to see. Here we are about to write one, and PowerPoint refuses
|
|
799
|
+
* it with `0x808D1005` - measured nine for nine on one-name packages. Raising
|
|
800
|
+
* the severity at the point of writing rather than at the point of reading is
|
|
801
|
+
* the whole shape of the read-leniently/write-strictly split this project uses
|
|
802
|
+
* everywhere.
|
|
803
|
+
*/
|
|
804
|
+
function v004PartNames(ctx) {
|
|
805
|
+
for (const part of ctx.parts()) for (const violation of validatePartName(part)) ctx.add("V004", partLocation(part), violation.rule + ": " + violation.message);
|
|
806
|
+
for (const collision of checkPartNameCollisions([...ctx.parts()])) ctx.add("V004", partLocation(collision.names[0]), collision.rule + ": " + collision.message);
|
|
807
|
+
}
|
|
808
|
+
/** The main presentation part: exactly one, of a type that matches the file. */
|
|
809
|
+
const MAIN_PART_TYPES = /* @__PURE__ */ new Set([
|
|
810
|
+
CONTENT_TYPE.presentation,
|
|
811
|
+
CONTENT_TYPE.template,
|
|
812
|
+
CONTENT_TYPE.slideshow,
|
|
813
|
+
"application/vnd.ms-powerpoint.presentation.macroEnabled.main+xml",
|
|
814
|
+
"application/vnd.ms-powerpoint.template.macroEnabled.main+xml",
|
|
815
|
+
"application/vnd.ms-powerpoint.slideshow.macroEnabled.main+xml"
|
|
816
|
+
]);
|
|
817
|
+
function v005MainPart(ctx) {
|
|
818
|
+
const rootRels = readRelsParts(ctx).find((rels) => rels.source === "/");
|
|
819
|
+
if (rootRels === void 0) {
|
|
820
|
+
ctx.add("V005", partLocation("/_rels/.rels"), "the package has no root relationship part. It is the only way in to a .pptx; without it nothing can be reached and PowerPoint reports the file as corrupted and unreadable.");
|
|
821
|
+
return;
|
|
822
|
+
}
|
|
823
|
+
const officeDocuments = rootRels.relationships.filter((rel) => rel.type === REL_TYPE.officeDocument);
|
|
824
|
+
if (officeDocuments.length === 0) {
|
|
825
|
+
ctx.add("V005", partLocation(rootRels.partName), "no officeDocument relationship. Nothing names the main presentation part.");
|
|
826
|
+
return;
|
|
827
|
+
}
|
|
828
|
+
if (officeDocuments.length > 1) for (const rel of officeDocuments.slice(1)) ctx.add("V005", elementLocation(rootRels.partName, rel.element), "a second officeDocument relationship (" + rel.id + " -> " + rel.target + "). Exactly one part is the main part.");
|
|
829
|
+
const main = officeDocuments[0];
|
|
830
|
+
const resolved = resolveTarget(rootRels.source, main.target);
|
|
831
|
+
if (resolved.part === null) {
|
|
832
|
+
ctx.add("V005", elementLocation(rootRels.partName, main.element), "the officeDocument target does not resolve: " + (resolved.failure ?? "unknown reason"));
|
|
833
|
+
return;
|
|
834
|
+
}
|
|
835
|
+
if (!ctx.store.has(resolved.part)) {
|
|
836
|
+
ctx.add("V005", elementLocation(rootRels.partName, main.element), "the officeDocument target " + resolved.part + " is not in the package.");
|
|
837
|
+
return;
|
|
838
|
+
}
|
|
839
|
+
const contentType = ctx.contentType(resolved.part);
|
|
840
|
+
if (contentType === void 0 || !MAIN_PART_TYPES.has(contentType)) ctx.add("V005", partLocation(resolved.part), "the main part is typed " + (contentType === void 0 ? "(nothing)" : "\"" + contentType + "\"") + ", which is not a PresentationML main-part type. PowerPoint also checks this against the file extension and refuses the pair when they disagree, which is why a macro-enabled deck has to be written .pptm.");
|
|
841
|
+
}
|
|
842
|
+
//#endregion
|
|
843
|
+
//#region src/rules/relationships.ts
|
|
844
|
+
/**
|
|
845
|
+
* `V006` … `V009`: the relationship graph.
|
|
846
|
+
*
|
|
847
|
+
* The graph is where a `.pptx` keeps almost everything that is not text. A
|
|
848
|
+
* slide does not name its layout, a picture does not name its image, and a
|
|
849
|
+
* chart does not name its workbook: each of them names an `rId`, and the `rId`
|
|
850
|
+
* means something only inside one `.rels` part. Three consequences run through
|
|
851
|
+
* all four rules below.
|
|
852
|
+
*
|
|
853
|
+
* **Ids are scoped, not global.** `rId3` in `slide1.xml.rels` and `rId3` in
|
|
854
|
+
* `slide2.xml.rels` are unrelated. A global id registry is not an optimisation,
|
|
855
|
+
* it is a bug, and it is a bug that works fine on every one-slide test deck.
|
|
856
|
+
*
|
|
857
|
+
* **Dangling is fatal; orphaned is harmless.** A reference to a relationship
|
|
858
|
+
* that is not there, or a relationship to a part that is not there, breaks the
|
|
859
|
+
* file. A relationship nothing references does not. That asymmetry is what
|
|
860
|
+
* decides the direction media garbage collection is allowed to be aggressive
|
|
861
|
+
* in, and getting it backwards is how a "clean-up" pass deletes the layouts a
|
|
862
|
+
* user was about to switch to.
|
|
863
|
+
*
|
|
864
|
+
* **Targets resolve against the source part's folder.** Not the package root,
|
|
865
|
+
* and not the `.rels` file's own folder.
|
|
866
|
+
*/
|
|
867
|
+
/**
|
|
868
|
+
* Attributes in the relationship namespace that name a relationship id.
|
|
869
|
+
*
|
|
870
|
+
* All of them do, in practice - `r:id`, `r:embed`, `r:link`, `r:pict`,
|
|
871
|
+
* `r:dm`, `r:lo`, `r:qs`, `r:cs`, `r:href` and the rest. Rather than list the
|
|
872
|
+
* ones we know, this rule matches on the *namespace*, so an attribute from a
|
|
873
|
+
* schema we have not read yet is checked rather than skipped. The two things
|
|
874
|
+
* that must not be treated as ids are handled explicitly below.
|
|
875
|
+
*/
|
|
876
|
+
function isRelationshipReference(owner, attr) {
|
|
877
|
+
if (attr.prefix === "" || attr.prefix === "xmlns") return false;
|
|
878
|
+
return attributeNamespaceOf(owner, attr) === NS.r;
|
|
879
|
+
}
|
|
880
|
+
/** Every `r:*` reference resolves in its own part's `.rels`. */
|
|
881
|
+
function v006ReferencesResolve(ctx) {
|
|
882
|
+
const relsBySource = bySource(readRelsParts(ctx));
|
|
883
|
+
for (const part of ctx.parts()) {
|
|
884
|
+
if (isRelationshipPartName(part)) continue;
|
|
885
|
+
const document = ctx.document(part);
|
|
886
|
+
if (document === null) continue;
|
|
887
|
+
const rels = relsBySource.get(normalizePartName(part));
|
|
888
|
+
const known = new Set((rels?.relationships ?? []).map((rel) => rel.id));
|
|
889
|
+
for (const element of descendantElements(document.root)) for (const attr of element.attributes) {
|
|
890
|
+
if (!isRelationshipReference(element, attr)) continue;
|
|
891
|
+
if (attr.value === "") continue;
|
|
892
|
+
if (known.has(attr.value)) continue;
|
|
893
|
+
ctx.add("V006", attributeLocation(part, element, attr.qname), rels === void 0 ? "<" + element.qname + " " + attr.qname + "=\"" + attr.value + "\"> refers to a relationship, but this part has no .rels at all." : "<" + element.qname + " " + attr.qname + "=\"" + attr.value + "\"> names a relationship that " + rels.partName + " does not declare. Ids are scoped to one .rels part; this one has " + (known.size === 0 ? "none" : [...known].sort().join(", ")) + ".");
|
|
894
|
+
}
|
|
895
|
+
}
|
|
896
|
+
}
|
|
897
|
+
/** Relationship ids are unique within a `.rels` and are `xsd:ID`s. */
|
|
898
|
+
function v007RelationshipIds(ctx) {
|
|
899
|
+
for (const rels of readRelsParts(ctx)) {
|
|
900
|
+
const seen = /* @__PURE__ */ new Map();
|
|
901
|
+
for (const rel of rels.relationships) {
|
|
902
|
+
if (rel.id === "") {
|
|
903
|
+
ctx.add("V007", elementLocation(rels.partName, rel.element), "a <Relationship> has no Id.");
|
|
904
|
+
continue;
|
|
905
|
+
}
|
|
906
|
+
if (!isValidRelationshipId(rel.id)) ctx.add("V007", elementLocation(rels.partName, rel.element), "Id=\"" + rel.id + "\" is not an xsd:ID. An xsd:ID is an XML NCName: it cannot begin with a digit and cannot contain a space or a colon.");
|
|
907
|
+
const count = (seen.get(rel.id) ?? 0) + 1;
|
|
908
|
+
seen.set(rel.id, count);
|
|
909
|
+
if (count === 2) ctx.add("V007", elementLocation(rels.partName, rel.element), "Id=\"" + rel.id + "\" appears twice. Which target a reference to it means would be undecidable.");
|
|
910
|
+
}
|
|
911
|
+
}
|
|
912
|
+
}
|
|
913
|
+
/** Internal targets resolve, stay inside the package, and name a part that exists. */
|
|
914
|
+
function v008TargetsResolve(ctx) {
|
|
915
|
+
for (const rels of readRelsParts(ctx)) for (const rel of rels.relationships) {
|
|
916
|
+
if (rel.targetMode === "External") continue;
|
|
917
|
+
if (rel.targetMode !== "Internal") {
|
|
918
|
+
ctx.add("V008", elementLocation(rels.partName, rel.element), "TargetMode=\"" + rel.targetMode + "\" is neither Internal nor External.");
|
|
919
|
+
continue;
|
|
920
|
+
}
|
|
921
|
+
if (rel.target === "") {
|
|
922
|
+
ctx.add("V008", elementLocation(rels.partName, rel.element), rel.id + " has an empty Target.");
|
|
923
|
+
continue;
|
|
924
|
+
}
|
|
925
|
+
const resolved = resolveTarget(rels.source, rel.target);
|
|
926
|
+
if (resolved.part === null) {
|
|
927
|
+
ctx.add("V008", elementLocation(rels.partName, rel.element), rel.id + " -> \"" + rel.target + "\" does not resolve against " + rels.source + ": " + (resolved.failure ?? "unknown reason"));
|
|
928
|
+
continue;
|
|
929
|
+
}
|
|
930
|
+
if (!ctx.store.has(resolved.part)) ctx.add("V008", elementLocation(rels.partName, rel.element), rel.id + " -> " + resolved.part + " names a part that is not in the package. A dangling relationship is fatal where an orphaned one is harmless, which is why garbage collection may only ever be aggressive in the other direction.");
|
|
931
|
+
}
|
|
932
|
+
}
|
|
933
|
+
/** The chartex content type. Not in `CONTENT_TYPE`: it is Microsoft's, not ECMA's. */
|
|
934
|
+
const CHART_EX_CONTENT_TYPE = "application/vnd.ms-office.chartex+xml";
|
|
935
|
+
/**
|
|
936
|
+
* The ECMA `customXml` relationship type.
|
|
937
|
+
*
|
|
938
|
+
* A `p:contentPart` reached by *this* type is a whole-package refusal, while
|
|
939
|
+
* the Microsoft 2010 type of the same name opens - and so do two relationship
|
|
940
|
+
* types that make no sense at all. PowerPoint validates that one type rather
|
|
941
|
+
* than types in general, which is a strange enough finding that it is worth
|
|
942
|
+
* writing the constant down beside the rule that uses it.
|
|
943
|
+
*/
|
|
944
|
+
const REL_CUSTOM_XML_ECMA = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/customXml";
|
|
945
|
+
/** Elements whose `r:` attribute means "this is a media object's target". */
|
|
946
|
+
const MEDIA_REFERENCE_ELEMENTS = /* @__PURE__ */ new Set([
|
|
947
|
+
"audioFile",
|
|
948
|
+
"videoFile",
|
|
949
|
+
"media"
|
|
950
|
+
]);
|
|
951
|
+
/**
|
|
952
|
+
* The edges a part must have, and the ones it must not.
|
|
953
|
+
*
|
|
954
|
+
* Four findings, none of them in any schema. See the rule's `why` in
|
|
955
|
+
* `rules.ts` for the bisections; the code below is deliberately a table read
|
|
956
|
+
* against the package rather than four separate walks.
|
|
957
|
+
*/
|
|
958
|
+
function v009RequiredEdges(ctx) {
|
|
959
|
+
const relsBySource = bySource(readRelsParts(ctx));
|
|
960
|
+
for (const part of ctx.parts()) {
|
|
961
|
+
if (isRelationshipPartName(part)) continue;
|
|
962
|
+
const contentType = ctx.contentType(part);
|
|
963
|
+
const rels = relsBySource.get(normalizePartName(part));
|
|
964
|
+
if (contentType === CONTENT_TYPE.slide) requireExactlyOne(ctx, part, rels, REL_TYPE.slideLayout, CONTENT_TYPE.slideLayout, "layout");
|
|
965
|
+
else if (contentType === CONTENT_TYPE.slideLayout) requireExactlyOne(ctx, part, rels, REL_TYPE.slideMaster, CONTENT_TYPE.slideMaster, "master");
|
|
966
|
+
else if (contentType === CHART_EX_CONTENT_TYPE) for (const [type, label] of [["http://schemas.microsoft.com/office/2011/relationships/chartStyle", "chartStyle"], ["http://schemas.microsoft.com/office/2011/relationships/chartColorStyle", "chartColorStyle"]]) {
|
|
967
|
+
if ((rels?.relationships ?? []).some((rel) => rel.type === type)) continue;
|
|
968
|
+
ctx.add("V009", partLocation(part), "a chartex part with no " + label + " relationship. Unlike a classic c:chartSpace, which needs neither, a cx:chartSpace without both is a whole-package refusal.");
|
|
969
|
+
}
|
|
970
|
+
}
|
|
971
|
+
for (const part of ctx.parts()) {
|
|
972
|
+
if (isRelationshipPartName(part)) continue;
|
|
973
|
+
const document = ctx.document(part);
|
|
974
|
+
if (document === null) continue;
|
|
975
|
+
const rels = relsBySource.get(normalizePartName(part));
|
|
976
|
+
if (rels === void 0) continue;
|
|
977
|
+
for (const element of descendantElements(document.root)) {
|
|
978
|
+
if (element.local !== "contentPart") continue;
|
|
979
|
+
for (const attr of element.attributes) {
|
|
980
|
+
if (attr.local !== "id" || attr.prefix === "") continue;
|
|
981
|
+
if (rels.relationships.find((rel) => rel.id === attr.value)?.type !== REL_CUSTOM_XML_ECMA) continue;
|
|
982
|
+
ctx.add("V009", attributeLocation(part, element, attr.qname), "a <" + element.qname + "> reached by the ECMA customXml relationship type is a whole-package refusal. The Microsoft 2010 type of the same name opens; PowerPoint validates this one type rather than types in general.");
|
|
983
|
+
}
|
|
984
|
+
}
|
|
985
|
+
}
|
|
986
|
+
checkSharedMedia(ctx, relsBySource);
|
|
987
|
+
}
|
|
988
|
+
function requireExactlyOne(ctx, part, rels, type, targetContentType, label) {
|
|
989
|
+
const matching = (rels?.relationships ?? []).filter((rel) => rel.type === type);
|
|
990
|
+
if (matching.length !== 1) {
|
|
991
|
+
ctx.add("V009", partLocation(part), "has " + String(matching.length) + " " + label + " relationship(s); it needs exactly one. The binding lives only in the .rels part - nothing in the part itself names its " + label + " - so zero and two are both whole-package refusals.");
|
|
992
|
+
return;
|
|
993
|
+
}
|
|
994
|
+
const rel = matching[0];
|
|
995
|
+
const resolved = resolveTarget(rels.source, rel.target);
|
|
996
|
+
if (resolved.part === null || !ctx.store.has(resolved.part)) {
|
|
997
|
+
ctx.add("V009", elementLocation(rels.partName, rel.element), "the " + label + " relationship points at a part that is not in the package.");
|
|
998
|
+
return;
|
|
999
|
+
}
|
|
1000
|
+
const actual = ctx.contentType(resolved.part);
|
|
1001
|
+
if (actual !== targetContentType) ctx.add("V009", elementLocation(rels.partName, rel.element), "the " + label + " relationship resolves to " + resolved.part + ", which is typed " + (actual === void 0 ? "(nothing)" : "\"" + actual + "\"") + " rather than \"" + targetContentType + "\". One pointing at a master instead was built as a single change and refused.");
|
|
1002
|
+
}
|
|
1003
|
+
/**
|
|
1004
|
+
* A media part that is both a transition sound and a media object's target.
|
|
1005
|
+
*
|
|
1006
|
+
* The only refusal we have measured whose message is "PowerPoint could not open
|
|
1007
|
+
* the file" rather than "the file or directory is corrupted and unreadable" -
|
|
1008
|
+
* which is worth recording, because the message is the only signal there is.
|
|
1009
|
+
* Two copies of the same bytes open; one shared copy does not.
|
|
1010
|
+
*/
|
|
1011
|
+
function checkSharedMedia(ctx, relsBySource) {
|
|
1012
|
+
const asTransitionSound = /* @__PURE__ */ new Map();
|
|
1013
|
+
const asMediaObject = /* @__PURE__ */ new Map();
|
|
1014
|
+
for (const part of ctx.parts()) {
|
|
1015
|
+
if (isRelationshipPartName(part)) continue;
|
|
1016
|
+
const document = ctx.document(part);
|
|
1017
|
+
if (document === null) continue;
|
|
1018
|
+
const rels = relsBySource.get(normalizePartName(part));
|
|
1019
|
+
if (rels === void 0) continue;
|
|
1020
|
+
for (const element of descendantElements(document.root)) {
|
|
1021
|
+
const isSound = element.local === "snd";
|
|
1022
|
+
const isMedia = MEDIA_REFERENCE_ELEMENTS.has(element.local);
|
|
1023
|
+
if (!isSound && !isMedia) continue;
|
|
1024
|
+
for (const attr of element.attributes) {
|
|
1025
|
+
if (attr.prefix === "" || attr.value === "") continue;
|
|
1026
|
+
if (attr.local !== "embed" && attr.local !== "link") continue;
|
|
1027
|
+
const rel = rels.relationships.find((r) => r.id === attr.value);
|
|
1028
|
+
if (rel === void 0 || rel.targetMode === "External") continue;
|
|
1029
|
+
const target = resolveTarget(rels.source, rel.target).part;
|
|
1030
|
+
if (target === null) continue;
|
|
1031
|
+
(isSound ? asTransitionSound : asMediaObject).set(normalizePartName(target), part);
|
|
1032
|
+
}
|
|
1033
|
+
}
|
|
1034
|
+
}
|
|
1035
|
+
for (const [target, soundOwner] of asTransitionSound) {
|
|
1036
|
+
const mediaOwner = asMediaObject.get(target);
|
|
1037
|
+
if (mediaOwner === void 0) continue;
|
|
1038
|
+
ctx.add("V009", partLocation(target), "is both a transition sound (from " + soundOwner + ") and a media object target (from " + mediaOwner + "). Two copies of the same bytes open; one shared copy is a refusal.");
|
|
1039
|
+
}
|
|
1040
|
+
}
|
|
1041
|
+
//#endregion
|
|
1042
|
+
//#region src/rules/order.ts
|
|
1043
|
+
/**
|
|
1044
|
+
* `V010` … `V012`: where children are allowed to be.
|
|
1045
|
+
*
|
|
1046
|
+
* OOXML complex types are `xsd:sequence` almost everywhere, and this is the
|
|
1047
|
+
* category with the best-attested consequence in the whole appendix: Microsoft's
|
|
1048
|
+
* own Open XML SDK shipped a regression that swapped two elements in
|
|
1049
|
+
* `slideMaster1.xml`, changed nothing else, and PowerPoint refused the file.
|
|
1050
|
+
*
|
|
1051
|
+
* The ranks come from `@pptx-studio/xml`'s generated table, which is built from
|
|
1052
|
+
* the ECMA-376 Transitional schemas and checked against 194 148 elements
|
|
1053
|
+
* PowerPoint wrote. That second number is what makes the rule safe to make
|
|
1054
|
+
* fatal: a table generated from a standard and never compared with reality
|
|
1055
|
+
* would be a very confident way to reject files that work.
|
|
1056
|
+
*
|
|
1057
|
+
* The three rules divide the question by what the table can and cannot see:
|
|
1058
|
+
*
|
|
1059
|
+
* - `V010` - the child is in the model but in the wrong place.
|
|
1060
|
+
* - `V011` - `extLst` last, which has to hold in the places the table cannot
|
|
1061
|
+
* rank at all.
|
|
1062
|
+
* - `V012` - the child is not in the model.
|
|
1063
|
+
*
|
|
1064
|
+
* `V010` skips anything it cannot rank, which is correct for ordering and is
|
|
1065
|
+
* exactly the blind spot `V012` exists to cover.
|
|
1066
|
+
*/
|
|
1067
|
+
/** Children in schema-sequence order. */
|
|
1068
|
+
function v010SchemaOrder(ctx) {
|
|
1069
|
+
forEachElement$1(ctx, (part, element) => {
|
|
1070
|
+
for (const problem of outOfOrderChildren(element)) ctx.add("V010", {
|
|
1071
|
+
part,
|
|
1072
|
+
xpath: xpathOf(element),
|
|
1073
|
+
offset: problem.at
|
|
1074
|
+
}, problem.detail + ". OOXML complex types are xsd:sequence and PowerPoint enforces the order literally.");
|
|
1075
|
+
});
|
|
1076
|
+
}
|
|
1077
|
+
/**
|
|
1078
|
+
* `extLst` is last, and every `a:ext` names its extension.
|
|
1079
|
+
*
|
|
1080
|
+
* There is exactly one type in the whole generated table where `extLst` is not
|
|
1081
|
+
* last: `p:control`, whose model is `extLst` then `pic`. That is not an
|
|
1082
|
+
* exception worth handling gracefully - `V025` refuses `p:control` outright,
|
|
1083
|
+
* because PowerPoint refuses it in all eight forms we tried - but it is skipped
|
|
1084
|
+
* here rather than reported twice.
|
|
1085
|
+
*/
|
|
1086
|
+
function v011ExtLstLast(ctx) {
|
|
1087
|
+
forEachElement$1(ctx, (part, element) => {
|
|
1088
|
+
if (element.local !== "extLst") return;
|
|
1089
|
+
const parent = element.parent;
|
|
1090
|
+
if (parent === void 0 || parent.local === "control") return;
|
|
1091
|
+
const siblings = childElements(parent);
|
|
1092
|
+
const last = siblings[siblings.length - 1];
|
|
1093
|
+
if (last !== element) ctx.add("V011", elementLocation(part, element), "<" + element.qname + "> is followed by <" + (last?.qname ?? "?") + "> inside <" + parent.qname + ">. An extension list is last in every type that has one.");
|
|
1094
|
+
for (const ext of childElements(element)) {
|
|
1095
|
+
if (ext.local !== "ext") continue;
|
|
1096
|
+
const uri = attributeValue(ext, "uri");
|
|
1097
|
+
if (uri !== void 0 && uri !== "") continue;
|
|
1098
|
+
ctx.add("V011", elementLocation(part, ext), "<" + ext.qname + "> has no @uri. The uri is the only identity an extension has, and the contract for extension markup is that it is carried through untouched *keyed by that uri* - so one without it cannot be preserved, only guessed at.");
|
|
1099
|
+
}
|
|
1100
|
+
});
|
|
1101
|
+
}
|
|
1102
|
+
/**
|
|
1103
|
+
* No child in a parent whose content model has no place for it.
|
|
1104
|
+
*
|
|
1105
|
+
* Deliberately conservative in four ways, because a false positive here refuses
|
|
1106
|
+
* a file somebody wanted:
|
|
1107
|
+
*
|
|
1108
|
+
* - only parents the generated table knows are checked, and `childRanks`
|
|
1109
|
+
* returns `undefined` for every open (`xsd:any`) type, so `a:ext`,
|
|
1110
|
+
* `p:ext` and `a:graphicData` - the three places the plan forbids us to
|
|
1111
|
+
* rebuild - are never looked at;
|
|
1112
|
+
* - **only children in the four namespaces the table was generated from.**
|
|
1113
|
+
* This is the one the corpus taught us. `childRanks` collapses "the schema
|
|
1114
|
+
* forbids this here" and "we have never read this schema" into the same
|
|
1115
|
+
* `undefined`, which is right for a caller trying to insert a child and
|
|
1116
|
+
* catastrophic for one trying to judge an existing one: without the
|
|
1117
|
+
* namespace test, the rule fired on forty `mc:AlternateContent` elements,
|
|
1118
|
+
* on `a14:m`, on `p14:honeycomb` and on `a37-mce`'s deliberate `zz:` markup -
|
|
1119
|
+
* which is `mc:Ignorable` extension markup doing exactly what it exists for;
|
|
1120
|
+
* - `mc:Choice` and `mc:Fallback` are unrankable parents, so their contents
|
|
1121
|
+
* fall out for free.
|
|
1122
|
+
*/
|
|
1123
|
+
function v012UnexpectedChild(ctx) {
|
|
1124
|
+
forEachElement$1(ctx, (part, element) => {
|
|
1125
|
+
const ranks = childRanks(element);
|
|
1126
|
+
if (ranks === void 0) return;
|
|
1127
|
+
for (const child of childElements(element)) {
|
|
1128
|
+
if (!isSchemaNamespace(namespaceOf(child))) continue;
|
|
1129
|
+
const key = elementKey(child);
|
|
1130
|
+
if (key === void 0 || ranks.has(key)) continue;
|
|
1131
|
+
ctx.add("V012", elementLocation(part, child), "<" + child.qname + "> is not a child <" + element.qname + "> admits. `a:ahXY` or `a:cxn` placed directly under `a:custGeom`, without its `a:ahLst`/`a:cxnLst` wrapper, is the case that made this a rule: a whole-package refusal that the ordering rule cannot see, because an unrankable child is skipped there rather than reported.");
|
|
1132
|
+
}
|
|
1133
|
+
});
|
|
1134
|
+
}
|
|
1135
|
+
/**
|
|
1136
|
+
* Every element of every XML part, once.
|
|
1137
|
+
*
|
|
1138
|
+
* The three rules above each want the same walk. Sharing it means one traversal
|
|
1139
|
+
* of the package instead of three, and - more usefully - one place where the
|
|
1140
|
+
* decision "which parts does an order rule apply to" is made. Relationship
|
|
1141
|
+
* parts are excluded: `Relationships` is not in any schema we generate from, so
|
|
1142
|
+
* every one of its children would be unrankable and the rules would be checking
|
|
1143
|
+
* nothing while appearing to check everything.
|
|
1144
|
+
*/
|
|
1145
|
+
function forEachElement$1(ctx, visit) {
|
|
1146
|
+
for (const part of ctx.parts()) {
|
|
1147
|
+
if (isRelationshipPartName(part)) continue;
|
|
1148
|
+
const document = ctx.document(part);
|
|
1149
|
+
if (document === null) continue;
|
|
1150
|
+
for (const element of descendantElements(document.root)) visit(part, element);
|
|
1151
|
+
}
|
|
1152
|
+
}
|
|
1153
|
+
//#endregion
|
|
1154
|
+
//#region src/rules/required.ts
|
|
1155
|
+
/**
|
|
1156
|
+
* `V013` … `V017`: children and attributes that are not optional.
|
|
1157
|
+
*
|
|
1158
|
+
* Every one of these is a `minOccurs` the schema states plainly, and every one
|
|
1159
|
+
* is broken the same way: by an editor deleting the last of something. The last
|
|
1160
|
+
* paragraph goes and a `p:txBody` is left with no `a:p`; a shape is dragged out
|
|
1161
|
+
* of a group and the group's `p:grpSpPr` is dropped with it. That is why these
|
|
1162
|
+
* are separate rules from the ordering ones even though a missing child and a
|
|
1163
|
+
* misplaced child are both "the sequence is wrong": the messages have to say
|
|
1164
|
+
* *add this*, not *move this*, and they have to fire on the parent rather than
|
|
1165
|
+
* on a child that is not there to point at.
|
|
1166
|
+
*/
|
|
1167
|
+
/** The twelve attributes of `CT_ColorMapping`. All required, no defaults. */
|
|
1168
|
+
const CLR_MAP_ATTRIBUTES = [
|
|
1169
|
+
"bg1",
|
|
1170
|
+
"tx1",
|
|
1171
|
+
"bg2",
|
|
1172
|
+
"tx2",
|
|
1173
|
+
"accent1",
|
|
1174
|
+
"accent2",
|
|
1175
|
+
"accent3",
|
|
1176
|
+
"accent4",
|
|
1177
|
+
"accent5",
|
|
1178
|
+
"accent6",
|
|
1179
|
+
"hlink",
|
|
1180
|
+
"folHlink"
|
|
1181
|
+
];
|
|
1182
|
+
/** `p:presentation` carries `p:notesSz`. */
|
|
1183
|
+
function v013NotesSize(ctx) {
|
|
1184
|
+
for (const part of ctx.parts()) {
|
|
1185
|
+
const document = ctx.document(part);
|
|
1186
|
+
if (document === null) continue;
|
|
1187
|
+
const root = document.root;
|
|
1188
|
+
if (root.local !== "presentation" || namespaceOf(root) !== NS.p) continue;
|
|
1189
|
+
if (childElements(root).some((child) => child.local === "notesSz")) continue;
|
|
1190
|
+
ctx.add("V013", elementLocation(part, root), "no <p:notesSz>. In CT_Presentation p:sldSz is [0..1] and p:notesSz is [1..1] - the asymmetry is the whole of this rule, and a generator that treats the two the same way writes a presentation part that is missing a required child.");
|
|
1191
|
+
}
|
|
1192
|
+
}
|
|
1193
|
+
/** `p:clrMap` carries all twelve attributes. */
|
|
1194
|
+
function v014ColorMap(ctx) {
|
|
1195
|
+
forEachElement(ctx, (part, element) => {
|
|
1196
|
+
if (element.local !== "clrMap" || namespaceOf(element) !== NS.p) return;
|
|
1197
|
+
const missing = CLR_MAP_ATTRIBUTES.filter((name) => attribute(element, name) === void 0);
|
|
1198
|
+
if (missing.length === 0) return;
|
|
1199
|
+
ctx.add("V014", elementLocation(part, element), "the colour map is missing " + missing.join(", ") + ". All twelve attributes of CT_ColorMapping are required and none has a default; a partial map is not a map with a gap, it is an invalid element - and it is the element that decides what bg1 and tx1 resolve to everywhere in the deck.");
|
|
1200
|
+
});
|
|
1201
|
+
}
|
|
1202
|
+
/** `p:spTree` begins with `p:nvGrpSpPr` then `p:grpSpPr`. */
|
|
1203
|
+
function v015ShapeTreePrologue(ctx) {
|
|
1204
|
+
forEachElement(ctx, (part, element) => {
|
|
1205
|
+
if (element.local !== "spTree" || namespaceOf(element) !== NS.p) return;
|
|
1206
|
+
const children = childElements(element);
|
|
1207
|
+
if (children[0]?.local !== "nvGrpSpPr" || children[1]?.local !== "grpSpPr") ctx.add("V015", elementLocation(part, element), "a shape tree begins with <nvGrpSpPr> then <grpSpPr>, in that order, before any shape. This one begins " + (children.length === 0 ? "with nothing" : children.slice(0, 2).map((child) => "<" + child.qname + ">").join(" then ")) + ". A shape tree is a group shape, and CT_GroupShape requires both even when the slide is empty.");
|
|
1208
|
+
});
|
|
1209
|
+
}
|
|
1210
|
+
/** Every text body has `a:bodyPr` and at least one `a:p`. */
|
|
1211
|
+
function v016TextBody(ctx) {
|
|
1212
|
+
forEachElement(ctx, (part, element) => {
|
|
1213
|
+
if (element.local !== "txBody") return;
|
|
1214
|
+
const children = childElements(element);
|
|
1215
|
+
if (!children.some((child) => child.local === "bodyPr")) ctx.add("V016", elementLocation(part, element), "<" + element.qname + "> has no <a:bodyPr>. CT_TextBody requires it first.");
|
|
1216
|
+
if (!children.some((child) => child.local === "p")) ctx.add("V016", elementLocation(part, element), "<" + element.qname + "> has no <a:p>. CT_TextBody is [1..unbounded] paragraphs, and an empty paragraph is not the same thing as no paragraph - a:endParaRPr is where an empty one gets its height. This is what deleting the last paragraph in an editor produces.");
|
|
1217
|
+
});
|
|
1218
|
+
}
|
|
1219
|
+
/** `p:graphicFrame` carries `p:xfrm` and `a:graphic`. */
|
|
1220
|
+
function v017GraphicFrame(ctx) {
|
|
1221
|
+
forEachElement(ctx, (part, element) => {
|
|
1222
|
+
if (element.local !== "graphicFrame" || namespaceOf(element) !== NS.p) return;
|
|
1223
|
+
const xfrm = childElements(element).find((child) => child.local === "xfrm");
|
|
1224
|
+
if (xfrm === void 0) ctx.add("V017", elementLocation(part, element), "no <p:xfrm>. Note the namespace: a graphic frame carries the PresentationML transform, not the a:xfrm every other shape uses. It has no placeholder-inherited geometry path either, which is why a rebound chart or table gets a copy of its layout transform rather than having its own deleted.");
|
|
1225
|
+
else if (namespaceOf(xfrm) !== NS.p) ctx.add("V017", elementLocation(part, xfrm), "the transform here is <" + xfrm.qname + ">, in the DrawingML namespace. CT_GraphicalObjectFrame takes p:xfrm; a:xfrm in its place is the same element name bound to the wrong schema.");
|
|
1226
|
+
if (childElements(element).find((child) => child.local === "graphic" && namespaceOf(child) === NS.a) === void 0) ctx.add("V017", elementLocation(part, element), "no <a:graphic>. The frame is the wrapper; the graphic is the thing in it, and a frame without one names no table, chart, diagram or OLE object at all.");
|
|
1227
|
+
});
|
|
1228
|
+
}
|
|
1229
|
+
/**
|
|
1230
|
+
* Presentation-family parts only.
|
|
1231
|
+
*
|
|
1232
|
+
* A `.pptx` carries whole foreign documents inside it - an embedded workbook
|
|
1233
|
+
* behind every chart, a Word document behind some OLE objects - and their parts
|
|
1234
|
+
* are XML with elements called `txBody` and `graphicFrame` in namespaces these
|
|
1235
|
+
* rules do not govern. Checking them would be applying PresentationML's
|
|
1236
|
+
* cardinalities to SpreadsheetML's schema, which is why every rule above tests
|
|
1237
|
+
* the namespace and not only the local name, and why the walk itself stops at
|
|
1238
|
+
* parts whose content type belongs to another format.
|
|
1239
|
+
*/
|
|
1240
|
+
const FOREIGN_CONTENT_TYPES = /* @__PURE__ */ new Set([
|
|
1241
|
+
CONTENT_TYPE.spreadsheet,
|
|
1242
|
+
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
|
1243
|
+
"application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml",
|
|
1244
|
+
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.main+xml"
|
|
1245
|
+
]);
|
|
1246
|
+
function forEachElement(ctx, visit) {
|
|
1247
|
+
for (const part of ctx.parts()) {
|
|
1248
|
+
if (isRelationshipPartName(part)) continue;
|
|
1249
|
+
const contentType = ctx.contentType(part);
|
|
1250
|
+
if (contentType !== void 0 && FOREIGN_CONTENT_TYPES.has(contentType)) continue;
|
|
1251
|
+
const document = ctx.document(part);
|
|
1252
|
+
if (document === null) continue;
|
|
1253
|
+
for (const element of descendantElements(document.root)) visit(part, element);
|
|
1254
|
+
}
|
|
1255
|
+
}
|
|
1256
|
+
//#endregion
|
|
1257
|
+
//#region src/rules/id.ts
|
|
1258
|
+
/**
|
|
1259
|
+
* `V018` … `V021`: four identifier spaces, and never one allocator.
|
|
1260
|
+
*
|
|
1261
|
+
* A presentation has four kinds of id and they do not share a rule between
|
|
1262
|
+
* them. Slide ids start at 256 and stop at 2147483647. Master and layout ids
|
|
1263
|
+
* start at 2147483648, and - the part no schema says - come out of **one**
|
|
1264
|
+
* counter shared between them. Shape ids are unique inside a part and free to
|
|
1265
|
+
* repeat across parts. Placeholder indices are not identifiers at all; they are
|
|
1266
|
+
* a join key against another part.
|
|
1267
|
+
*
|
|
1268
|
+
* Every one of those is a way to break a file that looks like a way to be
|
|
1269
|
+
* tidy. Renumbering shape ids to be unique across the deck is the obvious
|
|
1270
|
+
* example: it is more consistent, it is what a database would do, and it
|
|
1271
|
+
* detaches every `p:custDataLst`, VML `@spid` and animation target that names
|
|
1272
|
+
* the old number.
|
|
1273
|
+
*/
|
|
1274
|
+
const SLIDE_ID_MIN = 256;
|
|
1275
|
+
const SLIDE_ID_MAX = 2147483647;
|
|
1276
|
+
const SHEET_ID_MIN = 2147483648;
|
|
1277
|
+
const SHEET_ID_MAX = 4294967295;
|
|
1278
|
+
/** An `@id` read as a base-10 integer, or `null` when it is not one. */
|
|
1279
|
+
function integerAttribute(element, name) {
|
|
1280
|
+
const raw = attributeValue(element, name);
|
|
1281
|
+
if (raw === void 0 || !/^\d+$/.test(raw.trim())) return null;
|
|
1282
|
+
return Number(raw.trim());
|
|
1283
|
+
}
|
|
1284
|
+
/** `p:sldId/@id` is 256…2147483647 and unique. */
|
|
1285
|
+
function v018SlideIds(ctx) {
|
|
1286
|
+
for (const part of ctx.parts()) {
|
|
1287
|
+
const document = ctx.document(part);
|
|
1288
|
+
if (document === null) continue;
|
|
1289
|
+
const seen = /* @__PURE__ */ new Map();
|
|
1290
|
+
for (const element of descendantElements(document.root)) {
|
|
1291
|
+
if (element.local !== "sldId" || namespaceOf(element) !== NS.p) continue;
|
|
1292
|
+
const raw = attributeValue(element, "id");
|
|
1293
|
+
const value = integerAttribute(element, "id");
|
|
1294
|
+
if (value === null) {
|
|
1295
|
+
ctx.add("V018", attributeLocation(part, element, "id"), raw === void 0 ? "<p:sldId> has no @id." : "@id=\"" + raw + "\" is not a base-10 unsigned integer.");
|
|
1296
|
+
continue;
|
|
1297
|
+
}
|
|
1298
|
+
if (value < SLIDE_ID_MIN || value > SLIDE_ID_MAX) ctx.add("V018", attributeLocation(part, element, "id"), "@id=" + String(value) + " is outside ST_SlideId, which is " + String(SLIDE_ID_MIN) + "…" + String(SLIDE_ID_MAX) + ". The floor is the part that gets missed: nothing about a first slide suggests its id should start at 256.");
|
|
1299
|
+
if (seen.has(value)) ctx.add("V018", attributeLocation(part, element, "id"), "@id=" + String(value) + " is used twice in this slide list.");
|
|
1300
|
+
else seen.set(value, element);
|
|
1301
|
+
}
|
|
1302
|
+
}
|
|
1303
|
+
}
|
|
1304
|
+
/**
|
|
1305
|
+
* Master and layout ids share one number space.
|
|
1306
|
+
*
|
|
1307
|
+
* The uniqueness check runs across the whole package rather than per part,
|
|
1308
|
+
* because `p:sldMasterId` lives in `ppt/presentation.xml` and `p:sldLayoutId`
|
|
1309
|
+
* lives in each master's own `p:sldLayoutIdLst` - so a collision between the
|
|
1310
|
+
* two is invisible to anything that validates one part at a time. That is
|
|
1311
|
+
* exactly how `a12-masters` was written wrong: a chassis numbering masters and
|
|
1312
|
+
* layouts from two counters is indistinguishable from correct with one master
|
|
1313
|
+
* and collides on the second.
|
|
1314
|
+
*/
|
|
1315
|
+
function v019SheetIds(ctx) {
|
|
1316
|
+
const seen = /* @__PURE__ */ new Map();
|
|
1317
|
+
for (const part of ctx.parts()) {
|
|
1318
|
+
const document = ctx.document(part);
|
|
1319
|
+
if (document === null) continue;
|
|
1320
|
+
for (const element of descendantElements(document.root)) {
|
|
1321
|
+
const isMaster = element.local === "sldMasterId";
|
|
1322
|
+
const isLayout = element.local === "sldLayoutId";
|
|
1323
|
+
if (!isMaster && !isLayout || namespaceOf(element) !== NS.p) continue;
|
|
1324
|
+
const raw = attributeValue(element, "id");
|
|
1325
|
+
const value = integerAttribute(element, "id");
|
|
1326
|
+
if (value === null) {
|
|
1327
|
+
if (raw !== void 0) ctx.add("V019", attributeLocation(part, element, "id"), "@id=\"" + raw + "\" is not a base-10 unsigned integer.");
|
|
1328
|
+
continue;
|
|
1329
|
+
}
|
|
1330
|
+
if (value < SHEET_ID_MIN || value > SHEET_ID_MAX) {
|
|
1331
|
+
ctx.add("V019", attributeLocation(part, element, "id"), "@id=" + String(value) + " is outside " + (isMaster ? "ST_SlideMasterId" : "ST_SlideLayoutId") + ", which is " + String(SHEET_ID_MIN) + " and up.");
|
|
1332
|
+
continue;
|
|
1333
|
+
}
|
|
1334
|
+
const previous = seen.get(value);
|
|
1335
|
+
if (previous !== void 0) ctx.add("V019", attributeLocation(part, element, "id"), "@id=" + String(value) + " is already used by " + previous + ". Master and layout ids are one number space, not two: PowerPoint allocates them from a single running counter - master, its layouts, next master, its layouts - and refuses a package that does not.");
|
|
1336
|
+
else seen.set(value, "<" + element.qname + "> in " + part);
|
|
1337
|
+
}
|
|
1338
|
+
}
|
|
1339
|
+
}
|
|
1340
|
+
/**
|
|
1341
|
+
* The `mc:Choice` / `mc:Fallback` ancestors of an element, outermost first.
|
|
1342
|
+
*
|
|
1343
|
+
* Two shapes in different branches of the same `mc:AlternateContent` never
|
|
1344
|
+
* exist in the same document: a consumer picks one branch and the other is not
|
|
1345
|
+
* there. So they may carry the same `@id`, and PowerPoint's own writer does
|
|
1346
|
+
* exactly that - `a22-chartex` has a `p:graphicFrame` with `id="10"` in the
|
|
1347
|
+
* `mc:Choice` and the `p:pic` that stands in for it, also `id="10"`, in the
|
|
1348
|
+
* `mc:Fallback`. Reporting that as a duplicate would fire on files PowerPoint
|
|
1349
|
+
* wrote, which is the corpus's whole job to prevent.
|
|
1350
|
+
*/
|
|
1351
|
+
function branchPath(element) {
|
|
1352
|
+
const path = [];
|
|
1353
|
+
for (let node = element.parent; node !== void 0; node = node.parent) if (node.local === "Choice" || node.local === "Fallback") path.push(node);
|
|
1354
|
+
return path.reverse();
|
|
1355
|
+
}
|
|
1356
|
+
/**
|
|
1357
|
+
* True when two elements can never both be present.
|
|
1358
|
+
*
|
|
1359
|
+
* They cannot when their branch paths diverge at some depth *into two branches
|
|
1360
|
+
* of the same `mc:AlternateContent`*. Diverging into branches of two different
|
|
1361
|
+
* `mc:AlternateContent` elements is not exclusion - both are chosen, and both
|
|
1362
|
+
* sets of shapes are on the slide together.
|
|
1363
|
+
*/
|
|
1364
|
+
function mutuallyExclusive(a, b) {
|
|
1365
|
+
const depth = Math.min(a.length, b.length);
|
|
1366
|
+
for (let i = 0; i < depth; i++) {
|
|
1367
|
+
if (a[i] === b[i]) continue;
|
|
1368
|
+
return a[i].parent === b[i].parent;
|
|
1369
|
+
}
|
|
1370
|
+
return false;
|
|
1371
|
+
}
|
|
1372
|
+
/**
|
|
1373
|
+
* `p:cNvPr/@id` is unique within its part and reads as a positive signed
|
|
1374
|
+
* 32-bit integer.
|
|
1375
|
+
*
|
|
1376
|
+
* PresentationML only, which is narrower than "every element called `cNvPr`"
|
|
1377
|
+
* and had to be. A SmartArt drawing part writes `dsp:cNvPr id="0"` on every
|
|
1378
|
+
* shape it contains - PowerPoint generates those parts and regenerates them on
|
|
1379
|
+
* any diagram interaction, so whatever the ids there are for, it is not
|
|
1380
|
+
* identity. `a23-smartart` is where that turned up.
|
|
1381
|
+
*/
|
|
1382
|
+
function v020ShapeIds(ctx) {
|
|
1383
|
+
for (const part of ctx.parts()) {
|
|
1384
|
+
if (isRelationshipPartName(part)) continue;
|
|
1385
|
+
const document = ctx.document(part);
|
|
1386
|
+
if (document === null) continue;
|
|
1387
|
+
const seen = /* @__PURE__ */ new Map();
|
|
1388
|
+
for (const element of descendantElements(document.root)) {
|
|
1389
|
+
if (element.local !== "cNvPr" || namespaceOf(element) !== NS.p) continue;
|
|
1390
|
+
const raw = attributeValue(element, "id");
|
|
1391
|
+
if (raw === void 0) {
|
|
1392
|
+
ctx.add("V020", elementLocation(part, element), "<" + element.qname + "> has no @id.");
|
|
1393
|
+
continue;
|
|
1394
|
+
}
|
|
1395
|
+
const value = integerAttribute(element, "id");
|
|
1396
|
+
if (value === null) {
|
|
1397
|
+
ctx.add("V020", attributeLocation(part, element, "id"), "@id=\"" + raw + "\" is not a base-10 unsigned integer.");
|
|
1398
|
+
continue;
|
|
1399
|
+
}
|
|
1400
|
+
if (value > SLIDE_ID_MAX && value !== SHEET_ID_MAX) ctx.add("V020", attributeLocation(part, element, "id"), "@id=" + String(value) + " is in 2147483648…4294967294, which PowerPoint refuses outright. ST_DrawingElementId is xsd:unsignedInt and every value up to 2147483647 opens; PowerPoint reads this one signed. (4294967295 also opens - it is minus one, which PowerPoint keeps as a sentinel and renumbers away on save.)");
|
|
1401
|
+
const branch = branchPath(element);
|
|
1402
|
+
const others = seen.get(value) ?? [];
|
|
1403
|
+
const clash = others.find((other) => !mutuallyExclusive(other.branch, branch));
|
|
1404
|
+
if (clash !== void 0) ctx.add("V020", attributeLocation(part, element, "id"), "@id=" + String(value) + " is already used by <" + clash.label + "> in this part. Shape ids are unique within a part and free to repeat across parts - renumbering them to be unique across the deck is tidier and detaches every VML @spid and animation target that names the old number.");
|
|
1405
|
+
others.push({
|
|
1406
|
+
label: element.qname + " " + (attributeValue(element, "name") ?? ""),
|
|
1407
|
+
branch
|
|
1408
|
+
});
|
|
1409
|
+
seen.set(value, others);
|
|
1410
|
+
}
|
|
1411
|
+
}
|
|
1412
|
+
}
|
|
1413
|
+
/**
|
|
1414
|
+
* `CT_Placeholder` defaults, materialised.
|
|
1415
|
+
*
|
|
1416
|
+
* `@type` defaults to `body` and `@idx` to `0` in the ECMA-376 schema, and
|
|
1417
|
+
* `ctrTitle` normalises to `title` because they are the same slot with
|
|
1418
|
+
* different centring. Worth flagging while it is fresh: the plan says
|
|
1419
|
+
* `<p:ph/>` means `type="obj"` in sub-phase 2.9 and `type ??= 'body'` in 7.1,
|
|
1420
|
+
* and those cannot both be right. The schema says `body`, so that is what this
|
|
1421
|
+
* rule uses - and it is one of the reasons the rule is a warning rather than
|
|
1422
|
+
* fatal. Sub-phase 7.1 settles it against the 121-case matrix.
|
|
1423
|
+
*/
|
|
1424
|
+
function placeholdersOf(root) {
|
|
1425
|
+
const out = [];
|
|
1426
|
+
for (const element of descendantElements(root)) {
|
|
1427
|
+
if (element.local !== "ph" || namespaceOf(element) !== NS.p) continue;
|
|
1428
|
+
const rawType = attributeValue(element, "type") ?? "body";
|
|
1429
|
+
const type = rawType === "ctrTitle" ? "title" : rawType;
|
|
1430
|
+
const idx = Number(attributeValue(element, "idx") ?? "0");
|
|
1431
|
+
out.push({
|
|
1432
|
+
type,
|
|
1433
|
+
idx: Number.isFinite(idx) ? idx : 0,
|
|
1434
|
+
element
|
|
1435
|
+
});
|
|
1436
|
+
}
|
|
1437
|
+
return out;
|
|
1438
|
+
}
|
|
1439
|
+
/** Types matched on type alone, ignoring `@idx`. See tier 3. */
|
|
1440
|
+
const TYPE_ONLY = /* @__PURE__ */ new Set([
|
|
1441
|
+
"sldNum",
|
|
1442
|
+
"dt",
|
|
1443
|
+
"ftr",
|
|
1444
|
+
"hdr"
|
|
1445
|
+
]);
|
|
1446
|
+
/** The five tiers, in order. Returns the matching layout placeholder, or `null`. */
|
|
1447
|
+
function matchInLayout(slide, layout) {
|
|
1448
|
+
const exact = layout.find((ph) => ph.type === slide.type && ph.idx === slide.idx);
|
|
1449
|
+
if (exact !== void 0) return exact;
|
|
1450
|
+
if (slide.type === "title") {
|
|
1451
|
+
const anyTitle = layout.find((ph) => ph.type === "title");
|
|
1452
|
+
if (anyTitle !== void 0) return anyTitle;
|
|
1453
|
+
}
|
|
1454
|
+
if (TYPE_ONLY.has(slide.type)) {
|
|
1455
|
+
const byType = layout.find((ph) => ph.type === slide.type);
|
|
1456
|
+
if (byType !== void 0) return byType;
|
|
1457
|
+
}
|
|
1458
|
+
if (slide.type === "body") {
|
|
1459
|
+
const bodies = layout.filter((ph) => ph.type === "body");
|
|
1460
|
+
if (bodies.length === 1) return bodies[0];
|
|
1461
|
+
}
|
|
1462
|
+
return null;
|
|
1463
|
+
}
|
|
1464
|
+
/**
|
|
1465
|
+
* Every slide placeholder finds a counterpart in its layout.
|
|
1466
|
+
*
|
|
1467
|
+
* A warning, and the reason matters: PowerPoint opens the file. A `p:ph` that
|
|
1468
|
+
* matches nothing inherits nothing - no geometry, no text style, no prompt -
|
|
1469
|
+
* and the shape is drawn wherever the renderer can work out, which is a deck
|
|
1470
|
+
* that is silently wrong rather than a deck that is refused. Refusing an export
|
|
1471
|
+
* over it would block a user from saving a file that already opened fine
|
|
1472
|
+
* everywhere, which is the failure mode a validator has to avoid most.
|
|
1473
|
+
*/
|
|
1474
|
+
function v021PlaceholderIndices(ctx) {
|
|
1475
|
+
const relsBySource = bySource(readRelsParts(ctx));
|
|
1476
|
+
for (const part of ctx.parts()) {
|
|
1477
|
+
if (ctx.contentType(part) !== CONTENT_TYPE.slide) continue;
|
|
1478
|
+
const document = ctx.document(part);
|
|
1479
|
+
if (document === null) continue;
|
|
1480
|
+
const rels = relsBySource.get(normalizePartName(part));
|
|
1481
|
+
const layoutRel = (rels?.relationships ?? []).find((rel) => rel.type === REL_TYPE.slideLayout);
|
|
1482
|
+
if (layoutRel === void 0) continue;
|
|
1483
|
+
const layoutPart = resolveTarget(rels.source, layoutRel.target).part;
|
|
1484
|
+
if (layoutPart === null) continue;
|
|
1485
|
+
const layoutDocument = ctx.document(layoutPart);
|
|
1486
|
+
if (layoutDocument === null) continue;
|
|
1487
|
+
const layout = placeholdersOf(layoutDocument.root);
|
|
1488
|
+
for (const slidePh of placeholdersOf(document.root)) {
|
|
1489
|
+
if (matchInLayout(slidePh, layout) !== null) continue;
|
|
1490
|
+
ctx.add("V021", elementLocation(part, slidePh.element), "placeholder (type=" + slidePh.type + ", idx=" + String(slidePh.idx) + ") matches nothing in " + layoutPart + ", which offers " + (layout.length === 0 ? "none" : layout.map((ph) => ph.type + "/" + String(ph.idx)).join(", ")) + ". An unmatched placeholder inherits no geometry and no text style.");
|
|
1491
|
+
}
|
|
1492
|
+
}
|
|
1493
|
+
}
|
|
1494
|
+
//#endregion
|
|
1495
|
+
//#region src/rules/refusal.ts
|
|
1496
|
+
/**
|
|
1497
|
+
* `V022` … `V026`: schema-legal markup PowerPoint refuses.
|
|
1498
|
+
*
|
|
1499
|
+
* This is the category that justifies the package. Nothing here can be derived
|
|
1500
|
+
* from ECMA-376; every rule is the record of a package built with one change in
|
|
1501
|
+
* it, opened, and declined - "PowerPoint could not open the file", or
|
|
1502
|
+
* `0x80070570`, "the file or directory is corrupted and unreadable". No log, no
|
|
1503
|
+
* part named, no line number. Sub-phase 1.5's `cli bisect` exists because that
|
|
1504
|
+
* is the entire diagnostic channel, and these five rules are what it found
|
|
1505
|
+
* before it existed, by hand.
|
|
1506
|
+
*
|
|
1507
|
+
* Which means each of them is falsifiable in a way the schema rules are not: if
|
|
1508
|
+
* a later PowerPoint build opens one of these, the rule is wrong and should be
|
|
1509
|
+
* deleted. The `why` in `rules.ts` records what was tried and what opened, so
|
|
1510
|
+
* that the person who deletes it knows what they are contradicting.
|
|
1511
|
+
*/
|
|
1512
|
+
/** Parts where `hdr` and `sldImg` placeholders are legal - they belong to these families. */
|
|
1513
|
+
const NOTES_FAMILY = /* @__PURE__ */ new Set([
|
|
1514
|
+
CONTENT_TYPE.notesSlide,
|
|
1515
|
+
CONTENT_TYPE.notesMaster,
|
|
1516
|
+
CONTENT_TYPE.handoutMaster
|
|
1517
|
+
]);
|
|
1518
|
+
/** `p:ph/@type` is not `hdr` or `sldImg` on a slide, layout or master. */
|
|
1519
|
+
function v022PlaceholderType(ctx) {
|
|
1520
|
+
for (const part of ctx.parts()) {
|
|
1521
|
+
const contentType = ctx.contentType(part);
|
|
1522
|
+
if (contentType === void 0 || NOTES_FAMILY.has(contentType)) continue;
|
|
1523
|
+
if (contentType !== CONTENT_TYPE.slide && contentType !== CONTENT_TYPE.slideLayout && contentType !== CONTENT_TYPE.slideMaster) continue;
|
|
1524
|
+
const document = ctx.document(part);
|
|
1525
|
+
if (document === null) continue;
|
|
1526
|
+
for (const element of descendantElements(document.root)) {
|
|
1527
|
+
if (element.local !== "ph" || namespaceOf(element) !== NS.p) continue;
|
|
1528
|
+
const type = attributeValue(element, "type");
|
|
1529
|
+
if (type !== "hdr" && type !== "sldImg") continue;
|
|
1530
|
+
ctx.add("V022", attributeLocation(part, element, "type"), "type=\"" + type + "\" is a whole-package refusal here, alone and with no other change. The other seven content types were built as one-type packages in the same bisection and every one opens: obj, chart, tbl, clipArt, dgm, media, pic. Nothing in the schema says so - CT_Placeholder is one complex type shared by every sheet family and ST_PlaceholderType is one enumeration holding all sixteen values - but hdr and sldImg belong to the notes and handout families and PowerPoint enforces it.");
|
|
1531
|
+
}
|
|
1532
|
+
}
|
|
1533
|
+
}
|
|
1534
|
+
/**
|
|
1535
|
+
* The built-in geometry guides.
|
|
1536
|
+
*
|
|
1537
|
+
* Deliberately generous, and deliberately temporary. The authoritative table is
|
|
1538
|
+
* sub-phase 2.2's - forty-four seeded guides beside the seventeen `fmla`
|
|
1539
|
+
* operators - and it belongs in `@pptx-studio/geometry` rather than here. Until
|
|
1540
|
+
* it exists this list plus the two shape tests below stand in for it.
|
|
1541
|
+
*
|
|
1542
|
+
* Generous in the safe direction on purpose. This rule is fatal, so a name we
|
|
1543
|
+
* wrongly think is undefined refuses a file that works, while a name we wrongly
|
|
1544
|
+
* accept only means we miss one instance of a defect whose real-world form is a
|
|
1545
|
+
* typo or a deleted `a:gd` - and neither of those looks anything like `wd12`.
|
|
1546
|
+
*/
|
|
1547
|
+
const BUILTIN_GUIDES = /* @__PURE__ */ new Set([
|
|
1548
|
+
"3cd4",
|
|
1549
|
+
"3cd8",
|
|
1550
|
+
"5cd8",
|
|
1551
|
+
"7cd8",
|
|
1552
|
+
"b",
|
|
1553
|
+
"cd2",
|
|
1554
|
+
"cd4",
|
|
1555
|
+
"cd8",
|
|
1556
|
+
"h",
|
|
1557
|
+
"hc",
|
|
1558
|
+
"hd2",
|
|
1559
|
+
"hd3",
|
|
1560
|
+
"hd4",
|
|
1561
|
+
"hd5",
|
|
1562
|
+
"hd6",
|
|
1563
|
+
"hd8",
|
|
1564
|
+
"l",
|
|
1565
|
+
"ls",
|
|
1566
|
+
"r",
|
|
1567
|
+
"ss",
|
|
1568
|
+
"ssd2",
|
|
1569
|
+
"ssd4",
|
|
1570
|
+
"ssd6",
|
|
1571
|
+
"ssd8",
|
|
1572
|
+
"ssd16",
|
|
1573
|
+
"ssd32",
|
|
1574
|
+
"t",
|
|
1575
|
+
"vc",
|
|
1576
|
+
"w",
|
|
1577
|
+
"wd2",
|
|
1578
|
+
"wd3",
|
|
1579
|
+
"wd4",
|
|
1580
|
+
"wd5",
|
|
1581
|
+
"wd6",
|
|
1582
|
+
"wd8",
|
|
1583
|
+
"wd10",
|
|
1584
|
+
"wd32"
|
|
1585
|
+
]);
|
|
1586
|
+
/** `wd12`, `hd10`, `ssd12` - the same shape as a built-in, with a divisor we did not list. */
|
|
1587
|
+
const BUILTIN_SHAPED = /^(?:w|h|ss|ls)d\d+$/;
|
|
1588
|
+
/** `2cd4`, `5cd8` - a fraction of a full circle. */
|
|
1589
|
+
const ANGLE_SHAPED = /^\d+cd\d+$/;
|
|
1590
|
+
/** A literal number, in EMU or in 60000ths of a degree. Both may be negative. */
|
|
1591
|
+
const LITERAL = /^-?\d+$/;
|
|
1592
|
+
function isDefinedGuide(name, defined) {
|
|
1593
|
+
if (name === "" || LITERAL.test(name)) return true;
|
|
1594
|
+
if (defined.has(name) || BUILTIN_GUIDES.has(name)) return true;
|
|
1595
|
+
return BUILTIN_SHAPED.test(name) || ANGLE_SHAPED.test(name);
|
|
1596
|
+
}
|
|
1597
|
+
/** Attributes that hold a guide name rather than a value of their own. */
|
|
1598
|
+
const GUIDE_VALUED = {
|
|
1599
|
+
pt: ["x", "y"],
|
|
1600
|
+
pos: ["x", "y"],
|
|
1601
|
+
rect: [
|
|
1602
|
+
"l",
|
|
1603
|
+
"t",
|
|
1604
|
+
"r",
|
|
1605
|
+
"b"
|
|
1606
|
+
],
|
|
1607
|
+
ahXY: [
|
|
1608
|
+
"minX",
|
|
1609
|
+
"maxX",
|
|
1610
|
+
"minY",
|
|
1611
|
+
"maxY",
|
|
1612
|
+
"gdRefX",
|
|
1613
|
+
"gdRefY"
|
|
1614
|
+
],
|
|
1615
|
+
ahPolar: [
|
|
1616
|
+
"minAng",
|
|
1617
|
+
"maxAng",
|
|
1618
|
+
"minR",
|
|
1619
|
+
"maxR",
|
|
1620
|
+
"gdRefAng",
|
|
1621
|
+
"gdRefR"
|
|
1622
|
+
],
|
|
1623
|
+
cxn: ["ang"],
|
|
1624
|
+
arcTo: [
|
|
1625
|
+
"wR",
|
|
1626
|
+
"hR",
|
|
1627
|
+
"stAng",
|
|
1628
|
+
"swAng"
|
|
1629
|
+
]
|
|
1630
|
+
};
|
|
1631
|
+
/**
|
|
1632
|
+
* Every geometry guide named is a guide that is defined.
|
|
1633
|
+
*
|
|
1634
|
+
* Scoped to one `a:custGeom` or `a:prstGeom` at a time, because that is the
|
|
1635
|
+
* scope a guide has: `adj1` in one shape has nothing to do with `adj1` in the
|
|
1636
|
+
* next. A `prstGeom`'s guides come from the preset definition rather than from
|
|
1637
|
+
* the markup, so only its `a:avLst` is checked - which is the whole of what a
|
|
1638
|
+
* preset shape can get wrong from here, and is exactly what dragging an adjust
|
|
1639
|
+
* handle writes.
|
|
1640
|
+
*/
|
|
1641
|
+
function v023GeometryGuides(ctx) {
|
|
1642
|
+
forEachElement(ctx, (part, geometry) => {
|
|
1643
|
+
if (!(geometry.local === "custGeom") && geometry.local !== "prstGeom" || namespaceOf(geometry) !== NS.a) return;
|
|
1644
|
+
const defined = /* @__PURE__ */ new Set();
|
|
1645
|
+
for (const element of descendantElements(geometry)) {
|
|
1646
|
+
if (element.local !== "gd") continue;
|
|
1647
|
+
const name = attributeValue(element, "name");
|
|
1648
|
+
if (name !== void 0 && name !== "") defined.add(name);
|
|
1649
|
+
}
|
|
1650
|
+
for (const element of descendantElements(geometry)) {
|
|
1651
|
+
if (element.local === "gd") {
|
|
1652
|
+
const formula = attributeValue(element, "fmla");
|
|
1653
|
+
if (formula === void 0) continue;
|
|
1654
|
+
const args = formula.trim().split(/\s+/).slice(1);
|
|
1655
|
+
for (const arg of args) {
|
|
1656
|
+
if (isDefinedGuide(arg, defined)) continue;
|
|
1657
|
+
report(ctx, part, element, "fmla", arg, defined);
|
|
1658
|
+
}
|
|
1659
|
+
continue;
|
|
1660
|
+
}
|
|
1661
|
+
const names = GUIDE_VALUED[element.local];
|
|
1662
|
+
if (names === void 0 || namespaceOf(element) !== NS.a) continue;
|
|
1663
|
+
for (const attributeName of names) {
|
|
1664
|
+
const value = attribute(element, attributeName);
|
|
1665
|
+
if (value === void 0 || isDefinedGuide(value.value, defined)) continue;
|
|
1666
|
+
report(ctx, part, element, attributeName, value.value, defined);
|
|
1667
|
+
}
|
|
1668
|
+
}
|
|
1669
|
+
});
|
|
1670
|
+
}
|
|
1671
|
+
function report(ctx, part, element, attributeName, name, defined) {
|
|
1672
|
+
ctx.add("V023", attributeLocation(part, element, attributeName), "\"" + name + "\" names a guide nothing defines. This geometry defines " + (defined.size === 0 ? "none" : [...defined].sort().join(", ")) + ". ST_GeomGuideName is an unconstrained token, so nothing in the schema forbids it - and a package containing it is refused outright, not repaired and not drawn without the shape.");
|
|
1673
|
+
}
|
|
1674
|
+
/** The chart namespace, for `V024`. */
|
|
1675
|
+
const CHART_NS = "http://schemas.openxmlformats.org/drawingml/2006/chart";
|
|
1676
|
+
/**
|
|
1677
|
+
* A series' `c:tx` holds `c:strRef` or `c:v`, and nothing else.
|
|
1678
|
+
*
|
|
1679
|
+
* Scoped to `c:ser/c:tx` rather than to every `c:tx`, and the distinction is
|
|
1680
|
+
* real: a series' text is `CT_SerTx`, a choice of `c:strRef` or `c:v`, while a
|
|
1681
|
+
* title's or a data label's `c:tx` is `CT_Tx`, a choice of `c:strRef` or
|
|
1682
|
+
* `c:rich`. Checking both with one list would refuse every chart title in the
|
|
1683
|
+
* corpus.
|
|
1684
|
+
*/
|
|
1685
|
+
function v024SeriesText(ctx) {
|
|
1686
|
+
forEachElement(ctx, (part, element) => {
|
|
1687
|
+
if (element.local !== "tx" || namespaceOf(element) !== CHART_NS) return;
|
|
1688
|
+
const parent = element.parent;
|
|
1689
|
+
if (parent === void 0 || parent.local !== "ser" || namespaceOf(parent) !== CHART_NS) return;
|
|
1690
|
+
for (const child of childElements(element)) {
|
|
1691
|
+
if (child.local === "strRef" || child.local === "v") continue;
|
|
1692
|
+
ctx.add("V024", elementLocation(part, child), "<" + child.qname + "> inside a series <" + element.qname + ">. CT_SerTx is a choice of c:strRef or c:v and nothing else. The trap is that c:cat and c:val, two elements away in the same series, both accept the literal forms - so a c:strLit here looks like markup that ought to work, and it is a whole-package refusal.");
|
|
1693
|
+
}
|
|
1694
|
+
});
|
|
1695
|
+
}
|
|
1696
|
+
/** No `p:control`. */
|
|
1697
|
+
function v025Control(ctx) {
|
|
1698
|
+
forEachElement(ctx, (part, element) => {
|
|
1699
|
+
if (element.local !== "control" || namespaceOf(element) !== NS.p) return;
|
|
1700
|
+
ctx.add("V025", elementLocation(part, element), "a <p:control> is a whole-package refusal in all eight forms tried: bare, name-only, with and without r:id, with a p:pic preview, with an ActiveX part and its .bin, and inside a macro-enabled package. An empty <p:controls> is accepted, which places the refusal precisely on this element.");
|
|
1701
|
+
});
|
|
1702
|
+
}
|
|
1703
|
+
/** The chart-style namespace and its thirty-one entries. */
|
|
1704
|
+
const CHART_STYLE_NS = "http://schemas.microsoft.com/office/drawing/2012/chartStyle";
|
|
1705
|
+
const CHART_STYLE_ENTRIES = [
|
|
1706
|
+
"axisTitle",
|
|
1707
|
+
"categoryAxis",
|
|
1708
|
+
"chartArea",
|
|
1709
|
+
"dataLabel",
|
|
1710
|
+
"dataLabelCallout",
|
|
1711
|
+
"dataPoint",
|
|
1712
|
+
"dataPoint3D",
|
|
1713
|
+
"dataPointLine",
|
|
1714
|
+
"dataPointMarker",
|
|
1715
|
+
"dataPointMarkerLayout",
|
|
1716
|
+
"dataPointWireframe",
|
|
1717
|
+
"dataTable",
|
|
1718
|
+
"downBar",
|
|
1719
|
+
"dropLine",
|
|
1720
|
+
"errorBar",
|
|
1721
|
+
"floor",
|
|
1722
|
+
"gridlineMajor",
|
|
1723
|
+
"gridlineMinor",
|
|
1724
|
+
"hiLoLine",
|
|
1725
|
+
"leaderLine",
|
|
1726
|
+
"legend",
|
|
1727
|
+
"plotArea",
|
|
1728
|
+
"plotArea3D",
|
|
1729
|
+
"seriesAxis",
|
|
1730
|
+
"seriesLine",
|
|
1731
|
+
"title",
|
|
1732
|
+
"trendline",
|
|
1733
|
+
"trendlineLabel",
|
|
1734
|
+
"upBar",
|
|
1735
|
+
"valueAxis",
|
|
1736
|
+
"wall"
|
|
1737
|
+
];
|
|
1738
|
+
/**
|
|
1739
|
+
* A `cs:chartStyle` carries all thirty-one of its entries.
|
|
1740
|
+
*
|
|
1741
|
+
* The shape of this finding is the interesting half. A chart with **no**
|
|
1742
|
+
* chart-style relationship at all opens; a chart whose style part is present
|
|
1743
|
+
* and has four of thirty-one does not. So the rule is not "the part is
|
|
1744
|
+
* required", it is "if the part exists it must be complete" - a subset is worse
|
|
1745
|
+
* than an absence, which is the opposite of what any partial-styling model
|
|
1746
|
+
* would predict, and is why we found it by shipping four entries rather than by
|
|
1747
|
+
* reasoning.
|
|
1748
|
+
*/
|
|
1749
|
+
function v026ChartStyleComplete(ctx) {
|
|
1750
|
+
for (const part of ctx.parts()) {
|
|
1751
|
+
const document = ctx.document(part);
|
|
1752
|
+
if (document === null) continue;
|
|
1753
|
+
const root = document.root;
|
|
1754
|
+
if (root.local !== "chartStyle" || namespaceOf(root) !== CHART_STYLE_NS) continue;
|
|
1755
|
+
const present = new Set(childElements(root).map((child) => child.local));
|
|
1756
|
+
const missing = CHART_STYLE_ENTRIES.filter((name) => !present.has(name));
|
|
1757
|
+
if (missing.length === 0) continue;
|
|
1758
|
+
ctx.add("V026", elementLocation(part, root), "a chart style with " + String(CHART_STYLE_ENTRIES.length - missing.length) + " of its " + String(CHART_STYLE_ENTRIES.length) + " entries. Missing: " + missing.join(", ") + ". Four entries were refused and thirty-one opened; a chart with no chart-style relationship at all is fine.");
|
|
1759
|
+
}
|
|
1760
|
+
}
|
|
1761
|
+
//#endregion
|
|
1762
|
+
//#region src/rules/preservation.ts
|
|
1763
|
+
/**
|
|
1764
|
+
* `V027` … `V029`: what has to come back out unchanged.
|
|
1765
|
+
*
|
|
1766
|
+
* These are the rules the architecture exists for. Preserving charts, SmartArt,
|
|
1767
|
+
* animations, OLE objects and macros is unachievable in any design where the
|
|
1768
|
+
* writer has to *understand* a feature in order to emit it - and the moment
|
|
1769
|
+
* preservation is a feature rather than the default state, it is a feature with
|
|
1770
|
+
* a coverage percentage, and the percentage is never a hundred.
|
|
1771
|
+
*
|
|
1772
|
+
* So the default is that nothing changes, and these three rules are where that
|
|
1773
|
+
* intention becomes falsifiable.
|
|
1774
|
+
*
|
|
1775
|
+
* ## They need two packages, and they say so when they only have one
|
|
1776
|
+
*
|
|
1777
|
+
* All three compare against the package as it was opened. Asked to validate a
|
|
1778
|
+
* file with no history - `pptx-studio validate deck.pptx`, a deck somebody
|
|
1779
|
+
* dropped on a page - there is nothing to compare against and they do not run.
|
|
1780
|
+
* The report lists them under `skipped` with that reason, because a
|
|
1781
|
+
* preservation rule that reports nothing because it had nothing to compare
|
|
1782
|
+
* against looks exactly like one that found nothing, and those are opposite
|
|
1783
|
+
* answers.
|
|
1784
|
+
*/
|
|
1785
|
+
/** `ppt/embeddings/…` - OLE2/CFB compound files. Never ours to rewrite. */
|
|
1786
|
+
function isEmbedding(part) {
|
|
1787
|
+
return /^\/ppt\/embeddings\//i.test(part);
|
|
1788
|
+
}
|
|
1789
|
+
/**
|
|
1790
|
+
* A part nobody edited comes back out byte for byte.
|
|
1791
|
+
*
|
|
1792
|
+
* "Nobody edited" is `PartInfo.fromArchive`, the store's own record of whether a
|
|
1793
|
+
* part's bytes still come from the archive it was opened from - not a byte
|
|
1794
|
+
* comparison, which could not tell an edit that happened to produce identical
|
|
1795
|
+
* bytes from no edit at all. The rule then checks the converse: a part the store
|
|
1796
|
+
* says is untouched really is identical.
|
|
1797
|
+
*
|
|
1798
|
+
* That is not circular, because the two facts come from different places. The
|
|
1799
|
+
* flag is set by whoever called `replacePart`; the bytes come from whatever the
|
|
1800
|
+
* writer actually emitted. A writer that re-serialised a clean part - the exact
|
|
1801
|
+
* failure this rule exists for - would leave the flag alone and change the
|
|
1802
|
+
* bytes, and this is where the two stop agreeing.
|
|
1803
|
+
*/
|
|
1804
|
+
function v027UneditedPartsUnchanged(ctx) {
|
|
1805
|
+
if (ctx.baseline === null) return;
|
|
1806
|
+
for (const part of ctx.parts()) {
|
|
1807
|
+
const before = ctx.baselineRead(part);
|
|
1808
|
+
if (before === null) continue;
|
|
1809
|
+
const embedding = isEmbedding(part);
|
|
1810
|
+
if (embedding && ctx.edited(part)) {
|
|
1811
|
+
ctx.add("V027", partLocation(part), "an OLE embedding was replaced. ppt/embeddings/*.bin are OLE2/CFB compound files and any rewrite of one is a guaranteed repair prompt - we render their preview and never touch the object.");
|
|
1812
|
+
continue;
|
|
1813
|
+
}
|
|
1814
|
+
if (!embedding && ctx.edited(part)) continue;
|
|
1815
|
+
const after = ctx.read(part);
|
|
1816
|
+
if (after === null) continue;
|
|
1817
|
+
if (sameBytes(before, after)) continue;
|
|
1818
|
+
ctx.add("V027", partLocation(part), "nobody edited this part and its bytes changed anyway (" + String(before.byteLength) + " bytes in, " + String(after.byteLength) + " out). A part that was not edited is streamed through, never re-serialised: the moment that stops being true, every feature we cannot parse is a feature we can lose.");
|
|
1819
|
+
}
|
|
1820
|
+
}
|
|
1821
|
+
function sameBytes(a, b) {
|
|
1822
|
+
if (a.byteLength !== b.byteLength) return false;
|
|
1823
|
+
for (let i = 0; i < a.byteLength; i++) if (a[i] !== b[i]) return false;
|
|
1824
|
+
return true;
|
|
1825
|
+
}
|
|
1826
|
+
/** Local names of the containers held opaque, with the namespace-free test that finds them. */
|
|
1827
|
+
function isOpaqueContainer(element) {
|
|
1828
|
+
return element.local === "AlternateContent" || element.local === "ext";
|
|
1829
|
+
}
|
|
1830
|
+
/** Every opaque container in a document, as its exact source text. */
|
|
1831
|
+
function opaqueSources(document) {
|
|
1832
|
+
const out = [];
|
|
1833
|
+
for (const element of descendantElements(document.root)) {
|
|
1834
|
+
if (!isOpaqueContainer(element)) continue;
|
|
1835
|
+
out.push({
|
|
1836
|
+
element,
|
|
1837
|
+
source: sourceOf(document, element)
|
|
1838
|
+
});
|
|
1839
|
+
}
|
|
1840
|
+
return out;
|
|
1841
|
+
}
|
|
1842
|
+
/**
|
|
1843
|
+
* No `mc:AlternateContent` branch and no extension was rebuilt.
|
|
1844
|
+
*
|
|
1845
|
+
* The check is a multiset test, not a positional one: every opaque container in
|
|
1846
|
+
* the part we are about to write must appear, character for character,
|
|
1847
|
+
* somewhere in the part as it was opened. That formulation is deliberate.
|
|
1848
|
+
*
|
|
1849
|
+
* A positional comparison - same XPath, same text - would be exact and would
|
|
1850
|
+
* also fire every time a shape was inserted above one, because the index in the
|
|
1851
|
+
* path shifts. The multiset test cannot be fooled in the direction that matters
|
|
1852
|
+
* (text nobody wrote before is text we wrote) and is immune to the reordering
|
|
1853
|
+
* that is a normal, permitted edit.
|
|
1854
|
+
*
|
|
1855
|
+
* **Deletion is not this rule's business.** An extension can leave because the
|
|
1856
|
+
* shape holding it was deleted, and that is a document operation the user asked
|
|
1857
|
+
* for. What must never happen is markup *appearing* here that we composed:
|
|
1858
|
+
* `mc:Choice/@Requires` names a prefix rather than a URI, so a rewritten branch
|
|
1859
|
+
* is how ignorable extension markup becomes a hard error - and `a34-extlst`
|
|
1860
|
+
* measured that PowerPoint carries an unknown `a:ext/@uri` through untouched,
|
|
1861
|
+
* so an extension we rebuild is one the file would otherwise have kept for ever.
|
|
1862
|
+
*/
|
|
1863
|
+
function v028OpaqueContainersUnchanged(ctx) {
|
|
1864
|
+
if (ctx.baseline === null) return;
|
|
1865
|
+
for (const part of ctx.parts()) {
|
|
1866
|
+
if (isRelationshipPartName(part) || !ctx.edited(part)) continue;
|
|
1867
|
+
const after = ctx.document(part);
|
|
1868
|
+
const before = ctx.baselineDocument(part);
|
|
1869
|
+
if (after === null || before === null) continue;
|
|
1870
|
+
const known = new Set(opaqueSources(before).map((entry) => entry.source));
|
|
1871
|
+
for (const entry of opaqueSources(after)) {
|
|
1872
|
+
if (known.has(entry.source)) continue;
|
|
1873
|
+
const uri = attributeValue(entry.element, "uri");
|
|
1874
|
+
ctx.add("V028", elementLocation(part, entry.element), "<" + entry.element.qname + ">" + (uri === void 0 ? "" : " uri=\"" + uri + "\"") + " is not markup this part arrived with. These containers are carried through byte for byte and never composed: we do not know what is in them, which is the whole reason they are held opaque.");
|
|
1875
|
+
}
|
|
1876
|
+
}
|
|
1877
|
+
}
|
|
1878
|
+
function fieldsOf(root) {
|
|
1879
|
+
const out = [];
|
|
1880
|
+
for (const element of descendantElements(root)) {
|
|
1881
|
+
if (element.local !== "fld") continue;
|
|
1882
|
+
out.push({
|
|
1883
|
+
id: attributeValue(element, "id") ?? "",
|
|
1884
|
+
text: textContent(element),
|
|
1885
|
+
element
|
|
1886
|
+
});
|
|
1887
|
+
}
|
|
1888
|
+
return out;
|
|
1889
|
+
}
|
|
1890
|
+
/** `a:t` elements whose `xml:space` was declared, keyed by path. */
|
|
1891
|
+
function spacePreserved(root) {
|
|
1892
|
+
const out = /* @__PURE__ */ new Map();
|
|
1893
|
+
for (const element of descendantElements(root)) {
|
|
1894
|
+
if (element.local !== "t") continue;
|
|
1895
|
+
if (attributeValue(element, "xml:space") !== "preserve") continue;
|
|
1896
|
+
out.set(xpathOf(element), element);
|
|
1897
|
+
}
|
|
1898
|
+
return out;
|
|
1899
|
+
}
|
|
1900
|
+
/** The path of every `a:t`, so "is this one still there" is a set lookup. */
|
|
1901
|
+
function textPaths(root) {
|
|
1902
|
+
const out = /* @__PURE__ */ new Set();
|
|
1903
|
+
for (const element of descendantElements(root)) if (element.local === "t") out.add(xpathOf(element));
|
|
1904
|
+
return out;
|
|
1905
|
+
}
|
|
1906
|
+
/**
|
|
1907
|
+
* Text and field identity survive an edit.
|
|
1908
|
+
*
|
|
1909
|
+
* Three small, separately silent losses.
|
|
1910
|
+
*
|
|
1911
|
+
* **`xml:space="preserve"` is preserved, not required.** The distinction is
|
|
1912
|
+
* measured: 120 `<a:t>` elements in the corpus carry leading or trailing
|
|
1913
|
+
* whitespace *without* the attribute and PowerPoint round-trips every one of
|
|
1914
|
+
* them with the whitespace intact, so a rule that demanded the attribute would
|
|
1915
|
+
* fire on real files that work. What is not allowed is dropping one that was
|
|
1916
|
+
* there, because then the attribute's absence starts meaning something it did
|
|
1917
|
+
* not mean before.
|
|
1918
|
+
*
|
|
1919
|
+
* **`a:fld/@id` is a required `ST_Guid`** identifying the field across saves.
|
|
1920
|
+
* Regenerating one can make PowerPoint repair the file, and a repair is the one
|
|
1921
|
+
* outcome this whole package exists to prevent.
|
|
1922
|
+
*
|
|
1923
|
+
* **A field's cached `a:t` is its only rendering** anywhere we cannot evaluate
|
|
1924
|
+
* the field ourselves. Discarding it turns a date placeholder into an empty box
|
|
1925
|
+
* on every consumer that is not PowerPoint - including, for anything we have
|
|
1926
|
+
* not implemented, us.
|
|
1927
|
+
*/
|
|
1928
|
+
function v029TextAndFieldIdentity(ctx) {
|
|
1929
|
+
if (ctx.baseline === null) return;
|
|
1930
|
+
for (const part of ctx.parts()) {
|
|
1931
|
+
if (isRelationshipPartName(part) || !ctx.edited(part)) continue;
|
|
1932
|
+
const after = ctx.document(part);
|
|
1933
|
+
const before = ctx.baselineDocument(part);
|
|
1934
|
+
if (after === null || before === null) continue;
|
|
1935
|
+
const afterSpace = spacePreserved(after.root);
|
|
1936
|
+
const afterPaths = textPaths(after.root);
|
|
1937
|
+
for (const [path, element] of spacePreserved(before.root)) {
|
|
1938
|
+
if (afterSpace.has(path)) continue;
|
|
1939
|
+
if (!afterPaths.has(path)) continue;
|
|
1940
|
+
ctx.add("V029", {
|
|
1941
|
+
part,
|
|
1942
|
+
xpath: path,
|
|
1943
|
+
offset: element.start
|
|
1944
|
+
}, "this <" + element.qname + "> arrived with xml:space=\"preserve\" and no longer has it. The attribute is not required - 120 elements in our corpus carry edge whitespace without it - but dropping one that was written changes what the text is.");
|
|
1945
|
+
}
|
|
1946
|
+
const afterFields = fieldsOf(after.root);
|
|
1947
|
+
const beforeById = new Map(fieldsOf(before.root).map((field) => [field.id, field]));
|
|
1948
|
+
const beforeIds = new Set(beforeById.keys());
|
|
1949
|
+
for (const field of afterFields) {
|
|
1950
|
+
if (beforeIds.has(field.id)) {
|
|
1951
|
+
const original = beforeById.get(field.id);
|
|
1952
|
+
if (original.text !== "" && field.text.trim() === "") ctx.add("V029", elementLocation(part, field.element), "field " + field.id + " has lost its cached text (\"" + original.text + "\"). The cache is what renders anywhere the field cannot be evaluated, which includes every consumer that is not PowerPoint.");
|
|
1953
|
+
continue;
|
|
1954
|
+
}
|
|
1955
|
+
ctx.add("V029", elementLocation(part, field.element), "field id=\"" + field.id + "\" is not one this part arrived with. a:fld/@id is a required ST_Guid identifying the field across saves; regenerating one can make PowerPoint repair the file. The ids that were here: " + (beforeIds.size === 0 ? "none" : [...beforeIds].join(", ")) + ".");
|
|
1956
|
+
}
|
|
1957
|
+
}
|
|
1958
|
+
}
|
|
1959
|
+
//#endregion
|
|
1960
|
+
//#region src/validate.ts
|
|
1961
|
+
/**
|
|
1962
|
+
* The twenty-nine, wired to their implementations.
|
|
1963
|
+
*
|
|
1964
|
+
* A plain table, so that "is every rule reachable" is a thing a test can ask
|
|
1965
|
+
* rather than a thing a reader has to believe. `rules.test.ts` asserts that the
|
|
1966
|
+
* keys here are exactly `RULE_IDS` - a rule with a definition and no function
|
|
1967
|
+
* would otherwise sit in the table looking enforced.
|
|
1968
|
+
*/
|
|
1969
|
+
const IMPLEMENTATIONS = {
|
|
1970
|
+
V001: v001ContentTypeCoverage,
|
|
1971
|
+
V002: v002ContentTypeMap,
|
|
1972
|
+
V003: v003ArchiveShape,
|
|
1973
|
+
V004: v004PartNames,
|
|
1974
|
+
V005: v005MainPart,
|
|
1975
|
+
V006: v006ReferencesResolve,
|
|
1976
|
+
V007: v007RelationshipIds,
|
|
1977
|
+
V008: v008TargetsResolve,
|
|
1978
|
+
V009: v009RequiredEdges,
|
|
1979
|
+
V010: v010SchemaOrder,
|
|
1980
|
+
V011: v011ExtLstLast,
|
|
1981
|
+
V012: v012UnexpectedChild,
|
|
1982
|
+
V013: v013NotesSize,
|
|
1983
|
+
V014: v014ColorMap,
|
|
1984
|
+
V015: v015ShapeTreePrologue,
|
|
1985
|
+
V016: v016TextBody,
|
|
1986
|
+
V017: v017GraphicFrame,
|
|
1987
|
+
V018: v018SlideIds,
|
|
1988
|
+
V019: v019SheetIds,
|
|
1989
|
+
V020: v020ShapeIds,
|
|
1990
|
+
V021: v021PlaceholderIndices,
|
|
1991
|
+
V022: v022PlaceholderType,
|
|
1992
|
+
V023: v023GeometryGuides,
|
|
1993
|
+
V024: v024SeriesText,
|
|
1994
|
+
V025: v025Control,
|
|
1995
|
+
V026: v026ChartStyleComplete,
|
|
1996
|
+
V027: v027UneditedPartsUnchanged,
|
|
1997
|
+
V028: v028OpaqueContainersUnchanged,
|
|
1998
|
+
V029: v029TextAndFieldIdentity
|
|
1999
|
+
};
|
|
2000
|
+
/** Rules that cannot run without the archive bytes. See `Context.archive`. */
|
|
2001
|
+
const ARCHIVE_RULES = ["V003"];
|
|
2002
|
+
function openArchive(bytes, zip) {
|
|
2003
|
+
if (bytes === void 0) return null;
|
|
2004
|
+
try {
|
|
2005
|
+
return readZip(bytes, zip ?? {});
|
|
2006
|
+
} catch {
|
|
2007
|
+
return null;
|
|
2008
|
+
}
|
|
2009
|
+
}
|
|
2010
|
+
/**
|
|
2011
|
+
* Check a package against the rules.
|
|
2012
|
+
*
|
|
2013
|
+
* Never throws for anything it finds - a broken package produces a report with
|
|
2014
|
+
* findings in it, which is the shape a caller can render. It throws only when
|
|
2015
|
+
* it was handed something it cannot open at all.
|
|
2016
|
+
*/
|
|
2017
|
+
function validatePackage(options) {
|
|
2018
|
+
const archive = openArchive(options.bytes, options.zip);
|
|
2019
|
+
const store = options.store ?? openStore(options);
|
|
2020
|
+
const requested = resolveRules(options.rules);
|
|
2021
|
+
const skipped = [];
|
|
2022
|
+
const running = [];
|
|
2023
|
+
for (const id of requested) {
|
|
2024
|
+
if (ruleById(id).needsBaseline === true && options.baseline === void 0) {
|
|
2025
|
+
skipped.push({
|
|
2026
|
+
rule: id,
|
|
2027
|
+
why: "it compares against the package as it was opened, and none was supplied. A preservation rule with nothing to compare against reports nothing, which looks exactly like finding nothing."
|
|
2028
|
+
});
|
|
2029
|
+
continue;
|
|
2030
|
+
}
|
|
2031
|
+
if (ARCHIVE_RULES.includes(id) && archive === null) {
|
|
2032
|
+
skipped.push({
|
|
2033
|
+
rule: id,
|
|
2034
|
+
why: "it reads the archive itself, and only a PartStore was supplied."
|
|
2035
|
+
});
|
|
2036
|
+
continue;
|
|
2037
|
+
}
|
|
2038
|
+
running.push(id);
|
|
2039
|
+
}
|
|
2040
|
+
const ctx = createContext({
|
|
2041
|
+
store,
|
|
2042
|
+
bytes: options.bytes ?? null,
|
|
2043
|
+
archive,
|
|
2044
|
+
baseline: options.baseline ?? null
|
|
2045
|
+
});
|
|
2046
|
+
run(ctx, running);
|
|
2047
|
+
return buildReport({
|
|
2048
|
+
findings: attributeOrigins(ctx, options, running),
|
|
2049
|
+
checked: running,
|
|
2050
|
+
skipped,
|
|
2051
|
+
problems: ctx.problems
|
|
2052
|
+
});
|
|
2053
|
+
}
|
|
2054
|
+
function openStore(options) {
|
|
2055
|
+
if (options.bytes === void 0) throw new ValidateError("ERR_UNVALIDATABLE", "validatePackage needs either a `store` or the archive `bytes`; it was given neither.");
|
|
2056
|
+
try {
|
|
2057
|
+
return PartStore.open(options.bytes, options.zip);
|
|
2058
|
+
} catch (error) {
|
|
2059
|
+
throw new ValidateError("ERR_UNVALIDATABLE", "the package could not be opened, so there is nothing to validate: " + (error instanceof Error ? error.message : String(error)));
|
|
2060
|
+
}
|
|
2061
|
+
}
|
|
2062
|
+
function resolveRules(requested) {
|
|
2063
|
+
if (requested === void 0) return RULE_IDS;
|
|
2064
|
+
for (const id of requested) if (ruleById(id) === void 0) throw new ValidateError("ERR_UNKNOWN_RULE", "there is no rule " + id, { rule: id });
|
|
2065
|
+
return RULE_IDS.filter((id) => requested.includes(id));
|
|
2066
|
+
}
|
|
2067
|
+
function run(ctx, rules) {
|
|
2068
|
+
for (const id of rules) IMPLEMENTATIONS[id](ctx);
|
|
2069
|
+
}
|
|
2070
|
+
/**
|
|
2071
|
+
* Decide, for each finding, whether we introduced it.
|
|
2072
|
+
*
|
|
2073
|
+
* The second pass is the whole of it: run the same rules against the package as
|
|
2074
|
+
* it was opened and difference the two sets. A finding in both was already
|
|
2075
|
+
* there. See `report.ts` for why that question is the one that decides whether
|
|
2076
|
+
* an export is refused, and why answering it by differencing beats answering it
|
|
2077
|
+
* per rule.
|
|
2078
|
+
*
|
|
2079
|
+
* Three properties make it cheap enough to be unconditional in the only case
|
|
2080
|
+
* that matters. It runs **only when something fatal was found**, so a clean
|
|
2081
|
+
* export - which is nearly all of them - pays nothing. It runs **only the rules
|
|
2082
|
+
* that fired**, not all twenty-nine. And it skips the preservation rules, which
|
|
2083
|
+
* would be comparing the baseline against itself and would find nothing by
|
|
2084
|
+
* construction.
|
|
2085
|
+
*/
|
|
2086
|
+
function attributeOrigins(ctx, options, running) {
|
|
2087
|
+
const baseline = options.baseline;
|
|
2088
|
+
if (baseline === void 0) return ctx.findings;
|
|
2089
|
+
const fatal = ctx.findings.filter((finding) => finding.severity === "fatal");
|
|
2090
|
+
if (fatal.length === 0) return ctx.findings.map((finding) => ({
|
|
2091
|
+
...finding,
|
|
2092
|
+
origin: "introduced"
|
|
2093
|
+
}));
|
|
2094
|
+
const firedRules = new Set(fatal.map((finding) => finding.rule));
|
|
2095
|
+
const replay = running.filter((id) => firedRules.has(id) && ruleById(id).needsBaseline !== true);
|
|
2096
|
+
const before = createContext({
|
|
2097
|
+
store: baseline,
|
|
2098
|
+
bytes: options.baselineBytes ?? null,
|
|
2099
|
+
archive: openArchive(options.baselineBytes, options.zip),
|
|
2100
|
+
baseline: null
|
|
2101
|
+
});
|
|
2102
|
+
run(before, replay);
|
|
2103
|
+
const inherited = new Set(before.findings.map(findingKey));
|
|
2104
|
+
return ctx.findings.map((finding) => ({
|
|
2105
|
+
...finding,
|
|
2106
|
+
origin: inherited.has(findingKey(finding)) ? "inherited" : "introduced"
|
|
2107
|
+
}));
|
|
2108
|
+
}
|
|
2109
|
+
/**
|
|
2110
|
+
* Validate, and refuse to go on if we broke something.
|
|
2111
|
+
*
|
|
2112
|
+
* The one call an export path makes. It throws rather than returning a boolean
|
|
2113
|
+
* because the failure has to be impossible to ignore: PowerPoint emits no
|
|
2114
|
+
* diagnostic log, its refusal message names no part and no line, and a user who
|
|
2115
|
+
* gets a repair prompt has no way at all to find out why. This is the only
|
|
2116
|
+
* feedback loop that exists, and a caller who forgot to check a return value
|
|
2117
|
+
* would have removed it.
|
|
2118
|
+
*/
|
|
2119
|
+
function assertValid(options) {
|
|
2120
|
+
const report = validatePackage(options);
|
|
2121
|
+
if (report.ok) return report;
|
|
2122
|
+
const blocking = report.findings.filter((finding) => finding.severity === "fatal" && finding.origin !== "inherited");
|
|
2123
|
+
throw new ValidateError("ERR_VALIDATION_FAILED", "refusing to hand over the package: " + String(report.blocking) + " fatal finding(s) this session introduced. First: " + blocking[0].rule + " at " + blocking[0].where.part + (blocking[0].where.xpath === null ? "" : " " + blocking[0].where.xpath) + " - " + blocking[0].message, {
|
|
2124
|
+
part: blocking[0].where.part,
|
|
2125
|
+
report
|
|
2126
|
+
});
|
|
2127
|
+
}
|
|
2128
|
+
//#endregion
|
|
2129
|
+
export { BASELINE_RULES, PACKAGE_LOCATION, RULES, RULE_IDS, VALIDATE_ERROR_CODES, ValidateError, assertValid, attributeLocation, buildReport, createContext, elementLocation, findingKey, formatReport, inDocumentOrder, isReport, isValidateError, lineColumn, partLocation, rootName, ruleById, validatePackage, xpathOf, xpathOfAttribute };
|
|
2130
|
+
|
|
2131
|
+
//# sourceMappingURL=index.js.map
|