Building .pptx by hand: what PowerPoint accepts, and what silently breaks

We write PowerPoint and Word files a byte at a time — no zip library, no python-pptx, no template to clone — inside a Cloudflare Worker. This is what that taught us. Every failure below happened to a file that unzipped perfectly, and most of them happened to a file that passed every test we had.

The renderer this comes from produces .pptx and .docx files for paying clients. It is one module, 12,308 lines, deliberately pure — no environment, no network, no clock — so the same input produces the same bytes. It sits behind a suite that currently gates at 899 passing tests, 69 of them in ooxml.test.js alone.

None of that caught the worst bug in this article. That is the point of writing it down.

The container is a ZIP you have to write yourself

A .pptx is a ZIP of XML parts. In a Worker there is no zip library available and adding one was not on the table, so the container is written by hand: a CRC-32 table, DEFLATE through the platform's own CompressionStream('deflate-raw'), and the local header, central directory and end-of-central-directory records laid out field by field.

Office is unforgiving about those record layouts. A field one byte wide in the wrong place produces a file that opens nowhere, so every offset is spelled out rather than computed cleverly:

// local file header — 30 bytes plus the name
view.setUint32(0,  0x04034b50, true);      // signature
view.setUint16(4,  20, true);              // version needed to extract (2.0)
view.setUint16(6,  0, true);               // flags: no encryption, sizes known up front
view.setUint16(8,  stored ? 0 : 8, true);  // method: 0 stored, 8 deflate
view.setUint16(10, STAMP_TIME, true);
view.setUint16(12, STAMP_DATE, true);
view.setUint32(14, crc, true);
view.setUint32(18, data.length, true);     // compressed size
view.setUint32(22, raw.length, true);      // uncompressed size
view.setUint16(26, nameBytes.length, true);
view.setUint16(28, 0, true);               // extra field length

ooxml.js — the local header, written per entry

Four things about that block are decisions rather than transcription.

Flag bit 3 stays clear, so sizes are known before you write

Setting the data-descriptor flag lets you stream an entry and write its CRC afterwards. We do not. Sizes and CRC go in the header up front, which means every part is compressed fully in memory before its header is emitted. That costs memory and buys a package that no reader has to guess about. Worth knowing when you are debugging someone else's file: "the ZIP carries no data descriptors" is one of the things we checked while a file that opened nowhere was passing every other check.

The timestamp is a constant

// MS-DOS packs the date as ((year-1980)<<9)|(month<<5)|day
// and the time as (hour<<11)|(minute<<5)|(second/2).
const STAMP_DATE = ((2026 - 1980) << 9) | (1 << 5) | 1;
const STAMP_TIME = (12 << 11) | (0 << 5) | 0;

A build that changes every second cannot be diffed, cached or compared in a test. Freezing the stamp is what makes "the same deck with no style is byte-identical" an assertion a test can hold. The cost is real and we accept it: every file we ship reports 1 January 2026 in Finder. The date the client actually sees is written into the document, not into the ZIP header, which no reader displays.

Do not deflate a PNG

Image parts are added with store: true — method 0, no compression. PNG and JPEG are already compressed; deflating them again costs CPU on every export and gives back close to nothing. The same applies to the embedded .xlsx behind a chart, which is itself a ZIP.

The deflate deadlock

This one is specific to CompressionStream and cost an afternoon:

async function deflateRaw(bytes) {
  const source = new ReadableStream({
    start(controller) { controller.enqueue(bytes); controller.close(); },
  });
  const compressed = source.pipeThrough(new CompressionStream('deflate-raw'));
  return new Uint8Array(await new Response(compressed).arrayBuffer());
}

Grab the writable side, write to it, and only then start reading, and any payload larger than the internal queue blocks forever, because nothing is draining it. Feeding a ReadableStream through pipeThrough and letting Response consume it does both halves at once. Small decks worked. A deck with a photograph in it hung.

ZIP32 only, and we say so out loud

There is no ZIP64 in this writer, so the 32-bit size and offset fields cap the package at 0xffffffff. Rather than write a file that quietly wraps, the builder checks the running offset after each entry and throws. A refusal the client can read beats a package that unzips into nonsense.

Element order is a sequence, not a set

