001 Notes
Speeding Up pdftotext When You Only Want The Text
0. Abstract
How fast can PDF text extraction get when the caller mostly wants decoded text, not 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 -
Comments