Skip to content
Merged
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
7 changes: 5 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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
Expand Down
20 changes: 15 additions & 5 deletions src/atelier.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Image | null> {
const order = this._compositeOrder();
async toCompositeImage(visibleSurfaces?: Iterable<IAtelierSurfaceName>): Promise<Image | null> {
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);
Expand Down
34 changes: 34 additions & 0 deletions tests/atelier.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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));
})
})