Skip to content

Read and edit a deck ​

pptx-ts/read opens an existing .pptx, gives you its slides, shapes and text to read and change, and saves the result:

ts
import { readFile, writeFile } from 'node:fs/promises'
import { Presentation } from 'pptx-ts/read'

const deck = await Presentation.load(await readFile('deck.pptx'))
for (const slide of deck.slides) {
  for (const shape of slide.shapes) console.log(slide.index, shape.shapeType, shape.name, shape.text)
}

const title = deck.slides[0]?.placeholder('title')
if (title) title.text = 'Quarterly review'
await writeFile('deck-edited.pptx', await deck.save())

save() writes again only the parts you changed. Every other part keeps the bytes it was loaded with. Round-trip guarantee states the fidelity contract, and Read object model lists what each class reads.

Load and save ​

  • Presentation.load(input) takes the package as a Uint8Array, an ArrayBuffer, a Blob or a number[]. A Node Buffer is a Uint8Array. A string is a file path, read from disk under Node.
  • deck.save() resolves to a Uint8Array. Writing it to a file is up to you.
  • A setter or a method changes the part in memory and marks it dirty. Reading a property never marks a part dirty.
  • save() serializes each dirty part again and writes every other part with its original bytes.
  • deck.opc is the package underneath, with its parts, content types and relationships. Round-trip guarantee covers it.
  • Positions and sizes are in EMU, 914400 to the inch. Layout units has the conversion helpers.

Find a shape ​

CallFindsLooks inside groups
slide.shapesevery top-level shape, in document orderno
slide.shapeByName(name)the first top-level shape with that name (p:cNvPr/@name)no
slide.shapeById(id)the first top-level shape with that drawing id (p:cNvPr/@id)no
slide.shapeByIdDeep(id)the shape with that drawing id, visiting each group before its childrenyes
slide.placeholder(type, idx?)the first top-level placeholder with that p:ph/@type, and with that idx when you pass oneno
ts
const slide = deck.slides[0]
if (!slide) throw new Error('the deck has no slides')

const caption = slide.shapeByName('Caption')
const insideGroup = slide.shapeByIdDeep(12)
const body = slide.placeholder('body', '1')
console.log(caption?.placeholder, insideGroup?.shapeType, body?.text)
  • The finders return undefined when nothing matches.
  • placeholder() returns an AutoShape, since only p:sp shapes can be placeholders.
  • shape.placeholder is { type, idx }, or null when the shape is not a placeholder. A p:ph with no idx reads as idx: '0'.
  • To find a shape by name inside a group, walk group.shapes.
  • isAutoShape, isPicture, isGraphicFrame, isGroupShape and isConnector narrow a shape to its class. shape.shapeType names the class as a string.
  • Tables have their own editing members. See Reading it back.

Replace text ​

Three setters replace text at three levels:

SetterReplacesKeeps
shape.text, textFrame.textevery paragraph, with one paragraph holding one runthe first paragraph's properties, and its first run's character formatting
paragraph.textthe runs, line breaks and fields of that paragraph, with one runthe paragraph's level, alignment and bullet, its first run's formatting, and every other paragraph
run.textthe run's textthe run's formatting
ts
const title = slide.placeholder('title')
if (title) title.text = 'New title'

const second = slide.placeholder('body', '1')?.textFrame?.paragraphs[1]
if (second) second.text = 'Second bullet, rewritten'
  • The string goes into a single run. A \n in it does not start a new paragraph.
  • Leading and trailing spaces are kept (xml:space="preserve").
  • Setting text on a shape with no text frame, such as a picture, throws UnsupportedFeatureError with code shape/no-text-frame.
  • To change one run and leave its neighbours alone, set run.text through textFrame.paragraphs[i].runs[j].

Change position, size and colour ​

ts
const callout = slide.shapeByName('Callout')
if (callout) {
  callout.left = 914400
  callout.top = 457200
  callout.width = 3657600
  callout.fillColor = '1F4E79'
  callout.lineColor = 'D4D4D4'
}

