Paste Markdown into LibreOffice Writer as real formatting — headings, lists, tables and
links — instead of a wall of literal ## and **.
Status: installable extension, pre-release. Packaged as an
.oxtwith a toolbar button, Edit-menu entry andTools > Add-Onsentry, covered by 24 passing in-office tests including a full clipboard-to-document round trip. Not yet published to the LibreOffice Extensions site — see R1.
Markdown is how text arrives now: from chat clients, LLMs, note apps, code review, docs repos. Writer has no way to interpret it on paste, so you paste raw syntax and reformat by hand.
LibreOffice 26.2 added a native Markdown file filter, which handles
File > Open document.md. It does nothing for pasting a fragment into a document you are
already writing. That gap is what this fills.
- LibreOffice 26.2 or newer, Writer. The native Markdown filter arrived in 26.2 and this builds directly on it.
- Nothing else. No
pip install, no bundled parser, no network access at any point.
Developed and tested against the Flatpak build (org.libreoffice.LibreOffice) on Linux.
Other platforms and packaging should work but are not yet verified.
Download the .oxt from the releases page and open it with LibreOffice, or build it
yourself:
git clone https://github.com/cclambie/libre-office-paste-markdown.git
cd libre-office-paste-markdown
./tools/build_oxt.sh # -> build/libre-paste-md-<version>.oxt
./tools/install_oxt.sh # installs it via unopkgClose LibreOffice first — installing while it runs silently does nothing.
Then copy some Markdown and use any of:
- the Paste as Markdown toolbar button
- Edit > Paste as Markdown, next to Paste Special
- Tools > Add-Ons > Paste as Markdown
No keyboard shortcut is bound by default, because Ctrl+Shift+V belongs to Paste Special
and anything else risks colliding with your own bindings. Bind one via
Tools > Customize > Keyboard if you want it.
To uninstall: ./tools/install_oxt.sh --remove, or use the Extension Manager.
It does not parse Markdown. It hands the clipboard text to LibreOffice's own Markdown filter and inserts the result at the cursor:
cursor.insertDocumentFromURL(tmp_url, (PropertyValue("FilterName", "Markdown"),))Markdown compliance is therefore inherited from the filter (MD4C, CommonMark + GFM) rather than reimplemented. The trade-off is the hard 26.2+ requirement.
insertDocumentFromURL corrupts the document and its undo record when it has to merge
its first paragraph into an existing one. Measured behaviour of the naive approach:
| Insertion point | Result after one undo |
|---|---|
| Start of a paragraph with content after it | everything lost |
| Mid-paragraph | adjacent text lost permanently |
| Selection collapsed to its end (still mid-paragraph) | adjacent text still lost |
| End of a paragraph, even with paragraphs following | lossless |
So the engine never inserts at an arbitrary offset. It first manoeuvres onto a paragraph boundary, picking one of three strategies:
| 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 |
It then removes the empty paragraph the filter appends, and restores the host paragraph's
style, which the filter otherwise overwrites. The whole operation is wrapped in a single
undo context, so one Ctrl+Z reverts it exactly.
Deliberately staged behind a GitHub release: the extension needs LibreOffice 26.2+, which
most people browsing the site cannot yet run, and early "doesn't work" ratings from
ineligible installs would outlive the situation that caused them. See
PUBLISHING.md for the process and the trigger for going ahead.
Strip YAML front matter (planned default: on). An actionable message on LibreOffice older than 26.2 rather than a confusing failure.
Verify on deb/rpm LibreOffice, Windows and macOS. The bundled Python version differs by platform, so the syntax floor needs measuring rather than assuming.
Currently out of scope: remote URLs turn a paste into a network request, which is a privacy decision needing explicit opt-in, and local paths make documents non-portable. Alt text is rendered for now.
Would mean vendoring a Markdown parser and emitting Writer content directly — an order of magnitude more work, reimplementing what the native filter already does well. Deliberately not planned.
Pasting a Markdown table into a spreadsheet range. Impress and Draw are not planned.
Recorded so they are not rediscovered as bugs:
- Table cells (was D5). Cells were believed to flatten Markdown to plain text. They do
not — headings, list membership and the cell's own style all survive. The original
finding came from reading
cell.getString(), which simply joins paragraphs with\n. - List ids on headings (was D4). Headings appeared to carry a spurious list id. They do
not: a heading created by hand, with no paste involved, already carries
NumberingRules. That is Writer's own outline numbering, not something this introduces. - Host paragraph restyling (was D6). Inserting used to overwrite the host paragraph's
style, silently converting a heading or quote to
Text body. Fixed — the style is captured and restored, except where the filter legitimately claims an empty paragraph.
src/pastemd/insert.py boundary-safe insertion
src/pastemd/clipboard.py clipboard text, platform flavour handling
src/pastemd/paste.py guards, temp file, undo grouping
src/pastemd/report.py messages and the error log
oxt/ extension manifest, Addons.xcu, icons
oxt/python/pastemd_component.py UNO dispatch component behind the button
scripts/paste_markdown.py Tools > Macros entry point (development)
tests/integration/ in-office test suites
tools/ build, install and test scripts
Errors the user should not have to read go to ~/.cache/libre-paste-md/error.log; a
Flatpak GUI app has no console, so stderr is simply lost.
./tools/run_office_tests.sh # all suites
./tools/run_office_tests.sh tests/integration/test_r1.py # one suiteTests cannot run while LibreOffice is open. LibreOffice is single-instance: a second invocation is forwarded to the running one, ignoring the isolated test profile. The runner detects this and refuses rather than failing confusingly.
Tests run in-process via LibreOffice's Python script provider, in a throwaway profile,
because the external UNO socket bridge does not work under Flatpak. See
CLAUDE.md for the details and the traps worth not rediscovering, and
SCOPE.md for the design decisions and their evidence.
MPL-2.0, matching LibreOffice. No vendored dependencies.