Skip to content

Examples

Every snippet below is runnable as-is; they all start from document bytes (Uint8Array) however you obtained them — a File, a fetch, fs.

Ream.parse reads the document once into the interlayer; every convert renders from it without re-parsing:

import { Ream } from 'reamkit';
const doc = Ream.parse(bytes);
const pdf = await doc.convert('pdf', { fonts });
const svg = await doc.convert('svg', { fonts }); // page-stack preview, no PDF involved
const html = await doc.convert('html'); // flowed HTML — no fonts, zero I/O
const md = await doc.convert('md'); // GitHub-Flavored Markdown — same, narrower
const docx = await doc.convert('docx'); // WordprocessingML back out — no fonts, no layout
const xlsx = await doc.convert('xlsx'); // SpreadsheetML back out — from an .xlsx source

convert('docx') writes the parsed document back to a valid .docx. The round-trip is semantic, not byte-exact — the writer emits the resolved formatting as direct properties rather than named styles — so use it to normalize, sanitize or programmatically edit a document in the browser, not to preserve the original markup verbatim. Images, tables, lists, links, bookmarks, shapes, headers/footers, multi-section geometry, footnotes/endnotes, charts and OfficeMath all round-trip, and a drawing that states where on the page it belongs is written back as the wp:anchor that puts it there rather than flattened into the text flow. No image part the format cannot show goes into the package: JPEG 2000 is dropped with a loss that names it.

import { Ream } from 'reamkit';
const doc = Ream.parse(bytes); // a .docx (xlsx has no docx writer)
const out = await doc.convert('docx');
// `out` is a fresh, valid .docx — hand it to a download, an upload, or re-parse it.

docx → markdown: structure without geometry

Section titled “docx → markdown: structure without geometry”

convert('md') writes GitHub-Flavored Markdown. Like HTML it is a flow medium — no pagination, no layout engine, no fonts — but a much narrower one, so it keeps what the document says and reports everything it cannot say back.

import { Ream } from 'reamkit';
const doc = Ream.parse(bytes);
const { bytes: md, losses } = await doc.convertWithReport('md');
// Headings, lists (with their real numbers and nesting), GFM tables,
// links, footnotes and the text inside shapes all survive.
console.log(new TextDecoder().decode(md));
// Everything markdown has no syntax for is reported — once each, however
// many paragraphs it happened in:
// [dropped] text: paragraph alignment has no markdown expression
// [degraded] tables: merged cells flattened — markdown has no spans
for (const loss of losses) console.log(loss.severity, loss.feature, loss.detail);

Pictures are inlined as data: URIs so the output is a single self-contained file. When you would rather write the image bytes yourself — a docs site, a repo — ask for links instead and the writer names them predictably:

const md = await doc.convert('md', { images: 'link' }); // ![](./media/image1.png)
const bare = await doc.convert('md', { images: 'drop' }); // no pictures at all

A deck’s only structure is where one slide ends and the next begins, and the .pptx / .ppt readers carry that as a page break. Markdown has no pages, so by default the break is dropped and reported — ask for the rule and each slide gets its own section:

const deck = Ream.parse(pptxBytes);
const md = await deck.convert('md', { pageBreaks: 'rule' }); // --- between slides

A workbook has the same problem and a different answer: markdown cannot tell one sheet from the next, so each opens with a heading carrying its tab name. A printed page never shows one — Excel and Calc emit it nowhere — so this is markdown’s alone, and { sheetNames: false } turns it off:

const book = Ream.parse(xlsxBytes);
const md = await book.convert('md'); // # Revenue, then its grid; # Costs, then its

Any attachment → Markdown for a language model

Section titled “Any attachment → Markdown for a language model”

A chat that takes a document and hands it to a model wants what the document says, not how its pages look — and one function serves all seven inputs, a PDF and the legacy binaries included:

import { Ream } from 'reamkit';
/** An attached document as Markdown a model can read, whatever its format. */
async function attachmentToMarkdown(bytes: Uint8Array): Promise<string> {
const doc = Ream.parse(bytes); // .docx .xlsx .pptx .pdf .doc .xls .ppt — sniffed
const deck = doc.format === 'pptx' || doc.format === 'ppt';
const md = await doc.convert('md', {
images: 'drop', // no base64 pictures in the prompt
pageBreaks: deck ? 'rule' : 'drop', // a deck's slides apart by ---
});
return new TextDecoder().decode(md);
}

