OpenDocument, zipped or flat.

content.xml plus its styles and metadata parts, a markup vocabulary of its own — and a tracked-changes block that 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 — 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, which 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, it moves it into a <text:tracked-changes> block near the top of <office:text>. The text is still there, and '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 and handles the flat single-file XML variants without assuming a zip container is present — 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. 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
curl -X POST https://api.txtfetch.com/v1/extract \
  -H "Authorization: Bearer $TXTFETCH_KEY" \
  -F file=@meeting-minutes.odt
Python
import 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"])
JavaScript
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);
Go
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
curl -X POST "https://api.txtfetch.com/v1/extract?url=https://example.com/docs/project-charter.odt" \
  -H "Authorization: Bearer $TXTFETCH_KEY"
Python
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"])
JavaScript
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);
Go
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": "..."
}

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, and 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 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

more-formats

Stop parsing. Start shipping.

Create an account and get an API key in minutes — the free Hobby plan needs no card.

Get started →