fix(preview): support document previews in app webviews

This commit is contained in:
caoqianming 2026-08-10 08:55:44 +08:00
parent 9032c5b517
commit 8d741e4141
15 changed files with 506 additions and 22 deletions

3
APP.md
View File

@ -55,9 +55,12 @@ https://<zcbot 域名>/static/dev.html?embed=1&relogin_url=<回登录入口的
| DOM storage | `setDomStorageEnabled(true)`(必须) | 自带 | | DOM storage | `setDomStorageEnabled(true)`(必须) | 自带 |
| 键盘顶起 | `windowSoftInputMode="adjustResize"` | 自带 | | 键盘顶起 | `windowSoftInputMode="adjustResize"` | 自带 |
| 外链 | 非 platform/zcbot 域交给系统浏览器 | 同左 | | 外链 | 非 platform/zcbot 域交给系统浏览器 | 同左 |
| PDF/PPT/HTML 预览 | zcbot 前端内置渲染,无需原生 PDF 组件 | 同左 |
SSE 流式输出两端 WebView 原生支持,无需处理。iOS 上架提醒:纯套壳有 App Store 4.2(最小功能)被拒风险;企业分发 / TestFlight / 仅 Android 无此问题。 SSE 流式输出两端 WebView 原生支持,无需处理。iOS 上架提醒:纯套壳有 App Store 4.2(最小功能)被拒风险;企业分发 / TestFlight / 仅 Android 无此问题。
URL 白名单只处理**主 frame**导航。Android 检查 `request.isForMainFrame`,iOS 检查 `navigationAction.targetFrame?.isMainFrame`;不要拦截 zcbot 页面内部的 worker 或子 frame 请求,否则 PDF.js worker、HTML sandbox 等站内预览仍会白屏。
--- ---
## 4. 备选:H5 登录页 ## 4. 备选:H5 登录页

View File

@ -5,6 +5,10 @@
> 所以不是每个版本号都有条目。条目格式 `## <版本> — <日期>`,新条目加在最上面。 > 所以不是每个版本号都有条目。条目格式 `## <版本> — <日期>`,新条目加在最上面。
> 工程口径的完整记录见 `PROGRESS.md` / git log。 > 工程口径的完整记录见 `PROGRESS.md` / git log。
## 0.63.2 — 2026-08-10
- App 内的 PDF 和 PPT 预览不再依赖 WebView 自带的 PDF 查看能力现可直接翻页、适宽和缩放HTML 预览同步修复部分 WebView 中加载空白的问题。
## 0.63.1 — 2026-08-07 ## 0.63.1 — 2026-08-07
- 管理员调整外部系统定义时不再清除用户凭据:查询策略、提示和限额变化不中断连接;接口目标或认证配置变化后只暂停调用,用户在“外部”页面测试成功即可恢复。 - 管理员调整外部系统定义时不再清除用户凭据:查询策略、提示和限额变化不中断连接;接口目标或认证配置变化后只暂停调用,用户在“外部”页面测试成功即可恢复。

View File