Markdown needs no fonts, so this touches neither the network nor a file system and runs as well in the page, before anything is uploaded, as on a server. A PDF comes back as the document it was — read from its structure tree when it is tagged, from where its glyphs stand when it is not: paragraphs, headings, lists, columns and tables. A form or a drawing comes back line by line instead, each line where it stands. Ream does no OCR, so a scan gives up its text only from a text layer OCR software left under the picture. What Markdown cannot carry — charts, math, headers and footers — is in convertWithReport’s losses, as with any other target.

xlsx → HTML / SVG: a workbook as Excel shows it

Section titled “xlsx → HTML / SVG: a workbook as Excel shows it”

A workbook comes out two ways. Its PDF is the printed pages — print area, repeated titles, page breaks, fit-to-page, the drawings cut to the page they print on. Its HTML and SVG are what Excel’s window shows: every visible tab, one grid a sheet with all its columns at full size, text running on across the row, drawings over the cells where they are anchored, the window’s gridlines where the sheet asks for them, and a red corner on every cell with a note.

const book = Ream.parse(xlsxBytes);
const pdf = await book.convert('pdf', { fonts }); // the printed pages
const html = await book.convert('html'); // the window: one <section data-sheet> a tab
const svg = await book.convert('svg', { fonts }); // a picture of each sheet, whole

The HTML needs no fonts — the browser sets the text, and a note shows on hover over its cell. The SVG draws every glyph from its face’s own outline, so it looks the same in any viewer with no font to find; the words are laid over the glyphs unseen, so the text can still be searched and selected.

convert('xlsx') writes a spreadsheet’s grid back to a valid .xlsx. Unlike the docx writer it consumes the native grid tree, so the round-trip is lossless on the grid surface — cells, styles, merges, the print model, conditional formatting, sparklines, tables and embedded charts all survive a read → write → read loop byte-stably. It requires a spreadsheet source; a .docx has no grid.

import { Ream } from 'reamkit';
const doc = Ream.parse(xlsxBytes); // a .xlsx
const out = await doc.convert('xlsx');
// `out` is a fresh, valid .xlsx — normalize, sanitize, or re-parse it.

Password-protected Word and Excel documents

Section titled “Password-protected Word and Excel documents”

A password-protected .docx / .xlsx is not a zip at all — ECMA-376 §2.3 puts the whole package inside an OLE container as ciphertext — so it has to be decrypted before any reader sees it. The same password option does that, for both Office schemes (the 2007 standard one and the agile one 2010 and later write):

const doc = Ream.parse(docxBytes, { password: 'letmein' });
const pdf = await doc.convert('pdf');

A wrong password is an error, not a loss — here and in a PDF alike: every scheme stores a verifier, so the file can say the password is wrong instead of handing back rubbish. It comes as WrongPasswordError — a class, so a corrupt file is not mistaken for a bad password:

import { Ream, WrongPasswordError } from 'reamkit';
try {
Ream.parse(bytes, { password });
} catch (e) {
if (e instanceof WrongPasswordError) promptAgain();
else throw e; // not a password problem
}

A document that needs a password and gets none throws too, naming the option to pass. An interface usually wants to know before that — to show a password field rather than an error — and isEncryptedPackage answers from the bytes:

import { Ream, isEncryptedPackage } from 'reamkit';
const password = isEncryptedPackage(bytes) ? await askTheUser() : undefined;
const doc = Ream.parse(bytes, { password });

Decryption only: Ream never writes an encrypted package, so convert output is always in the clear.

Ream.parse also accepts a PDF — including a modern compressed one (cross-reference streams, object streams) or an encrypted one. A tagged PDF (the ones Ream writes) is rebuilt from its structure tree — headings, paragraphs, tables, lists in reading order, through the role map a document names its own elements by, with an inline element (a span, a link, MathML) kept in its parent’s line; an untagged PDF is reconstructed heuristically from glyph positions — in the columns of prose the page was set in, breaking to the next column and balancing the last page where the page does, with a running head or foot kept out of the body and its page number written as a field — counted in the page’s own numerals, front matter in roman, and a foot to each side where a book signs its even and odd pages apart — a page ruled into a grid rebuilt as a table with its own columns and row heights, a table set across the columns read whole, a code listing kept line for line, a drawing kept with the labels set on it as one figure, a list’s items and an index’s hanging entries a paragraph apiece, a heading on a dark bar given the bar as its shading, a word the line broke in half put back together, and a bracketed block of rows and columns read as an OfficeMath matrix.

