Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 13 additions & 4 deletions src/Node/Inline/HtmlEntityNode.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,20 @@
use PhpMarkdown\Node\InlineNodeInterface;

/**
* A valid HTML entity that must be passed through verbatim to the renderer.
* Examples: &    
* An HTML entity decoded to its Unicode character, HTML-escaped for safe verbatim output.
*
* SECURITY: only entities validated by InlineParser::scan() reach this node.
* The renderer MUST NOT call esc() on the entity string.
* InlineParser::scan() resolves the raw entity reference (named or numeric) to the
* Unicode character(s) it represents, then applies htmlspecialchars() so that characters
* that are meaningful in HTML (e.g. & < >) are re-encoded as HTML entities while all
* other characters are stored as literal UTF-8.
*
* Examples (raw entity → stored value):
* &amp; → &amp; (& re-encoded)
* &copy; → © (literal UTF-8)
* &#160; → \xC2\xA0 (literal non-breaking space)
* &#0; → \xEF\xBF\xBD (U+FFFD replacement character)
*
* SECURITY: the renderer MUST NOT call esc() on the entity string — it is already safe.
*/
final readonly class HtmlEntityNode implements InlineNodeInterface
{
Expand Down
35 changes: 24 additions & 11 deletions src/Parser/InlineParser.php
Original file line number Diff line number Diff line change
Expand Up @@ -204,7 +204,9 @@ private function scan(string $text, int $depth): array

$raw = $m[0];

// Determine whether this is a numeric or named entity.
// Determine whether this is a numeric or named entity and compute $decodedSafe:
// the Unicode character(s) that the entity represents, HTML-escaped as needed
// so the renderer can output the value verbatim in an HTML text node.
if ($raw[1] === '#') {
// Numeric entity — extract the codepoint.
$inner = substr($raw, 2, -1); // strip leading '&#' and trailing ';'
Expand All @@ -214,25 +216,34 @@ private function scan(string $text, int $depth): array
$codepoint = (int) $inner;
}

// Reject invalid / dangerous codepoints (CommonMark §2.5 & HTML5 §8.1.4).
$valid = !(
$codepoint === 0 // NUL
// CommonMark §2.5 / HTML5 §8.1.4 — three buckets:
if ($codepoint === 0
|| ($codepoint >= 0xD800 && $codepoint <= 0xDFFF)
) {
// NUL and surrogates → replacement character U+FFFD.
$decodedSafe = "\u{FFFD}";
} elseif (
$codepoint > 0x10FFFF // beyond Unicode range
|| ($codepoint >= 0x0001 && $codepoint <= 0x001F
&& $codepoint !== 0x0009 // TAB
&& $codepoint !== 0x000A // LF
&& $codepoint !== 0x000D) // CR
|| $codepoint === 0x007F // DEL
|| ($codepoint >= 0xD800 && $codepoint <= 0xDFFF) // surrogates
|| ($codepoint >= 0xFDD0 && $codepoint <= 0xFDEF) // non-characters
|| $codepoint === 0xFFFE
|| $codepoint === 0xFFFF
|| $codepoint > 0x10FFFF // beyond Unicode range
);

if (!$valid) {
) {
// Invalid / non-character codepoint → pass raw entity through as text.
$buffer .= $raw;
$pos += strlen($raw);
continue;
} else {
// Valid codepoint → decode to UTF-8 char, then HTML-escape if needed.
$decodedSafe = htmlspecialchars(
mb_chr($codepoint, 'UTF-8'),
ENT_HTML5,
'UTF-8',
);
}
} else {
// Named entity — PHP recognises it if html_entity_decode changes it.
Expand All @@ -243,12 +254,14 @@ private function scan(string $text, int $depth): array
$pos += strlen($raw);
continue;
}
// Decode succeeded: HTML-escape the resulting character(s) for safe output.
$decodedSafe = htmlspecialchars($decoded, ENT_HTML5, 'UTF-8');
}

