feat(ask): download real PDFs from the chat UI, drop the screenshot path

The "Download PDF" button (both the report CTA and the per-answer icon)
and the once-per-report auto-download now fetch
GET /api/ask/messages/[id]/pdf instead of screenshotting the DOM with
html-to-image. That fetch/blob/anchor-download logic lives in its own
new download-pdf-button.tsx, kept small and separate from chat-view.tsx
since another change is landing in that file's sidebar at the same time.

A Turn's `id` (the stored ask_messages row id) now rides in from a
thread reload (rowToTurn) or from the stream's "done" event once the
row is persisted; both the button and the auto-download wait for it.

Removes answer-pdf.ts (the html-to-image renderer and its page-slicing
math) and its tests — nothing else used them. slugify moved to
src/lib/reports/pdf/filename.ts in an earlier commit.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
DEls 2026-09-27 09:16:46 +02:00
parent 58b625701e
commit 2f613181ac
4 changed files with 118 additions and 303 deletions

View file

@ -1,53 +0,0 @@
import { describe, it, expect } from "vitest";
import { slicePages, slugify } from "./answer-pdf";
describe("slicePages", () => {
it("returns a single page break (0) when everything fits", () => {
expect(slicePages([0, 100], [100, 50], 1000)).toEqual([0]);
});
it("breaks before a block that would overflow the page, never inside one", () => {
// block0: 0-100, block1: 100-300 (height 200), block2: 300-350
const breaks = slicePages([0, 100, 300], [100, 200, 50], 250);
expect(breaks).toEqual([0, 100]);
});
it("gives an oversized single block its own page rather than splitting it", () => {
const breaks = slicePages([0, 500], [500, 50], 250);
// block0 alone is already 500 > 250 — no earlier content to break
// off from, so it stays on page 1; block1 doesn't fit alongside it.
expect(breaks).toEqual([0, 500]);
});
it("packs as many consecutive blocks as fit per page", () => {
const tops = [0, 50, 100, 150, 200];
const heights = [50, 50, 50, 50, 50]; // bottoms: 50,100,150,200,250
const breaks = slicePages(tops, heights, 120);
// Page1: 0-100 (blocks 0,1 = 100 <=120); block2 (bottom150) would make 150>120 -> break at 100
// Page2 starts at 100: blocks2,3 -> bottom 200, 200-100=100<=120; block4 bottom250-100=150>120 -> break at 200
expect(breaks).toEqual([0, 100, 200]);
});
it("returns [0] for no blocks", () => {
expect(slicePages([], [], 1000)).toEqual([0]);
});
});
describe("slugify", () => {
it("lowercases and hyphenates", () => {
expect(slugify("The Hills Fuel Report")).toBe("the-hills-fuel-report");
});
it("strips accents and punctuation", () => {
expect(slugify("André's Café — Q3!")).toBe("andre-s-cafe-q3");
});
it("falls back to 'answer' for an empty/unusable title", () => {
expect(slugify(" ")).toBe("answer");
});
it("caps length at 60 characters", () => {
const long = "a".repeat(200);
expect(slugify(long).length).toBeLessThanOrEqual(60);
});
});

View file

