Skip to content

Repository files navigation

ts-pptx

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.

Install

npm install ts-pptx
import { Presentation, Inches, Pt } from "ts-pptx";

Quick start

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.


Units

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);      // 24

Plain numbers are assignable too (the brand is advisory) — shape.left = 914400 works.


Opening & saving

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:9

Slides

prs.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) { /* ... */ }

Add / delete / duplicate / reorder

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-collected

addSlide returns a Slide whose placeholders are ready to fill:

for (const ph of slide.placeholders) {
  if (ph.placeholderFormat.type === "ctrTitle") ph.textFrame.text = "Hello";
}

Shapes

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 .textFrame

Placeholders 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

Text lives in a TextFrameParagraph[]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 untouched

Use run.text when you want to change wording and keep the look. Use paragraph.text/frame.text when you intend to rebuild content.

Fonts & color

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.

Hyperlinks

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 | undefined

Text frame body properties

tf.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 paragraph

Pictures

import { 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.


Tables

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.


Speaker notes

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 | undefined

Autoshapes, fills, lines, z-order

import { 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, …).


Charts (read-only)

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.

SmartArt (read-only text)

if (shape.hasSmartArt) {
  shape.smartArt.texts;                     // ["Plan", "Build", "Design", "Ship"]
  shape.smartArt.nodes;                     // [{ text, level }, ...]  (level from the diagram graph)
}

Document properties

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 | undefined

The fidelity model

This 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.


Notes for python-pptx users

The API mirrors python-pptx (snake_casecamelCase), so its documentation transfers. Intentional differences:

  • Presentation.open(...) (async) replaces the module-level Presentation() factory; there is no built-in default template — a source is always required. save/toBuffer are async.
  • Reads never mutate (above). frame.text/paragraph.text/run.text setters 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 a Length class; use Length.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.


Escape hatch

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;

Developing ts-pptx

npm install
npm run build        # tsc → dist/
npm test             # vitest (unit + fixture round-trip + fidelity guards)
  • Tests live in tests/. Real .pptx inputs are in tests/assets/ (round-trip-only) and fixtures/ (the authored oracle corpus). The backbone test round-trips every XML entry of every deck through parse → serialize and asserts byte-equality.
  • Gate decks: each milestone has scripts/make-mN-gate.mjs that emits out/mN-*.pptx for 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 from src/index.ts.
  • License: MIT. All dependencies permissive. Keep the dependency tree tiny.

Credits & license

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.

About

No description, website, or topics provided.

Resources

Stars

Watchers

Forks

Releases

Packages

Contributors

Languages