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:
- 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 acm(Current Transformation Matrix) operator. - 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:
- Drops Page 2 through Page N:
sipsonly reads the first page of a PDF container and silently discards all remaining pages. - Rasterizes at
72 DPIBefore Resampling: By default,sipsrenders Page 1 at612 x 792px (dpiWidth: 72.000). If you pass--resampleWidth 2550to request a300 DPI(2550 x 3300px) image,sipsstill rasterizes the PDF vector layer at612 x 792px first and then interpolates that blurry low-res bitmap up to2550 x 3300px. 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
/SMaskTransparency 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/ImageXObject and a secondary 8-bit/DeviceGray/SMaskXObject containing the alpha mask. The base image keeps whatever color the transparent pixels happened to hold; that is often pure blackRGB(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 populardoc.extract_image(xref)fetches only the base image (the/SMaskxref is returned separately under the"smask"key), so that hidden matte shows up as a solid background box. - Prepress 4-Channel
/DeviceCMYKStreams: PDFs exported from Adobe InDesign or commercial print pipelines embed/DCTDecodeimages in/DeviceCMYK(or/ICCBasedwith/N 4and/Alternate /DeviceCMYK). Saving raw 4-channel CMYK JPEG bytes directly for web use triggers oversaturated or inverted colors in viewers that do not parse AdobeAPP14YCCK 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 1800px (995,182-byte) sRGB/DCTDecodephotograph inside a288 x 216pt (4.0 x 3.0inch =600effective DPI) frame, with a red vector callout box and yellow leader line drawn on top in the page’s/Contentsstream. - Page 2: Embeds a
1600 x 1200px 4-channel/DeviceCMYK/DCTDecodeimage (400effective DPI) alongside an800 x 800px transparent badge stored with a separate/SMaskalpha XObject (320effective DPI). - Page 3: Contains a
100%vector schematic with0.5pt grid lines and8.5pt crimson (#D62828) and royal blue (#1D4ED8) typography (0embedded/ImageXObjects).
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:
- Direct
/DCTDecodeStream Copy (extract_embedded_xobjects_as_jpg): Inspects every/Subtype /ImageXObject on each page. If an image is already a 3-channelRGB/DCTDecodeJPEG without an/SMask(Page 1,xref=7), it copiesxo.read_raw_bytes()directly to disk—preserving the exact995,182-byte original JPEG (SHA-256prefixccf331fc9ddd86f1) with zero generation loss. If the XObject usesCMYK(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 normalizedsRGBJPEG withsubsampling=0(4:4:4). - Clipped Native-DPI Figure Rendering (
extract_clipped_composite_figure): When you need the embedded2400 x 1800px 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 callspage.get_pixmap(dpi=600, clip=bbox)to render a2400 x 1800px composite JPG (895,975bytes) without rendering the surrounding white page margins. - Full-Page Rasterization & Chroma Subsampling Benchmark (
benchmark_full_page_rasterization): Rasterizes Page 3’s pure vector schematic across72,108,150, and300 DPI(4:2:0vs.4:4:4), inspecting the rawSOF0(0xFFC0) sampling factor bytes and measuring PSNR and maximum pixel error on the colored8.5pt text against an uncompressed300 DPIreference.
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:
- Selectable Render Scale (
1.5x,2x, or3x): It renders each page withpdf.jsonto an HTML5<canvas>with a white background at1.5x,2x(the default), or3xthe page’s point size. That is108,144, or216 DPI, so a612 x 792pt US Letter page becomes918 x 1188,1224 x 1584, or1836 x 2376px. - Format Selection (
JPG,PNG, orWebP): JPG is the default. PNG is lossless, and JPG and WebP are encoded at quality0.92. - 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
.ziparchive 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 DPIwork well for slide decks, documentation wikis, and CMS articles, and you can rotate pages before exporting. - Does not unpack raw
/DCTDecodeXObjects or render above216 DPI: Because the tool rasterizes entire pages and accepts one PDF at a time, useextract_high_res_jpg.pywhen you need300–600 DPIprint 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 the4:4:4Python path. - Post-extraction workflows: If you need to shrink extracted
300 DPIJPEGs 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 Method | Page 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 jpeg | 288 x 216 px (98.6% pixels lost) | Yes (at blurry 72 DPI) | Not measured (upsampled from a 72 DPI render) | Renders via CoreGraphics | No (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 Copy | 2400 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) |