Fixed custom form rendering

X-Lovable-Edit-ID: edt-8968bc10-1ea1-47b5-8664-5525e99eaf9b
Co-authored-by: renee-png <262607627+renee-png@users.noreply.github.com>
This commit is contained in:
gpt-engineer-app[bot]
2026-04-18 02:34:14 +00:00
co-authored by renee-png
+210 -53
View File
@@ -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 <br> 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.
// <br> 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 <p> 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,
@@ -462,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));
@@ -469,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;
@@ -488,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;
@@ -507,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;
}
@@ -579,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));
@@ -594,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 ?? []),
}),
],
});
@@ -630,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 }),
}),
);
}