← Code-Übersicht

HtmlToMarkdown.php

Pfad: src/Infrastructure/Content/HtmlToMarkdown.php

Ext: php

Größe: 3769 Bytes

Geändert: 2026-07-16 01:38:51+02

<?php
declare(strict_types=1);

namespace Demo\Infrastructure\Content;

use Demo\Domain\Dokumentation\HtmlKonverter;
use DOMDocument;
use DOMNode;

/**
 * Wandelt Editor-HTML in die erlaubte Markdown-Teilmenge zurueck.
 * Nur Whitelist-Elemente (h1-h6, p/div, ul/ol/li, strong/b) werden erzeugt;
 * alles andere liefert nur seinen Text, Script/Style werden verworfen.
 * Damit ist die Konvertierung zugleich die Sanitisierungs-Grenze.
 */
final class HtmlToMarkdown implements HtmlKonverter
{
    /**
     * Konvertiert HTML nach Markdown.
     *
     * @param string $html Rohes Editor-HTML.
     * @return string Bereinigtes Markdown.
     */
    public function convert(string $html): string
    {
        if (trim($html) === '') {
            return '';
        }
        $doc = new DOMDocument();
        libxml_use_internal_errors(true);
        $doc->loadHTML(
            '<?xml encoding="utf-8"?><html><body>' . $html . '</body></html>',
            LIBXML_NOERROR | LIBXML_NOWARNING
        );
        libxml_clear_errors();
        $body = $doc->getElementsByTagName('body')->item(0);
        if ($body === null) {
            return '';
        }
        $bloecke = [];
        foreach (iterator_to_array($body->childNodes) as $node) {
            $md = $this->block($node);
            if ($md !== '') {
                $bloecke[] = $md;
            }
        }

        return implode("\n\n", $bloecke);
    }

    /**
     * Wandelt einen Knoten auf Blockebene in eine Markdown-Zeile(n) um.
     *
     * @param DOMNode $node Blockknoten.
     * @return string Markdown-Block oder leerer String.
     */
    private function block(DOMNode $node): string
    {
        $tag = strtolower($node->nodeName);
        if ($tag === '#text') {
            return $this->inline($node);
        }
        if (preg_match('/^h([1-6])$/', $tag, $m) === 1) {
            return str_repeat('#', (int) $m[1]) . ' ' . $this->inline($node);
        }
        if ($tag === 'ul' || $tag === 'ol') {
            return $this->liste($node, $tag === 'ol');
        }
        if ($tag === 'script' || $tag === 'style') {
            return '';
        }

        return $this->inline($node);
    }

    /**
     * Rendert eine Liste; jedes li wird zu einer Markdown-Zeile.
     *
     * @param DOMNode $node      ul- oder ol-Knoten.
     * @param bool    $nummeriert True fuer ol.
     * @return string Mehrzeiliger Listen-Block.
     */
    private function liste(DOMNode $node, bool $nummeriert): string
    {
        $zeilen = [];
        $i = 1;
        foreach ($node->childNodes as $kind) {
            if (strtolower($kind->nodeName) !== 'li') {
                continue;
            }
            $text = $this->inline($kind);
            if ($text === '') {
                continue;
            }
            $zeilen[] = ($nummeriert ? $i . '. ' : '- ') . $text;
            $i++;
        }

        return implode("\n", $zeilen);
    }

    /**
     * Sammelt den Inline-Text eines Knotens; strong/b werden zu **fett**.
     *
     * @param DOMNode $node Knoten.
     * @return string Zusammengefasster, whitespace-normalisierter Text.
     */
    private function inline(DOMNode $node): string
    {
        $tag = strtolower($node->nodeName);
        if ($tag === '#text') {
            return (string) $node->nodeValue;
        }
        if ($tag === 'script' || $tag === 'style') {
            return '';
        }
        $inhalt = '';
        foreach ($node->childNodes as $kind) {
            $inhalt .= $this->inline($kind);
        }
        $inhalt = trim((string) preg_replace('/\s+/', ' ', $inhalt));
        if (($tag === 'strong' || $tag === 'b') && $inhalt !== '') {
            return '**' . $inhalt . '**';
        }

        return $inhalt;
    }
}