Building ‘Paste as Markdown’ for LibreOffice

By Craig Lambie ·

First published on craiglambie.com.

Markdown is how text arrives now. Chat clients, LLMs, notes apps, code review, docs repos — all of it comes out as ## and **. And every time I pasted any of it into LibreOffice Writer, I got exactly that: a wall of literal syntax I then reformatted by hand.

So I built a Paste as Markdown button. It took an afternoon to make work and considerably longer to make safe, which is the interesting part.
The result is libre-office-paste-markdown, MPL-2.0, requires LibreOffice 26.2+.

First: don’t write a parser

My initial plan was the obvious one — vendor a CommonMark parser, walk the token stream,
emit Writer content through the UNO text API. That’s a real project: paragraph styles,
nested list numbering via NumberingRules, table construction, and a compliance test
corpus you now own forever.

Then I checked what LibreOffice 26.2 actually ships, and found this in writer.xcd:

“Markdown -> Flags: IMPORT EXPORT ALIEN Type: generic_Markdown DocumentService: com.sun.star.text.TextDocument “

A native, bidirectional Markdown filter, built on MD4C. It landed in 26.2 for
File → Open document.md. Nobody had wired it up to paste.

I ran a representative document through it and checked the resulting flat ODF. Headings
became Heading_20_1 with correct outline levels. Nested bullet and ordered lists became
real text:list structures. Blockquotes became Quotations. GFM pipe tables became actual
Writer tables. Links got Internet_20_link.

So the entire extension became one line:

“python cursor.insertDocumentFromURL(tmp_url, (PropertyValue(“FilterName”, “Markdown”),)) “

Write the clipboard text to a temp file, hand it to the filter, insert at the cursor.
CommonMark and GFM compliance inherited rather than reimplemented, and no dependencies to
vendor. The cost is a hard LibreOffice 26.2 requirement, which I took deliberately.

That one line is also where the trouble starts.

The bug

Early on I wrote a test that pasted into a document containing the word original, then
pressed undo once, and checked the document was back to original.

It came back empty.

My first write-up said “undo removes pre-existing content too”. That was wrong — or rather,
it was an inference I hadn’t earned. I had measured the state before the paste and
after the undo, and nothing in between. Two completely different bugs fit that data: the
undo over-reverting, or the paste itself destroying content and the undo faithfully
restoring an empty document.

So I measured the middle. Four scenarios, capturing document state before the paste, after
the paste, and after each undo:

| Scenario | After paste | After one undo | |
|—|—|—|—|
| Cursor at start of original | ’Horiginal\nalpha\n’ | ” | total loss |
| Cursor at end of original | ’original\nH\nalpha\n’ | ’original’ | correct |
| Selection deleted, paste into gap | ’KEEP KEEP\nH\nalpha\n’ | ’KEEP replaceme’ | data loss |
| Control: plain insertString in the same undo context | ’XXXoriginal’ | ’original’ | correct |

Look at the first row. The Markdown heading # H and the existing word original have
been merged into a single paragraph: Horiginal. And look at the third: after undo, the
trailing KEEP is gone permanently. A second undo doesn’t bring it back. The stack is
empty. That text is simply destroyed.

The control row matters most. A plain insertString inside the identical undo context
behaves perfectly, which clears the undo machinery of suspicion and points squarely at
insertDocumentFromURL.

The pattern: it only misbehaves when the insertion point is not a clean paragraph
boundary
and the filter has to merge its first paragraph into an existing one. That merge
writes a corrupt undo record. Where no merge is needed, both the insert and the undo are
exactly right.

This is a nasty failure mode. It doesn’t throw. It doesn’t warn. It produces a document
that looks fine, and destroys a few words somewhere else — and the undo you’d reach for is
the thing that finalises the loss.

The fix I would have shipped

The intuitive repair is to normalise the cursor first: if there’s a selection, collapse it
to its end, then paste there. Clean, one line, obviously correct.

I tested it before writing it:

“E: collapse selection to END, paste there (mid-paragraph) before paste : ‘KEEP replaceme KEEP’ after 1 undo : ‘KEEP replaceme’ –> FAIL — CONTENT CHANGED/LOST “

Still destroys the text. Collapsing to the end of the selection leaves the cursor in the
middle of a paragraph, which is the actual trigger. The intuition was directionally right
and specifically useless.

What does work is landing on a paragraph boundary. Two verified-lossless approaches:

“H: cursor at end of a paragraph, with more paragraphs after -> lossless G: delete selection, SPLIT the paragraph, paste at the split -> lossless “

Scenario H matters because it proves “end of paragraph” is generally safe, not an artifact
of being at the end of the document.

So the extension never inserts at an arbitrary offset. It manoeuvres onto a boundary first:

| Cursor is | Strategy |
|—|—|
| at a paragraph end | insert directly — already safe |
| at a paragraph start with one before it | insert at the end of the previous paragraph |
| anywhere else | split the paragraph, insert at the end of the first half |

