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:
parent
5cccff9a77
commit
93d8fef17d
5 changed files with 248 additions and 1 deletions
7
package-lock.json
generated
7
package-lock.json
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
53
src/app/dashboard/chat/answer-pdf.test.ts
Normal file
53
src/app/dashboard/chat/answer-pdf.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
152
src/app/dashboard/chat/answer-pdf.ts
Normal file
152
src/app/dashboard/chat/answer-pdf.ts
Normal 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;
|
||||
});
|
||||
}
|
||||
|
|
@ -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>
|
||||
)}
|
||||
|
|
|
|||
Loading…
Reference in a new issue