001 Notes

Speeding Up pdftotext When You Only Want The Text

0. Abstract

This note keeps the code-heavy version of the pdftotext optimization work.

The question was narrow:

How fast can PDF text extraction get when the caller mostly wants decoded text, not expensive layout recovery?

The patch stack targeted Poppler 26.06.0 and touched only a small surface:

FileRole
utils/CMakeLists.txtlink pdftotext against threads
utils/pdftotext.1document new CLI flags
utils/pdftotext.ccCLI, threading, unordered mode, XML/TSV/bbox fast paths
poppler/TextOutputDev.hfast-mode data layout and API hooks
poppler/TextOutputDev.ccordered-path extraction fast paths
poppler/Gfx.ccskip image decoding for text-only extraction

The measured result on the checked synthetic corpus:

  • ordered auto-threaded mode: about 4.98x to 6.23x faster than vanilla;
  • unordered auto-threaded mode: about 9.54x to 12.70x faster than vanilla;
  • best observed point across build flavors: 13.17x faster than vanilla.

Important correctness boundary:

  • ordered threaded extraction matched vanilla on the checked corpus;
  • unordered extraction was faster, but not semantically equivalent on every checked file.

1. Baseline And Choke Points

The stock extraction path spent time in three broad areas:

  • TextOutputDev and TextPage work on every character;
  • layout reconstruction and word sorting;
  • utility-side string handling in bbox, TSV, and XML output.

That points to a pipeline problem, not a single bug:

character ingestion
-> text objects
-> ordering / grouping
-> utility formatting
-> final output

So the right strategy was:

  1. reduce utility overhead;
  2. reduce per-character ordered-path cost;
  3. skip obviously irrelevant non-text work;
  4. parallelize at the page level;
  5. add a different algorithm for callers that do not need reading-order reconstruction.

Old Shape

Stock pdftotext was single-threaded and did not link Threads::Threads.

Why That Matters

Page extraction is naturally parallel if each worker owns its own PDFDoc.

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

The binary gained the thread runtime needed for page-worker pools.

3. Add -threads And -unordered

Old Shape

There was no thread-count knob and no “fastest text dump, order does not matter” mode.

Patch Shape

CLI additions:

 #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)" },

Man-page additions:

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

The user-facing contract became explicit:

  • -threads N for faster ordered extraction;
  • -unordered for the maximum-throughput path when reading order is not required.

4. Stop Wasting Time In XML, bbox, And TSV Output

Old Shape

The utility-side XML path repeatedly rebuilt and rescanned strings:

word -> std::string
     -> replace &
     -> replace '
     -> replace "
     -> replace <
     -> replace >

It also paid unnecessary cost when the output map was UTF-8 and the text was plain ASCII.

Patch Shape

One-pass XML-special detection:

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;
}

AVX2 fast reject:

__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);
}

Direct append for common ASCII words:

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 */
}

Representative call-site change:

-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

  • one-pass escaping instead of repeated whole-string replacement;
  • AVX2 fast reject for strings with no XML-special bytes;
  • direct UTF-8 ASCII append on the common path;
  • less allocation churn in utility formatting.

This mostly helped bbox-oriented modes where wrapper work was dominating more than actual extraction.

5. Add A Fast Extraction Mode Inside TextOutputDev

Old Shape

The ordered path stored and maintained data that plain text output did not always need:

  • per-character CharCode;
  • per-character text matrices;
  • color and invisible-state work;
  • render-only structures used by selection code.

It also rebuilt TextPool state aggressively and took generic Unicode paths even when the real case was UTF-8 ASCII.

Patch Shape: Split Text State From Render State

Header-level idea:

-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;

Implementation idea:

-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()
+{
+    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

-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;
+    }
+    ...
+}

Expose fast mode through TextOutputDev:

+void TextOutputDev::enableFastTextExtraction(bool fastModeA)
+{
+    fastMode = fastModeA;
+    if (text) {
+        text->setFastMode(fastMode);
+    }
+}

What Changed

  • TextWord keeps only text-side state in fast mode;
  • render-only metadata is skipped;
  • TextPool reuses memory instead of churning containers;
  • addChar avoids generic whitespace and font-matrix work on the hot path;
  • UTF-8 ASCII output goes straight to bytes.

This was the core single-thread speedup for the ordered path.

6. Skip Image Decoding For Text-Only Extraction

Old Shape

