feat(workbench): preview Word and Excel files read-only

.docx and .xlsx were classed unsupported, so a delivered report or sheet
could only be opened in the system app. The kernel now serves them as an
office preview kind (binary, 32 MB), and the workbench renders them to static
HTML (docx-preview; ExcelJS with SSF number formats) shown through
staticDocument() in a sandbox="" frame, so nothing in a file runs or fetches.
Both parsers are lazy chunks, and a machine setting turns the preview off.

Refs #14622
This commit is contained in:
esengine committed 2026-11-01 08:14:16 -08:00
1 parent f770f33791
commit fb08cdc0d5
27 files changed
+2145 -14

No files matched your search

+4
View File
@@ -37,7 +37,9 @@
"@mantine/hooks": "9.7.4",
"@noble/curves": "2.4.0",
"codemirror": "^6.0.2",
"docx-preview": "0.4.1",
"dompurify": "^3.4.17",
"exceljs": "4.4.0",
"katex": "^0.19.1",
"lucide-react": "^1.64.0",
"maplibre-gl": "6.16.0",
@@ -55,6 +57,7 @@
"remark-gemoji": "^8.0.0",
"remark-gfm": "^4.0.1",
"remark-math": "^6.0.0",
"ssf": "0.11.2",
"zod": "3.25.76"
},
"devDependencies": {
@@ -66,6 +69,7 @@
"@types/react-dom": "^19.3.0",
"@vitejs/plugin-react": "^6.1.2",
"jsdom": "^30.1.3",
"jszip": "3.10.2",
"playwright": "^1.64.1",
"typescript": "^7.0.2",
"vite": "^8.3.5",
+5 -3
View File
@@ -607,7 +607,7 @@ declare global {
__panes: () => number;
// Answer every completion request with this one, as the kernel would for a command typed in full.
__complete: (answer: import("../src/port/port").Completion) => void;
__previewFiles: (files: Record<string, { kind: PreviewInfo["kind"]; text: string }>) => void;
__previewFiles: (files: Record<string, { kind: PreviewInfo["kind"]; text?: string; base64?: string }>) => void;
// Sub-agent runs the kernel would list, each with the conversation it would return.
__subagents: (runs: { run: SubagentRun; messages: HistoryMessage[] }[]) => void;
// What folding a stored transcript into cards costs on its own, with no
@@ -628,13 +628,15 @@ window.__toast = pushToast;
window.__syntax = { apply: applyPack, mount: mountEditor };
window.__feed = (ev) => hub.feeds.forEach((p) => p.feed(ev));
window.__panes = () => hub.feeds.size;
const fixtureBytes = (f: { text?: string; base64?: string }) =>
f.base64 !== undefined ? Uint8Array.from(atob(f.base64), (c) => c.charCodeAt(0)) : new TextEncoder().encode(f.text ?? "");
window.__previewFiles = (files) => hub.feeds.forEach((port) => {
port.workspacePreviewInfo = async (ref) => ({
ref, kind: files[ref.path].kind, size: new TextEncoder().encode(files[ref.path].text).length,
ref, kind: files[ref.path].kind, size: fixtureBytes(files[ref.path]).length,
version: "fixture", limit: 32 << 20, readOnly: false,
});
port.workspacePreviewContent = async (ref) => ({
data: new TextEncoder().encode(files[ref.path].text).buffer, version: "fixture",
data: fixtureBytes(files[ref.path]).buffer, version: "fixture",
});
});
window.__subagents = (runs) => hub.feeds.forEach((port) => {
@@ -0,0 +1,47 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { chromium } from "playwright";
// OFFICE_SAMPLES names a folder holding report.docx and sales.xlsx; OUT where the shots go.
const dir = process.env.OFFICE_SAMPLES;
const out = process.env.OUT ?? "/tmp";
assert.ok(dir, "set OFFICE_SAMPLES");
const files = {
"report.docx": { kind: "office", base64: readFileSync(join(dir, "report.docx")).toString("base64") },
"sales.xlsx": { kind: "office", base64: readFileSync(join(dir, "sales.xlsx")).toString("base64") },
};
const browser = await chromium.launch(process.env.CHROME ? { executablePath: process.env.CHROME } : {});
try {
for (const [scheme, width] of [["light", 1440], ["dark", 1440], ["light", 390]]) {
const page = await browser.newPage({ viewport: { width, height: 900 }, colorScheme: scheme });
const errors = [];
const requests = [];
page.on("pageerror", (err) => errors.push(err.message));
page.on("request", (req) => requests.push(req.url()));
await page.goto(process.env.PERF_URL ?? "http://127.0.0.1:4399/perf.html?pref=zh", { waitUntil: "networkidle" });
await page.waitForFunction(() => window.__panes?.() > 0);
await page.evaluate((files) => {
window.__previewFiles(files);
window.__feed({ kind: "turn_started", authoredTurn: 1, msgIndex: 0, text: "整理季度报告" });
window.__feed({ kind: "tool_dispatch", tool: { id: "present", name: "present_files", readOnly: true } });
window.__feed({ kind: "tool_result", tool: { id: "present", name: "present_files", readOnly: true,
output: "Presented files", deliveries: Object.keys(files).map((path) => ({ path, description: "季度材料" })) } });
window.__feed({ kind: "text", text: "报告和表格已经生成。" });
window.__feed({ kind: "turn_done" });
}, files);
for (const name of width < 600 ? ["sales.xlsx"] : ["report.docx", "sales.xlsx"]) {
await page.locator(".delivery-open").filter({ hasText: name }).click();
const frame = page.frameLocator('iframe[title="Office 文档预览"]');
await frame.locator(name.endsWith("docx") ? "section.docx" : "table").first().waitFor({ timeout: 15000 });
assert.equal(await page.locator('iframe[title="Office 文档预览"]').getAttribute("sandbox"), "");
await page.waitForTimeout(300);
await page.screenshot({ path: join(out, `office-${name.split(".")[1]}-${scheme}-${width}.png`) });
}
assert.deepEqual(errors, []);
assert.deepEqual(requests.filter((u) => !u.startsWith("http://127.0.0.1") && !u.startsWith("http://localhost") && !u.startsWith("data:") && !u.startsWith("blob:")), []);
await page.close();
}
} finally {
await browser.close();
}
File diff suppressed because it is too large. Load diff
+1
View File
@@ -110,6 +110,7 @@ export const ACTIONS: UIAction[] = [
{ id: "appearance.new-answer-marker", kind: "view", target: "none", proof: "interaction" },
{ id: "appearance.plan-progress", kind: "view", target: "none", proof: "interaction" },
{ id: "appearance.composer-hint", kind: "view", target: "none", proof: "interaction" },
{ id: "appearance.office-preview", kind: "view", target: "none", proof: "interaction" },
{ id: "appearance.graphics", kind: "view", target: "none", proof: "interaction" },
{ id: "window.global-shortcut", kind: "shell-native", target: "none", proof: "browser" },
{ id: "appearance.session-tabs", kind: "view", target: "none", proof: "interaction" },
@@ -3,6 +3,7 @@ import type { UIAction } from "./actions";
export const DOCUMENT_ACTIONS: UIAction[] = [
{ id: "document.zoom", kind: "view", target: "entity", proof: "interaction" },
{ id: "document.page", kind: "view", target: "entity", proof: "interaction" },
{ id: "document.sheet", kind: "view", target: "entity", proof: "interaction" },
{ id: "document.scroll", kind: "view", target: "entity", proof: "interaction" },
{ id: "document.refresh", kind: "repeatable", target: "entity", proof: "interaction" },
{ id: "delivery.preview", kind: "navigation", target: "entity", proof: "interaction" },
@@ -39,4 +39,11 @@ export const EN_DOCUMENT: Record<string, string> = {
"文件不是有效的 UTF-8 文本": "The file is not valid UTF-8 text.",
"读取期间文件发生变化,请刷新": "The file changed while being read. Refresh to try again.",
"文件预览读取失败,请重试": "Could not read the file preview. Try again.",
"只读预览,宏、脚本和外部资源未加载": "Read-only preview. Macros, scripts and external resources are not loaded.",
"无法解析这个文件,可以用系统应用打开": "This file could not be read. Open it in the system app instead.",
"工作表": "Sheets",
"只显示前 {rows} 行、{cols} 列": "Showing the first {rows} rows and {cols} columns only.",
"这个工作簿没有可见的工作表": "This workbook has no visible sheets.",
"Office 文档预览": "Office document preview",
"Office 文档预览已在设置中关闭": "Office document preview is turned off in Settings.",
};
@@ -1159,6 +1159,7 @@ export const EN_SETTINGS: Record<string, string> = {
"收起时显示计划进度": "Show collapsed plan progress",
"在收起的计划卡片下方显示进度条,随已完成步骤更新。默认关闭,仅保存在本机。": "Show a progress bar below the collapsed plan card, updated as steps complete. Off by default; saved only on this machine.",
"输入框提示": "Composer hint",
"在工作台里只读预览 Word(.docx)和 Excel(.xlsx)。首次打开这类文件时才加载,在隔离的框中显示,不运行宏和脚本。关闭后只能用系统应用打开。仅保存在本机。": "Read-only preview of Word (.docx) and Excel (.xlsx) files in the workbench. It loads the first time such a file is opened and renders in an isolated frame without running macros or scripts. When off, these files open only in the system app. Saved on this device only.",
"在空白任务输入框中显示任务和排队提示。默认显示,仅保存在本机,不影响输入和发送。": "Show task and queue guidance in the empty composer. On by default; saved only on this machine. Does not affect typing or sending.",
"计划进度": "Plan progress",
"折叠预设": "Folding preset",
+1 -1
View File
@@ -106,7 +106,7 @@ export interface FileRef {
export interface PreviewInfo {
ref: FileRef;
kind: "markdown" | "text" | "image" | "pdf" | "table" | "html" | "unsupported";
kind: "markdown" | "text" | "image" | "pdf" | "table" | "html" | "office" | "unsupported";
size: number;
version: string;
limit: number;
+27
View File
@@ -187,6 +187,33 @@ export function setShowsComposerHint(on: boolean): void {
composerHintListeners.forEach((fn) => fn());
}
const OFFICE_PREVIEW_KEY = "rx-office-preview";
const officePreviewListeners = new Set<() => void>();
export function previewsOffice(): boolean {
try {
return localStorage.getItem(OFFICE_PREVIEW_KEY) !== "off";
} catch {
return true;
}
}
export function onOfficePreviewChange(fn: () => void): () => void {
officePreviewListeners.add(fn);
return () => {
officePreviewListeners.delete(fn);
};
}
export function setPreviewsOffice(on: boolean): void {
try {
localStorage.setItem(OFFICE_PREVIEW_KEY, on ? "on" : "off");
} catch {
/* unavailable storage keeps the default */
}
officePreviewListeners.forEach((fn) => fn());
}
const PLAN_PROGRESS_KEY = "rx-plan-progress";
const planProgressListeners = new Set<() => void>();
@@ -20,6 +20,7 @@ import { LiveStreamBudgetSection } from "./LiveStreamBudgetSection";
import { NewAnswerSection } from "./NewAnswerSection";
import { PlanProgressSection } from "./PlanProgressSection";
import { ComposerHintSection } from "./ComposerHintSection";
import { OfficePreviewSection } from "./OfficePreviewSection";
import { SessionQuickSection } from "./SessionQuickSection";
import { NavEntriesSection } from "./NavEntriesSection";
import { NavRailSection } from "./NavRailSection";
@@ -576,6 +577,7 @@ export function Appearance({ port, theme, onTheme, contrast, onContrast, weight,
<GraphicsSection />
<SendShortcut />
<ComposerHintSection />
<OfficePreviewSection />
<RunningMessageSetting />
<EscStopSetting />
<GlobalShortcutSection />
@@ -0,0 +1,16 @@
import { useSyncExternalStore } from "react";
import { t } from "../i18n";
import { onOfficePreviewChange, previewsOffice, setPreviewsOffice } from "../state/prefs";
import { Group } from "./Group";
export function OfficePreviewSection() {
const on = useSyncExternalStore(onOfficePreviewChange, previewsOffice, previewsOffice);
return (
<Group id="office-preview" title={t("Office 文档预览")} hint={t("在工作台里只读预览 Word(.docx)和 Excel(.xlsx)。首次打开这类文件时才加载,在隔离的框中显示,不运行宏和脚本。关闭后只能用系统应用打开。仅保存在本机。")}>
<div className="seg" data-text role="group" aria-label={t("Office 文档预览")}>
<button data-action="appearance.office-preview" data-value="off" aria-pressed={!on} onClick={() => setPreviewsOffice(false)}>{t("关闭")}</button>
<button data-action="appearance.office-preview" data-value="on" aria-pressed={on} onClick={() => setPreviewsOffice(true)}>{t("预览")}</button>
</div>
</Group>
);
}
@@ -1,4 +1,5 @@
import { lazy, Suspense, useEffect, useState } from "react";
import { lazy, Suspense, useEffect, useState, useSyncExternalStore } from "react";
import { onOfficePreviewChange, previewsOffice } from "../../state/prefs";
import type { AgentPort } from "../../port/port";
import { t } from "../../i18n";
import { useDocument } from "./useDocument";
@@ -9,6 +10,7 @@ import type { DocumentViewState } from "./viewState";
const PdfPreview = lazy(() => import("./PdfPreview"));
const DelimitedPreview = lazy(() => import("./DelimitedPreview"));
const StaticHtmlPreview = lazy(() => import("./StaticHtmlPreview"));
const OfficePreview = lazy(() => import("./OfficePreview"));
function Picture({ data, path, view }: { data: ArrayBuffer; path: string; view: DocumentViewState }) {
const [url, setURL] = useState("");
@@ -38,11 +40,15 @@ export default function DocumentPreview({ port, path, shown, refresh, onOpenExte
view: DocumentViewState;
}) {
const { value, reload } = useDocument(port, path, shown, refresh);
const officeOn = useSyncExternalStore(onOfficePreviewChange, previewsOffice, previewsOffice);
const { info, data, error } = value ?? {};
const officeWaiting = officeOn && info?.kind === "office" && !data && !error;
useEffect(() => { if (officeWaiting) reload(); }, [officeWaiting, reload]);
const fallback = <p role="alert">{t("此文件暂不支持预览")}</p>;
let body = <p role="status">{t("正在读取…")}</p>;
if (error) body = <p role="alert">{error}</p>;
else if (info?.kind === "unsupported") body = fallback;
else if (info?.kind === "office" && !officeOn) body = <p role="status">{t("Office 文档预览已在设置中关闭")}</p>;
else if (info && info.size > info.limit) body = <p role="alert">{t("文件超过预览大小限制")}</p>;
else if (data && info) {
const text = () => new TextDecoder("utf-8", { fatal: true }).decode(data);
@@ -51,6 +57,7 @@ export default function DocumentPreview({ port, path, shown, refresh, onOpenExte
case "table": body = <DelimitedPreview path={path} text={text()} view={view} />; break;
case "html": body = <StaticHtmlPreview text={text()} />; break;
case "image": body = <Picture data={data} path={path} view={view} />; break;
case "office": body = <OfficePreview data={data} path={path} view={view} />; break;
default: body = fallback;
}
}
@@ -0,0 +1,69 @@
import { useEffect, useMemo, useRef, useState } from "react";
import { t } from "../../i18n";
import { staticDocument } from "./staticDocument";
import { readWorkbook, SHEET_COLUMN_LIMIT, SHEET_ROW_LIMIT, type SheetView } from "./officeSheet";
import { fitZoom, readWordDocument, type WordView } from "./officeWord";
import type { DocumentViewState } from "./viewState";
type Parsed = { kind: "word"; word: WordView } | { kind: "sheets"; sheets: SheetView[] } | { kind: "failed" };
function useWidth() {
const ref = useRef<HTMLDivElement>(null);
const [width, setWidth] = useState(0);
useEffect(() => {
const el = ref.current;
if (!el || typeof ResizeObserver === "undefined") return;
const observer = new ResizeObserver(([entry]) => setWidth(Math.round(entry.contentRect.width)));
observer.observe(el);
return () => observer.disconnect();
}, []);
return [ref, width] as const;
}
export default function OfficePreview({ data, path, view }: { data: ArrayBuffer; path: string; view: DocumentViewState }) {
const [parsed, setParsed] = useState<Parsed>();
const [sheet, updateSheet] = useState(view.sheet ?? 0);
const setSheet = (i: number) => { view.sheet = i; updateSheet(i); };
const [box, width] = useWidth();
const [chosen, updateZoom] = useState(view.zoom);
const setZoom = (value: number | undefined) => { view.zoom = value; updateZoom(value); };
useEffect(() => {
let live = true;
setParsed(undefined);
const read = /\.xlsx$/i.test(path)
? readWorkbook(data).then((sheets): Parsed => ({ kind: "sheets", sheets }))
: readWordDocument(data).then((word): Parsed => ({ kind: "word", word }));
read.catch((): Parsed => ({ kind: "failed" })).then((p) => { if (live) setParsed(p); });
return () => { live = false; };
}, [data, path]);
const current = parsed?.kind === "sheets" ? parsed.sheets[Math.min(sheet, parsed.sheets.length - 1)] : undefined;
const fit = parsed?.kind === "word" ? fitZoom(width, parsed.word.pageWidth) : 1;
const zoom = chosen ?? fit;
const html = parsed?.kind === "word" ? `<style>body{zoom:${zoom}}</style>${parsed.word.html}` : current?.html;
const doc = useMemo(() => html === undefined ? "" : staticDocument(html), [html]);
let body;
if (!parsed) body = <p role="status">{t("正在读取…")}</p>;
else if (parsed.kind === "failed") body = <p role="alert">{t("无法解析这个文件,可以用系统应用打开")}</p>;
else if (parsed.kind === "sheets" && !current) body = <p role="status">{t("这个工作簿没有可见的工作表")}</p>;
else body = <iframe className="document-html document-office" title={t("Office 文档预览")} sandbox="" srcDoc={doc} />;
return <div className="document-office-view" ref={box}>
{parsed && parsed.kind !== "failed" && <p className="document-notice">{t("只读预览,宏、脚本和外部资源未加载")}</p>}
{parsed?.kind === "word" && (
<div className="document-controls">
<button data-action="document.zoom" data-target={path} onClick={() => setZoom(Math.max(0.25, zoom - 0.1))} aria-label={t("缩小")}>−</button>
<button data-action="document.zoom" data-target={path} aria-pressed={chosen === undefined} onClick={() => setZoom(undefined)}>{t("适应宽度")} · {Math.round(zoom * 100)}%</button>
<button data-action="document.zoom" data-target={path} onClick={() => setZoom(Math.min(3, zoom + 0.1))} aria-label={t("放大")}>+</button>
</div>
)}
{parsed?.kind === "sheets" && parsed.sheets.length > 1 && (
<div className="document-controls" role="tablist" aria-label={t("工作表")}>
{parsed.sheets.map((s, i) => (
<button key={s.name} role="tab" aria-selected={s === current} data-action="document.sheet" data-target={s.name} onClick={() => setSheet(i)}>{s.name}</button>
))}
</div>
)}
{current?.clipped && <p className="document-notice">{t("只显示前 {rows} 行、{cols} 列", { rows: SHEET_ROW_LIMIT, cols: SHEET_COLUMN_LIMIT })}</p>}
{body}
</div>;
}
@@ -1,4 +1,4 @@
.document-preview, .document-pdf {
.document-preview, .document-pdf, .document-office-view {
display: flex;
flex: 1;
flex-direction: column;
@@ -23,6 +23,15 @@
.document-notice, .document-preview > p {
padding: 8px 12px;
}
.document-office-view > .document-notice {
margin: 0;
font-size: 12px;
font-weight: normal;
color: var(--muted);
}
.document-controls [role="tab"][aria-selected="true"] {
border-color: var(--text);
}
.document-html {
flex: 1;
width: 100%;
@@ -0,0 +1,56 @@
// @vitest-environment jsdom
import { act, cleanup, render, screen, waitFor } from "@testing-library/react";
import ExcelJS from "exceljs";
import { afterEach, beforeEach, expect, it, vi } from "vitest";
import { MockPort } from "../../port/mock";
import { setPreviewsOffice } from "../../state/prefs";
import DocumentPreview from "./DocumentPreview";
import { previewKind } from "./registry";
beforeEach(() => localStorage.clear());
afterEach(() => cleanup());
async function sheetBytes(): Promise<ArrayBuffer> {
const book = new ExcelJS.Workbook();
book.addWorksheet("Q3").addRow(["区域", 1100]);
return new Uint8Array((await book.xlsx.writeBuffer()) as ArrayBuffer).slice().buffer;
}
function officePort(data: ArrayBuffer) {
const port = new MockPort();
vi.spyOn(port, "workspacePreviewInfo").mockResolvedValue({ ref: { path: "budget.xlsx" }, kind: "office", version: "v1", size: data.byteLength, limit: 32 << 20, readOnly: false });
const content = vi.spyOn(port, "workspacePreviewContent").mockResolvedValue({ data, version: "v1" });
return { port, content };
}
it("classes the formats it can read as office and leaves the rest unsupported", () => {
expect(previewKind("报告.docx")).toBe("office");
expect(previewKind("budget.XLSX")).toBe("office");
for (const name of ["old.doc", "old.xls", "deck.pptx", "old.ppt"]) expect(previewKind(name)).toBe("unsupported");
});
it("renders a workbook in a sandboxed frame", async () => {
const { port } = officePort(await sheetBytes());
render(<DocumentPreview port={port} path="budget.xlsx" shown refresh="" view={{}} />);
const frame = await screen.findByTitle("Office 文档预览", undefined, { timeout: 5000 });
expect(frame.getAttribute("sandbox")).toBe("");
expect(frame.getAttribute("srcdoc")).toContain("1100");
expect(frame.getAttribute("srcdoc")).toContain("Content-Security-Policy");
});
it("with the setting off reads no content, and turning it on loads the preview", async () => {
setPreviewsOffice(false);
const { port, content } = officePort(await sheetBytes());
render(<DocumentPreview port={port} path="budget.xlsx" shown refresh="" view={{}} />);
await screen.findByText("Office 文档预览已在设置中关闭");
expect(content).not.toHaveBeenCalled();
act(() => setPreviewsOffice(true));
await waitFor(() => expect(content).toHaveBeenCalled());
expect(await screen.findByTitle("Office 文档预览", undefined, { timeout: 5000 })).toBeTruthy();
});
it("says so instead of breaking when the file is not a real workbook", async () => {
const { port } = officePort(new TextEncoder().encode("not a zip").buffer);
render(<DocumentPreview port={port} path="budget.xlsx" shown refresh="" view={{}} />);
expect(await screen.findByText("无法解析这个文件,可以用系统应用打开", undefined, { timeout: 5000 })).toBeTruthy();
});
@@ -0,0 +1,102 @@
// @vitest-environment jsdom
import ExcelJS from "exceljs";
import JSZip from "jszip";
import { expect, it } from "vitest";
import { displayText, readWorkbook, SHEET_ROW_LIMIT } from "./officeSheet";
import { fitZoom, readWordDocument } from "./officeWord";
import { staticDocument } from "./staticDocument";
async function workbook(build: (book: ExcelJS.Workbook) => void): Promise<ArrayBuffer> {
const book = new ExcelJS.Workbook();
build(book);
const buf = await book.xlsx.writeBuffer();
return new Uint8Array(buf as ArrayBuffer).slice().buffer;
}
const parse = (html: string) => new DOMParser().parseFromString(staticDocument(html), "text/html");
it("shows a cell the way its own number format writes it", () => {
expect(displayText(1100, "#,##0")).toBe("1,100");
expect(displayText(0.31, "0.0%")).toBe("31.0%");
expect(displayText(-0.04, "0.0%")).toBe("-4.0%");
expect(displayText(1100)).toBe("1100");
expect(displayText({ formula: "SUM(A1:A2)", result: 2500 } as ExcelJS.CellValue, "#,##0")).toBe("2,500");
expect(displayText({ richText: [{ text: "华" }, { text: "东" }] } as ExcelJS.CellValue)).toBe("华东");
expect(displayText(new Date(Date.UTC(2026, 9, 1)), "yyyy-mm-dd")).toBe("2026-10-01");
expect(displayText(12, "[bogus")).toBe("12");
});
it("renders sheets with merges, styles and escaped text, skipping hidden ones", async () => {
const data = await workbook((book) => {
const ws = book.addWorksheet("Q3");
ws.addRow(["区域", "7月"]);
ws.addRow(["<script>alert(1)</script>", 1100]);
ws.getCell("B2").numFmt = "#,##0";
ws.getCell("A1").font = { bold: true, color: { argb: "FFFFFFFF" } };
ws.getCell("A1").fill = { type: "pattern", pattern: "solid", fgColor: { argb: "FF1F4E79" } };
ws.mergeCells("A3:B3");
ws.getCell("A3").value = "备注";
book.addWorksheet("secret", { state: "hidden" }).addRow(["hidden"]);
});
const sheets = await readWorkbook(data);
expect(sheets.map((s) => s.name)).toEqual(["Q3"]);
const doc = parse(sheets[0].html);
expect(doc.querySelector("script")).toBeNull();
expect(doc.querySelector('[data-cell="A2"]')?.textContent).toBe("<script>alert(1)</script>");
expect(doc.querySelector('[data-cell="B2"]')?.textContent).toBe("1,100");
const header = doc.querySelector<HTMLElement>('[data-cell="A1"]')!;
expect(header.style.background).toContain("rgb(31, 78, 121)");
expect(header.style.fontWeight).toBe("bold");
expect(doc.querySelector('[data-cell="A3"]')?.getAttribute("colspan")).toBe("2");
expect(doc.querySelector('[data-cell="B3"]')).toBeNull();
});
it("clips a sheet past the row limit and says so", async () => {
const data = await workbook((book) => {
const ws = book.addWorksheet("big");
for (let i = 0; i < SHEET_ROW_LIMIT + 5; i++) ws.addRow([i]);
});
const [sheet] = await readWorkbook(data);
expect(sheet.clipped).toBe(true);
expect(sheet.rows).toBe(SHEET_ROW_LIMIT + 5);
expect(parse(sheet.html).querySelectorAll("tbody tr")).toHaveLength(SHEET_ROW_LIMIT);
});
async function wordFile(body: string): Promise<ArrayBuffer> {
const zip = new JSZip();
zip.file("[Content_Types].xml", '<?xml version="1.0"?><Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types"><Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/><Default Extension="xml" ContentType="application/xml"/><Override PartName="/word/document.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml"/></Types>');
zip.file("_rels/.rels", '<?xml version="1.0"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="word/document.xml"/></Relationships>');
zip.file("word/document.xml", `<?xml version="1.0"?><w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:body>${body}</w:body></w:document>`);
return zip.generateAsync({ type: "arraybuffer" });
}
it("renders a Word body to static HTML that keeps its text and runs nothing", async () => {
const data = await wordFile('<w:p><w:r><w:rPr><w:b/></w:rPr><w:t>季度报告</w:t></w:r><w:r><w:t xml:space="preserve"> &lt;img src=x onerror=alert(1)&gt;</w:t></w:r></w:p>');
const word = await readWordDocument(data);
const doc = parse(word.html);
expect(doc.body.textContent).toContain("季度报告");
expect(doc.body.textContent).toContain("<img src=x onerror=alert(1)>");
expect(doc.querySelector("img,script,[onerror]")).toBeNull();
expect(doc.querySelector('meta[http-equiv="Content-Security-Policy"]')).not.toBeNull();
});
it("refuses something that is not an Office package", async () => {
await expect(readWorkbook(new TextEncoder().encode("not a zip").buffer)).rejects.toThrow();
await expect(readWordDocument(new TextEncoder().encode("not a zip").buffer)).rejects.toThrow();
});
it("draws Symbol-font bullets as the glyphs they stand for", async () => {
const data = await wordFile('<w:p><w:r><w:t>\uF0B7 one \uF0A7 two</w:t></w:r></w:p>');
const { html } = await readWordDocument(data);
expect(html).toContain("• one ▪ two");
expect(html).not.toMatch(/[\uF020-\uF0FF]/);
});
it("fits a page to the frame in 5% steps and never past 100%", () => {
expect(fitZoom(848, 816)).toBe(1);
expect(fitZoom(2000, 816)).toBe(1);
expect(fitZoom(520, 816)).toBe(0.55);
expect(fitZoom(100, 816)).toBe(0.25);
expect(fitZoom(520, 0)).toBe(1);
expect(fitZoom(0, 816)).toBe(1);
});
@@ -0,0 +1,114 @@
import type { Cell, CellValue, Workbook, Worksheet } from "exceljs";
import * as SSF from "ssf";
export const SHEET_ROW_LIMIT = 2000;
export const SHEET_COLUMN_LIMIT = 200;
export interface SheetView {
name: string;
html: string;
rows: number;
columns: number;
clipped: boolean;
}
const escape = (s: string) => s.replace(/[&<>"']/g, (c) => `&#${c.charCodeAt(0)};`);
const color = (argb: unknown) => typeof argb === "string" && /^[0-9a-f]{8}$/i.test(argb) ? `#${argb.slice(2)}` : "";
const EXCEL_EPOCH = Date.UTC(1899, 11, 30);
function raw(value: CellValue): unknown {
if (value === null || value === undefined) return "";
if (value instanceof Date) return value;
if (typeof value !== "object") return value;
if ("richText" in value) return value.richText.map((part) => part.text).join("");
if ("result" in value) return value.result === undefined ? "" : raw(value.result as CellValue);
if ("error" in value) return value.error;
if ("text" in value) return value.text;
return "";
}
/** The text Excel would show: the cell's own number format applied to its value. */
export function displayText(value: CellValue, numFmt?: string): string {
const v = raw(value);
const format = numFmt && numFmt !== "General" ? numFmt : undefined;
try {
if (v instanceof Date) return SSF.format(format ?? "yyyy-mm-dd", (v.getTime() - EXCEL_EPOCH) / 864e5);
if (typeof v === "number" && format) return SSF.format(format, v);
} catch {
// An unparseable format falls back to the plain value rather than failing the sheet.
}
if (v instanceof Date) return v.toISOString().slice(0, 10);
return String(v);
}
function cellStyle(cell: Cell): string {
const parts: string[] = [];
const fill = cell.fill?.type === "pattern" ? color(cell.fill.fgColor?.argb) : "";
if (fill) parts.push(`background:${fill}`);
const font = cell.font;
if (font?.bold) parts.push("font-weight:bold");
if (font?.italic) parts.push("font-style:italic");
const fg = color(font?.color?.argb);
if (fg) parts.push(`color:${fg}`);
if (typeof font?.size === "number" && font.size > 0 && font.size < 200) parts.push(`font-size:${font.size}pt`);
const align = cell.alignment?.horizontal;
if (align === "left" || align === "center" || align === "right") parts.push(`text-align:${align}`);
else if (typeof raw(cell.value) === "number") parts.push("text-align:right");
return parts.join(";");
}
function decodeRange(range: string): { top: number; left: number; bottom: number; right: number } | undefined {
const m = /^([A-Z]+)(\d+):([A-Z]+)(\d+)$/.exec(range);
if (!m) return undefined;
const col = (s: string) => [...s].reduce((n, ch) => n * 26 + ch.charCodeAt(0) - 64, 0);
return { top: Number(m[2]), left: col(m[1]), bottom: Number(m[4]), right: col(m[3]) };
}
export function sheetView(ws: Worksheet): SheetView {
const rows = Math.min(ws.rowCount, SHEET_ROW_LIMIT);
const columns = Math.min(ws.columnCount, SHEET_COLUMN_LIMIT);
const span = new Map<string, { rows: number; cols: number }>();
const covered = new Set<string>();
for (const range of (ws.model as { merges?: string[] }).merges ?? []) {
const r = decodeRange(range);
if (!r) continue;
span.set(`${r.top}:${r.left}`, { rows: r.bottom - r.top + 1, cols: r.right - r.left + 1 });
for (let y = r.top; y <= r.bottom; y++) for (let x = r.left; x <= r.right; x++) if (y !== r.top || x !== r.left) covered.add(`${y}:${x}`);
}
const widths: string[] = [];
for (let x = 1; x <= columns; x++) {
const w = ws.getColumn(x).width;
widths.push(`<col style="width:${Math.round(Math.min(Math.max(typeof w === "number" ? w : 9, 2), 120) * 7 + 10)}px">`);
}
let body = "";
for (let y = 1; y <= rows; y++) {
const row = ws.getRow(y);
let cells = `<th>${y}</th>`;
for (let x = 1; x <= columns; x++) {
if (covered.has(`${y}:${x}`)) continue;
const cell = row.getCell(x);
const merge = span.get(`${y}:${x}`);
const attrs = merge ? `${merge.rows > 1 ? ` rowspan="${merge.rows}"` : ""}${merge.cols > 1 ? ` colspan="${merge.cols}"` : ""}` : "";
const style = cellStyle(cell);
cells += `<td data-cell="${escape(cell.address)}"${attrs}${style ? ` style="${style}"` : ""}>${escape(displayText(cell.value, cell.numFmt))}</td>`;
}
body += `<tr>${cells}</tr>`;
}
let head = "<th></th>";
for (let x = 1; x <= columns; x++) head += `<th>${escape(ws.getColumn(x).letter)}</th>`;
const html = `<style>${SHEET_CSS}</style><table><colgroup><col style="width:40px">${widths.join("")}</colgroup><thead><tr>${head}</tr></thead><tbody>${body}</tbody></table>`;
return { name: ws.name, html, rows: ws.rowCount, columns: ws.columnCount, clipped: ws.rowCount > rows || ws.columnCount > columns };
}
const SHEET_CSS = "body{margin:0;font:13px system-ui,sans-serif;color:#1f1f1f;background:#fff}"
+ "table{border-collapse:collapse;table-layout:fixed}"
+ "td,th{border:1px solid #d9d9d9;padding:2px 6px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}"
+ "th{background:#f3f3f3;color:#666;font-weight:normal;position:sticky;top:0}"
+ "tbody th{position:sticky;left:0}";
export async function readWorkbook(data: ArrayBuffer): Promise<SheetView[]> {
const { default: ExcelJS } = await import("exceljs");
const book: Workbook = new ExcelJS.Workbook();
await book.xlsx.load(data);
return book.worksheets.filter((ws) => ws.state !== "hidden" && ws.state !== "veryHidden").map(sheetView);
}
@@ -0,0 +1,49 @@
const WORD_CSS = "body{margin:0;background:#e8e8e8}"
+ ".docx-wrapper{background:#e8e8e8;padding:16px 0}"
+ ".docx-wrapper>section.docx{margin:0 auto 16px;box-shadow:0 1px 4px rgba(0,0,0,.2)}";
// Word draws bullets with private-use code points from the Symbol and Wingdings
// fonts, which the preview cannot load; these are the Unicode glyphs they stand for.
const SYMBOL_BULLETS: Record<string, string> = {
"": "•", "": "▪", "": "□", "": "●", "": "■",
"": "➢", "": "❖", "": "✓", "": "➔", "": "◇",
};
export interface WordView {
html: string;
/** The first page's width in CSS pixels, 0 when the file declares none. */
pageWidth: number;
}
function pixels(width: string): number {
const m = /^([\d.]+)(pt|px|in|cm|mm)$/.exec(width.trim());
if (!m) return 0;
const per: Record<string, number> = { pt: 4 / 3, px: 1, in: 96, cm: 96 / 2.54, mm: 96 / 25.4 };
return Number(m[1]) * per[m[2]];
}
/** A Word file as static HTML: images inlined as data URLs, nothing fetched later. */
export async function readWordDocument(data: ArrayBuffer): Promise<WordView> {
const { renderAsync } = await import("docx-preview");
const body = document.createElement("div");
const styles = document.createElement("div");
await renderAsync(data, body, styles, {
inWrapper: true,
breakPages: true,
ignoreLastRenderedPageBreak: true,
useBase64URL: true,
renderHeaders: true,
renderFooters: true,
renderFootnotes: true,
});
const page = body.querySelector<HTMLElement>("section.docx");
const html = `<style>${WORD_CSS}</style>${styles.innerHTML}${body.innerHTML}`
.replace(/[-]/g, (c) => SYMBOL_BULLETS[c] ?? c);
return { html, pageWidth: page ? pixels(page.style.width) : 0 };
}
/** The zoom that fits a page of pageWidth into a frame of frameWidth, in 5% steps. */
export function fitZoom(frameWidth: number, pageWidth: number): number {
if (pageWidth <= 0 || frameWidth <= 0) return 1;
return Math.max(0.25, Math.min(1, Math.floor(((frameWidth - 32) / pageWidth) * 20) / 20));
}
@@ -7,7 +7,8 @@ export const renderers: ReadonlyArray<{ kind: Kind; extensions: RegExp; source:
{ kind: "pdf", extensions: /\.pdf$/i, source: false },
{ kind: "table", extensions: /\.(csv|tsv)$/i, source: true },
{ kind: "html", extensions: /\.html?$/i, source: true },
{ kind: "unsupported", extensions: /\.(docx?|xlsx?|pptx?|zip|gz|7z|rar|exe|dll|bin)$/i, source: false },
{ kind: "office", extensions: /\.(docx|xlsx)$/i, source: false },
{ kind: "unsupported", extensions: /\.(doc|xls|pptx?|zip|gz|7z|rar|exe|dll|bin)$/i, source: false },
];
export function previewKind(path: string): Kind {
@@ -3,6 +3,7 @@ import type { AgentPort } from "../../port/port";
import type { PreviewInfo } from "../../port/workspace";
import { parseGrantedFile } from "../grantedfiles";
import { reason } from "../../i18n/kernel";
import { previewsOffice } from "../../state/prefs";
type Loaded = { info: PreviewInfo; data?: ArrayBuffer; error?: string };
@@ -31,7 +32,7 @@ export function useDocument(port: AgentPort, path: string, shown: boolean, refre
observed = info;
if (controller.signal.aborted) return;
let value: Loaded;
if (info.kind === "unsupported" || info.size > info.limit) value = { info };
if (info.kind === "unsupported" || info.size > info.limit || (info.kind === "office" && !previewsOffice())) value = { info };
else if (!force && cache.current?.data && cache.current.info.version === info.version) value = { ...cache.current, info, error: undefined };
else {
const loaded = await port.workspacePreviewContent(ref, controller.signal);
@@ -3,6 +3,7 @@ export interface DocumentViewState {
left?: number;
page?: number;
zoom?: number;
sheet?: number;
}
export function documentState(states: Map<string, DocumentViewState>, path: string): DocumentViewState {
@@ -329,6 +329,7 @@ export const SETTINGS: SettingEntry[] = [
{ section: "appearance", anchor: "folding", title: "会话折叠", scope: "machine", apply: "immediate", keywords: ["展开", "收起", "思考", "执行过程", "步骤", "输出", "简报", "fold"] },
{ section: "appearance", anchor: "plan-progress", title: "收起时显示计划进度", scope: "machine", apply: "immediate", keywords: ["计划", "进度条", "待办", "收起", "todo", "plan", "progress", "collapsed"] },
{ section: "appearance", anchor: "composer-hint", title: "输入框提示", scope: "machine", apply: "immediate", keywords: ["占位", "任务", "提示", "输入框", "placeholder", "composer", "hint"] },
{ section: "appearance", anchor: "office-preview", title: "Office 文档预览", scope: "machine", apply: "immediate", keywords: ["office", "word", "excel", "docx", "xlsx", "工作台", "预览", "文档", "表格"] },
{ section: "appearance", anchor: "window", title: "窗口", scope: "machine", apply: "immediate", keywords: ["托盘", "关闭行为", "休眠", "睡眠"] },
// Immediate because the endpoint hands the holder every running sink reads,
// not because the file was written: writing it is what a restart would need.
+1 -1
View File
@@ -16,7 +16,7 @@ export const PAGES: Partial<Record<Section, readonly SettingPage[]>> = {
appearance: [
{ id: "look", label: "外观与主题", anchors: ["mode", "scheme", "font", "size", "weight", "contrast", "effects", "wallpaper", "language", "advanced-appearance"] },
{ id: "navigation", label: "导航与侧栏", anchors: ["navrail", "naventries", "session-quick-actions", "legacy-sessions", "live-grace", "open-session-limit", "live-stream-budget", "session-tabs"] },
{ id: "conversation", label: "会话显示", anchors: ["new-answer-marker", "composer-hint", "folding", "plan-progress"] },
{ id: "conversation", label: "会话显示", anchors: ["new-answer-marker", "composer-hint", "folding", "plan-progress", "office-preview"] },
{ id: "shortcuts", label: "快捷键", anchors: ["send-shortcut", "running-message-intent", "esc-stop", "global-shortcut", "keys"] },
{ id: "window", label: "窗口与性能", anchors: ["window", "notify", "page-sleep", "graphics"] },
],
+8 -5
View File
@@ -15,9 +15,10 @@ import (
)
const (
TextLimit = 2 << 20
ImageLimit = 10 << 20
PDFLimit = 32 << 20
TextLimit = 2 << 20
ImageLimit = 10 << 20
PDFLimit = 32 << 20
OfficeLimit = 32 << 20
)
var (
@@ -208,7 +209,7 @@ func (s Service) content(ctx context.Context, ref Ref, textOnly bool) ([]byte, I
if metadata(ref, after).Version != info.Version {
return nil, info, ErrChanged
}
if info.Kind != "pdf" && info.Kind != "image" && (!utf8.Valid(data) || strings.IndexByte(string(data), 0) >= 0) {
if info.Kind != "pdf" && info.Kind != "image" && info.Kind != "office" && (!utf8.Valid(data) || strings.IndexByte(string(data), 0) >= 0) {
return nil, info, ErrNotText
}
return data, info, nil
@@ -226,7 +227,9 @@ func Format(path string) (string, int64) {
return "pdf", PDFLimit
case ".png", ".jpg", ".jpeg", ".gif", ".webp", ".avif", ".bmp", ".ico", ".svg":
return "image", ImageLimit
case ".doc", ".docx", ".xls", ".xlsx", ".ppt", ".pptx", ".zip", ".gz", ".7z", ".rar", ".exe", ".dll", ".bin":
case ".docx", ".xlsx":
return "office", OfficeLimit
case ".doc", ".xls", ".ppt", ".pptx", ".zip", ".gz", ".7z", ".rar", ".exe", ".dll", ".bin":
return "unsupported", 0
default:
return "text", TextLimit
+20
View File
@@ -87,3 +87,23 @@ func TestGrantedRootAndCancellation(t *testing.T) {
t.Fatalf("canceled read = %v", err)
}
}
func TestOfficeDocumentsPreviewAsBinaryWithinTheirLimit(t *testing.T) {
root := t.TempDir()
zip := []byte("PK\x03\x04\x00binary office package")
s := Service{Primary: root}
for _, name := range []string{"报告.docx", "budget.XLSX"} {
if err := os.WriteFile(filepath.Join(root, name), zip, 0o600); err != nil {
t.Fatal(err)
}
got, info, err := s.Content(context.Background(), Ref{Path: name})
if err != nil || info.Kind != "office" || info.Limit != OfficeLimit || string(got) != string(zip) {
t.Fatalf("%s: kind=%q limit=%d err=%v", name, info.Kind, info.Limit, err)
}
}
for _, name := range []string{"old.doc", "old.xls", "slides.pptx", "old.ppt"} {
if kind, _ := Format(name); kind != "unsupported" {
t.Fatalf("%s: kind %q, want unsupported", name, kind)
}
}
}
@@ -0,0 +1,2 @@
- 新增:工作台可只读预览 Word(.docx)和 Excel(.xlsx),含多工作表、数字格式和合并单元格;在隔离框中显示、不运行宏和脚本,可在设置里关闭。关联 #14622。
- New: the workbench previews Word (.docx) and Excel (.xlsx) read-only in an isolated frame with no macros or scripts; it can be turned off in Settings. Refs #14622.