Xournal++ (.xopp) format¶
Status: read + write, both fidelity-aware; open, fully-documented format
(no reverse engineering needed — this is not a guessed spec). Round-tripped
by core/tests/test_xopp.py, including against a hand-shaped file meant to
resemble real Xournal++ output.
Confidence markers as in docs/formats/goodnotes.md, used sparingly here
since the container format itself is public; they mark claims about our
mapping choices and about real-world Xournal++ output we haven't sampled
directly.
Container [verified]¶
Gzip-compressed XML, root <xournal fileversion="4"> (the Xournal++
successor to the original Xournal .xoj; our reader/writer target
fileversion 4 only). XoppReader.detect() accepts either a gzip member
(magic 1f 8b) or plain XML and checks for <xournal in the first 512
decompressed bytes — so ungzipped .xopp/.xoj files parse too.
extensions = (".xopp", ".xoj") on the reader; the writer only emits
.xopp (always gzipped).
Document tree: <xournal><title/><page width height><background/>
<layer><stroke/><text/></layer></page>…</xournal>. We read/write title,
page, background (partially), layer, stroke, text. We do not read
or write <image> elements (see Known gaps).
Implementation: core/src/inkterop/formats/xopp/{common,reader,writer}.py.
common.py holds the shared lookup tables both directions use.
Coordinates & units [verified]¶
Xournal++ stores coordinates directly in PDF points, y down, and (in
practice) already rebased so the page's top-left is (0, 0) — there is no
separate scale or origin field in the format.
- Read:
Page.point_scale = 1.0always;Page.bounds = Rect(0, 0, w, h)from the<page>element'swidth/heightattributes (default 612×792 if absent).Document.orientationis inferred as"landscape"when the first page is wider than tall, else"portrait". - Write: coordinates are rebased and scaled per the IR contract —
(x - bounds.x_min) * point_scale,(y - bounds.y_min) * point_scale— so a foreign-unit page (e.g. reMarkable's x-centered canvas,point_scale ≈ 0.3171) lands correctly in point space starting at(0, 0). Widths are scaled by the samepoint_scalefactor. Verified bytest_scaled_coordinates(reMarkable-shaped bounds/scale in, checks output page width/height and rebased stroke endpoints) andtest_remarkable_fixture_to_xopp(real.rmfixture through the full reader → xopp → reader path, geometry checked toabs=1e-4).
The width attribute [verified]¶
A <stroke>'s width attribute is a space-separated list of floats read
by point count n:
- 1 value → constant-width stroke. IR:
appearance.mode = STROKED_CONSTANT,appearance.width= that value, and theWIDTHchannel is filled with the value repeatedntimes (soStroke.validate()still sees one width per point). - ≥ n values (Xournal++'s own convention: nominal width + one width
per SEGMENT, i.e.
n - 1segment widths fornpoints, totalingnvalues) → variable-width stroke. IR:appearance.mode = STROKED_VARIABLE,appearance.width = None, and theWIDTHchannel is built as[nominal] + segment_widths[0:n-1]— point 0 gets the nominal width, and pointi(fori ≥ 1) gets the width of the segment ending at it. There is no dedicated "point 0" width in Xournal++'s own model; reusing the nominal value for point 0 is our choice, not a format fact.
On write, the inverse holds exactly: _stroke_xml emits the WIDTH
channel values verbatim as the width attribute (n values: index 0 is
treated as "nominal", the rest as segment widths) when len(WIDTH) > 1,
or a single value when the appearance is STROKED_CONSTANT or no WIDTH
channel exists (defaulting to 2.0). Because read and write use the exact
inverse transform, test_round_trip checks the 3-point variable-width
pen stroke ([1.5, 2.25, 3.0]) survives byte-for-value round-trip via
pytest.approx.
Tool mapping [verified]¶
Xournal++ only has three tool values: pen, highlighter, eraser.
- Read (
TOOL_TO_FAMILY):pen → PEN,highlighter → HIGHLIGHTER,eraser → ERASER; anything else defaults toPEN. - Write (
family_to_tool, a collapse):HIGHLIGHTERandSHADER→"highlighter";ERASER→"eraser"; every other family (PEN,BALLPOINT,FINELINER,PENCIL,MECHANICAL_PENCIL,MARKER,BRUSH,CALLIGRAPHY,UNKNOWN) →"pen".
This is a lossy collapse in the pen-family direction: converting a
reMarkable ballpoint or fineliner stroke to xopp and back yields
ToolFamily.PEN, not the original sub-family, because Xournal++ has no
concept of pen sub-types to write it into. NativeTool(FORMAT_ID, tool,
{}) is preserved on strokes we read from xopp, so a same-format xopp →
xopp round-trip of a file we didn't write ourselves still keeps its literal
tool string; strokes originating from other formats simply don't have a
native xopp tool record to fall back on.
Color, alpha, opacity [verified]¶
color_to_hex(color, opacity)writes#RRGGBBAA, 2 hex digits per channel, where the alpha byte iscolor.a * opacity— i.e. a stroke's per-point-independent opacity (appearance.opacity, defaulting to0.5for highlighters and1.0otherwise when there's no appearance to draw from) is folded into the single alpha byte on write. There is no separate opacity attribute.hex_to_coloraccepts both 6-digit (RRGGBB, alpha assumedff) and 8-digit (RRGGBBAA) hex strings. On read, the alpha byte is split back out:opacity = color.a, and theStroke.color/appearance.colorcarry the RGB withareset toColor's default of1.0.parse_coloralso accepts Xournal++'s named colors instead of hex —NAMED_COLORScoversblack, blue, red, green, gray, lightblue, lightgreen, magenta, orange, yellow, white(Xournal++'s preset palette[inferred], not independently confirmed against Xournal++ source); an unrecognized name falls back to black. This is read-only compatibility — our writer always emits hex, never a named color. Verified against a hand-written fixture usingcolor="blue"intest_reads_handwritten_xournalpp_file.
Background style [verified]¶
STYLE_TO_KIND (read) / KIND_TO_STYLE (write) map between Xournal++'s
<background style="…"> values and TemplateBackground.kind:
| xopp style | IR kind |
|---|---|
dotted |
dots |
lined |
lines |
ruled |
lines |
graph |
grid |
plain |
(no background) |
lined and ruled both read as lines (Xournal++ has used both terms
across versions [inferred]); on write we only ever emit ruled for
lines — round-tripping a lined-style file through our writer will
silently normalize it to ruled. A style="plain" (or missing
<background>) produces Page.background = None, not a ColorBackground.
Two things the reader/writer do not handle: a <background
type="pdf"> (image/PDF page background) only triggers a warning log
(_logger.warning("unsupported xopp background type %r", …)) and the
page's background stays None; and on write, ColorBackground /
ImageBackground / PdfBackground pages are not special-cased at all —
the writer always emits type="solid" color="#ffffffff" and only inspects
isinstance(page.background, TemplateBackground) for the style
attribute. This matches the general IR note that no renderer/writer
consumes PdfBackground yet (docs/ir.md).
Round-trip guarantees¶
Covered by core/tests/test_xopp.py:
test_round_trip— full document (title, page bounds,TemplateBackgroundkind, two strokes of different tools/appearance modes, one text block with XML special characters) written and read back: geometry,WIDTHchannel, color (to ~1/255 hex-quantization tolerance), highlighterSTROKED_CONSTANTwidth/opacity/underlay, and text content all survive.test_scaled_coordinates— a page in foreign units (reMarkable canvas shape, non-zero-origin bounds,point_scale = 685/2160) rebases and scales correctly to point space on write, confirmed by re-reading.test_reads_handwritten_xournalpp_file— a file shaped like real Xournal++ output (not written by us): named color, single constant-width value,graphbackground style, 2-digit-precision alpha — parses correctly, i.e. we're compatible with the format as external tools emit it, not just self-consistent.test_remarkable_fixture_to_xopp— end-to-end: a real.rmfixture read by the reMarkable reader, converted to xopp, and read back; geometry checked toabs=1e-4after the unit conversion,WIDTHchannel checked non-NaN.test_raw_fidelity_rejected—Fidelity.RAWraisesValueError(xopp has no field for raw pen dynamics; use IR-JSON or InkML instead), per the writer's docstring.
The writer sets validated = True — per docs/validated-writes.md, open
formats with round-trip test coverage are validated without a manual
app-open check; Xournal++ itself is also expected to open our output since
its own XML parser is documented as lenient (unverified independently — we
have not opened our output in Xournal++).
Known gaps¶
- Images (
<image>elements) are not read or written —RasterImagecontent in the IR has no xopp path. - PDF-background pages (
<background type="pdf">) are not read (logged and dropped) andPdfBackgroundis not written. - Audio — Xournal++ has no audio-annotation concept in this format, so
there is nothing to map; not a gap so much as N/A
[inferred]. - Tool sub-family is lossy through xopp in both directions except for
highlighter/eraser, as described above. - Text formatting —
<text>only carries plain content, font, size, color, and position; no rich formatting, no font family beyond"Sans"written on our side.
Changelog¶
- 2026-07-09: initial spec covering the reader/writer as implemented and tested.