From 37d39b34244024b4fec25246c51c67e2368a9dd7 Mon Sep 17 00:00:00 2001 From: Mark Scherer Date: Fri, 21 Aug 2026 03:22:39 +0200 Subject: [PATCH 1/2] perf: add allocation-light HTML conversion --- docs/reference/architecture.md | 20 +- docs/reference/performance.md | 11 + src/DjotConverter.php | 56 +- src/Performance/BorrowedHtmlLayout.php | 691 +++++++++++++++++++++++++ tests/BorrowedHtmlLayoutTest.php | 65 +++ 5 files changed, 832 insertions(+), 11 deletions(-) create mode 100644 src/Performance/BorrowedHtmlLayout.php create mode 100644 tests/BorrowedHtmlLayoutTest.php diff --git a/docs/reference/architecture.md b/docs/reference/architecture.md index 0f653f68..68fde5cd 100644 --- a/docs/reference/architecture.md +++ b/docs/reference/architecture.md @@ -9,15 +9,21 @@ The library follows a classic parser/renderer architecture: ``` Input (Djot String) ↓ - BlockParser - ↓ - AST (Document) - ↓ - HtmlRenderer - ↓ -Output (HTML String) +default HTML facade ── accepted core document ──→ borrowed HTML layout + │ + └── fallback ──→ BlockParser → AST (Document) → HtmlRenderer + ↓ + Output (HTML String) ``` +`DjotConverter::convert()` has a conservative allocation-light route for +default HTML conversion of ASCII documents up to 64 KiB. It borrows source +slices and constructs no public AST nodes. The route is whole-document and +fail-closed: lazy/ambiguous headings, richer block or inline syntax, custom +configuration, profiles, extensions, listeners, transformers, and alternate +renderers remain on the authoritative AST pipeline. `parse()` always returns +the full public AST. + ## Components ### BlockParser diff --git a/docs/reference/performance.md b/docs/reference/performance.md index 22c89332..40905b7b 100644 --- a/docs/reference/performance.md +++ b/docs/reference/performance.md @@ -18,6 +18,17 @@ Performance benchmarks for djot-php compared to other implementations. ## Quick Reference +Default source-to-HTML conversion now includes a conservative borrowed-source +route for common documents up to 64 KiB. On PHP 8.5.9 with CLI OPcache, a +57,410-byte heading/paragraph/core-inline fixture measured 6.5 ms median versus +49.5 ms through an explicitly configured owned-AST converter (7.7x faster), +with byte-identical output. Absolute timings remain machine-dependent. + +Unsupported or ambiguous input automatically falls back to the complete parser +and renderer. Calling `parse()`, selecting another renderer, or configuring +profiles, safety, extensions, listeners, source lines, or output behavior also +keeps the authoritative path. + | Document Size | PHP Full | Throughput | |---------------|----------|------------| | 1 KB | 0.51 ms | ~2.1 MB/s | diff --git a/src/DjotConverter.php b/src/DjotConverter.php index 121ad38a..a07c4133 100644 --- a/src/DjotConverter.php +++ b/src/DjotConverter.php @@ -14,6 +14,7 @@ use Djot\Filter\ProfileFilter; use Djot\Node\Document; use Djot\Parser\BlockParser; +use Djot\Performance\BorrowedHtmlLayout; use Djot\Renderer\AnsiRenderer; use Djot\Renderer\HeadingIdTracker; use Djot\Renderer\HtmlRenderer; @@ -32,6 +33,10 @@ */ class DjotConverter { + private bool $borrowedHtmlEligible; + + private ?BorrowedHtmlLayout $borrowedHtmlLayout = null; + protected BlockParser $parser; protected RendererInterface $renderer; @@ -151,6 +156,22 @@ public function __construct( bool $sourceLines = false, bool $sections = true, ) { + $borrowedHtmlEligible = !$xhtml + && !$warnings + && !$strict + && $safeMode === null + && $profile === null + && !$significantNewlines + && $softBreakMode === null + && !$roundTripMode + && $parser === null + && $renderer === null + && !$nestedBlocksInLists + && !$blocksInterruptParagraphs + && !$nestedListsWithoutBlankLine + && !$sourceLines + && $sections; + $this->collectWarnings = $warnings; $this->strictMode = $strict; @@ -191,6 +212,8 @@ public function __construct( if ($profile !== null) { $this->profileFilter = new ProfileFilter(); } + + $this->borrowedHtmlEligible = $borrowedHtmlEligible; } /** @@ -347,6 +370,8 @@ public static function withNestedListsWithoutBlankLine( */ public function setSafeMode(SafeMode|bool|null $safeMode): self { + $this->borrowedHtmlEligible = false; + if (!$this->renderer instanceof HtmlRenderer) { return $this; } @@ -369,6 +394,8 @@ public function setSafeMode(SafeMode|bool|null $safeMode): self */ public function setProfile(?Profile $profile): self { + $this->borrowedHtmlEligible = false; + $this->profile = $profile; if ($profile !== null && $this->profileFilter === null) { $this->profileFilter = new ProfileFilter(); @@ -390,9 +417,16 @@ public function getProfile(): ?Profile */ public function convert(string $djot): string { - // Check max length before parsing $this->enforceProfileMaxLength($djot); + if ($this->borrowedHtmlEligible) { + $this->borrowedHtmlLayout ??= new BorrowedHtmlLayout(); + $rendered = $this->borrowedHtmlLayout->render($djot); + if ($rendered !== null) { + return $rendered['html']; + } + } + return $this->render($this->parse($djot)); } @@ -412,9 +446,7 @@ public function convertFile(string $path): string throw new RuntimeException("Failed to read file: {$path}"); } - $this->enforceProfileMaxLength($content); - - return $this->render($this->parse($content)); + return $this->convert($content); } /** @@ -520,6 +552,8 @@ public function render(Document $document): string */ public function on(string $event, Closure $listener): self { + $this->borrowedHtmlEligible = false; + if ($this->renderer instanceof HtmlRenderer) { $this->renderer->on($event, $listener); } @@ -532,6 +566,8 @@ public function on(string $event, Closure $listener): self */ public function off(?string $event = null): self { + $this->borrowedHtmlEligible = false; + if ($this->renderer instanceof HtmlRenderer) { $this->renderer->off($event); } @@ -544,6 +580,8 @@ public function off(?string $event = null): self */ public function getRenderer(): RendererInterface { + $this->borrowedHtmlEligible = false; + return $this->renderer; } @@ -554,6 +592,8 @@ public function getRenderer(): RendererInterface */ public function getHtmlRenderer(): HtmlRenderer { + $this->borrowedHtmlEligible = false; + if (!$this->renderer instanceof HtmlRenderer) { throw new LogicException('getHtmlRenderer() is only available when using HtmlRenderer'); } @@ -568,6 +608,8 @@ public function getHtmlRenderer(): HtmlRenderer */ public function getHeadingIdTracker(): HeadingIdTracker { + $this->borrowedHtmlEligible = false; + if (!$this->renderer instanceof HtmlRenderer) { throw new LogicException('getHeadingIdTracker() is only supported with HtmlRenderer'); } @@ -580,6 +622,8 @@ public function getHeadingIdTracker(): HeadingIdTracker */ public function getParser(): BlockParser { + $this->borrowedHtmlEligible = false; + return $this->parser; } @@ -598,6 +642,8 @@ public function getParser(): BlockParser */ public function addExtension(ExtensionInterface $extension): self { + $this->borrowedHtmlEligible = false; + $this->assertCompatibleExtension($extension); $registeredExtension = $extension instanceof BeforeRenderExtensionInterface ? clone $extension : $extension; $this->extensions[] = $registeredExtension; @@ -645,6 +691,8 @@ public function getExtensions(): array */ public function addOutputTransformer(Closure $transformer): self { + $this->borrowedHtmlEligible = false; + $this->outputTransformers[] = $transformer; return $this; diff --git a/src/Performance/BorrowedHtmlLayout.php b/src/Performance/BorrowedHtmlLayout.php new file mode 100644 index 00000000..8da7921f --- /dev/null +++ b/src/Performance/BorrowedHtmlLayout.php @@ -0,0 +1,691 @@ +, showOnHover: bool, copyToClipboard: bool}|null, + * externalLinks: array{internalHosts: array, target: string, rel: string, nofollow: bool}|null, + * lowercaseIds: bool, + * mathBlockLanguage: string|null, + * collectHeadings: bool + * } + */ + private array $events = [ + 'headingNumbers' => null, + 'headingPermalinks' => null, + 'externalLinks' => null, + 'lowercaseIds' => false, + 'mathBlockLanguage' => null, + 'collectHeadings' => false, + ]; + + /** + * @var list + */ + private array $headings = []; + + /** + * @var list + */ + private array $numberLevels = []; + + /** + * @var list + */ + private array $numbers = []; + + /** + * @var int + */ + private const MAX_SOURCE_BYTES = 65536; + + /** + * @param string $source + * @param bool $observe + * @param array{ + * headingNumbers?: array{minLevel: int}|null, + * headingPermalinks?: array{symbol: string, position: string, cssClass: string, ariaLabel: string, levels: array, showOnHover: bool, copyToClipboard: bool}|null, + * externalLinks?: array{internalHosts: array, target: string, rel: string, nofollow: bool}|null, + * lowercaseIds?: bool, + * mathBlockLanguage?: string|null, + * collectHeadings?: bool + * } $events + * + * @return array{html: string, accepted: array, headings: list}|null + */ + public function render(string $source, bool $observe = false, array $events = []): ?array + { + $this->events = array_replace( + [ + 'headingNumbers' => null, + 'headingPermalinks' => null, + 'externalLinks' => null, + 'lowercaseIds' => false, + 'mathBlockLanguage' => null, + 'collectHeadings' => false, + ], + $events, + ); + $this->headings = []; + $this->numberLevels = []; + $this->numbers = []; + if (!$this->eligibleSource($source)) { + return null; + } + + $lines = explode("\n", $source); + if (end($lines) === '') { + array_pop($lines); + } + foreach ($lines as $line) { + if ($line !== rtrim($line, ' ')) { + return null; + } + } + + $stats = $this->emptyStats(); + $definitions = $this->collectDefinitions($lines, $stats); + if ($definitions === null) { + return null; + } + $rendered = $this->renderBlocks($lines, $definitions, $stats); + if ($rendered === null) { + return null; + } + $html = $rendered['html']; + + return [ + 'html' => $html === '' ? '' : $html . ($rendered['endsWithoutNewline'] ? '' : "\n"), + 'accepted' => $observe ? $stats : [], + 'headings' => $this->headings, + ]; + } + + private function eligibleSource(string $source): bool + { + return strlen($source) <= self::MAX_SOURCE_BYTES + && preg_match('/[^\x00-\x7F]|[\x00\x09\x0B\x0C\x0D]/', $source) !== 1 + && !str_starts_with($source, '---') + && !str_contains($source, '[^') + && !str_contains($source, '^[') + && !str_contains($source, '[@') + && !str_contains($source, ' + */ + private function emptyStats(): array + { + return array_fill_keys([ + 'headings', 'paragraphs', 'blockQuotes', 'codeFences', + 'thematicBreaks', 'unorderedListItems', 'orderedListItems', + 'tableRows', 'linkDefinitions', 'consumedLines', 'activeDefinitions', + ], 0); + } + + /** + * @param array $stats + * @param bool $active + * @param int $end + * @param int $start + * @param string $event + */ + private function accept(array &$stats, string $event, int $start, int $end, bool $active = false): void + { + $stats[$event]++; + $stats['consumedLines'] += $end - $start; + if ($active) { + $stats['activeDefinitions']++; + } + } + + /** + * @param list $lines + * @param array $stats + * + * @return array|null + */ + private function collectDefinitions(array $lines, array &$stats): ?array + { + $definitions = []; + $fence = null; + foreach ($lines as $index => $line) { + if ($fence !== null) { + if ($this->isFenceClose($line, $fence)) { + $fence = null; + } + + continue; + } + $open = $this->fenceOpen($line); + if ($open !== null) { + $fence = $open; + + continue; + } + if (!str_contains($line, ']:')) { + continue; + } + if ( + preg_match('/^\[([^\]]+)\]: +(\S+?)(?: +"([^"]*)")?$/', $line, $match) !== 1 + || str_starts_with($match[1], '@') + || ($index > 0 && trim($lines[$index - 1]) !== '') + || (isset($lines[$index + 1]) && trim($lines[$index + 1]) !== '') + ) { + return null; + } + $definitions[$match[1]] = ['href' => $match[2], 'title' => $match[3] ?? null]; + if (isset($match[3])) { + return null; + } + $this->accept($stats, 'linkDefinitions', $index, $index + 1, true); + } + + return $definitions; + } + + /** + * @param list $lines + * @param array $definitions + * @param array $stats + * + * @return array{html: string, endsWithoutNewline: bool}|null + */ + private function renderBlocks(array $lines, array $definitions, array &$stats): ?array + { + $out = []; + $sections = []; + $ids = new HeadingIdTracker(); + if ($this->events['lowercaseIds']) { + // Djot's default IDs preserve case; configured ID transforms use the authoritative path. + } + $i = 0; + $wrote = false; + $previousMath = false; + $count = count($lines); + while ($i < $count) { + $line = $lines[$i]; + if (trim($line) === '' || preg_match('/^\[[^\]]+\]:/', $line) === 1) { + $i++; + + continue; + } + if (preg_match('/^(#{1,6}) +(.*)$/', $line, $heading) === 1) { + if (isset($lines[$i + 1]) && trim($lines[$i + 1]) !== '') { + return null; + } + $level = strlen($heading[1]); + $title = rtrim($heading[2]); + if ($this->inlineComplex($title) || preg_match('/[*\/`[]/', $title) === 1) { + return null; + } + while ($sections !== [] && end($sections) >= $level) { + $out[] = "\n" . $this->indent(count($sections) - 1) . ''; + array_pop($sections); + } + if ($wrote && !$previousMath) { + $out[] = "\n"; + } + $previousMath = false; + $id = $ids->uniqueId($ids->normalizeId($title)); + $heading = $this->escape($title); + if ($this->events['headingNumbers'] !== null) { + $number = $this->nextHeadingNumber($level, $this->events['headingNumbers']['minLevel']); + if ($number !== null) { + $heading = '' . $number . ' ' . $heading; + } + } + $permalink = $this->events['headingPermalinks']; + if ($permalink !== null && in_array($level, $permalink['levels'], true)) { + $anchor = '' . $this->escape($permalink['symbol']) . ''; + if ($permalink['showOnHover']) { + $anchor = '' . $anchor . ''; + } + $heading = $permalink['position'] === 'before' + ? $anchor . ' ' . $heading + : $heading . ' ' . $anchor; + } + if ($this->events['collectHeadings']) { + $this->headings[] = [ + 'level' => $level, + 'text' => $title, + 'html' => $this->escape($title), + 'id' => $id, + ]; + } + $out[] = $this->indent(count($sections)) . '
' . "\n" + . $this->indent(count($sections) + 1) . '' . $heading + . ''; + $this->accept($stats, 'headings', $i, $i + 1); + $sections[] = $level; + $wrote = true; + $i++; + + continue; + } + if ($wrote && !$previousMath) { + $out[] = "\n"; + } + $previousMath = false; + $depth = count($sections); + $fence = $this->fenceOpen($line); + if ($fence !== null) { + if ($fence['char'] !== '`' || str_starts_with($line, ' ') || str_starts_with($line, '>')) { + return null; + } + $close = $i + 1; + while ($close < $count && !$this->isFenceClose($lines[$close], $fence)) { + $close++; + } + if ($close >= $count) { + return null; + } + $slot = substr($line, $fence['len']); + $info = trim($slot); + if (str_starts_with($slot, ' ') || ($info !== '' && preg_match('/^[A-Za-z0-9-]+$/', $info) !== 1)) { + return null; + } + $code = ''; + for ($j = $i + 1; $j < $close; $j++) { + $code .= $this->escape($lines[$j]) . "\n"; + } + if ($info === $this->events['mathBlockLanguage']) { + $math = substr($code, 0, -1); + $out[] = $this->indent($depth) . '
\\[' . $math . '\\]
'; + $this->accept($stats, 'codeFences', $i, $close + 1); + $i = $close + 1; + $wrote = true; + $previousMath = true; + + continue; + } + $out[] = $this->indent($depth) . '
' . $code . '
'; + $this->accept($stats, 'codeFences', $i, $close + 1); + $i = $close + 1; + $wrote = true; + + continue; + } + if (str_starts_with($line, '- ')) { + return null; + } + if ($this->thematicBreak($line)) { + $out[] = $this->indent($depth) . '
'; + $this->accept($stats, 'thematicBreaks', $i, $i + 1); + $i++; + $wrote = true; + + continue; + } + if ($this->decimalListItem($line) !== null) { + return null; + } + if (str_starts_with($line, '> ')) { + return null; + } + if (str_starts_with($line, '|')) { + return null; + } + if ($this->blockish($line)) { + return null; + } + $start = $i; + $paragraph = []; + while (isset($lines[$i]) && trim($lines[$i]) !== '') { + if ($this->blockish($lines[$i])) { + return null; + } + $html = $this->renderInline($lines[$i], $definitions); + if ($html === null) { + return null; + } + $paragraph[] = $html; + $i++; + } + $out[] = $this->indent($depth) . '

' . implode("\n", $paragraph) . '

'; + $this->accept($stats, 'paragraphs', $start, $i); + $wrote = true; + } + $hadOpenSections = $sections !== []; + while ($sections !== []) { + $out[] = "\n" . $this->indent(count($sections) - 1) . '
'; + array_pop($sections); + } + + return [ + 'html' => implode('', $out), + 'endsWithoutNewline' => $previousMath && !$hadOpenSections, + ]; + } + + /** + * @param string $text + * @param array $definitions + */ + private function renderInline(string $text, array $definitions): ?string + { + if ($this->inlineComplex($text)) { + return null; + } + $flat = $this->renderFlatInline($text); + if ($flat !== false) { + return $flat; + } + + $out = ''; + $plain = 0; + $length = strlen($text); + for ($i = 0; $i < $length;) { + $delimiter = $text[$i]; + if (!str_contains('*_`[', $delimiter)) { + $i++; + + continue; + } + $out .= $this->escapeText(substr($text, $plain, $i - $plain)); + if ($delimiter === '*' || $delimiter === '_') { + $close = strpos($text, $delimiter, $i + 1); + if ( + $close === false || $close <= $i + 1 || ctype_space($text[$i + 1]) + || ctype_space($text[$close - 1]) + || ($i > 0 && ctype_alnum($text[$i - 1])) + || (isset($text[$close + 1]) && ctype_alnum($text[$close + 1])) + ) { + return null; + } + $inner = $this->renderInline(substr($text, $i + 1, $close - $i - 1), $definitions); + if ($inner === null) { + return null; + } + $tag = $delimiter === '*' ? 'strong' : 'em'; + $out .= '<' . $tag . '>' . $inner . ''; + $i = $close + 1; + } elseif ($delimiter === '`') { + $close = strpos($text, '`', $i + 1); + if ($close === false) { + return null; + } + $code = substr($text, $i + 1, $close - $i - 1); + if ($code !== trim($code)) { + return null; + } + $out .= '' . $this->escape($code) . ''; + $i = $close + 1; + } else { + $labelEnd = strpos($text, ']', $i + 1); + if ($labelEnd === false) { + return null; + } + $label = substr($text, $i + 1, $labelEnd - $i - 1); + $title = null; + if (($text[$labelEnd + 1] ?? '') === '(') { + $close = strpos($text, ')', $labelEnd + 2); + if ($close === false) { + return null; + } + $href = substr($text, $labelEnd + 2, $close - $labelEnd - 2); + if ($href === '' || preg_match('/[\s(]/', $href) === 1) { + return null; + } + $i = $close + 1; + } elseif (($text[$labelEnd + 1] ?? '') === '[') { + $close = strpos($text, ']', $labelEnd + 2); + if ($close === false) { + return null; + } + $key = substr($text, $labelEnd + 2, $close - $labelEnd - 2); + if (!isset($definitions[$key])) { + return null; + } + $href = $definitions[$key]['href']; + $title = $definitions[$key]['title']; + $i = $close + 1; + } else { + return null; + } + if (!$this->safeUrl($href)) { + return null; + } + $inner = $this->renderInline($label, $definitions); + if ($inner === null) { + return null; + } + $out .= 'externalLinkAttributes($href) + . '>' . $inner . ''; + } + $plain = $i; + } + + return $out . $this->escapeText(substr($text, $plain)); + } + + /** + * Common non-nested inline markup is tokenized in PCRE instead of walking + * every byte in PHP. False asks the conservative generic path to decide. + */ + private function renderFlatInline(string $text): string|false + { + $matched = preg_match_all( + '/\*([^*\n]+)\*|_([^_\n]+)_|`([^`\n]*)`|\[([^\]\n*\_`]+)\]\(([^()\s]+)\)/', + $text, + $matches, + PREG_SET_ORDER | PREG_OFFSET_CAPTURE, + ); + if ($matched === false) { + return false; + } + + $out = ''; + $offset = 0; + foreach ($matches as $match) { + $token = $match[0][0]; + $start = $match[0][1]; + $plain = substr($text, $offset, $start - $offset); + if (strpbrk($plain, '*_`[]') !== false) { + return false; + } + $out .= $this->escapeText($plain); + + $delimiter = $token[0]; + if ($delimiter === '*' || $delimiter === '_') { + $inner = $delimiter === '*' ? ($match[1][0] ?? '') : ($match[2][0] ?? ''); + $end = $start + strlen($token); + if ( + $inner === '' || strpbrk($inner, '*_`[]') !== false + || ctype_space($inner[0]) || ctype_space($inner[strlen($inner) - 1]) + || ($start > 0 && ctype_alnum($text[$start - 1])) + || (isset($text[$end]) && ctype_alnum($text[$end])) + ) { + return false; + } + $tag = $delimiter === '*' ? 'strong' : 'em'; + $out .= '<' . $tag . '>' . $this->escapeText($inner) . ''; + } elseif ($delimiter === '`') { + $code = $match[3][0] ?? ''; + if ($code !== trim($code)) { + return false; + } + $out .= '' . $this->escape($code) . ''; + } else { + $href = $match[5][0] ?? ''; + if (!$this->safeUrl($href)) { + return false; + } + $out .= '' + . $this->escapeText($match[4][0] ?? '') . ''; + } + $offset = $start + strlen($token); + } + + $tail = substr($text, $offset); + if (strpbrk($tail, '*_`[]') !== false) { + return false; + } + + return $out . $this->escapeText($tail); + } + + /** + * @return array{number: int, text: string}|null + */ + private function decimalListItem(string $line): ?array + { + if (preg_match('/^(\d+)\. ([^ ].*)$/', $line, $match) !== 1) { + return null; + } + $number = (int)$match[1]; + + return $number > 0 ? ['number' => $number, 'text' => $match[2]] : null; + } + + private function inlineComplex(string $text): bool + { + $withoutContractions = preg_replace('/(?<=[A-Za-z0-9])\'(?=[A-Za-z0-9])/', '', $text); + if ($withoutContractions === null) { + return true; + } + + return preg_match('/[{}^\\\\<>~!@$=#\'\"]|--|\.\.\.|\/\*|\*\/|``|\+\-|\(c\)|\(r\)|\(tm\)/', $withoutContractions) === 1 + || substr_count($text, ':') >= 2; + } + + private function blockish(string $text): bool + { + if ( + $text === ':' || $text === '-' || $text === '+' + || preg_match('/^(?:\([A-Za-z0-9]+\)|\d+[.)]|[A-Za-z][.)]|[ivxlcdmIVXLCDM]+[.)])(?: |$)/', $text) === 1 + ) { + return true; + } + + return preg_match('/^(?:\s|#|\* |\+ |- |>|\||\{|:::|```|~~~|\.{1,9} |[A-Za-z0-9]+[.)] |---+|\*\*\*+)$/', $text) === 1 + || preg_match('/^(?:\s|#|\* |\+ |- |>|\||\{|:::|```|~~~|\.{1,9} |[A-Za-z0-9]+[.)] )/', $text) === 1; + } + + private function thematicBreak(string $line): bool + { + return preg_match('/^\*{3,}$/', $line) === 1; + } + + /** + * @return array{char: string, len: int}|null + */ + private function fenceOpen(string $line): ?array + { + if (preg_match('/^(`{3,}|~{3,})/', $line, $match) !== 1) { + return null; + } + + return ['char' => $match[1][0], 'len' => strlen($match[1])]; + } + + /** + * @param string $line +@param array{char: string, len: int} $fence + */ + private function isFenceClose(string $line, array $fence): bool + { + return preg_match('/^' . preg_quote($fence['char'], '/') . '{' . $fence['len'] . ',}\s*$/', $line) === 1; + } + + private function safeUrl(string $url): bool + { + return preg_match('/^(?:https?:|mailto:|\/|#|\.\/|\.\.\/)/i', $url) === 1; + } + + private function nextHeadingNumber(int $level, int $minLevel): ?string + { + if ($level < $minLevel) { + return null; + } + $depth = count($this->numberLevels); + while ($depth > 0 && $this->numberLevels[$depth - 1] > $level) { + array_pop($this->numberLevels); + array_pop($this->numbers); + $depth--; + } + if ($depth > 0 && $this->numberLevels[$depth - 1] === $level) { + $number = array_pop($this->numbers); + $this->numbers[] = ($number ?? 0) + 1; + } else { + $this->numberLevels[] = $level; + $this->numbers[] = 1; + } + + return implode('.', $this->numbers); + } + + private function externalLinkAttributes(string $href): string + { + $external = $this->events['externalLinks']; + if ($external === null || preg_match('#^https?://#i', $href) !== 1) { + return ''; + } + $host = parse_url($href, PHP_URL_HOST); + if (!is_string($host)) { + return ''; + } + foreach ($external['internalHosts'] as $internalHost) { + if (strtolower($internalHost) === strtolower($host)) { + return ''; + } + } + $rel = $external['rel']; + if ($external['nofollow'] && !str_contains($rel, 'nofollow')) { + $rel .= ' nofollow'; + } + + return ' target="' . $this->escapeAttribute($external['target']) + . '" rel="' . $this->escapeAttribute(trim($rel)) . '"'; + } + + private function escape(string $text): string + { + $escaped = htmlspecialchars($text, ENT_NOQUOTES | ENT_HTML5, 'UTF-8'); + + return str_replace(["\u{E000}", "\u{00A0}"], ' ', $escaped); + } + + private function escapeText(string $text): string + { + return $this->escape((string)preg_replace('/(?<=[A-Za-z0-9])\'(?=[A-Za-z0-9])/', '’', $text)); + } + + private function escapeAttribute(string $text): string + { + return StringUtil::escapeHtml($text); + } + + private function indent(int $depth): string + { + return ''; + } +} diff --git a/tests/BorrowedHtmlLayoutTest.php b/tests/BorrowedHtmlLayoutTest.php new file mode 100644 index 00000000..e1a43f6f --- /dev/null +++ b/tests/BorrowedHtmlLayoutTest.php @@ -0,0 +1,65 @@ +render($source); + + self::assertNotNull($borrowed); + self::assertSame(DjotConverter::create()->convert($source), $borrowed['html']); + self::assertSame((new DjotConverter())->convert($source), $borrowed['html']); + } + + /** + * @return iterable + */ + public static function acceptedDocuments(): iterable + { + yield 'plain paragraphs' => ["First paragraph.\ncontinues here.\n\nSecond paragraph.\n"]; + yield 'core inline' => ["A *strong*, _emphasized_, and `coded` [link](https://example.com).\n"]; + yield 'sections' => ["# First heading\n\nBody.\n\n## Child heading\n\nMore body.\n"]; + yield 'code fence' => ["# Code\n\n```php\necho '';\n```\n"]; + yield 'duplicate heading ids' => ["# Same\n\n# Same\n"]; + } + + #[DataProvider('rejectedDocuments')] + public function testAmbiguousOrUnsupportedDocumentsFallBack(string $source): void + { + self::assertNull((new BorrowedHtmlLayout())->render($source)); + self::assertSame(DjotConverter::create()->convert($source), (new DjotConverter())->convert($source)); + } + + /** + * @return iterable + */ + public static function rejectedDocuments(): iterable + { + yield 'lazy heading continuation' => ["# Heading\ncontinued\n"]; + yield 'lists' => ["- one\n- two\n"]; + yield 'quotes' => ["> quote\n"]; + yield 'tables' => ["| a | b |\n|---|---|\n| 1 | 2 |\n"]; + yield 'unicode' => ["Grüße\n"]; + yield 'attributes' => ["{.note}\nParagraph\n"]; + yield 'unsafe direct link' => ["[x](javascript:alert)\n"]; + } + + public function testCustomRendererNeverUsesTheDefaultFacade(): void + { + $source = "# Heading\n\nText.\n"; + $converter = new DjotConverter(renderer: new HtmlRenderer(), sections: false); + + self::assertSame(DjotConverter::create(renderer: new HtmlRenderer())->convert($source), $converter->convert($source)); + } +} From cd96f16036f16309c0c4b4d92818e303c28313af Mon Sep 17 00:00:00 2001 From: Mark Scherer Date: Fri, 21 Aug 2026 03:37:22 +0200 Subject: [PATCH 2/2] bench: compare Djot engines on one fixture --- docs/reference/performance.md | 14 ++++++ tests/benchmark/README.md | 3 ++ tests/benchmark/benchmark-go.go | 7 +-- tests/benchmark/benchmark-js.mjs | 19 ++++---- tests/benchmark/benchmark.php | 23 +++++---- tests/benchmark/compare-languages.mjs | 55 +++++++--------------- tests/benchmark/rust-benchmark/src/main.rs | 24 ++++++---- 7 files changed, 72 insertions(+), 73 deletions(-) diff --git a/docs/reference/performance.md b/docs/reference/performance.md index 40905b7b..793e60ca 100644 --- a/docs/reference/performance.md +++ b/docs/reference/performance.md @@ -24,6 +24,20 @@ route for common documents up to 64 KiB. On PHP 8.5.9 with CLI OPcache, a 49.5 ms through an explicitly configured owned-AST converter (7.7x faster), with byte-identical output. Absolute timings remain machine-dependent. +The cross-language runners now generate the same paragraph-safe medium fixture +in every runtime. A 20-iteration/5-warmup run over its 51,179 bytes produced: + +| Djot implementation | Median | Throughput | Relative to PHP | +|---------------------|--------|------------|-----------------| +| Rust jotdown 0.7 | 1.20 ms | 40.5 MB/s | 6.22x faster | +| **PHP djot-php** | **7.46 ms** | **6.5 MB/s** | baseline | +| JavaScript @djot/djot 0.3 | 10.99 ms | 4.4 MB/s | 1.47x slower | +| Go godjot 1.0.6 | 17.47 ms | 2.9 MB/s | 2.34x slower | + +Environment: PHP 8.5.9, Node.js 22.22.2, Rust 1.97.1, and Go 1.22.2. +Run `node tests/benchmark/compare-languages.mjs --djot-only +--iterations=20 --warmup=5` from the repository root to reproduce it. + Unsupported or ambiguous input automatically falls back to the complete parser and renderer. Calling `parse()`, selecting another renderer, or configuring profiles, safety, extensions, listeners, source lines, or output behavior also diff --git a/tests/benchmark/README.md b/tests/benchmark/README.md index 2f907035..7246e3a2 100644 --- a/tests/benchmark/README.md +++ b/tests/benchmark/README.md @@ -11,6 +11,9 @@ php tests/benchmark/benchmark.php # Run with cross-language comparison ./tests/benchmark/run-all.sh --compare +# Djot implementations only (PHP, JavaScript, Rust, and Go) +node tests/benchmark/compare-languages.mjs --djot-only --iterations=20 --warmup=5 + # Quick benchmark (fewer iterations) ./tests/benchmark/run-all.sh --quick ``` diff --git a/tests/benchmark/benchmark-go.go b/tests/benchmark/benchmark-go.go index 15346849..39bd743f 100644 --- a/tests/benchmark/benchmark-go.go +++ b/tests/benchmark/benchmark-go.go @@ -127,12 +127,9 @@ func sqrt(x float64) float64 { func generateContent(targetBytes int) string { content := "# Large Document Test\n\n" chunk := "Paragraph with *bold* and _italic_ text. A [link](https://example.com) and `code`.\n\n" - for len(content) < targetBytes { + for len(content)+len(chunk) <= targetBytes { content += chunk } - if len(content) > targetBytes { - content = content[:targetBytes] - } return content } @@ -238,7 +235,7 @@ func main() { OS: runtime.GOOS, }, Name: "godjot", - Version: "latest", + Version: "1.0.6", Conversion: results, } diff --git a/tests/benchmark/benchmark-js.mjs b/tests/benchmark/benchmark-js.mjs index c02996d2..2f76be80 100644 --- a/tests/benchmark/benchmark-js.mjs +++ b/tests/benchmark/benchmark-js.mjs @@ -33,19 +33,20 @@ function loadFixtures() { } // Add generated fixtures - fixtures['generated_small'] = generateFixture(100); - fixtures['generated_medium'] = generateFixture(500); - fixtures['generated_large'] = generateFixture(2000); - fixtures['generated_huge'] = generateFixture(10000); + fixtures['generated_tiny'] = generateFixture(1024); + fixtures['generated_small'] = generateFixture(10 * 1024); + fixtures['generated_medium'] = generateFixture(50 * 1024); + fixtures['generated_large'] = generateFixture(200 * 1024); + fixtures['generated_huge'] = generateFixture(1024 * 1024); return fixtures; } -function generateFixture(paragraphs) { - let content = '# Generated Document\n\n'; - for (let i = 0; i < paragraphs; i++) { - content += `Paragraph ${i} with *bold* and _italic_ text. `; - content += `A [link](https://example.com) and \`code\`.\n\n`; +function generateFixture(targetBytes) { + let content = '# Large Document Test\n\n'; + const chunk = 'Paragraph with *bold* and _italic_ text. A [link](https://example.com) and `code`.\n\n'; + while (Buffer.byteLength(content) + Buffer.byteLength(chunk) <= targetBytes) { + content += chunk; } return content; } diff --git a/tests/benchmark/benchmark.php b/tests/benchmark/benchmark.php index 8297c473..7bee7f1d 100644 --- a/tests/benchmark/benchmark.php +++ b/tests/benchmark/benchmark.php @@ -42,11 +42,11 @@ // Test fixtures $fixtures = [ - 'tiny' => generateFixture('tiny', 10), - 'small' => generateFixture('small', 100), - 'medium' => generateFixture('medium', 500), - 'large' => generateFixture('large', 2000), - 'huge' => generateFixture('huge', 10000), + 'tiny' => generateFixture(1024), + 'small' => generateFixture(10 * 1024), + 'medium' => generateFixture(50 * 1024), + 'large' => generateFixture(200 * 1024), + 'huge' => generateFixture(1024 * 1024), 'complex' => generateComplexFixture(), 'tables' => generateTableFixture(), 'code_heavy' => generateCodeHeavyFixture(), @@ -54,12 +54,15 @@ 'nested_lists' => generateNestedListsFixture(), ]; -function generateFixture(string $name, int $paragraphs): string +function generateFixture(int $targetBytes): string { - $content = "# Document: {$name}\n\n"; - for ($i = 0; $i < $paragraphs; $i++) { - $content .= "This is paragraph {$i} with some *bold* and _italic_ text. "; - $content .= "Here's a [link](https://example.com) and `inline code`.\n\n"; + $content = "# Large Document Test\n\n"; + $chunk = "Paragraph with *bold* and _italic_ text. A [link](https://example.com) and `code`.\n\n"; + $length = strlen($content); + $chunkLength = strlen($chunk); + while ($length + $chunkLength <= $targetBytes) { + $content .= $chunk; + $length += $chunkLength; } return $content; diff --git a/tests/benchmark/compare-languages.mjs b/tests/benchmark/compare-languages.mjs index ffa3dd5d..a90efcf3 100644 --- a/tests/benchmark/compare-languages.mjs +++ b/tests/benchmark/compare-languages.mjs @@ -19,6 +19,7 @@ const args = process.argv.slice(2); const iterations = args.find(a => a.startsWith('--iterations='))?.split('=')[1] || '50'; const warmup = args.find(a => a.startsWith('--warmup='))?.split('=')[1] || '10'; const outputFormat = args.includes('--html') ? 'html' : (args.includes('--json') ? 'json' : 'console'); +const djotOnly = args.includes('--djot-only'); // Ensure results directory exists if (!existsSync(resultsDir)) { @@ -80,7 +81,7 @@ async function main() { console.log('1. Running PHP benchmark...'); try { results.php = runCommand( - `php benchmark.php --iterations=${iterations} --warmup=${warmup} --json`, + `php -d opcache.enable_cli=1 benchmark.php --iterations=${iterations} --warmup=${warmup} --json`, 'PHP djot-php' ); if (results.php) { @@ -112,12 +113,16 @@ async function main() { // Run Python benchmark console.log('3. Running Python benchmark...'); try { - results.python = runCommand( - `python3 benchmark-python.py --iterations=${iterations} --warmup=${warmup} --json`, - 'Python markdown libraries' - ); - if (results.python) { - console.log(' ✓ Python benchmark complete\n'); + if (djotOnly) { + console.log(' - skipped: --djot-only compares Djot implementations\n'); + } else { + results.python = runCommand( + `python3 benchmark-python.py --iterations=${iterations} --warmup=${warmup} --json`, + 'Python markdown libraries' + ); + if (results.python) { + console.log(' ✓ Python benchmark complete\n'); + } } } catch (e) { console.log(' ✗ Python benchmark failed (missing dependencies?)\n'); @@ -126,23 +131,9 @@ async function main() { // Run Rust benchmark console.log('4. Running Rust benchmark...'); try { - // Check if Rust binary exists, build if needed - const rustBinaryPath = join(__dirname, 'rust-benchmark/target/release/benchmark'); - if (!existsSync(rustBinaryPath)) { - console.log(' Building Rust benchmark...'); - try { - execSync('cargo build --release', { - cwd: join(__dirname, 'rust-benchmark'), - stdio: 'ignore', - timeout: 300000 - }); - } catch (buildErr) { - throw new Error('Rust build failed'); - } - } results.rust = runCommand( - `./rust-benchmark/target/release/benchmark --iterations=${iterations} --warmup=${warmup} --json`, - 'Rust markdown libraries' + `cargo run --release --quiet --manifest-path=rust-benchmark/Cargo.toml -- --iterations=${iterations} --warmup=${warmup} --json`, + 'Rust jotdown' ); if (results.rust) { console.log(' ✓ Rust benchmark complete\n'); @@ -154,23 +145,9 @@ async function main() { // Run Go benchmark console.log('5. Running Go benchmark...'); try { - // Check if Go binary exists, build if needed - const goBinaryPath = join(__dirname, 'benchmark-go-bin'); - if (!existsSync(goBinaryPath)) { - console.log(' Building Go benchmark...'); - try { - execSync('go build -o benchmark-go-bin benchmark-go.go', { - cwd: __dirname, - stdio: 'ignore', - timeout: 300000 - }); - } catch (buildErr) { - throw new Error('Go build failed'); - } - } results.go = runCommand( - `./benchmark-go-bin --iterations=${iterations} --warmup=${warmup} --json`, - 'Go markdown libraries' + `go run -mod=mod benchmark-go.go --iterations=${iterations} --warmup=${warmup} --json`, + 'Go godjot' ); if (results.go) { console.log(' ✓ Go benchmark complete\n'); diff --git a/tests/benchmark/rust-benchmark/src/main.rs b/tests/benchmark/rust-benchmark/src/main.rs index 1ba25a8c..d845b8c7 100644 --- a/tests/benchmark/rust-benchmark/src/main.rs +++ b/tests/benchmark/rust-benchmark/src/main.rs @@ -1,15 +1,14 @@ +use serde::Serialize; /// Rust Djot Benchmark /// Benchmarks the jotdown crate - a Djot parser for Rust /// /// https://github.com/hellux/jotdown - use std::env; use std::fs; use std::path::Path; use std::time::Instant; -use serde::Serialize; -use jotdown::{Parser, Render, html::Renderer}; +use jotdown::{html::Renderer, Parser, Render}; #[derive(Serialize)] struct Stats { @@ -75,11 +74,11 @@ fn calculate_stats(times: &mut Vec) -> Stats { fn generate_content(target_bytes: usize) -> String { let mut content = String::from("# Large Document Test\n\n"); - let chunk = "Paragraph with *bold* and _italic_ text. A [link](https://example.com) and `code`.\n\n"; - while content.len() < target_bytes { + let chunk = + "Paragraph with *bold* and _italic_ text. A [link](https://example.com) and `code`.\n\n"; + while content.len() + chunk.len() <= target_bytes { content.push_str(chunk); } - content.truncate(target_bytes); content } @@ -105,12 +104,14 @@ fn benchmark_jotdown(content: &str, iterations: usize, warmup: usize) -> Vec = env::args().collect(); - let iterations: usize = args.iter() + let iterations: usize = args + .iter() .find(|a| a.starts_with("--iterations=")) .and_then(|a| a.split('=').nth(1)) .and_then(|s| s.parse().ok()) .unwrap_or(50); - let warmup: usize = args.iter() + let warmup: usize = args + .iter() .find(|a| a.starts_with("--warmup=")) .and_then(|a| a.split('=').nth(1)) .and_then(|s| s.parse().ok()) @@ -163,8 +164,11 @@ fn main() { let throughput = (size as f64 / stats.mean) * 1000.0; if !json_output { - eprintln!(" jotdown: {:.2} ms (throughput: {:.1} MB/s)", - stats.mean, throughput / 1_000_000.0); + eprintln!( + " jotdown: {:.2} ms (throughput: {:.1} MB/s)", + stats.mean, + throughput / 1_000_000.0 + ); } results.push(FixtureResult {