Co-authored-by: renee-png <262607627+renee-png@users.noreply.github.com>
492 lines
16 KiB
TypeScript
492 lines
16 KiB
TypeScript
import {
|
|
AlignmentType,
|
|
Document,
|
|
HeightRule,
|
|
ImageRun,
|
|
Packer,
|
|
PageOrientation,
|
|
Paragraph,
|
|
Table,
|
|
TableCell,
|
|
TableRow,
|
|
TextRun,
|
|
WidthType,
|
|
BorderStyle,
|
|
Footer,
|
|
LevelFormat,
|
|
PageNumber,
|
|
} from "docx";
|
|
import pkg from "file-saver";
|
|
const { saveAs } = pkg;
|
|
|
|
export interface PleadingFootnote {
|
|
id: number;
|
|
text: string;
|
|
}
|
|
|
|
export interface PleadingSignature {
|
|
/** Multi-line block placed under the signature line (name, bar no., firm, etc.) */
|
|
block: string;
|
|
/** Optional typed signature (rendered in a cursive script font above the line) */
|
|
typed?: string;
|
|
/** Optional PNG/JPG bytes of a scanned signature placed above the line (for DOCX) */
|
|
imageBytes?: ArrayBuffer | Uint8Array;
|
|
/** Same image as a data URL (for PDF generator, which needs a string) */
|
|
imageDataUrl?: string;
|
|
imageType?: "png" | "jpg" | "jpeg";
|
|
}
|
|
|
|
export interface ServiceContact {
|
|
name: string;
|
|
role?: string;
|
|
company?: string;
|
|
email?: string;
|
|
phone?: string;
|
|
address?: string; // multi-line
|
|
}
|
|
|
|
export interface PleadingInput {
|
|
courtType: "CIRCUIT" | "COUNTY";
|
|
circuit: string; // e.g. "ELEVENTH"
|
|
county: string; // e.g. "Miami-Dade"
|
|
plaintiffs: string; // multi-line
|
|
defendants: string; // multi-line
|
|
caseNumber: string;
|
|
title?: string;
|
|
bodyHtml?: string; // rich HTML body
|
|
body?: string; // legacy plain text fallback
|
|
footnotes?: PleadingFootnote[];
|
|
footerLeft?: string;
|
|
footerCenter?: string;
|
|
footerRight?: string; // user text prepended to "Page X of Y"
|
|
signature?: PleadingSignature;
|
|
serviceList?: ServiceContact[];
|
|
serviceListTitle?: string;
|
|
}
|
|
|
|
const FONT = "Bookman Old Style";
|
|
const SIZE = 24; // 12pt (docx uses half-points)
|
|
|
|
const noBorder = {
|
|
top: { style: BorderStyle.NONE, size: 0, color: "FFFFFF" },
|
|
bottom: { style: BorderStyle.NONE, size: 0, color: "FFFFFF" },
|
|
left: { style: BorderStyle.NONE, size: 0, color: "FFFFFF" },
|
|
right: { style: BorderStyle.NONE, size: 0, color: "FFFFFF" },
|
|
insideHorizontal: { style: BorderStyle.NONE, size: 0, color: "FFFFFF" },
|
|
insideVertical: { style: BorderStyle.NONE, size: 0, color: "FFFFFF" },
|
|
};
|
|
|
|
const captionLeftBorders = {
|
|
top: { style: BorderStyle.NONE, size: 0, color: "FFFFFF" },
|
|
bottom: { style: BorderStyle.SINGLE, size: 8, color: "000000" },
|
|
left: { style: BorderStyle.NONE, size: 0, color: "FFFFFF" },
|
|
right: { style: BorderStyle.SINGLE, size: 8, color: "000000" },
|
|
};
|
|
|
|
function run(text: string, opts: { bold?: boolean; italic?: boolean; underline?: boolean; superscript?: boolean; size?: number } = {}) {
|
|
return new TextRun({
|
|
text,
|
|
bold: opts.bold,
|
|
italics: opts.italic,
|
|
underline: opts.underline ? {} : undefined,
|
|
superScript: opts.superscript,
|
|
font: FONT,
|
|
size: opts.size ?? SIZE,
|
|
});
|
|
}
|
|
|
|
function p(text: string, opts: { bold?: boolean; align?: (typeof AlignmentType)[keyof typeof AlignmentType] } = {}) {
|
|
return new Paragraph({
|
|
alignment: opts.align,
|
|
children: [run(text, { bold: opts.bold })],
|
|
});
|
|
}
|
|
|
|
function emptyP() {
|
|
return new Paragraph({ children: [run("")] });
|
|
}
|
|
|
|
// ---------- HTML → docx Paragraphs ----------
|
|
|
|
type RunStyle = { bold?: boolean; italic?: boolean; underline?: boolean; superscript?: boolean };
|
|
|
|
function alignFromStyle(node: Element): (typeof AlignmentType)[keyof typeof AlignmentType] | undefined {
|
|
const ta = (node.getAttribute("style") || "").match(/text-align:\s*(left|center|right|justify)/i)?.[1]?.toLowerCase();
|
|
switch (ta) {
|
|
case "center": return AlignmentType.CENTER;
|
|
case "right": return AlignmentType.RIGHT;
|
|
case "justify": return AlignmentType.JUSTIFIED;
|
|
case "left": return AlignmentType.LEFT;
|
|
default: return undefined;
|
|
}
|
|
}
|
|
|
|
function collectRuns(node: Node, style: RunStyle, out: TextRun[]) {
|
|
if (node.nodeType === Node.TEXT_NODE) {
|
|
const text = node.textContent || "";
|
|
if (text) out.push(run(text, style));
|
|
return;
|
|
}
|
|
if (node.nodeType !== Node.ELEMENT_NODE) return;
|
|
const el = node as Element;
|
|
const tag = el.tagName.toLowerCase();
|
|
const next: RunStyle = { ...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(new TextRun({ text: "", break: 1, font: FONT, size: SIZE }));
|
|
return;
|
|
}
|
|
el.childNodes.forEach((child) => collectRuns(child, next, out));
|
|
}
|
|
|
|
function blockToParagraphs(el: Element, listCtx?: { ref: string; level: number }): Paragraph[] {
|
|
const tag = el.tagName.toLowerCase();
|
|
const align = alignFromStyle(el);
|
|
|
|
if (tag === "ul" || tag === "ol") {
|
|
const ref = tag === "ul" ? "pl-bullets" : "pl-numbers";
|
|
const level = (listCtx?.level ?? -1) + 1;
|
|
const out: Paragraph[] = [];
|
|
el.querySelectorAll(":scope > li").forEach((li) => {
|
|
const runs: TextRun[] = [];
|
|
li.childNodes.forEach((child) => {
|
|
if (child.nodeType === Node.ELEMENT_NODE && /^(ul|ol)$/i.test((child as Element).tagName)) return;
|
|
collectRuns(child, {}, runs);
|
|
});
|
|
out.push(new Paragraph({
|
|
numbering: { reference: ref, level },
|
|
children: runs.length ? runs : [run("")],
|
|
}));
|
|
li.querySelectorAll(":scope > ul, :scope > ol").forEach((nested) => {
|
|
out.push(...blockToParagraphs(nested as Element, { ref, level }));
|
|
});
|
|
});
|
|
return out;
|
|
}
|
|
|
|
if (tag === "blockquote") {
|
|
const out: Paragraph[] = [];
|
|
el.childNodes.forEach((child) => {
|
|
if (child.nodeType === Node.ELEMENT_NODE) {
|
|
out.push(...blockToParagraphs(child as Element).map((par) => par));
|
|
} else if (child.nodeType === Node.TEXT_NODE && (child.textContent || "").trim()) {
|
|
out.push(new Paragraph({
|
|
alignment: align,
|
|
indent: { left: 720 },
|
|
children: [run(child.textContent || "")],
|
|
}));
|
|
}
|
|
});
|
|
if (out.length === 0) out.push(new Paragraph({ indent: { left: 720 }, children: [run("")] }));
|
|
return out;
|
|
}
|
|
|
|
if (tag === "h1" || tag === "h2" || tag === "h3") {
|
|
const runs: TextRun[] = [];
|
|
el.childNodes.forEach((c) => collectRuns(c, { bold: true }, runs));
|
|
return [new Paragraph({ alignment: align, children: runs.length ? runs : [run("")] })];
|
|
}
|
|
|
|
// Default: paragraph
|
|
const runs: TextRun[] = [];
|
|
el.childNodes.forEach((c) => collectRuns(c, {}, runs));
|
|
return [new Paragraph({ alignment: align, children: runs.length ? runs : [run("")] })];
|
|
}
|
|
|
|
function htmlToParagraphs(html: string): Paragraph[] {
|
|
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: Paragraph[] = [];
|
|
Array.from(root.children).forEach((child) => {
|
|
out.push(...blockToParagraphs(child));
|
|
});
|
|
return out;
|
|
}
|
|
|
|
function footerCell(width: number, align: (typeof AlignmentType)[keyof typeof AlignmentType], children: TextRun[]) {
|
|
return new TableCell({
|
|
width: { size: width, type: WidthType.DXA },
|
|
borders: noBorder,
|
|
margins: { top: 40, bottom: 40, left: 60, right: 60 },
|
|
children: [
|
|
new Paragraph({
|
|
alignment: align,
|
|
children: children.length ? children : [run("")],
|
|
}),
|
|
],
|
|
});
|
|
}
|
|
|
|
function buildFooterTable(left?: string, center?: string, right?: string): Table {
|
|
const total = 9360;
|
|
const col = Math.floor(total / 3);
|
|
const cols = [col, col, total - 2 * col];
|
|
|
|
const leftRuns: TextRun[] = left ? [run(left, { size: 20 })] : [];
|
|
const centerRuns: TextRun[] = center ? [run(center, { size: 20 })] : [];
|
|
|
|
// Right column: optional user text + always "Page X of Y"
|
|
const rightRuns: TextRun[] = [];
|
|
if (right && right.trim()) {
|
|
rightRuns.push(run(`${right} `, { size: 20 }));
|
|
}
|
|
rightRuns.push(run("Page ", { size: 20 }));
|
|
rightRuns.push(new TextRun({ children: [PageNumber.CURRENT], font: FONT, size: 20 }));
|
|
rightRuns.push(run(" of ", { size: 20 }));
|
|
rightRuns.push(new TextRun({ children: [PageNumber.TOTAL_PAGES], font: FONT, size: 20 }));
|
|
|
|
return new Table({
|
|
width: { size: total, type: WidthType.DXA },
|
|
columnWidths: cols,
|
|
borders: noBorder,
|
|
rows: [
|
|
new TableRow({
|
|
children: [
|
|
footerCell(cols[0], AlignmentType.LEFT, leftRuns),
|
|
footerCell(cols[1], AlignmentType.CENTER, centerRuns),
|
|
footerCell(cols[2], AlignmentType.RIGHT, rightRuns),
|
|
],
|
|
}),
|
|
],
|
|
});
|
|
}
|
|
|
|
export function buildPleadingDoc(input: PleadingInput): Document {
|
|
const headerLine1 = `IN THE ${input.courtType} COURT OF THE ${input.circuit} JUDICIAL CIRCUIT,`;
|
|
const headerLine2 = `IN AND FOR ${input.county.toUpperCase()} COUNTY, FLORIDA`;
|
|
|
|
const plaintiffLines = (input.plaintiffs || "").split("\n").filter((l) => l.trim().length > 0);
|
|
const defendantLines = (input.defendants || "").split("\n").filter((l) => l.trim().length > 0);
|
|
|
|
// Left side of caption — all bold
|
|
const leftCells: Paragraph[] = [];
|
|
plaintiffLines.forEach((l) => leftCells.push(p(l, { bold: true })));
|
|
if (plaintiffLines.length === 0) leftCells.push(p("", { bold: true }));
|
|
leftCells.push(emptyP());
|
|
leftCells.push(p("Plaintiff(s),", { bold: true }));
|
|
leftCells.push(emptyP());
|
|
leftCells.push(p("v.", { bold: true }));
|
|
leftCells.push(emptyP());
|
|
defendantLines.forEach((l) => leftCells.push(p(l, { bold: true })));
|
|
if (defendantLines.length === 0) leftCells.push(p("", { bold: true }));
|
|
leftCells.push(emptyP());
|
|
leftCells.push(p("Defendant(s).", { bold: true }));
|
|
|
|
const rightCells: Paragraph[] = [
|
|
p(`CASE NO.: ${input.caseNumber || ""}`, { bold: true }),
|
|
];
|
|
|
|
const captionTable = new Table({
|
|
width: { size: 9360, type: WidthType.DXA },
|
|
columnWidths: [5400, 3960],
|
|
rows: [
|
|
new TableRow({
|
|
height: { value: 400, rule: HeightRule.AUTO },
|
|
children: [
|
|
new TableCell({
|
|
width: { size: 5400, type: WidthType.DXA },
|
|
borders: captionLeftBorders,
|
|
margins: { top: 80, bottom: 80, left: 0, right: 200 },
|
|
children: leftCells,
|
|
}),
|
|
new TableCell({
|
|
width: { size: 3960, type: WidthType.DXA },
|
|
borders: noBorder,
|
|
margins: { top: 80, bottom: 80, left: 200, right: 0 },
|
|
children: rightCells,
|
|
}),
|
|
],
|
|
}),
|
|
],
|
|
});
|
|
|
|
const bodyParagraphs: Paragraph[] = [];
|
|
if (input.title) {
|
|
bodyParagraphs.push(emptyP());
|
|
bodyParagraphs.push(p(input.title.toUpperCase(), { bold: true, align: AlignmentType.CENTER }));
|
|
bodyParagraphs.push(emptyP());
|
|
}
|
|
if (input.bodyHtml && input.bodyHtml.trim()) {
|
|
bodyParagraphs.push(...htmlToParagraphs(input.bodyHtml));
|
|
} else if (input.body) {
|
|
input.body.split("\n").forEach((line) => bodyParagraphs.push(p(line)));
|
|
}
|
|
|
|
// Signature block (above footnotes, after body) — right-side, indented ~2/3 of page width
|
|
const sig = input.signature;
|
|
const SIG_INDENT = 6240; // ~4.33" from left margin (page content is 9360 dxa wide)
|
|
const sigParagraph = (children: TextRun[]) =>
|
|
new Paragraph({ indent: { left: SIG_INDENT }, children });
|
|
if (sig && (sig.block?.trim() || sig.imageBytes || sig.typed?.trim())) {
|
|
bodyParagraphs.push(emptyP());
|
|
bodyParagraphs.push(emptyP());
|
|
if (sig.imageBytes) {
|
|
const data =
|
|
sig.imageBytes instanceof Uint8Array
|
|
? sig.imageBytes
|
|
: new Uint8Array(sig.imageBytes);
|
|
bodyParagraphs.push(
|
|
new Paragraph({
|
|
indent: { left: SIG_INDENT },
|
|
children: [
|
|
new ImageRun({
|
|
type: (sig.imageType as any) || "png",
|
|
data,
|
|
transformation: { width: 200, height: 60 },
|
|
altText: { title: "Signature", description: "Attorney signature", name: "signature" },
|
|
}),
|
|
],
|
|
}),
|
|
);
|
|
} else if (sig.typed?.trim()) {
|
|
// Typed cursive signature — script font, larger size
|
|
bodyParagraphs.push(
|
|
new Paragraph({
|
|
indent: { left: SIG_INDENT },
|
|
children: [
|
|
new TextRun({
|
|
text: sig.typed.trim(),
|
|
font: "Lucida Handwriting",
|
|
size: 36,
|
|
}),
|
|
],
|
|
}),
|
|
);
|
|
} else {
|
|
bodyParagraphs.push(emptyP());
|
|
bodyParagraphs.push(emptyP());
|
|
}
|
|
bodyParagraphs.push(sigParagraph([run("_______________________________")]));
|
|
sig.block.split("\n").forEach((line) =>
|
|
bodyParagraphs.push(sigParagraph([run(line)])),
|
|
);
|
|
}
|
|
|
|
// Footnotes rendered as endnote-style block at bottom of document
|
|
const footnotes = (input.footnotes || []).filter((f) => f.text.trim().length > 0);
|
|
if (footnotes.length > 0) {
|
|
bodyParagraphs.push(emptyP());
|
|
bodyParagraphs.push(new Paragraph({
|
|
border: { top: { style: BorderStyle.SINGLE, size: 6, color: "000000", space: 4 } },
|
|
children: [run("")],
|
|
}));
|
|
footnotes.forEach((f) => {
|
|
bodyParagraphs.push(new Paragraph({
|
|
children: [
|
|
run(`${f.id}`, { superscript: true, size: 20 }),
|
|
run(" "),
|
|
run(f.text, { size: 20 }),
|
|
],
|
|
}));
|
|
});
|
|
}
|
|
|
|
return new Document({
|
|
styles: {
|
|
default: { document: { run: { font: FONT, size: SIZE } } },
|
|
},
|
|
numbering: {
|
|
config: [
|
|
{
|
|
reference: "pl-bullets",
|
|
levels: [0, 1, 2].map((lvl) => ({
|
|
level: lvl,
|
|
format: LevelFormat.BULLET,
|
|
text: "\u2022",
|
|
alignment: AlignmentType.LEFT,
|
|
style: { paragraph: { indent: { left: 720 * (lvl + 1), hanging: 360 } } },
|
|
})),
|
|
},
|
|
{
|
|
reference: "pl-numbers",
|
|
levels: [0, 1, 2].map((lvl) => ({
|
|
level: lvl,
|
|
format: LevelFormat.DECIMAL,
|
|
text: `%${lvl + 1}.`,
|
|
alignment: AlignmentType.LEFT,
|
|
style: { paragraph: { indent: { left: 720 * (lvl + 1), hanging: 360 } } },
|
|
})),
|
|
},
|
|
],
|
|
},
|
|
sections: [
|
|
{
|
|
properties: {
|
|
page: {
|
|
size: { width: 12240, height: 15840, orientation: PageOrientation.PORTRAIT },
|
|
margin: { top: 1440, right: 1440, bottom: 1440, left: 1440 },
|
|
},
|
|
},
|
|
footers: {
|
|
default: new Footer({
|
|
children: [buildFooterTable(input.footerLeft, input.footerCenter, input.footerRight)],
|
|
}),
|
|
},
|
|
children: [
|
|
p(headerLine1, { bold: true, align: AlignmentType.CENTER }),
|
|
p(headerLine2, { bold: true, align: AlignmentType.CENTER }),
|
|
emptyP(),
|
|
captionTable,
|
|
...bodyParagraphs,
|
|
],
|
|
},
|
|
],
|
|
});
|
|
}
|
|
|
|
export async function downloadPleading(input: PleadingInput, filename: string) {
|
|
const doc = buildPleadingDoc(input);
|
|
const blob = await Packer.toBlob(doc);
|
|
saveAs(blob, filename.endsWith(".docx") ? filename : `${filename}.docx`);
|
|
}
|
|
|
|
// ---------- Generic template (custom fields) ----------
|
|
|
|
export interface GenericDocInput {
|
|
title?: string;
|
|
body: string; // Already merged (fields substituted)
|
|
fontFamily?: string;
|
|
fontSizePt?: number;
|
|
}
|
|
|
|
export async function downloadGeneric(input: GenericDocInput, filename: string) {
|
|
const font = input.fontFamily || "Bookman Old Style";
|
|
const size = (input.fontSizePt || 12) * 2;
|
|
|
|
const mkP = (text: string, bold = false, center = false) =>
|
|
new Paragraph({
|
|
alignment: center ? AlignmentType.CENTER : undefined,
|
|
children: [new TextRun({ text, bold, font, size })],
|
|
});
|
|
|
|
const children: Paragraph[] = [];
|
|
if (input.title) {
|
|
children.push(mkP(input.title, true, true));
|
|
children.push(new Paragraph({ children: [new TextRun({ text: "", font, size })] }));
|
|
}
|
|
input.body.split("\n").forEach((line) => children.push(mkP(line)));
|
|
|
|
const doc = new Document({
|
|
styles: { default: { document: { run: { font, size } } } },
|
|
sections: [
|
|
{
|
|
properties: {
|
|
page: {
|
|
size: { width: 12240, height: 15840 },
|
|
margin: { top: 1440, right: 1440, bottom: 1440, left: 1440 },
|
|
},
|
|
},
|
|
children,
|
|
},
|
|
],
|
|
});
|
|
|
|
const blob = await Packer.toBlob(doc);
|
|
saveAs(blob, filename.endsWith(".docx") ? filename : `${filename}.docx`);
|
|
}
|