Then it removes the empty paragraph the filter appends, and restores the host paragraph’s
style, which the filter otherwise silently overwrites — paste into a heading and it would
quietly become body text.

Ten regression tests now cover this, and every one asserts the same property: after
pasting and pressing undo exactly once, the document is byte-identical to what it was
before.

Three bugs that weren’t

Along the way I “found” three more defects. All three were me reading the wrong thing.

Lists were being flattened. I dumped the paragraphs after a paste and saw alpha and
beta come back as Text body, not List Bullet. Lists destroyed. Except in ODF, list
membership doesn’t live in the paragraph style at all — it lives in NumberingRules and
ListId. The lists were perfectly intact. Checking ParaStyleName alone told me nothing.

Table cells flattened everything to plain text. The cell contained
’Heading\nBody\nalpha\nbeta\n’ — clearly just text with newlines. Except getString() on
a cell joins its paragraphs with newlines. Enumerating the cell properly showed
Heading 1, both list items with their list membership, and the cell’s own style intact.
Same mistake, second time.

Headings came back carrying a spurious list id. This one I only settled by building a
control: create a Heading 1 by hand, never paste into it, and measure. It already carries
NumberingRules. That’s Writer’s own outline numbering. Nothing to do with the extension.

Three false alarms, one root cause: I inspected a convenient property instead of the one
that actually encodes the thing I cared about. The control experiment in the third case is
what I should have reached for in the first.

Packaging: three silent failures

Turning a working macro into an installable .oxt produced three more failures that all
shared a characteristic — nothing threw where you could see it.

The view cursor. The macro read the caret via controller.getViewCursor() and passed it
straight to the insert. A view cursor doesn’t implement XDocumentInsertable, so
insertDocumentFromURL simply isn’t on it. AttributeError, on the very first real click.
My tests had all driven the engine with a text cursor — they tested the machinery and
never the entry path. The fix is one line (view.getText().createTextCursorByRange(view)),
which is exactly the sort of thing that makes it embarrassing rather than interesting.

pythonpath/. LibreOffice is documented to add an extension’s pythonpath/ directory
to sys.path when loading a Python component from it. It didn’t. The dispatch resolved
fine, the button “worked”, and absolutely nothing happened — the import failed inside a
handler that had nothing to report with, because the reporting module was in the package
that failed to import. The component now locates itself explicitly through the
PackageInformationProvider singleton and logs bootstrap failures on its own.

__file__ is a URL. In LibreOffice’s Python script provider, __file__ isn’t a
filesystem path — it’s file:///home/you/…. Feed that to os.path.abspath() and you get
a plausible-looking path glued to the current working directory, no exception, just a
directory that doesn’t exist. That would have broken package resolution for every user.

All three were caught by one test: put Markdown on the real system clipboard, dispatch the
actual toolbar command, and assert the document changed. Everything before that tested
components in isolation and would have shipped a button that did nothing.

Testing LibreOffice is its own adventure

Two things cost me real time.

The UNO socket bridge doesn’t work under Flatpak. resolve() succeeds, you get a
service manager, and then any createInstance call dies with Binary URP bridge disposed during call — every service, while soffice.bin stays alive and the port stays open. The
answer is to stop using it: run tests in-process via the Python script provider, which
is also how the extension itself runs, so the tests exercise the real path.

LibreOffice is single-instance, and it lies about it. With an instance already running,
a second invocation is forwarded to it and -env:UserInstallation is ignored entirely. The
symptom is exit code 0, no output, no results — while the script is actually being looked
up in your real profile and errors pop up in your open window. Even –convert-to
silently does nothing. The test runner now refuses to start if pgrep -x soffice.bin
matches, because failing loudly beats an hour of confusion.

I’ll also admit that an early version of my test script began with pkill -f soffice.bin,
which matched its own command line and killed the shell running it. That was the mysterious
exit code 144. pkill -x.

Where it’s at

libre-office-paste-markdown v0.1.0 — MPL-2.0, LibreOffice 26.2+, no
dependencies, no network access. Toolbar button, an entry in the Edit menu next to Paste
Special, and one under Tools → Add-Ons. The whole paste is a single undo step.

24 tests, all in-process against a real LibreOffice, including the full clipboard round
trip.

Honest caveat: it’s verified on Flatpak LibreOffice 26.2.3.2 on Linux only. Other platforms
should work but I haven’t tested them, which is also why it isn’t on the LibreOffice
Extensions site yet — that, and the fact that almost nobody browsing it can run 26.2 today.

The thing I’d take away

The naive version of this extension worked. It pasted Markdown, it looked right, and it
destroyed a few words of your document every time you pasted mid-paragraph — with the undo
key as the trigger that made the loss permanent.

Nothing about testing it by using it would have caught that. It took writing down the
property that actually mattered — paste, undo once, expect the document unchanged — and
then measuring the state at each step rather than at the ends.

And every time I trusted a quick reading over a measurement, I was wrong: three phantom
bugs from checking the wrong property, and one obvious fix that didn’t work. The
measurements were never the slow path.

Comments

Comments open once this piece is posted to Mastodon.