feat(ask): download answers as PDF

src/app/dashboard/chat/answer-pdf.ts: slicePages (the pure page-break
maths — packs consecutive top-level blocks under an A4 content height,
breaking only between blocks, an oversized single block gets its own
page rather than being split) and answerToPdf (clones the answer at a
fixed 794px/A4 width in light theme, renders it with html-to-image at
pixelRatio 2, and embeds one page per slice via pdf-lib with a header
and "Page n of N" footer). A new "PDF" button sits next to the
existing "Save as PDF" print button (relabelled "Print" for clarity —
it was always window.print()), filename
ask-<thread-title-slug>-<date>.pdf.

Live browser check skipped: reaching an answer at all now goes through
the picker -> threaded /api/chat flow, which needs ask_threads (not
yet applied). Covered by unit tests on the page-slicing maths and
slugify instead; the render/embed path itself is browser-only DOM code
that can't be meaningfully unit-tested under vitest's node environment.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
DEls 2026-09-26 19:02:17 +02:00
parent 5cccff9a77
commit 93d8fef17d
5 changed files with 248 additions and 1 deletions

7
package-lock.json generated
View file

@ -24,6 +24,7 @@
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"date-fns": "^4.1.0",
"html-to-image": "^1.11.13",
"jsqr": "^1.4.0",
"leaflet": "^1.9.4",
"leaflet.markercluster": "^1.5.3",
@ -9649,6 +9650,12 @@
"node": ">=16.9.0"
}
},
"node_modules/html-to-image": {
"version": "1.11.13",
"resolved": "https://registry.npmjs.org/html-to-image/-/html-to-image-1.11.13.tgz",
"integrity": "sha512-cuOPoI7WApyhBElTTb9oqsawRvZ0rHhaHwghRLlTuffoD1B2aDemlCruLeZrUIIdvG7gs9xeELEPm6PhuASqrg==",
"license": "MIT"
},
"node_modules/html-url-attributes": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/html-url-attributes/-/html-url-attributes-3.0.1.tgz",

View file

@ -31,6 +31,7 @@
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"date-fns": "^4.1.0",
"html-to-image": "^1.11.13",
"jsqr": "^1.4.0",
"leaflet": "^1.9.4",
"leaflet.markercluster": "^1.5.3",

View file

@ -0,0 +1,53 @@
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

@ -0,0 +1,152 @@
/**
* "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);
}
/**
* 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);
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 image = await fetch(dataUrl).then((r) => r.arrayBuffer());
const img = await loadImage(dataUrl);
const pdf = await PDFDocument.create();
const font = await pdf.embedFont(StandardFonts.Helvetica);
const png = await pdf.embedPng(image);
// The PNG was rendered at pixelRatio 2 off our fixed CSS width, so this
// scale converts its pixel dimensions back to the same CSS-px units
// the page and the block offsets are already measured in.
const scale = PAGE_WIDTH_PX / img.width;
const imgWidthPts = img.width * scale;
const imgHeightPts = img.height * scale;
const contentTopY = PAGE_HEIGHT_PX - HEADER_PX;
for (let i = 0; i < breaks.length; i++) {
const sliceTop = breaks[i];
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),
});
// One full-height image draw per page, positioned so this page's
// slice starts at contentTopY — everything else falls outside the
// page's own bounds and simply isn't visible, which is how the
// "crop" happens without pdf-lib needing an explicit clip call.
page.drawImage(png, {
x: 0,
y: contentTopY - imgHeightPts + sliceTop * scale,
width: imgWidthPts,
height: imgHeightPts,
});
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,6 +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 type { ChartSpec } from "@/lib/reports/chart-spec";
import {
SLASH_COMMANDS,
@ -455,6 +456,32 @@ 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 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;
a.download = `ask-${slugify(title)}-${date}.pdf`;
a.click();
URL.revokeObjectURL(url);
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} finally {
setPdfBusyIndex(null);
}
},
[threads, activeThreadId],
);
// Pending photos attached to the next user turn. Declared before any
// handler that reads it; useCallback closes over this state at the
// time the handler is created, so the state must be in scope by then.
@ -1126,7 +1153,14 @@ function ChatViewInner() {
onClick={() => printAnswer(i)}
className="print-hide ml-auto rounded-full border border-border px-2 py-0.5 text-[11px] text-muted-foreground transition-colors hover:border-[#F2B165]/50 hover:text-foreground"
>
Save as PDF
Print
</button>
<button
onClick={() => downloadAnswerAsPdf(i)}
disabled={pdfBusyIndex === i}
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>
)}