Files
InkNote/frontend/src/export/zip.ts
T
marcandClaude Opus 5 2611d87f8c Export notes as Markdown or PDF
The reason to build this is not a missing feature — it is that trusting a
program you maintain alone with years of notes should not mean the notes
can only ever be read by that program.

Reachable from a note's toolbar, a notebook's context menu, and Account →
Alle Notizen exportieren.

Markdown carries over everything with an equivalent: headings, lists,
quotes, code, tables, and task state as `- [x]`. A link to another note in
the same export becomes a relative link to its file; a link to a note
outside it degrades to the plain title rather than leaving a dead
reference. Notes with images or a drawing arrive as a ZIP so the files
travel along, and a multi-note export gets a README index.

PDF goes through the browser's print dialog rather than a bundled PDF
library — the browser already has a good one, and it is the only format
where drawings sit inside the document. Markdown gets them as a PNG
beside the text, with a line saying so, because dropping them silently
would be the wrong kind of quiet.

Two decisions worth recording:

- the ZIP writer is eighty lines here rather than a dependency. Note text
  is small and images are already compressed, so compression bought
  nothing, and stored entries are a handful of fixed-layout records.
- Markdown escaping only covers what changes meaning mid-line, plus a
  leading list or heading marker. Escaping every character Markdown ever
  uses turns a sentence into `Hallo\. Alles gut\!`, which defeats the
  point of choosing a readable format.

42 unit tests on the serialiser and the archive format, plus a browser
suite that unpacks a real download and reads the bytes back.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-08 14:46:53 +02:00

151 lines
5.3 KiB
TypeScript

/**
* A minimal ZIP writer — stored (uncompressed) entries only.
*
* Exporting a notebook means handing over many files at once, and the browser
* can only offer one download. That needs a container, and ZIP is the one every
* operating system opens without being asked twice.
*
* Written out rather than pulled in: the format's stored variant is a handful of
* fixed-layout records, which is less code than the dependency would be, and one
* fewer thing to keep updated in a project maintained by one person. Compression
* is skipped on purpose — note text is small, and images are already compressed.
*/
const CRC_TABLE = (() => {
const t = new Uint32Array(256);
for (let i = 0; i < 256; i++) {
let c = i;
for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
t[i] = c >>> 0;
}
return t;
})();
function crc32(bytes: Uint8Array): number {
let c = 0xffffffff;
for (let i = 0; i < bytes.length; i++) c = CRC_TABLE[(c ^ bytes[i]!)! & 0xff]! ^ (c >>> 8);
return (c ^ 0xffffffff) >>> 0;
}
/** ZIP stores timestamps as MS-DOS date and time words. */
function dosDateTime(d: Date): { date: number; time: number } {
return {
date: (((d.getFullYear() - 1980) & 0x7f) << 9) | ((d.getMonth() + 1) << 5) | d.getDate(),
time: (d.getHours() << 11) | (d.getMinutes() << 5) | (d.getSeconds() >> 1),
};
}
export interface ZipEntry {
/** Path inside the archive; forward slashes make folders. */
name: string;
data: Uint8Array | string;
}
const encoder = new TextEncoder();
const toBytes = (d: Uint8Array | string) => (typeof d === "string" ? encoder.encode(d) : d);
/**
* The archive as bytes.
*
* Kept separate from `createZip` so the format can be verified without a Blob —
* jsdom's has no `arrayBuffer`, and a pure byte function is the easier thing to
* reason about in any case.
*/
export function createZipBytes(entries: ZipEntry[], now = new Date()): Uint8Array {
const { date, time } = dosDateTime(now);
const chunks: Uint8Array[] = [];
const central: Uint8Array[] = [];
let offset = 0;
for (const entry of entries) {
const name = encoder.encode(entry.name);
const data = toBytes(entry.data);
const crc = crc32(data);
const local = new DataView(new ArrayBuffer(30));
local.setUint32(0, 0x04034b50, true); // local file header
local.setUint16(4, 20, true); // version needed
local.setUint16(6, 0x0800, true); // UTF-8 names
local.setUint16(8, 0, true); // stored, no compression
local.setUint16(10, time, true);
local.setUint16(12, date, true);
local.setUint32(14, crc, true);
local.setUint32(18, data.length, true);
local.setUint32(22, data.length, true);
local.setUint16(26, name.length, true);
local.setUint16(28, 0, true); // extra field length
chunks.push(new Uint8Array(local.buffer), name, data);
const dir = new DataView(new ArrayBuffer(46));
dir.setUint32(0, 0x02014b50, true); // central directory header
dir.setUint16(4, 20, true); // version made by
dir.setUint16(6, 20, true); // version needed
dir.setUint16(8, 0x0800, true);
dir.setUint16(10, 0, true);
dir.setUint16(12, time, true);
dir.setUint16(14, date, true);
dir.setUint32(16, crc, true);
dir.setUint32(20, data.length, true);
dir.setUint32(24, data.length, true);
dir.setUint16(28, name.length, true);
dir.setUint32(42, offset, true); // where the local header sits
central.push(new Uint8Array(dir.buffer), name);
offset += 30 + name.length + data.length;
}
const centralSize = central.reduce((n, c) => n + c.length, 0);
const end = new DataView(new ArrayBuffer(22));
end.setUint32(0, 0x06054b50, true); // end of central directory
end.setUint16(8, entries.length, true);
end.setUint16(10, entries.length, true);
end.setUint32(12, centralSize, true);
end.setUint32(16, offset, true);
const parts = [...chunks, ...central, new Uint8Array(end.buffer)];
const total = parts.reduce((n, p) => n + p.length, 0);
const out = new Uint8Array(total);
let at = 0;
for (const part of parts) {
out.set(part, at);
at += part.length;
}
return out;
}
/**
* Wraps bytes in a Blob.
*
* Goes through the underlying buffer rather than handing the view straight to
* `new Blob`, which TypeScript rejects because a view may sit on a shared
* buffer. The offsets are honoured, so a view over part of a larger buffer
* yields only its own bytes.
*/
export function bytesToBlob(bytes: Uint8Array, type: string): Blob {
// Copied into a plain ArrayBuffer: a view can sit on a SharedArrayBuffer,
// which Blob does not accept, and the copy also honours the view's offsets.
const copy = new Uint8Array(bytes.byteLength);
copy.set(bytes);
return new Blob([copy.buffer], { type });
}
export function createZip(entries: ZipEntry[], now = new Date()): Blob {
return bytesToBlob(createZipBytes(entries, now), "application/zip");
}
/** Hands a blob to the browser as a download. */
export function download(blob: Blob, filename: string): void {
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
a.remove();
// Freed on the next tick; revoking immediately cancels the download in Safari.
setTimeout(() => URL.revokeObjectURL(url), 10_000);
}
export const __testing = { crc32, dosDateTime };