https://txtfetch.com/extract/odf/
OpenDocument, zipped or flat.
content.xml plus its styles and metadata parts, a markup vocabulary of its own. And a tracked-changes block means struck-out text is still in the file you're about to extract.
the-problem
OpenDocument files are a zip archive containing content.xml, the actual document text and structure, plus separate styles.xml and meta.xml parts. Text lives inside <text:p> paragraph and <text:span> run elements. That's comparable to OOXML's structure, but with its own vocabulary. Not every ODF file is even zipped. The 'flat' variants, .fodt and similarly flat ODS/ODP, are plain single-file XML with no zip container at all. That breaks any extractor that assumes 'ODF means unzip first'. And then there's tracked changes, the trap nobody expects. Deleting a paragraph in LibreOffice with change tracking on doesn't remove it from the file. Instead it moves into a <text:tracked-changes> block near the top of <office:text>. The text is still there, so 'the text of this document' stops being a question with one answer.
one-request-solution
txtfetch reads content.xml's paragraph and span structure directly. It handles the flat single-file XML variants without assuming a zip container is present. So a .fodt goes through the same endpoint as a zipped .odt. On tracked changes it does the honest thing rather than the convenient one. The document's full text content comes back, deletions included, because that text is genuinely in the file. This is worth knowing before you index legal or HR documents. If you only want accepted text, resolve the changes in LibreOffice, or strip <text:tracked-changes> from content.xml, before extracting.
curl -X POST https://api.txtfetch.com/v1/extract \
-H "Authorization: Bearer $TXTFETCH_KEY" \
-F file=@meeting-minutes.odtimport os
import requests
with open("meeting-minutes.odt", "rb") as f:
r = requests.post(
"https://api.txtfetch.com/v1/extract",
headers={"Authorization": f"Bearer {os.environ['TXTFETCH_KEY']}"},
files={"file": f},
)
print(r.json()["extracted_text"])import { readFile } from "node:fs/promises";
const file = new Blob([await readFile("meeting-minutes.odt")]);
const form = new FormData();
form.append("file", file, "meeting-minutes.odt");
const res = await fetch("https://api.txtfetch.com/v1/extract", {
method: "POST",
headers: { Authorization: `Bearer ${process.env.TXTFETCH_KEY}` },
body: form,
});
const { extracted_text } = await res.json();
console.log(extracted_text);package main
import (
"bytes"
"encoding/json"
"fmt"
"io"
"mime/multipart"
"net/http"
"os"
)
type extractResponse struct {
Status string `json:"status"`
ExtractedText string `json:"extracted_text"`
}
func main() {
f, err := os.Open("meeting-minutes.odt")
if err != nil {
panic(err)
}
defer f.Close()
var body bytes.Buffer
writer := multipart.NewWriter(&body)
part, err := writer.CreateFormFile("file", "meeting-minutes.odt")
if err != nil {
panic(err)
}
if _, err := io.Copy(part, f); err != nil {
panic(err)
}
writer.Close()
req, err := http.NewRequest("POST", "https://api.txtfetch.com/v1/extract", &body)
if err != nil {
panic(err)
}
req.Header.Set("Authorization", "Bearer "+os.Getenv("TXTFETCH_KEY"))
req.Header.Set("Content-Type", writer.FormDataContentType())
resp, err := http.DefaultClient.Do(req)
if err != nil {
panic(err)
}
defer resp.Body.Close()
var result extractResponse
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
panic(err)
}
fmt.Println(result.ExtractedText)
}Or skip the download. Pass a url parameter and txtfetch fetches the document server-side:
curl -X POST "https://api.txtfetch.com/v1/extract?url=https://example.com/docs/project-charter.odt" \
-H "Authorization: Bearer $TXTFETCH_KEY"import os
import requests
r = requests.post(
"https://api.txtfetch.com/v1/extract",
headers={"Authorization": f"Bearer {os.environ['TXTFETCH_KEY']}"},
params={"url": "https://example.com/docs/project-charter.odt"},
)
print(r.json()["extracted_text"])const endpoint = new URL("https://api.txtfetch.com/v1/extract");
endpoint.searchParams.set("url", "https://example.com/docs/project-charter.odt");
const res = await fetch(endpoint, {
method: "POST",
headers: { Authorization: `Bearer ${process.env.TXTFETCH_KEY}` },
});
const { extracted_text } = await res.json();
console.log(extracted_text);package main
import (
"encoding/json"
"fmt"
"net/http"
"net/url"
"os"
)
type extractResponse struct {
Status string `json:"status"`
ExtractedText string `json:"extracted_text"`
}
func main() {
endpoint, err := url.Parse("https://api.txtfetch.com/v1/extract")
if err != nil {
panic(err)
}
q := endpoint.Query()
q.Set("url", "https://example.com/docs/project-charter.odt")
endpoint.RawQuery = q.Encode()
req, err := http.NewRequest("POST", endpoint.String(), nil)
if err != nil {
panic(err)
}
req.Header.Set("Authorization", "Bearer "+os.Getenv("TXTFETCH_KEY"))
resp, err := http.DefaultClient.Do(req)
if err != nil {
panic(err)
}
defer resp.Body.Close()
var result extractResponse
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
panic(err)
}
fmt.Println(result.ExtractedText)
}{
"status": "success",
"extracted_text": "..."
}what-comes-back
The corpus behind /diff has no recorded .odt document yet, so we have nothing honest to show you here. Run one of your own instead. The free converter reads the file in your browser, and nothing is uploaded.
formats-covered
.odt.ods.odp.fodt.otp.ott
faq
- Does extracted text include content that was deleted via tracked changes?
- Yes, and it's worth planning for. OpenDocument keeps deleted text in a <text:tracked-changes> block instead of removing it from the file. Extraction returns the document's full text content, deletions included. Because that block sits near the start of <office:text>, a struck-out paragraph can even appear ahead of the text that's actually in the document. If your index must hold only accepted text, accept or reject the changes in LibreOffice first, or filter <text:tracked-changes> out of content.xml yourself.
- Are the flat XML variants like .fodt supported?
- Yes. Flat ODF files are single-file XML with no zip container, unlike regular .odt/.ods/.odp. txtfetch's parser handles both the zipped and flat forms.
- How is this different from a .docx or .pptx?
- Same general shape: a container with a structured markup part. But it's a completely different vocabulary and packaging, produced by LibreOffice/OpenOffice/Google Docs' ODF export rather than Microsoft's OOXML.
- Are .ott and .otp templates supported the same way?
- Yes. Templates use the same content.xml structure as their non-template counterparts and go through the same endpoint.
go-further
- Read the .odt guide →
- Drop a real .odt (or flat .fodt) and see the exact text it extracts to, free →
- Drop a real .ods (or flat .fods) and see every sheet's text, free →
- Drop a real .odp (or flat .fodp) and see its slide text and speaker notes, free →
- API quickstart →
- How the pipeline works →
- Extract it from your language →
- Get an API key →
more-formats
Send a real .odt through it.
One HTTP call returns the text. Read one in your browser first, for free.
Get an API key →