Try opening a 20-turn architecture or debugging conversation in ChatGPT, Claude, Copilot, or DeepSeek right now and pressing Cmd+P (Ctrl+P) to save a PDF reference for your team wiki or tablet.

Three things almost always go catastrophically wrong:

  1. Only Page 1 Prints (The 100vh Flex Scroll Trap): Everything above or below the current viewport disappears because modern single-page chat apps lock html, body to height: 100vh; overflow: hidden and scroll an inner flex: 1; overflow-y: auto container. Browser print engines paginate the root document viewport—not nested scrollboxes!
  2. Code Blocks & Tables Get Sliced Horizontally: <pre><code> blocks styled with overflow-x: auto; white-space: pre clip the right 40% of your SQL queries or TypeScript types, and page breaks slice single lines of code vertically in half.
  3. Dark Mode Torches Printer Toner: Terminal dark slate backgrounds (#0f172a), sticky composer drawers, sidebar thread histories, and low-contrast #94a3b8 text produce muddy, ink-soaked pages.

Many existing "ChatGPT to PDF" tools work around this by rasterizing the DOM onto an HTML5 <canvas> (html2canvas) and wrapping giant PNG screenshots inside a PDF container—resulting in 25 MB files where you cannot Cmd+F search or copy-paste a single line of code, or worse, requiring terrifying <all_urls> "Read and change all your data on all websites" permissions.

We built and open-sourced CleanPrint & AI-Chat to PDF Studio (live on Mozilla Firefox Add-ons and unpacked for Chrome/Edge/Brave) to produce true 80 KB selectable vector PDFs in <50ms locally. Here is how the DOM purification and @media print architecture works under the hood.

1. Escaping the 100vh Scroll Container Trap Non-Destructively

If you try to mutate ChatGPT or Claude's live React/Next.js Fiber tree in-place—stripping overflow: hidden and deleting sidebar nodes—two bad things happen: CSS specificity wars with Tailwind utility classes break layout unpredictably, and the moment React reconciles state, your mutated DOM crashes with hydration/removeChild errors when the user returns to their chat.

Instead, CleanPrint treats the live SPA DOM as read-only input, extracts a clean semantic AST, mounts an isolated sibling root (#cleanprint-studio-root) directly under document.body, and hides sibling app shells purely via a reversible root class:

html.cleanprint-studio-active, html.cleanprint-studio-active body { background: #e2e8f0 !important; height: auto !important; overflow: visible !important; margin: 0 !important; padding: 0 !important; } html.cleanprint-studio-active body > *:not(#cleanprint-studio-root) { display: none !important; } 

Why is body > *:not(#cleanprint-studio-root) { display: none !important; } so effective?

  • Every nested 100vh React wrapper (#__next, main, sticky navbars, composer inputs) stays completely intact in memory—preserving scroll position, React event listeners, and streaming state.
  • #cleanprint-studio-root sits in normal document block flow with height: auto, allowing Blink, Gecko, and WebKit's native print pagination engine to lay out 1 to 100+ vector pages effortlessly.
  • Clicking Restore Original Web View simply removes html.cleanprint-studio-active and unmounts #cleanprint-studio-root, restoring window.scrollTo(0, savedScrollY) with zero page reload.

2. Multi-Platform AI Turn Extraction + Density Fallback

Different LLM interfaces mark up conversation turns differently, and those selectors evolve. In src/purifier.js, our parser cascades through three tiers before falling back to an article link-density scorer (for GitHub READMEs, MDN docs, and engineering blogs):

// Tier 1: OpenAI / ChatGPT semantic author role attributes var roleNodes = doc.querySelectorAll('[data-message-author-role]'); // Tier 2: Claude, Copilot & Gemini custom elements / test IDs var combinedTurnNodes = doc.querySelectorAll( '[data-testid="user-message"], .font-claude-message, ' + '[data-content="user-message"], [data-content="ai-message"], ' + 'user-query, model-response, .copilot-chat-message' ); // Tier 3: Generic turn wrappers & link-density article root fallback 

Inside each turn, a recursive walker normalizes headings (h1–h6), inline formatting runs (code, strong, em, resolved a[href]), Markdown tables, blockquotes, lists, and fenced <pre><code> blocks—automatically detecting the programming language either from language-* classes or adjacent header pills (sql, python, typescript) while ignoring UI buttons like "Copy" or "Edit".

3. Passing Mozilla AMO Security Review: Zero innerHTML & Least-Privilege MV3

If you have ever submitted an extension to Mozilla Add-ons (AMO), you know their automated linter and human security reviewers strictly flag any assignment to .innerHTML, insertAdjacentHTML, or outerHTML inside privileged scripts due to DOM XSS risks when parsing untrusted web pages.

CleanPrint contains zero .innerHTML calls across the entire codebase (enforced automatically by our build script before running syntax checks in JavaScriptCore). Every purified card, table cell, inline code badge, and multi-tab Table of Contents entry is constructed through a tiny 25-line hyperscript helper using document.createElement and textContent:

function el(tag, attrs, children) { var node = document.createElement(tag); if (attrs) { Object.keys(attrs).forEach(function (k) { if (k === 'className') node.className = attrs[k]; else if (k === 'textContent') node.textContent = attrs[k]; else if (k.indexOf('on') === 0 && typeof attrs[k] === 'function') { node.addEventListener(k.slice(2).toLowerCase(), attrs[k]); } else if (attrs[k] != null) { node.setAttribute(k, String(attrs[k])); } }); } (children || []).forEach(function (ch) { if (ch == null) return; node.appendChild(typeof ch === 'string' ? document.createTextNode(ch) : ch); }); return node; } 

Equally important is Manifest V3 permission hygiene. CleanPrint requests strictly:

"permissions": ["activeTab", "scripting", "storage"] 

No <all_urls>. No persistent content scripts slowing down every page load. purifier.js is injected strictly on-demand via scripting.executeScript when—and only when—you click the toolbar icon. For cross-browser compatibility between Chromium (service_worker) and Firefox MV3 (background.scripts + browser_specific_settings.gecko with data_collection_permissions.required: ["none"]), we maintain clean target manifests sharing 100% of the runtime JS.

4. Print-Media CSS That Never Cuts Off Code Blocks or Wastes Toner

To ensure exported PDFs read like typeset technical handouts rather than broken web dumps, our print stylesheet combines four rules:

/* 1. Wrap long code lines cleanly instead of clipping horizontally */ #cleanprint-studio-root .cp-pre { white-space: pre-wrap; word-break: break-word; overflow-x: visible; line-height: 1.5; } /* 2. Keep code blocks, tables, figures & headings anchored across page breaks */ #cleanprint-studio-root .cp-heading, #cleanprint-studio-root .cp-qa-header { break-after: avoid; page-break-after: avoid; } #cleanprint-studio-root .cp-avoid-break, #cleanprint-studio-root pre, #cleanprint-studio-root table { break-inside: avoid; page-break-inside: avoid; } /* 3. Eco Low-Ink Mode: strip dark terminal fills into crisp monochrome hairlines */ #cleanprint-studio-root.cp-low-ink .cp-code-block { background: #ffffff !important; color: #0f172a !important; border: 1.25px solid #334155 !important; } 

Toggling Eco Low-Ink Mode replaces solid #1e293b code backgrounds and tinted prompt cards with pure #ffffff paper fills and sharp #334155 borders—cutting black toner/ink usage by roughly 65%–75% on code-heavy LLM threads while boosting laser-print legibility.

Install or Inspect the Source (MIT Licensed)

Happy printing—and may your <pre><code> blocks never get chopped at column 80 again!