001Notes
Speeding Up pdftotext When You Only Want The Text
On this page46
A benchmark-backed note on making PDF text extraction faster with cheaper utility output, lighter ordered text ingestion, image-skip logic, page threading, and an unordered direct-text mode.
Article details
- Status
- Benchmark Backed Draft
- Subcategory
- PDF Text Extraction
- Last reviewed
- 26 Aug 2026
46 sections
0. Abstract
How fast can PDF text extraction get when the caller wants decoded text
without expensive layout recovery?
| File | Role |
|---|---|
utils/CMakeLists.txt | link pdftotext against threads |
utils/pdftotext.1 | document new CLI flags |
utils/pdftotext.cc | CLI, threading, unordered mode, XML/TSV/bbox fast paths |
poppler/TextOutputDev.h | fast-mode data layout and API hooks |
poppler/TextOutputDev.cc | ordered-path extraction fast paths |
poppler/Gfx.cc | skip image decoding for text-only extraction |
1. Baseline And Choke Points
character ingestion
-> text objects
-> ordering / grouping
-> utility formatting
-> final output
2. Link pdftotext Against Threads
pdftotext Against ThreadsOld Shape
Why That Matters
Patch Shape
# pdftotext
+find_package(Threads REQUIRED)
set(pdftotext_SOURCES ${common_srcs}
pdftotext.cc printencodings.cc
)
add_executable(pdftotext ${pdftotext_SOURCES})
-target_link_libraries(pdftotext ${common_libs})
+target_link_libraries(pdftotext ${common_libs} Threads::Threads)
What Changed
3. Add -threads And -unordered
-threads And -unorderedOld Shape
Patch Shape
#include "PDFDocFactory.h"
+#include "OutputDev.h"
#include "TextOutputDev.h"
+#include <atomic>
+#include <mutex>
+#include <optional>
+#include <thread>
+#include <vector>
...
+static bool unordered = false;
+static int threadCount = 1;
...
+{ .arg = "-unordered", .kind = argFlag, .val = &unordered, .size = 0,
+ .usage = "emit decoded text in arbitrary order without layout reconstruction" },
+{ .arg = "-threads", .kind = argInt, .val = &threadCount, .size = 0,
+ .usage = "number of worker threads for page extraction (default is 1, 0 uses hardware concurrency)" },
+.B \-unordered
+Emit decoded text in arbitrary order without layout reconstruction.
+Only plain text output is supported.
...
+.BI \-threads " number"
+Use multiple worker threads for page extraction. The default is 1. Use
+0 to select the hardware concurrency. Reading a PDF from stdin always
+uses one thread.
What Changed
4. Stop Wasting Time In XML, bbox, And TSV Output
Old Shape
word -> std::string
-> replace &
-> replace '
-> replace "
-> replace <
-> replace >
Patch Shape
static bool hasXmlSpecialScalar(const char *s, size_t len)
{
for (size_t i = 0; i < len; ++i) {
switch (s[i]) {
case '&':
case '\'':
case '"':
case '<':
case '>':
return true;
default:
break;
}
}
return false;
}
__attribute__((target("avx2")))
static bool hasXmlSpecialAvx2(const char *s, size_t len)
{
const __m256i amp = _mm256_set1_epi8('&');
const __m256i apos = _mm256_set1_epi8('\'');
const __m256i quot = _mm256_set1_epi8('"');
const __m256i lt = _mm256_set1_epi8('<');
const __m256i gt = _mm256_set1_epi8('>');
size_t i = 0;
for (; i + 32 <= len; i += 32) {
const __m256i v = _mm256_loadu_si256(reinterpret_cast<const __m256i *>(s + i));
__m256i match = _mm256_or_si256(_mm256_cmpeq_epi8(v, amp), _mm256_cmpeq_epi8(v, apos));
match = _mm256_or_si256(match, _mm256_cmpeq_epi8(v, quot));
match = _mm256_or_si256(match, _mm256_cmpeq_epi8(v, lt));
match = _mm256_or_si256(match, _mm256_cmpeq_epi8(v, gt));
if (_mm256_movemask_epi8(match)) {
return true;
}
}
return hasXmlSpecialScalar(s + i, len - i);
}
static void appendWordText(std::string &out, const TextWord *word,
const UnicodeMap *uMap, bool utf8Map)
{
if (utf8Map) {
bool ascii = true;
for (int i = 0; i < word->getLength(); ++i) {
if (*word->getChar(i) >= 0x80) {
ascii = false;
break;
}
}
if (ascii) {
out.reserve(out.size() + word->getLength());
for (int i = 0; i < word->getLength(); ++i) {
out.push_back(static_cast<char>(*word->getChar(i)));
}
return;
}
}
/* generic path */
}
-const std::unique_ptr<std::string> wordText = word->getText();
-const std::string myString = myXmlTokenReplace(wordText->c_str());
-fprintf(f, "...>%s</word>\n", myString.c_str());
+wordText.clear();
+escapedWordText.clear();
+appendWordText(wordText, word, uMap, utf8Map);
+appendXmlTokenReplace(escapedWordText, wordText.data(), wordText.size());
+fprintf(f, "...>%s</word>\n", escapedWordText.c_str());
What Changed
5. Add A Fast Extraction Mode Inside TextOutputDev
TextOutputDevOld Shape
Patch Shape: Split Text State From Render State
-TextWord(const GfxState *state, int rotA, double fontSize);
+TextWord(const GfxState *state, int rotA, double fontSize, bool fastModeA);
...
struct CharInfo
{
Unicode text;
- CharCode charcode;
int charPos;
double edge;
TextFontInfo *font;
+};
+struct RenderInfo
+{
+ CharCode charcode;
Matrix textMat;
};
std::vector<CharInfo> chars;
+std::vector<RenderInfo> renderInfos;
+bool fastMode;
-TextWord::TextWord(const GfxState *state, int rotA, double fontSizeA)
+TextWord::TextWord(const GfxState *state, int rotA, double fontSizeA, bool fastModeA)
{
+ fastMode = fastModeA;
- invisible = state->getRender() == 3;
+ invisible = !fastMode && state->getRender() == 3;
...
- chars.push_back(CharInfo{ .text = u, .charcode = c, .charPos = charPosA, .edge = 0.0, .font = fontA, .textMat = textMatA });
+ chars.push_back(CharInfo{ .text = u, .charPos = charPosA, .edge = 0.0, .font = fontA });
+ if (!fastMode) {
+ renderInfos.push_back(RenderInfo{ .charcode = c, .textMat = textMatA });
+ }
}
Patch Shape: Reuse TextPool
TextPool TextPool::~TextPool()
+{
+ clear();
+}
+
+void TextPool::clear()
{
for (auto &wordList : pool) {
delete wordList.head;
}
- pool.resize(0);
+ pool.clear();
+ minBaseIdx = 0;
+ maxBaseIdx = -1;
}
Patch Shape: Cut Work In TextPage::addChar
TextPage::addChar-curWord = new TextWord(state, rot, curFontSize);
+curWord = new TextWord(state, rot, curFontSize, fastMode);
...
-Matrix mat;
+Matrix mat {};
+const bool singleUnicode = uLen == 1;
+const Unicode u0 = singleUnicode ? u[0] : 0;
...
-if (uLen == 1 && UnicodeIsWhitespace(u[0])) {
+if (singleUnicode) {
+ const bool asciiWhitespace = u0 == 0x20 || (u0 >= 0x09 && u0 <= 0x0d);
+ if (asciiWhitespace || (u0 >= 0x80 && UnicodeIsWhitespace(u0))) {
+ charPos += nBytes;
+ endWord();
+ return;
+ }
+ if (u0 == static_cast<Unicode>(0x0)) {
+ charPos += nBytes;
+ return;
+ }
+}
...
-state->getFontTransMat(...);
+if (!fastMode) {
+ state->getFontTransMat(...);
+}
Patch Shape: UTF-8 ASCII Fast Dumping
+int TextPage::dumpFragment(const Unicode *text, int len, const UnicodeMap *uMap,
+ bool utf8Map, GooString *s) const
+{
+ if (utf8Map && primaryLR) {
+ for (int i = 0; i < len; ++i) {
+ if (text[i] >= 0x80) {
+ return reorderText(text, len, uMap, primaryLR, s, nullptr);
+ }
+ }
+ for (int i = 0; i < len; ++i) {
+ s->push_back(static_cast<char>(text[i]));
+ }
+ return len;
+ }
+ ...
+}
+void TextOutputDev::enableFastTextExtraction(bool fastModeA)
+{
+ fastMode = fastModeA;
+ if (text) {
+ text->setFastMode(fastMode);
+ }
+}
What Changed
6. Skip Image Decoding For Text-Only Extraction
Old Shape
Patch Shape
+static bool skipTextOnlyImageData(Stream *str, int width, int height,
+ int bits, int components, bool inlineImg)
+{
+ if (!inlineImg) {
+ str->close();
+ return true;
+ }
+ while (remaining > 0) {
+ const unsigned int chunk =
+ remaining > UINT_MAX ? UINT_MAX : static_cast<unsigned int>(remaining);
+ const unsigned int discarded = str->discardChars(chunk);
+ remaining -= discarded;
+ if (discarded != chunk) {
+ break;
+ }
+ }
+ str->close();
+ return true;
+}
...
+if (!out->needNonText()) {
+ const int components = mask ? 1 : textOnlyImageComponents(dict, csMode);
+ if (skipTextOnlyImageData(str, width, height, bits, components, inlineImg)) {
+ return;
+ }
+}
What Changed
7. Add Page-Level Threading For Ordered Extraction
Old Shape
Why The Concurrency Shape Matters
one worker
-> one PDFDoc
-> one page-local output buffer
-> merge by page order at the end
Patch Shape: Validate Modes And Compute Worker Count
+if (threadCount < 0) {
+ error(errCommandLine, -1, "Bogus value provided for -threads");
+ return 99;
+}
+if (unordered && hasUnorderedIncompatibleOptions()) {
+ error(errCommandLine, -1, "-unordered can only be used with plain text output ...");
+ return 99;
+}
...
+const int pageCount = lastPage - firstPage + 1;
+const int nThreads = getEffectiveThreadCount(threadCount, pageCount,
+ fileName.compare("fd://0") == 0);
Patch Shape: Parallel Page Renderers
static bool renderPagesInParallel(const GooString &fileName,
const std::optional<GooString> &ownerPW,
const std::optional<GooString> &userPW,
int first, int last, int nThreads,
PageRenderMode mode,
EndOfLineKind textEOL,
EndOfLineHyphenMode hyphenMode,
std::vector<std::string> *pageOutputs)
{
const int pageCount = last - first + 1;
pageOutputs->clear();
pageOutputs->resize(pageCount);
std::atomic<int> nextPage{ first };
std::atomic<bool> failed{ false };
std::vector<std::thread> workers;
workers.reserve(nThreads);
for (int i = 0; i < nThreads; ++i) {
workers.emplace_back([&]() {
std::unique_ptr<PDFDoc> workerDoc =
PDFDocFactory().createPDFDoc(fileName, ownerPW, userPW);
if (!workerDoc || !workerDoc->isOk()) {
failed.store(true);
return;
}
while (!failed.load()) {
const int page = nextPage.fetch_add(1);
if (page > last) {
break;
}
std::string pageOutput;
if (!renderPage(workerDoc.get(), page, mode, textEOL,
hyphenMode, &pageOutput)) {
failed.store(true);
break;
}
(*pageOutputs)[page - first] = std::move(pageOutput);
}
});
}
for (std::thread &worker : workers) {
worker.join();
}
return !failed.load();
}
-TextOutputDev textOut(...);
-printWordBBox(f, doc.get(), &textOut, firstPage, lastPage);
+if (nThreads > 1) {
+ std::vector<std::string> pageOutputs;
+ renderPagesInParallel(..., &pageOutputs);
+ writePageOutputs(f, pageOutputs);
+} else {
+ TextOutputDev textOut(...);
+ textOut.enableFastTextExtraction(true);
+ printWordBBox(...);
+}
What Changed
8. Add -unordered: A Direct OutputDev For Pure Text Dumping
-unordered: A Direct OutputDev For Pure Text DumpingOld Shape
Patch Shape: New Direct OutputDev
OutputDevclass UnorderedTextOutputDev final : public OutputDev
{
public:
UnorderedTextOutputDev(std::string *outA, const UnicodeMap *uMapA,
bool utf8MapA)
: out(outA), uMap(uMapA), utf8Map(utf8MapA) { }
bool upsideDown() override { return true; }
bool useDrawChar() override { return true; }
bool interpretType3Chars() override { return false; }
bool needNonText() override { return false; }
bool needCharCount() override { return false; }
void drawChar(GfxState *, double, double, double, double,
double, double, CharCode, int nBytes,
const Unicode *u, int uLen) override
{
if (actualText) {
actualTextSawGlyph = actualTextSawGlyph || nBytes > 0;
return;
}
if (!u || uLen <= 0) {
return;
}
appendMappedText(*out, u, uLen, uMap, utf8Map, &lastWasSeparator);
}
void endString(GfxState *) override
{
if (!out->empty() && !lastWasSeparator) {
appendMappedUnicode(*out, 0x20, uMap, utf8Map);
lastWasSeparator = true;
}
}
void beginActualText(GfxState *, const std::string &text) override
{
actualText = text;
actualTextSawGlyph = false;
}
void endActualText(GfxState *) override
{
if (actualText && actualTextSawGlyph) {
const std::vector<Unicode> uText = TextStringToUCS4(*actualText);
appendMappedText(*out, uText.data(), static_cast<int>(uText.size()),
uMap, utf8Map, &lastWasSeparator);
}
actualText.reset();
actualTextSawGlyph = false;
}
};
Patch Shape: Unordered Page Path
+if (unordered) {
+ f = openOutputFile(*textFileName, "wb");
+ if (!f) {
+ return 2;
+ }
+ const bool rendered =
+ renderUnorderedPages(doc.get(), fileName, ownerPW, userPW,
+ firstPage, lastPage, nThreads, textEOL, f);
+ if (f != stdout) {
+ fclose(f);
+ }
+ return rendered ? 0 : 1;
+}
Patch Shape: Unordered Parallelism
static bool renderUnorderedPagesParallel(const GooString &fileName,
const std::optional<GooString> &ownerPW,
const std::optional<GooString> &userPW,
int first, int last, int nThreads,
EndOfLineKind textEOL, FILE *f)
{
std::atomic<int> nextPage{ first };
std::atomic<bool> failed{ false };
std::mutex writeMutex;
std::vector<std::thread> workers;
workers.reserve(nThreads);
for (int i = 0; i < nThreads; ++i) {
workers.emplace_back([&]() {
std::unique_ptr<PDFDoc> workerDoc =
PDFDocFactory().createPDFDoc(fileName, ownerPW, userPW);
if (!workerDoc || !workerDoc->isOk()) {
failed.store(true);
return;
}
std::string pageOutput;
while (!failed.load()) {
const int page = nextPage.fetch_add(1);
if (page > last) {
break;
}
if (!renderUnorderedPage(workerDoc.get(), page, textEOL,
&pageOutput)) {
failed.store(true);
break;
}
std::lock_guard<std::mutex> lock(writeMutex);
fwrite(pageOutput.data(), 1, pageOutput.size(), f);
}
});
}
...
}
What Changed
9. Correctness Boundary
| Case | Ordered exact vs vanilla | Ordered word-bag vs vanilla | Unordered exact vs vanilla | Unordered word-bag vs vanilla |
|---|---|---|---|---|
| simple text PDF | pass | pass | fail | fail |
| multi-column PDF | pass | pass | fail | pass |
10. Benchmarks
Runtime Matrix
| Case | Vanilla | Ordered-1 | Ordered-4 | Ordered-auto | Unordered-1 | Unordered-2 | Unordered-4 | Unordered-auto |
|---|---|---|---|---|---|---|---|---|
| simple | 0.168450 | 0.120720 | 0.036545 | 0.027057 | 0.044090 | 0.026655 | 0.016219 | 0.013260 |
| columns | 0.113443 | 0.080123 | 0.027246 | 0.022761 | 0.030187 | 0.018725 | 0.012933 | 0.011893 |
Speedups Versus Vanilla
| Case | Ordered-1 | Ordered-4 | Ordered-auto | Unordered-1 | Unordered-2 | Unordered-4 | Unordered-auto |
|---|---|---|---|---|---|---|---|
| simple | 1.40x | 4.61x | 6.23x | 3.82x | 6.32x | 10.39x | 12.70x |
| columns | 1.42x | 4.16x | 4.98x | 3.76x | 6.06x | 8.77x | 9.54x |
Ordered Build-Flavor Speedups
| Case | Optimized-1 | Optimized-4 | Optimized-auto | LTO-1 | LTO-4 | LTO-auto | PGO/LTO-1 | PGO/LTO-4 | PGO/LTO-auto |
|---|---|---|---|---|---|---|---|---|---|
| simple | 1.46x | 4.67x | 6.22x | 1.47x | 4.69x | 5.80x | 1.48x | 4.74x | 6.29x |
| columns | 1.44x | 4.32x | 5.23x | 1.42x | 4.41x | 5.31x | 1.44x | 4.30x | 5.09x |
Unordered Build-Flavor Speedups
| Case | Optimized-1 | Optimized-2 | Optimized-4 | Optimized-auto | LTO-1 | LTO-2 | LTO-4 | LTO-auto | PGO/LTO-1 | PGO/LTO-2 | PGO/LTO-4 | PGO/LTO-auto |
|---|---|---|---|---|---|---|---|---|---|---|---|---|
| simple | 3.87x | 6.58x | 10.55x | 12.28x | 3.60x | 6.59x | 10.62x | 11.97x | 3.81x | 6.45x | 10.29x | 13.17x |
| columns | 3.76x | 6.40x | 9.37x | 10.59x | 3.79x | 6.15x | 9.12x | 9.99x | 3.76x | 6.28x | 9.24x | 10.32x |
Fastest Measured Commands
pdftotext -q -unordered -threads 0 input.pdf -
pdftotext -q -threads 0 input.pdf -
11. Bottom Line
Primary references