/** * @trilium-script * * id: auto-import-rnote * type: backend * title: Auto-import Rnote to Canvas * executeButton: true * executeDescription: Converts all .rnote children of this note into Excalidraw canvas notes. */ import zlib from "zlib"; // ── Types (rnote v0.9+ / v0.13 JSON format) ──────────────────────────────── interface RnoteColor { r: number; g: number; b: number; a: number; } interface RnoteElement { pos: [number, number]; pressure: number; } interface RnoteSegment { lineto?: { end: RnoteElement }; quadbezto?: { cp: [number, number]; end: RnoteElement }; cubbezto?: { cp1: [number, number]; cp2: [number, number]; end: RnoteElement }; } interface RnotePath { start: RnoteElement; segments: RnoteSegment[]; } interface RnoteSmoothStyle { stroke_width: number; stroke_color: RnoteColor; fill_color?: RnoteColor; pressure_curve: string; line_style?: string; line_cap?: string; } interface RnoteBrushStroke { path: RnotePath; style: { smooth?: RnoteSmoothStyle; rough?: Record; textured?: Record }; } interface RnoteTextStyle { font_family?: string; font_size?: number; color?: RnoteColor; } interface RnoteTextStroke { text: string; transform: { affine: number[] | number[][] }; // v0.13: style, v0.14: text_style style?: RnoteTextStyle; text_style?: RnoteTextStyle; } interface RnoteBitmapImage { // v0.13: image is base64 string, v0.14: image is { data, pixel_width, pixel_height, ... } image: string | { data: string; pixel_width?: number; pixel_height?: number; memory_format?: string }; // v0.13: { mins, maxs }, v0.14: { cuboid: { half_extents }, transform: { affine } } rectangle: | { mins: [number, number]; maxs: [number, number] } | { cuboid: { half_extents: [number, number] }; transform: { affine: number[] } }; } interface RnoteSlotEntry { value: { brushstroke?: RnoteBrushStroke; textstroke?: RnoteTextStroke; bitmapimage?: RnoteBitmapImage; vectorimage?: Record; shapestroke?: Record; } | null; version: number; } interface RnoteFile { version: string; data: { engine_snapshot: { document: { x: number; y: number; width: number; height: number; }; stroke_components: RnoteSlotEntry[]; }; }; } // ── Raw RGBA → PNG encoding ───────────────────────────────────────────────── /** Encode raw RGBA pixels into a minimal PNG buffer using zlib. */ function encodeRawRgbaToPng(data: Buffer, width: number, height: number): Buffer { // Un-premultiply alpha (R8g8b8a8Premultiplied → straight RGBA) const straight = Buffer.from(data); for (let i = 0; i < straight.length; i += 4) { const a = straight[i + 3]; if (a > 0 && a < 255) { straight[i] = Math.min(255, Math.round((straight[i] * 255) / a)); straight[i + 1] = Math.min(255, Math.round((straight[i + 1] * 255) / a)); straight[i + 2] = Math.min(255, Math.round((straight[i + 2] * 255) / a)); } } // Build PNG filter rows: each row prefixed with filter byte 0 (None) const rowLen = width * 4; const rawRows = Buffer.alloc(height * (1 + rowLen)); for (let y = 0; y < height; y++) { rawRows[y * (1 + rowLen)] = 0; // filter: None straight.copy(rawRows, y * (1 + rowLen) + 1, y * rowLen, y * rowLen + rowLen); } const compressed = zlib.deflateSync(rawRows); // CRC-32 (used by PNG chunks) const crcTable: number[] = []; for (let n = 0; n < 256; n++) { let c = n; for (let k = 0; k < 8; k++) c = (c & 1) ? (0xedb88320 ^ (c >>> 1)) : (c >>> 1); crcTable[n] = c; } function crc32(buf: Buffer): number { let crc = 0xffffffff; for (let i = 0; i < buf.length; i++) crc = crcTable[(crc ^ buf[i]) & 0xff] ^ (crc >>> 8); return (crc ^ 0xffffffff) >>> 0; } function makeChunk(type: string, data: Buffer): Buffer { const len = Buffer.alloc(4); len.writeUInt32BE(data.length); const typeB = Buffer.from(type, "ascii"); const payload = Buffer.concat([typeB, data]); const crcB = Buffer.alloc(4); crcB.writeUInt32BE(crc32(payload)); return Buffer.concat([len, payload, crcB]); } // IHDR: width, height, bit depth 8, color type 6 (RGBA) const ihdrData = Buffer.alloc(13); ihdrData.writeUInt32BE(width, 0); ihdrData.writeUInt32BE(height, 4); ihdrData[8] = 8; // bit depth ihdrData[9] = 6; // color type: RGBA ihdrData[10] = 0; // compression ihdrData[11] = 0; // filter ihdrData[12] = 0; // interlace return Buffer.concat([ Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]), // PNG signature makeChunk("IHDR", ihdrData), makeChunk("IDAT", compressed), makeChunk("IEND", Buffer.alloc(0)), ]); } // ── Color conversion ──────────────────────────────────────────────────────── function convertColor(c: RnoteColor): string { const r = Math.round(c.r * 255); const g = Math.round(c.g * 255); const b = Math.round(c.b * 255); return `#${r.toString(16).padStart(2, "0")}${g.toString(16).padStart(2, "0")}${b.toString(16).padStart(2, "0")}`; } function convertOpacity(c: RnoteColor): number { return Math.round(c.a * 100); } // ── Excalidraw element helpers ────────────────────────────────────────────── function generateId(): string { const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"; let id = ""; for (let i = 0; i < 20; i++) { id += chars[Math.floor(Math.random() * chars.length)]; } return id; } function makeBaseElement(type: string, x: number, y: number, width: number, height: number) { return { id: generateId(), type, x, y, width, height, angle: 0, strokeColor: "#000000", backgroundColor: "transparent", fillStyle: "solid", strokeWidth: 1, strokeStyle: "solid", roughness: 0, opacity: 100, groupIds: [] as string[], frameId: null, index: null, roundness: null, seed: Math.floor(Math.random() * 2_000_000_000), version: 1, versionNonce: Math.floor(Math.random() * 2_000_000_000), isDeleted: false, boundElements: null, updated: Date.now(), link: null, locked: false, } as Record; } // ── Bezier flattening ─────────────────────────────────────────────────────── const BEZIER_STEPS = 8; function flattenQuadBezier( p0: [number, number], pr0: number, cp: [number, number], p1: [number, number], pr1: number, ): { pos: [number, number]; pressure: number }[] { const result: { pos: [number, number]; pressure: number }[] = []; for (let i = 1; i <= BEZIER_STEPS; i++) { const t = i / BEZIER_STEPS; const u = 1 - t; const x = u * u * p0[0] + 2 * u * t * cp[0] + t * t * p1[0]; const y = u * u * p0[1] + 2 * u * t * cp[1] + t * t * p1[1]; const p = u * pr0 + t * pr1; result.push({ pos: [x, y], pressure: p }); } return result; } function flattenCubicBezier( p0: [number, number], pr0: number, cp1: [number, number], cp2: [number, number], p1: [number, number], pr1: number, ): { pos: [number, number]; pressure: number }[] { const result: { pos: [number, number]; pressure: number }[] = []; for (let i = 1; i <= BEZIER_STEPS; i++) { const t = i / BEZIER_STEPS; const u = 1 - t; const x = u * u * u * p0[0] + 3 * u * u * t * cp1[0] + 3 * u * t * t * cp2[0] + t * t * t * p1[0]; const y = u * u * u * p0[1] + 3 * u * u * t * cp1[1] + 3 * u * t * t * cp2[1] + t * t * t * p1[1]; const p = u * pr0 + t * pr1; result.push({ pos: [x, y], pressure: p }); } return result; } // ── Converters ────────────────────────────────────────────────────────────── interface ExcalidrawFile { mimeType: string; id: string; dataURL: string; created: number; lastRetrieved: number; } function convertBrushStroke(stroke: RnoteBrushStroke) { const style = stroke.style.smooth; if (!style) return null; // rough/textured styles not yet supported // Flatten path into points + pressures const allPoints: { pos: [number, number]; pressure: number }[] = [ { pos: stroke.path.start.pos, pressure: stroke.path.start.pressure }, ]; let lastPos = stroke.path.start.pos; let lastPressure = stroke.path.start.pressure; for (const seg of stroke.path.segments) { if (seg.lineto) { const end = seg.lineto.end; allPoints.push({ pos: end.pos, pressure: end.pressure }); lastPos = end.pos; lastPressure = end.pressure; } else if (seg.quadbezto) { const end = seg.quadbezto.end; const flattened = flattenQuadBezier(lastPos, lastPressure, seg.quadbezto.cp, end.pos, end.pressure); allPoints.push(...flattened); lastPos = end.pos; lastPressure = end.pressure; } else if (seg.cubbezto) { const end = seg.cubbezto.end; const flattened = flattenCubicBezier(lastPos, lastPressure, seg.cubbezto.cp1, seg.cubbezto.cp2, end.pos, end.pressure); allPoints.push(...flattened); lastPos = end.pos; lastPressure = end.pressure; } } if (allPoints.length < 2) return null; const originX = allPoints[0].pos[0]; const originY = allPoints[0].pos[1]; const points: number[][] = []; const pressures: number[] = []; let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity; for (const pt of allPoints) { const px = pt.pos[0] - originX; const py = pt.pos[1] - originY; points.push([px, py]); pressures.push(pt.pressure); minX = Math.min(minX, px); minY = Math.min(minY, py); maxX = Math.max(maxX, px); maxY = Math.max(maxY, py); } const el = makeBaseElement("freedraw", originX, originY, maxX - minX, maxY - minY); el.points = points; el.pressures = pressures; el.simulatePressure = false; el.strokeColor = convertColor(style.stroke_color); el.strokeWidth = Math.max(1, Math.floor((style.stroke_width || 1) / 2)); el.opacity = convertOpacity(style.stroke_color); el.lastCommittedPoint = points[points.length - 1] || null; return el; } function convertTextStroke(text: RnoteTextStroke) { if (!text.text) return null; // Extract position from affine transform // v0.13: nested arrays [[1,0,tx],[0,1,ty],[0,0,1]] // v0.14: flat array [1,0,0, 0,1,0, tx,ty,1] const affine = text.transform?.affine; let x = 0, y = 0; if (Array.isArray(affine?.[0])) { // nested: row-major 3x3 const nested = affine as number[][]; x = nested[0]?.[2] ?? 0; y = nested[1]?.[2] ?? 0; } else if (affine) { // flat 9-element array (column-major: [m00,m10,m20, m01,m11,m21, tx,ty,1]) const flat = affine as number[]; x = flat[6] ?? 0; y = flat[7] ?? 0; } const ts = text.text_style ?? text.style; const size = ts?.font_size ?? 12; const color = ts?.color ?? { r: 0, g: 0, b: 0, a: 1 }; const lines = text.text.split("\n"); const estWidth = Math.max(...lines.map((l) => l.length)) * size * 0.6; const estHeight = lines.length * size * 1.3; const el = makeBaseElement("text", x, y, estWidth, estHeight); el.text = text.text; el.fontSize = size; el.fontFamily = 5; el.textAlign = "left"; el.verticalAlign = "top"; el.strokeColor = convertColor(color); el.opacity = convertOpacity(color); el.containerId = null; el.originalText = text.text; el.autoResize = true; el.lineHeight = 1.25; return el; } function convertBitmapImage(img: RnoteBitmapImage, files: Record) { if (!img.image) return null; // Extract base64 image data and encode as PNG if needed let dataURL: string; if (typeof img.image === "string") { // v0.13: already PNG base64 dataURL = `data:image/png;base64,${img.image}`; } else { // v0.14: raw RGBA pixels, need to encode as PNG const rawBuf = Buffer.from(img.image.data, "base64"); const pw = img.image.pixel_width ?? 0; const ph = img.image.pixel_height ?? 0; if (!pw || !ph) return null; const pngBuf = encodeRawRgbaToPng(rawBuf, pw, ph); dataURL = `data:image/png;base64,${pngBuf.toString("base64")}`; } // Extract bounds: v0.13 uses mins/maxs, v0.14 uses cuboid + transform let minX: number, minY: number, w: number, h: number; if ("mins" in img.rectangle) { [minX, minY] = img.rectangle.mins; const [maxX, maxY] = img.rectangle.maxs; w = maxX - minX; h = maxY - minY; } else { const [halfW, halfH] = img.rectangle.cuboid.half_extents; const affine = img.rectangle.transform.affine; // flat affine: tx=affine[6], ty=affine[7] const cx = affine[6] ?? 0; const cy = affine[7] ?? 0; minX = cx - halfW; minY = cy - halfH; w = halfW * 2; h = halfH * 2; } const fileId = generateId(); files[fileId] = { mimeType: "image/png", id: fileId, dataURL, created: Date.now(), lastRetrieved: Date.now(), }; const el = makeBaseElement("image", minX, minY, w, h); el.fileId = fileId; el.status = "saved"; el.scale = [1, 1]; return el; } // ── Main conversion ───────────────────────────────────────────────────────── function convertRnote(doc: RnoteFile) { const snapshot = doc.data?.engine_snapshot; if (!snapshot) throw new Error("Not a valid .rnote file: missing engine_snapshot"); const elements: Record[] = []; const files: Record = {}; for (const entry of snapshot.stroke_components) { if (!entry.value) continue; if (entry.value.brushstroke) { const el = convertBrushStroke(entry.value.brushstroke); if (el) elements.push(el); } else if (entry.value.textstroke) { const el = convertTextStroke(entry.value.textstroke); if (el) elements.push(el); } else if (entry.value.bitmapimage) { const el = convertBitmapImage(entry.value.bitmapimage, files); if (el) elements.push(el); } } return { type: "excalidraw", version: 2, elements, files, appState: { gridModeEnabled: false, viewBackgroundColor: "#ffffff", }, }; } // ── Entry point ───────────────────────────────────────────────────────────── const scriptNote = api.currentNote; const childNotes = scriptNote.getChildNotes(); for (const note of childNotes) { if (note.type !== "file" || !note.title?.endsWith(".rnote")) { continue; } try { const content = note.getContent(); const jsonBuffer = zlib.gunzipSync(content); const doc: RnoteFile = JSON.parse(jsonBuffer.toString("utf-8")); const excalidrawData = convertRnote(doc); const title = note.title.replace(/\.rnote$/, ""); const { note: canvasNote } = api.createNewNote({ parentNoteId: scriptNote.noteId, title, content: JSON.stringify(excalidrawData), type: "canvas", }); api.log(`auto-import-rnote: converted "${note.title}" → canvas note ${canvasNote.noteId}`); } catch (err) { const msg = err instanceof Error ? err.message : String(err); api.log(`auto-import-rnote: failed for "${note.title}": ${msg}`); } }