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:
commit
41d51ecbcf
5 changed files with 58 additions and 21 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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;}}
|
||||
|
|
|
|||
56
static/ui.js
56
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=`<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';
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue