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.

Rotation APIs are not the same operation For a page that already has Rotate 90, pypdf page.rotate(90) adds 90 and results in 180. PyMuPDF page.set_rotation(90) sets the value absolutely and results in 90, a no-op. Setting the raw Rotate value in the page dictionary to 90 also results in 90. Code must decide whether it means rotate by or rotate to, and compute the correction from the current value. Call Meaning Result on Rotate 90 pypdf page.rotate(90) rotate BY 90 180 PyMuPDF set_rotation(90) rotate TO 90 90 (unchanged) page /Rotate = 90 rotate TO 90 90 (unchanged) correction = (target - current) % 360 explicit always correct

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.

Choosing how to detect orientation For each page the tree asks whether a text layer exists. Pages with text use the dominant line direction from PyMuPDF to compute the correction. Pages without text are rendered and passed to Tesseract orientation detection, which is applied only above a confidence threshold. Blank pages or low-confidence results are left unchanged and listed for review. Does the page have a text layer? get_text dict line directions yes Line direction dominant dir vector no Tesseract OSD render at 150 dpi blank / photo Leave unchanged list for review Apply correction current + extra Confidence >= 2 apply Rotate value

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)
Scan batch clean-up pipeline Five steps. Orientation is corrected per page using text direction or Tesseract orientation detection. Separator sheets are removed. The batch is reversed if printed page numbers decrease. Signature pages are moved to the end. The document is saved with garbage collection so removed pages do not remain in the file. Orientation text dir or OSD Drop separators by text rule Reverse if needed page numbers fall Move signatures to the end Save garbage=4

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.

Part of Merging and Splitting PDF Documents.