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(`