diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index dd613cd..30edad2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -27,6 +27,7 @@ jobs: cache: pnpm - run: pnpm install --frozen-lockfile - run: pnpm typecheck + - run: pnpm test:release - run: pnpm format:check - run: pnpm build - run: pnpm exec playwright install --with-deps chromium diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 3420387..1309616 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -25,6 +25,7 @@ jobs: - uses: actions/checkout@v6 with: ref: ${{ github.event.release.tag_name || github.sha }} + fetch-depth: 0 - uses: pnpm/action-setup@v6 with: version: 11.6.0 @@ -32,19 +33,13 @@ jobs: with: node-version: 24 cache: pnpm - - name: Validate stable release version + - name: Validate stable release version and tagged package source env: RELEASE_TAG: ${{ inputs.release_tag || github.event.release.tag_name }} - run: | - node --input-type=module <<'NODE' - import assert from 'node:assert/strict'; - import { readFileSync } from 'node:fs'; - const { version } = JSON.parse(readFileSync('package.json', 'utf8')); - assert.match(version, /^\d+\.\d+\.\d+$/); - assert.equal(process.env.RELEASE_TAG, `v${version}`); - NODE + run: node scripts/verify-release-source.mjs - run: pnpm install --frozen-lockfile - run: pnpm typecheck + - run: pnpm test:release - run: pnpm format:check - run: pnpm exec playwright install --with-deps - run: pnpm test @@ -54,7 +49,10 @@ jobs: - uses: actions/upload-artifact@v7 with: name: npm-tarball - path: artifacts/*.tgz + path: | + artifacts/*.tgz + artifacts/release.json + artifacts/publish-release.mjs if-no-files-found: error retention-days: 7 @@ -76,9 +74,4 @@ jobs: name: npm-tarball path: artifacts - name: Publish the verified tarball through npm trusted publishing - run: | - set -eu - set -- artifacts/*.tgz - [ "$#" -eq 1 ] - [ -f "$1" ] - npm publish "$PWD/$1" --ignore-scripts --access public --provenance --tag latest + run: node artifacts/publish-release.mjs artifacts/release.json diff --git a/CHANGELOG.md b/CHANGELOG.md index e7af851..c5dc700 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,16 @@ # Changelog +## 0.3.0 + +- Preserve unsaved Office edits when updating read-only mode or close-button visibility; serialize correlated mode changes without rebuilding the iframe. +- Retry editor readiness negotiation for slow startup and retain legacy editable compatibility; stream legacy chunks with cancellation and acknowledgement backpressure. +- Support ordinary HTTP hosts without `crypto.randomUUID`. +- Make PDF canvases responsive and cap per-page backing pixels/DPR; add opt-in virtual rendering with bounded active canvases and explicit cancellation lifetime. +- Expose the full custom element TypeScript API and safely identify constructors returned by manual registration. +- Remove the unused production ranui dependency and obsolete style transformer. +- Release demo-owned Blob URLs on replacement/disposal and explain the remote Office iframe privacy boundary. +- Verify release source inputs and exact artifact integrity; make matching-version publish retries idempotent. + ## 0.2.0 - Restore bubbling, composed `load` and `error` events; emit only for the current operation. diff --git a/README.md b/README.md index 22a90b8..fca7e4b 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@ or conversion server is required by this package. ## Install and automatic registration ```sh -npm install @ranui/preview@0.2.0 +npm install @ranui/preview@0.3.0 ``` ```ts @@ -53,20 +53,21 @@ The existing root namespace (`import { preview } from '@ranui/preview'`), ## API -| Attribute / property | Default | Behavior | -| --------------------- | ------------------------------ | ----------------------------------------------------------------------------------------------------------- | -| `src` | unset | PDF, DOCX, PPTX, XLSX or XLS URL; setting it opens the preview | -| `baseUrl` / `baseurl` | `https://edit.chaxus.com` | Office editor deployment URL, including a path/query if needed | -| `readonly` | false | Opt-in Office read-only mode; empty attribute or property `true` enables it; `readonly="false"` disables it | -| `closeable` | enabled | Set `closeable="false"` to hide the close control and disable Escape dismissal | -| `label` | trigger text or “Preview file” | Accessible name of the trigger | +| Attribute / property | Default | Behavior | +| ---------------------- | ------------------------------ | ----------------------------------------------------------------------------------------------------------- | +| `src` | unset | PDF, DOCX, PPTX, XLSX or XLS URL; setting it opens the preview | +| `baseUrl` / `baseurl` | `https://edit.chaxus.com` | Office editor deployment URL, including a path/query if needed | +| `readonly` | false | Opt-in Office read-only mode; empty attribute or property `true` enables it; `readonly="false"` disables it | +| `closeable` | enabled | Set `closeable="false"` to hide the close control and disable Escape dismissal | +| `pdfMode` / `pdf-mode` | `eager` | PDF rendering mode: all pages, or opt-in viewport rendering with `virtual` | +| `label` | trigger text or “Preview file” | Accessible name of the trigger | `src`, `label`, `baseUrl` and `closeable` retain their string property contracts; -`readonly` is a boolean property. Use attributes before registration completes. +`readonly` is a boolean property; `pdfMode` accepts `eager` or `virtual`. Use attributes before registration completes. Call `showPreview()` or `closePreview()` after upgrading the element. -`load` fires once when all PDF pages finish painting or the Office editor confirms -opening. `error` fires once on a download, unsupported format or renderer error; +`load` fires once when all PDF pages finish painting (default), the initial viewport +and buffer finish painting (`pdf-mode="virtual"`), or the Office editor confirms opening. `error` fires once on a download, unsupported format or renderer error; its `detail` contains the error. Both events bubble and cross shadow boundaries. Replacing the source, closing or disconnecting cancels the active operation and suppresses its obsolete events. Each element owns its dialog, iframe and worker. @@ -87,10 +88,39 @@ messages only from that iframe and its configured origin. Existing deployments using the `RENDER_OFFICE` chunk protocol remain supported in the default editable mode. An old renderer without the embed API cannot promise enforced read-only mode: requesting it returns an explicit error asking -you to update the deployment. Updating `readonly` while open reloads the file in -the requested mode. This controls editing in the renderer; it does not prevent +you to update the deployment. Updating `readonly` while open uses +`document:set-readonly` without reloading the file, preserving unsaved edits. Changing +`closeable` only updates the dialog control. Mode-change errors emit `error` while +leaving the existing editor available. This controls editing in the renderer; it does not prevent users from downloading or copying bytes they already have access to. +## PDF rendering and memory + +PDF canvases resize with their container. Backing stores are capped at DPR 2, +4,194,304 pixels per page and 8,192 pixels per dimension; unusually large pages +trade resolution for bounded allocation. Default `eager` mode still paints every +page before `load`, so total canvas memory grows with page count. + +For long documents, set ``. +Virtual mode maintains page-height placeholders and renders the viewport plus a +buffer, retaining at most six canvases. Scrolling and container resizing repaint +as needed. Its PDF worker remains alive until the preview closes, its source +changes or the element disconnects. Errors after initial `load` emit `error`. + +`document.createElement('r-preview')` exposes the full TypeScript element API. +Manual registration may return an unrelated constructor when a host already owns +the tag; narrow its result with `isPreviewConstructor` before constructing a viewer: + +```ts +import { registerPreview, isPreviewConstructor } from '@ranui/preview/manual'; +const Preview = await registerPreview('app-file-preview'); +if (isPreviewConstructor(Preview)) { + const element = new Preview(); + element.pdfMode = 'virtual'; + document.body.append(element); +} +``` + ## Intranet and offline deployment - **PDF:** serve the installed package's complete `dist` directory. Its bundled @@ -115,6 +145,10 @@ component makes no request to the remote Office editor. ## Compatibility and migration +0.3.0 preserves eager PDF completion and adds optional virtual rendering. UI and +read-only changes preserve the active Office editor. The unused production `ranui` +dependency and obsolete style transformer have been removed. + 0.2.0 preserves automatic `r-preview` registration and existing URL attributes, while adding manual registration and opt-in read-only behavior. It replaces vendored PDF.js 3.2 with 6.4 and corrects page numbering, completion, failures and @@ -134,6 +168,7 @@ pnpm exec playwright install pnpm typecheck pnpm format:check pnpm test +pnpm test:release pnpm build pnpm test:package ``` @@ -141,10 +176,14 @@ pnpm test:package Package verification installs the actual tarball in an isolated consumer and checks strict TypeScript, manual SSR imports, public exports and PDF assets. GitHub CI runs browser regressions and packaging checks. A stable GitHub release -`v0.2.0` runs the release checks and publishes the exact verified tarball through -npm trusted publishing. Configure `@ranui/preview` for repository `ranuts/fileview`, +`v0.3.0` runs the release checks and publishes the exact verified tarball through +npm trusted publishing. A release manifest records its SHA-512 integrity. Retrying +publishing skips an already published version only when registry integrity matches; +a mismatch fails. Manual retries verify the release tag's package inputs while +allowing workflow-only recovery fixes. Configure `@ranui/preview` for repository `ranuts/fileview`, workflow `release.yml`, environment `npm`, and allow direct `npm publish`. -Live Office checks and release coverage: [verification report](docs/verification-0.2.0.md). +Live Office checks and release coverage: [0.3.0 verification report](docs/verification-0.3.0.md) +and [0.2.0 report](docs/verification-0.2.0.md). See [CHANGELOG.md](CHANGELOG.md). Licensed under [AGPL-3.0](LICENSE). diff --git a/README.zh-CN.md b/README.zh-CN.md index 5efdc48..219736f 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -8,7 +8,7 @@ ## 安装和自动注册 ```sh -npm install @ranui/preview@0.2.0 +npm install @ranui/preview@0.3.0 ``` ```ts @@ -51,19 +51,21 @@ vue({ template: { compilerOptions: { isCustomElement: (tag) => tag === 'app-file ## API -| 属性 | 默认值 | 行为 | -| --------------------- | --------------------------- | ----------------------------------------------------------------------------- | -| `src` | 无 | PDF、DOCX、PPTX、XLSX、XLS 文件 URL;赋值后打开预览 | -| `baseUrl` / `baseurl` | `https://edit.chaxus.com` | Office 编辑器页面地址,可以带路径和查询参数 | -| `readonly` | false | 显式启用 Office 只读模式;空属性或 JS 属性 true 启用,`readonly="false"` 关闭 | -| `closeable` | 启用 | `closeable="false"` 隐藏关闭按钮,同时禁用 Escape 关闭 | -| `label` | 触发元素文字或 Preview file | 触发元素的无障碍名称 | +| 属性 | 默认值 | 行为 | +| ---------------------- | --------------------------- | ----------------------------------------------------------------------------- | +| `src` | 无 | PDF、DOCX、PPTX、XLSX、XLS 文件 URL;赋值后打开预览 | +| `baseUrl` / `baseurl` | `https://edit.chaxus.com` | Office 编辑器页面地址,可以带路径和查询参数 | +| `readonly` | false | 显式启用 Office 只读模式;空属性或 JS 属性 true 启用,`readonly="false"` 关闭 | +| `closeable` | 启用 | `closeable="false"` 隐藏关闭按钮,同时禁用 Escape 关闭 | +| `pdfMode` / `pdf-mode` | `eager` | PDF 全页渲染,或显式设置 `virtual` 按可见区域渲染 | +| `label` | 触发元素文字或 Preview file | 触发元素的无障碍名称 | `src`、`label`、`baseUrl`、`closeable` 保留原来的字符串属性约定;新增的 -`readonly` 是布尔 JavaScript 属性。注册完成前可以使用 HTML 属性。 +`readonly` 是布尔 JavaScript 属性;`pdfMode` 接受 `eager` 或 `virtual`。注册完成前可以使用 HTML 属性。 元素升级完成后可调用 `showPreview()` 和 `closePreview()`。 -PDF 所有页面绘制完成,或 Office 编辑器确认打开成功后,触发一次 `load`。 +默认 PDF 所有页面绘制完成、虚拟模式首屏及缓冲区域绘制完成,或 Office +编辑器确认打开成功后,触发一次 `load`。 下载、格式或编辑器错误触发一次 `error`,其 `detail` 为错误对象。 两个事件都可以冒泡并穿过 Shadow DOM。替换来源、关闭、移除元素会取消 当前操作,旧操作不会再发完成事件。每个实例独立管理弹窗、iframe 和 worker。 @@ -81,9 +83,36 @@ Office 渲染由 iframe 部署负责。当前 原来使用 `RENDER_OFFICE` 分块协议的自托管部署,在默认可编辑模式下继续 兼容。旧编辑器没有只读接口时,不能保证真正只读,因此请求只读会明确提示 -升级部署。预览过程中修改 `readonly` 会按新的模式重新打开原文件。 +升级部署。预览过程中修改 `readonly` 通过 `document:set-readonly` +更新当前编辑器,保留未保存的编辑内容;修改 `closeable` 只更新弹窗控件。 +模式切换失败会触发 `error`,并保留原有编辑器。 只读约束的是编辑器的编辑行为,无法阻止拥有文件的用户下载或复制内容。 +## PDF 渲染与内存 + +PDF 画布随容器宽度缩放。每页画布的 DPR 上限为 2,像素上限为 4,194,304, +单边上限为 8,192 像素。超大页面会降低分辨率以限制单页分配;默认 `eager` +模式仍在所有页面绘制完成后发出 `load`,总画布内存会随页数增加。 + +长文档可以设置 ``。 +虚拟模式保留页面高度占位,只绘制可见区域及缓冲区,同时最多保留六个画布; +滚动和容器尺寸变化会按需重绘。worker 保留到关闭预览、替换来源或移除元素。 +首屏 `load` 之后的渲染失败仍会触发 `error`。 + +`document.createElement('r-preview')` 现在具有完整的 TypeScript 元素接口。 +手动注册遇到已被宿主占用的标签时,可能返回其他组件的构造函数;创建实例前 +可以使用 `isPreviewConstructor` 判断: + +```ts +import { registerPreview, isPreviewConstructor } from '@ranui/preview/manual'; +const Preview = await registerPreview('app-file-preview'); +if (isPreviewConstructor(Preview)) { + const element = new Preview(); + element.pdfMode = 'virtual'; + document.body.append(element); +} +``` + ## 内网和离线部署 - **PDF:** 部署完整 `dist`。PDF.js API、worker 和 @@ -106,6 +135,9 @@ PDF 预览不会创建 Office iframe;仅注册组件、放置未打开的组 ## 兼容与迁移 +0.3.0 保留默认 PDF 全页完成语义,新增可选虚拟渲染。弹窗控件与只读切换 +保留当前 Office 编辑器;移除了未使用的生产依赖 ranui 和旧样式转换插件。 + 0.2.0 保留自动注册和现有 URL 属性,新增可选手动注册和只读模式。PDF.js 由内置 3.2 升至 6.4,修复页码、重复页面、完成时机、失败处理和 worker 清理。 CI 验证当前 Chromium、Firefox、WebKit;新引擎不再沿用未经验证的 @@ -122,16 +154,20 @@ pnpm exec playwright install pnpm typecheck pnpm format:check pnpm test +pnpm test:release pnpm build pnpm test:package ``` 包验证会在隔离目录安装实际 tarball,检查严格 TypeScript、手动入口 SSR、 公开入口和 PDF 资产。GitHub CI 执行浏览器回归与包验证。 -发布正式 GitHub Release `v0.2.0` 后,发布流程会验证并发布同一个 tarball。 -npm 可信发布配置为包 `@ranui/preview`、仓库 `ranuts/fileview`、工作流 +发布正式 GitHub Release `v0.3.0` 后,发布流程会验证并发布同一个 tarball。 +发布清单记录 SHA-512 完整性;重试时只有 registry 的同版本完整性匹配才会 +跳过发布,不匹配则失败。手动重试会核对 tag 的包输入文件,并允许仅工作流 +修复的恢复操作。npm 可信发布配置为包 `@ranui/preview`、仓库 `ranuts/fileview`、工作流 `release.yml`、环境 `npm`,并允许直接 `npm publish`。 -真实 Office 测试与覆盖边界见[验证报告](docs/verification-0.2.0.md)。 +真实 Office 测试与覆盖边界见 [0.3.0 验证报告](docs/verification-0.3.0.md) +和 [0.2.0 报告](docs/verification-0.2.0.md)。 变更见 [CHANGELOG.md](CHANGELOG.md),许可证为 [AGPL-3.0](LICENSE)。 diff --git a/components/preview/core.ts b/components/preview/core.ts index 82e5d76..91fa16a 100644 --- a/components/preview/core.ts +++ b/components/preview/core.ts @@ -1,5 +1,6 @@ import { DOCX, PDF, PPTX, XLS, XLSX } from './constant'; -import { renderOffice } from './office'; +import { renderOffice, type OfficeSession } from './office'; +import { createRequestId } from './request-id'; import less from './index.less?inline'; const DEFAULT_RENDERER = 'https://edit.chaxus.com'; @@ -15,12 +16,15 @@ function div(className: string, ...children: Node[]): HTMLDivElement { /** Browser implementation, loaded only when registration is explicitly requested. */ export class PreviewElement extends HTMLElement { - static observedAttributes = ['src', 'closeable', 'readonly', 'baseurl', 'label']; + static observedAttributes = ['src', 'closeable', 'readonly', 'baseurl', 'label', 'pdf-mode']; private modal?: HTMLDivElement; private context?: HTMLDivElement; private loading?: HTMLDivElement; private closeButton?: HTMLButtonElement; private operation?: AbortController; + private officeSession?: OfficeSession; + private activePdf = false; + private modeError?: HTMLDivElement; private previousFocus?: HTMLElement; private focusFrame?: number; @@ -68,6 +72,14 @@ export class PreviewElement extends HTMLElement { else this.removeAttribute('readonly'); } + /** Eager paints every page before load; virtual paints the initial viewport and buffer. */ + get pdfMode(): 'eager' | 'virtual' { + return this.getAttribute('pdf-mode') === 'virtual' ? 'virtual' : 'eager'; + } + set pdfMode(value: 'eager' | 'virtual') { + this.setAttribute('pdf-mode', value); + } + connectedCallback() { if (!this.hasAttribute('role')) this.setAttribute('role', 'button'); if (!this.hasAttribute('tabindex')) this.tabIndex = 0; @@ -84,6 +96,7 @@ export class PreviewElement extends HTMLElement { this.closePreview(false); this.modal?.remove(); this.modal = undefined; + this.closeButton = undefined; } attributeChangedCallback(name: string, oldValue: string | null, value: string | null) { if (oldValue === value || !this.isConnected) return; @@ -91,7 +104,13 @@ export class PreviewElement extends HTMLElement { else if (name === 'src') { if (value) this.showPreview(); else this.closePreview(); - } else if (this.modal?.style.display !== 'none' && this.modal) this.showPreview(); + } else if (this.modal?.style.display !== 'none' && this.modal) { + if (name === 'closeable') this.updateCloseButton(); + else if (name === 'readonly') this.updateReadonly(); + else if (name === 'pdf-mode') { + if (this.activePdf) this.showPreview(); + } else this.showPreview(); + } } private triggerKeydown = (event: KeyboardEvent) => { if (event.target === this && (event.key === 'Enter' || event.key === ' ')) { @@ -121,6 +140,10 @@ export class PreviewElement extends HTMLElement { closePreview = (restoreFocus: boolean | Event = true) => { this.operation?.abort(); this.operation = undefined; + this.officeSession = undefined; + this.activePdf = false; + this.modeError?.remove(); + this.modeError = undefined; this.context?.replaceChildren(); if (this.modal) this.modal.style.display = 'none'; if (this.focusFrame !== undefined) cancelAnimationFrame(this.focusFrame); @@ -141,7 +164,7 @@ export class PreviewElement extends HTMLElement { this.context = div('r-preview-context'); this.modal = div('r-preview-mask'); this.modal.id = document.getElementById('r-preview-mask') - ? `r-preview-mask-${crypto.randomUUID()}` + ? `r-preview-mask-${createRequestId()}` : 'r-preview-mask'; this.modal.setAttribute('role', 'dialog'); this.modal.setAttribute('aria-label', 'File preview'); @@ -152,6 +175,8 @@ export class PreviewElement extends HTMLElement { } private updateCloseButton() { const options = this.modal!.querySelector('.r-preview-options')!; + const hadFocus = this.closeButton === document.activeElement; + if (Boolean(this.closeButton) === (this.closeable !== 'false')) return; options.replaceChildren(); this.closeButton = undefined; if (this.closeable !== 'false') { @@ -164,6 +189,23 @@ export class PreviewElement extends HTMLElement { options.append(button); this.closeButton = button; } + if (hadFocus) (this.closeButton || this.modal)?.focus(); + } + private reportSessionError(error: unknown, controller: AbortController) { + if (controller.signal.aborted || this.operation !== controller || !this.isConnected) return; + this.modeError?.remove(); + this.modeError = div('r-preview-error'); + this.modeError.setAttribute('role', 'alert'); + this.modeError.textContent = error instanceof Error ? error.message : String(error); + this.context!.append(this.modeError); + this.dispatchEvent(new CustomEvent('error', { bubbles: true, composed: true, detail: error })); + } + private updateReadonly() { + if (!this.officeSession || !this.operation) return; + const controller = this.operation; + this.modeError?.remove(); + this.modeError = undefined; + void this.officeSession.setReadonly(this.readonly).catch((error) => this.reportSessionError(error, controller)); } private createLoading() { const badge = document.createElement('span'); @@ -195,6 +237,10 @@ export class PreviewElement extends HTMLElement { showPreview = () => { if (!this.src || !this.isConnected) return; this.operation?.abort(); + this.officeSession = undefined; + this.activePdf = false; + this.modeError?.remove(); + this.modeError = undefined; const controller = new AbortController(); this.operation = controller; if (!this.modal) this.createModal(); @@ -236,11 +282,22 @@ export class PreviewElement extends HTMLElement { this.loading!.querySelector('.r-preview-loading-name')!.textContent = name; this.loading!.querySelector('.r-preview-loading-badge')!.textContent = name.split('.').pop()!.toUpperCase(); if (type === PDF) { + this.activePdf = true; const container = div('r-preview-context-pdf'); this.context!.append(container); const { renderPdf } = await import('./pdf'); signal.throwIfAborted(); - await renderPdf(file, { dom: container, signal }); + let backgroundError = false; + await renderPdf(file, { + dom: container, + signal, + mode: this.pdfMode, + onError: (result) => { + if (backgroundError || !current()) return; + backgroundError = true; + this.reportSessionError(new Error(result?.message || 'PDF rendering failed'), controller); + }, + }); } else { const iframe = document.createElement('iframe'); iframe.title = 'Document preview'; @@ -251,7 +308,20 @@ export class PreviewElement extends HTMLElement { this.context!.append(iframe); const url = new URL(this.baseUrl, location.href); if (!['https:', 'http:'].includes(url.protocol)) throw new Error('baseUrl must be an HTTP(S) renderer URL'); - await renderOffice(file, { iframe, url, readonly: this.readonly, signal }); + const preview = this; + const session = await renderOffice(file, { + iframe, + url, + signal, + get readonly() { + return preview.readonly; + }, + }); + if (!current()) return; + this.officeSession = session; + // Attribute changes during fetch/readiness/open apply to this same editor. + if (this.readonly !== session.initialReadonly) + await session.setReadonly(this.readonly).catch((error) => this.reportSessionError(error, controller)); } if (!current()) return; this.loading?.remove(); diff --git a/components/preview/office-chunks.ts b/components/preview/office-chunks.ts new file mode 100644 index 0000000..4c9c8ff --- /dev/null +++ b/components/preview/office-chunks.ts @@ -0,0 +1,19 @@ +/** The ranuts MessageCodec wire format uses 16 KiB binary strings, not base64. */ +export async function* encodeOfficeChunks(file: File, signal: AbortSignal) { + const chunkSize = 16 * 1024; + const totalChunks = Math.ceil(file.size / chunkSize); + for (let chunkIndex = 0; chunkIndex < totalChunks; chunkIndex++) { + signal.throwIfAborted(); + const buffer = await file.slice(chunkIndex * chunkSize, (chunkIndex + 1) * chunkSize).arrayBuffer(); + signal.throwIfAborted(); + yield { + name: file.name, + type: file.type, + size: file.size, + lastModified: file.lastModified, + totalChunks, + chunkIndex, + data: String.fromCharCode(...new Uint8Array(buffer)), + }; + } +} diff --git a/components/preview/office.ts b/components/preview/office.ts index 3a87f08..4d04cca 100644 --- a/components/preview/office.ts +++ b/components/preview/office.ts @@ -1,4 +1,6 @@ -import { Client, MessageCodec } from 'ranuts/utils'; +import { Client } from 'ranuts/utils'; +import { createRequestId } from './request-id'; +import { encodeOfficeChunks } from './office-chunks'; interface OfficeOptions { iframe: HTMLIFrameElement; @@ -7,94 +9,197 @@ interface OfficeOptions { signal: AbortSignal; } -/** A connection belongs to one preview operation, never the global page. */ -export async function renderOffice(file: File, options: OfficeOptions): Promise { - const { iframe, url, readonly, signal } = options; +export interface OfficeSession { + initialReadonly: boolean; + setReadonly(value: boolean): Promise; +} + +function waitForRenderer(options: OfficeOptions, probeLegacy: () => Promise): Promise { + const { iframe, url, signal } = options; + signal.throwIfAborted(); + return new Promise((resolve, reject) => { + const id = createRequestId(); + let retry: ReturnType | undefined; + let finished = false; + let legacyStarted = false; + const deadline = setTimeout( + () => + finish( + false, + new Error( + options.readonly + ? 'Read-only mode requires a renderer with the document embed API; update your baseUrl deployment' + : 'Office renderer did not become ready', + ), + ), + 60_000, + ); + const onMessage = (event: MessageEvent) => { + if (event.source !== iframe.contentWindow || event.origin !== url.origin) return; + if (event.data?.type === 'document:ready' || (event.data?.id === id && event.data?.type === 'document:state')) + finish(true); + }; + const probe = () => { + try { + iframe.contentWindow?.postMessage({ id, type: 'document:get-state' }, url.origin); + } catch (error) { + finish(false, error); + } + }; + const onLoad = () => { + clearInterval(retry); + // Retry because embedded apps can install their message handler after load. + const loadedAt = performance.now(); + retry = setInterval(() => { + // Mode may change while the embedded app is still becoming ready. + if (!legacyStarted && !options.readonly && performance.now() - loadedAt >= 1_500) { + legacyStarted = true; + // Only an acknowledged chunk proves legacy support; keep modern probes alive meanwhile. + void probeLegacy().then( + () => finish(false), + (error) => finish(false, error), + ); + } + probe(); + }, 250); + probe(); + }; + const onAbort = () => finish(false, signal.reason); + const finish = (modern: boolean, error?: unknown) => { + if (finished) return; + finished = true; + clearTimeout(deadline); + clearInterval(retry); + window.removeEventListener('message', onMessage); + iframe.removeEventListener('load', onLoad); + signal.removeEventListener('abort', onAbort); + if (error) reject(error); + else resolve(modern); + }; + window.addEventListener('message', onMessage); + iframe.addEventListener('load', onLoad); + signal.addEventListener('abort', onAbort, { once: true }); + iframe.src = url.href; + }); +} + +function request( + { iframe, url, signal }: OfficeOptions, + type: string, + payload: object, + responseType: string, +): Promise> { signal.throwIfAborted(); - let bridgeId: string | undefined; - let dispose = () => {}; + return new Promise((resolve, reject) => { + const id = createRequestId(); + const timeout = setTimeout(() => finish(new Error('Office document request timed out')), 120_000); + const onAbort = () => finish(signal.reason); + const onMessage = (event: MessageEvent) => { + if (event.source !== iframe.contentWindow || event.origin !== url.origin || event.data?.id !== id) return; + if (event.data.type === 'document:error') + finish(new Error(event.data.payload?.message || 'Office request failed')); + else if (event.data.type === responseType) finish(undefined, event.data.payload); + }; + const finish = (error?: unknown, result: Record = {}) => { + clearTimeout(timeout); + window.removeEventListener('message', onMessage); + signal.removeEventListener('abort', onAbort); + if (error) reject(error); + else resolve(result); + }; + window.addEventListener('message', onMessage); + signal.addEventListener('abort', onAbort, { once: true }); + try { + iframe.contentWindow!.postMessage({ id, type, payload }, url.origin); + } catch (error) { + finish(error); + } + }); +} + +/** A connection belongs to one preview operation, never the global page. */ +export async function renderOffice(file: File, options: OfficeOptions): Promise { + const { iframe, url, signal } = options; + const legacyController = new AbortController(); + let connection: ReturnType | undefined; + let chunks: ReturnType | undefined; + let remainingChunks = 0; + const assertLegacyEditable = () => { + signal.throwIfAborted(); + if (options.readonly) + throw new Error('Read-only mode requires a renderer with the document embed API; update your baseUrl deployment'); + }; + const dispose = () => { + legacyController.abort(signal.reason); + if (connection) Client.remove(connection.id); + }; + signal.addEventListener('abort', dispose, { once: true }); try { - const modern = await new Promise((resolve, reject) => { - const probeId = crypto.randomUUID(); - let probeTimer: ReturnType | undefined; - const deadline = setTimeout(() => finish(false, new Error('Office renderer did not become ready')), 60_000); - const onMessage = (event: MessageEvent) => { - if (event.source !== iframe.contentWindow || event.origin !== url.origin) return; - if ( - event.data?.type === 'document:ready' || - (event.data?.id === probeId && event.data?.type === 'document:state') - ) - finish(true); - }; - const onLoad = () => { - iframe.contentWindow?.postMessage({ id: probeId, type: 'document:get-state' }, url.origin); - probeTimer = setTimeout(() => finish(false), 600); - }; - const onAbort = () => finish(false, signal.reason); - const cleanup = () => { - clearTimeout(deadline); - clearTimeout(probeTimer); - window.removeEventListener('message', onMessage); - iframe.removeEventListener('load', onLoad); - signal.removeEventListener('abort', onAbort); - }; - const finish = (value: boolean, error?: unknown) => { - cleanup(); - if (error) reject(error); - else resolve(value); - }; - window.addEventListener('message', onMessage); - iframe.addEventListener('load', onLoad); - signal.addEventListener('abort', onAbort, { once: true }); - iframe.src = url.href; + const modern = await waitForRenderer(options, async () => { + signal.throwIfAborted(); + connection = Client.connect({ + id: createRequestId(), + targetOrigin: url.origin, + targetWindow: iframe.contentWindow!, + }); + chunks = encodeOfficeChunks(file, legacyController.signal); + const first = await chunks.next(); + legacyController.signal.throwIfAborted(); + assertLegacyEditable(); + if (first.done) throw new Error('Cannot open an empty Office document with a legacy renderer'); + remainingChunks = first.value.totalChunks - 1; + await Client.call({ id: connection.id, type: 'RENDER_OFFICE', payload: first.value }); + legacyController.signal.throwIfAborted(); + assertLegacyEditable(); }); signal.throwIfAborted(); + const readonly = options.readonly; if (modern) { - await new Promise((resolve, reject) => { - const id = crypto.randomUUID(); - const timeout = setTimeout(() => finish(new Error('Office document open timed out')), 120_000); - const onAbort = () => finish(signal.reason); - const onMessage = (event: MessageEvent) => { - if (event.source !== iframe.contentWindow || event.origin !== url.origin || event.data?.id !== id) return; - if (event.data.type === 'document:error') - finish(new Error(event.data.payload?.message || 'Office open failed')); - if (event.data.type === 'document:opened') { - if (readonly && event.data.payload?.readonly !== true) - finish(new Error('Renderer did not enforce read-only mode')); - else finish(); - } - }; - const finish = (error?: unknown) => { - clearTimeout(timeout); - window.removeEventListener('message', onMessage); - signal.removeEventListener('abort', onAbort); - if (error) reject(error); - else resolve(); - }; - window.addEventListener('message', onMessage); - signal.addEventListener('abort', onAbort, { once: true }); - iframe.contentWindow!.postMessage({ id, type: 'document:open-file', payload: { file, readonly } }, url.origin); - }); - } else { - if (readonly) - throw new Error( - 'Read-only mode requires a renderer with the document embed API; update your baseUrl deployment', - ); - const connection = Client.connect({ targetOrigin: url.origin, targetWindow: iframe.contentWindow! }); - bridgeId = connection.id; - dispose = () => Client.remove(connection.id); - signal.addEventListener('abort', dispose, { once: true }); - const chunks = await MessageCodec.encodeFileChunked(file); - signal.throwIfAborted(); - // Preserve the original chunk protocol for existing self-hosted renderers. - for (const chunk of chunks) { - await Client.call({ id: connection.id, type: 'RENDER_OFFICE', payload: chunk }); - signal.throwIfAborted(); - } + // A tentative legacy request must not outlive the confirmed modern connection. + dispose(); + const opened = await request(options, 'document:open-file', { file, readonly }, 'document:opened'); + if (readonly && opened.readonly !== true) throw new Error('Renderer did not enforce read-only mode'); + let pending = Promise.resolve(); + return { + initialReadonly: readonly, + setReadonly(value) { + // Only one command may be in flight, so fast toggles cannot apply out of order. + const change = pending.then(async () => { + const changed = await request( + options, + 'document:set-readonly', + { readonly: value }, + 'document:readonly-changed', + ); + if (changed.readonly !== value) throw new Error('Renderer did not apply read-only mode'); + }); + pending = change.catch(() => {}); + return change; + }, + }; } - signal.throwIfAborted(); + // The first chunk is already acknowledged. Continue the same iterator without sending it twice. + while (remainingChunks > 0) { + assertLegacyEditable(); + const chunk = await chunks!.next(); + assertLegacyEditable(); + await Client.call({ id: connection!.id, type: 'RENDER_OFFICE', payload: chunk.value }); + assertLegacyEditable(); + remainingChunks--; + } + assertLegacyEditable(); + return { + initialReadonly: false, + async setReadonly(value) { + signal.throwIfAborted(); + if (value) + throw new Error( + 'Read-only mode requires a renderer with the document embed API; update your baseUrl deployment', + ); + }, + }; } finally { signal.removeEventListener('abort', dispose); - if (bridgeId) Client.remove(bridgeId); + dispose(); } } diff --git a/components/preview/pdf.ts b/components/preview/pdf.ts index 96cac16..9c034cd 100644 --- a/components/preview/pdf.ts +++ b/components/preview/pdf.ts @@ -4,14 +4,20 @@ import PDF_WORKER_JS from 'pdfjs-dist/build/pdf.worker.min.mjs?raw'; import type { RenderOptions } from '@/components/preview/types'; const PDF_ASSETS = new URL(__FILEVIEW_PDF_ASSET_BASE__, import.meta.url); +const MAX_CANVAS_PIXELS = 4_194_304; +const MAX_CANVAS_DIMENSION = 8192; +const MAX_LIVE_PAGES = 6; -/** Render every page with an owned worker, so concurrent previews can be cancelled independently. */ +/** Eager completion paints every page; virtual completion paints the first viewport buffer. */ export const renderPdf = async (file: File, options: RenderOptions): Promise => { const { signal } = options; + const virtual = options.mode === 'virtual'; + if (virtual && !signal) throw new Error('Virtual PDF rendering requires an AbortSignal for cleanup'); if (signal?.aborted) return; const dom = options.dom ?? document.body; - const canvases: HTMLCanvasElement[] = []; + const canvases = new Set(); + const placeholders: HTMLDivElement[] = []; let workerUrl: string | undefined; let port: Worker | undefined; let worker: PDFWorker | undefined; @@ -19,7 +25,10 @@ export const renderPdf = async (file: File, options: RenderOptions): Promise | undefined; + let disconnectViewport = () => {}; let interrupt: (reason: unknown) => void = () => {}; const interrupted = new Promise((_, reject) => { interrupt = reject; @@ -27,16 +36,99 @@ export const renderPdf = async (file: File, options: RenderOptions): Promise {}); const wait = (promise: Promise): Promise => Promise.race([promise, interrupted]); + const release = (canvas: HTMLCanvasElement) => { + canvas.remove(); + canvas.width = canvas.height = 0; + canvases.delete(canvas); + }; + const dispose = (removePages: boolean): Promise => { + if (disposal) return disposal; + disconnectViewport(); + renderTask?.cancel(); + signal?.removeEventListener('abort', abort); + if (removePages) { + for (const canvas of canvases) release(canvas); + for (const placeholder of placeholders) placeholder.remove(); + } + disposal = (async () => { + if (loadingTask) { + const destruction = loadingTask.destroy(); + // A crashed worker cannot acknowledge PDF.js's termination message. + if (workerFailed) void destruction.catch(() => {}); + else await destruction.catch(() => {}); + } + worker?.destroy(); + port?.removeEventListener('error', workerError); + port?.removeEventListener('messageerror', workerError); + port?.terminate(); + if (workerUrl) URL.revokeObjectURL(workerUrl); + })(); + return disposal; + }; const abort = () => { renderTask?.cancel(); interrupt(new DOMException('PDF rendering aborted', 'AbortError')); + if (retained) void dispose(true); + }; + const backgroundError = (error: unknown) => { + if (signal?.aborted || disposal) return; + void dispose(true); + options.onError?.({ success: false, message: error instanceof Error ? error.message : String(error) }); }; const workerError = (event: ErrorEvent | MessageEvent) => { workerFailed = true; - interrupt(event instanceof ErrorEvent ? new Error(event.message) : new Error('PDF worker message failed')); + const error = event instanceof ErrorEvent ? new Error(event.message) : new Error('PDF worker message failed'); + interrupt(error); + if (retained) backgroundError(error); }; signal?.addEventListener('abort', abort, { once: true }); + const paint = async (pageNumber: number, parent: HTMLElement) => { + signal?.throwIfAborted(); + const page = await wait(pdf!.getPage(pageNumber)); + let canvas: HTMLCanvasElement | undefined; + try { + signal?.throwIfAborted(); + const baseViewport = page.getViewport({ scale: 1 }); + const availableWidth = dom.clientWidth || baseViewport.width; + const viewport = page.getViewport({ scale: Math.min(1, availableWidth / baseViewport.width) }); + const density = Math.min( + window.devicePixelRatio || 1, + 2, + Math.sqrt(MAX_CANVAS_PIXELS / (viewport.width * viewport.height)), + MAX_CANVAS_DIMENSION / viewport.width, + MAX_CANVAS_DIMENSION / viewport.height, + ); + canvas = document.createElement('canvas'); + canvas.width = Math.max(1, Math.floor(viewport.width * density)); + canvas.height = Math.max(1, Math.floor(viewport.height * density)); + canvas.style.width = `${viewport.width}px`; + canvas.style.maxWidth = '100%'; + canvas.style.height = 'auto'; + canvas.style.aspectRatio = `${viewport.width} / ${viewport.height}`; + canvas.style.margin = '0 auto'; + canvas.style.flexShrink = '0'; + canvas.style.display = 'block'; + const canvasContext = canvas.getContext('2d'); + if (!canvasContext) throw new Error('PDF rendering requires a 2D canvas context'); + canvases.add(canvas); + parent.append(canvas); + renderTask = page.render({ + canvas, + canvasContext, + viewport, + transform: density === 1 ? undefined : [density, 0, 0, density, 0, 0], + }); + await wait(renderTask.promise); + renderTask = undefined; + } catch (error) { + if (canvas) release(canvas); + throw error; + } finally { + page.cleanup(); + } + }; + try { const data = new Uint8Array(await wait(file.arrayBuffer())); signal?.throwIfAborted(); @@ -58,62 +150,117 @@ export const renderPdf = async (file: File, options: RenderOptions): Promise(); + let activePage = -1; + let dirty = false; + let layoutVersion = 0; + let pending: Promise | undefined; + const selectPages = () => { + const root = dom.getBoundingClientRect(); + const height = dom.clientHeight || window.innerHeight; + const center = root.top + height / 2; + const nearby = placeholders + .map((placeholder, index) => ({ index, bounds: placeholder.getBoundingClientRect() })) + .filter(({ bounds }) => bounds.bottom >= root.top - height && bounds.top <= root.top + height * 2) + .sort( + (a, b) => + Math.abs((a.bounds.top + a.bounds.bottom) / 2 - center) - + Math.abs((b.bounds.top + b.bounds.bottom) / 2 - center), + ) + .slice(0, MAX_LIVE_PAGES); + selected = new Set(nearby.map(({ index }) => index)); + if (activePage >= 0 && !selected.has(activePage)) renderTask?.cancel(); + for (let index = 0; index < placeholders.length; index++) { + if (!selected.has(index)) { + for (const canvas of placeholders[index].querySelectorAll('canvas')) release(canvas); + } + } + }; + const pump = async () => { + do { + dirty = false; + selectPages(); + for (const index of selected) { + if (dirty || signal!.aborted || disposal) break; + if (placeholders[index].querySelector('canvas')) continue; + activePage = index; + const paintingLayout = layoutVersion; + try { + await paint(index + 1, placeholders[index]); + } catch (error) { + if ((selected.has(index) && paintingLayout === layoutVersion) || signal!.aborted) throw error; + } finally { + activePage = -1; + } + } + } while (dirty && !signal!.aborted && !disposal); + }; + const schedule = () => { + if (signal!.aborted || disposal) return; + dirty = true; + selectPages(); + if (!pending) { + pending = pump().finally(() => { + pending = undefined; + }); + void pending.catch((error) => { + if (retained) backgroundError(error); + }); + } + }; + const resize = new ResizeObserver(() => { + let changed = false; + placeholders.forEach((placeholder, index) => { + const width = `${Math.min(dom.clientWidth || widths[index], widths[index])}px`; + if (placeholder.style.width === width) return; + changed = true; + placeholder.style.width = width; + for (const canvas of placeholder.querySelectorAll('canvas')) release(canvas); + }); + if (changed) { + layoutVersion++; + renderTask?.cancel(); + schedule(); + } + }); + const intersection = new IntersectionObserver(schedule, { root: dom, rootMargin: '100% 0px' }); + placeholders.forEach((placeholder) => intersection.observe(placeholder)); + resize.observe(dom); + dom.addEventListener('scroll', schedule, { passive: true }); + disconnectViewport = () => { + resize.disconnect(); + intersection.disconnect(); + dom.removeEventListener('scroll', schedule); + }; + schedule(); + await pending; + signal!.throwIfAborted(); + retained = true; } signal?.throwIfAborted(); completed = true; } catch (error) { if (!signal?.aborted) throw error; } finally { - renderTask?.cancel(); - if (loadingTask) { - const destruction = loadingTask.destroy(); - // A crashed worker cannot acknowledge PDF.js's termination message. - if (workerFailed) void destruction.catch(() => {}); - else await destruction.catch(() => {}); - } - worker?.destroy(); - port?.removeEventListener('error', workerError); - port?.removeEventListener('messageerror', workerError); - port?.terminate(); - if (workerUrl) URL.revokeObjectURL(workerUrl); - signal?.removeEventListener('abort', abort); - if (!completed || signal?.aborted) { - for (const canvas of canvases) { - canvas.remove(); - canvas.width = canvas.height = 0; - } - } + if (!retained) await dispose(!completed || !!signal?.aborted); } if (completed && !signal?.aborted) options.onLoad?.({ success: true, data: pdf }); }; diff --git a/components/preview/request-id.ts b/components/preview/request-id.ts new file mode 100644 index 0000000..fff7d6f --- /dev/null +++ b/components/preview/request-id.ts @@ -0,0 +1,5 @@ +/** Correlation identifiers also work on HTTP intranets without randomUUID. */ +export function createRequestId(): string { + const bytes = crypto.getRandomValues(new Uint8Array(16)); + return Array.from(bytes, (byte) => byte.toString(16).padStart(2, '0')).join(''); +} diff --git a/components/preview/types.ts b/components/preview/types.ts index 3104acd..e16fb48 100644 --- a/components/preview/types.ts +++ b/components/preview/types.ts @@ -5,6 +5,8 @@ export interface BaseReturn { } export interface RenderOptions { + /** Virtual PDF rendering retains resources until signal aborts; completion means the first viewport is ready. */ + mode?: 'eager' | 'virtual'; signal?: AbortSignal; dom?: HTMLElement; onError?: (msg?: BaseReturn) => void; diff --git a/docs/verification-0.3.0.md b/docs/verification-0.3.0.md new file mode 100644 index 0000000..68e391b --- /dev/null +++ b/docs/verification-0.3.0.md @@ -0,0 +1,43 @@ +# 0.3.0 verification + +## Compatibility contract + +- Automatic registration, the passive manual entry, existing ESM/UMD namespaces and editable Office defaults remain available. +- Default PDF `load` still means every page is painted; eager rendering releases its worker after completion. +- Virtual PDF rendering is opt-in through `pdf-mode="virtual"` / `pdfMode`. Its initial `load` means the viewport and buffer are ready; the worker lives until cancellation. At most six canvases are retained. Placeholders preserve page positions. +- Office `readonly` changes use correlated `document:set-readonly` commands. They preserve the iframe, unsaved edits and dirty state; command failure reports an error while preserving the current editor. Legacy renderers still support editable files and reject unsupported read-only requests. + +## Reproduced before fixing + +Browser regressions initially failed for: lost Office edits on close-control/read-only changes, delayed modern renderer startup, missing `crypto.randomUUID` on HTTP, legacy chunk reads continuing after cancellation, missing acknowledgement backpressure, non-responsive PDF width, excessive page backing pixels/DPR, and demo Blob URLs accumulating when selecting a replacement file. + +Additional regressions cover rapid mode changes, source replacement, disconnection/reconnection, PDF mode changes, virtual viewport recycling, resize during active painting, and retained-worker failure cleanup. Real PDF fixtures exercise actual PDF.js painting and pixels, rather than mocking the renderer. + +## Validation + +Local final validation passed 144 browser regressions across the three engines, 44 release-helper unit tests, frozen installation, strict TypeScript, formatting and ESM/UMD/declaration builds. + +The release checks run strict TypeScript, formatting, release-helper unit tests, browser regressions in Chromium/Firefox/WebKit, ESM/UMD/declaration builds and installed-tarball consumer checks. Installed-package verification checks passive SSR import, typed custom element properties/methods, safe constructor narrowing, React attributes, public exports and local PDF worker/font/cMap/WASM assets. + +Release-helper tests use a fake registry and injected publish runner. They exercise new publication, matching-version retries, integrity/name/version mismatches, HTTP/auth/network errors, immutable stored artifacts and source-tag validation. They do not publish a test version to npm. + +## Live Office integration + +Opt-in command: + +```sh +FILEVIEW_LIVE_OFFICE=1 FILEVIEW_LIVE_CASE=default.docx pnpm exec tsx scripts/office-smoke.mjs +FILEVIEW_LIVE_OFFICE=1 FILEVIEW_LIVE_CASE=default.pptx pnpm exec tsx scripts/office-smoke.mjs +``` + +Both live cases passed against the deployed renderer. The initial PPTX attempt stalled inside SDK loading; a clean retry completed successfully. + +The Vite server runs separately on port 5201. The script connects to `https://edit.chaxus.com` and exercises the actual document embed API and Office SDK. DOCX coverage inserts a real unsaved text edit, toggles close controls and read-only mode in both directions, verifies SDK restrictions and dirty-state retention, and saves/parses the DOCX to confirm the edit survives. It also asserts iframe identity and exactly one initial `load`. PPTX verifies live mode changes and iframe retention; it does not insert a synthetic edit into a slide. + +## Limits + +- The original attachment for Issue #4 is still unavailable. Synthetic image/text coverage from 0.2.0 does not establish that the reporter's exact document is fixed. +- Virtual mode caps active canvas allocations, not total process memory: PDF.js still retains document data and page metadata. Eager total canvas memory continues to grow with page count. +- Large-page pixel caps deliberately trade resolution for bounded per-page allocation. Eager canvases scale down with their container after painting; virtual mode also repaints on container resize. +- Office file contents become accessible to the configured iframe. Private/intranet deployments should host that editor themselves. +- Live renderer tests are outside CI because they depend on the separately deployed editor and its network/assets. diff --git a/index.html b/index.html index eb59950..bb6bd0d 100644 --- a/index.html +++ b/index.html @@ -8,7 +8,7 @@ @ranui/preview — Embed a PDF, Word, Excel & PowerPoint Viewer @@ -43,7 +43,7 @@ "@context": "https://schema.org", "@type": "SoftwareSourceCode", "name": "@ranui/preview", - "description": "A framework-free web component () that renders PDF, DOCX, PPTX and XLSX files entirely in the browser, with no backend and no upload.", + "description": "A framework-free web component () that renders PDF, DOCX, PPTX and XLSX files entirely in the browser, with local PDF rendering and a configurable Office editor iframe.", "url": "https://fileview.chaxus.com/", "codeRepository": "https://github.com/ranuts/fileview", "programmingLanguage": "TypeScript", @@ -86,7 +86,7 @@ "name": "Do I need a backend or conversion service?", "acceptedAnswer": { "@type": "Answer", - "text": "No. Rendering happens entirely in the browser, so files never leave the user's device — no server required." + "text": "No conversion server is required. PDFs render locally. Office files are passed to the configured editor iframe, which can access their contents. Self-host that editor for private or intranet documents." } }, { @@ -399,7 +399,7 @@ } .console::before { /* device label, top-left */ - content: '\25AA renders on your device \2014 nothing leaves the client'; + content: '\25AA local PDF \2014 configurable Office editor'; position: absolute; top: 14px; left: 20px; @@ -1160,7 +1160,7 @@

- Try it — your file never leaves this page + Try it — local PDF, configurable Office editor Pick a local .pdf .docx .pptx or .xlsx — it opens in a full-screen preview.