= {
"&": "&",
"<": "<",
">": ">",
};
const escapeHtml = (text: string): string =>
text.replace(/[&<>]/g, (c) => ESCAPES[c]);
/**
* Body text → Quill HTML. Clauses become `` carrying their outline token so
* the author sees the real numbering; bullets become a `
` under the clause
* they belong to.
*/
export function bodyToHtml(body: string): string {
const lines = (body ?? "").split("\n").filter((l) => l.trim().length > 0);
if (lines.length === 0) return "
";
const out: string[] = [];
let inList = false;
for (const line of lines) {
const trimmed = line.trim();
if (trimmed.startsWith("- ")) {
if (!inList) {
out.push("");
inList = true;
}
out.push(`- ${escapeHtml(trimmed.slice(2).trim())}
`);
continue;
}
if (inList) {
out.push("
");
inList = false;
}
out.push(`${escapeHtml(trimmed)}
`);
}
if (inList) out.push("
");
return out.join("");
}
/**
* Quill HTML → body text. `` inside a `` becomes a "- " bullet; every
* other block becomes its own line. Quill's indent classes
* (`ql-indent-1`, …) map onto sub-clause depth, so indenting in the toolbar
* produces "1.1"-style nesting — the exact digits are placeholders, the server
* renumbers them.
*
* Runs through DOMParser rather than regex: the input is real HTML from a
* contenteditable, and entity handling (&, ) has to be right or the
* text lands in the PDF mangled.
*/
export function htmlToBody(html: string): string {
if (!html) return "";
const doc = new DOMParser().parseFromString(
`${html}
`,
"text/html",
);
const root = doc.getElementById("root");
if (!root) return "";
const lines: string[] = [];
const textOf = (el: Element): string =>
// is the nbsp Quill inserts for trailing spaces — plain space in the
// stored body, otherwise it survives into the contract text.
(el.textContent ?? "").replace(/ /g, " ").trim();
const indentOf = (el: Element): number => {
const match = /ql-indent-(\d+)/.exec(el.className ?? "");
return match ? Number(match[1]) : 0;
};
const walk = (node: Element, insideList: boolean) => {
for (const child of Array.from(node.children)) {
const tag = child.tagName.toLowerCase();
if (tag === "ul" || tag === "ol") {
// is authored numbering; the body format numbers clauses itself,
// so an ordered list is clause lines, not bullets.
walk(child, tag === "ul");
continue;
}
if (tag === "li") {
const text = textOf(child);
if (!text) continue;
if (insideList) {
lines.push(`- ${text}`);
} else {
const depth = indentOf(child) + 1;
lines.push(`${Array.from({ length: depth }, () => "1").join(".")}. ${text}`);
}
continue;
}
if (tag === "p" || tag === "div") {
const text = textOf(child);
if (text) {
const depth = indentOf(child);
lines.push(
depth > 0
? `${Array.from({ length: depth + 1 }, () => "1").join(".")}. ${text}`
: text,
);
}
continue;
}
// Anything else (blockquote, heading, stray span): keep its text on a
// line rather than dropping the author's words.
const text = textOf(child);
if (text) lines.push(text);
}
};
walk(root, false);
return lines.join("\n");
}