How DotMorph turns Pillow, librsvg, poppler, Ghostscript, ImageMagick, ffmpeg, LibreOffice and pandoc into one conversion engine, and why its SVG rendering moved from ImageMagick to librsvg over ctypes.

In part 1 I introduced DotMorph, a radial-menu file converter for Linux. This post is about the part you don't see: the engine that decides how a file gets converted, and the bug that made me rewrite it.

It started with a broken PNG

The first version of DotMorph sent most image work to ImageMagick. Then I converted an SVG logo to PNG and got this (left):

The same SVG rendered by ImageMagick's MSVG renderer (gradients, dashes, clip-path and text broken) and by librsvg (correct)

Same file, two renderers. ImageMagick's built-in SVG renderer (MSVG) dropped the gradients, the dashed stroke and the clip-path, spread letter-spacing across the whole word and stacked a <tspan> on top of its line. On the logo that started this, I measured the damage against Chromium's rendering of the same file: a mean pixel difference of 8.09.

The fix was obvious once I said it out loud: every GNOME desktop already ships the renderer GNOME itself uses, librsvg. After switching, the difference against Chromium dropped to 0.43.

But "just call a different tool for SVG" exposed the real problem: the app was a pile of if extension == ... branches. Adding a renderer meant touching everything. So I rebuilt the engine around one idea.

Backends declare, the registry decides

Every converter is a Backend. It doesn't get called by name; it declares what it needs and what it can do:

@dataclass(frozen=True) class Requirement: tool: str # executable on PATH, "py:module" (Python package) or "lib:name" (shared library)  packages: dict[str, str] = field(default_factory=dict) # package manager -> package  purpose: str = "" def present(self) -> bool: if self.tool.startswith("py:"): return importlib.util.find_spec(self.tool[3:]) is not None if self.tool.startswith("lib:"): return ctypes.util.find_library(self.tool[4:]) is not None return shutil.which(self.tool) is not None 

A backend lists its requirements (ffmpeg, py:PIL, lib:rsvg-2…) and returns the pairs it can handle right now, as {source format: {targets}}. The registry builds the live table from whatever is installed at this moment:

@lru_cache(maxsize=1) def _table() -> dict[str, dict[str, Backend]]: table: dict[str, dict[str, Backend]] = {} for backend in backends(): try: pairs = backend.pairs() except Exception: # a broken tool must never take the whole app down  continue for src, actions in pairs.items(): slot = table.setdefault(src, {}) for action in actions: slot.setdefault(action, backend) return table 

Three things fall out of this for free:

  • Priority is just order. Backends are registered best-first (Pillow, poppler, Ghostscript, librsvg, ImageMagick, ffmpeg, LibreOffice, pandoc), and setdefault means the first one that can do a conversion owns it. SVG → PNG goes to librsvg; ImageMagick still handles PSD, XCF, HEIC and JPEG XL.
  • The menu never lies. The wheel shows actions_for(format), so a conversion only appears if a backend can do it on this machine.
  • Missing tools become advice. Each requirement knows its package name per package manager, so dotmorph doctor and the error messages can say sudo dnf install poppler-utils instead of "command not found". formats --missing even tells you how many conversions installing a tool would unlock.

On my machine that table holds 440+ conversions across 53 formats. On a minimal install it might be 60, and that's fine.

One conversion, start to finish

The five steps of a conversion: detect, plan, workdir, run, finalize

Whether it comes from the wheel, the CLI, a file-manager quick action or a watched folder, every conversion goes through the same path.

Safe output

The rule: you never see a half-written file, and nothing gets overwritten.

@contextmanager def workdir(out: Path) -> Iterator[Path]: """Hidden scratch dir inside the output dir: same filesystem, so the final move is a rename, big videos don't fill a tmpfs /tmp, and half-written files never show up.""" with tempfile.TemporaryDirectory(prefix=".dotmorph-", dir=out) as tmp: yield Path(tmp) def finalize(tmp: Path, dest: Path, overwrite: bool) -> Path: """Move a finished temp file to its final name. The name is picked under a lock so parallel jobs never claim the same 'name (1).ext'.""" with _finalize_lock: final = unique(dest, overwrite) shutil.move(str(tmp), final) return final 

