mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-10-21 09:32:24 +00:00
🩹 fix: LibreOffice PDF embed renders via pdf.js (Chrome blocks blob: PDFs too)
Manual e2e on PR #12934 round 2: switching from `data:` to `blob:`
URLs (commit d90f26c11) didn't fix the "This page has been blocked
by Chrome" interstitial. Chrome blocks BOTH data: AND blob: PDF
navigations inside sandboxed iframes — the built-in PDF viewer
requires a top-level browsing context. The Sandpack host iframe is
sandboxed, so neither approach works.
Fix: switch from native browser PDF viewer to pdf.js (Mozilla's
pdfjs-dist) loaded from CDN. pdf.js renders to `<canvas>` which
works in any context — no plugin, no privileged viewer, no
top-level requirement. ~1 MB CDN load is acceptable for a path
that's already opt-in via `OFFICE_PREVIEW_LIBREOFFICE=true`.
Implementation:
- Pin pdf.js v3.11.174 (single-file UMD; v4+ uses ES modules which
complicate the load + SRI flow)
- Worker URL pointed at the same jsdelivr origin; CSP `worker-src
https://cdn.jsdelivr.net blob:` allows it
- DPR-aware canvas rendering: scale based on `panelWidth /
page.viewport.width * devicePixelRatio` so retina displays get
crisp pixels
- Sequential page rendering (Promise chain) so a many-slide PDF
doesn't spawn N parallel render jobs
- 15 s timeout safety net (was 4 s for the native viewer; pdf.js
with DPR=2 on a many-page PDF can take longer)
CSP changes:
- Added `script-src https://cdn.jsdelivr.net 'unsafe-inline'` (was
inline-only)
- Added `worker-src https://cdn.jsdelivr.net blob:`
- Removed `frame-src` entirely (no nested iframes)
- Removed `object-src` (no `<object>`/`<embed>` either)
Same diagnostic surfacing as the other CDN paths: failure reasons
shown via `<details>` disclosure inline + `console.error` to
DevTools.
Tests updated: PDF.js script presence, GlobalWorkerOptions setup,
canvas render path, all the new failure detection paths. Negative
assertions for both `data:application/pdf` and `blob:...application
/pdf` so a future "let's just try the native viewer again" rewrite
can't silently re-introduce the Chrome block.
SRI hashes intentionally omitted (unlike docx-preview / pptx-
preview) — operator opted in by setting the env flag and trusts
the LibreOffice render pipeline. Worth adding once the path is
proven in production.
This commit is contained in:
parent
d90f26c11c
commit
b2cc81ad8f
2 changed files with 203 additions and 105 deletions
|
|
@ -80,71 +80,81 @@ describe('libreoffice (env gating + wrapper)', () => {
|
||||||
* wrappers. */
|
* wrappers. */
|
||||||
const FAKE_PDF_B64 = 'JVBERi0xLjQK'; // "%PDF-1.4\n" base64
|
const FAKE_PDF_B64 = 'JVBERi0xLjQK'; // "%PDF-1.4\n" base64
|
||||||
|
|
||||||
it('emits a complete sandboxed HTML document with PDF bytes embedded as a data block', () => {
|
it('emits a complete sandboxed HTML document that renders via pdf.js to canvas', () => {
|
||||||
const html = buildPdfEmbedDocument(FAKE_PDF_B64);
|
const html = buildPdfEmbedDocument(FAKE_PDF_B64);
|
||||||
expect(html).toMatch(/^<!DOCTYPE html>/);
|
expect(html).toMatch(/^<!DOCTYPE html>/);
|
||||||
expect(html).toContain('<title>Preview</title>');
|
expect(html).toContain('<title>Preview</title>');
|
||||||
expect(html).toContain('id="lc-pdf"');
|
/* PDF bytes embedded as a base64 data block — pdf.js decodes
|
||||||
/* PDF bytes live in a `<script type="application/octet-stream;base64">`
|
* them at runtime and renders to canvas. We do NOT use any
|
||||||
* data block — the bootstrap reads it and constructs a blob: URL
|
* `<iframe src="data:application/pdf;...">` or `src="blob:...">`
|
||||||
* at runtime. We deliberately do NOT use `<iframe src="data:
|
* pattern because Chrome blocks BOTH data: AND blob: PDF
|
||||||
* application/pdf;...">` because Chrome blocks data: navigations
|
* navigations in sandboxed iframes (the built-in PDF viewer
|
||||||
* inside sandboxed iframes (manual e2e on PR #12934 — surfaced
|
* requires a top-level browsing context). The Sandpack host
|
||||||
* as the "This page has been blocked by Chrome" interstitial).
|
* iframe is sandboxed, so neither approach renders. PDF.js
|
||||||
* blob: URLs are same-origin and bypass that restriction. */
|
* draws to canvas which works in any context. Manual e2e on
|
||||||
|
* PR #12934. */
|
||||||
expect(html).toContain('id="lc-pdf-data"');
|
expect(html).toContain('id="lc-pdf-data"');
|
||||||
expect(html).toContain(FAKE_PDF_B64);
|
expect(html).toContain(FAKE_PDF_B64);
|
||||||
|
expect(html).toContain('id="lc-render"');
|
||||||
|
/* Negative assertions: we MUST NOT have any nested iframe
|
||||||
|
* navigation to a PDF URL. A future "let's just embed the PDF
|
||||||
|
* natively" rewrite can't silently re-introduce the Chrome
|
||||||
|
* block. */
|
||||||
expect(html).not.toMatch(/src="data:application\/pdf/);
|
expect(html).not.toMatch(/src="data:application\/pdf/);
|
||||||
/* The bootstrap code that converts the base64 to a blob URL. */
|
expect(html).not.toMatch(/src="blob:[^"]*application\/pdf/);
|
||||||
expect(html).toContain('URL.createObjectURL');
|
expect(html).not.toMatch(/<iframe[^>]+id="lc-pdf"/);
|
||||||
expect(html).toContain('new Blob');
|
/* PDF.js loaded from CDN. */
|
||||||
expect(html).toContain("type: 'application/pdf'");
|
expect(html).toContain('cdn.jsdelivr.net/npm/pdfjs-dist@');
|
||||||
|
expect(html).toContain('pdfjsLib.getDocument');
|
||||||
|
expect(html).toContain('GlobalWorkerOptions.workerSrc');
|
||||||
|
expect(html).toContain('page.render');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('CSP allows blob: in frame-src (NOT data:) and locks the iframe down otherwise', () => {
|
it('CSP allows pdf.js script + worker from jsdelivr; locks down everything else', () => {
|
||||||
const html = buildPdfEmbedDocument(FAKE_PDF_B64);
|
const html = buildPdfEmbedDocument(FAKE_PDF_B64);
|
||||||
const cspMatch = html.match(/<meta http-equiv="Content-Security-Policy" content="([^"]+)">/);
|
const cspMatch = html.match(/<meta http-equiv="Content-Security-Policy" content="([^"]+)">/);
|
||||||
expect(cspMatch).not.toBeNull();
|
expect(cspMatch).not.toBeNull();
|
||||||
const csp = cspMatch![1];
|
const csp = cspMatch![1];
|
||||||
expect(csp).toMatch(/default-src 'none'/);
|
expect(csp).toMatch(/default-src 'none'/);
|
||||||
/* blob: in frame-src, NOT data: — Chrome blocks data:application/pdf
|
/* pdf.js needs its main script + worker. Both come from the
|
||||||
* navigations inside sandboxed iframes (anti-phishing measure
|
* same jsdelivr host. */
|
||||||
* since Chrome 76). The bootstrap creates blob: URLs at runtime
|
expect(csp).toMatch(/script-src https:\/\/cdn\.jsdelivr\.net 'unsafe-inline'/);
|
||||||
* which Chrome treats as same-origin and allows. Manual e2e on
|
expect(csp).toMatch(/worker-src[^;]*https:\/\/cdn\.jsdelivr\.net/);
|
||||||
* PR #12934. */
|
expect(csp).toMatch(/worker-src[^;]*\bblob:/);
|
||||||
expect(csp).toMatch(/frame-src[^;]*\bblob:/);
|
/* Negative assertions for the previous-iteration approaches:
|
||||||
expect(csp).not.toMatch(/frame-src[^;]*\bdata:/);
|
* no `frame-src` (no nested iframes anymore), no PDF data: or
|
||||||
/* No outbound HTTP from the rendered iframe — a malicious PDF
|
* blob: navigation paths. */
|
||||||
* can't beacon home from inside the viewer. */
|
expect(csp).not.toMatch(/frame-src/);
|
||||||
|
/* No outbound HTTP from the rendered iframe — pdf.js doesn't
|
||||||
|
* fetch anything (PDF bytes are inline, fonts subset-embedded
|
||||||
|
* by LibreOffice). */
|
||||||
expect(csp).toMatch(/connect-src 'none'/);
|
expect(csp).toMatch(/connect-src 'none'/);
|
||||||
/* No external scripts (unlike docx-preview / pptx-preview the
|
|
||||||
* native browser PDF viewer doesn't need a CDN library). */
|
|
||||||
expect(csp).not.toMatch(/cdn\.jsdelivr\.net/);
|
|
||||||
expect(csp).not.toMatch(/unsafe-eval/);
|
expect(csp).not.toMatch(/unsafe-eval/);
|
||||||
expect(csp).toMatch(/base-uri 'none'/);
|
expect(csp).toMatch(/base-uri 'none'/);
|
||||||
expect(csp).toMatch(/form-action 'none'/);
|
expect(csp).toMatch(/form-action 'none'/);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('exposes a fallback message that surfaces when the browser PDF viewer is disabled', () => {
|
it('exposes a fallback message + diagnostic disclosure when pdf.js fails', () => {
|
||||||
const html = buildPdfEmbedDocument(FAKE_PDF_B64);
|
const html = buildPdfEmbedDocument(FAKE_PDF_B64);
|
||||||
expect(html).toContain('id="lc-fallback"');
|
expect(html).toContain('id="lc-fallback"');
|
||||||
expect(html).toContain('PDF preview unavailable in this browser');
|
expect(html).toContain('PDF preview unavailable');
|
||||||
/* The 4-second heuristic timer that swaps to the fallback. */
|
expect(html).toContain('id="lc-fallback-reason"');
|
||||||
expect(html).toContain('4000');
|
expect(html).toContain('Diagnostic details');
|
||||||
|
/* Multiple failure paths feed showFallback: pdf.js not loaded,
|
||||||
|
* unhandled rejection from the parser, sync error from the
|
||||||
|
* bootstrap, render timeout. */
|
||||||
|
expect(html).toContain("typeof pdfjsLib === 'undefined'");
|
||||||
|
expect(html).toContain("addEventListener('unhandledrejection'");
|
||||||
|
expect(html).toContain('pdf-render-timeout');
|
||||||
|
expect(html).toContain('15000');
|
||||||
/* Reasons logged to console.error for power-user debugging. */
|
/* Reasons logged to console.error for power-user debugging. */
|
||||||
expect(html).toContain("console.error('[libreoffice-pdf] fallback fired:'");
|
expect(html).toContain("console.error('[libreoffice-pdf] fallback fired:'");
|
||||||
});
|
});
|
||||||
|
|
||||||
it('uses #view=FitH so the PDF fills the panel width on first paint', () => {
|
|
||||||
const html = buildPdfEmbedDocument(FAKE_PDF_B64);
|
|
||||||
expect(html).toContain('#view=FitH');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('embeds large base64 payloads inside the data block without escaping issues', () => {
|
it('embeds large base64 payloads inside the data block without escaping issues', () => {
|
||||||
/* The base64 alphabet (A-Za-z0-9+/=) contains no characters that
|
/* The base64 alphabet (A-Za-z0-9+/=) contains no characters that
|
||||||
* could break out of `<script type="application/octet-stream;
|
* could break out of `<script type="application/octet-stream;
|
||||||
* base64">...</script>` — base64 cannot contain `<`, `>`, `&`, or
|
* base64">...</script>`. Sanity-check the data round-trip. */
|
||||||
* quote characters. Sanity-check that the data round-trips. */
|
|
||||||
const big = 'A'.repeat(100_000);
|
const big = 'A'.repeat(100_000);
|
||||||
const html = buildPdfEmbedDocument(big);
|
const html = buildPdfEmbedDocument(big);
|
||||||
const dataBlock = html.match(
|
const dataBlock = html.match(
|
||||||
|
|
@ -296,11 +306,12 @@ describe('libreoffice integration (skipped unless LibreOffice is on $PATH)', ()
|
||||||
const out = await tryLibreOfficePreview(buf, 'docx', 512 * 1024);
|
const out = await tryLibreOfficePreview(buf, 'docx', 512 * 1024);
|
||||||
expect(out).not.toBeNull();
|
expect(out).not.toBeNull();
|
||||||
expect(out!).toMatch(/^<!DOCTYPE html>/);
|
expect(out!).toMatch(/^<!DOCTYPE html>/);
|
||||||
/* PDF bytes are embedded as a base64 data block (not as a data:
|
/* PDF bytes embedded as a base64 data block; pdf.js renders to
|
||||||
* URL — Chrome blocks data:application/pdf in sandboxed iframes;
|
* canvas (Chrome blocks both data: and blob: PDF navigations
|
||||||
* the bootstrap converts to a blob: URL at runtime). */
|
* in sandboxed iframes — the canvas path is the only thing
|
||||||
|
* that works in our context). */
|
||||||
expect(out!).toContain('id="lc-pdf-data"');
|
expect(out!).toContain('id="lc-pdf-data"');
|
||||||
expect(out!).toContain('URL.createObjectURL');
|
expect(out!).toContain('pdfjsLib.getDocument');
|
||||||
expect(Buffer.byteLength(out!, 'utf-8')).toBeLessThanOrEqual(512 * 1024);
|
expect(Buffer.byteLength(out!, 'utf-8')).toBeLessThanOrEqual(512 * 1024);
|
||||||
},
|
},
|
||||||
35_000,
|
35_000,
|
||||||
|
|
|
||||||
|
|
@ -255,46 +255,61 @@ function runConversion(binary: string, inputPath: string, tempDir: string): Prom
|
||||||
/**
|
/**
|
||||||
* Build the iframe HTML that embeds the PDF for in-panel rendering. The
|
* Build the iframe HTML that embeds the PDF for in-panel rendering. The
|
||||||
* PDF is base64-encoded as a `data:` URI and pointed to by an inner
|
* PDF is base64-encoded as a `data:` URI and pointed to by an inner
|
||||||
* `<iframe>` so the host browser's PDF viewer (PDF.js in Firefox, Chrome's
|
* canvas via PDF.js (Mozilla's pdfjs-dist) loaded from CDN.
|
||||||
* built-in viewer, Safari's Preview-driven viewer) can render it directly.
|
|
||||||
*
|
*
|
||||||
* Why blob: URL (vs data: URL):
|
* Why PDF.js + canvas (vs. native browser PDF viewer):
|
||||||
* Chrome blocks `data:application/pdf` navigations inside sandboxed
|
* We tried `<iframe src="data:application/pdf;...">` first — Chrome
|
||||||
* iframes (anti-phishing measure since Chrome 76 — surfaces as a
|
* blocks data: PDF navigation in sandboxed iframes since Chrome 76.
|
||||||
* "This page has been blocked by Chrome" interstitial). The Sandpack
|
* We tried `<iframe src="blob:...">` next — Chrome ALSO blocks
|
||||||
* iframe IS sandboxed, so the inner iframe's data: navigation hits
|
* blob: PDFs in sandboxed iframes (the built-in PDF viewer requires
|
||||||
* that block. Constructing a `blob:` URL at runtime via
|
* a top-level browsing context). The Sandpack host iframe IS
|
||||||
* `URL.createObjectURL(new Blob([bytes], {type: 'application/pdf'}))`
|
* sandboxed, so neither works. Manual e2e on PR #12934 — both
|
||||||
* produces a same-origin URL that Chrome treats as legitimate
|
* produced the "This page has been blocked by Chrome" interstitial.
|
||||||
* navigation — works inside sandboxed contexts where data: doesn't.
|
|
||||||
* Manual e2e on PR #12934.
|
|
||||||
*
|
*
|
||||||
* Why an inner iframe rather than `<embed>` or `<object>`:
|
* PDF.js renders to `<canvas>` which works in ANY context (sandboxed,
|
||||||
* `<embed>` and `<object>` rendering is least consistent across modern
|
* embedded, restricted CSP) because it's pure JS — no plugin, no
|
||||||
* browsers (Chrome's pdfium plugin requires CSP `object-src` and
|
* privileged viewer. ~1 MB CDN load is acceptable for a feature
|
||||||
* some headless contexts disable it). `<iframe src="blob:...">` is
|
* that's already env-gated and opt-in.
|
||||||
* the most reliable cross-browser path; Chromium/Firefox/Safari all
|
|
||||||
* serve their built-in PDF viewer for it.
|
|
||||||
*
|
*
|
||||||
* The inner iframe uses `#view=FitH` to size to the panel's width
|
* Worker handling:
|
||||||
* on first paint.
|
* PDF.js wants a Web Worker for parsing (offloads CPU from main
|
||||||
|
* thread). The worker URL is loaded from the same jsdelivr origin;
|
||||||
|
* CSP `worker-src https://cdn.jsdelivr.net blob:` allows it. blob:
|
||||||
|
* covers the case where pdf.js wraps the worker source in a Blob
|
||||||
|
* to dodge cross-origin worker restrictions.
|
||||||
*/
|
*/
|
||||||
|
const PDF_JS_CDN = {
|
||||||
|
/* Pinned to v3.11.174 (last v3 release) — it's a single-file UMD
|
||||||
|
* bundle that loads via a plain `<script>` tag. v4+ uses ES modules
|
||||||
|
* (`pdf.min.mjs`) which complicates the load + SRI flow. v3 still
|
||||||
|
* receives security backports per Mozilla's policy.
|
||||||
|
*
|
||||||
|
* SRI hashes intentionally OMITTED here (unlike docx-preview /
|
||||||
|
* pptx-preview) because the LibreOffice preview path is opt-in and
|
||||||
|
* the operator has already chosen to trust the LibreOffice render
|
||||||
|
* pipeline. Adding SRI is a follow-up worth doing once this path
|
||||||
|
* is proven in production. */
|
||||||
|
pdf: 'https://cdn.jsdelivr.net/npm/pdfjs-dist@3.11.174/build/pdf.min.js',
|
||||||
|
worker: 'https://cdn.jsdelivr.net/npm/pdfjs-dist@3.11.174/build/pdf.worker.min.js',
|
||||||
|
} as const;
|
||||||
|
|
||||||
export function buildPdfEmbedDocument(pdfBase64: string): string {
|
export function buildPdfEmbedDocument(pdfBase64: string): string {
|
||||||
/* CSP scoping:
|
/* CSP scoping:
|
||||||
* - `default-src 'none'`: lock everything down.
|
* - `default-src 'none'`: lock everything down.
|
||||||
* - `frame-src blob:`: allow the inner `<iframe src="blob:...">`
|
* - `script-src https://cdn.jsdelivr.net 'unsafe-inline'`: load
|
||||||
* navigation that the bootstrap creates from the PDF bytes.
|
* pdf.js from CDN + run our bootstrap inline.
|
||||||
* `data:` is intentionally NOT in `frame-src` because Chrome
|
* - `worker-src https://cdn.jsdelivr.net blob:`: pdf.js spawns a
|
||||||
* blocks it in sandboxed contexts anyway.
|
* parser worker; jsdelivr is the origin we loaded the worker
|
||||||
* - `script-src 'unsafe-inline'`: only our tiny bootstrap script.
|
* script from, blob: covers pdf.js's same-origin worker wrap.
|
||||||
* - `style-src 'unsafe-inline'`: page chrome (no external sheets).
|
* - `connect-src 'none'`: pdf.js doesn't fetch anything — the
|
||||||
* - `connect-src 'none'`: rendered iframe makes no network calls.
|
* PDF bytes are inline, fonts are subset-embedded by LibreOffice.
|
||||||
*/
|
* - `style-src 'unsafe-inline'`: page chrome.
|
||||||
|
* - `img-src 'self' data: blob:`: pdf.js may rasterize embedded
|
||||||
|
* bitmaps via canvas (data:/blob: covers internal handoffs). */
|
||||||
const csp = [
|
const csp = [
|
||||||
"default-src 'none'",
|
"default-src 'none'",
|
||||||
'frame-src blob:',
|
"script-src https://cdn.jsdelivr.net 'unsafe-inline'",
|
||||||
"object-src 'self' blob:",
|
'worker-src https://cdn.jsdelivr.net blob:',
|
||||||
"script-src 'unsafe-inline'",
|
|
||||||
"style-src 'unsafe-inline'",
|
"style-src 'unsafe-inline'",
|
||||||
"img-src 'self' data: blob:",
|
"img-src 'self' data: blob:",
|
||||||
"connect-src 'none'",
|
"connect-src 'none'",
|
||||||
|
|
@ -311,56 +326,128 @@ export function buildPdfEmbedDocument(pdfBase64: string): string {
|
||||||
<style>
|
<style>
|
||||||
:root { color-scheme: light dark; --bg: #ffffff; --fg: #1f2937; --muted: #6b7280; }
|
:root { color-scheme: light dark; --bg: #ffffff; --fg: #1f2937; --muted: #6b7280; }
|
||||||
@media (prefers-color-scheme: dark) { :root { --bg: #1a1a2e; --fg: #e5e7eb; --muted: #9ca3af; } }
|
@media (prefers-color-scheme: dark) { :root { --bg: #1a1a2e; --fg: #e5e7eb; --muted: #9ca3af; } }
|
||||||
html, body { margin: 0; padding: 0; height: 100%; background: var(--bg); color: var(--fg); font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; }
|
html, body { margin: 0; padding: 0; min-height: 100vh; background: var(--bg); color: var(--fg); font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; }
|
||||||
#lc-pdf { width: 100%; height: 100vh; border: 0; display: block; }
|
#lc-render { padding: 16px; box-sizing: border-box; display: flex; flex-direction: column; align-items: center; gap: 16px; }
|
||||||
|
#lc-render canvas { max-width: 100%; height: auto; box-shadow: 0 2px 8px rgba(0, 0, 0, 0.18); border-radius: 4px; background: #ffffff; }
|
||||||
|
#lc-loading { padding: 24px; color: var(--muted); font-size: 14px; text-align: center; }
|
||||||
#lc-fallback { display: none; padding: 24px; font-size: 14px; line-height: 1.5; color: var(--muted); text-align: center; }
|
#lc-fallback { display: none; padding: 24px; font-size: 14px; line-height: 1.5; color: var(--muted); text-align: center; }
|
||||||
#lc-fallback.visible { display: block; }
|
#lc-fallback.visible { display: block; }
|
||||||
|
#lc-fallback-reason { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 12px; color: var(--muted); margin-top: 6px; word-break: break-word; }
|
||||||
</style>
|
</style>
|
||||||
|
<script src="${PDF_JS_CDN.pdf}"></script>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<iframe id="lc-pdf" title="PDF preview"></iframe>
|
<div id="lc-render"><div id="lc-loading">Loading preview…</div></div>
|
||||||
<div id="lc-fallback">PDF preview unavailable in this browser. Please download the file to view it.</div>
|
<div id="lc-fallback">
|
||||||
|
<p>PDF preview unavailable. Please download the file to view it.</p>
|
||||||
|
<details style="margin-top: 8px;">
|
||||||
|
<summary style="cursor: pointer; font-size: 12px;">Diagnostic details</summary>
|
||||||
|
<div id="lc-fallback-reason"></div>
|
||||||
|
</details>
|
||||||
|
</div>
|
||||||
<script id="lc-pdf-data" type="application/octet-stream;base64">${pdfBase64}</script>
|
<script id="lc-pdf-data" type="application/octet-stream;base64">${pdfBase64}</script>
|
||||||
<script>
|
<script>
|
||||||
(function () {
|
(function () {
|
||||||
var pdfFrame = document.getElementById('lc-pdf');
|
var container = document.getElementById('lc-render');
|
||||||
|
var loading = document.getElementById('lc-loading');
|
||||||
var fallback = document.getElementById('lc-fallback');
|
var fallback = document.getElementById('lc-fallback');
|
||||||
if (!pdfFrame || !fallback) { return; }
|
var reasonEl = document.getElementById('lc-fallback-reason');
|
||||||
|
var settled = false;
|
||||||
|
|
||||||
function showFallback(reason) {
|
function showFallback(reason) {
|
||||||
pdfFrame.style.display = 'none';
|
if (settled) { return; }
|
||||||
fallback.classList.add('visible');
|
settled = true;
|
||||||
if (reason && typeof console !== 'undefined' && console.error) {
|
if (loading) { loading.remove(); }
|
||||||
console.error('[libreoffice-pdf] fallback fired:', reason);
|
if (container) { container.style.display = 'none'; }
|
||||||
|
if (fallback) { fallback.classList.add('visible'); }
|
||||||
|
var text = reason ? String(reason).slice(0, 500) : 'no reason reported';
|
||||||
|
if (reasonEl) { reasonEl.textContent = text; }
|
||||||
|
if (typeof console !== 'undefined' && console.error) {
|
||||||
|
console.error('[libreoffice-pdf] fallback fired:', text);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
function markSuccess() { settled = true; }
|
||||||
|
|
||||||
/* Decode the embedded base64 and create a blob: URL. Chrome blocks
|
/* pdf.js wraps async errors as unhandled rejections; catch them at
|
||||||
* data:application/pdf in sandboxed iframes (parent Sandpack iframe
|
* the window level so we never silently fail. */
|
||||||
* is sandboxed); blob: URLs are treated as same-origin and bypass
|
window.addEventListener('unhandledrejection', function (e) {
|
||||||
* that restriction. Manual e2e on PR #12934 — "This page has been
|
if (settled) { return; }
|
||||||
* blocked by Chrome" interstitial was the symptom. */
|
showFallback((e.reason && e.reason.message) || 'unhandled-rejection');
|
||||||
var loaded = false;
|
});
|
||||||
try {
|
window.addEventListener('error', function (e) {
|
||||||
var b64 = document.getElementById('lc-pdf-data').textContent.trim();
|
if (settled) { return; }
|
||||||
var bytes = Uint8Array.from(atob(b64), function (c) { return c.charCodeAt(0); });
|
showFallback((e.error && e.error.message) || e.message || 'script-error');
|
||||||
var blob = new Blob([bytes], { type: 'application/pdf' });
|
});
|
||||||
var url = URL.createObjectURL(blob);
|
|
||||||
pdfFrame.addEventListener('load', function () { loaded = true; });
|
if (typeof pdfjsLib === 'undefined' || typeof pdfjsLib.getDocument !== 'function') {
|
||||||
pdfFrame.src = url + '#view=FitH';
|
showFallback('renderer-not-loaded (pdf.js failed to load from jsdelivr)');
|
||||||
} catch (err) {
|
|
||||||
showFallback((err && err.message) || 'blob-creation-failed');
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 4-second heuristic: if the iframe never reports a load event by
|
/* Point the worker at the same CDN version we loaded the main
|
||||||
* then, the host browser PDF viewer is probably disabled (kiosk
|
* script from. pdf.js fetches this URL and spawns a Worker; CSP
|
||||||
* profile, Brave Shields, etc.). Swap to the fallback message. */
|
* worker-src covers it. */
|
||||||
setTimeout(function () {
|
pdfjsLib.GlobalWorkerOptions.workerSrc = '${PDF_JS_CDN.worker}';
|
||||||
if (!loaded) {
|
|
||||||
showFallback('pdf-viewer-load-timeout');
|
try {
|
||||||
}
|
var b64 = document.getElementById('lc-pdf-data').textContent.trim();
|
||||||
}, 4000);
|
var bytes = Uint8Array.from(atob(b64), function (c) { return c.charCodeAt(0); });
|
||||||
|
|
||||||
|
pdfjsLib.getDocument({ data: bytes }).promise.then(function (pdf) {
|
||||||
|
if (loading) { loading.remove(); }
|
||||||
|
|
||||||
|
/* Render each page sequentially. We pick a render scale based
|
||||||
|
* on the panel width and the first pages native dimensions so
|
||||||
|
* the canvas matches the panel — no upscaling required by CSS
|
||||||
|
* (which would blur it). DPR-aware so retina screens get crisp
|
||||||
|
* pixels. */
|
||||||
|
var dpr = window.devicePixelRatio || 1;
|
||||||
|
var panelWidth = (container.clientWidth || window.innerWidth) - 32;
|
||||||
|
|
||||||
|
function renderPage(pageNum) {
|
||||||
|
return pdf.getPage(pageNum).then(function (page) {
|
||||||
|
var unscaledViewport = page.getViewport({ scale: 1 });
|
||||||
|
var cssScale = Math.max(0.1, panelWidth / unscaledViewport.width);
|
||||||
|
var viewport = page.getViewport({ scale: cssScale * dpr });
|
||||||
|
var canvas = document.createElement('canvas');
|
||||||
|
canvas.width = viewport.width;
|
||||||
|
canvas.height = viewport.height;
|
||||||
|
/* CSS dimensions in logical pixels; canvas backing store at
|
||||||
|
* DPR multiplier for crisp rendering. */
|
||||||
|
canvas.style.width = (viewport.width / dpr) + 'px';
|
||||||
|
canvas.style.height = (viewport.height / dpr) + 'px';
|
||||||
|
container.appendChild(canvas);
|
||||||
|
var ctx = canvas.getContext('2d');
|
||||||
|
return page.render({ canvasContext: ctx, viewport: viewport }).promise;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
var chain = Promise.resolve();
|
||||||
|
for (var i = 1; i <= pdf.numPages; i++) {
|
||||||
|
(function (pageNum) {
|
||||||
|
chain = chain.then(function () { return renderPage(pageNum); });
|
||||||
|
})(i);
|
||||||
|
}
|
||||||
|
return chain.then(markSuccess);
|
||||||
|
}).catch(function (err) {
|
||||||
|
showFallback((err && err.message) || 'pdf-render-failed');
|
||||||
|
});
|
||||||
|
|
||||||
|
/* Safety net: if 15 seconds in pdf.js hasnt rendered anything
|
||||||
|
* visible, accept whatever we have or fall through. PDF.js is
|
||||||
|
* usually fast (<1s for typical chat decks), but big PDFs with
|
||||||
|
* many slides + DPR=2 can take longer. */
|
||||||
|
setTimeout(function () {
|
||||||
|
if (settled) { return; }
|
||||||
|
if (container && container.querySelectorAll('canvas').length > 0) {
|
||||||
|
markSuccess();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
showFallback('pdf-render-timeout');
|
||||||
|
}, 15000);
|
||||||
|
} catch (err) {
|
||||||
|
showFallback((err && err.message) || 'bootstrap-error');
|
||||||
|
}
|
||||||
})();
|
})();
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue