validate
The 34 rules a .pptx must not break, with the part and XPath of everything that fired.
The 34 rules a .pptx must not break, with the part and XPath of everything that fired.
npm install @pptx-studio/validate @pptx-studio/opc
What it is
PowerPoint repairs or refuses a file silently, and names no part and no line when it does. @pptx-studio/validate runs 34 rules over a package - half derived from the schema, half from decks PowerPoint declined one change at a time - and reports findings with a part, an XPath and whether the problem was introduced by this session or inherited from the file.
assertValid throws rather than returning a boolean, so a writer cannot hand over bytes it just broke.
30-second example
const report = validatePackage({ bytes }); // never throws for a finding
report.ok; // false when anything fatal fired
report.findings; // [{ rule: 'V022', severity: 'fatal', where: { part, xpath }, message, origin }]
report.skipped; // the baseline rules, with why
console.log(formatReport(report, { explain: true }));Key concepts
Findings are not errors
validatePackage never throws for a finding; it returns them. A rule that could not run is listed under skipped with the reason - reported nothing and found nothing are opposite meanings.
Two halves: derivable and measured
Each rule carries its evidence: schema for a rule ECMA-376 states, measured for one found by making PowerPoint refuse a file, both when both hold.
"Did we break it", not "is it valid"
With a baseline, each finding says whether it was introduced or inherited. A deck that was already broken can still be exported without making it worse.
What passing does not mean
The rules are the ways PowerPoint is known to refuse a file, not every way it could. No hosted runner has Office, so "opens in PowerPoint" is measured by hand.
API reference
40 exports of @pptx-studio/validate 0.5.0, read from the package this site installed. Click a name for its declaration.
src/errors.ts
VALIDATE_ERROR_CODESconstMachine-readable failure reasons.const VALIDATE_ERROR_CODES: readonly ["ERR_VALIDATION_FAILED", "ERR_UNVALIDATABLE", "ERR_UNKNOWN_RULE"];
ValidateErrorCodetypetype ValidateErrorCode = (typeof VALIDATE_ERROR_CODES)[number];
ValidateErrorDetailtypeinterface ValidateErrorDetail { /** The part the failure is about, if it is about one. */ readonly part?: string; /** The rule id a caller got wrong, for `ERR_UNKNOWN_RULE`. */ readonly rule?: string; /** * The full report, for `ERR_VALIDATION_FAILED`. * * Typed `unknown` rather than `Report` on purpose: `report.ts` imports this * module for the error, so typing this field would make the two circular. * Callers narrow it with `isReport`. */ readonly report?: unknown; }ValidateErrorclassclass ValidateError extends Error { readonly code: ValidateErrorCode; readonly detail: ValidateErrorDetail; constructor(code: ValidateErrorCode, message: string, detail?: ValidateErrorDetail); }isValidateErrorfunctionfunction isValidateError(value: unknown): value is ValidateError;
src/rules/rules.ts
RuleCategorytypeThe rules, and the evidence for each:schema,measured(PowerPoint refused or repaired it), orboth. A fatal a caller introduced refuses an export; the same fatal inherited from the opened file is reported and does not. The README lists every rule.type RuleCategory = 'package' | 'relationships' | 'order' | 'required' | 'ids' | 'refused' | 'preservation';
Severitytypetype Severity = 'fatal' | 'warning';
EvidencetypeWhere the rule comes from. See the header.type Evidence = 'schema' | 'measured' | 'both';
Ruletypeinterface Rule { readonly id: RuleId; readonly category: RuleCategory; readonly severity: Severity; readonly evidence: Evidence; /** One line, imperative, in the voice of the thing that must be true. */ readonly title: string; /** Why the rule exists, for whoever is about to argue with it. */ readonly why: string; /** * True when the rule needs the package as it was opened; without a baseline it is reported as * skipped, never as passed. */ readonly needsBaseline?: true; }RULESconstconst RULES: readonly [{ readonly id: "V001"; readonly category: "package"; readonly severity: "fatal"; readonly evidence: "both"; readonly title: "every part resolves to a content type"; readonly why: string; }, { readonly id: "V002"; readonly category: "package"; readonly severity: "fatal"; readonly evidence: "both"; readonly title: "the content-type map is internally consistent"; readonly why: string; }, { readonly id: "V003"; readonly category: "package"; readonly severity: "fatal"; readonly evidence: "schema"; readonly title: "the archive carries no directory entries and no ZIP64 record"; readonly why: string; }, { readonly id: "V004"; readonly category: "package"; readonly severity: "fatal"; readonly evidence: "both"; readonly title: "part names are ASCII, well formed, and free of pointless percent-escapes"; readonly why: string; }, { readonly id: "V005"; readonly category: "package"; readonly severity: "fatal"; readonly evidence: "both"; readonly title: "there is exactly one main presentation part, of a matching content type"; readonly why: string; }, { readonly id: "V006"; readonly category: "relationships"; readonly severity: "fatal"; readonly evidence: "both"; readonly title: "every relationship reference resolves in its own part’s `.rels`"; readonly why: string; }, { readonly id: "V007"; readonly category: "relationships"; readonly severity: "fatal"; readonly evidence: "schema"; readonly title: "relationship ids are unique within a `.rels` and match the `xsd:ID` grammar"; readonly why: string; }, { readonly id: "V008"; readonly category: "relationships"; readonly severity: "fatal"; readonly evidence: "both"; readonly title: "internal targets resolve inside the package, relative to the source part’s folder"; readonly why: string; }, { readonly id: "V009"; readonly category: "relationships"; readonly severity: "fatal"; readonly evidence: "measured"; readonly title: "the relationship edges a part must have, and the ones it must not"; readonly why: string; }, { readonly id: "V010"; readonly category: "order"; readonly severity: "fatal"; readonly evidence: "both"; readonly title: "children appear in schema-sequence order"; readonly why: string; }, { readonly id: "V011"; readonly category: "order"; readonly severity: "fatal"; readonly evidence: "schema"; readonly title: "`extLst` is last among its siblings, and every `a:ext` carries a `@uri`"; readonly why: string; }, { readonly id: "V012"; readonly category: "order"; readonly severity: "fatal"; readonly evidence: "both"; readonly title: "no child appears in a parent whose content model has no place for it"; readonly why: string; }, { readonly id: "V013"; readonly category: "required"; readonly severity: "fatal"; readonly evidence: "schema"; readonly title: "`p:presentation` carries `p:notesSz`"; readonly why: string; }, { readonly id: "V014"; readonly category: "required"; readonly severity: "fatal"; readonly evidence: "schema"; readonly title: "`p:clrMap` carries all twelve attributes"; readonly why: string; }, { readonly id: "V015"; readonly category: "required"; readonly severity: "fatal"; readonly evidence: "schema"; readonly title: "`p:spTree` begins with `p:nvGrpSpPr` then `p:grpSpPr`"; readonly why: string; }, { readonly id: "V016"; readonly category: "required"; readonly severity: "fatal"; readonly evidence: "both"; readonly title: "every text body has `a:bodyPr` and at least one `a:p`"; readonly why: string; }, { readonly id: "V017"; readonly category: "required"; readonly severity: "fatal"; readonly evidence: "schema"; readonly title: "`p:graphicFrame` carries `p:xfrm` and `a:graphic`"; readonly why: string; }, { readonly id: "V018"; readonly category: "ids"; readonly severity: "fatal"; readonly evidence: "schema"; readonly title: "`p:sldId/@id` is 256…2147483647 and unique"; readonly why: string; }, { readonly id: "V019"; readonly category: "ids"; readonly severity: "fatal"; readonly evidence: "both"; readonly title: "master and layout ids are ≥ 2147483648 and unique **across both lists**"; readonly why: string; }, { readonly id: "V020"; readonly category: "ids"; readonly severity: "fatal"; readonly evidence: "both"; readonly title: "`p:cNvPr/@id` is unique within its part and ≤ 2147483647"; readonly why: string; }, { readonly id: "V021"; readonly category: "ids"; readonly severity: "warning"; readonly evidence: "schema"; readonly title: "a slide placeholder’s `(type, idx)` has a counterpart in its layout"; readonly why: string; }, { readonly id: "V022"; readonly category: "refused"; readonly severity: "fatal"; readonly evidence: "measured"; readonly title: "`p:ph/@type` is not `hdr` or `sldImg` outside a notes or handout part"; readonly why: string; }, { readonly id: "V023"; readonly category: "refused"; readonly severity: "fatal"; readonly evidence: "measured"; readonly title: "every geometry guide named is a guide that is defined"; readonly why: string; }, { readonly id: "V024"; readonly category: "refused"; readonly severity: "fatal"; readonly evidence: "both"; readonly title: "`c:tx` holds `c:strRef` or `c:v`, never `c:strLit`"; readonly why: string; }, { readonly id: "V025"; readonly category: "refused"; readonly severity: "fatal"; readonly evidence: "measured"; readonly title: "no `p:control`"; readonly why: string; }, { readonly id: "V026"; readonly category: "refused"; readonly severity: "fatal"; readonly evidence: "measured"; readonly title: "a `cs:chartStyle` carries all thirty-one of its entries"; readonly why: string; }, { readonly id: "V027"; readonly category: "preservation"; readonly severity: "fatal"; readonly evidence: "schema"; readonly needsBaseline: true; readonly title: "a part nobody edited comes back out byte-for-byte"; readonly why: string; }, { readonly id: "V028"; readonly category: "preservation"; readonly severity: "fatal"; readonly evidence: "schema"; readonly needsBaseline: true; readonly title: "no `mc:AlternateContent` branch and no `extLst` was rebuilt"; readonly why: string; }, { readonly id: "V029"; readonly category: "preservation"; readonly severity: "fatal"; readonly evidence: "both"; readonly needsBaseline: true; readonly title: "text and field identity survive: `xml:space`, `a:fld/@id`, cached field text"; readonly why: string; }, { readonly id: "V030"; readonly category: "required"; readonly severity: "warning"; readonly evidence: "measured"; readonly title: "a table is the one PowerPoint reads: a cell per column in every row, a flag where a span covers"; readonly why: string; }, { readonly id: "V031"; readonly category: "required"; readonly severity: "fatal"; readonly evidence: "both"; readonly title: "`a:gridCol/@w`, `a:tr/@h`, and typed `gridSpan`, `rowSpan`, `hMerge`, `vMerge`"; readonly why: string; }, { readonly id: "V032"; readonly category: "ids"; readonly severity: "warning"; readonly evidence: "measured"; readonly title: "a table names one of PowerPoint's 74 built-in table styles, or none"; readonly why: string; }, { readonly id: "V033"; readonly category: "required"; readonly severity: "fatal"; readonly evidence: "both"; readonly title: "a table style id is a braced GUID, and the table-style part is one PowerPoint keeps"; readonly why: string; }, { readonly id: "V034"; readonly category: "required"; readonly severity: "warning"; readonly evidence: "measured"; readonly title: "a border a cell writes is the one PowerPoint draws on that edge"; readonly why: string; }];RuleIdtypetype RuleId = (typeof RULES)[number]['id'];
ruleByIdfunctionfunction ruleById(id: string): Rule | undefined;
RULE_IDSconstconst RULE_IDS: readonly RuleId[];
BASELINE_RULESconstRules that need the package as it was opened. SeeRule.needsBaseline.const BASELINE_RULES: readonly RuleId[];
src/report/location.ts
LocationtypeA location inside a part, or the part itself.interface Location { /** * The part name, with its leading slash: `/ppt/slides/slide1.xml`. * * `/` means the finding is about the package rather than about any one part - * the archive shape, the content-type map, the relationship graph as a whole. */ readonly part: string; /** The path to the element or attribute, or `null` for a whole-part finding. */ readonly xpath: string | null; /** Byte offset of the node in the part's source, or `null`. For `cli bisect`. */ readonly offset: number | null; }partLocationfunctionA location that names a part and nothing inside it.function partLocation(part: string): Location;
PACKAGE_LOCATIONconstThe location of the package itself: the archive, the content types, the graph.const PACKAGE_LOCATION: Location;
xpathOffunctionThe XPath of an element, from the document root.function xpathOf(element: XElement): string;
xpathOfAttributefunctionThe XPath of an attribute: its owner's path, then/@qname.function xpathOfAttribute(owner: XElement, attr: XAttribute | string): string;
elementLocationfunctionA location for an element inside a named part.function elementLocation(part: string, element: XElement): Location;
attributeLocationfunctionA location for one attribute of an element.function attributeLocation(part: string, element: XElement, qname: string): Location;
lineColumnfunctionline:columnfor an offset, 1-based, ornull.function lineColumn(source: string, offset: number): { line: number; column: number; };inDocumentOrderfunctionDocument order, for sorting findings within a part.function inDocumentOrder(a: XNode, b: XNode): number;
rootNamefunctionThe root element's qname, for the handful of rules that dispatch on it.function rootName(document: XDocument): string;
src/report/report.ts
OrigintypeA finding isinheritedwhen the package as it was opened already had it,introducedwhen it did not, computed by running the rules on both and differencing. Only an introduced fatal refuses an export, so a deck with a defect it arrived with can still be saved.type Origin = /** The finding is in the package we are about to write and was not in the original. */ 'introduced' | /** The finding was already there when the package was opened. */ 'inherited' | /** No baseline was supplied, so the question was not asked. */ 'unknown';
Findingtypeinterface Finding { readonly rule: RuleId; readonly severity: Severity; readonly category: RuleCategory; readonly where: Location; /** * What is wrong, in one sentence, naming the values involved. * * Written to be read on its own: a message that says "invalid id" and leaves * the reader to go and look is a message that costs more than it saves. */ readonly message: string; readonly origin: Origin; }SkippedRuletypeA rule that did not run, and why. Never silent - seeReport.skipped.interface SkippedRule { readonly rule: RuleId; readonly why: string; }ReadProblemtypeSomething that could not be read. Not a rule violation; a gap in coverage.interface ReadProblem { readonly part: string; readonly message: string; }Reporttypeinterface Report { /** Sorted: fatal before warning, then by part, then by offset. */ readonly findings: readonly Finding[]; /** Rules that ran. */ readonly checked: readonly RuleId[]; /** * Rules that did not run, and why. * * Present so that a report can never quietly mean less than it looks like it * means. Six rules need the package as it was opened; asked to validate a * package with no baseline they report nothing, and "reported nothing" and * "found nothing" are the same shape on a screen and opposite in meaning. */ readonly skipped: readonly SkippedRule[]; /** Parts that could not be read, and the rules that therefore did not see them. */ readonly problems: readonly ReadProblem[]; /** Fatal findings this session introduced. The number that refuses an export. */ readonly blocking: number; /** True when nothing blocking was found. Not the same as "no findings". */ readonly ok: boolean; }findingKeyfunctionThe key two reports are differenced on.function findingKey(finding: Finding): string;
buildReportfunctionfunction buildReport(input: { readonly findings: readonly Finding[]; readonly checked: readonly RuleId[]; readonly skipped: readonly SkippedRule[]; readonly problems: readonly ReadProblem[]; }): Report;isReportfunctionfunction isReport(value: unknown): value is Report;
FormatOptionstypeinterface FormatOptions { /** Include `inherited` findings. Default true. */ readonly inherited?: boolean; /** Include warnings. Default true. */ readonly warnings?: boolean; /** Append each rule's `why`, once per rule. Default false. */ readonly explain?: boolean; }formatReportfunctionA report as text.function formatReport(report: Report, options?: FormatOptions): string;
src/context.ts
ContexttypeThe state a rule reads, and the one method it writes. Every rule is(ctx: Context) => void, so one runs alone against a package built in a test. Trees parse on first ask and are cached; a part that will not parse returnsnulland records aReadProblem.interface Context { /** The package about to be handed over, or the one being inspected. */ readonly store: PartStore; /** The archive bytes, when the caller had them. `null` when only a store was given. */ readonly bytes: Uint8Array | null; /** * The archive those bytes describe, central directory only - nothing is * inflated to build it. * * `PartStore` deliberately does not expose this. It drops directory entries * on the way in, because a directory entry is not a part and carrying a thing * with no part name through the whole system would mean every consumer has to * remember it is there. That is right for the store and it leaves three rules * with nothing to look at: whether the archive has directory entries, whether * it carries a ZIP64 record, and what `[Content_Types].xml` actually *says* as * opposed to what parsing it produced. * * `null` when the caller passed a store and no bytes; the rules that need it * are then skipped with a reason rather than passing vacuously. */ readonly archive: ZipArchive | null; /** The package as it was opened. `null` for an inspection with no history. */ readonly baseline: PartStore | null; /** Part names in package order, `[Content_Types].xml` excluded - it is not a part. */ parts(): readonly PartName[]; /** A part's bytes, or `null` if it is not there. Never throws. */ read(part: string): Uint8Array | null; /** Content type, or `undefined` when nothing in the map covers the part. */ contentType(part: string): string | undefined; /** The parsed tree, cached. `null` for binary parts and for parts that will not parse. */ document(part: string): XDocument | null; /** * `[Content_Types].xml` as the archive holds it, parsed. * * Not reachable through `parts()`: the content-type stream is not a part. It * has no content type of its own and nothing ever relates to it, which is why * `PartStore` keeps it outside the part map entirely. * * Read from the markup rather than from `store.contentTypes` for the reason * `rels.ts` gives at length: `ContentTypes.parse` collapses a duplicate * `Default` that agrees with itself and refuses one that does not, so a rule * that asks the parsed map whether there are duplicates is asking the one * object in the system that cannot answer. */ contentTypesDocument(): XDocument | null; /** The same three, against the baseline. `null` throughout when there is none. */ baselineRead(part: string): Uint8Array | null; baselineDocument(part: string): XDocument | null; /** * True when this session replaced or added the part. * * Derived from `PartInfo.fromArchive`, which is the store's own record of * whether a part's bytes still come from the archive it was opened from. It * is the only honest source for "did we touch this" - a byte comparison * cannot tell an edit that happened to produce identical bytes from no edit * at all, and the preservation rules care about the difference. */ edited(part: string): boolean; add(rule: RuleId, where: Location, message: string): void; problem(part: string, message: string): void; }ContextInputtypeinterface ContextInput { readonly store: PartStore; readonly bytes?: Uint8Array | null; readonly archive?: ZipArchive | null; readonly baseline?: PartStore | null; }createContextfunctionfunction createContext(input: ContextInput): RuntimeContext;
src/validate.ts
ValidateOptionstypeinterface ValidateOptions { /** * The package about to be handed over. * * On an export this is the store the writer emitted from, **not** a store * re-opened from the written bytes. The difference is `V027`: a re-opened * store thinks every part came from the archive, so the one rule that asks * "did anything change that nobody edited" would have no history to ask * about and would answer yes about every deliberate edit. */ readonly store?: PartStore; /** * The archive, when the caller has it. * * Required for `V003` and for the duplicate half of `V002` - a duplicate * `<Default>` is visible only in the markup, because the parsed content-type * map collapses one that agrees with itself. Given a `store` and no `bytes`, * those are skipped and the report says so. */ readonly bytes?: Uint8Array; /** The package as it was opened. The preservation rules need it. */ readonly baseline?: PartStore; /** The baseline's archive, so an inherited `V003` can be recognised as inherited. */ readonly baselineBytes?: Uint8Array; /** A subset to run. Defaults to every rule. */ readonly rules?: readonly RuleId[]; /** Passed to `readZip` when `bytes` are given. */ readonly zip?: ReadZipOptions; }validatePackagefunctionCheck a package against the rules.function validatePackage(options: ValidateOptions): Report;
assertValidfunctionValidate, and refuse to go on if we broke something.function assertValid(options: ValidateOptions): Report;
Errors
Throws ValidateError, whose code is one of:
- ERR_VALIDATION_FAILED
- ERR_UNVALIDATABLE
- ERR_UNKNOWN_RULE
Try it live
Reading the deck…