The scratch folder lives next to the output, not in /tmp. That keeps the final move a cheap rename on the same filesystem, and a 4 GB video doesn't fill a RAM-backed /tmp. Batches run in parallel, so picking photo (1).jpg happens under a lock. Otherwise two jobs can both decide that name is free.

Cancel that actually cancels

Clicking the centre of the wheel cancels. That sounds trivial until the tool you're running has children: LibreOffice spawns helpers, and ffmpeg flushes its encoder for seconds after SIGTERM. Every tool starts in its own session (start_new_session=True), and cancel kills the whole process group:

def _kill_group(proc: subprocess.Popen) -> None: # Partial output is thrown away anyway, so don't wait long for a graceful exit  # (ffmpeg flushing its encoder on SIGTERM can take seconds).  for sig, grace in ((signal.SIGTERM, 0.4), (signal.SIGKILL, 2)): try: os.killpg(proc.pid, sig) except (ProcessLookupError, PermissionError): return try: proc.wait(timeout=grace) return except subprocess.TimeoutExpired: continue 

Cancel went from about 2 seconds to about 0.5.

Honest progress

ffmpeg reports progress with -progress pipe:1, and poppler gets one tick per rendered page. Batches count files. Some jobs can't report anything, and one of them taught me to stop faking it. Making a GIF runs a palette pass that reads the whole video before writing a single frame, so the bar sat at 0% the entire time. Now, when a tool can't report progress, the wheel shows elapsed seconds instead of a fake percentage.

Small things that matter

  • Stream copy first. MOV → MP4 with the same codec is a remux: seconds instead of minutes, and lossless. If the container refuses the codec, it falls back to a transcode.
  • Encoders are probed, not assumed: libx264 → openh264 → mpeg4, VP9 → VP8. Distros ship very different ffmpeg builds.
  • compress never makes things bigger. If the "compressed" file is larger, you get the original back.
  • LibreOffice gets a private profile, so a conversion works while you have LibreOffice open, and those jobs are serialised automatically.

Back to SVG: librsvg without a new dependency

I didn't want to add a Python binding (and its build headaches) just for SVG. librsvg and cairo are already on practically every Linux desktop, so DotMorph calls them directly with ctypes:

class _Libs: def __init__(self) -> None: def load(name: str, fallback: str) -> ctypes.CDLL: return ctypes.CDLL(ctypes.util.find_library(name) or fallback) self.rsvg = load("rsvg-2", "librsvg-2.so.2") self.cairo = load("cairo", "libcairo.so.2") ... r = self.rsvg r.rsvg_handle_new_from_file.restype = vp r.rsvg_handle_new_from_file.argtypes = [ctypes.c_char_p, perr] r.rsvg_handle_render_document.restype = i r.rsvg_handle_render_document.argtypes = [vp, vp, ctypes.POINTER(_Rect), perr] 

Declaring restype and argtypes for every function isn't optional. Without them ctypes assumes int, and on 64-bit a pointer gets silently truncated. That gives you crashes that only show up sometimes.

Two bonuses came with it:

  • Vector PDF. Rendering onto a cairo PDF surface instead of an image surface gives a real vector PDF. Text stays selectable text.
  • The right size. The old path rendered SVGs 1.56× larger than their declared size, because it reused the 150 dpi default meant for PDF pages (150 / 96 = 1.56). SVG now has its own default of 96 dpi, so a 480×300 SVG becomes a 480×300 PNG. --dpi 192 gives you 2×.

And the backend's requirement is simply lib:rsvg-2. If it's missing, ImageMagick quietly takes over SVG again, because it's next in line in the registry.

What I'd tell myself at the start

  1. Make capabilities data, not code paths. Once backends declared what they do, the menu, the CLI, doctor, the shell completions and the docs all came from the same table.
  2. Compare against a reference. A screenshot showed the SVG bug; a number (8.09 → 0.43 against Chromium) proved the fix and keeps "looks fine to me" out of the discussion.
  3. Don't fake progress. An honest timer beats a frozen percentage.

In part 3 we go to the other side: the QML wheel, GPU-drawn ring sectors, particles, and how a daemon makes it open in 0.1 s.