From a691538b13b1fbaa45700bce8337db9389d9b1a1 Mon Sep 17 00:00:00 2001 From: Andreas Brus Date: Fri, 28 Aug 2026 15:28:19 +0200 Subject: [PATCH] Prevent crash when the mark stack runs empty in DOMSerializer closeMarkTags() pops the mark stack once per tag it has to close, but the stack can run empty before every tag is closed when marks are nested inconsistently -- e.g. a mark duplicated on a node that already inherits the same mark from a sibling. array_pop() then returns null and renderHTML() is called on null, raising a fatal error. Stop closing tags once the stack is empty instead. Adds a regression test covering a duplicated mark inherited from a sibling. --- src/Core/DOMSerializer.php | 9 ++++++ tests/DOMSerializer/WrongFormatTest.php | 40 +++++++++++++++++++++++++ 2 files changed, 49 insertions(+) diff --git a/src/Core/DOMSerializer.php b/src/Core/DOMSerializer.php index 7714bd8b3..73b4ba320 100644 --- a/src/Core/DOMSerializer.php +++ b/src/Core/DOMSerializer.php @@ -122,6 +122,15 @@ private function closeMarkTags($markTagsToClose, &$markStack, &$markTagsToReopen while (! empty($markTagsToClose)) { # close mark tag from the top of the stack $markTag = array_pop($markStack); + + # the mark stack can run empty before every tag was closed when marks + # are nested inconsistently (e.g. a mark duplicated on a node that + # inherits the same mark from a sibling). Stop here instead of calling + # a method on null. + if ($markTag === null) { + break; + } + $markExtension = $markTag[0]; $mark = $markTag[1]; $html[] = $this->renderClosingTag($markExtension->renderHTML($mark)); diff --git a/tests/DOMSerializer/WrongFormatTest.php b/tests/DOMSerializer/WrongFormatTest.php index d4927e004..ad0d35bfd 100644 --- a/tests/DOMSerializer/WrongFormatTest.php +++ b/tests/DOMSerializer/WrongFormatTest.php @@ -127,3 +127,43 @@ expect($result)->toEqual('Example Link'); }); + +test('duplicated mark inherited from a sibling does not crash the serializer', function () { + // The second text node carries the same mark twice while inheriting it from + // the first node. Opening is skipped for both duplicates (the sibling already + // "has" the mark), but closing fires for both, popping the mark stack once more + // than it was filled. This used to call a method on null in closeMarkTags(). + $document = [ + 'type' => 'doc', + 'content' => [ + [ + 'type' => 'paragraph', + 'content' => [ + [ + 'type' => 'text', + 'text' => 'a', + 'marks' => [ + ['type' => 'bold'], + ], + ], + [ + 'type' => 'text', + 'text' => 'b', + 'marks' => [ + ['type' => 'bold'], + ['type' => 'bold'], + ], + ], + ], + ], + ], + ]; + + $result = (new Editor([ + 'extensions' => [ + new StarterKit, + ], + ]))->setContent($document)->getHTML(); + + expect($result)->toEqual('

ab

'); +});