@ -311,7 +311,7 @@ scheduled_jobs(§8.5) channel_bindings(§8.7,判别列+JSONB)
### 8.3 PPTX 前端在线预览(✅ Stage 1) ### 8.3 PPTX 前端在线预览(✅ Stage 1)
关键洞察:前端已有 PDF iframe 路径 → 后端 soffice 转 PDF 即可,前端几乎不动。选 LibreOffice(像素级保真、任意 pptx)不选轻量 HTML(复杂失真)/PDF→PNG(失矢量)。转换在 web host 不进沙盒;宏安全 high + 禁网 + 仅本人 user_root。与 `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单页按需渲染避免长文档一次性占满内存Web 端与 App 共用,不新增免鉴权文件 URL。任意 HTML 仍在 opaque-origin sandbox iframe 内运行,但内容入口改为同源静态宿主页 + postMessage避开 WebView 的 `srcdoc` 空白和原生 URL 白名单误拦 `blob:`。与 `pptx_preview.py`(agent 生成期自检)分工。Stage 2 未做:常驻 listener / eager 预转。
### 8.4 运维监控 / 无感更新(监控 ✅ / 换版 design) ### 8.4 运维监控 / 无感更新(监控 ✅ / 换版 design)

View File

@ -2,7 +2,7 @@
> 配合 `DESIGN.md`。本文件只记 phase 状态、决策偏差、文件量、下一步。每条 1-2 句:做了啥 + 关键判断;细节查 `git log` / `git diff` / `DESIGN §7.9` > 配合 `DESIGN.md`。本文件只记 phase 状态、决策偏差、文件量、下一步。每条 1-2 句:做了啥 + 关键判断;细节查 `git log` / `git diff` / `DESIGN §7.9`
最后更新:2026-08-07(外部连接定义更新保凭据 + agent 状态可见,bump 0.63.1) 最后更新:2026-08-10(App WebView PDF/PPT/HTML 预览兼容,bump 0.63.2)
--- ---
@ -21,6 +21,10 @@
## 已完成关键能力 ## 已完成关键能力
### 2026-08-10
- **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 ### 2026-08-07
- **08-07 / 0.63.1 / 外部定义更新保凭据 + agent 状态可见**:生产 task `96bef58e` 暴露旧版 Factory 定义补齐默认认证字段时被误判为认证绑定变化,用户密文被清空为 `needs_credentials`,而工具组整体隐藏又让模型转查本地与互联网。现对新旧配置先做默认值补全后的语义比较:查询提示、推荐入口、执行策略和响应限额变化由 active 连接原子跟随 revision目标、登录、认证绑定或 TLS 变化保留密文并置 `needs_reverify`,用户主动测试成功后恢复。只读 `external_system_list` 在任意连接状态下均可挂载并返回操作提示search/call/read/export 仍仅对 active 且 revision 匹配的连接开放。完整 512 项 unittest 全绿(17 skip),外部系统专项 46 项、无 DB 路由 23 项、外部模块 mypy、Python 编译、Ruff 致命规则与 diff 检查通过;无 schema、migration、依赖或 HTTP API 变化,只读排查生产 task未写生产 DB。 - **08-07 / 0.63.1 / 外部定义更新保凭据 + agent 状态可见**:生产 task `96bef58e` 暴露旧版 Factory 定义补齐默认认证字段时被误判为认证绑定变化,用户密文被清空为 `needs_credentials`,而工具组整体隐藏又让模型转查本地与互联网。现对新旧配置先做默认值补全后的语义比较:查询提示、推荐入口、执行策略和响应限额变化由 active 连接原子跟随 revision目标、登录、认证绑定或 TLS 变化保留密文并置 `needs_reverify`,用户主动测试成功后恢复。只读 `external_system_list` 在任意连接状态下均可挂载并返回操作提示search/call/read/export 仍仅对 active 且 revision 匹配的连接开放。完整 512 项 unittest 全绿(17 skip),外部系统专项 46 项、无 DB 路由 23 项、外部模块 mypy、Python 编译、Ruff 致命规则与 diff 检查通过;无 schema、migration、依赖或 HTTP API 变化,只读排查生产 task未写生产 DB。

2
RUN.md
View File

@ -1032,7 +1032,7 @@ sudo xfs_quota -x -c "limit -p bhard=10g zcbot_<user_uuid>" /opt
- **入口**:`main.py`(`web / db / probe / user`)→ `core/agent_builder.py::build_agent` - **入口**:`main.py`(`web / db / probe / user`)→ `core/agent_builder.py::build_agent`
- **核心**:`core/{agent_builder, loop, session, task, llm, memory, paths}.py` + `core/storage/{engine,models,utils}.py` + `db/migrations/` - **核心**:`core/{agent_builder, loop, session, task, llm, memory, paths}.py` + `core/storage/{engine,models,utils}.py` + `db/migrations/`
- **工具**:`tools/{fs, shell, run_python, skill_tool}.py` - **工具**:`tools/{fs, shell, run_python, skill_tool}.py`
- **Web**:`web/{app.py, auth.py, broker.py, sinks.py}` + `web/static/dev.html`(dev SPA)+ `web/static/vendor/`(office 预览 jszip/docx-preview/xlsx) - **Web**:`web/{app.py, auth.py, broker.py, sinks.py}` + `web/static/dev.html`(dev SPA)+ `web/static/html_preview_host.html`(WebView 兼容的 sandbox HTML 宿主页)+ `web/static/vendor/`(PDF.js 与 office 预览 jszip/docx-preview/xlsx)
- **配置**:`config/agent.yaml` + `config/models/*.yaml`(§3.2 Model Profile) - **配置**:`config/agent.yaml` + `config/models/*.yaml`(§3.2 Model Profile)
- **模型档位(per-account 模型访问)**:`config/agent.yaml` `model_tiers` 段定义「档位→可用模型 id 集合」;`users.plan` 存档位名,空/未知 → `default` 档,`role=admin` 全开。管理后台「各用户用量」表的「档位」下拉改 plan(`PATCH /v1/admin/users/{uid}/plan`);档位定义见 `GET /v1/admin/tiers`。改 `model_tiers` 后**重启 web** 生效;无需 migration(`plan` 列 0001 起就有)。模型 id:文本=`family.variant`,图/视频=variant key。行为:用户只看到本档模型;显式选档外模型 403;老 task 下次发消息若模型已不在档位内 → 自动落回 `deepseek_v4.flash` - **模型档位(per-account 模型访问)**:`config/agent.yaml` `model_tiers` 段定义「档位→可用模型 id 集合」;`users.plan` 存档位名,空/未知 → `default` 档,`role=admin` 全开。管理后台「各用户用量」表的「档位」下拉改 plan(`PATCH /v1/admin/users/{uid}/plan`);档位定义见 `GET /v1/admin/tiers`。改 `model_tiers` 后**重启 web** 生效;无需 migration(`plan` 列 0001 起就有)。模型 id:文本=`family.variant`,图/视频=variant key。行为:用户只看到本档模型;显式选档外模型 403;老 task 下次发消息若模型已不在档位内 → 自动落回 `deepseek_v4.flash`
- **Skill**:`skills/{coding,ppt,proposal}/SKILL.md`(渐进披露,§3.5) - **Skill**:`skills/{coding,ppt,proposal}/SKILL.md`(渐进披露,§3.5)

View File

@ -1,3 +1,3 @@
# zcbot 版本号单一事实源:web/app.py 的 FastAPI version、/healthz 返回、前端展示都引这里。 # zcbot 版本号单一事实源:web/app.py 的 FastAPI version、/healthz 返回、前端展示都引这里。
# 改版本只动这一行。 # 改版本只动这一行。
__version__ = "0.63.1" __version__ = "0.63.2"

View File

@ -28,11 +28,35 @@ test("HTML preview allows HTTPS scripts while blocking host privileges and navig
assert.match(document, /base-uri 'none'/); assert.match(document, /base-uri 'none'/);
assert.ok(document.indexOf("Content-Security-Policy") < document.indexOf("<title>")); assert.ok(document.indexOf("Content-Security-Policy") < document.indexOf("<title>"));
const attrs = {}; const attrs = {};
const frame = { setAttribute(name, value) { attrs[name] = value; } }; let onload = null;
let posted = null;
const frame = {
contentWindow: { postMessage(value, origin) { posted = { value, origin }; } },
setAttribute(name, value) { attrs[name] = value; },
addEventListener(type, fn) { if (type === "load") onload = fn; },
};
configureHtmlPreviewFrame(frame, "<h1>x</h1>"); configureHtmlPreviewFrame(frame, "<h1>x</h1>");
assert.equal(attrs.sandbox, "allow-scripts"); assert.equal(attrs.sandbox, "allow-scripts");
assert.equal(attrs.csp, undefined);
assert.doesNotMatch(attrs.sandbox, /allow-same-origin|allow-forms|allow-top-navigation|allow-popups/); assert.doesNotMatch(attrs.sandbox, /allow-same-origin|allow-forms|allow-top-navigation|allow-popups/);
assert.equal(frame.referrerPolicy, "no-referrer"); assert.equal(frame.referrerPolicy, "no-referrer");
assert.equal(frame.src, "/static/html_preview_host.html");
assert.equal(frame.srcdoc, undefined);
onload();
assert.equal(posted.origin, "*");
assert.equal(posted.value.type, "zcbot-html-preview");
assert.match(posted.value.document, /<h1>x<\/h1>/);
});
test("HTML preview uses a same-origin message host instead of srcdoc or blob navigation", () => {
assert.match(previewJs, /configureHtmlPreviewFrame\(frame, text\)/);
const source = readFileSync(new URL("../web/static/js/preview_content.js", import.meta.url), "utf8");
const host = readFileSync(new URL("../web/static/html_preview_host.html", import.meta.url), "utf8");
assert.match(source, /HTML_PREVIEW_HOST = "\/static\/html_preview_host\.html"/);
assert.match(source, /contentWindow\.postMessage/);
assert.doesNotMatch(source, /URL\.createObjectURL|frame\.srcdoc\s*=/);
assert.match(host, /event\.source !== parent/);
assert.match(host, /document\.write\(event\.data\.document\)/);
}); });
test("HTML fragments receive a restrictive head before their content", () => { test("HTML fragments receive a restrictive head before their content", () => {
@ -49,6 +73,15 @@ test("main and mini previews expose preview/source mode controls", () => {
} }
}); });
test("PDF previews use the bundled PDF.js canvas renderer for WebView compatibility", () => {
assert.match(previewJs, /vendor\/pdfjs\/pdf\.min\.js/);
assert.match(previewJs, /pdfjsLib\.getDocument\(\{ data \}\)/);
assert.match(previewJs, /page\.render\(\{/);
assert.doesNotMatch(previewJs, /iframe class="preview-frame" src="\$\{[^}]*application\/pdf/);
assert.match(pageHtml, /\.pdf-viewport/);
assert.match(pageHtml, /\.pdf-canvas/);
});
test("assistant HTML artifacts render inline with lazy loading and an expand action", () => { 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"); const mediaJs = readFileSync(new URL("../web/static/js/media.js", import.meta.url), "utf8");
assert.match(mediaJs, /cat === "html"/); assert.match(mediaJs, /cat === "html"/);

View File

@ -14,6 +14,7 @@ UI_CSS = STATIC_DIR / "ui.css"
JS_DIR = STATIC_DIR / "js" JS_DIR = STATIC_DIR / "js"
VENDOR_DIR = STATIC_DIR / "vendor" / "markdown" VENDOR_DIR = STATIC_DIR / "vendor" / "markdown"
MERMAID_VENDOR_DIR = STATIC_DIR / "vendor" / "mermaid" MERMAID_VENDOR_DIR = STATIC_DIR / "vendor" / "mermaid"
PDFJS_VENDOR_DIR = STATIC_DIR / "vendor" / "pdfjs"
def _frontend_source() -> str: def _frontend_source() -> str:
@ -50,6 +51,19 @@ class StaticVendorTests(unittest.TestCase):
self.assertGreater(mermaid_bundle.stat().st_size, 1_000_000) self.assertGreater(mermaid_bundle.stat().st_size, 1_000_000)
self.assertTrue(mermaid_license.exists(), f"missing vendored license: {mermaid_license}") self.assertTrue(mermaid_license.exists(), f"missing vendored license: {mermaid_license}")
def test_pdfjs_assets_are_vendored_with_license(self) -> None:
for name in ("pdf.min.js", "pdf.worker.min.js", "LICENSE.txt"):
path = PDFJS_VENDOR_DIR / name
self.assertTrue(path.exists(), f"missing vendored asset: {path}")
self.assertGreater(path.stat().st_size, 0, f"empty vendored asset: {path}")
def test_html_preview_host_is_shipped(self) -> None:
host = STATIC_DIR / "html_preview_host.html"
self.assertTrue(host.exists())
source = host.read_text(encoding="utf-8")
self.assertIn("zcbot-html-preview", source)
self.assertIn("event.source !== parent", source)
def test_dev_html_uses_local_ui_foundation(self) -> None: def test_dev_html_uses_local_ui_foundation(self) -> None:
html = DEV_HTML.read_text(encoding="utf-8") html = DEV_HTML.read_text(encoding="utf-8")

View File

@ -1363,6 +1363,17 @@
} }
#file-preview-modal .body iframe.preview-frame { width: 100%; height: 100%; border: 0; } #file-preview-modal .body iframe.preview-frame { width: 100%; height: 100%; border: 0; }
iframe.preview-html-frame { background: #fff; } iframe.preview-html-frame { background: #fff; }
.pdf-preview { padding: 0 !important; overflow: hidden !important; background: #eef1f5; }
.pdf-toolbar {
min-height: 44px; padding: 6px 10px; display: flex; align-items: center; gap: 7px;
background: var(--panel); border-bottom: 1px solid var(--border); flex: 0 0 auto;
}
.pdf-toolbar label { display: flex; align-items: center; gap: 5px; font-size: 12px; color: var(--muted); white-space: nowrap; }
.pdf-toolbar .pdf-page { width: 48px; padding: 5px 4px; text-align: center; }
.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-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 { #file-preview-modal .body pre.preview-text {
margin: 0; padding: 8px; background: var(--code-bg); margin: 0; padding: 8px; background: var(--code-bg);
border-radius: var(--r-md); white-space: pre-wrap; word-break: break-word; border-radius: var(--r-md); white-space: pre-wrap; word-break: break-word;

View File

@ -0,0 +1,23 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; object-src 'none'; base-uri 'none'; form-action 'none'">
<title>HTML 文件预览</title>
<style>html,body{height:100%;margin:0}body{display:grid;place-items:center;color:#667085;font:13px system-ui,sans-serif}</style>
</head>
<body>
<span>加载 HTML 中…</span>
<script>
window.addEventListener("message", function receivePreview(event) {
if (event.source !== parent || !event.data || event.data.type !== "zcbot-html-preview") return;
if (typeof event.data.document !== "string") return;
window.removeEventListener("message", receivePreview);
document.open();
document.write(event.data.document);
document.close();
});
</script>
</body>
</html>

View File

@ -1,5 +1,6 @@
// 文件预览:主弹框(图/视频/PDF/文本/markdown/html/docx/xlsx,大文件降级下载)+ // 文件预览:主弹框(图/视频/PDF/文本/markdown/html/docx/xlsx,大文件降级下载)+
// 同时再开一个的小窗预览(mini)。docx/xlsx 走 loadScript 懒加载 vendor。 // 同时再开一个的小窗预览(mini)。PDF 用 PDF.js canvas 渲染以兼容 App WebView
// docx/xlsx 走 loadScript 懒加载 vendor。
// 导出 open*/close* 供 files / 媒体 chip / 粘贴文件 / main 的 Esc 关栈调用; // 导出 open*/close* 供 files / 媒体 chip / 粘贴文件 / main 的 Esc 关栈调用;
// _categorize 也供 media 段判图/视频。反向依赖 downloadFile(media)、logout(auth)。 // _categorize 也供 media 段判图/视频。反向依赖 downloadFile(media)、logout(auth)。
import { state } from "./state.js"; import { state } from "./state.js";
@ -13,6 +14,8 @@ import { downloadFile } from "./media.js";
// ───── file preview ───── // ───── file preview ─────
const PREVIEW_TEXT_MAX = 2 * 1024 * 1024; const PREVIEW_TEXT_MAX = 2 * 1024 * 1024;
const PREVIEW_BIN_MAX = 50 * 1024 * 1024; const PREVIEW_BIN_MAX = 50 * 1024 * 1024;
const PDFJS_SRC = "/static/vendor/pdfjs/pdf.min.js";
const PDFJS_WORKER_SRC = "/static/vendor/pdfjs/pdf.worker.min.js";
const _scriptCache = new Map(); const _scriptCache = new Map();
function loadScript(src) { function loadScript(src) {
@ -76,6 +79,15 @@ function _resetTextModes(prefix) {
if (modes) modes.hidden = true; if (modes) modes.hidden = true;
} }
async function _loadPdfJs() {
await loadScript(PDFJS_SRC);
if (!window.pdfjsLib || !window.pdfjsLib.getDocument) {
throw new Error("PDF.js 不可用");
}
window.pdfjsLib.GlobalWorkerOptions.workerSrc = PDFJS_WORKER_SRC;
return window.pdfjsLib;
}
function _showSource(body, text) { function _showSource(body, text) {
body.className = "body"; body.className = "body";
body.innerHTML = ""; body.innerHTML = "";
@ -272,7 +284,7 @@ export async function openFilePreview(rel, taskId = "", legacy = false) {
const cat = _categorize(rel); const cat = _categorize(rel);
// pptx/ppt:后端转 PDF 再复用现成 PDF iframe(非下载原文件),首次稍候 + 失败回退下载。 // pptx/ppt:后端转 PDF 再复用现成 PDF iframe(非下载原文件),首次稍候 + 失败回退下载。
if (cat === "ppt") { await _showPptAsPdf(rel, $("fp-body"), $("fp-meta"), _showFallback, _trackBlobUrl, taskId, legacy); return; } if (cat === "ppt") { await _showPptAsPdf(rel, $("fp-body"), $("fp-meta"), _showFallback, taskId, legacy); return; }
try { try {
const r = await fetch(_fileDownloadUrl(rel, taskId, legacy), { const r = await fetch(_fileDownloadUrl(rel, taskId, legacy), {
headers: { "Authorization": "Bearer " + state.token }, headers: { "Authorization": "Bearer " + state.token },
@ -297,7 +309,7 @@ export async function openFilePreview(rel, taskId = "", legacy = false) {
} }
if (cat === "image") _showImage(blob); if (cat === "image") _showImage(blob);
else if (cat === "video") _showVideo(blob); else if (cat === "video") _showVideo(blob);
else if (cat === "pdf") _showPdf(blob); else if (cat === "pdf") await _showPdf(blob);
else if (cat === "docx") await _showDocx(blob); else if (cat === "docx") await _showDocx(blob);
else if (cat === "xlsx") await _showXlsx(blob); else if (cat === "xlsx") await _showXlsx(blob);
else _showFallback("暂不支持在线预览此格式,请下载查看"); else _showFallback("暂不支持在线预览此格式,请下载查看");
@ -336,15 +348,167 @@ function _showVideo(blob) {
body.appendChild(v); body.appendChild(v);
} }
function _showPdf(blob) { const _pdfPreviewStates = new WeakMap();
const url = _trackBlobUrl(blob, "application/pdf");
const body = $("fp-body"); function _clearPdfPreview(body) {
body.className = "body"; const state = _pdfPreviewStates.get(body);
body.innerHTML = `<iframe class="preview-frame" src="${url}"></iframe>`; if (!state) return;
state.disposed = true;
if (state.renderTask) state.renderTask.cancel();
if (state.loadingTask) state.loadingTask.destroy();
if (state.resizeObserver) state.resizeObserver.disconnect();
if (state.onResize) window.removeEventListener("resize", state.onResize);
if (state.pdf) state.pdf.destroy();
_pdfPreviewStates.delete(body);
} }
// pptx/ppt → 后端转 PDF → iframe。main / mini 共用:传各自 body / meta / fallback / 追踪 blob 的 fn。 async function _showPdfIn(body, blob, fallbackFn) {
async function _showPptAsPdf(rel, body, metaEl, fallbackFn, trackFn = _trackBlobUrl, taskId = "", legacy = false) { _clearPdfPreview(body);
const state = {
disposed: false,
pdf: null,
loadingTask: null,
pageNumber: 1,
zoom: 1,
renderTask: null,
renderSeq: 0,
resizeObserver: null,
onResize: null,
};
_pdfPreviewStates.set(body, state);
body.className = "body pdf-preview";
body.innerHTML = `
<div class="pdf-toolbar">
<button class="small pdf-prev" type="button" aria-label="上一页" disabled>上一页</button>
<label> <input class="pdf-page" type="number" min="1" value="1" inputmode="numeric" disabled> / <span class="pdf-pages"></span> </label>
<button class="small pdf-next" type="button" aria-label="下一页" disabled>下一页</button>
<span class="pdf-toolbar-spacer"></span>
<button class="small pdf-zoom-out" type="button" aria-label="缩小" disabled></button>
<span class="pdf-zoom">适宽</span>
<button class="small pdf-zoom-in" type="button" aria-label="放大" disabled></button>
</div>
<div class="pdf-viewport"><div class="ph">解析 PDF </div></div>`;
const viewportHost = body.querySelector(".pdf-viewport");
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 () => {
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 baseViewport = page.getViewport({ scale: 1 });
const fitScale = Math.max(0.1, (viewportHost.clientWidth - 24) / baseViewport.width);
const viewport = page.getViewport({ scale: fitScale * state.zoom });
const outputScale = Math.min(window.devicePixelRatio || 1, 2);
const canvas = document.createElement("canvas");
const context = canvas.getContext("2d");
canvas.className = "pdf-canvas";
canvas.width = Math.floor(viewport.width * outputScale);
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({
canvasContext: context,
viewport,
transform: outputScale === 1 ? null : [outputScale, 0, 0, outputScale, 0, 0],
});
try {
await state.renderTask.promise;
} catch (e) {
if (e && e.name === "RenderingCancelledException") return;
throw e;
} finally {
if (seq === state.renderSeq) state.renderTask = null;
}
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 });
};
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);
});
};
prev.onclick = () => goToPage(state.pageNumber - 1);
next.onclick = () => goToPage(state.pageNumber + 1);
pageInput.onchange = () => goToPage(parseInt(pageInput.value, 10) || 1);
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);
});
};
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);
});
};
try {
const pdfjsLib = await _loadPdfJs();
const data = new Uint8Array(await blob.arrayBuffer());
state.loadingTask = pdfjsLib.getDocument({ data });
const pdf = await state.loadingTask.promise;
state.loadingTask = null;
if (state.disposed || _pdfPreviewStates.get(body) !== state) {
pdf.destroy();
return;
}
state.pdf = pdf;
pagesLabel.textContent = String(pdf.numPages);
pageInput.max = String(pdf.numPages);
pageInput.disabled = false;
zoomOut.disabled = false;
zoomIn.disabled = false;
await renderPage();
const rerender = () => {
if (state.disposed) return;
renderPage().catch(() => {});
};
if (window.ResizeObserver) {
state.resizeObserver = new ResizeObserver(rerender);
state.resizeObserver.observe(viewportHost);
} else {
state.onResize = rerender;
window.addEventListener("resize", rerender);
}
} catch (e) {
if (!state.disposed) {
_clearPdfPreview(body);
fallbackFn("PDF 预览失败:" + e.message);
}
}
}
async function _showPdf(blob) {
await _showPdfIn($("fp-body"), blob, _showFallback);
}
// pptx/ppt → 后端转 PDF → PDF.js。main / mini 共用各自 body / meta / fallback。
async function _showPptAsPdf(rel, body, metaEl, fallbackFn, taskId = "", legacy = false) {
body.className = "body center"; body.className = "body center";
body.innerHTML = `<div class="ph"><div class="preview-spinner"></div>由 PPT 转换为 PDF · 首次稍候…</div>`; body.innerHTML = `<div class="ph"><div class="preview-spinner"></div>由 PPT 转换为 PDF · 首次稍候…</div>`;
if (metaEl) metaEl.textContent = ""; if (metaEl) metaEl.textContent = "";
@ -367,8 +531,7 @@ async function _showPptAsPdf(rel, body, metaEl, fallbackFn, trackFn = _trackBlob
} }
const blob = await r.blob(); const blob = await r.blob();
if (metaEl) metaEl.textContent = humanSize(blob.size) + " · PDF 预览"; if (metaEl) metaEl.textContent = humanSize(blob.size) + " · PDF 预览";
body.className = "body"; await _showPdfIn(body, blob, fallbackFn);
body.innerHTML = `<iframe class="preview-frame" src="${trackFn(blob, "application/pdf")}"></iframe>`;
} }
function _showText(text) { _showSource($("fp-body"), text); } function _showText(text) { _showSource($("fp-body"), text); }
@ -467,6 +630,7 @@ export function closeFilePreview() {
document.body.classList.remove("fp-open"); document.body.classList.remove("fp-open");
$("file-preview-modal").style.removeProperty("--preview-bottom-inset"); $("file-preview-modal").style.removeProperty("--preview-bottom-inset");
_clearZoom($("fp-body")); _clearZoom($("fp-body"));
_clearPdfPreview($("fp-body"));
_resetTextModes("fp"); _resetTextModes("fp");
$("fp-body").innerHTML = ""; $("fp-body").innerHTML = "";
_flushBlobUrls(); _flushBlobUrls();
@ -499,6 +663,7 @@ async function openMiniFilePreview(rel) {
const body = $("mp-body"); const body = $("mp-body");
_resetTextModes("mp"); _resetTextModes("mp");
_clearZoom(body); _clearZoom(body);
_clearPdfPreview(body);
body.className = "body center"; body.className = "body center";
body.innerHTML = `<div class="ph">加载中…</div>`; body.innerHTML = `<div class="ph">加载中…</div>`;
_flushMiniBlobUrls(); _flushMiniBlobUrls();
@ -506,7 +671,7 @@ async function openMiniFilePreview(rel) {
const cat = _categorize(rel); const cat = _categorize(rel);
if (cat === "ppt") { if (cat === "ppt") {
await _showPptAsPdf(rel, $("mp-body"), $("mp-meta"), _showMiniFallback, _trackMiniBlobUrl); await _showPptAsPdf(rel, $("mp-body"), $("mp-meta"), _showMiniFallback);
return; return;
} }
try { try {
@ -547,8 +712,7 @@ async function openMiniFilePreview(rel) {
v.controls = true; v.controls = true;
body.appendChild(v); body.appendChild(v);
} else if (cat === "pdf") { } else if (cat === "pdf") {
body.className = "body"; await _showPdfIn(body, blob, _showMiniFallback);
body.innerHTML = `<iframe class="preview-frame" src="${_trackMiniBlobUrl(blob, "application/pdf")}"></iframe>`;
} else { } else {
_showMiniFallback("暂不支持小窗预览此格式,请下载查看"); _showMiniFallback("暂不支持小窗预览此格式,请下载查看");
} }
@ -576,6 +740,7 @@ function _showMiniFallback(msg) {
export function closeMiniPreview() { export function closeMiniPreview() {
$("mini-preview-modal").classList.remove("show"); $("mini-preview-modal").classList.remove("show");
_clearZoom($("mp-body")); _clearZoom($("mp-body"));
_clearPdfPreview($("mp-body"));
_resetTextModes("mp"); _resetTextModes("mp");
$("mp-body").innerHTML = ""; $("mp-body").innerHTML = "";
_flushMiniBlobUrls(); _flushMiniBlobUrls();

View File

@ -1,5 +1,7 @@
// 可独立测试的 HTML 预览安全策略。脚本和 HTTPS 外部资源可运行,满足图表、 // 可独立测试的 HTML 预览安全策略。脚本和 HTTPS 外部资源可运行,满足图表、
// 地图、WebGL 等通用产物iframe 保持 opaque origin不能触达 zcbot 宿主权限。 // 地图、WebGL 等通用产物iframe 保持 opaque origin不能触达 zcbot 宿主权限。
// 使用同源静态宿主页 + postMessage而非 iframe.srcdoc / blob URL部分 WebView
// 对 srcdoc 支持不完整,原生壳的 URL 白名单又常会误拦 blob: 子 frame。
export const HTML_PREVIEW_CSP = "default-src 'none'; " + export const HTML_PREVIEW_CSP = "default-src 'none'; " +
"script-src 'unsafe-inline' 'unsafe-eval' 'wasm-unsafe-eval' https:; " + "script-src 'unsafe-inline' 'unsafe-eval' 'wasm-unsafe-eval' https:; " +
"style-src 'unsafe-inline' https:; img-src data: blob: https:; " + "style-src 'unsafe-inline' https:; img-src data: blob: https:; " +
@ -7,6 +9,7 @@ export const HTML_PREVIEW_CSP = "default-src 'none'; " +
"worker-src blob:; child-src blob: https:; frame-src https:; manifest-src https:; " + "worker-src blob:; child-src blob: https:; frame-src https:; manifest-src https:; " +
"object-src 'none'; form-action 'none'; base-uri 'none'"; "object-src 'none'; form-action 'none'; base-uri 'none'";
export const HTML_PREVIEW_SANDBOX = "allow-scripts"; export const HTML_PREVIEW_SANDBOX = "allow-scripts";
export const HTML_PREVIEW_HOST = "/static/html_preview_host.html";
export function htmlPreviewDocument(text) { export function htmlPreviewDocument(text) {
const meta = `<meta http-equiv="Content-Security-Policy" content="${HTML_PREVIEW_CSP}">`; const meta = `<meta http-equiv="Content-Security-Policy" content="${HTML_PREVIEW_CSP}">`;
@ -20,8 +23,11 @@ export function htmlPreviewDocument(text) {
export function configureHtmlPreviewFrame(frame, text, title = "HTML 文件预览") { export function configureHtmlPreviewFrame(frame, text, title = "HTML 文件预览") {
frame.title = title; frame.title = title;
frame.setAttribute("sandbox", HTML_PREVIEW_SANDBOX); frame.setAttribute("sandbox", HTML_PREVIEW_SANDBOX);
frame.setAttribute("csp", HTML_PREVIEW_CSP);
frame.referrerPolicy = "no-referrer"; frame.referrerPolicy = "no-referrer";
frame.srcdoc = htmlPreviewDocument(text); const document = htmlPreviewDocument(text);
frame.addEventListener("load", () => {
frame.contentWindow.postMessage({ type: "zcbot-html-preview", document }, "*");
}, { once: true });
frame.src = HTML_PREVIEW_HOST;
return frame; return frame;
} }

177
web/static/vendor/pdfjs/LICENSE.txt vendored Normal file
View File

@ -0,0 +1,177 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS

22
web/static/vendor/pdfjs/pdf.min.js vendored Normal file

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long