Gfx::doImage still walked setup and decode paths even when the output device only wanted text.

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

  • XObject image streams close immediately for text-only output;
  • inline image bytes are discarded without decoding;
  • parser alignment stays correct;
  • mixed text/image PDFs stop paying for obviously irrelevant image work.

7. Add Page-Level Threading For Ordered Extraction

Old Shape

The stock tool used one PDFDoc, one TextOutputDev, and one serial page loop.

Why The Concurrency Shape Matters

Pages are independent enough to parallelize, but document state and extraction state are not cheap places to force sharing.

The safe model is:

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();
}

Representative branch replacement:

-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

  • -threads 0 maps to hardware concurrency;
  • stdin still collapses to one thread;
  • each worker owns its own PDFDoc;
  • ordered modes write results in page order after workers finish.

This was the multiplicative win on the safe ordered path.

8. Add -unordered: A Direct OutputDev For Pure Text Dumping

Old Shape

Even -raw still went through:

  • TextOutputDev;
  • TextPage;
  • word creation;
  • line and block structures;
  • sorting and coalescing;
  • dump logic.

That is still a lot of layout reconstruction for a caller that only wants decoded text.

Patch Shape: New Direct OutputDev

class 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

  • TextOutputDev and TextPage are bypassed entirely;
  • decoded characters are emitted directly from drawChar;
  • ActualText is preserved;
  • needNonText() == false also activates image skipping in Gfx;
  • worker threads can write page output immediately in unordered mode.

This was the biggest raw throughput win in the patch set.

9. Correctness Boundary

The public story here has to stay hard-edged.

Ordered mode is the safe speedup path on the checked corpus.

Unordered mode is the maximum-throughput path, but it is not semantically equivalent on every checked PDF.

Checked status:

CaseOrdered exact vs vanillaOrdered word-bag vs vanillaUnordered exact vs vanillaUnordered word-bag vs vanilla
simple text PDFpasspassfailfail
multi-column PDFpasspassfailpass

That leads directly to the contract:

  • use ordered threaded extraction when output compatibility matters;
  • use unordered mode when raw throughput matters more than reconstructed order.

10. Benchmarks

All timings below are 5-run mean seconds.

Runtime Matrix

CaseVanillaOrdered-1Ordered-4Ordered-autoUnordered-1Unordered-2Unordered-4Unordered-auto
simple0.1684500.1207200.0365450.0270570.0440900.0266550.0162190.013260
columns0.1134430.0801230.0272460.0227610.0301870.0187250.0129330.011893

Speedups Versus Vanilla

CaseOrdered-1Ordered-4Ordered-autoUnordered-1Unordered-2Unordered-4Unordered-auto
simple1.40x4.61x6.23x3.82x6.32x10.39x12.70x
columns1.42x4.16x4.98x3.76x6.06x8.77x9.54x

Ordered Build-Flavor Speedups

CaseOptimized-1Optimized-4Optimized-autoLTO-1LTO-4LTO-autoPGO/LTO-1PGO/LTO-4PGO/LTO-auto
simple1.46x4.67x6.22x1.47x4.69x5.80x1.48x4.74x6.29x
columns1.44x4.32x5.23x1.42x4.41x5.31x1.44x4.30x5.09x

Unordered Build-Flavor Speedups

CaseOptimized-1Optimized-2Optimized-4Optimized-autoLTO-1LTO-2LTO-4LTO-autoPGO/LTO-1PGO/LTO-2PGO/LTO-4PGO/LTO-auto
simple3.87x6.58x10.55x12.28x3.60x6.59x10.62x11.97x3.81x6.45x10.29x13.17x
columns3.76x6.40x9.37x10.59x3.79x6.15x9.12x9.99x3.76x6.28x9.24x10.32x

Fastest Measured Commands

Fastest pure-throughput shape:

pdftotext -q -unordered -threads 0 input.pdf -

Safe speedup path with checked compatibility:

pdftotext -q -threads 0 input.pdf -

11. Bottom Line

The speedup story was not one trick.

It was a stack:

  1. cheaper XML, TSV, and bbox utility work;
  2. cheaper ordered-path character ingestion;
  3. skipped image decode for text-only output;
  4. page-level threading;
  5. a new unordered direct-text algorithm.

That stack produced the final measured result:

  • ordered auto-threaded mode: roughly 5x to 6x;
  • unordered auto-threaded mode: roughly 9.5x to 12.7x;
  • best observed point: 13.17x.

The decision rule is straightforward:

  • use ordered threaded extraction for the safe speedup path;
  • use unordered extraction when the requirement is literally “just give me the text” and output equivalence is not the primary constraint.

Comments