✨ 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.
- Optimize
.notefiles — shrink 5–10x with zero visual quality loss - Import SVGs — convert SVG drawings into
.noteformat 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
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
.notefile 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.apkwasm-pack build --target web --out-dir web/pkg
uv run python -m http.server 8080 --directory webImportant
Bump the CACHE_NAME version in web/sw.js when deploying changes to force clients to re-fetch.
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.
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.
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 filenamespointsDocUUID— appears in point path and shape protobuf field 16shapeDocUUID— appears in shape path and shape protobuf field 18noteId— root folder name, also in virtual/doc and document pathsshapeUUID— per-stroke ID, appears in both#pointsindex and shape protobuf field 1
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.
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/*.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).
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.
| 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"} |
| 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 |
| 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 |
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.
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
.notefiles. Using them results in unfilled outlines. - pen_type 40 supports fills natively via
displayFillColorandfillAttrin the GeoJSONfeatureCollection(protobuf field 20). The device renders these correctly. - pen_type 40 does NOT use binary
#pointsdata. Geometry is stored entirely in the GeoJSON. The matrix (protobuf field 8) transforms local → page coordinates.
Conversion pipeline for SVG fills:
- Ellipses → pen_type 40
MultiPointwithsubType: "Oval"+fillAttr - Rectangles → pen_type 40
Polygon(4-edge rectangle) +fillAttr - Polygons → pen_type 40
Polygon(edge pairs) +fillAttr
Each stroke is rendered by:
- Looking up metadata — the shape protobuf provides pen_type, thickness, color (ARGB), and an optional affine transform matrix
- 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 - Unwrapping tilt — 8-bit tilt values that wrap at 256 are unwrapped with modular arithmetic to produce continuous angles
- Computing width — per-point width is computed from pressure and pen-type-specific formulas (see Width Formulas below)
- 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%.
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 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_xencodes pen azimuth (wraps near 0/255);tilt_yencodes 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.
See calligraphy.md for the detailed chisel-tip model, width formula, smoothing pipeline, and known limitations.
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 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 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.
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.
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.
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/ 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.
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.
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 = 5used for pressure-sensitive rendering in the backup renderer
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:
- Spins up a local HTTP server serving the
web/directory - Uses Playwright (headless Chromium) to load
headless.html, which initializes the WASM renderer - Passes the
.notefile as base64 towindow.renderNote(), captures the canvas as a PNG - Compares rendered vs gold pixel-by-pixel, computing MAE (mean absolute error), max error, and percentage of differing pixels
- If a
.notefile is provided, extracts per-shape bounding boxes from the protobuf and reports per-region metrics - 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
- Header u32: Version number or page count? Only value
1observed. - 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.)
- 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.
- 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 (withmaxPressure,revisedDisplayScale,source). - 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).
- Velocity effects on width: Velocity is not stored in
.notepoint data (onlytime_deltais 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. - 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
displayScaleJSON 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.