Compare commits

...
10 Commits
Author SHA1 Message Date
dualfroz edc0d7ff06 Describe this repository 2026-10-22 21:07:42 +02:00
dualfroz af172f8665 fix: keep non-breaking spaces in text
Since the `u` flag was added to the whitespace regex in TextConverter
(7f03acf), `\s` also matches U+00A0, so ` ` and literal non-breaking
spaces were turned into regular spaces. Leave U+00A0 out of the collapse.

EmphasisConverter used trim(), which does not strip U+00A0, so a
non-breaking space at the edge of <em>/<strong> would now end up inside
the delimiters (`**Total:&nbsp;**`), where it stops them from flanking the
text. Strip it there too; like before, it becomes a regular space outside
the delimiters.

Fixes #258
2026-10-03 02:31:49 +02:00
upstream 87f03af20c Upstream thephpleague/html-to-markdown state of the touched files before aa29149d30 2026-10-03 02:31:49 +02:00
dualfroz 94062cea69 fix: use autolinks for URLs that contain an ampersand
The link text goes through TextConverter, which escapes `&` to `&amp;`,
while the href is the decoded attribute value, so `$href === $text` never
held for a URL with a query string like `?a=1&b=2`. Undo that escaping
before comparing. Only `&amp;` is undone: `<` and `>` cannot appear in an
autolink, so those links keep the inline form.

Fixes #248
2026-10-03 02:27:25 +02:00
upstream a82cc3b9b6 Upstream thephpleague/html-to-markdown state of the touched files before f345fb4bc5 2026-10-03 02:27:25 +02:00
dualfroz 1bb05d0606 fix: separate block elements from the inline content before them
An image or link right before a heading or paragraph ended up on the
same line, e.g. `![](a.png)### A`.

Fixes #259
2026-10-02 16:19:40 +02:00
upstream 6b7a074e8d Upstream thephpleague/html-to-markdown state of the touched files before 7af7830eab 2026-10-02 16:19:40 +02:00
dualfroz 806387f3f6 fix: strip the newline right after the <pre> start tag
Fixes #244
2026-10-02 16:19:40 +02:00
upstream 710bc5b8ca Upstream thephpleague/html-to-markdown state of the touched files before e793ef24d6 2026-10-02 16:19:40 +02:00
dualfroz f194c36ae3 fix: honor start="0" on ordered lists (#277) 2026-09-07 09:42:24 -04:00
8 changed files with 590 additions and 2 deletions

No files matched your search

+10
View File
@@ -0,0 +1,10 @@
# Contributions to thephpleague/html-to-markdown
Convert HTML to Markdown with PHP
This repository holds only my commits to the upstream project, with their original author, date and diff.
Each of my commits is preceded by a snapshot of the upstream files it touched, so every diff shows exactly what I changed.
## Merged pull requests
- [fix: honor start="0" on ordered lists](https://github.com/thephpleague/html-to-markdown/pull/277) (2026-09-07)
+77
View File
@@ -0,0 +1,77 @@
<?php
declare(strict_types=1);
namespace League\HTMLToMarkdown\Converter;
use League\HTMLToMarkdown\Coerce;
use League\HTMLToMarkdown\Configuration;
use League\HTMLToMarkdown\ConfigurationAwareInterface;
use League\HTMLToMarkdown\ElementInterface;
class EmphasisConverter implements ConverterInterface, ConfigurationAwareInterface
{
/** @var Configuration */
protected $config;
protected function getNormTag(?ElementInterface $element): string
{
if ($element !== null && ! $element->isText()) {
$tag = $element->getTagName();
if ($tag === 'i' || $tag === 'em') {
return 'em';
}
if ($tag === 'b' || $tag === 'strong') {
return 'strong';
}
}
return '';
}
public function setConfig(Configuration $config): void
{
$this->config = $config;
}
public function convert(ElementInterface $element): string
{
$tag = $this->getNormTag($element);
$value = $element->getValue();
// Unlike trim(), this also strips non-breaking spaces, which would keep the delimiters from flanking the text
$content = \preg_replace('~^\s+|\s+$~u', '', $value);
\assert(\is_string($content));
if (! $content) {
return $value;
}
if ($tag === 'em') {
$style = Coerce::toString($this->config->getOption('italic_style'));
} else {
$style = Coerce::toString($this->config->getOption('bold_style'));
}
$prefix = \preg_match('~^\s~u', $value) === 1 ? ' ' : '';
$suffix = \preg_match('~\s$~u', $value) === 1 ? ' ' : '';
/* If this node is immediately preceded or followed by one of the same type don't emit
* the start or end $style, respectively. This prevents <em>foo</em><em>bar</em> from
* being converted to *foo**bar* which is incorrect. We want *foobar* instead.
*/
$preStyle = $this->getNormTag($element->getPreviousSibling()) === $tag ? '' : $style;
$postStyle = $this->getNormTag($element->getNextSibling()) === $tag ? '' : $style;
return $prefix . $preStyle . $content . $postStyle . $suffix;
}
/**
* @return string[]
*/
public function getSupportedTags(): array
{
return ['em', 'i', 'strong', 'b'];
}
}
+77
View File
@@ -0,0 +1,77 @@
<?php
declare(strict_types=1);
namespace League\HTMLToMarkdown\Converter;
use League\HTMLToMarkdown\Configuration;
use League\HTMLToMarkdown\ConfigurationAwareInterface;
use League\HTMLToMarkdown\ElementInterface;
class LinkConverter implements ConverterInterface, ConfigurationAwareInterface
{
/** @var Configuration */
protected $config;
public function setConfig(Configuration $config): void
{
$this->config = $config;
}
public function convert(ElementInterface $element): string
{
$href = $element->getAttribute('href');
$title = $element->getAttribute('title');
$text = \trim($element->getValue(), "\t\n\r\0\x0B");
if ($title !== '') {
$markdown = '[' . $text . '](' . $href . ' "' . $title . '")';
} elseif ($href === \str_replace('&amp;', '&', $text) && $this->isValidAutolink($href)) {
$markdown = '<' . $href . '>';
} elseif ($href === 'mailto:' . $text && $this->isValidEmail($text)) {
$markdown = '<' . $text . '>';
} else {
if (\stristr($href, ' ')) {
$href = '<' . $href . '>';
}
$markdown = '[' . $text . '](' . $href . ')';
}
if (! $href) {
if ($this->shouldStrip()) {
$markdown = $text;
} else {
$markdown = \html_entity_decode($element->getChildrenAsString());
}
}
return $markdown;
}
/**
* @return string[]
*/
public function getSupportedTags(): array
{
return ['a'];
}
private function isValidAutolink(string $href): bool
{
$useAutolinks = $this->config->getOption('use_autolinks');
return $useAutolinks && (\preg_match('/^[A-Za-z][A-Za-z0-9.+-]{1,31}:[^<>\x00-\x20]*/i', $href) === 1);
}
private function isValidEmail(string $email): bool
{
// Email validation is messy business, but this should cover most cases
return \filter_var($email, FILTER_VALIDATE_EMAIL) !== false;
}
private function shouldStrip(): bool
{
return \boolval($this->config->getOption('strip_placeholder_links') ?? false);
}
}
+2 -2
View File
@@ -52,8 +52,8 @@ class ListItemConverter implements ConverterInterface, ConfigurationAwareInterfa
return $prefix . $this->listItemStyle . ' ' . $value . "\n";
}
if ($listType === 'ol' && ($parent = $element->getParent()) && ($start = \intval($parent->getAttribute('start')))) {
$number = $start + $element->getSiblingPosition() - 1;
if ($listType === 'ol' && ($parent = $element->getParent()) && \is_numeric($start = $parent->getAttribute('start'))) {
$number = (int) $start + $element->getSiblingPosition() - 1;
} else {
$number = $element->getSiblingPosition();
}
+62
View File
@@ -0,0 +1,62 @@
<?php
declare(strict_types=1);
namespace League\HTMLToMarkdown\Converter;
use League\HTMLToMarkdown\ElementInterface;
class PreformattedConverter implements ConverterInterface
{
public function convert(ElementInterface $element): string
{
$preContent = \html_entity_decode($element->getChildrenAsString());
$preContent = \preg_replace('/<pre\b[^>]*>/', '', $preContent);
\assert($preContent !== null);
$preContent = \str_replace('</pre>', '', $preContent);
// A newline right after the <pre> start tag is not part of the content (HTML spec), but the parser keeps it
$preContent = \preg_replace('/^(?:\r\n|\r|\n)/', '', $preContent);
\assert($preContent !== null);
/*
* Checking for the code tag.
* Usually pre tags are used along with code tags. This conditional will check for already converted code tags,
* which use backticks, and if those backticks are at the beginning and at the end of the string it means
* there's no more information to convert.
*/
$firstBacktick = \strpos(\trim($preContent), '`');
$lastBacktick = \strrpos(\trim($preContent), '`');
if ($firstBacktick === 0 && $lastBacktick === \strlen(\trim($preContent)) - 1) {
return $preContent . "\n\n";
}
// If the execution reaches this point it means it's just a pre tag, with no code tag nested
// Empty lines are a special case
if ($preContent === '') {
return "```\n```\n\n";
}
// Normalizing new lines
$preContent = \preg_replace('/\r\n|\r|\n/', "\n", $preContent);
\assert(\is_string($preContent));
// Ensure there's a newline at the end
if (\strrpos($preContent, "\n") !== \strlen($preContent) - \strlen("\n")) {
$preContent .= "\n";
}
// Use three backticks
return "```\n" . $preContent . "```\n\n";
}
/**
* @return string[]
*/
public function getSupportedTags(): array
{
return ['pre'];
}
}
+48
View File
@@ -0,0 +1,48 @@
<?php
declare(strict_types=1);
namespace League\HTMLToMarkdown\Converter;
use League\HTMLToMarkdown\ElementInterface;
class TextConverter implements ConverterInterface
{
public function convert(ElementInterface $element): string
{
$markdown = $element->getValue();
// Remove leftover \n at the beginning of the line
$markdown = \ltrim($markdown, "\n");
// Replace sequences of invisible characters with spaces, keeping non-breaking spaces
$markdown = \preg_replace('~[^\S\x{00A0}]+~u', ' ', $markdown);
\assert(\is_string($markdown));
// Escape the following characters: '*', '_', '[', ']' and '\'
if (($parent = $element->getParent()) && $parent->getTagName() !== 'div') {
$markdown = \preg_replace('~([*_\\[\\]\\\\])~u', '\\\\$1', $markdown);
\assert(\is_string($markdown));
}
$markdown = \preg_replace('~^#~u', '\\\\#', $markdown);
\assert(\is_string($markdown));
if ($markdown === ' ') {
$next = $element->getNext();
if (! $next || $next->isBlock()) {
$markdown = '';
}
}
return \htmlspecialchars($markdown, ENT_NOQUOTES, 'UTF-8');
}
/**
* @return string[]
*/
public function getSupportedTags(): array
{
return ['#text'];
}
}
+305
View File
@@ -0,0 +1,305 @@
<?php
declare(strict_types=1);
namespace League\HTMLToMarkdown;
/**
* A helper class to convert HTML to Markdown.
*
* @author Colin O'Dell <colinodell@gmail.com>
* @author Nick Cernis <nick@cern.is>
*
* @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
{
/**
* Blocks which start a new paragraph when they come right after inline content
*/
private const SEPARATED_BLOCK_TAGS = ['blockquote', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'hr', 'p', 'pre'];
/** @var Environment */
protected $environment;
/**
* Constructor
*
* @param Environment|array<string, mixed> $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 <a> 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 <br> into `\n` instead of ` \n`
'list_item_style' => '-', // Set the default character for each <li> in a <ul>. 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 <caption> 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('<?xml encoding="UTF-8">' . $html);
$document->encoding = 'UTF-8';
$this->replaceMisplacedComments($document);
if ($this->getConfig()->getOption('suppress_errors')) {
\libxml_clear_errors();
}
return $document;
}
/**
* Finds any comment nodes outside <html> element and moves them into <body>.
*
* @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 <body> 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.
*
* @param bool $followsInlineContent Whether the element comes right after inline content, like an image, on the same line
*
* @return string The Markdown the element was replaced with
*/
private function convertChildren(ElementInterface $element, bool $followsInlineContent = false): string
{
// Don't convert HTML code inside <code> and <pre> 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()) {
$childFollowsInlineContent = false;
foreach ($element->getChildren() as $child) {
$childMarkdown = $this->convertChildren($child, $childFollowsInlineContent);
if ($child->getTagName() === '#comment' || \trim($childMarkdown, ' ') === '') {
continue;
}
$childFollowsInlineContent = ! $child->isBlock() && \substr(\rtrim($childMarkdown, ' '), -1) !== "\n";
}
}
// Now that child nodes have been converted, convert the original node
$markdown = $this->convertToMarkdown($element);
// These blocks only add newlines after themselves, so one that follows inline content, like an image, needs them before it too
if ($followsInlineContent && \in_array($element->getTagName(), self::SEPARATED_BLOCK_TAGS, true) && \trim($markdown) !== '') {
$markdown = "\n\n" . $markdown;
}
// Create a DOM text node containing the Markdown equivalent of the original node
// Replace the old $node e.g. '<h3>Title</h3>' with the new $markdown_node e.g. '### Title'
$element->setFinalMarkdown($markdown);
return $markdown;
}
/**
* Convert to Markdown
*
* Converts an individual node into a #text node containing a string of its Markdown equivalent.
*
* Example: An <h3> 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('/<!DOCTYPE [^>]+>/', '', $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 = ['<?xml encoding="UTF-8">', '<html>', '</html>', '<body>', '</body>', '<head>', '</head>', '&#xD;'];
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('<h1>test</h1>');
*
* @param array<string, mixed> $options
*
* @return $this
*/
public function setOptions(array $options)
{
$config = $this->getConfig();
foreach ($options as $key => $option) {
$config->setOption($key, $option);
}
return $this;
}
}
+9
View File
@@ -192,6 +192,7 @@ class HtmlConverterTest extends TestCase
$this->assertHtmlGivesMarkdown('<ol><li> Item A</li><li> Item B</li></ol>', "1. Item A\n2. Item B");
$this->assertHtmlGivesMarkdown('<ol><li> <h3> Item A</h3><p>Description</p></li><li> Item B</li></ol>', "1. ### Item A\n \n Description\n2. Item B");
$this->assertHtmlGivesMarkdown('<ol start="120"><li>Item A</li><li>Item B</li></ol>', "120. Item A\n121. Item B");
$this->assertHtmlGivesMarkdown('<ol start="0"><li>Item A</li><li>Item B</li></ol>', "0. Item A\n1. Item B");
$this->assertHtmlGivesMarkdown('<ul><li>first item of first list</li><li>second item of first list</li></ul><ul><li>first item of second list</li></ul>', "- first item of first list\n- second item of first list\n\n* first item of second list", ['list_item_style_alternate' => '*']);
}
@@ -377,6 +378,14 @@ EOT;
$this->assertHtmlGivesMarkdown('<code>&lt;p&gt;Some sample HTML&lt;/p&gt;</code>', '`<p>Some sample HTML</p>`');
}
public function testNonBreakingSpaces(): void
{
$this->assertHtmlGivesMarkdown('<p>This is a test and&nbsp;test</p>', "This is a test and\u{00A0}test");
$this->assertHtmlGivesMarkdown("<p>12,50\u{00A0}\u{00A0}€ \n total</p>", "12,50\u{00A0}\u{00A0}€ total");
$this->assertHtmlGivesMarkdown('<p><strong>Total:&nbsp;</strong>12,50&nbsp;€</p>', "**Total:** 12,50\u{00A0}€");
$this->assertHtmlGivesMarkdown('<p>a<em>&nbsp;</em>b</p>', "a\u{00A0}b");
}
public function testSetOption(): void
{
$markdown = new HtmlConverter();