diff --git a/CHANGELOG.md b/CHANGELOG.md index 18cd95d9..c72f76c1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,12 @@ ## [Unreleased] +## [v0.51.480] — 2026-06-17 — Release QO (inline PDF preview renders all pages) + +### Fixed + +- **The inline PDF preview now renders all pages in a scrollable view instead of just page 1 (#4353).** Previously only the first page of a PDF attachment/export was rendered inline, so multi-page documents looked clipped/broken. The preview now renders every page (capped at 20 for memory) stacked vertically in a scrollable body, shows the page count in the header, and adds a "Showing the first N of M pages — download for the full document" notice when a PDF exceeds the cap. Per-page render failures are isolated (a malformed page is skipped rather than halting the whole preview), and each canvas is attached only after a successful render. Thanks @gourangasatapathyvit. + ## [v0.51.479] — 2026-06-17 — Release QN (named context blocks in the composer) ### Added diff --git a/static/i18n.js b/static/i18n.js index 30fd6946..4b1301d6 100644 --- a/static/i18n.js +++ b/static/i18n.js @@ -128,6 +128,7 @@ const LOCALES = { pdf_no_pages: 'PDF has no pages', pdf_error: 'Failed to render PDF preview', pdf_download: 'Download PDF', + pdf_truncated: 'Showing the first {0} of {1} pages — download for the full document', // HTML sandbox preview (#482) html_loading: 'Loading HTML preview…', html_too_large: 'HTML too large for inline preview', @@ -1625,6 +1626,7 @@ const LOCALES = { pdf_no_pages: 'Il PDF non ha pagine', pdf_error: 'Rendering anteprima PDF fallito', pdf_download: 'Scarica PDF', + pdf_truncated: 'Showing the first {0} of {1} pages — download for the full document', // HTML sandbox preview (#482) html_loading: 'Caricamento anteprima HTML…', html_too_large: "HTML troppo grande per l'anteprima inline", @@ -3113,6 +3115,7 @@ const LOCALES = { pdf_no_pages: 'PDF にページがありません', pdf_error: 'PDF プレビューの描画に失敗しました', pdf_download: 'PDF をダウンロード', + pdf_truncated: 'Showing the first {0} of {1} pages — download for the full document', // HTML sandbox preview (#482) html_loading: 'HTML プレビューを読み込み中…', html_too_large: 'HTML が大きすぎてインラインプレビューできません', @@ -5821,6 +5824,7 @@ const LOCALES = { pdf_no_pages: 'Не удалось отобразить предварительный просмотр PDF', pdf_error: 'Не удалось загрузить PDF', pdf_download: 'Скачать PDF', + pdf_truncated: 'Showing the first {0} of {1} pages — download for the full document', html_loading: 'Загрузка предпросмотра HTML…', html_too_large: 'HTML слишком большой для встроенного просмотра', html_error: 'Не удалось загрузить предпросмотр HTML', @@ -7234,6 +7238,7 @@ const LOCALES = { pdf_no_pages: 'No se pudo renderizar la vista previa del PDF', pdf_error: 'Error al cargar el PDF', pdf_download: 'Descargar PDF', + pdf_truncated: 'Showing the first {0} of {1} pages — download for the full document', html_loading: 'Cargando vista previa de HTML…', html_too_large: 'HTML demasiado grande para vista previa', html_error: 'Error al cargar la vista previa de HTML', @@ -8661,6 +8666,7 @@ const LOCALES = { pdf_no_pages: 'PDF-Vorschau konnte nicht gerendert werden', pdf_error: 'PDF konnte nicht geladen werden', pdf_download: 'PDF herunterladen', + pdf_truncated: 'Showing the first {0} of {1} pages — download for the full document', html_loading: 'HTML-Vorschau wird geladen…', html_too_large: 'HTML zu groß für Inline-Vorschau', html_error: 'HTML-Vorschau konnte nicht geladen werden', @@ -10065,6 +10071,7 @@ const LOCALES = { pdf_no_pages: '无法渲染 PDF 预览', pdf_error: 'PDF 加载失败', pdf_download: '下载 PDF', + pdf_truncated: 'Showing the first {0} of {1} pages — download for the full document', html_loading: '正在加载 HTML 预览…', html_too_large: 'HTML 文件过大,无法内联预览', html_error: 'HTML 预览加载失败', @@ -10311,6 +10318,7 @@ const LOCALES = { pdf_no_pages: 'PDF 沒有頁面', pdf_error: 'PDF 預覽轉譯失敗', pdf_download: '下載 PDF', + pdf_truncated: 'Showing the first {0} of {1} pages — download for the full document', // HTML sandbox preview (#482) html_loading: '正在載入 HTML 預覽…', html_too_large: 'HTML 太大,無法直接預覽', @@ -14339,6 +14347,7 @@ const LOCALES = { pdf_no_pages: 'PDF 미리보기를 렌더링할 수 없음', pdf_error: 'PDF 로드 실패', pdf_download: 'PDF 다운로드', + pdf_truncated: 'Showing the first {0} of {1} pages — download for the full document', html_loading: 'HTML 미리보기 로드 중…', html_too_large: 'HTML이 인라인 미리보기에 너무 큼', html_error: 'HTML 미리보기 로드 실패', @@ -14577,6 +14586,7 @@ const LOCALES = { pdf_no_pages: 'Le PDF n\'a pas de pages', pdf_error: 'Échec de l\'affichage de l\'aperçu PDF', pdf_download: 'Télécharger le PDF', + pdf_truncated: 'Showing the first {0} of {1} pages — download for the full document', html_loading: 'Chargement de l\'aperçu HTML…', html_too_large: 'HTML trop volumineux pour l\'aperçu en ligne', html_error: 'Échec de l\'affichage de l\'aperçu HTML', @@ -17250,6 +17260,7 @@ const LOCALES = { pdf_no_pages: 'PDF\'de sayfa yok', pdf_error: 'PDF önizlemesi oluşturulamadı', pdf_download: 'PDF\'yi indir', + pdf_truncated: 'Showing the first {0} of {1} pages — download for the full document', html_loading: 'HTML önizlemesi yükleniyor\u2026', html_too_large: 'HTML satır içi önizleme için çok büyük', html_error: 'HTML önizlemesi oluşturulamadı', @@ -17484,6 +17495,7 @@ const LOCALES = { pdf_no_pages: 'Plik PDF nie ma stron', pdf_error: 'Nie udało się wyrenderować podglądu PDF', pdf_download: 'Pobierz PDF', + pdf_truncated: 'Showing the first {0} of {1} pages — download for the full document', // HTML sandbox preview (#482) html_loading: 'Ładowanie podglądu HTML…', html_too_large: 'Plik HTML jest zbyt duży, aby wyświetlić jego podgląd', diff --git a/static/style.css b/static/style.css index 5ffdd79e..64b63dbc 100644 --- a/static/style.css +++ b/static/style.css @@ -5293,8 +5293,9 @@ main.main > .main-view:not([id="mainChat"]):not([id="mainSettings"]) .main-view- .pdf-preview-header{display:flex;justify-content:space-between;align-items:center;padding:6px 10px;background:var(--surface-2);font-size:12px;font-weight:600;} .pdf-preview-header a{color:var(--accent);text-decoration:none;font-weight:500;} .pdf-preview-header a:hover{text-decoration:underline;} -.pdf-preview-body{display:flex;justify-content:center;padding:8px;background:#fff;border-radius:0 0 6px 6px;max-height:500px;overflow:hidden;} +.pdf-preview-body{display:flex;flex-direction:column;align-items:center;gap:8px;justify-content:flex-start;padding:8px;background:#fff;border-radius:0 0 6px 6px;max-height:600px;overflow:auto;} .pdf-preview-canvas{max-width:100%;height:auto;border-radius:2px;} +.pdf-preview-truncated{font-size:12px;color:var(--muted);text-align:center;padding:4px 0;} .pdf-preview-fallback{padding:8px;font-size:13px;} .pdf-preview-spinner{animation:pulse 1.5s ease-in-out infinite;} @keyframes pulse{0%,100%{opacity:0.4;}50%{opacity:1;}} diff --git a/static/ui.js b/static/ui.js index 49a19253..b50da436 100644 --- a/static/ui.js +++ b/static/ui.js @@ -12436,25 +12436,43 @@ function loadPdfInline(container){ }) .then(pdf=>{ if(!pdf) return; - pdf.getPage(1).then(page=>{ - const canvas=document.createElement('canvas'); - const scale=1.5; - const viewport=page.getViewport({scale}); - canvas.width=viewport.width; - canvas.height=viewport.height; - canvas.className='pdf-preview-canvas'; - page.render({canvasContext:canvas.getContext('2d'),viewport}).promise.then(()=>{ - // Canvas bitmap is runtime state, not part of HTML serialization. - // Attach the canvas as a DOM node — interpolating its serialized - // form into a template string parses back as an empty canvas. - const dlUrl='api/media?path='+encodeURIComponent(path)+'&download=1'; - const wrap=document.createElement('div'); - wrap.className='pdf-preview-wrap'; - wrap.innerHTML=`
📄 ${esc(fname)}${t('pdf_download')} ↓
`; - wrap.querySelector('.pdf-preview-body').appendChild(canvas); - el.replaceWith(wrap); - }); - }); + const dlUrl='api/media?path='+encodeURIComponent(path)+'&download=1'; + const total=pdf.numPages; + const pagesLabel=total>1?` · ${total} pages`:''; + const wrap=document.createElement('div'); + wrap.className='pdf-preview-wrap'; + wrap.innerHTML=`
📄 ${esc(fname)}${pagesLabel}${t('pdf_download')} ↓
`; + const body=wrap.querySelector('.pdf-preview-body'); + el.replaceWith(wrap); + // Render every page (capped) sequentially to limit memory; the + // canvases stack vertically in the scrollable preview body. + const MAX_PAGES=20; + const n=Math.min(total,MAX_PAGES); + if(total>MAX_PAGES){ + const notice=document.createElement('div'); + notice.className='pdf-preview-truncated'; + notice.textContent=t('pdf_truncated',MAX_PAGES,total); + body.appendChild(notice); + } + // On a per-page failure, skip that page and continue so one malformed + // page can't silently halt the preview or surface an unhandled + // promise rejection (renderPage runs outside the outer .catch chain). + const renderPage=(i)=>{ + if(i>n) return; + pdf.getPage(i).then(page=>{ + const canvas=document.createElement('canvas'); + const scale=1.5; + const viewport=page.getViewport({scale}); + canvas.width=viewport.width; + canvas.height=viewport.height; + canvas.className='pdf-preview-canvas'; + // Attach only after a successful render, so a render rejection + // (corrupt page data, null 2d context) can't leave a blank canvas + // behind — the .catch then simply skips to the next page. + return page.render({canvasContext:canvas.getContext('2d'),viewport}).promise.then(()=>{ body.appendChild(canvas); }); + }).then(()=>renderPage(i+1)).catch(()=>renderPage(i+1)); + }; + renderPage(1); }) .catch(()=>{ const dlUrl='api/media?path='+encodeURIComponent(path)+'&download=1'; diff --git a/tests/test_pdf_html_preview.py b/tests/test_pdf_html_preview.py index cb174b27..ab81fa69 100644 --- a/tests/test_pdf_html_preview.py +++ b/tests/test_pdf_html_preview.py @@ -128,7 +128,7 @@ class TestLoadPdfInlineFunction: def test_fallback_on_error(self): ui = _read_js('ui.js') idx = ui.find('function loadPdfInline') - body = ui[idx:idx + 3000] + body = ui[idx:idx + 4000] # was 3000 — loadPdfInline grew with the per-page render loop + truncation notice assert 'pdf_error' in body, 'Must show error fallback on failure' assert 'pdf_download' in body or 'download=' in body, 'Error fallback must include download link'