Edit a shape and save a file PowerPoint opens
Move, colour or retype a shape as XML edits with exact inverses, then export with the firewall on.
Goal
Change one thing on one slide and get a .pptx back in which every other part is byte-identical.
You need
npm install @pptx-studio/opc @pptx-studio/xml @pptx-studio/model @pptx-studio/writerSteps
1. Open with a baseline
openPackage gives the store you edit and the untouched twin the checks compare against.
import { openPackage } from '@pptx-studio/writer';
const pkg = openPackage(bytes); // { store, baseline, baselineBytes }2. Find the shape in its part
Match by namespace URI, never by prefix: a deck may spell p: as anything.
import { descendantElements, namespaceOf, parseXml } from '@pptx-studio/xml';
const NS_P = 'http://schemas.openxmlformats.org/presentationml/2006/main';
const part = '/ppt/slides/slide1.xml';
const doc = parseXml(pkg.store.read(part));
const cNvPr = [...descendantElements(doc.root)].find(
(el) =>
el.local === 'cNvPr' &&
namespaceOf(el) === NS_P &&
el.attributes.some((a) => a.qname === 'id' && a.value === '4'),
);
const shape = cNvPr?.parent?.parent; // p:sp > p:nvSpPr > p:cNvPr3. Plan the edit and keep the inverse
A move is two setAttribute edits on a:off; a colour sets a:srgbClr/@val; a retype sets an a:t's text. applyEdits applies a batch atomically and returns the inverses - your undo stack.
import { applyEdits, type XmlEdit } from '@pptx-studio/xml';
const off = /* the shape's own a:xfrm/a:off */ offsetOf(shape);
const x = off.attributes.find((a) => a.qname === 'x')!;
const edits: XmlEdit[] = [
{ kind: 'setAttribute', element: off, qname: 'x', value: String(Number(x.value) + 914400) }, // one inch right
];
const undo = applyEdits(edits);4. A shape that inherits gets an override of its own
A placeholder with no a:xfrm takes its position from the layout, and a shape with no a:solidFill is coloured by its style or the theme. Give it one, where the schema puts it: that is what PowerPoint writes for the same gesture, and the layout keeps everything else. insertInOrder is the only sanctioned way to add an OOXML child.
import { insertInOrder, newAttribute, newElement } from '@pptx-studio/xml';
const spPr = /* the shape's p:spPr */ propertiesOf(shape);
const fill = newElement(
'a:solidFill',
[],
[newElement('a:srgbClr', [newAttribute('val', 'E8453C')])],
);
const undoFill = applyEdits([insertInOrder(spPr, fill)]);A text colour is the same edit one level down: an a:solidFill in each run's a:rPr, and in the paragraph's a:endParaRPr when it has one, which is what PowerPoint writes for a font colour over the whole text. A run without an a:rPr is given one first, and insertInOrder puts it before the a:t.
5. Serialise the part and export
Only the touched part is rewritten. exportPackage runs the preservation check and the 34 rules and throws rather than return bytes it just broke.
import { serializeXml } from '@pptx-studio/xml';
import { exportPackage } from '@pptx-studio/writer';
pkg.store.replacePart(part, serializeXml(doc));
const result = exportPackage(pkg);
result.rewritten; // ['/ppt/slides/slide1.xml']
result.streamed; // every other part, copied still compressed
const url = URL.createObjectURL(
new Blob([result.bytes.slice()], {
type: 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
}),
);What can go wrong
XmlErrorwithERR_INVALID_EDIT- The edit named a node that is not in the tree, or an attribute that does not exist on it.XmlErrorwithERR_SCHEMA_ORDER- An inserted child landed out of schema order; useinsertInOrder.WriterErrorwithERR_PRESERVATION_BROKEN- An entry not rewritten this session differs from the baseline - something wrote to the store outside the edit path.ValidateErrorwithERR_VALIDATION_FAILED- A rule fired on the export;detail.reportnames the rule, the part and the XPath.
Try it
Reading the deck…