Co-authored-by: renee-png <262607627+renee-png@users.noreply.github.com>
This commit is contained in:
gpt-engineer-app[bot]
2026-04-17 02:16:33 +00:00
co-authored by renee-png
parent 2b8010c3b4
commit 3297d6efea
+300
View File
@@ -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(`<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") { 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`);
}