diff --git a/src/lib/pdf-pleading.ts b/src/lib/pdf-pleading.ts new file mode 100644 index 0000000..48726ad --- /dev/null +++ b/src/lib/pdf-pleading.ts @@ -0,0 +1,300 @@ +import jsPDF from "jspdf"; +import type { PleadingInput } from "./docx-pleading"; + +// 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("times", 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)); +} + +function alignOf(el: Element): Block extends { align: infer A } ? A : never { + const ta = (el.getAttribute("style") || "").match(/text-align:\s*(left|center|right|justify)/i)?.[1]?.toLowerCase(); + return (ta as any) || "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" }); + + 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 rightColW = CONTENT_W - leftColW - 24; + const leftX = MARGIN; + const rightX = MARGIN + leftColW + 24; + + const plaintiffLines = (input.plaintiffs || "").split("\n").filter((l) => l.trim()); + const defendantLines = (input.defendants || "").split("\n").filter((l) => l.trim()); + + setFont(pdf, {}); + const captionLeft: string[] = []; + if (plaintiffLines.length) captionLeft.push(...plaintiffLines); else captionLeft.push(" "); + captionLeft.push(""); + captionLeft.push("Plaintiff(s),"); + captionLeft.push(""); + captionLeft.push("v."); + captionLeft.push(""); + if (defendantLines.length) captionLeft.push(...defendantLines); else captionLeft.push(" "); + captionLeft.push(""); + captionLeft.push("Defendant(s)."); + + const captionStartY = y; + let ly = y; + captionLeft.forEach((line) => { pdf.text(line, leftX, ly); ly += LINE_H; }); + + setFont(pdf, { bold: true }); + pdf.text(`CASE NO.: ${input.caseNumber || ""}`, rightX, y); + + const captionEndY = ly; + pdf.setLineWidth(0.6); + pdf.line(MARGIN + leftColW + 12, captionStartY - LINE_H + 4, MARGIN + leftColW + 12, captionEndY); + void rightColW; + + 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") { 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; + }); + } + + 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; + }); + } + } + + 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`); +}