This is the single largest category of "PowerPoint says the file is corrupt and tells you nothing useful". Most OOXML complex types are declared in the schema as xsd:sequence. Children must appear in the declared order. Get it wrong and you do not get a styling bug — you get a file that will not open, or Word's repair dialog with your name on it.

Here is every sequence that has actually bitten this renderer, with what each one does when you break it. These are not read off the spec: each row is a fault that happened here.

Parent typeThe orderWhat breaks
CT_Slide cSld, clrMapOvr, transition, timing, extLst Transition before the colour map, or timing before the transition, and PowerPoint declares the file corrupt
CT_TextParagraphProperties (a:pPr) spcBef before spcAft; spacing, then bullet colour, size, font, character; then run defaults Reversed, PowerPoint rejects the file outright. Found when a caller's spaceAfter was being silently dropped
CT_TextCharacterProperties (a:rPr) a:highlight after the fill and before a:latin Not a styling bug. A file PowerPoint refuses to open
a:gsLst (gradient stops) Positions must ascend, and be unique Repair dialog rather than a refusal — arguably worse, because the client sees it
CT_TcPrBase (Word table cell) cnfStyle, tcW, gridSpan, hMerge, vMerge, tcBorders, shd, noWrap, tcMar, textDirection, tcFitText, vAlign, hideMark shd written before tcBorders and Word rejects the document
CT_SectPr (Word section) footerReference before pgSz Word rejects the whole section — presents as a file that will not open, not as a missing page number
CT_BarSer, CT_PlotArea Sequence, not choice One of the ways PowerPoint decides a chart is broken

Sequences this renderer has broken, and what each one produced

The CT_Slide order is stated in exactly one place in the file, which is the only reason it stays right:

function slideXml(shapes, { morph = false, timing = '' } = {}) {
  return `${XML_DECL}<p:sld xmlns:a="${NS.a}" xmlns:r="${NS.r}" xmlns:p="${NS.p}">`
    + '<p:cSld><p:spTree>'
    + /* … shapes … */
    + '</p:spTree></p:cSld>'
    + '<p:clrMapOvr><a:masterClrMapping/></p:clrMapOvr>'
    + `${morph ? TRANSITION_MORPH : TRANSITION_FADE}${timing}</p:sld>`;
}

The tcPr fault: five green instruments, one file Word refuses

The first draft of the Word diagram writer put shd before tcBorders. Nothing noticed. The package walked cleanly. Every CRC matched. The XML well-formedness checker passed it. unzip -t was happy. macOS textutil rendered the whole diagram.

The only thing that reads the schema is the program the client opens the file in, and that is the one reader you cannot run inside a unit test. So the order is now asserted directly, over every w:tcPr in the document rather than only the diagram's — the table and the bar chart are built by hand out of the same elements and are one careless edit from the same fault.

One detail from writing that assertion is worth stealing. The first version matched cell children with /<w:([A-Za-z]+)[ /]/ and passed. <w:tcBorders> is followed by neither a space nor a slash, so the pattern silently skipped the very element that was out of place. An instrument that cannot see the fault reports no fault. The working pattern is [ />].

Relationship targets resolve against the declaring part

A relationship target is resolved against the folder of the part that declares it, not against the package root. This is stated plainly in OPC and it is still the easiest thing in the format to get wrong, because both spellings produce a valid ZIP.

A slide lives at ppt/slides/slide1.xml and its relationships live at ppt/slides/_rels/slide1.xml.rels. A target of media/image1.png therefore resolves to ppt/slides/media/image1.png — a path with nothing at it. The package unzips perfectly. Every part is present. And the slide draws an empty picture frame, with no error anywhere.

Part declaring the relationshipLives atCorrect target for an image
Word documentword/document.xmlmedia/image1.png
Word headerword/header1.xmlmedia/image1.png
PowerPoint slideppt/slides/slide1.xml../media/image1.png
PowerPoint slide layoutppt/slideLayouts/slideLayout1.xml../media/image1.png
PowerPoint notes slideppt/notesSlides/notesSlide1.xml../media/image1.png

Same media folder, two different spellings, because the declaring part sits at a different depth

The Word parts and the PowerPoint parts share one media builder in our renderer, and the prefix is passed in rather than hard-coded, with the reason recorded at the line that does it:

