@bendyline/squisq-formats
Document format converters for Squisq. Import and export between Squisq's Markdown-based document model and common file formats — DOCX, PDF, HTML, EPUB, PPTX, XLSX, and CSV. All converters run in the browser or Node with no server or native binaries required.
Part of the Squisq monorepo.
Install
npm install @bendyline/squisq-formats @bendyline/squisq
How Conversion Works
All converters use Squisq's MarkdownDocument AST (from @bendyline/squisq/markdown) as the pivot format:
- Import parses a file into a
MarkdownDocument. ThexxxToContainervariants (DOCX, PDF, PPTX) also extract embedded images into aContentContaineralongside the markdown. - Export serializes a
MarkdownDocumentout to the target format. Each format also has adocToXxx/xxxToDocwrapper that converts throughMarkdownDocumentinternally.
Conversions preserve document structure and most of the flavor of the source — headings, text formatting, lists, tables, links, images — but they are not lossless round-trips. Per-format fidelity limits are listed below.
All exports that accept a themeId (DOCX, PDF, EPUB, PPTX, plain HTML) apply Squisq theme colors/typography, and fall back to the doc's frontmatter theme (squisq-theme / themeId / theme keys) when the option is omitted.
For a uniform, format-agnostic entry point over every converter, use the format registry and convert() (@bendyline/squisq-formats/registry, also re-exported from the root) — see Programmatic convert() below.
Supported Formats
| Format | Import | Export | Theme | Subpath |
|---|---|---|---|---|
| DOCX (Word) | ✅ | ✅ | ✅ | @bendyline/squisq-formats/docx |
| ✅ | ✅ | ✅ | @bendyline/squisq-formats/pdf | |
| HTML | ✅ | ✅ | ✅ | @bendyline/squisq-formats/html |
| EPUB (e-book) | — | ✅ | ✅ | @bendyline/squisq-formats/epub |
| PPTX (PowerPoint) | ✅ | ✅ | ✅ | @bendyline/squisq-formats/pptx |
| XLSX (Excel) | ✅ | ✅¹ | — | @bendyline/squisq-formats/xlsx |
| CSV | ✅ | ✅ | — | @bendyline/squisq-formats/csv |
| Container ZIP | ✅ | ✅ | — | @bendyline/squisq-formats/container |
¹ XLSX export is tables-only (v1.5) — see the XLSX section.
Quick Examples
DOCX
import {
markdownDocToDocx,
docxToMarkdownDoc,
docxToContainer,
} from '@bendyline/squisq-formats/docx';
// Export: MarkdownDocument → DOCX (ArrayBuffer)
const docxBytes = await markdownDocToDocx(markdownDoc, { title: 'Report', themeId: 'documentary' });
// Import: DOCX (ArrayBuffer | Blob) → MarkdownDocument
const imported = await docxToMarkdownDoc(docxBuffer, { extractImages: true });
// Import with assets: DOCX → ContentContainer (markdown + images/ files)
const container = await docxToContainer(docxBuffer);
Fidelity: import covers headings, paragraphs, inline formatting (bold, italic, strikethrough, inline code), hyperlinks, lists, tables, blockquotes, code blocks, footnotes, and embedded images (opt-in via extractImages; always on for docxToContainer). Export embeds images only when provided via options.images; otherwise they appear as placeholder text.
import {
markdownDocToPdf,
pdfToMarkdownDoc,
pdfToContainer,
configurePdfWorker,
} from '@bendyline/squisq-formats/pdf';
// Configure the PDF.js worker (needed for import in some environments)
configurePdfWorker('/pdf.worker.min.mjs');
// Export: MarkdownDocument → PDF (ArrayBuffer)
const pdfBytes = await markdownDocToPdf(markdownDoc, { pageSize: 'a4' });
// Import: PDF → MarkdownDocument (heuristic structure detection)
const imported = await pdfToMarkdownDoc(pdfBuffer);
// Import with assets: PDF → ContentContainer (markdown + extracted images)
const container = await pdfToContainer(pdfBuffer);
Fidelity: export uses pdf-lib's standard 14 fonts (themeId affects colors only). Text they cannot encode — Greek, Cyrillic, Vietnamese, arrows, math, box drawing, symbols — is drawn with embedded Unicode fonts: the bundled DejaVu Sans faces, plus monochrome Noto Emoji, Noto Sans SC and Noto Sans KR for emoji, Chinese, Japanese and Korean, each loaded and subset only for a document that needs it (unicodeFonts supplies your own faces, or false turns this off). Scripts none of these cover, such as Thai, are still replaced, and reported once through onWarning. Chinese and Japanese lines break between characters. Lines break at a <br> (the only line break a table cell can hold), table columns are sized to their content, and a word too long for its line or cell breaks between characters. Pages stay white for printing. Theme text, heading, and link colors are retained when they meet 4.5:1 contrast against both white paper and the shaded table header; otherwise the exporter uses its default dark ink for that role. This also applies to inherited and custom themes. Pass images (keyed by the markdown URL, as for DOCX) to draw PNG and JPEG images that stand alone in a paragraph; other images print as an [Image: alt] note. PDF has no semantic structure, so import is heuristic and best-effort: headings are detected by font size, and tables / code blocks / blockquotes / links via the detectTables / detectCodeBlocks / detectBlockquotes / detectLinks options (all default true). pdfToMarkdownDoc is text-only; pdfToContainer also extracts embedded XObject and inline raster images in browsers and Node, placed by page — each image is inserted after the last content block from its page (image-only pages fall back to the nearest preceding page with content, else the document end). Placement is page-level only; masks, vector artwork, and images that PDF.js cannot expose as decoded pixels are not emitted.
Diagrams as pictures
DOCX, EPUB, PDF and PPTX cannot draw Mermaid or Squisq diagrams, so on their
own they export diagram source as text. rasterizeDiagrams finds every
diagram — Mermaid fences, ASCII diagram/timeline/tree fences, and
{[drawing]}/{[layout]}/{[diagram]} heading blocks — asks a renderer for
a PNG of each, and returns the document with each diagram replaced by an
image plus the bytes for the exporters' images option. Drawing needs a
browser; @bendyline/squisq-react/diagram-pictures provides the renderer.
import { rasterizeDiagrams } from '@bendyline/squisq-formats/diagrams';
import { createDiagramPictureRenderer } from '@bendyline/squisq-react/diagram-pictures';
const pictured = await rasterizeDiagrams(markdownDoc, createDiagramPictureRenderer());
const docx = await markdownDocToDocx(pictured.markdownDoc, { images: pictured.images });
const pdf = await markdownDocToPdf(pictured.markdownDoc, { images: pictured.images });
// PPTX keeps drawings, timelines and ASCII diagrams as native shapes;
// only Mermaid needs a picture.
const pptx = await docToPptx(doc, { diagramImages: pictured.mermaid });
A diagram the renderer cannot draw stays as source; the export never fails on
it. Image entries carry their display size (width/height in CSS pixels),
so a picture rendered at 2× prints at 1×.
HTML
import { PLAYER_BUNDLE } from '@bendyline/squisq-react/standalone-source';
import {
docToHtml,
docToHtmlZip,
markdownDocToPlainHtml,
htmlToMarkdownDoc,
} from '@bendyline/squisq-formats/html';
// Interactive player export: Doc → single self-contained HTML string
// (player inlined, images as data URIs, timer-based playback — no audio)
const html = docToHtml(doc, { playerScript: PLAYER_BUNDLE, images });
// Interactive player export: Doc → ZIP Blob (external assets + optional audio)
const zipBlob = await docToHtmlZip(doc, { playerScript: PLAYER_BUNDLE, images, audio });
// Static export: MarkdownDocument → semantic HTML page (no player)
const page = markdownDocToPlainHtml(markdownDoc, { themeId: 'warm-earth' });
// Import: HTML → MarkdownDocument (sanitized by default)
const imported = await htmlToMarkdownDoc(htmlString);
To place a document inside your own page — a site header, navigation, footer, and stylesheet — write the page as an HTML template with standard <slot> elements and fill it:
import { applyHtmlPageTemplate, markdownDocToPlainHtmlParts } from '@bendyline/squisq-formats/html';
// template: <title><slot name="title">Post</slot> · My Blog</title> … <h1><slot name="title"></slot></h1> <slot></slot>
const parts = markdownDocToPlainHtmlParts(markdownDoc);
const page = applyHtmlPageTemplate(
template,
{
bodyHtml: parts.bodyHtml,
headHtml: parts.featureHeadHtml,
title: 'Hello',
values: { date: '2026-10-28' },
},
{ templatePath: '_templates/post.html', outputPath: 'posts/hello.html' },
);
The unnamed <slot> receives the body (between <!-- squisq:content --> markers, which htmlToMarkdownDoc honors on re-import), named slots receive text values or keep their fallback children, relative URLs are rebased from the template's folder to the page's, and everything else in the template is copied byte for byte.
The subpath also exports markdownDocsToPlainHtmlBundle / markdownDocsToHtmlBundle (recursive multi-doc ZIP bundles with .md links rewritten to .html), htmlToMarkdownDocSync / htmlToMarkdown, and helpers (collectImagePaths, collectLinkRefs, inferMimeType).
EPUB
import { markdownDocToEpub } from '@bendyline/squisq-formats/epub';
// Export: MarkdownDocument → EPUB 3 (ArrayBuffer). No import path.
const epubBytes = await markdownDocToEpub(markdownDoc, {
title: 'My Book',
author: 'Jane Doe',
images, // Map<string, ArrayBuffer> — embedded when provided
coverImage, // optional JPEG/PNG
});
Chapters split at H1/H2 boundaries. Providing audio + audioSegments generates EPUB 3 Media Overlays (SMIL) for synchronized narration.
PPTX
import {
docToPptx,
markdownDocToPptx,
pptxToMarkdownDoc,
pptxToContainer,
} from '@bendyline/squisq-formats/pptx';
// Export: MarkdownDocument → PPTX (ArrayBuffer)
// slideBreak: 'h1' | 'h2' (default — H1 and H2 both break) | 'heading'
const pptxBytes = await markdownDocToPptx(markdownDoc, { slideBreak: 'h2', images });
// Export: player-ready Doc -> 16:9 PPTX using the slideshow's canonical
// block expansion and materialized visual layers (managed cover included by default)
const visualDeck = await docToPptx(slideshowDoc, { images });
// Import: PPTX → MarkdownDocument (add { extractImages: true } to reference slide images)
const imported = await pptxToMarkdownDoc(pptxBuffer);
// Import with assets: PPTX → ContentContainer (markdown + extracted slide images)
const container = await pptxToContainer(pptxBuffer);
Fidelity: markdownDocToPptx preserves inline formatting as DrawingML runs. docToPptx treats a template-backed/player-ready Doc as a visual deck: it uses the same renderable-block flattening, timing expansion, managed-cover policy, theme, transitions, and materialized layer stack as slideshow view, so the PPTX has the same slide count and a matching 16:9 canvas. Text, shapes, tables, paths, and supplied images become editable native PowerPoint objects; browser-only map/video/Mermaid layers degrade to a static image when supplied or a labeled text fallback. Import reads slide order from ppt/presentation.xml and converts each slide's title (→ H2), body text (→ bullet list), and tables. Slide-image extraction (v1.5): import can now extract slide-level <p:pic> bitmaps into images/ — pptxToContainer returns a container with those files and forces extractImages: true, while pptxToMarkdownDoc leaves it off by default. Honest limit: only slide-level <p:pic> bitmaps are extracted — layout/master images, charts, SmartArt, and picture-fills are not.
CSV
import { csvToMarkdownDoc, markdownDocToCsv, parseCsv } from '@bendyline/squisq-formats/csv';
// Import: CSV (string | ArrayBuffer | Blob) → single-table MarkdownDocument
const tableDoc = await csvToMarkdownDoc(csvText, { delimiter: ',', hasHeader: true });
// Export: serializes one table node to CSV text (the first by default)
const csv = markdownDocToCsv(markdownDoc);
// Pick another table in a multi-table document:
const second = markdownDocToCsv(markdownDoc, { tableIndex: 1 });
Self-contained RFC-4180 parser/serializer (not OOXML). Cell formatting is flattened to plain text on export. Export emits a single table — the first, or tableIndex (zero-based). An out-of-range tableIndex throws; a table-less document exports to an empty string.
XLSX
import { xlsxToMarkdownDoc, markdownDocToXlsx } from '@bendyline/squisq-formats/xlsx';
// Import: XLSX → MarkdownDocument (one table per sheet; or pick one via `sheet`)
const imported = await xlsxToMarkdownDoc(xlsxBuffer, { sheet: 0 });
// Export: MarkdownDocument → XLSX (ArrayBuffer) — one worksheet per markdown table
const xlsxBytes = await markdownDocToXlsx(markdownDoc);
Export is implemented (v1.5) with tables-only fidelity: every markdown table becomes one worksheet, named after the nearest preceding heading (auto-named Sheet1, Sheet2, … otherwise). All non-table content is dropped. Numeric-looking cells are written as numbers; everything else as inline strings. A document with no tables yields a single empty (but valid) sheet — export never throws. markdownDocToXlsx / docToXlsx now return Promise<ArrayBuffer> (previously threw / returned Blob).
Container ZIP
import {
containerToZip,
zipToContainer,
ZipSafetyError,
} from '@bendyline/squisq-formats/container';
// ContentContainer → ZIP Blob (paths preserved)
const zipBlob = await containerToZip(container);
// ZIP → MemoryContentContainer (directories skipped, path traversal rejected)
const container2 = await zipToContainer(zipData, {
maxUncompressedBytes: 128 * 1024 * 1024,
});
Archive import safety
Every production ZIP read—container ZIP/DBK sniffing and OOXML-backed DOCX,
PPTX, and XLSX import—uses one JSZip-backed bounded read session. The session
checks paths and central-directory metadata before inflation, then consumes
JSZip's incremental member stream while sharing an actual emitted-byte budget
across all members. Repeated OOXML part reads are cached and charged once;
concurrent streams are all paused on the first failure. [Content_Types].xml
is capped at 1 MiB and each relationship part at 4 MiB before DOM parsing.
OoxmlPackage is opaque: create it with openPackage() and access parts through
the bounded getters rather than reaching into a raw JSZip archive.
ZipSafetyLimits is accepted by zipToContainer and inherited by the OOXML
import option types:
| Field | Default | Meaning |
|---|---|---|
maxEntries | 10_000 | Total central records, including directory records |
maxEntryUncompressedBytes | maxUncompressedBytes | Maximum emitted bytes retained for one member |
maxUncompressedBytes | 512 * 1024 * 1024 | Aggregate emitted bytes across the archive |
maxCompressionRatio | 1_000 | Maximum uncompressed/compressed ratio for one member |
Failures are ZipSafetyError instances, exported from the package root and
the /container and /ooxml subpaths. They expose code, and where relevant
path, limit, actual, and cause. Stable codes are invalid-limit,
invalid-archive, unsafe-path, invalid-entry-metadata,
too-many-entries, entry-too-large, archive-too-large,
compression-ratio-exceeded, size-mismatch, crc-mismatch, and
decompression-failed.
JSZip's public pause is cooperative: it prevents the next compressed-input tick, but pako may finish producing chunks already in the current synchronous tick. Once a limit fails, Squisq discards those chunks immediately—they are not retained, CRC-counted, or written to a container.
Doc-level Convenience Functions
Each format also exports Doc-level wrappers that handle the Markdown ↔ Doc conversion internally:
import { docToDocx, docxToDoc } from '@bendyline/squisq-formats/docx';
import { docToPdf, pdfToDoc } from '@bendyline/squisq-formats/pdf';
import { docToPptx, pptxToDoc } from '@bendyline/squisq-formats/pptx';
import { docToEpub } from '@bendyline/squisq-formats/epub';
import { csvToDoc } from '@bendyline/squisq-formats/csv';
import { xlsxToDoc, docToXlsx } from '@bendyline/squisq-formats/xlsx';
Programmatic convert()
@bendyline/squisq-formats/registry (also re-exported from the package root) is a uniform, format-agnostic front door over every converter. Each format is a FormatDefinition that knows how to import raw bytes into the MarkdownDocument pivot and/or export back out; convert() normalizes any source into a Doc, optionally applies a theme/transform, and hands off to the target exporter. Every result comes back as the same ConversionResult (bytes + mimeType + suggestedFilename + warnings). Converter modules load lazily, so importing the registry never eagerly bundles a heavy converter.
import { convert } from '@bendyline/squisq-formats/registry';
const result = await convert(
{ kind: 'markdown', markdown: '# Hello', baseName: 'greeting' },
'docx',
);
// result.bytes, result.mimeType, result.suggestedFilename === 'greeting.docx'
ConvertOptions also accepts themeRegistry and transformRegistry for
caller-owned custom definitions. transformStyle may be either a style id or
a call-scoped TransformStyleConfig; these options never mutate process-global
state.
Sources are { kind: 'bytes' | 'markdown' | 'doc', … }; byte sources are format-sniffed by magic bytes + extension (pass from to skip). Failures throw a structured ConversionError with a stable code (unknown-format, unsupported-input, unsupported-output, invalid-input, missing-dependency, conversion-failed) plus a hint.
Player-embedding HTML export (html / htmlzip) needs the standalone player bundle. Pass resolvePlayerScript, or you'll get a missing-dependency error:
const html = await convert({ kind: 'markdown', markdown: src }, 'html', {
resolvePlayerScript: () =>
import('@bendyline/squisq-react/standalone-source').then((m) => m.PLAYER_BUNDLE),
});
Also exported: createRegistry / defaultRegistry / defaultFormats, BUILTIN_FORMAT_IDS, ConversionError, and the ConvertSource / ConvertOptions / ConversionResult / FormatDefinition / FormatRegistry types.
Subpath Exports
| Subpath | Contents |
|---|---|
@bendyline/squisq-formats/docx | DOCX import/export (+ docxToContainer) |
@bendyline/squisq-formats/pdf | PDF import/export (+ pdfToContainer, configurePdfWorker) |
@bendyline/squisq-formats/html | Player HTML export, static plain-HTML export + bundles, HTML import |
@bendyline/squisq-formats/html-prepare | prepareMarkdownDocForPlainHtml: data tables and pictures for plain-HTML pages |
@bendyline/squisq-formats/html-icons | plainHtmlIcons: inline icons as SVG outlines for plain-HTML pages |
@bendyline/squisq-formats/epub | EPUB 3 e-book export (with optional Media Overlays) |
@bendyline/squisq-formats/pptx | PPTX export + import (text/lists/tables + slide images) |
@bendyline/squisq-formats/xlsx | XLSX import + tables-only export |
@bendyline/squisq-formats/csv | CSV import/export (RFC 4180) |
@bendyline/squisq-formats/ooxml | Shared OOXML infrastructure (package reader/writer, XML utilities, namespaces) |
@bendyline/squisq-formats/diagrams | rasterizeDiagrams / findDiagrams: diagrams as pictures for document exports |
@bendyline/squisq-formats/container | ContentContainer ↔ ZIP serialization |
@bendyline/squisq-formats/registry | Format registry + programmatic convert() (also re-exported from the root) |
The package root re-exports the common converters; ./container, the plain-HTML/bundle functions, docxToContainer, pdfToContainer, PdfPageSize, and the image utilities are subpath-only.
See the full API Reference for every signature and options interface.
Related Packages
| Package | Description |
|---|---|
| @bendyline/squisq | Headless core — schemas, templates, spatial, markdown, storage |
| @bendyline/squisq-react | React components for rendering docs (+ PLAYER_BUNDLE) |
| @bendyline/squisq-cli | squisq CLI — batch conversion + MP4 rendering |
| @bendyline/squisq-editor-react | React editor with raw/WYSIWYG/preview modes |