// Rich-Text-Dokumentmodell — die wiederverwendbare Grundlage für TextEntity- // Annotationen (Text-/Stempel-Bauteil). Bewusst frei von App-Store und React: // ein reines Datenmodell + Operationen, unit-getestet. // // Aufbau (an DOSSIERs text_editor.py angelehnt: Runs mit text/font/bold/italic/ // underline/strike/sup/sub/color, Grössen in Punkt): // // RichTextDoc = { version, paragraphs: Paragraph[] } // Paragraph = { runs: TextRun[], align? } // TextRun = { text: string, marks: Marks } // Marks = { bold?, italic?, underline?, strike?, super?, sub?, // font?, sizePt?, color? } // // Absätze trennen Zeilen (harte Umbrüche); innerhalb eines Absatzes tragen die // Runs die Zeichenformatierung. Ein „Zeichen-Offset" adressiert das Dokument // linear über alle Absätze hinweg, wobei jeder Absatzumbruch als EIN Zeichen // zählt (wie „\n"). Das erlaubt Bereichs-Operationen (applyMark/toggleMark) über // Absatzgrenzen hinweg. /** Zeichen-Marks eines Runs. Alle optional; fehlend = „nicht gesetzt". */ export interface Marks { bold?: boolean; italic?: boolean; underline?: boolean; strike?: boolean; /** Hochgestellt. super und sub schliessen sich gegenseitig aus. */ super?: boolean; /** Tiefgestellt. */ sub?: boolean; /** Schriftfamilie (CSS font-family-Wert), z. B. "Helvetica". */ font?: string; /** Schriftgrösse in typografischen Punkt (pt). */ sizePt?: number; /** Farbe als "#rrggbb". */ color?: string; } /** Ein zusammenhängender Textabschnitt mit einheitlicher Formatierung. */ export interface TextRun { text: string; marks: Marks; } /** Absatz-Ausrichtung. */ export type Align = "left" | "center" | "right"; /** Zeilenhöhe als Vielfaches der Basisgrösse, wenn nicht am Absatz gesetzt. */ export const DEFAULT_LINE_HEIGHT = 1.3; /** Ein Absatz: eine Zeile aus Runs plus optionale Ausrichtung/Zeilenhöhe. */ export interface Paragraph { runs: TextRun[]; align?: Align; /** Zeilenhöhe als Vielfaches der Basisgrösse (z. B. 1.3 = 130 %). */ lineHeight?: number; } /** Das gesamte Rich-Text-Dokument. */ export interface RichTextDoc { version: 1; paragraphs: Paragraph[]; } /** Namen der booleschen Marks (togglebar). */ export type BoolMark = "bold" | "italic" | "underline" | "strike" | "super" | "sub"; /** Ein Zeichen-Bereich [start, end) im linearen Dokument-Offset. */ export interface TextRange { start: number; end: number; } // ── Konstruktoren ────────────────────────────────────────────────────────── /** Leeres Dokument (ein leerer Absatz). */ export function emptyDoc(): RichTextDoc { return { version: 1, paragraphs: [{ runs: [] }] }; } /** * Dokument aus reinem Text. Zeilenumbrüche ("\n") werden zu Absätzen; die * übergebenen Marks gelten für den ganzen Text. */ export function docFromText(text: string, marks: Marks = {}): RichTextDoc { const lines = text.split("\n"); const paragraphs: Paragraph[] = lines.map((line) => ({ runs: line.length ? [{ text: line, marks: { ...marks } }] : [], })); return { version: 1, paragraphs }; } /** Erzeugt einen Run (defensive Kopie der Marks). */ export function makeRun(text: string, marks: Marks = {}): TextRun { return { text, marks: { ...marks } }; } // ── Extraktion ───────────────────────────────────────────────────────────── /** Reintext des Dokuments; Absätze werden mit "\n" verbunden. */ export function plainText(doc: RichTextDoc): string { return doc.paragraphs .map((p) => p.runs.map((r) => r.text).join("")) .join("\n"); } /** Ist das Dokument (nach Reintext) leer? */ export function isEmpty(doc: RichTextDoc): boolean { return plainText(doc).length === 0; } /** Gesamtlänge in Zeichen (inkl. Absatzumbrüche als je 1 Zeichen). */ export function docLength(doc: RichTextDoc): number { return plainText(doc).length; } // ── Marks-Vergleich / Normalisierung ─────────────────────────────────────── const MARK_KEYS: (keyof Marks)[] = [ "bold", "italic", "underline", "strike", "super", "sub", "font", "sizePt", "color", ]; /** Normalisiert Marks: entfernt „falsy"/leere Einträge, damit Vergleiche stabil sind. */ export function normalizeMarks(marks: Marks): Marks { const out: Marks = {}; for (const key of MARK_KEYS) { const value = marks[key]; if (value === undefined || value === false || value === null) continue; if ((key === "font" || key === "color") && value === "") continue; // super und sub sind exklusiv — sub gewinnt nicht über super, wir behalten beide // Werte nur wenn true; Auflösung geschieht bei toggleMark. (out as Record)[key] = value; } return out; } /** Strukturvergleich zweier Marks-Objekte (nach Normalisierung). */ export function marksEqual(a: Marks, b: Marks): boolean { const na = normalizeMarks(a); const nb = normalizeMarks(b); for (const key of MARK_KEYS) { if ((na as Record)[key] !== (nb as Record)[key]) { return false; } } return true; } /** * Führt benachbarte Runs mit gleichen Marks zusammen und entfernt leere Runs. * Idempotent; hält das Dokument nach Editieroperationen kompakt. */ export function normalizeDoc(doc: RichTextDoc): RichTextDoc { const paragraphs: Paragraph[] = doc.paragraphs.map((p) => { const merged: TextRun[] = []; for (const run of p.runs) { if (run.text.length === 0) continue; const norm = { text: run.text, marks: normalizeMarks(run.marks) }; const last = merged[merged.length - 1]; if (last && marksEqual(last.marks, norm.marks)) { last.text += norm.text; } else { merged.push(norm); } } const out: Paragraph = { runs: merged }; if (p.align && p.align !== "left") out.align = p.align; return out; }); if (paragraphs.length === 0) paragraphs.push({ runs: [] }); return { version: 1, paragraphs }; } // ── Bereichs-Adressierung (linearer Offset ↔ Absatz/Run) ──────────────────── interface ParaSpan { index: number; /** Start-Offset dieses Absatzes im linearen Dokument. */ start: number; /** End-Offset (exklusive Umbruch) = start + Reintextlänge des Absatzes. */ end: number; } /** Berechnet Start/End-Offsets je Absatz. */ function paragraphSpans(doc: RichTextDoc): ParaSpan[] { const spans: ParaSpan[] = []; let cursor = 0; doc.paragraphs.forEach((p, index) => { const len = p.runs.reduce((n, r) => n + r.text.length, 0); spans.push({ index, start: cursor, end: cursor + len }); cursor += len + 1; // +1 für den Absatzumbruch }); return spans; } /** * Wendet `fn` auf jeden Run an, der (ganz oder teilweise) im Bereich [start,end) * liegt; teilt Runs an den Bereichsgrenzen auf. Gibt ein neues, normalisiertes * Dokument zurück. Die Callback bekommt die aktuellen Marks des Teil-Runs und * liefert die neuen Marks. */ export function mapRunsInRange( doc: RichTextDoc, range: TextRange, fn: (marks: Marks) => Marks, ): RichTextDoc { const start = Math.max(0, Math.min(range.start, range.end)); const end = Math.max(range.start, range.end); if (start === end) return normalizeDoc(doc); const spans = paragraphSpans(doc); const paragraphs: Paragraph[] = doc.paragraphs.map((p, pIdx) => { const span = spans[pIdx]; // Absatz komplett ausserhalb des Bereichs? if (span.end <= start || span.start >= end) return p; const newRuns: TextRun[] = []; let runStart = span.start; for (const run of p.runs) { const runEnd = runStart + run.text.length; const selStart = Math.max(start, runStart); const selEnd = Math.min(end, runEnd); if (selEnd <= selStart) { // Run ausserhalb — unverändert übernehmen. newRuns.push(run); } else { const relStart = selStart - runStart; const relEnd = selEnd - runStart; if (relStart > 0) { newRuns.push(makeRun(run.text.slice(0, relStart), run.marks)); } newRuns.push(makeRun(run.text.slice(relStart, relEnd), fn(run.marks))); if (relEnd < run.text.length) { newRuns.push(makeRun(run.text.slice(relEnd), run.marks)); } } runStart = runEnd; } return { ...p, runs: newRuns }; }); return normalizeDoc({ version: 1, paragraphs }); } // ── Mark-Operationen ─────────────────────────────────────────────────────── /** * Setzt ein Mark auf einen Bereich. `value` bei boolschen Marks true/false; * bei font/sizePt/color der jeweilige Wert (oder undefined zum Entfernen). * super/sub werden exklusiv gehalten. */ export function applyMark( doc: RichTextDoc, range: TextRange, mark: K, value: Marks[K] | undefined, ): RichTextDoc { return mapRunsInRange(doc, range, (marks) => { const next: Marks = { ...marks }; if (value === undefined || value === false) { delete next[mark]; } else { (next as Record)[mark] = value; if (mark === "super" && value) delete next.sub; if (mark === "sub" && value) delete next.super; } return next; }); } /** * Ist ein boolsches Mark über den GESAMTEN Bereich aktiv? (Für den * Toolbar-Zustand: „Fett"-Knopf gedrückt, wenn die ganze Auswahl fett ist.) */ export function isMarkActive(doc: RichTextDoc, range: TextRange, mark: BoolMark): boolean { const start = Math.max(0, Math.min(range.start, range.end)); const end = Math.max(range.start, range.end); if (start === end) return false; const spans = paragraphSpans(doc); let sawAny = false; for (let pIdx = 0; pIdx < doc.paragraphs.length; pIdx++) { const span = spans[pIdx]; if (span.end <= start || span.start >= end) continue; let runStart = span.start; for (const run of doc.paragraphs[pIdx].runs) { const runEnd = runStart + run.text.length; const selStart = Math.max(start, runStart); const selEnd = Math.min(end, runEnd); if (selEnd > selStart) { sawAny = true; if (!run.marks[mark]) return false; } runStart = runEnd; } } return sawAny; } /** * Schaltet ein boolsches Mark über den Bereich um: ist es überall aktiv → * entfernen, sonst überall setzen. */ export function toggleMark(doc: RichTextDoc, range: TextRange, mark: BoolMark): RichTextDoc { const active = isMarkActive(doc, range, mark); return applyMark(doc, range, mark, active ? undefined : true); } /** * Liefert den gemeinsamen Wert eines Nicht-Bool-Marks über den Bereich, oder * undefined wenn uneinheitlich/leer. (Für Toolbar-Anzeige von Grösse/Farbe/Font.) */ export function commonMarkValue( doc: RichTextDoc, range: TextRange, mark: K, ): Marks[K] | undefined { const start = Math.max(0, Math.min(range.start, range.end)); const end = Math.max(range.start, range.end); if (start === end) return undefined; const spans = paragraphSpans(doc); let value: Marks[K] | undefined; let first = true; for (let pIdx = 0; pIdx < doc.paragraphs.length; pIdx++) { const span = spans[pIdx]; if (span.end <= start || span.start >= end) continue; let runStart = span.start; for (const run of doc.paragraphs[pIdx].runs) { const runEnd = runStart + run.text.length; const selStart = Math.max(start, runStart); const selEnd = Math.min(end, runEnd); if (selEnd > selStart) { const v = run.marks[mark]; if (first) { value = v; first = false; } else if (v !== value) { return undefined; } } runStart = runEnd; } } return value; } // ── Serialisierung (stabiles JSON) ───────────────────────────────────────── /** * Stabile JSON-Serialisierung: Schlüssel in fester Reihenfolge, damit gleiche * Dokumente denselben String ergeben (Diff-/Test-freundlich). */ export function serialize(doc: RichTextDoc): string { const norm = normalizeDoc(doc); const paragraphs = norm.paragraphs.map((p) => { const runs = p.runs.map((r) => { const marks: Record = {}; for (const key of MARK_KEYS) { const v = (r.marks as Record)[key]; if (v !== undefined) marks[key] = v; } return { text: r.text, marks }; }); const obj: Record = { runs }; if (p.align && p.align !== "left") obj.align = p.align; return obj; }); return JSON.stringify({ version: 1, paragraphs }); } /** * Liest ein Dokument aus JSON. Robust gegenüber fehlenden Feldern; unbekannter * Input ergibt ein leeres Dokument statt eines Fehlers. */ export function deserialize(json: string): RichTextDoc { try { const raw = JSON.parse(json) as unknown; return fromJson(raw); } catch { return emptyDoc(); } } /** Wandelt ein bereits geparstes Objekt in ein valides Dokument. */ export function fromJson(raw: unknown): RichTextDoc { if (!raw || typeof raw !== "object") return emptyDoc(); const obj = raw as { paragraphs?: unknown }; if (!Array.isArray(obj.paragraphs)) return emptyDoc(); const paragraphs: Paragraph[] = obj.paragraphs.map((p) => { const pp = (p ?? {}) as { runs?: unknown; align?: unknown }; const runs: TextRun[] = Array.isArray(pp.runs) ? pp.runs.map((r) => { const rr = (r ?? {}) as { text?: unknown; marks?: unknown }; const text = typeof rr.text === "string" ? rr.text : ""; const marks = normalizeMarks((rr.marks ?? {}) as Marks); return { text, marks }; }) : []; const para: Paragraph = { runs }; if (pp.align === "center" || pp.align === "right") para.align = pp.align; return para; }); return normalizeDoc({ version: 1, paragraphs }); } // ── Style-Presets ────────────────────────────────────────────────────────── /** Ein benannter Stil (Marks-Vorlage), z. B. „Titel". */ export interface TextStylePreset { /** Stabiler Schlüssel (englisch), z. B. "title". */ id: string; /** Anzeigename (bereits übersetzt oder Schlüssel für t()). */ label: string; /** Marks, die der Stil auf die Auswahl (oder das ganze Dokument) legt. */ marks: Marks; } /** * Standard-Presets, an DOSSIERs Textstile angelehnt. Grössen in pt; die * SVG-/Canvas-Renderer rechnen pt → Meter/px selbst um. */ export const DEFAULT_PRESETS: TextStylePreset[] = [ { id: "title", label: "Titel", marks: { bold: true, sizePt: 18 } }, { id: "subtitle", label: "Untertitel", marks: { italic: true, sizePt: 13 } }, { id: "label", label: "Beschriftung", marks: { sizePt: 10 } }, { id: "note", label: "Notiz", marks: { italic: true, sizePt: 8, color: "#8a8580" } }, ]; /** * Wendet ein Preset auf einen Bereich an (setzt jede Mark des Presets). Ein * leerer Bereich wird auf das ganze Dokument angewendet. */ export function applyPreset( doc: RichTextDoc, range: TextRange, preset: TextStylePreset, ): RichTextDoc { const full: TextRange = range.start === range.end ? { start: 0, end: Math.max(1, docLength(doc)) } : range; let out = doc; for (const key of MARK_KEYS) { const v = (preset.marks as Record)[key]; if (v !== undefined) { out = applyMark(out, full, key as keyof Marks, v as Marks[keyof Marks]); } } return out; }