When you rotate an upside-down scan using Chrome’s toolbar button or View → Rotate View in Adobe Acrobat Reader, the change only updates the viewer’s in-memory rendering matrix. Close the tab or email the attachment, and the recipient still receives an upside-down document. To permanently rotate scanned PDF pages, you need to look at how the PDF specification (ISO 32000-1) separates page dictionary metadata, content stream transformation matrices, and raw raster image streams.

What Actually Changes When You Permanently Rotate a PDF Page

In ISO 32000-1 (Section 7.7.3.3, Table 30), a scanned page consists of three distinct layers that affect orientation:

  1. The /Page Dictionary /Rotate Entry: An integer multiple of 90 (default 0) specifying the number of degrees the page should be rotated clockwise when displayed or printed. Values such as 360 or -90 are legal, but libraries normally write 0, 90, 180, or 270.
  2. The /Contents Stream Matrix (cm): PostScript-like operators (such as q 612 0 0 792 0 0 cm /Im0 Do Q) that map the embedded image onto the page’s MediaBox coordinate grid.
  3. The /Image XObject Stream: The compressed raster pixel buffer (DCTDecode JPEG or JBIG2Decode monochrome) captured by the scanner sensor.
flowchart LR
    A["Viewer Rotate Button<br/>(Chrome / Reader View Menu)"] -->|RAM Only| B["Temporary Viewport<br/>(Reverts on close)"]
    C["Page Dictionary /Rotate<br/>(pdf-lib / pypdf / PyMuPDF)"] -->|Writes /Rotate 90, 180, 270| D["Permanent in All Viewers<br/>(Zero JPEG re-encoding)"]
    E["Content Stream Matrix (cm)<br/>(transfer_rotation_to_content)"] -->|Transposes MediaBox & sets /Rotate 0| F["Normalized Coordinates<br/>(Safe for naive crop/stamp scripts)"]

To see how these three layers interact on scanner-style output, we can generate a 4-page, 150 DPI US Letter (612 x 792 pt, 1275 x 1650 px) scanned packet (scanned_batch.pdf) that reproduces three common Automatic Document Feeder (ADF) orientation errors:

  • Page 1: Upright contract front page (/Rotate 0)
  • Page 2: Upside-down reverse side from a short-edge (“tumbled”) duplex scan (pixels inverted 180°, /Rotate 0)
  • Page 3: Landscape revenue table where the scanner’s auto-orientation sensor set /Rotate 90, leaving the table upside down in landscape view (needs another +180° relative rotation to reach 270°)
  • Page 4: Upside-down reverse side from the duplex scan (pixels inverted 180°, /Rotate 0)

We can inspect the /Rotate attribute, MediaBox dimensions, /Image XObject SHA-256 hash, and /Contents stream of each page using pypdf and Pillow. On both macOS (Homebrew Python) and Ubuntu/Debian Linux, PEP 668 blocks system-wide pip installs by default, so create a virtual environment first:

python3 -m venv .venv
.venv/bin/pip install pypdf pillow pymupdf

Build the test packet with make_scanned_batch.py:

import io
import pymupdf
from PIL import Image, ImageDraw

def scan(lines, size=(1275, 1650)):
    img = Image.new("L", size, 250)
    draw = ImageDraw.Draw(img)
    for i, line in enumerate(lines):
        draw.text((120, 150 + 60 * i), line, fill=20, font_size=40)
    return img

pages = [
    (scan(["Service Contract", "Page 1 (front)"]), 0),
    (scan(["Terms and Conditions", "Page 2 (back)"]).rotate(180), 0),
    (scan(["Q1 Revenue Table", "Page 3 (landscape)"], (1650, 1275)).rotate(-90, expand=True), 90),
    (scan(["Signatures", "Page 4 (back)"]).rotate(180), 0),
]

doc = pymupdf.open()
for img, rot in pages:
    buf = io.BytesIO()
    img.convert("RGB").save(buf, "JPEG", quality=85, dpi=(150, 150))
    page = doc.new_page(width=612, height=792)
    page.insert_image(page.rect, stream=buf.getvalue())
    page.set_rotation(rot)