// Valid entity: flush pending buffer, emit node, advance.
// Valid entity: flush pending buffer, emit decoded node, advance.
$tokens = $this->flushBuffer($buffer, $tokens);
$buffer = '';
$tokens[] = new HtmlEntityNode($raw);
$tokens[] = new HtmlEntityNode($decodedSafe);
$pos += strlen($raw);
continue;
}
Expand Down
24 changes: 14 additions & 10 deletions tests/Unit/InlineParserEntityTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -32,18 +32,20 @@ public function testDecimalEntityProducesHtmlEntityNode(): void
{
$nodes = $this->parser->parse('&#160;');

// &#160; = U+00A0 (non-breaking space) decoded to literal UTF-8 \xC2\xA0.
$this->assertCount(1, $nodes);
$this->assertInstanceOf(HtmlEntityNode::class, $nodes[0]);
$this->assertSame('&#160;', $nodes[0]->entity);
$this->assertSame("\xC2\xA0", $nodes[0]->entity);
}

public function testHexEntityProducesHtmlEntityNode(): void
{
$nodes = $this->parser->parse('&#x00A0;');

// &#x00A0; = U+00A0 (non-breaking space) decoded to literal UTF-8 \xC2\xA0.
$this->assertCount(1, $nodes);
$this->assertInstanceOf(HtmlEntityNode::class, $nodes[0]);
$this->assertSame('&#x00A0;', $nodes[0]->entity);
$this->assertSame("\xC2\xA0", $nodes[0]->entity);
}

public function testBareAmpersandProducesTextNode(): void
Expand All @@ -66,22 +68,24 @@ public function testInvalidNamedEntityProducesTextNode(): void
}
}

public function testNullCodepointEntityProducesTextNode(): void
public function testNullCodepointEntityProducesReplacementCharNode(): void
{
$nodes = $this->parser->parse('&#0;');

foreach ($nodes as $node) {
$this->assertNotInstanceOf(HtmlEntityNode::class, $node);
}
// &#0; (NUL) is mapped to U+FFFD per CommonMark §2.5 / HTML5 §8.1.4.
$this->assertCount(1, $nodes);
$this->assertInstanceOf(HtmlEntityNode::class, $nodes[0]);
$this->assertSame("\u{FFFD}", $nodes[0]->entity);
}

public function testSurrogateEntityProducesTextNode(): void
public function testSurrogateEntityProducesReplacementCharNode(): void
{
$nodes = $this->parser->parse('&#xD800;');

foreach ($nodes as $node) {
$this->assertNotInstanceOf(HtmlEntityNode::class, $node);
}
// Surrogate codepoints are mapped to U+FFFD per CommonMark §2.5 / HTML5 §8.1.4.
$this->assertCount(1, $nodes);
$this->assertInstanceOf(HtmlEntityNode::class, $nodes[0]);
$this->assertSame("\u{FFFD}", $nodes[0]->entity);
}

public function testCodeSpanContentIsNotScannedForEntities(): void
Expand Down
157 changes: 157 additions & 0 deletions tests/Unit/Parser/HtmlEntityDecodingTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
<?php

declare(strict_types=1);

namespace PhpMarkdown\Tests\Unit\Parser;

use PhpMarkdown\MarkdownParser;
use PHPUnit\Framework\Attributes\RequiresPhpExtension;
use PHPUnit\Framework\TestCase;

