Merge pull request #4379 from nesquena/stage-4353

Release v0.51.480 — QO (inline PDF preview renders all pages #4353)
This commit is contained in:
nesquena-hermes 2026-06-17 14:14:18 -07:00 committed by GitHub
commit 41d51ecbcf
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 58 additions and 21 deletions

View file

@ -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

View file

@ -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',

View file

@ -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;}}

View file

@ -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=`<div class="pdf-preview-header"><span>📄 ${esc(fname)}</span><a href="${dlUrl}" download="${esc(fname)}" class="pdf-download-link">${t('pdf_download')} ↓</a></div><div class="pdf-preview-body"></div>`;
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=`<div class="pdf-preview-header"><span>📄 ${esc(fname)}${pagesLabel}</span><a href="${dlUrl}" download="${esc(fname)}" class="pdf-download-link">${t('pdf_download')} ↓</a></div><div class="pdf-preview-body"></div>`;
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';

View file

@ -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'