Read, edit, and save existing PowerPoint (.pptx) files in Node — a TypeScript port of python-pptx.
The JS ecosystem has no python-pptx equivalent: pptxgenjs only creates decks. ts-pptx opens an existing presentation, lets you read and edit its slides/text/pictures/tables/notes/shapes, and saves it back — in pure TypeScript, with jszip as the only runtime dependency. It runs anywhere Node ≥ 18 runs (including the Electron main process); no native modules, no DOM.
Its defining property is fidelity: an edit touches only what it must. Opening and re-saving with no edits produces an equivalent package (every untouched part byte-identical); editing one run changes one run. See The fidelity model.
- Node ≥ 18 · ESM · MIT · one runtime dep (
jszip) - Ergonomics mirror python-pptx, so its docs and examples translate directly.
npm install ts-pptximport { Presentation, Inches, Pt } from "ts-pptx";import { Presentation, Inches, Pt } from "ts-pptx";
const prs = await Presentation.open("deck.pptx"); // path, Uint8Array, or ArrayBuffer
for (const slide of prs.slides) {
for (const shape of slide.shapes) {
if (shape.hasTextFrame) {
console.log(shape.textFrame.text);
}
}
}
// edit the first run of the title without disturbing its formatting
const title = prs.slides.get(0).shapes.title;
if (title?.hasTextFrame) {
title.textFrame.paragraphs[0].runs[0].text = "New Title";
}
await prs.save("deck-edited.pptx"); // or: const bytes = await prs.toBuffer();Everything except open, save, and toBuffer is synchronous (those three touch the zip container, which is async). The object model — slides, shapes, text — is all sync.
Positions and sizes are EMU (English Metric Units) throughout — a branded number. Build them with the unit constructors; convert out with Length.
import { Emu, Inches, Pt, Cm, Mm, Centipoints, Length } from "ts-pptx";
shape.left = Inches(1); // 914400
shape.width = Cm(5); // 1800000
run.font.size = Pt(24); // font size is EMU stored as centipoints
Length.inches(shape.left); // 1
Length.pt(run.font.size); // 24Plain numbers are assignable too (the brand is advisory) — shape.left = 914400 works.
const prs = await Presentation.open(source); // string path | Uint8Array | ArrayBuffer
await prs.save("out.pptx"); // write to disk
const bytes = await prs.toBuffer(); // Uint8Array (e.g. to stream/upload)
prs.slideWidth; // Emu | undefined
prs.slideHeight;
prs.slideWidth = Inches(13.333); // set 16:9prs.slides.length;
prs.slides.get(0); // by index (throws RangeError if OOB)
prs.slides.byId(256); // by slide id → Slide | undefined
prs.slides.index(slide); // → number
for (const slide of prs.slides) { /* ... */ }const layout = prs.slideLayouts[0]; // masters/layouts: prs.slideMasters, prs.slideLayouts
const slide = prs.slides.addSlide(layout); // clones the layout's placeholders (minus date/footer/#)
prs.slides.duplicate(slide); // deep copy (notes copied; media shared) → new Slide
prs.slides.move(0, 2); // move slide at index 0 to index 2
slide.delete(); // removes it; orphaned media is garbage-collectedaddSlide returns a Slide whose placeholders are ready to fill:
for (const ph of slide.placeholders) {
if (ph.placeholderFormat.type === "ctrTitle") ph.textFrame.text = "Hello";
}Every shape on a slide is one of: Shape (autoshape / textbox / placeholder host), Picture, GraphicFrame (table / chart / SmartArt), Connector, GroupShape. Access is via slide.shapes:
slide.shapes.length;
slide.shapes.get(0);
slide.shapes.title; // the idx-0 placeholder, or undefined
for (const shape of slide.shapes) { /* ... */ }Common surface on every shape:
shape.shapeId; // number
shape.name; // string (read/write)
shape.shapeType; // MSO_SHAPE_TYPE member ("AUTO_SHAPE", "PICTURE", ...)
shape.left; shape.top; shape.width; shape.height; // Emu | undefined (read/write)
shape.rotation; // degrees clockwise (read/write)
shape.isPlaceholder;
shape.placeholderFormat.idx; // throws if not a placeholder
shape.placeholderFormat.type; // PP_PLACEHOLDER member
shape.hasTextFrame; // non-mutating test before .textFramePlaceholders inherit geometry. A slide placeholder with no explicit position reports the layout placeholder's position (and the layout inherits from the master). So shape.left gives the effective value, matching what PowerPoint renders.
Identity is stable — slide.shapes.get(0) === slide.shapes.get(0).
Text lives in a TextFrame → Paragraph[] → Run[] tree. Which setter you use determines what formatting is preserved — this mirrors python-pptx exactly:
const tf = shape.textFrame; // shape.hasTextFrame first (creating one is a write)
tf.text; // all text; "\n" between paragraphs, "\v" for line breaks
tf.text = "one\ntwo"; // REPLACES everything: one paragraph per "\n".
// Resets paragraph/run formatting (bodyPr/lstStyle kept)
const p = tf.paragraphs[0];
p.text = "abc\ndef"; // replaces runs; KEEPS paragraph formatting (pPr)
p.alignment = "ctr"; // PP_ALIGN member (or undefined to clear)
p.level = 1; // 0..8 indent
p.lineSpacing = new Lines(1.5); // multiple of line height...
p.lineSpacing = Pt(18); // ...or a fixed height
p.spaceBefore = Pt(6);
p.addRun(); p.addLineBreak();
const r = p.runs[0];
r.text = "just the text"; // THE formatting-preserving edit: rPr untouchedUse run.text when you want to change wording and keep the look. Use paragraph.text/frame.text when you intend to rebuild content.
const f = run.font;
f.name = "Calibri"; // string | undefined (undefined = inherit from theme)
f.size = Pt(18); // Emu | undefined
f.bold = true; f.italic = false; // boolean | undefined (undefined = inherit)
f.underline = true; // true/false, or an MSO_UNDERLINE token, or undefined
f.color.rgb = "CC0000"; // 6-hex string; switches the color to explicit RGB
f.color.themeColor = "accent2"; // MSO_THEME_COLOR member; switches to a theme color
f.color.brightness = -0.25; // -1.0..1.0 (tint/shade); requires a color first
f.color.type; // "RGB" | "SCHEME" | ... | undefined (read-only)paragraph.font is the paragraph's default run properties; the same Font API applies.
run.hyperlink.address = "https://example.com/"; // add/change (creates the external relationship)
run.hyperlink.address = undefined; // remove (drops the relationship too)
run.hyperlink.address; // string | undefinedtf.wordWrap = false; // boolean | undefined
tf.verticalAnchor = "ctr"; // MSO_ANCHOR ("t" | "ctr" | "b") | undefined
tf.autoSize = "SHAPE_TO_FIT_TEXT"; // MSO_AUTO_SIZE member | undefined
tf.marginLeft = Inches(0.1); // marginTop/Right/Bottom too (Emu, with PowerPoint defaults)
tf.addParagraph();
tf.clear(); // reduce to a single empty paragraphimport { Inches } from "ts-pptx";
// add — bytes or a file path; identical images are deduped package-wide by SHA1
const pic = slide.shapes.addPicture(bytes, Inches(1), Inches(1)); // native size
slide.shapes.addPicture("logo.png", Inches(1), Inches(1), Inches(2)); // width given → aspect kept
slide.shapes.addPicture("logo.png", Inches(1), Inches(1), Inches(2), Inches(1)); // both → stretched
// read
pic.image.blob; // Uint8Array
pic.image.contentType; // "image/png"
pic.image.size; // [pxWidth, pxHeight]
pic.image.dpi; // [horz, vert]
pic.image.sha1;
// crop (fractions, 0.0–1.0)
pic.cropLeft = 0.1; pic.cropRight = 0.1;
pic.cropTop; pic.cropBottom;
// swap the image, keep the shape's position/size/crop
pic.replaceImage(newBytes);Supported image formats for sizing/DPI sniffing: PNG, JPEG, GIF, BMP, TIFF.
import { Inches } from "ts-pptx";
const frame = slide.shapes.addTable(3, 4, Inches(1), Inches(1), Inches(8), Inches(2));
const table = frame.table; // frame.hasTable to test first
table.cell(0, 0).text = "Header"; // cell text delegates to a TextFrame — full font API applies
const cell = table.cell(1, 1);
cell.textFrame.paragraphs[0].runs[0].font.bold = true;
cell.verticalAnchor = "ctr";
cell.marginLeft = Inches(0.05);
table.rows.length; table.columns.length;
table.rows[0].height; table.columns[0].width;
table.firstRow = true; // header-row banding; also firstCol/lastRow/lastCol
table.horzBanding = true; table.vertBanding = false;
// merged cells (read)
cell.isMergeOrigin; cell.isSpanned; cell.spanHeight; cell.spanWidth;Reading an existing table's merged cells is supported; programmatic merge/split is not yet.
slide.hasNotesSlide; // non-mutating test
const notes = slide.notesSlide; // created on demand (a notes master is added if the deck lacks one)
notes.text; // "" when empty
notes.text = "Remember to slow down here.";
notes.notesTextFrame; // the body placeholder's TextFrame | undefinedimport { MSO_SHAPE, Inches, Pt } from "ts-pptx";
const rr = slide.shapes.addShape(MSO_SHAPE.ROUNDED_RECTANGLE, Inches(1), Inches(1), Inches(2), Inches(1));
rr.adjustments.set(0, 0.5); // corner radius (normalized); .get(i), .length
rr.fill.solid();
rr.fill.foreColor.themeColor = "accent2";
const oval = slide.shapes.addShape(MSO_SHAPE.OVAL, Inches(3), Inches(1), Inches(2), Inches(2));
oval.fill.solid();
oval.fill.foreColor.rgb = "CC0000";
oval.line.width = Pt(3);
oval.line.color.rgb = "00AA00";
const box = slide.shapes.addTextbox(Inches(1), Inches(4), Inches(3), Inches(0.5));
box.textFrame.text = "caption";
// fills — read non-destructively, write explicitly
shape.fill.type; // MSO_FILL member | undefined
shape.fill.solid(); shape.fill.background();
// z-order (document order = back-to-front)
slide.shapes.sendToBack(oval);
slide.shapes.bringToFront(rr);
slide.shapes.moveTo(box, 0);MSO_SHAPE holds ~180 preset-geometry types (RECTANGLE, CHEVRON, RIGHT_ARROW, STAR_5_POINT, …).
for (const shape of slide.shapes) {
if (!shape.hasChart) continue;
const chart = shape.chart;
chart.chartType; // "COLUMN_CLUSTERED", "PIE", "LINE_MARKERS", ...
chart.categories.labels; // ["East", "West", "Midwest"]
for (const s of chart.series) {
s.name; // "Q1"
s.values; // [19.2, 21.4, 16.7] (missing points → undefined)
}
}Values come from the chart's cached data, so no spreadsheet parsing is needed. Chart writing, axes, legend, and data labels are out of scope.
if (shape.hasSmartArt) {
shape.smartArt.texts; // ["Plan", "Build", "Design", "Ship"]
shape.smartArt.nodes; // [{ text, level }, ...] (level from the diagram graph)
}const p = prs.coreProperties; // read-only
p.title; p.author; p.subject; p.keywords; p.comments; p.category;
p.lastModifiedBy; p.revision; // number
p.created; p.modified; p.lastPrinted; // Date | undefinedThis is what sets ts-pptx apart and what to rely on:
- Open → save with no edits is loss-free. Every part that wasn't touched is written back byte-identical (comparing decompressed entry bytes — the zip container is re-deflated). Unknown/vendor XML you don't model survives untouched. Zip entries unreachable from the relationship graph are carried through verbatim (python-pptx drops these).
- An edit touches only what it must. Change one run's text and only that slide part changes — and within it, only that text node. This makes edits auditable and merge-friendly.
- Reads never mutate. Accessing
font.color,line.color,textFrame, etc. to read never writes XML. (State materializes only on an actual write.) This is a deliberate divergence from python-pptx, whose color/fill accessors rewrite XML on read — it's required for the loss-free guarantee above.
Practical consequence: you can open a deck, walk every slide/shape/run to extract data, save, and get back a byte-for-byte-equivalent file.
The API mirrors python-pptx (snake_case → camelCase), so its documentation transfers. Intentional differences:
Presentation.open(...)(async) replaces the module-levelPresentation()factory; there is no built-in default template — a source is always required.save/toBufferare async.- Reads never mutate (above).
frame.text/paragraph.text/run.textsetters keep python's exact formatting semantics. - New APIs python-pptx lacks:
slide.delete(),slides.duplicate(),slides.move(),picture.replaceImage(), shape z-order, SmartArt text extraction. - Slides are not renumbered on access (python renames slide parts), and new slides/pictures get first-available-gap partnames — both required by byte-preservation.
- Units are a branded
number(Emu), not aLengthclass; useLength.inches(x)etc. to convert out.
Out of scope for now (flagged, not silently dropped): chart writing, cell merge/split writing, freeform builder, movies/OLE embedding, font-metric text auto-fit.
Every proxy exposes its raw XML element via .element (an XmlElement from the lossless DOM) for anything the typed API doesn't model yet. Mutating it directly can produce an invalid package — know the schema before you do.
shape.element; // the p:sp / p:pic / p:graphicFrame element
run.element; paragraph.element; table.cell(0,0).textFrame.element;npm install
npm run build # tsc → dist/
npm test # vitest (unit + fixture round-trip + fidelity guards)- Tests live in
tests/. Real.pptxinputs are intests/assets/(round-trip-only) andfixtures/(the authored oracle corpus). The backbone test round-trips every XML entry of every deck throughparse → serializeand asserts byte-equality. - Gate decks: each milestone has
scripts/make-mN-gate.mjsthat emitsout/mN-*.pptxfor manual review in PowerPoint (npm run build && node scripts/make-m1-gate.mjs). - Architecture (source of truth: the python-pptx design it ports):
src/xml/lossless parser/DOM/serializer →src/oxml/typed element wrappers →src/opc/package/parts/relationships →src/{shapes,text,dml,chart,smartart,parts}/the public API, re-exported fromsrc/index.ts. - License: MIT. All dependencies permissive. Keep the dependency tree tiny.
ts-pptx is a TypeScript port of python-pptx by Steve Canny — its layered architecture and API are the design this library reproduces. python-pptx is MIT-licensed; its copyright and permission notice are reproduced in LICENSE under "Third-party notices", as that license requires. Enormous thanks to that project and its maintainers.
ts-pptx is MIT-licensed (© FlowDot LLC). The .pptx format is the open ECMA-376 (Office Open XML) standard.