const lead = slide.placeholder('body', '1')?.textFrame?.paragraphs[0]?.runs[0]
if (lead) {
  lead.bold = true
  lead.fontSizePt = 28
  lead.color = '1F4E79'
}
  • left, top, width and height are in EMU.
  • fillColor and lineColor take a six-digit hex colour, and fillSchemeColor and lineSchemeColor take a theme colour name such as accent2.
  • fillColor = null removes the shape's own fill, so it inherits one from its style or placeholder. noFill() writes an explicit empty fill instead.
  • A run takes bold, italic, underline, fontSizePt, fontName, color and schemeColor.
  • Shape and Run in the API reference list every setter.

Add and remove shapes ​

ts
const box = slide.addTextBox({
  text: 'Draft',
  left: 914400,
  top: 457200,
  width: 4572000,
  height: 914400,
  name: 'Draft stamp',
})
const run = box.textFrame?.paragraphs[0]?.runs[0]
if (run) run.bold = true

slide.addPicture(await readFile('logo.png'), { left: 8229600, top: 457200, width: 914400, height: 914400 })

slide.shapeByName('Old caption')?.delete()
  • addTextBox adds a rectangle text box (txBox="1") with one paragraph. left, top, width and height are required, in EMU. text defaults to an empty paragraph, and name to TextBox <id>.
  • addPicture stores the image as a part under /ppt/media/, registers its content type, and adds an image relationship from the slide. name defaults to Picture <id>.
  • addPicture recognises PNG, JPEG, GIF, BMP, TIFF and WebP from the bytes. For any other format, pass extension and contentType.
  • A new shape gets a drawing id one above the highest on the slide, and goes in front of the other shapes.
  • shape.delete() removes the shape from its slide or group, and the object is unusable afterwards. A deleted picture's relationship and media part stay in the package.
  • The animations of a deleted shape, or of a shape inside a deleted group, are removed with it. A connector attached to one keeps its line, and that end is left unattached.

Read and write speaker notes ​

A slide's notes live in a separate notes slide part. Three getters read it:

GetterReturnsKeeps formattingnull when
slide.notesTextthe notes body as a string, paragraphs joined by \nnothe slide has no notes part
slide.notesTextFramethe notes body as a TextFrame, with its paragraphs, runs and hyperlinksyesthe slide has no notes part, or its notes part has no body text frame
slide.notesSlidethe whole notes slide as a NotesSlide, with its slide image, body and slide number placeholdersyesthe slide has no notes part
  • A notes part with an empty body gives notesText === ''. So does a notes part with no body text frame, for which notesTextFrame is null.
  • Every slide TsPptx writes has a notes part, so its notesText is '' rather than null when it has no notes. A slide imported without importNotes has no notes part.
  • A notes run reports resolvedColor, resolvedSizePt, resolvedFontFace and resolvedBold through the deck's notes master and its theme.
  • A notes hyperlink resolves run.hyperlink.url through the notes part's own relationships.

slide.addNotes(text) writes notes and returns the NotesSlide:

ts
if (slide.notesText === null) slide.addNotes('Open with the headline number.\nThen the three drivers.')

const first = slide.notesTextFrame?.paragraphs[0]?.runs[0]
if (first) first.bold = true
  • A \n starts a new paragraph. The runs have no formatting of their own, so style them afterwards through notesTextFrame.
  • On a slide that has notes, addNotes replaces the body's paragraphs. The part's geometry and its other two placeholders stay as they were.
  • On a slide with no notes part, it creates one. The slide gets a relationship to it, and the notes part gets a relationship to the notes master (rId1) and one back to the slide (rId2).

One notes master per deck ​

A deck holds at most one notes master (p:notesMasterIdLst allows 0..1). Every way of adding notes follows the same rule:

CallThe deck has a notes masterThe deck has none
slide.addNotesthe new notes use ita notes master is added, bound to a copy of this deck's theme
importSlide, importSlides with importNotesthe imported notes use it, and the source deck's notes master and theme are not copiedthe source deck's notes master and its theme are copied, in a batch from the first request that carries notes
appendSlidesthe appended notes use itthe generator's notes master and its theme are added

A deck's own notes styling therefore wins whenever it has one, and no mix of these calls adds a second notes master.

Replace the image of a picture ​

ts
import { isPicture } from 'pptx-ts/read'

const picture = slide.shapes.find(isPicture)
picture?.setImage(await readFile('our-logo.png'), { contentType: 'image/png', fit: 'cover' })

