Last active
February 14, 2026 16:14
-
-
Save minanagehsalalma/d2b15c616721c4de05be1b4416785655 to your computer and use it in GitHub Desktop.
A tampermonkey script to export a selected ChatGPT message to proper pdf
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| // ==UserScript== | |
| // @name ChatGPT Message PDF Downloader — Arabic/RTL (no @require) | |
| // @namespace https://github.com/ | |
| // @version 10.0 | |
| // @description Export any ChatGPT message to a centered A4 PDF, with full Arabic/RTL support and dark theme. Normal click = selectable text via print; Shift+click = one-click image PDF. | |
| // @author You | |
| // @match https://chatgpt.com/* | |
| // @match https://chat.openai.com/* | |
| // @grant GM_xmlhttpRequest | |
| // @grant GM.xmlHttpRequest | |
| // @connect cdn.jsdelivr.net | |
| // @connect cdnjs.cloudflare.com | |
| // @connect unpkg.com | |
| // @connect fonts.googleapis.com | |
| // @connect fonts.gstatic.com | |
| // @run-at document-idle | |
| // @license MIT | |
| // ==/UserScript== | |
| (function () { | |
| 'use strict'; | |
| // ---------- A4 geometry ---------- | |
| const MM_TO_PX = 96 / 25.4; | |
| const A4_W_PX = Math.round(210 * MM_TO_PX); // ~794 px | |
| const A4_H_PX = Math.round(297 * MM_TO_PX); // ~1123 px | |
| const PAD_MM = 15; | |
| const PAD_PX = Math.round(PAD_MM * MM_TO_PX); | |
| // Canvas safety: | |
| // Many browsers cap canvas width/height to ~16384px. We avoid that by rendering page-by-page when needed. | |
| const SAFE_MAX_CANVAS_DIM = 16384; | |
| // ---------- CDNs (multiple fallbacks) ---------- | |
| const H2C_URLS = [ | |
| 'https://cdn.jsdelivr.net/npm/html2canvas@1.4.1/dist/html2canvas.min.js', | |
| 'https://cdnjs.cloudflare.com/ajax/libs/html2canvas/1.4.1/html2canvas.min.js', | |
| 'https://unpkg.com/html2canvas@1.4.1/dist/html2canvas.min.js' | |
| ]; | |
| const JSPDF_URLS = [ | |
| 'https://cdn.jsdelivr.net/npm/jspdf@2.5.1/dist/jspdf.umd.min.js', | |
| 'https://cdnjs.cloudflare.com/ajax/libs/jspdf/2.5.1/jspdf.umd.min.js', | |
| 'https://unpkg.com/jspdf@2.5.1/dist/jspdf.umd.min.js' | |
| ]; | |
| // ---------- small utils ---------- | |
| const containsArabic = (s) => /[\u0600-\u06FF\u0750-\u077F\u08A0-\u08FF\uFB50-\uFDFF\uFE70-\uFEFF]/.test(s || ''); | |
| const sanitizeFilename = (name) => (name || '').replace(/[\/\\?%*:|"<>]/g, '_').trim() || 'ChatGPT'; | |
| function ensureArabicFonts() { | |
| if (document.getElementById('cgptpdf-ar-fonts')) return; | |
| const link = document.createElement('link'); | |
| link.id = 'cgptpdf-ar-fonts'; | |
| link.rel = 'stylesheet'; | |
| link.href = 'https://fonts.googleapis.com/css2?family=Noto+Kufi+Arabic:wght@400;700&family=Noto+Naskh+Arabic:wght@400;700&display=swap'; | |
| document.head.appendChild(link); | |
| } | |
| async function waitForFonts(doc = document) { | |
| if (!doc.fonts) return; | |
| try { | |
| await Promise.allSettled([ | |
| doc.fonts.load('16px "Noto Naskh Arabic"'), | |
| doc.fonts.load('16px "Noto Kufi Arabic"') | |
| ]); | |
| await doc.fonts.ready; | |
| } catch {/* ignore */} | |
| } | |
| // ---------- CSP-safe text fetch (GM_xhr first, then fetch) ---------- | |
| function requestText(url) { | |
| // Tampermonkey / Violentmonkey: | |
| if (typeof GM_xmlhttpRequest === 'function') { | |
| return new Promise((resolve, reject) => { | |
| GM_xmlhttpRequest({ | |
| method: 'GET', | |
| url, | |
| responseType: 'text', | |
| anonymous: true, | |
| onload: (r) => { | |
| const status = r.status || 0; | |
| if (status >= 200 && status < 300) resolve(r.responseText || ''); | |
| else reject(new Error('HTTP ' + status)); | |
| }, | |
| onerror: () => reject(new Error('network_error')), | |
| ontimeout: () => reject(new Error('timeout')) | |
| }); | |
| }); | |
| } | |
| // Greasemonkey 4+ / some managers: | |
| if (typeof GM !== 'undefined' && GM && typeof GM.xmlHttpRequest === 'function') { | |
| return GM.xmlHttpRequest({ method: 'GET', url, responseType: 'text', anonymous: true }) | |
| .then((r) => { | |
| const status = r.status || 0; | |
| if (status >= 200 && status < 300) return r.responseText || ''; | |
| throw new Error('HTTP ' + status); | |
| }); | |
| } | |
| // Last resort: | |
| return fetch(url, { cache: 'no-store', credentials: 'omit' }) | |
| .then((res) => { | |
| if (!res.ok) throw new Error('HTTP ' + res.status); | |
| return res.text(); | |
| }); | |
| } | |
| async function fetchEval(url) { | |
| const code = await requestText(url); | |
| // Evaluate in this userscript context: | |
| (0, eval)(code + '\n//# sourceURL=' + url); | |
| } | |
| async function loadLibSequence(urls, testFn) { | |
| if (testFn()) return true; | |
| for (const url of urls) { | |
| try { | |
| await fetchEval(url); | |
| if (testFn()) return true; | |
| } catch { /* try next */ } | |
| } | |
| return !!testFn(); | |
| } | |
| async function ensureLibsOrNull() { | |
| const gotH2C = await loadLibSequence(H2C_URLS, () => | |
| (typeof html2canvas !== 'undefined') || (window && window.html2canvas) | |
| ); | |
| const gotJSPDF = await loadLibSequence(JSPDF_URLS, () => | |
| (window && ((window.jspdf && window.jspdf.jsPDF) || window.jsPDF)) | |
| ); | |
| return (gotH2C && gotJSPDF) ? { | |
| html2canvas: window.html2canvas || html2canvas, | |
| JsPDF: (window.jspdf && window.jspdf.jsPDF) || window.jsPDF | |
| } : null; | |
| } | |
| // ---------- extraction & cleaning ---------- | |
| function extractAndCleanContent(turnElement) { | |
| const messageContent = turnElement.querySelector('[data-message-author-role]') || turnElement; | |
| const clone = messageContent.cloneNode(true); | |
| clone.querySelectorAll('*').forEach(el => { | |
| const tag = el.tagName.toLowerCase(); | |
| // strip UI and scripts | |
| if (['button', 'script', 'style', 'svg'].includes(tag) || el.getAttribute('role') === 'button') { | |
| el.remove(); return; | |
| } | |
| // keep safe attrs (bidi + links + images) | |
| const allowed = new Set(['href', 'dir', 'lang', 'target', 'rel', 'src', 'alt', 'width', 'height']); | |
| [...el.attributes].forEach(a => { if (!allowed.has(a.name)) el.removeAttribute(a.name); }); | |
| if (tag === 'img') el.setAttribute('crossorigin', 'anonymous'); | |
| // code blocks: keep plain code | |
| if (tag === 'pre') { | |
| const code = el.querySelector('code'); | |
| if (code) el.textContent = code.textContent || ''; | |
| } | |
| }); | |
| // ensure correct base direction per block | |
| clone.querySelectorAll('p,li,div,blockquote,h1,h2,h3,h4,h5,h6,td,th,dd,dt').forEach(el => { | |
| if (!el.hasAttribute('dir')) el.setAttribute('dir', 'auto'); | |
| }); | |
| const leading = (clone.textContent || '').trim().slice(0, 140); | |
| if (containsArabic(leading) && !clone.hasAttribute('dir')) clone.setAttribute('dir', 'rtl'); | |
| return clone; | |
| } | |
| // ---------- host + styles ---------- | |
| function buildRenderHost(cleanContent) { | |
| // Keep host offscreen (NOT opacity:0) so ancestor opacity never affects rendering. | |
| const host = document.createElement('div'); | |
| Object.assign(host.style, { | |
| position: 'fixed', | |
| left: '-10000px', | |
| top: '0', | |
| width: A4_W_PX + 'px', | |
| zIndex: '-1', | |
| pointerEvents: 'none' | |
| }); | |
| const page = document.createElement('div'); | |
| Object.assign(page.style, { | |
| width: A4_W_PX + 'px', | |
| minHeight: A4_H_PX + 'px', | |
| background: '#343541', | |
| color: '#ececf1', | |
| display: 'block', | |
| margin: '0' | |
| }); | |
| const inner = document.createElement('div'); | |
| inner.style.padding = PAD_PX + 'px'; | |
| inner.appendChild(cleanContent); | |
| page.appendChild(inner); | |
| const style = document.createElement('style'); | |
| style.textContent = ` | |
| @page { size: A4 portrait; margin: 0; } | |
| *, *::before, *::after { box-sizing: border-box; } | |
| html, body { margin: 0 !important; padding: 0 !important; } | |
| [dir="rtl"] { direction: rtl; unicode-bidi: isolate; } | |
| [dir="ltr"] { direction: ltr; unicode-bidi: isolate; } | |
| [dir="auto"] { unicode-bidi: isolate; } | |
| :lang(ar), [dir="rtl"] { | |
| font-family: "Noto Naskh Arabic","Noto Kufi Arabic",Tahoma,Arial,sans-serif !important; | |
| font-variant-ligatures: contextual; | |
| } | |
| body, div, p, li, blockquote { | |
| font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, | |
| "Noto Naskh Arabic","Noto Kufi Arabic", Arial, Tahoma, sans-serif; | |
| line-height: 1.6; | |
| color: #ececf1; | |
| overflow-wrap: anywhere; | |
| word-break: break-word; | |
| } | |
| hr { border: 0; border-top: 1px solid #565869; margin: 16px 0; } | |
| h1,h2,h3,h4,h5,h6 { color: #fff; page-break-after: avoid !important; margin: 1em 0 .6em; } | |
| p,ul,ol,pre,blockquote,table { margin: .85em 0; } | |
| pre { | |
| background:#000 !important; | |
| color:#d1d5db !important; | |
| border:1px solid #565869 !important; | |
| border-radius:6px !important; | |
| padding:12px !important; | |
| font-family:"Cascadia Mono","SFMono-Regular",Consolas,"Liberation Mono",monospace !important; | |
| font-size:11px !important; | |
| line-height:1.4 !important; | |
| white-space:pre-wrap !important; | |
| overflow-wrap:anywhere !important; | |
| direction:ltr !important; | |
| unicode-bidi:isolate !important; | |
| } | |
| p code, li code, span code, code:not(pre code) { | |
| font-family:"Cascadia Mono","SFMono-Regular",Consolas,"Liberation Mono",monospace !important; | |
| background:#1e1e1e; | |
| padding:2px 4px; | |
| border-radius:4px; | |
| font-size:.9em; | |
| display:inline-block; | |
| white-space:pre; | |
| vertical-align:baseline; | |
| direction:ltr; | |
| unicode-bidi:isolate; | |
| } | |
| a { | |
| color:#ececf1 !important; | |
| text-decoration:none !important; | |
| background:#40414f; | |
| border:1px solid #565869; | |
| border-radius:6px; | |
| padding:3px 8px; | |
| font-size:13px; | |
| margin:0 2px; | |
| display:inline; | |
| white-space:nowrap; | |
| vertical-align:baseline; | |
| } | |
| ul,ol { padding-inline-start:20px; } | |
| li { margin-bottom:.5em; } | |
| p:empty, div:empty, span:empty { display:none; } | |
| `; | |
| host.appendChild(style); | |
| host.appendChild(page); | |
| return { host, page }; | |
| } | |
| // ---------- HTML2CANVAS + jsPDF path (automatic download; IMAGE-BASED = NO SELECTABLE TEXT) ---------- | |
| async function renderToPdfAuto(turnElement) { | |
| const libs = await ensureLibsOrNull(); | |
| if (!libs) throw new Error('libs_missing'); | |
| const { html2canvas, JsPDF } = libs; | |
| ensureArabicFonts(); | |
| await waitForFonts(document); | |
| const cleanContent = extractAndCleanContent(turnElement); | |
| const { host, page } = buildRenderHost(cleanContent); | |
| document.body.appendChild(host); | |
| const jsPDFCtor = (window.jspdf && window.jspdf.jsPDF) || JsPDF; | |
| const pdf = new jsPDFCtor({ unit: 'mm', format: 'a4', orientation: 'portrait' }); | |
| const pageWidthMm = pdf.internal.pageSize.getWidth(); // 210 | |
| const pageHeightMm = pdf.internal.pageSize.getHeight(); // 297 | |
| const scale = Math.min(2, (window.devicePixelRatio || 1) * 1.5); | |
| // Measure content height in CSS px | |
| const contentHeightPx = Math.max(page.scrollHeight, A4_H_PX); | |
| // Predict single-canvas height at chosen scale | |
| const predictedFullCanvasH = Math.ceil(contentHeightPx * scale); | |
| // If we'd exceed safe canvas limits, render page-by-page (prevents falling back to Print) | |
| const shouldPageRender = predictedFullCanvasH > SAFE_MAX_CANVAS_DIM; | |
| const baseOpts = { | |
| scale, | |
| useCORS: true, | |
| allowTaint: false, | |
| backgroundColor: '#343541', | |
| foreignObjectRendering: true, | |
| scrollX: 0, | |
| scrollY: 0, | |
| width: A4_W_PX, | |
| windowWidth: A4_W_PX | |
| }; | |
| const renderPaged = async () => { | |
| const pages = Math.ceil(contentHeightPx / A4_H_PX); | |
| // Pad the page element height to a clean multiple so each capture is a full A4 frame | |
| page.style.height = (pages * A4_H_PX) + 'px'; | |
| for (let i = 0; i < pages; i++) { | |
| const y = i * A4_H_PX; | |
| const canvas = await html2canvas(page, { | |
| ...baseOpts, | |
| x: 0, | |
| y, | |
| height: A4_H_PX, | |
| windowHeight: A4_H_PX | |
| }); | |
| const imgData = canvas.toDataURL('image/jpeg', 0.96); | |
| if (i > 0) pdf.addPage(); | |
| pdf.addImage(imgData, 'JPEG', 0, 0, pageWidthMm, pageHeightMm, undefined, 'FAST'); | |
| } | |
| }; | |
| const renderSingleThenSlice = async () => { | |
| const canvas = await html2canvas(page, { | |
| ...baseOpts, | |
| x: 0, | |
| y: 0, | |
| height: contentHeightPx, | |
| windowHeight: contentHeightPx | |
| }); | |
| // Slice the big canvas into A4 pages (no drift: x=0, width=full) | |
| const pxPerMm = canvas.width / pageWidthMm; | |
| const pageHeightPxScaled = Math.round(pageHeightMm * pxPerMm); | |
| const sliceCanvas = document.createElement('canvas'); | |
| const ctx = sliceCanvas.getContext('2d'); | |
| sliceCanvas.width = canvas.width; | |
| let offset = 0; | |
| let pageIndex = 0; | |
| while (offset < canvas.height) { | |
| const sliceH = Math.min(pageHeightPxScaled, canvas.height - offset); | |
| sliceCanvas.height = sliceH; | |
| ctx.clearRect(0, 0, sliceCanvas.width, sliceCanvas.height); | |
| ctx.drawImage(canvas, 0, -offset); | |
| const imgData = sliceCanvas.toDataURL('image/jpeg', 0.96); | |
| if (pageIndex > 0) pdf.addPage(); | |
| const sliceMm = sliceH / pxPerMm; | |
| pdf.addImage(imgData, 'JPEG', 0, 0, pageWidthMm, sliceMm, undefined, 'FAST'); | |
| offset += sliceH; | |
| pageIndex++; | |
| } | |
| }; | |
| try { | |
| if (shouldPageRender) { | |
| await renderPaged(); | |
| } else { | |
| // Try the faster single-canvas path first; if it fails, fall back to paged (still no Print headers/footers). | |
| try { | |
| await renderSingleThenSlice(); | |
| } catch { | |
| await renderPaged(); | |
| } | |
| } | |
| const chatTitle = (document.title || 'ChatGPT').trim(); | |
| const firstLine = (turnElement.textContent || '').trim().split('\n')[0].slice(0, 60); | |
| const filename = sanitizeFilename(`${chatTitle} - ${firstLine}`) + '.pdf'; | |
| pdf.save(filename); | |
| } finally { | |
| host.remove(); | |
| } | |
| } | |
| // ---------- Beautiful instruction modal ---------- | |
| function showPrintInstructions() { | |
| return new Promise((resolve) => { | |
| const modal = document.createElement('div'); | |
| modal.style.cssText = ` | |
| position: fixed; | |
| top: 0; | |
| left: 0; | |
| width: 100%; | |
| height: 100%; | |
| background: rgba(0, 0, 0, 0.7); | |
| backdrop-filter: blur(4px); | |
| z-index: 10000; | |
| display: flex; | |
| align-items: center; | |
| justify-content: center; | |
| animation: fadeIn 0.2s ease-out; | |
| `; | |
| const dialog = document.createElement('div'); | |
| dialog.style.cssText = ` | |
| background: #2f2f2f; | |
| border-radius: 12px; | |
| padding: 32px; | |
| max-width: 520px; | |
| width: 90%; | |
| box-shadow: 0 20px 60px rgba(0, 0, 0, 0.5); | |
| color: #ececf1; | |
| font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; | |
| animation: slideUp 0.3s ease-out; | |
| `; | |
| dialog.innerHTML = ` | |
| <style> | |
| @keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } } | |
| @keyframes slideUp { from { transform: translateY(20px); opacity: 0; } to { transform: translateY(0); opacity: 1; } } | |
| </style> | |
| <div style="display: flex; align-items: center; gap: 12px; margin-bottom: 20px;"> | |
| <svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="#10a37f" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> | |
| <path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"></path> | |
| <polyline points="14 2 14 8 20 8"></polyline> | |
| <line x1="12" y1="18" x2="12" y2="12"></line> | |
| <line x1="9" y1="15" x2="15" y2="15"></line> | |
| </svg> | |
| <h2 style="margin: 0; font-size: 22px; font-weight: 600; color: #fff;">PDF Download Mode</h2> | |
| </div> | |
| <div style="background: #1f1f1f; border-left: 3px solid #10a37f; padding: 16px; border-radius: 8px; margin-bottom: 20px;"> | |
| <div style="font-size: 14px; font-weight: 600; color: #10a37f; margin-bottom: 8px;">✓ Selectable Text Mode (Recommended)</div> | |
| <div style="font-size: 14px; line-height: 1.6; color: #d1d5db;"> | |
| The print dialog is about to open. For best results with selectable text: | |
| </div> | |
| </div> | |
| <div style="margin-bottom: 24px;"> | |
| <div style="display: flex; gap: 12px; margin-bottom: 16px;"> | |
| <div style="flex-shrink: 0; width: 28px; height: 28px; background: #10a37f; border-radius: 50%; display: flex; align-items: center; justify-content: center; font-weight: 700; font-size: 14px; color: #000;">1</div> | |
| <div> | |
| <div style="font-size: 15px; font-weight: 600; color: #fff; margin-bottom: 4px;">Choose the right destination</div> | |
| <div style="font-size: 14px; line-height: 1.5; color: #d1d5db;"> | |
| Select <strong style="color: #10a37f;">"Save to PDF"</strong> (Chrome/Edge)<br> | |
| <span style="color: #ff6b6b;">Avoid "Microsoft Print to PDF"</span> - it may not preserve text selection | |
| </div> | |
| </div> | |
| </div> | |
| <div style="display: flex; gap: 12px; margin-bottom: 16px;"> | |
| <div style="flex-shrink: 0; width: 28px; height: 28px; background: #10a37f; border-radius: 50%; display: flex; align-items: center; justify-content: center; font-weight: 700; font-size: 14px; color: #000;">2</div> | |
| <div> | |
| <div style="font-size: 15px; font-weight: 600; color: #fff; margin-bottom: 4px;">Disable headers and footers</div> | |
| <div style="font-size: 14px; line-height: 1.5; color: #d1d5db;"> | |
| Uncheck "Headers and footers" option for a clean PDF | |
| </div> | |
| </div> | |
| </div> | |
| <div style="display: flex; gap: 12px;"> | |
| <div style="flex-shrink: 0; width: 28px; height: 28px; background: #565869; border-radius: 50%; display: flex; align-items: center; justify-content: center; font-weight: 700; font-size: 14px; color: #fff;">⚡</div> | |
| <div> | |
| <div style="font-size: 15px; font-weight: 600; color: #fff; margin-bottom: 4px;">Quick download mode</div> | |
| <div style="font-size: 14px; line-height: 1.5; color: #d1d5db;"> | |
| Hold <kbd style="background: #000; padding: 2px 6px; border-radius: 4px; font-family: monospace; font-size: 13px; border: 1px solid #565869;">Shift</kbd> when clicking for instant download<br> | |
| <span style="color: #999; font-size: 13px;">(image-based PDF, no text selection)</span> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| <div style="display: flex; justify-content: flex-end; gap: 12px;"> | |
| <button id="pdfModalClose" style=" | |
| background: #10a37f; | |
| color: #fff; | |
| border: none; | |
| padding: 10px 24px; | |
| border-radius: 6px; | |
| font-size: 14px; | |
| font-weight: 600; | |
| cursor: pointer; | |
| transition: background 0.2s; | |
| " onmouseover="this.style.background='#0d8c6a'" onmouseout="this.style.background='#10a37f'"> | |
| Got it! | |
| </button> | |
| </div> | |
| `; | |
| modal.appendChild(dialog); | |
| document.body.appendChild(modal); | |
| const closeModal = () => { | |
| modal.style.animation = 'fadeIn 0.2s ease-out reverse'; | |
| setTimeout(() => { | |
| modal.remove(); | |
| resolve(); | |
| }, 200); | |
| }; | |
| modal.addEventListener('click', (e) => { | |
| if (e.target === modal) closeModal(); | |
| }); | |
| dialog.querySelector('#pdfModalClose').addEventListener('click', closeModal); | |
| // Auto-close after 10 seconds | |
| setTimeout(closeModal, 10000); | |
| }); | |
| } | |
| // ---------- Print fallback (SELECTABLE TEXT - requires print dialog) ---------- | |
| async function printFallback(turnElement) { | |
| ensureArabicFonts(); | |
| await waitForFonts(document); | |
| const clean = extractAndCleanContent(turnElement); | |
| const html = document.createElement('html'); | |
| const head = document.createElement('head'); | |
| const body = document.createElement('body'); | |
| const style = document.createElement('style'); | |
| style.textContent = ` | |
| @page { size: A4; margin: 0; } | |
| @media print { | |
| * { -webkit-print-color-adjust: exact; print-color-adjust: exact; } | |
| } | |
| html, body { margin:0; padding:0; } | |
| body { | |
| background:#343541; | |
| color:#ececf1; | |
| padding: ${PAD_MM}mm; | |
| font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto, | |
| "Noto Naskh Arabic","Noto Kufi Arabic",Arial,Tahoma,sans-serif; | |
| line-height:1.6; | |
| overflow-wrap:anywhere; | |
| word-break:break-word; | |
| } | |
| [dir="rtl"] { direction: rtl; unicode-bidi: isolate; } | |
| [dir="ltr"] { direction: ltr; unicode-bidi: isolate; } | |
| [dir="auto"] { unicode-bidi: isolate; } | |
| :lang(ar), [dir="rtl"] { | |
| font-family: "Noto Naskh Arabic","Noto Kufi Arabic",Tahoma,Arial,sans-serif !important; | |
| font-variant-ligatures: contextual; | |
| } | |
| hr { border: 0; border-top: 1px solid #565869; margin: 16px 0; } | |
| h1,h2,h3,h4,h5,h6 { color:#fff; margin: 1em 0 .6em; } | |
| p,ul,ol,pre,blockquote,table { margin: .85em 0; } | |
| pre { | |
| background:#000!important;color:#d1d5db!important;border:1px solid #565869!important;border-radius:6px!important; | |
| padding:12px!important;font-family:"Cascadia Mono","SFMono-Regular",Consolas,"Liberation Mono",monospace!important; | |
| font-size:11px!important; line-height:1.4!important; white-space:pre-wrap!important; overflow-wrap:anywhere!important; | |
| direction:ltr!important; unicode-bidi:isolate!important; | |
| } | |
| p code, li code, span code, code:not(pre code) { | |
| font-family:"Cascadia Mono","SFMono-Regular",Consolas,"Liberation Mono",monospace!important; | |
| background:#1e1e1e; padding:2px 4px; border-radius:4px; font-size:.9em; display:inline-block; white-space:pre; | |
| vertical-align:baseline; direction:ltr; unicode-bidi:isolate; | |
| } | |
| a { | |
| color:#ececf1!important; text-decoration:none!important; background:#40414f; border:1px solid #565869; border-radius:6px; | |
| padding:3px 8px; font-size:13px; margin:0 2px; display:inline; white-space:nowrap; vertical-align:baseline; | |
| } | |
| ul,ol { padding-inline-start:20px; } li { margin-bottom:.5em; } p:empty, div:empty, span:empty { display:none; } | |
| `; | |
| head.appendChild(style); | |
| body.appendChild(clean); | |
| html.appendChild(head); | |
| html.appendChild(body); | |
| const title = (document.title || 'ChatGPT').trim(); | |
| const firstLine = (turnElement.textContent || '').trim().split('\n')[0].slice(0, 60); | |
| const iframe = document.createElement('iframe'); | |
| Object.assign(iframe.style, { position:'fixed', left:'-10000px', top:'0', width:'0', height:'0', opacity: '0' }); | |
| document.body.appendChild(iframe); | |
| const doc = iframe.contentDocument; | |
| doc.open(); | |
| doc.write('<!doctype html>' + html.outerHTML); | |
| doc.title = sanitizeFilename(`${title} - ${firstLine}`); | |
| doc.close(); | |
| const wait = (ms)=>new Promise(r=>setTimeout(r,ms)); | |
| try { | |
| if (doc.fonts) await doc.fonts.ready; | |
| const imgs = Array.from(doc.images || []); | |
| await Promise.all(imgs.map(img => img.complete ? Promise.resolve() : new Promise(res => { img.onload = img.onerror = res; }))); | |
| await wait(100); | |
| } catch {} | |
| // Show instructions modal FIRST, then open print dialog | |
| if (!window.__cgptpdf_print_hint_shown) { | |
| window.__cgptpdf_print_hint_shown = true; | |
| await showPrintInstructions(); | |
| } | |
| iframe.contentWindow.focus(); | |
| iframe.contentWindow.print(); | |
| setTimeout(() => iframe.remove(), 10000); | |
| } | |
| // ---------- UI wiring ---------- | |
| const pdfIconSvg = ` | |
| <svg width="18" height="18" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg" class="icon"> | |
| <path d="M11.5 2.5H5.5C4.95 2.5 4.5 2.95 4.5 3.5V16.5C4.5 17.05 4.95 17.5 5.5 17.5H14.5C15.05 17.5 15.5 17.05 15.5 16.5V6.5L11.5 2.5Z" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"></path> | |
| <path d="M11.5 2.5V6.5H15.5" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"></path> | |
| <path d="M7.5 12.5L10 15L12.5 12.5" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"></path> | |
| <path d="M10 15V9" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"></path> | |
| </svg> | |
| `; | |
| function addDownloadButton(turnElement) { | |
| const copyButton = turnElement.querySelector('button[data-testid*="copy"]'); | |
| if (!copyButton) return; | |
| const container = copyButton.parentElement; | |
| if (!container || container.querySelector('.download-pdf-button')) return; | |
| const btn = document.createElement('button'); | |
| btn.className = 'text-token-text-secondary hover:bg-token-bg-secondary rounded-lg download-pdf-button'; | |
| btn.title = 'Download as PDF (click = selectable text, shift+click = image PDF)'; | |
| btn.innerHTML = `<span class="touch:w-10 flex h-8 w-8 items-center justify-center">${pdfIconSvg}</span>`; | |
| // UPDATED CLICK HANDLER: Normal click = selectable text; Shift+click = image PDF | |
| btn.addEventListener('click', async (e) => { | |
| e.stopPropagation(); | |
| btn.disabled = true; | |
| const prev = btn.innerHTML; | |
| try { | |
| btn.innerHTML = '<span class="touch:w-10 flex h-8 w-8 items-center justify-center">…</span>'; | |
| // NORMAL CLICK => selectable text via print dialog (best RTL/Arabic support) | |
| // SHIFT-CLICK => one-click image PDF (current behavior; NOT selectable) | |
| if (!e.shiftKey) { | |
| await printFallback(turnElement); | |
| return; | |
| } | |
| // Shift-click path: your existing auto-download image PDF | |
| await renderToPdfAuto(turnElement); | |
| } catch (err) { | |
| // If anything fails, still fall back to print | |
| await printFallback(turnElement); | |
| } finally { | |
| btn.disabled = false; | |
| btn.innerHTML = prev; | |
| } | |
| }); | |
| copyButton.insertAdjacentElement('afterend', btn); | |
| } | |
| function processAllMessages() { | |
| document.querySelectorAll('article[data-testid^="conversation-turn-"]').forEach(addDownloadButton); | |
| } | |
| let debounceTimer; | |
| const observer = new MutationObserver(() => { | |
| clearTimeout(debounceTimer); | |
| debounceTimer = setTimeout(processAllMessages, 200); | |
| }); | |
| function initialize() { | |
| ensureArabicFonts(); | |
| processAllMessages(); | |
| const main = document.querySelector('main'); | |
| if (main) observer.observe(main, { childList: true, subtree: true }); | |
| } | |
| if (document.readyState === 'loading') { | |
| document.addEventListener('DOMContentLoaded', initialize); | |
| } else { | |
| initialize(); | |
| } | |
| })(); |
Author
minanagehsalalma
commented
Feb 14, 2026
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment