Skip to main content

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

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 .odt as 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:

  1. 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.

  2. Class in style_map: if you prefer dedicated ODT styles for each language (e.g. "Code Python", "Code JS"), map each one to a language-xxx class:

    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 .odt without generating HTML; returns list[Warning] and raises InvalidOdtError if 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, :::html blocks, 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)

Source distribution for odt2web 0.8.0
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)

Table of built distributions (wheels) for odt2web 0.8.0
File Interpreter ABI Platform Actions
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.

Release history Release notifications | RSS feed

This release

0.8.0 This release

2 release files No provenance

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page