{"slug": "pdfium-and-pypdf-return-different-text-from-the-same-pdf-four-mismatches-to-know", "title": "pdfium and pypdf return different text from the same PDF — four mismatches to know before you feed PDFs to an LLM", "summary": "A developer documented four cases where pdfium and pypdf extract different text from the same PDF, including zero horizontal scaling (0 Tz) that pdfium ignores but pypdf reads, /ActualText replacement strings, /ToUnicode mappings that turn an on-screen \"A\" into \"B\", and invisible Unicode tag characters that pass through both libraries. The findings matter for LLM pipelines that treat any single library's output as the document's text, and the developer turned the mismatch into a detection signal for hidden text.", "body_md": "I assumed that \"extract the text from this PDF\" gives you the same characters no matter which library you use. It does not. **Feed the same PDF to pdfium and to pypdf, and you get characters that only one of them returns, and characters that one of them silently replaces with a different sentence.**\n\nWorse, neither output necessarily matches what a human sees on screen. The page says Hello world; the extracted text says something else. The page shows nothing; the extracted text has an extra line. All of it is within the PDF specification, and neither library is wrong.\n\nMost pipelines that hand PDFs to an LLM take the output of one extraction library and treat it as \"the text of the document\". This article shows four cases where that assumption breaks, each with a minimal PDF you can build yourself and the actual output of both libraries. The second half is about how I turned the mismatch into a detection signal for hidden text.\n\n**TL;DR**\n\n`0 Tz` (zero horizontal scaling) does not exist as far as pdfium's text extraction is concerned. pypdf reads it.`/ActualText` makes pdfium return the replacement string `/ToUnicode` table makes \"A\" on screen come out as \"B\" in the text. Mappings to invisible Unicode tag characters pass through both libraries untouched.`Do` is read by neither. pypdf's `extract_xform_text` gets it.\nMy previous post ([I built a tool that finds \"invisible text\" hidden in PDFs](https://dev.to/okinawasoftware/i-built-a-tool-that-finds-invisible-text-hidden-in-pdfs-white-text-tiny-fonts-and-the-5982)) was about text that *is* extracted but cannot be seen, found by rendering the page with and without its text. This one is about a step earlier: the extracted text itself being different. You do not need to have read the first one.\n\nCode in this article was run with Python 3.12 / pypdfium2 5.12.1 (pdfium build 7947) / pypdf 6.15.0. The full reproduction script is at the end.\n\nIf you generate test PDFs with a library, you lose control over *how* the text is hidden. So I write raw PDF syntax, byte by byte. The builder is about 60 lines: stack objects, write the xref table by hand.\n\n``` python\nclass PDF:\n    def __init__(self):\n        self.objs = [None]                     # object numbers start at 1\n\n    def add(self, body):\n        self.objs.append(body.encode(\"latin-1\") if isinstance(body, str) else body)\n        return len(self.objs) - 1\n\n    def stream(self, dic, data):\n        data = data.encode(\"latin-1\") if isinstance(data, str) else data\n        return self.add(b\"<< \" + dic.encode() + b\" /Length \" + str(len(data)).encode()\n                        + b\" >>\\nstream\\n\" + data + b\"\\nendstream\")\n\n    def build(self, root):\n        out = bytearray(b\"%PDF-1.7\\n\")\n        offsets = [0] * len(self.objs)\n        for i in range(1, len(self.objs)):\n            offsets[i] = len(out)\n            out += f\"{i} 0 obj\\n\".encode() + self.objs[i] + b\"\\nendobj\\n\"\n        xref = len(out)\n        out += f\"xref\\n0 {len(self.objs)}\\n\".encode() + b\"0000000000 65535 f \\n\"\n        for i in range(1, len(self.objs)):\n            out += f\"{offsets[i]:010d} 00000 n \\n\".encode()\n        out += (f\"trailer\\n<< /Size {len(self.objs)} /Root {root} 0 R >>\\n\"\n                f\"startxref\\n{xref}\\n%%EOF\\n\").encode()\n        return bytes(out)\n```\n\nEvery page has one font (Helvetica, WinAnsiEncoding) and one visible line. Each experiment adds one more line.\n\n```\nVISIBLE = \"BT /F1 14 Tf 0 0 0 rg 60 780 Td (This is the visible body text.) Tj ET\\n\"\n```\n\nTwo readers, both using the most ordinary \"give me all the text on this page\" API:\n\n``` python\nimport pypdfium2 as pdfium\nimport pypdf\n\ndef pdfium_text(path):\n    return pdfium.PdfDocument(path)[0].get_textpage().get_text_range()\n\ndef pypdf_text(path):\n    return pypdf.PdfReader(path).pages[0].extract_text()\n```\n\n`Tz` is horizontal scaling. At `0 Tz` every glyph has zero width and nothing appears on screen. The height is normal, so a \"tiny font\" check does not fire either.\n\n```\nBT /F1 12 Tf 0 Tz 0 0 0 rg 60 700 Td (SECRET-1 zero width) Tj ET\npdfium : 'This is the visible body text.'\npypdf  : 'This is the visible body text.\\nSECRET-1 zero width'\n```\n\nNot one character of that line makes it into pdfium's text page (`FPDFText`). It is not even counted by `count_chars()`. pdfium builds its text page from layout, and a character with zero width apparently does not exist. pypdf just walks the `Tj` operators in the content stream and returns what it finds.\n\nFor what it's worth, at `1 Tz` (1%) pdfium does return the text. The character box is then 0.08 pt wide, and a 12 pt letter is drawn as a hairline. Unreadable to a person, present in both extractors.\n\n`/ActualText` is a marked-content property that says \"for text extraction, use this string instead of the glyphs in this span\". It has legitimate uses: turning the ligature ﬁ into \"fi\", rejoining a hyphenated word. Word uses it when exporting PDFs.\n\n```\n/Span << /ActualText (SECRET-2 replaced text) >> BDC\n  BT /F1 12 Tf 0 0 0 rg 60 700 Td (Hello world) Tj ET\nEMC\n```\n\nThe page draws Hello world.\n\n```\npdfium : 'This is the visible body text.\\r\\nSECRET-2 replaced text'\npypdf  : 'This is the visible body text.\\nHello world'\n```\n\npdfium follows the spec and returns the replacement. Hello world is nowhere in its output. pypdf ignores `/ActualText` and returns the glyphs as drawn.\n\nThis is not a question of which one is right. The spec intends the replacement, and pdfium honours it. The point is that **the string a person verified on screen and the string a pdfium-based consumer receives (copy from Chrome's viewer, a pypdfium2 pipeline) can be two different things.** And pdfium assigns the replacement characters the coordinates of the original glyphs. My \"render with and without text\" check from the previous post removes text at those coordinates, sees Hello world disappear, and concludes the text is visible. The replacement string sailed straight through as \"visible text\".\n\nA font's `/ToUnicode` entry is a table from character codes to Unicode. Extractors trust it. Rewrite the table and the glyphs stay the same while the extracted characters change.\n\nI put three tricks into one table:\n\n``` php\n3 beginbfchar\n<41> <DB40DC41>                    % 'A' -> U+E0041 (TAG LATIN CAPITAL LETTER A)\n<42> <00490067006E006F00720065>    % 'B' -> \"Ignore\" (one glyph, six characters)\n<43> <200B>                        % 'C' -> U+200B (ZERO WIDTH SPACE)\nendbfchar\nBT /F1 12 Tf 0 0 0 rg 60 700 Td (ABC abc) Tj ET\n```\n\nThe page draws ABC abc.\n\n```\npdfium : 'This is the visible body text.\\r\\n\\U000e0041Ignore\\u200b abc'\npypdf  : 'This is the visible body text.\\n\\U000e0041Ignore\\u200b abc'\n```\n\nThis time both agree. A became a tag character, B became \"Ignore\", C became a zero-width space. If you `print()` the result it looks like `Ignore abc`, because tag characters and zero-width spaces are invisible in terminals and editors alike.\n\nUnicode tag characters (U+E0000 to U+E007F) are an invisible copy of ASCII. Humans and editors do not see them, but an LLM tokenizer receives them as letters, which is why they are used to smuggle instructions (the technique is usually called ASCII smuggling). Being able to plant them through a font table means **a PDF whose page shows nothing but ordinary English can hand the extractor a different English sentence.**\n\nThe one-glyph-to-six-characters expansion deserves a look too. Ligature expansion (ﬁ to fi, ﬃ to ffi) is legitimate, so libraries accept multi-character mappings. The PDF spec sets no length limit; pypdf caps a single mapping at 512 bytes (256 UTF-16 characters) as a safety valve, which is still enough for a sentence or two per glyph.\n\nOne detail: pdfium's `FPDFText_GetUnicode` returns UTF-16 code units, so U+E0041 comes back as two values, `0xDB40, 0xDC41`. If you classify characters one at a time, you have to combine surrogate pairs yourself. pypdf returns a Python `str`, already combined.\n\n```\npdfium chars (code points >0x7e): ['0xdb40', '0xdc41', '0x200b']\npypdf  chars (code points >0x7e): ['0xe0041', '0x200b']\n```\n\nA Form XObject is a bundle of drawing operators, drawn when the page content invokes it with `/X0 Do`. If nothing invokes it, nothing is drawn. You can register it in the page's resource dictionary and simply never call it.\n\n```\n%% inside the XObject\nBT /F1 12 Tf 0 0 0 rg 60 500 Td (SECRET-4 inside unused xobject) Tj ET\n\n%% registered in the page resources, but the content never says /X0 Do\n/Resources << /Font << /F1 5 0 R >> /XObject << /X0 6 0 R >> >>\npdfium : 'This is the visible body text.'\npypdf  : 'This is the visible body text.'\n```\n\nNeither reads it. Both follow \"the text that appears when the page is drawn\", and an object that is never drawn is never visited. That is correct behaviour, and since nothing appears on screen it is not even hidden text in the naive sense.\n\nBut it is still in the file, and it can be pulled out with a different tool. pypdf has an API that reads a stream directly:\n\n```\nr = pypdf.PdfReader(path)\npage = r.pages[0]\nxo = page[\"/Resources\"][\"/XObject\"][\"/X0\"].get_object()\npage.extract_xform_text(xo)   # -> 'SECRET-4 inside unused xobject'\n```\n\n\"Invisible to the extractor but still in the file\" is the classic shape of leftover data. Editing tools can leave old objects in the resource dictionary and remove only the reference.\n\n| Trick | Human eye | pdfium | pypdf | Note | \n|---|---|---|---|---|\n| `0 Tz` | nothing | no characters | reads it | zero-width chars never enter pdfium's text page | \n| `/ActualText` | the glyphs | replacement string | the glyphs | the spec intends the replacement; coordinates are those of the original glyphs | \n| rewritten `/ToUnicode` | the glyphs | per the table | per the table | tag characters, zero-width, one-to-many mappings all pass through | \n| unused XObject | nothing | not read | not read | `extract_xform_text` reads it individually | \n\nFour rows, and not a single one where the human, pdfium and pypdf all see the same characters.\n\n**I did not test PyMuPDF, pdfminer.six or pdf.js.** This article covers exactly two readers. Swapping the two reader functions in the script at the end is all it takes to check another library; if you do, please leave a comment.\n\nThe one-sentence explanation: **text extraction is a by-product of rendering, and rendering is optimized for showing things, not for reporting them.** pdfium lays out the page, drops zero-width characters and honours replacement strings. pypdf reads the operator stream. Both are valid choices under the spec, and nothing anywhere guarantees \"this is the text you should give the model\".\n\nThe rest is about what I did with this. I build a tool that finds invisible text in PDFs (the previous post), and the four cases above were found as things that slipped past it.\n\nThe fix was not to replace pdfium with pypdf. pypdf ignores `/ActualText` and cannot do the render comparison. **The right use is to run both and look at the difference.**\n\nThe basic rule is \"report words that are in pypdf's output and not in pdfium's\". Compared naively, that produces nothing but false positives. Here is what I had to do after running it over 29 real Japanese business PDFs (quotes, delivery notes, vehicle registrations).\n\n**Normalize.** Strip whitespace, format characters, punctuation and symbols, and apply compatibility normalization (NFKC) so full-width/half-width forms and ligatures line up. pypdf and pdfium break lines in different places and treat punctuation differently; none of that should count.\n\n``` python\nimport unicodedata\n\ndef norm(s):\n    s = unicodedata.normalize(\"NFKC\", s or \"\")\n    return \"\".join(c for c in s\n                   if not unicodedata.category(c).startswith((\"Z\", \"C\", \"P\", \"S\")))\n```\n\n**Split words into CJK runs and non-CJK runs, and match them loosely.** pypdf tends to concatenate adjacent table cells. Two Japanese headers, \"vehicle type\" and \"registration number\", come back as one run with the first character missing; \"13th\" and \"issue date\" become one word. Judging those as \"missing from pdfium\" is wrong every time. For Japanese I accept a run if at least half of its 2-character fragments appear in the pdfium text; for everything else, half of its 4-character fragments.\n\n``` python\ndef segment_present(seg, target):\n    cjk = is_cjk(seg[0])           # kana / kanji / half-width katakana range check\n    k = 2 if cjk else 4\n    if len(seg) < (3 if cjk else 4) or seg in target:\n        return True\n    if not cjk and seg.isdigit():\n        return True            # digit runs get glued to the neighbouring cell's number\n    if len(seg) < k + 1:\n        return False\n    grams = [seg[i:i + k] for i in range(len(seg) - k + 1)]\n    return sum(g in target for g in grams) >= len(grams) * 0.5\n```\n\nThis leniency has a price: a short Japanese hidden word of 3 or 4 characters (say \"confidential\") can be missed if its two-character pieces occur in the visible text. I documented that as a known limit. Long text does not slip through, and hidden instructions are usually sentences.\n\n**Skip `/ActualText` that matches what is drawn.** Word attaches `/ActualText` to ligatures and soft hyphens. If the replacement string is contained in the glyph-side text (pypdf's output), the replacement is harmless. Only the ones that differ get reported.\n\n**Inspect `/ToUnicode` tables directly.** The difference cannot see this case (both libraries return the same thing), so I read the font dictionary. Three checks: a code that expands to 4 or more characters (ligatures stop at 3); any mapping to an invisible character (tags, zero-width, bidi controls); and, for simple fonts with a standard encoding such as WinAnsi, an alphanumeric mismatch between what the encoding says the glyph is and what the table says the character is. Embedded subset fonts with private glyph names (`g123` and the like) have no ground truth for \"what glyph is drawn\", so the third check is skipped for them.\n\n**Decode tag characters and show the message.** U+E0020 to U+E007E map back to ASCII with `chr(cp - 0xE0000)`. \"9 invisible characters\" is a fact; \"decoded, they say SECRET-44\" is something the reader can act on.\n\nWith all of that in place I ran the check over 29 real PDFs (mostly Japanese business forms) plus 17 synthetic samples. Exactly one finding came out of the new checks. A delivery note produced by Excel had, in its structure tree (the `/Alt` of a tagged figure), the string `C:\\Documents and Settings\\<username>\\My Documents\\My Pictures\\untitled.gif`. The alt text of an image contained a path from the author's PC. That is not a false positive; that is exactly the kind of thing the tool exists to find.\n\nThe previous post's render comparison removed text objects from the page and re-rendered. pdfium's `FPDFPage_RemoveObject` only works on top-level page objects, so text inside a Form XObject could not be removed and its area was excluded from the comparison. Text inside an XObject, covered with a white rectangle, walked straight through that gap.\n\nThe new version switches the text rendering mode to 3 (invisible) instead of deleting:\n\n```\nfor o in page.get_objects(max_depth=16):          # descends into XObjects\n    if o.type == R.FPDF_PAGEOBJ_TEXT:\n        mode = R.FPDFTextObj_GetTextRenderMode(o.raw)\n        want = (R.FPDF_TEXTRENDERMODE_CLIP            # modes 4-7: keep only the clip\n                if mode >= R.FPDF_TEXTRENDERMODE_FILL_CLIP\n                else R.FPDF_TEXTRENDERMODE_INVISIBLE)\n        R.FPDFTextObj_SetTextRenderMode(o.raw, want)\nimg_without_text = page.render(scale=2).to_pil()  # no gen_content() needed\n```\n\npdfium renders from the in-memory object state, so the change takes effect without regenerating the content stream (no `gen_content()` call). It works at any depth, and hidden text inside XObjects is now part of the comparison.\n\nFor the record:\n\nWhether or not you build tools, if you own a pipeline that hands PDFs to a model:\n\n`unicodedata.category(c) == \"Cf\"` and the U+E0000 to U+E007F range catches tag characters and zero-width injection. It is a few lines.\nBuilds the four PDFs, reads them with both libraries and prints the outputs side by side. About 150 lines including the builder; the only dependencies are pypdfium2 and pypdf.\n\narticle7_experiments.py (full)\n\n``` python\n# -*- coding: utf-8 -*-\nimport os\nimport tempfile\n\nimport pypdf\nimport pypdfium2 as pdfium\nimport pypdfium2.raw as R\n\nOUT = tempfile.mkdtemp(prefix=\"article7_\")\n\ndef _b(x):\n    return x.encode(\"latin-1\") if isinstance(x, str) else x\n\nclass PDF:\n    def __init__(self):\n        self.objs = [None]\n\n    def add(self, body):\n        self.objs.append(_b(body))\n        return len(self.objs) - 1\n\n    def reserve(self):\n        self.objs.append(b\"\")\n        return len(self.objs) - 1\n\n    def set(self, num, body):\n        self.objs[num] = _b(body)\n\n    def stream(self, dic, data):\n        data = _b(data)\n        return self.add(b\"<< \" + _b(dic).strip() + b\" /Length \" +\n                        str(len(data)).encode() + b\" >>\\nstream\\n\" + data +\n                        b\"\\nendstream\")\n\n    def build(self, root):\n        out = bytearray(b\"%PDF-1.7\\n%\\xe2\\xe3\\xcf\\xd3\\n\")\n        offsets = [0] * len(self.objs)\n        for i in range(1, len(self.objs)):\n            offsets[i] = len(out)\n            out += f\"{i} 0 obj\\n\".encode() + self.objs[i] + b\"\\nendobj\\n\"\n        xref = len(out)\n        n = len(self.objs)\n        out += f\"xref\\n0 {n}\\n\".encode() + b\"0000000000 65535 f \\n\"\n        for i in range(1, n):\n            out += f\"{offsets[i]:010d} 00000 n \\n\".encode()\n        out += (f\"trailer\\n<< /Size {n} /Root {root} 0 R >>\\n\"\n                f\"startxref\\n{xref}\\n%%EOF\\n\").encode()\n        return bytes(out)\n\ndef make_pdf(name, content, font_extra=\"\", resources_extra=\"\", hook=None):\n    p = PDF()\n    extra = hook(p) if hook else {}\n    font = p.add(\"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica \"\n                 \"/Encoding /WinAnsiEncoding \" + font_extra.format(**extra) + \" >>\")\n    cs = p.stream(\"\", content)\n    pages = p.reserve()\n    page = p.add(f\"<< /Type /Page /Parent {pages} 0 R /MediaBox [0 0 595 842] \"\n                 f\"/Resources << /Font << /F1 {font} 0 R >> \"\n                 f\"{resources_extra.format(**extra)} >> /Contents {cs} 0 R >>\")\n    p.set(pages, f\"<< /Type /Pages /Kids [{page} 0 R] /Count 1 >>\")\n    cat = p.add(f\"<< /Type /Catalog /Pages {pages} 0 R >>\")\n    path = os.path.join(OUT, name)\n    with open(path, \"wb\") as f:\n        f.write(p.build(cat))\n    return path\n\nVISIBLE = \"BT /F1 14 Tf 0 0 0 rg 60 780 Td (This is the visible body text.) Tj ET\\n\"\n\ndef pdfium_text(path):\n    doc = pdfium.PdfDocument(path)\n    return doc[0].get_textpage().get_text_range()\n\ndef pdfium_chars(path):\n    doc = pdfium.PdfDocument(path)\n    tp = doc[0].get_textpage()\n    return \"\".join(chr(R.FPDFText_GetUnicode(tp.raw, i) or 0)\n                   for i in range(tp.count_chars()))\n\ndef pypdf_text(path):\n    return pypdf.PdfReader(path).pages[0].extract_text()\n\ndef show(title, path):\n    print(f\"\\n=== {title} ===\")\n    print(\"pdfium :\", repr(pdfium_text(path)))\n    print(\"pypdf  :\", repr(pypdf_text(path)))\n\n# 1. zero-width text (0 Tz)\np1 = make_pdf(\"1_tz_zero.pdf\", VISIBLE +\n              \"BT /F1 12 Tf 0 Tz 0 0 0 rg 60 700 Td (SECRET-1 zero width) Tj ET\\n\")\nshow(\"1. 0 Tz (zero-width text)\", p1)\n\n# 2. ActualText\np2 = make_pdf(\"2_actualtext.pdf\", VISIBLE +\n              \"/Span << /ActualText (SECRET-2 replaced text) >> BDC \"\n              \"BT /F1 12 Tf 0 0 0 rg 60 700 Td (Hello world) Tj ET EMC\\n\")\nshow(\"2. /ActualText\", p2)\n\n# 3. rewritten ToUnicode\ncmap = (\"/CIDInit /ProcSet findresource begin 12 dict begin begincmap \"\n        \"/CMapName /Custom def 1 begincodespacerange <00> <FF> endcodespacerange\\n\"\n        \"3 beginbfchar\\n\"\n        \"<41> <DB40DC41>\\n\"\n        \"<42> <00490067006E006F00720065>\\n\"\n        \"<43> <200B>\\n\"\n        \"endbfchar\\nendcmap CMapName currentdict /CMap defineresource pop end end\")\np3 = make_pdf(\"3_tounicode.pdf\", VISIBLE +\n              \"BT /F1 12 Tf 0 0 0 rg 60 700 Td (ABC abc) Tj ET\\n\",\n              font_extra=\"/ToUnicode {tu} 0 R\",\n              hook=lambda p: {\"tu\": p.stream(\"\", cmap)})\nshow(\"3. /ToUnicode remap\", p3)\nprint(\"pdfium chars (code points >0x7e):\",\n      [hex(ord(c)) for c in pdfium_chars(p3) if ord(c) > 0x7e])\nprint(\"pypdf  chars (code points >0x7e):\",\n      [hex(ord(c)) for c in pypdf_text(p3) if ord(c) > 0x7e])\n\n# 4. unused Form XObject\ndef _xobj(p):\n    font = p.add(\"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica \"\n                 \"/Encoding /WinAnsiEncoding >>\")\n    xo = p.stream(\"/Type /XObject /Subtype /Form /BBox [0 0 595 842] \"\n                  f\"/Resources << /Font << /F1 {font} 0 R >> >>\",\n                  \"BT /F1 12 Tf 0 0 0 rg 60 500 Td (SECRET-4 inside unused xobject) Tj ET\\n\")\n    return {\"xo\": xo}\n\np4 = make_pdf(\"4_unused_xobject.pdf\", VISIBLE,\n              resources_extra=\"/XObject << /X0 {xo} 0 R >>\", hook=_xobj)\nshow(\"4. unused Form XObject (never drawn with Do)\", p4)\nr = pypdf.PdfReader(p4)\npg = r.pages[0]\nxo = pg[\"/Resources\"][\"/XObject\"][\"/X0\"].get_object()\nprint(\"pypdf extract_xform_text:\", repr(pg.extract_xform_text(xo)))\n\nprint(\"\\nPDFs written to:\", OUT)\n```\n\nOutput, verbatim:\n\n```\n=== 1. 0 Tz (zero-width text) ===\npdfium : 'This is the visible body text.'\npypdf  : 'This is the visible body text.\\nSECRET-1 zero width'\n\n=== 2. /ActualText ===\npdfium : 'This is the visible body text.\\r\\nSECRET-2 replaced text'\npypdf  : 'This is the visible body text.\\nHello world'\n\n=== 3. /ToUnicode remap ===\npdfium : 'This is the visible body text.\\r\\n\\U000e0041Ignore\\u200b abc'\npypdf  : 'This is the visible body text.\\n\\U000e0041Ignore\\u200b abc'\npdfium chars (code points >0x7e): ['0xdb40', '0xdc41', '0x200b']\npypdf  chars (code points >0x7e): ['0xe0041', '0x200b']\n\n=== 4. unused Form XObject (never drawn with Do) ===\npdfium : 'This is the visible body text.'\npypdf  : 'This is the visible body text.'\npypdf extract_xform_text: 'SECRET-4 inside unused xobject'\n```\n\nThese checks shipped in the next version of the tool from the previous post, **PDF Privacy Checker**, on the Microsoft Store (detection is free; it runs fully offline, and not a single byte of your file leaves your PC).\n\n[https://apps.microsoft.com/detail/9PLRJHFTPS53?hl=en-us&gl=US](https://apps.microsoft.com/detail/9PLRJHFTPS53?hl=en-us&gl=US)\n\nIf you have a trick you want to see run through this, leave a comment. Adding a case to the script above takes one function call.\n\n**Okinawa Software Lab.** I lead in-house digital transformation at a small company in Okinawa, Japan. I build the tools we need ourselves, and I publish PDF apps on the Microsoft Store that follow the same principle: everything happens on your own PC.", "url": "https://wpnews.pro/news/pdfium-and-pypdf-return-different-text-from-the-same-pdf-four-mismatches-to-know", "canonical_source": "https://dev.to/okinawasoftware/pdfium-and-pypdf-return-different-text-from-the-same-pdf-four-mismatches-to-know-before-you-feed-3l6c", "published_at": "2026-09-23 13:14:33+00:00", "updated_at": "2026-09-23 13:29:41.599334+00:00", "lang": "en", "topics": ["ai-tools", "developer-tools", "natural-language-processing"], "entities": ["pdfium", "pypdf", "pypdfium2", "Python", "Helvetica"], "alternates": {"html": "https://wpnews.pro/news/pdfium-and-pypdf-return-different-text-from-the-same-pdf-four-mismatches-to-know", "markdown": "https://wpnews.pro/news/pdfium-and-pypdf-return-different-text-from-the-same-pdf-four-mismatches-to-know.md", "text": "https://wpnews.pro/news/pdfium-and-pypdf-return-different-text-from-the-same-pdf-four-mismatches-to-know.txt", "jsonld": "https://wpnews.pro/news/pdfium-and-pypdf-return-different-text-from-the-same-pdf-four-mismatches-to-know.jsonld"}}