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:
@@ -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 }),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user