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 EncodingRaw Stream OperatorChannels Sent to Printer RIPInkjet / Laser Printhead BehaviorTypical MFD Billing (Device-Dependent)
3-Channel RGB (/DeviceRGB)0.08 0.22 0.48 rgR, 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 kC, M, Y, K (4 channels)Lays down 210% CMY ink under 90% K toner on every characterUsually 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 CMYKCan still fire C + M + Y + K despite looking gray on screenOften billed as Color Page (non-zero CMY in the CMYK objects)
True 1-Channel Grayscale (/DeviceGray)0.08 gGray (1 channel)Usually printed with Black (K) only, though some inkjet drivers still blend composite grayNormally 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 /DeviceCMYK Rich 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 rg background and 0.118 0.161 0.231 rg code container) with syntax-highlighted terminal output.
  • Page 3: A 2400 x 1650 px (about 320 DPI as placed on the page) 24-bit /DeviceRGB raster 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:

  1. Zero /DeviceGray (g/G) operators exist in the file: Instead, Quartz embeds a 2,612-byte 3-channel RGB ICC profile (/Cs1, /N 3) and a 36,893-byte 4-channel CMYK ICC profile (/Cs2, /N 4), bloating the PDF by 19.0% (from 974,148 bytes to 1,158,791 bytes).
  2. 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.
  3. 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 for R == G == B, or that only checks /ColorSpace dictionaries, 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/K colors and one image XObject per page. It does not convert colors set with cs/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 on cs/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:

  1. Page Rendering at 2x Scale (144 DPI): It opens the PDF with pdf.js and renders each page onto an HTML5 <canvas> at twice its point size (1224 x 1584 pixels for a 612 x 792 pt US Letter page), showing page-by-page progress.
  2. 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 quality 0.92.
  3. PDF Reconstruction (pdf-lib): It places each JPEG on a new page with the original page dimensions and downloads grayscale_<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 DPI and 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 the pikepdf script above to preserve native vector fonts. (For court e-filing rules and 1-bit CCITT Group 4 fax 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 = B before 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 /DeviceRGB content, or you need a true 1-channel file, use the pikepdf /DeviceGray rewriter 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=True script.

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 WorkflowOutput SizeSize ChangeVector Text (1,428 chars)True /DeviceGrayPage 2 Dark-Slide CoverageTotal Toner Used (5% Page Equiv.)
Original 24-Bit Color PDF974,148 BBaselinePreserved (1,428)No (53 RGB/CMYK ops)75.73%20.30 pages (Uses C+M+Y+K)
macOS Preview Gray Tone.qfilter1,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.