// A relationship target is resolved against the folder of the part that DECLARES it,
// not against the package root. Word's document and header both sit in `word/`, so
// they say `media/…`; a PowerPoint slide sits one level deeper and has to say
// `../media/…`. Getting this wrong produces a package that unzips perfectly and
// shows an empty picture frame, which is the hardest kind of wrong to notice.
file: name,
path: `${prefix}media/${name}`,

Two further rules in the same family, both of which produce "PowerPoint thinks this is broken" rather than a readable error:

  • Relationship ids are scoped to the declaring part. Every part gets its own counter. rId1 in a slide and rId1 in the presentation are unrelated, and sharing a generator across parts is how you end up pointing at the wrong thing.
  • The workbook behind a chart is a package relationship (…/relationships/package), not an image and not a document part. Declaring it as anything else is one of the ways PowerPoint decides a chart is broken.

Content types, and the picture frame that draws nothing

[Content_Types].xml is checked before anything else in the package is read. An undeclared part fails Word's package check before a single line of your XML is parsed.

The subtler failure is a part that is declared, correctly, and is still wrong. A JPEG stored as image1.png picks up image/png from the Default Extension="png" rule, and Office draws an empty frame. No error, no warning, no repair dialog. The declared extension loses to the bytes:

function sniffExt(bytes, declared) {
  // PNG: 89 50 4E 47
  if (bytes[0] === 0x89 && bytes[1] === 0x50 && bytes[2] === 0x4e && bytes[3] === 0x47) return 'png';
  // JPEG: FF D8 FF
  if (bytes[0] === 0xff && bytes[1] === 0xd8 && bytes[2] === 0xff) return 'jpeg';
  const named = String(declared ?? '').toLowerCase();
  if (named === 'jpg' || named === 'jpeg') return 'jpeg';
  return 'png';
}

The related discipline: only emit a Default for extensions actually present in the package. Declaring jpeg in a package with no JPEG in it is legal, and it is also a lie about what is inside — and it hides exactly the bug above, where a part's real type and its declared type have diverged.

The one element that made every file we ever shipped unopenable

This is the most expensive thing in this article, so it gets the most detail.

A client opened a real deck on a Mac. Keynote said:

<name>.pptx can't be imported. The file format is invalid.

The cause was <p:clrMapOvr>. We emitted it on every slide part and on no notes part. Every deck this product makes carries speaker notes, so every deck ever delivered was unopenable in Keynote — which means unopenable for every Mac client without Office. The external design review that scored a real client deck 4/10 was reviewing a document that a large share of its intended readers could not open at all.

What made it survive that long is the list of things that said the file was fine:

InstrumentVerdict on the broken file
Microsoft PowerPointOpens it
LibreOffice ImpressOpens it — and every render, gauntlet page and screenshot in this project was made with it
unzip -tClean
ZIP structureNo data descriptors, every offset sound
Relationship resolutionEvery relationship resolves to a part that exists
Content typesEvery part declared, every image's bytes match its declared extension
macOS Quick LookRenders a thumbnail
Our own unit tests741 of them passed
Apple Keynote 14.4"The file format is invalid."

Eight green instruments, one file half our clients could not open

clrMapOvr is optional in CT_NotesSlide. The schema permits its absence. Keynote requires it anyway.

An element being optional to a schema is not the same as being optional to a reader. That sentence is now written into the renderer, into the test that pins it, and into the conformance suite, because it is the general lesson and the specific element is only an example of it.

How it was found

Not by reading the spec — the spec says the element is optional, which is true and useless here. It was found by bisecting against Keynote itself, driven from AppleScript:

osascript -e 'tell application "Keynote" to open POSIX file "…"'
  • Strip the notes entirely — it opens.
  • Keep the notes master, drop the notes slides — it opens.
  • Add <p:clrMapOvr><a:masterClrMapping/></p:clrMapOvr> to the notes slides — it opens, with everything intact.

The fix is one element, in the right place, on every notes part:

+ '</p:spTree></p:cSld>'
+ '<p:clrMapOvr><a:masterClrMapping/></p:clrMapOvr>'
+ '</p:notes>'

The instrument that came out of it

Two tests now exist, and the second one matters more than the first.

The first asserts the element is present on every notes part and after </p:cSld>, because the sequence trap from the previous section applies here too. The second drives Keynote for real, on a deck built through the current renderer, and asserts it opens with at least fourteen slides.

Then a third test rebuilds the broken file — notes without clrMapOvr — and asserts that Keynote refuses it:

test('and Keynote REFUSES the deck that shipped — the check proves it can still see', …)

A check that has never failed is not an instrument. If that assertion ever starts passing the wrong way, Keynote has changed and the suite has gone blind, which is a thing worth being told. The same file also refuses to treat "could not run" as a pass: when there is no GUI session or Keynote is not installed it calls t.skip() with a printed reason — "NOT CHECKED. This is not a pass." — rather than returning quietly green.

There is a cost. Driving real applications takes about half a minute per run and opens windows on the machine. There is a GAUNTLET_SKIP_CONSUMER=1 escape, and the file says what skipping means: a decision to ship without asking the only question that has ever caught this class of fault.

Embedded fonts are mostly a no-op

The obvious answer to "our deck looks different on the client's laptop" is to embed the typeface. We tested that properly before building it, by hand-assembling minimal PPTX and DOCX packages the same way the Worker does, referencing a font that was not installed on the test machine, and opening each one in a real application.

ReaderHonours a font embedded in .pptx?How we know
LibreOffice ImpressYesTested
Apple Keynote 14.4NoTested, definitive
Google SlidesNoDocumented
PowerPoint on WindowsPer spec, yesNot tested — no PowerPoint on this machine

Font embedding in .pptx, measured against a face absent from the machine

So the feature would cost two to four days of hand-rolled binary work — an sfnt table parser, OS/2 and head and name extraction, an EOT header writer — with a silent failure mode, to serve at best one renderer, and that renderer is the one we cannot test. We dropped it.

The trap for anyone who does implement it

.fntdata is not raw TTF. It must be EOT-wrapped. Raw TTF is silently ignored — no warning, no fallback message, just the substituted face — so anyone implementing from the spec alone ships a no-op and does not find out. The header is the W3C Submission EOT, uncompressed, version 0x00020001:

body += struct.pack('<I', len(ttf_bytes))          # FontDataSize
body += struct.pack('<I', 0x00020001)              # Version
body += struct.pack('<I', 0)                       # Flags (0 = uncompressed, not MTX)
body += panose                                    # 10 bytes from OS/2
body += struct.pack('<B', 1)                       # Charset DEFAULT_CHARSET
body += struct.pack('<B', italic)                  # from head.macStyle & 2
body += struct.pack('<I', os2.usWeightClass)
body += struct.pack('<H', os2.fsType)
body += struct.pack('<H', 0x504C)                  # MagicNumber
# … unicode ranges, code page ranges, checkSumAdjustment, four reserved …
# then family / style / version / full name, each UTF-16LE,
# each preceded by a uint16 length and followed by a uint16 padding
body += struct.pack('<H', 0)                       # RootStringSize = 0
body += ttf_bytes
eot = struct.pack('<I', len(body) + 4) + body      # EOTSize includes itself

EOT wrapper, from the reference implementation written during the test

Word is the exception, and it is cheap

DOCX embedding needs no font parsing at all. Plain TTF at /word/fonts/font1.ttf, a relationship in word/_rels/fontTable.xml.rels, and:

<w:font w:name="Alexandria">
  <w:embedRegular r:id="rId1"
                  w:fontKey="{00000000-0000-0000-0000-000000000000}"
                  w:subsetted="0"/>
</w:font>

That worked in LibreOffice Writer and matches what a real Google-Docs-produced .docx contains. The obfuscated variant (ECMA-376 Part 4 §2.8.1) is barely harder: extension .odttf, content type application/vnd.openxmlformats-officedocument.obfuscatedFont, and the first 32 bytes of the font XORed with the GUID's bytes in reverse order, repeating every 16.

What we did instead

We render a PDF alongside the .pptx. A PDF embeds its fonts always, on every platform, with no exceptions, and it is what actually gets forwarded and read. The .pptx stays as the editable working copy. That makes the font question irrelevant to readers instead of trying to answer it.

One consequence we like: because the PDF is produced by headless LibreOffice in a container where we control the installed fonts, the PDF can carry a distinctive licensed-open face while the .pptx names one that ships with Office.

