@unisim/doc
v0.6.0
Published
The suite's document stack: read Word, OpenDocument, RTF, HTML, Markdown, CSV, JSON and text into one model, and write it back out as PDF, text, HTML, Markdown, CSV or JSON. No runtime dependencies.
Readme
@unisim/doc
The suite's document stack. Reads Word, OpenDocument, RTF, HTML, Markdown, CSV, JSON and plain text into one model, and writes that model back out as PDF, text, HTML, Markdown, CSV or JSON.
No runtime dependencies. That is not incidental — one of its two consumers (Universal Converter) is sold on being offline from first visit with nothing fetched, so a dependency here would cost that product its pitch.
import { readToDoc, convertDocument, DEFAULT_DOC_SETTINGS } from '@unisim/doc'
// The model, for a caller that wants to inspect or combine documents
const doc = await readToDoc(file)
// Or straight to an output
const { blob, ext, notices, pages } = await convertDocument(file, {
...DEFAULT_DOC_SETTINGS,
format: 'pdf',
})convertDocument hands back the target extension, not a filename. Naming
the output file is the caller's job on purpose: the app knows what the source
was called and which of its own naming rules apply, and a library offering a
second opinion is how you end up chasing a .png.png.
Why it exists
It existed twice. On 2026-08-13 two sessions that could not see each other shipped the same 1,200–1,500 lines on the same afternoon — a ZIP reader, a namespace-agnostic XML layer, an OOXML walker, an ODF walker, and a tokenise → wrap → paginate layout engine — into Universal PDF and Universal Converter. Both files ended up carrying a header saying "a fix to one is a fix to neither."
This is Converter's version, because it was the better one: a model designed as
a hub (nine readers × six writers) rather than grown out of a Markdown renderer,
with heading level 4, real image blocks, underline/strike on a run, and
DocNotice — a channel for reporting what a conversion lost, which the
other had no equivalent of. Universal PDF's model had exactly one thing this
lacked, pagesetup, which was merged in.
What it does not do
It re-typesets a document; it does not reproduce the page. Fonts, columns,
headers and footers, text boxes and floating shapes do not survive. Anything a
reader has to drop is reported through RichDoc.notices rather than discovered
later — the failure worth designing against is someone assuming the output is a
copy.
Two writers, on purpose
The PDF writer here goes through pdfcore — base-14 fonts, its own AFM tables,
no dependency. A consumer that already carries pdf-lib for other reasons
(Universal PDF does, for signing, redaction and forms) gains nothing by dropping
it, and can take the readers and the model from here while keeping its own
writer. The seam is that write/pdf.ts depends only on pdfcore's small
surface — addPage / drawText / drawRect / drawImage / widthOfText.
Notes for anyone editing it
- Imports carry a
.jsextension. Not.ts(which needsallowImportingTsExtensions, only legal withnoEmit) and not extensionless (emitted verbatim, sodist/index.jswould import./convertand Node would refuse it). The first build of this package made exactly that mistake. - The model is the intersection of what these formats can express, not the union. A DOCX can hold a text box rotated 3° over a chart; Markdown cannot. Anything outside the vocabulary is flattened on the way in, by the reader that knows what it meant.
pagesetupis an instruction, not a property, because that is how the source formats model it — orientation belongs to a section, and a section runs until the next break. ⚠️ The two office formats express it in opposite directions: OOXML'sw:sectPrsits on the last paragraph of the section it describes, ODF'sstyle:master-page-nameon the first paragraph of the new one. Reading them the same way puts every change one page out.
Tests
npm testCovers what is DOM-free: the hand-written PDF byte layer (including that the
xref offsets actually point at their objects — a reader that repairs a damaged
table will open a broken file, so "it opened in Chrome" proves nothing), pages
of differing sizes in one document, and the setSize guard rail. The rest is
DOM-bound and is covered by the consuming apps' browser suites — in particular
Universal PDF's e2e/office-import.e2e.mjs, which drives real .docx/.odt
fixtures and asserts on the page sizes pdf.js reads back out.
