Copilot Chat Conversation Export: Inline Form XObjects in PDF using
- @adangel
- Copilot
- @adangel
- Copilot
- @adangel
- Copilot
- Practical ways
- Important caveats
- If your goal is compatibility
- @adangel
- Copilot
- @adangel
- Copilot
- @adangel
- Copilot
- In short
- Structure
- @adangel
- Copilot
- @adangel
- Copilot
- @adangel
- Copilot
- @adangel
- Copilot
- @adangel
- Copilot
- @adangel
- Copilot
- @adangel
- Copilot
- @adangel
- Copilot
- @adangel
- Copilot
- @adangel
- Copilot
- @adangel
- Copilot
- @adangel
- Copilot
- If this fixes visibility
- @adangel
- Copilot
@adangel
In a PDF document, what does the following mean?
1 w
/GS1 gs
q
/Fm1 Do
Q
Copilot
It’s a sequence of PDF content stream operators. In plain English:
-
1 w→ set the line width to1user unit. -
/GS1 gs→ apply the named graphics state resourceGS1(from the page/resource dictionary’sExtGState), which can set things like opacity, blend mode, overprint, etc. -
q→ save the current graphics state. -
/Fm1 Do→ draw/invoke the XObject namedFm1(very often a Form XObject, i.e., reusable content). -
Q→ restore the previous graphics state (undoing changes made afterq).
So overall: set some drawing state, save it, paint Fm1, then restore state.
@adangel
what is a Form XObject
Copilot
A Form XObject is a reusable chunk of PDF graphics/content—basically a mini page fragment you can place multiple times.
Think of it like a stamp:
- It contains its own content stream (text, paths, images, etc.).
- It has its own resources (fonts, images, color spaces, etc.).
- It can have its own coordinate system (
Matrix) and bounding box (BBox). - You draw it with
Do(e.g.,/Fm1 Do), often after applying transforms (scale/translate/rotate).
Why PDFs use it:
- Reuse same artwork/logo/header efficiently.
- Smaller files (define once, use many).
- Structured rendering (encapsulated graphics).
@adangel
how can I convert a PDF file to not use Form XObject?
Copilot
Flatten it by expanding each Form XObject inline into the page content streams.
Practical ways
-
Regenerate via print-to-PDF / Ghostscript (easiest)
- This often collapses form usage (not guaranteed 100%).
- Example:
gs -o out.pdf -sDEVICE=pdfwrite in.pdf
-
Use a PDF SDK/library and “flatten forms” programmatically
- Parse each page content stream.
- Replace every
/Name Dothat points to a Form XObject with:q- the current CTM concatenated with the form’s matrix (
cm) - the form’s content tokens
Q
- Merge referenced resources (fonts/images/extgstate) into page resources with renamed keys to avoid collisions.
-
Render and re-create pages (guaranteed removal)
- Rasterize each page to image, then build a new PDF from images.
- This removes Form XObjects entirely, but loses vector text/graphics/searchability.
Important caveats
-
Not trivial if you want true vector-preserving flattening:
- Nested forms
- Transparency groups/isolated knockout behavior
- Clipping paths
- Resource name collisions
- Optional content/layers
- Many tools reintroduce forms for optimization even after processing.
If your goal is compatibility
Usually better: target the specific consumer (printer/viewer/parser) and test with a Ghostscript rewrite first.
If that fails, use rasterization as a last resort.
If you want, I can give you a concrete Python approach (e.g., pikepdf + token walk) for inlining Form XObjects.
@adangel
create a small script to inline Form XObject
Copilot
Great—here’s a minimal Python script using pikepdf that inlines simple Form XObjects found in page content streams.
#!/usr/bin/env python3
"""
Inline simple Form XObjects in PDF page content streams.
Requirements:
pip install pikepdf
Usage:
python inline_form_xobjects.py input.pdf output.pdf
Notes:
- Handles direct `/Name Do` calls where `/Name` resolves to a Form XObject in page /Resources /XObject.
- Recurses into nested Form XObjects.
- Wraps inlined form content in `q ... Q` and applies the form /Matrix with `cm` if present.
- Merges form resources into page resources with auto-renaming to avoid key collisions.
- This is intentionally small and pragmatic, not a full PDF interpreter.
"""
import sys
import re
from typing import Dict, Tuple, Optional, Set
import pikepdf
from pikepdf import Name, Dictionary, Stream, Array
# ---------- basic tokenization helpers (lightweight, not full PDF parser) ----------
_WHITESPACE = b" \t\r\n\x0c\x00"
def _skip_ws(data: bytes, i: int) -> int:
n = len(data)
while i < n and data[i] in _WHITESPACE:
i += 1
return i
def _read_name(data: bytes, i: int) -> Tuple[bytes, int]:
# data[i] must be '/'
j = i + 1
n = len(data)
while j < n and data[j] not in _WHITESPACE + b"[]<>()/%":
j += 1
return data[i:j], j
def _read_word(data: bytes, i: int) -> Tuple[bytes, int]:
j = i
n = len(data)
while j < n and data[j] not in _WHITESPACE:
# stop at delimiters commonly separating tokens
if data[j] in b"[]<>()/":
break
j += 1
if j == i and i < n and data[i] in b"[]<>()":
return data[i:i+1], i + 1
return data[i:j], j
# ---------- resource merge helpers ----------
RESOURCE_CATEGORIES = [
Name("/ExtGState"),
Name("/ColorSpace"),
Name("/Pattern"),
Name("/Shading"),
Name("/XObject"),
Name("/Font"),
Name("/Properties"),
]
def ensure_dict(obj, pdf) -> Dictionary:
if obj is None:
return Dictionary()
if isinstance(obj, Dictionary):
return obj
# fallback
return Dictionary(obj)
def _unique_name(target_dict: Dictionary, base: Name) -> Name:
if base not in target_dict:
return base
base_str = str(base) # "/F1"
k = 1
while True:
cand = Name(base_str + f"_inl{k}")
if cand not in target_dict:
return cand
k += 1
def merge_resources_with_rename(
page_resources: Dictionary,
form_resources: Dictionary,
) -> Dict[Name, Name]:
"""
Merge form_resources into page_resources.
Returns name remap dict for *all* categories:
old Name -> new Name
"""
remap: Dict[Name, Name] = {}
for cat in RESOURCE_CATEGORIES:
src_cat = form_resources.get(cat, None)
if not isinstance(src_cat, Dictionary):
continue
dst_cat = page_resources.get(cat, None)
if not isinstance(dst_cat, Dictionary):
dst_cat = Dictionary()
page_resources[cat] = dst_cat
for old_name, val in src_cat.items():
old_name = Name(old_name)
if old_name in dst_cat:
# identical object? keep existing mapping
try:
if dst_cat[old_name].objgen == val.objgen:
remap[old_name] = old_name
continue
except Exception:
pass
new_name = _unique_name(dst_cat, old_name)
else:
new_name = old_name
dst_cat[new_name] = val
remap[old_name] = new_name
return remap
def remap_names_in_content(content: bytes, remap: Dict[Name, Name]) -> bytes:
"""
Lightweight lexical rename of name tokens, good enough for content streams.
"""
if not remap:
return content
out = bytearray()
i = 0
n = len(content)
while i < n:
b = content[i]
if b == ord('/'):
tok, j = _read_name(content, i) # b"/F1"
old = Name(tok.decode("latin1"))
new = remap.get(old, old)
out.extend(str(new).encode("latin1"))
i = j
else:
out.append(b)
i += 1
return bytes(out)
# ---------- form inlining ----------
def get_stream_bytes(obj: Stream) -> bytes:
return obj.read_bytes()
def get_form_matrix(form_obj: Stream) -> Optional[Array]:
m = form_obj.get(Name("/Matrix"), None)
if isinstance(m, Array) and len(m) == 6:
return m
return None
def is_form_xobject(obj) -> bool:
if not isinstance(obj, Stream):
return False
st = obj.get(Name("/Subtype"), None)
ty = obj.get(Name("/Type"), None)
return st == Name("/Form") or (ty == Name("/XObject") and st == Name("/Form"))
def inline_content_stream(
pdf: pikepdf.Pdf,
content: bytes,
page_resources: Dictionary,
local_resources: Dictionary,
visited_objgens: Set[Tuple[int, int]],
) -> bytes:
"""
Inline `/Name Do` when Name references Form XObject in local_resources/XObject.
"""
xobjs = local_resources.get(Name("/XObject"), None)
if not isinstance(xobjs, Dictionary):
return content
out = bytearray()
i = 0
n = len(content)
while i < n:
i0 = i
i = _skip_ws(content, i)
out.extend(content[i0:i]) # preserve ws
if i >= n:
break
# try read name token
if content[i] == ord('/'):
name_tok, j = _read_name(content, i) # /Fm1
k = _skip_ws(content, j)
op, k2 = _read_word(content, k) # maybe Do
if op == b"Do":
xname = Name(name_tok.decode("latin1"))
xobj = xobjs.get(xname, None)
if xobj is not None and is_form_xobject(xobj):
# recursion guard
objgen = getattr(xobj, "objgen", None)
if objgen and objgen in visited_objgens:
# cyclic ref; leave as-is
out.extend(content[i:k2])
i = k2
continue
if objgen:
visited_objgens.add(objgen)
# form resources
form_resources = xobj.get(Name("/Resources"), None)
if not isinstance(form_resources, Dictionary):
form_resources = Dictionary()
# merge resources into page, build remap
remap = merge_resources_with_rename(page_resources, form_resources)
# recurse into form content
form_bytes = get_stream_bytes(xobj)
inlined_inner = inline_content_stream(
pdf=pdf,
content=form_bytes,
page_resources=page_resources,
local_resources=form_resources,
visited_objgens=visited_objgens,
)
# remap resource names lexically
inlined_inner = remap_names_in_content(inlined_inner, remap)
# apply form matrix
matrix = get_form_matrix(xobj)
block = bytearray()
block.extend(b"q\n")
if matrix is not None:
nums = " ".join(str(float(v)).rstrip("0").rstrip(".") if isinstance(v, float) else str(v) for v in matrix)
block.extend(nums.encode("latin1"))
block.extend(b" cm\n")
block.extend(inlined_inner)
if not inlined_inner.endswith(b"\n"):
block.extend(b"\n")
block.extend(b"Q\n")
out.extend(block)
i = k2
if objgen:
visited_objgens.discard(objgen)
continue
# not a Form Do -> copy token and continue from j
out.extend(content[i:j])
i = j
continue
# normal token/char
tok, j = _read_word(content, i)
if j == i: # safety
out.append(content[i])
i += 1
else:
out.extend(content[i:j])
i = j
return bytes(out)
def page_content_bytes(page) -> bytes:
"""
Return concatenated content stream bytes for a page.
"""
c = page.get(Name("/Contents"), None)
if c is None:
return b""
if isinstance(c, Stream):
return c.read_bytes()
if isinstance(c, Array):
parts = []
for s in c:
if isinstance(s, Stream):
parts.append(s.read_bytes())
return b"\n".join(parts)
return b""
def set_page_content_bytes(pdf: pikepdf.Pdf, page, data: bytes) -> None:
page[Name("/Contents")] = pdf.make_stream(data)
def process_pdf(infile: str, outfile: str) -> None:
with pikepdf.open(infile) as pdf:
for page in pdf.pages:
page_resources = page.get(Name("/Resources"), None)
if not isinstance(page_resources, Dictionary):
page_resources = Dictionary()
page[Name("/Resources")] = page_resources
original = page_content_bytes(page)
if not original:
continue
rewritten = inline_content_stream(
pdf=pdf,
content=original,
page_resources=page_resources,
local_resources=page_resources,
visited_objgens=set(),
)
if rewritten != original:
set_page_content_bytes(pdf, page, rewritten)
pdf.save(outfile)
def main():
if len(sys.argv) != 3:
print("Usage: python inline_form_xobjects.py input.pdf output.pdf")
sys.exit(2)
infile, outfile = sys.argv[1], sys.argv[2]
process_pdf(infile, outfile)
print(f"Wrote {outfile}")
if __name__ == "__main__":
main()
Quick test
python inline_form_xobjects.py in.pdf out.pdf
Important limitations (so you’re not surprised)
- This is a lightweight tokenizer, not a full PDF content parser.
- It handles common
/Name Doform usage, but may break on exotic syntax or tricky edge cases. - It does not explicitly preserve all semantics of transparency groups/knockout behavior in complex files.
- Some downstream optimizers may reintroduce Form XObjects.
If you want, I can provide a more robust version that uses pikepdf.parse_content_stream/operator-level rewriting for better correctness.
@adangel
/CREO_o6 BMC
/Processed /Prop1 BDC
EMC
EMC
What does the following mean?
Copilot
This is marked-content tagging in a PDF content stream (for structure/metadata), not drawing by itself.
-
/CREO_o6 BMC
Begin a marked-content section with tagCREO_o6. -
/Processed /Prop1 BDC
Begin another marked-content section with tagProcessed, with properties taken from resource nameProp1(usually from/Resources /Properties). -
EMC
End the inner marked-content section (Processed). -
EMC
End the outer marked-content section (CREO_o6).
So it’s nested annotation scopes:
CREO_o6 { Processed(with Prop1) { ... } }
If there are no painting operators inside, it only marks logical structure/artifact metadata for consumers (accessibility, extraction, workflows, vendor tooling like Creo), not visible output directly.
@adangel
q
/PlacedPDF /Prop4 BDC
BT
0 Tc
0 Tw
4 Tr
/F1 1 Tf
17.799999237 0 0 17.799999237 517.120483398 848.462097167 Tm
/CREO_o197 BMC
/Processed /Prop1 BDC
/Cs1 cs
.211036369 .286165624 .490344554 scn
[( S) 2 (u) -1 (p) 1 (p) 1 (o) -2 (r) -25 (t) ] TJ
EMC
EMC
0 Tc
0 Tw
/F2 1 Tf
9.399999618 0 0 9.399999618 369.718688964 832.601379394 Tm
/CREO_o198 BMC
/Processed /Prop1 BDC
/Cs1 cs
.211036369 .286165624 .490344554 scn
[(L) -4 (i) 2 (n) 4 (u) -4 (x) ] TJ
EMC
EMC
0 Tc
0 Tw
2.352 0 Td
/CREO_o199 BMC
/Processed /Prop1 BDC
/Cs1 cs
.211036369 .286165624 .490344554 scn
[( ) 1 (S) -1 (e) -3 (rv) 13 (e) -3 (r) ] TJ
EMC
EMC
/CREO_o200 BMC
/Processed /Prop1 BDC
/Cs1 cs
.211036369 .286165624 .490344554 scn
( ) Tj
EMC
EMC
0 Tc
0 Tw
3.234999895 0 Td
/CREO_o201 BMC
/Processed /Prop1 BDC
/Cs1 cs
.98868221 .741394817 .159299567 scn
(&) Tj
EMC
EMC
0 Tc
0 Tw
.819 0 Td
/CREO_o202 BMC
/Processed /Prop1 BDC
/Cs1 cs
.211036369 .286165624 .490344554 scn
[( ) 1 (D) 5 (e) -3 (s) -5 (kt) 9 (o) 4 (p) 9 (,) ] TJ
EMC
EMC
0 Tc
0 Tw
3.874 0 Td
/CREO_o203 BMC
/Processed /Prop1 BDC
/Cs1 cs
.211036369 .286165624 .490344554 scn
[( ) 1 (O) 3 (p) -2 (e) -3 (n) 4 (S) -1 (t) -1 (a) -4 (c) 4 (k,) ] TJ
EMC
EMC
0 Tc
0 Tw
4.991 0 Td
/CREO_o204 BMC
/Processed /Prop1 BDC
/Cs1 cs
.211036369 .286165624 .490344554 scn
[( ) 1 (C) 28 (e) -3 (p) -2 (h) 4 (,) ] TJ
EMC
EMC
0 Tc
0 Tw
2.555 0 Td
/CREO_o205 BMC
/Processed /Prop1 BDC
/Cs1 cs
.211036369 .286165624 .490344554 scn
[( ) 1 (K) 36 (u) -4 (b) -3 (e) -3 (rn) 4 (e) -3 (t) 9 (e) -3 (s) -5 (,) ] TJ
EMC
EMC
0 Tc
0 Tw
5.247 0 Td
/CREO_o206 BMC
/Processed /Prop1 BDC
/Cs1 cs
.211036369 .286165624 .490344554 scn
( ) Tj
EMC
EMC
0 Tc
0 Tw
-23.063999176 -1.294 Td
/CREO_o207 BMC
/Processed /Prop1 BDC
/Cs1 cs
.211036369 .286165624 .490344554 scn
[(O) 3 (p) -2 (e) -3 (n) 4 (s) -5 (h) 4 (i) 2 (f) 21 (t) -23 (,) ] TJ
EMC
EMC
0 Tc
0 Tw
4.322 0 Td
/CREO_o208 BMC
/Processed /Prop1 BDC
/Cs1 cs
.211036369 .286165624 .490344554 scn
[( ) 1 (R) 10 (a) -4 (n) 4 (c) 4 (h) 4 (e) -3 (r) 63 (,) ] TJ
EMC
EMC
0 Tc
0 Tw
3.821 0 Td
/CREO_o209 BMC
/Processed /Prop1 BDC
/Cs1 cs
.211036369 .286165624 .490344554 scn
[( ) 33 (An) 4 (s) -5 (i) 2 (b) -3 (l) 5 (e) -3 (,) ] TJ
EMC
EMC
0 Tc
0 Tw
3.544 0 Td
/CREO_o210 BMC
/Processed /Prop1 BDC
/Cs1 cs
.211036369 .286165624 .490344554 scn
[( ) 1 (P) 27 (u) -4 (p) -2 (p) -2 (e) -3 (t) ] TJ
EMC
EMC
0 Tc
0 Tw
3.278 0 Td
/CREO_o211 BMC
/Processed /Prop1 BDC
/Cs1 cs
.211036369 .286165624 .490344554 scn
( ) Tj
EMC
EMC
0 Tc
0 Tw
.266 0 Td
/CREO_o212 BMC
/Processed /Prop1 BDC
/Cs1 cs
.98868221 .741394817 .159299567 scn
(&) Tj
EMC
EMC
0 Tc
0 Tw
.819 0 Td
/CREO_o213 BMC
/Processed /Prop1 BDC
/Cs1 cs
.211036369 .286165624 .490344554 scn
[( ) 12 (v) 3 (i) 2 (e) -3 (l) 5 (e) -3 (s) ] TJ
EMC
EMC
0 Tc
0 Tw
2.734999895 0 Td
/CREO_o214 BMC
/Processed /Prop1 BDC
/Cs1 cs
.211036369 .286165624 .490344554 scn
[( ) 1 (m) 3 (e) -3 (h) 4 (r) ] TJ
EMC
EMC
ET
EMC
Q
What does this section mean?
Copilot
It’s a text drawing block that renders styled text (German + tech terms), wrapped in marked-content tags.
In short
This paints roughly:
“Support”
“Linux Server & Desktop, OpenStack, Ceph, Kubernetes, OpenShift, Rancher, Ansible, Puppet & vieles mehr”
with specific fonts, sizes, colors, and manual kerning/position tweaks.
Structure
-
q ... Q→ save/restore graphics state. -
/PlacedPDF /Prop4 BDC ... EMC→ marked-content wrapper with property dictionaryProp4. -
BT ... ET→ begin/end text object.
Inside BT...ET:
-
0 Tc,0 Tw→ char/word spacing reset. -
4 Tr→ text rendering mode = fill text + add to clipping path (important: text can also define clipping). -
/F1 1 Tfthen17.79... Tm→ select font resourceF1, size via text matrix (large “Support” line). -
/F2 1 Tfthen9.39... Tm→ switch to smaller font for rest. - Repeated
Td→ move text position incrementally. -
TJ/Tj→ show text:-
TJarrays include kerning adjustments ([(S) 2 (u) -1 ...]). -
Tjshows a simple string like(&)or( ).
-
-
/Cs1 cs+... scn→ set non-stroking color in color spaceCs1.-
.211 .286 .490= bluish text -
.989 .741 .159= yellow/orange for the&characters.
-
-
/CREO_o### BMC+/Processed /Prop1 BDC…EMC→ nested producer tags (likely from Creo pipeline), mostly metadata/structure, not changing glyph shapes directly.
So this section is essentially vendor-tagged, manually kerned text layout commands for that support-capabilities sentence.
@adangel
Create a script to remove the manually kerning tweaks
Copilot
#!/usr/bin/env python3
"""
Remove manual kerning tweaks from PDF text-show operators by rewriting TJ arrays.
What it does:
- Finds TJ arrays like: [(A) -120 (V) 30 (A)] TJ
- Removes all numeric adjustments, keeping only text strings:
[(A) (V) (A)] TJ
- Optionally converts single-element TJ arrays to Tj:
[(Hello)] TJ -> (Hello) Tj
What it does NOT do:
- Full PDF syntax parsing (this is a pragmatic tokenizer)
- Preserve exact original spacing from kerning numbers (visual text may reflow slightly)
Usage:
python remove_manual_kerning.py in.pdf out.pdf
python remove_manual_kerning.py in.pdf out.pdf --keep-tj
"""
import sys
from typing import Tuple, List
import pikepdf
from pikepdf import Name, Array, Stream
WS = b" \t\r\n\x0c\x00"
def skip_ws(data: bytes, i: int) -> int:
n = len(data)
while i < n and data[i] in WS:
i += 1
return i
def read_number_token(data: bytes, i: int) -> Tuple[bytes, int]:
j = i
n = len(data)
if j < n and data[j] in b"+-":
j += 1
dot_seen = False
while j < n:
c = data[j]
if c >= ord('0') and c <= ord('9'):
j += 1
continue
if c == ord('.') and not dot_seen:
dot_seen = True
j += 1
continue
break
return data[i:j], j
def is_number_token(tok: bytes) -> bool:
if not tok:
return False
try:
float(tok.decode("latin1"))
return True
except Exception:
return False
def read_literal_string(data: bytes, i: int) -> Tuple[bytes, int]:
# data[i] == '('
n = len(data)
j = i + 1
depth = 1
out = bytearray()
out.append(ord('('))
while j < n and depth > 0:
c = data[j]
out.append(c)
if c == ord('\\'):
# escape next byte if present
j += 1
if j < n:
out.append(data[j])
elif c == ord('('):
depth += 1
elif c == ord(')'):
depth -= 1
j += 1
return bytes(out), j
def read_hex_string(data: bytes, i: int) -> Tuple[bytes, int]:
# data[i] == '<' and next != '<'
n = len(data)
j = i + 1
while j < n and data[j] != ord('>'):
j += 1
if j < n:
j += 1
return data[i:j], j
def read_array(data: bytes, i: int) -> Tuple[bytes, int]:
# data[i] == '[' ; returns full bracketed bytes
n = len(data)
j = i + 1
depth = 1
out = bytearray()
out.append(ord('['))
while j < n and depth > 0:
c = data[j]
if c == ord('('):
s, j2 = read_literal_string(data, j)
out.extend(s)
j = j2
continue
if c == ord('<'):
# could be hex string or dictionary start <<
if j + 1 < n and data[j + 1] == ord('<'):
# dictionary marker inside array, copy literally
out.append(c)
j += 1
out.append(data[j])
j += 1
continue
hs, j2 = read_hex_string(data, j)
out.extend(hs)
j = j2
continue
out.append(c)
if c == ord('['):
depth += 1
elif c == ord(']'):
depth -= 1
j += 1
return bytes(out), j
def tokenize_array_items(arr: bytes) -> List[bytes]:
# arr includes '[' ... ']'
i = 1
n = len(arr) - 1
items = []
while i < n:
i = skip_ws(arr, i)
if i >= n:
break
c = arr[i]
if c == ord('('):
s, j = read_literal_string(arr, i)
items.append(s)
i = j
elif c == ord('<'):
if i + 1 < n and arr[i + 1] == ord('<'):
# dictionary token, keep raw until matching >> heuristically
j = i + 2
while j + 1 < n and not (arr[j] == ord('>') and arr[j + 1] == ord('>')):
j += 1
j = min(j + 2, n)
items.append(arr[i:j])
i = j
else:
hs, j = read_hex_string(arr, i)
items.append(hs)
i = j
elif c == ord('/'):
j = i + 1
while j < n and arr[j] not in WS + b"[]<>()/%":
j += 1
items.append(arr[i:j])
i = j
else:
tok, j = read_number_token(arr, i)
if tok:
items.append(tok)
i = j
else:
# fallback generic token
j = i
while j < n and arr[j] not in WS + b"[]":
j += 1
items.append(arr[i:j])
i = j
return items
def rewrite_tj_array(arr: bytes, keep_tj: bool) -> bytes:
items = tokenize_array_items(arr)
kept = []
for t in items:
t_strip = t.strip()
if not t_strip:
continue
# Keep only strings (literal or hex)
if t_strip.startswith(b"(") or (t_strip.startswith(b"<") and not t_strip.startswith(b"<<")):
kept.append(t_strip)
elif is_number_token(t_strip):
# drop kerning adjustment
continue
else:
# unknown token in TJ array: keep conservatively
kept.append(t_strip)
if not kept:
# empty text; keep a harmless empty string
kept = [b"()"]
if (not keep_tj) and len(kept) == 1:
# convert to Tj operand form; caller appends operator
return kept[0] + b" Tj"
return b"[ " + b" ".join(kept) + b" ] TJ"
def rewrite_content_stream(data: bytes, keep_tj: bool) -> bytes:
out = bytearray()
i = 0
n = len(data)
while i < n:
c = data[i]
if c == ord('['):
arr, j = read_array(data, i)
k = skip_ws(data, j)
# read operator token after array
op_start = k
while k < n and data[k] not in WS:
if data[k] in b"[]<>()":
break
k += 1
op = data[op_start:k]
if op == b"TJ":
rewritten = rewrite_tj_array(arr, keep_tj=keep_tj)
out.extend(rewritten)
i = k
continue
else:
out.extend(arr)
i = j
continue
out.append(c)
i += 1
return bytes(out)
def page_content_bytes(page) -> bytes:
c = page.get(Name("/Contents"), None)
if c is None:
return b""
if isinstance(c, Stream):
return c.read_bytes()
if isinstance(c, Array):
parts = []
for s in c:
if isinstance(s, Stream):
parts.append(s.read_bytes())
return b"\n".join(parts)
return b""
def set_page_content_bytes(pdf: pikepdf.Pdf, page, data: bytes) -> None:
page[Name("/Contents")] = pdf.make_stream(data)
def process_pdf(infile: str, outfile: str, keep_tj: bool) -> None:
with pikepdf.open(infile) as pdf:
changed = 0
for page in pdf.pages:
original = page_content_bytes(page)
if not original:
continue
rewritten = rewrite_content_stream(original, keep_tj=keep_tj)
if rewritten != original:
set_page_content_bytes(pdf, page, rewritten)
changed += 1
pdf.save(outfile)
print(f"Wrote {outfile} (modified pages: {changed})")
def main():
if len(sys.argv) < 3:
print("Usage: python remove_manual_kerning.py in.pdf out.pdf [--keep-tj]")
sys.exit(2)
infile = sys.argv[1]
outfile = sys.argv[2]
keep_tj = "--keep-tj" in sys.argv[3:]
process_pdf(infile, outfile, keep_tj=keep_tj)
if __name__ == "__main__":
main()
Install dependency:
pip install pikepdf
Run:
python remove_manual_kerning.py in.pdf out.pdf
If you want, I can provide a second version that only removes kerning for specific text blocks/tags (e.g. only inside /Processed ... BDC).
@adangel
The script only considers TJs directly in the content, but not in referenced Form XObjects. Please adjust the script to also replace TJs in any Dictionary, that contains Form XObjects
Copilot
Good catch. You need to recurse through page resources and process every Form XObject stream (including nested ones), not just page /Contents.
Here’s an updated script that:
- rewrites
TJin page content streams - rewrites
TJin all reachable Form XObject streams found in resource dictionaries - recurses into nested Form XObjects
- avoids infinite loops by tracking visited objects
#!/usr/bin/env python3
"""
Remove manual kerning tweaks from PDF text-show operators by rewriting TJ arrays,
including inside referenced Form XObjects (recursively).
Requirements:
pip install pikepdf
Usage:
python remove_manual_kerning_all_forms.py in.pdf out.pdf
python remove_manual_kerning_all_forms.py in.pdf out.pdf --keep-tj
"""
import sys
from typing import Tuple, List, Set
import pikepdf
from pikepdf import Name, Array, Stream, Dictionary
WS = b" \t\r\n\x0c\x00"
# ---------------- tokenizer helpers ----------------
def skip_ws(data: bytes, i: int) -> int:
n = len(data)
while i < n and data[i] in WS:
i += 1
return i
def read_number_token(data: bytes, i: int) -> Tuple[bytes, int]:
j = i
n = len(data)
if j < n and data[j] in b"+-":
j += 1
dot_seen = False
digit_seen = False
while j < n:
c = data[j]
if ord('0') <= c <= ord('9'):
digit_seen = True
j += 1
continue
if c == ord('.') and not dot_seen:
dot_seen = True
j += 1
continue
break
if not digit_seen:
return b"", i
return data[i:j], j
def is_number_token(tok: bytes) -> bool:
if not tok:
return False
try:
float(tok.decode("latin1"))
return True
except Exception:
return False
def read_literal_string(data: bytes, i: int) -> Tuple[bytes, int]:
# data[i] == '('
n = len(data)
j = i + 1
depth = 1
out = bytearray(b"(")
while j < n and depth > 0:
c = data[j]
out.append(c)
if c == ord('\\'):
j += 1
if j < n:
out.append(data[j])
elif c == ord('('):
depth += 1
elif c == ord(')'):
depth -= 1
j += 1
return bytes(out), j
def read_hex_string(data: bytes, i: int) -> Tuple[bytes, int]:
# data[i] == '<' and next != '<'
n = len(data)
j = i + 1
while j < n and data[j] != ord('>'):
j += 1
if j < n:
j += 1
return data[i:j], j
def read_array(data: bytes, i: int) -> Tuple[bytes, int]:
# data[i] == '['
n = len(data)
j = i + 1
depth = 1
out = bytearray(b"[")
while j < n and depth > 0:
c = data[j]
if c == ord('('):
s, j2 = read_literal_string(data, j)
out.extend(s)
j = j2
continue
if c == ord('<'):
if j + 1 < n and data[j + 1] == ord('<'):
out.extend(b"<<")
j += 2
continue
hs, j2 = read_hex_string(data, j)
out.extend(hs)
j = j2
continue
out.append(c)
if c == ord('['):
depth += 1
elif c == ord(']'):
depth -= 1
j += 1
return bytes(out), j
def tokenize_array_items(arr: bytes) -> List[bytes]:
# arr includes [ ... ]
i = 1
n = len(arr) - 1
items: List[bytes] = []
while i < n:
i = skip_ws(arr, i)
if i >= n:
break
c = arr[i]
if c == ord('('):
s, j = read_literal_string(arr, i)
items.append(s)
i = j
elif c == ord('<'):
if i + 1 < n and arr[i + 1] == ord('<'):
# dictionary token inside array (rare for TJ arrays)
j = i + 2
while j + 1 < n and not (arr[j] == ord('>') and arr[j + 1] == ord('>')):
j += 1
j = min(j + 2, n)
items.append(arr[i:j])
i = j
else:
hs, j = read_hex_string(arr, i)
items.append(hs)
i = j
elif c == ord('/'):
j = i + 1
while j < n and arr[j] not in WS + b"[]<>()/%":
j += 1
items.append(arr[i:j])
i = j
else:
tok, j = read_number_token(arr, i)
if tok:
items.append(tok)
i = j
else:
j = i
while j < n and arr[j] not in WS + b"[]":
j += 1
items.append(arr[i:j])
i = j
return items
# ---------------- TJ rewrite ----------------
def rewrite_tj_array(arr: bytes, keep_tj: bool) -> bytes:
items = tokenize_array_items(arr)
kept: List[bytes] = []
for t in items:
s = t.strip()
if not s:
continue
# Keep text strings only; drop numeric kerning adjustments
if s.startswith(b"(") or (s.startswith(b"<") and not s.startswith(b"<<")):
kept.append(s)
elif is_number_token(s):
continue
else:
# conservative: keep unknown tokens
kept.append(s)
if not kept:
kept = [b"()"]
if (not keep_tj) and len(kept) == 1:
return kept[0] + b" Tj"
return b"[ " + b" ".join(kept) + b" ] TJ"
def rewrite_content_stream(data: bytes, keep_tj: bool) -> bytes:
out = bytearray()
i = 0
n = len(data)
while i < n:
if data[i] == ord('['):
arr, j = read_array(data, i)
k = skip_ws(data, j)
op_start = k
while k < n and data[k] not in WS and data[k] not in b"[]<>()":
k += 1
op = data[op_start:k]
if op == b"TJ":
out.extend(rewrite_tj_array(arr, keep_tj=keep_tj))
i = k
continue
else:
out.extend(arr)
i = j
continue
out.append(data[i])
i += 1
return bytes(out)
# ---------------- PDF traversal ----------------
def stream_bytes(obj: Stream) -> bytes:
return obj.read_bytes()
def set_stream_bytes(pdf: pikepdf.Pdf, obj: Stream, data: bytes) -> None:
# Replace stream data while keeping dictionary entries.
obj.write(data)
def is_form_xobject(obj) -> bool:
if not isinstance(obj, Stream):
return False
subtype = obj.get(Name("/Subtype"), None)
typ = obj.get(Name("/Type"), None)
return subtype == Name("/Form") or (typ == Name("/XObject") and subtype == Name("/Form"))
def process_form_xobject_recursive(
pdf: pikepdf.Pdf,
form_obj: Stream,
keep_tj: bool,
visited: Set[tuple],
) -> int:
"""
Process this form's stream, then recurse into its /Resources /XObject forms.
Returns number of modified streams.
"""
changed = 0
objgen = getattr(form_obj, "objgen", None)
if objgen is not None:
if objgen in visited:
return 0
visited.add(objgen)
# Rewrite this form stream
original = stream_bytes(form_obj)
rewritten = rewrite_content_stream(original, keep_tj=keep_tj)
if rewritten != original:
set_stream_bytes(pdf, form_obj, rewritten)
changed += 1
# Recurse into nested forms referenced by this form's resources
resources = form_obj.get(Name("/Resources"), None)
if isinstance(resources, Dictionary):
changed += process_forms_in_resources(pdf, resources, keep_tj, visited)
return changed
def process_forms_in_resources(
pdf: pikepdf.Pdf,
resources: Dictionary,
keep_tj: bool,
visited: Set[tuple],
) -> int:
changed = 0
xobj_dict = resources.get(Name("/XObject"), None)
if not isinstance(xobj_dict, Dictionary):
return 0
for _name, xobj in xobj_dict.items():
if is_form_xobject(xobj):
changed += process_form_xobject_recursive(pdf, xobj, keep_tj, visited)
return changed
def page_content_bytes(page) -> bytes:
c = page.get(Name("/Contents"), None)
if c is None:
return b""
if isinstance(c, Stream):
return c.read_bytes()
if isinstance(c, Array):
parts = []
for s in c:
if isinstance(s, Stream):
parts.append(s.read_bytes())
return b"\n".join(parts)
return b""
def set_page_content_bytes(pdf: pikepdf.Pdf, page, data: bytes) -> None:
page[Name("/Contents")] = pdf.make_stream(data)
def process_pdf(infile: str, outfile: str, keep_tj: bool) -> None:
with pikepdf.open(infile) as pdf:
modified_streams = 0
visited_forms: Set[tuple] = set()
for page in pdf.pages:
# 1) rewrite page content
original = page_content_bytes(page)
if original:
rewritten = rewrite_content_stream(original, keep_tj=keep_tj)
if rewritten != original:
set_page_content_bytes(pdf, page, rewritten)
modified_streams += 1
# 2) rewrite all reachable form xobjects from page resources
resources = page.get(Name("/Resources"), None)
if isinstance(resources, Dictionary):
modified_streams += process_forms_in_resources(
pdf, resources, keep_tj, visited_forms
)
pdf.save(outfile)
print(f"Wrote {outfile} (modified streams: {modified_streams})")
def main():
if len(sys.argv) < 3:
print("Usage: python remove_manual_kerning_all_forms.py in.pdf out.pdf [--keep-tj]")
sys.exit(2)
infile = sys.argv[1]
outfile = sys.argv[2]
keep_tj = "--keep-tj" in sys.argv[3:]
process_pdf(infile, outfile, keep_tj=keep_tj)
if __name__ == "__main__":
main()
If you want, I can also add a --only-tag Processed mode to rewrite TJ only inside specific BMC/BDC ... EMC marked-content regions.
@adangel
please combine the single letter string literals into one literal for TJs
Copilot
Absolutely—good improvement.
Direct answer: update the rewrite_tj_array() logic so after removing numeric kerning values, it merges adjacent literal strings like (S) (u) (p) into (Sup) (and similarly adjacent hex strings into one hex string).
Use this drop-in replacement for rewrite_tj_array() plus helpers:
def _is_literal_string_token(tok: bytes) -> bool:
return tok.startswith(b"(") and tok.endswith(b")")
def _is_hex_string_token(tok: bytes) -> bool:
return tok.startswith(b"<") and tok.endswith(b">") and not tok.startswith(b"<<")
def _decode_pdf_literal(tok: bytes) -> bytes:
"""
Decode a PDF literal string token ( ... ) into raw bytes.
Handles balanced parens/escapes as they appear in tokenized literals.
"""
assert _is_literal_string_token(tok)
s = tok[1:-1]
out = bytearray()
i = 0
n = len(s)
while i < n:
c = s[i]
if c != 0x5C: # backslash
out.append(c)
i += 1
continue
i += 1
if i >= n:
out.append(0x5C)
break
e = s[i]
i += 1
# Simple escapes
mapping = {
ord('n'): 0x0A,
ord('r'): 0x0D,
ord('t'): 0x09,
ord('b'): 0x08,
ord('f'): 0x0C,
ord('('): ord('('),
ord(')'): ord(')'),
ord('\\'): ord('\\'),
}
if e in mapping:
out.append(mapping[e])
continue
# Line continuation: backslash + EOL => ignore both
if e == 0x0D: # \r
if i < n and s[i] == 0x0A:
i += 1
continue
if e == 0x0A: # \n
continue
# Octal escape: up to 3 octal digits (already consumed first digit e)
if 0x30 <= e <= 0x37:
oct_digits = [e]
for _ in range(2):
if i < n and 0x30 <= s[i] <= 0x37:
oct_digits.append(s[i])
i += 1
else:
break
out.append(int(bytes(oct_digits).decode("latin1"), 8) & 0xFF)
continue
# Unknown escape => literal char
out.append(e)
return bytes(out)
def _encode_pdf_literal(raw: bytes) -> bytes:
"""
Encode raw bytes into a safe PDF literal string token ( ... ).
Escapes backslash and parentheses; uses common escapes for controls.
"""
out = bytearray(b"(")
for b in raw:
if b == 0x5C: # \
out.extend(b"\\\\")
elif b == 0x28: # (
out.extend(b"\\(")
elif b == 0x29: # )
out.extend(b"\\)")
elif b == 0x0A:
out.extend(b"\\n")
elif b == 0x0D:
out.extend(b"\\r")
elif b == 0x09:
out.extend(b"\\t")
elif b == 0x08:
out.extend(b"\\b")
elif b == 0x0C:
out.extend(b"\\f")
elif b < 32 or b > 126:
out.extend(f"\\{b:03o}".encode("latin1"))
else:
out.append(b)
out.extend(b")")
return bytes(out)
def _normalize_hex_payload(payload: bytes) -> bytes:
# Remove whitespace inside hex string and uppercase
payload = b"".join(payload.split()).upper()
if len(payload) % 2 == 1:
payload += b"0" # PDF rule: odd nibble implicitly padded
return payload
def _merge_adjacent_string_tokens(tokens: list[bytes]) -> list[bytes]:
"""
Merge adjacent string tokens:
- literal + literal => one literal
- hex + hex => one hex
Keep literal/hex boundaries separate (no cross-type conversion).
"""
merged: list[bytes] = []
i = 0
n = len(tokens)
while i < n:
t = tokens[i]
if _is_literal_string_token(t):
raw = bytearray(_decode_pdf_literal(t))
j = i + 1
while j < n and _is_literal_string_token(tokens[j]):
raw.extend(_decode_pdf_literal(tokens[j]))
j += 1
merged.append(_encode_pdf_literal(bytes(raw)))
i = j
continue
if _is_hex_string_token(t):
payload = _normalize_hex_payload(t[1:-1])
j = i + 1
while j < n and _is_hex_string_token(tokens[j]):
payload += _normalize_hex_payload(tokens[j][1:-1])
j += 1
merged.append(b"<" + payload + b">")
i = j
continue
merged.append(t)
i += 1
return merged
def rewrite_tj_array(arr: bytes, keep_tj: bool) -> bytes:
items = tokenize_array_items(arr)
# 1) Remove numeric kerning adjustments, keep string operands
kept: list[bytes] = []
for t in items:
s = t.strip()
if not s:
continue
if _is_literal_string_token(s) or _is_hex_string_token(s):
kept.append(s)
elif is_number_token(s):
# remove kerning tweak
continue
else:
# conservative fallback
kept.append(s)
if not kept:
kept = [b"()"]
# 2) Merge adjacent strings into one literal/hex token
kept = _merge_adjacent_string_tokens(kept)
# 3) Optionally collapse single-element TJ -> Tj
if (not keep_tj) and len(kept) == 1:
return kept[0] + b" Tj"
return b"[ " + b" ".join(kept) + b" ] TJ"
What this changes
Example input:
[( S) 2 (u) -1 (p) 1 (p) 1 (o) -2 (r) -25 (t) ] TJ
becomes:
( Support) Tj
(or [ ( Support) ] TJ with --keep-tj)
If you want, I can now give you one fully consolidated script (all previous fixes + this merge behavior integrated).
@adangel
/CREO_o244 BMC
/Processed /Prop1 BDC
/Cs1 cs
1 .999987423 1 scn
(8) Tj
EMC
EMC
What does the following mean? Is this wrapping with BMC and BDC necessary?
Copilot
It means:
-
/CREO_o244 BMC→ begin marked-content with tagCREO_o244 -
/Processed /Prop1 BDC→ begin nested marked-content with tagProcessedand property dictProp1 -
/Cs1 cs+1 .999987423 1 scn→ set non-stroking color (in colorspaceCs1) to almost white -
(8) Tj→ draw text string"8" -
EMC/EMC→ close inner and outer marked-content scopes
So yes, visually this paints the character 8 in that color; the BMC/BDC parts are metadata/structure wrappers.
Are BMC/BDC necessary?
- For rendering appearance: usually no.
- For structure/semantics/workflow metadata (tagged PDF, extraction hints, producer-specific processing): possibly yes.
If your goal is only visual fidelity (print/view), removing those wrappers is typically safe as long as you keep the actual graphics/text operators inside.
If your goal includes accessibility, logical structure, archival conformance, or downstream CAD/PLM workflows, keep them.
@adangel
Please create a new script to remove the wrapping with BMC/BDC parts in all content streams and resources inside Form XObjects.
Copilot
Great idea. Here’s a standalone script that removes all BMC … EMC and BDC … EMC wrappers (including nested ones) while preserving the enclosed drawing operators, across:
- page content streams
- Form XObject streams reachable via page/form resource dictionaries (recursive)
#!/usr/bin/env python3
"""
Remove marked-content wrappers (BMC/BDC ... EMC) from PDF content streams.
It processes:
- Page /Contents streams
- Form XObject streams in /Resources /XObject (recursively)
Wrappers removed:
- /Tag BMC ... EMC
- /Tag <<...>> BDC ... EMC
- /Tag /PropName BDC ... EMC
- /Tag <</MCID ...>> BDC ... EMC
(and nested combinations)
Preserves the enclosed operators/content bytes as much as possible.
Requirements:
pip install pikepdf
Usage:
python remove_marked_content_wrappers.py in.pdf out.pdf
"""
import sys
from typing import Set, Tuple
import pikepdf
from pikepdf import Name, Stream, Array, Dictionary
WS = b" \t\r\n\x0c\x00"
# ---------------- token readers ----------------
def skip_ws(data: bytes, i: int) -> int:
n = len(data)
while i < n and data[i] in WS:
i += 1
return i
def read_name(data: bytes, i: int) -> Tuple[bytes, int]:
# expects data[i] == '/'
j = i + 1
n = len(data)
while j < n and data[j] not in WS + b"[]<>()/%":
j += 1
return data[i:j], j
def read_word(data: bytes, i: int) -> Tuple[bytes, int]:
j = i
n = len(data)
while j < n and data[j] not in WS + b"[]<>()/%":
j += 1
return data[i:j], j
def read_literal_string(data: bytes, i: int) -> Tuple[bytes, int]:
# data[i] == '('
out = bytearray()
n = len(data)
depth = 0
j = i
while j < n:
c = data[j]
out.append(c)
if c == ord('('):
depth += 1
elif c == ord(')'):
depth -= 1
if depth == 0:
j += 1
break
elif c == ord('\\'):
j += 1
if j < n:
out.append(data[j])
j += 1
return bytes(out), j
def read_hex_string(data: bytes, i: int) -> Tuple[bytes, int]:
# data[i] == '<' and next != '<'
j = i + 1
n = len(data)
while j < n and data[j] != ord('>'):
j += 1
if j < n:
j += 1
return data[i:j], j
def read_dict(data: bytes, i: int) -> Tuple[bytes, int]:
# reads << ... >> including nested dictionaries and strings
assert i + 1 < len(data) and data[i] == ord('<') and data[i + 1] == ord('<')
out = bytearray(b"<<")
j = i + 2
n = len(data)
depth = 1
while j < n and depth > 0:
c = data[j]
if c == ord('('):
s, j2 = read_literal_string(data, j)
out.extend(s)
j = j2
continue
if c == ord('<'):
if j + 1 < n and data[j + 1] == ord('<'):
out.extend(b"<<")
j += 2
depth += 1
continue
hs, j2 = read_hex_string(data, j)
out.extend(hs)
j = j2
continue
if c == ord('>') and j + 1 < n and data[j + 1] == ord('>'):
out.extend(b">>")
j += 2
depth -= 1
continue
out.append(c)
j += 1
return bytes(out), j
def skip_single_operand(data: bytes, i: int) -> int:
i = skip_ws(data, i)
if i >= len(data):
return i
c = data[i]
if c == ord('/'):
_, j = read_name(data, i)
return j
if c == ord('<'):
if i + 1 < len(data) and data[i + 1] == ord('<'):
_, j = read_dict(data, i)
return j
_, j = read_hex_string(data, i)
return j
if c == ord('('):
_, j = read_literal_string(data, i)
return j
# generic token (number/keyword/etc.)
_, j = read_word(data, i)
return j
# ---------------- core rewrite ----------------
def remove_marked_content_wrappers(data: bytes) -> bytes:
out = bytearray()
i = 0
n = len(data)
while i < n:
c = data[i]
# Candidate start of BMC/BDC: must begin with a tag name
if c == ord('/'):
start = i
_, j = read_name(data, i)
k = skip_ws(data, j)
# try BMC: /Tag BMC
op, k2 = read_word(data, k)
if op == b"BMC":
# drop prefix "/Tag BMC", keep inner content
i = k2
continue
# try BDC: /Tag <props> BDC OR /Tag /PropName BDC
p = skip_single_operand(data, k)
p_ws = skip_ws(data, p)
op2, p2 = read_word(data, p_ws)
if op2 == b"BDC":
# drop prefix "/Tag <operand> BDC", keep inner content
i = p2
continue
# not BMC/BDC start, copy original '/'
out.append(data[start])
i = start + 1
continue
# Remove EMC operators (standalone)
if (c == ord('E') and data[i:i+3] == b"EMC"):
prev_ok = (i == 0) or (data[i - 1] in WS + b"[]<>()/%")
next_ok = (i + 3 >= n) or (data[i + 3] in WS + b"[]<>()/%")
if prev_ok and next_ok:
i += 3
continue
out.append(c)
i += 1
return bytes(out)
# ---------------- PDF traversal ----------------
def is_form_xobject(obj) -> bool:
if not isinstance(obj, Stream):
return False
subtype = obj.get(Name("/Subtype"), None)
typ = obj.get(Name("/Type"), None)
return subtype == Name("/Form") or (typ == Name("/XObject") and subtype == Name("/Form"))
def page_content_bytes(page) -> bytes:
c = page.get(Name("/Contents"), None)
if c is None:
return b""
if isinstance(c, Stream):
return c.read_bytes()
if isinstance(c, Array):
parts = []
for s in c:
if isinstance(s, Stream):
parts.append(s.read_bytes())
return b"\n".join(parts)
return b""
def set_page_content_bytes(pdf: pikepdf.Pdf, page, data: bytes) -> None:
page[Name("/Contents")] = pdf.make_stream(data)
def process_form_recursive(form: Stream, visited: Set[Tuple[int, int]]) -> int:
changed = 0
objgen = getattr(form, "objgen", None)
if objgen and objgen in visited:
return 0
if objgen:
visited.add(objgen)
original = form.read_bytes()
rewritten = remove_marked_content_wrappers(original)
if rewritten != original:
form.write(rewritten)
changed += 1
resources = form.get(Name("/Resources"), None)
if isinstance(resources, Dictionary):
changed += process_forms_in_resources(resources, visited)
return changed
def process_forms_in_resources(resources: Dictionary, visited: Set[Tuple[int, int]]) -> int:
changed = 0
xobj = resources.get(Name("/XObject"), None)
if not isinstance(xobj, Dictionary):
return 0
for _, xo in xobj.items():
if is_form_xobject(xo):
changed += process_form_recursive(xo, visited)
return changed
def process_pdf(infile: str, outfile: str) -> None:
with pikepdf.open(infile) as pdf:
modified_streams = 0
visited_forms: Set[Tuple[int, int]] = set()
for page in pdf.pages:
# page content
original = page_content_bytes(page)
if original:
rewritten = remove_marked_content_wrappers(original)
if rewritten != original:
set_page_content_bytes(pdf, page, rewritten)
modified_streams += 1
# forms reachable from page resources
resources = page.get(Name("/Resources"), None)
if isinstance(resources, Dictionary):
modified_streams += process_forms_in_resources(resources, visited_forms)
pdf.save(outfile)
print(f"Wrote {outfile} (modified streams: {modified_streams})")
def main():
if len(sys.argv) != 3:
print("Usage: python remove_marked_content_wrappers.py in.pdf out.pdf")
sys.exit(2)
process_pdf(sys.argv[1], sys.argv[2])
if __name__ == "__main__":
main()
Run:
pip install pikepdf
python remove_marked_content_wrappers.py in.pdf out.pdf
If you want, I can also provide a stricter parser-based version using operator parsing (more robust for exotic content streams).
@adangel
What does the section q/Q do?
Copilot
q and Q are PDF graphics-state save/restore operators.
-
q= push/save current graphics state -
Q= pop/restore previous graphics state
Graphics state includes things like:
- CTM (current transformation matrix)
- line width, dash, cap/join
- colors/color spaces
- clipping path
- text state parameters (when relevant via graphics state context)
- transparency/extgstate settings
So q ... Q creates a local scope: changes inside won’t leak outside.
@adangel
Create a new script, that reduces the levels of q/Q, so that there are at most two graphics state on the stack. The q/Q operators can appears in page content streams and in XObject Form inside resources.
Copilot
#!/usr/bin/env python3
"""
Reduce q/Q nesting depth in PDF content streams so stack depth is at most 2.
Scope:
- Page content streams
- Form XObject streams reachable via /Resources /XObject (recursive)
Behavior:
- Keeps only q operators that would bring depth to <= max_depth (default 2).
- Drops q operators that would exceed max_depth.
- Drops matching Q operators for dropped q's.
- Preserves balanced structure for kept q/Q pairs.
- If an unmatched kept Q appears at depth 0, it is dropped (defensive).
IMPORTANT:
This is a lexical stream rewrite, not a full graphics-state dataflow analysis.
If removed q/Q pairs enclosed state-changing operators, rendering can change.
Use on files where excessive nested q/Q are redundant.
Requirements:
pip install pikepdf
Usage:
python reduce_q_levels.py in.pdf out.pdf
python reduce_q_levels.py in.pdf out.pdf --max-depth 2
"""
import sys
from typing import List, Set, Tuple
import pikepdf
from pikepdf import Name, Stream, Array, Dictionary
WS = b" \t\r\n\x0c\x00"
DELIMS = b"[]<>()/%"
# ---------------- token scanners ----------------
def skip_ws(data: bytes, i: int) -> int:
n = len(data)
while i < n and data[i] in WS:
i += 1
return i
def read_name(data: bytes, i: int) -> int:
# data[i] == '/'
j = i + 1
n = len(data)
while j < n and data[j] not in WS + DELIMS:
j += 1
return j
def read_literal_string(data: bytes, i: int) -> int:
# data[i] == '('
n = len(data)
j = i + 1
depth = 1
while j < n and depth > 0:
c = data[j]
if c == ord('\\'):
j += 2
continue
if c == ord('('):
depth += 1
elif c == ord(')'):
depth -= 1
j += 1
return min(j, n)
def read_hex_string(data: bytes, i: int) -> int:
# data[i] == '<' and next != '<'
n = len(data)
j = i + 1
while j < n and data[j] != ord('>'):
j += 1
if j < n:
j += 1
return j
def read_dict(data: bytes, i: int) -> int:
# data[i:i+2] == b'<<'
n = len(data)
j = i + 2
depth = 1
while j < n and depth > 0:
c = data[j]
if c == ord('('):
j = read_literal_string(data, j)
continue
if c == ord('<'):
if j + 1 < n and data[j + 1] == ord('<'):
depth += 1
j += 2
continue
j = read_hex_string(data, j)
continue
if c == ord('>') and j + 1 < n and data[j + 1] == ord('>'):
depth -= 1
j += 2
continue
j += 1
return j
def read_word(data: bytes, i: int) -> int:
j = i
n = len(data)
while j < n and data[j] not in WS + DELIMS:
j += 1
return j
# ---------------- q/Q rewrite ----------------
def reduce_q_levels_in_stream(data: bytes, max_depth: int = 2) -> bytes:
out = bytearray()
i = 0
n = len(data)
depth = 0
# stack of booleans per seen q: True if kept, False if dropped
q_kept_stack: List[bool] = []
while i < n:
c = data[i]
# Copy complex tokens atomically so embedded "q"/"Q" are ignored
if c == ord('('):
j = read_literal_string(data, i)
out.extend(data[i:j])
i = j
continue
if c == ord('<'):
if i + 1 < n and data[i + 1] == ord('<'):
j = read_dict(data, i)
out.extend(data[i:j])
i = j
else:
j = read_hex_string(data, i)
out.extend(data[i:j])
i = j
continue
if c == ord('/'):
j = read_name(data, i)
out.extend(data[i:j])
i = j
continue
if c in WS:
out.append(c)
i += 1
continue
# Generic word/operator
j = read_word(data, i)
tok = data[i:j]
if tok == b"q":
keep = depth < max_depth
q_kept_stack.append(keep)
if keep:
depth += 1
out.extend(tok)
# else drop this q
elif tok == b"Q":
if q_kept_stack:
kept = q_kept_stack.pop()
if kept:
# normal close of a kept q
if depth > 0:
depth -= 1
out.extend(tok)
else:
# matching close for dropped q -> drop
pass
else:
# unmatched Q at stream level: drop defensively
pass
else:
out.extend(tok)
i = j
# Do NOT auto-insert trailing Qs; keep original stream flow.
return bytes(out)
# ---------------- PDF traversal ----------------
def is_form_xobject(obj) -> bool:
if not isinstance(obj, Stream):
return False
subtype = obj.get(Name("/Subtype"), None)
typ = obj.get(Name("/Type"), None)
return subtype == Name("/Form") or (typ == Name("/XObject") and subtype == Name("/Form"))
def page_content_bytes(page) -> bytes:
c = page.get(Name("/Contents"), None)
if c is None:
return b""
if isinstance(c, Stream):
return c.read_bytes()
if isinstance(c, Array):
parts = []
for s in c:
if isinstance(s, Stream):
parts.append(s.read_bytes())
return b"\n".join(parts)
return b""
def set_page_content_bytes(pdf: pikepdf.Pdf, page, data: bytes) -> None:
page[Name("/Contents")] = pdf.make_stream(data)
def process_form_recursive(form: Stream, visited: Set[Tuple[int, int]], max_depth: int) -> int:
changed = 0
objgen = getattr(form, "objgen", None)
if objgen and objgen in visited:
return 0
if objgen:
visited.add(objgen)
original = form.read_bytes()
rewritten = reduce_q_levels_in_stream(original, max_depth=max_depth)
if rewritten != original:
form.write(rewritten)
changed += 1
resources = form.get(Name("/Resources"), None)
if isinstance(resources, Dictionary):
changed += process_forms_in_resources(resources, visited, max_depth)
return changed
def process_forms_in_resources(resources: Dictionary, visited: Set[Tuple[int, int]], max_depth: int) -> int:
changed = 0
xobj = resources.get(Name("/XObject"), None)
if not isinstance(xobj, Dictionary):
return 0
for _, xo in xobj.items():
if is_form_xobject(xo):
changed += process_form_recursive(xo, visited, max_depth)
return changed
def process_pdf(infile: str, outfile: str, max_depth: int) -> None:
with pikepdf.open(infile) as pdf:
modified_streams = 0
visited_forms: Set[Tuple[int, int]] = set()
for page in pdf.pages:
# page content
original = page_content_bytes(page)
if original:
rewritten = reduce_q_levels_in_stream(original, max_depth=max_depth)
if rewritten != original:
set_page_content_bytes(pdf, page, rewritten)
modified_streams += 1
# form xobjects in page resources
resources = page.get(Name("/Resources"), None)
if isinstance(resources, Dictionary):
modified_streams += process_forms_in_resources(resources, visited_forms, max_depth)
pdf.save(outfile)
print(f"Wrote {outfile} (modified streams: {modified_streams}, max_depth: {max_depth})")
def main():
if len(sys.argv) < 3:
print("Usage: python reduce_q_levels.py in.pdf out.pdf [--max-depth N]")
sys.exit(2)
infile = sys.argv[1]
outfile = sys.argv[2]
max_depth = 2
if "--max-depth" in sys.argv:
idx = sys.argv.index("--max-depth")
if idx + 1 >= len(sys.argv):
print("Error: --max-depth requires a value")
sys.exit(2)
max_depth = int(sys.argv[idx + 1])
if max_depth < 0:
print("Error: --max-depth must be >= 0")
sys.exit(2)
process_pdf(infile, outfile, max_depth=max_depth)
if __name__ == "__main__":
main()
If you want, I can also provide a safer mode that only removes q/Q pairs proven to be no-ops (no state-changing operators inside).
@adangel
There seems to be an endless loop somewhere. Please add some debug log output to the script.
Copilot
Good call. Here’s a debug-enhanced version with:
- progress logs per page/form stream
- token-level trace option
- loop guard (detects non-advancing parser index)
- max-steps guard to prevent true endless loops
#!/usr/bin/env python3
"""
Reduce q/Q nesting depth in PDF content streams so stack depth is at most N,
with debug logging and loop guards.
Processes:
- Page content streams
- Form XObject streams via /Resources /XObject (recursive)
Usage:
python reduce_q_levels_debug.py in.pdf out.pdf
python reduce_q_levels_debug.py in.pdf out.pdf --max-depth 2 --debug
python reduce_q_levels_debug.py in.pdf out.pdf --debug --trace-tokens
"""
import sys
from typing import List, Set, Tuple, Optional
import pikepdf
from pikepdf import Name, Stream, Array, Dictionary
WS = b" \t\r\n\x0c\x00"
DELIMS = b"[]<>()/%"
# ---------------- logging ----------------
class Logger:
def __init__(self, enabled: bool = False, trace_tokens: bool = False):
self.enabled = enabled
self.trace_tokens = trace_tokens
def log(self, msg: str) -> None:
if self.enabled:
print(msg, file=sys.stderr)
def trace(self, msg: str) -> None:
if self.enabled and self.trace_tokens:
print(msg, file=sys.stderr)
# ---------------- token scanners ----------------
def skip_ws(data: bytes, i: int) -> int:
n = len(data)
while i < n and data[i] in WS:
i += 1
return i
def read_name(data: bytes, i: int) -> int:
j = i + 1
n = len(data)
while j < n and data[j] not in WS + DELIMS:
j += 1
return j
def read_literal_string(data: bytes, i: int) -> int:
n = len(data)
j = i + 1
depth = 1
while j < n and depth > 0:
c = data[j]
if c == ord('\\'):
j += 2
continue
if c == ord('('):
depth += 1
elif c == ord(')'):
depth -= 1
j += 1
return min(j, n)
def read_hex_string(data: bytes, i: int) -> int:
n = len(data)
j = i + 1
while j < n and data[j] != ord('>'):
j += 1
if j < n:
j += 1
return j
def read_dict(data: bytes, i: int) -> int:
n = len(data)
j = i + 2
depth = 1
while j < n and depth > 0:
c = data[j]
if c == ord('('):
j = read_literal_string(data, j)
continue
if c == ord('<'):
if j + 1 < n and data[j + 1] == ord('<'):
depth += 1
j += 2
continue
j = read_hex_string(data, j)
continue
if c == ord('>') and j + 1 < n and data[j + 1] == ord('>'):
depth -= 1
j += 2
continue
j += 1
return j
def read_word(data: bytes, i: int) -> int:
j = i
n = len(data)
while j < n and data[j] not in WS + DELIMS:
j += 1
return j
def safe_snippet(data: bytes, i: int, width: int = 40) -> str:
frag = data[i:i+width]
return frag.decode("latin1", errors="replace").replace("\n", "\\n").replace("\r", "\\r")
# ---------------- q/Q rewrite with guards ----------------
def reduce_q_levels_in_stream(
data: bytes,
max_depth: int,
logger: Logger,
stream_label: str,
max_steps_factor: int = 20,
) -> bytes:
out = bytearray()
i = 0
n = len(data)
depth = 0
q_kept_stack: List[bool] = []
steps = 0
max_steps = max(1000, n * max_steps_factor)
logger.log(f"[stream] begin {stream_label}: {n} bytes, max_depth={max_depth}, max_steps={max_steps}")
while i < n:
steps += 1
if steps > max_steps:
logger.log(
f"[stream] ABORT loop-guard in {stream_label}: steps={steps} i={i}/{n} "
f"next='{safe_snippet(data, i)}'"
)
# Fail-safe: append remaining unchanged and stop
out.extend(data[i:])
break
prev_i = i
c = data[i]
if c == ord('('):
j = read_literal_string(data, i)
if j <= i:
logger.log(f"[warn] non-advancing literal at i={i} in {stream_label}")
out.append(data[i])
i += 1
continue
out.extend(data[i:j])
i = j
continue
if c == ord('<'):
if i + 1 < n and data[i + 1] == ord('<'):
j = read_dict(data, i)
kind = "dict"
else:
j = read_hex_string(data, i)
kind = "hex"
if j <= i:
logger.log(f"[warn] non-advancing {kind} at i={i} in {stream_label}")
out.append(data[i])
i += 1
continue
out.extend(data[i:j])
i = j
continue
if c == ord('/'):
j = read_name(data, i)
if j <= i:
logger.log(f"[warn] non-advancing name at i={i} in {stream_label}")
out.append(data[i])
i += 1
continue
out.extend(data[i:j])
i = j
continue
if c in WS:
out.append(c)
i += 1
continue
j = read_word(data, i)
if j <= i:
logger.log(f"[warn] non-advancing word at i={i} in {stream_label}")
out.append(data[i])
i += 1
continue
tok = data[i:j]
tok_s = tok.decode("latin1", errors="replace")
if tok == b"q":
keep = depth < max_depth
q_kept_stack.append(keep)
if keep:
depth += 1
out.extend(tok)
logger.trace(
f"[tok] {stream_label} i={i} tok='q' keep={keep} depth={depth} stack={len(q_kept_stack)}"
)
elif tok == b"Q":
if q_kept_stack:
kept = q_kept_stack.pop()
if kept:
if depth > 0:
depth -= 1
out.extend(tok)
logger.trace(
f"[tok] {stream_label} i={i} tok='Q' matched_kept={kept} depth={depth} stack={len(q_kept_stack)}"
)
else:
logger.trace(f"[tok] {stream_label} i={i} tok='Q' unmatched_dropped")
else:
out.extend(tok)
if logger.trace_tokens and tok in (b"BT", b"ET", b"cm", b"Do", b"gs"):
logger.trace(f"[tok] {stream_label} i={i} tok='{tok_s}' depth={depth}")
i = j
if i <= prev_i:
logger.log(
f"[warn] parser did not advance in {stream_label}: prev_i={prev_i}, i={i}, "
f"next='{safe_snippet(data, i)}'"
)
out.append(data[i])
i += 1
logger.log(
f"[stream] end {stream_label}: out={len(out)} bytes, steps={steps}, final_depth={depth}, "
f"open_stack={len(q_kept_stack)}"
)
return bytes(out)
# ---------------- PDF traversal ----------------
def is_form_xobject(obj) -> bool:
if not isinstance(obj, Stream):
return False
subtype = obj.get(Name("/Subtype"), None)
typ = obj.get(Name("/Type"), None)
return subtype == Name("/Form") or (typ == Name("/XObject") and subtype == Name("/Form"))
def page_content_bytes(page) -> bytes:
c = page.get(Name("/Contents"), None)
if c is None:
return b""
if isinstance(c, Stream):
return c.read_bytes()
if isinstance(c, Array):
parts = []
for s in c:
if isinstance(s, Stream):
parts.append(s.read_bytes())
return b"\n".join(parts)
return b""
def set_page_content_bytes(pdf: pikepdf.Pdf, page, data: bytes) -> None:
page[Name("/Contents")] = pdf.make_stream(data)
def process_form_recursive(
form: Stream,
visited: Set[Tuple[int, int]],
max_depth: int,
logger: Logger,
label: str,
) -> int:
changed = 0
objgen = getattr(form, "objgen", None)
if objgen and objgen in visited:
logger.log(f"[form] skip visited {label} objgen={objgen}")
return 0
if objgen:
visited.add(objgen)
original = form.read_bytes()
logger.log(f"[form] process {label} objgen={objgen} size={len(original)}")
rewritten = reduce_q_levels_in_stream(original, max_depth, logger, label)
if rewritten != original:
form.write(rewritten)
changed += 1
logger.log(f"[form] modified {label}")
resources = form.get(Name("/Resources"), None)
if isinstance(resources, Dictionary):
changed += process_forms_in_resources(resources, visited, max_depth, logger, parent_label=label)
return changed
def process_forms_in_resources(
resources: Dictionary,
visited: Set[Tuple[int, int]],
max_depth: int,
logger: Logger,
parent_label: str,
) -> int:
changed = 0
xobj = resources.get(Name("/XObject"), None)
if not isinstance(xobj, Dictionary):
return 0
for xname, xo in xobj.items():
if is_form_xobject(xo):
label = f"{parent_label}/XObject{xname}"
changed += process_form_recursive(xo, visited, max_depth, logger, label)
return changed
def process_pdf(infile: str, outfile: str, max_depth: int, logger: Logger) -> None:
with pikepdf.open(infile) as pdf:
modified_streams = 0
visited_forms: Set[Tuple[int, int]] = set()
logger.log(f"[pdf] opened {infile}, pages={len(pdf.pages)}")
for idx, page in enumerate(pdf.pages, start=1):
page_label = f"page[{idx}]"
original = page_content_bytes(page)
logger.log(f"[page] {page_label} content_size={len(original)}")
if original:
rewritten = reduce_q_levels_in_stream(original, max_depth, logger, f"{page_label}/Contents")
if rewritten != original:
set_page_content_bytes(pdf, page, rewritten)
modified_streams += 1
logger.log(f"[page] modified {page_label}/Contents")
resources = page.get(Name("/Resources"), None)
if isinstance(resources, Dictionary):
modified_streams += process_forms_in_resources(
resources, visited_forms, max_depth, logger, parent_label=page_label
)
pdf.save(outfile)
logger.log(f"[pdf] saved {outfile}")
print(f"Wrote {outfile} (modified streams: {modified_streams}, max_depth: {max_depth})")
def parse_args(argv: List[str]) -> Tuple[str, str, int, bool, bool]:
if len(argv) < 3:
print("Usage: python reduce_q_levels_debug.py in.pdf out.pdf [--max-depth N] [--debug] [--trace-tokens]")
sys.exit(2)
infile = argv[1]
outfile = argv[2]
max_depth = 2
debug = "--debug" in argv
trace_tokens = "--trace-tokens" in argv
if "--max-depth" in argv:
i = argv.index("--max-depth")
if i + 1 >= len(argv):
print("Error: --max-depth requires a value")
sys.exit(2)
try:
max_depth = int(argv[i + 1])
except ValueError:
print("Error: --max-depth must be an integer")
sys.exit(2)
if max_depth < 0:
print("Error: --max-depth must be >= 0")
sys.exit(2)
return infile, outfile, max_depth, debug, trace_tokens
def main() -> None:
infile, outfile, max_depth, debug, trace_tokens = parse_args(sys.argv)
logger = Logger(enabled=debug, trace_tokens=trace_tokens)
process_pdf(infile, outfile, max_depth, logger)
if __name__ == "__main__":
main()
Quick usage
python reduce_q_levels_debug.py in.pdf out.pdf --debug
If you need detailed token flow for the problematic stream:
python reduce_q_levels_debug.py in.pdf out.pdf --debug --trace-tokens
If you share the debug output around the stuck stream, I can pinpoint the exact parser branch causing it.
@adangel
There is no endless loop anymore. Unfortunately, this changes the layout of the pdf document. Is there a way to simplify the structure without loosing the layout?
Copilot
Yes: don’t remove q/Q pairs blindly.
That changes layout whenever enclosed operators modify graphics state (CTM, colors, font state via gs, clipping, etc.).
Safer options:
-
Only remove provable no-op pairs
Removeq ... Qonly if the enclosed segment has no state-changing operators and no nested risky constructs. -
Canonicalize instead of reducing depth
Keep semantics identical, just prettify/normalize stream formatting and wrapper noise (e.g., remove non-semantic BMC/EMC tags if acceptable). -
Render-compare validation loop
After each rewrite, rasterize pages and image-diff. Keep only transformations with zero visual diff.
For your case, option 1 is best.
I can give you a script that removes only q/Q pairs where the enclosed tokens contain none of these risky ops:
-
cm,gs,w,J,j,M,d,ri,i -
CS,cs,SC,SCN,sc,scn,G,g,RG,rg,K,k -
BT,ET,Tf,Tm,Td,TD,T*,Tr,Ts,Tc,Tw,Tz,TL - path/paint/clip ops:
m,l,c,v,y,h,re,S,s,f,F,f*,B,B*,b,b*,n,W,W* -
Do,BI,ID,EI - marked-content ops if you want strict safety:
BMC,BDC,EMC,MP,DP
This keeps layout stable in practice because it only strips truly redundant save/restore scopes.
If you want, I’ll provide that conservative “no-op q/Q eliminator” script next.
@adangel
please create the script
Copilot
#!/usr/bin/env python3
"""
Conservatively remove only no-op q ... Q pairs from PDF content streams,
including Form XObjects in resource dictionaries (recursive).
A q...Q pair is removed ONLY when its enclosed content contains no operators
(other than whitespace/comments). This guarantees no rendering/layout change
from that specific removal.
Why so strict?
- Any operator inside may depend on graphics-state scoping.
- This script prioritizes visual fidelity over aggressiveness.
Processes:
- Page /Contents streams
- Form XObject streams reachable via /Resources /XObject recursively
Requirements:
pip install pikepdf
Usage:
python remove_noop_q_pairs.py in.pdf out.pdf
python remove_noop_q_pairs.py in.pdf out.pdf --debug
"""
import sys
from dataclasses import dataclass
from typing import List, Set, Tuple, Optional
import pikepdf
from pikepdf import Name, Stream, Array, Dictionary
WS = b" \t\r\n\x0c\x00"
DELIMS = b"[]<>()/%"
# ---------------- logging ----------------
class Logger:
def __init__(self, enabled: bool = False):
self.enabled = enabled
def log(self, msg: str) -> None:
if self.enabled:
print(msg, file=sys.stderr)
# ---------------- token model ----------------
@dataclass
class Token:
kind: str # 'ws', 'comment', 'name', 'string', 'hex', 'dict', 'array', 'word'
data: bytes
# ---------------- scanners ----------------
def read_literal_string(data: bytes, i: int) -> int:
# data[i] == '('
n = len(data)
j = i + 1
depth = 1
while j < n and depth > 0:
c = data[j]
if c == ord('\\'):
j += 2
continue
if c == ord('('):
depth += 1
elif c == ord(')'):
depth -= 1
j += 1
return min(j, n)
def read_hex_string(data: bytes, i: int) -> int:
# data[i] == '<' and next != '<'
n = len(data)
j = i + 1
while j < n and data[j] != ord('>'):
j += 1
if j < n:
j += 1
return j
def read_dict(data: bytes, i: int) -> int:
# data[i:i+2] == b'<<'
n = len(data)
j = i + 2
depth = 1
while j < n and depth > 0:
c = data[j]
if c == ord('('):
j = read_literal_string(data, j)
continue
if c == ord('<'):
if j + 1 < n and data[j + 1] == ord('<'):
depth += 1
j += 2
continue
j = read_hex_string(data, j)
continue
if c == ord('>') and j + 1 < n and data[j + 1] == ord('>'):
depth -= 1
j += 2
continue
j += 1
return j
def read_array(data: bytes, i: int) -> int:
# data[i] == '['
n = len(data)
j = i + 1
depth = 1
while j < n and depth > 0:
c = data[j]
if c == ord('('):
j = read_literal_string(data, j)
continue
if c == ord('<'):
if j + 1 < n and data[j + 1] == ord('<'):
j = read_dict(data, j)
continue
j = read_hex_string(data, j)
continue
if c == ord('['):
depth += 1
j += 1
continue
if c == ord(']'):
depth -= 1
j += 1
continue
j += 1
return min(j, n)
def tokenize_content(data: bytes) -> List[Token]:
tokens: List[Token] = []
i = 0
n = len(data)
while i < n:
c = data[i]
# whitespace
if c in WS:
j = i + 1
while j < n and data[j] in WS:
j += 1
tokens.append(Token("ws", data[i:j]))
i = j
continue
# comments: % ... end-of-line
if c == ord('%'):
j = i + 1
while j < n and data[j] not in b"\r\n":
j += 1
# include newline bytes as part of comment token (if present)
if j < n and data[j] == ord('\r'):
j += 1
if j < n and data[j] == ord('\n'):
j += 1
elif j < n and data[j] == ord('\n'):
j += 1
tokens.append(Token("comment", data[i:j]))
i = j
continue
# name
if c == ord('/'):
j = i + 1
while j < n and data[j] not in WS + DELIMS:
j += 1
tokens.append(Token("name", data[i:j]))
i = j
continue
# literal string
if c == ord('('):
j = read_literal_string(data, i)
tokens.append(Token("string", data[i:j]))
i = j
continue
# dict or hex
if c == ord('<'):
if i + 1 < n and data[i + 1] == ord('<'):
j = read_dict(data, i)
tokens.append(Token("dict", data[i:j]))
i = j
else:
j = read_hex_string(data, i)
tokens.append(Token("hex", data[i:j]))
i = j
continue
# array
if c == ord('['):
j = read_array(data, i)
tokens.append(Token("array", data[i:j]))
i = j
continue
# delimiters as individual word-like tokens
if c in b"]>)":
tokens.append(Token("word", data[i:i+1]))
i += 1
continue
# generic word/operator
j = i
while j < n and data[j] not in WS + DELIMS:
j += 1
# safety: ensure forward progress
if j == i:
j = i + 1
tokens.append(Token("word", data[i:j]))
i = j
return tokens
# ---------------- q/Q simplification ----------------
@dataclass
class Frame:
start_idx: int
has_operator: bool # any operator encountered inside this frame
def is_operator_token(tok: Token) -> bool:
# In content streams, operators are 'word' tokens.
# Operands are typically name/string/array/dict/hex/number(word).
# We treat *all* word tokens as potential operators for strict safety,
# except q/Q handled structurally in parser.
return tok.kind == "word"
def remove_noop_q_pairs_from_tokens(tokens: List[Token], logger: Optional[Logger] = None) -> Tuple[List[Token], int]:
remove = [False] * len(tokens)
stack: List[Frame] = []
removed_pairs = 0
for idx, tok in enumerate(tokens):
if tok.kind == "word" and tok.data == b"q":
stack.append(Frame(start_idx=idx, has_operator=False))
continue
if tok.kind == "word" and tok.data == b"Q":
if not stack:
# unmatched Q => leave untouched
continue
fr = stack.pop()
if not fr.has_operator:
# remove q ... Q where inside had no operators
remove[fr.start_idx] = True
remove[idx] = True
removed_pairs += 1
if logger:
logger.log(f"[rewrite] remove empty q/Q pair at token {fr.start_idx}-{idx}")
else:
# this Q itself is an operator in parent frame content
if stack:
stack[-1].has_operator = True
continue
# any operator token inside currently open frame marks it non-empty
if stack and is_operator_token(tok):
stack[-1].has_operator = True
# Build output
out = [t for i, t in enumerate(tokens) if not remove[i]]
return out, removed_pairs
def untokenize(tokens: List[Token]) -> bytes:
return b"".join(t.data for t in tokens)
def rewrite_stream_remove_noop_q(data: bytes, logger: Optional[Logger] = None) -> Tuple[bytes, int]:
toks = tokenize_content(data)
new_toks, removed_pairs = remove_noop_q_pairs_from_tokens(toks, logger=logger)
out = untokenize(new_toks)
return out, removed_pairs
# ---------------- PDF traversal ----------------
def is_form_xobject(obj) -> bool:
if not isinstance(obj, Stream):
return False
subtype = obj.get(Name("/Subtype"), None)
typ = obj.get(Name("/Type"), None)
return subtype == Name("/Form") or (typ == Name("/XObject") and subtype == Name("/Form"))
def page_content_bytes(page) -> bytes:
c = page.get(Name("/Contents"), None)
if c is None:
return b""
if isinstance(c, Stream):
return c.read_bytes()
if isinstance(c, Array):
parts = []
for s in c:
if isinstance(s, Stream):
parts.append(s.read_bytes())
return b"\n".join(parts)
return b""
def set_page_content_bytes(pdf: pikepdf.Pdf, page, data: bytes) -> None:
page[Name("/Contents")] = pdf.make_stream(data)
def process_form_recursive(form: Stream, visited: Set[Tuple[int, int]], logger: Logger) -> Tuple[int, int]:
changed_streams = 0
removed_pairs_total = 0
objgen = getattr(form, "objgen", None)
if objgen and objgen in visited:
return 0, 0
if objgen:
visited.add(objgen)
original = form.read_bytes()
rewritten, removed_pairs = rewrite_stream_remove_noop_q(original, logger=logger)
if rewritten != original:
form.write(rewritten)
changed_streams += 1
removed_pairs_total += removed_pairs
resources = form.get(Name("/Resources"), None)
if isinstance(resources, Dictionary):
c, r = process_forms_in_resources(resources, visited, logger)
changed_streams += c
removed_pairs_total += r
return changed_streams, removed_pairs_total
def process_forms_in_resources(resources: Dictionary, visited: Set[Tuple[int, int]], logger: Logger) -> Tuple[int, int]:
changed_streams = 0
removed_pairs_total = 0
xobj = resources.get(Name("/XObject"), None)
if not isinstance(xobj, Dictionary):
return 0, 0
for _, xo in xobj.items():
if is_form_xobject(xo):
c, r = process_form_recursive(xo, visited, logger)
changed_streams += c
removed_pairs_total += r
return changed_streams, removed_pairs_total
def process_pdf(infile: str, outfile: str, debug: bool = False) -> None:
logger = Logger(enabled=debug)
with pikepdf.open(infile) as pdf:
modified_streams = 0
removed_pairs_total = 0
visited_forms: Set[Tuple[int, int]] = set()
for pageno, page in enumerate(pdf.pages, start=1):
original = page_content_bytes(page)
if original:
rewritten, removed_pairs = rewrite_stream_remove_noop_q(original, logger=logger)
if rewritten != original:
set_page_content_bytes(pdf, page, rewritten)
modified_streams += 1
removed_pairs_total += removed_pairs
if debug:
logger.log(f"[page {pageno}] removed q/Q pairs: {removed_pairs}")
resources = page.get(Name("/Resources"), None)
if isinstance(resources, Dictionary):
c, r = process_forms_in_resources(resources, visited_forms, logger)
modified_streams += c
removed_pairs_total += r
pdf.save(outfile)
print(
f"Wrote {outfile} "
f"(modified streams: {modified_streams}, removed empty q/Q pairs: {removed_pairs_total})"
)
def main() -> None:
if len(sys.argv) < 3:
print("Usage: python remove_noop_q_pairs.py in.pdf out.pdf [--debug]")
sys.exit(2)
infile = sys.argv[1]
outfile = sys.argv[2]
debug = "--debug" in sys.argv[3:]
process_pdf(infile, outfile, debug=debug)
if __name__ == "__main__":
main()
Run:
pip install pikepdf
python remove_noop_q_pairs.py in.pdf out.pdf --debug
This version is intentionally conservative and should preserve layout.
@adangel
Is there a way, to simplify the graphics contexts. I have a viewer, that seems to have some trouble displaying such PDFs correctly.
Copilot
Yes. The safe path is: normalize/flatten content streams while preserving rendering semantics, instead of aggressively deleting q/Q.
For a fragile viewer, these usually help most:
- Flatten Form XObjects (inline them)
-
Remove marked-content wrappers (
BMC/BDC/EMC) if semantics not needed -
Normalize text operators (
TJkerning cleanup) -
Keep
q/Qbalanced, but remove only provable no-op pairs - Regenerate with Ghostscript as a compatibility pass
Most reliable one-shot compatibility pass:
gs -o fixed.pdf -sDEVICE=pdfwrite -dCompatibilityLevel=1.4 input.pdf
You can also try:
gs -o fixed.pdf -sDEVICE=pdfwrite \
-dCompatibilityLevel=1.4 \
-dDetectDuplicateImages=true \
-dCompressFonts=true \
input.pdf
If viewer is very limited, raster fallback (guaranteed display, loses selectable text/vector):
gs -o raster.pdf -sDEVICE=pdfwrite \
-dFILTERVECTOR -dFILTERTEXT \
input.pdf
Why not just reduce q/Q depth?
Because many PDFs rely on nested state scopes for CTM/clipping/transparency. Removing those can subtly move text/graphics.
Practical recommendation
Given your experiments:
- keep the conservative
remove_noop_q_pairs.py - run
remove_marked_content_wrappers.py(if tags unnecessary) - then run Ghostscript pdfwrite pass
If you want, I can create one combined pipeline script that runs these transforms in the safest order and writes intermediate files for diff/testing.
@adangel
Using gs with compatibility level 1.4 shows the same result - the text disappears, but the viewer would display it, if it is selected… what to try next?
Copilot
That symptom (invisible until selected) usually means a rendering mode / clipping / transparency issue in the viewer, not missing text data.
Try these next, in order:
-
Force text fill mode (
Tr 0) everywhere insideBT...ET
Your sample had4 Tr(fill + add to clipping). Buggy viewers often mishandle this. -
Remove text clipping operators (
Tr 4..7,W,W*) where possible. -
Flatten transparency
Convert to PDF 1.3 (not 1.4), which removes live transparency:gs -o out13.pdf -sDEVICE=pdfwrite -dCompatibilityLevel=1.3 in.pdf -
Convert text to outlines (last-resort vector-safe for display, loses searchable text):
- via prepress tools (Acrobat callas/pitstop), or
- raster fallback if needed.
Given your content, step 1 is most promising.
Here is a script to force Tr 0 in all text objects (pages + Form XObjects recursively):
#!/usr/bin/env python3
"""
Force PDF text rendering mode to 0 (fill) by rewriting `n Tr` inside BT...ET
for page content streams and Form XObjects recursively.
This targets viewer bugs where text with Tr 4..7 (clipping variants) becomes
invisible unless selected.
Requirements:
pip install pikepdf
Usage:
python force_text_render_mode_fill.py in.pdf out.pdf
python force_text_render_mode_fill.py in.pdf out.pdf --debug
"""
import sys
from typing import List, Set, Tuple
import pikepdf
from pikepdf import Name, Stream, Array, Dictionary
WS = b" \t\r\n\x0c\x00"
DELIMS = b"[]<>()/%"
class Logger:
def __init__(self, enabled=False):
self.enabled = enabled
def log(self, msg):
if self.enabled:
print(msg, file=sys.stderr)
def read_literal_string(data: bytes, i: int) -> int:
n = len(data)
j = i + 1
depth = 1
while j < n and depth > 0:
c = data[j]
if c == ord('\\'):
j += 2
continue
if c == ord('('):
depth += 1
elif c == ord(')'):
depth -= 1
j += 1
return min(j, n)
def read_hex_string(data: bytes, i: int) -> int:
n = len(data)
j = i + 1
while j < n and data[j] != ord('>'):
j += 1
if j < n:
j += 1
return j
def read_dict(data: bytes, i: int) -> int:
n = len(data)
j = i + 2
depth = 1
while j < n and depth > 0:
c = data[j]
if c == ord('('):
j = read_literal_string(data, j)
continue
if c == ord('<'):
if j + 1 < n and data[j + 1] == ord('<'):
depth += 1
j += 2
continue
j = read_hex_string(data, j)
continue
if c == ord('>') and j + 1 < n and data[j + 1] == ord('>'):
depth -= 1
j += 2
continue
j += 1
return j
def read_array(data: bytes, i: int) -> int:
n = len(data)
j = i + 1
depth = 1
while j < n and depth > 0:
c = data[j]
if c == ord('('):
j = read_literal_string(data, j)
continue
if c == ord('<'):
if j + 1 < n and data[j + 1] == ord('<'):
j = read_dict(data, j)
continue
j = read_hex_string(data, j)
continue
if c == ord('['):
depth += 1
elif c == ord(']'):
depth -= 1
j += 1
return min(j, n)
def read_name(data: bytes, i: int) -> int:
j = i + 1
n = len(data)
while j < n and data[j] not in WS + DELIMS:
j += 1
return j
def read_word(data: bytes, i: int) -> int:
j = i
n = len(data)
while j < n and data[j] not in WS + DELIMS:
j += 1
if j == i:
j += 1
return min(j, n)
def is_number_word(tok: bytes) -> bool:
try:
float(tok.decode("latin1"))
return True
except Exception:
return False
def rewrite_force_tr0(data: bytes, logger: Logger, label: str) -> Tuple[bytes, int]:
out = bytearray()
i = 0
n = len(data)
in_text = False
# pending numeric operand candidates (byte ranges already emitted)
recent_words: List[bytes] = []
tr_changes = 0
while i < n:
c = data[i]
# pass-through tokens that may contain delimiter chars
if c in WS:
j = i + 1
while j < n and data[j] in WS:
j += 1
out.extend(data[i:j])
i = j
continue
if c == ord('%'):
j = i + 1
while j < n and data[j] not in b"\r\n":
j += 1
if j < n and data[j] == ord('\r'):
j += 1
if j < n and data[j] == ord('\n'):
j += 1
elif j < n and data[j] == ord('\n'):
j += 1
out.extend(data[i:j])
i = j
continue
if c == ord('('):
j = read_literal_string(data, i)
out.extend(data[i:j])
i = j
continue
if c == ord('<'):
if i + 1 < n and data[i + 1] == ord('<'):
j = read_dict(data, i)
else:
j = read_hex_string(data, i)
out.extend(data[i:j])
i = j
continue
if c == ord('['):
j = read_array(data, i)
out.extend(data[i:j])
i = j
continue
if c == ord('/'):
j = read_name(data, i)
out.extend(data[i:j])
i = j
continue
j = read_word(data, i)
tok = data[i:j]
# Track BT/ET context
if tok == b"BT":
in_text = True
recent_words.clear()
out.extend(tok)
i = j
continue
if tok == b"ET":
in_text = False
recent_words.clear()
out.extend(tok)
i = j
continue
if in_text and tok == b"Tr":
# Replace immediate preceding numeric operand with 0 where feasible.
# We do this by appending canonical " 0 Tr" and relying on consumers;
# but to avoid duplicate operand confusion, only do this when recent
# token was a number (common pattern: "4 Tr").
if recent_words and is_number_word(recent_words[-1]):
# easiest safe rewrite: emit "0 Tr" and skip emitting current tok
# BUT previous number already emitted. So instead overwrite logic
# isn't possible in streaming; canonical workaround is append:
# "<prevnum> Tr 0 Tr" -> final mode becomes 0.
out.extend(tok)
out.extend(b" 0 Tr")
tr_changes += 1
logger.log(f"[{label}] forced Tr->0")
recent_words.clear()
else:
out.extend(tok)
i = j
continue
out.extend(tok)
# track recent word operands only inside text objects
if in_text:
if tok and tok not in (b"BT", b"ET"):
recent_words.append(tok)
if len(recent_words) > 4:
recent_words.pop(0)
i = j
return bytes(out), tr_changes
def is_form_xobject(obj) -> bool:
if not isinstance(obj, Stream):
return False
st = obj.get(Name("/Subtype"), None)
ty = obj.get(Name("/Type"), None)
return st == Name("/Form") or (ty == Name("/XObject") and st == Name("/Form"))
def page_content_bytes(page) -> bytes:
c = page.get(Name("/Contents"), None)
if c is None:
return b""
if isinstance(c, Stream):
return c.read_bytes()
if isinstance(c, Array):
parts = []
for s in c:
if isinstance(s, Stream):
parts.append(s.read_bytes())
return b"\n".join(parts)
return b""
def set_page_content_bytes(pdf: pikepdf.Pdf, page, data: bytes) -> None:
page[Name("/Contents")] = pdf.make_stream(data)
def process_form_recursive(form: Stream, visited: Set[Tuple[int, int]], logger: Logger, label: str) -> Tuple[int, int]:
changed_streams = 0
tr_changes_total = 0
objgen = getattr(form, "objgen", None)
if objgen and objgen in visited:
return 0, 0
if objgen:
visited.add(objgen)
original = form.read_bytes()
rewritten, tr_changes = rewrite_force_tr0(original, logger, label)
if rewritten != original:
form.write(rewritten)
changed_streams += 1
tr_changes_total += tr_changes
resources = form.get(Name("/Resources"), None)
if isinstance(resources, Dictionary):
c, t = process_forms_in_resources(resources, visited, logger, label)
changed_streams += c
tr_changes_total += t
return changed_streams, tr_changes_total
def process_forms_in_resources(resources: Dictionary, visited: Set[Tuple[int, int]], logger: Logger, parent_label: str) -> Tuple[int, int]:
changed_streams = 0
tr_changes_total = 0
xobj = resources.get(Name("/XObject"), None)
if not isinstance(xobj, Dictionary):
return 0, 0
for name_obj, xo in xobj.items():
if is_form_xobject(xo):
label = f"{parent_label}/XObject{name_obj}"
c, t = process_form_recursive(xo, visited, logger, label)
changed_streams += c
tr_changes_total += t
return changed_streams, tr_changes_total
def process_pdf(infile: str, outfile: str, debug: bool) -> None:
logger = Logger(enabled=debug)
with pikepdf.open(infile) as pdf:
modified_streams = 0
tr_changes_total = 0
visited_forms: Set[Tuple[int, int]] = set()
for idx, page in enumerate(pdf.pages, start=1):
label = f"page[{idx}]"
original = page_content_bytes(page)
if original:
rewritten, tr_changes = rewrite_force_tr0(original, logger, f"{label}/Contents")
if rewritten != original:
set_page_content_bytes(pdf, page, rewritten)
modified_streams += 1
tr_changes_total += tr_changes
resources = page.get(Name("/Resources"), None)
if isinstance(resources, Dictionary):
c, t = process_forms_in_resources(resources, visited_forms, logger, label)
modified_streams += c
tr_changes_total += t
pdf.save(outfile)
print(f"Wrote {outfile} (modified streams: {modified_streams}, Tr forced to 0: {tr_changes_total})")
def main():
if len(sys.argv) < 3:
print("Usage: python force_text_render_mode_fill.py in.pdf out.pdf [--debug]")
sys.exit(2)
infile = sys.argv[1]
outfile = sys.argv[2]
debug = "--debug" in sys.argv[3:]
process_pdf(infile, outfile, debug)
if __name__ == "__main__":
main()
If this fixes visibility
Then your viewer likely breaks on clipping text modes.
Next step would be a cleaner rewrite that replaces n Tr directly (not appending 0 Tr), but this quick version is good to validate the hypothesis fast.
@adangel
This works. Can you explain me, what the problem is likely, that the viewer has?
Copilot
Your viewer likely has a bug in text rendering mode with clipping (Tr 4..7), especially when combined with nested graphics states/forms.
In your PDF, text is drawn with 4 Tr:
-
Tr 4= fill text + add glyphs to clipping path.
A correct renderer should still fill glyphs visibly.
Your symptom (“text invisible, but appears when selected”) suggests the viewer:
- builds/uses the text clip path incorrectly, or
- applies clip/paint order wrong, or
- mishandles clip state across
q/Qor Form XObject boundaries.
Why selection still works:
- text extraction/selection uses text objects/font maps, not final painted pixels.
- so logical text is present, but paint step is wrong.
When you force Tr 0 (plain fill), you bypass the buggy clip-path code path, so text shows again.
So root cause is probably: renderer bug in clipping text modes (4–7), potentially aggravated by nested Form XObjects and state stack handling.
Comments
No comments yet.Leave a comment
Your email address will not be published. Required fields are marked *. All comments are held for moderation to avoid spam and abuse.