A page that is not prose in columns is read ACROSS: an invoice sets its labels along the left and its figures against the right margin, and every one of those figures belongs to the line beside it. Blocks set SIDE BY SIDE are another matter: the same invoice stacks its dates and numbers beside the address it bills, each stack at a leading of its own, and read across, every line would catch a line of the other. Such a band comes back as the borderless table a writer would set it in, each block read down on its own, while blocks whose lines share their baselines stay one text. A gap no word space could be — or one that lands on a stop the lines around it share — comes back as a TAB standing on the stop the page set the piece after it at, consecutive lines broken at the SAME stops come back as a table, with a column of figures that end together set against its right edge, and a line the page drew between two blocks becomes the border of the paragraph it separates rather than a shape anchored where the words used to be. What a form writes — a button’s caption, a field’s value, a tick — stands where the page placed it, over the box it is written on, rather than joining the lines of the page.

Raster images, hyperlinks and the page’s artwork come back too — images lifted out and sized from their placement (including the ones written into the content stream, stencil masks painted in the page’s own colour, a colour key leaving unpainted the samples it names, and a CMYK JPEG in the colours its inks paint rather than in Adobe’s inverted convention), link annotations re-attached to the text, filled paths, stroked lines — dashed and capped as the page draws them — and shading-pattern gradients turned into shapes, the clipping paths that limit them, tiling patterns (a hatch or a grid as the tone it gives the page), the opacity a fill or a stroke is painted at, the appearance an annotation carries — or one drawn from its properties where the file supplies none, a free-text note from its contents, and a form that asks for its appearances rebuilt showing its fields’ values — and the Type 3 glyphs that are drawings rather than letters. Colour comes back through whatever space states it: device, CIE (CalGray, CalRGB, Lab), an /ICCBased stream through the profile it carries, or a Separation/DeviceN run through its own tint transform — for an image as much as for a fill. A gradient the page paints with a bare sh is read too, function-based included, and words filled with one come back in its colours. The layers a file turns off stay off, and the box it says to show is the box you get. Type is read the way the font states it — the base encoding it declares, the built-in one a symbolic font carries (ZapfDingbats is a font of pictures and comes back as pictures), a predefined CMap it names instead of embedding — a ligature comes back as the letters it joins, punctuation a font names no character for is read off the shape the page draws, and a face that cannot be addressed by character at all is DRAWN from its outlines rather than guessed at. The result is an ordinary FlowDoc, so it converts onward like any other source. Written to .docx, a run names its face’s family — Inter, not Inter-SemiBold, a font no word processor has — and the face itself travels with the document: rebuilt as TrueType from the outlines the page drew, at the advances it set them at, with its kerning and the ligatures the page drew, and embedded where its licence lets it, so Word and LibreOffice break each line where the page broke it on a machine that has none of the document’s fonts. A word space TeX shrank to fit a line is written as narrow as TeX set it, a picture keeps its crop, its turn and its opacity, and a line its dash.

A form or a drawing is not a reflowable document, though — its rules and boxes are placed absolutely, and a label an inch from the box it labels says nothing. The file decides for itself: a paper is mostly lines and is re-flowed, a form or a drawing is mostly marks and is kept as a page, with every line where its glyphs stand. There is nothing to configure — the reader records which reading it took, in the losses:

const doc = Ream.parse(pdfBytes);
const docx = await doc.convert('docx');
doc.losses.find((l) => /read as a/u.test(l.detail)); // …which reading it took
import { Ream } from 'reamkit';
const doc = Ream.parse(pdfBytes); // doc.format === 'pdf'
const html = await doc.convert('html'); // the PDF's text as flowed HTML
const docx = await doc.convert('docx'); // …or an editable Word document
const { bytes, losses } = await doc.convertWithReport('html');
// losses note the untagged-heuristic degradation and any unread vector art.

A locked PDF opens with its password — the user password, or the owner’s, which opens it as well. The empty-string default unlocks the common permissions-only encryption (where the owner set restrictions but no open password), so most encrypted PDFs need no password at all:

const doc = Ream.parse(pdfBytes, { password: 'letmein' });

Every revision of the PDF security handler stores a verifier, so a wrong password — or none, where one is needed — is refused rather than decrypted into rubbish, with the same WrongPasswordError an Office package throws:

import { Ream, WrongPasswordError } from 'reamkit';
try {
Ream.parse(lockedPdf); // no password
} catch (err) {
if (err instanceof WrongPasswordError) {
err.message; // 'This PDF needs a password to open — pass it as `password`'
}
}

A file locked by a security handler Ream does not implement is not a password problem: it parses, and what could not be decrypted is a read-time loss. In strict mode that loss throws a ConversionLossError, with the offending Loss on its .loss property:

import { Ream, ConversionLossError } from 'reamkit';
try {
await Ream.parse(otherHandlerPdf).convert('html', { strict: true });
} catch (err) {
if (err instanceof ConversionLossError) {
err.loss.detail; // 'encrypted PDF — its security handler is not supported, …'
}
}

A PDF whose streams use a filter Ream does not carry

Section titled “A PDF whose streams use a filter Ream does not carry”

§7.4 leaves the filter set open, and PDF 2.0 added Brotli. A stream Ream cannot decode is a stream it has not read — and when that stream is the cross-reference, the file has no pages at all. Supply the decoder and it does:

import brotliPromise from 'brotli-dec-wasm'; // ~200 KB wasm, browsers and workers
import { Ream } from 'reamkit';
const brotli = await brotliPromise;
const doc = Ream.parse(pdfBytes, {
filters: { BrotliDecode: (bytes) => brotli.decompress(bytes) },
});

Any Brotli implementation you already ship will do — on a server the runtime carries one, so import { brotliDecompressSync } from 'node:zlib' and pass that instead. Ream itself imports neither: a 122 KB static dictionary in every browser bundle, for a filter almost nothing produces, is not a cost the common case should pay. Without a decoder the loss report names the filter, so a document that comes back empty says why.

A paper reads back as a re-flowable document — paragraphs, columns and tables in reading order. A form is not that: its grid is painted at fixed coordinates, and text that flows beside the grid sits in none of its boxes. So a form is read as a page instead, every line standing where its glyphs stand — and the file, not the call, decides which of the two it gets:

import { Ream } from 'reamkit';
const doc = Ream.parse(pdfBytes);
const pdf = await doc.convert('pdf', { fonts });

The reader counts the marks against the baselines on the median page and records which reading it took and why, so a plan folded into a report does not make the report a plan. Worth knowing when the output is DOCX, Markdown or HTML: a file read as a page has no reading order, no paragraphs and no tables to give them.

Ream.parse also accepts a PowerPoint .pptx. Each slide becomes a page at the deck size, its shapes read as positioned content — text boxes, placeholders, pictures, shapes, tables, charts, embedded objects, groups and hyperlinks. What a deck states once reaches every slide that relies on it: the master’s and the layouts’ own decoration (drawn under the slide’s content), the background — solid, gradient, picture or a theme reference — the deck’s colour map, the text style its p:defaultTextStyle and p:txStyles set, and the table styles in tableStyles.xml. The result is an ordinary FlowDoc, so it converts onward like any other source:

import { Ream } from 'reamkit';
const doc = Ream.parse(pptxBytes); // doc.format === 'pptx'
const pdf = await doc.convert('pdf', { fonts }); // a page per slide
const html = await doc.convert('html'); // …or the slides as flowed HTML

Review comments (w:commentReference) render as a bracketed superscript marker in the text and a “Comments” section after the body — author, date and content, with reply threads nested and resolved threads flagged, the commented range highlighted, and author identities resolved from people.xml. In PDF the marker is a clickable jump to the entry; pass commentAnnotations: true to also attach each comment as a native sticky-note annotation (interactive output only — suppressed under PDF/A and tagged):

import { Ream } from 'reamkit';
const doc = Ream.parse(docxBytes);
const pdf = await doc.convert('pdf', { fonts }); // markers + Comments section + highlights
const sticky = await doc.convert('pdf', { fonts, commentAnnotations: true }); // …plus /Text pop-ups
const html = await doc.convert('html'); // replies nested, resolved threads flagged
doc.flow.comments?.get('0'); // { content, author?, date?, authorId?, parentId?, done? }

