This library uses image-js and can be used inside of browser environments and/or node.
Ratta Supernote has often commented that the file-format is yet unstable and shouldn't be much relied upon (yet). Please keep this in mind.
For some quick snippets, take a look at the smoke tests.
toPdf renders each page's raster image into a PDF page and overlays the recognized handwriting (RTR) text invisibly at the location it was written, so the PDF is searchable and words can be selected/copied from the image. See tests/pdf.test.ts for a full example.
import { SupernoteX, toPdf } from 'supernote-typescript';
const note = new SupernoteX(buffer);
const pdfBytes = await toPdf(note);The default font (Helvetica) only supports Latin text. Pass fontBytes with a Unicode TTF/OTF to support other scripts:
const pdfBytes = await toPdf(note, { fontBytes: await fs.readFile('NotoSans-Regular.ttf') });toPdf is a convenience wrapper around three lower-level pieces, exported so applications can render pages in parallel (across Web Workers or Node worker_threads) instead of one at a time on the main thread:
extractPageRenderData(note, pageNumber)— pulls out the minimal, structured-clone-safe slice of one page needed to render it, safe topostMessageto a Worker.toImageandencodePng(fromimage-js) — both safe to call inside a Worker; render the page and encode it to PNG bytes there.createPdfContext(options?)/addPdfPage(ctx, page, image, options?)— must run on the main thread (they holdpdf-libobjects, which aren't structured-clone-safe);addPdfPageaccepts either anImageor already-encoded PNG bytes, so it can take a Worker's output directly.
import { SupernoteX, extractPageRenderData, createPdfContext, addPdfPage } from 'supernote-typescript';
const note = new SupernoteX(buffer);
// In each Worker: toImage(pageRenderData, [1]) then encodePng(image), then
// postMessage the PNG bytes back. See tests/fixtures/render-worker.mjs and
// tests/pdf-worker-roundtrip.test.ts for a full worker_threads example.
const pngBuffers = await Promise.all(
note.pages.map((_, i) => renderInWorker(extractPageRenderData(note, i + 1))),
);
// Back on the main thread: assemble the PDF from the rendered pages.
const ctx = await createPdfContext();
for (let i = 0; i < note.pages.length; i++) {
await addPdfPage(ctx, note.pages[i], pngBuffers[i]);
}
const pdfBytes = await ctx.pdfDoc.save();Note that only page rendering (toImage/encodePng) is parallelizable this way — PDF assembly, including the final pdfDoc.save(), is a single main-thread operation regardless of how many Workers rendered pages.
toSvg renders each page to a standalone SVG document: the rasterized page image embedded as a base64 PNG, with the recognized handwriting (RTR) text overlaid invisibly at the position it was written, same as toPdf. SVG has no native multi-page container, so toSvg returns one SVG string per page.
import { SupernoteX, toSvg } from 'supernote-typescript';
const note = new SupernoteX(buffer);
const svgs = await toSvg(note); // one SVG document string per page
await fs.writeFile('page-1.svg', svgs[0]);Pass { dpi } to size the SVG's width/height attributes in physical inches (the viewBox, and so the coordinate space the image and text sit in, always stays in raw pixels); pass { includeText: false } to skip the text overlay and just embed the image.
Like toPdf, toSvg is a convenience wrapper around lower-level pieces — extractPdfPageData, toImage/encodePng, and addSvgPage — for rendering pages in parallel across Workers. Unlike addPdfPage, addSvgPage doesn't touch any non-structured-clone-safe objects, so the whole per-page pipeline (toImage + encodePng + addSvgPage) can run inside a Worker, with only the resulting strings posted back to the main thread.
toImage always rasterizes at the note's native pageWidth×pageHeight — fine for a main view or export, but wasteful for something like a small thumbnail sidebar, where decoding and holding a full-resolution page in memory per thumbnail adds up fast on memory-constrained devices (this is what motivated #40). Pass { scale } to render directly at a reduced resolution instead:
const thumbnails = await toImage(note, undefined, { scale: 10 });scale is an integer downsample factor; output pages are ceil(pageWidth / scale) × ceil(pageHeight / scale). This isn't full-resolution decoding followed by a resize — each layer is decoded directly at the reduced resolution (RattaRLEDecoder.decodeAtScale, nearest-neighbor sampled), so the full-resolution buffer is never allocated at all. On a 1404×1872 page, scale: 10 produces a ~104 KB output buffer instead of the ~10 MB a full decode would need.
Omitting scale (or passing { scale: 1 }) renders at full resolution exactly as before.
Supernote's own export offers a "200%" option that produces a larger raster (e.g. 3840×5120 instead of 1920×2560) for use on high-density displays or print, at the cost of visibly softer pixel-grid edges on ink strokes since there's no extra stroke detail to reveal — it's a resize, not a redraw. Pass { upscale } to toImage/toSvg for the same effect:
const images = await toImage(note, undefined, { upscale: 2 }); // 2x pixel density
const svgs = await toSvg(note, { upscale: 2 });upscale is a bicubic resize applied after decoding/compositing (any finite number >= 1, not just an integer); it combines with scale (applied on top of whatever resolution scale decoded at). Unlike a plain resize, edge pixels are premultiplied by alpha before resizing and divided back out after, so anti-aliased edges stay anchored to their own ink color instead of picking up a dark fringe from the fully-transparent background. In toSvg, the viewBox and recognized-text overlay scale up right along with the raster, and dpi (if set) is scaled by the same factor so the physical page size stays put — upscale raises pixel density, it doesn't enlarge the page. It's real per-pixel CPU work, worth reserving for an explicit export rather than routine rendering.
.spd files, produced by the Supernote Atelier app, are a different format from .note files: a SQLite database of image tiles rather than the custom binary layout SupernoteX parses. SupernoteAtelier.open reads it (via sql.js) and exposes the tiles per surface (layer — surface names vary per file, e.g. surface_1 or a surface_9999 "Reference Layer"), plus best-effort decoded metadata (viewport, canvas size, layer names). Its .spd schema and ls layer encoding aren't officially documented; the reverse-engineered details are noted in src/atelier.ts.
toImage(surfaceName)stitches one surface's tiles into a single image, sized and positioned against every surface's tiles in the file so that different layers' images line up and can be composited on top of each other.toCompositeImage(visibleSurfaces?)flattens surfaces into one final image directly, layered bottom-to-top bylayersorder (best-effort, seetoImage's note aboutls) — the simplest way to get one finished picture out of a.spdfile without handling individual layers yourself. Defaults to every surface in the file; pass a subset of surface names (e.g. from a layer visibility toggle) to flatten only those.
import { SupernoteAtelier } from 'supernote-typescript';
const note = await SupernoteAtelier.open(buffer);
// One surface (layer) at a time:
const image = await note.toImage('surface_1');
// Every surface flattened into one final image:
const flattened = await note.toCompositeImage();
// Or just a chosen subset, e.g. hiding a "Reference Layer" background:
const withoutBackground = await note.toCompositeImage(['surface_1', 'surface_2']);SupernoteAtelier.open's second argument is passed straight through to sql.js's initSqlJs, so a bundler that can embed the .wasm file as bytes (e.g. esbuild's binary loader) can hand it over as wasmBinary, instead of sql.js fetching/reading a sibling sql-wasm.wasm file at runtime via locateFile — the one thing that would otherwise differ between Node/Electron and a mobile browser runtime:
// esbuild.config.mjs
loader: { '.wasm': 'binary' }, // resolves a `.wasm` import to a decoded Uint8Array
// your code
import sqlWasmBinary from 'sql.js/dist/sql-wasm.wasm';
const wasmBinary = sqlWasmBinary.buffer.slice(
sqlWasmBinary.byteOffset,
sqlWasmBinary.byteOffset + sqlWasmBinary.byteLength,
);
const note = await SupernoteAtelier.open(buffer, { wasmBinary });Used this way in the Supernote Obsidian Plugin, which runs unmodified on both desktop and mobile.
npx jest -t 'manta'
toSvg({ vectorInk: true }) redraws a page's ink as real vector paths, and
the fixtures under tests/input/ that ship with Supernote's own PDF export
give an independent answer for what that ink should look like. The site puts
the two side by side, page by page:
npm run build:site # writes site/
Both sides are vector ink only — the device keeps its background in a separate image, so ours has its background raster stripped to match. Each page is labelled with the ink it lays down as a fraction of the device's, which is blunt on its own but catches a whole stroke going missing or a tool drawn at the wrong width.
CI builds it on every pull request and attaches it as a downloadable
artifact; merges to main publish it to GitHub Pages
(.github/workflows/pages.yml).
New fixtures belong on it. Committing a device PDF export as <name>.pdf
beside <name>.note is all it takes for the pair to get a page — the build
finds them by name — and FIXTURE_NOTES in scripts/build-fixture-site.ts
is where that page says what the fixture isolates. See
tests/input/README.md, which catalogues the same
fixtures in more depth.
The per-page ink ratio is a blunt measure by design, and blunt in one
direction worth knowing: it sums each path's area instead of unioning them,
so a page we draw as many overlapping strokes reads high against an export
that merged its ink into a single path. sticker page 2 is the standing
example. Read the ratio next to the picture, not instead of it.
The scripts behind it are TypeScript like the rest of the repo, built by
tsconfig.scripts.json — so npm run build:site type-checks them, and a
type error there fails the build rather than surfacing at runtime.
npm version patch
npm run build
npm publish
- Thank you to Tiemen Schuijbroek for developing the initial supernote Typescript library I forked.
- Heavily inspired by the Python implementation by jya-dev. This one currently only supports the X series notebooks.