diff --git a/README.md b/README.md index 507745e..92f800e 100644 --- a/README.md +++ b/README.md @@ -53,6 +53,45 @@ 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. +### Reading Atelier `.spd` files + +`.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](https://github.com/sql-js/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](./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()` flattens every surface into one final image directly, layered bottom-to-top by `layers` order (best-effort, see `toImage`'s note about `ls`) — the simplest way to get one finished picture out of a `.spd` file without handling individual layers yourself. + +```ts +import { SupernoteAtelier } from 'supernote-typescript'; + +const note = await SupernoteAtelier.open(buffer); + +// One surface (layer) at a time: +const image = await note.toImage('surface_1'); + +// Or every surface flattened into one final image: +const flattened = await note.toCompositeImage(); +``` + +#### Bundling for the browser or mobile + +`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: + +```ts +// 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](https://github.com/philips/supernote-obsidian-plugin/pull/137), which runs unmodified on both desktop and mobile. + ## Developer Notes ### Test Individual Suite diff --git a/package-lock.json b/package-lock.json index aae2bfd..4eb20b1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,12 +13,14 @@ "color": "^5.0.3", "fs-extra": "^11.4.0", "image-js": "^1.7.0", - "pdf-lib": "^1.17.1" + "pdf-lib": "^1.17.1", + "sql.js": "^1.14.1" }, "devDependencies": { "@eslint/js": "10.0.1", "@types/fs-extra": "11.0.4", "@types/node": "26.1.1", + "@types/sql.js": "^1.4.11", "@typescript-eslint/eslint-plugin": "8.65.0", "@typescript-eslint/parser": "8.65.0", "@vitest/coverage-v8": "4.1.10", @@ -884,6 +886,13 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/emscripten": { + "version": "1.41.5", + "resolved": "https://registry.npmjs.org/@types/emscripten/-/emscripten-1.41.5.tgz", + "integrity": "sha512-cMQm7pxu6BxtHyqJ7mQZ2kXWV5SLmugybFdHCBbJ5eHzOo6VhBckEgAT3//rP5FwPHNPeEiq4SmQ5ucBwsOo4Q==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/esrecurse": { "version": "4.3.1", "resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz", @@ -936,6 +945,17 @@ "undici-types": "~8.3.0" } }, + "node_modules/@types/sql.js": { + "version": "1.4.11", + "resolved": "https://registry.npmjs.org/@types/sql.js/-/sql.js-1.4.11.tgz", + "integrity": "sha512-QXIx38p2ZThJaK9vP5ZdqdlRe1FG9I8SmCZOS7FHfB/2qPAjZwkL7/vlfPg6N/oWHuuOaGg/P/IRwfP2W0kWVQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/emscripten": "*", + "@types/node": "*" + } + }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "8.65.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.65.0.tgz", @@ -3177,6 +3197,12 @@ "node": ">=0.10.0" } }, + "node_modules/sql.js": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/sql.js/-/sql.js-1.14.1.tgz", + "integrity": "sha512-gcj8zBWU5cFsi9WUP+4bFNXAyF1iRpA3LLyS/DP5xlrNzGmPIizUeBggKa8DbDwdqaKwUcTEnChtd2grWo/x/A==", + "license": "MIT" + }, "node_modules/ssim.js": { "version": "3.5.0", "resolved": "https://registry.npmjs.org/ssim.js/-/ssim.js-3.5.0.tgz", diff --git a/package.json b/package.json index ec1e283..29ee448 100644 --- a/package.json +++ b/package.json @@ -40,6 +40,7 @@ "@eslint/js": "10.0.1", "@types/fs-extra": "11.0.4", "@types/node": "26.1.1", + "@types/sql.js": "^1.4.11", "@typescript-eslint/eslint-plugin": "8.65.0", "@typescript-eslint/parser": "8.65.0", "@vitest/coverage-v8": "4.1.10", @@ -55,6 +56,7 @@ "color": "^5.0.3", "fs-extra": "^11.4.0", "image-js": "^1.7.0", - "pdf-lib": "^1.17.1" + "pdf-lib": "^1.17.1", + "sql.js": "^1.14.1" } } diff --git a/src/atelier.ts b/src/atelier.ts new file mode 100644 index 0000000..e08db4c --- /dev/null +++ b/src/atelier.ts @@ -0,0 +1,404 @@ +import initSqlJs, { Database, SqlJsConfig, SqlValue } from 'sql.js'; +import { Image, ImageColorModel, decodePng } from 'image-js'; +import { compositeImages } from './conversion.js'; + +/** + * Support for `.spd` files created by the Supernote Atelier app. + * + * Unlike `.note` files (see `SupernoteX`), a `.spd` file is a plain SQLite + * database. Its schema and the meaning of several `config` entries are not + * publicly documented; the layout used here was reverse-engineered from + * https://github.com/Ziv-Ink/Atelier-parser, a community tool that writes + * `.spd` files, and cross-checked against a real device-generated `.spd` + * file (which is how the dynamic `surface_*` naming and `surface.width`/ + * `surface.height` config entries below were found; that tool only ever + * writes `surface_1`/`surface_2` and doesn't set those two keys at all). The + * `ls` layer list is still a best-effort decode: its format is unconfirmed. + */ + +/** A single Atelier canvas tile, as stored in a `surface_*` table. */ +export interface IAtelierTile { + /** Tile id. Encodes the tile's grid position (see `toImage`). */ + tid: number; + /** PNG-encoded tile image content. */ + bitmapBuffer: Uint8Array; +} + +/** Viewport position/zoom the editor had open, decoded from `vp.x`/`vp.y`/`vp.scale`. */ +export interface IAtelierViewport { + x: number; + y: number; + scale: number; +} + +/** Canvas pixel size, decoded from `surface.width`/`surface.height`. Tiles are + * only stored for grid cells that have been drawn on, so this can be larger + * than the bounding box of any one surface's own tiles (see `toImage`). */ +export interface IAtelierCanvasSize { + width: number; + height: number; +} + +/** A layer entry decoded from the `ls` config value. + * Best-effort: `ls`'s format isn't documented, see the module doc comment. + * `id` matches the numeric suffix of the corresponding `surface_{id}` table, + * e.g. `{ id: 9999, name: 'Reference Layer' }` pairs with `surface_9999`. */ +export interface IAtelierLayer { + id: number; + name: string; +} + +/** Name of a tile table found in a `.spd` file, e.g. `surface_1`. Real files + * aren't limited to `surface_1`/`surface_2`: layers can use arbitrary + * `surface_{layerId}` names (a "Reference Layer" observed in a real file used + * `surface_9999`), so this is whatever `surface_*` tables the file has. */ +export type IAtelierSurfaceName = string; + +const SURFACE_TABLE_PATTERN = /^surface_\d+$/; + +/** Tiles address their grid column in the upper bits and row in the lower + * bits of `tid`, i.e. `tid = col * TILE_ID_STRIDE + row + offset`, where + * `offset` depends on the tile's absolute position on Atelier's (much + * larger) virtual canvas. Reverse-engineered from the fixed `tids` table in + * https://github.com/Ziv-Ink/Atelier-parser/blob/main/atelierparser.py and + * confirmed against a real device-generated `.spd` file (row/col spans + * derived this way matched that file's `surface.width`/`surface.height`). + * Deriving row/col this way (rather than assuming a canvas size) means it + * keeps working regardless of where on the virtual canvas a document's + * tiles happen to sit. */ +const TILE_ID_STRIDE = 4096; + +/** Parsed Supernote Atelier `.spd` file. */ +export class SupernoteAtelier { + declare fmtVer?: number; + declare thumbnailBuffer: Uint8Array | null; + declare viewport?: IAtelierViewport; + /** Canvas pixel size, decoded from `surface.width`/`surface.height`. */ + declare canvasSize?: IAtelierCanvasSize; + declare layers?: IAtelierLayer[]; + /** Every `config` entry as raw bytes, keyed by name, for values not + * otherwise exposed (e.g. `frames`) or when the best-effort decodes above + * come back empty. */ + declare config: Record; + /** Tiles per surface, keyed by surface name (e.g. `surface_1`). */ + declare surfaces: Record; + /** Tile grid bounds shared across every surface in the file, so that + * images from different surfaces line up when composited (see `toImage`). + * `null` if the file has no tiles at all. */ + private declare _gridBounds: IGridBounds | null; + + private constructor() {} + + /** + * Parse a `.spd` file's contents. + * @param buffer Raw file contents. + * @param sqlJsConfig Passed through to `sql.js`'s `initSqlJs`, e.g. to + * supply `locateFile` when bundling for the browser. + */ + static async open(buffer: Uint8Array, sqlJsConfig?: SqlJsConfig): Promise { + const SQL = await initSqlJs(sqlJsConfig); + const db = new SQL.Database(buffer); + try { + const note = new SupernoteAtelier(); + note.config = note._parseConfig(db); + note.fmtVer = note._parseFmtVer(note.config); + note.thumbnailBuffer = note._parseThumbnail(note.config); + note.viewport = note._parseViewport(note.config); + note.canvasSize = note._parseCanvasSize(note.config); + note.layers = note._parseLayers(note.config); + note.surfaces = note._parseSurfaces(db); + note._gridBounds = computeGridBounds(Object.values(note.surfaces).flat()); + return note; + } finally { + db.close(); + } + } + + /** Read every row of the `config` table into a name -> bytes map. */ + private _parseConfig(db: Database): Record { + const config: Record = {}; + const results = db.exec('SELECT name, value FROM config'); + for (const row of results[0]?.values ?? []) { + const [name, value] = row as [string, SqlValue]; + config[name] = toBytes(value); + } + return config; + } + + private _parseFmtVer(config: Record): number | undefined { + if (!('fmt_ver' in config)) return undefined; + const parsed = parseInt(decodeUtf8(config.fmt_ver), 10); + return Number.isNaN(parsed) ? undefined : parsed; + } + + private _parseThumbnail(config: Record): Uint8Array | null { + const thumbnail = config.thumbnail; + return thumbnail && thumbnail.length > 0 ? thumbnail : null; + } + + private _parseViewport(config: Record): IAtelierViewport | undefined { + if (!('vp.x' in config) || !('vp.y' in config) || !('vp.scale' in config)) return undefined; + const x = parseFloat(decodeUtf8(config['vp.x'])); + const y = parseFloat(decodeUtf8(config['vp.y'])); + const scale = parseFloat(decodeUtf8(config['vp.scale'])); + if ([x, y, scale].some(Number.isNaN)) return undefined; + return { x, y, scale }; + } + + private _parseLayers(config: Record): IAtelierLayer[] | undefined { + if (!('ls' in config)) return undefined; + return decodeAtelierLayers(config.ls); + } + + private _parseCanvasSize(config: Record): IAtelierCanvasSize | undefined { + if (!('surface.width' in config) || !('surface.height' in config)) return undefined; + const width = parseFloat(decodeUtf8(config['surface.width'])); + const height = parseFloat(decodeUtf8(config['surface.height'])); + if ([width, height].some(Number.isNaN)) return undefined; + return { width, height }; + } + + /** Read every row of every `surface_{n}` tile table found in the file. + * Real files aren't limited to `surface_1`/`surface_2` (e.g. an imported + * background "Reference Layer" was observed in a real file as + * `surface_9999`, its `ls` layer id), so the tables to read are + * discovered from `sqlite_master` rather than assumed. */ + private _parseSurfaces(db: Database): Record { + const tableNames = (db.exec("SELECT name FROM sqlite_master WHERE type='table'")[0]?.values ?? []) + .map((row) => row[0] as string) + .filter((name) => SURFACE_TABLE_PATTERN.test(name)); + + const surfaces: Record = {}; + for (const name of tableNames) { + const results = db.exec(`SELECT tid, tile FROM ${name}`); + surfaces[name] = (results[0]?.values ?? []).map((row) => { + const [tid, tile] = row as [number, SqlValue]; + return { tid, bitmapBuffer: toBytes(tile) }; + }); + } + return surfaces; + } + + /** + * Stitch a surface's tiles into a single composite image, positioned by + * their tile ids (see `TILE_ID_STRIDE`). The output is sized and + * positioned against the tile grid bounds of *every* surface in the + * file (not just this one), so images from different surfaces of the + * same file line up and can be composited directly (e.g. drawing layers + * over an imported background that covers a larger area). Grid cells + * with no tile (nothing drawn there) are left transparent. Returns + * `null` if the surface doesn't exist or has no tiles anywhere in the file. + */ + async toImage(surfaceName: IAtelierSurfaceName = 'surface_1'): Promise { + const tiles = this.surfaces[surfaceName]; + if (!tiles || tiles.length === 0 || this._gridBounds === null) return null; + + const { minRow, minCol, maxRow, maxCol } = this._gridBounds; + + const decoded = await Promise.all( + tiles.map(async (tile) => ({ + row: tile.tid % TILE_ID_STRIDE, + col: Math.floor(tile.tid / TILE_ID_STRIDE), + image: normalizeTransparentPixels( + decodePng(tile.bitmapBuffer).convertBitDepth(8).convertColor(ImageColorModel.RGBA), + ), + })), + ); + + const tileWidth = decoded[0].image.width; + const tileHeight = decoded[0].image.height; + const output = new Image((maxCol - minCol + 1) * tileWidth, (maxRow - minRow + 1) * tileHeight, { + colorModel: ImageColorModel.RGBA, + }); + // image-js defaults a new image's alpha channel to fully opaque (and + // RGB to 0, i.e. opaque black), not transparent. Grid cells with no + // tile need to read as "nothing here" -- both for this method's own + // callers and for compositeImages() in toCompositeImage(), which + // would otherwise treat this opaque black filler as real content and + // paint over whatever it's layered onto. + output.getRawImage().data.fill(0); + + for (const { image, row, col } of decoded) { + pasteImage(output, image, (col - minCol) * tileWidth, (row - minRow) * tileHeight); + } + + return output; + } + + /** + * Stitch and flatten every surface in the file into one final image, in + * the same aligned coordinate space `toImage` uses. Surfaces are layered + * bottom-to-top using `layers` (from the `ls` config value) reversed: + * that list has been observed with the frontmost/topmost layer first + * (matching how most layer panels list layers), and painting back to + * front puts it visually on top. This ordering is a best-effort guess + * alongside the rest of `layers`, see the module doc comment; if `ls` + * didn't decode, surfaces are composited in an arbitrary order instead. + * Returns `null` if the file has no tiles at all. + */ + async toCompositeImage(): Promise { + const order = this._compositeOrder(); + const images: Image[] = []; + for (const surfaceName of order) { + const image = await this.toImage(surfaceName); + if (image !== null) images.push(image); + } + if (images.length === 0) return null; + + const output = images[0].clone(); + for (let i = 1; i < images.length; i++) { + compositeImages(images[i], output); + } + return output; + } + + /** Bottom-to-top surface names to composite in `toCompositeImage`. */ + private _compositeOrder(): IAtelierSurfaceName[] { + if (this.layers && this.layers.length > 0) { + return [...this.layers].reverse().map((layer) => `surface_${layer.id}`); + } + return Object.keys(this.surfaces); + } +} + +interface IGridBounds { + minRow: number; + minCol: number; + maxRow: number; + maxCol: number; +} + +/** Bounding box, in tile grid coordinates, of every tile across every + * surface passed in. `null` if `tiles` is empty. */ +function computeGridBounds(tiles: IAtelierTile[]): IGridBounds | null { + if (tiles.length === 0) return null; + const rows = tiles.map((t) => t.tid % TILE_ID_STRIDE); + const cols = tiles.map((t) => Math.floor(t.tid / TILE_ID_STRIDE)); + return { + minRow: Math.min(...rows), + minCol: Math.min(...cols), + maxRow: Math.max(...rows), + maxCol: Math.max(...cols), + }; +} + +/** Zeroes the RGB of every fully-transparent pixel in an 8-bit RGBA image, in + * place. `pasteImage` and `compositeImages` (from `conversion.ts`, reused by + * `toCompositeImage`) both treat a pixel as "nothing here" by checking + * whether its packed RGBA value is exactly zero -- which a transparent pixel + * only satisfies if its RGB happens to be zero too. PNG encoders are free to + * leave arbitrary RGB behind a zero alpha (a fully-transparent white pixel, + * e.g. `(255, 255, 255, 0)`, is packed as non-zero), so tiles need this + * normalization before compositing, rather than assuming every encoder + * zeroes RGB under a transparent alpha. */ +function normalizeTransparentPixels(image: Image): Image { + const data = image.getRawImage().data; + for (let i = 0; i < data.length; i += 4) { + if (data[i + 3] === 0) { + data[i] = 0; + data[i + 1] = 0; + data[i + 2] = 0; + } + } + return image; +} + +/** Copies `source`'s pixels into `destination` at pixel offset `(x, y)`. Both + * must be 8-bit RGBA images. */ +function pasteImage(destination: Image, source: Image, x: number, y: number) { + const dst = destination.getRawImage(); + const src = source.getRawImage(); + if (dst.bitDepth !== 8 || src.bitDepth !== 8 || dst.channels !== 4 || src.channels !== 4) { + throw new Error('pasteImage only supports 8-bit RGBA images.'); + } + const rowBytes = src.width * src.channels; + for (let row = 0; row < src.height; row++) { + const srcStart = row * rowBytes; + const dstStart = ((y + row) * dst.width + x) * dst.channels; + dst.data.set(src.data.subarray(srcStart, srcStart + rowBytes), dstStart); + } +} + +function toBytes(value: SqlValue): Uint8Array { + if (value === null) return new Uint8Array(); + if (value instanceof Uint8Array) return value; + if (typeof value === 'string') return new TextEncoder().encode(value); + return new TextEncoder().encode(String(value)); +} + +function decodeUtf8(bytes: Uint8Array): string { + return new TextDecoder('utf8').decode(bytes); +} + +interface IProtoField { + fieldNumber: number; + wireType: number; + value: number | Uint8Array; +} + +/** Reads a protobuf varint starting at `offset`, returning its value and the + * offset just past it. */ +function readVarint(data: Uint8Array, offset: number): [value: number, next: number] { + let result = 0; + let shift = 0; + let pos = offset; + while (true) { + if (pos >= data.length) throw new Error('Truncated varint.'); + const byte = data[pos++]; + result |= (byte & 0x7f) << shift; + if ((byte & 0x80) === 0) break; + shift += 7; + } + return [result >>> 0, pos]; +} + +/** Minimal protobuf wire-format walker: enough to read `ls`'s top-level + * varint and length-delimited fields. Only wire types 0 (varint) and 2 + * (length-delimited) are supported, which is all `ls` has been observed to + * use; anything else stops decoding early rather than misinterpreting bytes. */ +function readProtoFields(data: Uint8Array): IProtoField[] { + const fields: IProtoField[] = []; + let pos = 0; + while (pos < data.length) { + const [tag, afterTag] = readVarint(data, pos); + const fieldNumber = tag >>> 3; + const wireType = tag & 0x7; + if (wireType === 0) { + const [value, next] = readVarint(data, afterTag); + fields.push({ fieldNumber, wireType, value }); + pos = next; + } else if (wireType === 2) { + const [len, next] = readVarint(data, afterTag); + if (next + len > data.length) throw new Error('Truncated length-delimited field.'); + fields.push({ fieldNumber, wireType, value: data.subarray(next, next + len) }); + pos = next + len; + } else { + break; + } + } + return fields; +} + +/** Best-effort decode of the `ls` config value into a layer list: field 1 is + * a repeated submessage per layer, with the layer id in its field 1 (varint) + * and its name in field 2 (string). Returns `undefined` rather than throwing + * if the bytes don't match this shape, since `ls`'s format is unconfirmed. */ +function decodeAtelierLayers(data: Uint8Array): IAtelierLayer[] | undefined { + try { + const layers: IAtelierLayer[] = []; + for (const field of readProtoFields(data)) { + if (field.fieldNumber !== 1 || field.wireType !== 2) continue; + const sub = readProtoFields(field.value as Uint8Array); + const idField = sub.find((f) => f.fieldNumber === 1 && f.wireType === 0); + const nameField = sub.find((f) => f.fieldNumber === 2 && f.wireType === 2); + if (!nameField) continue; + layers.push({ + id: typeof idField?.value === 'number' ? idField.value : 0, + name: decodeUtf8(nameField.value as Uint8Array), + }); + } + return layers.length > 0 ? layers : undefined; + } catch { + return undefined; + } +} diff --git a/src/conversion.ts b/src/conversion.ts index 8c5c891..36790ba 100644 --- a/src/conversion.ts +++ b/src/conversion.ts @@ -51,7 +51,7 @@ function packRGBA(r: number, g: number, b: number, a: number): number { * pixel touched (two per pixel, for both reads and the write). For a * megapixel-scale page composited across several overlay layers, that adds * up to millions of short-lived array allocations; this does none. */ -function compositeImages(sourceImage: Image, destinationImage: Image) { +export function compositeImages(sourceImage: Image, destinationImage: Image) { if ( sourceImage.width !== destinationImage.width || sourceImage.height !== destinationImage.height diff --git a/src/index.ts b/src/index.ts index b71dc75..8ffbe5b 100644 --- a/src/index.ts +++ b/src/index.ts @@ -5,3 +5,11 @@ export type { ILink, IPage } from './format.js'; export { fetchMirrorFrame } from './mirror.js'; export { toPdf, createPdfContext, addPdfPage } from './pdf.js'; export type { ToPdfOptions, PdfContext, AddPdfPageOptions } from './pdf.js'; +export { SupernoteAtelier } from './atelier.js'; +export type { + IAtelierTile, + IAtelierViewport, + IAtelierCanvasSize, + IAtelierLayer, + IAtelierSurfaceName, +} from './atelier.js'; diff --git a/tests/atelier.test.ts b/tests/atelier.test.ts new file mode 100644 index 0000000..488bc38 --- /dev/null +++ b/tests/atelier.test.ts @@ -0,0 +1,191 @@ +import * as fs from "fs-extra" +import * as imagejs from "image-js" +import { describe, test, expect } from 'vitest' +import { SupernoteAtelier } from "../src/atelier" + +function readFileToUint8Array(filePath: string): Promise { + return new Promise((resolve, reject) => { + fs.readFile(`tests/input/${filePath}`, (err, data) => { + if (err) { + reject(err); + } else { + resolve(new Uint8Array(data.buffer)); + } + }); + }); +} + +describe("atelier", () => { + test("parses config and surfaces from a .spd file", async () => { + const note = await SupernoteAtelier.open(await readFileToUint8Array("sample.spd")); + expect(note.fmtVer).toEqual(2); + expect(note.viewport).toEqual({ x: 249984, y: 249984, scale: 1 }); + expect(note.canvasSize).toEqual({ width: 1536, height: 2048 }); + expect(note.layers).toEqual([ + { id: 3, name: "Layer 3" }, + { id: 2, name: "Layer 2" }, + { id: 1, name: "Layer 1" }, + { id: 9999, name: "Reference Layer" }, + ]); + expect(note.thumbnailBuffer).toBeNull(); + + // Real .spd files aren't limited to surface_1/surface_2; layers can use + // arbitrary surface_{layerId} names (e.g. surface_9999 for a "Reference + // Layer"), and a layer can exist with no tiles at all (surface_3 here). + expect(Object.keys(note.surfaces).sort()).toEqual(["surface_1", "surface_2", "surface_3", "surface_9999"]); + expect(note.surfaces.surface_1.length).toEqual(7 * 5); + expect(note.surfaces.surface_2.length).toEqual(8 * 6); + expect(note.surfaces.surface_3.length).toEqual(0); + expect(note.surfaces.surface_9999.length).toEqual(16 * 12); + for (const tile of note.surfaces.surface_1) { + expect(tile.bitmapBuffer.length).toBeGreaterThan(0); + } + }) + + test("stitches a surface's tiles into a composite image aligned to the file's shared tile grid", { timeout: 30000 }, async () => { + const note = await SupernoteAtelier.open(await readFileToUint8Array("sample.spd")); + + // surface_9999 covers every tile in the file, so it defines the shared + // grid bounds; every surface's image should come out that same size. + const background = await note.toImage("surface_9999"); + expect(background).not.toBeNull(); + expect(background!.width).toEqual(1536); + expect(background!.height).toEqual(2048); + await imagejs.writeSync(`tests/output/sample.spd-surface_9999.png`, background!); + + // surface_1/surface_2 only have sparse tiles (rows 2-8/cols 1-5 and rows + // 6-13/cols 5-10 respectively), but since toImage sizes against every + // surface's tiles, both come back the same full size as surface_9999 -- + // this is what makes them safe to composite directly on top of it. + const layer1 = await note.toImage("surface_1"); + expect(layer1!.width).toEqual(background!.width); + expect(layer1!.height).toEqual(background!.height); + await imagejs.writeSync(`tests/output/sample.spd-surface_1.png`, layer1!); + + const layer2 = await note.toImage("surface_2"); + expect(layer2!.width).toEqual(background!.width); + expect(layer2!.height).toEqual(background!.height); + await imagejs.writeSync(`tests/output/sample.spd-surface_2.png`, layer2!); + }) + + test("returns null for a surface with no tiles", async () => { + const note = await SupernoteAtelier.open(await readFileToUint8Array("sample.spd")); + expect(await note.toImage("surface_3")).toBeNull(); + }) + + test("returns null for a nonexistent surface", async () => { + const note = await SupernoteAtelier.open(await readFileToUint8Array("sample.spd")); + expect(await note.toImage("surface_42")).toBeNull(); + }) + + test("composites every surface into one flattened image", { timeout: 30000 }, async () => { + const note = await SupernoteAtelier.open(await readFileToUint8Array("sample.spd")); + const composite = await note.toCompositeImage(); + const background = await note.toImage("surface_9999"); + expect(composite).not.toBeNull(); + expect(composite!.width).toEqual(1536); + expect(composite!.height).toEqual(2048); + await imagejs.writeSync(`tests/output/sample.spd-composite.png`, composite!); + + // Outside every foreground layer's own tiles, the composite must match + // the background exactly -- catches compositing that blanks/overwrites + // areas it shouldn't (e.g. treating a transparent-but-non-black tile + // pixel, or an unpasted grid cell's non-transparent image-js default + // fill, as real content). + const untouchedPixel = { x: 0, y: 0 }; // outside surface_1/surface_2's tile ranges + expect(composite!.getPixel(untouchedPixel.x, untouchedPixel.y)).toEqual( + background!.getPixel(untouchedPixel.x, untouchedPixel.y), + ); + + // Inside surface_1's own tiles, the composite must differ from the bare + // background somewhere -- catches the opposite failure, e.g. compositing + // silently doing nothing. + let anyPixelDiffers = false; + for (let y = 2 * 128; y < 9 * 128 && !anyPixelDiffers; y++) { + for (let x = 1 * 128; x < 6 * 128; x++) { + if (!arraysEqual(composite!.getPixel(x, y), background!.getPixel(x, y))) { + anyPixelDiffers = true; + break; + } + } + } + expect(anyPixelDiffers).toBe(true); + }) +}) + +function arraysEqual(a: ArrayLike, b: ArrayLike): boolean { + if (a.length !== b.length) return false; + for (let i = 0; i < a.length; i++) { + if (a[i] !== b[i]) return false; + } + return true; +} + +describe("atelier real device file", () => { + test("parses config and surfaces from a real device-generated .spd file", async () => { + const note = await SupernoteAtelier.open(await readFileToUint8Array("real-device.spd")); + expect(note.fmtVer).toEqual(2); + expect(note.viewport).toEqual({ x: 249984, y: 249984, scale: 1 }); + expect(note.canvasSize).toEqual({ width: 1920, height: 2560 }); + expect(note.layers).toEqual([ + { id: 3, name: "Layer 3" }, + { id: 2, name: "Layer 2" }, + { id: 1, name: "Layer 1" }, + { id: 9999, name: "Reference Layer" }, + ]); + // thumbnail/templateData are stored with SQLite storage class TEXT despite + // holding binary (PNG) content, and get truncated at their first embedded + // NUL byte by whatever wrote them -- a real-device data quirk, not a bug + // in this parser. thumbnailBuffer is exposed as whatever bytes survive. + expect(note.thumbnailBuffer).not.toBeNull(); + expect(note.thumbnailBuffer!.length).toBeGreaterThan(0); + + const decoder = new TextDecoder("utf8"); + expect(decoder.decode(note.config.appVersion)).toEqual("1.1.82"); + expect(decoder.decode(note.config.template_name)).toEqual("/sdcard/myStyle/Supernote+-+Audubon+#029.jpeg"); + expect(decoder.decode(note.config.ppi)).toEqual("72"); + + // Real files aren't limited to surface_1/surface_2 (this one has a + // surface_9999 "Reference Layer" background), and a layer can exist with + // no tiles at all (surface_3 here). + expect(Object.keys(note.surfaces).sort()).toEqual(["surface_1", "surface_2", "surface_3", "surface_9999"]); + expect(note.surfaces.surface_1.length).toEqual(46); + expect(note.surfaces.surface_2.length).toEqual(31); + expect(note.surfaces.surface_3.length).toEqual(0); + expect(note.surfaces.surface_9999.length).toEqual(320); + }) + + test("stitches every surface of a real device-generated .spd file to the same aligned size", { timeout: 30000 }, async () => { + const note = await SupernoteAtelier.open(await readFileToUint8Array("real-device.spd")); + + // surface_1/surface_2 only have sparse tiles where the user actually + // drew, but toImage sizes against the shared tile grid across every + // surface in the file, so all three come back the same size and stay + // aligned with the surface_9999 background for direct compositing. + // (This ends up one tile wider than the nominal 1920px canvasSize -- + // the recorded tiles extend slightly past the configured canvas width.) + const background = await note.toImage("surface_9999"); + const layer1 = await note.toImage("surface_1"); + const layer2 = await note.toImage("surface_2"); + expect(background).not.toBeNull(); + expect(background!.width).toEqual(2048); + expect(background!.height).toEqual(2560); + expect([layer1!.width, layer1!.height]).toEqual([background!.width, background!.height]); + expect([layer2!.width, layer2!.height]).toEqual([background!.width, background!.height]); + + await imagejs.writeSync(`tests/output/real-device.spd-surface_9999.png`, background!); + await imagejs.writeSync(`tests/output/real-device.spd-surface_1.png`, layer1!); + await imagejs.writeSync(`tests/output/real-device.spd-surface_2.png`, layer2!); + + expect(await note.toImage("surface_3")).toBeNull(); + }) + + test("composites every surface of a real device-generated .spd file into one flattened image", { timeout: 30000 }, async () => { + const note = await SupernoteAtelier.open(await readFileToUint8Array("real-device.spd")); + const composite = await note.toCompositeImage(); + expect(composite).not.toBeNull(); + expect(composite!.width).toEqual(2048); + expect(composite!.height).toEqual(2560); + await imagejs.writeSync(`tests/output/real-device.spd-composite.png`, composite!); + }) +}) diff --git a/tests/input/README.md b/tests/input/README.md index 80a4521..1cbebc7 100644 --- a/tests/input/README.md +++ b/tests/input/README.md @@ -1,2 +1,4 @@ 'nomad-3.15.27-blank-2p.note' contains two pages of handwritten text, and demos the available writing tools. This was done using the blank background template, and contains a link to the first page on the second page. 'nomad-3.15.27-blank-shapes-and-RTR.note' conains a single page of shapes, patterns, and handwritten text. Headings and keyword highlighting included in file. This file was created using Real Time Recognition (allows export of handwritten notes as text within the nomad), with the blank background template. +'sample.spd' is a synthetic Atelier file (not exported from a real device). Its schema and layout (sparse per-layer tiles, a `surface_9999` "Reference Layer", a `surface_3` layer with no tiles, `surface.width`/`surface.height` config) were cross-checked against a real device-generated `.spd` file to keep it representative. +'real-device.spd' is a real Atelier file exported from a Supernote device: a built-in "Audubon" template background (`surface_9999`) with a couple of handwritten/sketch strokes on `Layer 1`/`Layer 2` (`surface_1`/`surface_2`) and an unused, tile-less `Layer 3` (`surface_3`). Used to confirm the schema, config encoding, and tile-id addressing against real output rather than only a community tool's approximation of the format. diff --git a/tests/input/real-device.spd b/tests/input/real-device.spd new file mode 100644 index 0000000..690272a Binary files /dev/null and b/tests/input/real-device.spd differ diff --git a/tests/input/sample.spd b/tests/input/sample.spd new file mode 100644 index 0000000..caa6cdb Binary files /dev/null and b/tests/input/sample.spd differ