picture.setImage(bytes, options) stores the bytes as a new media part, adds a relationship from the slide, and points the picture's a:blip/@r:embed at it.

  • contentType is required, such as 'image/png'. The bytes are not inspected. extension defaults from the content type.
  • The old media part is never changed or removed. Several pictures often share one media part, after an import for example, and each of the others keeps its image. The old part stays in the package even when no picture uses it any more.
  • fit sets the crop (a:srcRect) against the picture's current frame:
fitCrop
omittedunchanged, and so is the frame. A crop sized for the old image's aspect ratio stays and can distort the new image.
'cover'fills the frame, cropping the axis that overflows
'contain'fits the whole image inside the frame, leaving space at both ends of the short axis
'stretch'removes the crop, so the whole image stretches to the frame
  • 'cover' and 'contain' need a frame with a width and height above zero, and read the image's size from its bytes. When they cannot measure it, the crop stays as it was and a warning says so.
  • To point a picture at an image its slide already references, assign the relationship id, as in picture.imageRelId = other.imageRelId. No part is added, and the id must name an image relationship of that slide.

Edit the XML directly ​

For anything the typed setters do not cover, change the element and mark its part dirty:

ts
const watermark = slide.shapeByName('Watermark')
if (watermark) {
  watermark.element_.getElementsByTagName('p:cNvPr')[0]?.setAttribute('hidden', '1')
  watermark.markDirty()
}
  • element_ returns the live element behind a model object, and markDirty() marks the part that holds it.
  • Both are on Slide, SlideLayout, SlideMaster, Placeholder, NotesPlaceholder, Theme, every shape class, TextFrame, Paragraph, Run, Table, TableRow, TableCell, Chart, ChartAxis, ChartSeries, ChartEx, ChartExAxis, ChartExSeries, Diagram, DiagramNode, DiagramPoint, and the ResolvedTableStyle that table.resolvedStyle returns.
  • On a shape, text frame, paragraph or run, markDirty() marks the part that holds the shape: a slide, a layout or a master. On a chart object it marks the chart part, and on a resolved table style the deck's table styles part.
  • Without markDirty(), save() writes the part's original bytes and the change is lost. Nothing throws or warns.
  • deck.opc.part(partName) reaches a part that no model class covers. Change its dom, then call part.markDirty().
  • The trailing underscore makes every use of the hatch easy to find in your own code.
ts
const part = deck.opc.part('/ppt/slides/slide1.xml')
const text = part?.dom.getElementsByTagName('a:t')[0]
if (part && text) {
  text.textContent = 'New title'
  part.markDirty()
}

Invalid input ​

ConditionResultCode
the load input is not a zip archivethrows PackageReadErrorzip/not-a-zip-archive
the load input is none of the accepted typesthrows InvalidOptionErrorzip/unsupported-input
the archive has no [Content_Types].xmlthrows PackageReadErrorpackage/not-an-opc-package
a part has no content typethrows PackageReadErrorpackage/part-content-type-missing
text set on a shape with no text framethrows UnsupportedFeatureErrorshape/no-text-frame
an addTextBox or addPicture position or size that is not a finite numberthrows InvalidOptionErrorcoord/non-finite
an addTextBox or addPicture width or height of zero or lessthrows InvalidOptionErrorcoord/not-positive
addPicture bytes in an unrecognised format, without extension and contentTypethrows InvalidOptionErrorimage/undeterminable-type
setImage without contentTypethrows InvalidOptionErrorimage/missing-content-type
setImage with fit: 'cover' or 'contain' on a picture with no frame, or a zero-size framethrows InvalidOptionError, picture unchangedimage/fit-needs-extent
setImage with fit: 'cover' or 'contain' when the image size cannot be readwarns, crop unchangedimage/unmeasurable-natural-size
addNotes must add a notes master, and no theme is reachable from the slidethrows PackageReadError, no part addedpackage/part-missing
addNotes on a slide whose notes part has no body placeholderthrows PackageReadErrorpackage/part-has-no-root

Limits ​

  • Only shapeByIdDeep looks inside groups.
  • The text setters write a single run, and a \n stays inside it.
  • addTextBox adds a plain rectangle text box and addPicture a plain picture. Other shapes need an import or the XML.
  • shape.delete() leaves the shape's relationships and media in the package, and setImage leaves the image it replaced.
  • addNotes writes runs with no formatting.
  • A change made through element_ or part.dom is saved only after markDirty().

See also ​