Comments — threads and resolved flags included — also write back through convert('docx').

The binary Office formats parse through the same entry point and the same interlayer — Ream.parse(bytes) sniffs OLE2/CFB and picks the reader, so every target and every option below works on them unchanged. A legacy deck brings its master’s decoration and background, the text its slide list holds with the sizes, colours, typefaces and bullets its master states, grouped shapes and tables, picture fills and the metafiles it draws with; a legacy workbook brings its styling, its drawings placed on the sheet’s own grid, its charts, its print model and its conditional formatting.

const doc = Ream.parse(bytes); // .doc / .xls / .ppt — sniffed like the rest
const pdf = await doc.convert('pdf');
const xlsx = await doc.convert('xlsx'); // a .xls re-writes as a modern workbook

A pivot table’s cached output renders as an ordinary grid; on top of that Ream applies the table’s named pivot style (pivotTableStyleInfo) — banded rows and a styled header — and emphasises the grand-total / subtotal rows and columns. Nothing is recomputed from the cache:

import { Ream } from 'reamkit';
const doc = Ream.parse(xlsxBytes);
const pdf = await doc.convert('pdf', { fonts });
const html = await doc.convert('html');

A list data validation (<dataValidations>) paints an in-cell dropdown affordance — a small button with a ▾ — on every cell of its range, in both PDF and HTML. The validation, its formulas and the input/error prompts also round-trip through convert('xlsx'):

import { Ream } from 'reamkit';
const doc = Ream.parse(xlsxBytes);
const pdf = await doc.convert('pdf', { fonts }); // list cells show a ▾ dropdown button
const html = await doc.convert('html');
const xlsx = await doc.convert('xlsx'); // the <dataValidations> survive the round-trip

A slicer (xl/slicers + xl/slicerCaches) renders as a captioned button box after the grid — the way a chart frame does. A native-table slicer fills its buttons from the referenced table column’s distinct values and highlights the ones the column’s autofilter keeps; an OLAP/pivot slicer whose items live in a pivot cache degrades to a caption-only box.

import { Ream } from 'reamkit';
const doc = Ream.parse(xlsxBytes);
const pdf = await doc.convert('pdf', { fonts }); // a button box per slicer, after the grid
const html = await doc.convert('html');

