Files
docview/tests/fixtures/pdf/generate_annotation_flags.py
2026-09-21 13:41:40 +09:00

100 lines
6.4 KiB
Python

#!/usr/bin/env python3
"""Deterministic, self-authored PDF annotation geometry; no external content."""
from pathlib import Path
import hashlib
import json
root = Path(__file__).parent / "annotation-flags"
root.mkdir(exist_ok=True)
objects = ["", ""]
def add(value):
objects.append(value.encode("ascii") if isinstance(value, str) else value)
return len(objects)
def stream(content, extra):
data = content.encode("ascii")
return add(f"<< /Length {len(data)} {extra} >>\nstream\n".encode() + data + b"\nendstream")
# Non-identity AP matrix, named graphics state, and selected appearance state
# must survive rectangle compensation. Normalization still yields red left /
# blue right in the annotation's 32x16 rectangle; Off is deliberately green.
normal = stream("q /Opaque gs 1 0 0 rg 0 0 16 16 re f 0 0 1 rg 16 0 16 16 re f Q",
"/Type /XObject /Subtype /Form /BBox [0 0 32 16] /Matrix [2 0 0 3 7 11] "
"/Resources << /ExtGState << /Opaque << /Type /ExtGState /ca 1 /CA 1 >> >> >>")
green = stream("0 1 0 rg 0 0 32 16 re f", "/Type /XObject /Subtype /Form /BBox [0 0 32 16] /Resources << >>")
pages, cases = [], []
def page(annots, rotate, case):
pid = add(f"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 128 128] /CropBox [8 12 120 116] /Rotate {rotate} "
f"/Resources << >> /Annots [{' '.join(f'{x} 0 R' for x in annots)}] >>")
pages.append(pid)
cases.append(dict(page=len(pages)-1, intrinsicRotation=rotate, **case))
for kind in ["generated-link", "stored-link", "stored-comment"]:
for flags in [8, 16, 24]:
for rotate in [0, 90, 180, 270]:
extra = ("/C [1 0 0] /BS << /W 2 /S /S >>" if kind == "generated-link" else
f"/AP << /N << /On {normal} 0 R /Off {green} 0 R >> >> /AS /On")
subtype = "Stamp" if kind == "stored-comment" else "Link"
annot = add(f"<< /Type /Annot /Subtype /{subtype} /Rect [42 48 74 64] /F {flags} "
f"/Contents ({kind} flags {flags}) /A << /S /URI /URI (https://example.org/flags) >> {extra} >>")
page([annot], rotate, dict(kind=kind, flags=flags, rect=[42,48,74,64]))
# Annots order is retained: the later green appearance covers the generated
# border; Hidden / NoView appearances at distinct rectangles remain invisible.
annots = [add("<< /Type /Annot /Subtype /Link /Rect [42 48 74 64] /F 24 /C [1 0 0] /BS << /W 2 >> >>"),
add(f"<< /Type /Annot /Subtype /Link /Rect [41 47 75 65] /F 24 /AP << /N {green} 0 R >> >>")]
for flag, rect in [(26, "20 80 36 96"), (56, "80 80 96 96")]:
annots.append(add(f"<< /Type /Annot /Subtype /Link /Rect [{rect}] /F {flag} /Contents (Invisible comment) /AP << /N {normal} 0 R >> >>"))
page(annots, 0, dict(kind="visibility-zorder", flags=24))
# Right-edge clipping of an upright unscaled AP remains clipped to the CropBox.
edge = add(f"<< /Type /Annot /Subtype /Link /Rect [108 48 140 64] /F 24 /AP << /N {normal} 0 R >> >>")
page([edge], 0, dict(kind="crop-clipping", flags=24, rect=[108,48,140,64]))
objects[0] = b"<< /Type /Catalog /Pages 2 0 R >>"
objects[1] = f"<< /Type /Pages /Count {len(pages)} /Kids [{' '.join(f'{p} 0 R' for p in pages)}] >>".encode()
data = bytearray(b"%PDF-1.7\n%\xe2\xe3\xcf\xd3\n")
offsets = [0]
for index, value in enumerate(objects, 1):
offsets.append(len(data))
data += f"{index} 0 obj\n".encode() + value + b"\nendobj\n"
xref = len(data)
data += f"xref\n0 {len(offsets)}\n0000000000 65535 f \n".encode()
for offset in offsets[1:]: data += f"{offset:010d} 00000 n \n".encode()
data += f"trailer\n<< /Size {len(offsets)} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n".encode()
(root / "flags.pdf").write_bytes(data)
(root / "manifest.json").write_text(json.dumps(dict(generator="generate_annotation_flags.py",
license="Self-created test content; same license as DocView.", sha256=hashlib.sha256(data).hexdigest(),
cropBox=[8,12,120,116], cases=cases, expected="At 100% one PDF point is one logical pixel. "
"NoZoom preserves logical 32x16 size across zoom and scales only with DPR. NoRotate preserves upright "
"red-left/blue-right appearance around the original Rect upper-left through intrinsic plus user rotation. "
"Generated red border extends one point beyond Rect. This is synthetic geometry, not a general PDF compatibility golden."), indent=2)+"\n")
# Native Text icons have no supplied AP. Shared annotation objects exercise
# capture-once behavior across rotated pages without accumulating AP streams.
text_objects = [b"<< /Type /Catalog /Pages 2 0 R >>", b""]
text_content = b"BT /F1 10 Tf 12 100 Td (Native icon with unembedded body text) Tj ET"
text_objects.append(f"<< /Length {len(text_content)} >>\nstream\n".encode() + text_content + b"\nendstream")
text_objects.append(b"<< /Type /Annot /Subtype /Ink /Rect [80 24 100 34] /F 8 /Contents (Native ink) /BS << /W 4 >> /InkList [[80 24 90 34 100 24]] >>")
text_pages = []
for flags in [8,16,24]:
aid = len(text_objects) + 1
text_objects.append(f"<< /Type /Annot /Subtype /Text /Rect [42 48 74 64] /F {flags} /Contents (Native text icon) /C [1 0 0] >>".encode())
for rotation in [0,90,180,270]:
text_pages.append(len(text_objects)+1)
text_objects.append(f"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 128 128] /CropBox [8 12 120 116] /Rotate {rotation} "
f"/Resources << /Font << /F1 << /Type /Font /Subtype /Type1 /BaseFont /Times-Roman >> >> >> /Contents 3 0 R /Annots [{aid} 0 R 4 0 R 4 0 R] >>".encode())
text_objects[1] = f"<< /Type /Pages /Count 12 /Kids [{' '.join(f'{p} 0 R' for p in text_pages)}] >>".encode()
data = bytearray(b"%PDF-1.7\n")
offsets = [0]
for index, value in enumerate(text_objects, 1):
offsets.append(len(data))
data += f"{index} 0 obj\n".encode() + value + b"\nendobj\n"
xref = len(data)
data += f"xref\n0 {len(offsets)}\n0000000000 65535 f \n".encode()
for offset in offsets[1:]: data += f"{offset:010d} 00000 n \n".encode()
data += f"trailer\n<< /Size {len(offsets)} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n".encode()
(root / "text-icons.pdf").write_bytes(data)
(root / "text-icons-manifest.json").write_text(json.dumps(dict(license="Self-created test content; same license as DocView.",
sha256=hashlib.sha256(data).hexdigest(), originalRect=[42,48,74,64], nativeIconRect=[42,48,62,68],
expected="Twelve pages: flags 8/16/24, each with intrinsic rotations 0/90/180/270. Four pages share each annotation. "
"Capture the native generated AP once, retain source Rect, use the captured 20x20 icon Rect for drawing and hit testing."), indent=2)+"\n")