Added PDF export to pleadings
X-Lovable-Edit-ID: edt-4ae0fb0c-12f6-4ffa-b9cd-d8bb1c5c6fc8 Co-authored-by: renee-png <262607627+renee-png@users.noreply.github.com>
This commit is contained in:
@@ -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(`<div>${html}</div>`, "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<Block>((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`);
|
||||
}
|
||||
@@ -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={
|
||||
<>
|
||||
<Button variant="outline" onClick={onDownloadPdf}><FileText className="h-4 w-4 mr-2" /> Download PDF</Button>
|
||||
<Button variant="outline" onClick={onDownload}><Download className="h-4 w-4 mr-2" /> Download .docx</Button>
|
||||
<Button onClick={onSave} disabled={saving}>
|
||||
{saving ? <Loader2 className="h-4 w-4 mr-2 animate-spin" /> : <Save className="h-4 w-4 mr-2" />}
|
||||
|
||||
Reference in New Issue
Block a user