@office-kit/pptx-preview
v0.21.0
Published
Experimental preview renderer for @office-kit/pptx: render a slide to SVG (browser + Node) or rasterize it to a PNG/RGBA image in Node, with no browser binary.
Maintainers
Readme
@office-kit/pptx-preview
Preview renderer for @office-kit/pptx.
Turns a @office-kit/pptx slide model into an SVG (browser + Node) or rasterizes
it to a PNG / RGBA image in Node — with no headless browser.
Experimental (0.x). This package lives in the
@office-kit/pptxmonorepo and also powers the docs-site playground and the fidelity harness. The renderer is an approximation of desktop presentation app / LibreOffice output and is still evolving; the API may change between minor versions, and0.xsemver applies (a minor bump may break). See fidelity below.
Why
@office-kit/pptx core does not render — by design. But "show me this deck" comes up
constantly: a docs playground, a thumbnail service, a visual-diff test. The
hard requirement is that rendering must work in Node and rasterize to an
image without spawning a browser, so it fits CI and serverless. This package
lays text out as pure SVG <text> (no <foreignObject>) and paints it with
resvg, which has no browser dependency.
Entry points
| Import | Runtime | Use |
| ------------------------------- | -------------- | --------------------------------------------------- |
| @office-kit/pptx-preview | browser + Node | renderSlideToSvg → an SVG string |
| @office-kit/pptx-preview/node | Node only | renderSlideToImage / renderSlideToRgba → pixels |
The browser entry pulls in no Node built-ins (no node:fs, resvg, or
fontkit), so it bundles cleanly for the web.
Usage
SVG (browser or Node)
import { renderSlideToSvg } from '@office-kit/pptx-preview';
import { loadPresentation, getSlides } from '@office-kit/pptx';
const pres = await loadPresentation(bytes);
const svg = renderSlideToSvg(pres, getSlides(pres)[0]);
// → '<svg …>…</svg>' (text laid out via <foreignObject> — the browser wraps it)Pass { textLayout: 'svg', measureText } to lay text out as pure SVG
<text> instead of <foreignObject> (for example when the SVG is shown as an
<img> or sanitized). In that mode each run's font is replaced with one of the
bundled metric-compatible substitutes (substituteFamily: Carlito, Caladea,
Liberation Sans / Serif / Mono). To keep a family the viewer can load — say
through @font-face rules you embed in the SVG — pass resolveFamily, and a
measureText that knows that font's metrics so line breaks match its glyphs
(in a browser, for example, one that wraps CanvasRenderingContext2D.measureText
once the font has loaded). Return a single family name; the renderer quotes it
in the SVG when CSS requires that:
import { renderSlideToSvg, substituteFamily } from '@office-kit/pptx-preview';
const svg = renderSlideToSvg(pres, slide, {
textLayout: 'svg',
measureText,
resolveFamily: (family) =>
family === 'Lora' || family === 'Inter' ? family : substituteFamily(family),
});The bundled substitutes have no Japanese, Chinese or Korean glyphs. Pass
eastAsianFamily to write a run's East Asian characters (kana, kanji, Hangul,
and CJK and full-width punctuation) in a family the viewer has, in the weights
the deck uses; otherwise the glyphs are left to the rasterizer's fallback,
which in resvg ignores bold.
renderSlideLayoutToSvg(pres, layout, { master }) draws what a slide master
editor shows behind the placeholders: the layout's background and decorative
shapes over its master's (the master's are left out when the layout hides
background graphics), or with master: true the slide master's own background
and shapes. Placeholders are not drawn.
Underlines in custom HTML editors
textUnderlineStyle(underline, color) returns inline CSS for a DrawingML
underline, using the same patterns as the HTML preview. Pass a resolved CSS
color (for example, #154687) because SVG background patterns cannot inherit
the surrounding text color. Apply the style to an inline element; keep any
strikethrough on an outer element. For words, apply it separately to each
non-whitespace segment. false, none, and undefined return an empty string.
Pass false as the third argument to let native CSS decorations inherit the
text color; image-based patterns still use the supplied color.
PNG / RGBA (Node, no browser)
import { renderSlideToImage, renderSlideToRgba } from '@office-kit/pptx-preview/node';
import { loadPresentationFile, getSlides } from '@office-kit/pptx/node';
const pres = await loadPresentationFile('deck.pptx');
const slide = getSlides(pres)[0];
// PNG-encoded bytes:
const png = renderSlideToImage(pres, slide, { width: 1280 });
// Raw RGBA pixels (+ the same frame PNG-encoded), for SSIM / diffing:
const { image, png: png2 } = renderSlideToRgba(pres, slide, { width: 1280 });
// image: { width, height, data: Uint8Array } // row-major RGBAThe Node path lays text out as pure <text> and measures it with a fontkit
measurer over bundled metric-compatible fonts (Carlito ≈ Calibri, Caladea ≈
Cambria, Liberation ≈ Arial/Times/Courier; OFL / Apache-2.0, see
fonts/LICENSES.md). The measurer, resvg's font set, and the SVG family names
all reference the same fonts, so wrap/positioning math agrees with the painted
glyphs and the result is deterministic (no system fonts).
For Japanese, Chinese or Korean text, pass the faces in fontFiles — the
regular and the bold one — and register them with buildFontkitMeasurer({ fonts })
for the measurer. East Asian characters are drawn in the family of the first
fontFiles face that covers kana, in the face closest to each run's weight.
Every authored font name is drawn in a bundled substitute unless you pass
resolveFamily. Map a deck's font to a family you load through fontFiles
(and register with the measurer) to draw the run in that face; East Asian
characters keep the kana face above, and resolveFamily decides the rest.
const noto = 'fonts/NotoSansCJKjp-Regular.otf';
const png = renderSlideToImage(pres, slide, {
fontFiles: [noto],
measureText: buildFontkitMeasurer({ fonts: [{ family: 'Noto Sans CJK JP', source: noto }] }),
resolveFamily: (family) => (family === 'Meiryo' ? 'Noto Sans CJK JP' : substituteFamily(family)),
});Text-layout audit (overflow / soft-wrap detection)
import { auditTextLayout } from '@office-kit/pptx-preview';
import { buildFontkitMeasurer } from '@office-kit/pptx-preview/node';
// Register the fonts the deck actually uses (especially CJK) for accurate widths.
const measureText = buildFontkitMeasurer({
fonts: [{ family: '游ゴシック', source: 'fonts/YuGothic.ttf' }],
});
const issues = auditTextLayout(pres, { measureText, reportSoftWraps: true });
// → [{ kind: 'overflow-y', slideIndex: 2, shapeName: 'Title 1', overflowPx: 14.3, shapeOverflowPx: 9.5, lineHeightPx: 52.8, approximate: false }, …]auditTextLayout measures every shape's text body — placeholders, text boxes,
autoshapes, shapes inside groups — with the same engine the preview renders
with, honouring the effective bodyPr cascade (insets, wrap, anchor, vertical
text, columns) and normAutofit shrinking. It returns one issue per finding:
| kind | Meaning | Extra fields |
| ----------------- | ----------------------------------------------------------------------------------- | ------------------------------ |
| overflow-x | Text ink escapes the box horizontally (typically wrap="none") | overflowPx and the two below |
| overflow-y | Wrapped text is taller than the box (or escapes the top when bottom-anchored) | overflowPx and the two below |
| soft-wrap | A paragraph wrapped onto more lines than its authored <a:br> count (段落ち) | paragraphIndex, extraLines |
| word-break | A wrap split a word (Latin / Greek / Cyrillic letters or digits, or a katakana run) | paragraphIndex, text |
| short-last-line | A wrapped paragraph ends on a line of one or two characters (泣き別れ) | paragraphIndex, text |
| small-font | The shape draws text below minFontSizePt, after autofit shrinking | sizePt |
overflowPx is measured against the text area (the box minus its insets);
shapeOverflowPx is how much ink lies outside the shape itself, both sides
added (a centre-anchored body spills above and below; 0 while the spill stays
inside the insets, where nobody sees it); lineHeightPx is the
tallest line's height, about how far one misjudged wrap moves the text.
Every issue also carries slideIndex, shapeName, and approximate —
true when a width had to be estimated (the heuristic measurer, or glyphs the
available fonts don't cover), in which case treat borderline verdicts as
advisory.
Options:
measureText— the measurer. Defaults to the browser-safe heuristic (≈0.55 em per Latin glyph, 1 em per CJK glyph, alwaysapproximate); passbuildFontkitMeasurer()in Node for real glyph metrics.tolerancePx— overflow at or below this many px (96 DPI) is ignored. Default1; raise it if you only care about visibly broken slides.reportSoftWraps— opt intosoft-wrapissues. Off by default because wrapping is normal for body text; turn it on when auditing titles or labels meant to stay on one line.reportBadWraps— opt intoword-breakandshort-last-lineissues (horizontal text only). A word inside a kanji compound cannot be told apart from its neighbours, so only the scripts listed above are checked. Aword-breakissue is reported once per word; itstextis the word with|at each line break.short-last-linecounts characters, not words, so a Latin last line oftois reported and one oftheis not.minFontSizePt— reportsmall-fontfor shapes with text below this size.
Accuracy: the bundled fonts are metric-compatible with the common default fonts, so
verdicts for Calibri / Cambria / Arial / Times / Courier decks match real glyph
widths; for any other font (custom brand fonts, Japanese fonts), register the
real files via buildFontkitMeasurer({ fonts }) — a registered font is used
both for runs that name it and as a glyph fallback for CJK text in runs that
resolve to a Latin face. Line-break positions can differ from the reference desktop app by a
few characters in edge cases (kinsoku, hyphenation), which is what the default
1 px tolerance absorbs. Table cell text is not audited yet.
Fidelity
This is a high-fidelity preview, not a spec-complete presentation renderer.
Preset and custom geometry, solid/gradient/pattern/image fills (including the
placeholder layout/master cascade), strokes, rotation, effects (shadow, glow,
soft edge, reflection), images with adjustments, charts (column, bar, line,
area, pie, doughnut, scatter, radar, bubble), tables with per-run cell text and
every part of their table style (the 74 built-in table styles included:
fills, translucent bands, borders, text, the Themed Styles' background, and
the six style options; the background's shadow is not drawn),
vertical and multi-column text in both text-layout modes, picture bullets, and
template (layout/master) decoration all render. EMF pictures containing solid-filled
line/Bézier paths, including rectangular and path clipping with copy semantics,
render as transparent SVG images. Other EMF drawing commands, WMF, SmartArt,
animations, and 3D fall back to labelled placeholders carrying a machine-readable marker
(below). Per-slide closeness to a LibreOffice baseline is measured and gated
in CI by the fidelity harness in the monorepo (site/fidelity) — mean
fg-SSIM ≈ 0.78 across the corpus, with the residual gaps documented there.
Fallback markers
When a shape cannot be rendered (unsupported format, missing bytes, or
unrecognised content type), the renderer emits a labelled placeholder rectangle.
The placeholder's top-level <g> element carries a data-pptx-fallback
attribute so automated tooling can detect partial renders without string-parsing
the label text:
| Value | Trigger |
| ---------------- | ----------------------------------------------------------- |
| "image" | Image bytes missing (external link) or format not decodable |
| "chart" | Chart kind not modelled by this renderer |
| "graphicFrame" | Graphic frame with no recognised content (SmartArt, etc.) |
| "custGeom" | Shape uses custom geometry (<a:custGeom>) |
Example: svg.querySelectorAll('[data-pptx-fallback]') lists every shape that
did not fully render.
Mapping the SVG back to the deck
Slide shapes, and the paragraphs and table cells inside them, carry attributes that tie a click on the SVG back to the presentation. Master and layout decoration carries none.
| Attribute | On | Value |
| --------------------- | --------------------------------------------------------- | ---------------------------------------------------- |
| data-pptx-shape-id | A <g> wrapping each shape; groups and each member alike | getShapeId(shape) |
| data-pptx-paragraph | <p> (foreignObject), each line's <text> (svg mode) | 0-based paragraph index |
| data-pptx-cell | A <g> wrapping each table cell's fill and text | row,col, 0-based; paragraphs count within the cell |
const shape = target.closest('[data-pptx-shape-id]')?.getAttribute('data-pptx-shape-id');
const paragraph = target.closest('[data-pptx-paragraph]')?.getAttribute('data-pptx-paragraph');License
MIT (code). Bundled fonts: OFL-1.1 / Apache-2.0 — see fonts/LICENSES.md.
List numbering in custom editing surfaces
paragraphNumberLabels(paragraphs) accepts an array of { bulletStyle, level }
and returns a number label or null for each paragraph. It uses the same
per-level counters as the slide renderer, including restarts after a non-numbered
paragraph or a change of numbering scheme. Common Arabic, alphabetic and Roman
schemes are supported; other schemes currently fall back to Arabic numbers
followed by a period. Labels are display data and should not be inserted into
the editable paragraph text.
Text regions for editing overlays
shapeTextRect(shape) returns where a shape lays out its text, as fractions of its own extent: the <a:rect> its custom geometry states, or the one ECMA-376 defines for its preset (an ellipse's inscribed rectangle, a triangle's lower middle), following its adjust values. It is null when text takes the whole box. resolveTextBodyRect(bounds, margins, region) places that region in bounds less the body insets, giving the same inner text rectangle the preview uses before wrapping and autofit. Pass {x, y, w, h} and {left, top, right, bottom} in the same unit, normally EMU, with inherited/default margins already resolved. Margins that would collapse a region are dropped; a full box with nonpositive width or height stays degenerate so callers can handle it explicitly.
