writer
Export a deck: re-serialise only what changed, sweep orphaned media, check preservation, run the firewall.
Exports the deck, re-serialising only what changed, checking preservation, and refusing to return a file it broke.
npm install @pptx-studio/writer @pptx-studio/opc @pptx-studio/xml
What it is
openPackage opens a deck twice - once to edit and once as an untouched baseline. exportPackage writes the archive with only the rewritten parts serialised afresh, checks that every other entry is byte-identical to the baseline, runs the 34 rules, and returns bytes only if both pass. comparePackages is the round-trip comparator the CI badge is made of, and bisectPackages narrows a breaking change to the smallest edit that reproduces it.
30-second example
const pkg = openPackage(bytes); // { store, baseline, baselineBytes }
const part = '/ppt/slides/slide1.xml';
const doc = parseXml(pkg.store.read(part));
const title = [...descendantElements(doc.root)].find((el) => el.qname === 'a:t');
const node = title?.children[0];
if (node !== undefined && node.type === 'text') {
applyEdit({ kind: 'setValue', node, value: 'Hello from the browser' });
}
pkg.store.replacePart(part, serializeXml(doc));
const result = exportPackage(pkg); // throws rather than return a file PowerPoint would repair
result.rewritten; // ['/ppt/slides/slide1.xml']
result.streamed; // every other part, copied still compressed
const blob = new Blob([result.bytes.slice()], {
type: 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
});Key concepts
Why the writer may not understand the document
It streams parts it never parsed. That is the property that lets charts, SmartArt, macros and OLE objects survive a save from code that cannot draw them.
The baseline is not optional in practice
Without it three rules, the media sweep and the preservation check quietly skip themselves. openPackage gives you both stores from one set of bytes.
Media collection is timid on purpose
Only media orphaned by this session's edits is swept, and only when the reachability walk was complete; a part something unparsed might still point at is kept.
Round trip is not a byte comparison
XML parts are compared canonically, relationships as graphs with opaque ids, binaries by digest. Entry order and deflate level differ legitimately between two archives holding one document.
API reference
53 exports of @pptx-studio/writer 0.1.6, read from the package this site installed. Click a name for its declaration.
src/errors.ts
WRITER_ERROR_CODESconstThe writer's own failures: a hook that threw, a collection not shown to be safe, and its one assertion on its own output. Container and rule failures stayOpcErrorandValidateError.const WRITER_ERROR_CODES: readonly ["ERR_PREPARE_FAILED", "ERR_COLLECTION_UNSAFE", "ERR_PRESERVATION_BROKEN"];
WriterErrorCodetypetype WriterErrorCode = (typeof WRITER_ERROR_CODES)[number];
WriterErrorDetailtypeinterface WriterErrorDetail { /** The prepare hook that threw, for `ERR_PREPARE_FAILED`. */ readonly hook?: string; /** The part the failure is about, when it is about one. */ readonly part?: string; /** Relationship parts that would not parse, for `ERR_COLLECTION_UNSAFE`. */ readonly blockedBy?: readonly string[]; }WriterErrorclassclass WriterError extends Error { readonly name = "WriterError"; readonly code: WriterErrorCode; readonly detail: WriterErrorDetail; constructor(code: WriterErrorCode, message: string, detail?: WriterErrorDetail, options?: { cause?: unknown; }); }isWriterErrorfunctionfunction isWriterError(value: unknown): value is WriterError;
src/gc/reachability.ts
ReachabilitytypeWhich parts anything can still get to, and - the part that matters - whether we are sure.interface Reachability { /** Normalised names of every part something can still get to, including `.rels` parts. */ readonly reachable: ReadonlySet<string>; /** * False when at least one relationship part could not be read. * * A caller that deletes on the strength of an incomplete walk is deleting on * the strength of a parse error. */ readonly complete: boolean; /** The relationship parts that would not parse, and the root if it was unusable. */ readonly blockedBy: readonly string[]; }reachablePartsfunctionEvery part reachable by following relationships from the package root.function reachableParts(store: PartStore): Reachability;
orphanedPartsfunctionParts nothing in the package can get to.function orphanedParts(store: PartStore, walk: Reachability): string[];
src/gc/collect.ts
CollectionPolicytypeMark and sweep, for media.type CollectionPolicy = /** Only parts this session left unreferenced. The default. */ 'orphaned-here' | /** Every unreferenced part in the sweep set, however it got that way. */ 'every-orphan' | /** Collect nothing. */ 'none';
isMediaPartfunction/ppt/media/...- the default sweep set.function isMediaPart(partName: string): boolean;
CollectOptionstypeinterface CollectOptions { /** The package about to be written. */ readonly store: PartStore; /** The package as it was opened. Required by `orphaned-here`, ignored otherwise. */ readonly baseline?: PartStore | null; /** Defaults to `orphaned-here`. */ readonly policy?: CollectionPolicy; /** * Which parts the sweep may touch at all. Defaults to `isMediaPart`. * * Sub-phase 8.7 will pass one of its own, to collect a `ppt/fonts/*.fntdata` * whose typeface no run uses any more - PowerPoint requires every listed * embedded font to be in use. Anything wider than that wants the header above * read first. */ readonly sweepable?: (partName: string) => boolean; }KeptParttypeAn orphan the sweep declined to collect, and why.interface KeptPart { readonly part: string; readonly why: string; }CollectionPlantypeinterface CollectionPlan { /** Parts removed, in the order they were found. */ readonly collect: readonly string[]; /** Orphans left in place, each with a reason. Empty is the normal case. */ readonly kept: readonly KeptPart[]; /** * False when the relationship graph could not be walked completely. * * A caller that deletes on the strength of an incomplete walk is deleting on * the strength of a parse error. */ readonly safe: boolean; /** Relationship parts that would not parse. Empty when `safe`. */ readonly blockedBy: readonly string[]; }planCollectionfunctionPlan and apply in one pass.function planCollection(options: CollectOptions): CollectionPlan;
collectGarbagefunctionCollect, and refuse rather than guess.function collectGarbage(options: CollectOptions): CollectionPlan;
src/export/prepare.ts
PrepareContexttypeWork that has to happen at save time, contributed by the sub-phase that owns it - 8.7's fonts, 3.4's autofit, 10.8's media - so the writer knows none of it. Hooks run before collection, which they change, and before validation, which must check the markup they write. ADR 0011.interface PrepareContext { /** The package being exported. Hooks mutate this. */ readonly store: PartStore; /** The package as it was opened, when there is one. Read only; never mutate it. */ readonly baseline: PartStore | null; /** * Record something worth reporting. * * Surfaced on `ExportResult.prepared`. A hook that did nothing should say * nothing - the common case for every hook on most exports is silence, and a * note per hook per save would bury the one that mattered. */ note(message: string): void; }PrepareHooktypeinterface PrepareHook { /** Short and stable. It appears in `ExportResult` and in the error if this hook throws. */ readonly name: string; run(context: PrepareContext): void; }PrepareRecordtypeinterface PrepareRecord { readonly hook: string; readonly notes: readonly string[]; }runPreparefunctionRun the hooks in the order given.function runPrepare(hooks: readonly PrepareHook[], store: PartStore, baseline: PartStore | null): PrepareRecord[];
src/export/preserve.ts
PreservationChecktypeThe one assertion the writer makes about its own output.interface PreservationCheck { /** Entries compared and found identical. */ readonly checked: number; /** Entries deliberately not compared, because this session wrote them. */ readonly rewritten: number; /** Why the check did not run, or `null` when it did. */ readonly skipped: string | null; }assertPreservedfunctionCompare the emitted archive against the one it came from.function assertPreserved(output: Uint8Array, original: Uint8Array | undefined, rewritten: ReadonlySet<string>, zip?: ReadZipOptions): PreservationCheck;
src/export/export.ts
ExportOptionstypeHanding the package back: prepare hooks, collect, write, then check that nothing unedited changed and that the rules pass. The check judges the emitted archive, so bytes are never returned unjudged. ADR 0011.interface ExportOptions { /** The package to write, with whatever edits have been made to it. */ readonly store: PartStore; /** * The package as it was opened. * * Optional, and the three things it costs to leave out are worth knowing * before deciding to. Without it the three preservation rules are skipped * rather than passed; the media sweep collects nothing, because there is no * way to tell what this session orphaned; and `origin` is unavailable, so * every fatal finding counts as one we introduced. `openPackage` produces a * correct one in a line. */ readonly baseline?: PartStore; /** The archive the baseline was opened from. Needed by `V003` and the preservation check. */ readonly baselineBytes?: Uint8Array; /** Run in the order given, before everything else. See `prepare.ts`. */ readonly prepare?: readonly PrepareHook[]; /** Media garbage collection. Defaults to `orphaned-here`. See `collect.ts`. */ readonly collect?: CollectionPolicy; /** Which parts the sweep may touch. Defaults to `/ppt/media/`. */ readonly sweepable?: (partName: string) => boolean; /** * Set false to write without the firewall. * * There is no legitimate production use. It exists so that a test can build a * deliberately broken package and look at it, and so that `cli bisect` in 1.5 * can emit the intermediate packages whose whole purpose is to be rejected. */ readonly validate?: boolean; /** A subset of rules to run. Defaults to every rule. */ readonly rules?: readonly RuleId[]; /** Set false to skip comparing the output against the source archive. */ readonly verifyPreservation?: boolean; /** Passed through to `PartStore.write`. */ readonly normalizeEntryOrder?: boolean; /** Passed through to `PartStore.write`. */ readonly deflateLevel?: number; /** Passed to `readZip` wherever this package re-opens an archive. */ readonly zip?: ReadZipOptions; }ExportResulttypeinterface ExportResult { /** The package. Only ever returned when every check above passed. */ readonly bytes: Uint8Array; /** `null` when validation was turned off. */ readonly report: Report | null; /** Hooks that had something to say. Silent hooks are not listed. */ readonly prepared: readonly PrepareRecord[]; readonly collection: CollectionPlan; /** Parts serialised afresh. Empty on a no-op export - that is the point. */ readonly rewritten: readonly string[]; /** Parts streamed out of the source archive without being decompressed. */ readonly streamed: number; readonly preservation: PreservationCheck; }OpenPackagetypeA package and the untouched twin every check compares it against.interface OpenPackage { readonly store: PartStore; readonly baseline: PartStore; readonly baselineBytes: Uint8Array; }openPackagefunctionOpen a package for editing, with the baseline an export will need.function openPackage(bytes: Uint8Array, options?: ReadZipOptions): OpenPackage;
exportPackagefunctionfunction exportPackage(options: ExportOptions): ExportResult;
src/oracle/bisect.ts
SpantypeNarrowing a package PowerPoint refuses to the smallest change that causes it: hierarchical delta debugging (Zeller'sddmin, Misherghi and Su's HDD) over the changes between a package that opens and one that does not. ADR 0013 has the design.interface Span { readonly start: number; readonly end: number; }ChangeKindtypetype ChangeKind = /** The entry exists only in the broken package. */ 'entry-added' | /** The entry exists only in the original. */ 'entry-removed' | /** A whole entry. Binary, or markup whose two sides do not line up. */ 'entry' | /** One element and everything under it. */ 'element' | /** One text, CDATA, comment, PI or declaration node. */ 'node' | /** * A run of children replaced by another run of a different length. * * What an insertion or a deletion looks like. `was` empty means children were * added; `text` empty means they were taken away. */ 'children' | /** One element's start tag: its name, its attributes and their spacing. */ 'tag' | /** One attribute, including the whitespace in front of it. */ 'attribute';
Changetypeinterface Change { readonly id: number; /** ZIP entry name, without a leading slash: `ppt/slides/slide1.xml`. */ readonly entry: string; readonly kind: ChangeKind; /** * Where in the entry, for a person to read. * * An XPath-ish path for markup - `/p:sld/p:cSld/p:spTree/p:sp[3]/@name` - * built from qualified names exactly as the file writes them, because a path * that renamed prefixes could not be pasted back into a search. */ readonly where: string; /** Distance from the entry, so a report can indent. */ readonly depth: number; /** Where this sits in the **original** entry's text. Null for a whole entry. */ readonly span: Span | null; /** What the broken package has there. Null when the entry is only original. */ readonly text: string | null; /** What the original has there. Null when the entry is only in the broken one. */ readonly was: string | null; /** * Finer changes that together reproduce this one exactly. * * The exactness is what makes descending free: the regions between the * children are the regions that compared equal, so applying every child is * the same package as applying the parent. HDD relies on it to move down a * level without spending a run to re-establish that the configuration fails. */ readonly children: readonly Change[]; }flattenChangesfunctionEvery change in the forest, parents before children.function flattenChanges(changes: readonly Change[]): Change[];
leafChangesfunctionThe leaves: the finest changes this delta can express.function leafChanges(changes: readonly Change[]): Change[];
VerdicttypeWhat the oracle says about one candidate package.type Verdict = 'fails' | 'passes' | 'unresolved';
OracletypeDoes this package still show the problem? Synchronous: every oracle is pure computation or a subprocess its caller blocks on anyway.type Oracle = (bytes: Uint8Array, label: string) => Verdict;
RunEventtypeinterface RunEvent { readonly run: number; readonly applied: number; readonly total: number; readonly label: string; }BisectOptionstypeinterface BisectOptions { readonly oracle: Oracle; /** * Ceiling on oracle runs. * * `ddmin` is quadratic in the worst case and the interesting oracle takes * about a second and a half, so a run against a large delta can take a long * time. When the ceiling is hit the result carries `exhausted: true` and the * smallest failing configuration found so far - never a truncated answer * presented as a minimal one. */ readonly maxRuns?: number; /** Called before each oracle run, for a progress line. */ readonly onRun?: (run: RunEvent) => void; readonly zip?: ReadZipOptions; /** * Ceiling on the size of the delta tree. * * Two packages that share nothing produce a tree the size of the document. * Past this, subtrees stop being decomposed and stay atomic, which costs * precision and not correctness; `truncated` says when it happened. */ readonly maxChanges?: number; }BisectOutcometypetype BisectOutcome = /** A minimal failing set was found. */ 'localized' | /** The two packages hold the same entries, byte for byte. */ 'identical' | /** The broken package passes the oracle. There is nothing to look for. */ 'broken-passes' | /** The original fails too, so the cause is not in the difference. */ 'original-fails';
BisectResulttypeinterface BisectResult { readonly outcome: BisectOutcome; /** The whole delta, as a forest. One root per differing entry. */ readonly changes: readonly Change[]; /** The 1-minimal failing subset: drop any one of these and it passes. */ readonly minimal: readonly Change[]; /** The smallest package still showing the problem. Ready to open, or to keep. */ readonly bytes: Uint8Array; /** Oracle runs actually performed. */ readonly runs: number; /** Configurations answered from the cache instead of the oracle. */ readonly cached: number; /** Runs that answered neither question. */ readonly unresolved: number; /** True when `maxRuns` stopped the reduction early. */ readonly exhausted: boolean; /** True when `maxChanges` stopped the delta being decomposed further. */ readonly truncated: boolean; }collectDeltafunctionThe forest of changes between two packages. One root per differing entry.function collectDelta(original: ZipArchive, broken: ZipArchive, maxChanges?: number): { changes: Change[]; text: Map<string, string>; truncated: boolean; };bisectPackagesfunctionfunction bisectPackages(originalBytes: Uint8Array, brokenBytes: Uint8Array, options: BisectOptions): BisectResult;
describeChangefunctionOne line naming a change, without its text.function describeChange(change: Change): string;
summarizeBisectfunction2 change(s) in 1 entry(s), from a delta of 91, in 37 oracle run(s).function summarizeBisect(result: BisectResult): string;
src/oracle/roundtrip.ts
DifferenceKindtypeIs the deck we wrote the deck we read?type DifferenceKind = /** A part is in the original and not in what was written. */ 'part-removed' | /** A part is in what was written and not in the original. */ 'part-added' | /** Both have the part; the content types disagree. */ 'content-type' | /** Both have the part; the canonical XML disagrees. */ 'xml' | /** Both have the part; the bytes disagree. */ 'binary' | /** The relationship graphs of one source part disagree. */ 'relationship' | /** A part could not be read on one side or the other, so nothing can be said. */ 'unreadable';
Differencetypeinterface Difference { readonly kind: DifferenceKind; /** The part it is about. For a relationship difference, the *source* part. */ readonly part: string; readonly detail: string; }CompareHowtypetype CompareHow = 'xml' | 'binary' | 'relationships';
PartComparisontypeinterface PartComparison { readonly part: string; readonly how: CompareHow; readonly same: boolean; /** * The digest of the compared form on the original side: the canonical XML for * an XML part, the bytes for anything else, and the relationship graph with * the ids left out for a `.rels`. * * Two parts with the same digest are the same part in the sense this module * means. That is the value worth writing into a report. */ readonly digest: string; /** The same digest on the written side. Equal to `digest` whenever `same`. */ readonly writtenDigest: string; }RelabellingtypeOne relationship id that means the same thing under a different name.interface Relabelling { /** The part whose `.rels` this is - `/` for the package root. */ readonly source: string; readonly from: string; readonly to: string; /** What both ids point at, which is why they are the same label. */ readonly target: string; }RoundTripReporttypeinterface RoundTripReport { readonly ok: boolean; readonly parts: readonly PartComparison[]; readonly differences: readonly Difference[]; /** * Ids that moved. Empty for anything this package wrote, and the interesting * field when the comparison is against a file PowerPoint saved. */ readonly relabelled: readonly Relabelling[]; readonly counts: { readonly xml: number; readonly binary: number; readonly relationships: number; readonly same: number; }; }CompareOptionstypeinterface CompareOptions { /** Passed to `parseXml` for both sides. */ readonly limits?: XmlParseLimits; }comparePackagesfunctionCompare two packages.function comparePackages(original: PartStore, written: PartStore, options?: CompareOptions): RoundTripReport;
summarizeRoundTripfunctionA one-line summary, for a CLI and for a test failure message.function summarizeRoundTrip(report: RoundTripReport): string;
roundTripPackagefunctionRead a package, write it back, and compare the two.function roundTripPackage(bytes: Uint8Array, options?: RoundTripOptions): RoundTripResult;
RoundTripOptionstypeinterface RoundTripOptions extends Omit<ExportOptions, 'store' | 'baseline' | 'baselineBytes'>, CompareOptions {}RoundTripResulttypeinterface RoundTripResult { readonly exported: ExportResult; /** The package as re-opened from the bytes that were written. */ readonly written: PartStore; readonly comparison: RoundTripReport; readonly ok: boolean; }
Errors
Throws WriterError, whose code is one of:
- ERR_PREPARE_FAILED
- ERR_COLLECTION_UNSAFE
- ERR_PRESERVATION_BROKEN
Try it live
Reading the deck…