odt2web
Python library for converting OpenDocument Text (.odt) documents into
semantic HTML, with optional CSS and image extraction.
Preserves the semantic and structural intent of the document, not its printed-page appearance.
Table of contents
- Installation
- Basic usage
ConversionResult- Architecture
- Style system
- Code blocks
- Columns
- Assets
- Metadata
- Front matter (SSGs)
- Embedded raw HTML (
:::html) - Security
- CLI
- Configuration (YAML)
- API reference
- Extensibility
- Tests
- Known limitations
Installation
pip install .
Basic usage
from odt2web import convert
result = convert("article.odt")
print(result.html)
print(result.css)
result = convert(
"article.odt",
output_dir="dist/",
css=True,
extract_images=True,
)
From bytes:
from odt2web import convert_bytes
result = convert_bytes(odt_bytes)
ConversionResult
| Attribute | Type | Description |
|---|---|---|
result.html |
str |
The generated HTML. |
result.css |
str | None |
Generated CSS, if css=True. |
result.assets |
list[Asset] |
Extracted images/assets. |
result.metadata |
Metadata |
Document metadata (title, author, description — see Metadata). |
result.front_matter |
dict | None |
Parsed front matter block, if present — see Front matter. |
result.warnings |
list[Warning] |
Non-fatal issues found during conversion. |
Architecture
ODT → Package Reader → Document Model → Semantic Renderer → HTML + CSS
- Package Reader (
odt2web.reader): reads the.odtas a ZIP (content.xml,styles.xml,meta.xml,manifest.xml,Pictures/), without depending on LibreOffice. - Document Model (
odt2web.model): a custom intermediate representation (Document,Section,Heading,Paragraph,Table, ...), decoupled from any output format. - Semantic Renderer (
odt2web.renderer,odt2web.css): converts the model into HTML and generates only the CSS required by the resources actually used.
Style system
Configurable default mapping:
style_map = {
"Title": "h1",
"Heading 1": "h2",
"Heading 2": "h3",
"Heading 3": "h4",
"Quotation": "blockquote",
"Text Body": "p",
}
Custom styles (tag + class):
style_map = {
"Lead": {"tag": "p", "class": "lead"},
"Warning": {"tag": "aside", "class": "warning"},
}
result = convert("article.odt", style_map=style_map)
Style resolution follows the ODT style inheritance chain (a paragraph often
uses an automatic style generated by the editor, e.g. P1, whose parent is
the actual named style) and matches against style_map first, then against
the default mapping above.
Code blocks
For code blocks (consecutive paragraphs using LibreOffice's "Preformatted
Text" style, or any style mapped to pre), the library:
- merges consecutive paragraphs into a single block instead of creating one
<pre>per line; - generates
<pre><code class="language-xxx">...</code></pre>— the markup expected by highlighters; - does not apply bold/italic/etc. formatting to the content — inline formatting inside code would break copy-paste and highlighter tokenization.
The library itself does not perform syntax highlighting — that is handled by the highlighter you already use on your website (client-side or during the SSG build). The library's job is simply to provide HTML in the correct format.
The language of a code block can be specified in two ways:
-
Marker at the beginning of the block (Markdown-style): fencing the block with an opening line of three backticks followed by the language name (e.g.
python) defines the language and is removed from the final HTML. This does not require creating any new style in the ODT. -
Class in
style_map: if you prefer dedicated ODT styles for each language (e.g. "Code Python", "Code JS"), map each one to alanguage-xxxclass:style_map = { "Code Python": {"tag": "pre", "class": "language-python"}, "Code JS": {"tag": "pre", "class": "language-javascript"}, }
Inline code (a word or short snippet within a paragraph) uses LibreOffice's
native "Source Text" character style and is automatically converted to
<code>...</code>, with no additional configuration.
Columns
ODT sections with multiple columns generate:
<section class="odt-columns odt-columns-2">...</section>
.odt-columns-2 {
column-count: 2;
column-gap: 1.27cm;
}
The number of columns and the spacing are read directly from the ODT; text
distribution is delegated to CSS (column-count) and is never calculated
manually.
Assets
result = convert("article.odt", assets_dir="assets/")
dist/
├── index.html
├── style.css
└── assets/
├── image-001.png
└── image-002.jpg
Metadata
result.metadata.title
result.metadata.author
result.metadata.description
Front matter (SSGs)
If the document starts with a block delimited by ---, in the following
format:
---
title: My First Article
date: 2026-08-20
author: Author
tags: Python, Web
categories: Technology
---
(either as consecutive paragraphs — one per line — or as a single paragraph with manual line breaks inserted using Shift+Enter), it is recognized, removed from the normal text flow, and converted to:
<div class="ssg-frontmatter" data-ssg="frontmatter">
<meta data-key="title" content="My First Article">
<meta data-key="date" content="2026-08-20">
<meta data-key="author" content="Author">
<meta data-key="tags" content="Python, Web">
<meta data-key="categories" content="Technology">
</div>
It is always placed as the first content in the generated HTML. The
key-value pairs are also available in result.front_matter (dict). If
there is no block, the value is None and nothing is added to the HTML.
Empty paragraphs and page breaks preceding the block (common in editor
templates) do not prevent detection.
Embedded raw HTML (:::html)
For embeds (iframes, third-party widgets, etc.), instead of introducing a
syntax for each individual feature, the library provides a generic escape
hatch: a literal HTML block delimited by markers, written directly in the
.odt:
:::html
<iframe src="https://example.com/map" width="600" height="400"></iframe>
:::
(This works both as consecutive paragraphs and as a single paragraph with manual line breaks, anywhere in the document.)
It is passed through without escaping, but this is opt-in:
result = convert("article.odt", allow_raw_html=True)
By default (allow_raw_html=False), the block appears escaped as literal
text instead of being executed as HTML, with a warning in
result.warnings — the .odt is treated as untrusted input by default
(see Security). Only enable this for documents from authors
you trust.
The content undergoes basic typographic normalization (curly quotes →
straight quotes) before becoming HTML, since LibreOffice's autocorrect
often changes " to “/”, which would break attributes such as
src="...".
In the CLI: odt2web article.odt --allow-raw-html. In config.yaml:
output.allow_raw_html: true.
Security
The .odt is treated as untrusted input (odt2web.security): text is
always escaped; URLs (href, referenced images) are checked against an
allowlist of permitted schemes (http, https, mailto, tel, relative
URLs) — schemes such as javascript: or file: are removed.
No macros or embedded ODT content are executed. The only escape hatch that
emits unescaped content is the :::html block (above), and only when
explicitly enabled.
CLI
odt2web article.odt
odt2web article.odt -o dist/
odt2web article.odt --css
odt2web article.odt --assets assets/
odt2web article.odt --fragment
odt2web article.odt --allow-raw-html
odt2web article.odt --config config.yaml
odt2web --check article.odt
CLI default: complete HTML document (use --fragment to generate only the
fragment). In the Python API it is the opposite — see document= below.
Configuration (YAML)
output:
document: false
css: true
assets: assets
allow_raw_html: false
columns:
enabled: true
styles:
Title: h1
Heading 1: h2
Heading 2: h3
Quotation: blockquote
Lead:
tag: p
class: lead
convert("article.odt", config="config.yaml")
Priority: explicit argument passed to the call > configuration file > library default.
API reference
convert(
path,
*,
output_dir=None, # writes index.html/style.css/assets to this directory
css=True, # automatically generates CSS
extract_images=True, # extracts embedded images into separate files
assets_dir="assets", # subdirectory (relative to the HTML) for assets
document=False, # True = complete HTML document; False = fragment
style_map=None, # ODT style -> HTML mapping
custom_renderers=None, # additional renderers beyond those registered via @register_renderer
allow_raw_html=False, # enables :::html blocks without escaping
config=None, # path to a YAML configuration file
)
convert_bytes(data, **same_kwargs)— same functionality, using in-memory bytes.check(path)— validates the.odtwithout generating HTML; returnslist[Warning]and raisesInvalidOdtErrorif the package cannot be read.
Extensibility
from odt2web import register_renderer
from odt2web.renderer import render_inline
@register_renderer("custom:note")
def render_note(node, context):
return f"<aside class='note'>{render_inline(node.children, context)}</aside>"
style_map = {"Warning": {"tag": "custom:note"}}
A complete list of available CSS classes can be found here.
Known limitations
- Front matter,
:::htmlblocks, and code block merging operate at the document's "loose" level (outside lists/tables); inside a table cell or list item, each paragraph continues to be processed individually. - Table column widths and endnotes separated from footnotes are not implemented — the architecture (an intermediate model independent of HTML) already supports these without structural changes.
License
AGPL-3.0.
Contributing
Issues and pull requests are welcome.
Metadata
Release files for odt2web 0.8.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | Provenance | Actions |
|---|---|---|---|---|
| odt2web-0.8.0.tar.gz | 38.5 kB | Without provenance | Details for odt2web-0.8.0.tar.gz |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Actions Reset |
|---|---|---|---|---|
| odt2web-0.8.0-py3-none-any.whl | Python 3 | none | any | Details for odt2web-0.8.0-py3-none-any.whl |
Total release size: 79.1 kB
Release files / odt2web-0.8.0.tar.gz
| Download URL | odt2web-0.8.0.tar.gz |
|---|---|
| Size | 38.5 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
fb79f3a3022e73c48ae4d45e1e96bf833d2f28cd867a09919b8c52e764711a28
|
|
BLAKE2b-256 checksum How to use checksums |
2cf4b7e8101ed6acab743d270b88d223884dd65200ffb16eba72aefba92cadc8
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/7.0.0 CPython/3.14.8
|
Release files / odt2web-0.8.0-py3-none-any.whl
| Download URL | odt2web-0.8.0-py3-none-any.whl |
|---|---|
| Size | 40.6 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
9708fdb7aec327a3c44a36b94a4320575866fbb3a85e20ccabf129be62ad9d3e
|
|
BLAKE2b-256 checksum How to use checksums |
4f7e2025172252992c1de1ecf7050a60619e8edd7a0a1fce93affeed651d9b9d
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/7.0.0 CPython/3.14.8
|
Security for odt2web 0.8.0
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
No provenance found
None of the files in this release were uploaded with provenance metadata.
A note on package security
PyPI is a repository for distributing packages, not a service for auditing them. While third-party security researchers and our staff do their best to detect and remove malicious packages, PyPI cannot guarantee the quality, security or suitability of any given package.
To learn more about protecting yourself from malicious actors, visit our package security documentation.