From 99bb03c9dcb91d62260e32b8f84789af0b47389e Mon Sep 17 00:00:00 2001 From: "gpt-engineer-app[bot]" <159125892+gpt-engineer-app[bot]@users.noreply.github.com> Date: Sat, 18 Apr 2026 02:32:22 +0000 Subject: [PATCH 1/3] Changes Co-authored-by: renee-png <262607627+renee-png@users.noreply.github.com> --- src/components/forms/custom-form-builder.tsx | 81 ++++++++++++++++++-- 1 file changed, 74 insertions(+), 7 deletions(-) diff --git a/src/components/forms/custom-form-builder.tsx b/src/components/forms/custom-form-builder.tsx index e8575d3..0f8e38f 100644 --- a/src/components/forms/custom-form-builder.tsx +++ b/src/components/forms/custom-form-builder.tsx @@ -359,12 +359,14 @@ export function CustomFormBuilder() { editor.chain().focus().updateAttributes("paragraph", { caption: !cur }).run(); }; - // Linearized document segments for export. Tables now carry per-cell border data. - type TableCellSeg = { text: string; borders: CellBorders; isHeader: boolean }; + // Linearized document segments for export. Inline runs preserve bold/italic/underline. + // Newlines inside `text` (from
or block splits inside cells) are honored on render. + type InlineRun = { text: string; bold?: boolean; italic?: boolean; underline?: boolean }; + type TableCellSeg = { runs: InlineRun[]; borders: CellBorders; isHeader: boolean }; type Segment = | { kind: "text"; - text: string; + runs: InlineRun[]; align: "left" | "center" | "right" | "justify"; indent: number; caption?: boolean; @@ -372,6 +374,73 @@ export function CustomFormBuilder() { } | { kind: "table"; rows: TableCellSeg[][] }; + // Walk a DOM subtree, emitting inline runs with bold/italic/underline marks. + //
becomes a newline character within the current run stream. + const collectRuns = ( + node: Node, + marks: { bold?: boolean; italic?: boolean; underline?: boolean } = {}, + ): InlineRun[] => { + if (node.nodeType === Node.TEXT_NODE) { + const t = node.textContent ?? ""; + if (!t) return []; + return [{ text: t, ...marks }]; + } + if (node.nodeType !== Node.ELEMENT_NODE) return []; + const el = node as HTMLElement; + const tag = el.tagName.toLowerCase(); + if (tag === "br") return [{ text: "\n", ...marks }]; + const next = { ...marks }; + if (tag === "strong" || tag === "b") next.bold = true; + if (tag === "em" || tag === "i") next.italic = true; + if (tag === "u") next.underline = true; + // Honor inline style overrides too + const fw = el.style.fontWeight; + if (fw && (fw === "bold" || parseInt(fw, 10) >= 600)) next.bold = true; + if (el.style.fontStyle === "italic") next.italic = true; + const td = el.style.textDecoration || el.style.textDecorationLine; + if (td && td.includes("underline")) next.underline = true; + const out: InlineRun[] = []; + el.childNodes.forEach((child) => out.push(...collectRuns(child, next))); + return out; + }; + + // Merge adjacent runs with identical marks for cleaner output. + const mergeRuns = (runs: InlineRun[]): InlineRun[] => { + const out: InlineRun[] = []; + for (const r of runs) { + if (!r.text) continue; + const last = out[out.length - 1]; + if ( + last && + !!last.bold === !!r.bold && + !!last.italic === !!r.italic && + !!last.underline === !!r.underline + ) { + last.text += r.text; + } else { + out.push({ ...r }); + } + } + return out; + }; + + const collectCellRuns = (cell: HTMLElement): InlineRun[] => { + // Cells may contain multiple

blocks — join them with a newline. + const blocks = Array.from(cell.children).filter((c) => + ["p", "h1", "h2", "h3", "div", "li"].includes((c as HTMLElement).tagName.toLowerCase()), + ) as HTMLElement[]; + let runs: InlineRun[] = []; + if (blocks.length === 0) { + runs = collectRuns(cell); + } else { + blocks.forEach((b, i) => { + if (i > 0) runs.push({ text: "\n" }); + runs.push(...collectRuns(b)); + }); + } + return mergeRuns(runs); + }; + const getRenderedSegments = (): Segment[] => { if (!editor) return []; const html = editor.getHTML(); @@ -379,8 +448,6 @@ export function CustomFormBuilder() { tmp.innerHTML = html; const out: Segment[] = []; - const cellText = (cell: HTMLElement) => (cell.textContent ?? "").replace(/\s+/g, " ").trim(); - const pushBlock = (el: HTMLElement) => { const tag = el.tagName.toLowerCase(); if (tag === "table") { @@ -389,7 +456,7 @@ export function CustomFormBuilder() { const cells = Array.from(tr.children) as HTMLElement[]; rows.push( cells.map((c) => ({ - text: cellText(c), + runs: collectCellRuns(c), borders: parseBordersFromAttr(c.getAttribute("data-borders") ?? "trbl"), isHeader: c.tagName.toLowerCase() === "th", })), @@ -405,7 +472,7 @@ export function CustomFormBuilder() { const heading = tag === "h1" ? 1 : tag === "h2" ? 2 : tag === "h3" ? 3 : 0; out.push({ kind: "text", - text: el.textContent || "", + runs: mergeRuns(collectRuns(el)), align, indent: indentPx, caption, From 00f16ca400a4c6b809f5e4a0064003ad6ee20ef7 Mon Sep 17 00:00:00 2001 From: "gpt-engineer-app[bot]" <159125892+gpt-engineer-app[bot]@users.noreply.github.com> Date: Sat, 18 Apr 2026 02:33:23 +0000 Subject: [PATCH 2/3] Changes Co-authored-by: renee-png <262607627+renee-png@users.noreply.github.com> --- src/components/forms/custom-form-builder.tsx | 131 +++++++++++++++---- 1 file changed, 106 insertions(+), 25 deletions(-) diff --git a/src/components/forms/custom-form-builder.tsx b/src/components/forms/custom-form-builder.tsx index 0f8e38f..b183669 100644 --- a/src/components/forms/custom-form-builder.tsx +++ b/src/components/forms/custom-form-builder.tsx @@ -529,6 +529,84 @@ export function CustomFormBuilder() { doc.setFontSize(fontSize); const lineH = fontSize * 1.4; + // Group runs into visual lines (split where applied text contains "\n"). + const runsToLines = (runs: InlineRun[]): InlineRun[][] => { + const lines: InlineRun[][] = [[]]; + for (const r of runs) { + const parts = applyVariables(r.text, ctx).split("\n"); + parts.forEach((part, i) => { + if (i > 0) lines.push([]); + if (part) lines[lines.length - 1].push({ ...r, text: part }); + }); + } + return lines; + }; + + // Draw inline runs on one visual line, honoring word-wrap and per-run font style. + const drawRunsLine = ( + runs: InlineRun[], + startX: number, + startY: number, + widthLimit: number, + align: "left" | "center" | "right" | "justify", + ): number => { + type Token = { text: string; bold: boolean; italic: boolean; underline: boolean; isSpace: boolean }; + const tokens: Token[] = []; + for (const r of runs) { + const bold = !!r.bold; + const italic = !!r.italic; + const underline = !!r.underline; + const parts = r.text.split(/(\s+)/); + for (const p of parts) { + if (!p) continue; + tokens.push({ text: p, bold, italic, underline, isSpace: /^\s+$/.test(p) }); + } + } + const styleFor = (t: Token) => + t.bold && t.italic ? "bolditalic" : t.bold ? "bold" : t.italic ? "italic" : "normal"; + const widthOf = (t: Token) => { + doc.setFont(pdfFont, styleFor(t)); + return doc.getTextWidth(t.text); + }; + // Wrap into lines + const lines: Token[][] = [[]]; + let cur = 0; + for (const t of tokens) { + const w = widthOf(t); + if (cur + w > widthLimit && lines[lines.length - 1].length > 0) { + lines.push([]); + cur = 0; + if (t.isSpace) continue; + } + lines[lines.length - 1].push(t); + cur += w; + } + let yy = startY; + for (const ln of lines) { + while (ln.length && ln[ln.length - 1].isSpace) ln.pop(); + if (yy > pageH - margin) { + doc.addPage(); + yy = margin; + } + const lineW = ln.reduce((a, t) => a + widthOf(t), 0); + let x = startX; + if (align === "center") x = startX + (widthLimit - lineW) / 2; + else if (align === "right") x = startX + (widthLimit - lineW); + for (const t of ln) { + doc.setFont(pdfFont, styleFor(t)); + doc.text(t.text, x, yy); + if (t.underline && !t.isSpace) { + const w = widthOf(t); + doc.setLineWidth(0.5); + doc.line(x, yy + 1.5, x + w, yy + 1.5); + } + x += widthOf(t); + } + yy += lineH; + } + return yy; + }; + const drawTablePdf = (rows: TableCellSeg[][]) => { if (!rows.length) return; const cols = Math.max(...rows.map((r) => r.length)); @@ -536,11 +614,19 @@ export function CustomFormBuilder() { const cellPad = 4; for (let ri = 0; ri < rows.length; ri++) { const row = rows[ri]; - // Compute row height based on tallest cell - const cellLines = row.map((c) => - doc.splitTextToSize(applyVariables(c.text, ctx) || " ", colW - cellPad * 2), - ); - const rowH = Math.max(...cellLines.map((l) => l.length)) * lineH + cellPad * 2; + // Estimate row height by wrapping each cell's visual lines as plain text. + const cellLineCounts = row.map((c) => { + const ls = runsToLines(c?.runs ?? []); + let total = 0; + for (const lineRuns of ls) { + doc.setFont(pdfFont, "normal"); + const flat = lineRuns.map((r) => r.text).join(""); + const wrapped = doc.splitTextToSize(flat || " ", colW - cellPad * 2); + total += Math.max(1, wrapped.length); + } + return Math.max(1, total); + }); + const rowH = Math.max(...cellLineCounts) * lineH + cellPad * 2; if (y + rowH > pageH - margin) { doc.addPage(); y = margin; @@ -555,13 +641,11 @@ export function CustomFormBuilder() { if (borders.b) doc.line(x, y + rowH, x + colW, y + rowH); if (borders.l) doc.line(x, y, x, y + rowH); if (borders.r) doc.line(x + colW, y, x + colW, y + rowH); - const isHeader = cell?.isHeader ?? false; - doc.setFont(pdfFont, isHeader ? "bold" : "normal"); - const lines = cellLines[ci] ?? [""]; + // Render runs — no auto-bold for headers; user controls formatting. + const visualLines = runsToLines(cell?.runs ?? []); let ty = y + cellPad + lineH * 0.8; - for (const ln of lines) { - doc.text(ln, x + cellPad, ty); - ty += lineH; + for (const lineRuns of visualLines) { + ty = drawRunsLine(lineRuns, x + cellPad, ty, colW - cellPad * 2, "left"); } } y += rowH; @@ -574,24 +658,21 @@ export function CustomFormBuilder() { drawTablePdf(seg.rows); continue; } - const text = applyVariables(seg.text, ctx) || " "; - doc.setFont(pdfFont, seg.heading && seg.heading > 0 ? "bold" : "normal"); const indentPx = seg.indent || 0; const indentPt = indentPx * 0.75; // px → pt const segMaxW = maxW - indentPt; - const lines = doc.splitTextToSize(text, segMaxW); - for (const line of lines) { - if (y > pageH - margin) { - doc.addPage(); - y = margin; - } - let x = margin + indentPt; - if (seg.align === "center") x = pageW / 2; - else if (seg.align === "right") x = pageW - margin; - doc.text(line, x, y, { - align: seg.align === "center" ? "center" : seg.align === "right" ? "right" : "left", - }); + const startX = margin + indentPt; + const visualLines = runsToLines(seg.runs); + if (visualLines.length === 0 || (visualLines.length === 1 && visualLines[0].length === 0)) { y += lineH; + } else { + for (const lineRuns of visualLines) { + if (lineRuns.length === 0) { + y += lineH; + continue; + } + y = drawRunsLine(lineRuns, startX, y, segMaxW, seg.align); + } } y += seg.caption ? 2 : 4; } From c3c887856ab9aade3554f1264930c8cb98d84ea8 Mon Sep 17 00:00:00 2001 From: "gpt-engineer-app[bot]" <159125892+gpt-engineer-app[bot]@users.noreply.github.com> Date: Sat, 18 Apr 2026 02:33:42 +0000 Subject: [PATCH 3/3] Changes Co-authored-by: renee-png <262607627+renee-png@users.noreply.github.com> --- src/components/forms/custom-form-builder.tsx | 51 ++++++++++++-------- 1 file changed, 30 insertions(+), 21 deletions(-) diff --git a/src/components/forms/custom-form-builder.tsx b/src/components/forms/custom-form-builder.tsx index b183669..82182ee 100644 --- a/src/components/forms/custom-form-builder.tsx +++ b/src/components/forms/custom-form-builder.tsx @@ -727,6 +727,34 @@ export function CustomFormBuilder() { right: b.r ? onBorder : offBorder, }); + // Build TextRuns for an array of inline runs, honoring newlines (via break: 1) and bold/italic/underline. + const buildDocxRuns = (runs: InlineRun[], extra: { italic?: boolean } = {}): TextRun[] => { + const out: TextRun[] = []; + let isFirst = true; + for (const r of runs) { + const expanded = applyVariables(r.text, ctx); + const parts = expanded.split("\n"); + parts.forEach((part, i) => { + out.push( + new TextRun({ + text: part, + font: fontFamily, + size: fontSize * 2, + bold: !!r.bold, + italics: !!r.italic || !!extra.italic, + underline: r.underline ? {} : undefined, + break: !isFirst && i === 0 ? undefined : i > 0 ? 1 : undefined, + }), + ); + isFirst = false; + }); + } + if (out.length === 0) { + out.push(new TextRun({ text: "", font: fontFamily, size: fontSize * 2 })); + } + return out; + }; + for (const seg of segments) { if (seg.kind === "table") { const cols = Math.max(...seg.rows.map((r) => r.length)); @@ -742,21 +770,13 @@ export function CustomFormBuilder() { children: Array.from({ length: cols }).map((_, ci) => { const cell = row[ci]; const borders = cell?.borders ?? DEFAULT_BORDERS; - const isHeader = cell?.isHeader ?? false; return new DocxTableCell({ borders: docxBorders(borders), width: { size: colW, type: WidthType.DXA }, margins: { top: 80, bottom: 80, left: 120, right: 120 }, children: [ new DocxParagraph({ - children: [ - new TextRun({ - text: applyVariables(cell?.text ?? "", ctx), - font: fontFamily, - size: fontSize * 2, - bold: isHeader, - }), - ], + children: buildDocxRuns(cell?.runs ?? []), }), ], }); @@ -778,22 +798,11 @@ export function CustomFormBuilder() { ? AlignmentType.JUSTIFIED : AlignmentType.LEFT; const indentTwips = Math.round((seg.indent || 0) * 15); // ~px → twips (1px≈15twip) - const lines = applyVariables(seg.text, ctx).split("\n"); children.push( new DocxParagraph({ alignment: align, indent: indentTwips ? { left: indentTwips } : undefined, - children: lines.flatMap((line, idx) => { - const run = new TextRun({ - text: line, - font: fontFamily, - size: fontSize * 2, - italics: seg.caption, - bold: !!seg.heading && seg.heading > 0, - break: idx > 0 ? 1 : undefined, - }); - return [run]; - }), + children: buildDocxRuns(seg.runs, { italic: seg.caption }), }), ); }