@ -1,202 +0,0 @@
/**
* "Download as PDF" for one answer: a clone rendered at a fixed A4
* width, sliced into pages that never cut through a chart or table row,
* with a header and page-n-of-N footer.
*/
import { toPng } from "html-to-image";
import { PDFDocument, StandardFonts, rgb } from "pdf-lib";
/** A4 at 96 dpi, matching the plan's fixed render width. */
export const PAGE_WIDTH_PX = 794;
export const PAGE_HEIGHT_PX = 1123;
const HEADER_PX = 44;
const FOOTER_PX = 28;
export const CONTENT_HEIGHT_PX = PAGE_HEIGHT_PX - HEADER_PX - FOOTER_PX;
/**
* Page-break y-offsets (the first is always 0): the largest run of
* consecutive blocks whose combined height stays within `pageHeight`,
* breaking only *before* a block, never inside one. A single block
* taller than `pageHeight` (an oversized chart) still gets its own page
* rather than being split — there is no good cut point inside it.
*/
export function slicePages(blockTops: number[], blockHeights: number[], pageHeight: number): number[] {
if (blockTops.length === 0) return [0];
const breaks: number[] = [0];
let pageStart = blockTops[0];
for (let i = 0; i < blockTops.length; i++) {
const top = blockTops[i];
const bottom = top + blockHeights[i];
if (bottom - pageStart > pageHeight && top > pageStart) {
breaks.push(top);
pageStart = top;
}
}
return breaks;
}
/** "The Hills fuel report" -> "the-hills-fuel-report", capped for a sane filename. */
export function slugify(text: string): string {
const slug = text
.toLowerCase()
.normalize("NFD")
.replace(/[̀-ͯ]/g, "")
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "");
return (slug || "answer").slice(0, 60);
}
/** Direct children of `el`, used both to render and to compute page breaks. */
function topLevelBlocks(el: HTMLElement): HTMLElement[] {
return Array.from(el.children).filter((c): c is HTMLElement => c instanceof HTMLElement);
}
/**
* html-to-image serializes the clone to a standalone SVG document and
* draws *that* to a canvas — it never re-resolves CSS custom properties
* in that step. Recharts sets `fill`/`stroke` straight to `var(--series-1)`
* etc. as SVG attributes (see report-chart.tsx's `seriesColor`), so every
* bar/line/donut segment loses its color: an unresolvable `fill="var(...)"`
* falls back to SVG's fill default (black, hence solid black bars) and an
* unresolvable `stroke="var(...)"` falls back to the stroke default
* ("none", hence an invisible line) — axes and grid keep their look only
* because dark text/grey lines happen to be a plausible fallback too.
* Bake every such attribute down to its computed literal color first.
* Reads from `root` itself, which must already be connected to the
* document with its final (light-forced) theme applied, so the computed
* value matches what capture is meant to show.
*/
function bakeCssVariableColors(root: Element): void {
const elements = [root, ...Array.from(root.querySelectorAll("*"))];
for (const el of elements) {
for (const attr of ["fill", "stroke"]) {
const raw = el.getAttribute(attr);
if (!raw || !raw.includes("var(")) continue;
const resolved = getComputedStyle(el).getPropertyValue(attr).trim();
if (resolved) el.setAttribute(attr, resolved);
}
}
}
/**
* Clones `el` at a fixed A4 width in light theme, renders it to PNG,
* slices it into A4 pages without cutting a block, and returns a PDF
* Blob with a header and "Page n of N" footer.
*/
export async function answerToPdf(
el: HTMLElement,
meta: { title: string; date: string },
): Promise<Blob> {
const blocks = topLevelBlocks(el);
const blockTops = blocks.map((b) => b.offsetTop);
const blockHeights = blocks.map((b) => b.offsetHeight);
const totalHeight = el.scrollHeight || blockTops.reduce((max, t, i) => Math.max(max, t + blockHeights[i]), 0);
const clone = el.cloneNode(true) as HTMLElement;
clone.style.width = `${PAGE_WIDTH_PX}px`;
clone.classList.add("light");
clone.style.colorScheme = "light";
const host = document.createElement("div");
host.style.position = "fixed";
host.style.left = "-10000px";
host.style.top = "0";
host.style.width = `${PAGE_WIDTH_PX}px`;
host.appendChild(clone);
document.body.appendChild(host);
bakeCssVariableColors(clone);
let dataUrl: string;
try {
dataUrl = await toPng(clone, {
pixelRatio: 2,
width: PAGE_WIDTH_PX,
height: totalHeight,
backgroundColor: "#ffffff",
});
} finally {
document.body.removeChild(host);
}
const breaks = slicePages(blockTops, blockHeights, CONTENT_HEIGHT_PX);
const img = await loadImage(dataUrl);
const pdf = await PDFDocument.create();
const font = await pdf.embedFont(StandardFonts.Helvetica);
// The PNG was rendered at pixelRatio 2 off our fixed CSS width, so this
// factor converts CSS-px block offsets into the image's own device
// pixels for cropping.
const devicePxPerCssPx = img.width / PAGE_WIDTH_PX;
const contentTopY = PAGE_HEIGHT_PX - HEADER_PX;
for (let i = 0; i < breaks.length; i++) {
const sliceTop = breaks[i];
const sliceBottom = i + 1 < breaks.length ? breaks[i + 1] : totalHeight;
// Each page gets its own crop of exactly the content it shows — never
// the full-height image positioned by page bounds alone. A page that
// breaks early (to avoid splitting a block) is shorter than
// CONTENT_HEIGHT_PX; drawing the full image there let the page's
// fixed-height rectangle bleed into the next block's content,
// duplicating it across the page boundary.
const sliceHeightPx = Math.min(sliceBottom - sliceTop, CONTENT_HEIGHT_PX);
const canvas = document.createElement("canvas");
canvas.width = img.width;
canvas.height = Math.max(1, Math.round(sliceHeightPx * devicePxPerCssPx));
const ctx = canvas.getContext("2d");
if (!ctx) throw new Error("Could not get a 2D canvas context to crop the rendered answer.");
ctx.fillStyle = "#ffffff";
ctx.fillRect(0, 0, canvas.width, canvas.height);
ctx.drawImage(
img,
0,
sliceTop * devicePxPerCssPx,
img.width,
canvas.height,
0,
0,
canvas.width,
canvas.height,
);
const pageBytes = await fetch(canvas.toDataURL("image/png")).then((r) => r.arrayBuffer());
const pagePng = await pdf.embedPng(pageBytes);
const page = pdf.addPage([PAGE_WIDTH_PX, PAGE_HEIGHT_PX]);
page.drawText(`MEGA INFRASTRUCTURE · Ask report · ${meta.date}`, {
x: 24,
y: PAGE_HEIGHT_PX - 26,
size: 9,
font,
color: rgb(0.45, 0.45, 0.45),
});
page.drawImage(pagePng, {
x: 0,
y: contentTopY - sliceHeightPx,
width: PAGE_WIDTH_PX,
height: sliceHeightPx,
});
page.drawText(`Page ${i + 1} of ${breaks.length}`, {
x: PAGE_WIDTH_PX - 90,
y: 16,
size: 8,
font,
color: rgb(0.55, 0.55, 0.55),
});
}
const bytes = await pdf.save();
return new Blob([new Uint8Array(bytes)], { type: "application/pdf" });
}
function loadImage(src: string): Promise<HTMLImageElement> {
return new Promise((resolve, reject) => {
const img = new Image();
img.onload = () => resolve(img);
img.onerror = () => reject(new Error("Could not load the rendered answer image."));
img.src = src;
});
}

View file

@ -8,7 +8,7 @@ import { cn } from "@/lib/utils";
import { AnswerMarkdown } from "./answer-markdown";
import { UploadCard, type UploadState } from "./upload-card";
import { ActionCard, type ActionCardData } from "./action-card";
import { answerToPdf, slugify } from "./answer-pdf";
import { DownloadPdfButton, downloadMessagePdf } from "./download-pdf-button";
import { nextPdfTurnToDownload } from "./pdf-auto-download";
import type { PdfReportMeta } from "@/lib/agent/pdf-report";
import type { ChartSpec } from "@/lib/reports/chart-spec";
@ -38,6 +38,8 @@ type Turn =
actionIds?: string[];
/** Set once make_pdf_report fires — turns this answer into a report. */
pdf?: PdfReportMeta;
/** The stored `ask_messages` row id — set from the reload or once "done" reports it. Needed for GET /api/ask/messages/[id]/pdf. */
id?: string;
local?: boolean;
}
| { role: "upload"; upload: UploadState };
@ -118,6 +120,7 @@ function relativeTime(iso?: string): string {
/** Turns a stored `ask_messages` row into a UI Turn. */
function rowToTurn(row: {
id: string;
kind: string;
text: string;
display?: string;
@ -132,6 +135,7 @@ function rowToTurn(row: {
if (row.kind === "assistant") {
return {
role: "assistant",
id: row.id,
text: row.text,
charts: row.charts ?? [],
tools: (row.tools ?? []).filter((t) => t !== "error"),
@ -495,46 +499,27 @@ function ChatViewInner() {
}
}, []);
const [pdfBusyIndex, setPdfBusyIndex] = React.useState<number | null>(null);
const downloadAnswerAsPdf = React.useCallback(
async (index: number) => {
const el = document.getElementById(`answer-${index}`);
if (!el) return;
setPdfBusyIndex(index);
try {
const turn = turns[index];
const report = turn?.role === "assistant" ? turn.pdf : undefined;
const title = report?.title || threads.find((t) => t.id === activeThreadId)?.title || "answer";
const date = new Date().toISOString().slice(0, 10);
const blob = await answerToPdf(el, { title, date });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
const filename = report?.filename || `ask-${slugify(title)}-${date}.pdf`;
a.download = filename.endsWith(".pdf") ? filename : `${filename}.pdf`;
a.click();
URL.revokeObjectURL(url);
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} finally {
setPdfBusyIndex(null);
}
},
[threads, activeThreadId, turns],
);
// "Once per turn" auto-download for a finished Ask-made report.
// "Once per turn" auto-download for a finished Ask-made report — the
// server route (GET /api/ask/messages/[id]/pdf) needs the message's own
// stored id, which only exists once "done" has reported it (see the
// `event === "done"` handler above), so this waits for that too.
const pdfDownloadedThroughRef = React.useRef(0);
React.useEffect(() => {
const index = nextPdfTurnToDownload(turns, busy, pdfDownloadedThroughRef.current);
if (index === null) return;
const turn = turns[index];
const messageId = turn?.role === "assistant" ? turn.id : undefined;
if (!messageId || !actor) return;
pdfDownloadedThroughRef.current = turns.length;
// Deferred a microtask so downloadAnswerAsPdf's setState calls run as
// an async continuation, not synchronously in the effect body — see
// the identical note on the actor-loading effect above.
Promise.resolve().then(() => downloadAnswerAsPdf(index));
}, [turns, busy, downloadAnswerAsPdf]);
// Deferred a microtask so the download's own state stays an async
// continuation, not synchronous in the effect body — see the
// identical note on the actor-loading effect above.
Promise.resolve().then(() =>
downloadMessagePdf(messageId, actor.id).catch((err) =>
setError(err instanceof Error ? err.message : String(err)),
),
);
}, [turns, busy, actor]);
// Pending photos attached to the next user turn. Declared before any
// handler that reads it; useCallback closes over this state at the
@ -731,6 +716,17 @@ function ChatViewInner() {
// without the model signalling completion. Surface this so
// a quiet stream end isn't mistaken for a complete answer.
if (data?.stoppedOnLimit) setStoppedOnLimit(true);
// The persisted row's own id — GET /api/ask/messages/[id]/pdf
// needs it for both the manual button and the auto-download.
if (data?.messageId) {
setTurns((prev) => {
const next = [...prev];
const last = next[next.length - 1];
if (last.role !== "assistant") return prev;
next[next.length - 1] = { ...last, id: data.messageId as string };
return next;
});
}
}
}
}
@ -1230,13 +1226,13 @@ function ChatViewInner() {
)}
{t.pdf && !streaming && (
<button
onClick={() => downloadAnswerAsPdf(i)}
disabled={pdfBusyIndex === i}
<DownloadPdfButton
messageId={t.id}
actorId={actor?.id}
label={`Download PDF · ${t.pdf.title}`}
onError={setError}
className="print-hide mt-3 flex items-center gap-1.5 rounded-lg bg-[#F2B165] px-3.5 py-2 text-sm font-medium text-white transition-colors hover:bg-[#E08A3C] disabled:opacity-50"
>
{pdfBusyIndex === i ? "Rendering…" : `Download PDF · ${t.pdf.title}`}
</button>
/>
)}
{/* Provenance: which tools produced this answer. The point
@ -1259,13 +1255,13 @@ function ChatViewInner() {
>
Print
</button>
<button
onClick={() => downloadAnswerAsPdf(i)}
disabled={pdfBusyIndex === i}
<DownloadPdfButton
messageId={t.id}
actorId={actor?.id}
label="PDF"
onError={setError}
className="print-hide rounded-full border border-border px-2 py-0.5 text-[11px] text-muted-foreground transition-colors hover:border-[#F2B165]/50 hover:text-foreground disabled:opacity-50"
>
{pdfBusyIndex === i ? "Rendering…" : "PDF"}
</button>
/>
</div>
)}
</div>

View file

@ -0,0 +1,74 @@
"use client";
import * as React from "react";
/**
* Fetches the server-rendered PDF for one stored assistant message
* (`GET /api/ask/messages/[id]/pdf` — see src/lib/reports/pdf/build.ts)
* and saves it under the filename the server chose (its
* `Content-Disposition`). No DOM screenshot: the route already returns
* a real, selectable PDF.
*/
export async function downloadMessagePdf(messageId: string, actorId: string): Promise<void> {
const res = await fetch(`/api/ask/messages/${messageId}/pdf`, {
headers: { "x-ask-user": actorId },
});
if (!res.ok) throw new Error(`Could not build the PDF (HTTP ${res.status}).`);
const blob = await res.blob();
const disposition = res.headers.get("content-disposition") ?? "";
const match = /filename="([^"]+)"/.exec(disposition);
const filename = match?.[1] ?? "ask-report.pdf";
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
a.remove();
URL.revokeObjectURL(url);
}
/**
* A "Download PDF" trigger for one message. Its own small component (not
* inlined in chat-view.tsx) since another change is landing in that file
* at the same time — this keeps that diff small.
*/
export function DownloadPdfButton({
messageId,
actorId,
label,
className,
disabled,
busyLabel = "Rendering…",
onError,
}: {
messageId?: string;
actorId?: string;
label: string;
className?: string;
disabled?: boolean;
busyLabel?: string;
onError?: (message: string) => void;
}) {
const [busy, setBusy] = React.useState(false);
const onClick = React.useCallback(async () => {
if (!messageId || !actorId) return;
setBusy(true);
try {
await downloadMessagePdf(messageId, actorId);
} catch (err) {
onError?.(err instanceof Error ? err.message : String(err));
} finally {
setBusy(false);
}
}, [messageId, actorId, onError]);
return (
<button type="button" onClick={onClick} disabled={disabled || busy || !messageId || !actorId} className={className}>
{busy ? busyLabel : label}
</button>
);
}