pdfium and pypdf return different text from the same PDF — four mismatches to know before you feed PDFs to an LLM 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. 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. Worse, 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. Most 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. TL;DR 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. My 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. Code 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. If 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. python class PDF: def init self : self.objs = None object numbers start at 1 def add self, body : self.objs.append body.encode "latin-1" if isinstance body, str else body return len self.objs - 1 def stream self, dic, data : data = data.encode "latin-1" if isinstance data, str else data return self.add b"<< " + dic.encode + b" /Length " + str len data .encode + b" \nstream\n" + data + b"\nendstream" def build self, root : out = bytearray b"%PDF-1.7\n" offsets = 0 len self.objs for i in range 1, len self.objs : offsets i = len out out += f"{i} 0 obj\n".encode + self.objs i + b"\nendobj\n" xref = len out out += f"xref\n0 {len self.objs }\n".encode + b"0000000000 65535 f \n" for i in range 1, len self.objs : out += f"{offsets i :010d} 00000 n \n".encode out += f"trailer\n<< /Size {len self.objs } /Root {root} 0 R \n" f"startxref\n{xref}\n%%EOF\n" .encode return bytes out Every page has one font Helvetica, WinAnsiEncoding and one visible line. Each experiment adds one more line. VISIBLE = "BT /F1 14 Tf 0 0 0 rg 60 780 Td This is the visible body text. Tj ET\n" Two readers, both using the most ordinary "give me all the text on this page" API: python import pypdfium2 as pdfium import pypdf def pdfium text path : return pdfium.PdfDocument path 0 .get textpage .get text range def pypdf text path : return pypdf.PdfReader path .pages 0 .extract text 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. BT /F1 12 Tf 0 Tz 0 0 0 rg 60 700 Td SECRET-1 zero width Tj ET pdfium : 'This is the visible body text.' pypdf : 'This is the visible body text.\nSECRET-1 zero width' Not 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. For 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. /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 fi into "fi", rejoining a hyphenated word. Word uses it when exporting PDFs. /Span << /ActualText SECRET-2 replaced text BDC BT /F1 12 Tf 0 0 0 rg 60 700 Td Hello world Tj ET EMC The page draws Hello world. pdfium : 'This is the visible body text.\r\nSECRET-2 replaced text' pypdf : 'This is the visible body text.\nHello world' pdfium follows the spec and returns the replacement. Hello world is nowhere in its output. pypdf ignores /ActualText and returns the glyphs as drawn. This 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". A 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. I put three tricks into one table: php 3 beginbfchar <41