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);