Skip to content

API reference

inkterop is primarily a CLI, but the conversion pipeline is importable. The authoritative description of the data model is the IR specification — this page documents the code entry points.

Conversion pipeline

Cross-format conversion orchestration: read -> IR -> write.

parse_pages(spec, n)

'1-3,7' -> zero-based page indices (bounds-checked).

Source code in core/src/inkterop/convert.py
def parse_pages(spec: str, n: int) -> list[int]:
    """'1-3,7' -> zero-based page indices (bounds-checked)."""
    out: list[int] = []
    for part in spec.split(","):
        part = part.strip()
        if "-" in part:
            a, b = part.split("-", 1)
            out.extend(range(int(a) - 1, int(b)))
        else:
            out.append(int(part) - 1)
    bad = [i + 1 for i in out if i < 0 or i >= n]
    if bad:
        raise ConvertError(f"page(s) {bad} out of range (document has {n})")
    return out

read_input(in_path, cache_dir=None, pen_style='faithful')

Read a file via the registry, or a library document by name/uuid.

Source code in core/src/inkterop/convert.py
def read_input(in_path: Path, cache_dir: Path | None = None,
               pen_style: str = "faithful") -> ir.Document:
    """Read a file via the registry, or a library document by name/uuid."""
    if in_path.exists() and in_path.is_file():
        reader = formats.reader_for(in_path)
        if reader is None:
            raise ConvertError(
                f"no reader recognizes {in_path.name} "
                f"(known: {sorted({e for r in formats.readers() for e in r.extensions})})"
            )
        _logger.info("reading %s as %s", in_path.name, reader.format_id)
        return reader.read(in_path)

    # Not a file: try the reMarkable library by visible name or uuid.
    from .formats.remarkable.reader import read_library_document
    from .library import Library

    lib = Library(cache_dir)
    doc = lib.find(str(in_path))
    if doc is None or doc.is_folder:
        raise ConvertError(f"not a file and not a library document: {in_path}")
    return read_library_document(doc, pen_style=pen_style)

convert(in_path, out_path, fidelity=Fidelity.EXACT, pages=None, experimental=False, force=False, cache_dir=None, options=None)

Convert one document; returns the IR that was written.

Source code in core/src/inkterop/convert.py
def convert(in_path: Path, out_path: Path,
            fidelity: Fidelity = Fidelity.EXACT,
            pages: str | None = None,
            experimental: bool = False,
            force: bool = False,
            cache_dir: Path | None = None,
            options: dict[str, Any] | None = None) -> ir.Document:
    """Convert one document; returns the IR that was written."""
    writer = formats.writer_for(out_path)
    if writer is None:
        raise ConvertError(
            f"no writer for {out_path.suffix!r} "
            f"(known: {sorted({e for w in formats.writers() for e in w.extensions})})"
        )
    if not writer.validated and not experimental:
        raise ConvertError(
            f"the {writer.format_id} writer is not validated against the "
            f"target app yet; pass --experimental to use it anyway"
        )
    out_resolved = out_path.resolve()
    for root in _forbidden_roots():
        if root and out_resolved.is_relative_to(root) and not force:
            raise ConvertError(
                f"refusing to write into source-of-truth dir {root}"
            )

    doc = read_input(in_path, cache_dir=cache_dir)
    if pages:
        idx = parse_pages(pages, len(doc.pages))
        doc.pages = [doc.pages[i] for i in idx]
    doc.validate()
    writer.write(doc, out_path, fidelity, options)
    return doc

Format registry

Format registry: one reader (native -> IR) / writer (IR -> native) each.

Plain module-level registration by explicit import — no plugin magic.

reader_for(path)

Pick a reader: extension match first, confirmed by detect().

Source code in core/src/inkterop/formats/__init__.py
def reader_for(path: Path) -> FormatReader | None:
    """Pick a reader: extension match first, confirmed by detect()."""
    _load()
    ext = path.suffix.lower()
    candidates = [r for r in _READERS if ext in r.extensions]
    for r in candidates:
        if r.detect(path):
            return r
    # Extension is ambiguous across apps (.note!); fall back to sniffing all.
    for r in _READERS:
        if ext not in r.extensions and r.detect(path):
            return r
    return None

IR data model

The neutral ink document model.

Every format converts through this: reader (native -> Document) on one side, writer/renderer (Document -> native/PDF/SVG/...) on the other.

Coordinates stay in SOURCE units with a declared Page.point_scale (units -> PDF points); writers scale as needed. Y grows downward.

Rect dataclass

Axis-aligned bounding box in source units.

Source code in core/src/inkterop/ir/model.py
@dataclass
class Rect:
    """Axis-aligned bounding box in source units."""
    x_min: float
    y_min: float
    x_max: float
    y_max: float

    @property
    def width(self) -> float:
        return self.x_max - self.x_min

    @property
    def height(self) -> float:
        return self.y_max - self.y_min

Stroke dataclass

One pen stroke: parallel x/y point arrays plus optional per-point channels (pressure, tilt, width...), a semantic tool reference, and an optional exact appearance override. See the IR spec for the three-fidelity model.

Source code in core/src/inkterop/ir/model.py
@dataclass
class Stroke:
    """One pen stroke: parallel x/y point arrays plus optional per-point
    channels (pressure, tilt, width...), a semantic tool reference, and an
    optional exact appearance override. See the IR spec for the
    three-fidelity model."""
    x: list[float]
    y: list[float]
    tool: ToolRef
    color: Color  # semantic/base color
    channels: dict[Channel, list[float]] = field(default_factory=dict)
    appearance: StrokeAppearance | None = None  # None => style from tool family
    extra: dict[str, Any] = field(default_factory=dict)  # namespaced by format id

    def __len__(self) -> int:
        return len(self.x)

    def validate(self) -> None:
        if len(self.x) != len(self.y):
            raise ValueError(f"x/y length mismatch: {len(self.x)} != {len(self.y)}")
        for ch, values in self.channels.items():
            if len(values) != len(self.x):
                raise ValueError(
                    f"channel {ch.value} length {len(values)} != {len(self.x)} points"
                )

TextBlock dataclass

A typed-text run anchored at (x, y) in source units.

Source code in core/src/inkterop/ir/model.py
@dataclass
class TextBlock:
    """A typed-text run anchored at (x, y) in source units."""
    x: float
    y: float
    text: str
    font_size: float | None = None
    color: Color | None = None
    extra: dict[str, Any] = field(default_factory=dict)

RasterImage dataclass

Bitmap layer content (e.g. Supernote RLE layers, embedded images).

Source code in core/src/inkterop/ir/model.py
@dataclass
class RasterImage:
    """Bitmap layer content (e.g. Supernote RLE layers, embedded images)."""

    data: bytes  # encoded image bytes
    format: str  # "png", "jpeg", ...
    bounds: Rect | None = None  # placement in page units; None => full page

Layer dataclass

One z-ordered layer of a page: vector strokes, text, and/or a raster image.

Source code in core/src/inkterop/ir/model.py
@dataclass
class Layer:
    """One z-ordered layer of a page: vector strokes, text, and/or a
    raster image."""
    strokes: list[Stroke] = field(default_factory=list)
    texts: list[TextBlock] = field(default_factory=list)
    raster: RasterImage | None = None
    name: str = ""
    visible: bool = True

TemplateBackground dataclass

Procedural page template, resolved to concrete params by the reader.

Source code in core/src/inkterop/ir/model.py
@dataclass
class TemplateBackground:
    """Procedural page template, resolved to concrete params by the reader."""

    kind: str  # "dots" | "lines" | "grid" | "unknown"
    name: str = ""  # source template name, e.g. "P Dots S"
    pitch: float = 0.0  # dot/line spacing, source units
    line_width: float = 0.0
    dot_radius: float = 0.0
    gray: float = 0.62  # 0-1 luminance

PdfBackground dataclass

A page of an attached PDF used as the page background.

Source code in core/src/inkterop/ir/model.py
@dataclass
class PdfBackground:
    """A page of an attached PDF used as the page background."""
    attachment_key: str  # key into Document.attachments
    page_index: int

ImageBackground dataclass

A raster image used as the page background.

Source code in core/src/inkterop/ir/model.py
@dataclass
class ImageBackground:
    """A raster image used as the page background."""
    image: RasterImage

ColorBackground dataclass

A solid-color page background.

Source code in core/src/inkterop/ir/model.py
@dataclass
class ColorBackground:
    """A solid-color page background."""
    color: Color

Page dataclass

One page: bounds + unit scale, layers bottom-to-top, and an optional background.

Source code in core/src/inkterop/ir/model.py
@dataclass
class Page:
    """One page: bounds + unit scale, layers bottom-to-top, and an
    optional background."""
    bounds: Rect  # in source units (rM: x centered on 0, grown y)
    point_scale: float  # source units -> PDF points
    layers: list[Layer] = field(default_factory=list)
    background: Background | None = None
    extra: dict[str, Any] = field(default_factory=dict)

    def strokes(self):
        for layer in self.layers:
            if layer.visible:
                yield from layer.strokes

Document dataclass

The root IR object a reader produces and a writer consumes.

Source code in core/src/inkterop/ir/model.py
@dataclass
class Document:
    """The root IR object a reader produces and a writer consumes."""
    format_id: str
    title: str = ""
    pages: list[Page] = field(default_factory=list)
    orientation: str = "portrait"  # portrait | landscape (hint)
    attachments: dict[str, bytes | Path] = field(default_factory=dict)
    metadata: dict[str, Any] = field(default_factory=dict)
    extra: dict[str, Any] = field(default_factory=dict)

    def validate(self) -> None:
        for pi, page in enumerate(self.pages):
            for si_, stroke in enumerate(page.strokes()):
                try:
                    stroke.validate()
                except ValueError as e:
                    raise ValueError(f"page {pi} stroke {si_}: {e}") from e

Command line

inkterop CLI: mirror | watch | render | ls | convert | inspect