From 3726c5587c068d0a6c2d611d4020b6483ab4aa8f Mon Sep 17 00:00:00 2001 From: chaxus Date: Mon, 19 Oct 2026 11:55:39 +0800 Subject: [PATCH 1/5] fix(preview): preserve Office edits and bound PDF rendering resources --- components/preview/core.ts | 82 ++++- components/preview/office-chunks.ts | 19 ++ components/preview/office.ts | 269 ++++++++++----- components/preview/pdf.ts | 249 +++++++++++--- components/preview/request-id.ts | 5 + components/preview/types.ts | 2 + test/fixtures/hardening-editor.html | 60 ++++ test/fixtures/hardening-legacy.html | 33 ++ test/fixtures/pdf-busy-page.pdf | Bin 0 -> 52463 bytes test/fixtures/pdf-large-page.pdf | Bin 0 -> 461 bytes test/fixtures/pdf-thirty-pages.pdf | Bin 0 -> 7092 bytes test/office-hardening.spec.ts | 494 ++++++++++++++++++++++++++++ test/pdf.spec.ts | 255 +++++++++++++- 13 files changed, 1328 insertions(+), 140 deletions(-) create mode 100644 components/preview/office-chunks.ts create mode 100644 components/preview/request-id.ts create mode 100644 test/fixtures/hardening-editor.html create mode 100644 test/fixtures/hardening-legacy.html create mode 100644 test/fixtures/pdf-busy-page.pdf create mode 100644 test/fixtures/pdf-large-page.pdf create mode 100644 test/fixtures/pdf-thirty-pages.pdf create mode 100644 test/office-hardening.spec.ts 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/test/fixtures/hardening-editor.html b/test/fixtures/hardening-editor.html new file mode 100644 index 0000000..d745b2e --- /dev/null +++ b/test/fixtures/hardening-editor.html @@ -0,0 +1,60 @@ + + + + Stateful embed API fixture + + + + + diff --git a/test/fixtures/hardening-legacy.html b/test/fixtures/hardening-legacy.html new file mode 100644 index 0000000..a7e93d8 --- /dev/null +++ b/test/fixtures/hardening-legacy.html @@ -0,0 +1,33 @@ + + + + Legacy chunk acknowledgement fixture + + + + diff --git a/test/fixtures/pdf-busy-page.pdf b/test/fixtures/pdf-busy-page.pdf new file mode 100644 index 0000000000000000000000000000000000000000..ddc787638a320705cb2d6ddcce3199510718fdbc GIT binary patch literal 52463 zcmeI)%TC)s6adh@zv6CKwti4kkfJO~%ciZ0((Vuo)0i{};>tD>?bp{6L;^zU7aYVQ zv1c^qGIM9n#LmI_$Fn!(+xJ-+MRZp;*=!d1k3YZL$WNQGx$RbwpEs*^h-xaZi1|Eg zH_KEoy?Rmpom=3(In40O^sp4Zh`PX*2Za#DmaZya= ztD=a*qPUEF(GJ~x|8u&a=}PDG=`y>`IMo~CU25oAJL&yXJ73z(YWx*-Ra8f*$T0S8 zbGQAYi|AKV$z2=Kw{g8cQx>uNb!G~d=!n5IBJ=|>NSRST5Ci?-afx6o7z@UNvG7C& z`9MC9599;+AdLe3Kn(PQ-9VULSRaUi^?`Z8ykK51FRb>Rz}&b^3DyVJ2i6DH2i6Ds zsX80ZJnEzHJ(7^GX-^#d``57Hwo`hghe2cBp0)RMbR zCMMcF76ZG-c8~2I?>EilL^)ATloRDdIngI6K1s2AY}e4vsc!)CEl6ZUMr1@rWJE?} zL`GypMr1@rWJE?}L`GypMr1@rWJE?}L`GypMr1@rWJE?}L`GypMr1@rWJE?}L`Gyp zMr1@rWJE?}L`GypMr1@rWJE?}L`GypMr1@rWJE?}L`GypMr1@rWJE?}L`GypMr1@r zWJE?}L`GypMr1@rWJE?}L`GypMr1@rWJE?}L`GypMr1@rWJE?}L`GypMr1@rWJE?} zL`GypMr1@rWJE?}L`GypMr1@rWJE?}L`MISQPysj!`QdYU3%4B-DD4adz}?gXT|O( zj_Ufbj_cSTJBjVt*zAwh$FGi+W&P?{RX(db_RadX?X%e|^6%@vZPbx3x^9ee`mZfw YKF@}+>BsFg)m3$JoE;o|`gWH61LBk*r~m)} literal 0 HcmV?d00001 diff --git a/test/fixtures/pdf-large-page.pdf b/test/fixtures/pdf-large-page.pdf new file mode 100644 index 0000000000000000000000000000000000000000..78e83a5f1523df2d51fb04669e23142444eeb7ca GIT binary patch literal 461 zcmZWm%TB{E5WM><_L5`qqqq;L%Ati`08tfgRS$-2LQsj3?Mj8OXEzOMs_eni&dzvd z?QCtCKpnh$;@XW%kH1o;tz99 Bb=&{| literal 0 HcmV?d00001 diff --git a/test/fixtures/pdf-thirty-pages.pdf b/test/fixtures/pdf-thirty-pages.pdf new file mode 100644 index 0000000000000000000000000000000000000000..fd8cc662d919b98b43766ab3cc21f1cc65dab5c4 GIT binary patch literal 7092 zcmcIp%Wm5+5WM>}3vzI4#dVQdg{%N+zrNgMnAp359Kxax z1~eXX$HN^;q|dJ2U%pXq%b5~VwBLWt&dB^z@myRLT z30C9kkwrC4p*j}B6t8pTP|+YF5dAsT>s(P(G>Zs~sZQ}aR~j`s)CxG>r&Ee@^u} zR~ijXBLZWpQ@qZVMoZI(z?kZUzQ+Ar9BazHwls|h#7aXQlHfINKjT=Fs-|hQ$I_VM zb*?m8nnnboKc{+~D~*<>5rHw)DPHGFqoZj=U`%yFU*pk99BazHb~KF$#7aXQlHfI- z^u)0yRZY|Aj-@fh>s)DcG>r&Ee@^u}R~j8nBLZWpQ@qZV#)761ficwyeT}D-ajYr( zx}a%9AXXackOZ$EyXI~tMPX+0`6UYH{BiD`c)|RPz)NgjFqm zg1MU4C`zAT!YfN&w}Lf=@M^A4NqAa5)1sW@HC)AO6czJI zl`ylUMpf_{H9Th=^0b6I3&9lZdI_dspF=PsSq*LB)Q#|L&1<-t*CGcdPOh;=M@CAzLP2DB(LErUZbdZjk4f1s*=~J;YsUIi|U2AJz!|~p=19`J0?_FELJ&M$=dG9(2JElXPQ@nSb=4*sA zymy_I>~}cFd)K*&&4(-Yd$;eFoBO82zudlV{xyF6^M4DqZSVgYc{ route.fulfill({ contentType: mime, body: 'Office transfer' })); + await page.goto('/test/fixtures/preview.html'); + await page.evaluate( + async ({ query, readonly }) => { + await customElements.whenDefined('r-preview'); + const preview = document.createElement('r-preview'); + preview.id = 'preview'; + preview.setAttribute('baseurl', `${location.origin}/test/fixtures/hardening-editor.html${query}`); + if (readonly) preview.setAttribute('readonly', ''); + (window as any).events = []; + for (const type of ['load', 'error']) preview.addEventListener(type, () => (window as any).events.push(type)); + document.body.append(preview); + preview.setAttribute('src', '/report.docx'); + }, + { query, readonly }, + ); +} + +// Reloading the iframe or fetching again destroys these unsaved edits. +test('closeable changes retain the active editor and unsaved content', async ({ page }) => { + let fetches = 0; + page.on('request', (request) => { + if (request.url().endsWith('/report.docx')) fetches++; + }); + await setup(page); + const input = page.frameLocator('iframe').getByRole('textbox'); + await expect(input).toHaveValue('report.docx'); + await input.fill('Unsaved edit'); + await page.getByRole('button', { name: 'Close preview' }).focus(); + await page.evaluate(() => { + (window as any).originalIframe = document.querySelector('iframe'); + document.querySelector('#preview')!.setAttribute('closeable', 'false'); + }); + await expect(page.getByRole('button', { name: 'Close preview' })).toHaveCount(0); + await expect(input).toHaveValue('Unsaved edit'); + expect(await page.evaluate(() => document.querySelector('iframe') === (window as any).originalIframe)).toBe(true); + expect(await page.evaluate(() => document.activeElement?.getAttribute('role'))).toBe('dialog'); + await page.evaluate(() => document.querySelector('#preview')!.removeAttribute('closeable')); + await expect(page.getByRole('button', { name: 'Close preview' })).toHaveCount(1); + expect(fetches).toBe(1); +}); + +test('readonly commands serialize rapid toggles and retain unsaved document state', async ({ page }) => { + await setup(page, '?commandDelay=80'); + const input = page.frameLocator('iframe').getByRole('textbox'); + await expect(input).toHaveValue('report.docx'); + await input.fill('Keep this edit'); + await page.evaluate(() => { + const preview = document.querySelector('#preview')!; + for (const value of ['', 'false', '']) preview.setAttribute('readonly', value); + }); + await expect(input).toHaveAttribute('readonly', ''); + await expect(input).toHaveValue('Keep this edit'); + const frame = page.frames().find((frame) => frame.url().includes('hardening-editor'))!; + await expect.poll(() => frame.evaluate(() => (window as any).commands)).toEqual([true, false, true]); + expect(await frame.evaluate(() => (window as any).opens)).toBe(1); + expect(await frame.evaluate(() => (window as any).state.dirty)).toBe(true); + expect(await page.evaluate(() => (window as any).events)).toEqual(['load']); +}); + +test('readonly command errors preserve the existing editor and allow subsequent requests', async ({ page }) => { + await setup(page, '?reject'); + const input = page.frameLocator('iframe').getByRole('textbox'); + await expect(input).toHaveValue('report.docx'); + await input.fill('Do not discard'); + await page.evaluate(() => document.querySelector('#preview')!.setAttribute('readonly', '')); + await expect(page.getByRole('alert')).toContainText('Mode change rejected'); + await expect(input).toHaveValue('Do not discard'); + await page.evaluate(() => document.querySelector('#preview')!.setAttribute('readonly', 'false')); + await expect.poll(() => page.evaluate(() => (window as any).events)).toEqual(['load', 'error', 'error']); + await expect(input).toHaveValue('Do not discard'); +}); + +for (const readonly of [false, true]) { + test(`modern readiness retries a delayed state response (${readonly ? 'readonly' : 'editable'})`, async ({ + page, + }) => { + await setup(page, '?readyDelay=900&probeOnly', readonly); + await expect(page.frameLocator('iframe').getByRole('textbox')).toHaveValue('report.docx'); + await expect.poll(() => page.evaluate(() => (window as any).events)).toEqual(['load']); + }); +} + +test('ordinary HTTP crypto without randomUUID opens independent Office dialogs', async ({ page }) => { + await page.addInitScript(() => Object.defineProperty(crypto, 'randomUUID', { value: undefined })); + await setup(page); + await expect(page.frameLocator('iframe').getByRole('textbox')).toHaveValue('report.docx'); + await page.evaluate(() => { + const preview = document.querySelector('#preview')!.cloneNode() as HTMLElement; + preview.id = 'second'; + document.body.append(preview); + }); + await expect(page.locator('iframe')).toHaveCount(2); + await expect(page.frameLocator('iframe').nth(1).getByRole('textbox')).toHaveValue('report.docx'); + expect( + await page.locator('[role="dialog"]').evaluateAll((dialogs) => new Set(dialogs.map((dialog) => dialog.id)).size), + ).toBe(2); +}); + +test('legacy encoding stops reading as soon as a chunk read is aborted', async ({ page }) => { + await page.goto('/test/fixtures/preview.html'); + const result = await page.evaluate(async () => { + const modulePath = '/components/preview/office.ts'; + const { renderOffice } = await import(modulePath); + const iframe = document.createElement('iframe'); + document.body.append(iframe); + const controller = new AbortController(); + const file = new File([new Uint8Array(128 * 16384)], 'abort.docx'); + let reads = 0; + const slice = file.slice.bind(file); + file.slice = (start, end) => { + reads++; + const chunk = slice(start, end); + const read = chunk.arrayBuffer.bind(chunk); + chunk.arrayBuffer = async () => { + controller.abort(new DOMException('Cancelled chunk', 'AbortError')); + return read(); + }; + return chunk; + }; + try { + await renderOffice(file, { + iframe, + url: new URL('/test/fixtures/hardening-legacy.html', location.href), + readonly: false, + signal: controller.signal, + }); + return { reads, aborted: false }; + } catch (error) { + return { reads, aborted: (error as Error).name === 'AbortError' }; + } + }); + expect(result).toEqual({ reads: 1, aborted: true }); +}); + +test('legacy transfer reads only the next acknowledged chunk and preserves the wire file', async ({ page }) => { + await page.goto('/test/fixtures/preview.html'); + await page.evaluate(async () => { + const modulePath = '/components/preview/office.ts'; + const { renderOffice } = await import(modulePath); + const iframe = document.createElement('iframe'); + document.body.append(iframe); + const bytes = new Uint8Array(32770); + bytes[0] = 0; + bytes[1] = 128; + bytes[16383] = 255; + bytes[16384] = 200; + bytes[32769] = 254; + const file = new File([bytes], 'binary.docx', { type: 'application/octet-stream', lastModified: 123456789 }); + (window as any).reads = 0; + const slice = file.slice.bind(file); + file.slice = (start, end) => { + (window as any).reads++; + return slice(start, end); + }; + (window as any).transfer = renderOffice(file, { + iframe, + url: new URL('/test/fixtures/hardening-legacy.html?pause', location.href), + readonly: false, + signal: new AbortController().signal, + }); + }); + const frame = () => page.frames().find((frame) => frame.url().includes('hardening-legacy'))!; + await expect.poll(async () => frame()?.evaluate(() => (window as any).chunks.length)).toBe(1); + expect(await page.evaluate(() => (window as any).reads)).toBe(1); + for (const count of [2, 3]) { + await frame().evaluate(() => (window as any).releaseChunk()); + await expect.poll(() => frame().evaluate(() => (window as any).chunks.length)).toBe(count); + expect(await page.evaluate(() => (window as any).reads)).toBe(count); + } + await frame().evaluate(() => (window as any).releaseChunk()); + await page.evaluate(() => (window as any).transfer); + const received = await frame().evaluate(() => (window as any).received); + expect({ ...received, bytes: undefined }).toEqual({ + name: 'binary.docx', + type: 'application/octet-stream', + lastModified: 123456789, + bytes: undefined, + }); + expect(received.bytes.length).toBe(32770); + expect([0, 1, 16383, 16384, 32769].map((index) => received.bytes[index])).toEqual([0, 128, 255, 200, 254]); +}); + +test('readonly enabled during readiness keeps waiting for the modern editor', async ({ page }) => { + await setup(page, '?readyDelay=2200&probeOnly'); + await expect(page.frameLocator('iframe').getByRole('textbox')).toHaveValue('Original'); + await page.evaluate(() => document.querySelector('#preview')!.setAttribute('readonly', '')); + const input = page.frameLocator('iframe').getByRole('textbox'); + await expect(input).toHaveValue('report.docx'); + await expect(input).toHaveAttribute('readonly', ''); + await expect.poll(() => page.evaluate(() => (window as any).events)).toEqual(['load']); +}); + +test('label and PDF mode changes retain an Office document and unsaved edits', async ({ page }) => { + await setup(page); + const input = page.frameLocator('iframe').getByRole('textbox'); + await expect(input).toHaveValue('report.docx'); + await input.fill('Mode-independent edit'); + await page.evaluate(() => { + (window as any).originalIframe = document.querySelector('iframe'); + const preview = document.querySelector('#preview')!; + preview.setAttribute('label', 'New accessible name'); + preview.setAttribute('pdf-mode', 'virtual'); + (preview as any).pdfMode = 'eager'; + }); + await expect(page.locator('#preview')).toHaveAccessibleName('New accessible name'); + await expect(input).toHaveValue('Mode-independent edit'); + expect(await page.evaluate(() => document.querySelector('iframe') === (window as any).originalIframe)).toBe(true); + expect(await page.evaluate(() => document.querySelector('#preview')!.getAttribute('pdf-mode'))).toBe('eager'); +}); + +test('PDF mode selects virtual rendering, keeps closeable changes local, and reloads to eager explicitly', async ({ + page, +}) => { + await page.goto('/test/fixtures/preview.html'); + await page.evaluate(async () => { + await customElements.whenDefined('r-preview'); + const preview = document.createElement('r-preview'); + preview.id = 'preview'; + (window as any).events = []; + preview.addEventListener('load', () => (window as any).events.push('load')); + preview.setAttribute('pdf-mode', 'virtual'); + document.body.append(preview); + preview.setAttribute('src', '/test/fixtures/pdf-thirty-pages.pdf'); + }); + await expect.poll(() => page.evaluate(() => (window as any).events)).toEqual(['load']); + await expect(page.locator('[data-pdf-page]')).toHaveCount(30); + expect(await page.locator('canvas').count()).toBeLessThan(30); + await page.evaluate(() => { + (window as any).firstCanvas = document.querySelector('canvas'); + document.querySelector('#preview')!.setAttribute('closeable', 'false'); + }); + expect(await page.evaluate(() => document.querySelector('canvas') === (window as any).firstCanvas)).toBe(true); + expect(await page.evaluate(() => (window as any).events)).toEqual(['load']); + await page.evaluate(() => ((document.querySelector('#preview') as any).pdfMode = 'eager')); + await expect.poll(() => page.evaluate(() => (window as any).events)).toEqual(['load', 'load']); + await expect(page.locator('canvas')).toHaveCount(30); + await expect(page.locator('[data-pdf-page]')).toHaveCount(0); +}); + +test('readonly responses require the correlated id, renderer source, and exact origin', async ({ page }) => { + await setup(page, '?commandDelay=800'); + await expect(page.frameLocator('iframe').getByRole('textbox')).toHaveValue('report.docx'); + await page.evaluate(() => { + const preview = document.querySelector('#preview')!; + preview.setAttribute('readonly', ''); + preview.setAttribute('readonly', 'false'); + }); + const frame = page.frames().find((frame) => frame.url().includes('hardening-editor'))!; + await expect.poll(() => frame.evaluate(() => (window as any).commands.length)).toBe(1); + await page.evaluate(async () => { + const target = document.querySelector('iframe')!.contentWindow!; + const id = (target as any).pendingId; + for (const [source, origin, responseId] of [ + [window, location.origin, id], + [target, 'https://wrong-origin.invalid', id], + [target, location.origin, 'wrong-id'], + ]) { + dispatchEvent( + new MessageEvent('message', { + source, + origin, + data: { id: responseId, type: 'document:readonly-changed', payload: { readonly: true } }, + }), + ); + } + // Give an incorrectly accepted reply time to start the queued command. + await new Promise((resolve) => setTimeout(resolve, 80)); + }); + expect(await frame.evaluate(() => (window as any).commands)).toEqual([true]); + await expect.poll(() => frame.evaluate(() => (window as any).commands)).toEqual([true, false]); + await expect(page.frameLocator('iframe').getByRole('textbox')).not.toHaveAttribute('readonly'); +}); + +test('closing or replacing a document cancels pending mode changes without stale errors', async ({ page }) => { + await setup(page, '?commandDelay=800'); + await expect(page.frameLocator('iframe').getByRole('textbox')).toHaveValue('report.docx'); + await page.evaluate(() => { + const preview = document.querySelector('#preview')!; + preview.setAttribute('readonly', ''); + preview.setAttribute('readonly', 'false'); + }); + const frame = page.frames().find((frame) => frame.url().includes('hardening-editor'))!; + await expect.poll(() => frame.evaluate(() => (window as any).commands.length)).toBe(1); + await page.evaluate(() => document.querySelector('#preview')!.setAttribute('src', '/replacement.docx')); + await expect(page.frameLocator('iframe').getByRole('textbox')).toHaveValue('replacement.docx'); + await expect.poll(() => page.evaluate(() => (window as any).events)).toEqual(['load', 'load']); + await page.evaluate(() => { + document.querySelector('#preview')!.setAttribute('readonly', ''); + }); + const replacement = page.frames().find((frame) => frame.url().includes('hardening-editor'))!; + await expect.poll(() => replacement.evaluate(() => (window as any).commands.length)).toBe(1); + await page.getByRole('button', { name: 'Close preview' }).click(); + await expect(page.locator('iframe')).toHaveCount(0); + expect(await page.evaluate(() => (window as any).events)).toEqual(['load', 'load']); +}); + +test('a legacy readonly change reports unsupported mode without destroying unsaved edits', async ({ page }) => { + await setup(page); + await expect(page.frameLocator('iframe').getByRole('textbox')).toHaveValue('report.docx'); + await page.evaluate(() => + document + .querySelector('#preview')! + .setAttribute('baseurl', `${location.origin}/test/fixtures/hardening-legacy.html`), + ); + const input = page.frameLocator('iframe').getByRole('textbox'); + await expect(input).toHaveValue('report.docx'); + await input.fill('Unsaved legacy edit'); + await page.evaluate(() => document.querySelector('#preview')!.setAttribute('readonly', '')); + await expect(page.getByRole('alert')).toContainText('requires a renderer with the document embed API'); + await expect(input).toHaveValue('Unsaved legacy edit'); +}); + +test('reconnecting an opened preview restores a working close button', async ({ page }) => { + await setup(page); + await expect(page.frameLocator('iframe').getByRole('textbox')).toHaveValue('report.docx'); + await page.evaluate(() => { + const preview = document.querySelector('#preview')!; + preview.remove(); + document.body.append(preview); + }); + await expect(page.frameLocator('iframe').getByRole('textbox')).toHaveValue('report.docx'); + await page.getByRole('button', { name: 'Close preview' }).click({ timeout: 3000 }); + await expect(page.getByRole('dialog')).toHaveCount(0); +}); + +test('readonly toggles during delayed open settle to the latest requested mode', async ({ page }) => { + await setup(page, '?readyDelay=900&probeOnly&openDelay=800'); + const input = page.frameLocator('iframe').getByRole('textbox'); + await expect(input).toHaveValue('Original'); + await page.evaluate(() => document.querySelector('#preview')!.setAttribute('readonly', '')); + await expect(input).toHaveAttribute('readonly', ''); + await page.evaluate(() => document.querySelector('#preview')!.setAttribute('readonly', 'false')); + await expect.poll(() => page.evaluate(() => (window as any).events)).toEqual(['load']); + await expect(input).not.toHaveAttribute('readonly'); +}); + +test('editable modern readiness remains active while a legacy probe is unacknowledged', async ({ page }) => { + await setup(page, '?readyDelay=2200&probeOnly'); + await expect(page.frameLocator('iframe').getByRole('textbox')).toHaveValue('report.docx'); + await expect.poll(() => page.evaluate(() => (window as any).events)).toEqual(['load']); + const frame = page.frames().find((frame) => frame.url().includes('hardening-editor'))!; + expect(await frame.evaluate(() => (window as any).opens)).toBe(1); +}); + +test('readonly enabled during the first legacy ACK stops before reading another chunk', async ({ page }) => { + await page.goto('/test/fixtures/preview.html'); + await page.evaluate(async () => { + const modulePath = '/components/preview/office.ts'; + const { renderOffice } = await import(modulePath); + const iframe = document.createElement('iframe'); + document.body.append(iframe); + const file = new File([new Uint8Array(32770)], 'readonly.docx'); + const slice = file.slice.bind(file); + (window as any).reads = 0; + file.slice = (start, end) => { + (window as any).reads++; + return slice(start, end); + }; + const options = { + iframe, + url: new URL('/test/fixtures/hardening-legacy.html?pause', location.href), + readonly: false, + signal: new AbortController().signal, + }; + (window as any).enableReadonly = () => (options.readonly = true); + (window as any).transfer = renderOffice(file, options).then( + () => 'opened', + (error: Error) => error.message, + ); + }); + const frame = () => page.frames().find((frame) => frame.url().includes('hardening-legacy'))!; + await expect.poll(async () => frame()?.evaluate(() => (window as any).chunks.length)).toBe(1); + await page.evaluate(() => (window as any).enableReadonly()); + await frame().evaluate(() => (window as any).releaseChunk()); + expect(await page.evaluate(() => (window as any).transfer)).toContain( + 'requires a renderer with the document embed API', + ); + expect(await page.evaluate(() => (window as any).reads)).toBe(1); + expect(await frame().evaluate(() => (window as any).chunks.length)).toBe(1); +}); + +test('readonly enabled during the first legacy read prevents its first send', async ({ page }) => { + await page.goto('/test/fixtures/preview.html'); + const result = await page.evaluate(async () => { + const modulePath = '/components/preview/office.ts'; + const { renderOffice } = await import(modulePath); + const iframe = document.createElement('iframe'); + document.body.append(iframe); + const options = { + iframe, + url: new URL('/test/fixtures/hardening-legacy.html', location.href), + readonly: false, + signal: new AbortController().signal, + }; + const file = new File(['12345678'], 'first-read.docx'); + const slice = file.slice.bind(file); + file.slice = (start, end) => { + const chunk = slice(start, end); + const read = chunk.arrayBuffer.bind(chunk); + chunk.arrayBuffer = async () => { + const result = await read(); + options.readonly = true; + return result; + }; + return chunk; + }; + const outcome = await renderOffice(file, options).then( + () => 'opened', + (error: Error) => error.message, + ); + return { outcome, sent: (iframe.contentWindow as any).chunks.length }; + }); + expect(result.sent).toBe(0); + expect(result.outcome).toContain('requires a renderer with the document embed API'); +}); + +for (const size of [8, 32770]) { + test(`readonly enabled during the final legacy ACK rejects the ${size === 8 ? 'single' : 'multiple'} chunk transfer`, async ({ + page, + }) => { + await page.goto('/test/fixtures/preview.html'); + await page.evaluate(async (size) => { + const modulePath = '/components/preview/office.ts'; + const { renderOffice } = await import(modulePath); + const iframe = document.createElement('iframe'); + document.body.append(iframe); + const options = { + iframe, + url: new URL('/test/fixtures/hardening-legacy.html?pause', location.href), + readonly: false, + signal: new AbortController().signal, + }; + (window as any).enableReadonly = () => (options.readonly = true); + (window as any).transfer = renderOffice(new File([new Uint8Array(size)], 'last-ack.docx'), options).then( + () => 'opened', + (error: Error) => error.message, + ); + }, size); + const frame = () => page.frames().find((frame) => frame.url().includes('hardening-legacy'))!; + const count = size === 8 ? 1 : 3; + await expect.poll(async () => frame()?.evaluate(() => (window as any).chunks.length)).toBe(1); + for (let expected = 2; expected <= count; expected++) { + await frame().evaluate(() => (window as any).releaseChunk()); + await expect.poll(() => frame().evaluate(() => (window as any).chunks.length)).toBe(expected); + } + await page.evaluate(() => (window as any).enableReadonly()); + await frame().evaluate(() => (window as any).releaseChunk()); + expect(await page.evaluate(() => (window as any).transfer)).toContain( + 'requires a renderer with the document embed API', + ); + }); +} + +test('repeated iframe load events leave no readiness probes after cancellation', async ({ page }) => { + await page.goto('/test/fixtures/preview.html'); + await page.evaluate(async () => { + const modulePath = '/components/preview/office.ts'; + const { renderOffice } = await import(modulePath); + const iframe = document.createElement('iframe'); + document.body.append(iframe); + const controller = new AbortController(); + (window as any).cancelOffice = () => controller.abort(); + (window as any).transfer = renderOffice(new File(['12345678'], 'cancel.docx'), { + iframe, + url: new URL('/test/fixtures/hardening-editor.html?readyDelay=90000&probeOnly', location.href), + readonly: false, + signal: controller.signal, + }).catch(() => 'aborted'); + }); + const frame = () => page.frames().find((frame) => frame.url().includes('hardening-editor'))!; + await expect(page.frameLocator('iframe').getByRole('textbox')).toHaveValue('Original'); + await frame().evaluate(() => { + (window as any).probes = 0; + addEventListener('message', (event) => { + if (event.data?.type === 'document:get-state') (window as any).probes++; + }); + }); + await page.evaluate(() => { + const iframe = document.querySelector('iframe')!; + iframe.dispatchEvent(new Event('load')); + iframe.dispatchEvent(new Event('load')); + (window as any).cancelOffice(); + }); + expect(await page.evaluate(() => (window as any).transfer)).toBe('aborted'); + const before = await frame().evaluate(() => (window as any).probes); + // Observe more than two retry periods, so an orphaned interval cannot hide. + await page.evaluate(() => new Promise((resolve) => setTimeout(resolve, 600))); + expect(await frame().evaluate(() => (window as any).probes)).toBe(before); +}); diff --git a/test/pdf.spec.ts b/test/pdf.spec.ts index 2988599..2d65ad2 100644 --- a/test/pdf.spec.ts +++ b/test/pdf.spec.ts @@ -6,6 +6,7 @@ test.beforeEach(async ({ page }) => { await page.addInitScript(() => { const workers = new Set(); const urls = new Set(); + let workerCount = 0; const NativeWorker = window.Worker; const terminate = NativeWorker.prototype.terminate; NativeWorker.prototype.terminate = function () { @@ -16,6 +17,7 @@ test.beforeEach(async ({ page }) => { construct(target, args) { const worker = Reflect.construct(target, args) as Worker; workers.add(worker); + workerCount++; return worker; }, }); @@ -31,6 +33,11 @@ test.beforeEach(async ({ page }) => { revokeObjectURL.call(URL, url); }; (window as any).pdfResources = () => ({ workers: workers.size, urls: urls.size }); + (window as any).pdfWorkerCount = () => workerCount; + (window as any).crashPdfWorker = () => + workers.forEach((worker) => { + worker.dispatchEvent(new ErrorEvent('error', { message: 'PDF worker stopped' })); + }); }); await page.goto('/test/fixtures/pdf.html'); await page.waitForFunction(() => typeof (window as any).renderPdf === 'function'); @@ -40,7 +47,7 @@ test('PDF completion includes every painted page once at the container width and const externalRequests: string[] = []; const fontResponses: number[] = []; page.on('request', (request) => { - if (!request.url().startsWith('http://127.0.0.1:5199/') && !request.url().startsWith('blob:')) { + if (!request.url().startsWith(new URL(page.url()).origin + '/') && !request.url().startsWith('blob:')) { externalRequests.push(request.url()); } }); @@ -81,6 +88,252 @@ test('PDF completion includes every painted page once at the container width and expect(fontResponses.every((status) => status === 200)).toBe(true); }); +test('painted PDF pages shrink with their container while keeping their aspect ratio and pixels', async ({ page }) => { + await page.evaluate(async () => { + const file = new File([await (await fetch('/test/fixtures/pdf-two-pages.pdf')).arrayBuffer()], 'two.pdf'); + const dom = document.querySelector('#pages')!; + await (window as any).renderPdf(file, { dom }); + dom.style.width = '120px'; + }); + const sizes = await page.locator('canvas').evaluateAll((canvases) => + canvases.map((element) => { + const canvas = element as HTMLCanvasElement; + const bounds = canvas.getBoundingClientRect(); + return [bounds.width, bounds.height, canvas.width, canvas.height]; + }), + ); + expect(sizes).toEqual([ + [120, 80, 480, 320], + [120, 180, 400, 600], + ]); + expect(await page.evaluate(() => (window as any).pdfResources())).toEqual({ workers: 0, urls: 0 }); +}); + +test('large PDF pages preserve display geometry within a bounded canvas pixel budget', async ({ page }) => { + const result = await page.evaluate(async () => { + const file = new File([await (await fetch('/test/fixtures/pdf-large-page.pdf')).arrayBuffer()], 'large.pdf'); + const dom = document.querySelector('#pages')!; + dom.style.width = '2000px'; + await (window as any).renderPdf(file, { dom }); + const canvas = dom.querySelector('canvas')!; + return { + pixels: canvas.width * canvas.height, + display: [canvas.getBoundingClientRect().width, canvas.getBoundingClientRect().height], + center: Array.from(canvas.getContext('2d')!.getImageData(canvas.width / 2, canvas.height / 2, 1, 1).data), + }; + }); + expect(result.pixels).toBeLessThanOrEqual(4_194_304); + expect(result.display).toEqual([2000, 1600]); + expect(result.center).toEqual([0, 255, 0, 255]); +}); + +test.describe('high density screens', () => { + test.use({ deviceScaleFactor: 4 }); + + test('PDF backing resolution caps excessive display density', async ({ page }) => { + const size = await page.evaluate(async () => { + const file = new File([await (await fetch('/test/fixtures/pdf-two-pages.pdf')).arrayBuffer()], 'two.pdf'); + const dom = document.querySelector('#pages')!; + await (window as any).renderPdf(file, { dom }); + const canvas = dom.querySelector('canvas')!; + return [canvas.width, canvas.height]; + }); + expect(size).toEqual([480, 320]); + }); +}); + +test('virtual PDF mode requires an explicit cancellation owner', async ({ page }) => { + const message = await page.evaluate(async () => { + const file = new File([await (await fetch('/test/fixtures/pdf-two-pages.pdf')).arrayBuffer()], 'two.pdf'); + try { + await (window as any).renderPdf(file, { dom: document.querySelector('#pages'), mode: 'virtual' }); + return 'accepted'; + } catch (error) { + return (error as Error).message; + } + }); + expect(message).toContain('AbortSignal'); + expect(await page.evaluate(() => (window as any).pdfResources())).toEqual({ workers: 0, urls: 0 }); +}); + +test('virtual PDF mode paints a bounded viewport buffer and releases offscreen pages and its worker on abort', async ({ + page, +}) => { + await page.evaluate(async () => { + const file = new File([await (await fetch('/test/fixtures/pdf-thirty-pages.pdf')).arrayBuffer()], 'thirty.pdf'); + const dom = document.querySelector('#pages')!; + dom.style.height = '300px'; + dom.style.overflow = 'auto'; + const controller = new AbortController(); + (window as any).cancelPdf = () => controller.abort(); + (window as any).pdfLoads = 0; + await (window as any).renderPdf(file, { + dom, + mode: 'virtual', + signal: controller.signal, + onLoad: () => (window as any).pdfLoads++, + }); + }); + expect(await page.evaluate(() => (window as any).pdfLoads)).toBe(1); + expect(await page.locator('[data-pdf-page]').count()).toBe(30); + expect(await page.locator('canvas').count()).toBeLessThanOrEqual(6); + expect(await page.locator('canvas').count()).toBeGreaterThan(0); + expect(await page.evaluate(() => (window as any).pdfResources())).toEqual({ workers: 1, urls: 1 }); + await page.locator('#pages').evaluate((dom) => (dom.scrollTop = dom.scrollHeight)); + await expect + .poll(() => + page.locator('[data-pdf-page="30"] canvas').evaluateAll((canvases) => { + const canvas = canvases[0] as HTMLCanvasElement | undefined; + return canvas + ? Array.from(canvas.getContext('2d')!.getImageData(canvas.width / 2, canvas.height / 2, 1, 1).data) + : []; + }), + ) + .toEqual([0, 0, 255, 255]); + expect(await page.locator('[data-pdf-page="1"] canvas').count()).toBe(0); + expect(await page.locator('canvas').count()).toBeLessThanOrEqual(6); + await page.evaluate(() => (window as any).cancelPdf()); + await expect.poll(() => page.evaluate(() => (window as any).pdfResources())).toEqual({ workers: 0, urls: 0 }); + expect(await page.locator('[data-pdf-page], canvas').count()).toBe(0); +}); + +test('virtual PDF pages redraw on resize without loading the file or creating a worker again', async ({ page }) => { + await page.evaluate(async () => { + const file = new File([await (await fetch('/test/fixtures/pdf-thirty-pages.pdf')).arrayBuffer()], 'thirty.pdf'); + let reads = 0; + const arrayBuffer = file.arrayBuffer.bind(file); + file.arrayBuffer = () => { + reads++; + return arrayBuffer(); + }; + const dom = document.querySelector('#pages')!; + dom.style.cssText = 'width:120px;height:300px;overflow:auto;'; + const controller = new AbortController(); + (window as any).cancelPdf = () => controller.abort(); + (window as any).pdfReadCount = () => reads; + (window as any).pdfErrors = []; + await (window as any).renderPdf(file, { + dom, + mode: 'virtual', + signal: controller.signal, + onError: (error: unknown) => (window as any).pdfErrors.push(error), + }); + dom.style.width = '200px'; + }); + await expect + .poll(() => + page.locator('[data-pdf-page="1"] canvas').evaluateAll((elements) => { + const canvas = elements[0] as HTMLCanvasElement | undefined; + if (!canvas) return []; + return [ + canvas.width, + canvas.height, + ...canvas.getContext('2d')!.getImageData(canvas.width / 2, canvas.height / 2, 1, 1).data, + ]; + }), + ) + .toEqual([400, 600, 255, 0, 0, 255]); + expect(await page.evaluate(() => (window as any).pdfReadCount())).toBe(1); + expect(await page.evaluate(() => (window as any).pdfWorkerCount())).toBe(1); + expect(await page.evaluate(() => (window as any).pdfErrors)).toEqual([]); + expect(await page.evaluate(() => (window as any).pdfResources())).toEqual({ workers: 1, urls: 1 }); + await page.evaluate(() => (window as any).cancelPdf()); + await expect.poll(() => page.evaluate(() => (window as any).pdfResources())).toEqual({ workers: 0, urls: 0 }); +}); + +test('aborting virtual PDF during its first paint removes placeholders without reporting load or error', async ({ + page, +}) => { + const result = await page.evaluate(async () => { + const file = new File([await (await fetch('/test/fixtures/pdf-thirty-pages.pdf')).arrayBuffer()], 'thirty.pdf'); + const dom = document.querySelector('#pages')!; + dom.style.cssText = 'width:240px;height:300px;overflow:auto;'; + const controller = new AbortController(); + let loads = 0; + let errors = 0; + const observer = new MutationObserver(() => { + if (dom.querySelector('canvas')) controller.abort(); + }); + observer.observe(dom, { childList: true, subtree: true }); + await (window as any).renderPdf(file, { + dom, + mode: 'virtual', + signal: controller.signal, + onLoad: () => loads++, + onError: () => errors++, + }); + observer.disconnect(); + return { loads, errors, children: dom.children.length }; + }); + expect(result).toEqual({ loads: 0, errors: 0, children: 0 }); + expect(await page.evaluate(() => (window as any).pdfResources())).toEqual({ workers: 0, urls: 0 }); +}); + +test('resizing during a virtual paint retries the current viewport instead of failing the preview', async ({ + page, +}) => { + const result = await page.evaluate(async () => { + const file = new File([await (await fetch('/test/fixtures/pdf-busy-page.pdf')).arrayBuffer()], 'busy.pdf'); + const dom = document.querySelector('#pages')!; + dom.style.cssText = 'width:120px;height:300px;overflow:auto;'; + // Keep real canvas painting but force PDF.js to yield, so resize happens during a render task. + const fill = CanvasRenderingContext2D.prototype.fill; + let slowFills = 40; + CanvasRenderingContext2D.prototype.fill = function (...args: any[]) { + if (slowFills-- > 0) { + const until = performance.now() + 2; + while (performance.now() < until) { + /* expose a real asynchronous rendering window */ + } + } + return (fill as any).apply(this, args); + }; + const controller = new AbortController(); + const observer = new MutationObserver(() => { + if (dom.querySelector('canvas')) dom.style.width = '200px'; + }); + observer.observe(dom, { childList: true, subtree: true }); + let error = ''; + try { + await (window as any).renderPdf(file, { dom, mode: 'virtual', signal: controller.signal }); + } catch (reason) { + error = (reason as Error).message; + } + observer.disconnect(); + CanvasRenderingContext2D.prototype.fill = fill; + const canvas = dom.querySelector('canvas'); + const size = canvas ? [canvas.width, canvas.height] : []; + controller.abort(); + return { error, size }; + }); + expect(result).toEqual({ error: '', size: [400, 600] }); + await expect.poll(() => page.evaluate(() => (window as any).pdfResources())).toEqual({ workers: 0, urls: 0 }); +}); + +test('a virtual worker failure after completion reports one error and releases retained resources', async ({ + page, +}) => { + await page.evaluate(async () => { + const file = new File([await (await fetch('/test/fixtures/pdf-thirty-pages.pdf')).arrayBuffer()], 'thirty.pdf'); + const dom = document.querySelector('#pages')!; + dom.style.cssText = 'width:240px;height:300px;overflow:auto;'; + const controller = new AbortController(); + (window as any).pdfErrors = []; + await (window as any).renderPdf(file, { + dom, + mode: 'virtual', + signal: controller.signal, + onError: (error: unknown) => (window as any).pdfErrors.push(error), + }); + (window as any).crashPdfWorker(); + }); + await expect.poll(() => page.evaluate(() => (window as any).pdfResources())).toEqual({ workers: 0, urls: 0 }); + expect(await page.evaluate(() => (window as any).pdfErrors)).toEqual([ + { success: false, message: 'PDF worker stopped' }, + ]); + expect(await page.locator('[data-pdf-page], canvas').count()).toBe(0); +}); + test('a malformed PDF rejects promptly so the host can report one error', async ({ page }) => { const result = await page.evaluate(async () => { let loads = 0; From 27dad6493e870e4558895e9fc1a7b708bb71ab46 Mon Sep 17 00:00:00 2001 From: chaxus Date: Mon, 19 Oct 2026 11:55:39 +0800 Subject: [PATCH 2/5] refactor(package): expose typed elements and remove unused ranui --- manual.ts | 18 +- package.json | 6 +- plugins/load-style.ts | 34 --- pnpm-lock.yaml | 390 ------------------------------- pnpm-workspace.yaml | 1 - test/manual-registration.spec.ts | 58 +++++ test/package-consumer.ts | 40 +++- test/package-react.tsx | 8 +- typings.d.ts | 5 +- vite.config.ts | 6 - 10 files changed, 124 insertions(+), 442 deletions(-) delete mode 100644 plugins/load-style.ts create mode 100644 test/manual-registration.spec.ts diff --git a/manual.ts b/manual.ts index c660555..a2dc9fc 100644 --- a/manual.ts +++ b/manual.ts @@ -1,12 +1,28 @@ +import type { PreviewElement } from './components/preview/core'; export type { PreviewElement } from './components/preview/core'; export type { RenderOptions } from './components/preview/types'; +export type PreviewElementConstructor = new () => PreviewElement; +let implementation: PreviewElementConstructor | undefined; + +/** Narrow a registration result without treating an unrelated existing tag as a preview. */ +export function isPreviewConstructor(value: unknown): value is PreviewElementConstructor { + return ( + typeof value === 'function' && + implementation !== undefined && + (value === implementation || value.prototype instanceof implementation) + ); +} + /** Passive entry point: importing this module registers no custom elements. */ -export async function registerPreview(tagName = 'r-preview'): Promise { +export async function registerPreview( + tagName = 'r-preview', +): Promise { if (typeof window === 'undefined' || typeof customElements === 'undefined') return undefined; const existing = customElements.get(tagName); if (existing) return existing; const { PreviewElement } = await import('./components/preview/core'); + implementation = PreviewElement; // Multiple callers may await the same dynamic import concurrently. if (!customElements.get(tagName)) customElements.define(tagName, class extends PreviewElement {}); return customElements.get(tagName); diff --git a/package.json b/package.json index a4e8f20..0b74337 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@ranui/preview", - "version": "0.2.0", + "version": "0.3.0", "description": "Browser-native PDF and Office file preview with configurable self-hosted rendering", "main": "dist/umd/index.umd.cjs", "module": "dist/index.js", @@ -31,7 +31,8 @@ "typecheck": "tsc --noEmit", "format:check": "prettier --check .", "test:package": "node ./scripts/verify-package.mjs", - "prepack": "npm run build" + "prepack": "npm run build", + "test:release": "node --test test/release.test.mjs test/release-source.test.mjs" }, "files": [ "dist" @@ -92,7 +93,6 @@ "publint": "0.3.25" }, "dependencies": { - "ranui": "0.5.0", "ranuts": "0.5.0" }, "packageManager": "pnpm@11.6.0" diff --git a/plugins/load-style.ts b/plugins/load-style.ts deleted file mode 100644 index d97126d..0000000 --- a/plugins/load-style.ts +++ /dev/null @@ -1,34 +0,0 @@ -import type { Plugin } from 'vite'; -interface Options { - ignore?: Array; -} - -export default function loadStylePlugin(options?: Options): Plugin { - return { - name: 'vite-plugin-load-style', - transform(code, id) { - const path = /ranui\/components\/[a-z|A-Z\d]+\/index.ts/; - const stylePath = new RegExp(/((this\.)?[a-zA-Z\d]+)\s*=\s*this\.attachShadow\(\{.*\}\)/); - const { ignore = [] } = options ?? {}; - const match = stylePath.exec(code) ?? []; - const [fragment, statement] = match; - let result = code; - if (path.test(id) && !ignore.some((item) => new RegExp(item).test(id)) && fragment && statement) { - const front = `import f7170ee498e0dd32cbdcb63fba8f75cc from '${id.replace('index.ts', 'index.less?inline')}';`; - result = result.replace( - stylePath, - `${fragment}; - const F7170EE498E0DD32CBDCB63FBA8F75CC = document.createElement('style'); - F7170EE498E0DD32CBDCB63FBA8F75CC.textContent = f7170ee498e0dd32cbdcb63fba8f75cc; - ${statement}.appendChild(F7170EE498E0DD32CBDCB63FBA8F75CC);`, - ); - result = front + result; - } - - return { - code: result, - map: null, // TODO - }; - }, - }; -} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7ff0690..cc9394e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8,9 +8,6 @@ importers: .: dependencies: - ranui: - specifier: 0.5.0 - version: 0.5.0 ranuts: specifier: 0.5.0 version: 0.5.0 @@ -51,10 +48,6 @@ importers: packages: - '@alixex/ranview@0.2.0': - resolution: {integrity: sha512-0amq1DhpdJQiwGBe0n5CwsrgJvv0GuTuF1wDw+SPePPh9MdoP+pk2SY5E1I8MJ6wvC8beIWxII12KIbfbgIjRw==} - engines: {node: '>=24.0.0'} - '@esbuild/aix-ppc64@0.28.1': resolution: {integrity: sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==} engines: {node: '>=18'} @@ -413,55 +406,12 @@ packages: '@rolldown/pluginutils@1.0.1': resolution: {integrity: sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==} - '@shikijs/core@4.4.3': - resolution: {integrity: sha512-QCR4q2ZO/ILJEuwiBMel4wdcTDb1JGwfjKTxPDF6x8ixOaluPrVqIn06C99AcRPhmYlBR56d/Fb+GN58GzExpg==} - engines: {node: '>=20'} - - '@shikijs/engine-javascript@4.4.3': - resolution: {integrity: sha512-FbOjFJp9VLdo1Wevs10BBtVxiTWwNLqZh5Gkhjgda/ioL15YOgeSl9n+6XMa3qRlPQzfhFNe641SrynFHYG0nQ==} - engines: {node: '>=20'} - - '@shikijs/engine-oniguruma@4.4.3': - resolution: {integrity: sha512-EcOQkxdxGQrc1Row/cC2c96/v1dbZqGnEVu1qTuT/MJmp6+cXCvQussowVmCv5Tqr3KuY3c7IbM6HTW3LJ1k9w==} - engines: {node: '>=20'} - - '@shikijs/langs@4.4.3': - resolution: {integrity: sha512-ePic0yfAJGOF83D5wBHK/00EjK65oahBYxFk5epgq33WRv7X9UuxLEV8PtR0szC0z8dl7INIpIodB99JRFlR+A==} - engines: {node: '>=20'} - - '@shikijs/primitive@4.4.3': - resolution: {integrity: sha512-m0wBeLDQDeIxRdUmrCPdQqfuUamDwRL5isCfYbguKD6NiaKpVbsv+3J81DyIKgNW5h4WAIIr8T4EkgQrBBxvaQ==} - engines: {node: '>=20'} - - '@shikijs/themes@4.4.3': - resolution: {integrity: sha512-w8UHjeUnIR965KMWJHUPXOc2mNJUnK3vpVLYLvw5IYU2mnTTJ89E24OrJDBNiJDQ0qzb0tc4l7mrIXx5cFeIyw==} - engines: {node: '>=20'} - - '@shikijs/types@4.4.3': - resolution: {integrity: sha512-UEJxmRR++MAGR6hugn0vgVS2W/6lWAts84FFSrnlH9sP0LNol7E5+NQ792pH8liWUhyMyjhTgSUH3k7iD7tc5g==} - engines: {node: '>=20'} - - '@shikijs/vscode-textmate@10.0.2': - resolution: {integrity: sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg==} - - '@types/hast@3.0.5': - resolution: {integrity: sha512-rp/ezSWaD1m44dPKICGhiskI13nVr7qTloFwDa/IYkhhf5nzwP+zIQcIJh3WIFSBOy/H1PzB40jPjMDksN4F+g==} - - '@types/mdast@4.0.4': - resolution: {integrity: sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==} - '@types/node@26.6.5': resolution: {integrity: sha512-uMzsv9stDdofIZBx4UGh4Z9ZgXnGC9seRDscilBcTHAeOFk67MZg/k9bWVfO+bk4qZmG+l52hRnLwUiH2LnJTg==} '@types/react@19.2.17': resolution: {integrity: sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==} - '@types/trusted-types@2.0.7': - resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==} - - '@types/unist@3.0.3': - resolution: {integrity: sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==} - '@typescript/typescript-aix-ppc64@7.0.2': resolution: {integrity: sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ==} engines: {node: '>=16.20.0'} @@ -582,9 +532,6 @@ packages: cpu: [x64] os: [win32] - '@ungap/structured-clone@1.4.0': - resolution: {integrity: sha512-1mEZtMKPM09vDmQt5y7YvmN2+DFTP7Tg0EWXdic8/C6VRnpb33e4ghisCIE3WZjsE2N8mf+QV1Zqh7ZFYLWInQ==} - acorn@8.18.0: resolution: {integrity: sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==} engines: {node: '>=0.4.0'} @@ -593,18 +540,6 @@ packages: buffer-from@1.1.2: resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==} - ccount@2.0.1: - resolution: {integrity: sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==} - - character-entities-html4@2.1.0: - resolution: {integrity: sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==} - - character-entities-legacy@3.0.0: - resolution: {integrity: sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ==} - - comma-separated-tokens@2.0.3: - resolution: {integrity: sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==} - commander@2.20.3: resolution: {integrity: sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==} @@ -631,20 +566,10 @@ packages: supports-color: optional: true - dequal@2.0.3: - resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} - engines: {node: '>=6'} - detect-libc@2.1.2: resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} engines: {node: '>=8'} - devlop@1.1.0: - resolution: {integrity: sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==} - - dompurify@3.4.16: - resolution: {integrity: sha512-sqo+pNp3qRhCIpbgRi1y8Tgk27Bo2Ry7w0dC1NBeNTdZChWjz9Xb/KOoZbRP/R6pQZ80Qw8YhXw13hWWBbMRnQ==} - errno@0.1.8: resolution: {integrity: sha512-dJ6oBr5SQ1VSd9qkk7ByRgb/1SH4JZjCHSW/mr63/QcXO9zLVxvJ6Oy13nio03rxpSnVDDjFor75SjVeZWPW/A==} hasBin: true @@ -671,15 +596,6 @@ packages: graceful-fs@4.2.11: resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} - hast-util-to-html@9.0.5: - resolution: {integrity: sha512-OguPdidb+fbHQSU4Q4ZiLKnzWo8Wwsf5bZfbvu7//a9oTYoqD/fWpe96NuHkoS9h0ccGOTe0C4NGXdtS0iObOw==} - - hast-util-whitespace@3.0.0: - resolution: {integrity: sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==} - - html-void-elements@3.0.0: - resolution: {integrity: sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg==} - iconv-lite@0.4.24: resolution: {integrity: sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==} engines: {node: '>=0.10.0'} @@ -782,29 +698,6 @@ packages: resolution: {integrity: sha512-IfpFq6UM39dUNiphpA6uDezNx/AvWyhwfICWPR3t1VspkgkMZrL+Rk1RbN1bx+aeNYwOrqGJgEgV3yotk+ZUVw==} engines: {node: '>=18'} - marked@18.0.9: - resolution: {integrity: sha512-/Sa4qiiHZxf0/FQdBBowr9q4r10krCwMvpK48FUBdXdUXScDxiQGR9zCPrFgRVR5LU3iySOiIjy09ZQvADir1w==} - engines: {node: '>= 20'} - hasBin: true - - mdast-util-to-hast@13.2.1: - resolution: {integrity: sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA==} - - micromark-util-character@2.1.1: - resolution: {integrity: sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==} - - micromark-util-encode@2.0.1: - resolution: {integrity: sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw==} - - micromark-util-sanitize-uri@2.0.1: - resolution: {integrity: sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ==} - - micromark-util-symbol@2.0.1: - resolution: {integrity: sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==} - - micromark-util-types@2.0.3: - resolution: {integrity: sha512-oxB2Ik03hI0gv+VNn9tnh1t1YEe9MDPptViAEgfdf3YQHsn0pzGTgCdlSCJXcwhqm8phaEuM7zeEu3QQzVBrPg==} - mime@1.6.0: resolution: {integrity: sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==} engines: {node: '>=4'} @@ -835,12 +728,6 @@ packages: engines: {node: '>= 4.4.x'} hasBin: true - oniguruma-parser@0.12.2: - resolution: {integrity: sha512-6HVa5oIrgMC6aA6WF6XyyqbhRPJrKR02L20+2+zpDtO5QAzGHAUGw5TKQvwi5vctNnRHkJYmjAhRVQF2EKdTQw==} - - oniguruma-to-es@4.3.6: - resolution: {integrity: sha512-csuQ9x3Yr0cEIs/Zgx/OEt9iBw9vqIunAPQkx19R/fiMq2oGVTgcMqO/V3Ybqefr1TBvosI6jU539ksaBULJyA==} - package-manager-detector@1.8.0: resolution: {integrity: sha512-yQA4H19AmPEoMUeavPMDIe1higySl/gH/yaQrkT/s07Qp+7pp2hYz30N3z2l5BkjVkF9Ow6o0wjJamm2y7Sn0A==} @@ -885,9 +772,6 @@ packages: probe-image-size@7.4.0: resolution: {integrity: sha512-cdEprVtZxV+awMde9X+4jILBFYh4CARxVrQaMl4wY4YcPWbul9jntXrIW95NInBDyJwcVUP3U0T6yukN8rMBaQ==} - property-information@7.2.0: - resolution: {integrity: sha512-IAtzIB6sUiWaJYrX9smp3V46pBGbBeLFRGdh25kg1334VcBlD8HzhPeNIWQH9zhGmo2itIe25EHt9dQP7G5hmg==} - prr@1.0.1: resolution: {integrity: sha512-yPw4Sng1gWghHQWj0B3ZggWUm4qVbPwPFcRG8KyxiU7J2OHFSoEHKS+EZ3fv5l1t9CyCiop6l/ZYeWbrgoQejw==} @@ -896,26 +780,10 @@ packages: engines: {node: '>=18'} hasBin: true - ranui@0.5.0: - resolution: {integrity: sha512-XYYbyHiVD56YZmw8HEbxIz9hpJQo/ytuVQrbTMKuG900Rvr/DvAo6fYRPTz5VNDzBBPIr3PjDmBBeJQOU7jJfg==} - engines: {node: '>=24.0.0'} - ranuts@0.5.0: resolution: {integrity: sha512-lpzx1NoyNHL+rkJaJApeOJDQewf8T6mxberuwu1Mb67s62H0OrKoMoXjmEB/9B74cFblfNt6jIfRBI+stQsU0w==} engines: {node: '>=24.0.0'} - regex-recursion@6.0.2: - resolution: {integrity: sha512-0YCaSCq2VRIebiaUviZNs0cBz1kg5kVS2UKUfNIx8YVs1cN3AV7NTctO5FOKBA+UT2BPJIWZauYHPqJODG50cg==} - - regex-utilities@2.3.0: - resolution: {integrity: sha512-8VhliFJAWRaUiVvREIiW2NXXTmHs4vMNnSzuJVhscgmGav3g9VDxLrQndI3dZZVVdp0ZO/5v0xmX516/7M9cng==} - - regex@6.1.0: - resolution: {integrity: sha512-6VwtthbV4o/7+OaAF9I5L5V3llLEsoPyq9P1JVXkedTP33c7MfCG0/5NOPcSJn0TzXcG9YUrR0gQSWioew3LDg==} - - remend@1.3.0: - resolution: {integrity: sha512-iIhggPkhW3hFImKtB10w0dz4EZbs28mV/dmbcYVonWEJ6UGHHpP+bFZnTh6GNWJONg5m+U56JrL+8IxZRdgWjw==} - rolldown@1.2.14: resolution: {integrity: sha512-eTBTTqMR6uHKLgcUqpJP8ntzDI6V4LYA8negAMBEI7gkgVvOdrx6VsvS2Ym+83KhOPdLCHWJu0w7UQFJZhHKNw==} engines: {node: ^20.19.0 || >=22.12.0} @@ -932,10 +800,6 @@ packages: resolution: {integrity: sha512-42tBVwLWnaQvW5zc4HbZrTuWccECCZfBi92FDuwtqxasH+JbPB3/FOKb1m222K42R4WxuxzzMsTswfzgtSu64Q==} engines: {node: '>=11.0.0'} - shiki@4.4.3: - resolution: {integrity: sha512-Mb/GvXPHBAXdgGIcnfU5L3ldpn1XcxrGkPHwqgRx17/I2XRfqlFKk2vGkHWINn1kdXvzJZeuO3is6I9KLPFm0g==} - engines: {node: '>=20'} - source-map-js@1.2.2: resolution: {integrity: sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==} engines: {node: '>=0.10.0'} @@ -947,19 +811,9 @@ packages: resolution: {integrity: sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==} engines: {node: '>=0.10.0'} - space-separated-tokens@2.0.2: - resolution: {integrity: sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==} - stream-parser@0.3.1: resolution: {integrity: sha512-bJ/HgKq41nlKvlhccD5kaCr/P+Hu0wPNKPJOH7en+YrJu/9EgqUF+88w5Jb6KNcjOFMhfX4B2asfeAtIGuHObQ==} - stringify-entities@4.0.4: - resolution: {integrity: sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==} - - temml@0.13.4: - resolution: {integrity: sha512-k1yolMBswx34Jw9hZn5Xh2/GBlwqlW+wiN7QaUYUMhSa1ypfti6rlCfSmCxWyooxH1G32C/Z+qVvgAsBOELZBQ==} - engines: {node: '>=18.13.0'} - terser@5.51.2: resolution: {integrity: sha512-bWnjSNscmuI+GJze6ZupnHP8G/cTcsJF+bXCeQknk2SHQsgbNJnLrqiH9jZ2W4STPVXH2mDKKRX3iwPhc9Cn/Q==} engines: {node: '>=10'} @@ -973,9 +827,6 @@ packages: resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} engines: {node: '>=12.0.0'} - trim-lines@3.0.1: - resolution: {integrity: sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==} - tsx@4.23.15: resolution: {integrity: sha512-Yiex1Ovn8z2xPpOWckIiysV1SSyRMY9BkLF++q0yKiDxCqRhosKfMg3janKkiLBwZ5c/YryloKwGZcrEmtwxKw==} engines: {node: '>=18.0.0'} @@ -989,27 +840,6 @@ packages: undici-types@8.9.0: resolution: {integrity: sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg==} - unist-util-is@6.0.1: - resolution: {integrity: sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g==} - - unist-util-position@5.0.0: - resolution: {integrity: sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA==} - - unist-util-stringify-position@4.0.0: - resolution: {integrity: sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ==} - - unist-util-visit-parents@6.0.2: - resolution: {integrity: sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ==} - - unist-util-visit@5.1.0: - resolution: {integrity: sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg==} - - vfile-message@4.0.3: - resolution: {integrity: sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==} - - vfile@6.0.3: - resolution: {integrity: sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==} - vite@8.3.4: resolution: {integrity: sha512-IftLE8phJhdNRIGNEV4JCCvfNScoZQVfcXaN8TQropWGJtOHcu+iAB9pHcuGvmEb/kddw0LBe/jS8ztdC6xdiQ==} engines: {node: ^20.19.0 || >=22.12.0} @@ -1053,15 +883,8 @@ packages: yaml: optional: true - zwitch@2.0.4: - resolution: {integrity: sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==} - snapshots: - '@alixex/ranview@0.2.0': - dependencies: - ranuts: 0.5.0 - '@esbuild/aix-ppc64@0.28.1': optional: true @@ -1264,54 +1087,6 @@ snapshots: '@rolldown/pluginutils@1.0.1': {} - '@shikijs/core@4.4.3': - dependencies: - '@shikijs/primitive': 4.4.3 - '@shikijs/types': 4.4.3 - '@shikijs/vscode-textmate': 10.0.2 - '@types/hast': 3.0.5 - hast-util-to-html: 9.0.5 - - '@shikijs/engine-javascript@4.4.3': - dependencies: - '@shikijs/types': 4.4.3 - '@shikijs/vscode-textmate': 10.0.2 - oniguruma-to-es: 4.3.6 - - '@shikijs/engine-oniguruma@4.4.3': - dependencies: - '@shikijs/types': 4.4.3 - '@shikijs/vscode-textmate': 10.0.2 - - '@shikijs/langs@4.4.3': - dependencies: - '@shikijs/types': 4.4.3 - - '@shikijs/primitive@4.4.3': - dependencies: - '@shikijs/types': 4.4.3 - '@shikijs/vscode-textmate': 10.0.2 - '@types/hast': 3.0.5 - - '@shikijs/themes@4.4.3': - dependencies: - '@shikijs/types': 4.4.3 - - '@shikijs/types@4.4.3': - dependencies: - '@shikijs/vscode-textmate': 10.0.2 - '@types/hast': 3.0.5 - - '@shikijs/vscode-textmate@10.0.2': {} - - '@types/hast@3.0.5': - dependencies: - '@types/unist': 3.0.3 - - '@types/mdast@4.0.4': - dependencies: - '@types/unist': 3.0.3 - '@types/node@26.6.5': dependencies: undici-types: 8.9.0 @@ -1320,11 +1095,6 @@ snapshots: dependencies: csstype: 3.2.3 - '@types/trusted-types@2.0.7': - optional: true - - '@types/unist@3.0.3': {} - '@typescript/typescript-aix-ppc64@7.0.2': optional: true @@ -1385,20 +1155,10 @@ snapshots: '@typescript/typescript-win32-x64@7.0.2': optional: true - '@ungap/structured-clone@1.4.0': {} - acorn@8.18.0: {} buffer-from@1.1.2: {} - ccount@2.0.1: {} - - character-entities-html4@2.1.0: {} - - character-entities-legacy@3.0.0: {} - - comma-separated-tokens@2.0.3: {} - commander@2.20.3: {} copy-anything@3.0.5: @@ -1417,18 +1177,8 @@ snapshots: ms: 2.1.3 optional: true - dequal@2.0.3: {} - detect-libc@2.1.2: {} - devlop@1.1.0: - dependencies: - dequal: 2.0.3 - - dompurify@3.4.16: - optionalDependencies: - '@types/trusted-types': 2.0.7 - errno@0.1.8: dependencies: prr: 1.0.1 @@ -1473,26 +1223,6 @@ snapshots: graceful-fs@4.2.11: optional: true - hast-util-to-html@9.0.5: - dependencies: - '@types/hast': 3.0.5 - '@types/unist': 3.0.3 - ccount: 2.0.1 - comma-separated-tokens: 2.0.3 - hast-util-whitespace: 3.0.0 - html-void-elements: 3.0.0 - mdast-util-to-hast: 13.2.1 - property-information: 7.2.0 - space-separated-tokens: 2.0.2 - stringify-entities: 4.0.4 - zwitch: 2.0.4 - - hast-util-whitespace@3.0.0: - dependencies: - '@types/hast': 3.0.5 - - html-void-elements@3.0.0: {} - iconv-lite@0.4.24: dependencies: safer-buffer: 2.1.2 @@ -1577,37 +1307,6 @@ snapshots: make-dir@5.1.0: optional: true - marked@18.0.9: {} - - mdast-util-to-hast@13.2.1: - dependencies: - '@types/hast': 3.0.5 - '@types/mdast': 4.0.4 - '@ungap/structured-clone': 1.4.0 - devlop: 1.1.0 - micromark-util-sanitize-uri: 2.0.1 - trim-lines: 3.0.1 - unist-util-position: 5.0.0 - unist-util-visit: 5.1.0 - vfile: 6.0.3 - - micromark-util-character@2.1.1: - dependencies: - micromark-util-symbol: 2.0.1 - micromark-util-types: 2.0.3 - - micromark-util-encode@2.0.1: {} - - micromark-util-sanitize-uri@2.0.1: - dependencies: - micromark-util-character: 2.1.1 - micromark-util-encode: 2.0.1 - micromark-util-symbol: 2.0.1 - - micromark-util-symbol@2.0.1: {} - - micromark-util-types@2.0.3: {} - mime@1.6.0: optional: true @@ -1636,14 +1335,6 @@ snapshots: sax: 1.6.1 optional: true - oniguruma-parser@0.12.2: {} - - oniguruma-to-es@4.3.6: - dependencies: - oniguruma-parser: 0.12.2 - regex: 6.1.0 - regex-recursion: 6.0.2 - package-manager-detector@1.8.0: {} parse-node-version@1.0.1: {} @@ -1681,8 +1372,6 @@ snapshots: - supports-color optional: true - property-information@7.2.0: {} - prr@1.0.1: optional: true @@ -1693,32 +1382,10 @@ snapshots: picocolors: 1.1.1 sade: 1.8.1 - ranui@0.5.0: - dependencies: - '@alixex/ranview': 0.2.0 - dompurify: 3.4.16 - marked: 18.0.9 - ranuts: 0.5.0 - remend: 1.3.0 - shiki: 4.4.3 - temml: 0.13.4 - ranuts@0.5.0: dependencies: jschardet: 3.1.4 - regex-recursion@6.0.2: - dependencies: - regex-utilities: 2.3.0 - - regex-utilities@2.3.0: {} - - regex@6.1.0: - dependencies: - regex-utilities: 2.3.0 - - remend@1.3.0: {} - rolldown@1.2.14: dependencies: '@oxc-project/types': 0.154.0 @@ -1750,17 +1417,6 @@ snapshots: sax@1.6.1: optional: true - shiki@4.4.3: - dependencies: - '@shikijs/core': 4.4.3 - '@shikijs/engine-javascript': 4.4.3 - '@shikijs/engine-oniguruma': 4.4.3 - '@shikijs/langs': 4.4.3 - '@shikijs/themes': 4.4.3 - '@shikijs/types': 4.4.3 - '@shikijs/vscode-textmate': 10.0.2 - '@types/hast': 3.0.5 - source-map-js@1.2.2: {} source-map-support@0.5.21: @@ -1770,8 +1426,6 @@ snapshots: source-map@0.6.1: {} - space-separated-tokens@2.0.2: {} - stream-parser@0.3.1: dependencies: debug: 2.6.9 @@ -1779,13 +1433,6 @@ snapshots: - supports-color optional: true - stringify-entities@4.0.4: - dependencies: - character-entities-html4: 2.1.0 - character-entities-legacy: 3.0.0 - - temml@0.13.4: {} - terser@5.51.2: dependencies: '@jridgewell/source-map': 0.3.11 @@ -1800,8 +1447,6 @@ snapshots: fdir: 6.5.0(picomatch@4.0.5) picomatch: 4.0.5 - trim-lines@3.0.1: {} - tsx@4.23.15: dependencies: esbuild: 0.28.1 @@ -1833,39 +1478,6 @@ snapshots: undici-types@8.9.0: {} - unist-util-is@6.0.1: - dependencies: - '@types/unist': 3.0.3 - - unist-util-position@5.0.0: - dependencies: - '@types/unist': 3.0.3 - - unist-util-stringify-position@4.0.0: - dependencies: - '@types/unist': 3.0.3 - - unist-util-visit-parents@6.0.2: - dependencies: - '@types/unist': 3.0.3 - unist-util-is: 6.0.1 - - unist-util-visit@5.1.0: - dependencies: - '@types/unist': 3.0.3 - unist-util-is: 6.0.1 - unist-util-visit-parents: 6.0.2 - - vfile-message@4.0.3: - dependencies: - '@types/unist': 3.0.3 - unist-util-stringify-position: 4.0.0 - - vfile@6.0.3: - dependencies: - '@types/unist': 3.0.3 - vfile-message: 4.0.3 - vite@8.3.4(@types/node@26.6.5)(esbuild@0.28.1)(less@4.9.1)(terser@5.51.2)(tsx@4.23.15): dependencies: lightningcss: 1.33.0 @@ -1880,5 +1492,3 @@ snapshots: less: 4.9.1 terser: 5.51.2 tsx: 4.23.15 - - zwitch@2.0.4: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index b13858d..8260fff 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -4,6 +4,5 @@ allowBuilds: # published alphas install immediately. Versioned entries don't satisfy the # pre-run lockfile verification — bare names are required. minimumReleaseAgeExclude: - - ranui - ranuts - '@alixex/ranview@0.2.0' diff --git a/test/manual-registration.spec.ts b/test/manual-registration.spec.ts new file mode 100644 index 0000000..c4fa07c --- /dev/null +++ b/test/manual-registration.spec.ts @@ -0,0 +1,58 @@ +import { expect, test } from '@playwright/test'; + +test('own registration returns one constructor whose instances expose the preview API', async ({ page }) => { + await page.goto('/test/fixtures/modern-editor.html'); + const result = await page.evaluate(async () => { + const entry = '/manual.ts'; + const manual: typeof import('../manual') = await import(entry); + const [first, second] = await Promise.all([ + manual.registerPreview('typed-file-preview'), + manual.registerPreview('typed-file-preview'), + ]); + if (!manual.isPreviewConstructor(first)) throw new Error('Missing typed preview constructor'); + const element = new first(); + element.src = '/sample.pdf'; + element.baseUrl = 'https://example.com'; + element.readonly = true; + element.closeable = 'false'; + element.showPreview(); + element.closePreview(false); + return { + same: first === second, + registered: first === customElements.get('typed-file-preview'), + src: element.src, + baseUrl: element.baseUrl, + readonly: element.readonly, + closeable: element.closeable, + defaultRegistered: Boolean(customElements.get('r-preview')), + }; + }); + expect(result).toEqual({ + same: true, + registered: true, + src: '/sample.pdf', + baseUrl: 'https://example.com', + readonly: true, + closeable: 'false', + defaultRegistered: false, + }); +}); + +test('an unrelated existing custom tag keeps its constructor and cannot acquire preview types', async ({ page }) => { + await page.goto('/test/fixtures/modern-editor.html'); + const result = await page.evaluate(async () => { + const entry = '/manual.ts'; + const manual: typeof import('../manual') = await import(entry); + class ForeignElement extends HTMLElement {} + customElements.define('foreign-file-preview', ForeignElement); + const existing = await manual.registerPreview('foreign-file-preview'); + await manual.registerPreview('own-file-preview'); + return { + preserved: existing === ForeignElement, + typedAsPreview: manual.isPreviewConstructor(existing), + undefinedIsPreview: manual.isPreviewConstructor(undefined), + registered: customElements.get('foreign-file-preview') === ForeignElement, + }; + }); + expect(result).toEqual({ preserved: true, typedAsPreview: false, undefinedIsPreview: false, registered: true }); +}); diff --git a/test/package-consumer.ts b/test/package-consumer.ts index dc61a4b..b64912e 100644 --- a/test/package-consumer.ts +++ b/test/package-consumer.ts @@ -1,14 +1,44 @@ import { preview } from '@ranui/preview'; -import { registerPreview } from '@ranui/preview/manual'; +import { isPreviewConstructor, registerPreview } from '@ranui/preview/manual'; import type { RenderOptions } from '@ranui/preview/type'; import type { PreviewAttributes } from '@ranui/preview/typings'; import type * as PreviewModule from '@ranui/preview/preview'; import type * as LegacyModule from '@ranui/preview/dist/index.js'; -const options: RenderOptions = { dom: document.createElement('div'), signal: new AbortController().signal }; -const attributes: PreviewAttributes = { src: '/sample.pdf', closeable: false, baseUrl: 'https://example.com' }; +const options: RenderOptions = { + dom: document.createElement('div'), + signal: new AbortController().signal, + mode: 'virtual', +}; +const attributes: PreviewAttributes = { + src: '/sample.pdf', + closeable: false, + baseUrl: 'https://example.com', + 'pdf-mode': 'virtual', +}; const registration: Promise = registerPreview(); -const customElement: HTMLElement = document.createElement('r-preview'); +const customElement = document.createElement('r-preview'); +customElement.src = '/sample.pdf'; +customElement.baseUrl = 'https://example.com'; +customElement.closeable = 'false'; +customElement.readonly = true; +customElement.pdfMode = 'virtual'; +const pdfMode: 'eager' | 'virtual' = customElement.pdfMode; +customElement.showPreview(); +customElement.closePreview(false); +async function registeredElement() { + const Constructor = await registerPreview('consumer-preview'); + if (isPreviewConstructor(Constructor)) { + const element = new Constructor(); + element.src = '/sample.pdf'; + element.baseUrl = 'https://example.com'; + element.readonly = true; + element.pdfMode = 'virtual'; + element.showPreview(); + element.closePreview(); + } +} +void registeredElement; const rootNamespace: typeof PreviewModule = preview; const legacyNamespace: typeof LegacyModule.preview = preview; -void [options, attributes, registration, customElement, rootNamespace, legacyNamespace]; +void [pdfMode, options, attributes, registration, customElement, rootNamespace, legacyNamespace]; diff --git a/test/package-react.tsx b/test/package-react.tsx index d13c914..28e813b 100644 --- a/test/package-react.tsx +++ b/test/package-react.tsx @@ -2,7 +2,13 @@ import type * as React from 'react'; import '@ranui/preview/typings'; const preview: React.JSX.Element = ( - + Preview ); diff --git a/typings.d.ts b/typings.d.ts index b3c267d..374adaa 100644 --- a/typings.d.ts +++ b/typings.d.ts @@ -1,8 +1,11 @@ +import type { PreviewElement } from './components/preview/core.js'; + /** Attributes supported by the r-preview custom element. React is optional. */ export interface PreviewAttributes { src?: string; closeable?: boolean | string; readonly?: boolean | string; + 'pdf-mode'?: 'eager' | 'virtual'; baseUrl?: string; baseurl?: string; label?: string; @@ -21,7 +24,7 @@ export interface PreviewAttributes { declare global { interface HTMLElementTagNameMap { - 'r-preview': HTMLElement; + 'r-preview': PreviewElement; } namespace JSX { interface IntrinsicElements { diff --git a/vite.config.ts b/vite.config.ts index fa24ee5..bd53364 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -2,7 +2,6 @@ import path, { resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; import { defineConfig } from 'vite'; import type { BuildOptions, UserConfig } from 'vite'; -import loadStyle from './plugins/load-style.ts'; import { PORT } from './build/config.ts'; const __filename = fileURLToPath(import.meta.url); @@ -68,11 +67,6 @@ export const viteConfig: UserConfig = { optimizeDeps: { exclude: ['public'], }, - plugins: [ - loadStyle({ - ignore: ['ranui/components/modal/index.ts'], - }), - ], resolve: { alias: { '@/components': resolve(__dirname, 'components/'), From 11876c1052a22035aef719ceec3eaf5065e359b6 Mon Sep 17 00:00:00 2001 From: chaxus Date: Mon, 19 Oct 2026 11:55:39 +0800 Subject: [PATCH 3/5] fix(release): verify immutable artifacts and retry publishing safely --- .github/workflows/ci.yml | 1 + .github/workflows/release.yml | 25 ++-- scripts/publish-release.mjs | 52 ++++++++ scripts/release-artifact.mjs | 34 +++++ scripts/verify-package.mjs | 11 +- scripts/verify-release-source.mjs | 37 ++++++ test/package-browser.mjs | 38 +++++- test/release-source.test.mjs | 53 ++++++++ test/release.test.mjs | 198 ++++++++++++++++++++++++++++++ 9 files changed, 430 insertions(+), 19 deletions(-) create mode 100644 scripts/publish-release.mjs create mode 100644 scripts/release-artifact.mjs create mode 100644 scripts/verify-release-source.mjs create mode 100644 test/release-source.test.mjs create mode 100644 test/release.test.mjs 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/scripts/publish-release.mjs b/scripts/publish-release.mjs new file mode 100644 index 0000000..54dcfe2 --- /dev/null +++ b/scripts/publish-release.mjs @@ -0,0 +1,52 @@ +import assert from 'node:assert/strict'; +import { createHash } from 'node:crypto'; +import { spawnSync } from 'node:child_process'; +import { readFile } from 'node:fs/promises'; +import { dirname, resolve } from 'node:path'; +import { pathToFileURL } from 'node:url'; + +const publicRegistry = 'https://registry.npmjs.org'; +const stableVersion = /^(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)$/; +const publishArguments = ['--ignore-scripts', '--access', 'public', '--provenance', '--tag', 'latest']; +const npmPublish = async (tarball, args) => { + const result = spawnSync('npm', ['publish', tarball, ...args], { stdio: 'inherit', shell: false }); + if (result.error) throw result.error; + assert.equal(result.status, 0, 'npm publish failed'); +}; + +/** Publish only the stored, verified archive; never run pack, build, or lifecycle scripts. */ +export async function publishVerifiedArtifact({ manifestPath, registry = publicRegistry, publish = npmPublish }) { + const manifest = JSON.parse(await readFile(manifestPath, 'utf8')); + assert.equal(manifest.name, '@ranui/preview', 'Unexpected release package name'); + assert.match(manifest.version, stableVersion, 'Invalid stable release version'); + assert.equal(manifest.filename, `ranui-preview-${manifest.version}.tgz`, 'Invalid release archive filename'); + const tarball = resolve(dirname(manifestPath), manifest.filename); + const bytes = await readFile(tarball); + const integrity = `sha512-${createHash('sha512').update(bytes).digest('base64')}`; + assert.equal(manifest.integrity, integrity, 'Release archive integrity mismatch'); + const unpack = spawnSync('tar', ['-xOf', tarball, 'package/package.json'], { encoding: 'utf8', shell: false }); + if (unpack.error) throw unpack.error; + assert.equal(unpack.status, 0, 'Cannot read release archive metadata'); + const packed = JSON.parse(unpack.stdout); + assert.equal(packed.name, manifest.name, 'Archive package name differs from verified metadata'); + assert.equal(packed.version, manifest.version, 'Archive version differs from verified metadata'); + + const metadataUrl = new URL(`${manifest.name.replace('/', '%2F')}/${manifest.version}`, `${registry}/`); + const response = await fetch(metadataUrl, { redirect: 'error', signal: AbortSignal.timeout(30_000) }); + if (response.status === 404) { + assert.deepEqual(await readFile(tarball), bytes, 'Release archive integrity changed during registry lookup'); + await publish(tarball, publishArguments); + return 'published'; + } + assert.equal(response.status, 200, `Registry metadata request failed: HTTP ${response.status}`); + const existing = await response.json(); + assert.equal(existing.name, manifest.name, 'Registry metadata package name mismatch'); + assert.equal(existing.version, manifest.version, 'Registry metadata version mismatch'); + assert.equal(existing.dist?.integrity, integrity, 'Published version has different archive integrity'); + return 'already-published'; +} + +if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) { + assert.equal(process.argv.length, 3, 'Usage: node publish-release.mjs '); + console.log(await publishVerifiedArtifact({ manifestPath: resolve(process.argv[2]) })); +} diff --git a/scripts/release-artifact.mjs b/scripts/release-artifact.mjs new file mode 100644 index 0000000..9e40405 --- /dev/null +++ b/scripts/release-artifact.mjs @@ -0,0 +1,34 @@ +import assert from 'node:assert/strict'; +import { createHash } from 'node:crypto'; +import { mkdir, readFile, writeFile } from 'node:fs/promises'; +import { join } from 'node:path'; + +/** Keep an existing release identity intact when verification is run again. */ +export async function storeVerifiedArtifact({ tarball, destination, name, version }) { + assert.equal(name, '@ranui/preview', 'Unexpected release package name'); + assert.match(version, /^(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)$/, 'Invalid stable release version'); + const filename = `ranui-preview-${version}.tgz`; + const bytes = await readFile(tarball); + const manifest = { + name, + version, + filename, + integrity: `sha512-${createHash('sha512').update(bytes).digest('base64')}`, + }; + await mkdir(destination, { recursive: true }); + const storeOnce = async (path, content) => { + try { + await writeFile(path, content, { flag: 'wx' }); + } catch (error) { + if (error.code !== 'EEXIST') throw error; + assert.deepEqual( + await readFile(path), + Buffer.from(content), + `Existing release artifact has different integrity: ${path}`, + ); + } + }; + await storeOnce(join(destination, filename), bytes); + await storeOnce(join(destination, 'release.json'), `${JSON.stringify(manifest, null, 2)}\n`); + return manifest; +} diff --git a/scripts/verify-package.mjs b/scripts/verify-package.mjs index 649e406..163aeb9 100644 --- a/scripts/verify-package.mjs +++ b/scripts/verify-package.mjs @@ -1,3 +1,4 @@ +import { storeVerifiedArtifact } from './release-artifact.mjs'; import { verifyBrowserPackage } from '../test/package-browser.mjs'; import assert from 'node:assert/strict'; import { spawnSync } from 'node:child_process'; @@ -27,7 +28,8 @@ const run = (command, arguments_, cwd, capture = false) => { try { let tarball = option('--tarball'); if (!tarball) { - const destination = option('--out-dir') ?? temporary; + // Pack into scratch space so reruns cannot overwrite a previously verified archive. + const destination = temporary; await mkdir(destination, { recursive: true }); const packed = JSON.parse( run('npm', ['pack', '--ignore-scripts', '--json', '--pack-destination', destination], root, true), @@ -156,7 +158,12 @@ console.log('SSR manual import and public module resolution passed');\n`, compile('ESNext', 'Bundler', ['consumer.ts', 'react.tsx']); run(resolve(root, 'node_modules/.bin/publint'), ['run', installed, '--strict', '--pack', 'false'], root); await verifyBrowserPackage(installed, root); - console.log(`Verified installed tarball ${tarball}`); + const destination = option('--out-dir'); + if (destination) { + await storeVerifiedArtifact({ tarball, destination, name: manifest.name, version: manifest.version }); + await cp(resolve(root, 'scripts/publish-release.mjs'), resolve(destination, 'publish-release.mjs')); + } + console.log(`Verified installed tarball ${tarball}${destination ? `; stored artifact in ${destination}` : ''}`); } finally { await rm(temporary, { recursive: true, force: true }); } diff --git a/scripts/verify-release-source.mjs b/scripts/verify-release-source.mjs new file mode 100644 index 0000000..8e26713 --- /dev/null +++ b/scripts/verify-release-source.mjs @@ -0,0 +1,37 @@ +import assert from 'node:assert/strict'; +import { spawnSync } from 'node:child_process'; +import { readFile } from 'node:fs/promises'; +import { resolve } from 'node:path'; +import { pathToFileURL } from 'node:url'; + +const recoveryFiles = new Set([ + 'scripts/publish-release.mjs', + 'scripts/release-artifact.mjs', + 'scripts/verify-release-source.mjs', + 'test/release.test.mjs', + 'test/release-source.test.mjs', +]); + +/** Recovery may update release plumbing, while the tagged package inputs remain identical. */ +export function assertReleaseSource({ tag, version, changedFiles }) { + assert.match(version, /^(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)$/, 'Invalid stable release version'); + assert.equal(tag, `v${version}`, 'Release tag must match the package version'); + const changed = changedFiles.filter((file) => !file.startsWith('.github/workflows/') && !recoveryFiles.has(file)); + assert.equal(changed.length, 0, `Release source differs from ${tag}: ${changed.join(', ')}`); +} + +if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) { + assert.equal(process.argv.length, 2, 'Set RELEASE_TAG; this script accepts no arguments'); + const { version } = JSON.parse(await readFile('package.json', 'utf8')); + const tag = process.env.RELEASE_TAG; + // Validate before passing the ref to git. No user input is evaluated by a shell. + assertReleaseSource({ tag, version, changedFiles: [] }); + const result = spawnSync('git', ['diff', '--name-only', '-z', `${tag}^{commit}`, 'HEAD', '--'], { + encoding: 'utf8', + shell: false, + }); + if (result.error) throw result.error; + assert.equal(result.status, 0, `Cannot compare release tag: ${result.stderr}`); + assertReleaseSource({ tag, version, changedFiles: result.stdout.split('\0').filter(Boolean) }); + console.log(`Release source matches ${tag}; workflow-only recovery changes are allowed`); +} diff --git a/test/package-browser.mjs b/test/package-browser.mjs index fe94541..7ca8fee 100644 --- a/test/package-browser.mjs +++ b/test/package-browser.mjs @@ -23,6 +23,7 @@ export async function verifyBrowserPackage(installed, root) { } let file; if (pathname === '/sample.pdf') file = resolve(root, 'test/fixtures/pdf-two-pages.pdf'); + else if (pathname === '/long.pdf') file = resolve(root, 'test/fixtures/pdf-thirty-pages.pdf'); else if (pathname.startsWith('/package/')) { file = resolve(distribution, decodeURIComponent(pathname.slice('/package/'.length))); if (!file.startsWith(`${distribution}${sep}`)) throw new Error('Invalid package path'); @@ -101,9 +102,44 @@ export async function verifyBrowserPackage(installed, root) { `${entry}: both PDF pages must be painted`, ); assert(fonts.length > 0 && fonts.every((status) => status === 200), `${entry}: bundled standard fonts must load`); + await page.evaluate(async () => { + const preview = document.querySelector('r-preview'); + preview.closePreview(); + preview.pdfMode = 'virtual'; + const ready = new Promise((accept, reject) => { + preview.addEventListener('load', accept, { once: true }); + preview.addEventListener('error', (event) => reject(new Error(String(event.detail))), { once: true }); + }); + preview.src = '/long.pdf'; + await Promise.race([ + ready, + new Promise((_, reject) => setTimeout(() => reject(new Error('Virtual PDF preview timed out')), 15000)), + ]); + }); + assert.equal(await page.locator('[data-pdf-page]').count(), 30); + assert((await page.locator('canvas').count()) <= 6, `${entry}: virtual canvas count must be bounded`); + await page.locator('.r-preview-context-pdf').evaluate((dom) => { + dom.scrollTop = dom.scrollHeight; + }); + await page.waitForFunction( + () => { + const canvas = document.querySelector('[data-pdf-page="30"] canvas'); + if (!canvas) return false; + const pixel = canvas.getContext('2d').getImageData(canvas.width / 2, canvas.height / 2, 1, 1).data; + return pixel[0] === 0 && pixel[1] === 0 && pixel[2] === 255 && pixel[3] === 255; + }, + undefined, + { timeout: 15000 }, + ); + assert.equal(await page.locator('[data-pdf-page="1"] canvas').count(), 0); + assert((await page.locator('canvas').count()) <= 6); + await page.evaluate(() => document.querySelector('r-preview').closePreview()); + assert.equal(await page.locator('[data-pdf-page],canvas').count(), 0); assert.deepEqual(failures, [], `${entry}: browser errors and failed assets`); await page.close(); - console.log(`Installed tarball ${entry}: registration, both PDF pages, and packaged font URLs passed`); + console.log( + `Installed tarball ${entry}: registration, eager/virtual PDF pixels, cleanup, and packaged font URLs passed`, + ); } } finally { await browser?.close(); diff --git a/test/release-source.test.mjs b/test/release-source.test.mjs new file mode 100644 index 0000000..43a27a6 --- /dev/null +++ b/test/release-source.test.mjs @@ -0,0 +1,53 @@ +import assert from 'node:assert/strict'; +import { test } from 'node:test'; + +import { assertReleaseSource } from '../scripts/verify-release-source.mjs'; + +test('workflow-only recovery can use the selected workflow ref with unchanged release payload', () => { + assert.doesNotThrow(() => + assertReleaseSource({ + tag: 'v0.3.0', + version: '0.3.0', + changedFiles: [ + '.github/workflows/release.yml', + 'scripts/publish-release.mjs', + 'scripts/verify-release-source.mjs', + 'test/release.test.mjs', + 'test/release-source.test.mjs', + ], + }), + ); +}); + +test('a tag checkout with no changed files can release', () => { + assert.doesNotThrow(() => assertReleaseSource({ tag: 'v0.3.0', version: '0.3.0', changedFiles: [] })); +}); + +for (const path of [ + 'package.json', + 'pnpm-lock.yaml', + 'pnpm-workspace.yaml', + 'README.md', + 'LICENSE', + 'manual.ts', + 'typings.d.ts', + 'components/preview/core.ts', + 'vite.config.ts', + 'build/config.es.ts', + 'scripts/build.mjs', + 'scripts/finalize-build.mjs', + 'scripts/verify-package.mjs', +]) { + test(`release source changes cannot hide behind a workflow recovery: ${path}`, () => { + assert.throws( + () => assertReleaseSource({ tag: 'v0.3.0', version: '0.3.0', changedFiles: [path] }), + /differs|changed/i, + ); + }); +} + +for (const tag of ['v0.2.0', 'v0.3.0; touch injected', '--help', 'v01.3.0', 'v0.3.0-beta.1']) { + test(`invalid or mismatched release tag is rejected: ${tag}`, () => { + assert.throws(() => assertReleaseSource({ tag, version: '0.3.0', changedFiles: [] }), /tag|version/i); + }); +} diff --git a/test/release.test.mjs b/test/release.test.mjs new file mode 100644 index 0000000..c95646c --- /dev/null +++ b/test/release.test.mjs @@ -0,0 +1,198 @@ +import { storeVerifiedArtifact } from '../scripts/release-artifact.mjs'; +import assert from 'node:assert/strict'; +import { createHash } from 'node:crypto'; +import { spawnSync } from 'node:child_process'; +import { createServer } from 'node:http'; +import { mkdtemp, mkdir, readFile, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { test } from 'node:test'; + +import { publishVerifiedArtifact } from '../scripts/publish-release.mjs'; + +async function fixture(t, status = 404, response = { error: 'Not found' }) { + const directory = await mkdtemp(join(tmpdir(), 'fileview-release-test-')); + t.after(() => rm(directory, { recursive: true, force: true })); + await mkdir(join(directory, 'package')); + await writeFile( + join(directory, 'package/package.json'), + JSON.stringify({ name: '@ranui/preview', version: '0.2.1' }), + ); + const filename = 'ranui-preview-0.2.1.tgz'; + const archive = join(directory, filename); + assert.equal(spawnSync('tar', ['-czf', archive, '-C', directory, 'package']).status, 0); + const bytes = await readFile(archive); + const manifest = { + name: '@ranui/preview', + version: '0.2.1', + filename, + integrity: `sha512-${createHash('sha512').update(bytes).digest('base64')}`, + }; + const manifestPath = join(directory, 'release.json'); + await writeFile(manifestPath, JSON.stringify(manifest)); + const requests = []; + const server = createServer((request, result) => { + requests.push(request.url); + result.writeHead(status, { 'content-type': 'application/json' }); + result.end(typeof response === 'string' ? response : JSON.stringify(response)); + }); + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + t.after(() => new Promise((resolve) => server.close(resolve))); + const registry = `http://127.0.0.1:${server.address().port}`; + const published = []; + const publish = async (path, args) => published.push({ path, args, bytes: await readFile(path) }); + return { directory, archive, bytes, manifest, manifestPath, registry, published, publish, requests }; +} + +test('missing registry version publishes the exact verified archive without scripts', async (t) => { + const f = await fixture(t); + assert.equal(await publishVerifiedArtifact(f), 'published'); + assert.deepEqual(f.requests, ['/@ranui%2Fpreview/0.2.1']); + assert.equal(f.published.length, 1); + assert.equal(f.published[0].path, f.archive); + assert.deepEqual(f.published[0].bytes, f.bytes); + assert.deepEqual(f.published[0].args, ['--ignore-scripts', '--access', 'public', '--provenance', '--tag', 'latest']); +}); + +test('identical published integrity succeeds without republishing', async (t) => { + const f = await fixture(t, 200); + // A second local registry has the real fixture identity, just as public metadata does. + const server = createServer((_, response) => + response.end( + JSON.stringify({ + name: f.manifest.name, + version: f.manifest.version, + dist: { integrity: f.manifest.integrity }, + }), + ), + ); + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + t.after(() => new Promise((resolve) => server.close(resolve))); + assert.equal( + await publishVerifiedArtifact({ ...f, registry: `http://127.0.0.1:${server.address().port}` }), + 'already-published', + ); + assert.equal(f.published.length, 0); +}); + +test('a published version with different bytes fails without publishing', async (t) => { + const f = await fixture(t, 200, { + name: '@ranui/preview', + version: '0.2.1', + dist: { integrity: 'sha512-different' }, + }); + await assert.rejects(publishVerifiedArtifact(f), /different|integrity/i); + assert.equal(f.published.length, 0); +}); + +for (const status of [401, 403, 429, 500, 503]) { + test(`registry HTTP ${status} fails closed instead of publishing`, async (t) => { + const f = await fixture(t, status); + await assert.rejects(publishVerifiedArtifact(f), new RegExp(String(status))); + assert.equal(f.published.length, 0); + }); +} + +for (const response of ['broken json', {}, { name: '@ranui/preview', version: '0.2.1', dist: {} }]) { + test(`invalid existing registry metadata fails closed: ${JSON.stringify(response)}`, async (t) => { + const f = await fixture(t, 200, response); + await assert.rejects(publishVerifiedArtifact(f)); + assert.equal(f.published.length, 0); + }); +} + +test('network failure is not interpreted as an unpublished version', async (t) => { + const f = await fixture(t); + await assert.rejects(publishVerifiedArtifact({ ...f, registry: 'http://127.0.0.1:1' })); + assert.equal(f.published.length, 0); +}); + +test('changed archive fails verification before registry lookup or publishing', async (t) => { + const f = await fixture(t); + await writeFile(f.archive, 'replaced archive'); + await assert.rejects(publishVerifiedArtifact(f), /integrity/i); + assert.deepEqual(f.requests, []); + assert.equal(f.published.length, 0); +}); + +for (const patch of [ + { filename: '../outside.tgz' }, + { filename: '$(touch injected).tgz' }, + { version: '0.2.1; touch injected' }, + { name: '@ranui/preview; touch injected' }, + { name: '@ranui/other' }, + { version: '0.2.2' }, +]) { + test(`invalid or mismatched release identity cannot publish: ${JSON.stringify(patch)}`, async (t) => { + const f = await fixture(t); + await writeFile(f.manifestPath, JSON.stringify({ ...f.manifest, ...patch })); + await assert.rejects(publishVerifiedArtifact(f)); + assert.deepEqual(f.requests, []); + assert.equal(f.published.length, 0); + }); +} + +test('npm publishing failure propagates instead of claiming success', async (t) => { + const f = await fixture(t); + await assert.rejects( + publishVerifiedArtifact({ + ...f, + publish: async () => { + throw new Error('npm rejected'); + }, + }), + /npm rejected/, + ); +}); + +test('the archive cannot change during the registry lookup and then be published', async (t) => { + const f = await fixture(t); + const server = createServer(async (_, response) => { + await writeFile(f.archive, 'changed while requesting registry metadata'); + response.writeHead(404); + response.end(); + }); + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + t.after(() => new Promise((resolve) => server.close(resolve))); + await assert.rejects( + publishVerifiedArtifact({ ...f, registry: `http://127.0.0.1:${server.address().port}` }), + /integrity/i, + ); + assert.equal(f.published.length, 0); +}); + +test('storing a verified archive records its exact version and SHA512 identity', async (t) => { + const f = await fixture(t); + const destination = join(f.directory, 'artifacts'); + await storeVerifiedArtifact({ tarball: f.archive, destination, name: f.manifest.name, version: f.manifest.version }); + assert.deepEqual(await readFile(join(destination, f.manifest.filename)), f.bytes); + assert.deepEqual(JSON.parse(await readFile(join(destination, 'release.json'), 'utf8')), f.manifest); +}); + +test('storing the same verified bytes twice retains the existing artifact identity', async (t) => { + const f = await fixture(t); + const options = { + tarball: f.archive, + destination: join(f.directory, 'artifacts'), + name: f.manifest.name, + version: f.manifest.version, + }; + await storeVerifiedArtifact(options); + await storeVerifiedArtifact(options); + assert.deepEqual(await readFile(join(options.destination, f.manifest.filename)), f.bytes); +}); + +test('rebuilding a different archive cannot overwrite an existing version artifact', async (t) => { + const f = await fixture(t); + const options = { + tarball: f.archive, + destination: join(f.directory, 'artifacts'), + name: f.manifest.name, + version: f.manifest.version, + }; + await storeVerifiedArtifact(options); + await writeFile(f.archive, 'different rebuild'); + await assert.rejects(storeVerifiedArtifact(options), /different|integrity/i); + assert.deepEqual(await readFile(join(options.destination, f.manifest.filename)), f.bytes); + assert.deepEqual(JSON.parse(await readFile(join(options.destination, 'release.json'), 'utf8')), f.manifest); +}); From 9362aa1a1b6f7a7129944f5cfb3a7b342c1bbfde Mon Sep 17 00:00:00 2001 From: chaxus Date: Mon, 19 Oct 2026 11:55:39 +0800 Subject: [PATCH 4/5] docs(preview): explain rendering modes and manage demo file URLs --- CHANGELOG.md | 11 +++++ README.md | 71 ++++++++++++++++++++++++-------- README.zh-CN.md | 64 ++++++++++++++++++++++------- docs/verification-0.3.0.md | 43 +++++++++++++++++++ index.html | 43 +++++++++++++------ scripts/office-smoke.mjs | 84 ++++++++++++++++++++++++++++++++++++++ test/demo.spec.ts | 50 +++++++++++++++++++++++ 7 files changed, 324 insertions(+), 42 deletions(-) create mode 100644 docs/verification-0.3.0.md create mode 100644 test/demo.spec.ts 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/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.