Skip to content

Deck to script ​

pptx-ts/script reads a .pptx through pptx-ts/read and prints a TypeScript module that rebuilds the deck through the write API. With the module you get a list of fidelity notes, one for each construct the rebuilt deck does not carry.

ts
import { copyFile, mkdir, readFile, writeFile } from 'node:fs/promises'
import { Presentation } from 'pptx-ts/read'
import { printScript, readModelToIr } from 'pptx-ts/script'

const deck = await Presentation.load(await readFile('source.pptx'))
const { code, assets, notes } = printScript(readModelToIr(deck))

await mkdir('out/assets', { recursive: true })
await writeFile('out/deck.ts', code)
for (const [name, bytes] of assets) await writeFile(`out/assets/${name}`, bytes)
// The template-anchored script loads the unmodified source deck from beside itself.
await copyFile('source.pptx', 'out/template.pptx')

for (const note of notes) console.log(note.slideNumber, note.construct, note.detail)

Run the printed module to write the deck:

sh
node out/deck.ts

It writes out/output.pptx. Node runs the .ts file directly by stripping its types, so there is no build step. The module uses top-level await and imports pptx-ts, so run it where that package resolves, such as inside your project. Every path in the script resolves against the script's own location, not the working directory.

For a script that needs no template, call printStandaloneScript in place of printScript and skip the copyFile line.

Two outputs ​

Both printers take the same DeckIr. They differ in where the deck's masters, layouts and theme come from.

Template-anchoredStandalone
PrinterprintScriptprintStandaloneScript
Masters, layouts and themeThe source deck's, unchanged. Presentation.fromTemplate removes only its slidesRe-authored as pptx.theme and one defineSlideMaster call per source layout
Ships beside the scriptThe unmodified source deck as template.pptx, and the media files the slides useThe media files the slides and layouts use, or nothing with assets: 'inline'
Entry callsPresentation.fromTemplate(), then deck.appendSlides() for each run of slides that share a layoutnew PptxTs(), then pptx.addSlide({ masterTitle }) for each slide
Editable in the scriptSlide contentSlide content, theme colours and fonts, and layouts
ChartsRebuilt with addChart from the chart's cached valuesSame
chartEx charts, SmartArt and undecoded graphic framesThe slide is copied from the source with deck.importSlide()The slide is transcribed and the frame is dropped
Table stylesThe source a:tableStyleId GUID, printed as tableStyleSame
Document propertiesAll keptTitle, author, subject, revision and company. The rest are dropped

Use the template-anchored output when the rebuilt deck has to look like the source and you can ship the source deck with the script. Use the standalone output when the script has to run with nothing but the package.

How it works ​

readModelToIr() makes every mapping decision and returns a DeckIr. The IR is plain data, which JSON.stringify and structuredClone both round-trip. Each slide holds its content as write-API method names with literal option objects. Media bytes sit in DeckIr.assets, and an option refers to them as { $asset: name }. A printer only spells out those values, and both printers share the code that prints a slide. So the two outputs print the same slide bodies from one IR, and you can inspect or change the IR before you print it.

Geometry is EMU-exact ​

Positions and sizes print as "<n>emu" strings, which every Coord option accepts, so they reach the output unrounded. Four options take inches only: colW, rowH, margin, and the width and height of defineLayout. The converter prints those to six decimal places, which convert back to the source EMU value. The template-anchored script relies on this, because appendSlides throws when the generated slide size differs from the template's.

Transitions are filtered against a closed vocabulary ​

A slide's transition prints as an assignment such as slide.transition = { type: 'push', speed: 'slow', durationMs: 1250, variant: { dir: 'd' } }. speed is always printed. Duration, advance on click, advance time, the type's own attributes and the sound appear only when the source sets them.

The write API names the 21 base ECMA-376 transitions. The converter keeps a transition only when its element is in the p namespace and has one of those names. PowerPoint's newer effects, such as Morph and Vortex in the p14, p15 and p159 namespaces, are dropped with a slide.transition note, and the slide advances with no effect.

A stop-previous sound (p:endSnd) and an embedded start sound both carry in both outputs. A start sound whose relationship does not resolve to an audio part is dropped with a slide.transitionSound note.

Slide numbers ​

A slide-number placeholder whose one paragraph holds only the slide-number field prints as slide.slideNumber = { … }, with its position and every text option it states: alignment, vertical anchor, margins, paragraph indents, text direction and warp, and the field's character formatting. A placeholder bound to its layout's by idx, as PowerPoint writes it, also gets a shape.placeholder note: slideNumber writes an unbound placeholder, so what the source inherited from the layout does not carry. A slide-number placeholder with other text in it is mapped as a shape, and its field is dropped with a text.field note.

Theme colours ​

The write path accepts ten scheme colour tokens: tx1, tx2, bg1, bg2 and accent1 to accent6. A colour stated as one of them prints as that token and resolves against the output deck's theme. In the template-anchored output that theme is the source theme. In the standalone output it is the re-authored pptx.theme.

The other seven tokens (dk1, lt1, dk2, lt2, hlink, folHlink and phClr) print as the hex value they resolve to in the source, with a *.schemeToken note. Those colours stop following the theme.

What the standalone output cannot rebuild ​

The template-anchored output reuses the source deck's masters, layouts and theme, so it keeps every row below. The standalone output rebuilds them through the write API. Each row lacks a read accessor, a write option, or both.

ConstructRead accessorWrite optionTemplate-anchored outputStandalone outputNote
Theme format scheme (a:fmtScheme)NoneNone. The write path emits Office'sKeptOffice's format schemetheme.fmtScheme
Master text styles (p:txStyles)NoneSlideMasterProps.textStylesKeptBuilt-in defaultsmaster.txStyles
Shapes on a slide masterSlideMaster.shapesNone. defineSlideMaster creates a layoutKeptDroppedmaster.decoration
Colour map (p:clrMap)SlideMaster.colorMapNone. The write path emits the identity mapKeptIdentity mapmaster.colorMap
More than one slide masterPresentation.masters()One shared masterKeptOne master, with the first master's theme and colour mapmaster.multiple
Layout placeholdersSlideLayout.placeholdersdefineSlideMaster({ objects }), left unusedKeptDroppedmaster.placeholders
Shapes on a layoutSlideLayout.shapesdefineSlideMaster({ objects })KeptRe-authored. A group becomes loose shapes and a table is droppedlayout.*
Layout background taken from the theme (p:bgRef)The resolved fillA colour or an imageKeptThe resolved fill, baked inmaster.background
Layout namesPresentation.layouts()defineSlideMaster({ title })KeptA repeated name gets a suffix. A tab or line break becomes a spacemaster.nameCollision, master.name
Document propertiesPresentation.coreProperties, Presentation.appPropertiesTitle, author, subject, revision and companyKeptThose five. A property the source left blank gets the library's valuedeck.docProps, deck.docPropsDefault
Blank DEFAULT layoutNot applicableAdded by every new PptxTs()Not addedAdded ahead of the source layoutsmaster.default

The standalone output leaves layout placeholders out on purpose. The write path gives every slide an empty shape for each layout placeholder the slide does not fill. The converter writes every source shape as positioned content, never into a placeholder, so declaring the placeholders would add empty shapes to every slide.

Fidelity notes ​

Each construct an output does not carry becomes a FidelityNote in PrintedScript.notes. The printed module repeats the list in a comment block at its top, grouped by deck and by slide.

ts
interface FidelityNote {
  slideNumber: number | null // 1-based source slide, or null for a deck-level loss
  shapeName: string | null // the source shape's p:cNvPr/@name, '' when it has none, null when the loss is not about one shape
  construct: string // stable dotted key, such as 'line.width'
  disposition: 'dropped' | 'flattened' | 'approximated'
  cause: 'unread' | 'unwritable' | 'unsupported'
  detail: string // one sentence for a human
}
ValueMeaning
droppedNothing in the output carries the construct
flattenedA value survives and the structure around it does not, such as a placeholder rebuilt as a plain shape
approximatedThe structure survives with a different value, such as a chart rebuilt from its cached points
unreadpptx-ts/read has no accessor for the construct, so the converter never sees it
unwritableThe read model reports the construct and the write API has no option for it
unsupportedBoth APIs handle the construct, and this output cannot carry it

A note that repeats the same slide, shape, construct and text is recorded once.

Which notes apply to each output ​

Read PrintedScript.notes, not DeckIr.fidelity. The IR holds every note the converter recorded. Each printer filters that list and adds notes of its own:

  • The note catalogue marks each construct as applying to both outputs, to the standalone output only, or to the template-anchored output only. The master, theme and document-property notes and every construct under the layout. prefix apply to the standalone output only, because the template-anchored output keeps those parts of the source.
  • slide.carried applies to the template-anchored output only. On a slide it copies, that output keeps slide.carried and the note for the frame that forced the copy (chartEx.all, diagram.all or graphicFrame.unknown). It drops the slide's other notes, because the copy keeps what they describe.
  • Both printers add slide.name for a slide with its own name, which has no write-API setter.
  • The template-anchored printer adds slide.layout when the slide's layout name is not unique.
  • The standalone printer adds slide.layout when a slide's layout produced no master, master.default for the extra layout, and deck.docPropsDefault for properties the write path stamps.

Pass PrintedScript.notes to diffDeckIr() when you verify a conversion.

Known losses ​

The counts come from pnpm run script:census, run in a clone of the repository over test/read/fixtures/. That directory holds 61 .pptx fixtures, and all 61 convert. The census reads .pptx files only, so it skips the template.potx beside them.

A count is the number of fixtures that raise the note at least once. Each fixture targets a few constructs, so a count shows what the corpus exercises, not how often a construct occurs in real decks. The table lists only constructs some fixture raises. knownNoteConstructs() returns the full catalogue.

The Output column says where the note fires. A construct that fires in both outputs has the same count in each. A construct under the layout. prefix is the slide construct of the same name, raised while re-authoring a layout's shapes. Across the corpus the standalone output raises 878 notes and the template-anchored output 429, which is 3 to 12 more per deck.

ConstructOutputCauseWhat happensFixtures
text.color.inheritedBothunsupportedA run with no colour of its own gets its inherited colour baked in, because the write path paints an uncoloured run black. It stops following theme changes. A link, a run in a shape that carries a link of its own, a run in a shape that carries as a placeholder, and a run in a cell of a table with tableStyle are the exceptions: the write path leaves those uncoloured too, so a link keeps the theme hyperlink colour, a placeholder run its layout's and a cell run its table style's28/61
line.widthBothunreadAn outline taken from the theme line style (p:style/a:lnRef) keeps its colour and loses its width and dash10/61
shape.frameInheritedBothunsupportedA shape positioned by its layout or master gets that position baked in, and stops following edits to the layout or master9/61
chart.workbookBothunsupportedThe chart is rebuilt from its cached values. The plotted numbers match, and the workbook's formulas, extra columns and formatting are gone5/61
chart.schemeTokenBothunwritableA chart line or text colour outside the ten mapped scheme tokens, or one with a theme transform such as a:lumMod, takes the default colour4/61
diagram.allBothunwritableNo write API builds SmartArt. The template-anchored output copies the slide, and the standalone output drops the diagram3/61
slide.animationBothunwritableA build effect addAnimation cannot write is dropped: one in an interactive sequence, such as PowerPoint's click-to-play media trigger, a preset other than the eight addAnimation writes, or one whose target has no name unique on its slide. An effect that builds part of a shape's text builds the whole shape. An after-previous step that starts at a delay of its own plays when the step before it ends3/61
text.endParagraph.langBothunwritableParagraphs that end in different languages (a:endParaRPr/@lang) all end in en-US. Their runs keep their languages3/61
text.hyperlink.underlineBothunwritableA link that states no underline comes back stating u="sng". It looks the same3/61
chart.dataPointsBothunwritableA data point or pie slice in a theme colour, a pattern or an outline of its own comes back in the series' colour or its own literal fill. A radar's or bubble's own points are dropped2/61
chart.xLabelsBothunwritableA scatter or bubble chart plotted against text X labels plots at the same positions, and the labels are gone2/61
group.childSpaceBothunsupportedThe children of a group that scales them are printed pre-scaled, so resizing the group no longer rescales them2/61
shape.emptyBothunsupportedA shape with no text and no geometry of its own, such as an unfilled placeholder, is omitted2/61
slide.comment.modernBothunwritableModern (p188) review comments are dropped. Legacy comments carry as addComment2/61
chart.axisBothunwritableAn axis crossing at the other axis' maximum or minimum crosses at zero, and a custom display unit is dropped. A combo's secondary axis takes a setting its primary axis states and it does not1/61
chart.blanksBothunwritableA blank cached data point becomes 0, so a line chart dips to the axis where the source showed a break1/61
chart.comboBothunwritableA combo whose plots addChart cannot combine becomes a single chart of its first plot's type. A combo it can combine (area, bar, line and scatter plots) keeps its plots, and numbers its series plot by plot, so a source that numbers them otherwise changes its legend and colour order1/61
chart.invertIfNegativeBothunwritableA series that inverts its negative bars (c:invertIfNegative) draws them in the series' colour1/61
chart.labelsBothunwritableData labels lose a legend key, a category name on any chart but a pie or doughnut, and text of their own on any chart but a scatter1/61
connector.bindingBothunsupportedA connector bound to a shape with no name, a name another shape on the slide shares, or one that is not written, no longer follows it1/61
fill.gradient.pathBothunwritableA rect path gradient becomes radial1/61
fill.schemeTokenBothunwritableA fill colour outside the ten mapped scheme tokens is baked to hex1/61
graphicFrame.unknownBothunreadA graphic frame the reader does not decode, such as a 3D model, an OLE object or ink, is dropped. The template-anchored output copies the slide1/61
image.svgBothunsupportedAn SVG picture keeps its vector part. The write path generates a new raster fallback in place of the source's1/61
line.arrowSizeBothunwritableArrowheads render at the default width and length1/61
line.schemeTokenBothunwritableAn outline colour outside the ten mapped scheme tokens is baked to hex1/61
media.audioVideoBothunwritableAudio or video linked from outside the package, or inside a group or on a layout, becomes a still image of its poster frame1/61
shape.custGeom.guidesBothunwritableA picture clipped to a freeform, or a freeform holding text, keeps its path and loses its guides, adjust handles and connection sites, which only addShape takes, so a path point naming one of those guides writes 0. A path point naming a guide in a path scaled onto its box is approximated1/61
shape.placeholderBothunwritableA placeholder whose p:ph type or idx has no placeholder spelling becomes a plain shape with its inherited geometry and styling baked in. A title, and any text placeholder with one of the six types the string form names and no idx, carries as that string. Any other text placeholder, and a filled picture, table or chart placeholder, carries as a placeholder: { type, idx } binding1/61
slide.backgroundBothunwritableA slide background taken from the theme (p:bgRef) is baked to the fill it resolves to1/61
table.cell.fill.picture.geometryBothunwritableA cell's picture fill keeps its image. Its tiling, destination inset, DPI and rotate-with-shape setting do not carry1/61
table.rowAutoBothunsupportedAuto-height rows get an even share of the table height instead of fitting their content1/61
text.bullet.schemeTokenBothunwritableA bullet colour outside the ten mapped scheme tokens is baked to hex1/61
text.fieldBothunreadThe text of a date or footer field, or of a slide number beside other text, is dropped1/61
text.kernBothunwritableA spaced run that states no kerning comes back stating kern="0", because the write path turns kerning off on spaced text it is given no kerning for1/61
text.paraSpaceZeroBothunwritableAn explicit zero space before or after a paragraph is dropped, so the list style's spacing comes back1/61
slide.carriedTemplate-anchoredunwritableA slide holding a frame the write API cannot author is copied from the source. It renders the same, and the script does not describe its contents4/61
slide.layoutTemplate-anchoredunsupportedSeveral source layouts share the slide's layout name, so the slide binds to a gallery position1/61
deck.docPropsStandaloneunwritableKeywords, description, category, content status and last-modified-by are dropped61/61
deck.docPropsDefaultStandaloneunwritableA title, author, subject, revision or company the source left blank gets the library's value61/61
master.defaultStandaloneunsupportedThe layout gallery gains a blank DEFAULT layout ahead of the source layouts61/61
master.placeholdersStandaloneunsupportedA layout placeholder a slide on that layout leaves out is not re-declared, because the write path would seed an empty copy onto that slide61/61
master.txStylesStandaloneunreadPlaceholder text falls back to built-in size, face, colour, indent and bullet per list level61/61
theme.fmtSchemeStandaloneunreadThe theme's fill, line and effect style lists become Office's61/61
master.backgroundStandaloneunwritableA layout background taken from the theme is baked to the fill it resolves to60/61
master.decorationStandaloneunwritableShapes on a slide master are dropped6/61
master.nameStandaloneunwritableA tab or line break in a layout name becomes a space5/61
layout.text.color.inheritedStandaloneunsupportedAs text.color.inherited, on a layout shape4/61
master.colorMapStandaloneunwritableA remapped colour map becomes the identity map, so scheme colours resolve to different hex values4/61
layout.groupStandaloneunwritableA group on a layout becomes loose shapes in the same positions2/61
layout.fill.gradient.schemeTokenStandaloneunwritableA gradient stop colour on a layout shape, outside the ten mapped tokens, is baked to hex1/61
layout.fill.schemeTokenStandaloneunwritableAs fill.schemeToken, on a layout shape1/61
master.multipleStandaloneunsupportedSeveral slide masters collapse into one, with the first master's theme and colour map1/61
master.nameCollisionStandaloneunsupportedA repeated layout name gets a suffix, such as Title Slide (2)1/61

Carried without a note ​

These constructs carry in both outputs and raise no note.

Source constructWhat the script writes
A paragraph with no bullet element of its ownbullet: 'inherit'
Paragraph left margin and first-line indent (a:pPr/@marL, @indent)paraMarginLeft and paraIndent, in points, or 'inherit'
Numbering start, bullet font, bullet size percentage and bullet colournumberStartAt, fontFace, size and color inside bullet
A picture bullet (a:buBlip)bullet: { image: { data }, size }, the image carried as an asset
An explicit off: u="none", strike="noStrike", cap="none"underline: { style: 'none' }, strike: 'noStrike', caps: 'none'
Superscript and subscriptbaseline
A run's language, East Asian face, glow, shadow, outline, transparency and underline colourlang, fontFaceEA, glow, shadow, outline, transparency and underline.color. A table cell takes its first run's shadow as the cell's
The language every paragraph of a text ends in (a:endParaRPr/@lang)lang on the text. A run in another language states its own
A line break between two runs (a:br)softBreakBefore on the run after it
Right-to-left paragraphs and tab stopsrtlMode and tabStops
Text columns and a WordArt warpcolumns, columnSpacing and textWarp
A shape's lock flags (a:spLocks)objectLock on addText and addShape
A picture's, table's and group's lock flags (a:picLocks, a:graphicFrameLocks, a:grpSpLocks)objectLock on addImage, addTable and addGroup
A baked autofit scale (a:normAutofit/@fontScale, @lnSpcReduction)fit: { type: 'shrink', fontScale, lnSpcReduction }. A bare <a:normAutofit/> is fit: 'shrink'
A table cell with a fill of its ownfill on the cell. A styled cell with no fill of its own is left to tableStyle
A picture fill on a shape or cellfill: { type: 'image', image: { data, crop }, transparency }
A group's rotation and flipsrotate, flipH and flipV on addGroup, with the children in the group's own space
A picture's cropcrop on addImage
Embedded audio and video (a:audioFile, a:videoFile) and its loop timingaddMedia with type, data, extn, cover from the poster frame, and loop or loopCount. An online video is type: 'online' with its link. A rotation or flip is dropped under media.formatting
A slide's legacy review comments (p:cm)addComment with author, initials, text, x, y and date, after the slide's shapes
A slide's build effects (p:timing main sequence) in the eight presets addAnimation writesaddAnimation with preset, the target's objectName, trigger and durationMs, after the slide's shapes
A picture's recolour and transparency (a:grayscl, a:biLevel, a:duotone, a:clrChange, a:alphaModFix)grayscale, biLevel, duotone, clrChange and transparency on addImage. A colour change to a transparent colour (Set Transparent Color) keeps the colour and loses the transparency, under image.recolor
A background picture's crop and transparency (a:alphaModFix)background: { image: { data, crop }, transparency }
A connector's route, bends and bend positions (bentConnector3-5, curvedConnector3-5)type, bends and adj on addConnector. Any other route prints straight under connector.route
A connector bound to a shape with a name unique on its slide (a:stCxn, a:endCxn)startShape, endShape and their Idx sites
A picture cropped to a preset shapeshape and shapeAdjust on addImage
An outline's cap (a:ln/@cap)line.cap
A transition's type, speed, duration, advance and soundslide.transition
Position and size"<n>emu" strings
Shapes on a layout, standalone outputdefineSlideMaster({ objects })

