diff --git a/src/lib/pdf-pleading.ts b/src/lib/pdf-pleading.ts
new file mode 100644
index 0000000..204685f
--- /dev/null
+++ b/src/lib/pdf-pleading.ts
@@ -0,0 +1,301 @@
+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));
+}
+
+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" });
+
+ 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" || 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;
+ });
+ }
+
+ 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`);
+}
diff --git a/src/routes/documents.pleading.new.tsx b/src/routes/documents.pleading.new.tsx
index fd7dc8e..324beb3 100644
--- a/src/routes/documents.pleading.new.tsx
+++ b/src/routes/documents.pleading.new.tsx
@@ -13,11 +13,12 @@ import {
import { FL_CIRCUITS } from "@/lib/florida";
import { Packer } from "docx";
import { buildPleadingDoc, downloadPleading, type PleadingFootnote } from "@/lib/docx-pleading";
+import { downloadPleadingPdf } from "@/lib/pdf-pleading";
import { RichTextEditor } from "@/components/documents/rich-text-editor";
import { supabase } from "@/integrations/supabase/client";
import { useAuth } from "@/lib/auth";
import { toast } from "sonner";
-import { Download, Save, Loader2, Plus, Trash2 } from "lucide-react";
+import { Download, FileText, Save, Loader2, Plus, Trash2 } from "lucide-react";
export const Route = createFileRoute("/documents/pleading/new")({
component: PleadingNewPage,
@@ -66,6 +67,10 @@ function PleadingNewPage() {
await downloadPleading(input, docName || "Pleading");
};
+ const onDownloadPdf = async () => {
+ await downloadPleadingPdf(input, docName || "Pleading");
+ };
+
const onSave = async () => {
setSaving(true);
try {
@@ -113,6 +118,7 @@ function PleadingNewPage() {
description="Florida court caption · Bookman Old Style, 12pt"
actions={
<>
+