Skip to content

Add SupernoteAtelier parser for Atelier .spd files - #33

Merged
philips merged 2 commits into
mainfrom
worktree-spd-parser
Jul 28, 2026
Merged

Add SupernoteAtelier parser for Atelier .spd files#33
philips merged 2 commits into
mainfrom
worktree-spd-parser

Conversation

@philips-clanker

@philips-clanker philips-clanker commented Jul 27, 2026

Copy link
Copy Markdown
Collaborator

Summary

  • Adds SupernoteAtelier, a parser for .spd files produced by the Supernote Atelier app.
  • Unlike .note files (a custom binary layout, parsed by SupernoteX), .spd files are plain SQLite databases (tiled canvas images + a small config table). Reads them via sql.js and exposes:
    • config — every config row as raw bytes
    • fmtVer, thumbnailBuffer, viewport (vp.x/vp.y/vp.scale), canvasSize (surface.width/surface.height), layers — decoded from config
    • surfaces — the per-tile PNG blobs from every surface_* table in the file, keyed by table name (discovered dynamically)
    • toImage(surfaceName) — stitches one surface's tiles into a single image, sized/positioned against the tile-grid bounds of every surface in the file (not just the requested one), so different layers line up and can be composited
    • toCompositeImage() — flattens every surface into one final image directly, layered bottom-to-top by layers order (best-effort). The simple one-call API for "just give me a finished picture."
  • The .spd schema (especially the ls/layers config encoding) isn't officially documented. The initial implementation was reverse-engineered from https://github.com/Ziv-Ink/Atelier-parser, a community tool that writes .spd files — then checked against a real device-generated .spd file, which surfaced real bugs the community tool didn't expose:
    • Surfaces aren't limited to surface_1/surface_2. Real files can have arbitrary surface_{layerId} tables (e.g. a "Reference Layer" used surface_9999), so surface tables are discovered from sqlite_master instead of assumed.
    • Different layers can have sparse, differently-offset tile coverage, so per-surface tight-crop stitching produced images with different origins that couldn't be composited correctly. toImage now sizes/positions against the shared tile grid across all surfaces.
    • Building toCompositeImage surfaced two more, both invisible when looking at a single surface's image in isolation and only apparent once actually layering surfaces together: image-js defaults a new image's buffer to opaque black, not transparent, so unpasted grid cells were being treated as real content and wiped out whatever was underneath; and the reused compositeImages treats a pixel as transparent only when its packed RGBA is exactly zero, which a transparent-but-non-black pixel (e.g. transparent white) fails, so tiles are now normalized to zero RGB wherever alpha is zero before compositing.
    • This also confirmed the tile-id addressing scheme (tid = col * 4096 + row + offset) generalizes correctly to a real, non-default canvas size.
  • Test fixtures:
    • tests/input/sample.spd — synthetic, shaped to mirror the real file (sparse per-layer tiles, a surface_9999 reference layer, an empty surface_3, surface.width/surface.height config).
    • tests/input/real-device.spd — a real Atelier export from a Supernote device (a built-in "Audubon" template background with a couple of sketch strokes on two layers), used to verify the parser against actual hardware output.

Closes #31.

Test plan

  • npm run build (tsc) succeeds
  • npm run lint passes
  • npm test — full suite passes (33/33), including tests/atelier.test.ts against both fixtures
  • Visually inspected stitched/composited PNGs from both fixtures to confirm correct tile alignment and layer stacking
  • Pixel-level assertions that compositing leaves untouched areas identical to the background and does change pixels under foreground layer tiles

@philips-clanker
philips-clanker marked this pull request as ready for review July 28, 2026 00:17
@philips-clanker
philips-clanker marked this pull request as draft July 28, 2026 00:17
@philips
philips marked this pull request as ready for review July 28, 2026 17:17
.spd files (from the Supernote Atelier app) are a different format
from .note files: a SQLite database of tiled canvas images plus a
small config table, rather than the custom binary layout SupernoteX
parses. SupernoteAtelier.open() reads it via sql.js and exposes:

- config: every config row as raw bytes
- fmtVer, thumbnailBuffer, viewport, canvasSize, layers: decoded from
  config (fmtVer/viewport/canvasSize are straightforward; layers is a
  best-effort protobuf decode of the undocumented `ls` value)
- surfaces: per-tile PNG blobs for every surface_* table in the file,
  discovered dynamically rather than assumed to be surface_1/surface_2
- toImage(surfaceName): stitches one surface's tiles into an image,
  sized/positioned against the tile-grid bounds of every surface in
  the file so different layers line up and can be composited
- toCompositeImage(): flattens every surface into one final image,
  layered bottom-to-top by layers order (best-effort) -- the simple
  one-call API for "just give me a finished picture"

The .spd schema (especially the `ls` layer list encoding) isn't
officially documented. The implementation started from reverse-
engineering https://github.com/Ziv-Ink/Atelier-parser, a community
tool that writes .spd files, then was corrected against a real
device-generated .spd file, which exposed several things that tool's
approximation of the format didn't:

- Surfaces aren't limited to surface_1/surface_2; real files can have
  arbitrary surface_{layerId} tables (e.g. a "Reference Layer" used
  surface_9999).
- Layers can have sparse, differently-offset tile coverage, so a
  naive per-surface tight crop produced images with different
  origins that couldn't be composited correctly.
- image-js defaults a new image's buffer to opaque black, not
  transparent, so unpasted grid cells were treated as real content
  and wiped out whatever was layered underneath.
- The shared compositeImages() helper (now exported from
  conversion.ts for reuse) treats a pixel as transparent only when
  its packed RGBA is exactly zero, which a transparent-but-non-black
  pixel (e.g. transparent white) fails; tiles are now normalized to
  zero RGB wherever alpha is zero before compositing.
- The tile-id addressing scheme (tid = col * 4096 + row + offset)
  was confirmed to generalize correctly to a real, non-default
  canvas size.

Test fixtures: tests/input/sample.spd is synthetic, shaped to mirror
the real file (sparse per-layer tiles, a surface_9999 reference
layer, an empty surface_3, surface.width/surface.height config).
tests/input/real-device.spd is a real Atelier export from a Supernote
device, used to verify the parser against actual hardware output
rather than only a community tool's approximation of the format.

Closes #31.
SupernoteAtelier.open's sqlJsConfig passthrough already supports this
(it's forwarded to sql.js's initSqlJs unchanged), but it wasn't
documented. Adds the wasmBinary recipe, validated by the Supernote
Obsidian Plugin's .spd prototype, which uses it to avoid sql.js's
default locateFile() fetch/readFileSync -- the one thing that
otherwise differs between desktop and mobile Obsidian.
@philips
philips merged commit 37f2b8f into main Jul 28, 2026
1 check passed
pull Bot pushed a commit to ben-vargas/supernote-obsidian-plugin that referenced this pull request Jul 28, 2026
Adds a minimal read-only viewer for .spd files (Supernote Atelier app),
built on supernote-typescript's SupernoteAtelier parser
(philips/supernote-typescript#33). Registers the 'spd' extension to a new
SupernoteAtelierView that flattens every layer with toCompositeImage() and
shows the result as a single image — no page concept, so none of
SupernoteView's zoom/find/thumbnail toolbar or embed support yet.

sql.js's wasm binary is embedded into the bundle via esbuild's binary
loader (esbuild.config.mjs) rather than fetched/read at runtime, so it
works the same on desktop and mobile without a locateFile() callback.

Points the supernote-typescript submodule at a local merge of PR philips#33
(worktree-spd-parser) onto main, pushed to spd-prototype-merge-main so the
pinned commit resolves for anyone cloning this branch.

Closes philips#136.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

support atlier .spd files

2 participants