From 6b7a074e8db3b198f609cc55acfdff50c436cae6 Mon Sep 17 00:00:00 2001 From: upstream Date: Fri, 2 Oct 2026 16:19:40 +0200 Subject: [PATCH] Upstream thephpleague/html-to-markdown state of the touched files before 7af7830eab --- src/HtmlConverter.php | 283 ++++++++++++++++++++++++++++++++++++ tests/HtmlConverterTest.php | 14 +- 2 files changed, 289 insertions(+), 8 deletions(-) create mode 100644 src/HtmlConverter.php diff --git a/src/HtmlConverter.php b/src/HtmlConverter.php new file mode 100644 index 0000000..5ce8e5b --- /dev/null +++ b/src/HtmlConverter.php @@ -0,0 +1,283 @@ + + * @author Nick Cernis + * + * @link https://github.com/thephpleague/html-to-markdown/ Latest version on GitHub. + * + * @license http://www.opensource.org/licenses/mit-license.php MIT + */ +class HtmlConverter implements HtmlConverterInterface +{ + /** @var Environment */ + protected $environment; + + /** + * Constructor + * + * @param Environment|array $options Environment object or configuration options + */ + public function __construct($options = []) + { + if ($options instanceof Environment) { + $this->environment = $options; + } elseif (\is_array($options)) { + $defaults = [ + 'header_style' => 'setext', // Set to 'atx' to output H1 and H2 headers as # Header1 and ## Header2 + 'suppress_errors' => true, // Set to false to show warnings when loading malformed HTML + 'strip_tags' => false, // Set to true to strip tags that don't have markdown equivalents. N.B. Strips tags, not their content. Useful to clean MS Word HTML output. + 'strip_placeholder_links' => false, // Set to true to remove that doesn't have href. + 'bold_style' => '**', // DEPRECATED: Set to '__' if you prefer the underlined style + 'italic_style' => '*', // DEPRECATED: Set to '_' if you prefer the underlined style + 'remove_nodes' => '', // space-separated list of dom nodes that should be removed. example: 'meta style script' + 'hard_break' => false, // Set to true to turn
into `\n` instead of ` \n` + 'list_item_style' => '-', // Set the default character for each
  • in a
      . Can be '-', '*', or '+' + 'preserve_comments' => false, // Set to true to preserve comments, or set to an array of strings to preserve specific comments + 'use_autolinks' => true, // Set to true to use simple link syntax if possible. Will always use []() if set to false + 'table_pipe_escape' => '\|', // Replacement string for pipe characters inside markdown table cells + 'table_caption_side' => 'top', // Set to 'top' or 'bottom' to show content before or after table, null to suppress + ]; + + $this->environment = Environment::createDefaultEnvironment($defaults); + + $this->environment->getConfig()->merge($options); + } + } + + public function getEnvironment(): Environment + { + return $this->environment; + } + + public function getConfig(): Configuration + { + return $this->environment->getConfig(); + } + + /** + * Convert + * + * @see HtmlConverter::convert + * + * @return string The Markdown version of the html + */ + public function __invoke(string $html): string + { + return $this->convert($html); + } + + /** + * Convert + * + * Loads HTML and passes to getMarkdown() + * + * @return string The Markdown version of the html + * + * @throws \InvalidArgumentException|\RuntimeException + */ + public function convert(string $html): string + { + if (\trim($html) === '') { + return ''; + } + + $document = $this->createDOMDocument($html); + + // Work on the entire DOM tree (including head and body) + if (! ($root = $document->getElementsByTagName('html')->item(0))) { + throw new \InvalidArgumentException('Invalid HTML was provided'); + } + + $rootElement = new Element($root); + $this->convertChildren($rootElement); + + // Store the now-modified DOMDocument as a string + $markdown = $document->saveHTML(); + + if ($markdown === false) { + throw new \RuntimeException('Unknown error occurred during HTML to Markdown conversion'); + } + + return $this->sanitize($markdown); + } + + private function createDOMDocument(string $html): \DOMDocument + { + $document = new \DOMDocument(); + + if ($this->getConfig()->getOption('suppress_errors')) { + // Suppress conversion errors (from http://bit.ly/pCCRSX) + \libxml_use_internal_errors(true); + } + + // Hack to load utf-8 HTML (from http://bit.ly/pVDyCt) + $document->loadHTML('' . $html); + $document->encoding = 'UTF-8'; + + $this->replaceMisplacedComments($document); + + if ($this->getConfig()->getOption('suppress_errors')) { + \libxml_clear_errors(); + } + + return $document; + } + + /** + * Finds any comment nodes outside element and moves them into . + * + * @see https://github.com/thephpleague/html-to-markdown/issues/212 + * @see https://3v4l.org/7bC33 + */ + private function replaceMisplacedComments(\DOMDocument $document): void + { + // Find ny comment nodes at the root of the document. + $misplacedComments = (new \DOMXPath($document))->query('/comment()'); + if ($misplacedComments === false) { + return; + } + + $body = $document->getElementsByTagName('body')->item(0); + if ($body === null) { + return; + } + + // Loop over comment nodes in reverse so we put them inside in + // their original order. + for ($index = $misplacedComments->length - 1; $index >= 0; $index--) { + // DOMNameSpaceNode, which item() can also return, isn't a DOMNode and can't be inserted. + $comment = $misplacedComments->item($index); + if (! $comment instanceof \DOMNode) { + continue; + } + + if ($body->firstChild === null) { + $body->insertBefore($comment); + } else { + $body->insertBefore($comment, $body->firstChild); + } + } + } + + /** + * Convert Children + * + * Recursive function to drill into the DOM and convert each node into Markdown from the inside out. + * + * Finds children of each node and convert those to #text nodes containing their Markdown equivalent, + * starting with the innermost element and working up to the outermost element. + */ + private function convertChildren(ElementInterface $element): void + { + // Don't convert HTML code inside and
       blocks to Markdown - that should stay as HTML
      +        // except if the current node is a code tag, which needs to be converted by the CodeConverter.
      +        if ($element->isDescendantOf(['pre', 'code']) && $element->getTagName() !== 'code') {
      +            return;
      +        }
      +
      +        // Give converter a chance to inspect/modify the DOM before children are converted
      +        $converter = $this->environment->getConverterByTag($element->getTagName());
      +        if ($converter instanceof PreConverterInterface) {
      +            $converter->preConvert($element);
      +        }
      +
      +        // If the node has children, convert those to Markdown first
      +        if ($element->hasChildren()) {
      +            foreach ($element->getChildren() as $child) {
      +                $this->convertChildren($child);
      +            }
      +        }
      +
      +        // Now that child nodes have been converted, convert the original node
      +        $markdown = $this->convertToMarkdown($element);
      +
      +        // Create a DOM text node containing the Markdown equivalent of the original node
      +
      +        // Replace the old $node e.g. '

      Title

      ' with the new $markdown_node e.g. '### Title' + $element->setFinalMarkdown($markdown); + } + + /** + * Convert to Markdown + * + * Converts an individual node into a #text node containing a string of its Markdown equivalent. + * + * Example: An

      node with text content of 'Title' becomes a text node with content of '### Title' + * + * @return string The converted HTML as Markdown + */ + protected function convertToMarkdown(ElementInterface $element): string + { + $tag = $element->getTagName(); + + // Strip nodes named in remove_nodes + $tagsToRemove = \explode(' ', Coerce::toString($this->getConfig()->getOption('remove_nodes') ?? '')); + if (\in_array($tag, $tagsToRemove, true)) { + return ''; + } + + $converter = $this->environment->getConverterByTag($tag); + + return $converter->convert($element); + } + + protected function sanitize(string $markdown): string + { + $markdown = \html_entity_decode($markdown, ENT_QUOTES, 'UTF-8'); + $markdown = \preg_replace('/]+>/', '', $markdown); // Strip doctype declaration + \assert($markdown !== null); + $markdown = \trim($markdown); // Remove blank spaces at the beggining of the html + + /* + * Removing unwanted tags. Tags should be added to the array in the order they are expected. + * XML, html and body opening tags should be in that order. Same case with closing tags + */ + $unwanted = ['', '', '', '', '', '', '', ' ']; + + foreach ($unwanted as $tag) { + if (\strpos($tag, '/') === false) { + // Opening tags + if (\strpos($markdown, $tag) === 0) { + $markdown = \substr($markdown, \strlen($tag)); + } + } else { + // Closing tags + if (\strpos($markdown, $tag) === \strlen($markdown) - \strlen($tag)) { + $markdown = \substr($markdown, 0, -\strlen($tag)); + } + } + } + + return \trim($markdown, "\n\r\0\x0B"); + } + + /** + * Pass a series of key-value pairs in an array; these will be passed + * through the config and set. + * The advantage of this is that it can allow for static use (IE in Laravel). + * An example being: + * + * HtmlConverter::setOptions(['strip_tags' => true])->convert('

      test

      '); + * + * @param array $options + * + * @return $this + */ + public function setOptions(array $options) + { + $config = $this->getConfig(); + + foreach ($options as $key => $option) { + $config->setOption($key, $option); + } + + return $this; + } +} diff --git a/tests/HtmlConverterTest.php b/tests/HtmlConverterTest.php index 673bff1..e62247e 100644 --- a/tests/HtmlConverterTest.php +++ b/tests/HtmlConverterTest.php @@ -293,15 +293,13 @@ EOT; $this->assertHtmlGivesMarkdown('
        one line with spaces  
      ', "```\n one line with spaces \n```"); $this->assertHtmlGivesMarkdown('
      ', "```\n```");
               $this->assertHtmlGivesMarkdown('
      ', "```\n```\n\n```\n```");
      -        $this->assertHtmlGivesMarkdown("
      \n
      ", "```\n```"); + $this->assertHtmlGivesMarkdown("
      \n
      ", "```\n\n```"); $this->assertHtmlGivesMarkdown("
      foo\n
      ", "```\nfoo\n```"); - $this->assertHtmlGivesMarkdown("
      \nfoo
      ", "```\nfoo\n```"); - $this->assertHtmlGivesMarkdown("
      \nfoo\n
      ", "```\nfoo\n```"); - $this->assertHtmlGivesMarkdown("
      \r\nfoo
      ", "```\nfoo\n```"); - $this->assertHtmlGivesMarkdown("
      \n\nfoo
      ", "```\n\nfoo\n```"); - $this->assertHtmlGivesMarkdown("
      \n\n
      ", "```\n\n```"); - $this->assertHtmlGivesMarkdown("
      \n\n\n
      ", "```\n\n\n```"); - $this->assertHtmlGivesMarkdown("
      \n
      \n
      ", "```\n```\n\n```\n```"); + $this->assertHtmlGivesMarkdown("
      \nfoo
      ", "```\n\nfoo\n```"); + $this->assertHtmlGivesMarkdown("
      \nfoo\n
      ", "```\n\nfoo\n```"); + $this->assertHtmlGivesMarkdown("
      \n\n
      ", "```\n\n\n```"); + $this->assertHtmlGivesMarkdown("
      \n\n\n
      ", "```\n\n\n\n```"); + $this->assertHtmlGivesMarkdown("
      \n
      \n
      ", "```\n\n```\n\n```\n\n```"); $this->assertHtmlGivesMarkdown("
      one\ntwo\r\nthree
      \n

      line

      ", "```\none\ntwo\nthree\n```\n\nline"); $this->assertHtmlGivesMarkdown("
      test with attributes
      ", "```\ntest with attributes\n```"); }