/**
* Acceptance tests for HTML entity decoding (story 52-html-entity-decoding).
*
* Entities are decoded to their Unicode characters by InlineParser::scan() and
* stored as the decoded + HTML-safe representation in HtmlEntityNode::$entity.
* The renderer outputs the value verbatim so the HTML roundtrip is lossless.
*/
#[RequiresPhpExtension('intl')]
final class HtmlEntityDecodingTest extends TestCase
{
private MarkdownParser $parser;

protected function setUp(): void
{
$this->parser = new MarkdownParser();
}

private function parse(string $markdown): string
{
return $this->parser->parse($markdown);
}

// ── AC-1: Named entities are decoded to their Unicode characters ──────────

public function testAc1NamedEntitiesDecoded(): void
{
// &nbsp; → U+00A0 (literal); &amp; → &amp;; &copy; → ©; &AElig; → Æ; &Dcaron; → Ď
$result = $this->parse('&nbsp; &amp; &copy; &AElig; &Dcaron;');

$this->assertSame("<p>\xC2\xA0 &amp; \xC2\xA9 \xC3\x86 \xC4\x8E</p>\n", $result);
}

// ── AC-2: Decimal numeric character references ────────────────────────────

public function testAc2DecimalNumericRefs(): void
{
// &#35; → #; &#1234; → Ӓ (U+04D2); &#992; → Ϡ (U+03E0); &#0; → U+FFFD
$result = $this->parse('&#35; &#1234; &#992; &#0;');

$this->assertSame("<p># \u{04D2} \u{03E0} \u{FFFD}</p>\n", $result);
}

// ── AC-3: Hexadecimal numeric character references ────────────────────────

public function testAc3HexNumericRefs(): void
{
// &#X22; → " (U+0022); &#XD06; → ആ (U+0D06, Malayalam); &#xcab; → ಫ (U+0CAB, Kannada)
$result = $this->parse('&#X22; &#XD06; &#xcab;');

$this->assertSame("<p>\" \u{0D06} \u{0CAB}</p>\n", $result);
}

// ── AC-4: Invalid / unrecognised entity names pass through as literal text ─

public function testAc4InvalidEntityNamesLiteral(): void
{
// &nbsp (no semicolon), &x; (unknown), &#; (no digits), &#x; (no hex digits),
// &#87654321; (too many digits), &ThisIsNotDefined; (unknown)
// All bare & chars must become &amp; in output.
$result = $this->parse('&nbsp &x; &#; &#x; &#87654321; &ThisIsNotDefined;');

$this->assertSame(
"<p>&amp;nbsp &amp;x; &amp;#; &amp;#x; &amp;#87654321; &amp;ThisIsNotDefined;</p>\n",
$result,
);
}

// ── AC-5: Entity without trailing semicolon is literal text ──────────────

public function testAc5EntityWithoutSemicolonIsLiteral(): void
{
$result = $this->parse('&copy');

$this->assertSame("<p>&amp;copy</p>\n", $result);
}

// ── AC-6: Entities inside code spans are not decoded ─────────────────────

public function testAc6EntityInCodeSpanNotDecoded(): void
{
$result = $this->parse('`f&ouml;f&ouml;`');

$this->assertSame("<p><code>f&amp;ouml;f&amp;ouml;</code></p>\n", $result);
}

// ── AC-7: Entities inside indented code blocks are not decoded ────────────

public function testAc7EntityInIndentedCodeBlockNotDecoded(): void
{
$result = $this->parse(' f&ouml;f&ouml;');

$this->assertSame("<pre><code>f&amp;ouml;f&amp;ouml;\n</code></pre>\n", $result);
}

// ── AC-8: Entities in link URL and title ─────────────────────────────────

/**
* @todo Needs link-level entity decoding + URL percent-encoding, out of scope for story 52.
* [foo](/f&ouml;&ouml; "f&ouml;&ouml;") should produce
* <p><a href="/f%C3%B6%C3%B6" title="föö">foo</a></p>
* but the current implementation does not decode entities in link URLs or titles.
*/
public function testAc8EntityInLinkUrlAndTitle(): void
{
// Current behaviour: & in URL is HTML-escaped by the renderer's esc(); title same.
// When link-level entity decoding is implemented, update the assertion below.
$result = $this->parse('[foo](/f&ouml;&ouml; "f&ouml;&ouml;")');

$this->assertSame(
'<p><a href="/f&amp;ouml;&amp;ouml;" title="f&amp;ouml;&amp;ouml;">foo</a></p>' . "\n",
$result,
);
}

// ── AC-9: Numeric entity suppresses emphasis delimiter ────────────────────

public function testAc9NumericEntitySuppressesEmphasis(): void
{
// &#42; = '*' decoded as HtmlEntityNode, not a DelimiterRun, so no emphasis.
// The real *foo* on the next line does produce emphasis.
$result = $this->parse("&#42;foo&#42;\n*foo*");

$this->assertSame("<p>*foo*\n<em>foo</em></p>\n", $result);
}

// ── AC-10: Fenced code block language info-string entity decoding ─────────

/**
* @todo Needs block-level entity decoding, out of scope for story 52.
* Spec ex 34: the fenced code language string should be decoded.
*/
public function testAc10FencedCodeLanguageEntityDecoding(): void
{
// CommonMark spec ex 34: ``` &amp;lang ``` should produce class="language-&lang"
// or similar with decoded entity in the language attribute.
// Block-level entity decoding is out of scope for this story; recording expected
// behaviour for future implementation.
$result = $this->parse("```&amp;lang\ncode\n```");

// Current behaviour: &amp; in info string is sanitized by the renderer
// (non-alphanumeric chars stripped from language). Mark current output and revisit.
$this->assertSame(
"<pre><code class=\"language-amplang\">code</code></pre>\n",
$result,
);
}
}
Loading