Get started with the OPF toolkit
Create and check a first deck, then use the toolkit sections for your integration.
Create a deck and export it
Use the published CLI for a first local run. No account or model call is required.
npm init -y
npm install --save-dev @openpresentation/cli @openpresentation/opf-render @openpresentation/opf-pptx
npm install --save-dev @expo-google-fonts/roboto@0.4.3 @expo-google-fonts/roboto-mono@0.4.2 @expo-google-fonts/caladea@0.4.2 @expo-google-fonts/arimo@0.4.3 @expo-google-fonts/tinos@0.4.2 @expo-google-fonts/cousine@0.4.3 @expo-google-fonts/gelasio@0.4.1 @expo-google-fonts/noto-sans@0.4.2Save this complete document as deck.opf.json.
{
"name": "First deck",
"slides": [
{
"title": "Make the next action clear",
"text": "Keep the message in OPF. Export PowerPoint when you need an Office handoff."
}
]
}npx opf validate deck.opf.json
npx opf convert deck.opf.json deck.pptxPreview and export share core composition. Native Office text flow and fonts can still differ; keep the OPF source and inspect conversion diagnostics. For a website, editor or Node app, use the runnable starters.
The stack
A slide deck is a JSON file. One core package validates it and computes the geometry of every slide. The renderer, the PowerPoint exporter and the editor share that geometry. Native text flow and font availability still set fidelity limits. Four libraries and a CLI, each its own MIT-licensed repository. This site's tests run every Node snippet on this page against the published packages, and type-check the browser snippets.
The rule that holds it together: preview and export only agree when they resolve the same core. When core changes geometry, the renderer, PowerPoint exporter and editor raise their minimum core in the same release series. Core 0.12.0 did: font sizes snap to PowerPoint's 0.01 pt grid, a wrapped trailing space hangs, promoted regions compose in reading order and right-to-left decks mirror. Install the libraries as a set, pinned to the versions above.
npm install @openpresentation/opf@0.20.3 @openpresentation/opf-render@0.20.2 @openpresentation/opf-pptx@0.20.3 @openpresentation/gallery@^3.0.0
# PNG and PDF export and exact text measurement use the renderer's optional peers; an SVG-only install needs none of them.
npm install @resvg/resvg-js@^2.6.2 sharp@^0.35.5 pdf-lib@^1.17.1 @expo-google-fonts/roboto@0.4.3 @expo-google-fonts/roboto-mono@0.4.2 @expo-google-fonts/caladea@0.4.2 @expo-google-fonts/arimo@0.4.3 @expo-google-fonts/tinos@0.4.2 @expo-google-fonts/cousine@0.4.3 @expo-google-fonts/gelasio@0.4.1 @expo-google-fonts/noto-sans@0.4.2
npm install @openpresentation/opf-editor@0.20.3
npm install --save-dev @types/nodeRun npm ls @openpresentation/opf afterwards. It should list one copy of core; two copies mean two packages resolved different cores, and a preview and an export may disagree. Each package declares the Node versions it supports in engines.node, and Node 24 is the development runtime.
The document
Name files *.opf.json. Only a nonempty slides array is required; name labels the document. Other fields are optional and resolved from catalogs your host registers. The rest of this page works on this file.
{
"name": "Quarterly Review",
"design": {
"theme": "minimal",
"fontScheme": "roboto",
"footer": { "left": { "text": "Internal" }, "right": { "text": "{{slide.number}} / {{deck.slideCount}}" } }
},
"slides": [
{ "id": "title", "section": "Start", "title": "Quarterly Review", "subtitle": "Q3 2026" },
{ "id": "agenda", "title": "Agenda", "items": ["Revenue", "Product", "Hiring"] },
{
"id": "rev",
"title": "Revenue",
"chart": {
"type": "column",
"data": { "columns": ["Quarter", "Revenue"], "rows": [["Q1", 12], ["Q2", 18]] }
}
}
]
}Content shapes
A slide carries one payload at its root (text, items, bullets, quote, metric, code, timeline, chart, table, image, video), or several as blocks, nested groups, or promoted regions (left, center, right, top:left, and so on). Text can be rich runs with links, citations (cite) and inline footnotes. Images, charts, tables and video take a caption. Lists can be numbered, per level, with numbering styles such as roman-upper. See promoted regions, implicit blocks and content payloads.
Design resolution
Every visual choice resolves top-down, and the first layer that sets a value wins.
- The slide's
design, for one slide. - The deck's
design, for the whole deck. - The resolved theme, with its color scheme and font scheme, from a catalog.
- The selected layout record's design defaults.
- Engine defaults.
The design resolution reference has the details.
OPF v0.20.3 uses nested arrays for named grid areas, optional tracks and contentregions. Regions accept content kinds and bind compatible items in reading order; content groups retain their own nested composition. Dynamic layoutsdescribes composition and pagination.
Catalogs
Core ships no catalog records in its main entry. A document references a theme, layout, color scheme, font scheme, narrative, audience, purpose or tone by id or name:id, and the host registers the catalogs those ids come from: validate(deck, { catalogs: [defaultCatalog] }), where defaultCatalog is the catalog exported by pptx.gallery from @openpresentation/gallery. Saving embeds every record a document uses (embed, or opf embed), so the file renders the same offline and with no catalog registered. A reference that resolves nowhere is a warning. An unresolved layout composes automatically; unresolved theme, color or font references use engine defaults.
| Catalog | Records |
|---|---|
| Audiences | 16 |
| Chart Types | 27 |
| Color Schemes | 14 |
| Font Schemes | 89 |
| Languages | 93 |
| Layouts | 28 |
| Narratives | 48 |
| Purposes | 9 |
| Social Platforms | 10 |
| Themes | 4 |
| Tones | 7 |
Validate in code
import { readFile } from "node:fs/promises"
import { assertValid, validate } from "@openpresentation/opf"
import { gallery as defaultCatalog } from "@openpresentation/gallery"
import type { Presentation } from "@openpresentation/opf/types"
const deck: unknown = JSON.parse(await readFile("deck.opf.json", "utf8"))
// Report style: never throws, returns every finding. Core ships no catalog records: register the catalogs your
// documents reference (the first is the host default), or only the records a document embeds resolve.
const catalogs = [defaultCatalog]
const { valid, findings } = validate(deck, { only: ["format", "references"], catalogs })
for (const finding of findings) console.error(finding.severity + " " + finding.path + ": " + finding.message)
// Guard style: throws OPFValidationError, then narrows the type.
assertValid(deck)
const typed: Presentation = deck
// A catalog is records keyed by id, grouped by kind.
const scheme = typeof typed.design?.fontScheme === "string" ? defaultCatalog.fontSchemes?.[typed.design.fontScheme] : undefined
console.log(valid, scheme)Only errors make valid false. An unknown catalog id, such as a misspelled font scheme, is a warning, so check the findings for warnings (or run opf validate --fail-on warning) when a typo should fail your build.
For neutral facts about a deck (slide, word and image counts, notes, alt text, speaking time) with no judgments, call stats(deck) or run opf stats deck.opf.json; see the stats reference.
Subpath exports keep bundles small: /schemas, /validator, /types, /composition, /pagination, /data, /markdown, /yaml, /patch, /diff, /format, /convert, /font-policy, and the raw /spec/* JSON. Catalog records come from @openpresentation/gallery, registered by the host or embedded in the deck.
Validate
One checker, from "is this valid OPF" to "will people be able to read it". It never renders: layout rules run on core's own composition, and every result is a list of findings in one format.
validate
Takes a deck or its JSON text and returns valid, findings, counts and what ran. Each finding has a stable opf/<rule> id, a severity, a category, a JSON Pointer path, and a hint; text input adds source ranges, and a fix is attached where one is safe.Categories
format and references (schema, semantic rules, catalog and asset ids) and policy (your contracts) can be errors. accessibility, layout and content are warnings and info: WCAG AA contrast against the resolved background, overflow, minimum type size, alt text, reading order, chart series told apart by color alone.Options
only and ignore take rule ids, bare rule names or categories; severity re-ranks a rule; fonts passes the renderer's measurement. validate(deck, { only: ['format'] }) is the cheap check for a hot path.Exit codes
CLI reports are JSON. 0 is success, possibly with warnings; 1 is an invalid document or a failed check; 2 is a usage or I/O error. --fail-on warning fails on warnings.import { readFile } from "node:fs/promises"
import { validate } from "@openpresentation/opf"
const source = await readFile("deck.opf.json", "utf8")
// One checker. Text is read as strict JSON, and its findings carry source ranges (and fixes where one is safe).
const report = validate(source)
console.log(report.valid, report.counts) // { error, warning, info }
// Run only what you need: a rule id, a bare rule name or a category (format, references, policy, accessibility, layout, content).
const accessibility = validate(JSON.parse(source), { only: ["text-contrast", "missing-alt-text"] })
console.log(accessibility.counts)Compose and paginate
Layout is a pure function in core. Every engine calls it, reducing geometry drift between SVG preview and PPTX export. Native text flow remains viewer-dependent.
import { readFile } from "node:fs/promises"
import { paginate, resolveSlideContext } from "@openpresentation/opf"
import { gallery as defaultCatalog } from "@openpresentation/gallery"
import { composeSlide } from "@openpresentation/opf/composition"
import { loadFonts } from "@openpresentation/opf-render/fonts-node"
const deck = JSON.parse(await readFile("deck.opf.json", "utf8"))
const fonts = await loadFonts({ pack: "base" }) // bundled, hash-pinned font files
const catalogs = [defaultCatalog] // core ships no records: the host registers the catalogs a document's references resolve in
// The slide's canvas, layout, theme and font families, resolved the one way every engine resolves them.
const { options } = resolveSlideContext(deck, 0, { fonts, catalogs })
const geometry = composeSlide(deck.slides[0], options)
const { presentation, pages } = paginate(deck, { fonts, catalogs })
console.log(geometry.width, geometry.height, presentation.slides.length, pages.length)- Measured, not guessed. Pass the same font
optionsto composition, preview and export. Sizes snap to PowerPoint's 0.01 pt grid, so the preview draws exactly the size the PPTX stores. - Pagination never drops content. It returns ordinary OPF slides plus a mapping back to the source, keeping text, whitespace, reading order and list numbering across continuation pages.
- Furniture. Shared headers and footers reserve bands. Body content stays between
geometry.furniture.headerBottomandfooterTop, and footnotes reserve their own area above the footer. - Right-to-left. The deck language sets direction. Columns, regions, list markers, tables and timelines mirror, and an authored
leftmeans the start edge. - Shared visual tables. Code syntax colors, metric trend arrows, the DrawingML pattern bitmaps and contrast-safe chart palettes live in core, so both engines draw them alike.
More in dynamic layouts and rich text measurement.
Render: SVG, PNG, PDF
@openpresentation/opf-render turns composed geometry into deterministic SVG, then rasterizes it or converts it to PDF. It makes no network request and reads no clock, and uses system fonts only if you ask.
import { readFile, writeFile } from "node:fs/promises"
import { gallery as defaultCatalog } from "@openpresentation/gallery"
import { paginate } from "@openpresentation/opf/pagination"
import { toPdf, toPng, toSvg } from "@openpresentation/opf-render"
import { loadFonts } from "@openpresentation/opf-render/fonts-node"
const deck = JSON.parse(await readFile("deck.opf.json", "utf8"))
const fonts = await loadFonts({ pack: "office", substitutionPolicy: "visual" })
const catalogs = [defaultCatalog]
const { presentation } = paginate(deck, { fonts, catalogs })
const svgs = toSvg(presentation, { fonts, catalogs, trace: true }) // one SVG per slide, data-opf-path on every element
await writeFile("slide-1.svg", svgs[0])
await writeFile("slide-1.png", await toPng(presentation, 1, { fonts, catalogs })) // slides count from 1
await writeFile("deck.pdf", await toPdf(svgs, { fonts })) // a deck or its SVGs; vector, selectable text
console.log(fonts.registry.substitutions) // what replaced what- Vector PDF by default. TrueType subsets with ToUnicode, native gradients and clips, link annotations, tagged structure, and one SVG pixel per PDF point. An effect with no PDF form rasterizes that one element and reports
pdf-raster-fallback;strict: truethrows instead.raster: truekeeps image-per-slide output. - Fonts. The
basepack (Roboto, Roboto Mono) or theofficepack (metric-compatible substitutes such as Arimo, Tinos, Carlito, and Intos for Aptos), with ametricorvisualsubstitution policy. Script faces load on demand withscripts: "auto". Symbol fonts such as Wingdings are mapped without being bundled. - What to install. From 0.16 the converters and the font packages are optional peers, loaded on first use, so an SVG-only install holds none of them.
/svgneeds nothing;/png(or the root) needs@resvg/resvg-js, plussharpfor WebP and rotated JPEG pictures;/pdfneedssharpfor pictures, andpdf-liband@resvg/resvg-jsforraster: true;loadFontsneeds the@expo-google-fonts/*packages of its pack. The install command above lists the set these samples use. A missing converter rejects withconverter-missing, whose message carries the install command. - Browser.
/svgis the pure renderer,/fonts-browserloads hash-verified font bytes lazily, and/export-browserproduces PDF and PNG with no Node modules. - Self-host the fonts.
npx opf-preview-fonts public/opf-fonts --scripts Jpan,Arabcopies the pinned files with SHA-256 checks. Never hotlink a font CDN. See fonts and fidelity.
Embed a presentation on a website
This HTML requires a bundler such as Vite to resolve the npm import. Serve your deck as public/deck.opf.json and copy the fonts into public/opf-fonts. Download the complete website starter for an executable project with local deck data, self-hosted fonts and PowerPoint/PDF downloads.
<script type="module">import "@openpresentation/opf-render/element/define"</script>
<opf-deck src="/deck.opf.json" fonts="/opf-fonts/" thumbnails present></opf-deck>The element fires ready, slidechange, presentstart, presentend and error. Also exported are toHtml, for server markup with a no-JavaScript fallback, and present(), for a full-screen slideshow with a speaker view. There are no transitions or builds yet.
PowerPoint: export and import
@openpresentation/opf-pptx writes editable native PowerPoint and reads it back. It is pure, runs locally, and works in the browser too.
import { readFile, writeFile } from "node:fs/promises"
import { gallery as defaultCatalog } from "@openpresentation/gallery"
import { paginate } from "@openpresentation/opf/pagination"
import { loadFonts } from "@openpresentation/opf-render/fonts-node"
import { fromPptx, toPptx } from "@openpresentation/opf-pptx"
const deck = JSON.parse(await readFile("deck.opf.json", "utf8"))
const fonts = await loadFonts({ pack: "office", substitutionPolicy: "visual" })
const catalogs = [defaultCatalog]
const { presentation } = paginate(deck, { fonts, catalogs })
const bytes = await toPptx(presentation, { fonts, catalogs }) // the same fonts and catalogs as the preview
await writeFile("deck.pptx", bytes)
const roundTrip = await fromPptx(bytes) // mechanical reimport; retain the original OPF source
console.log(roundTrip.name, (roundTrip.slides as unknown[]).length)The second snippet goes the other way. It reads a deck made elsewhere, and fills the template from Markdown and templates while exporting it.
import { readFile, writeFile } from "node:fs/promises"
import { gallery as defaultCatalog } from "@openpresentation/gallery"
import { fromPptx, toPptx } from "@openpresentation/opf-pptx"
// A deck made elsewhere: a mechanical import, plus raw per-shape facts to rebuild semantics yourself.
const { presentation, signals } = await fromPptx(await readFile("incoming.pptx"), { signals: true })
console.log(Object.keys(presentation), Object.keys(signals))
// Fill a template while exporting.
const template = JSON.parse(await readFile("quarterly.opf.json", "utf8"))
const filled = await toPptx(template, { catalogs: [defaultCatalog], variables: { client: "Globex", revenue: 1250000, wins: ["Faster onboarding"] } })
await writeFile("globex.pptx", filled)Native, editable objects
Text boxes, tables with merged and styled cells, native charts with embedded workbooks, the PowerPoint section list, slide backgrounds, and SVG pictures with a PNG fallback.Office 2016 chart types
Treemap, histogram, pareto, box-and-whisker, waterfall and funnel export as chartex parts by default (charts: "auto"). world stays a column fallback, because PowerPoint's map needs online geodata.Fonts are named, never embedded
The PPTX lists only the deck's fonts, with per-run language, theme east-Asian and complex-script slots, and right-to-left runs. The viewer needs the licensed fonts installed.Deterministic bytes
ZIP order, timestamps and workbook parts are normalized. Full provenance restores supported authored semantics when native evidence is unchanged; mechanical import can normalize fields. Keep the original OPF as canonical source.Import from a deck made elsewhere is mechanical: text, tables, images and cached chart data map across, and a shape with no mapping becomes an editable text block with a diagnostic. There is no AI classification pass; the per-shape signals are there for you to rebuild structure yourself.
Editor primitives
@openpresentation/opf-editor is headless first. Every change is a validated JSON Patch with its inverse, so undo is a replay.
import { readFile } from "node:fs/promises"
import { gallery as defaultCatalog } from "@openpresentation/gallery"
import { createEditorSession, saveDocument } from "@openpresentation/opf-editor"
import { switchDimension } from "@openpresentation/opf-editor/switches"
const deck = JSON.parse(await readFile("deck.opf.json", "utf8"))
// The editor imports no catalog data: the host registers the catalogs once, and pickers, the canvas and exports all use them.
const editor = createEditorSession(deck, { rejectInvalid: true, catalogs: [defaultCatalog] })
const change = editor.set("slides.0.title", "Quarterly plan")
console.log(change.patches) // [{ op: "replace", path: "/slides/0/title", value: "Quarterly plan" }]
editor.undo()
editor.redo()
// A gallery dimension is one transaction, so one undo restores the deck.
switchDimension(editor, "font-schemes", "georgia")
editor.undo()
// Saving embeds every record the deck references (core embed), so the file renders the same with no catalog registered.
const { document: saved } = saveDocument(editor)
console.log(Object.keys((saved as { catalogs?: object }).catalogs ?? {}))In a browser, bind your own preview to the session, or take the ready-made canvas. This code needs a DOM. The live editor guide has a complete page.
import { createEditorSession, createSvgTraceBinding } from "@openpresentation/opf-editor"
import { createCanvasEditor } from "@openpresentation/opf-editor/canvas"
import { toSvg } from "@openpresentation/opf-render/svg"
import { loadFonts } from "@openpresentation/opf-render/fonts-browser"
import { gallery as defaultCatalog } from "@openpresentation/gallery"
// Your own preview: draw one slide with trace on, then bind the drawn elements back to the document.
const editor = createEditorSession(deck, { rejectInvalid: true, catalogs: [defaultCatalog] })
preview.innerHTML = toSvg(editor.presentation, 1, { trace: true, catalogs: editor.catalogs }) // slides count from 1
const binding = createSvgTraceBinding(preview.querySelector("svg")!, editor, {
onSelect(selection) {
inspector.open({ path: selection.path, commit: (value: unknown) => binding.commit(selection.element, value) })
},
})
// Or take the ready-made canvas, with inline text editing and a fonts handle (fonts load before pixels).
const fonts = await loadFonts({ faces, lazyFontsBaseUrl: "/opf-fonts/lazy/", scriptBaseUrl: "/opf-fonts/scripts/" })
const canvas = createCanvasEditor(container, {
editor, // the session registers the catalogs the canvas draws with
fonts,
onCommit: ({ editor }) => save(editor.presentation),
})
await canvas.readyThe editor exposes many subpath entries, each opt-in. The ones you will reach for first:
| Entry | Gives you |
|---|---|
/canvas | SVG canvas with inline text editing, track-divider dragging and a font gate (fonts load before pixels). |
/switches | Gallery dimensions such as switchDimension(editor, "font-schemes", "georgia"), each one undoable transaction. |
/slides, /outline | Slide management and an outline view. |
/data-grid, /tables | A spreadsheet grid with CSV and TSV paste, transpose and cell merge. |
/persistence | Autosave to IndexedDB with a restore prompt. Local only. |
/export | Browser PDF, PNG and SVG downloads with progress and cancel. |
/find, /image-crop, /review-panel | Find and replace, crop with a focal point, and audit findings in the interface. |
/react, /svelte | Bindings. You pass in your own React; nothing is bundled. |
Data, charts, tables
Requires @openpresentation/opf 0.13.0 or later. Shared datasets, number formats, series mapping by column name and strict chart numbers are not in earlier core releases, and a deck that uses them does not validate there. The renderer and the PowerPoint exporter need releases built on the same core to draw and export them. The editor's data grid and opf ingest --dataset need an editor and a CLI release built on core 0.13.0 or later; check the changelog before relying on them.
A dataset is declared once and referenced by name from charts and tables. The contract is in docs/chart-table-data.md.
{
"datasets": {
"revenue": {
"columns": ["Quarter", { "name": "Revenue", "format": "$#,##0.0" }, "Costs"],
"rows": [["Q1", 12, 8], ["Q2", 18, 11], ["Q3", 24, 15]],
"source": { "src": "./data/revenue.csv", "retrieved": "2026-10-05" }
}
},
"slides": [
{
"title": "Revenue",
"chart": {
"type": "line",
"data": { "dataset": "revenue" },
"mapping": { "series": ["Revenue"] },
"legend": "bottom",
"axisTitles": { "value": "USD m" }
}
},
{ "title": "Detail", "table": { "dataset": "revenue", "fields": ["Quarter", "Revenue"] } }
]
}- Strict chart numbers. A chart value is a finite number or a strict decimal string such as
"12","-3.5"or"1e6". Values such as"12%","$5"and"1,234"become gaps with achart-value-not-numericwarning, never a guessed value. This is the one behavior change in that release. - Migration is a validation fix. For a column whose text cells share one style,
suggestChartNumberFixreturns a JSON Patch that stores numbers and adds theNumberFormatthat displays the same text.validateattaches it to the warning. - One format language.
NumberFormat(#,##0,0.0%,$#,##0.00) is shared with number variables, and converts to and from Excel format codes (toExcelNumberFormat,fromExcelNumberFormat) for the embedded workbook. - Provenance, not live links.
sourcerecords where data came from, and no engine fetches it. Data is always inlinecolumnsandrowsor a top-level dataset. - Chart options
axisTitles,legendanddataLabelsare in core 0.12, with achart-option-adaptedwarning where a chart type cannot show one. See CSV and JSON data for the CLI import commands.
A table carries a text alternative the way a chart does: table.alt says what the table shows (the point and the key numbers, not "a table"), and "" marks it decorative. The preview names the table with it, PowerPoint stores it as the table frame's description, and core adds no validation rule for it. See content payloads and tables.
opf ingest revenue.csv --as chart --category Quarter --into deck.opf.json --in-place
opf ingest revenue.csv --as table --dataset revenue --into deck.opf.json --in-placeThe second command stores imported rows in the revenue dataset and adds a table that references it.
Markdown and templates
Two text-first ways in: write a deck as Markdown, or fill a template from JSON or CSV.
Markdown dialect
---
name: Q3 Business Review
language: en-US
---
<!-- slide: id=cover layout=title section=Overview -->
# Q3 Business Review
## Operations and growth
Note: Two minutes on the agenda, then straight into the numbers.
---
# Revenue grew every quarter
```chart column
Quarter,Revenue
Q1,12
Q2,18
```import { readFile, writeFile } from "node:fs/promises"
import { fromMarkdown, toMarkdown } from "@openpresentation/opf/markdown"
const { presentation, valid, findings } = fromMarkdown(await readFile("deck.md", "utf8"))
if (!valid) {
for (const finding of findings) {
console.error(finding.location.line + ":" + finding.location.column + " " + finding.message)
}
}
// Any valid deck writes back out. What the dialect cannot express goes into opf-slide and opf-block YAML fences.
const { markdown, report } = toMarkdown(presentation)
await writeFile("roundtrip.md", markdown)
console.log(report.lossless)fromMarkdown and opf convert deck.md deck.opf.json return the deck plus findings mapped back to Markdown lines. toMarkdown and opf convert deck.opf.json deck.opf.md write any valid deck; what the dialect cannot express goes into opf-slide and opf-block YAML fences, so the round trip is lossless.
Since 0.16 a file named deck.opf.md is a Markdown deck in every opf command, and core has one entry point for all three text forms. The file name decides the form (.opf.md is Markdown, .yaml and .yml are YAML, anything else is JSON), the content is never sniffed, and a plain .md file is never a deck (only opf convert reads a plain .md input as OPF Markdown). From 0.17 the entry points are parse and stringify (0.16 called them readDeck and writeDeck). parse returns the deck and throws OPFValidationError, with findings located by line and column, on a syntax, schema or reference error; validate(text) returns the full report, warnings included.
import { readFile, writeFile } from "node:fs/promises"
import { parse, stringify } from "@openpresentation/opf"
import { gallery as defaultCatalog } from "@openpresentation/gallery"
// One entry point for every text form. The file name decides the form: .opf.md is Markdown, .yaml and .yml are YAML,
// anything else is JSON (the content is never sniffed, and a plain .md file is never a deck).
// parse returns the deck, and throws an OPFValidationError for a syntax, schema or reference error, with every finding
// located (error.report.findings, line and column) in the text that was read. validate(text) returns the full report.
const presentation = parse(await readFile("deck.md", "utf8"), {
filename: "deck.opf.md",
catalogs: [defaultCatalog],
})
console.log(presentation.slides.length)
await writeFile("deck.opf.md", stringify(presentation, { filename: "deck.opf.md" }))
await writeFile("deck.out.opf.json", stringify(presentation, { filename: "deck.out.opf.json" }))Templates and variables
A template is an incomplete OPF file: "template": true, typed variables, {{id}} inside strings, and var:id where a whole typed value belongs.
{
"template": true,
"name": "Quarterly review for {{client}}",
"variables": {
"client": { "type": "text", "example": "Acme Corp" },
"revenue": { "type": "number", "format": "$#,##0", "example": 1250000 },
"wins": { "type": "list", "example": ["Faster onboarding", "Lower churn"] }
},
"slides": [
{ "id": "cover", "title": "Quarterly review: {{client}}" },
{ "id": "wins", "title": "Wins", "bullets": ["Revenue {{revenue}}", "var:wins"] }
]
}import { readFile } from "node:fs/promises"
import { resolveVariables } from "@openpresentation/opf"
const template = JSON.parse(await readFile("quarterly.opf.json", "utf8"))
// Pure and deterministic. A value comes from you or the declaration, never from the library.
const result = resolveVariables(template, { client: "Globex", revenue: 1250000, wins: ["Faster onboarding"] }, { strict: true })
console.log(result)resolveVariables is pure and never invents content. Also in core, @openpresentation/opf/convert changes a block's kind (list to table, timeline to table, text to quote and so on), splits and merges slides, and reports whether the conversion was lossless or what it lost. Guides: templates and variables.
Patch, diff, merge
Decks live in git, so core ships the tools to change and reconcile them precisely.
[
{ "op": "test", "path": "/slides/0/id", "value": "title" },
{ "op": "replace", "path": "/slides/0/title", "value": "Approve the next milestone" },
{ "op": "add", "path": "/slides/-", "value": { "id": "next", "title": "Next steps" } }
]import { readFile } from "node:fs/promises"
import { diff, merge } from "@openpresentation/opf/diff"
import { applyPatch, applyPatchWithInverse, invertPatch } from "@openpresentation/opf/patch"
const base = JSON.parse(await readFile("deck.opf.json", "utf8"))
const patch = JSON.parse(await readFile("changes.json", "utf8"))
// Apply a patch, validate the result, and keep the patch that undoes it.
const ours = applyPatchWithInverse(base, patch, { validate: true })
console.log(ours.inverse, invertPatch(base, patch))
// Compare two decks: slides match by id, then content, then similarity.
const changes = diff(base, ours.presentation)
console.log(changes.summary, changes.patch)
// Three-way merge. Collisions come back as conflicts, never as a silent choice.
const theirs = applyPatch(base, [{ op: "replace", path: "/slides/1/title", value: "Agenda and goals" }])
const { merged, conflicts, clean } = merge(base, ours.presentation, theirs)
console.log(clean, conflicts.length, merged)/patchis the single RFC 6902 implementation, used by the CLI and the editor alike.applyPatchWithInverseandinvertPatchgive you undo.diffmatches slides by id, then content, then similarity, detects moves, and emits a patch from one deck to the other.mergeis three-way. Collisions become conflict objects with the base, ours and theirs values, and the CLI blocks the write unless you pass--prefer ours|theirs.- Writes are atomic and validated first, and a failed patch leaves the file untouched.
CLI and agent skills
opf bundles the schema, the default catalog snapshot and the validator, so it needs no network. The rendering commands load opf-render and opf-pptx as optional peers on first use.
npm install -g @openpresentation/cli @openpresentation/opf-render @openpresentation/opf-pptx @resvg/resvg-js@^2.6.2 sharp@^0.35.5 pdf-lib@^1.17.1 @expo-google-fonts/roboto@0.4.3 @expo-google-fonts/roboto-mono@0.4.2 @expo-google-fonts/caladea@0.4.2 @expo-google-fonts/arimo@0.4.3 @expo-google-fonts/tinos@0.4.2 @expo-google-fonts/cousine@0.4.3 @expo-google-fonts/gelasio@0.4.1 @expo-google-fonts/noto-sans@0.4.2
opf --versionFrom code
Since 0.18, import * as opf from "@openpresentation/opf" is the one import for every runtime. In Node, Bun and Deno it also carries core's file API, convert, open and save, plus ingest, fill, edit, stats, paginate and embed with the CLI's defaults; in a browser or a worker the same names load, and the file functions reject with node-only. It is the simple path when an application exports a deck as PDF, PNG, SVG or PowerPoint, imports a PowerPoint file, or changes a deck file's form, without spawning opf and without wiring fonts and catalogs by hand. The extensions name the formats, nothing is written until everything is produced, and opf convert runs the same code, so the bytes match the command's. opf-render and opf-pptx stay optional peers, loaded the first time a call needs them (a missing one throws peer-not-installed with the install command). The options are the opf convert flags in camel case (slides, scale, raster, text, charts, images, fonts, name), and every error carries a code to branch on. The 0.17 @openpresentation/opf/node subpath is gone; the catalog is now exported as gallery from @openpresentation/gallery. Prefer a shorter name? npm i opf@npm:@openpresentation/opf installs the same package as opf.
import * as opf from "@openpresentation/opf"
// One import in every runtime; in Node it also has the file API. Files in, files out; the extensions name the formats.
// opf convert does the same from a shell.
await opf.convert("deck.opf.json", "deck.pdf")
await opf.convert("deck.opf.json", "deck.pptx")
await opf.convert("deck.opf.json", "slides/deck.png", { slides: "1-3", scale: 2 }) // slides/deck-1.png, -2, -3
// open reads .opf.md, .yaml, .yml or .json (or imports a .pptx); save writes the form the extension names.
const deck = await opf.open("deck.opf.json")
deck.slides.push({ title: "Q4" })
await opf.save(deck, "deck.opf.md")
// With no output path, convert writes nothing and returns the files: { files: [{ name, type, bytes }], findings }.
const { files } = await opf.convert(deck, { format: "pdf" })
// Import is a conversion, not a lossless round trip: what it cannot keep is in findings.
const { findings } = await opf.convert("deck.pptx", "deck.imported.opf.json")
console.log(files[0].name, deck.slides.length, findings.length)| Command | What it does |
|---|---|
create, validate | Start a deck, check it, and report findings with source ranges; validate --list-rules prints the rules. |
edit, diff, merge, format | JSON Patch editing and version-control helpers. |
stats | Neutral facts about a deck: counts, words, notes, alt text and speaking time. |
paginate, embed | Split overflow into slides; embed the catalog records a document uses for offline use. |
fill, ingest | Templates filled from data, and CSV or JSON into tables and charts. |
convert, doctor | One file into another, the formats named by the extensions: JSON, YAML and Markdown decks, PNG and SVG slides, PDF, PPTX, a zip of slides, and PPTX back to OPF. doctor reports which formats this install can write and the one command that installs the rest. |
schemas, schema, catalogs, catalog | Look up exact schema branches and catalog records. |
skills install|status|update | Install the six agent skills into your project. |
opf create deck.opf.json --title "Quarterly Review"
opf validate deck.opf.json --fail-on warning
opf validate deck.opf.json --only accessibility --format text
opf validate --list-rules
opf stats deck.opf.json --format textopf convert deck.md deck.opf.json
opf convert deck.opf.json deck.opf.md
opf fill quarterly.opf.json clients.csv "decks/qbr-{client}.opf.json"
opf fill quarterly.opf.json --examples > preview.opf.jsonopf edit deck.opf.json --patch changes.json --in-place --expect-sha256 "$SHA"
opf diff before.opf.json after.opf.json --format patch
opf merge base.opf.json ours.opf.json theirs.opf.json merged.opf.json
opf format decks/*.opf.json --checkopf convert deck.opf.json slides/deck.png --slides 1,3-5 --scale 2
opf convert deck.opf.json deck.pdf --raster --scale 2
opf convert deck.opf.json deck.pptx --charts native
opf convert deck.pptx deck.opf.json --signals signals.json
opf doctor deck.opf.json --format textopf convert deck.opf.json deck.pdf
opf convert deck.opf.md - --to pdf > deck.pdf
opf convert deck.pptx deck.opf.yamlopf convert validates first and is deterministic: no network, no system fonts, and no clock unless you pass --date. It reads fonts only from the bundled pack plus --fonts. Every command that writes a deck takes an optional output after its inputs (stdout without one), -i to rewrite the input, and format, embed and paginate take --check. The CLI guide has every option.
Agent skills
One command copies six skills into .agents/skills (or pass --agent claude-code, codex or cursor): opf-author, opf-layout, opf-presets, opf-edit, opf-export and opf-inspect. The installer refuses to overwrite skills you have customized, and works offline once the CLI is installed.
npx @openpresentation/cli@latest skills install
npx @openpresentation/cli@latest skills install --agent claude-codeSee the agent quickstart and agent skills.
Known limits
Schema support is not the same as renderer, editor or PowerPoint fidelity. These are the gaps worth knowing about before you build on the toolkit.
| Area | Status |
|---|---|
| Native PowerPoint fidelity in general | Tracked in opf#87. Bounded native checks pass; arbitrary Office layout and reflow is not certified. |
Linked chart data (ChartDataSource) | Not planned for now (opf#240). Embed the data and record source. |
| Video export, external chart data | Not supported. Engines never fetch data or media. |
| PDF/UA and PDF/A | Not claimed. The vector PDF is tagged and has selectable text. |
| Mermaid and general SVG diagrams | Planned. An embedded SVG is not an editable native shape. |
| Full WYSIWYG editing (IME, bidirectional text) | Schema support is not editor coverage. |
Keep going with the developer docs index, the spec, the examples and the playground.