Co-authored-by: renee-png <262607627+renee-png@users.noreply.github.com>
420 lines
15 KiB
TypeScript
420 lines
15 KiB
TypeScript
import jsPDF from "jspdf";
|
|
import type { PleadingInput } from "./docx-pleading";
|
|
import { BOOKMAN_FAMILY, registerBookmanFont } from "./bookman-fonts";
|
|
import { BIRDS_FAMILY, registerBirdsFont } from "./birds-font";
|
|
|
|
// 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(`<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;
|
|
Array.from(listEl.children)
|
|
.filter((child): child is Element => child.tagName.toLowerCase() === "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 });
|
|
Array.from(li.children)
|
|
.filter((child): child is Element => /^(ul|ol)$/i.test(child.tagName))
|
|
.forEach((nested) => walkList(nested, nested.tagName.toLowerCase() === "ol", level + 1));
|
|
});
|
|
};
|
|
|
|
const walk = (node: Element) => {
|
|
const tag = node.tagName.toLowerCase();
|
|
if (tag === "ul") return walkList(node, false, 0);
|
|
if (tag === "ol") return walkList(node, true, 0);
|
|
if (tag === "blockquote") {
|
|
const tokens: Token[] = [];
|
|
node.childNodes.forEach((c) => tokenize(c, {}, tokens));
|
|
out.push({ kind: "para", align: alignOf(node), tokens, indent: 36 });
|
|
return;
|
|
}
|
|
if (tag === "h1" || tag === "h2" || tag === "h3") {
|
|
const tokens: Token[] = [];
|
|
node.childNodes.forEach((c) => tokenize(c, { bold: true }, tokens));
|
|
out.push({ kind: "para", align: alignOf(node), tokens });
|
|
return;
|
|
}
|
|
if (tag === "p") {
|
|
const tokens: Token[] = [];
|
|
node.childNodes.forEach((c) => tokenize(c, {}, tokens));
|
|
out.push({ kind: "para", align: alignOf(node), tokens });
|
|
return;
|
|
}
|
|
if (tag === "div" || tag === "section" || tag === "article") {
|
|
Array.from(node.children).forEach((child) => walk(child));
|
|
return;
|
|
}
|
|
const tokens: Token[] = [];
|
|
node.childNodes.forEach((c) => tokenize(c, {}, tokens));
|
|
if (tokens.length > 0) out.push({ kind: "para", align: alignOf(node), tokens });
|
|
};
|
|
|
|
Array.from(root.children).forEach((el) => walk(el));
|
|
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 pushPiece = (text: string, style: Style) => {
|
|
if (!text) return;
|
|
const cur = lines[lines.length - 1];
|
|
const isWhitespace = /^\s+$/.test(text);
|
|
if (isWhitespace) {
|
|
if (cur.length === 0) return;
|
|
cur.push({ text, style, w: measure(text, style) });
|
|
return;
|
|
}
|
|
|
|
const w = measure(text, style);
|
|
if (lineWidth(cur) + w > maxWidth && cur.length > 0) {
|
|
while (cur.length && /^\s+$/.test(cur[cur.length - 1].text)) cur.pop();
|
|
lines.push([{ text, style, w }]);
|
|
return;
|
|
}
|
|
|
|
cur.push({ text, 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 chunks = part.match(/\s+|[^\s]+/g) || [part];
|
|
for (const chunk of chunks) pushPiece(chunk, 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;
|
|
if (!/^\s+$/.test(p.text)) 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);
|
|
registerBirdsFont(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 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);
|
|
// Bottom border only under the left caption cell
|
|
pdf.line(leftX, captionEndY + 2, MARGIN + leftColW + 12, captionEndY + 2);
|
|
|
|
y = captionEndY + LINE_H;
|
|
|
|
if (input.title) {
|
|
setFont(pdf, { bold: true });
|
|
const titleText = input.title.toUpperCase();
|
|
const titleLines = pdf.splitTextToSize(titleText, CONTENT_W) as string[];
|
|
ensureSpace(LINE_H * (titleLines.length + 2));
|
|
y += LINE_H; // one blank line before title
|
|
titleLines.forEach((line, i) => {
|
|
const isLast = i === titleLines.length - 1;
|
|
pdf.text(line, PAGE_W / 2, y, { align: "center" });
|
|
if (isLast) {
|
|
const tw = pdf.getTextWidth(line);
|
|
pdf.setLineWidth(0.6);
|
|
pdf.line((PAGE_W - tw) / 2, y + 1.5, (PAGE_W + tw) / 2, y + 1.5);
|
|
}
|
|
y += LINE_H;
|
|
});
|
|
y += LINE_H; // one blank line after title
|
|
}
|
|
|
|
setFont(pdf, {}); // reset to non-bold for body
|
|
const blocks: Block[] = input.bodyHtml && input.bodyHtml.trim()
|
|
? htmlToBlocks(input.bodyHtml)
|
|
: (input.body || "").split("\n").map<Block>((line) => ({ kind: "para", align: "left", tokens: [{ text: line, style: {} }] }));
|
|
|
|
if (blocks.length === 0 && input.bodyHtml) {
|
|
// Fallback: strip HTML tags and render as plain text paragraphs
|
|
const plain = input.bodyHtml
|
|
.replace(/<\/?(p|div|h[1-6]|li|br)[^>]*>/gi, "\n")
|
|
.replace(/<[^>]+>/g, "");
|
|
plain.split(/\n+/).filter((l) => l.trim()).forEach((line) => {
|
|
blocks.push({ 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);
|
|
setFont(pdf, {}); // reset per-line so prior block styling never bleeds in
|
|
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) - 108; // shifted 1.5" left of the 2/3 mark
|
|
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 signature rendered in the embedded Birds handwriting font.
|
|
pdf.setFont(BIRDS_FAMILY, "normal");
|
|
pdf.setFontSize(14);
|
|
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`);
|
|
}
|