SmartArt (DOCX and PPTX) renders either way round. Where the file caches a pre-rendered DrawingML drawing (diagrams/drawing#.xml) that is drawn directly; where it does not, the diagram is laid out from its own data, layout, colors and quickStyle parts — the algorithms (lin, composite, cycle, the hierarchy pair, snake and the rest), the constraint solver that sizes and spaces the boxes, and the colour and style parts that paint them. Scheme colours resolve through the document or deck theme, and box text is measured so a box grows to what it holds.

Nothing special is needed to get it — it is part of the ordinary conversion:

import { Ream } from 'reamkit';
const { bytes, losses } = await Ream.parse(docxBytes).convertWithReport('pdf', { fonts });
// `losses` is where anything the file asks for and Ream cannot draw is reported.
import { Ream } from 'reamkit';
input.addEventListener('change', async () => {
const bytes = new Uint8Array(await input.files![0].arrayBuffer());
const pdf = await Ream.parse(bytes).convert('pdf');
window.open(URL.createObjectURL(new Blob([pdf], { type: 'application/pdf' })));
});
import { readFileSync, writeFileSync } from 'node:fs';
import { Ream } from 'reamkit';
const doc = Ream.parse(new Uint8Array(readFileSync('report.docx')));
writeFileSync('report.pdf', await doc.convert('pdf'));

The whole PDF/A family is supported (1a/1b, 2a/2b/2u, 3a/3b/3u). PDF/A-3 can carry the source document inside the PDF as an associated file:

const { bytes: pdfa, losses } = await doc.convertWithReport('pdf', {
fonts,
pdfA: 'PDF/A-3b',
embedSource: true, // .docx/.xlsx rides along as /AF with /AFRelationship /Source
});

'PDF/A-1a' / 'PDF/A-2a' / 'PDF/A-3a' additionally emit the tagged logical structure (headings, tables, lists, figure alt text).

pdfUA: true produces ISO 14289-1-conformant output — tagged structure, alternate descriptions on links, an always-announced document title. It combines with PDF/A in a single file (both veraPDF-validated):

const accessible = await doc.convert('pdf', { fonts, pdfUA: true });
const archival = await doc.convert('pdf', { fonts, pdfA: 'PDF/A-2a', pdfUA: true });

PKCS#7 detached (ISO 32000 §12.8) via WebCrypto — RSA or ECDSA:

const signed = await doc.convert('pdf', {
fonts,
signature: {
certificate: certificateDer, // Uint8Array, DER
privateKey: cryptoKey, // WebCrypto CryptoKey
// optional: signingTime, reason, location, pades: true, timestampUrl
},
});

Chain providers; the first byte answer wins. A remote or local winner is recorded as a substituted loss:

import { Ream, callerFontProvider, localFontProvider, remoteFontProvider } from 'reamkit';
const { bytes, losses } = await doc.convertWithReport('pdf', {
fontProviders: [
callerFontProvider(myFonts), // your bytes first
localFontProvider(), // system fonts (Chromium Local Font Access;
// embedding-restricted fonts are never used)
remoteFontProvider(), // open substitute set, last resort
],
});
// losses[0] → { severity: 'substituted', feature: 'fonts.substitution', … }

Fonts embedded in the document itself (w:embed, including obfuscated .odttf, and a deck’s p:embeddedFontLst) always win — glyph-exact, no substitution. A w:rFonts that names a theme slot rather than a family resolves through the theme’s major / minor fonts before any of this runs.

A face the network fails to deliver is not remembered as failed: the next conversion asks for it again, so a server that builds its provider chain once and reuses it keeps working after a dropped connection. A required face that cannot be fetched still throws, with the network’s error as its cause.

The substitute set is Latin, so a document holding another writing system is served separately: a Noto face is fetched per SCRIPT — Japanese, Korean, Chinese, Arabic, Hebrew, Thai, geometric symbols — and only for the scripts the document actually contains. It is chosen per character, so a paragraph that mixes Latin and Arabic is drawn in both faces rather than in one with holes:

// A sheet of Korean: NotoSansKR is fetched, in the regular weight only.
// The same call on a Latin-only document downloads nothing extra.
const pdf = await Ream.parse(bytes).convert('pdf');

Ream is a correct typesetter: it lays out faithfully for the font you give it, and a document Word sets — a .docx or a .doc — stands its lines as Word sets them, by rules measured in Word. For closer visual parity with a specific renderer, pass a layoutProfile — it switches how text is measured, broken into lines and stacked to match that tool. Paired with the metric-compatible substitutes above (so the same glyph advances are in play), the page geometry tracks the target closely:

const pdf = await doc.convert('pdf', { fonts, layoutProfile: 'libreoffice' });
// 'word' targets Microsoft Word; 'ream' (the default) is Ream's own typesetter.

'libreoffice' breaks lines greedily (first-fit) and takes each line from the face in hand — its hhea line, or its OS/2 typo line where the face asks for that; 'word' measures without kerning (Word’s default), breaks first-fit and stands every line as Word sets it, whatever the document. The profile applies to flowing text (DOCX/PPTX); spreadsheet geometry follows Excel’s row model regardless.

Make any loss fatal instead of reported:

import { ConversionLossError } from 'reamkit';
try {
await doc.convert('pdf', { fonts, strict: true });
} catch (e) {
if (e instanceof ConversionLossError) {
// e.loss — what exactly would have been dropped/degraded/substituted
}
}

parse produces a format-neutral tree before any rendering:

const doc = Ream.parse(bytes);
doc.format; // 'docx' | 'xlsx'
doc.losses; // read-time losses
for (const el of doc.flow.body) {
// el.kind: 'paragraph' | 'table' | 'image' | 'chart' | 'shape'
}

/Info is read automatically from the document’s docProps/core.xml; caller values override it:

const pdf = await doc.convert('pdf', {
fonts,
info: { title: 'Q4 Report', author: 'Finance' },
});
import { getHyphenator } from 'reamkit';
const hyphenator = await getHyphenator('en-us'); // or 'ru'
const pdf = await doc.convert('pdf', { fonts, hyphenator });