Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 26 additions & 1 deletion src/atelier.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,21 @@ const SURFACE_TABLE_PATTERN = /^surface_\d+$/;
* tiles happen to sit. */
const TILE_ID_STRIDE = 4096;

/** Hard ceiling on a composited image's total pixel count. `toImage()` sizes
* its output against the tile-grid bounding box across *every* tile in the
* file (see its doc comment) with no other check - a single tile whose `tid`
* is far from the rest (a corrupted file, or a stray mark placed far off on
* Atelier's much larger virtual canvas, see `TILE_ID_STRIDE`'s doc comment)
* blows that bounding box out arbitrarily far, and `toImage()` would
* otherwise allocate accordingly. On a memory-constrained host (verified:
* this is what caused supernote-obsidian-plugin#147, a hard iOS crash on
* open, not a catchable one - WKWebView's per-process memory ceiling is far
* stricter than desktop's) that's an out-of-memory crash rather than a slow
* tab. Real device-generated files observed so far top out around
* 1920x2560 (~4.9 megapixels; see atelier.test.ts) - this leaves generous
* headroom above that while still keeping the worst case bounded. */
const MAX_COMPOSITE_PIXELS = 32_000_000;

/** Parsed Supernote Atelier `.spd` file. */
export class SupernoteAtelier {
declare fmtVer?: number;
Expand Down Expand Up @@ -207,7 +222,17 @@ export class SupernoteAtelier {

const tileWidth = decoded[0].image.width;
const tileHeight = decoded[0].image.height;
const output = new Image((maxCol - minCol + 1) * tileWidth, (maxRow - minRow + 1) * tileHeight, {
const width = (maxCol - minCol + 1) * tileWidth;
const height = (maxRow - minRow + 1) * tileHeight;
if (width * height > MAX_COMPOSITE_PIXELS) {
throw new Error(
`Refusing to composite "${surfaceName}": computed size ${width}x${height} ` +
`(${(width * height).toLocaleString()} px) exceeds the ${MAX_COMPOSITE_PIXELS.toLocaleString()}px ` +
'safety limit. This usually means one tile is positioned far from the rest ' +
'(a corrupted or out-of-range tile id) rather than a genuinely huge canvas.',
);
}
const output = new Image(width, height, {
colorModel: ImageColorModel.RGBA,
});
// image-js defaults a new image's alpha channel to fully opaque (and
Expand Down
58 changes: 58 additions & 0 deletions tests/atelier.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import * as fs from "fs-extra"
import * as imagejs from "image-js"
import initSqlJs from "sql.js"
import { describe, test, expect } from 'vitest'
import { SupernoteAtelier } from "../src/atelier"

Expand All @@ -15,6 +16,31 @@ function readFileToUint8Array(filePath: string): Promise<Uint8Array> {
});
}

// Minimal `.spd` SQLite database, built directly (not via a real device
// export) so a test can place a tile wherever it wants - including
// positions no real file would ever have, to exercise toImage()'s size
// guard. Only what `_parseConfig`/`_parseSurfaces` actually read: a `config`
// table (left empty here - canvasSize/layers/etc. are all optional) and one
// `tid`/`tile` table per surface named in `tiles`.
async function buildSpdBuffer(tiles: { surface: string; tid: number }[]): Promise<Uint8Array> {
const SQL = await initSqlJs();
const db = new SQL.Database();
db.run("CREATE TABLE config (name TEXT, value BLOB)");

const tilePng = imagejs.encodePng(new imagejs.Image(4, 4, { colorModel: imagejs.ImageColorModel.RGBA }));
const surfaceNames = new Set(tiles.map((t) => t.surface));
for (const surface of surfaceNames) {
db.run(`CREATE TABLE ${surface} (tid INTEGER, tile BLOB)`);
}
for (const { surface, tid } of tiles) {
db.run(`INSERT INTO ${surface} (tid, tile) VALUES (?, ?)`, [tid, tilePng]);
}

const buffer = db.export();
db.close();
return buffer;
}

describe("atelier", () => {
test("parses config and surfaces from a .spd file", async () => {
const note = await SupernoteAtelier.open(await readFileToUint8Array("sample.spd"));
Expand Down Expand Up @@ -222,4 +248,36 @@ describe("atelier real device file", () => {
expect(composite).not.toBeNull();
expect(composite!.getPixel(0, 0)).toEqual(background!.getPixel(0, 0));
})

// Regression test for supernote-obsidian-plugin#147: a tile positioned far
// from the rest (tid encodes grid column in its upper bits, see
// TILE_ID_STRIDE's doc comment in atelier.ts) used to make toImage() size
// its output against that outlier with no check at all, allocating
// gigabytes for what should be a tiny image - fine on desktop, a hard OOM
// crash on a memory-constrained mobile WebView.
test("toImage refuses to composite when a tile sits far outside the rest", async () => {
// tid = col * 4096 + row (TILE_ID_STRIDE). One ordinary tile at the
// origin, one 10 million columns away - a stand-in for a corrupted/
// out-of-range tid, since no real device drawing spans that far. With
// these 4x4 tiles that's a (10,000,001 * 4) x 4 output, comfortably over
// the 32-megapixel safety limit.
const buffer = await buildSpdBuffer([
{ surface: "surface_1", tid: 0 },
{ surface: "surface_1", tid: 10_000_000 * 4096 },
]);
const note = await SupernoteAtelier.open(buffer);
await expect(note.toImage("surface_1")).rejects.toThrow(/exceeds the .* safety limit/);
})

test("toImage composites normally when tiles stay within the safety limit", async () => {
const buffer = await buildSpdBuffer([
{ surface: "surface_1", tid: 0 },
{ surface: "surface_1", tid: 1 * 4096 + 1 }, // one tile over, one down
]);
const note = await SupernoteAtelier.open(buffer);
const image = await note.toImage("surface_1");
expect(image).not.toBeNull();
expect(image!.width).toEqual(4 * 2);
expect(image!.height).toEqual(4 * 2);
})
})