And a trap attached to changing the face at all

Our wrap estimator is calibrated per typeface. Average advance width is a per-face constant, so swapping the font changes every wrap estimate, every auto-fit size and every derived box in the file. Georgia and Verdana are materially wider than the ClearType faces we set. Re-measure against a real render before believing any layout after a swap.

Related, and worth stating because it invalidated a whole review: neither of our two faces is installed on the build machine, so LibreOffice substitutes Liberation Sans and Arial when rendering to PDF. Every screenshot taken locally showed Arial, and a blind reviewer graded exactly that, reporting "system Arial/Helvetica throughout, hierarchy carried by size and colour alone". That finding was about our renderer, not about the deck. Run pdffonts on the PDF before believing anything about type in a local render.

Morph, and the wrapper that does the opposite of its job

Morph is the strongest native motion tool in PowerPoint and the easiest to misuse. It works by matching shapes across two slides and moving them, so between slides that share nothing it invents movement out of coincidence — a heading sliding into a chart axis because both happen to be dark.

Two rules follow, and they are different in kind.

Morph is a p14 extension, so it needs a fallback

const TRANSITION_FADE = '<p:transition spd="med" advClick="1"><p:fade/></p:transition>';

const TRANSITION_MORPH = `<mc:AlternateContent xmlns:mc="${NS.mc}">`
  + `<mc:Choice xmlns:p14="${NS.p14}" Requires="p14">`
  + '<p:transition spd="slow" p14:dur="700" advClick="1"><p14:morph option="byObject"/></p:transition>'
  + '</mc:Choice>'
  + `<mc:Fallback>${TRANSITION_FADE}</mc:Fallback>`
  + '</mc:AlternateContent>';

A reader that does not know p14 must be handed something. With the wrapper, an old copy of PowerPoint, Keynote or Google Slides degrades to the fade the rest of the deck uses. Without it, it degrades to nothing.

The inverse trap: do not wrap core elements

The obvious generalisation — "wrap anything animated in mc:AlternateContent" — is wrong, and expensively so. p:timing, p:tnLst, p:seq, p:par, p:set, p:animEffect and p:animScale are core ECMA-376 Part 1 §19.5, in the same p namespace as p:sld itself, and have been since PowerPoint 2007. There is no p14 to fall back from.

Wrapping them in Requires="p14" would be a false claim that also suppressed the animation in every reader that supports it but not p14 — the wrapper doing the opposite of its job. A reader that does not implement p:timing ignores the element and shows the finished page, which is the correct degradation and the one every static renderer already performs.

Morph only where objects genuinely persist

Our rule is mechanical and lives at the one place that knows both slides:

const frame = (isTitle || fullBleedGround || sidePart)
  ? `bleed-${index}`
  : `standard-${inverted ? 'brand' : 'paper'}`;
const morph = index > 0 && frame === lastFrame && !frame.startsWith('bleed');

Morph fires only when this slide and the previous one are the same kind of page: standard furniture, same ground, no full-bleed picture on either. Then the logo, footer, rule and heading are genuinely the same objects at the same coordinates, and Morph holds the frame still and cross-dissolves only what changed. Any change of frame gets the fade, because the frame really did change and a fade is the honest description of that.

Morph and entrance animations fight, and one of them has to yield

If a slide's build includes an entrance on the heading, the heading is invisible when the slide arrives — so Morph has nothing to morph into. It dissolves the previous slide's heading away to nothing, and then the click builds this one back in. That is the one combination of the two features that looks broken.

It is resolved where both facts are known, rather than by forbidding either:

const buildsHead = motion.scope === 'page' && !morph;

The measurement that found a feature was not shipping at all

Sixteen slides straight out of the builder, unzipped and counted: 16 of 16 carried a p:transition, 4 of them Morph — and exactly one carried a p:timing.

The build code was forty correct lines wired to a single call site, the one-column body path. Every other page of every deck the product had ever produced arrived complete, in one frame, with nothing built. No reviewer had mentioned it, because every critic here judges rendered PNGs and a transition does not exist in a still image. Nine review rounds could not see it. One unzip and a grep could.

One ampersand

