diff --git a/CHANGELOG.md b/CHANGELOG.md index 2165672..bacf8fc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,10 @@ > 所以不是每个版本号都有条目。条目格式 `## <版本> — <日期>`,新条目加在最上面。 > 工程口径的完整记录见 `PROGRESS.md` / git log。 +## 0.63.3 — 2026-08-10 + +- PDF 和 PPT 预览恢复连续滚动阅读,无需反复点击上一页、下一页;页码跳转和缩放仍可使用,长文档也会按浏览位置逐页加载。 + ## 0.63.2 — 2026-08-10 - App 内的 PDF 和 PPT 预览不再依赖 WebView 自带的 PDF 查看能力,现可直接翻页、适宽和缩放;HTML 预览同步修复部分 WebView 中加载空白的问题。 diff --git a/DESIGN.md b/DESIGN.md index 83f9838..3b9a402 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -311,7 +311,7 @@ scheduled_jobs(§8.5) channel_bindings(§8.7,判别列+JSONB) ### 8.3 PPTX 前端在线预览(✅ Stage 1) -关键洞察:后端 soffice 把 PPT/PPTX 统一转成 PDF,前端只维护一条 PDF 展示路径。选 LibreOffice(像素级保真、任意 pptx)不选轻量 HTML(复杂失真)/PDF→PNG(失矢量)。转换在 web host 不进沙盒;宏安全 high + 禁网 + 仅本人 user_root。2026-08 App WebView 暴露浏览器内置 PDF 插件不可用,展示层由 blob iframe 改为本地 PDF.js canvas(单页按需渲染,避免长文档一次性占满内存);Web 端与 App 共用,不新增免鉴权文件 URL。任意 HTML 仍在 opaque-origin sandbox iframe 内运行,但内容入口改为同源静态宿主页 + postMessage,避开 WebView 的 `srcdoc` 空白和原生 URL 白名单误拦 `blob:`。与 `pptx_preview.py`(agent 生成期自检)分工。Stage 2 未做:常驻 listener / eager 预转。 +关键洞察:后端 soffice 把 PPT/PPTX 统一转成 PDF,前端只维护一条 PDF 展示路径。选 LibreOffice(像素级保真、任意 pptx)不选轻量 HTML(复杂失真)/PDF→PNG(失矢量)。转换在 web host 不进沙盒;宏安全 high + 禁网 + 仅本人 user_root。2026-08 App WebView 暴露浏览器内置 PDF 插件不可用,展示层由 blob iframe 改为本地 PDF.js canvas;页面保持纵向连续滚动,只在视口前后懒渲染并释放远处 Canvas,兼顾阅读习惯与长文档内存。Web 端与 App 共用,不新增免鉴权文件 URL。任意 HTML 仍在 opaque-origin sandbox iframe 内运行,但内容入口改为同源静态宿主页 + postMessage,避开 WebView 的 `srcdoc` 空白和原生 URL 白名单误拦 `blob:`。与 `pptx_preview.py`(agent 生成期自检)分工。Stage 2 未做:常驻 listener / eager 预转。 ### 8.4 运维监控 / 无感更新(监控 ✅ / 换版 design) diff --git a/PROGRESS.md b/PROGRESS.md index 64a043e..11e0cb4 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -2,7 +2,7 @@ > 配合 `DESIGN.md`。本文件只记 phase 状态、决策偏差、文件量、下一步。每条 1-2 句:做了啥 + 关键判断;细节查 `git log` / `git diff` / `DESIGN §7.9`。 -最后更新:2026-08-10(App WebView PDF/PPT/HTML 预览兼容,bump 0.63.2) +最后更新:2026-08-10(PDF/PPT 连续滚动预览,bump 0.63.3) --- @@ -23,6 +23,8 @@ ### 2026-08-10 +- **08-10 / 0.63.3 / PDF/PPT 连续滚动预览**:PDF.js 展示由上一页/下一页驱动的单页 Canvas 恢复为纵向连续页列表,保留页码跳转、适宽和缩放;页面进入视口前后才渲染,滚远后释放 Canvas,兼顾原有阅读习惯与长文档内存占用。Node 前端预览 10 项、JavaScript 语法及 diff 检查通过;无 schema、migration、HTTP API、依赖或运行方式变化,未连接生产 DB。 + - **08-10 / 0.63.2 / App WebView PDF/PPT/HTML 预览兼容**:PDF 与 PPT 转换结果不再通过 blob iframe 依赖浏览器内置 PDF 插件,改为本地固化 PDF.js 后按单页 Canvas 渲染,支持翻页、适宽与缩放,并在关闭或切换预览时释放 loading/render task、worker 和文档资源。HTML 保留 opaque-origin sandbox 安全边界,内容入口由兼容不稳的 `srcdoc` 改为同源静态宿主页 + postMessage,避开原生 URL 白名单误拦 `blob:`;App 对接文档明确白名单仅处理主 frame。Node 前端 18 项、静态资源 unittest 12 项、JavaScript/PDF.js bundle 语法及 diff 检查通过;无 schema、migration、HTTP API 或 Python 依赖变化,未连接生产 DB。 ### 2026-08-07 diff --git a/core/__init__.py b/core/__init__.py index d99c3d6..407b0d9 100644 --- a/core/__init__.py +++ b/core/__init__.py @@ -1,3 +1,3 @@ # zcbot 版本号单一事实源:web/app.py 的 FastAPI version、/healthz 返回、前端展示都引这里。 # 改版本只动这一行。 -__version__ = "0.63.2" +__version__ = "0.63.3" diff --git a/tests/frontend_preview.test.mjs b/tests/frontend_preview.test.mjs index 35909ef..0f143cc 100644 --- a/tests/frontend_preview.test.mjs +++ b/tests/frontend_preview.test.mjs @@ -82,6 +82,15 @@ test("PDF previews use the bundled PDF.js canvas renderer for WebView compatibil assert.match(pageHtml, /\.pdf-canvas/); }); +test("PDF previews render a continuously scrollable lazy page list", () => { + assert.match(previewJs, /class="pdf-pages-list"/); + assert.match(previewJs, /new IntersectionObserver/); + assert.match(previewJs, /root: viewportHost/); + assert.match(previewJs, /viewportHost\.addEventListener\("scroll"/); + assert.doesNotMatch(previewJs, /class="small pdf-(?:prev|next)"/); + assert.match(pageHtml, /\.pdf-pages-list\s*\{[^}]*flex-direction:\s*column/s); +}); + test("assistant HTML artifacts render inline with lazy loading and an expand action", () => { const mediaJs = readFileSync(new URL("../web/static/js/media.js", import.meta.url), "utf8"); assert.match(mediaJs, /cat === "html"/); diff --git a/web/static/dev.html b/web/static/dev.html index 6ee7b2c..8adb9be 100644 --- a/web/static/dev.html +++ b/web/static/dev.html @@ -1373,6 +1373,11 @@ .pdf-toolbar-spacer { flex: 1; } .pdf-zoom { min-width: 38px; text-align: center; font-size: 12px; color: var(--muted); } .pdf-viewport { flex: 1; min-height: 0; overflow: auto; padding: 12px; overscroll-behavior: contain; } + .pdf-pages-list { display: flex; flex-direction: column; align-items: center; gap: 14px; min-width: max-content; } + .pdf-page-shell { + position: relative; flex: 0 0 auto; display: flex; align-items: center; justify-content: center; + background: #fff; color: var(--muted); font-size: 12px; box-shadow: 0 2px 12px rgba(15,23,42,.18); + } .pdf-canvas { display: block; margin: 0 auto; background: #fff; box-shadow: 0 2px 12px rgba(15,23,42,.18); } #file-preview-modal .body pre.preview-text { margin: 0; padding: 8px; background: var(--code-bg); diff --git a/web/static/js/preview.js b/web/static/js/preview.js index 8ebfefc..e2264e5 100644 --- a/web/static/js/preview.js +++ b/web/static/js/preview.js @@ -354,9 +354,14 @@ function _clearPdfPreview(body) { const state = _pdfPreviewStates.get(body); if (!state) return; state.disposed = true; - if (state.renderTask) state.renderTask.cancel(); + if (state.renderTasks) { + for (const task of state.renderTasks.values()) task.cancel(); + state.renderTasks.clear(); + } if (state.loadingTask) state.loadingTask.destroy(); if (state.resizeObserver) state.resizeObserver.disconnect(); + if (state.pageObserver) state.pageObserver.disconnect(); + if (state.scrollFrame) cancelAnimationFrame(state.scrollFrame); if (state.onResize) window.removeEventListener("resize", state.onResize); if (state.pdf) state.pdf.destroy(); _pdfPreviewStates.delete(body); @@ -370,18 +375,19 @@ async function _showPdfIn(body, blob, fallbackFn) { loadingTask: null, pageNumber: 1, zoom: 1, - renderTask: null, + renderTasks: new Map(), renderSeq: 0, + pageShells: [], + pageObserver: null, resizeObserver: null, onResize: null, + scrollFrame: null, }; _pdfPreviewStates.set(body, state); body.className = "body pdf-preview"; body.innerHTML = `
- - 适宽 @@ -393,20 +399,22 @@ async function _showPdfIn(body, blob, fallbackFn) { const pageInput = body.querySelector(".pdf-page"); const pagesLabel = body.querySelector(".pdf-pages"); const zoomLabel = body.querySelector(".pdf-zoom"); - const prev = body.querySelector(".pdf-prev"); - const next = body.querySelector(".pdf-next"); const zoomOut = body.querySelector(".pdf-zoom-out"); const zoomIn = body.querySelector(".pdf-zoom-in"); - const renderPage = async () => { + const renderPage = async (pageNumber) => { if (state.disposed || !state.pdf) return; - const seq = ++state.renderSeq; - if (state.renderTask) state.renderTask.cancel(); - const page = await state.pdf.getPage(state.pageNumber); - if (state.disposed || seq !== state.renderSeq) return; + const shell = state.pageShells[pageNumber - 1]; + if (!shell) return; + const renderKey = `${state.renderSeq}:${viewportHost.clientWidth}:${state.zoom}`; + if (shell.dataset.renderKey === renderKey || state.renderTasks.has(pageNumber)) return; + const page = await state.pdf.getPage(pageNumber); + if (state.disposed || !shell.isConnected) return; const baseViewport = page.getViewport({ scale: 1 }); const fitScale = Math.max(0.1, (viewportHost.clientWidth - 24) / baseViewport.width); const viewport = page.getViewport({ scale: fitScale * state.zoom }); + shell.dataset.baseWidth = String(baseViewport.width); + shell.dataset.baseHeight = String(baseViewport.height); const outputScale = Math.min(window.devicePixelRatio || 1, 2); const canvas = document.createElement("canvas"); const context = canvas.getContext("2d"); @@ -415,56 +423,72 @@ async function _showPdfIn(body, blob, fallbackFn) { canvas.height = Math.floor(viewport.height * outputScale); canvas.style.width = Math.floor(viewport.width) + "px"; canvas.style.height = Math.floor(viewport.height) + "px"; - viewportHost.innerHTML = ""; - viewportHost.appendChild(canvas); - state.renderTask = page.render({ + shell.style.width = Math.floor(viewport.width) + "px"; + shell.style.height = Math.floor(viewport.height) + "px"; + const renderTask = page.render({ canvasContext: context, viewport, transform: outputScale === 1 ? null : [outputScale, 0, 0, outputScale, 0, 0], }); + state.renderTasks.set(pageNumber, renderTask); try { - await state.renderTask.promise; + await renderTask.promise; } catch (e) { if (e && e.name === "RenderingCancelledException") return; throw e; } finally { - if (seq === state.renderSeq) state.renderTask = null; + if (state.renderTasks.get(pageNumber) === renderTask) state.renderTasks.delete(pageNumber); } - if (state.disposed || seq !== state.renderSeq) return; - pageInput.value = String(state.pageNumber); - prev.disabled = state.pageNumber <= 1; - next.disabled = state.pageNumber >= state.pdf.numPages; - zoomLabel.textContent = state.zoom === 1 ? "适宽" : Math.round(state.zoom * 100) + "%"; - viewportHost.scrollTo({ top: 0, left: 0 }); + if (state.disposed || renderKey !== `${state.renderSeq}:${viewportHost.clientWidth}:${state.zoom}`) return; + shell.innerHTML = ""; + shell.appendChild(canvas); + shell.dataset.renderKey = renderKey; }; const goToPage = (pageNumber) => { if (!state.pdf) return; state.pageNumber = Math.min(state.pdf.numPages, Math.max(1, pageNumber)); - renderPage().catch((e) => { - if (state.disposed) return; - _clearPdfPreview(body); - fallbackFn("PDF 渲染失败:" + e.message); - }); + pageInput.value = String(state.pageNumber); + const shell = state.pageShells[state.pageNumber - 1]; + if (shell) viewportHost.scrollTo({ top: Math.max(0, shell.offsetTop - 12), behavior: "smooth" }); }; - prev.onclick = () => goToPage(state.pageNumber - 1); - next.onclick = () => goToPage(state.pageNumber + 1); pageInput.onchange = () => goToPage(parseInt(pageInput.value, 10) || 1); + + const rerenderVisiblePages = () => { + state.renderSeq += 1; + for (const task of state.renderTasks.values()) task.cancel(); + state.renderTasks.clear(); + const availableWidth = Math.max(1, viewportHost.clientWidth - 24); + for (const shell of state.pageShells) { + shell.dataset.renderKey = ""; + const baseWidth = Number(shell.dataset.baseWidth); + const baseHeight = Number(shell.dataset.baseHeight); + const scale = Math.max(0.1, availableWidth / baseWidth) * state.zoom; + shell.style.width = Math.floor(baseWidth * scale) + "px"; + shell.style.height = Math.floor(baseHeight * scale) + "px"; + const rect = shell.getBoundingClientRect(); + const hostRect = viewportHost.getBoundingClientRect(); + if (rect.bottom >= hostRect.top - hostRect.height && rect.top <= hostRect.bottom + hostRect.height) { + renderPage(Number(shell.dataset.page)).catch(handleRenderError); + } + } + }; + + const handleRenderError = (e) => { + if (state.disposed || (e && e.name === "RenderingCancelledException")) return; + _clearPdfPreview(body); + fallbackFn("PDF 渲染失败:" + e.message); + }; + zoomOut.onclick = () => { state.zoom = Math.max(0.5, +(state.zoom / 1.25).toFixed(2)); - renderPage().catch((e) => { - if (state.disposed) return; - _clearPdfPreview(body); - fallbackFn("PDF 渲染失败:" + e.message); - }); + zoomLabel.textContent = state.zoom === 1 ? "适宽" : Math.round(state.zoom * 100) + "%"; + rerenderVisiblePages(); }; zoomIn.onclick = () => { state.zoom = Math.min(3, +(state.zoom * 1.25).toFixed(2)); - renderPage().catch((e) => { - if (state.disposed) return; - _clearPdfPreview(body); - fallbackFn("PDF 渲染失败:" + e.message); - }); + zoomLabel.textContent = state.zoom === 1 ? "适宽" : Math.round(state.zoom * 100) + "%"; + rerenderVisiblePages(); }; try { @@ -483,10 +507,58 @@ async function _showPdfIn(body, blob, fallbackFn) { pageInput.disabled = false; zoomOut.disabled = false; zoomIn.disabled = false; - await renderPage(); + const firstPage = await pdf.getPage(1); + const firstViewport = firstPage.getViewport({ scale: 1 }); + const initialScale = Math.max(0.1, (viewportHost.clientWidth - 24) / firstViewport.width); + viewportHost.innerHTML = '
'; + const pagesList = viewportHost.firstElementChild; + for (let pageNumber = 1; pageNumber <= pdf.numPages; pageNumber += 1) { + const shell = document.createElement("div"); + shell.className = "pdf-page-shell"; + shell.dataset.page = String(pageNumber); + shell.dataset.baseWidth = String(firstViewport.width); + shell.dataset.baseHeight = String(firstViewport.height); + shell.style.width = Math.floor(firstViewport.width * initialScale) + "px"; + shell.style.height = Math.floor(firstViewport.height * initialScale) + "px"; + shell.innerHTML = `第 ${pageNumber} 页`; + pagesList.appendChild(shell); + state.pageShells.push(shell); + } + state.pageObserver = new IntersectionObserver((entries) => { + for (const entry of entries) { + if (entry.isIntersecting) renderPage(Number(entry.target.dataset.page)).catch(handleRenderError); + } + }, { root: viewportHost, rootMargin: "100% 0px" }); + for (const shell of state.pageShells) state.pageObserver.observe(shell); + + viewportHost.addEventListener("scroll", () => { + if (state.scrollFrame) return; + state.scrollFrame = requestAnimationFrame(() => { + state.scrollFrame = null; + const center = viewportHost.scrollTop + viewportHost.clientHeight / 2; + let current = 1; + let distance = Infinity; + for (const shell of state.pageShells) { + const delta = Math.abs(shell.offsetTop + shell.offsetHeight / 2 - center); + if (delta < distance) { + distance = delta; + current = Number(shell.dataset.page); + } + const farAway = shell.offsetTop + shell.offsetHeight < viewportHost.scrollTop - viewportHost.clientHeight * 2 + || shell.offsetTop > viewportHost.scrollTop + viewportHost.clientHeight * 3; + if (farAway && shell.dataset.renderKey) { + shell.innerHTML = `第 ${shell.dataset.page} 页`; + shell.dataset.renderKey = ""; + } + } + state.pageNumber = current; + pageInput.value = String(current); + }); + }, { passive: true }); + const rerender = () => { if (state.disposed) return; - renderPage().catch(() => {}); + rerenderVisiblePages(); }; if (window.ResizeObserver) { state.resizeObserver = new ResizeObserver(rerender);