@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.
@@ -0,0 +1,707 @@
1
+ import { XAttribute, XDocument, XElement, XNode } from "@pptx-studio/xml";
2
+ import { PartName, PartStore, ReadZipOptions, ZipArchive } from "@pptx-studio/opc";
3
+ //#region src/errors.d.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
+ declare const VALIDATE_ERROR_CODES: readonly ["ERR_VALIDATION_FAILED", "ERR_UNVALIDATABLE", "ERR_UNKNOWN_RULE"];
28
+ type ValidateErrorCode = (typeof VALIDATE_ERROR_CODES)[number];
29
+ interface ValidateErrorDetail {
30
+ /** The part the failure is about, if it is about one. */
31
+ readonly part?: string;
32
+ /** The rule id a caller got wrong, for `ERR_UNKNOWN_RULE`. */
33
+ readonly rule?: string;
34
+ /**
35
+ * The full report, for `ERR_VALIDATION_FAILED`.
36
+ *
37
+ * Typed `unknown` rather than `Report` on purpose: `report.ts` imports this
38
+ * module for the error, so typing this field would make the two circular.
39
+ * Callers narrow it with `isReport`.
40
+ */
41
+ readonly report?: unknown;
42
+ }
43
+ declare class ValidateError extends Error {
44
+ readonly code: ValidateErrorCode;
45
+ readonly detail: ValidateErrorDetail;
46
+ constructor(code: ValidateErrorCode, message: string, detail?: ValidateErrorDetail);
47
+ }
48
+ declare function isValidateError(value: unknown): value is ValidateError;
49
+ //#endregion
50
+ //#region src/rules/rules.d.ts
51
+ /**
52
+ * The twenty-nine rules, and the evidence for each.
53
+ *
54
+ * ## Why a table and not twenty-nine functions
55
+ *
56
+ * The functions are in the files beside this one. What lives here is the part a
57
+ * person reads: what each rule claims, how sure we are, and *how we know*. A
58
+ * validator whose rules exist only as code is a validator nobody can audit, and
59
+ * this one has to be audited, because roughly half of it enforces things no
60
+ * schema says.
61
+ *
62
+ * That is the fact that shapes the whole package. ECMA-376 is a description of
63
+ * a file format; PowerPoint is an implementation that refuses files the
64
+ * description permits. Sub-phase 0.7 and the corpus bisections in
65
+ * `tools/corpus/ROSTER.md` found nineteen such refusals by building a package
66
+ * with one change in it and watching PowerPoint decline to open it - no
67
+ * diagnostic, no log, no part named, just "PowerPoint could not open the file"
68
+ * or `0x80070570`. Every rule below whose `evidence` says `measured` came from
69
+ * that loop and from nowhere else.
70
+ *
71
+ * So each rule carries three things beyond its check:
72
+ *
73
+ * - `evidence` - `schema` (ECMA-376 says so), `measured` (we watched PowerPoint
74
+ * refuse it), or `both`.
75
+ * - `why` - the sentence that justifies the rule to somebody who is about to
76
+ * delete it because it fired on their file.
77
+ * - `severity` - `fatal` refuses an export; `warning` is reported and does not.
78
+ *
79
+ * ## On being wrong in the safe direction
80
+ *
81
+ * A false positive here blocks an export a user wanted. A false negative hands
82
+ * them a file PowerPoint will not open, with no way to find out why. Those are
83
+ * not symmetric, but the first is not free either - a validator that fires on
84
+ * good files gets turned off, and then it catches nothing.
85
+ *
86
+ * The resolution is `origin`, which lives on the finding rather than here: a
87
+ * fatal a caller *introduced* refuses the export, and the identical fatal that
88
+ * was already in the file when it was opened is reported and does not. That is
89
+ * the same split `PartStore.write` already makes for dangling relationships,
90
+ * and for the same reason - refusing to re-export a file we did not break makes
91
+ * the file unopenable in this editor and does not fix anything.
92
+ */
93
+ type RuleCategory = 'package' | 'relationships' | 'order' | 'required' | 'ids' | 'refused' | 'preservation';
94
+ type Severity = 'fatal' | 'warning';
95
+ /** Where the rule comes from. See the header. */
96
+ type Evidence = 'schema' | 'measured' | 'both';
97
+ interface Rule {
98
+ readonly id: RuleId;
99
+ readonly category: RuleCategory;
100
+ readonly severity: Severity;
101
+ readonly evidence: Evidence;
102
+ /** One line, imperative, in the voice of the thing that must be true. */
103
+ readonly title: string;
104
+ /** Why the rule exists, for whoever is about to argue with it. */
105
+ readonly why: string;
106
+ /**
107
+ * True when the rule needs the package as it was opened as well as the
108
+ * package about to be written.
109
+ *
110
+ * Six rules do. They are skipped rather than passed when no baseline is
111
+ * given, and the report says so - a preservation rule that silently reports
112
+ * nothing because it had nothing to compare against is worse than one that
113
+ * did not run, because it looks like a pass.
114
+ */
115
+ readonly needsBaseline?: true;
116
+ }
117
+ declare const RULES: readonly [{
118
+ readonly id: "V001";
119
+ readonly category: "package";
120
+ readonly severity: "fatal";
121
+ readonly evidence: "both";
122
+ readonly title: "every part resolves to a content type";
123
+ readonly why: string;
124
+ }, {
125
+ readonly id: "V002";
126
+ readonly category: "package";
127
+ readonly severity: "fatal";
128
+ readonly evidence: "both";
129
+ readonly title: "the content-type map is internally consistent";
130
+ readonly why: string;
131
+ }, {
132
+ readonly id: "V003";
133
+ readonly category: "package";
134
+ readonly severity: "fatal";
135
+ readonly evidence: "schema";
136
+ readonly title: "the archive carries no directory entries and no ZIP64 record";
137
+ readonly why: string;
138
+ }, {
139
+ readonly id: "V004";
140
+ readonly category: "package";
141
+ readonly severity: "fatal";
142
+ readonly evidence: "both";
143
+ readonly title: "part names are ASCII, well formed, and free of pointless percent-escapes";
144
+ readonly why: string;
145
+ }, {
146
+ readonly id: "V005";
147
+ readonly category: "package";
148
+ readonly severity: "fatal";
149
+ readonly evidence: "both";
150
+ readonly title: "there is exactly one main presentation part, of a matching content type";
151
+ readonly why: string;
152
+ }, {
153
+ readonly id: "V006";
154
+ readonly category: "relationships";
155
+ readonly severity: "fatal";
156
+ readonly evidence: "both";
157
+ readonly title: "every relationship reference resolves in its own part’s `.rels`";
158
+ readonly why: string;
159
+ }, {
160
+ readonly id: "V007";
161
+ readonly category: "relationships";
162
+ readonly severity: "fatal";
163
+ readonly evidence: "schema";
164
+ readonly title: "relationship ids are unique within a `.rels` and match the `xsd:ID` grammar";
165
+ readonly why: string;
166
+ }, {
167
+ readonly id: "V008";
168
+ readonly category: "relationships";
169
+ readonly severity: "fatal";
170
+ readonly evidence: "both";
171
+ readonly title: "internal targets resolve inside the package, relative to the source part’s folder";
172
+ readonly why: string;
173
+ }, {
174
+ readonly id: "V009";
175
+ readonly category: "relationships";
176
+ readonly severity: "fatal";
177
+ readonly evidence: "measured";
178
+ readonly title: "the relationship edges a part must have, and the ones it must not";
179
+ readonly why: string;
180
+ }, {
181
+ readonly id: "V010";
182
+ readonly category: "order";
183
+ readonly severity: "fatal";
184
+ readonly evidence: "both";
185
+ readonly title: "children appear in schema-sequence order";
186
+ readonly why: string;
187
+ }, {
188
+ readonly id: "V011";
189
+ readonly category: "order";
190
+ readonly severity: "fatal";
191
+ readonly evidence: "schema";
192
+ readonly title: "`extLst` is last among its siblings, and every `a:ext` carries a `@uri`";
193
+ readonly why: string;
194
+ }, {
195
+ readonly id: "V012";
196
+ readonly category: "order";
197
+ readonly severity: "fatal";
198
+ readonly evidence: "both";
199
+ readonly title: "no child appears in a parent whose content model has no place for it";
200
+ readonly why: string;
201
+ }, {
202
+ readonly id: "V013";
203
+ readonly category: "required";
204
+ readonly severity: "fatal";
205
+ readonly evidence: "schema";
206
+ readonly title: "`p:presentation` carries `p:notesSz`";
207
+ readonly why: string;
208
+ }, {
209
+ readonly id: "V014";
210
+ readonly category: "required";
211
+ readonly severity: "fatal";
212
+ readonly evidence: "schema";
213
+ readonly title: "`p:clrMap` carries all twelve attributes";
214
+ readonly why: string;
215
+ }, {
216
+ readonly id: "V015";
217
+ readonly category: "required";
218
+ readonly severity: "fatal";
219
+ readonly evidence: "schema";
220
+ readonly title: "`p:spTree` begins with `p:nvGrpSpPr` then `p:grpSpPr`";
221
+ readonly why: string;
222
+ }, {
223
+ readonly id: "V016";
224
+ readonly category: "required";
225
+ readonly severity: "fatal";
226
+ readonly evidence: "schema";
227
+ readonly title: "every text body has `a:bodyPr` and at least one `a:p`";
228
+ readonly why: string;
229
+ }, {
230
+ readonly id: "V017";
231
+ readonly category: "required";
232
+ readonly severity: "fatal";
233
+ readonly evidence: "schema";
234
+ readonly title: "`p:graphicFrame` carries `p:xfrm` and `a:graphic`";
235
+ readonly why: string;
236
+ }, {
237
+ readonly id: "V018";
238
+ readonly category: "ids";
239
+ readonly severity: "fatal";
240
+ readonly evidence: "schema";
241
+ readonly title: "`p:sldId/@id` is 256…2147483647 and unique";
242
+ readonly why: string;
243
+ }, {
244
+ readonly id: "V019";
245
+ readonly category: "ids";
246
+ readonly severity: "fatal";
247
+ readonly evidence: "both";
248
+ readonly title: "master and layout ids are ≥ 2147483648 and unique **across both lists**";
249
+ readonly why: string;
250
+ }, {
251
+ readonly id: "V020";
252
+ readonly category: "ids";
253
+ readonly severity: "fatal";
254
+ readonly evidence: "both";
255
+ readonly title: "`p:cNvPr/@id` is unique within its part and ≤ 2147483647";
256
+ readonly why: string;
257
+ }, {
258
+ readonly id: "V021";
259
+ readonly category: "ids";
260
+ readonly severity: "warning";
261
+ readonly evidence: "schema";
262
+ readonly title: "a slide placeholder’s `(type, idx)` has a counterpart in its layout";
263
+ readonly why: string;
264
+ }, {
265
+ readonly id: "V022";
266
+ readonly category: "refused";
267
+ readonly severity: "fatal";
268
+ readonly evidence: "measured";
269
+ readonly title: "`p:ph/@type` is not `hdr` or `sldImg` outside a notes or handout part";
270
+ readonly why: string;
271
+ }, {
272
+ readonly id: "V023";
273
+ readonly category: "refused";
274
+ readonly severity: "fatal";
275
+ readonly evidence: "measured";
276
+ readonly title: "every geometry guide named is a guide that is defined";
277
+ readonly why: string;
278
+ }, {
279
+ readonly id: "V024";
280
+ readonly category: "refused";
281
+ readonly severity: "fatal";
282
+ readonly evidence: "both";
283
+ readonly title: "`c:tx` holds `c:strRef` or `c:v`, never `c:strLit`";
284
+ readonly why: string;
285
+ }, {
286
+ readonly id: "V025";
287
+ readonly category: "refused";
288
+ readonly severity: "fatal";
289
+ readonly evidence: "measured";
290
+ readonly title: "no `p:control`";
291
+ readonly why: string;
292
+ }, {
293
+ readonly id: "V026";
294
+ readonly category: "refused";
295
+ readonly severity: "fatal";
296
+ readonly evidence: "measured";
297
+ readonly title: "a `cs:chartStyle` carries all thirty-one of its entries";
298
+ readonly why: string;
299
+ }, {
300
+ readonly id: "V027";
301
+ readonly category: "preservation";
302
+ readonly severity: "fatal";
303
+ readonly evidence: "schema";
304
+ readonly needsBaseline: true;
305
+ readonly title: "a part nobody edited comes back out byte-for-byte";
306
+ readonly why: string;
307
+ }, {
308
+ readonly id: "V028";
309
+ readonly category: "preservation";
310
+ readonly severity: "fatal";
311
+ readonly evidence: "schema";
312
+ readonly needsBaseline: true;
313
+ readonly title: "no `mc:AlternateContent` branch and no `extLst` was rebuilt";
314
+ readonly why: string;
315
+ }, {
316
+ readonly id: "V029";
317
+ readonly category: "preservation";
318
+ readonly severity: "fatal";
319
+ readonly evidence: "both";
320
+ readonly needsBaseline: true;
321
+ readonly title: "text and field identity survive: `xml:space`, `a:fld/@id`, cached field text";
322
+ readonly why: string;
323
+ }];
324
+ type RuleId = (typeof RULES)[number]['id'];
325
+ declare function ruleById(id: string): Rule | undefined;
326
+ declare const RULE_IDS: readonly RuleId[];
327
+ /** Rules that need the package as it was opened. See `Rule.needsBaseline`. */
328
+ declare const BASELINE_RULES: readonly RuleId[];
329
+ //#endregion
330
+ //#region src/report/location.d.ts
331
+ /**
332
+ * Where a finding is, in a form a person can act on.
333
+ *
334
+ * ## Why an XPath and not a byte offset
335
+ *
336
+ * Both, actually - the offset is on the finding too, because it is what
337
+ * `cli bisect` needs in sub-phase 1.5 and what a diff viewer can highlight. But
338
+ * the offset is useless to a human: it names a position in a file that is
339
+ * usually one line long and often two megabytes wide, and it stops being true
340
+ * the moment anything above it changes.
341
+ *
342
+ * An XPath survives editing, survives reformatting, and can be pasted into
343
+ * every XML tool there is. More to the point, it is the only form in which
344
+ * a report about eleven parts is *readable* - `ppt/slides/slide3.xml` at
345
+ * `/p:sld/p:cSld/p:spTree/p:sp[2]/p:nvSpPr/p:cNvPr/@id` says what is wrong in
346
+ * one line, and byte 41 207 does not.
347
+ *
348
+ * ## Prefixes are the document's own, always
349
+ *
350
+ * The path is built from `qname` as written in the file - not from a canonical
351
+ * prefix table, and not from the namespace URI. If a part spells the
352
+ * PresentationML namespace `pp:` instead of `p:`, the path says `pp:`, because
353
+ * the point of the path is that a reader can find the element in the file in
354
+ * front of them. A canonicalised path would be *more correct* as an XPath
355
+ * expression and less useful as a location, and this is a diagnostic rather
356
+ * than a query.
357
+ *
358
+ * The same choice is made everywhere in this project for the same reason, and
359
+ * it is not stylistic: `mc:Ignorable` and `mc:Choice/@Requires` hold prefixes
360
+ * rather than URIs, so a prefix is load-bearing data in OOXML and inventing one
361
+ * is never a neutral act.
362
+ *
363
+ * ## The positional predicate
364
+ *
365
+ * `[n]` is emitted only where it disambiguates - that is, when the element has
366
+ * a sibling of the same `qname`. `/p:sld/p:cSld/p:spTree/p:sp[2]` is exact;
367
+ * `/p:sld[1]/p:cSld[1]/p:spTree[1]` is noise. Positions are 1-based, counting
368
+ * only elements of that name, which is what XPath means by `position()` inside
369
+ * a name test and is what every XML tool will agree with.
370
+ */
371
+ /** A location inside a part, or the part itself. */
372
+ interface Location {
373
+ /**
374
+ * The part name, with its leading slash: `/ppt/slides/slide1.xml`.
375
+ *
376
+ * `/` means the finding is about the package rather than about any one part -
377
+ * the archive shape, the content-type map, the relationship graph as a whole.
378
+ */
379
+ readonly part: string;
380
+ /** The path to the element or attribute, or `null` for a whole-part finding. */
381
+ readonly xpath: string | null;
382
+ /** Byte offset of the node in the part's source, or `null`. For `cli bisect`. */
383
+ readonly offset: number | null;
384
+ }
385
+ /** A location that names a part and nothing inside it. */
386
+ declare function partLocation(part: string): Location;
387
+ /** The location of the package itself: the archive, the content types, the graph. */
388
+ declare const PACKAGE_LOCATION: Location;
389
+ /**
390
+ * The XPath of an element, from the document root.
391
+ *
392
+ * Never throws and never returns an empty string: a detached element - one
393
+ * whose `parent` chain does not reach a root - still gets its own name, so a
394
+ * finding built from a node the caller synthesised is degraded rather than
395
+ * lost.
396
+ */
397
+ declare function xpathOf(element: XElement): string;
398
+ /** The XPath of an attribute: its owner's path, then `/@qname`. */
399
+ declare function xpathOfAttribute(owner: XElement, attr: XAttribute | string): string;
400
+ /** A location for an element inside a named part. */
401
+ declare function elementLocation(part: string, element: XElement): Location;
402
+ /**
403
+ * A location for one attribute of an element.
404
+ *
405
+ * The offset is the attribute's own start when the element carries it, and the
406
+ * element's start when it does not - which is the case that matters, because a
407
+ * *missing* required attribute is a finding and it has to point somewhere.
408
+ */
409
+ declare function attributeLocation(part: string, element: XElement, qname: string): Location;
410
+ /**
411
+ * `line:column` for an offset, 1-based, or `null`.
412
+ *
413
+ * Not part of `Location`, because computing it means scanning the source from
414
+ * the beginning and a report with three hundred findings would scan it three
415
+ * hundred times. Callers that render a report for a human ask for it once, at
416
+ * the point of rendering, for the findings they are about to show.
417
+ */
418
+ declare function lineColumn(source: string, offset: number): {
419
+ line: number;
420
+ column: number;
421
+ };
422
+ /** Document order, for sorting findings within a part. */
423
+ declare function inDocumentOrder(a: XNode, b: XNode): number;
424
+ /** The root element's qname, for the handful of rules that dispatch on it. */
425
+ declare function rootName(document: XDocument): string;
426
+ //#endregion
427
+ //#region src/report/report.d.ts
428
+ /**
429
+ * What validation produces.
430
+ *
431
+ * ## Origin, and why it is computed rather than guessed
432
+ *
433
+ * The hardest question this package has to answer is not "is this file valid".
434
+ * It is **"did we break it"**, and those are different questions with different
435
+ * consequences. A fatal finding in a deck the user imported ten seconds ago is
436
+ * information; the same finding in a deck they just edited is a bug in us, and
437
+ * handing over the bytes would give them a repair prompt with no explanation.
438
+ *
439
+ * Refusing both would be the strict-looking choice and it is the wrong one. It
440
+ * would mean that a deck with one pre-existing defect - a dangling image
441
+ * relationship, say, which PowerPoint tolerates and which is common in files
442
+ * that have been through three other tools - could be opened in this editor and
443
+ * never saved again. The editor would be refusing to give the user back their
444
+ * own file over a problem it did not cause and cannot fix. `PartStore.write`
445
+ * already made exactly this call for dangling relationships, and this is the
446
+ * same call generalised to all twenty-nine rules.
447
+ *
448
+ * So `origin` is not a per-rule judgement call. It is computed by running the
449
+ * rules a second time against the package **as it was opened** and differencing
450
+ * the two reports: a finding that is in both is `inherited`, one that is only in
451
+ * the new report is `introduced`. That is exact, it needs no rule to reason
452
+ * about history, and it cannot drift from what the rules actually do.
453
+ *
454
+ * The second pass only happens when the first found something fatal, so a clean
455
+ * export - the overwhelmingly common case - pays nothing for it.
456
+ */
457
+ type Origin =
458
+ /** The finding is in the package we are about to write and was not in the original. */
459
+ 'introduced' |
460
+ /** The finding was already there when the package was opened. */
461
+ 'inherited' |
462
+ /** No baseline was supplied, so the question was not asked. */
463
+ 'unknown';
464
+ interface Finding {
465
+ readonly rule: RuleId;
466
+ readonly severity: Severity;
467
+ readonly category: RuleCategory;
468
+ readonly where: Location;
469
+ /**
470
+ * What is wrong, in one sentence, naming the values involved.
471
+ *
472
+ * Written to be read on its own: a message that says "invalid id" and leaves
473
+ * the reader to go and look is a message that costs more than it saves.
474
+ */
475
+ readonly message: string;
476
+ readonly origin: Origin;
477
+ }
478
+ /** A rule that did not run, and why. Never silent - see `Report.skipped`. */
479
+ interface SkippedRule {
480
+ readonly rule: RuleId;
481
+ readonly why: string;
482
+ }
483
+ /** Something that could not be read. Not a rule violation; a gap in coverage. */
484
+ interface ReadProblem {
485
+ readonly part: string;
486
+ readonly message: string;
487
+ }
488
+ interface Report {
489
+ /** Sorted: fatal before warning, then by part, then by offset. */
490
+ readonly findings: readonly Finding[];
491
+ /** Rules that ran. */
492
+ readonly checked: readonly RuleId[];
493
+ /**
494
+ * Rules that did not run, and why.
495
+ *
496
+ * Present so that a report can never quietly mean less than it looks like it
497
+ * means. Six rules need the package as it was opened; asked to validate a
498
+ * package with no baseline they report nothing, and "reported nothing" and
499
+ * "found nothing" are the same shape on a screen and opposite in meaning.
500
+ */
501
+ readonly skipped: readonly SkippedRule[];
502
+ /** Parts that could not be read, and the rules that therefore did not see them. */
503
+ readonly problems: readonly ReadProblem[];
504
+ /** Fatal findings this session introduced. The number that refuses an export. */
505
+ readonly blocking: number;
506
+ /** True when nothing blocking was found. Not the same as "no findings". */
507
+ readonly ok: boolean;
508
+ }
509
+ /**
510
+ * The key two reports are differenced on.
511
+ *
512
+ * Includes the message, because the message carries the instance - the id that
513
+ * collided, the guide that was not defined. Two findings of the same rule at
514
+ * the same place with different values are different findings, and treating
515
+ * them as one would let an introduced defect hide behind an inherited one.
516
+ */
517
+ declare function findingKey(finding: Finding): string;
518
+ declare function buildReport(input: {
519
+ readonly findings: readonly Finding[];
520
+ readonly checked: readonly RuleId[];
521
+ readonly skipped: readonly SkippedRule[];
522
+ readonly problems: readonly ReadProblem[];
523
+ }): Report;
524
+ declare function isReport(value: unknown): value is Report;
525
+ interface FormatOptions {
526
+ /** Include `inherited` findings. Default true. */
527
+ readonly inherited?: boolean;
528
+ /** Include warnings. Default true. */
529
+ readonly warnings?: boolean;
530
+ /** Append each rule's `why`, once per rule. Default false. */
531
+ readonly explain?: boolean;
532
+ }
533
+ /**
534
+ * A report as text.
535
+ *
536
+ * Grouped by part rather than by rule, because the question a reader has is
537
+ * "what is wrong with this file", and a file is a set of parts. Grouping by
538
+ * rule is the right shape for the corpus gate and the wrong one here.
539
+ */
540
+ declare function formatReport(report: Report, options?: FormatOptions): string;
541
+ //#endregion
542
+ //#region src/context.d.ts
543
+ /**
544
+ * The state a rule reads, and the one method it writes.
545
+ *
546
+ * Every rule is `(ctx: Context) => void`. That is a deliberately small
547
+ * interface, and it is the same shape `checkLayering({manifests, catalog})` and
548
+ * `checkCorpus({manifests, files, ...})` already have in this repository, for
549
+ * the same reason: a rule that takes a context and returns nothing is a rule
550
+ * that can be run against a package assembled in a test, with no temporary
551
+ * directory, no archive on disk and no other twenty-eight rules running beside
552
+ * it obscuring which one fired.
553
+ *
554
+ * ## Everything is lazy, and everything is cached
555
+ *
556
+ * Twelve of the rules want the parsed tree of every XML part. Parsing each part
557
+ * once per rule would be twelve passes over a deck that can be two hundred
558
+ * megabytes. Parsing all of them up front would be one pass too many for the
559
+ * package-level rules, which need no trees at all.
560
+ *
561
+ * So `document()` parses on first ask and remembers. A part that is not XML
562
+ * returns `null` and is not asked again. A part that will not *parse* also
563
+ * returns `null` - and records a `ReadProblem`, because a rule that silently
564
+ * saw nothing is indistinguishable from a rule that found nothing, and those
565
+ * are opposite answers.
566
+ */
567
+ interface Context {
568
+ /** The package about to be handed over, or the one being inspected. */
569
+ readonly store: PartStore;
570
+ /** The archive bytes, when the caller had them. `null` when only a store was given. */
571
+ readonly bytes: Uint8Array | null;
572
+ /**
573
+ * The archive those bytes describe, central directory only - nothing is
574
+ * inflated to build it.
575
+ *
576
+ * `PartStore` deliberately does not expose this. It drops directory entries
577
+ * on the way in, because a directory entry is not a part and carrying a thing
578
+ * with no part name through the whole system would mean every consumer has to
579
+ * remember it is there. That is right for the store and it leaves three rules
580
+ * with nothing to look at: whether the archive has directory entries, whether
581
+ * it carries a ZIP64 record, and what `[Content_Types].xml` actually *says* as
582
+ * opposed to what parsing it produced.
583
+ *
584
+ * `null` when the caller passed a store and no bytes; the rules that need it
585
+ * are then skipped with a reason rather than passing vacuously.
586
+ */
587
+ readonly archive: ZipArchive | null;
588
+ /** The package as it was opened. `null` for an inspection with no history. */
589
+ readonly baseline: PartStore | null;
590
+ /** Part names in package order, `[Content_Types].xml` excluded - it is not a part. */
591
+ parts(): readonly PartName[];
592
+ /** A part's bytes, or `null` if it is not there. Never throws. */
593
+ read(part: string): Uint8Array | null;
594
+ /** Content type, or `undefined` when nothing in the map covers the part. */
595
+ contentType(part: string): string | undefined;
596
+ /** The parsed tree, cached. `null` for binary parts and for parts that will not parse. */
597
+ document(part: string): XDocument | null;
598
+ /**
599
+ * `[Content_Types].xml` as the archive holds it, parsed.
600
+ *
601
+ * Not reachable through `parts()`: the content-type stream is not a part. It
602
+ * has no content type of its own and nothing ever relates to it, which is why
603
+ * `PartStore` keeps it outside the part map entirely.
604
+ *
605
+ * Read from the markup rather than from `store.contentTypes` for the reason
606
+ * `rels.ts` gives at length: `ContentTypes.parse` collapses a duplicate
607
+ * `Default` that agrees with itself and refuses one that does not, so a rule
608
+ * that asks the parsed map whether there are duplicates is asking the one
609
+ * object in the system that cannot answer.
610
+ */
611
+ contentTypesDocument(): XDocument | null;
612
+ /** The same three, against the baseline. `null` throughout when there is none. */
613
+ baselineRead(part: string): Uint8Array | null;
614
+ baselineDocument(part: string): XDocument | null;
615
+ /**
616
+ * True when this session replaced or added the part.
617
+ *
618
+ * Derived from `PartInfo.fromArchive`, which is the store's own record of
619
+ * whether a part's bytes still come from the archive it was opened from. It
620
+ * is the only honest source for "did we touch this" - a byte comparison
621
+ * cannot tell an edit that happened to produce identical bytes from no edit
622
+ * at all, and the preservation rules care about the difference.
623
+ */
624
+ edited(part: string): boolean;
625
+ add(rule: RuleId, where: Location, message: string): void;
626
+ problem(part: string, message: string): void;
627
+ }
628
+ interface ContextInput {
629
+ readonly store: PartStore;
630
+ readonly bytes?: Uint8Array | null;
631
+ readonly archive?: ZipArchive | null;
632
+ readonly baseline?: PartStore | null;
633
+ }
634
+ declare class RuntimeContext implements Context {
635
+ #private;
636
+ readonly store: PartStore;
637
+ readonly bytes: Uint8Array | null;
638
+ readonly archive: ZipArchive | null;
639
+ readonly baseline: PartStore | null;
640
+ readonly findings: Finding[];
641
+ readonly problems: ReadProblem[];
642
+ constructor(input: ContextInput);
643
+ parts(): readonly PartName[];
644
+ read(part: string): Uint8Array | null;
645
+ contentType(part: string): string | undefined;
646
+ document(part: string): XDocument | null;
647
+ baselineDocument(part: string): XDocument | null;
648
+ contentTypesDocument(): XDocument | null;
649
+ baselineRead(part: string): Uint8Array | null;
650
+ edited(part: string): boolean;
651
+ add(rule: RuleId, where: Location, message: string): void;
652
+ problem(part: string, message: string): void;
653
+ }
654
+ declare function createContext(input: ContextInput): RuntimeContext;
655
+ //#endregion
656
+ //#region src/validate.d.ts
657
+ interface ValidateOptions {
658
+ /**
659
+ * The package about to be handed over.
660
+ *
661
+ * On an export this is the store the writer emitted from, **not** a store
662
+ * re-opened from the written bytes. The difference is `V027`: a re-opened
663
+ * store thinks every part came from the archive, so the one rule that asks
664
+ * "did anything change that nobody edited" would have no history to ask
665
+ * about and would answer yes about every deliberate edit.
666
+ */
667
+ readonly store?: PartStore;
668
+ /**
669
+ * The archive, when the caller has it.
670
+ *
671
+ * Required for `V003` and for the duplicate half of `V002` - a duplicate
672
+ * `<Default>` is visible only in the markup, because the parsed content-type
673
+ * map collapses one that agrees with itself. Given a `store` and no `bytes`,
674
+ * those are skipped and the report says so.
675
+ */
676
+ readonly bytes?: Uint8Array;
677
+ /** The package as it was opened. The six preservation rules need it. */
678
+ readonly baseline?: PartStore;
679
+ /** The baseline's archive, so an inherited `V003` can be recognised as inherited. */
680
+ readonly baselineBytes?: Uint8Array;
681
+ /** A subset to run. Defaults to all twenty-nine. */
682
+ readonly rules?: readonly RuleId[];
683
+ /** Passed to `readZip` when `bytes` are given. */
684
+ readonly zip?: ReadZipOptions;
685
+ }
686
+ /**
687
+ * Check a package against the rules.
688
+ *
689
+ * Never throws for anything it finds - a broken package produces a report with
690
+ * findings in it, which is the shape a caller can render. It throws only when
691
+ * it was handed something it cannot open at all.
692
+ */
693
+ declare function validatePackage(options: ValidateOptions): Report;
694
+ /**
695
+ * Validate, and refuse to go on if we broke something.
696
+ *
697
+ * The one call an export path makes. It throws rather than returning a boolean
698
+ * because the failure has to be impossible to ignore: PowerPoint emits no
699
+ * diagnostic log, its refusal message names no part and no line, and a user who
700
+ * gets a repair prompt has no way at all to find out why. This is the only
701
+ * feedback loop that exists, and a caller who forgot to check a return value
702
+ * would have removed it.
703
+ */
704
+ declare function assertValid(options: ValidateOptions): Report;
705
+ //#endregion
706
+ export { BASELINE_RULES, type Context, type ContextInput, type Evidence, type Finding, type FormatOptions, type Location, type Origin, PACKAGE_LOCATION, RULES, RULE_IDS, type ReadProblem, type Report, type Rule, type RuleCategory, type RuleId, type Severity, type SkippedRule, VALIDATE_ERROR_CODES, ValidateError, type ValidateErrorCode, type ValidateErrorDetail, type ValidateOptions, assertValid, attributeLocation, buildReport, createContext, elementLocation, findingKey, formatReport, inDocumentOrder, isReport, isValidateError, lineColumn, partLocation, rootName, ruleById, validatePackage, xpathOf, xpathOfAttribute };
707
+ //# sourceMappingURL=index.d.ts.map