Before a large print run, most users assume that any document that looks gray on screen will automatically save ink and print from the black cartridge. In practice, three hidden PDF color-space traps regularly cause “grayscale” documents to drain Cyan, Magenta, and Yellow (CMY) cartridges, trigger color click charges that are typically several times the mono rate, or empty a black toner cartridge far faster than its rated yield suggests.
Under the PDF specification (ISO 32000-1, Section 8.6.4), every color on a page is declared in an explicit color space (/DeviceRGB, /DeviceCMYK, /ICCBased, or /DeviceGray). Depending on the device, the printer driver, its Raster Image Processor (RIP), or the print-accounting software decides between color and mono from those declarations or from the rendered pixels—not from how desaturated the page looks to the human eye.
The 3 Hidden Ink and Toner Traps in PDF Printing
1. Composite Gray Mixing & 4-Channel “Rich Black”
When a PDF exported from InDesign, Canva, Figma, or a browser print engine contains dark headings or body text, those elements are rarely encoded in 1-channel /DeviceGray (0 g). Instead, they are stored as 3-channel /DeviceRGB triplets (r g b rg), as 4-channel /DeviceCMYK Rich Black (such as 0.75 0.68 0.67 0.90 k, produced when sRGB #000000 is converted through a standard coated CMYK ICC profile), or—common in InDesign and other prepress exports—as /ICCBased or /Separation colors set with cs/scn.
| PDF Color Encoding | Raw Stream Operator | Channels Sent to Printer RIP | Inkjet / Laser Printhead Behavior | Typical MFD Billing (Device-Dependent) |
|---|---|---|---|---|
3-Channel RGB (/DeviceRGB) | 0.08 0.22 0.48 rg | R, G, B (3 channels) | Often fires C + M + Y + K nozzles (composite gray unless the driver forces K-only) | Usually billed as Color Page |
4-Channel Rich Black (/DeviceCMYK) | 0.75 0.68 0.67 0.90 k | C, M, Y, K (4 channels) | Lays down 210% CMY ink under 90% K toner on every character | Usually billed as Color Page (non-zero CMY planes) |
macOS Gray Tone.qfilter (/ICCBased) | 0.7558 0.71 0.6514 0.8107 sc | /N 3 RGB & /N 4 CMYK | Can still fire C + M + Y + K despite looking gray on screen | Often billed as Color Page (non-zero CMY in the CMYK objects) |
True 1-Channel Grayscale (/DeviceGray) | 0.08 g | Gray (1 channel) | Usually printed with Black (K) only, though some inkjet drivers still blend composite gray | Normally billed as Monochrome Page |
On consumer and office inkjets, printing a /DeviceRGB or /DeviceCMYK PDF in standard grayscale often defaults to composite gray—mixing Cyan, Magenta, and Yellow droplets to smooth mid-tone shading and deepen blacks, which is why many inkjets refuse to print a black-and-white document when a Magenta or Cyan cartridge is empty. On leased office multifunction devices (MFDs), a single forgotten blue hyperlink (0.04 0.36 0.82 rg) or Rich Black heading can flag the entire page on the color counter.
2. The “Toner Sponge” Problem on Dark-Mode Slides & Callout Banners
Converting a PDF’s color space from /DeviceRGB to /DeviceGray removes the color channels (and, on most devices, color ink consumption), but it does not reduce total ink or toner volume if your PDF contains dark-themed slide backgrounds, dark IDE code blocks (#0F172A or #1E293B), or solid navy report banners.
Monochrome laser toner yields (ISO/IEC 19752)—such as 2,500 pages per cartridge—are measured by printing a standard text test page of roughly 5% coverage; inkjet yields (ISO/IEC 24711) use the ISO/IEC 24712 suite of mixed test pages instead. When a dark slate slide background (RGB(15, 23, 42)) is converted directly to grayscale using ITU-R BT.601 luminance (Y = 0.299 * R + 0.587 * G + 0.114 * B), it maps to Y = 22.8 on a 0–255 scale—instructing the printer to lay down 91.1% solid black toner density across the entire sheet.
As we measure below, a single dark-mode handout slide with 75.7% total page coverage consumes the equivalent of 15.1 standard 5%-coverage pages of black toner. Coverage that heavy can also curl thin copy paper and let light text inside dark banners fill in due to halftone dot gain.
3. Isoluminant Chart Colors Collapsing Into Identical Gray Bars
Because standard grayscale conversion weights green (58.7%) much more heavily than red (29.9%) or blue (11.4%), distinct categorical colors in bar charts can share almost exactly the same luminance:
- Crimson Red
RGB(214, 40, 40)→0.299(214) + 0.587(40) + 0.114(40) = 92.0 - Forest Teal
RGB(18, 130, 90)→0.299(18) + 0.587(130) + 0.114(90) = 92.0 - Royal Blue
RGB(45, 96, 195)→0.299(45) + 0.587(96) + 0.114(195) = 92.0
When printed in grayscale, all three chart series render as indistinguishable Y = 92 mid-gray bars (63.9% black ink density).
Testing Grayscale Conversion Methods on a 3-Page Color Deck
To benchmark how different grayscale conversion tools affect color operators, file size, and actual ISO/IEC 19752 toner consumption, let’s generate a deterministic 3-page US Letter (612 x 792 pt) test document (quarterly_deck_color.pdf).
On both macOS (Homebrew Python) and Debian/Ubuntu Linux, PEP 668 blocks global pip installs by default, so create a local virtual environment with pikepdf (built on C++ libqpdf), pymupdf, and Pillow:
python3 -m venv .venv
.venv/bin/pip install pikepdf pymupdf pillow
Save and run build_test_deck.py to generate the 3-page test PDF containing:
- Page 1: An executive report with 4-channel
/DeviceCMYKRich Black headings (0.75 0.68 0.67 0.90 k), CMYK body copy (0.70 0.65 0.64 0.88 k), an RGB navy header bar, a dark blue callout banner (0.12 0.32 0.65 rg), and a blue hyperlink. - Page 2: A dark-mode engineering deployment handout (
0.059 0.090 0.165 rgbackground and0.118 0.161 0.231 rgcode container) with syntax-highlighted terminal output. - Page 3: A
2400 x 1650px (about320 DPIas placed on the page) 24-bit/DeviceRGBraster graphic containing a continuous-tone equipment banner and three isoluminant (Y = 92) chart bars.
import io
import os
import pymupdf
from PIL import Image, ImageDraw
doc = pymupdf.open()
# --- PAGE 1: Corporate Report with CMYK Rich Black & RGB Accents ---
p1 = doc.new_page(width=612, height=792)
shape1 = p1.new_shape()
shape1.draw_rect(pymupdf.Rect(36, 36, 576, 86))
shape1.finish(fill=(0.08, 0.22, 0.48), color=None)
shape1.draw_rect(pymupdf.Rect(36, 220, 576, 310))
shape1.finish(fill=(0.12, 0.32, 0.65), color=(0.05, 0.18, 0.42), width=1.5)
shape1.commit()
p1.insert_text((50, 67), "Q3 INFRASTRUCTURE COST & PRINT AUDIT REPORT", fontsize=16, fontname="helv", color=(1, 1, 1))
p1.insert_text((36, 120), "1. Executive Summary (Rich Black CMYK Heading)", fontsize=14, fontname="helv", color=(0.75, 0.68, 0.67, 0.90))
body_lines = [
"Office multifunction printers (MFDs) bill pages based on internal PDF color space operators.",
"Even when body copy appears black, exporting a document with 4-channel CMYK rich black",
"(0.75 0.68 0.67 0.90 k) or RGB hyperlinks forces the RIP to engage Cyan, Magenta, and Yellow",
"imaging drums. Converting the PDF stream to /DeviceGray (g/G) usually limits output to K toner.",
]
y_pos = 145
for line in body_lines:
p1.insert_text((36, y_pos), line, fontsize=10.5, fontname="helv", color=(0.70, 0.65, 0.64, 0.88))
y_pos += 16
p1.insert_text((50, 248), "CRITICAL NOTICE: Dark Callout Boxes Drain Black Toner in Naive Grayscale", fontsize=12, fontname="helv", color=(1, 1, 1))
p1.insert_text((50, 270), "When a dark blue banner (RGB 0.12, 0.32, 0.65) is converted to grayscale without", fontsize=10, fontname="helv", color=(0.95, 0.97, 1.0))
p1.insert_text((50, 286), "background lightening, it prints at 70% solid black toner density across 48,600 sq pts.", fontsize=10, fontname="helv", color=(0.95, 0.97, 1.0))
p1.insert_text((36, 345), "Reference Specification: ISO 32000-1 Section 8.6.4 (Device Color Spaces) - https://example.com/spec", fontsize=10, fontname="helv", color=(0.04, 0.36, 0.82))
# --- PAGE 2: Dark-Mode Architecture Slide / Code Handout ("The Toner Sponge") ---
p2 = doc.new_page(width=612, height=792)
shape2 = p2.new_shape()
shape2.draw_rect(pymupdf.Rect(24, 24, 588, 768))
shape2.finish(fill=(0.059, 0.090, 0.165), color=(0.18, 0.24, 0.35), width=1.0)
shape2.draw_rect(pymupdf.Rect(48, 140, 564, 480))
shape2.finish(fill=(0.118, 0.161, 0.231), color=(0.28, 0.35, 0.48), width=1.0)
shape2.commit()
p2.insert_text((48, 72), "PRODUCTION DEPLOYMENT PIPELINE (DARK-MODE HANDOUT)", fontsize=16, fontname="helv", color=(0.95, 0.96, 0.98))
p2.insert_text((48, 100), "Printing dark-themed slide decks or IDE snippets directly in grayscale wastes 15x more toner.", fontsize=10.5, fontname="helv", color=(0.58, 0.72, 0.95))
code_snippet = [
"> kubectl get pods -n production -l app=payment-gateway",
"NAME READY STATUS RESTARTS AGE",
"payment-gateway-6d8f9b7c4-2x9k1 2/2 Running 0 14d",
"payment-gateway-6d8f9b7c4-8m4p2 2/2 Running 0 14d",
"",
"# Worker memory limit verification",
"resources:",
" limits:",
" memory: \"2048Mi\"",
" cpu: \"1000m\"",
]
cy = 175
for cline in code_snippet:
col = (0.30, 0.85, 0.55) if cline.startswith(">") else ((0.55, 0.65, 0.78) if cline.startswith("#") else (0.92, 0.94, 0.97))
p2.insert_text((68, cy), cline, fontsize=10.5, fontname="cour", color=col)
cy += 24
# --- PAGE 3: Embedded 24-Bit RGB Raster Chart & Hardware Photo ---
p3 = doc.new_page(width=612, height=792)
p3.insert_text((36, 56), "3. Regional Server Load & Color Raster Appendix", fontsize=14, fontname="helv", color=(0.10, 0.15, 0.25))
p3.insert_text((36, 78), "Embedded 24-bit /DeviceRGB raster image (2400x1650 px) with isoluminant color bars:", fontsize=10.5, fontname="helv", color=(0.20, 0.22, 0.26))
w, h = 2400, 1650
raw_rgb = bytearray(w * h * 3)
for y in range(750):
row_off = y * w * 3
for x in range(w):
idx = row_off + x * 3
v1 = ((x * 37 + y * 73) ^ (x * y)) & 63
raw_rgb[idx] = min(255, max(0, int(35 + 165 * (x / w) + v1)))
raw_rgb[idx+1] = min(255, max(0, int(45 + 135 * (y / 750) + (v1 ^ 29))))
raw_rgb[idx+2] = min(255, max(0, int(195 - 125 * (x / w) + (v1 >> 1))))
for y in range(750, h):
row_off = y * w * 3
for x in range(w):
idx = row_off + x * 3
raw_rgb[idx], raw_rgb[idx+1], raw_rgb[idx+2] = 248, 249, 252
img = Image.frombytes("RGB", (w, h), bytes(raw_rgb))
draw = ImageDraw.Draw(img)
draw.rectangle([270, 820, 720, 1500], fill=(214, 40, 40))
draw.rectangle([975, 820, 1425, 1500], fill=(18, 130, 90))
draw.rectangle([1680, 820, 2130, 1500], fill=(45, 96, 195))
img_buf = io.BytesIO()
img.save(img_buf, format="JPEG", quality=92)
p3.insert_image(pymupdf.Rect(36, 100, 576, 471), stream=img_buf.getvalue())
doc.save("quarterly_deck_color.pdf", garbage=4, deflate=True)
doc.close()
print("Saved quarterly_deck_color.pdf:", os.path.getsize("quarterly_deck_color.pdf"), "bytes")
Output:
Saved quarterly_deck_color.pdf: 974148 bytes
Why macOS Preview’s Built-In “Gray Tone” Quartz Filter Fails the Print Test
A widespread recommendation for macOS users is to open a PDF in Preview, choose File → Export, and select the Gray Tone Quartz Filter (/System/Library/Filters/Gray Tone.qfilter).
To apply the same filter from the command line, save this as qfilter.swift. It uses PDFKit’s write(to:withOptions:) with the QuartzFilter write option, which is not formally documented by Apple but is what Preview’s export uses:
import Foundation
import Quartz
let args = CommandLine.arguments
let doc = PDFDocument(url: URL(fileURLWithPath: args[1]))!
let filter = QuartzFilter(url: URL(fileURLWithPath: "/System/Library/Filters/Gray Tone.qfilter"))!
let ok = doc.write(to: URL(fileURLWithPath: args[2]),
withOptions: [PDFDocumentWriteOption(rawValue: "QuartzFilter"): filter])
print(ok ? "Saved \(args[2])" : "Write failed")
Run swift qfilter.swift quarterly_deck_color.pdf quarterly_deck_mac_qfilter.pdf (we used macOS 26.5; Quartz stamps the write time into the file, so the byte count can differ by a byte or two between runs, and other macOS versions may differ more), then inspect the result with pikepdf to see why print shops and MFDs can still flag the output as a color document:
import pikepdf
pdf = pikepdf.open("quarterly_deck_mac_qfilter.pdf")
p1 = pdf.pages[0]
for k, v in p1.Resources.ColorSpace.items():
s = v[1]
print(f"Page 1 ColorSpace {k}: /N={s.get('/N')}, /Alternate={s.get('/Alternate')}, bytes={len(s.read_raw_bytes())}")
for operands, op in pikepdf.parse_content_stream(p1):
if str(op) in ("cs", "CS", "sc", "SC", "g", "G"):
vals = [str(x) if isinstance(x, pikepdf.Name) else round(float(x), 4) for x in operands]
print(" ", vals, str(op))
Output:
Page 1 ColorSpace /Cs1: /N=3, /Alternate=/DeviceRGB, bytes=2612
Page 1 ColorSpace /Cs2: /N=4, /Alternate=/DeviceCMYK, bytes=36893
['/Cs1'] cs
[0.2309, 0.2309, 0.2309] sc
[0.3304, 0.3303, 0.3303] sc
['/Cs1'] CS
[0.1931, 0.193, 0.193] SC
[0.9966, 0.9966, 0.9966] sc
['/Cs2'] cs
[0.7558, 0.71, 0.6514, 0.8107] sc
[0.7639, 0.7118, 0.655, 0.7847] sc
['/Cs1'] cs
[0.9966, 0.9966, 0.9966] sc
[0.9666, 0.9666, 0.9666] sc
[0.3826, 0.3825, 0.3825] sc
Look at what macOS’s Gray Tone.qfilter actually writes into the PDF:
- Zero
/DeviceGray(g/G) operators exist in the file: Instead, Quartz embeds a2,612-byte 3-channel RGB ICC profile (/Cs1,/N 3) and a36,893-byte 4-channel CMYK ICC profile (/Cs2,/N 4), bloating the PDF by 19.0% (from974,148bytes to1,158,791bytes). - CMYK text becomes 4-channel composite ink: Our Rich Black heading (
0.75 0.68 0.67 0.90 k) is rewritten to[0.7558, 0.71, 0.6514, 0.8107] sc—meaning every character still commands 75.6% Cyan, 71.0% Magenta, and 65.1% Yellow alongside 81.1% Black. - Rounding leaves slightly non-neutral RGB triplets: Notice
[0.3304, 0.3303, 0.3303] sc(R != G). A print spooler or accounting tool that tests forR == G == B, or that only checks/ColorSpacedictionaries, may bill this page at the color rate.
Method 1: True DeviceGray and Ink-Saver Conversion in Python
To remove the CMY channels from a page’s rg/RG/k/K color operators and its image XObjects, and stop dark-mode slides or callout banners from draining black toner, we can parse each page’s content stream with pikepdf.parse_content_stream() and rewrite both into 1-channel /DeviceGray:
flowchart TD
A["Input PDF Page<br/>(/DeviceRGB, /DeviceCMYK, /ICCBased)"] --> B["Rewrite Embedded /Image XObjects<br/>to 1-Channel /DeviceGray (/BitsPerComponent 8)"]
B --> C["Parse /Contents AST Tokens<br/>via pikepdf.parse_content_stream()"]
C --> D{"Operator Type?"}
D -->|"k / K (4-Channel CMYK)"| E["Snap Rich Black (K >= 0.80, CMY >= 1.0)<br/>to 92% Single-Channel Black (0.08 g)<br/>(Strips C, M, Y Ink)"]
D -->|"rg / RG (3-Channel RGB)"| F{"Ink-Saver Mode Enabled<br/>& Dark Vector Fill (Y < 0.35)?"}
F -->|"No (Standard Grayscale)"| G["Map to ITU-R BT.601 Luminance<br/>(g / G in /DeviceGray)"]
F -->|"Yes (Dark Slide / Banner)"| H["Lighten Fill to 5% Gray Tint (0.95 g)<br/>& Invert Inner Light Text to Charcoal"]
Save and run convert_pdf_grayscale.py to generate both a standard /DeviceGray file (quarterly_deck_devicegray.pdf) and an ink-saving print version (quarterly_deck_inksaver.pdf):
import io
import pikepdf
def convert_pdf_to_devicegray(in_path: str, out_path: str, ink_saver: bool = False) -> None:
pdf = pikepdf.open(in_path)
for page in pdf.pages:
# 1. Convert embedded /Image XObjects in-place to 1-channel /DeviceGray
if "/XObject" in page.Resources:
for _, xo in page.Resources.XObject.items():
# Skip 1-bit /ImageMask stencils: they carry no color space of their own
if xo.get("/Subtype") == "/Image" and not xo.get("/ImageMask", False):
pim = pikepdf.PdfImage(xo)
im_gray = pim.as_pil_image().convert("L")
buf = io.BytesIO()
im_gray.save(buf, format="JPEG", quality=85)
xo.write(buf.getvalue(), filter=pikepdf.Name("/DCTDecode"))
xo["/ColorSpace"] = pikepdf.Name("/DeviceGray")
xo["/BitsPerComponent"] = 8
# The new JPEG holds final gray values, so an old /Decode array
# (e.g. [1 0 1 0 1 0 1 0] on inverted CMYK JPEGs) no longer applies
if "/Decode" in xo:
del xo["/Decode"]
# 2. Rewrite vector and text color operators (rg/RG/k/K) to /DeviceGray (g/G)
instructions = pikepdf.parse_content_stream(page)
new_inst = []
in_text_obj = False
dark_bg_active = False
for operands, op in instructions:
op_name = str(op)
if op_name == "BT":
in_text_obj = True
elif op_name == "ET":
in_text_obj = False
if op_name in ("rg", "RG"):
r, g, b = [float(x) for x in operands]
# ITU-R BT.601 luminance
y = round(0.299 * r + 0.587 * g + 0.114 * b, 4)
if ink_saver and op_name == "rg":
if not in_text_obj and y < 0.35:
# Dark vector banner or slide background -> lighten to 5% gray tint
y = 0.95
dark_bg_active = True
elif in_text_obj and dark_bg_active and y > 0.55:
# Light text inside a lightened dark container -> invert to dark charcoal
y = round(max(0.08, 1.0 - y), 4)
target_op = pikepdf.Operator("g" if op_name == "rg" else "G")
new_inst.append(([y], target_op))
elif op_name in ("k", "K"):
c, m, y_c, k_blk = [float(x) for x in operands]
# Snap 4-channel Rich Black (high K + CMY support) to a single-channel 92% black
if k_blk >= 0.80 and (c + m + y_c) >= 1.0:
gray_val = 0.08
else:
gray_val = round(max(0.0, 1.0 - min(1.0, k_blk + 0.299 * c + 0.587 * m + 0.114 * y_c)), 4)
target_op = pikepdf.Operator("g" if op_name == "k" else "G")
new_inst.append(([gray_val], target_op))
else:
new_inst.append((operands, op))
page.Contents = pdf.make_stream(pikepdf.unparse_content_stream(new_inst))
pdf.save(out_path)
pdf.close()
convert_pdf_to_devicegray("quarterly_deck_color.pdf", "quarterly_deck_devicegray.pdf", ink_saver=False)
convert_pdf_to_devicegray("quarterly_deck_color.pdf", "quarterly_deck_inksaver.pdf", ink_saver=True)
[!NOTE] This rewriter is deliberately minimal and fits our test deck, which only uses
rg/RG/k/Kcolors and one image XObject per page. It does not convert colors set withcs/CS/sc/scn(/ICCBased,/Separation,/Indexed, or pattern colors), smooth shadings (sh), content inside Form XObjects, inline images (BI … EI), or annotation appearances. PDFs exported from InDesign and other prepress tools rely heavily oncs/scn, so extend the operator handling—or audit the output with the script below—before trusting it on real-world files.
Method 2: Fast In-Browser Grayscale Conversion
When you need to quickly flatten a color PDF brochure, receipt packet, or image-heavy report into grayscale for home or office printing without setting up Python, you can use Utiliome’s PDF to Grayscale tool, which runs locally in browser memory without uploading your document.
The tool converts the PDF in three steps:
- Page Rendering at
2xScale (144 DPI): It opens the PDF withpdf.jsand renders each page onto an HTML5<canvas>at twice its point size (1224 x 1584pixels for a612 x 792pt US Letter page), showing page-by-page progress. - Per-Pixel Luminance Conversion & JPEG Encoding: It replaces every pixel’s red, green, and blue values with the same ITU-R BT.601 luminance (
Y = 0.299 * R + 0.587 * G + 0.114 * B), the same formula used throughout this guide. It then encodes the page as a JPEG at quality0.92. - PDF Reconstruction (
pdf-lib): It places each JPEG on a new page with the original page dimensions and downloadsgrayscale_<filename>.pdf.
Understanding what the browser tool does helps you pick the right method for your print job:
- File size depends on the source: PDFs dominated by high-resolution color photos or scans often shrink, because those rasters are resampled to
144 DPIand stored as near-neutral JPEGs. A text-only or vector PDF usually grows instead, because each page becomes a full-page image. - Flattens vector text (selectable characters drop to
0): Because every page is rendered to a bitmap canvas, selectable text is rasterized. If you need searchable text on a scanned document after flattening, run OCR PDF, or use thepikepdfscript above to preserve native vector fonts. (For court e-filing rules and1-bitCCITT Group 4fax encoding, see our separate guide on grayscale vs monochrome PDFs for legal submission.) - Output is tagged
/DeviceRGB, not/DeviceGray: Browsers encode canvas JPEGs with 3 color components, so each page image is embedded as/ColorSpace /DeviceRGB. The pixels are neutral (R = G = Bbefore JPEG compression), so they print gray on most home and office drivers, especially with “Black Ink Only” checked. Whether a printer or MFD meters a neutral RGB page as monochrome depends on the device. If your print-accounting system flags any/DeviceRGBcontent, or you need a true 1-channel file, use thepikepdf/DeviceGrayrewriter above. - No manual contrast sliders or dark-mode inversion: The tool converts one PDF at a time automatically, so dark-themed slides remain dark gray unless you lighten them beforehand with the
ink_saver=Truescript.
Benchmarking Toner Coverage, Color Channels, and File Size Across 4 Outputs
We ran an automated audit script (audit_all.py) across the original and the three converted PDFs produced above to measure selectable text characters, remaining non-gray color operators, and per-page ink/toner coverage (%) (mean((255 - Y) / 255) * 100 at 150 DPI), along with the ISO/IEC 19752 5%-coverage page equivalent (how many standard text pages’ worth of black toner the 3-page document consumes):
import os
import pikepdf
import pymupdf
def audit_pdf(filename: str) -> None:
size_b = os.path.getsize(filename)
doc = pymupdf.open(filename)
pk = pikepdf.open(filename)
total_chars = 0
non_gray_ops = 0
gray_ops = 0
non_gray_imgs = 0
gray_imgs = 0
page_coverages = []
for idx, page in enumerate(doc):
total_chars += len(page.get_text().strip())
pix = page.get_pixmap(dpi=150, colorspace=pymupdf.csGRAY)
samples = pix.samples
mean_ink = (sum(255 - b for b in samples) / (len(samples) * 255.0)) * 100.0
page_coverages.append(mean_ink)
pk_page = pk.pages[idx]
for operands, op in pikepdf.parse_content_stream(pk_page):
s_op = str(op)
if s_op in ("rg", "RG", "k", "K", "sc", "SC", "scn", "SCN"):
non_gray_ops += 1
elif s_op in ("g", "G"):
gray_ops += 1
if "/XObject" in pk_page.Resources:
for _, xo in pk_page.Resources.XObject.items():
if xo.get("/Subtype") == "/Image":
if str(xo.get("/ColorSpace")) == "/DeviceGray":
gray_imgs += 1
else:
non_gray_imgs += 1
avg_cov = sum(page_coverages) / len(page_coverages)
iso_pages = sum(c / 5.0 for c in page_coverages)
print(f"=== {filename} ({size_b:,d} bytes) ===")
print(f" Selectable text chars : {total_chars}")
print(f" Color/ICC ops vs Gray : non_gray_ops={non_gray_ops}, gray_ops={gray_ops}")
print(f" Image XObjects : non_gray_imgs={non_gray_imgs}, gray_imgs={gray_imgs}")
print(f" Page Ink Coverage (%) : P1={page_coverages[0]:.2f}%, P2={page_coverages[1]:.2f}%, P3={page_coverages[2]:.2f}% (Avg={avg_cov:.2f}%)")
print(f" ISO/IEC 19752 5% Equiv: {iso_pages:.2f} standard pages of toner for 3 sheets")
for f in [
"quarterly_deck_color.pdf",
"quarterly_deck_mac_qfilter.pdf",
"quarterly_deck_devicegray.pdf",
"quarterly_deck_inksaver.pdf",
]:
audit_pdf(f)
Output:
=== quarterly_deck_color.pdf (974,148 bytes) ===
Selectable text chars : 1428
Color/ICC ops vs Gray : non_gray_ops=53, gray_ops=0
Image XObjects : non_gray_imgs=1, gray_imgs=0
Page Ink Coverage (%) : P1=11.79%, P2=75.73%, P3=13.98% (Avg=33.83%)
ISO/IEC 19752 5% Equiv: 20.30 standard pages of toner for 3 sheets
=== quarterly_deck_mac_qfilter.pdf (1,158,791 bytes) ===
Selectable text chars : 1428
Color/ICC ops vs Gray : non_gray_ops=21, gray_ops=0
Image XObjects : non_gray_imgs=1, gray_imgs=0
Page Ink Coverage (%) : P1=11.71%, P2=75.58%, P3=13.61% (Avg=33.63%)
ISO/IEC 19752 5% Equiv: 20.18 standard pages of toner for 3 sheets
=== quarterly_deck_devicegray.pdf (739,461 bytes) ===
Selectable text chars : 1428
Color/ICC ops vs Gray : non_gray_ops=0, gray_ops=53
Image XObjects : non_gray_imgs=0, gray_imgs=1
Page Ink Coverage (%) : P1=12.13%, P2=76.07%, P3=14.84% (Avg=34.35%)
ISO/IEC 19752 5% Equiv: 20.61 standard pages of toner for 3 sheets
=== quarterly_deck_inksaver.pdf (739,446 bytes) ===
Selectable text chars : 1428
Color/ICC ops vs Gray : non_gray_ops=0, gray_ops=53
Image XObjects : non_gray_imgs=0, gray_imgs=1
Page Ink Coverage (%) : P1=2.84%, P2=5.78%, P3=14.84% (Avg=7.82%)
ISO/IEC 19752 5% Equiv: 4.69 standard pages of toner for 3 sheets
| Conversion Workflow | Output Size | Size Change | Vector Text (1,428 chars) | True /DeviceGray | Page 2 Dark-Slide Coverage | Total Toner Used (5% Page Equiv.) |
|---|---|---|---|---|---|---|
| Original 24-Bit Color PDF | 974,148 B | Baseline | Preserved (1,428) | No (53 RGB/CMYK ops) | 75.73% | 20.30 pages (Uses C+M+Y+K) |
macOS Preview Gray Tone.qfilter | 1,158,791 B | +19.0% | Preserved (1,428) | No (21 ICC /N 3 & /N 4 ops) | 75.58% | 20.18 pages (CMYK objects still carry C+M+Y) |
Python /DeviceGray (ink_saver=False) | 739,461 B | -24.1% | Preserved (1,428) | Yes (53 g/G ops + /DeviceGray img) | 76.07% | 20.61 pages (gray only) |
Python /DeviceGray (ink_saver=True) | 739,446 B | -24.1% | Preserved (1,428) | Yes (53 g/G ops + /DeviceGray img) | 5.78% | 4.69 pages |
We left the browser PDF to Grayscale tool out of this audit because its output depends on each browser’s JPEG encoder. Structurally, its result has 0 selectable characters and stores every page as a neutral-gray /DeviceRGB JPEG; because it does not repaint dark backgrounds, dark slides still print as near-solid black.
The 76.9% toner reduction in the last row (20.30 → 4.69 page equivalents) belongs to this one test deck, and most of it comes from repainting the full-bleed dark slide on Page 2 to a light tint—which changes how the slide looks. In our test, plain grayscale conversion left total black toner about where it started (20.18–20.61 page equivalents).
If your print job also contains accidental blank back pages from duplex scanning or oversized image streams that slow down your printer’s network spooler, you can clean up empty sheets using our guide on how to detect and delete blank pages from scanned PDFs or shrink heavy embedded graphics further with Compress PDF.