diff --git a/package-lock.json b/package-lock.json index 56d822b..b126244 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index 5817928..0fda445 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/app/dashboard/chat/answer-pdf.test.ts b/src/app/dashboard/chat/answer-pdf.test.ts new file mode 100644 index 0000000..e4fc84f --- /dev/null +++ b/src/app/dashboard/chat/answer-pdf.test.ts @@ -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); + }); +}); diff --git a/src/app/dashboard/chat/answer-pdf.ts b/src/app/dashboard/chat/answer-pdf.ts new file mode 100644 index 0000000..4a13c59 --- /dev/null +++ b/src/app/dashboard/chat/answer-pdf.ts @@ -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 { + 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 { + 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; + }); +} diff --git a/src/app/dashboard/chat/chat-view.tsx b/src/app/dashboard/chat/chat-view.tsx index 269f59e..3d18567 100644 --- a/src/app/dashboard/chat/chat-view.tsx +++ b/src/app/dashboard/chat/chat-view.tsx @@ -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(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 + + )}