doc.save("scanned_batch.pdf")

Then run inspect_rotation.py:

import hashlib
from pypdf import PdfReader

reader = PdfReader("scanned_batch.pdf")
for i, page in enumerate(reader.pages, start=1):
    mb = page.mediabox
    contents = page.get_contents().get_data().decode("latin1").strip().replace("\n", " ")
    img = page.images[0]
    digest = hashlib.sha256(img.data).hexdigest()[:12]
    print(
        f"Page {i}: /Rotate={page.rotation:<3} "
        f"MediaBox={float(mb.width):.0f}x{float(mb.height):.0f}  "
        f"XObject={img.image.size[0]}x{img.image.size[1]}px (sha256={digest})  "
        f'Contents="{contents}"'
    )

Output:

Page 1: /Rotate=0   MediaBox=612x792  XObject=1275x1650px (sha256=a55807960d04)  Contents="q 612 0 0 792 0 0 cm /fzImg0 Do Q"
Page 2: /Rotate=0   MediaBox=612x792  XObject=1275x1650px (sha256=f3658406acc2)  Contents="q 612 0 0 792 0 0 cm /fzImg0 Do Q"
Page 3: /Rotate=90  MediaBox=612x792  XObject=1275x1650px (sha256=cb306182d1c9)  Contents="q 612 0 0 792 0 0 cm /fzImg0 Do Q"
Page 4: /Rotate=0   MediaBox=612x792  XObject=1275x1650px (sha256=26b049a0c8fa)  Contents="q 612 0 0 792 0 0 cm /fzImg0 Do Q"

Notice that all four pages have identical MediaBox dimensions (612x792) and identical /Contents streams (q 612 0 0 792 0 0 cm /fzImg0 Do Q). Pages 2 and 4 appear upside down because the scanner captured the physical sheets upside down (/Rotate=0), whereas Page 3 already carries /Rotate=90, as if the scanner’s auto-orientation had set it.

Method 1: Visual Batch & Per-Page Rotation in the Browser

When you have a packet with scattered upside-down pages, receipts, or sideways tables, visually checking thumbnails is faster than counting page numbers in a terminal. You can fix and save the orientation using the Rotate PDF Pages tool. The tool runs in your browser, so the file is not uploaded.

Here is what the tool does:

  1. Thumbnail Grid: When you select a PDF file, the tool renders every page as a thumbnail with pdf.js. Thumbnails already reflect any /Rotate value stored in the file, so a page the scanner tagged /Rotate 90 previews exactly as viewers show it.
  2. Batch and Per-Page Controls:
    • Choose a target in the toolbar filter—All Pages, Select Even Pages, Select Odd Pages, Portrait, or Landscape—and click -90°, +90°, or 180° to rotate every matching page at once. For a tumbled duplex scan, pick Select Even Pages and click 180°.
    • Use the Rotate left and Rotate right buttons on an individual thumbnail to fix specific pages. Any modified page displays a purple badge (+90°, +180°, or +270°, since -90° wraps modulo 360 to +270°).
    • Each card can also be moved left or right (or dragged) and removed. Undo steps back one change, and Reset clears all rotations and restores any removed or moved pages.
  3. Relative /Rotate Update via pdf-lib: When you click Apply Changes, the tool reads each page’s existing /Rotate angle, adds your rotation modulo 360, and writes the new value with pdf-lib page.setRotation(). Image streams are copied unchanged.

Keep one implementation detail in mind: the tool always copies your pages into a new PDF document when saving, even if you only rotated them. Document-level structures such as bookmarks (/Outlines), page labels, and the Info dictionary (title, author) are not carried over. If you need to keep bookmarks, use PyMuPDF’s in-place set_rotation() from Method 2 instead.

Method 2: Relative Rotation in Python (pypdf and PyMuPDF)

For automated pipelines or 100-page duplex scans where every second page is upside down, use Python.