Everything that reaches these documents comes from a language model or from a client's own account. A single unescaped & in a company name is the difference between a report and a file Office refuses to open. So escaping is applied to every interpolated value without exception, including the ones that "cannot" contain markup:

function esc(value) {
  return String(value ?? '')
    .replace(/&/g,  '&amp;')
    .replace(/</g,  '&lt;')
    .replace(/>/g,  '&gt;')
    .replace(/"/g,  '&quot;')
    .replace(/'/g,  '&apos;')
    // XML 1.0 cannot represent these at all, escaped or not
    .replace(/[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F-\u009F\uFFFE\uFFFF]/g, '');
}

The control characters are stripped rather than escaped because XML 1.0 has no way to represent them at all&#x1; is not a rescue, it is a second invalid document. Models occasionally emit them inside pasted text, so this is not theoretical.

Where escaping ends and typography begins

We also curl quotes, because &apos; renders as a vertical typewriter tick at 40pt and it is unmistakable. But that transform is deliberately not part of esc.

esc also escapes attribute values — shape names, alt text, relationship ids — and a curled apostrophe in a relationship id is a broken package. So curling is applied only where a run's text is written, never to an attribute. Two functions, one boundary, and the boundary is the whole reason it is safe:

// an apostrophe after a letter or digit is a contraction or possessive;
// a quote after a boundary opens; every other one closes
.replace(/(\p{L}|\p{N})'/gu, '’')
.replace(/(^|[\s([{<—-])'/gu, '$1‘')
.replace(/(^|[\s([{<—-])"/gu, '$1“')
.replace(/"/g, '”')

The reason this is done in the renderer rather than asked of the model is durability. A prompt instruction is obeyed by the model it was tested against and can be un-learned by the next revision. A normalisation in the renderer cannot.

What we still get wrong, and what we have not tested

A page that admits its gaps is more use than one that bluffs, and these are ours.

We have never tested Microsoft PowerPoint on Windows

There is no PowerPoint on the machine this is built on. Every claim in this article of the form "PowerPoint accepts X" is grounded in the ECMA-376 schema, in LibreOffice, in Keynote, and in the fact that clients open the files we send them. It is not grounded in a Windows PowerPoint run. Given that the central lesson here is that a schema and a reader are different things, we are not going to pretend otherwise. If a claim above matters to you, verify it in the reader you actually ship to.

The EOT path is written and unverified

The reference implementation exists and produces a file. We know raw TTF in .fntdata is ignored. We do not know that our EOT is accepted by the one renderer it would be built for, because that renderer is the one we cannot run.

No ZIP64

The package cannot exceed 0xffffffff bytes. We throw at the limit rather than emit something broken, and the error the client sees stops at what happened rather than telling them to try again with fewer pictures — an instruction they have no way to follow.

Everything is assembled in memory

Sizes and CRCs go in the local headers up front, which rules out streaming an entry. Fine for a deck, wrong for anything large.

Fixed timestamps are a lie we chose

Every file we produce reports 1 January 2026 in Finder and Explorer. Determinism was worth more to us than an accurate ZIP header; that is a trade, not a free win.

a:highlight has no padding control

The marker-pen fill sits flush against the stems of the letters and cuts the ascenders. That reads as deliberate on a 44pt statement and cramped on an 18pt bullet. We restrict where the device is used. We have not solved it.

If you are building one of these, do these three things

Render it, do not only test it. Well-formed XML, matching CRCs and resolving relationships are necessary and nowhere near sufficient. Five green instruments passed a file Word would refuse.

Drive a real consumer, and drive the strictest one. Keynote is stricter than PowerPoint and much stricter than LibreOffice. Half a minute of AppleScript per run would have caught the worst bug in this article on the day it was written instead of months later.

Give every check a negative control. A test that has never failed is not an instrument. Rebuild the broken file deliberately and assert that the reader rejects it — that is the only way you find out when your instrument has gone blind.

Where this comes from

BATech Studio generates branded presentations and documents from a customer's own website — their colours, their logo, their tone — and hands back an editable .pptx. The renderer described here is the part that turns a finished design into a file, and every fault above was found while shipping it to paying clients.

If you want to see what it produces, the product is at batechstudio.ai/studio, and the agency that built it is at batechstudio.ai. If you found a mistake in this page, or a reader that disagrees with our table, tell us — we will test it and correct it here.