Skip to content

nrontsis/boox-note-optimizer

Repository files navigation

Boox Note Optimizer

Try the Web App Live Here!

An open-source toolkit for working with Boox .note files outside the Boox app. Preview, export, optimize, and convert your handwritten notes — all in the browser, fully private.

Features

  • Optimize .note files — shrink 5–10x with zero visual quality loss
  • Import SVGs — convert SVG drawings into .note format for your Boox device
  • Preview & compare — side-by-side visual comparison of original vs optimized
  • Export — download optimized .note, or export as PDF or SVG
  • E-ink color preview — simulate how colors look on a Kaleido 3 display
  • Edit online — open your drawing in an SVG editor, modify it, and re-import
  • Fully private — everything runs in your browser, no data uploaded
  • Works offline — installable as a PWA

Android APK

A pre-built APK is available in GitHub Releases. Install it on your Boox device for the best experience:

  • Open in Notes: After optimization, tap "Open" to launch the result directly in the Boox Notes app
  • Receive shared files: Share a .note file from the Notes app to Note Optimizer
  • Offline: Works offline after first load

Exported files are saved to Downloads/Note Optimizer/ and automatically cleaned up on each export.

To build from source (Docker, no local Android SDK needed):

cd android
./build.sh              # generates keystore on first run, outputs NoteOptimizer.apk

Development

wasm-pack build --target web --out-dir web/pkg
uv run python -m http.server 8080 --directory web

Important

Bump the CACHE_NAME version in web/sw.js when deploying changes to force clients to re-fetch.

.note File Format Documentation

A .note file is a ZIP archive produced by Boox/Onyx e-ink tablets. It stores handwritten strokes as sequences of pressure/tilt-sensitive points, plus per-stroke metadata (pen type, color, thickness, transform) in protobuf. The rendering model is pen-type-dependent: some pens produce constant-width line segments, others produce pressure-modulated variable-width strokes, filled polygons, raster textures, or scanline fills.

This document describes the file format and rendering rules inferred from examining .note files and comparing against device-exported PDFs.

Boox .note File Format

Inferred from multiple test files and cross-referenced against device-exported PDFs. The following related repos were particularly helpful:

Warning

Details might vary across firmware versions — this repo was only tested with Note Air 5c files generated with latest firmware as of 2026/02/18.

ZIP Structure

A .note file is a ZIP archive. It can contain either a single note or multiple notes.

Single-note archive — the most common format from device export. Contains note/pb/note_info with the note's metadata. All paths are rooted at <noteId>/:

<noteId>/
├── note/pb/note_info                                              # protobuf: note metadata
├── template/json/<pageUUID>.template_json                         # JSON: page template config
├── virtual/doc/pb/<noteId>                                        # protobuf: document model
├── virtual/page/pb/<pageUUID>                                     # protobuf: virtual page model
├── document/<noteId>/template/json/<pageUUID>.template_json       # duplicate template config
├── pageModel/pb/<pageModelUUID>                                   # protobuf: page model entry
├── resource/pb/<resourceUUID>#<timestamp>                         # binary: embedded resources
├── point/<pageUUID>/<pageUUID>#<pointsDocUUID>#points             # BINARY: stroke point data
├── shape/<pageUUID>#<shapeDocUUID>#<timestamp>.zip                # nested ZIP → protobuf: stroke metadata
├── extra/pb/extra                                                 # protobuf: extra metadata
└── stash/                                                         # undo history (safe to drop)
    ├── shape/                                                     # current undo buffer
    └── archivedShape/<timestamp>/<pageUUID>#<shapeUUID>#<ts>.zip  # archived undo entries

Multi-note archive — contains a note_tree file in the root with protobuf metadata for all notes. Each note's files are nested under <noteId>/ with the same structure as above.

Key UUID cross-references:

  • pageUUID — appears in point path, shape path, template filenames
  • pointsDocUUID — appears in point path and shape protobuf field 16
  • shapeDocUUID — appears in shape path and shape protobuf field 18
  • noteId — root folder name, also in virtual/doc and document paths
  • shapeUUID — per-stroke ID, appears in both #points index and shape protobuf field 1

#points Binary Format

Main stroke data blob. All multi-byte integers are big-endian.

┌─────────────────────── HEADER (76 bytes) ───────────────────────┐
│ u32       : version or page count (observed: 1)                 │
│ 36B ASCII : pageUUID (may be hyphenated or condensed+space-padded) │
│ 36B ASCII : pointsDocUUID (always hyphenated)                   │
├─────────────────────── STROKE DATA (contiguous) ────────────────┤
│ For each stroke:                                                │
│   4B       : zero padding (always 0x00000000)                   │
│   N × 16B  : points (see Point Format below)                   │
├─────────────────────── INDEX ───────────────────────────────────┤
│ For each stroke (44 bytes):                                     │
│   36B ASCII : shapeUUID (matches shape protobuf field 1)        │
│   u32       : offset (absolute from start of blob, incl header) │
│   u32       : size (4B pad + N × 16B points)                    │
│                                                                 │
│ u32  : index_start_offset (absolute from start of blob)         │
└─────────────────────────────────────────────────────────────────┘

The last 4 bytes of the entire blob always point to where the index begins.

Parsing: Read the header, then read the last 4 bytes to get the index start offset, parse the index to get stroke UUIDs / offsets / sizes, then parse each stroke's points using those offsets.

Point Format

Each point is 16 bytes, big-endian (>ffBBHI in struct notation):

Offset Size Type Field Range Notes
0 4 f32 BE x ~0–1860 Horizontal coordinate (PDF points)
4 4 f32 BE y ~0–2480 Vertical coordinate (PDF points)
8 1 u8 tilt_x 0–255 Stylus tilt X component, wraps at 256
9 1 u8 tilt_y 0–255 Stylus tilt Y component, wraps at 256
10 2 u16 BE pressure 0–4095 Stylus pressure (hardware max 4095)
12 4 u32 BE time_delta ms Time delta from previous point

Coordinate system: Coordinates are in PDF points (1:1 mapping to device-exported PDFs). Despite the 1404×1872 display resolution, the stored coordinates span the full 1860×2480 page. Observed ranges: x ≈ 0–1860, y ≈ 0–2480. Verified by direct comparison of .note point coordinates against device-exported PDF path segments.

Tilt wrapping: Tilt values wrap at 256 (e.g., 254 → 0 is a +2 change, not -254). Continuous tilt must be unwrapped with modular arithmetic before interpolation.

Time delta: Milliseconds since the previous point within the same stroke. The first point's time_delta appears to be an absolute offset.

Shape Protobuf (inside nested ZIP)

shape/*.zip contains another ZIP with a single protobuf file. The top-level message contains repeated field 1 submessages, one per stroke:

Field Wire Type Content Notes
1 2 string shapeUUID Matches #points index UUID
2 0 varint Created timestamp Epoch ms
3 0 varint Modified timestamp Epoch ms
4 0 varint Color ARGB u32 (see below)
5 5 float32 Thickness Line width
6 0 varint Layer ID Z-order layer assignment. References a layer in the page's layerList (see Stroke Z-Ordering).
7 2 string Bounding box JSON {"bottom","empty","left","right","stability","top"}
8 2 string Transform matrix JSON 3×3 row-major affine: {"values":[a,b,tx,c,d,ty,0,0,1]} or direct [a,b,tx,c,d,ty,0,0,1]. Applied to strokes that were moved/scaled on-device.
11 2 string Pen config JSON See below
12 0 varint Pen type Brush tool identifier (see Pen Types)
16 2 string pointsDocUUID Same as #points header/path
17 2 string Line style JSON {"lineStyle":{"phase","type"}}
18 2 string shapeDocUUID Same as shape ZIP path
20 2 string Extra JSON Contains featureCollection for pen_type=40 geometric shapes. See Geometric Shapes section.
21 2 string Unknown Observed: "[]"
22 2 string Rich text HTML HTML-formatted text content for text boxes (pen_type=6, 16).
25 2 bytes Point list Binary point data for geometric shapes (see Point List Format below)
26 2 string Repo JSON Observed: '{"repo":{}}'

Color encoding (ARGB u32):

Alpha = (color >> 24) & 0xFF
Red   = (color >> 16) & 0xFF
Green = (color >>  8) & 0xFF
Blue  = (color      ) & 0xFF

Pen config JSON (field 11):

{
  "penType": 5,
  "maxPressure": 4095.0,
  "displayScale": 0.9435484,
  "dpi": 320.0,
  "alphaFactor": 1.0,
  "pressureSensitivity": ...
}

Not all strokes have pen config — a minority lack it entirely. On some devices/firmware versions this field may contain a simpler displayScale-only JSON (with maxPressure, revisedDisplayScale, source).

Ordering: Shape protobuf submessage order may differ from the #points index order. They are cross-referenced by shapeUUID (protobuf field 1 = index UUID).

Note Metadata Protobuf (note/pb/note_info or note_tree)

The note metadata protobuf (field tags from reverse engineering):

Field Type Content Notes
1 string noteId Note UUID
2 uint64 Created timestamp Epoch ms
3 uint64 Modified timestamp Epoch ms
6 string Note name
8 uint32 Flag
9 float Pen width
10 float Scale factor
11 string Pen settings JSON Detailed pen config with quick pen list
12 string Canvas state JSON Contains pageInfoMap with per-page layerList (see Stroke Z-Ordering), plus page dimensions and zoom info
13 string Background config JSON Page background settings
14 string Device info JSON Device name and screen dimensions
15 uint32 Fill color
16 uint32 Pen type
20 string Active pages JSON {"pageNameList": [<pageUUID>, ...]}
21 string Reserved pages JSON Same format as active pages
22 float Canvas width
23 float Canvas height
24 string Location
44 string Detached pages JSON Same format as active pages

Multi-note archives use a note_tree file whose protobuf wraps repeated note metadata messages at tag 1.

Page Model Protobuf (pageModel/pb/)

Field Type Content Notes
1 string pageUUID
2 string Layers JSON {"layerList": [{"id": N, "lock": bool, "show": bool}, ...]}
5 uint64 Created timestamp Epoch ms
6 uint64 Modified timestamp Epoch ms
7 string Dimensions JSON {"top", "right", "bottom", "left", "empty", "stability"}

Virtual Doc Protobuf (virtual/doc/pb/)

Field Type Content Notes
1 string virtualDocUUID
2 uint64 Created timestamp Epoch ms
3 uint64 Modified timestamp Epoch ms
4 string Template UUID References a pageUUID
5 float Stability
9 string Content JSON Content ID, page ID, page size, relative path, content type

Virtual Page Protobuf (virtual/page/pb/)

Field Type Content Notes
1 string pageUUID
2 uint64 Created timestamp Epoch ms
3 uint64 Modified timestamp Epoch ms
4 float Zoom scale
6 string Dimensions JSON Same format as page model
7 string Layout JSON
8 string Geo JSON
10 string Template path
12 string Page number

Pen Types

Field 12 in the shape protobuf identifies the brush tool. Observed values and their rendering behavior (determined by comparing .note stroke data against device-exported PDFs):

pen_type Brush Name (approx) Rendering Notes
2 Ballpoint / Fineliner Stroked line segments, constant width Pressure-agnostic. Width = thickness.
5 Fountain Pen Stroked line segments, varying width per segment Pressure-sensitive. Width derived from pressure per point.
15 Highlighter Stroked line segments, constant width Very thick. Constant width = thickness. Multiply blend mode at ~50% opacity.
21 Marker Stroked line segments, varying width per segment Pressure-sensitive, similar to pen_type 5.
22 Charcoal Per-stroke raster image Tilt-sensitive. See Charcoal Raster Rendering section below.
37 Fill Scanline fill rectangles Points are interleaved scanline pairs: even-indexed = left edge, odd-indexed = right edge. Each pair defines one horizontal fill span. Thickness always 1.0.
40 Geometric Shapes GeoJSON-based rendering Uses field 20 extra JSON with featureCollection. See Geometric Shapes (pen_type=40) section.
60 Calligraphy Brush A Filled polygon (no stroke) Tilt-sensitive. Closed filled path (~5x more segments than input points). No per-segment widths.
61 Calligraphy Brush B Filled polygon (no stroke) Tilt-sensitive. Same as 60 but different fill tessellation.

Rendering summary:

  • Stroked types (2, 5, 15, 21): Each point maps to approximately one line segment. Width is either constant (types 2, 15) or derived from pressure (types 5, 21).
  • Fill type (37): Points encode a scanline fill — even/odd interleaved pairs define horizontal spans that tile the filled region.
  • Filled types (60, 61): The stroke outline is tessellated into a closed polygon. Segment count is much larger than point count (~5x). No per-segment width — the shape is filled.
  • Raster types (22): Each stroke is a separate raster image. See Charcoal Raster Rendering below.
  • Text types (6, 16): Text boxes with plain text (field 10) and/or HTML rich text (field 22). See Text Boxes section.
  • Geometric shapes (40): GeoJSON-based vector shapes using field 20. See Geometric Shapes section.

SVG Import: Fill Export Format

When importing SVGs into .note format, filled shapes are converted to pen_type 40 (GeoJSON geometric shapes) which supports native fills via the fillAttr property. Key lessons:

  • pen_type 0, 1, 17 are NOT used in any observed .note files. Using them results in unfilled outlines.
  • pen_type 40 supports fills natively via displayFillColor and fillAttr in the GeoJSON featureCollection (protobuf field 20). The device renders these correctly.
  • pen_type 40 does NOT use binary #points data. Geometry is stored entirely in the GeoJSON. The matrix (protobuf field 8) transforms local → page coordinates.

Conversion pipeline for SVG fills:

  1. Ellipses → pen_type 40 MultiPoint with subType: "Oval" + fillAttr
  2. Rectangles → pen_type 40 Polygon (4-edge rectangle) + fillAttr
  3. Polygons → pen_type 40 Polygon (edge pairs) + fillAttr

Rendering Pipeline

Each stroke is rendered by:

  1. Looking up metadata — the shape protobuf provides pen_type, thickness, color (ARGB), and an optional affine transform matrix
  2. Applying the transform — if the stroke was moved/scaled on-device, the 3×3 affine matrix ({"values":[a,b,tx,c,d,ty,0,0,1]} or direct [a,b,tx,c,d,ty,0,0,1]) is applied to each point: x' = a*x + b*y + tx, y' = c*x + d*y + ty
  3. Unwrapping tilt — 8-bit tilt values that wrap at 256 are unwrapped with modular arithmetic to produce continuous angles
  4. Computing width — per-point width is computed from pressure and pen-type-specific formulas (see Width Formulas below)
  5. Drawing — the pen type determines the drawing primitive (line segments, filled polygon, raster texture, or scanline rectangles)

Compositing:

  • Most pen types use normal (source-over) blending at full opacity
  • Highlighter (pen_type 15) uses multiply blend mode at ~50% opacity
  • All stroked types use round line caps and round line joins
  • Strokes are rendered bottom-to-top in creation-timestamp order (field 2 in shape protobuf); later strokes occlude earlier ones

Color: Stored as ARGB u32 in protobuf field 4. For most pen types, alpha comes from the ARGB value. The highlighter overrides alpha to ~50%.

Width Formulas (Pressure-Sensitive Pens)

Fitted by comparing .note stroke data against device-exported PDF output. Thickness values in .note are already in PDF points — no scaling needed.

pen_type Formula Params RMSE
2 (Ballpoint) w = thickness (constant) exact
5 (Fountain) w = thickness × 1.37 × (pressure/4095)^0.59 k=1.37, exp=0.59 0.063
15 (Highlighter) w = thickness (constant) exact
21 (Marker) w = thickness × 2.35 × (pressure/4095)^0.43 k=2.35, exp=0.43 1.207

For variable-width pens (5, 21), each segment uses the average pressure of its two endpoints. Width is clamped to a minimum of 0.5pt.

Charcoal Raster Rendering (pen_type=22)

Charcoal strokes are not rendered as vector paths. On the device, each charcoal stroke is exported as a raster image: a solid-color RGB layer with a binary alpha mask that creates the "grain" texture.

Width envelope: The charcoal stroke's outline follows the same pressure-dependent variable-width model as the fountain pen: w = thickness × 1.37 × (pressure/4095)^0.59, rendered as a filled polygon (same fill_stroke_outline approach as calligraphy).

Texture characteristics:

  • The alpha mask forms a scattered dot pattern along the stroke path — sparse individual pixels with gaps between them
  • Density varies along the stroke, roughly correlating with pressure
  • The pattern resembles charcoal on textured paper — not a solid filled path
  • tilt_x encodes pen azimuth (wraps near 0/255); tilt_y encodes elevation (narrow range)
  • The exact algorithm mapping (position, tilt, pressure) → pixel mask is unknown, but the texture appears to be a deterministic scattered pattern

Rendering approach: Charcoal is approximated with procedural stippling: a 64×64 tiled grain pattern (solid color with ~30% of pixels erased) is used as a CanvasPattern fill inside the stroke's variable-width polygon outline. The pattern's RNG is seeded per-stroke (from UUID hash) for deterministic output. This produces a visually similar scattered grain effect without reverse-engineering the exact device algorithm.

Calligraphy Brush Rendering (pen_type=60, 61)

See calligraphy.md for the detailed chisel-tip model, width formula, smoothing pipeline, and known limitations.

Templates & Backgrounds

Pages can have templates (ruled lines, grids, dot grids) and background images.

Templates are stored at template/json/<pageUUID>.template_json. The JSON contains:

{
  "layoutType": "LayoutResVector",
  "properties": {
    "resourceAttr": { "resName": "template/<templateName>" },
    "spacing": 68.0,
    "shaderRect": { "left": 0, "right": 1860, "top": 0, "bottom": 2480 }
  }
}

The template SVG is fetched from the Boox CDN at https://static.send2boox.com/device/note/template/{templateName}.svg. Observed template names: ic_horizontal_line_24 (ruled lines), new_scribble_back_ground_grid_point (dot grid).

Backgrounds are stored in the note_info protobuf field 13 as a JSON string:

{
  "useDocBKGround": true,
  "docBKGround": { "type": 1, "resId": "<resourceUUID>" },
  "pageBKGroundMap": { "<pageUUID>": { "type": 1, "resId": "<resourceUUID>" } }
}

Background type 1 = image file. The resId references a resource in resource/pb/. Per-page overrides are in pageBKGroundMap.

Text Boxes (pen_type=6, 16)

Text boxes are shape entries with pen_type 6 or 16. They may appear in the #points index (with 2 points defining the bounding box corners) or only in the shape protobuf.

Content fields:

  • Field 10: plain text content
  • Field 22: HTML rich text (e.g. <p><span style="...">text</span></p>)
  • Field 9: text style JSON with formatting properties:
    { "textSize": 32, "textBold": false, "textItalic": false, "alignType": 0, "textSpacing": 1.2 }
    alignType: 0=left, 1=center, 2=right.

Positioning: From bounding_rect (field 7) or from 2 points in the #points data defining opposite corners of the text box.

Geometric Shapes (pen_type=40)

Geometric shapes drawn with the Boox shape tools (lines, arrows, polygons, ovals, curves, brackets, etc.) use pen_type=40 and store their geometry in protobuf field 20 (extra) instead of field 25 (pointList).

Field 20 format: A JSON string containing a featureCollection key whose value is itself a JSON string in GeoJSON-like format:

{
  "featureCollection": "{\"type\":\"FeatureCollection\",\"features\":[...]}"
}

Coordinate system: Coordinates in the geometry are in local space. The matrix (field 8) transforms local → page coordinates: x' = a*x + b*y + tx, y' = c*x + d*y + ty. The matrix can include Y-flips (negative d), scaling, and rotation.

Geometry types observed in .note files:

geometry.type subType coords format Rendering
LineString "" [[x0,y0],[x1,y1]] Straight line
LineString WaveLine [[x0,y0],[x1,y1]] Sine wave between endpoints
DirectionLine "" [[x0,y0],[x1,y1]] Line with arrowhead at end
BidirectionalLine "" [[x0,y0],[x1,y1]] Line with arrowheads at both ends
MultiLineString "" [[[x0,y0],[x1,y1]], ...] Multiple line segments (used for arrow head lines)
Polygon "" [[[start,end], ...]] Pairs of segment endpoints forming a closed ring
MultiPoint Oval [[x0,y0],[x1,y1]] Bounding box → ellipse
MultiPoint Curve [[start],[control],[end]] Quadratic Bezier curve
MultiPoint Arc [[bboxMin],[bboxMax],[angleCtrl]] Elliptical arc within bounding box. 3rd point x=0 → upper half, x=180 → lower half
MultiPoint Bracket [[tip],[topEnd],[bottomEnd]] Bracket/brace: tip is the apex, topEnd/bottomEnd are the open ends
FeatureCollection Surface nested features[] Recurse into sub-features. Used for compound shapes: 3D solids (cube, pyramid, cylinder), shapes with hidden edges

Polygon coordinate format: Unlike standard GeoJSON, polygon coordinates are stored as pairs of [start_point, end_point] for each edge segment, not as a simple vertex list. The first point of each pair forms the polygon vertex.

SubType location: The geometry subtype (Oval, Arc, Curve, Bracket, WaveLine, Surface) is stored in feature.properties.subType, not on the geometry object itself.

Styling: Color from field 4, thickness from field 5. Individual features may have a strokeAttr object with lineWidth and color overrides.

Dashed lines: Features can specify lineStyle: {"dashLineIntervals": [8.0, 5.0], "phase": 0.0, "type": 1} for dashed rendering. Used for hidden edges in 3D shapes.

WaveLine properties: WaveLine features include waveAttr in feature.properties: {"wavyLength": 24.0, "wavyPeak": 6.0, "wavyOffset": 0.0} controlling wavelength, amplitude, and phase offset.

Point List Format (field 25)

Used by geometric shapes with pen_type 0 (ellipse), 1 (rectangle), 7 (line), 8/10–12/17/18/24/26/27 (polygons), 28 (arrow), 31 (polyline). Binary format: 4-byte header followed by 16-byte records (same layout as stroke points: x:f32 BE, y:f32 BE, then 8 bytes of size/pressure/event_time). Only x,y are used for shape geometry.

Page Geometry & Coordinate Mapping

The page size is 1860 x 2480 points (approximately 25.83 x 34.44 inches at 72 DPI). Coordinates in .note files map 1:1 to device-exported PDF coordinates — no scaling is needed.

Stroke Z-Ordering

Each stroke has a layer ID (shape protobuf field 6) that assigns it to a layer. The page's layer order is defined in the note metadata (field 12) pageInfoMap JSON:

{
  "pageInfoMap": {
    "<pageUUID>": {
      "layerList": [
        {"id": 1234567890, "lock": false, "show": true},
        {"id": 9876543210, "lock": false, "show": true}
      ]
    }
  }
}

Rendering order: When layers exist, strokes are sorted by layer position (order in layerList) first, then by creation timestamp (field 2) within each layer. When no layer information is available, strokes fall back to creation-timestamp-only ordering. Within a page, the #points index order may differ from creation order; the shape metadata is authoritative.

Fill strokes (pen_type 37) are rendered in the same layer+timestamp order as all other strokes.

Stash (Undo History)

stash/ contains undo history (~46% of total file size in typical files). Safe to drop entirely — the device does not require it for rendering. The debloater strips this directory on export.

Decimation (Optimization) Algorithm

The optimizer reduces file size by removing redundant points from strokes using a modified Ramer-Douglas-Peucker algorithm that operates in 5 dimensions: spatial (x, y) plus attribute (pressure, tilt_x, tilt_y). The cost of removing a point is the maximum of:

  • Spatial deviation: perpendicular distance from the point to the line segment connecting its neighbors
  • Attribute deviation: maximum interpolation error across pressure and tilt channels (scaled by user-configurable equivalence factors)

Points at sharp turns (>30° angle change, i.e. cos(angle) < 0.866) are never removed. First and last points of each stroke are always preserved. A priority queue processes points in ascending cost order, updating neighbor costs after each removal. The threshold parameter controls the quality/size tradeoff.

Older Backup Format (different!)

The OnyxNoteRenderer project handles a different format: SQLite-based .note backup files where points are Nx6 float32 (byteswapped) in a NewShapeModel table, with coordinates in normalized 0–1 range. This is the cloud backup/export format, not the same as standalone .note ZIP files from the device.

Key differences from the standalone format:

  • SQLite database vs ZIP archive
  • Normalized 0–1 coordinates vs device-unit coordinates
  • 6 floats per point (x, y, pressure, ?, ?, ?) vs 16-byte packed struct
  • shapeType = 5 used for pressure-sensitive rendering in the backup renderer

Rendering Comparison Tool (compare.py)

A CLI tool for testing rendering accuracy against gold reference PNGs (device-exported screenshots or PDF rasterizations). Requires uv — dependencies are declared inline via PEP 723.

Subcommands:

# Render a .note file to PNG via headless Chromium
uv run compare.py render shapes.note [-o rendered.png] [--page 0]

# Compare two existing PNGs (gold vs rendered)
uv run compare.py diff gold.png rendered.png [--note shapes.note] [--non-overlapping] [-o diff.png]

# Render + compare in one step (most common)
uv run compare.py check shapes.note shapes.png [--non-overlapping] [-o diff.png] [--page 0]

How it works:

  1. Spins up a local HTTP server serving the web/ directory
  2. Uses Playwright (headless Chromium) to load headless.html, which initializes the WASM renderer
  3. Passes the .note file as base64 to window.renderNote(), captures the canvas as a PNG
  4. Compares rendered vs gold pixel-by-pixel, computing MAE (mean absolute error), max error, and percentage of differing pixels
  5. If a .note file is provided, extracts per-shape bounding boxes from the protobuf and reports per-region metrics
  6. Outputs a diff visualization with errors amplified 4× and bounding boxes outlined in green (MAE < 1) or red

Options:

  • --non-overlapping: Only report metrics for shapes whose bounding boxes don't overlap with any other, avoiding ambiguous attribution of errors
  • --page N: Render page N (0-indexed, default 0)
  • -o FILE: Output path for the diff image

Output example:

Overall: MAE=1.64  Max=255  Diff pixels: 42,139/4,612,800 (0.91%)

Per bounding box (15 regions):
  [ 841, 310 → 1174, 615] pen=40  MAE=  7.40  Max=255  diff=5.2%  DIFF
  [ 100, 200 →  400, 500] pen=40  MAE=  0.45  Max= 12  diff=0.3%  OK

Open Questions

  1. Header u32: Version number or page count? Only value 1 observed.
  2. Bounding boxes: Must they be updated when points change, or does the device recompute? (Currently we do not update them and the device accepts the file.)
  3. Pen type completeness: 10 values observed (2, 5, 6, 15, 16, 21, 22, 37, 40, 60, 61). The Boox app offers additional brush tools (pencil, etc.) whose pen_type integers have not yet been observed in test files.
  4. Pen config absence: Why do some strokes (~12%) lack pen config JSON (field 11)? Possibly firmware version dependent. On some devices/firmware versions this field may contain a simpler displayScale-only JSON (with maxPressure, revisedDisplayScale, source).
  5. Charcoal texture algorithm: The exact device algorithm for (position, tilt, pressure) → pixel mask is unknown. Our procedural stipple approximation is visually similar but not pixel-identical. Tilt_x encodes pen azimuth (256 units = full circle, typically 0–25 range); tilt_y encodes elevation (typically 15–33 range).
  6. Velocity effects on width: Velocity is not stored in .note point data (only time_delta is stored, from which velocity could theoretically be recomputed using inter-point distances). The marker pen's higher RMSE (1.207 vs 0.063 for fountain) may partly reflect unmodeled velocity effects in the width formula.
  7. Firmware variation: Format details observed on our device may differ across firmware versions or device models. The boox-note-parser project (based on Note Air 4 C, app version 42842) reports some differences in shape protobuf field interpretation — their field 11 contains a simpler displayScale JSON and they don't identify fields 4 (color) or 12 (pen_type). These may be firmware-dependent or represent a different interpretation of the same data.