glove-env-slides
v1.0.0
Published
Slides stdlib adapter for glove-working-environment. Builds PowerPoint decks and reads them back as env:slides — create, describe, extract text and speaker notes, outline. Paths in, paths out.
Readme
glove-env-slides
PowerPoint decks for glove-working-environment. Registers as env:slides: an agent builds a deck from a spec, or reads one it was handed.
pnpm add glove-env-slidesimport { createWorkingEnvironment } from "glove-working-environment";
import { slides } from "glove-env-slides";
const env = await createWorkingEnvironment({ stdlib: [slides()] });Building
import { create } from 'env:slides';
export default async function main() {
return create({
title: 'Q3 Review',
subtitle: 'Prepared for the board',
footer: 'Confidential',
slides: [
{ title: 'Headline', metric: { value: '$4.2M', caption: 'revenue, up 12% QoQ' } },
{ title: 'By region', table: [['Region', 'Revenue'], ['EMEA', '$1.8M'], ['AMER', '$2.4M']] },
{
title: 'What changed',
bullets: ['EMEA renewals landed early', ' two of them slipped from Q2'],
notes: 'The board asked about the Q2 slip last time — lead with it.',
},
],
}, '/out/q3.pptx');
}A slide takes one kind of content — bullets, table, metric or body, applied in that order — plus an optional image (a VFS path, so generate it with env:images first) and notes. Indent a bullet by prefixing two spaces.
A table longer than a slide continues onto further slides with its header repeated, rather than running off the bottom. That matters more than it sounds: an overflowing table still puts every row in the file, so extract() finds them all and nothing looks wrong until someone opens the deck.
The palette and layout are fixed rather than configurable. An agent choosing colours per deck produces something worse than a consistent default, and every knob is a decision it has to spend a turn on.
When the spec is not enough
create builds a good standard deck and cannot build a different one — a two-column slide, a chart, particular colours and positions. So pptxgenjs is exported as-is:
import { PptxGenJS } from 'env:slides';
export default async function main() {
const pptx = new PptxGenJS();
pptx.layout = 'LAYOUT_16x9'; // 10 × 5.63 inches; positions are inches
const s = pptx.addSlide();
s.addText('By region', { x: 0.6, y: 0.4, w: 8.8, fontSize: 26, bold: true, color: '1A1A2E' });
s.addShape(pptx.ShapeType.rect, { x: 0.6, y: 1.1, w: 1.2, h: 0.06, fill: { color: '2563EB' } });
s.addTable(rows, { x: 0.6, y: 1.5, w: 4.2, autoPage: true, autoPageRepeatHeader: true });
s.addImage({ path: '/tmp/chart.png', x: 5.2, y: 1.5, w: 4.2, h: 3.3 });
s.addNotes('Revenue is concentrated in EMEA.');
return pptx.writeFile({ fileName: '/out/q3.pptx' });
}That is pptxgenjs, verbatim from its own documentation — which is the point. Models have read thousands of examples of this shape, and an API that differs makes them translate.
Everything is synchronous until writeFile, the only await and the only thing that produces a file. The library's own writeFile is replaced, not wrapped: bytes are produced in memory and land in the VFS through the guarded handle. addImage({ path }) resolves through the VFS too — left alone, pptxgenjs would open that path on the host filesystem.
One thing that does not carry over: you cannot read values back off the deck mid-build. The whole recording replays at the write, so there is nothing to return; interpolating a builder yields a label saying so rather than throwing.
Reading
describe() answers "what am I holding?" for a few dozen tokens — no deck is small enough to read blind:
const summary = await describe('/inbox/board.pptx');
// { format: 'pptx', slides: 31, titles: [...], words: 2140, media: 6, bytes: 284102 }extract() returns every slide's title, body paragraphs and speaker notes. outline() flattens the whole deck to markdown with ## Slide N: Title headers — write that to a file and grep it, which costs a fraction of pulling 31 slides through the response cap.
Editing, not regenerating
create() writes a new deck. replaceText() changes one that already exists — 'fix the typo on slide 4' — and the difference is the whole point:
await replaceText('/inbox/q3.pptx', { 'Nortwind': 'Northwind' }, { slides: 4 });
// → { path, replacements: 1, slides: [{ slide: 4, replacements: 1 }], unmatched: [] }Only the slide parts holding the matched text are rewritten. Every other part — masters, layouts, theme, ppt/media/*, animations, the slides you did not scope to — is copied across still compressed, so code that never decoded a part cannot change it. Measured on a deck this adapter wrote: the edit rewrote one of 50 parts and left the other 49 byte-identical, where reading the text out and rebuilding the deck lost the chart image and the footer's slide layout. On a deck a designer made, the rebuild loses the design.
pptxgenjs cannot open a .pptx, so this goes through the same independent OOXML path as the reader: inflate the part, splice its text runs, re-emit. Text split across formatting runs is still found — runs are joined per paragraph before matching — and a replacement lands in the run its match started in, keeping that run's font, size and colour. A search that matches nothing throws rather than writing an identical deck.
Anything beyond replacing strings — adding a slide, moving one, changing a layout — still means building a new deck.
Why the reader is not pptxgenjs
Writing goes through pptxgenjs; reading goes through this package's own ZIP + OOXML reader in src/pptx.ts, and that asymmetry is deliberate.
Verifying a writer with its own library proves only that it is self-consistent. A title written into the wrong placeholder, or a bullet silently dropped, round-trips perfectly through the library that made the mistake. Opening the file independently is what catches it — and it is the only way to read a deck this environment did not write, which is most of the review work an agent is actually asked to do.
Two things that reader gets right and a naive one does not:
- Runs are joined within a paragraph. PowerPoint splits one visual line into several
<a:t>runs wherever formatting changes, so "Revenue grew 12%" is three runs. Joining on run boundaries turns one bullet into three and every count downstream is wrong. - Footers live on the slide master, not the slide. A footer stamped into each slide's own XML comes back as a body line on every slide, so a 30-slide deck yields 30 copies of "Confidential" and any summary built from the text inherits them. Chrome is not content.
- Speaker notes are resolved through relationships, not by number.
slide7.xml↔notesSlide7.xmlholds for decks this package writes and for little else: PowerPoint numbers notes parts in creation order, so a deck where slide 2 got notes first hasnotesSlide1.xmlhanging off slide 2. The rels part is the answer the file itself gives; the numeric convention survives only as the fallback for a deck with no rels at all.
The test suite reads decks back through that independent path, and one test skips both readers entirely — it runs the system unzip to CRC-check the archive and assert every OOXML part the spec requires is present, so a deck only our own code can open still fails.
Handling
Claims .pptx by extension only. A pptx is a ZIP and so are .docx and .xlsx, so the PK signature cannot tell them apart — claiming it would steal every Office file from the adapter that owns it. describe() verifies by looking for ppt/slides/ inside and says so when the container is some other kind of Office file.
Limits
- ZIP64 archives are refused with a message rather than mis-parsed.
- Charts are not generated. Render one with
env:imagesand place it viaimage. - Reading recovers text, notes and media counts — not layout, animation or theming. It is built for review, not for round-tripping someone else's design.
- Editing replaces text, and only text. It preserves layout, animation and theming by never touching them, which is a different guarantee from understanding them:
replaceTextcannot add a slide, resize a shape or restyle a run, and matching is literal, case-sensitive and confined to one paragraph.
