Skip to content

visitor_text reports Form XObject text twice with unreliable matrices #4079

Closed

Description

@Pastel-aile

Summary

When PageObject.extract_text(visitor_text=...) processes a page that paints a Form XObject containing text,
visitor_text is called twice for the same text:

  1. once from inside the form, with a cm/tm that ignore the form's /Matrix and the outer CTM at the Do operator, and
  2. once more after the form has been processed, with the whole form text and the outer memo_cm/memo_tm
    (e.g. identity/(0, 0), or the position of the page text that was emitted just before Do).

This appears to produce duplicate visitor_text callbacks with matrices that do not represent the Form's final page-space position.
Consumers that compute positions as cm × tm therefore get two spans for the same text, and neither is at the rendered position
when the form has a non-identity /Matrix or is painted under a non-identity CTM.

The documentation already notes that "in complicated PDF documents the coordinates given to the visitor functions may be wrong".
This report is about the duplicate callback in particular, which is hard for a consumer to detect without also tracking Do
through visitor_operand_before / visitor_operand_after. We are not sure whether the duplicate is intended; please advise.

Environment

  • pypdf 6.16.2 and 6.18.1 (identical results)
  • Python 3.14, Linux

Minimal reproduction (fully synthetic PDF, no external files)

import io
from pypdf import PdfReader


def build(page_content: bytes, form_matrix: str, form_content: bytes) -> bytes:
    objs = [
        b"<< /Type /Catalog /Pages 2 0 R >>",
        b"<< /Type /Pages /Kids [4 0 R] /Count 1 >>",
        b"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>",
        b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 595 842] "
        b"/Resources << /Font << /F1 3 0 R >> /XObject << /Fx 6 0 R >> >> /Contents 5 0 R >>",
        b"<< /Length %d >>\nstream\n%s\nendstream" % (len(page_content), page_content),
        b"<< /Type /XObject /Subtype /Form /BBox [0 0 595 842] /Matrix [%s] "
        b"/Resources << /Font << /F1 3 0 R >> >> /Length %d >>\nstream\n%s\nendstream"
        % (form_matrix.encode(), len(form_content), form_content),
    ]
    out = bytearray(b"%PDF-1.7\n")
    offsets = []
    for i, body in enumerate(objs, 1):
        offsets.append(len(out))
        out += b"%d 0 obj\n%s\nendobj\n" % (i, body)
    xref = len(out)
    out += b"xref\n0 %d\n0000000000 65535 f \n" % (len(objs) + 1)
    out += b"".join(b"%010d 00000 n \n" % o for o in offsets)
    out += b"trailer\n<< /Size %d /Root 1 0 R >>\nstartxref\n%d\n%%%%EOF\n" % (len(objs) + 1, xref)
    return bytes(out)


def show(label, pdf):
    print(label)
    page = PdfReader(io.BytesIO(pdf)).pages[0]
    depth = [0]

    def before(op, args, cm, tm):
        if op == b"Do":
            depth[0] += 1

    def after(op, args, cm, tm):
        if op == b"Do":
            depth[0] -= 1

    def text(t, cm, tm, font, size):
        if t.strip():
            x = cm[0] * tm[4] + cm[2] * tm[5] + cm[4]
            y = cm[1] * tm[4] + cm[3] * tm[5] + cm[5]
            print(f"  depth={depth[0]} text={t!r} position=({x:g}, {y:g})")

    page.extract_text(visitor_text=text, visitor_operand_before=before, visitor_operand_after=after)


TEXT = b"BT /F1 12 Tf 1 0 0 1 %d %d Tm (%s) Tj ET"

# A: form with /Matrix [1 0 0 1 50 50]; expected rendered origin (150, 250)
show("A", build(b"q /Fx Do Q", "1 0 0 1 50 50", TEXT % (100, 200, b"1234")))
# B: page text at (100, 700), then identity form with text at (300, 700)
show("B", build(TEXT % (100, 700, b"1111") + b" /Fx Do", "1 0 0 1 0 0", TEXT % (300, 700, b"2222")))

Observed (6.16.2 and 6.18.1)

A
  depth=1 text='1234' position=(100, 200)
  depth=1 text='1234' position=(0, 0)
B
  depth=0 text='1111' position=(100, 700)
  depth=1 text='2222' position=(300, 700)
  depth=1 text='2222' position=(100, 700)

Expected

  • A: a single report of '1234' whose cm × tm gives the rendered origin (150, 250) (form /Matrix applied),
    or at least no second report.
  • B: a single report of '2222' at (300, 700). The second report reuses the page's previous text position (100, 700),
    so it looks like '2222' is also painted where '1111' is.

Independent parsers (pdfminer.six, poppler pdftotext -bbox) report '1234' at (150, 250) for A and a single '2222' at (300, 700) for B.

Notes

Activity

  1. stefan6419846 commented on Sep 14, 2026

    @stefan6419846
    Collaborator

    Thanks for the report. For me it does not sound correct to get multiple reports for the same object in the visitor callback.

  2. added a commit that references this issue on Sep 16, 2026
  3. added a commit that references this issue on Sep 18, 2026
    f0b34a2
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    workflow-text-extractionFrom a users perspective, text extraction is the affected feature/workflow

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions