Most attempts to pull a sharp JPG out of a PDF fail because they treat the PDF as a flat image file. Under ISO 32000-1 (PDF 1.7) and its successor ISO 32000-2 (PDF 2.0), a PDF page is a hybrid PostScript coordinate grid (1 point = 1/72 inch) that combines two distinct graphic layers:

  1. Embedded Raster Image XObjects (/Subtype /Image): Standalone compressed bitstreams (photographs, scans, or product cutouts) stored as stream objects in the PDF body—the cross-reference table only records where each object starts—and positioned on the page via a cm (Current Transformation Matrix) operator.
  2. Vector Content Streams (/Contents): Mathematical Bézier curves, table borders, and embedded font glyphs (BT ... ET) that have no intrinsic pixel dimensions until a rasterizer evaluates them at a target Dots Per Inch (DPI).

If you use a full-page rasterizer at screen scale (72 to 108 DPI) to pull a 2400 x 1800 px photograph out of a small frame on a brochure page, you discard over 96% of the original camera pixels and introduce double-DCT generation loss (re-quantizing an already compressed 8x8 Discrete Cosine Transform grid). Conversely, if you run a raw XObject stream extractor on an architectural diagram or annotated chart, you either extract 0 files (because the page is pure vector) or get an unlabelled background photo stripped of all vector callout text.

flowchart TD
    A["Inspect PDF Target Graphic<br/>(page.get_image_info / Resources.XObject)"] --> B{"Is target an embedded<br/>/Subtype /Image XObject?"}
    B -->|"No (0 XObjects: Vector Diagram,<br/>Slide, or Text Table)"| C{"Target Output Use Case?"}
    C -->|"Quick Web / Slide Deck<br/>(108–216 DPI / 1.5x–3x Scale)"| D["Browser PDF to JPG Tool<br/>(pdf.js Canvas → JPG / PNG / WebP)"]
    C -->|"Print / Fine Colored Text<br/>(300 DPI / 4.17x Scale)"| E["Rasterize @ 300 DPI with 4:4:4<br/>(subsampling=0 / cjpeg -sample 1x1)"]
    B -->|"Yes (Embedded Photo or Scan)"| F{"Does page draw vector callouts<br/>over the image bbox?"}
    F -->|"Yes (Need Photo + Vector Labels)"| G["Clipped Native-DPI Rasterization<br/>(get_pixmap dpi=eff_dpi, clip=bbox)"]
    F -->|"No (Want Original Sensor Stream)"| H{"Check XObject /Filter,<br/>/ColorSpace & /SMask"}
    H -->|"RGB + /DCTDecode + No /SMask"| I["Direct Byte Copy (xo.read_raw_bytes)<br/>100% Lossless (0 Generation Loss)"]
    H -->|"/DeviceCMYK or /SMask Present"| J["Composite /SMask on White &<br/>Convert CMYK to sRGB 4:4:4 JPG"]

The 4 Hidden Resolution Traps in PDF-to-JPG Extraction

1. Placed Bounding Box Size vs. Native XObject Stream Resolution

Inside a PDF page’s /Contents stream, an embedded /Image XObject always starts in a 1 x 1 unit square and is scaled onto the page by a transformation matrix (a 0 0 d e f cm /Im0 Do). A 2400 x 1800 px (4.32 MP) product photograph placed inside a 4.0 x 3.0 inch (288 x 216 pt) layout frame has an effective placed resolution of 600 DPI (Effective DPI = (Stream Pixel Width * 72) / Placed Bounding Box Width in Points = (2400 * 72) / 288 = 600 DPI).

Note also that PDF renderers ignore the fallback JFIF APP0 density tag (72 or 96 DPI) stored inside an embedded JPEG header; only the ratio of stream pixels to the PDF bounding box determines effective DPI. If you rasterize the entire US Letter page (612 x 792 pt) at 72 DPI, that 288 x 216 pt frame is rendered as a 288 x 216 px patch—throwing away 98.56% of the 4,320,000 pixels stored inside the PDF object. Even a full-page 300 DPI rasterization renders that 4 x 3 inch frame at 1200 x 900 px, discarding 75.0% of the embedded image’s native pixels.

2. Why macOS sips Destroys PDF Resolution

Many terminal guides recommend the built-in macOS sips utility (sips -s format jpeg input.pdf --out output.jpg). When tested against a multi-page PDF on macOS, sips fails in two ways:

  1. Drops Page 2 through Page N: sips only reads the first page of a PDF container and silently discards all remaining pages.
  2. Rasterizes at 72 DPI Before Resampling: By default, sips renders Page 1 at 612 x 792 px (dpiWidth: 72.000). If you pass --resampleWidth 2550 to request a 300 DPI (2550 x 3300 px) image, sips still rasterizes the PDF vector layer at 612 x 792 px first and then interpolates that blurry low-res bitmap up to 2550 x 3300 px. Fine detail that a true 300 DPI render would resolve—hairlines, small type, sub-point patterns—is already averaged away at 72 DPI and cannot come back.

3. The SMask Black-Box Trap & CMYK JPEGs

When extracting raw /Image XObjects directly from a PDF, two internal PDF structures routinely corrupt the output JPG:

  • Split /SMask Transparency Streams: Because baseline JPEG (ITU-T T.81) has no alpha channel, PDF writers split transparent PNG cutouts into two separate objects: a base /DeviceRGB /Image XObject and a secondary 8-bit /DeviceGray /SMask XObject containing the alpha mask. The base image keeps whatever color the transparent pixels happened to hold; that is often pure black RGB(0, 0, 0)—as in our test badge, which is drawn on a (0, 0, 0, 0) background—but it can be any color. Calling PyMuPDF’s popular doc.extract_image(xref) fetches only the base image (the /SMask xref is returned separately under the "smask" key), so that hidden matte shows up as a solid background box.
  • Prepress 4-Channel /DeviceCMYK Streams: PDFs exported from Adobe InDesign or commercial print pipelines embed /DCTDecode images in /DeviceCMYK (or /ICCBased with /N 4 and /Alternate /DeviceCMYK). Saving raw 4-channel CMYK JPEG bytes directly for web use triggers oversaturated or inverted colors in viewers that do not parse Adobe APP14 YCCK color transform markers.

4. 4:2:0 Chroma Subsampling Blur on Colored Text

When you rasterize a vector PDF page containing colored text (such as crimson warning callouts or blue hyperlinks on a white background), standard JPEG encoders (libjpeg-turbo, Pillow default, and Poppler pdftoppm -jpeg) convert RGB to YCbCr and apply 4:2:0 chroma subsampling.

In the JPEG SOF0 (0xFFC0) frame header, 4:2:0 sets the luminance (Y) sampling factor byte to 0x22 (2x2) and the chrominance (Cb, Cr) factors to 0x11 (1x1). While black-on-white text stays at 300 DPI, the color channels are downsampled to 150 DPI (2x2 pixel blocks), causing single-channel pixel errors of up to 95 out of 255 (37.3%) along 1-pixel colored font strokes. Switching the encoder to 4:4:4 (0x11 / 0x11 / 0x11) retains full 300 DPI chroma resolution across every pixel.


Building a Deterministic 3-Page Test PDF to Benchmark Extraction

To test every extraction method with reproducible numbers, let’s build a 3-page US Letter (612 x 792 pt) PDF (product_spec_sheet.pdf, 3,347,451 bytes) that includes all three real-world PDF graphics structures.

On both macOS (Homebrew Python) and Ubuntu/Debian Linux (where PEP 668 blocks system-wide pip installs), create a local virtual environment with pikepdf, pymupdf, and pillow:

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

