Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
161 changes: 140 additions & 21 deletions src/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -12045,17 +12045,70 @@ def get_pixmap(

def remove_rotation(self):
"""Set page rotation to 0 while maintaining visual appearance."""
rot = self.rotation # normalized rotation value
if rot == 0:
return Identity # nothing to do
rotation = self.rotation # normalized rotation value
if rotation == 0:
return Identity # nothing to do

quad_annots = {
mupdf.PDF_ANNOT_HIGHLIGHT,
mupdf.PDF_ANNOT_UNDERLINE,
mupdf.PDF_ANNOT_SQUIGGLY,
mupdf.PDF_ANNOT_STRIKE_OUT,
}
line_annots = {
mupdf.PDF_ANNOT_LINE,
mupdf.PDF_ANNOT_POLY_LINE,
mupdf.PDF_ANNOT_POLYGON,
}

doc = self.parent
annot_states = []
for annot in self.annots():
annot_type = annot.type[0]
annot_rotation = annot.rotation
state = {
"annot": annot,
"type": annot_type,
"rect": Rect(annot.rect),
"apn_matrix": Matrix(annot.apn_matrix),
}
if annot_type == mupdf.PDF_ANNOT_FREE_TEXT:
if annot_rotation != -1:
state["new_annot_rotation"] = (annot_rotation - rotation) % 360
else:
state["new_annot_rotation"] = -1
if annot_type in quad_annots or annot_type in line_annots:
state["vertices"] = [Point(p) for p in annot.vertices]
elif annot_type == mupdf.PDF_ANNOT_INK:
state["ink"] = [[Point(p) for p in subpoints] for subpoints in annot.vertices]
elif annot_type == mupdf.PDF_ANNOT_FREE_TEXT:
key_type, _ = doc.xref_get_key(annot.xref, "CL")
if key_type == "array":
state["cl"] = [Point(p) for p in annot.vertices]
annot_states.append(state)

link_rects = []
link = mupdf.fz_load_links(self.this)
while link.m_internal:
link_rects.append(Rect(JM_py_from_rect(link.rect())))
link = link.next()

link_xrefs = [
xref
for xref, annot_type, _ in self.annot_xrefs()
if annot_type == mupdf.PDF_ANNOT_LINK
]
link_states = list(zip(link_xrefs, link_rects))
widgets = [(widget, Rect(widget.rect)) for widget in self.widgets()]
coord_transform = +self.rotation_matrix

# need to derotate the page's content
mb = self.mediabox # current mediabox

if rot == 90:
if rotation == 90:
# before derotation, shift content horizontally
mat0 = Matrix(1, 0, 0, 1, mb.y1 - mb.x1 - mb.x0 - mb.y0, 0)
elif rot == 270:
elif rotation == 270:
# before derotation, shift content vertically
mat0 = Matrix(1, 0, 0, 1, 0, mb.x1 - mb.y1 - mb.y0 - mb.x0)
else: # rot = 180
Expand All @@ -12068,36 +12121,102 @@ def remove_rotation(self):
_ = TOOLS._insert_contents(self, cmd, False) # prepend to page contents

# swap x- and y-coordinates
if rot in (90, 270):
if rotation in (90, 270):
x0, y0, x1, y1 = mb
mb.x0 = y0
mb.y0 = x0
mb.x1 = y1
mb.y1 = x1
self.set_mediabox(mb)

transform = coord_transform
self.set_rotation(0)
rot = ~mat # inverse of the derotation matrix

for annot in self.annots(): # modify rectangles of annotations
r = annot.rect * rot
# TODO: only try to set rectangle for applicable annot types
annot.set_rect(r)
for link in self.get_links(): # modify 'from' rectangles of links
r = link["from"] * rot
self.delete_link(link)
link["from"] = r
try: # invalid links remain deleted
self.insert_link(link)

pdf_mb = Rect(self.mediabox)

def to_pdf_point(point):
point = Point(point)
return Point(point.x + pdf_mb.x0, pdf_mb.y1 - point.y)

def points_string(points):
return "[" + " ".join(_format_g(tuple(to_pdf_point(p))) for p in points) + "]"

def nested_points_string(points):
return "[" + " ".join(points_string(subpoints) for subpoints in points) + "]"

for state in annot_states:
annot = state["annot"]
annot_type = state["type"]
r = state["rect"] * transform
p0 = to_pdf_point(Point(r.x0, r.y1))
p1 = to_pdf_point(Point(r.x1, r.y0))
doc.xref_set_key(annot.xref, "Rect", f"[{_format_g((p0.x, p0.y, p1.x, p1.y))}]")

if annot_type in quad_annots or annot_type in line_annots:
points = [p * transform for p in state["vertices"]]
key = "QuadPoints" if annot_type in quad_annots else ("L" if annot_type == mupdf.PDF_ANNOT_LINE else "Vertices")
doc.xref_set_key(annot.xref, key, points_string(points))

if annot_type == mupdf.PDF_ANNOT_INK:
points = [[p * transform for p in subpoints] for subpoints in state["ink"]]
doc.xref_set_key(annot.xref, "InkList", nested_points_string(points))

if "cl" in state:
cl_points = [p * transform for p in state["cl"]]
doc.xref_set_key(annot.xref, "CL", points_string(cl_points))

if "new_annot_rotation" in state and state["new_annot_rotation"] != -1:
doc.xref_set_key(annot.xref, "Rotate", str(int(state["new_annot_rotation"])))

try:
if annot_type == mupdf.PDF_ANNOT_FREE_TEXT:
if rotation == 180:
annot.set_apn_matrix(state["apn_matrix"] * transform)
else:
cx = (r.x0 + r.x1) / 2
cy = (r.y0 + r.y1) / 2
flip180 = Matrix(-1, 0, 0, -1, 2 * cx, 2 * cy)
annot.set_apn_matrix(state["apn_matrix"] * transform * flip180)
elif annot_type in (
mupdf.PDF_ANNOT_HIGHLIGHT,
mupdf.PDF_ANNOT_UNDERLINE,
mupdf.PDF_ANNOT_SQUIGGLY,
mupdf.PDF_ANNOT_STRIKE_OUT,
mupdf.PDF_ANNOT_LINE,
mupdf.PDF_ANNOT_POLY_LINE,
mupdf.PDF_ANNOT_POLYGON,
mupdf.PDF_ANNOT_TEXT,
mupdf.PDF_ANNOT_INK,
):
annot._update_appearance(rotate=0 if annot_type in (mupdf.PDF_ANNOT_STAMP, mupdf.PDF_ANNOT_TEXT) else -1)
elif annot_type == mupdf.PDF_ANNOT_STAMP:
if rotation == 180:
annot.set_apn_matrix(state["apn_matrix"] * transform)
else:
# For quarter turns, invert page rotation for AP so
# stamp orientation and scale stay unchanged.
annot.set_apn_matrix(state["apn_matrix"] * ~transform)
else:
ap_map = state["rect"].torect(r)
annot.set_apn_matrix(state["apn_matrix"] * ap_map)
except Exception:
# Annotation has no AP/N matrix to adjust.
pass
for widget in self.widgets(): # modify field rectangles
r = widget.rect * rot

for xref, r in link_states: # modify 'from' rectangles of links
# Keep link hot areas in the same visual page position.
p0 = to_pdf_point(Point(r.x0, r.y1))
p1 = to_pdf_point(Point(r.x1, r.y0))
doc.xref_set_key(xref, "Rect", f"[{_format_g((p0.x, p0.y, p1.x, p1.y))}]")
JM_refresh_links(self._pdf_page())

for widget, r in widgets: # modify field rectangles
r = r * transform
if r.is_empty or r.is_infinite:
continue
widget.rect = r
widget.update()
return rot # the inverse of the generated derotation matrix
return ~mat # the inverse of the generated derotation matrix

def cluster_drawings(
self, clip=None, drawings=None, x_tolerance: float = 3, y_tolerance: float = 3,
Expand Down
Binary file added tests/resources/test-4000-links.pdf
Binary file not shown.
118 changes: 117 additions & 1 deletion tests/test_remove-rotation.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
import os
import pymupdf
from gentle_compare import gentle_compare
import time
from gentle_compare import gentle_compare, rms

scriptdir = os.path.dirname(__file__)
ROTATIONS = (90, 180, 270)


def test_remove_rotation():
Expand All @@ -28,3 +30,117 @@ def test_remove_rotation():
words1.sort(key=lambda w: w[4]) # sort by word strings
assert pix1.digest == pix0.digest, f"{pix1.digest}/{pix0.digest}"
assert gentle_compare(words0, words1)


def test_5077():
"""Confirm performance improvement for many links."""
filename = os.path.join(scriptdir, "resources", "test-4000-links.pdf")
doc = pymupdf.open(filename)
page = doc[0]
print(f"{page.rotation=}, link count={len(page.get_links())}")
t0 = time.time()
page.remove_rotation()
t1 = time.time()
assert t1 - t0 < 1, f"de-rotation time {t1-t0:.3f} seconds is too long."


def _add_test_content(page, rotation):
# Simple frame to make orientation changes visible.
page.draw_polyline([(20, 20), (400, 20), (400, 300), (20, 300), (20, 20)])
page.draw_line((20, 160), (400, 160))
page.insert_text((30, 150), f"PAGE ROT {rotation}", fontsize=10)

# Geometric annotations.
rect_a = pymupdf.Rect(30, 30, 90, 80)
square = page.add_rect_annot(rect_a)
square.set_colors(stroke=(1, 0, 0))
square.set_border(width=1)
square.update()

rect_b = pymupdf.Rect(110, 30, 170, 80)
circle = page.add_circle_annot(rect_b)
circle.set_colors(stroke=(0, 0, 1))
circle.set_border(width=1)
circle.update()

line = page.add_line_annot((190, 30), (260, 80))
line.set_colors(stroke=(0, 0.5, 0))
line.set_border(width=1)
line.update()

polyline = page.add_polyline_annot([(280, 30), (320, 40), (290, 80), (350, 70)])
polyline.set_colors(stroke=(1, 0.4, 0))
polyline.set_border(width=1)
polyline.update()

polygon = page.add_polygon_annot([(30, 100), (80, 120), (60, 150), (20, 140)])
polygon.set_colors(stroke=(0.6, 0, 0.8))
polygon.set_border(width=1)
polygon.update()

ink = page.add_ink_annot(
[
[(110, 100), (120, 120), (130, 110), (140, 130)],
[(150, 100), (160, 120), (170, 110)],
]
)
ink.set_colors(stroke=(0, 0.7, 0.7))
ink.set_border(width=1)
ink.update()

# FreeText in the historically problematic corner.
ft_rect = pymupdf.Rect(340, 250, 400, 300)
ft = page.add_freetext_annot(
ft_rect,
f"FT {rotation}",
text_color=(1, 0, 0),
rotate=rotation,
)
ft.update()

# Sticky note + stamp.
text_annot = page.add_text_annot((200, 120), f"Note {rotation}")
text_annot.update()

stamp_rect = pymupdf.Rect(280, 100, 380, 145)
stamp = page.add_stamp_annot(stamp_rect, stamp=13) # Draft
stamp.update()

# Marker annotations based on searched text quads.
marker_text = "MARK_A MARK_B MARK_C MARK_D"
page.insert_text((30, 220), marker_text, fontsize=12)

q1 = page.search_for("MARK_A", quads=True)
if q1:
page.add_highlight_annot(q1)

q2 = page.search_for("MARK_B", quads=True)
if q2:
page.add_underline_annot(q2)

q3 = page.search_for("MARK_C", quads=True)
if q3:
page.add_strikeout_annot(q3)

q4 = page.search_for("MARK_D", quads=True)
if q4:
page.add_squiggly_annot(q4)


def _make_input_pdf(rotation):
doc = pymupdf.open()
page = doc.new_page(width=420, height=320)
page.set_rotation(rotation)
_add_test_content(page, rotation)
return doc


def test_5059():
for rot in ROTATIONS:
src = _make_input_pdf(rot)
page = src[0]
pix_orig = page.get_pixmap(colorspace=pymupdf.csGRAY)
page.remove_rotation()
pix_rot0 = page.get_pixmap(colorspace=pymupdf.csGRAY)
rms_val = rms(pix_orig.samples, pix_rot0.samples, verbose=False)
assert rms_val < 2, f"RMS diff for rotation {rot}: {rms_val}"
Loading