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 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; } 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 verticalLine = { 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.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(`
${html}
`, "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 const leftCells: Paragraph[] = []; plaintiffLines.forEach((l) => leftCells.push(p(l))); if (plaintiffLines.length === 0) leftCells.push(p("")); leftCells.push(emptyP()); leftCells.push(p("Plaintiff(s),")); leftCells.push(emptyP()); leftCells.push(p("v.")); leftCells.push(emptyP()); defendantLines.forEach((l) => leftCells.push(p(l))); if (defendantLines.length === 0) leftCells.push(p("")); leftCells.push(emptyP()); leftCells.push(p("Defendant(s).")); 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: verticalLine, 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`); }