// Render-Adapter für das Rich-Text-Modell (richText.ts): einmal nach HTML/CSS // (für den WYSIWYG-Editor und die Bildschirm-Vorschau auf dem Canvas) und einmal // nach SVG- (für den vektoriellen Grundriss-/Print-Renderer). Beide Wege // bilden dieselbe Mark→Stil-Zuordnung ab, damit Editor-Vorschau und gezeichneter // Text identisch aussehen. Reines Modul: keine React-/App-Abhängigkeit. import type { Marks, Paragraph, RichTextDoc, TextRun } from "./richText"; // ── Text-Escaping ─────────────────────────────────────────────────────────── /** Escapt Text für HTML/XML/SVG-Inhalt (kein Attribut). */ export function escapeText(s: string): string { return s .replace(/&/g, "&") .replace(//g, ">"); } /** Escapt einen Wert für ein doppelt-gequotetes XML-Attribut. */ function escapeAttr(s: string): string { return escapeText(s).replace(/"/g, """); } // ── Gemeinsame Mark→Stil-Zuordnung ────────────────────────────────────────── /** Kombiniert underline + strike zu einem CSS/SVG-text-decoration-Wert. */ function textDecoration(marks: Marks): string | undefined { const parts: string[] = []; if (marks.underline) parts.push("underline"); if (marks.strike) parts.push("line-through"); return parts.length ? parts.join(" ") : undefined; } /** Faktor, um den super-/sub-Text zu verkleinern. */ const SCRIPT_SCALE = 0.7; /** * CSS-Deklarationen (als `{prop: value}`) für einen Run. `basePt` ist die * Basisschriftgrösse in pt, falls der Run selbst keine `sizePt` trägt. */ export function runCssProps(marks: Marks, basePt = 12): Record { const css: Record = {}; if (marks.bold) css["font-weight"] = "700"; if (marks.italic) css["font-style"] = "italic"; const deco = textDecoration(marks); if (deco) css["text-decoration"] = deco; if (marks.font) css["font-family"] = marks.font; const sizePt = (marks.sizePt ?? basePt) * (marks.super || marks.sub ? SCRIPT_SCALE : 1); css["font-size"] = `${round(sizePt)}pt`; if (marks.color) css["color"] = marks.color; if (marks.super) css["vertical-align"] = "super"; if (marks.sub) css["vertical-align"] = "sub"; return css; } function cssPropsToString(css: Record): string { return Object.entries(css) .map(([k, v]) => `${k}:${v}`) .join(";"); } function round(n: number): number { return Math.round(n * 1000) / 1000; } // ── HTML-Render (Editor / Canvas-Vorschau) ────────────────────────────────── export interface HtmlOptions { /** Basisschriftgrösse in pt für Runs ohne eigene Grösse. */ basePt?: number; /** Standardfarbe (CSS) falls kein Run eine Farbe setzt. */ color?: string; } /** * Rendert das Dokument zu einem sanitisierten HTML-String: ein `
` pro * Absatz (mit `text-align`), darin ein `` pro Run mit Inline-Styles. * Leere Absätze erhalten ein `
`, damit die Zeilenhöhe erhalten bleibt. */ export function docToHtml(doc: RichTextDoc, opts: HtmlOptions = {}): string { const basePt = opts.basePt ?? 12; return doc.paragraphs .map((p) => paragraphToHtml(p, basePt, opts.color)) .join(""); } function paragraphToHtml(p: Paragraph, basePt: number, color?: string): string { const align = p.align && p.align !== "left" ? ` style="text-align:${p.align}"` : ""; if (p.runs.length === 0) return `
`; const inner = p.runs.map((r) => runToHtmlSpan(r, basePt, color)).join(""); return `${inner}`; } function runToHtmlSpan(run: TextRun, basePt: number, color?: string): string { const css = runCssProps(run.marks, basePt); if (color && !css["color"]) css["color"] = color; const style = cssPropsToString(css); const text = escapeText(run.text) || ""; return `${text}`; } // ── SVG-Render (Grundriss / Print) ────────────────────────────────────────── /** Beschreibung eines einzelnen (ein Run innerhalb einer Zeile). */ export interface SvgTspan { text: string; /** Schriftgrösse in Benutzereinheiten (bereits umgerechnet). */ fontSize: number; fontWeight?: "700"; fontStyle?: "italic"; fontFamily?: string; fill: string; textDecoration?: string; /** Vertikaler Versatz für Hoch-/Tiefstellung, in Benutzereinheiten. */ baselineShift?: number; } /** Eine gerenderte Zeile (= Absatz) mit ihren Runs. */ export interface SvgLine { tspans: SvgTspan[]; align: "left" | "center" | "right"; } export interface SvgTextOptions { /** Ankerpunkt X in Benutzereinheiten. */ x: number; /** Baseline der ERSTEN Zeile, Y in Benutzereinheiten. */ y: number; /** Zeilenvorschub (Baseline zu Baseline) in Benutzereinheiten. */ lineHeight: number; /** * Umrechnung Schriftgrösse: `sizePt` × `unitPerPt` = Grösse in Benutzereinheiten. * Der Aufrufer bestimmt so, ob Text in px, mm oder Metern gezeichnet wird. */ unitPerPt: number; /** Basis-`sizePt` für Runs ohne eigene Grösse. */ basePt?: number; /** Standardfarbe "#rrggbb" für Runs ohne Farbe. */ color?: string; } /** * Zerlegt das Dokument in Zeilen/Tspans mit bereits in Benutzereinheiten * umgerechneten Grössen. Der Aufrufer positioniert die Zeilen selbst (Baseline * der Zeile i = `y + i·lineHeight`). */ export function docToLines(doc: RichTextDoc, opts: SvgTextOptions): SvgLine[] { const basePt = opts.basePt ?? 12; const color = opts.color ?? "#000000"; return doc.paragraphs.map((p) => ({ align: p.align ?? "left", tspans: p.runs .filter((r) => r.text.length > 0) .map((r) => runToTspan(r, basePt, opts.unitPerPt, color)), })); } function runToTspan(run: TextRun, basePt: number, unitPerPt: number, color: string): SvgTspan { const m = run.marks; const script = m.super || m.sub; const sizePt = (m.sizePt ?? basePt) * (script ? SCRIPT_SCALE : 1); const fontSize = sizePt * unitPerPt; const tspan: SvgTspan = { text: run.text, fontSize: round(fontSize), fill: m.color ?? color, }; if (m.bold) tspan.fontWeight = "700"; if (m.italic) tspan.fontStyle = "italic"; if (m.font) tspan.fontFamily = m.font; const deco = textDecoration(m); if (deco) tspan.textDecoration = deco; if (m.super) tspan.baselineShift = round(fontSize * 0.35); if (m.sub) tspan.baselineShift = round(-fontSize * 0.2); return tspan; } /** * Baut ein komplettes ``-SVG-Element als String. Jede Zeile ist ein * ``, darin ein `` je Run. `text-anchor` folgt der * Absatz-Ausrichtung (left→start, center→middle, right→end). */ export function docToSvgText(doc: RichTextDoc, opts: SvgTextOptions): string { const lines = docToLines(doc, opts); const parts: string[] = []; lines.forEach((line, i) => { const dy = i === 0 ? 0 : opts.lineHeight; const anchor = line.align === "center" ? "middle" : line.align === "right" ? "end" : "start"; const runSpans = line.tspans.map((t) => tspanToSvg(t)).join(""); // Leere Zeile: ein Zero-Width-Space, damit dy dennoch wirkt. const content = runSpans || "​"; parts.push( `${content}`, ); }); return `${parts.join("")}`; } function tspanToSvg(t: SvgTspan): string { const attrs: string[] = [`font-size="${t.fontSize}"`, `fill="${escapeAttr(t.fill)}"`]; if (t.fontWeight) attrs.push(`font-weight="${t.fontWeight}"`); if (t.fontStyle) attrs.push(`font-style="${t.fontStyle}"`); if (t.fontFamily) attrs.push(`font-family="${escapeAttr(t.fontFamily)}"`); if (t.textDecoration) attrs.push(`text-decoration="${t.textDecoration}"`); if (t.baselineShift) attrs.push(`dy="${-t.baselineShift}"`); const body = escapeText(t.text); // Bei baseline-shift den dy nach dem Run zurücksetzen, damit die Grundlinie hält. if (t.baselineShift) { return `${body}`; } return `${body}`; }