Rotate and Reorder PDF Pages with Python
The scanning station produces batches where every other page is upside down (duplex scans of single-sided originals fed the wrong way), landscape spreadsheets appear sideways, and a 40-page contract arrives in reverse order because the stack was loaded face-up. The first script rotates with page.rotate(90) and the pages come out rotated the wrong way, or rotated twice; reordering by index works until someone inserts a cover page and every subsequent rule is off by one.
Root Cause
PDF page rotation is a property, /Rotate, applied on top of the page content when it is displayed. It accepts multiples of 90 degrees, clockwise, and it is cumulative when using library helpers: pypdf's page.rotate(90) adds 90 to whatever is already set, and PyMuPDF's page.set_rotation(90) sets an absolute value. Scripts that mix the two ideas — or that rotate a page that already carries /Rotate 90 — end up at 180 or 0 instead of the intended orientation. Deciding which rotation a page needs is a separate problem: the file does not know that a scan is sideways, so orientation must be inferred from the text layer or from OCR's orientation detection. Reordering by fixed indices is brittle for the same reason as any positional logic; rules based on page content ("the signature page goes last") survive inserted and removed pages.
Minimal Diagnostic
Report each page's stored rotation, its displayed shape, and a quick orientation signal from the text layer — the direction of text lines.
# pip install pymupdf
from collections import Counter
from pathlib import Path
import pymupdf
SOURCE = Path("in/scan-batch-0917.pdf")
def text_direction(page: pymupdf.Page) -> str:
dirs = Counter()
for block in page.get_text("dict")["blocks"]:
for line in block.get("lines", []):
dx, dy = (round(v) for v in line["dir"])
dirs[(dx, dy)] += len("".join(s["text"] for s in line["spans"]))
if not dirs:
return "no text"
(dx, dy), _ = dirs.most_common(1)[0]
return {(1, 0): "upright", (-1, 0): "upside down", (0, 1): "rotated 90 cw", (0, -1): "rotated 90 ccw"}.get((dx, dy), "skewed")
def diagnose(pdf_path: Path) -> None:
try:
doc = pymupdf.open(pdf_path)
except (pymupdf.FileDataError, RuntimeError) as exc:
raise SystemExit(f"cannot open {pdf_path}: {exc}")
with doc:
for page in doc:
shape = "landscape" if page.rect.width > page.rect.height else "portrait"
print(f"page {page.number + 1:>3}: /Rotate={page.rotation:<3} shown {shape:<9} text {text_direction(page)}")
if __name__ == "__main__":
diagnose(SOURCE)
page 1: /Rotate=0 shown portrait text upright
page 2: /Rotate=0 shown portrait text upside down
page 3: /Rotate=90 shown landscape text rotated 90 ccw
page 4: /Rotate=0 shown portrait text no text
Page 2 needs 180 degrees. Page 3 already carries /Rotate 90 but its text still runs the wrong way, so it needs a further correction. Page 4 has no text layer — orientation must come from OCR.
Fix: Compute the Correction from Detected Orientation
Decide the rotation each page needs from its text direction, add it to the current value, and set the result absolutely. Changed lines carry comments.
# pip install pymupdf
from pathlib import Path
import pymupdf
SOURCE = Path("in/scan-batch-0917.pdf")
DEST = Path("out/scan-batch-0917-upright.pdf")
CORRECTION = { # extra clockwise rotation to make text upright
"upright": 0,
"upside down": 180,
"rotated 90 cw": 270,
"rotated 90 ccw": 90,
}
def upright_pages(src: Path, dest: Path) -> list[tuple[int, int, int]]:
changes = []
with pymupdf.open(src) as doc:
for page in doc:
direction = text_direction(page) # from the diagnostic
extra = CORRECTION.get(direction)
if extra is None:
continue # no text or skewed: leave for OCR step
current = page.rotation
target = (current + extra) % 360 # changed: correction relative to current
if target != current:
page.set_rotation(target) # changed: absolute, applied once
changes.append((page.number + 1, current, target))
dest.parent.mkdir(parents=True, exist_ok=True)
doc.save(dest, garbage=3, deflate=True)
return changes
if __name__ == "__main__":
for page_no, before, after in upright_pages(SOURCE, DEST):
print(f"page {page_no}: /Rotate {before} -> {after}")
line["dir"] in PyMuPDF is reported in the page's unrotated coordinate system, which is exactly why the correction must be added to the existing /Rotate rather than replacing it. Setting the rotation is metadata-only: the content stream is untouched, text stays selectable, and the operation takes microseconds per page.
Variant Fix 1: Pages Without Text — OCR Orientation Detection
Scanned pages need Tesseract's orientation and script detection (OSD), which reports how many degrees the image is rotated:
# pip install pymupdf pytesseract pillow
import io
import re
import pymupdf
import pytesseract
from PIL import Image
def osd_rotation(page: pymupdf.Page, dpi: int = 150) -> int | None:
pix = page.get_pixmap(dpi=dpi) # rendered as displayed, including /Rotate
image = Image.open(io.BytesIO(pix.tobytes("png")))
try:
osd = pytesseract.image_to_osd(image)
except pytesseract.TesseractError:
return None # too little text to decide (blank pages, photos)
angle = int(re.search(r"Rotate: (\d+)", osd).group(1))
confidence = float(re.search(r"Orientation confidence: ([\d.]+)", osd).group(1))
return angle if confidence >= 2.0 else None
def fix_scanned(doc: pymupdf.Document) -> None:
for page in doc:
if text_direction(page) != "no text":
continue
angle = osd_rotation(page)
if angle:
page.set_rotation((page.rotation + angle) % 360)
OSD's Rotate value is the clockwise rotation to apply to the rendered image to make it upright, and because the render already includes the current /Rotate, adding it to the current value is correct. The confidence threshold avoids flipping blank or photo-only pages on a guess. Orientation problems also wreck OCR quality itself, as covered in fix OCR garbage on rotated scans.
Variant Fix 2: Reorder, Delete and Insert by Rules
Reverse-fed batches, cover sheets to drop, and signature pages to move are best expressed as rules evaluated against page content, producing a new page order:
# pip install pymupdf
from pathlib import Path
import pymupdf
def page_order(doc: pymupdf.Document) -> list[int]:
texts = [page.get_text().lower() for page in doc]
indices = list(range(doc.page_count))
# drop scanner separator sheets
indices = [i for i in indices if "batch separator" not in texts[i]]
# reverse if the batch was fed face-up: page labels decrease
numbers = [int(t.split("page ")[1].split()[0]) for t in (texts[i] for i in indices)
if "page " in t and t.split("page ")[1].split()[0].isdigit()]
if len(numbers) >= 3 and numbers == sorted(numbers, reverse=True):
indices.reverse()
# move signature pages to the end, keeping their relative order
signature = [i for i in indices if "signed for and on behalf of" in texts[i]]
return [i for i in indices if i not in signature] + signature
def reorder(src: Path, dest: Path) -> list[int]:
with pymupdf.open(src) as doc:
order = page_order(doc)
doc.select(order) # keeps only these pages, in this order
dest.parent.mkdir(parents=True, exist_ok=True)
doc.save(dest, garbage=4, deflate=True) # garbage=4 drops objects of removed pages
return order
Document.select rewrites the page tree in one operation and preserves links and bookmarks that point to kept pages. Insert pages from another file afterwards with doc.insert_pdf(other, from_page=0, to_page=0, start_at=0) — for example a generated cover sheet. For splitting by ranges instead of reordering, see split a PDF by page ranges with Python.
The same operations in pypdf, for pure-Python environments:
# pip install "pypdf>=4.0"
from pathlib import Path
from pypdf import PdfReader, PdfWriter
def reorder_pypdf(src: Path, dest: Path, order: list[int], rotations: dict[int, int]) -> None:
reader = PdfReader(src)
writer = PdfWriter()
for index in order:
page = reader.pages[index]
extra = rotations.get(index, 0)
if extra:
page.rotate(extra) # pypdf: rotate BY, clockwise, cumulative
writer.add_page(page)
with dest.open("wb") as fh:
writer.write(fh)
Verification
Check that every page with text now reads upright, the page count matches the input minus deleted pages, and no page was duplicated or lost by the reordering.
# pip install pymupdf
import hashlib
from collections import Counter
from pathlib import Path
import pymupdf
def page_fingerprints(doc: pymupdf.Document) -> Counter:
return Counter(hashlib.sha1(" ".join(p.get_text().split()).encode()).hexdigest() for p in doc)
def verify_cleanup(original: Path, cleaned: Path, dropped_phrases: tuple[str, ...] = ("batch separator",)) -> None:
with pymupdf.open(original) as a, pymupdf.open(cleaned) as b:
kept_original = Counter({h: n for h, n in page_fingerprints(a).items()})
for page in a:
if any(p in page.get_text().lower() for p in dropped_phrases):
kept_original[hashlib.sha1(" ".join(page.get_text().split()).encode()).hexdigest()] -= 1
assert +kept_original == page_fingerprints(b), "pages lost or duplicated during reordering"
sideways = [p.number + 1 for p in b if text_direction(p) not in ("upright", "no text")]
assert not sideways, f"pages still not upright: {sideways}"
print(f"{cleaned.name}: all text pages upright, page set preserved")
Fingerprinting pages by normalised text proves the reordered document contains the same pages as the original, just rearranged — text extraction is independent of rotation, so a rotated page has the same fingerprint as before. Pages with no text share one empty fingerprint; count them separately if blank pages matter.
FAQ
Does rotating change the page content or file size?
No. /Rotate is a single number in the page dictionary. Viewers apply it at display time.
Why is my landscape page portrait after "fixing" it?
The page was already set to /Rotate 90, and the code set an absolute value of 0 or added another 90. Always compute from the current value.
Can I rotate only the content, not the page?
Yes, by wrapping the content stream in a transformation, but it changes the page box geometry and is rarely needed. /Rotate is the standard mechanism.
How do I fix slightly skewed scans (a few degrees)? Rotation in 90-degree steps cannot fix skew. Deskew the images before OCR, as in improve OCR accuracy with image preprocessing.
Related
- Merging and Splitting PDF Documents — page-level document assembly
- Split a PDF by Page Ranges with Python — cutting documents apart by rules
- Fix OCR Garbage on Rotated Scans — orientation problems in OCR output
- Render PDF Thumbnails with PyMuPDF — previews that respect page rotation
Part of Merging and Splitting PDF Documents.