pikepdf 10.13.0.post1: removing /ActualText and /Alt from every object in pdf.objects, then saving a new PDF, still leaves alternate text in decoded content
pikepdf 10.13.0.post1: removing /ActualText and /Alt from every object in pdf.objects, then saving a new PDF, still leaves alternate text in decoded content. Recursive removal from nested direct dictionaries fixed resource Properties dictionaries but did not fix two stream-level audit failures. The document had already been redacted and saved through PyMuPDF 1.28.2, so a fresh catalog and garbage collection were not enough. I needed to remove non-rendering alternate text without rasterizing or changing the visible page.
Marked-content BDC operands can contain direct property dictionaries inside page or Form XObject content bytes. Those dictionaries are not children of the PDF object graph exposed by pdf.objects. Recursive object cleanup is necessary for /Resources /Properties dictionaries, but content streams need a separate parsed rewrite. Use pikepdf.parse_content_stream(), recursively prune each instruction operand, rebuild changed ContentStreamInstruction objects, and use unparse_content_stream() only for changed streams. instruction.operands is a Python list, so passing it to a walker that only recognizes pikepdf.Array silently does nothing. Walk its elements. Preserve ContentStreamInlineImage instructions unchanged.
Minimal runnable demonstration of the separate stream surface:
import pikepdf
pdf = pikepdf.Pdf.new()
page = pdf.add_blank_page()
page.Contents = pikepdf.Stream(pdf, b'/Span << /ActualText (secret) /Alt (secret alt) >> BDC EMC')
for obj in pdf.objects:
if isinstance(obj, (pikepdf.Dictionary, pikepdf.Stream)):
for key in ('/ActualText', '/Alt'):
if key in obj:
del obj[key]
assert b'secret' in page.Contents.read_bytes()
instructions = list(pikepdf.parse_content_stream(page.Contents))
for i, instruction in enumerate(instructions):
if isinstance(instruction, pikepdf.ContentStreamInlineImage):
continue
operands = instruction.operands
for operand in operands:
if isinstance(operand, pikepdf.Dictionary):
for key in ('/ActualText', '/Alt'):
if key in operand:
del operand[key]
instructions[i] = pikepdf.ContentStreamInstruction(operands, instruction.operator)
page.Contents.write(pikepdf.unparse_content_stream(instructions))
assert b'secret' not in page.Contents.read_bytes()For a general sanitizer, recurse through direct dictionaries and arrays and cover every page content stream plus /Subtype /Form streams; do not interpret image pixels or font programs as content instructions. A synthetic regression combining nested resource properties, inline page BDC dictionaries, Form BDC dictionaries and an inline RGB image passed with identical PyMuPDF-rendered pixel bytes and preserved visible text after this rewrite. This removes accessibility alternate text, so it is appropriate only when that loss is part of the intended sanitization contract, not a general optimization.