import jsPDF from "jspdf"; import type { PleadingInput } from "./docx-pleading"; import { BOOKMAN_FAMILY, registerBookmanFont } from "./bookman-fonts"; // Letter @ 72dpi: 612 x 792. 1" margins = 72. const PAGE_W = 612; const PAGE_H = 792; const MARGIN = 72; const CONTENT_W = PAGE_W - MARGIN * 2; const FONT_SIZE = 12; const LINE_H = 16; // ~1.33x leading at 12pt const FOOTER_GAP = 18; const FOOTER_FONT = 10; type Style = { bold?: boolean; italic?: boolean; underline?: boolean; superscript?: boolean }; type Token = { text: string; style: Style }; type Block = | { kind: "para"; align: "left" | "center" | "right" | "justify"; tokens: Token[]; indent?: number } | { kind: "list-item"; ordered: boolean; level: number; index: number; tokens: Token[] } | { kind: "spacer" } | { kind: "rule" }; function setFont(pdf: jsPDF, style: Style, size = FONT_SIZE) { let weight: "normal" | "bold" | "italic" | "bolditalic" = "normal"; if (style.bold && style.italic) weight = "bolditalic"; else if (style.bold) weight = "bold"; else if (style.italic) weight = "italic"; pdf.setFont(BOOKMAN_FAMILY, weight); pdf.setFontSize(size); } function tokenize(node: Node, style: Style, out: Token[]) { if (node.nodeType === Node.TEXT_NODE) { const t = node.textContent || ""; if (t) out.push({ text: t, style }); return; } if (node.nodeType !== Node.ELEMENT_NODE) return; const el = node as Element; const tag = el.tagName.toLowerCase(); const next: Style = { ...style }; if (tag === "strong" || tag === "b") next.bold = true; if (tag === "em" || tag === "i") next.italic = true; if (tag === "u") next.underline = true; if (tag === "sup") next.superscript = true; if (tag === "br") { out.push({ text: "\n", style }); return; } el.childNodes.forEach((c) => tokenize(c, next, out)); } type Align = "left" | "center" | "right" | "justify"; function alignOf(el: Element): Align { const ta = (el.getAttribute("style") || "").match(/text-align:\s*(left|center|right|justify)/i)?.[1]?.toLowerCase(); return (ta as Align) || "left"; } function htmlToBlocks(html: string): Block[] { if (typeof window === "undefined" || !html) return []; const doc = new DOMParser().parseFromString(`
${html}
`, "text/html"); const root = doc.body.firstElementChild; if (!root) return []; const out: Block[] = []; const walkList = (listEl: Element, ordered: boolean, level: number) => { let idx = 1; listEl.querySelectorAll(":scope > li").forEach((li) => { const tokens: Token[] = []; li.childNodes.forEach((child) => { if (child.nodeType === Node.ELEMENT_NODE && /^(ul|ol)$/i.test((child as Element).tagName)) return; tokenize(child, {}, tokens); }); out.push({ kind: "list-item", ordered, level, index: idx++, tokens }); li.querySelectorAll(":scope > ul").forEach((n) => walkList(n as Element, false, level + 1)); li.querySelectorAll(":scope > ol").forEach((n) => walkList(n as Element, true, level + 1)); }); }; Array.from(root.children).forEach((el) => { const tag = el.tagName.toLowerCase(); if (tag === "ul") return walkList(el, false, 0); if (tag === "ol") return walkList(el, true, 0); if (tag === "blockquote") { const tokens: Token[] = []; el.childNodes.forEach((c) => tokenize(c, {}, tokens)); out.push({ kind: "para", align: alignOf(el), tokens, indent: 36 }); return; } if (tag === "h1" || tag === "h2" || tag === "h3") { const tokens: Token[] = []; el.childNodes.forEach((c) => tokenize(c, { bold: true }, tokens)); out.push({ kind: "para", align: alignOf(el), tokens }); return; } const tokens: Token[] = []; el.childNodes.forEach((c) => tokenize(c, {}, tokens)); out.push({ kind: "para", align: alignOf(el), tokens }); }); return out; } type Piece = { text: string; style: Style; w: number }; function layoutTokens(pdf: jsPDF, tokens: Token[], maxWidth: number): Piece[][] { const lines: Piece[][] = [[]]; const measure = (text: string, style: Style) => { setFont(pdf, style, style.superscript ? FONT_SIZE * 0.75 : FONT_SIZE); return pdf.getTextWidth(text); }; const lineWidth = (line: Piece[]) => line.reduce((a, p) => a + p.w, 0); const pushWord = (word: string, style: Style) => { if (!word) return; const w = measure(word, style); const cur = lines[lines.length - 1]; if (lineWidth(cur) + w > maxWidth && cur.length > 0) { while (cur.length && /\s+$/.test(cur[cur.length - 1].text)) cur.pop(); lines.push([{ text: word, style, w }]); } else { cur.push({ text: word, style, w }); } }; for (const tok of tokens) { const parts = tok.text.split(/(\n)/); for (const part of parts) { if (part === "\n") { lines.push([]); continue; } if (!part) continue; const words = part.match(/\S+\s*/g) || [part]; for (const w of words) pushWord(w, tok.style); } } return lines; } function drawLine(pdf: jsPDF, line: Piece[], x: number, y: number, align: "left" | "center" | "right" | "justify", maxWidth: number, isLast: boolean) { const trimmed = [...line]; while (trimmed.length && /^\s+$/.test(trimmed[trimmed.length - 1].text)) trimmed.pop(); const totalW = trimmed.reduce((a, p) => a + p.w, 0); let startX = x; let extraSpace = 0; if (align === "center") startX = x + (maxWidth - totalW) / 2; else if (align === "right") startX = x + (maxWidth - totalW); else if (align === "justify" && !isLast) { const spaces = trimmed.filter((p) => /\s$/.test(p.text)).length; if (spaces > 0) extraSpace = (maxWidth - totalW) / spaces; } let cx = startX; for (const p of trimmed) { const sz = p.style.superscript ? FONT_SIZE * 0.75 : FONT_SIZE; setFont(pdf, p.style, sz); const drawY = p.style.superscript ? y - 4 : y; pdf.text(p.text, cx, drawY); if (p.style.underline) { const tw = p.w; pdf.setLineWidth(0.5); pdf.line(cx, y + 1.5, cx + tw, y + 1.5); } cx += p.w + (/\s$/.test(p.text) ? extraSpace : 0); } } function drawFooter(pdf: jsPDF, page: number, totalPages: number, left?: string, center?: string, right?: string) { setFont(pdf, {}, FOOTER_FONT); const y = PAGE_H - MARGIN + FOOTER_GAP * 2; const rightText = `${right ? `${right} ` : ""}Page ${page} of ${totalPages}`; if (left) pdf.text(left, MARGIN, y); if (center) { const cw = pdf.getTextWidth(center); pdf.text(center, (PAGE_W - cw) / 2, y); } const rw = pdf.getTextWidth(rightText); pdf.text(rightText, PAGE_W - MARGIN - rw, y); } export async function downloadPleadingPdf(input: PleadingInput, filename: string) { const pdf = new jsPDF({ unit: "pt", format: "letter" }); registerBookmanFont(pdf); let y = MARGIN; const bottomLimit = PAGE_H - MARGIN; const ensureSpace = (needed: number) => { if (y + needed > bottomLimit) { pdf.addPage(); y = MARGIN; } }; setFont(pdf, { bold: true }); const h1 = `IN THE ${input.courtType} COURT OF THE ${input.circuit} JUDICIAL CIRCUIT,`; const h2 = `IN AND FOR ${input.county.toUpperCase()} COUNTY, FLORIDA`; pdf.text(h1, PAGE_W / 2, y, { align: "center" }); y += LINE_H; pdf.text(h2, PAGE_W / 2, y, { align: "center" }); y += LINE_H * 1.5; const leftColW = CONTENT_W * 0.58; const leftX = MARGIN; const rightX = MARGIN + leftColW + 24; const captionRightEdge = MARGIN + CONTENT_W; const labelIndent = 24; // ~1/3" indent for "Plaintiff(s)," / "Defendant(s)." const plaintiffLines = (input.plaintiffs || "").split("\n").filter((l) => l.trim()); const defendantLines = (input.defendants || "").split("\n").filter((l) => l.trim()); setFont(pdf, { bold: true }); type CaptionRow = { text: string; indent?: number }; const captionLeft: CaptionRow[] = []; if (plaintiffLines.length) plaintiffLines.forEach((l) => captionLeft.push({ text: l })); else captionLeft.push({ text: " " }); captionLeft.push({ text: "" }); captionLeft.push({ text: "Plaintiff(s),", indent: labelIndent }); captionLeft.push({ text: "" }); captionLeft.push({ text: "v." }); captionLeft.push({ text: "" }); if (defendantLines.length) defendantLines.forEach((l) => captionLeft.push({ text: l })); else captionLeft.push({ text: " " }); captionLeft.push({ text: "" }); captionLeft.push({ text: "Defendant(s).", indent: labelIndent }); const captionStartY = y; let ly = y; captionLeft.forEach((row) => { pdf.text(row.text, leftX + (row.indent || 0), ly); ly += LINE_H; }); setFont(pdf, { bold: true }); pdf.text(`CASE NO.: ${input.caseNumber || ""}`, rightX, y); const captionEndY = ly; pdf.setLineWidth(0.6); // Vertical divider between caption columns pdf.line(MARGIN + leftColW + 12, captionStartY - LINE_H + 4, MARGIN + leftColW + 12, captionEndY); // Bottom border spanning BOTH caption cells (full content width) pdf.line(leftX, captionEndY + 2, captionRightEdge, captionEndY + 2); y = captionEndY + LINE_H; if (input.title) { ensureSpace(LINE_H * 2); setFont(pdf, { bold: true }); pdf.text(input.title.toUpperCase(), PAGE_W / 2, y, { align: "center" }); y += LINE_H * 1.5; } const blocks = input.bodyHtml && input.bodyHtml.trim() ? htmlToBlocks(input.bodyHtml) : (input.body || "").split("\n").map((line) => ({ kind: "para", align: "left", tokens: [{ text: line, style: {} }] })); for (const block of blocks) { if (block.kind === "spacer" || block.kind === "rule") { y += LINE_H; continue; } if (block.kind === "list-item") { const indent = 24 + block.level * 24; const bulletX = MARGIN + indent - 16; const textX = MARGIN + indent; const maxW = CONTENT_W - indent; const lines = layoutTokens(pdf, block.tokens, maxW); lines.forEach((line, i) => { ensureSpace(LINE_H); if (i === 0) { setFont(pdf, {}); pdf.text(block.ordered ? `${block.index}.` : "•", bulletX, y); } drawLine(pdf, line, textX, y, "left", maxW, i === lines.length - 1); y += LINE_H; }); continue; } const indent = block.indent ?? 0; const maxW = CONTENT_W - indent; const x = MARGIN + indent; const lines = layoutTokens(pdf, block.tokens, maxW); if (lines.length === 1 && lines[0].length === 0) { y += LINE_H; continue; } lines.forEach((line, i) => { ensureSpace(LINE_H); drawLine(pdf, line, x, y, block.align, maxW, i === lines.length - 1); y += LINE_H; }); } // Signature block (above footnotes) — right-side, indented ~2/3 of page width const sig = input.signature; const SIG_X = MARGIN + CONTENT_W * (2 / 3) - 54; // shifted 0.75" left if (sig && (sig.block?.trim() || sig.imageDataUrl || sig.typed?.trim())) { ensureSpace(LINE_H * 6); y += LINE_H * 1.5; if (sig.imageDataUrl) { try { const fmt = (sig.imageType || "png").toUpperCase(); pdf.addImage(sig.imageDataUrl, fmt as any, SIG_X, y - LINE_H, 150, 50); y += 50 - LINE_H + 4; } catch { y += LINE_H * 2; } } else if (sig.typed?.trim()) { // Typed cursive signature — fall back to italic Bookman (script fonts not embedded) setFont(pdf, { italic: true }, 18); pdf.text(sig.typed.trim(), SIG_X, y); y += LINE_H; } else { y += LINE_H * 3; } setFont(pdf, {}); pdf.text("_______________________________", SIG_X, y); y += LINE_H; sig.block.split("\n").forEach((line) => { ensureSpace(LINE_H); pdf.text(line, SIG_X, y); y += LINE_H; }); } const fns = (input.footnotes || []).filter((f) => f.text.trim().length > 0); if (fns.length > 0) { ensureSpace(LINE_H * 2); y += LINE_H * 0.5; pdf.setLineWidth(0.6); pdf.line(MARGIN, y, MARGIN + CONTENT_W * 0.4, y); y += LINE_H * 0.6; for (const f of fns) { const tokens: Token[] = [ { text: `${f.id}`, style: { superscript: true } }, { text: ` ${f.text}`, style: {} }, ]; const lines = layoutTokens(pdf, tokens, CONTENT_W); lines.forEach((line, i) => { ensureSpace(LINE_H); drawLine(pdf, line, MARGIN, y, "left", CONTENT_W, i === lines.length - 1); y += LINE_H; }); } } // Service list — fresh page at end const serviceList = (input.serviceList || []).filter((c) => c.name.trim().length > 0); if (serviceList.length > 0) { pdf.addPage(); y = MARGIN; const slTitle = (input.serviceListTitle || "SERVICE LIST").toUpperCase(); setFont(pdf, { bold: true }); pdf.text(slTitle, PAGE_W / 2, y, { align: "center" }); y += LINE_H * 2; setFont(pdf, {}); serviceList.forEach((c, idx) => { const block: string[] = []; block.push(c.name); const sub = [c.role, c.company].filter(Boolean).join(", "); if (sub) block.push(sub); if (c.address) c.address.split("\n").forEach((l) => block.push(l)); if (c.phone) block.push(`Tel: ${c.phone}`); if (c.email) block.push(`Email: ${c.email}`); block.forEach((line, i) => { ensureSpace(LINE_H); setFont(pdf, { bold: i === 0 }); pdf.text(line, MARGIN, y); y += LINE_H; }); if (idx < serviceList.length - 1) y += LINE_H * 0.5; }); } const total = pdf.getNumberOfPages(); for (let i = 1; i <= total; i++) { pdf.setPage(i); drawFooter(pdf, i, total, input.footerLeft, input.footerCenter, input.footerRight); } pdf.save(filename.endsWith(".pdf") ? filename : `${filename}.pdf`); }