Save and run build_test_pdf.py:

  • Page 1: Embeds a 2400 x 1800 px (995,182-byte) sRGB /DCTDecode photograph inside a 288 x 216 pt (4.0 x 3.0 inch = 600 effective DPI) frame, with a red vector callout box and yellow leader line drawn on top in the page’s /Contents stream.
  • Page 2: Embeds a 1600 x 1200 px 4-channel /DeviceCMYK /DCTDecode image (400 effective DPI) alongside an 800 x 800 px transparent badge stored with a separate /SMask alpha XObject (320 effective DPI).
  • Page 3: Contains a 100% vector schematic with 0.5 pt grid lines and 8.5 pt crimson (#D62828) and royal blue (#1D4ED8) typography (0 embedded /Image XObjects).
import io
import os
import pymupdf
from PIL import Image, ImageDraw

def create_test_pdf(pdf_path: str) -> None:
    doc = pymupdf.open()

    # --- PAGE 1: 2400x1800 DCTDecode Photo in 288x216 pt Frame (600 DPI) + Vector Callout ---
    p1 = doc.new_page(width=612, height=792)
    p1.insert_text((36, 48), "PAGE 1: EMBEDDED 2400x1800 DCTDECODE PHOTO IN 288x216 PT FRAME", fontsize=13, fontname="helv", color=(0.08, 0.12, 0.20))

    photo = Image.new("RGB", (2400, 1800))
    draw_p = ImageDraw.Draw(photo)
    for y in range(0, 1800, 4):
        r = int(30 + (y / 1800) * 160)
        g = int(70 + (y / 1800) * 110)
        b = int(140 + (y / 1800) * 95)
        draw_p.rectangle([0, y, 2400, y + 4], fill=(r, g, b))
    for x in range(100, 2400, 100):
        draw_p.line([(x, 0), (x, 1800)], fill=(255, 255, 255), width=2)
    for y in range(100, 1800, 100):
        draw_p.line([(0, y), (2400, y)], fill=(255, 255, 255), width=2)
    for radius in range(50, 750, 50):
        draw_p.ellipse([1200 - radius, 900 - radius, 1200 + radius, 900 + radius], outline=(255, 220, 60), width=4)

    photo_buf = io.BytesIO()
    photo.save(photo_buf, format="JPEG", quality=92, subsampling=0)
    p1.insert_image(pymupdf.Rect(36, 72, 324, 288), stream=photo_buf.getvalue())

    shape1 = p1.new_shape()
    shape1.draw_rect(pymupdf.Rect(210, 90, 314, 122))
    shape1.finish(fill=(0.84, 0.16, 0.16), color=(1, 1, 1), width=1.5)
    shape1.draw_line(pymupdf.Point(210, 106), pymupdf.Point(180, 180))
    shape1.finish(color=(1, 0.9, 0.2), width=2.0)
    shape1.commit()
    p1.insert_text((218, 110), "VECTOR CALLOUT", fontsize=9.5, fontname="helv", color=(1, 1, 1))
    p1.insert_text((36, 315), "Note: Extracting the raw /DCTDecode XObject yields the 2400x1800 px sensor image,", fontsize=10, fontname="helv", color=(0.2, 0.2, 0.2))
    p1.insert_text((36, 330), "but omits the red 'VECTOR CALLOUT' overlay drawn in the page /Contents stream.", fontsize=10, fontname="helv", color=(0.2, 0.2, 0.2))

    # --- PAGE 2: 1600x1200 4-Channel /DeviceCMYK JPEG + 800x800 RGBA (/SMask) ---
    p2 = doc.new_page(width=612, height=792)
    p2.insert_text((36, 48), "PAGE 2: PREPRESS /DEVICECMYK XOBJECT & TRANSPARENT /SMASK CUTOUT", fontsize=13, fontname="helv", color=(0.08, 0.12, 0.20))

    cmyk_img = Image.new("CMYK", (1600, 1200))
    draw_c = ImageDraw.Draw(cmyk_img)
    for x in range(0, 1600, 8):
        c_val = int((x / 1600) * 200)
        m_val = int(180 - (x / 1600) * 140)
        draw_c.rectangle([x, 0, x + 8, 1200], fill=(c_val, m_val, 30, 20))
    draw_c.rectangle([200, 200, 1400, 1000], outline=(0, 0, 0, 240), width=10)
    cmyk_buf = io.BytesIO()
    cmyk_img.save(cmyk_buf, format="JPEG", quality=90)
    p2.insert_image(pymupdf.Rect(36, 72, 324, 288), stream=cmyk_buf.getvalue())

    rgba_img = Image.new("RGBA", (800, 800), (0, 0, 0, 0))
    draw_a = ImageDraw.Draw(rgba_img)
    draw_a.ellipse([80, 80, 720, 720], fill=(24, 118, 210, 255), outline=(15, 23, 42, 255), width=12)
    draw_a.ellipse([220, 220, 580, 580], fill=(255, 255, 255, 255))
    rgba_buf = io.BytesIO()
    rgba_img.save(rgba_buf, format="PNG")
    p2.insert_image(pymupdf.Rect(350, 72, 530, 252), stream=rgba_buf.getvalue())

    # --- PAGE 3: 100% Vector Schematic & Colored Typography (0 Image XObjects) ---
    p3 = doc.new_page(width=612, height=792)
    p3.insert_text((36, 48), "PAGE 3: PURE VECTOR SCHEMATIC & CHROMA SUBSAMPLING TEST (0 XOBJECTS)", fontsize=13, fontname="helv", color=(0.08, 0.12, 0.20))

    shape3 = p3.new_shape()
    shape3.draw_rect(pymupdf.Rect(36, 72, 576, 420))
    shape3.finish(fill=(1, 1, 1), color=(0.15, 0.20, 0.30), width=1.0)
    for gx in range(60, 560, 24):
        shape3.draw_line(pymupdf.Point(gx, 80), pymupdf.Point(gx, 410))
        shape3.finish(color=(0.88, 0.91, 0.95), width=0.5)
    for gy in range(96, 410, 24):
        shape3.draw_line(pymupdf.Point(44, gy), pymupdf.Point(568, gy))
        shape3.finish(color=(0.88, 0.91, 0.95), width=0.5)

    shape3.draw_rect(pymupdf.Rect(60, 100, 290, 220))
    shape3.finish(fill=(1, 1, 1), color=(0.839, 0.157, 0.157), width=1.5)
    shape3.draw_rect(pymupdf.Rect(320, 100, 550, 220))
    shape3.finish(fill=(1, 1, 1), color=(0.114, 0.306, 0.847), width=1.5)
    shape3.commit()

    p3.insert_text((74, 128), "CRITICAL RED BUS: 400V DC +/- 0.5%", fontsize=11, fontname="helv", color=(0.839, 0.157, 0.157))
    p3.insert_text((74, 148), "Fine 8pt crimson typography on white tests", fontsize=8.5, fontname="helv", color=(0.839, 0.157, 0.157))
    p3.insert_text((74, 162), "4:2:0 vs 4:4:4 chroma subsampling blur.", fontsize=8.5, fontname="helv", color=(0.839, 0.157, 0.157))

    p3.insert_text((334, 128), "PRIMARY TELEMETRY: 10GBASE-SR OPTIC", fontsize=11, fontname="helv", color=(0.114, 0.306, 0.847))
    p3.insert_text((334, 148), "Fine 8pt royal blue typography on white tests", fontsize=8.5, fontname="helv", color=(0.114, 0.306, 0.847))
    p3.insert_text((334, 162), "Cb/Cr 2x2 macroblock halving artifacts.", fontsize=8.5, fontname="helv", color=(0.114, 0.306, 0.847))

    doc.save(pdf_path)
    doc.close()
    print(f"Created {pdf_path} ({os.path.getsize(pdf_path):,d} bytes)")

if __name__ == "__main__":
    create_test_pdf("product_spec_sheet.pdf")

First, let’s verify what happens when we run macOS’s built-in sips converter on product_spec_sheet.pdf:

sips -s format jpeg product_spec_sheet.pdf --out sips_out.jpg
sips -g pixelWidth -g pixelHeight -g dpiWidth sips_out.jpg
stat -f "%N: %z bytes" sips_out.jpg

Output:

/path/to/product_spec_sheet.pdf
  /path/to/sips_out.jpg
/path/to/sips_out.jpg
  pixelWidth: 612
  pixelHeight: 792
  dpiWidth: 72.000
sips_out.jpg: 45445 bytes

Not only did sips drop Pages 2 and 3, it crushed Page 1 down to 612 x 792 px (45,445 bytes), reducing our 2400 x 1800 px embedded photo to a 288 x 216 px thumbnail.


Method 1: Lossless Image Extraction & Clipped Rendering in Python

To extract the highest possible resolution from a PDF without falling into the /SMask black-box trap, 4-channel CMYK color shifts, or 4:2:0 chroma blur, save and run extract_high_res_jpg.py. It implements all three precision workflows:

  1. Direct /DCTDecode Stream Copy (extract_embedded_xobjects_as_jpg): Inspects every /Subtype /Image XObject on each page. If an image is already a 3-channel RGB /DCTDecode JPEG without an /SMask (Page 1, xref=7), it copies xo.read_raw_bytes() directly to disk—preserving the exact 995,182-byte original JPEG (SHA-256 prefix ccf331fc9ddd86f1) with zero generation loss. If the XObject uses CMYK (Page 2, xref=18) or has an /SMask (Page 2, xref=22), it composites any transparency onto a white (255, 255, 255) background and encodes a normalized sRGB JPEG with subsampling=0 (4:4:4).
  2. Clipped Native-DPI Figure Rendering (extract_clipped_composite_figure): When you need the embedded 2400 x 1800 px photo on Page 1 plus the red "VECTOR CALLOUT" overlay drawn on top of it, it reads the image’s placed bounding box (bbox = (36.0, 72.0, 324.0, 288.0)), calculates its exact effective DPI (600 DPI), and calls page.get_pixmap(dpi=600, clip=bbox) to render a 2400 x 1800 px composite JPG (895,975 bytes) without rendering the surrounding white page margins.
  3. Full-Page Rasterization & Chroma Subsampling Benchmark (benchmark_full_page_rasterization): Rasterizes Page 3’s pure vector schematic across 72, 108, 150, and 300 DPI (4:2:0 vs. 4:4:4), inspecting the raw SOF0 (0xFFC0) sampling factor bytes and measuring PSNR and maximum pixel error on the colored 8.5 pt text against an uncompressed 300 DPI reference.
import hashlib
import io
import math
import os
import pikepdf
import pymupdf
from PIL import Image

def sha256_prefix(data: bytes) -> str:
    return hashlib.sha256(data).hexdigest()[:16]

def inspect_sof0(jpg_bytes: bytes) -> str:
    idx = jpg_bytes.find(b"\xff\xc0")
    if idx == -1:
        idx = jpg_bytes.find(b"\xff\xc2")
    if idx == -1:
        return "N/A"
    sof = jpg_bytes[idx : idx + 19]
    n = sof[9]
    return "/".join(f"0x{sof[11 + 3 * i]:02x}" for i in range(n))

def extract_embedded_xobjects_as_jpg(pdf_path: str, out_dir: str) -> None:
    os.makedirs(out_dir, exist_ok=True)
    pdf = pikepdf.open(pdf_path)
    doc = pymupdf.open(pdf_path)

    print("=== 1. DIRECT EMBEDDED XOBJECT EXTRACTION (PIKEPDF + PILLOW) ===")
    for page_idx, (pk_page, mu_page) in enumerate(zip(pdf.pages, doc)):
        if "/XObject" not in pk_page.Resources:
            print(f"Page {page_idx + 1}: 0 embedded /Image XObjects (pure vector page)")
            continue

        bbox_map = {}
        for info in mu_page.get_image_info(xrefs=True):
            bbox = pymupdf.Rect(info["bbox"])
            eff_x = round(info["width"] * 72.0 / bbox.width)
            eff_y = round(info["height"] * 72.0 / bbox.height)
            bbox_map[info["xref"]] = (bbox, eff_x, eff_y)

        for name, xo in pk_page.Resources.XObject.items():
            if xo.get("/Subtype") != "/Image":
                continue
            xref = xo.objgen[0]
            pim = pikepdf.PdfImage(xo)
            filt = str(xo.get("/Filter"))
            smask = xo.get("/SMask")
            _, eff_x, eff_y = bbox_map.get(xref, (None, 0, 0))

            # Case A: Native 3-channel RGB /DCTDecode with no /SMask -> zero-loss stream copy
            if filt == "/DCTDecode" and pim.mode == "RGB" and smask is None:
                raw_jpg = xo.read_raw_bytes()
                out_file = os.path.join(out_dir, f"p{page_idx+1}_xref{xref}_lossless.jpg")
                with open(out_file, "wb") as f:
                    f.write(raw_jpg)
                print(
                    f"Page {page_idx+1} {name} (xref={xref}): {pim.width}x{pim.height}px "
                    f"(placed at {eff_x}x{eff_y} DPI) -> Lossless /DCTDecode copy "
                    f"({len(raw_jpg):,d} B, sha256={sha256_prefix(raw_jpg)})"
                )

            # Case B: 4-channel CMYK or /SMask transparency -> normalize to sRGB 4:4:4 JPEG
            else:
                # pikepdf >= 10 applies an attached /SMask by default and returns RGBA/LA
                pil_img = pim.as_pil_image()
                if smask is not None and pil_img.mode not in ("RGBA", "LA"):
                    # Older pikepdf: load the soft mask as its own image and attach it
                    alpha = pikepdf.PdfImage(smask).as_pil_image().convert("L")
                    pil_img = pil_img.convert("RGB")
                    pil_img.putalpha(alpha.resize(pil_img.size))
                orig_mode = pil_img.mode
                if pil_img.mode in ("RGBA", "LA"):
                    white_bg = Image.new("RGB", pil_img.size, (255, 255, 255))
                    white_bg.paste(pil_img.convert("RGBA"), mask=pil_img.getchannel("A"))
                    rgb_img = white_bg
                else:
                    rgb_img = pil_img.convert("RGB")

                out_file = os.path.join(out_dir, f"p{page_idx+1}_xref{xref}_srgb.jpg")
                rgb_img.save(out_file, format="JPEG", quality=95, subsampling=0, optimize=True)
                fsize = os.path.getsize(out_file)
                print(
                    f"Page {page_idx+1} {name} (xref={xref}): {pim.width}x{pim.height}px "
                    f"(placed at {eff_x}x{eff_y} DPI, mode={orig_mode}, SMask={smask is not None}) "
                    f"-> Normalized sRGB 4:4:4 JPG ({fsize:,d} B)"
                )

def extract_clipped_composite_figure(pdf_path: str, page_num: int, out_path: str) -> None:
    doc = pymupdf.open(pdf_path)
    page = doc[page_num - 1]
    info = page.get_image_info(xrefs=True)[0]
    bbox = pymupdf.Rect(info["bbox"])
    eff_dpi = round(info["width"] * 72.0 / bbox.width)

    pix = page.get_pixmap(dpi=eff_dpi, clip=bbox, alpha=False)
    img = Image.frombytes("RGB", (pix.width, pix.height), pix.samples)
    img.save(out_path, format="JPEG", quality=92, subsampling=0, optimize=True)
    print(
        f"\n=== 2. CLIPPED COMPOSITE FIGURE (PHOTO + VECTOR CALLOUTS AT {eff_dpi} DPI) ===\n"
        f"Saved {out_path}: {pix.width}x{pix.height}px ({os.path.getsize(out_path):,d} B) "
        f"from bbox={tuple(round(v, 1) for v in bbox)}"
    )

def benchmark_full_page_rasterization(pdf_path: str) -> None:
    doc = pymupdf.open(pdf_path)
    page3 = doc[2]

    pix_ref = page3.get_pixmap(dpi=300, alpha=False)
    ref_img = Image.frombytes("RGB", (pix_ref.width, pix_ref.height), pix_ref.samples)
    text_crop = (240, 400, 2300, 750)
    ref_crop_bytes = ref_img.crop(text_crop).tobytes()

    print("\n=== 3. FULL-PAGE RASTERIZATION BENCHMARK (PAGE 3 VECTOR SCHEMATIC) ===")
    configs = [
        ("72 DPI (1.0x Screen Default)", 72, 2, 90),
        ("108 DPI (1.5x Scale)", 108, 2, 92),
        ("150 DPI (2.08x Standard Draft)", 150, 2, 90),
        ("300 DPI (4.17x Print 4:2:0)", 300, 2, 90),
        ("300 DPI (4.17x Print 4:4:4)", 300, 0, 90),
    ]

    for label, dpi, sub, qual in configs:
        pix = page3.get_pixmap(dpi=dpi, alpha=False)
        im = Image.frombytes("RGB", (pix.width, pix.height), pix.samples)
        buf = io.BytesIO()
        im.save(buf, format="JPEG", quality=qual, subsampling=sub, optimize=True)
        jpg_bytes = buf.getvalue()
        sof_factors = inspect_sof0(jpg_bytes)

        dec = Image.open(io.BytesIO(jpg_bytes))
        dec_eval = dec.resize(ref_img.size, Image.Resampling.BICUBIC) if dec.size != ref_img.size else dec
        test_crop_bytes = dec_eval.crop(text_crop).tobytes()
        mse = sum((a - b) ** 2 for a, b in zip(ref_crop_bytes, test_crop_bytes)) / len(ref_crop_bytes)
        max_err = max(abs(a - b) for a, b in zip(ref_crop_bytes, test_crop_bytes))
        psnr = 10 * math.log10((255 ** 2) / mse)

        print(
            f"{label:30s}: {pix.width:4d}x{pix.height:<4d}, SOF0={sof_factors:14s}, "
            f"size={len(jpg_bytes):7,d} B, PSNR={psnr:5.2f} dB, MaxErr={max_err:3d}"
        )

if __name__ == "__main__":
    extract_embedded_xobjects_as_jpg("product_spec_sheet.pdf", "extracted_xobjects")
    extract_clipped_composite_figure("product_spec_sheet.pdf", 1, "p1_composite_600dpi.jpg")
    benchmark_full_page_rasterization("product_spec_sheet.pdf")

Output:

=== 1. DIRECT EMBEDDED XOBJECT EXTRACTION (PIKEPDF + PILLOW) ===
Page 1 /fzImg0 (xref=7): 2400x1800px (placed at 600x600 DPI) -> Lossless /DCTDecode copy (995,182 B, sha256=ccf331fc9ddd86f1)
Page 2 /fzImg0 (xref=18): 1600x1200px (placed at 400x400 DPI, mode=CMYK, SMask=False) -> Normalized sRGB 4:4:4 JPG (61,916 B)
Page 2 /fzImg1 (xref=22): 800x800px (placed at 320x320 DPI, mode=RGBA, SMask=True) -> Normalized sRGB 4:4:4 JPG (69,845 B)
Page 3: 0 embedded /Image XObjects (pure vector page)

=== 2. CLIPPED COMPOSITE FIGURE (PHOTO + VECTOR CALLOUTS AT 600 DPI) ===
Saved p1_composite_600dpi.jpg: 2400x1800px (895,975 B) from bbox=(36.0, 72.0, 324.0, 288.0)

=== 3. FULL-PAGE RASTERIZATION BENCHMARK (PAGE 3 VECTOR SCHEMATIC) ===
72 DPI (1.0x Screen Default)  :  612x792 , SOF0=0x22/0x11/0x11, size= 37,140 B, PSNR=20.34 dB, MaxErr=194
108 DPI (1.5x Scale)          :  918x1188, SOF0=0x22/0x11/0x11, size= 68,447 B, PSNR=23.06 dB, MaxErr=177
150 DPI (2.08x Standard Draft): 1275x1650, SOF0=0x22/0x11/0x11, size=111,474 B, PSNR=23.33 dB, MaxErr=197
300 DPI (4.17x Print 4:2:0)   : 2550x3300, SOF0=0x22/0x11/0x11, size=289,865 B, PSNR=33.89 dB, MaxErr= 95
300 DPI (4.17x Print 4:4:4)   : 2550x3300, SOF0=0x11/0x11/0x11, size=413,282 B, PSNR=40.98 dB, MaxErr= 46

You can also verify the extracted file sizes and SHA-256 digests directly in your shell. Note the flag differences between BSD utilities on macOS and GNU coreutils on Linux:

# macOS (BSD stat and shasum):
stat -f "%N: %z bytes" extracted_xobjects/* p1_composite_600dpi.jpg
shasum -a 256 extracted_xobjects/p1_xref7_lossless.jpg

# Linux (GNU coreutils stat and sha256sum):
stat -c "%n: %s bytes" extracted_xobjects/* p1_composite_600dpi.jpg
sha256sum extracted_xobjects/p1_xref7_lossless.jpg

Output:

extracted_xobjects/p1_xref7_lossless.jpg: 995182 bytes
extracted_xobjects/p2_xref18_srgb.jpg: 61916 bytes
extracted_xobjects/p2_xref22_srgb.jpg: 69845 bytes
p1_composite_600dpi.jpg: 895975 bytes
ccf331fc9ddd86f16af3ec97ac8f5bbbe0e35ac77657ddc7bc42fa6964736af8  extracted_xobjects/p1_xref7_lossless.jpg

If you prefer a one-line CLI unpacker for embedded XObjects and already have pymupdf installed in your virtualenv, .venv/bin/pymupdf extract -images product_spec_sheet.pdf writes img-7.jpeg (995,182 B), img-18.jpeg (56,795 B, automatically converted from CMYK to RGB), and img-22.png (11,000 B, preserving RGBA transparency as PNG). Similarly, if you have libjpeg-turbo installed (brew install jpeg-turbo on macOS or apt install libjpeg-turbo-progs on Debian/Ubuntu), you can encode an uncompressed .ppm raster into a 4:4:4 JPEG from the terminal using cjpeg -quality 90 -sample 1x1 -optimize -outfile p3_cjpeg_444.jpg page3_300dpi.ppm, after first saving the reference render with page.get_pixmap(dpi=300, alpha=False).save("page3_300dpi.ppm"). In our run (libjpeg-turbo 3.1.4 and Pillow 13.0), this produced a 413,282-byte file that cmp reports as byte-identical to Pillow’s subsampling=0 output above—both encoders are libjpeg-turbo with the same settings.


Method 2: Fast In-Browser Page Rasterization

When you want to convert a multi-page slide deck, invoice, or document into individual page images without setting up a Python environment, Utiliome’s PDF to JPG tool processes your PDF locally in browser memory with zero network uploads.

The tool runs a simple full-page rasterization pipeline:

  1. Selectable Render Scale (1.5x, 2x, or 3x): It renders each page with pdf.js onto an HTML5 <canvas> with a white background at 1.5x, 2x (the default), or 3x the page’s point size. That is 108, 144, or 216 DPI, so a 612 x 792 pt US Letter page becomes 918 x 1188, 1224 x 1584, or 1836 x 2376 px.
  2. Format Selection (JPG, PNG, or WebP): JPG is the default. PNG is lossless, and JPG and WebP are encoded at quality 0.92.
  3. Single Image or ZIP Download: You can download any page on its own, or select pages (all are selected by default) and export them together. One selected page downloads as a single image; two or more are bundled into a .zip archive with files named <name>-page-01.jpg, <name>-page-02.jpg, and so on.

Knowing what the tool does clarifies when to use it versus the Python scripts above:

  • Best for full-page web, slide, and document previews: Whole-page images at 108–216 DPI work well for slide decks, documentation wikis, and CMS articles, and you can rotate pages before exporting.
  • Does not unpack raw /DCTDecode XObjects or render above 216 DPI: Because the tool rasterizes entire pages and accepts one PDF at a time, use extract_high_res_jpg.py when you need 300–600 DPI print rasters or lossless extraction of embedded camera photos. Browser JPEG encoders also choose their own chroma subsampling, so for fine colored text at print resolution, use the 4:4:4 Python path.
  • Post-extraction workflows: If you need to shrink extracted 300 DPI JPEGs for web delivery, run them through Compress Image, or re-bundle selected images back into a document using JPG to PDF. If you are preparing pages for grayscale printing, see our guide on converting color PDFs to grayscale to save ink or the PDF to Grayscale tool.

Comparison of PDF-to-JPG Extraction & Rasterization Methods

Extraction / Rasterization MethodPage 1 Photo Output (2400x1800 Native)Preserves Vector Callouts & Text?Page 3 Colored Text PSNR (vs. 300 DPI)Handles /DeviceCMYK & /SMask?Multi-Page Support
macOS sips -s format jpeg288 x 216 px (98.6% pixels lost)Yes (at blurry 72 DPI)Not measured (upsampled from a 72 DPI render)Renders via CoreGraphicsNo (Drops Pages 2+)
Browser PDF to JPG (1.5x–3x / 108–216 DPI)576 x 432 px at the default 144 DPI (up to 864 x 648 px at 216 DPI)Yes (full-page render)Not measured (browser JPEG encoder)Yes (Composited on <canvas>)Yes (ZIP when 2+ pages selected)
PyMuPDF Full-Page 300 DPI (4:2:0 Default)1200 x 900 px (inside 2550x3300 page)Yes (300 DPI full page)33.89 dB (289,865 B, MaxErr=95)Yes (alpha=False white bg)Yes (All pages)
PyMuPDF Full-Page 300 DPI (4:4:4, subsampling=0)1200 x 900 px (inside 2550x3300 page)Yes (300 DPI full page)40.98 dB (413,282 B, MaxErr=46)Yes (alpha=False white bg)Yes (All pages)
PyMuPDF Clipped bbox (dpi=600, clip=bbox)2400 x 1800 px (100% native scale)Yes (600 DPI vector overlay)N/A (Crops to figure bbox)Yes (alpha=False white bg)Yes (Per figure bbox)
pikepdf Direct /DCTDecode Stream Copy2400 x 1800 px (995,182 B, 0% loss)No (Omits /Contents vectors)N/A (0 XObjects on vector pages)Yes (With Pillow RGBA/CMYK branch)Yes (All XObjects)