From e1b20e739577b7cbf1f1ba8ec535daba5f698018 Mon Sep 17 00:00:00 2001 From: "gpt-engineer-app[bot]" <159125892+gpt-engineer-app[bot]@users.noreply.github.com> Date: Fri, 17 Apr 2026 01:55:15 +0000 Subject: [PATCH] Changes Co-authored-by: renee-png <262607627+renee-png@users.noreply.github.com> --- src/lib/docx-pleading.ts | 177 ++++++++++++++++++++++++++++++++++++++- 1 file changed, 173 insertions(+), 4 deletions(-) diff --git a/src/lib/docx-pleading.ts b/src/lib/docx-pleading.ts index b4fd212..71770a8 100644 --- a/src/lib/docx-pleading.ts +++ b/src/lib/docx-pleading.ts @@ -11,9 +11,16 @@ import { TextRun, WidthType, BorderStyle, + Footer, + LevelFormat, } from "docx"; import { saveAs } from "file-saver"; +export interface PleadingFootnote { + id: number; + text: string; +} + export interface PleadingInput { courtType: "CIRCUIT" | "COUNTY"; circuit: string; // e.g. "ELEVENTH" @@ -22,7 +29,9 @@ export interface PleadingInput { defendants: string; // multi-line caseNumber: string; title?: string; - body?: string; + bodyHtml?: string; // rich HTML body + body?: string; // legacy plain text fallback + footnotes?: PleadingFootnote[]; } const FONT = "Bookman Old Style"; @@ -44,8 +53,16 @@ const verticalLine = { right: { style: BorderStyle.SINGLE, size: 8, color: "000000" }, }; -function run(text: string, opts: { bold?: boolean } = {}) { - return new TextRun({ text, bold: opts.bold, font: FONT, size: SIZE }); +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] } = {}) { @@ -59,6 +76,108 @@ 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; +} + 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`; @@ -114,14 +233,59 @@ export function buildPleadingDoc(input: PleadingInput): Document { bodyParagraphs.push(p(input.title.toUpperCase(), { bold: true, align: AlignmentType.CENTER })); bodyParagraphs.push(emptyP()); } - if (input.body) { + 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))); } + // 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: { @@ -130,6 +294,11 @@ export function buildPleadingDoc(input: PleadingInput): Document { margin: { top: 1440, right: 1440, bottom: 1440, left: 1440 }, }, }, + footers: { + default: new Footer({ + children: [new Paragraph({ alignment: AlignmentType.CENTER, children: [run("")] })], + }), + }, children: [ p(headerLine1, { bold: true, align: AlignmentType.CENTER }), p(headerLine2, { bold: true, align: AlignmentType.CENTER }),