The 97-2003 binary formats, read properly.
OLE2 Compound File Binary Format isn't a smaller version of OOXML — it's a different filesystem-in-a-file, and .doc text isn't stored in reading order.
the-problem
Before OOXML, Office files were OLE2 — the Compound File Binary Format — which is essentially a tiny FAT filesystem embedded in one file, streams and all. It shares nothing structurally with the zip-of-XML that .docx/.xlsx/.pptx use, so a parser built for the modern formats throws outright on a legacy one rather than degrading gracefully. Word's .doc format compounds this: the document text sits in a WordDocument stream as a piece table — a list of byte ranges that must be reassembled in a specific order to produce readable text. Read the stream's raw bytes start to finish and you get scrambled, out-of-order text, not garbage exactly, but wrong. There's also a classic naming trap: plenty of files with a .doc extension are actually RTF or a renamed OOXML file underneath, and extension-based routing gets those wrong before extraction even starts.
one-request-solution
txtfetch detects OLE2's compound-file signature from the file's actual bytes — not its extension — and routes it to Tika's POI-backed legacy parsers (HWPF for .doc, HSSF for .xls, HSLF for .ppt), which understand the piece table and reassemble .doc text in the correct reading order. Because detection is byte-based, a file named .doc that's really RTF or renamed OOXML still gets parsed correctly instead of misrouted.
curl -X POST https://api.txtfetch.com/v1/extract \
-H "Authorization: Bearer $TXTFETCH_KEY" \
-F file=@1998-contract-template.docimport os
import requests
with open("1998-contract-template.doc", "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("1998-contract-template.doc")]);
const form = new FormData();
form.append("file", file, "1998-contract-template.doc");
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("1998-contract-template.doc")
if err != nil {
panic(err)
}
defer f.Close()
var body bytes.Buffer
writer := multipart.NewWriter(&body)
part, err := writer.CreateFormFile("file", "1998-contract-template.doc")
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/archive/legacy-invoice.xls" \
-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/archive/legacy-invoice.xls"},
)
print(r.json()["extracted_text"])const endpoint = new URL("https://api.txtfetch.com/v1/extract");
endpoint.searchParams.set("url", "https://example.com/archive/legacy-invoice.xls");
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/archive/legacy-invoice.xls")
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
.doc.xls.ppt.pub.wpd
faq
- Why does my .doc file come out as scrambled or out-of-order gibberish?
- The legacy .doc format stores text as a piece table — byte ranges in the WordDocument stream that aren't laid out in reading order. A byte-scraping extractor that reads the stream start-to-finish gets the pieces in the wrong order. Tika's HWPF parser reassembles them correctly.
- Do modern, OOXML-only libraries support .doc and .xls at all?
- Usually not — many libraries built only for .docx/.xlsx/.pptx throw an error on an OLE2 file rather than degrading. txtfetch routes OLE2 signatures to dedicated legacy parsers (POI's HWPF/HSSF/HSLF) instead.
- What if a file is named .doc but is actually RTF or a renamed OOXML file?
- txtfetch detects the real format from the file's byte signature, not its extension, so a mislabeled file still routes to the correct parser.
- Are .pub (Publisher) and .wpd (WordPerfect) files supported too?
- Yes — both are legacy binary formats handled through the same Apache Tika pipeline as .doc/.xls/.ppt.
go-further
Stop parsing. Start shipping.
Create an account and get an API key in minutes — the free Hobby plan needs no card.
Get started →