A crop with a negative inset, or with opposite insets that add up to 100% or more, has no crop spelling and raises image.crop, fill.picture.geometry, or for a background slide.background or master.background. An autofit percentage outside 0 to 100 raises text.autofit.fontScale or text.autofit.lnSpcReduction.

Reader gaps ​

pptx-ts/read has no accessor for these, so neither output can carry them. Their notes have the cause unread:

  • The theme's format scheme (a:fmtScheme), and with it the width and dash of an outline taken from the theme
  • Master text styles (p:txStyles)
  • Text fields
  • Graphic frames other than tables, charts and SmartArt

Verifying a conversion ​

diffDeckIr() checks a conversion. Convert the source, run the printed script, convert the output, and compare the two IRs with the printed notes as the exclusion list.

ts
import { readFile } from 'node:fs/promises'
import { Presentation } from 'pptx-ts/read'
import { canonicalDeckIr, diffDeckIr, printScript, readModelToIr } from 'pptx-ts/script'

const ir = readModelToIr(await Presentation.load(await readFile('source.pptx')))
const printed = printScript(ir)
// Write printed.code and its assets, then run the script, as in the first example.

const rebuilt = readModelToIr(await Presentation.load(await readFile('out/output.pptx')))
const report = diffDeckIr(canonicalDeckIr(ir), canonicalDeckIr(rebuilt), printed.notes)
for (const difference of report.undeclared) {
  console.log(difference.slideNumber, difference.shapeName, difference.path, difference.expected, difference.actual)
}

canonicalDeckIr() removes values that mean the same as their absence in OOXML, such as bold: false, and compares media by content. report.added lists values the write path states where the source inherited one. diffDeckIr accepts a fixed set of those write-path defaults and reports any other as undeclared.

A difference no note covers is a converter defect. In a clone of the repository, pnpm run script:roundtrip -- --dir <path> runs the check over a folder of decks. The testing guide lists its options.

What a clean run does not prove ​

Both IRs come from the same reader. A construct the reader does not see is missing from both and compares equal. Two source constructs that map to the same call also compare equal. A clean report means nothing the converter can see was lost, not that nothing was lost.

Options ​

Both printers take the first four options. See CommonPrintOptions and PrintScriptOptions.

OptionDefaultEffect
outputPath'./output.pptx'Where the script writes the deck
assets'file''file' returns media bytes in PrintedScript.assets for you to write. 'inline' embeds them in the script as data: URIs, at about 4/3 the byte size, and returns an empty map
assetDir'./assets'Where the script reads media files from when assets is 'file'
packageName'pptx-ts'The import specifier the script uses. Point it at a local build or a fork
templatePath'./template.pptx'printScript only. Where the script loads the unmodified source deck from

The script resolves every path against its own location.

Known limitations ​

  • The standalone output drops a table on a layout (layout.decoration) and turns a group on a layout into loose shapes (layout.group).
  • A slide master's own shapes have no write-side target (master.decoration).
  • The package has no command-line converter. Call the functions from your own script.
  • The published counts measure the construct-targeted corpus. Run pnpm run script:census -- --dir <path> in a clone to count notes on your own decks.

See also ​