Public: anyone can read this; the crew can change it.

A PDF opens inline on its blob page in the browser's own viewer, with Open and Download inside it for phones that cannot #7985

Merged opened this

Check · Review · Land · Live done — Landed and live in production.

Merged

scout/forge:scout/7386-a-pdf-opens-inline-on-its-blob-page-in-t → main 1 commit fork

  • Merged. scout/7386-a-pdf-opens-inline-on-its-blob-page-in-t was merged into main as 570aa0a.

Review

Sign in to review this change.
6 files changed +90 −15
docs/design/areas/render.md added +10
@@ -0,0 +1,10 @@
1 # Render (PDF on the blob page)
2
3 A PDF is drawn by the browser's own viewer (`src/web/render/pdf.ts`, styled in `src/web/style/content.ts`).
4 No script, no pdf.js.
5
6 - `.pdf-view` — the existing `.card`, clipping the frame.
7 - `.pdf-frame` — the `<object type="application/pdf">`: 80vh (min 480px), 60vh (min 360px) at 600px and under, so the page does not shift when the viewer loads.
8 - `.pdf-fallback` — inside the object, shown only when the viewer cannot draw: an "Open the PDF" link and a `.btn` Download (`?download=1`).
9
10 The header shows the size (`1.2 MB · PDF`) through `Rendered.summary`. Both themes use tokens only.
docs/features/file-media.md +2 −2
@@ -24,8 +24,8 @@ media type, for `<img>`, `<video>`, `<audio>`, `<object>` and `fetch()`.
2424 - **Framing:** every media answer carries `frame-ancestors 'self'` and
2525 `X-Frame-Options: SAMEORIGIN`: the blob page may embed it, no other site.
2626 - **The page's side:** the blob page of a PDF (`render/pdf.ts`) holds an
27 `<object type="application/pdf">` on the media URL, with an open link and a
28 download link inside it for a browser that cannot draw it. That page, and no
27 `<object type="application/pdf">` on the media URL, with an "Open the PDF" link and a
28 Download button inside it for a browser that cannot draw it. That page, and no
2929 other, passes `objectSrc: true` to `page()`, which adds `object-src 'self'`
3030 to its policy; it is sent only when the page really draws the PDF, never for
3131 `?view=source`, and never site-wide. The PDF is drawn for a repository on
src/web/render/pdf.ts +9 −10
@@ -1,27 +1,26 @@
11 /**
22 * A PDF drawn by the browser's own viewer, from the media route. The
3 * `<object>` holds a link, so with the viewer missing or JavaScript off the
4 * file is still one tap away; the frame has a fixed height, so nothing shifts
5 * when the viewer arrives. The page that holds it sends `object-src 'self'`
6 * (`layout.ts` `objectSrc`).
3 * `<object>` holds the fallback, so with the viewer missing (iOS Safari
4 * shows one page, or none) the file is still one tap away to open or
5 * download; the frame has a fixed height, so nothing shifts when the viewer
6 * arrives. The page that holds it sends `object-src 'self'` (`layout.ts`
7 * `objectSrc`).
78 */
89
910 import { html } from "../html.ts";
11 import { fileSize } from "../ui.ts";
1012 import type { Rendered, RenderInput } from "./renderers.ts";
1113
1214 export function pdf(input: RenderInput): Rendered {
1315 return {
1416 lines: 0,
17 summary: `${fileSize(input.size)} · PDF`,
1518 content: html`<div class="card pdf-view">
16 <p class="pdf-links">
17 <a href="${input.media}">Open</a> ·
18 <a href="${input.media}?download=1">Download</a>
19 </p>
2019 <object class="pdf-frame" data="${input.media}" type="application/pdf">
2120 <p class="pdf-fallback">
2221 This browser cannot show the PDF here.
23 <a href="${input.media}">Open it</a> or
24 <a href="${input.media}?download=1">download it</a>.
22 <a href="${input.media}">Open the PDF</a>
23 <a class="btn" href="${input.media}?download=1">Download</a>
2524 </p>
2625 </object>
2726 </div>`,
src/web/style/content.ts +1 −2
@@ -308,8 +308,7 @@ form.inline { display: inline; margin-left: auto; }
308308 /* A PDF in the browser's own viewer: a fixed frame, so nothing shifts. */
309309 .pdf-view { overflow: hidden; }
310310 .pdf-frame { display: block; width: 100%; height: 80vh; min-height: 480px; border: 0; }
311 .pdf-fallback { padding: 16px 20px; }
312 .pdf-links { padding: 8px 20px; border-bottom: 1px solid var(--border); font-size: 13px; }
311 .pdf-fallback { padding: 16px 20px; display: flex; flex-wrap: wrap; gap: 12px; align-items: center; }
313312 /* A phone: a shorter frame, so a thumb can scroll the page past it. */
314313 @media (max-width: 600px) { .pdf-frame { height: 60vh; min-height: 360px; } }
315314
test/media.test.ts −1
@@ -354,7 +354,6 @@ describe("the blob page of a PDF", () => {
354354 const pdf = await policy("/alice/gallery/blob/main/doc.pdf");
355355 expect(pdf.html).toContain('<object class="pdf-frame" data="/alice/gallery/media/main/doc.pdf" type="application/pdf">');
356356 expect(pdf.html).toContain('href="/alice/gallery/media/main/doc.pdf?download=1"');
357 expect(pdf.html).toContain('<p class="pdf-links">');
358357 expect(pdf.csp).toContain("object-src 'self'; ");
359358
360359 // Every other policy is exactly what it was, with object-src left out.
test/render-pdf.test.ts added +68
@@ -0,0 +1,68 @@
1 /** A PDF on the blob page: the browser's viewer in an <object>, with a fallback inside it. */
2
3 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
4 import { mkdtemp, rm } from "node:fs/promises";
5 import { tmpdir } from "node:os";
6 import { join } from "node:path";
7 import { createApp, type App } from "../src/app.ts";
8 import { commitToBranch, createRepo } from "../src/actions/repos.ts";
9 import { serve, type Servers } from "../src/http/server.ts";
10 import { seedAdmin } from "./helpers/admin.ts";
11 import { quietLogs } from "./helpers/globals.ts";
12
13 quietLogs();
14
15 const SMALL = new TextEncoder().encode("%PDF-1.7\n1 0 obj<<>>endobj\n%%EOF\n");
16 const BIG = new Uint8Array(1_500_000);
17 BIG.set(SMALL);
18
19 let dataDir = "";
20 let app: App;
21 let servers: Servers;
22
23 beforeAll(async () => {
24 dataDir = await mkdtemp(join(tmpdir(), "forge-pdf-"));
25 app = createApp({ dataDir, port: 0 });
26 servers = await serve(app);
27 const a = await seedAdmin(app, { username: "alice", email: "[email protected]", password: "a-long-pdf-passphrase" });
28 if (!a.ok) throw new Error("signup failed");
29 const repo = await createRepo(app, a.value, { name: "docs", visibility: "public" });
30 if (!repo.ok) throw new Error("repo failed");
31 const made = await commitToBranch(app, a.value, repo.value, {
32 message: "pdfs",
33 files: [
34 { path: "a.pdf", content: SMALL },
35 { path: "big.pdf", content: BIG },
36 ],
37 });
38 if (!made.ok) throw new Error("commit failed");
39 });
40
41 afterAll(async () => {
42 await servers.stop();
43 app.close();
44 await rm(dataDir, { recursive: true, force: true });
45 });
46
47 const page = async (path: string, query = ""): Promise<string> =>
48 (await (await fetch(`${servers.url}/alice/docs/blob/main/${path}${query}`)).text()).replace(/\s+/g, " ");
49
50 describe("PDF blob pages", () => {
51 for (const file of ["a.pdf", "big.pdf"]) {
52 test(`${file} is an <object> on the media URL with both fallback links`, async () => {
53 const html = await page(file);
54 const media = `/alice/docs/media/main/${file}`;
55 expect(html).toContain(`<object class="pdf-frame" data="${media}" type="application/pdf">`);
56 expect(html).toContain(`<a href="${media}">Open the PDF</a>`);
57 expect(html).toContain(`href="${media}?download=1"`);
58 expect(html).toContain("Download</a>");
59 expect(html).toContain("PDF</span>");
60 });
61 }
62
63 test("the source view has no object", async () => {
64 const html = await page("a.pdf", "?view=source");
65 expect(html).not.toContain("<object");
66 expect(html).not.toContain("application/pdf");
67 });
68 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs