From 377109f9539d267a11f62554be7376a832c909a7 Mon Sep 17 00:00:00 2001 From: Brandon Philips's Clanker Date: Tue, 28 Jul 2026 13:58:06 -0700 Subject: [PATCH] Support filtering toCompositeImage() to a subset of layers Adds an optional visibleSurfaces parameter, closing #35: the Obsidian plugin's .spd viewer wants a per-layer visibility toggle (philips/supernote-obsidian-plugin#138), which needs compositing only a chosen subset of surfaces instead of always every surface in the file. undefined (the default) keeps existing behavior. When given, surfaces not in the set are dropped from _compositeOrder()'s result before compositing, so the bottom-to-top layers-based ordering is unchanged -- only which surfaces participate changes. Surface names not present in the file are silently ignored, same as toImage() already does for an unknown surface name. --- README.md | 7 +++++-- src/atelier.ts | 20 +++++++++++++++----- tests/atelier.test.ts | 34 ++++++++++++++++++++++++++++++++++ 3 files changed, 54 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 92f800e..c94a1df 100644 --- a/README.md +++ b/README.md @@ -58,7 +58,7 @@ Note that only page rendering (`toImage`/`encodePng`) is parallelizable this way `.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. +- `toCompositeImage(visibleSurfaces?)` flattens surfaces 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. Defaults to every surface in the file; pass a subset of surface names (e.g. from a layer visibility toggle) to flatten only those. ```ts import { SupernoteAtelier } from 'supernote-typescript'; @@ -68,8 +68,11 @@ 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: +// 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']); ``` #### Bundling for the browser or mobile diff --git a/src/atelier.ts b/src/atelier.ts index e08db4c..8ce1acc 100644 --- a/src/atelier.ts +++ b/src/atelier.ts @@ -226,18 +226,28 @@ export class SupernoteAtelier { } /** - * Stitch and flatten every surface in the file into one final image, in - * the same aligned coordinate space `toImage` uses. Surfaces are layered + * Stitch and flatten a set of surfaces 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. + * @param visibleSurfaces Surface names to include (e.g. from a + * layer-visibility toggle), in any order -- composite order is still + * decided by `layers`/`ls`, not by the order given here. Defaults to + * every surface in the file. Names not present in the file are ignored. + * Returns `null` if nothing ends up included (no tiles at all, or an + * empty/all-excluded `visibleSurfaces`). */ - async toCompositeImage(): Promise { - const order = this._compositeOrder(); + async toCompositeImage(visibleSurfaces?: Iterable): Promise { + let order = this._compositeOrder(); + if (visibleSurfaces !== undefined) { + const visible = new Set(visibleSurfaces); + order = order.filter((surfaceName) => visible.has(surfaceName)); + } + const images: Image[] = []; for (const surfaceName of order) { const image = await this.toImage(surfaceName); diff --git a/tests/atelier.test.ts b/tests/atelier.test.ts index 488bc38..4ff6a4c 100644 --- a/tests/atelier.test.ts +++ b/tests/atelier.test.ts @@ -188,4 +188,38 @@ describe("atelier real device file", () => { expect(composite!.height).toEqual(2560); await imagejs.writeSync(`tests/output/real-device.spd-composite.png`, composite!); }) + + test("toCompositeImage can composite a subset of surfaces, e.g. hiding the reference layer", { timeout: 30000 }, async () => { + const note = await SupernoteAtelier.open(await readFileToUint8Array("real-device.spd")); + const full = await note.toCompositeImage(); + const withoutBackground = await note.toCompositeImage(["surface_1", "surface_2"]); + expect(withoutBackground).not.toBeNull(); + // Still sized/aligned against every surface in the file, not just the + // ones included -- same coordinate space as toImage()/the full composite. + expect(withoutBackground!.width).toEqual(full!.width); + expect(withoutBackground!.height).toEqual(full!.height); + await imagejs.writeSync(`tests/output/real-device.spd-composite-no-background.png`, withoutBackground!); + + // (70, 0) is outside surface_1/surface_2's own tiles but inside + // surface_9999's (the "Reference Layer" background) -- so excluding + // surface_9999 should leave it transparent, unlike the full composite + // where the background shows through (opaque white paper there). + expect(withoutBackground!.getPixel(70, 0)).toEqual([0, 0, 0, 0]); + expect(full!.getPixel(70, 0)).toEqual([255, 255, 255, 255]); + }) + + test("toCompositeImage returns null when the requested subset has no content", async () => { + const note = await SupernoteAtelier.open(await readFileToUint8Array("real-device.spd")); + expect(await note.toCompositeImage([])).toBeNull(); + // surface_3 exists (it's a real layer) but has no tiles of its own. + expect(await note.toCompositeImage(["surface_3"])).toBeNull(); + }) + + test("toCompositeImage ignores requested surface names the file doesn't have", { timeout: 30000 }, async () => { + const note = await SupernoteAtelier.open(await readFileToUint8Array("real-device.spd")); + const background = await note.toImage("surface_9999"); + const composite = await note.toCompositeImage(["surface_9999", "surface_no_such_layer"]); + expect(composite).not.toBeNull(); + expect(composite!.getPixel(0, 0)).toEqual(background!.getPixel(0, 0)); + }) })