Why Relative Rotation Matters for Pre-Rotated Pages

If a scanner already wrote /Rotate 90 on Page 3 of scanned_batch.pdf and the page appears upside down (180° off), setting /Rotate to an absolute value of 180 will leave the page sideways instead of at (90 + 180) % 360 = 270.

In pypdf, page.rotate(angle) is relative by default—it adds angle to the existing /Rotate value modulo 360:

import hashlib
from pypdf import PdfReader, PdfWriter

reader = PdfReader("scanned_batch.pdf")
writer = PdfWriter()

# 0-based page indices -> relative clockwise rotation in degrees
rotations = {
    1: 180,  # Page 2: duplex reverse page (0 -> 180)
    2: 180,  # Page 3: landscape table tagged /Rotate 90 by scanner (90 + 180 -> 270)
    3: 180,  # Page 4: duplex reverse page (0 -> 180)
}

for idx, page in enumerate(reader.pages):
    if idx in rotations:
        page.rotate(rotations[idx])  # adds to existing /Rotate modulo 360
    writer.add_page(page)

with open("rotated_pypdf.pdf", "wb") as f:
    writer.write(f)

# Inspect the saved output
check = PdfReader("rotated_pypdf.pdf")
for i, page in enumerate(check.pages, start=1):
    mb = page.mediabox
    digest = hashlib.sha256(page.images[0].data).hexdigest()[:12]
    print(f"Page {i}: /Rotate={page.rotation:<3} MediaBox={float(mb.width):.0f}x{float(mb.height):.0f}  XObject sha256={digest}")

Output:

Page 1: /Rotate=0   MediaBox=612x792  XObject sha256=a55807960d04
Page 2: /Rotate=180 MediaBox=612x792  XObject sha256=f3658406acc2
Page 3: /Rotate=270 MediaBox=612x792  XObject sha256=cb306182d1c9
Page 4: /Rotate=180 MediaBox=612x792  XObject sha256=26b049a0c8fa

Every XObject sha256 hash in rotated_pypdf.pdf is identical to scanned_batch.pdf (a55807960d04, f3658406acc2, cb306182d1c9, 26b049a0c8fa), confirming that zero JPEG re-encoding occurred.

[!TIP] If every even page (2, 4, 6...) in a duplex scan is upside down because the stack was flipped on the short edge, replace the rotations dictionary above with a slice loop over odd 0-based indices:

for page in reader.pages[1::2]:
    page.rotate(180)

In PyMuPDF (pymupdf), page.set_rotation() is an absolute setter, so always add page.rotation modulo 360 when applying a relative adjustment:

import pymupdf

doc = pymupdf.open("scanned_batch.pdf")
rotations = {1: 180, 2: 180, 3: 180}  # 0-based indices for Pages 2, 3, 4

for idx, delta in rotations.items():
    page = doc[idx]
    before = page.rotation
    page.set_rotation((before + delta) % 360)
    print(
        f"Page {idx + 1}: /Rotate {before} -> {page.rotation}, "
        f"display rect={page.rect.width:.0f}x{page.rect.height:.0f} pt"
    )

doc.save("rotated_mupdf.pdf", garbage=4, deflate=True)
doc.close()

Output:

Page 2: /Rotate 0 -> 180, display rect=612x792 pt
Page 3: /Rotate 90 -> 270, display rect=792x612 pt
Page 4: /Rotate 0 -> 180, display rect=612x792 pt

Method 3: Baking the Rotation into the Page Content Before Cropping or Stamping

Look back at Page 3 in the pypdf output above: even after setting /Rotate=270, its raw MediaBox is still 612x792 (portrait) rather than 792x612 (landscape).

While every PDF viewer respects /Rotate=270 when displaying the page, scripts or tools that manipulate raw MediaBox coordinates without reading /Rotate—such as coordinate-based cropping or text stamping—will measure from the unrotated origin.

Calling page.transfer_rotation_to_content() in pypdf solves this without re-encoding the image: it prepends a coordinate transformation matrix (cm) to the /Contents stream, transposes MediaBox when rotating by 90° or 270°, and resets /Rotate to 0:

from pypdf import PdfReader, PdfWriter

reader = PdfReader("rotated_pypdf.pdf")
writer = PdfWriter()

for page in reader.pages:
    if page.rotation != 0:
        page.transfer_rotation_to_content()
    writer.add_page(page)

with open("rotated_baked.pdf", "wb") as f:
    writer.write(f)

check = PdfReader("rotated_baked.pdf")
for i, page in enumerate(check.pages, start=1):
    mb = page.mediabox
    contents = page.get_contents().get_data().decode("latin1").strip().replace("\n", " ")
    print(f'Page {i}: /Rotate={page.rotation:<3} MediaBox={float(mb.width):.0f}x{float(mb.height):.0f}  Contents="{contents}"')

Output:

Page 1: /Rotate=0   MediaBox=612x792  Contents="q 612 0 0 792 0 0 cm /fzImg0 Do Q"
Page 2: /Rotate=0   MediaBox=612x792  Contents="q -1 -0.00000000000000012246468 0.00000000000000012246468 -1 612 792 cm q 612 0 0 792 0 0 cm /fzImg0 Do Q Q"
Page 3: /Rotate=0   MediaBox=792x612  Contents="q -0.00000000000000018369702 1 -1 -0.00000000000000018369702 792 0.0 cm q 612 0 0 792 0 0 cm /fzImg0 Do Q Q"
Page 4: /Rotate=0   MediaBox=612x792  Contents="q -1 -0.00000000000000012246468 0.00000000000000012246468 -1 612 792 cm q 612 0 0 792 0 0 cm /fzImg0 Do Q Q"

After transfer_rotation_to_content(), Page 3’s MediaBox is genuinely 792x612, /Rotate is 0 on every page, and the file size barely changes. You can verify the byte sizes across all methods using stat (stat -f "%N: %z bytes" on macOS BSD stat, or stat -c "%n: %s bytes" on Linux GNU coreutils):

# macOS (BSD stat)
stat -f "%N: %z bytes" scanned_batch.pdf rotated_pypdf.pdf rotated_baked.pdf rotated_mupdf.pdf

# Linux (GNU coreutils)
# stat -c "%n: %s bytes" scanned_batch.pdf rotated_pypdf.pdf rotated_baked.pdf rotated_mupdf.pdf

Output:

scanned_batch.pdf: 174296 bytes
rotated_pypdf.pdf: 174375 bytes
rotated_baked.pdf: 174591 bytes
rotated_mupdf.pdf: 173784 bytes

Comparing Permanent PDF Rotation Methods

MethodWhat It ModifiesRe-Encodes Scan JPEG?Transposes MediaBox on 90°/270°?Best Use Case
Rotate PDF Pages (pdf-lib)/Page dictionary /Rotate (saved as a new document; bookmarks dropped)No (0% quality loss)No (viewer rotates on display)Visual inspection of mixed-orientation scans or one-click full-document rotation
Python pypdf (page.rotate())/Page dictionary /Rotate (relative)NoNoBatch-fixing duplex tumbled scans (reader.pages[1::2])
Python pymupdf (page.set_rotation())/Page dictionary /Rotate (absolute)NoReflected in page.rect, raw MediaBox unchangedFast in-place edits (the garbage=4 save rewrites and compacts the whole file)
Python pypdf (transfer_rotation_to_content())Wraps /Contents in cm matrix & resets /Rotate to 0NoYes (612x792 becomes 792x612)Preparing rotated scans before coordinate-based cropping or text stamping

Once your scanned pages are upright, you can clean up the rest of the packet: if a duplex scan of single-sided sheets left empty reverse pages, see our guide on detecting and deleting blank pages from scanned PDFs or remove them in the Remove PDF Pages tool. If you plan to trim dark scanner borders with the PDF Cropper or stamp numbers with PDF Page Numbers, see our guides on splitting two-page book scans and PDF page number placement.