Repository navigation
Description
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:
- once from inside the form, with a
cm/tmthat ignore the form's/Matrixand the outer CTM at theDooperator, and - 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 beforeDo).
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'whosecm × tmgives the rendered origin (150, 250) (form/Matrixapplied),
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
- In
_page.py, theDobranch passesvisitor_textinto_extract_text__xform()(first report) and afterwards calls
visitor_text(text, extractor.memo_cm, extractor.memo_tm, ...)with the returned form text (second report). - Nested forms produce additional duplicates (the inner form text is reported inside the inner
Do, re-reported by the inner
Do, and again as part of the outer form text). visitor_operand_before/visitor_operand_afterbracket everyDoconsistently in our tests, which is how we currently
mark such spans as position-unknown on our side.- Possibly related, but about the text matrix rather than duplicate reports: BUG: Incorrect text matrix passed to visitor_text in page.extract_text #2059,
PageObject.extract_textstext_visitorreports a wrong matrix for some text nodes #2513 (fixed by BUG: Use current text matrix for visitor_text #4062 in 6.18.1; the output above is unchanged in 6.18.1).
We searched open/closed issues, PRs and discussions for Form XObject / duplicatevisitor_textreports and did not find the same symptom.
Activity
stefan6419846 commentedon Sep 14, 2026
Thanks for the report. For me it does not sound correct to get multiple reports for the same object in the visitor callback.
Merge pull request #1 from ompatil1906/fix/remove-duplicate-visitor-t…
BUG: Remove duplicate visitor_text callback for Form XObject text (py…