Files
2026-09-22 13:14:06 +02:00

93 lines
4.1 KiB
JavaScript

// Wandelt Markdown in Pango-Markup für St.Label um.
// Unterstützt: Überschriften, Fett, Kursiv, Durchgestrichen, Code (inline und Block),
// Listen, Checkboxen, Zitate, Links, Trennlinien.
const CODE_BG = '#2b2b2b';
const LINK_COLOR = '#8ab4f8';
const QUOTE_COLOR = '#b0b0b0';
// Platzhalter für herausgenommene Code-Spans (Private-Use-Zeichen, kommt in Text nicht vor).
const HOLE = '';
export function escapeMarkup(text) {
return text.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
function inline(text) {
// Inline-Code zuerst herausnehmen, damit Sternchen darin unberührt bleiben.
const codes = [];
let out = escapeMarkup(text).replace(/`([^`]+)`/g, (_, code) => {
codes.push(`<span font_family="monospace" background="${CODE_BG}">${code}</span>`);
return `${HOLE}${codes.length - 1}${HOLE}`;
});
out = out
.replace(/!\[([^\]]*)\]\([^)]*\)/g, '[Bild: $1]')
.replace(/\[([^\]]+)\]\([^)]*\)/g, `<u><span foreground="${LINK_COLOR}">$1</span></u>`)
.replace(/\[\[([^\]]+)\]\]/g, `<u><span foreground="${LINK_COLOR}">$1</span></u>`)
.replace(/\*\*(.+?)\*\*/g, '<b>$1</b>')
.replace(/__(.+?)__/g, '<b>$1</b>')
.replace(/~~(.+?)~~/g, '<s>$1</s>')
.replace(/(^|[^*\w])\*([^*\s](?:[^*]*[^*\s])?)\*(?!\w)/g, '$1<i>$2</i>')
.replace(/(^|[^_\w])_([^_\s](?:[^_]*[^_\s])?)_(?!\w)/g, '$1<i>$2</i>');
return out.replace(new RegExp(`${HOLE}(\\d+)${HOLE}`, 'g'), (_, i) => codes[Number(i)]);
}
// Absolute Punktgrößen: Schlüsselwörter wie xx-large skalieren im ClutterText den ganzen Text.
const HEADING_SIZE = ['18pt', '15pt', '12.5pt', '11pt', '11pt', '11pt'];
export function renderMarkdown(text) {
const lines = text.replace(/\r\n/g, '\n').split('\n');
const out = [];
let inCode = false;
for (const line of lines) {
if (/^\s*```/.test(line)) {
inCode = !inCode;
continue;
}
if (inCode) {
out.push(`<span font_family="monospace" background="${CODE_BG}">${escapeMarkup(line) || ' '}</span>`);
continue;
}
let match;
if ((match = line.match(/^(#{1,6})\s+(.*)$/))) {
const level = match[1].length;
out.push(`<span size="${HEADING_SIZE[level - 1]}" weight="bold">${inline(match[2])}</span>`);
} else if (/^\s*([-*_])(\s*\1){2,}\s*$/.test(line)) {
out.push('<span foreground="#666666">────────────────────────────</span>');
} else if ((match = line.match(/^(\s*)[-*+]\s+\[([ xX])\]\s+(.*)$/))) {
const box = match[2] === ' ' ? '☐' : '☑';
const body = match[2] === ' ' ? inline(match[3]) : `<span foreground="#888888"><s>${inline(match[3])}</s></span>`;
out.push(`${indent(match[1])}${box} ${body}`);
} else if ((match = line.match(/^(\s*)[-*+]\s+(.*)$/))) {
out.push(`${indent(match[1])}• ${inline(match[2])}`);
} else if ((match = line.match(/^(\s*)(\d+)[.)]\s+(.*)$/))) {
out.push(`${indent(match[1])}${match[2]}. ${inline(match[3])}`);
} else if ((match = line.match(/^>\s?(.*)$/))) {
out.push(`<span foreground="${QUOTE_COLOR}">▎ <i>${inline(match[1])}</i></span>`);
} else {
out.push(inline(line));
}
}
return out.join('\n');
}
// Verschachtelte Listen: je 2 Leerzeichen oder 1 Tab eine Ebene tiefer.
function indent(spaces) {
const level = spaces.replace(/\t/g, ' ').length / 2;
return ' '.repeat(Math.floor(level) + 1);
}
// Was nach Enter am Ende dieser Zeile in der nächsten stehen soll.
// null: keine Liste. '': leerer Listenpunkt, der Marker soll weg. Sonst der Marker für den nächsten Punkt.
export function continueList(line) {
let m;
if ((m = line.match(/^(\s*)([-*+])\s+\[[ xX]\]\s+(.*)$/)))
return m[3].trim() ? `${m[1]}${m[2]} [ ] ` : '';
if ((m = line.match(/^(\s*)([-*+])\s+(.*)$/)))
return m[3].trim() ? `${m[1]}${m[2]} ` : '';
if ((m = line.match(/^(\s*)(\d+)([.)])\s+(.*)$/)))
return m[4].trim() ? `${m[1]}${Number(m[2]) + 1}${m[3]} ` : '';
return null;
}