Last active
June 19, 2026 07:52
-
-
Save adamziel/d659e65206719325e115c12796fd23b0 to your computer and use it in GitHub Desktop.
WordPress Playground demo: Port Libs Document Intake
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| <?php | |
| declare(strict_types=1); | |
| namespace PortLibs\Pandoc; | |
| final class AstNode | |
| { | |
| /** | |
| * @param array<string, mixed> $attrs | |
| * @param list<AstNode> $children | |
| */ | |
| public function __construct( | |
| public readonly string $type, | |
| public readonly array $attrs = [], | |
| public readonly array $children = [], | |
| ) { | |
| } | |
| public function attr(string $name, mixed $default = null): mixed | |
| { | |
| return $this->attrs[$name] ?? $default; | |
| } | |
| } | |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| <?php | |
| declare(strict_types=1); | |
| namespace PortLibs\Pandoc; | |
| final class DocxReader | |
| { | |
| private const W_NS = 'http://schemas.openxmlformats.org/wordprocessingml/2006/main'; | |
| private const R_NS = 'http://schemas.openxmlformats.org/officeDocument/2006/relationships'; | |
| private const A_NS = 'http://schemas.openxmlformats.org/drawingml/2006/main'; | |
| private const REL_NS = 'http://schemas.openxmlformats.org/package/2006/relationships'; | |
| /** @var array<string, array{headingLevel?: int, strong?: bool, emph?: bool}> */ | |
| private array $styles = []; | |
| /** @var array<string, string> */ | |
| private array $numbering = []; | |
| /** @var array<string, array{target: string, type: string, mode: string}> */ | |
| private array $relationships = []; | |
| /** @var array<string, list<AstNode>> */ | |
| private array $footnotes = []; | |
| /** @var array<string, list<AstNode>> */ | |
| private array $endnotes = []; | |
| /** @var array<string, array{author: string, date: string, children: list<AstNode>, text: string}> */ | |
| private array $comments = []; | |
| public function readDocxFile(string $path): AstNode | |
| { | |
| if (!class_exists(\ZipArchive::class)) { | |
| throw new \RuntimeException('DOCX analysis needs PHP ZipArchive, which is unavailable in this runtime.'); | |
| } | |
| $zip = new \ZipArchive(); | |
| if ($zip->open($path) !== true) { | |
| throw new \InvalidArgumentException("Unable to open DOCX package '{$path}'."); | |
| } | |
| try { | |
| $document_xml = $zip->getFromName('word/document.xml'); | |
| if (!is_string($document_xml)) { | |
| throw new \InvalidArgumentException('DOCX package is missing word/document.xml.'); | |
| } | |
| $styles_xml = $zip->getFromName('word/styles.xml'); | |
| $numbering_xml = $zip->getFromName('word/numbering.xml'); | |
| $rels_xml = $zip->getFromName('word/_rels/document.xml.rels'); | |
| $core_xml = $zip->getFromName('docProps/core.xml'); | |
| $footnotes_xml = $zip->getFromName('word/footnotes.xml'); | |
| $endnotes_xml = $zip->getFromName('word/endnotes.xml'); | |
| $comments_xml = $zip->getFromName('word/comments.xml'); | |
| $entries = []; | |
| $media = []; | |
| $header_xmls = []; | |
| $footer_xmls = []; | |
| for ($i = 0; $i < $zip->numFiles; $i++) { | |
| $stat = $zip->statIndex($i); | |
| $name = is_array($stat) ? (string) ($stat['name'] ?? '') : ''; | |
| if ($name === '') { | |
| continue; | |
| } | |
| $entries[] = $name; | |
| if (str_starts_with($name, 'word/media/')) { | |
| $media[] = $name; | |
| } | |
| if (preg_match('#^word/header\d*\.xml$#', $name) === 1) { | |
| $xml = $zip->getFromName($name); | |
| if (is_string($xml)) { | |
| $header_xmls[$name] = $xml; | |
| } | |
| } | |
| if (preg_match('#^word/footer\d*\.xml$#', $name) === 1) { | |
| $xml = $zip->getFromName($name); | |
| if (is_string($xml)) { | |
| $footer_xmls[$name] = $xml; | |
| } | |
| } | |
| } | |
| ksort($header_xmls); | |
| ksort($footer_xmls); | |
| } finally { | |
| $zip->close(); | |
| } | |
| return $this->readPackage( | |
| $document_xml, | |
| is_string($styles_xml) ? $styles_xml : '', | |
| is_string($numbering_xml) ? $numbering_xml : '', | |
| is_string($rels_xml) ? $rels_xml : '', | |
| is_string($core_xml) ? $core_xml : '', | |
| is_string($footnotes_xml) ? $footnotes_xml : '', | |
| is_string($endnotes_xml) ? $endnotes_xml : '', | |
| is_string($comments_xml) ? $comments_xml : '', | |
| $header_xmls, | |
| $footer_xmls, | |
| $entries, | |
| $media, | |
| ); | |
| } | |
| /** | |
| * @param array<string, string> $header_xmls | |
| * @param array<string, string> $footer_xmls | |
| * @param list<string> $entries | |
| * @param list<string> $media | |
| */ | |
| private function readPackage( | |
| string $document_xml, | |
| string $styles_xml, | |
| string $numbering_xml, | |
| string $rels_xml, | |
| string $core_xml, | |
| string $footnotes_xml, | |
| string $endnotes_xml, | |
| string $comments_xml, | |
| array $header_xmls, | |
| array $footer_xmls, | |
| array $entries, | |
| array $media | |
| ): AstNode { | |
| $this->styles = $styles_xml !== '' ? $this->styles($this->loadXml($styles_xml, 'DOCX styles.xml')) : []; | |
| $this->numbering = $numbering_xml !== '' ? $this->numbering($this->loadXml($numbering_xml, 'DOCX numbering.xml')) : []; | |
| $this->relationships = $rels_xml !== '' ? $this->relationships($this->loadXml($rels_xml, 'DOCX document relationships')) : []; | |
| $this->footnotes = $footnotes_xml !== '' ? $this->notes($this->loadXml($footnotes_xml, 'DOCX footnotes.xml'), 'footnote') : []; | |
| $this->endnotes = $endnotes_xml !== '' ? $this->notes($this->loadXml($endnotes_xml, 'DOCX endnotes.xml'), 'endnote') : []; | |
| $this->comments = $comments_xml !== '' ? $this->comments($this->loadXml($comments_xml, 'DOCX comments.xml')) : []; | |
| $document = $this->loadXml($document_xml, 'DOCX document.xml'); | |
| $body = $this->firstElementByLocalName($document, 'body'); | |
| $headers = $this->partBlocks($header_xmls, 'DOCX header'); | |
| $footers = $this->partBlocks($footer_xmls, 'DOCX footer'); | |
| $body_children = $body instanceof \DOMElement ? $this->bodyBlocks($body) : []; | |
| $children = array_merge( | |
| $this->partDivs($headers, 'docx-header'), | |
| $body_children, | |
| $this->partDivs($footers, 'docx-footer') | |
| ); | |
| if ($children === []) { | |
| $children[] = new AstNode('paragraph', ['text' => 'No readable DOCX body content was found.'], [ | |
| new AstNode('text', ['text' => 'No readable DOCX body content was found.']), | |
| ]); | |
| } | |
| $metadata = $core_xml !== '' ? $this->coreProperties($this->loadXml($core_xml, 'DOCX core properties')) : []; | |
| $metadata['docxPackageEntries'] = count($entries); | |
| $metadata['docxMediaFiles'] = $media; | |
| $metadata['docxRelationshipCount'] = count($this->relationships); | |
| $metadata['docxNumberingDefinitions'] = count($this->numbering); | |
| $metadata['docxFootnotes'] = count($this->footnotes); | |
| $metadata['docxEndnotes'] = count($this->endnotes); | |
| $metadata['docxComments'] = count($this->comments); | |
| $metadata['docxHeaders'] = count($headers); | |
| $metadata['docxFooters'] = count($footers); | |
| $metadata['docxHeaderFiles'] = array_keys($header_xmls); | |
| $metadata['docxFooterFiles'] = array_keys($footer_xmls); | |
| return new AstNode('document', ['meta' => $metadata], $children); | |
| } | |
| /** | |
| * @param array<string, string> $xmlParts | |
| * @return array<string, list<AstNode>> | |
| */ | |
| private function partBlocks(array $xmlParts, string $label): array | |
| { | |
| $parts = []; | |
| foreach ($xmlParts as $name => $xml) { | |
| if ($xml === '') { | |
| continue; | |
| } | |
| $root = $this->loadXml($xml, $label . ' ' . $name)->documentElement; | |
| if (!$root instanceof \DOMElement) { | |
| continue; | |
| } | |
| $blocks = $this->bodyBlocks($root); | |
| if ($blocks !== []) { | |
| $parts[$name] = $blocks; | |
| } | |
| } | |
| return $parts; | |
| } | |
| /** | |
| * @param array<string, list<AstNode>> $parts | |
| * @return list<AstNode> | |
| */ | |
| private function partDivs(array $parts, string $class): array | |
| { | |
| $divs = []; | |
| $index = 1; | |
| foreach ($parts as $name => $blocks) { | |
| $divs[] = new AstNode('div', [ | |
| 'id' => $class . '-' . $index, | |
| 'classes' => [$class], | |
| 'attributes' => [ | |
| 'data-docx-part' => $name, | |
| 'data-pandoc-source' => 'docx', | |
| ], | |
| ], $blocks); | |
| $index++; | |
| } | |
| return $divs; | |
| } | |
| /** | |
| * @return list<AstNode> | |
| */ | |
| private function bodyBlocks(\DOMElement $body): array | |
| { | |
| $blocks = []; | |
| $pendingItems = []; | |
| $pendingOrdered = false; | |
| $flushList = function () use (&$blocks, &$pendingItems, &$pendingOrdered): void { | |
| if ($pendingItems === []) { | |
| return; | |
| } | |
| $blocks[] = new AstNode($pendingOrdered ? 'ordered_list' : 'bullet_list', [], $pendingItems); | |
| $pendingItems = []; | |
| $pendingOrdered = false; | |
| }; | |
| foreach ($body->childNodes as $child) { | |
| if (!$child instanceof \DOMElement) { | |
| continue; | |
| } | |
| if ($child->localName === 'p') { | |
| $paragraph = $this->paragraph($child); | |
| if (!$paragraph instanceof AstNode) { | |
| continue; | |
| } | |
| $numId = $this->paragraphNumId($child); | |
| if ($numId !== '') { | |
| $ordered = ($this->numbering[$numId] ?? 'bullet') !== 'bullet'; | |
| if ($pendingItems !== [] && $pendingOrdered !== $ordered) { | |
| $flushList(); | |
| } | |
| $pendingOrdered = $ordered; | |
| $pendingItems[] = new AstNode('list_item', [], [$paragraph]); | |
| continue; | |
| } | |
| $flushList(); | |
| $blocks[] = $paragraph; | |
| continue; | |
| } | |
| if ($child->localName === 'tbl') { | |
| $flushList(); | |
| $blocks[] = $this->table($child); | |
| } | |
| } | |
| $flushList(); | |
| return $blocks; | |
| } | |
| private function paragraph(\DOMElement $paragraph): ?AstNode | |
| { | |
| $inlines = $this->inlineChildren($paragraph); | |
| $text = $this->plainText($inlines); | |
| if ($text === '' && $inlines === []) { | |
| return null; | |
| } | |
| $level = $this->headingLevel($paragraph); | |
| if ($level !== null) { | |
| return new AstNode('heading', ['level' => $level, 'text' => $text], $inlines); | |
| } | |
| return new AstNode('paragraph', ['text' => $text], $inlines); | |
| } | |
| /** | |
| * @return list<AstNode> | |
| */ | |
| private function inlineChildren(\DOMElement $container): array | |
| { | |
| $inlines = []; | |
| foreach ($container->childNodes as $child) { | |
| if (!$child instanceof \DOMElement) { | |
| continue; | |
| } | |
| if ($child->localName === 'r') { | |
| array_push($inlines, ...$this->run($child)); | |
| continue; | |
| } | |
| if ($child->localName === 'hyperlink') { | |
| $link = $this->hyperlink($child); | |
| if ($link instanceof AstNode) { | |
| $inlines[] = $link; | |
| } | |
| continue; | |
| } | |
| if ($child->localName === 'ins' || $child->localName === 'del') { | |
| $span = $this->trackedChangeSpan($child); | |
| if ($span instanceof AstNode) { | |
| $inlines[] = $span; | |
| } | |
| } | |
| } | |
| return $this->mergeAdjacentText($inlines); | |
| } | |
| /** | |
| * @return list<AstNode> | |
| */ | |
| private function run(\DOMElement $run): array | |
| { | |
| $nodes = []; | |
| foreach ($run->childNodes as $child) { | |
| if (!$child instanceof \DOMElement) { | |
| continue; | |
| } | |
| if ($child->localName === 't' || $child->localName === 'delText') { | |
| $text = $child->textContent; | |
| if ($text !== '') { | |
| $nodes[] = new AstNode('text', ['text' => $text]); | |
| } | |
| continue; | |
| } | |
| if ($child->localName === 'tab') { | |
| $nodes[] = new AstNode('text', ['text' => "\t"]); | |
| continue; | |
| } | |
| if ($child->localName === 'br' || $child->localName === 'cr') { | |
| $nodes[] = new AstNode('linebreak'); | |
| continue; | |
| } | |
| if ($child->localName === 'drawing') { | |
| $image = $this->drawingImage($child); | |
| if ($image instanceof AstNode) { | |
| $nodes[] = $image; | |
| } | |
| continue; | |
| } | |
| if ($child->localName === 'footnoteReference') { | |
| $note = $this->noteReference($child, 'footnote'); | |
| if ($note instanceof AstNode) { | |
| $nodes[] = $note; | |
| } | |
| continue; | |
| } | |
| if ($child->localName === 'endnoteReference') { | |
| $note = $this->noteReference($child, 'endnote'); | |
| if ($note instanceof AstNode) { | |
| $nodes[] = $note; | |
| } | |
| continue; | |
| } | |
| if ($child->localName === 'commentReference') { | |
| $note = $this->noteReference($child, 'comment'); | |
| if ($note instanceof AstNode) { | |
| $nodes[] = $note; | |
| } | |
| } | |
| } | |
| $style = $this->runStyle($run); | |
| if (($style['strong'] ?? false) && $nodes !== []) { | |
| $nodes = [new AstNode('strong', [], $nodes)]; | |
| } | |
| if (($style['emph'] ?? false) && $nodes !== []) { | |
| $nodes = [new AstNode('emph', [], $nodes)]; | |
| } | |
| return $nodes; | |
| } | |
| private function trackedChangeSpan(\DOMElement $change): ?AstNode | |
| { | |
| $children = $this->inlineChildren($change); | |
| if ($children === []) { | |
| return null; | |
| } | |
| $attributes = array_filter([ | |
| 'author' => $this->attr($change, self::W_NS, 'author'), | |
| 'date' => $this->attr($change, self::W_NS, 'date'), | |
| ], static fn (string $value): bool => $value !== ''); | |
| return new AstNode('span', [ | |
| 'classes' => [$change->localName === 'del' ? 'deletion' : 'insertion'], | |
| 'attributes' => $attributes, | |
| ], $children); | |
| } | |
| private function noteReference(\DOMElement $reference, string $kind): ?AstNode | |
| { | |
| $id = $this->attr($reference, self::W_NS, 'id'); | |
| if ($id === '') { | |
| return null; | |
| } | |
| $attrs = [ | |
| 'id' => $id, | |
| 'noteType' => $kind, | |
| ]; | |
| if ($kind === 'footnote') { | |
| $children = $this->footnotes[$id] ?? []; | |
| } elseif ($kind === 'endnote') { | |
| $children = $this->endnotes[$id] ?? []; | |
| } else { | |
| $comment = $this->comments[$id] ?? null; | |
| if (!is_array($comment)) { | |
| return null; | |
| } | |
| $attrs['author'] = $comment['author']; | |
| $attrs['date'] = $comment['date']; | |
| $children = $comment['children']; | |
| } | |
| if ($children === []) { | |
| return null; | |
| } | |
| return new AstNode('note', $attrs, $children); | |
| } | |
| private function hyperlink(\DOMElement $hyperlink): ?AstNode | |
| { | |
| $inlines = []; | |
| foreach ($hyperlink->childNodes as $child) { | |
| if ($child instanceof \DOMElement && $child->localName === 'r') { | |
| array_push($inlines, ...$this->run($child)); | |
| } | |
| } | |
| if ($inlines === []) { | |
| return null; | |
| } | |
| $rid = $this->attr($hyperlink, self::R_NS, 'id'); | |
| $url = $rid !== '' ? ($this->relationships[$rid]['target'] ?? '') : ''; | |
| if ($url === '') { | |
| $anchor = $this->attr($hyperlink, self::W_NS, 'anchor'); | |
| $url = $anchor !== '' ? '#' . $anchor : ''; | |
| } | |
| return new AstNode('link', ['url' => $url, 'title' => ''], $inlines); | |
| } | |
| private function drawingImage(\DOMElement $drawing): ?AstNode | |
| { | |
| foreach ($drawing->getElementsByTagNameNS(self::A_NS, 'blip') as $blip) { | |
| if (!$blip instanceof \DOMElement) { | |
| continue; | |
| } | |
| $rid = $this->attr($blip, self::R_NS, 'embed'); | |
| if ($rid === '') { | |
| $rid = $this->attr($blip, self::R_NS, 'link'); | |
| } | |
| $target = $rid !== '' ? ($this->relationships[$rid]['target'] ?? '') : ''; | |
| if ($target === '') { | |
| continue; | |
| } | |
| $url = $this->relationships[$rid]['mode'] === 'External' ? $target : $this->normalizeWordTarget($target); | |
| return new AstNode('image', ['url' => $url, 'title' => '', 'alt' => '']); | |
| } | |
| return null; | |
| } | |
| private function table(\DOMElement $table): AstNode | |
| { | |
| $rows = []; | |
| foreach ($table->childNodes as $child) { | |
| if ($child instanceof \DOMElement && $child->localName === 'tr') { | |
| $rows[] = $this->tableRow($child); | |
| } | |
| } | |
| return new AstNode('table', [], [ | |
| new AstNode('table_head'), | |
| new AstNode('table_body', [], $rows), | |
| ]); | |
| } | |
| private function tableRow(\DOMElement $row): AstNode | |
| { | |
| $cells = []; | |
| foreach ($row->childNodes as $cell) { | |
| if (!$cell instanceof \DOMElement || $cell->localName !== 'tc') { | |
| continue; | |
| } | |
| $cells[] = $this->tableCell($cell); | |
| } | |
| return new AstNode('table_row', [], $cells); | |
| } | |
| private function tableCell(\DOMElement $cell): AstNode | |
| { | |
| $blocks = []; | |
| foreach ($cell->childNodes as $child) { | |
| if ($child instanceof \DOMElement && $child->localName === 'p') { | |
| $paragraph = $this->paragraph($child); | |
| if ($paragraph instanceof AstNode) { | |
| $blocks[] = $paragraph; | |
| } | |
| } elseif ($child instanceof \DOMElement && $child->localName === 'tbl') { | |
| $blocks[] = $this->table($child); | |
| } | |
| } | |
| $text = trim(implode(' ', array_map(fn (AstNode $block): string => $this->nodeText($block), $blocks))); | |
| return new AstNode('table_cell', [ | |
| 'text' => $text, | |
| 'colspan' => $this->gridSpan($cell), | |
| 'rowspan' => 1, | |
| ], $blocks); | |
| } | |
| private function gridSpan(\DOMElement $cell): int | |
| { | |
| foreach ($cell->getElementsByTagNameNS(self::W_NS, 'gridSpan') as $gridSpan) { | |
| if ($gridSpan instanceof \DOMElement) { | |
| return max(1, (int) ($this->attr($gridSpan, self::W_NS, 'val') ?: '1')); | |
| } | |
| } | |
| return 1; | |
| } | |
| private function headingLevel(\DOMElement $paragraph): ?int | |
| { | |
| foreach ($paragraph->getElementsByTagNameNS(self::W_NS, 'pStyle') as $style) { | |
| if (!$style instanceof \DOMElement) { | |
| continue; | |
| } | |
| $styleId = $this->attr($style, self::W_NS, 'val'); | |
| if (isset($this->styles[$styleId]['headingLevel'])) { | |
| return max(1, min(6, (int) $this->styles[$styleId]['headingLevel'])); | |
| } | |
| if (preg_match('/heading\s*([1-6])|Heading([1-6])/i', $styleId, $m)) { | |
| return (int) ($m[1] !== '' ? $m[1] : $m[2]); | |
| } | |
| } | |
| foreach ($paragraph->getElementsByTagNameNS(self::W_NS, 'outlineLvl') as $outline) { | |
| if ($outline instanceof \DOMElement) { | |
| return max(1, min(6, (int) ($this->attr($outline, self::W_NS, 'val') ?: '0') + 1)); | |
| } | |
| } | |
| return null; | |
| } | |
| private function paragraphNumId(\DOMElement $paragraph): string | |
| { | |
| foreach ($paragraph->getElementsByTagNameNS(self::W_NS, 'numPr') as $numPr) { | |
| if (!$numPr instanceof \DOMElement) { | |
| continue; | |
| } | |
| foreach ($numPr->childNodes as $child) { | |
| if ($child instanceof \DOMElement && $child->localName === 'numId') { | |
| return $this->attr($child, self::W_NS, 'val'); | |
| } | |
| } | |
| } | |
| return ''; | |
| } | |
| /** | |
| * @return array{strong?: bool, emph?: bool} | |
| */ | |
| private function runStyle(\DOMElement $run): array | |
| { | |
| $style = []; | |
| foreach ($run->getElementsByTagNameNS(self::W_NS, 'rPr') as $rPr) { | |
| if (!$rPr instanceof \DOMElement || $rPr->parentNode !== $run) { | |
| continue; | |
| } | |
| foreach ($rPr->childNodes as $prop) { | |
| if (!$prop instanceof \DOMElement) { | |
| continue; | |
| } | |
| if ($prop->localName === 'b' && $this->truthyOnOff($prop)) { | |
| $style['strong'] = true; | |
| } elseif ($prop->localName === 'i' && $this->truthyOnOff($prop)) { | |
| $style['emph'] = true; | |
| } elseif ($prop->localName === 'rStyle') { | |
| $styleId = $this->attr($prop, self::W_NS, 'val'); | |
| $style = array_replace($style, $this->styles[$styleId] ?? []); | |
| } | |
| } | |
| } | |
| return $style; | |
| } | |
| private function truthyOnOff(\DOMElement $element): bool | |
| { | |
| $value = strtolower($this->attr($element, self::W_NS, 'val')); | |
| return !in_array($value, ['0', 'false', 'off', 'no'], true); | |
| } | |
| /** | |
| * @return array<string, array{headingLevel?: int, strong?: bool, emph?: bool}> | |
| */ | |
| private function styles(\DOMDocument $dom): array | |
| { | |
| $styles = []; | |
| foreach ($dom->getElementsByTagNameNS(self::W_NS, 'style') as $style) { | |
| if (!$style instanceof \DOMElement) { | |
| continue; | |
| } | |
| $styleId = $this->attr($style, self::W_NS, 'styleId'); | |
| if ($styleId === '') { | |
| continue; | |
| } | |
| $entry = []; | |
| foreach ($style->childNodes as $child) { | |
| if (!$child instanceof \DOMElement) { | |
| continue; | |
| } | |
| if ($child->localName === 'pPr') { | |
| foreach ($child->getElementsByTagNameNS(self::W_NS, 'outlineLvl') as $outline) { | |
| if ($outline instanceof \DOMElement) { | |
| $entry['headingLevel'] = max(1, min(6, (int) ($this->attr($outline, self::W_NS, 'val') ?: '0') + 1)); | |
| } | |
| } | |
| } elseif ($child->localName === 'rPr') { | |
| foreach ($child->childNodes as $prop) { | |
| if (!$prop instanceof \DOMElement) { | |
| continue; | |
| } | |
| if ($prop->localName === 'b' && $this->truthyOnOff($prop)) { | |
| $entry['strong'] = true; | |
| } elseif ($prop->localName === 'i' && $this->truthyOnOff($prop)) { | |
| $entry['emph'] = true; | |
| } | |
| } | |
| } | |
| } | |
| if ($entry === [] && preg_match('/heading\s*([1-6])|Heading([1-6])/i', $styleId, $m)) { | |
| $entry['headingLevel'] = (int) ($m[1] !== '' ? $m[1] : $m[2]); | |
| } | |
| $styles[$styleId] = $entry; | |
| } | |
| return $styles; | |
| } | |
| /** | |
| * @return array<string, string> | |
| */ | |
| private function numbering(\DOMDocument $dom): array | |
| { | |
| $abstractFormats = []; | |
| foreach ($dom->getElementsByTagNameNS(self::W_NS, 'abstractNum') as $abstractNum) { | |
| if (!$abstractNum instanceof \DOMElement) { | |
| continue; | |
| } | |
| $abstractId = $this->attr($abstractNum, self::W_NS, 'abstractNumId'); | |
| $format = 'bullet'; | |
| foreach ($abstractNum->getElementsByTagNameNS(self::W_NS, 'numFmt') as $numFmt) { | |
| if ($numFmt instanceof \DOMElement) { | |
| $format = $this->attr($numFmt, self::W_NS, 'val') ?: 'bullet'; | |
| break; | |
| } | |
| } | |
| if ($abstractId !== '') { | |
| $abstractFormats[$abstractId] = $format; | |
| } | |
| } | |
| $numbering = []; | |
| foreach ($dom->getElementsByTagNameNS(self::W_NS, 'num') as $num) { | |
| if (!$num instanceof \DOMElement) { | |
| continue; | |
| } | |
| $numId = $this->attr($num, self::W_NS, 'numId'); | |
| $abstractId = ''; | |
| foreach ($num->getElementsByTagNameNS(self::W_NS, 'abstractNumId') as $abstract) { | |
| if ($abstract instanceof \DOMElement) { | |
| $abstractId = $this->attr($abstract, self::W_NS, 'val'); | |
| break; | |
| } | |
| } | |
| if ($numId !== '') { | |
| $numbering[$numId] = $abstractFormats[$abstractId] ?? 'bullet'; | |
| } | |
| } | |
| return $numbering; | |
| } | |
| /** | |
| * @return array<string, array{target: string, type: string, mode: string}> | |
| */ | |
| private function relationships(\DOMDocument $dom): array | |
| { | |
| $rels = []; | |
| foreach ($dom->getElementsByTagNameNS(self::REL_NS, 'Relationship') as $rel) { | |
| if (!$rel instanceof \DOMElement) { | |
| continue; | |
| } | |
| $id = $rel->getAttribute('Id'); | |
| if ($id === '') { | |
| continue; | |
| } | |
| $rels[$id] = [ | |
| 'target' => $rel->getAttribute('Target'), | |
| 'type' => $rel->getAttribute('Type'), | |
| 'mode' => $rel->getAttribute('TargetMode'), | |
| ]; | |
| } | |
| return $rels; | |
| } | |
| /** | |
| * @return array<string, list<AstNode>> | |
| */ | |
| private function notes(\DOMDocument $dom, string $localName): array | |
| { | |
| $notes = []; | |
| foreach ($dom->getElementsByTagNameNS(self::W_NS, $localName) as $note) { | |
| if (!$note instanceof \DOMElement) { | |
| continue; | |
| } | |
| $id = $this->attr($note, self::W_NS, 'id'); | |
| if ($id === '' || (int) $id < 1) { | |
| continue; | |
| } | |
| $children = $this->bodyBlocks($note); | |
| if ($children !== []) { | |
| $notes[$id] = $children; | |
| } | |
| } | |
| return $notes; | |
| } | |
| /** | |
| * @return array<string, array{author: string, date: string, children: list<AstNode>, text: string}> | |
| */ | |
| private function comments(\DOMDocument $dom): array | |
| { | |
| $comments = []; | |
| foreach ($dom->getElementsByTagNameNS(self::W_NS, 'comment') as $comment) { | |
| if (!$comment instanceof \DOMElement) { | |
| continue; | |
| } | |
| $id = $this->attr($comment, self::W_NS, 'id'); | |
| if ($id === '') { | |
| continue; | |
| } | |
| $children = $this->bodyBlocks($comment); | |
| if ($children === []) { | |
| continue; | |
| } | |
| $comments[$id] = [ | |
| 'author' => $this->attr($comment, self::W_NS, 'author'), | |
| 'date' => $this->attr($comment, self::W_NS, 'date'), | |
| 'children' => $children, | |
| 'text' => trim(implode(' ', array_map(fn (AstNode $block): string => $this->nodeText($block), $children))), | |
| ]; | |
| } | |
| return $comments; | |
| } | |
| /** | |
| * @return array<string, mixed> | |
| */ | |
| private function coreProperties(\DOMDocument $dom): array | |
| { | |
| $map = [ | |
| 'title' => 'title', | |
| 'creator' => 'author', | |
| 'created' => 'date', | |
| 'description' => 'description', | |
| 'subject' => 'subject', | |
| ]; | |
| $meta = []; | |
| foreach ($dom->getElementsByTagName('*') as $element) { | |
| if (!$element instanceof \DOMElement) { | |
| continue; | |
| } | |
| $key = $map[$element->localName] ?? ''; | |
| if ($key === '') { | |
| continue; | |
| } | |
| $text = trim(preg_replace('/\s+/', ' ', $element->textContent) ?? $element->textContent); | |
| if ($text === '') { | |
| continue; | |
| } | |
| $meta[$key] = $text; | |
| if ($key === 'title') { | |
| $meta['titleInlines'] = [new AstNode('text', ['text' => $text])]; | |
| } | |
| } | |
| return $meta; | |
| } | |
| private function loadXml(string $xml, string $label): \DOMDocument | |
| { | |
| if (!class_exists(\DOMDocument::class)) { | |
| throw new \RuntimeException($label . ' needs DOMDocument, which is unavailable in this runtime.'); | |
| } | |
| $dom = new \DOMDocument('1.0', 'UTF-8'); | |
| $previous = libxml_use_internal_errors(true); | |
| $ok = $dom->loadXML($xml, LIBXML_NONET | LIBXML_NOERROR | LIBXML_NOWARNING); | |
| libxml_clear_errors(); | |
| libxml_use_internal_errors($previous); | |
| if (!$ok) { | |
| throw new \InvalidArgumentException($label . ' is not valid XML.'); | |
| } | |
| return $dom; | |
| } | |
| private function firstElementByLocalName(\DOMDocument $dom, string $localName): ?\DOMElement | |
| { | |
| foreach ($dom->getElementsByTagName('*') as $element) { | |
| if ($element instanceof \DOMElement && $element->localName === $localName) { | |
| return $element; | |
| } | |
| } | |
| return null; | |
| } | |
| private function attr(\DOMElement $element, string $namespace, string $name): string | |
| { | |
| $value = $element->getAttributeNS($namespace, $name); | |
| if ($value !== '') { | |
| return $value; | |
| } | |
| foreach ($element->attributes ?? [] as $attribute) { | |
| if ($attribute instanceof \DOMAttr && $attribute->localName === $name) { | |
| return $attribute->value; | |
| } | |
| } | |
| return ''; | |
| } | |
| /** | |
| * @param list<AstNode> $inlines | |
| */ | |
| private function plainText(array $inlines): string | |
| { | |
| $text = ''; | |
| foreach ($inlines as $inline) { | |
| $text .= $this->nodeText($inline); | |
| } | |
| return trim(preg_replace('/\s+/', ' ', $text) ?? $text); | |
| } | |
| private function nodeText(AstNode $node): string | |
| { | |
| if (isset($node->attrs['text'])) { | |
| return (string) $node->attrs['text']; | |
| } | |
| if ($node->type === 'linebreak') { | |
| return ' '; | |
| } | |
| $text = ''; | |
| foreach ($node->children as $child) { | |
| $text .= $this->nodeText($child); | |
| } | |
| return $text; | |
| } | |
| /** | |
| * @param list<AstNode> $nodes | |
| * @return list<AstNode> | |
| */ | |
| private function mergeAdjacentText(array $nodes): array | |
| { | |
| $merged = []; | |
| foreach ($nodes as $node) { | |
| $last = $merged[array_key_last($merged)] ?? null; | |
| if ($node->type === 'text' && $last instanceof AstNode && $last->type === 'text') { | |
| $merged[array_key_last($merged)] = new AstNode('text', [ | |
| 'text' => (string) $last->attr('text', '') . (string) $node->attr('text', ''), | |
| ]); | |
| continue; | |
| } | |
| $merged[] = $node; | |
| } | |
| return $merged; | |
| } | |
| private function normalizeWordTarget(string $target): string | |
| { | |
| if (preg_match('/^[a-z][a-z0-9+.-]*:/i', $target)) { | |
| return $target; | |
| } | |
| $target = str_replace('\\', '/', $target); | |
| if (str_starts_with($target, '../')) { | |
| $target = substr($target, 3); | |
| } elseif (!str_starts_with($target, 'word/')) { | |
| $target = 'word/' . ltrim($target, '/'); | |
| } | |
| return $target; | |
| } | |
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| <?php | |
| declare(strict_types=1); | |
| namespace PortLibs\Pandoc; | |
| final class EpubReader | |
| { | |
| private const OPF_MEDIA_TYPE = 'application/oebps-package+xml'; | |
| private const DC_NAMESPACE = 'http://purl.org/dc/elements/1.1/'; | |
| public function __construct(private readonly array $options = []) | |
| { | |
| } | |
| public function readEpubFile(string $path): AstNode | |
| { | |
| if (!class_exists(\ZipArchive::class)) { | |
| throw new \RuntimeException('EPUB analysis needs PHP ZipArchive, which is unavailable in this runtime.'); | |
| } | |
| $zip = new \ZipArchive(); | |
| if ($zip->open($path) !== true) { | |
| throw new \InvalidArgumentException("Unable to open EPUB package '{$path}'."); | |
| } | |
| try { | |
| $container_xml = $zip->getFromName('META-INF/container.xml'); | |
| if (!is_string($container_xml)) { | |
| throw new \InvalidArgumentException('EPUB package is missing META-INF/container.xml.'); | |
| } | |
| $rootfile = $this->rootfilePath($container_xml); | |
| $opf_xml = $zip->getFromName($rootfile); | |
| if (!is_string($opf_xml)) { | |
| throw new \InvalidArgumentException("EPUB package is missing OPF rootfile '{$rootfile}'."); | |
| } | |
| return $this->readPackage($zip, $rootfile, $opf_xml); | |
| } finally { | |
| $zip->close(); | |
| } | |
| } | |
| private function readPackage(\ZipArchive $zip, string $rootfile, string $opf_xml): AstNode | |
| { | |
| $dom = $this->loadXml($opf_xml, 'EPUB OPF package'); | |
| $package = $dom->documentElement; | |
| if (!$package instanceof \DOMElement || $package->localName !== 'package') { | |
| throw new \InvalidArgumentException('EPUB OPF root must be a package element.'); | |
| } | |
| $base_path = $this->dirname($rootfile); | |
| $metadata = $this->metadata($package); | |
| $manifest = $this->manifest($package); | |
| $spine_ids = $this->spineIds($package); | |
| $children = []; | |
| $resources = []; | |
| foreach ($spine_ids as $idref) { | |
| if (!isset($manifest[$idref])) { | |
| continue; | |
| } | |
| $item = $manifest[$idref]; | |
| $href = $this->normalizeZipPath($base_path . '/' . $item['href']); | |
| $media_type = strtolower($item['media-type']); | |
| if (!str_contains($media_type, 'html') && !str_ends_with($href, '.xhtml') && !str_ends_with($href, '.html')) { | |
| continue; | |
| } | |
| $xhtml = $zip->getFromName($href); | |
| if (!is_string($xhtml)) { | |
| continue; | |
| } | |
| $resources[] = $href; | |
| $document = (new MarkdownReader(['htmlNativeDivs' => true]))->read($this->rewriteRelativeLinks($xhtml, $this->dirname($href))); | |
| array_push($children, ...$document->children); | |
| } | |
| if ($children === []) { | |
| $children[] = new AstNode('paragraph', ['text' => 'No readable EPUB spine content was found.'], [ | |
| new AstNode('text', ['text' => 'No readable EPUB spine content was found.']), | |
| ]); | |
| } | |
| $metadata['epubRootfile'] = $rootfile; | |
| $metadata['epubSpineItems'] = count($spine_ids); | |
| $metadata['epubReadableResources'] = $resources; | |
| return new AstNode('document', ['meta' => $metadata], $children); | |
| } | |
| private function rootfilePath(string $container_xml): string | |
| { | |
| $dom = $this->loadXml($container_xml, 'EPUB container'); | |
| $xpath = new \DOMXPath($dom); | |
| $rootfiles = $xpath->query('//*[local-name()="rootfile"]'); | |
| if (!$rootfiles instanceof \DOMNodeList) { | |
| throw new \InvalidArgumentException('EPUB container rootfile list cannot be read.'); | |
| } | |
| $fallback = ''; | |
| foreach ($rootfiles as $rootfile) { | |
| if (!$rootfile instanceof \DOMElement) { | |
| continue; | |
| } | |
| $path = trim($rootfile->getAttribute('full-path')); | |
| if ($path === '') { | |
| continue; | |
| } | |
| if ($fallback === '') { | |
| $fallback = $path; | |
| } | |
| if (trim($rootfile->getAttribute('media-type')) === self::OPF_MEDIA_TYPE) { | |
| return $this->normalizeZipPath($path); | |
| } | |
| } | |
| if ($fallback !== '') { | |
| return $this->normalizeZipPath($fallback); | |
| } | |
| throw new \InvalidArgumentException('EPUB container does not declare an OPF rootfile.'); | |
| } | |
| /** | |
| * @return array<string, mixed> | |
| */ | |
| private function metadata(\DOMElement $package): array | |
| { | |
| $meta = []; | |
| foreach ($package->childNodes as $child) { | |
| if (!$child instanceof \DOMElement || $child->localName !== 'metadata') { | |
| continue; | |
| } | |
| foreach ($child->childNodes as $entry) { | |
| if (!$entry instanceof \DOMElement) { | |
| continue; | |
| } | |
| $name = $entry->localName; | |
| $text = trim(preg_replace('/\s+/', ' ', $entry->textContent) ?? $entry->textContent); | |
| if ($text === '') { | |
| continue; | |
| } | |
| if ($entry->namespaceURI === self::DC_NAMESPACE || in_array($name, ['title', 'creator', 'date', 'language', 'identifier', 'subject', 'description'], true)) { | |
| $key = match ($name) { | |
| 'creator' => 'author', | |
| 'language' => 'lang', | |
| default => $name, | |
| }; | |
| if (isset($meta[$key])) { | |
| $meta[$key] = is_array($meta[$key]) ? array_merge($meta[$key], [$text]) : [$meta[$key], $text]; | |
| } else { | |
| $meta[$key] = $text; | |
| } | |
| if ($key === 'title') { | |
| $meta['titleInlines'] = [new AstNode('text', ['text' => $text])]; | |
| } | |
| continue; | |
| } | |
| if ($name === 'meta' && trim($entry->getAttribute('property')) !== '') { | |
| $meta['epubProperties'][trim($entry->getAttribute('property'))][] = $text; | |
| } | |
| } | |
| } | |
| return $meta; | |
| } | |
| /** | |
| * @return array<string, array{href: string, media-type: string}> | |
| */ | |
| private function manifest(\DOMElement $package): array | |
| { | |
| $items = []; | |
| foreach ($package->getElementsByTagName('*') as $element) { | |
| if (!$element instanceof \DOMElement || $element->localName !== 'item') { | |
| continue; | |
| } | |
| $id = trim($element->getAttribute('id')); | |
| $href = trim($element->getAttribute('href')); | |
| if ($id === '' || $href === '') { | |
| continue; | |
| } | |
| $items[$id] = [ | |
| 'href' => html_entity_decode($href, ENT_QUOTES | ENT_XML1, 'UTF-8'), | |
| 'media-type' => trim($element->getAttribute('media-type')), | |
| ]; | |
| } | |
| return $items; | |
| } | |
| /** | |
| * @return list<string> | |
| */ | |
| private function spineIds(\DOMElement $package): array | |
| { | |
| $ids = []; | |
| foreach ($package->getElementsByTagName('*') as $element) { | |
| if (!$element instanceof \DOMElement || $element->localName !== 'itemref') { | |
| continue; | |
| } | |
| $idref = trim($element->getAttribute('idref')); | |
| if ($idref !== '') { | |
| $ids[] = $idref; | |
| } | |
| } | |
| return $ids; | |
| } | |
| private function rewriteRelativeLinks(string $html, string $base_path): string | |
| { | |
| return preg_replace_callback('/\b(src|href)=(["\'])([^"\']+)\2/i', function (array $match) use ($base_path): string { | |
| $url = html_entity_decode($match[3], ENT_QUOTES | ENT_HTML5, 'UTF-8'); | |
| if ($this->isAbsoluteUrl($url) || str_starts_with($url, '#') || str_starts_with($url, 'data:') || str_starts_with($url, 'mailto:')) { | |
| return $match[0]; | |
| } | |
| $rewritten = $this->normalizeZipPath($base_path . '/' . $url); | |
| return $match[1] . '=' . $match[2] . $rewritten . $match[2]; | |
| }, $html) ?? $html; | |
| } | |
| private function isAbsoluteUrl(string $url): bool | |
| { | |
| return (bool) preg_match('/^[a-z][a-z0-9+.-]*:/i', $url) || str_starts_with($url, '//'); | |
| } | |
| private function loadXml(string $xml, string $label): \DOMDocument | |
| { | |
| if (!class_exists(\DOMDocument::class)) { | |
| throw new \RuntimeException($label . ' needs DOMDocument, which is unavailable in this runtime.'); | |
| } | |
| $dom = new \DOMDocument('1.0', 'UTF-8'); | |
| $previous = libxml_use_internal_errors(true); | |
| $ok = $dom->loadXML($xml, LIBXML_NONET | LIBXML_NOERROR | LIBXML_NOWARNING); | |
| libxml_clear_errors(); | |
| libxml_use_internal_errors($previous); | |
| if (!$ok) { | |
| throw new \InvalidArgumentException($label . ' is not valid XML.'); | |
| } | |
| return $dom; | |
| } | |
| private function dirname(string $path): string | |
| { | |
| $dir = str_replace('\\', '/', dirname($path)); | |
| return $dir === '.' ? '' : $dir; | |
| } | |
| private function normalizeZipPath(string $path): string | |
| { | |
| $parts = []; | |
| foreach (explode('/', str_replace('\\', '/', $path)) as $part) { | |
| if ($part === '' || $part === '.') { | |
| continue; | |
| } | |
| if ($part === '..') { | |
| array_pop($parts); | |
| continue; | |
| } | |
| $parts[] = $part; | |
| } | |
| return implode('/', $parts); | |
| } | |
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| <?php | |
| declare(strict_types=1); | |
| namespace PortLibs\Pandoc; | |
| final class HtmlWriter | |
| { | |
| /** @var list<array{number:int, node:AstNode}> */ | |
| private array $footnotes = []; | |
| private int $nextFootnoteNumber = 1; | |
| private int $flushedFootnoteCount = 0; | |
| /** | |
| * @param array{htmlQTags?: bool, htmlMathMethod?: string|array<string, mixed>, mathMethod?: string, writerHTMLMathMethod?: string|array<string, mixed>, referenceLocation?: string, writerReferenceLocation?: string, sectionDivs?: bool, writerSectionDivs?: bool, writerWrapText?: string, wrap?: string} $options | |
| */ | |
| public function __construct(private readonly array $options = []) | |
| { | |
| } | |
| public function write(AstNode $document): string | |
| { | |
| if ($document->type !== 'document') { | |
| throw new \InvalidArgumentException('HTML writer expects a document node'); | |
| } | |
| $previousFootnotes = $this->footnotes; | |
| $previousNextFootnoteNumber = $this->nextFootnoteNumber; | |
| $previousFlushedFootnoteCount = $this->flushedFootnoteCount; | |
| $this->footnotes = []; | |
| $this->nextFootnoteNumber = 1; | |
| $this->flushedFootnoteCount = 0; | |
| try { | |
| return $this->renderDocument($document); | |
| } finally { | |
| $this->footnotes = $previousFootnotes; | |
| $this->nextFootnoteNumber = $previousNextFootnoteNumber; | |
| $this->flushedFootnoteCount = $previousFlushedFootnoteCount; | |
| } | |
| } | |
| private function renderDocument(AstNode $document): string | |
| { | |
| if ($this->usesSectionDivs()) { | |
| return $this->renderSectionDivDocument($document); | |
| } | |
| $referenceLocation = $this->referenceLocation(); | |
| $blocks = []; | |
| foreach ($document->children as $node) { | |
| if ($referenceLocation === 'end_of_section' && $node->type === 'heading' && $blocks !== []) { | |
| $this->appendRenderedBlock($blocks, $this->renderPendingFootnotes($referenceLocation)); | |
| } | |
| $html = $this->renderBlock($node); | |
| if ($html !== '') { | |
| $blocks[] = $html; | |
| } | |
| if ($referenceLocation === 'end_of_block') { | |
| $this->appendRenderedBlock($blocks, $this->renderPendingFootnotes($referenceLocation)); | |
| } | |
| } | |
| $this->appendRenderedBlock($blocks, $this->renderPendingFootnotes($referenceLocation)); | |
| return implode("\n", $blocks); | |
| } | |
| private function usesSectionDivs(): bool | |
| { | |
| return (bool) ($this->options['writerSectionDivs'] ?? $this->options['sectionDivs'] ?? false); | |
| } | |
| private function renderSectionDivDocument(AstNode $document): string | |
| { | |
| $index = 0; | |
| $items = $this->collectSectionItems($document->children, $index, 0); | |
| $blocks = $this->renderSectionItems($items, true); | |
| $this->appendRenderedBlock($blocks, $this->renderPendingFootnotes($this->referenceLocation())); | |
| return implode("\n", $blocks); | |
| } | |
| /** | |
| * @param list<AstNode> $nodes | |
| * @return list<array{type:string, node?:AstNode, heading?:AstNode, level?:int, children?:array<int, mixed>}> | |
| */ | |
| private function collectSectionItems(array $nodes, int &$index, int $parentLevel): array | |
| { | |
| $items = []; | |
| $count = count($nodes); | |
| while ($index < $count) { | |
| $node = $nodes[$index]; | |
| if ($node->type === 'heading') { | |
| $level = max(1, min(6, (int) $node->attr('level', 1))); | |
| if ($level <= $parentLevel) { | |
| break; | |
| } | |
| $index++; | |
| $items[] = [ | |
| 'type' => 'section', | |
| 'heading' => $node, | |
| 'level' => $level, | |
| 'children' => $this->collectSectionItems($nodes, $index, $level), | |
| ]; | |
| continue; | |
| } | |
| $items[] = [ | |
| 'type' => 'block', | |
| 'node' => $node, | |
| ]; | |
| $index++; | |
| } | |
| return $items; | |
| } | |
| /** | |
| * @param list<array{type:string, node?:AstNode, heading?:AstNode, level?:int, children?:array<int, mixed>}> $items | |
| * @return list<string> | |
| */ | |
| private function renderSectionItems(array $items, bool $root = false): array | |
| { | |
| $referenceLocation = $this->referenceLocation(); | |
| $blocks = []; | |
| foreach ($items as $item) { | |
| if ($item['type'] === 'section') { | |
| if ($referenceLocation === 'end_of_section') { | |
| $this->appendRenderedBlock($blocks, $this->renderPendingFootnotes($referenceLocation)); | |
| } | |
| $heading = $item['heading'] ?? null; | |
| $children = $item['children'] ?? []; | |
| if ($heading instanceof AstNode && is_array($children)) { | |
| $blocks[] = $this->renderSectionDiv($heading, (int) ($item['level'] ?? 1), $children); | |
| } | |
| continue; | |
| } | |
| $node = $item['node'] ?? null; | |
| if (!$node instanceof AstNode) { | |
| continue; | |
| } | |
| $html = $this->renderBlock($node); | |
| if ($html !== '') { | |
| $blocks[] = $html; | |
| } | |
| if ($referenceLocation === 'end_of_block') { | |
| $this->appendRenderedBlock($blocks, $this->renderPendingFootnotes($referenceLocation)); | |
| } | |
| } | |
| if ($root && $referenceLocation === 'end_of_section') { | |
| $this->appendRenderedBlock($blocks, $this->renderPendingFootnotes($referenceLocation)); | |
| } | |
| return $blocks; | |
| } | |
| /** | |
| * @param list<array{type:string, node?:AstNode, heading?:AstNode, level?:int, children?:array<int, mixed>}> $children | |
| */ | |
| private function renderSectionDiv(AstNode $heading, int $level, array $children): string | |
| { | |
| $referenceLocation = $this->referenceLocation(); | |
| $lines = ['<div' . $this->renderSectionAttributes($heading, $level) . '>']; | |
| $lines[] = $this->renderSectionHeading($heading); | |
| foreach ($this->renderSectionItems($children) as $html) { | |
| $lines[] = $html; | |
| } | |
| if ($referenceLocation === 'end_of_section') { | |
| $this->appendRenderedBlock($lines, $this->renderPendingFootnotes($referenceLocation)); | |
| } | |
| $lines[] = '</div>'; | |
| return implode("\n", $lines); | |
| } | |
| private function renderSectionAttributes(AstNode $heading, int $level): string | |
| { | |
| $attrs = $this->attrTuple($heading); | |
| $attrs['classes'] = array_values(array_unique(array_merge( | |
| ['section', 'level' . $level], | |
| $attrs['classes'] | |
| ))); | |
| return $this->renderAttrTuple($attrs); | |
| } | |
| private function renderSectionHeading(AstNode $heading): string | |
| { | |
| $level = max(1, min(6, (int) $heading->attr('level', 1))); | |
| $attrs = $this->attrTuple($heading); | |
| $attrs['id'] = ''; | |
| return '<h' . $level . $this->renderAttrTuple($attrs, $this->allowedHeadingAttribute(...)) . '>' | |
| . $this->renderInlines($heading->children) | |
| . '</h' . $level . '>'; | |
| } | |
| private function renderBlock(AstNode $node): string | |
| { | |
| return match ($node->type) { | |
| 'plain' => $this->renderInlines($node->children), | |
| 'paragraph' => '<p>' . $this->renderInlines($node->children) . '</p>', | |
| 'heading' => $this->renderHeading($node), | |
| 'code_block' => $this->renderCodeBlock($node), | |
| 'horizontal_rule' => $this->renderHorizontalRule(), | |
| 'line_block' => $this->renderLineBlock($node), | |
| 'figure' => $this->renderFigure($node), | |
| 'table' => $this->renderTable($node), | |
| 'div' => $this->renderDiv($node), | |
| 'bullet_list' => $this->renderList($node, false), | |
| 'ordered_list' => $this->renderList($node, true), | |
| 'definition_list' => $this->renderDefinitionList($node), | |
| 'blockquote' => $this->renderBlockQuote($node), | |
| 'raw_html' => (string) $node->attr('html', $node->attr('text', '')), | |
| 'raw_block' => $this->renderRawBlock($node), | |
| 'raw_tex' => '', | |
| default => $this->renderInlines($node->children), | |
| }; | |
| } | |
| private function renderHeading(AstNode $node): string | |
| { | |
| $level = max(1, min(6, (int) $node->attr('level', 1))); | |
| return '<h' . $level . $this->renderAttributes($node, $this->allowedHeadingAttribute(...)) . '>' | |
| . $this->renderInlines($node->children) | |
| . '</h' . $level . '>'; | |
| } | |
| private function renderCodeBlock(AstNode $node): string | |
| { | |
| return '<pre' . $this->renderAttributes($node) . '><code>' | |
| . $this->esc((string) $node->attr('text', '')) | |
| . '</code></pre>'; | |
| } | |
| private function renderHorizontalRule(): string | |
| { | |
| return '<hr />'; | |
| } | |
| private function renderLineBlock(AstNode $node): string | |
| { | |
| $lines = []; | |
| foreach ($node->children as $line) { | |
| if ($line->type !== 'line') { | |
| continue; | |
| } | |
| $lines[] = $line->children === [] | |
| ? $this->esc((string) $line->attr('text', '')) | |
| : $this->renderInlines($line->children); | |
| } | |
| return '<div class="line-block">' . implode('<br />', $lines) . '</div>'; | |
| } | |
| private function renderDiv(AstNode $node): string | |
| { | |
| if ($this->divIsWrapper($node)) { | |
| $html = $this->renderBlock($node->children[0]); | |
| return $html === '' ? '' : $this->addRootAttributes($html, $this->wrapperDivAttributes($node)); | |
| } | |
| $attrs = $this->attrTuple($node); | |
| $isCslBibBody = $attrs['id'] === 'refs' || in_array('csl-bib-body', $attrs['classes'], true); | |
| $isCslBibEntry = in_array('csl-entry', $attrs['classes'], true); | |
| if ($isCslBibBody && !array_key_exists('role', $attrs['attributes'])) { | |
| $attrs['attributes']['role'] = 'list'; | |
| } | |
| if ($isCslBibEntry && !array_key_exists('role', $attrs['attributes'])) { | |
| $attrs['attributes']['role'] = 'listitem'; | |
| } | |
| $tag = 'div'; | |
| if (in_array('section', $attrs['classes'], true)) { | |
| $tag = 'section'; | |
| $attrs['classes'] = array_values(array_filter( | |
| $attrs['classes'], | |
| static fn (string $class): bool => $class !== 'section' | |
| )); | |
| } | |
| if (in_array('column', $attrs['classes'], true) && isset($attrs['attributes']['width'])) { | |
| $width = $attrs['attributes']['width']; | |
| unset($attrs['attributes']['width']); | |
| $existingStyle = rtrim((string) ($attrs['attributes']['style'] ?? ''), ';'); | |
| $attrs['attributes']['style'] = ($existingStyle === '' ? '' : $existingStyle . ';') . 'width:' . $width . ';'; | |
| } | |
| $lines = ['<' . $tag . $this->renderAttrTuple($attrs) . '>']; | |
| foreach ($node->children as $child) { | |
| $html = $this->renderDivChild($child, $isCslBibEntry); | |
| if ($html !== '') { | |
| $lines[] = $html; | |
| } | |
| } | |
| $lines[] = '</' . $tag . '>'; | |
| return implode("\n", $lines); | |
| } | |
| private function divIsWrapper(AstNode $node): bool | |
| { | |
| if (count($node->children) !== 1) { | |
| return false; | |
| } | |
| $attrs = $this->attrTuple($node); | |
| return ($attrs['attributes']['wrapper'] ?? '') === '1'; | |
| } | |
| /** | |
| * @return array{id:string, classes:list<string>, attributes:array<string, string>} | |
| */ | |
| private function wrapperDivAttributes(AstNode $node): array | |
| { | |
| $attrs = $this->attrTuple($node); | |
| unset($attrs['attributes']['wrapper']); | |
| return $attrs; | |
| } | |
| private function renderDivChild(AstNode $node, bool $cslEntry): string | |
| { | |
| if ($cslEntry && $node->type === 'paragraph') { | |
| return $this->renderInlines($node->children); | |
| } | |
| return $this->renderBlock($node); | |
| } | |
| /** | |
| * @param array{id:string, classes:list<string>, attributes:array<string, string>} $attrs | |
| */ | |
| private function addRootAttributes(string $html, array $attrs): string | |
| { | |
| if ($attrs['id'] === '' && $attrs['classes'] === [] && $attrs['attributes'] === []) { | |
| return $html; | |
| } | |
| if (preg_match('/^<([A-Za-z][A-Za-z0-9:-]*)([^>]*)>/', $html, $match) !== 1) { | |
| return $html; | |
| } | |
| $openingTag = $match[0]; | |
| $tag = $match[1]; | |
| $tail = $match[2]; | |
| $selfClosing = str_ends_with(rtrim($tail), '/'); | |
| if ($selfClosing) { | |
| $tail = rtrim(substr(rtrim($tail), 0, -1)); | |
| } | |
| $merged = $this->mergeRenderedAttributes($tail, $attrs); | |
| $replacement = '<' . $tag . $this->renderAttrTuple($merged) . ($selfClosing ? ' /' : '') . '>'; | |
| return $replacement . substr($html, strlen($openingTag)); | |
| } | |
| /** | |
| * @param array{id:string, classes:list<string>, attributes:array<string, string>} $attrs | |
| * @return array{id:string, classes:list<string>, attributes:array<string, string>} | |
| */ | |
| private function mergeRenderedAttributes(string $tail, array $attrs): array | |
| { | |
| $merged = [ | |
| 'id' => '', | |
| 'classes' => [], | |
| 'attributes' => [], | |
| ]; | |
| if (preg_match_all('/\s+([A-Za-z_:][A-Za-z0-9_.:-]*)="([^"]*)"/', $tail, $matches, PREG_SET_ORDER) !== false) { | |
| foreach ($matches as $match) { | |
| $name = $match[1]; | |
| $value = html_entity_decode($match[2], ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'); | |
| if ($name === 'id') { | |
| $merged['id'] = $value; | |
| continue; | |
| } | |
| if ($name === 'class') { | |
| $classes = preg_split('/\s+/', trim($value)) ?: []; | |
| $merged['classes'] = array_values(array_filter($classes, static fn (string $class): bool => $class !== '')); | |
| continue; | |
| } | |
| $merged['attributes'][$name] = $value; | |
| } | |
| } | |
| if ($merged['id'] === '' && $attrs['id'] !== '') { | |
| $merged['id'] = $attrs['id']; | |
| } | |
| foreach ($attrs['classes'] as $class) { | |
| if (!in_array($class, $merged['classes'], true)) { | |
| $merged['classes'][] = $class; | |
| } | |
| } | |
| foreach ($attrs['attributes'] as $name => $value) { | |
| if (!array_key_exists($name, $merged['attributes'])) { | |
| $merged['attributes'][$name] = $value; | |
| } | |
| } | |
| return $merged; | |
| } | |
| private function renderRawBlock(AstNode $node): string | |
| { | |
| $format = strtolower((string) $node->attr('format', '')); | |
| if (!$this->isRawHtmlFormat($format)) { | |
| return ''; | |
| } | |
| return (string) $node->attr('text', $node->attr('html', '')); | |
| } | |
| private function isRawHtmlFormat(string $format): bool | |
| { | |
| return in_array($format, [ | |
| 'html', | |
| 'html4', | |
| 'html5', | |
| 'epub', | |
| 'epub2', | |
| 'epub3', | |
| 's5', | |
| 'slidy', | |
| 'slideous', | |
| 'dzslides', | |
| 'revealjs', | |
| ], true); | |
| } | |
| private function renderFigure(AstNode $node): string | |
| { | |
| $lines = ['<figure' . $this->renderAttributes($node) . '>']; | |
| foreach ($node->children as $child) { | |
| $html = $this->renderFigureBodyBlock($child); | |
| if ($html !== '') { | |
| $lines[] = $html; | |
| } | |
| } | |
| $caption = $this->renderFigureCaption($node); | |
| if ($caption !== '') { | |
| $ariaHidden = $this->figureCaptionMatchesImageAlt($node) ? ' aria-hidden="true"' : ''; | |
| $lines[] = '<figcaption' . $ariaHidden . '>' . $caption . '</figcaption>'; | |
| } | |
| $lines[] = '</figure>'; | |
| return implode("\n", $lines); | |
| } | |
| private function renderFigureBodyBlock(AstNode $node): string | |
| { | |
| if ($this->isInlineNode($node)) { | |
| return $this->renderInline($node); | |
| } | |
| return $this->renderBlock($node); | |
| } | |
| private function renderFigureCaption(AstNode $node): string | |
| { | |
| $captionInlines = $node->attr('captionInlines', null); | |
| if (is_array($captionInlines)) { | |
| $html = ''; | |
| foreach ($captionInlines as $inline) { | |
| if (!$inline instanceof AstNode) { | |
| $html = ''; | |
| break; | |
| } | |
| $html .= $this->renderInline($inline); | |
| } | |
| if ($html !== '') { | |
| return $html; | |
| } | |
| } | |
| $caption = (string) $node->attr('caption', ''); | |
| return $caption === '' ? '' : $this->esc($caption); | |
| } | |
| private function figureCaptionMatchesImageAlt(AstNode $node): bool | |
| { | |
| $caption = $this->plainFigureCaptionText($node); | |
| if ($caption === '') { | |
| return false; | |
| } | |
| $image = $this->firstFigureImage($node); | |
| if (!$image instanceof AstNode) { | |
| return false; | |
| } | |
| $alt = (string) $image->attr('alt', ''); | |
| if ($alt === '') { | |
| $alt = $this->plainInlineText($image->children); | |
| } | |
| return $caption === $alt; | |
| } | |
| private function firstFigureImage(AstNode $node): ?AstNode | |
| { | |
| foreach ($node->children as $child) { | |
| if ($child->type === 'image') { | |
| return $child; | |
| } | |
| $nested = $this->firstFigureImage($child); | |
| if ($nested instanceof AstNode) { | |
| return $nested; | |
| } | |
| } | |
| return null; | |
| } | |
| private function plainFigureCaptionText(AstNode $node): string | |
| { | |
| $captionInlines = $node->attr('captionInlines', null); | |
| if (is_array($captionInlines)) { | |
| $allInlines = true; | |
| foreach ($captionInlines as $inline) { | |
| if (!$inline instanceof AstNode) { | |
| $allInlines = false; | |
| break; | |
| } | |
| } | |
| if ($allInlines) { | |
| return $this->plainInlineText($captionInlines); | |
| } | |
| } | |
| return trim((string) $node->attr('caption', '')); | |
| } | |
| private function renderTable(AstNode $node): string | |
| { | |
| $head = null; | |
| $bodies = []; | |
| $foot = null; | |
| foreach ($node->children as $child) { | |
| if ($child->type === 'table_head') { | |
| $head = $child; | |
| continue; | |
| } | |
| if ($child->type === 'table_body') { | |
| $bodies[] = $child; | |
| continue; | |
| } | |
| if ($child->type === 'table_foot') { | |
| $foot = $child; | |
| } | |
| } | |
| $lines = ['<table' . $this->renderTableAttributes($node) . '>']; | |
| $this->appendRenderedBlock($lines, $this->renderTableCaption($node)); | |
| $this->appendRenderedBlock($lines, $this->renderTableColgroup($node)); | |
| if ($head instanceof AstNode && $this->tableRows($head) !== []) { | |
| $lines[] = '<thead' . $this->renderHtmlAttributes($head) . '>'; | |
| foreach ($this->tableRows($head) as $row) { | |
| $lines[] = $this->renderTableRow($row, $node, true); | |
| } | |
| $lines[] = '</thead>'; | |
| } | |
| foreach ($bodies as $body) { | |
| $lines[] = '<tbody' . $this->renderHtmlAttributes($body) . '>'; | |
| $headRows = $body->attr('headRows', []); | |
| if (is_array($headRows)) { | |
| foreach ($headRows as $row) { | |
| if ($row instanceof AstNode && $row->type === 'table_row') { | |
| $lines[] = $this->renderTableRow($row, $node, true); | |
| } | |
| } | |
| } | |
| $rowHeadColumns = max(0, (int) $body->attr('rowHeadColumns', 0)); | |
| foreach ($this->tableRows($body) as $row) { | |
| $lines[] = $this->renderTableRow($row, $node, false, $rowHeadColumns); | |
| } | |
| $lines[] = '</tbody>'; | |
| } | |
| if ($foot instanceof AstNode && $this->tableRows($foot) !== []) { | |
| $lines[] = '<tfoot' . $this->renderHtmlAttributes($foot) . '>'; | |
| foreach ($this->tableRows($foot) as $row) { | |
| $lines[] = $this->renderTableRow($row, $node, false); | |
| } | |
| $lines[] = '</tfoot>'; | |
| } | |
| $lines[] = '</table>'; | |
| return implode("\n", $lines); | |
| } | |
| private function renderTableAttributes(AstNode $node): string | |
| { | |
| $attrs = $this->htmlAttrTuple($node); | |
| $totalWidth = $this->tableTotalExplicitWidth($node); | |
| if ( | |
| $totalWidth > 0.0 | |
| && $totalWidth < 1.0 | |
| && !array_key_exists('style', $attrs['attributes']) | |
| ) { | |
| $attrs['attributes']['style'] = 'width:' . (int) round($totalWidth * 100) . '%;'; | |
| } | |
| return $this->renderAttrTuple($attrs); | |
| } | |
| private function renderTableCaption(AstNode $node): string | |
| { | |
| $captionInlines = $node->attr('captionInlines', null); | |
| if (is_array($captionInlines)) { | |
| $html = ''; | |
| foreach ($captionInlines as $inline) { | |
| if (!$inline instanceof AstNode) { | |
| $html = ''; | |
| break; | |
| } | |
| $html .= $this->renderInline($inline); | |
| } | |
| if ($html !== '') { | |
| return '<caption>' . $html . '</caption>'; | |
| } | |
| } | |
| $caption = (string) $node->attr('caption', ''); | |
| return $caption === '' ? '' : '<caption>' . $this->esc($caption) . '</caption>'; | |
| } | |
| private function renderTableColgroup(AstNode $node): string | |
| { | |
| $widths = $node->attr('widths', null); | |
| if (!is_array($widths) || $widths === []) { | |
| return ''; | |
| } | |
| $hasExplicitWidth = false; | |
| foreach ($widths as $width) { | |
| if (is_numeric($width) && (float) $width > 0.0) { | |
| $hasExplicitWidth = true; | |
| break; | |
| } | |
| } | |
| if (!$hasExplicitWidth) { | |
| return ''; | |
| } | |
| $cols = []; | |
| foreach ($widths as $width) { | |
| if (is_numeric($width) && (float) $width > 0.0) { | |
| $cols[] = '<col style="width: ' . (int) floor((float) $width * 100) . '%" />'; | |
| continue; | |
| } | |
| $cols[] = '<col />'; | |
| } | |
| return '<colgroup>' . implode('', $cols) . '</colgroup>'; | |
| } | |
| private function tableTotalExplicitWidth(AstNode $node): float | |
| { | |
| $widths = $node->attr('widths', null); | |
| if (!is_array($widths)) { | |
| return 0.0; | |
| } | |
| $total = 0.0; | |
| foreach ($widths as $width) { | |
| if (is_numeric($width) && (float) $width > 0.0) { | |
| $total += (float) $width; | |
| } | |
| } | |
| return $total; | |
| } | |
| /** | |
| * @return list<AstNode> | |
| */ | |
| private function tableRows(AstNode $section): array | |
| { | |
| return array_values(array_filter( | |
| $section->children, | |
| static fn (AstNode $child): bool => $child->type === 'table_row' | |
| )); | |
| } | |
| private function renderTableRow(AstNode $row, AstNode $table, bool $header, int $rowHeadColumns = 0): string | |
| { | |
| $html = '<tr' . $this->renderHtmlAttributes($row) . '>'; | |
| $logicalColumn = 0; | |
| foreach ($row->children as $cell) { | |
| if ($cell->type !== 'table_cell') { | |
| continue; | |
| } | |
| $colspan = max(1, (int) $cell->attr('colspan', 1)); | |
| $tag = $header | |
| || $cell->attr('header') === true | |
| || ($logicalColumn < $rowHeadColumns && $logicalColumn + $colspan <= $rowHeadColumns) | |
| ? 'th' | |
| : 'td'; | |
| $html .= '<' . $tag . $this->renderTableCellAttributes($table, $logicalColumn, $cell) . '>' | |
| . $this->renderTableCellContents($cell) | |
| . '</' . $tag . '>'; | |
| $logicalColumn += $colspan; | |
| } | |
| return $html . '</tr>'; | |
| } | |
| private function renderTableCellAttributes(AstNode $table, int $logicalColumn, AstNode $cell): string | |
| { | |
| $attrs = $this->htmlAttrTuple($cell); | |
| $sourceStyle = trim((string) ($attrs['attributes']['style'] ?? '')); | |
| unset($attrs['attributes']['style']); | |
| $colspan = (int) $cell->attr('colspan', 1); | |
| if ($colspan > 1) { | |
| $attrs['attributes']['colspan'] = (string) $colspan; | |
| } | |
| $rowspan = (int) $cell->attr('rowspan', 1); | |
| if ($rowspan > 1) { | |
| $attrs['attributes']['rowspan'] = (string) $rowspan; | |
| } | |
| $styles = []; | |
| if ($sourceStyle !== '') { | |
| $styles[] = rtrim($sourceStyle, ';'); | |
| } | |
| $alignment = (string) $cell->attr('align', ''); | |
| $alignments = $table->attr('alignments', []); | |
| if ($alignment === '' && is_array($alignments)) { | |
| $alignment = (string) ($alignments[$logicalColumn] ?? 'default'); | |
| } | |
| if ( | |
| in_array($alignment, ['left', 'right', 'center'], true) | |
| && preg_match('/(?:^|;)\s*text-align\s*:/i', $sourceStyle) !== 1 | |
| ) { | |
| $styles[] = 'text-align:' . $alignment; | |
| } | |
| if ($styles !== []) { | |
| $attrs['attributes']['style'] = implode('; ', $styles); | |
| } | |
| return $this->renderAttrTuple($attrs); | |
| } | |
| private function renderTableCellContents(AstNode $cell): string | |
| { | |
| if ($cell->children === []) { | |
| return $this->esc((string) $cell->attr('text', '')); | |
| } | |
| $html = ''; | |
| foreach ($cell->children as $child) { | |
| $html .= $this->isInlineNode($child) | |
| ? $this->renderInline($child) | |
| : $this->renderBlock($child); | |
| } | |
| return $html; | |
| } | |
| private function renderList(AstNode $node, bool $ordered): string | |
| { | |
| $tag = $ordered ? 'ol' : 'ul'; | |
| $lines = ['<' . $tag . $this->renderListAttributes($node, $ordered) . '>']; | |
| foreach ($node->children as $item) { | |
| if ($item->type === 'list_item') { | |
| $lines[] = $this->renderListItem($item); | |
| } | |
| } | |
| $lines[] = '</' . $tag . '>'; | |
| return implode("\n", $lines); | |
| } | |
| private function renderListAttributes(AstNode $node, bool $ordered): string | |
| { | |
| $attrs = $this->attrTuple($node); | |
| if ($ordered) { | |
| $listAttributes = []; | |
| $start = (int) $node->attr('start', 1); | |
| if ($start !== 1) { | |
| $listAttributes['start'] = (string) $start; | |
| } | |
| $type = $this->orderedListHtmlType((string) $node->attr('style', 'default')); | |
| if ($type !== '') { | |
| $listAttributes['type'] = $type; | |
| } | |
| if ((string) $node->attr('style', '') === 'example' && !in_array('example', $attrs['classes'], true)) { | |
| $attrs['classes'][] = 'example'; | |
| } | |
| $attrs['attributes'] = $listAttributes + $attrs['attributes']; | |
| return $this->renderAttrTuple($attrs); | |
| } | |
| if ($this->listIsTaskList($node) && !in_array('task-list', $attrs['classes'], true)) { | |
| $attrs['classes'][] = 'task-list'; | |
| } | |
| return $this->renderAttrTuple($attrs); | |
| } | |
| private function orderedListHtmlType(string $style): string | |
| { | |
| return match ($style) { | |
| 'decimal', 'example' => '1', | |
| 'lower_alpha' => 'a', | |
| 'upper_alpha' => 'A', | |
| 'lower_roman' => 'i', | |
| 'upper_roman' => 'I', | |
| default => '', | |
| }; | |
| } | |
| private function renderListItem(AstNode $item): string | |
| { | |
| $parts = []; | |
| $inlineNodes = []; | |
| $taskChecked = $item->attr('taskChecked', null); | |
| $taskPending = is_bool($taskChecked); | |
| foreach ($item->children as $child) { | |
| if ($this->isInlineNode($child)) { | |
| $inlineNodes[] = $child; | |
| continue; | |
| } | |
| $this->appendListItemInlinePart($parts, $inlineNodes, $taskChecked, $taskPending); | |
| if ($child->type === 'plain') { | |
| $html = $this->renderInlines($child->children); | |
| if ($taskPending) { | |
| $html = $this->renderTaskListLabel((bool) $taskChecked, $html); | |
| $taskPending = false; | |
| } | |
| $parts[] = $html; | |
| continue; | |
| } | |
| if ($child->type === 'paragraph') { | |
| $html = $this->renderInlines($child->children); | |
| if ($taskPending) { | |
| $html = $this->renderTaskListLabel((bool) $taskChecked, $html); | |
| $taskPending = false; | |
| } | |
| $parts[] = '<p>' . $html . '</p>'; | |
| continue; | |
| } | |
| $html = $this->renderBlock($child); | |
| if ($html !== '') { | |
| $parts[] = $html; | |
| } | |
| } | |
| $this->appendListItemInlinePart($parts, $inlineNodes, $taskChecked, $taskPending); | |
| if ($taskPending) { | |
| $parts[] = $this->renderTaskListLabel((bool) $taskChecked, ''); | |
| } | |
| if ($parts === []) { | |
| return '<li></li>'; | |
| } | |
| return '<li>' . implode("\n", $parts) . '</li>'; | |
| } | |
| /** | |
| * @param list<string> $parts | |
| * @param list<AstNode> $inlineNodes | |
| */ | |
| private function appendListItemInlinePart( | |
| array &$parts, | |
| array &$inlineNodes, | |
| mixed $taskChecked, | |
| bool &$taskPending | |
| ): void { | |
| if ($inlineNodes === []) { | |
| return; | |
| } | |
| $html = $this->renderInlines($inlineNodes); | |
| if ($taskPending && is_bool($taskChecked)) { | |
| $html = $this->renderTaskListLabel($taskChecked, $html); | |
| $taskPending = false; | |
| } | |
| $parts[] = $html; | |
| $inlineNodes = []; | |
| } | |
| private function renderTaskListLabel(bool $checked, string $html): string | |
| { | |
| return '<label><input type="checkbox"' . ($checked ? ' checked=""' : '') . ' />' . $html . '</label>'; | |
| } | |
| private function listIsTaskList(AstNode $node): bool | |
| { | |
| if ($node->attr('taskList') === true) { | |
| return true; | |
| } | |
| if ($node->children === []) { | |
| return false; | |
| } | |
| foreach ($node->children as $child) { | |
| if ($child->type !== 'list_item' || !is_bool($child->attr('taskChecked', null))) { | |
| return false; | |
| } | |
| } | |
| return true; | |
| } | |
| private function renderDefinitionList(AstNode $node): string | |
| { | |
| $lines = ['<dl>']; | |
| foreach ($node->children as $item) { | |
| if ($item->type !== 'definition_item') { | |
| continue; | |
| } | |
| $children = $item->children; | |
| $term = array_shift($children); | |
| if (!$term instanceof AstNode || $term->type !== 'term') { | |
| $term = new AstNode('term', ['text' => (string) $item->attr('term', '')]); | |
| } | |
| $termHtml = $term->children === [] | |
| ? $this->esc((string) $term->attr('text', '')) | |
| : $this->renderInlines($term->children); | |
| $lines[] = '<dt>' . $termHtml . '</dt>'; | |
| foreach ($children as $definition) { | |
| if ($definition->type !== 'definition') { | |
| continue; | |
| } | |
| $lines[] = '<dd>'; | |
| foreach ($this->renderDefinitionBlocks($definition) as $html) { | |
| $lines[] = $html; | |
| } | |
| $lines[] = '</dd>'; | |
| } | |
| } | |
| $lines[] = '</dl>'; | |
| return implode("\n", $lines); | |
| } | |
| private function renderBlockQuote(AstNode $node): string | |
| { | |
| $lines = ['<blockquote>']; | |
| foreach ($node->children as $child) { | |
| $html = $this->renderBlock($child); | |
| if ($html !== '') { | |
| $lines[] = $html; | |
| } | |
| } | |
| $lines[] = '</blockquote>'; | |
| return implode("\n", $lines); | |
| } | |
| /** | |
| * @return list<string> | |
| */ | |
| private function renderDefinitionBlocks(AstNode $node): array | |
| { | |
| $blocks = []; | |
| foreach ($node->children as $child) { | |
| $html = $this->renderBlock($child); | |
| if ($html !== '') { | |
| $blocks[] = $html; | |
| } | |
| } | |
| return $blocks; | |
| } | |
| /** | |
| * @param list<AstNode> $nodes | |
| */ | |
| private function renderInlines(array $nodes): string | |
| { | |
| $html = ''; | |
| foreach ($nodes as $node) { | |
| $html .= $this->renderInline($node); | |
| } | |
| return $html; | |
| } | |
| private function renderInline(AstNode $node): string | |
| { | |
| return match ($node->type) { | |
| 'text' => $this->esc((string) $node->attr('text', '')), | |
| 'softbreak' => $this->renderSoftBreak(), | |
| 'linebreak' => $this->renderLineBreak(), | |
| 'code' => $this->renderCode($node), | |
| 'image' => $this->renderImage($node), | |
| 'emph' => '<em>' . $this->renderInlines($node->children) . '</em>', | |
| 'strong' => '<strong>' . $this->renderInlines($node->children) . '</strong>', | |
| 'underline' => '<u>' . $this->renderInlines($node->children) . '</u>', | |
| 'strikeout' => '<del>' . $this->renderInlines($node->children) . '</del>', | |
| 'small_caps' => '<span class="smallcaps">' . $this->renderInlines($node->children) . '</span>', | |
| 'superscript' => '<sup>' . $this->renderInlines($node->children) . '</sup>', | |
| 'subscript' => '<sub>' . $this->renderInlines($node->children) . '</sub>', | |
| 'span' => $this->renderSpan($node), | |
| 'quoted' => $this->renderQuoted($node), | |
| 'link' => $this->renderLink($node), | |
| 'note' => $this->renderNoteReference($node), | |
| 'citation' => $this->renderCitation($node), | |
| 'math' => $this->renderMath($node), | |
| 'raw_html_inline' => (string) $node->attr('html', $node->attr('text', '')), | |
| 'raw_inline' => $this->renderRawInline($node), | |
| default => $this->renderInlines($node->children), | |
| }; | |
| } | |
| private function renderSoftBreak(): string | |
| { | |
| return $this->htmlWrapText() === 'preserve' ? "\n" : ' '; | |
| } | |
| private function renderLineBreak(): string | |
| { | |
| return '<br />' . "\n"; | |
| } | |
| private function renderRawInline(AstNode $node): string | |
| { | |
| $format = strtolower((string) $node->attr('format', '')); | |
| if (!$this->isRawHtmlFormat($format)) { | |
| return ''; | |
| } | |
| return (string) $node->attr('text', $node->attr('html', '')); | |
| } | |
| private function htmlWrapText(): string | |
| { | |
| $value = (string) ($this->options['writerWrapText'] ?? $this->options['wrap'] ?? 'auto'); | |
| $normalized = strtolower(str_replace(['-', '_', ' '], '', $value)); | |
| return match ($normalized) { | |
| 'preserve', 'wrappreserve' => 'preserve', | |
| 'none', 'wrapnone' => 'none', | |
| default => 'auto', | |
| }; | |
| } | |
| private function renderLink(AstNode $node): string | |
| { | |
| $attrs = $this->attrTuple($node); | |
| $sourceHref = $attrs['attributes']['href'] ?? ''; | |
| $sourceTitle = $attrs['attributes']['title'] ?? ''; | |
| unset($attrs['attributes']['href'], $attrs['attributes']['title']); | |
| $url = (string) $node->attr('url', $node->attr('href', $sourceHref)); | |
| $title = (string) $node->attr('title', $sourceTitle); | |
| $renderedAttrs = ' href="' . $this->esc($url) . '"'; | |
| if ($title !== '') { | |
| $renderedAttrs .= ' title="' . $this->esc($title) . '"'; | |
| } | |
| $renderedAttrs .= $this->renderAttrTuple($attrs); | |
| return '<a' . $renderedAttrs . '>' . $this->renderInlines($this->linksAsSpans($node->children)) . '</a>'; | |
| } | |
| /** | |
| * @param list<AstNode> $nodes | |
| * @return list<AstNode> | |
| */ | |
| private function linksAsSpans(array $nodes): array | |
| { | |
| $mapped = []; | |
| foreach ($nodes as $node) { | |
| $children = $node->children === [] ? [] : $this->linksAsSpans($node->children); | |
| if ($node->type !== 'link') { | |
| $mapped[] = $children === $node->children | |
| ? $node | |
| : new AstNode($node->type, $node->attrs, $children); | |
| continue; | |
| } | |
| $attrs = $node->attrs; | |
| unset($attrs['url'], $attrs['href'], $attrs['title']); | |
| $attributes = $attrs['attributes'] ?? []; | |
| if (is_array($attributes)) { | |
| unset($attributes['href'], $attributes['title']); | |
| if ($attributes === []) { | |
| unset($attrs['attributes']); | |
| } else { | |
| $attrs['attributes'] = $attributes; | |
| } | |
| } | |
| $mapped[] = new AstNode('span', $attrs, $children); | |
| } | |
| return $mapped; | |
| } | |
| private function renderSpan(AstNode $node): string | |
| { | |
| $attrs = $this->spanAttrsWithCslStyle($this->attrTuple($node)); | |
| $spanLike = $this->spanLikeTagsAndClasses($attrs['classes']); | |
| $html = $this->renderInlines($node->children); | |
| if ($spanLike === null) { | |
| $attrs['classes'] = $this->classesWithoutCslNoStyleMarkers($attrs['classes']); | |
| return '<span' . $this->renderAttrTuple($attrs) . '>' . $html . '</span>'; | |
| } | |
| $attrs['classes'] = $spanLike['classes']; | |
| $outerIndex = count($spanLike['tags']) - 1; | |
| foreach ($spanLike['tags'] as $index => $wrapper) { | |
| $tag = $wrapper['tag']; | |
| $wrapperClasses = $wrapper['classes']; | |
| if ($index === $outerIndex) { | |
| $outerAttrs = $attrs; | |
| $outerAttrs['classes'] = array_values(array_unique(array_merge($wrapperClasses, $attrs['classes']))); | |
| $tagAttrs = $this->renderAttrTuple($outerAttrs); | |
| } else { | |
| $tagAttrs = $this->renderClassOnlyAttr($wrapperClasses); | |
| } | |
| $html = '<' . $tag . $tagAttrs . '>' . $html . '</' . $tag . '>'; | |
| } | |
| return $html; | |
| } | |
| /** | |
| * @param array{id:string, classes:list<string>, attributes:array<string, string>} $attrs | |
| * @return array{id:string, classes:list<string>, attributes:array<string, string>} | |
| */ | |
| private function spanAttrsWithCslStyle(array $attrs): array | |
| { | |
| $styles = []; | |
| if (in_array('csl-no-emph', $attrs['classes'], true)) { | |
| $styles[] = 'font-style:normal;'; | |
| } | |
| if (in_array('csl-no-strong', $attrs['classes'], true)) { | |
| $styles[] = 'font-weight:normal;'; | |
| } | |
| if (in_array('csl-no-smallcaps', $attrs['classes'], true)) { | |
| $styles[] = 'font-variant:normal;'; | |
| } | |
| if ($styles === []) { | |
| return $attrs; | |
| } | |
| $existingStyle = trim($attrs['attributes']['style'] ?? ''); | |
| $attrs['attributes']['style'] = implode('', $styles) | |
| . ($existingStyle === '' ? '' : rtrim($existingStyle, ';') . ';'); | |
| return $attrs; | |
| } | |
| /** | |
| * @param list<string> $classes | |
| * @return list<string> | |
| */ | |
| private function classesWithoutCslNoStyleMarkers(array $classes): array | |
| { | |
| return array_values(array_filter( | |
| $classes, | |
| static fn (string $class): bool => !in_array($class, [ | |
| 'csl-no-emph', | |
| 'csl-no-strong', | |
| 'csl-no-smallcaps', | |
| ], true) | |
| )); | |
| } | |
| /** | |
| * @param list<string> $classes | |
| * @return array{tags:list<array{tag:string, classes:list<string>}>, classes:list<string>}|null | |
| */ | |
| private function spanLikeTagsAndClasses(array $classes): ?array | |
| { | |
| $firstSpecial = null; | |
| foreach ($classes as $index => $class) { | |
| if ($this->isSpanLikeClass($class)) { | |
| $firstSpecial = $index; | |
| break; | |
| } | |
| } | |
| if ($firstSpecial === null) { | |
| return null; | |
| } | |
| $retainedClasses = []; | |
| foreach (array_slice($classes, $firstSpecial + 1) as $class) { | |
| if (!$this->isSpanLikeClass($class)) { | |
| array_unshift($retainedClasses, $class); | |
| } | |
| } | |
| $tags = []; | |
| for ($index = count($classes) - 1; $index >= 0; $index--) { | |
| if ($classes[$index] === 'smallcaps') { | |
| $tags[] = ['tag' => 'span', 'classes' => ['smallcaps']]; | |
| continue; | |
| } | |
| if ($classes[$index] === 'underline') { | |
| $tags[] = ['tag' => 'u', 'classes' => []]; | |
| } | |
| } | |
| foreach ($classes as $class) { | |
| if ($this->isHtmlSpanLikeElement($class)) { | |
| $tags[] = ['tag' => $class, 'classes' => []]; | |
| } | |
| } | |
| return $tags === [] ? null : [ | |
| 'tags' => $tags, | |
| 'classes' => array_values(array_unique($retainedClasses)), | |
| ]; | |
| } | |
| private function isSpanLikeClass(string $class): bool | |
| { | |
| return $class === 'smallcaps' | |
| || $class === 'underline' | |
| || $this->isHtmlSpanLikeElement($class); | |
| } | |
| private function isHtmlSpanLikeElement(string $class): bool | |
| { | |
| return in_array($class, ['kbd', 'mark', 'dfn', 'abbr'], true); | |
| } | |
| /** | |
| * @param list<string> $classes | |
| */ | |
| private function renderClassOnlyAttr(array $classes): string | |
| { | |
| return $classes === [] ? '' : ' class="' . $this->esc(implode(' ', $classes)) . '"'; | |
| } | |
| private function renderCitation(AstNode $node): string | |
| { | |
| $citations = $this->citationEntries($node); | |
| $ids = []; | |
| foreach ($citations as $citation) { | |
| $id = (string) ($citation['id'] ?? $citation['citationId'] ?? ''); | |
| if ($id !== '') { | |
| $ids[] = $id; | |
| } | |
| } | |
| $display = $node->children; | |
| if ($display === []) { | |
| $text = (string) $node->attr('text', $ids === [] ? '' : '[' . implode('; ', array_map( | |
| static fn (string $id): string => '@' . $id, | |
| $ids | |
| )) . ']'); | |
| $display = $text === '' ? [] : [new AstNode('text', ['text' => $text])]; | |
| } | |
| $attrs = [ | |
| 'id' => '', | |
| 'classes' => ['citation'], | |
| 'attributes' => [], | |
| ]; | |
| if ($ids !== []) { | |
| $attrs['attributes']['data-cites'] = implode(' ', $ids); | |
| } | |
| return '<span' . $this->renderAttrTuple($attrs) . '>' | |
| . $this->renderInlines($this->addBibliorefRoles($display)) | |
| . '</span>'; | |
| } | |
| /** | |
| * @return list<array<string, mixed>> | |
| */ | |
| private function citationEntries(AstNode $node): array | |
| { | |
| $citations = $node->attr('citations', null); | |
| if (is_array($citations) && $citations !== []) { | |
| $entries = []; | |
| foreach ($citations as $citation) { | |
| if ($citation instanceof AstNode) { | |
| $entries[] = [ | |
| 'id' => (string) $citation->attr('id', $citation->attr('citationId', '')), | |
| ]; | |
| continue; | |
| } | |
| if (is_array($citation)) { | |
| $entries[] = $citation; | |
| } | |
| } | |
| return $entries; | |
| } | |
| $id = (string) $node->attr('id', $node->attr('citationId', '')); | |
| return $id === '' ? [] : [['id' => $id]]; | |
| } | |
| /** | |
| * @param list<AstNode> $nodes | |
| * @return list<AstNode> | |
| */ | |
| private function addBibliorefRoles(array $nodes): array | |
| { | |
| $mapped = []; | |
| foreach ($nodes as $node) { | |
| $children = $node->children === [] ? [] : $this->addBibliorefRoles($node->children); | |
| if ($node->type !== 'link') { | |
| $mapped[] = $children === $node->children | |
| ? $node | |
| : new AstNode($node->type, $node->attrs, $children); | |
| continue; | |
| } | |
| $linkAttrs = $this->attrTuple($node); | |
| $url = (string) $node->attr('url', $node->attr('href', $linkAttrs['attributes']['href'] ?? '')); | |
| if (!str_starts_with($url, '#ref-')) { | |
| $mapped[] = $children === $node->children | |
| ? $node | |
| : new AstNode($node->type, $node->attrs, $children); | |
| continue; | |
| } | |
| $attrs = $node->attrs; | |
| $attributes = $attrs['attributes'] ?? []; | |
| if (!is_array($attributes)) { | |
| $attributes = []; | |
| } | |
| $attributes['role'] ??= 'doc-biblioref'; | |
| $attrs['attributes'] = $attributes; | |
| $mapped[] = new AstNode('link', $attrs, $children); | |
| } | |
| return $mapped; | |
| } | |
| private function renderNoteReference(AstNode $node): string | |
| { | |
| $number = $this->nextFootnoteNumber++; | |
| $this->footnotes[] = [ | |
| 'number' => $number, | |
| 'node' => $node, | |
| ]; | |
| return '<a href="#fn' . $number . '" class="footnote-ref" id="fnref' . $number . '" role="doc-noteref"><sup>' | |
| . $number | |
| . '</sup></a>'; | |
| } | |
| private function renderCode(AstNode $node): string | |
| { | |
| $rawText = (string) $node->attr('text', ''); | |
| $text = $this->esc($rawText); | |
| $attrs = $this->attrTuple($node); | |
| $highlightLanguage = $this->highlightLanguage($attrs['classes']); | |
| if ($highlightLanguage !== null) { | |
| $codeAttrs = $attrs; | |
| $codeAttrs['classes'] = $this->highlightedCodeClasses($attrs['classes'], $highlightLanguage); | |
| $code = '<code' . $this->renderAttrTuple($codeAttrs) . '>' | |
| . $this->highlightCodeText($rawText, $highlightLanguage) | |
| . '</code>'; | |
| if (in_array('sample', $attrs['classes'], true)) { | |
| return '<samp>' . $code . '</samp>'; | |
| } | |
| if (in_array('variable', $attrs['classes'], true)) { | |
| return '<var>' . $code . '</var>'; | |
| } | |
| return $code; | |
| } | |
| if ($this->isBareCodeRole($attrs, 'sample')) { | |
| return '<samp>' . $text . '</samp>'; | |
| } | |
| if ($this->isBareCodeRole($attrs, 'variable')) { | |
| return '<var>' . $text . '</var>'; | |
| } | |
| return '<code' . $this->renderAttrTuple($attrs) . '>' . $text . '</code>'; | |
| } | |
| private function renderMath(AstNode $node): string | |
| { | |
| $text = (string) $node->attr('text', ''); | |
| $display = $node->attr('display') === true; | |
| $class = $display ? 'display' : 'inline'; | |
| return match ($this->htmlMathMethod()) { | |
| 'webtex' => $this->renderWebTeXMath($text, $display, $class), | |
| 'gladtex' => $this->renderGladTeXMath($text, $display), | |
| 'mathjax' => '<span class="math ' . $class . '">' | |
| . $this->esc(($display ? '\\[' : '\\(') . $text . ($display ? '\\]' : '\\)')) | |
| . '</span>', | |
| 'katex' => '<span class="math ' . $class . '">' . $this->esc($text) . '</span>', | |
| default => '<span class="math ' . $class . '">' . $this->esc($text) . '</span>', | |
| }; | |
| } | |
| private function renderWebTeXMath(string $text, bool $display, string $class): string | |
| { | |
| $trimmed = trim($text); | |
| $stylePrefix = $display ? '\\displaystyle ' : '\\textstyle '; | |
| $src = $this->htmlMathUrl('webtex') . rawurlencode($stylePrefix . $trimmed); | |
| return '<img style="vertical-align:middle" src="' . $this->esc($src) . '"' | |
| . ' alt="' . $this->esc($trimmed) . '"' | |
| . ' title="' . $this->esc($trimmed) . '"' | |
| . ' class="math ' . $class . '" />'; | |
| } | |
| private function renderGladTeXMath(string $text, bool $display): string | |
| { | |
| return '<eq env="' . ($display ? 'displaymath' : 'math') . '">' | |
| . $this->esc($text) | |
| . '</eq>'; | |
| } | |
| private function htmlMathMethod(): string | |
| { | |
| $value = $this->htmlMathOptionValue(); | |
| if (is_array($value)) { | |
| $value = $value['method'] ?? $value['type'] ?? $value['name'] ?? ''; | |
| } | |
| $normalized = strtolower(str_replace(['-', '_', ' '], '', (string) $value)); | |
| return match ($normalized) { | |
| 'webtex' => 'webtex', | |
| 'gladtex' => 'gladtex', | |
| 'mathjax' => 'mathjax', | |
| 'katex' => 'katex', | |
| default => 'plain', | |
| }; | |
| } | |
| private function htmlMathOptionValue(): mixed | |
| { | |
| return $this->options['writerHTMLMathMethod'] | |
| ?? $this->options['htmlMathMethod'] | |
| ?? $this->options['mathMethod'] | |
| ?? 'mathjax'; | |
| } | |
| private function htmlMathUrl(string $method): string | |
| { | |
| $value = $this->htmlMathOptionValue(); | |
| if (is_array($value)) { | |
| foreach (['url', 'baseUrl', 'src'] as $key) { | |
| if (isset($value[$key]) && is_scalar($value[$key])) { | |
| return (string) $value[$key]; | |
| } | |
| } | |
| } | |
| return $method === 'webtex' ? 'https://latex.codecogs.com/png.latex?' : ''; | |
| } | |
| /** | |
| * @param list<string> $classes | |
| */ | |
| private function highlightLanguage(array $classes): ?string | |
| { | |
| if (in_array('nolanguage', $classes, true)) { | |
| return null; | |
| } | |
| foreach ($classes as $class) { | |
| if ($class === 'haskell') { | |
| return $class; | |
| } | |
| } | |
| return null; | |
| } | |
| /** | |
| * @param list<string> $classes | |
| * @return list<string> | |
| */ | |
| private function highlightedCodeClasses(array $classes, string $language): array | |
| { | |
| $ordered = ['sourceCode', $language]; | |
| foreach ($classes as $class) { | |
| if (in_array($class, ['sourceCode', $language, 'nolanguage'], true)) { | |
| continue; | |
| } | |
| if (!in_array($class, $ordered, true)) { | |
| $ordered[] = $class; | |
| } | |
| } | |
| return $ordered; | |
| } | |
| private function highlightCodeText(string $text, string $language): string | |
| { | |
| if ($language !== 'haskell') { | |
| return $this->esc($text); | |
| } | |
| $parts = preg_split('/([!#$%&*+.\/<=>?@\\\\^|~:-]+)/u', $text, -1, PREG_SPLIT_DELIM_CAPTURE); | |
| if ($parts === false) { | |
| return $this->esc($text); | |
| } | |
| $html = ''; | |
| foreach ($parts as $part) { | |
| if ($part === '') { | |
| continue; | |
| } | |
| if (preg_match('/^[!#$%&*+.\/<=>?@\\\\^|~:-]+$/u', $part) === 1) { | |
| $html .= '<span class="op">' . $this->esc($part) . '</span>'; | |
| continue; | |
| } | |
| $html .= $this->esc($part); | |
| } | |
| return $html; | |
| } | |
| private function renderImage(AstNode $node): string | |
| { | |
| $attrs = $this->attrTuple($node); | |
| $sourceAttr = $attrs['attributes']['src'] ?? null; | |
| $titleAttr = $attrs['attributes']['title'] ?? null; | |
| $altAttr = $attrs['attributes']['alt'] ?? null; | |
| $attrs['attributes'] = array_filter( | |
| $attrs['attributes'], | |
| static fn (string $name): bool => !in_array($name, ['src', 'title', 'alt'], true), | |
| ARRAY_FILTER_USE_KEY | |
| ); | |
| $source = (string) $node->attr('url', $node->attr('src', is_string($sourceAttr) ? $sourceAttr : '')); | |
| $parts = ['src="' . $this->esc($source) . '"']; | |
| $title = (string) $node->attr('title', ''); | |
| if ($title === '' && is_string($titleAttr)) { | |
| $title = $titleAttr; | |
| } | |
| if ($title !== '') { | |
| $parts[] = 'title="' . $this->esc($title) . '"'; | |
| } | |
| $alt = $node->attr('alt', null); | |
| if ((!is_string($alt) || $alt === '') && is_string($altAttr)) { | |
| $alt = $altAttr; | |
| } | |
| if (!is_string($alt) || $alt === '') { | |
| $alt = $this->plainInlineText($node->children); | |
| } | |
| $category = $this->mediaCategory($source); | |
| if ($category === 'video' || $category === 'audio') { | |
| return $this->renderMediaImage($category, $source, $title, $alt, $attrs); | |
| } | |
| if ($category !== null && $category !== 'image') { | |
| return $this->renderEmbeddedMedia($source, $title, $attrs); | |
| } | |
| if ($alt !== '') { | |
| $parts[] = 'alt="' . $this->esc($alt) . '"'; | |
| } | |
| $renderedAttrs = $this->renderAttrTuple($attrs); | |
| if ($renderedAttrs !== '') { | |
| $parts[] = ltrim($renderedAttrs); | |
| } | |
| return '<img ' . implode(' ', $parts) . ' />'; | |
| } | |
| /** | |
| * @param array{id:string, classes:list<string>, attributes:array<string, string>} $attrs | |
| */ | |
| private function renderMediaImage(string $category, string $source, string $title, string $alt, array $attrs): string | |
| { | |
| $tag = $category === 'audio' ? 'audio' : 'video'; | |
| $fallback = $alt === '' ? ucfirst($category) : $alt; | |
| $parts = ['src="' . $this->esc($source) . '"']; | |
| if ($title !== '') { | |
| $parts[] = 'title="' . $this->esc($title) . '"'; | |
| } | |
| $renderedAttrs = $this->renderAttrTuple($attrs); | |
| if ($renderedAttrs !== '') { | |
| $parts[] = ltrim($renderedAttrs); | |
| } | |
| $parts[] = 'controls=""'; | |
| return '<' . $tag . ' ' . implode(' ', $parts) . '><a href="' | |
| . $this->esc($source) . '">' . $this->esc($fallback) . '</a></' . $tag . '>'; | |
| } | |
| /** | |
| * @param array{id:string, classes:list<string>, attributes:array<string, string>} $attrs | |
| */ | |
| private function renderEmbeddedMedia(string $source, string $title, array $attrs): string | |
| { | |
| $parts = ['src="' . $this->esc($source) . '"']; | |
| if ($title !== '') { | |
| $parts[] = 'title="' . $this->esc($title) . '"'; | |
| } | |
| $renderedAttrs = $this->renderAttrTuple($attrs); | |
| if ($renderedAttrs !== '') { | |
| $parts[] = ltrim($renderedAttrs); | |
| } | |
| return '<embed ' . implode(' ', $parts) . ' />'; | |
| } | |
| private function mediaCategory(string $source): ?string | |
| { | |
| if (preg_match('/^data:([^;,]+)/i', $source, $m) === 1) { | |
| $mime = strtolower($m[1]); | |
| $slash = strpos($mime, '/'); | |
| return $slash === false ? null : substr($mime, 0, $slash); | |
| } | |
| $sourceWithoutGzip = preg_replace('/\.gz$/i', '', $source) ?? $source; | |
| $path = parse_url($sourceWithoutGzip, PHP_URL_PATH); | |
| if (!is_string($path) || $path === '') { | |
| $path = $sourceWithoutGzip; | |
| } | |
| $extension = strtolower(pathinfo($path, PATHINFO_EXTENSION)); | |
| return match ($extension) { | |
| 'apng', 'avif', 'bmp', 'emf', 'gif', 'ico', 'jfif', 'jpeg', 'jpg', 'jxl', 'png', 'svg', 'svgz', 'tif', 'tiff', 'webp', 'wmf' => 'image', | |
| 'avi', 'm4v', 'mkv', 'mov', 'mp4', 'mpe', 'mpeg', 'mpg', 'ogv', 'qt', 'webm' => 'video', | |
| 'aac', 'flac', 'm4a', 'mid', 'midi', 'mp3', 'mpga', 'oga', 'opus', 'wav' => 'audio', | |
| 'pdf' => 'application', | |
| default => null, | |
| }; | |
| } | |
| private function renderQuoted(AstNode $node): string | |
| { | |
| if ((bool) ($this->options['htmlQTags'] ?? false)) { | |
| $quoted = $this->qTagContentsAndAttributes($node); | |
| return '<q' . $quoted['attrs'] . '>' . $quoted['contents'] . '</q>'; | |
| } | |
| $open = $node->attr('kind') === 'single' ? "\u{2018}" : "\u{201C}"; | |
| $close = $node->attr('kind') === 'single' ? "\u{2019}" : "\u{201D}"; | |
| return $open . $this->renderInlines($node->children) . $close; | |
| } | |
| private function referenceLocation(): string | |
| { | |
| $value = (string) ($this->options['referenceLocation'] ?? $this->options['writerReferenceLocation'] ?? 'end_of_document'); | |
| $normalized = strtolower(str_replace(['-', '_', ' '], '', $value)); | |
| return match ($normalized) { | |
| 'endofblock' => 'end_of_block', | |
| 'endofsection' => 'end_of_section', | |
| default => 'end_of_document', | |
| }; | |
| } | |
| /** | |
| * @param list<string> $blocks | |
| */ | |
| private function appendRenderedBlock(array &$blocks, string $html): void | |
| { | |
| if ($html !== '') { | |
| $blocks[] = $html; | |
| } | |
| } | |
| private function renderPendingFootnotes(string $referenceLocation): string | |
| { | |
| $pending = array_slice($this->footnotes, $this->flushedFootnoteCount); | |
| if ($pending === []) { | |
| return ''; | |
| } | |
| $this->flushedFootnoteCount = count($this->footnotes); | |
| $class = 'footnotes footnotes-' . str_replace('_', '-', $referenceLocation); | |
| $lines = ['<div class="' . $class . '">']; | |
| if ($referenceLocation !== 'end_of_block') { | |
| $lines[] = '<hr />'; | |
| } | |
| $start = $pending[0]['number']; | |
| $lines[] = $start === 1 ? '<ol>' : '<ol start="' . $start . '">'; | |
| foreach ($pending as $entry) { | |
| $lines[] = $this->renderFootnoteItem($entry['number'], $entry['node']); | |
| } | |
| $lines[] = '</ol>'; | |
| $lines[] = '</div>'; | |
| return implode("\n", $lines); | |
| } | |
| private function renderFootnoteItem(int $number, AstNode $node): string | |
| { | |
| $blocks = []; | |
| foreach ($node->children as $child) { | |
| $html = $this->renderBlock($child); | |
| if ($html !== '') { | |
| $blocks[] = $html; | |
| } | |
| } | |
| if ($blocks === []) { | |
| $blocks[] = '<p></p>'; | |
| } | |
| $lastIndex = array_key_last($blocks); | |
| if ($lastIndex !== null) { | |
| $backlink = '<a href="#fnref' . $number . '" class="footnote-back" role="doc-backlink">' . "\u{21A9}\u{FE0E}" . '</a>'; | |
| if (str_ends_with($blocks[$lastIndex], '</p>')) { | |
| $blocks[$lastIndex] = substr($blocks[$lastIndex], 0, -4) . $backlink . '</p>'; | |
| } else { | |
| $blocks[$lastIndex] .= $backlink; | |
| } | |
| } | |
| return '<li id="fn' . $number . '">' . implode("\n", $blocks) . '</li>'; | |
| } | |
| /** | |
| * @return array{contents:string, attrs:string} | |
| */ | |
| private function qTagContentsAndAttributes(AstNode $node): array | |
| { | |
| if (count($node->children) === 1 && $node->children[0]->type === 'span') { | |
| $spanAttrs = $this->attrTuple($node->children[0]); | |
| if ( | |
| $spanAttrs['id'] === '' | |
| && $spanAttrs['classes'] === [] | |
| && array_keys($spanAttrs['attributes']) === ['cite'] | |
| ) { | |
| return [ | |
| 'contents' => $this->renderInlines($node->children[0]->children), | |
| 'attrs' => ' cite="' . $this->esc($spanAttrs['attributes']['cite']) . '"', | |
| ]; | |
| } | |
| } | |
| return [ | |
| 'contents' => $this->renderInlines($node->children), | |
| 'attrs' => '', | |
| ]; | |
| } | |
| /** | |
| * @param array{id:string, classes:list<string>, attributes:array<string, string>} $attrs | |
| */ | |
| private function isBareCodeRole(array $attrs, string $role): bool | |
| { | |
| return $attrs['id'] === '' | |
| && $attrs['classes'] === [$role] | |
| && $attrs['attributes'] === []; | |
| } | |
| private function renderAttributes(AstNode $node, ?callable $attributeFilter = null): string | |
| { | |
| return $this->renderAttrTuple($this->attrTuple($node), $attributeFilter); | |
| } | |
| private function renderHtmlAttributes(AstNode $node): string | |
| { | |
| return $this->renderAttrTuple($this->htmlAttrTuple($node)); | |
| } | |
| /** | |
| * @return array{id:string, classes:list<string>, attributes:array<string, string>} | |
| */ | |
| private function attrTuple(AstNode $node): array | |
| { | |
| $id = (string) $node->attr('id', ''); | |
| $classes = $node->attr('classes', []); | |
| $attributes = $node->attr('attributes', []); | |
| return [ | |
| 'id' => $id, | |
| 'classes' => is_array($classes) ? array_values(array_map('strval', $classes)) : [], | |
| 'attributes' => is_array($attributes) | |
| ? array_map('strval', array_filter($attributes, static fn (mixed $value): bool => is_scalar($value))) | |
| : [], | |
| ]; | |
| } | |
| /** | |
| * @return array{id:string, classes:list<string>, attributes:array<string, string>} | |
| */ | |
| private function htmlAttrTuple(AstNode $node): array | |
| { | |
| $attrs = $this->attrTuple($node); | |
| $htmlAttributes = $node->attr('htmlAttributes', []); | |
| if (!is_array($htmlAttributes)) { | |
| return $attrs; | |
| } | |
| if ($attrs['id'] === '' && isset($htmlAttributes['id']) && is_scalar($htmlAttributes['id'])) { | |
| $attrs['id'] = (string) $htmlAttributes['id']; | |
| } | |
| if ($attrs['classes'] === [] && isset($htmlAttributes['class']) && is_scalar($htmlAttributes['class'])) { | |
| $classes = preg_split('/\s+/', trim((string) $htmlAttributes['class'])) ?: []; | |
| $attrs['classes'] = array_values(array_filter($classes, static fn (string $class): bool => $class !== '')); | |
| } | |
| foreach ($htmlAttributes as $name => $value) { | |
| $name = (string) $name; | |
| if ( | |
| in_array(strtolower($name), ['id', 'class'], true) | |
| || !is_scalar($value) | |
| || array_key_exists($name, $attrs['attributes']) | |
| ) { | |
| continue; | |
| } | |
| $attrs['attributes'][$name] = (string) $value; | |
| } | |
| return $attrs; | |
| } | |
| /** | |
| * @param array{id:string, classes:list<string>, attributes:array<string, string>} $attrs | |
| */ | |
| private function renderAttrTuple(array $attrs, ?callable $attributeFilter = null): string | |
| { | |
| $parts = []; | |
| if ($attrs['id'] !== '') { | |
| $parts[] = 'id="' . $this->esc($attrs['id']) . '"'; | |
| } | |
| if ($attrs['classes'] !== []) { | |
| $parts[] = 'class="' . $this->esc(implode(' ', $attrs['classes'])) . '"'; | |
| } | |
| foreach ($attrs['attributes'] as $name => $value) { | |
| $name = (string) $name; | |
| if (preg_match('/^[A-Za-z_:][A-Za-z0-9_.:-]*$/', $name) !== 1) { | |
| continue; | |
| } | |
| if ($attributeFilter !== null && !$attributeFilter($name)) { | |
| continue; | |
| } | |
| $parts[] = $name . '="' . $this->esc($value) . '"'; | |
| } | |
| return $parts === [] ? '' : ' ' . implode(' ', $parts); | |
| } | |
| private function allowedHeadingAttribute(string $name): bool | |
| { | |
| return in_array($name, ['lang', 'dir', 'title', 'style', 'align'], true) | |
| || str_starts_with($name, 'data-') | |
| || str_starts_with($name, 'aria-'); | |
| } | |
| private function isInlineNode(AstNode $node): bool | |
| { | |
| return in_array($node->type, [ | |
| 'text', | |
| 'softbreak', | |
| 'linebreak', | |
| 'code', | |
| 'image', | |
| 'emph', | |
| 'strong', | |
| 'underline', | |
| 'strikeout', | |
| 'small_caps', | |
| 'superscript', | |
| 'subscript', | |
| 'span', | |
| 'quoted', | |
| 'link', | |
| 'note', | |
| 'citation', | |
| 'math', | |
| 'raw_html_inline', | |
| 'raw_inline', | |
| ], true); | |
| } | |
| /** | |
| * @param list<AstNode> $nodes | |
| */ | |
| private function plainInlineText(array $nodes): string | |
| { | |
| $text = ''; | |
| foreach ($nodes as $node) { | |
| $text .= match ($node->type) { | |
| 'text', 'code' => (string) $node->attr('text', ''), | |
| 'softbreak', 'linebreak' => ' ', | |
| 'image' => (string) $node->attr('alt', $this->plainInlineText($node->children)), | |
| 'citation' => (string) $node->attr('text', $this->plainInlineText($node->children)), | |
| 'math' => (string) $node->attr('text', ''), | |
| 'raw_html_inline' => '', | |
| default => $this->plainInlineText($node->children), | |
| }; | |
| } | |
| return trim(preg_replace('/\s+/u', ' ', $text) ?? $text); | |
| } | |
| private function esc(string $value): string | |
| { | |
| return htmlspecialchars($value, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'); | |
| } | |
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| <?php | |
| declare(strict_types=1); | |
| namespace PortLibs\Pandoc; | |
| final class JsonReader | |
| { | |
| /** @var list<int> */ | |
| public const PANDOC_API_VERSION = [1, 23, 1, 2]; | |
| public function read(string $json): AstNode | |
| { | |
| $decoded = json_decode($json, true, 512, JSON_THROW_ON_ERROR); | |
| $document = $this->expectMap($decoded, 'Pandoc JSON document'); | |
| $this->assertCompatibleVersion($document['pandoc-api-version'] ?? null); | |
| $meta = $this->parseMeta($document['meta'] ?? []); | |
| $blocks = $this->parseBlockList($document['blocks'] ?? []); | |
| return new AstNode('document', $meta === [] ? [] : ['meta' => $meta], $blocks); | |
| } | |
| private function assertCompatibleVersion(mixed $value): void | |
| { | |
| $version = $this->expectList($value, 'pandoc-api-version'); | |
| if (count($version) < 2) { | |
| throw new \InvalidArgumentException('Pandoc JSON API version must include major and minor numbers'); | |
| } | |
| $major = $version[0]; | |
| $minor = $version[1]; | |
| if (!is_int($major) || !is_int($minor)) { | |
| throw new \InvalidArgumentException('Pandoc JSON API version entries must be integers'); | |
| } | |
| if ($major !== self::PANDOC_API_VERSION[0] || $minor !== self::PANDOC_API_VERSION[1]) { | |
| throw new \InvalidArgumentException(sprintf( | |
| 'Incompatible Pandoc JSON API version %s; expected major/minor %d.%d', | |
| implode('.', array_map(static fn (mixed $part): string => (string) $part, $version)), | |
| self::PANDOC_API_VERSION[0], | |
| self::PANDOC_API_VERSION[1], | |
| )); | |
| } | |
| } | |
| /** | |
| * @return array<string, mixed> | |
| */ | |
| private function parseMeta(mixed $value): array | |
| { | |
| $entries = $this->expectMap($value, 'meta'); | |
| $meta = []; | |
| foreach ($entries as $key => $entry) { | |
| $parsed = $this->parseMetaValue($entry); | |
| if ($key === 'title' && $this->isTypedValue($parsed, 'MetaInlines')) { | |
| $meta['titleInlines'] = $parsed['value']; | |
| $meta['title'] = $this->plainInlineText($parsed['value']); | |
| continue; | |
| } | |
| if ($key === 'author' && $this->isTypedValue($parsed, 'MetaList')) { | |
| $authorInlines = []; | |
| $authors = []; | |
| foreach ($parsed['value'] as $author) { | |
| if ($this->isTypedValue($author, 'MetaInlines')) { | |
| $authorInlines[] = $author['value']; | |
| $authors[] = $this->plainInlineText($author['value']); | |
| } | |
| } | |
| if ($authorInlines !== []) { | |
| $meta['authorInlines'] = $authorInlines; | |
| $meta['author'] = $authors; | |
| continue; | |
| } | |
| } | |
| if ($key === 'date' && $this->isTypedValue($parsed, 'MetaInlines')) { | |
| $meta['dateInlines'] = $parsed['value']; | |
| $meta['date'] = $this->plainInlineText($parsed['value']); | |
| continue; | |
| } | |
| $meta[(string) $key] = $parsed; | |
| } | |
| return $meta; | |
| } | |
| private function parseMetaValue(mixed $value): mixed | |
| { | |
| [$type, $payload] = $this->tagged($value, 'meta value'); | |
| return match ($type) { | |
| 'MetaMap' => ['type' => 'MetaMap', 'value' => $this->parseMetaMap($payload)], | |
| 'MetaList' => ['type' => 'MetaList', 'value' => array_map(fn (mixed $item): mixed => $this->parseMetaValue($item), $this->expectList($payload, 'MetaList'))], | |
| 'MetaBool' => (bool) $payload, | |
| 'MetaString' => $this->expectString($payload, 'MetaString'), | |
| 'MetaInlines' => ['type' => 'MetaInlines', 'value' => $this->parseInlineList($payload)], | |
| 'MetaBlocks' => ['type' => 'MetaBlocks', 'value' => $this->parseBlockList($payload)], | |
| default => throw new \InvalidArgumentException("Unsupported Pandoc JSON meta value '{$type}'"), | |
| }; | |
| } | |
| /** | |
| * @return array<string, mixed> | |
| */ | |
| private function parseMetaMap(mixed $value): array | |
| { | |
| $entries = $this->expectMap($value, 'MetaMap'); | |
| $mapped = []; | |
| foreach ($entries as $key => $entry) { | |
| $mapped[(string) $key] = $this->parseMetaValue($entry); | |
| } | |
| return $mapped; | |
| } | |
| /** | |
| * @return list<AstNode> | |
| */ | |
| private function parseBlockList(mixed $value): array | |
| { | |
| return array_map(fn (mixed $block): AstNode => $this->parseBlock($block), $this->expectList($value, 'block list')); | |
| } | |
| private function parseBlock(mixed $value): AstNode | |
| { | |
| [$type, $payload] = $this->tagged($value, 'block'); | |
| return match ($type) { | |
| 'Plain' => $this->inlineBlock('plain', $payload), | |
| 'Para' => $this->inlineBlock('paragraph', $payload), | |
| 'LineBlock' => $this->parseLineBlock($payload), | |
| 'CodeBlock' => $this->parseCodeBlock($payload), | |
| 'RawBlock' => $this->parseRawBlock($payload), | |
| 'BlockQuote' => new AstNode('blockquote', [], $this->parseBlockList($payload)), | |
| 'OrderedList' => $this->parseOrderedList($payload), | |
| 'BulletList' => $this->parseBulletList($payload), | |
| 'DefinitionList' => $this->parseDefinitionList($payload), | |
| 'Header' => $this->parseHeader($payload), | |
| 'HorizontalRule' => new AstNode('horizontal_rule'), | |
| 'Table' => $this->parseTable($payload), | |
| 'Figure' => $this->parseFigure($payload), | |
| 'Div' => $this->parseDiv($payload), | |
| default => throw new \InvalidArgumentException("Unsupported Pandoc JSON block '{$type}'"), | |
| }; | |
| } | |
| private function inlineBlock(string $type, mixed $payload): AstNode | |
| { | |
| $inlines = $this->parseInlineList($payload); | |
| return new AstNode($type, ['text' => $this->plainInlineText($inlines)], $inlines); | |
| } | |
| private function parseLineBlock(mixed $payload): AstNode | |
| { | |
| $lines = []; | |
| foreach ($this->expectList($payload, 'LineBlock') as $line) { | |
| $inlines = $this->parseInlineList($line); | |
| $lines[] = new AstNode('line', ['text' => $this->plainInlineText($inlines)], $inlines); | |
| } | |
| return new AstNode('line_block', [], $lines); | |
| } | |
| private function parseCodeBlock(mixed $payload): AstNode | |
| { | |
| $items = $this->expectTuple($payload, 2, 'CodeBlock'); | |
| $attrs = $this->parseAttr($items[0]); | |
| $attrs['text'] = $this->expectString($items[1], 'CodeBlock text'); | |
| return new AstNode('code_block', $attrs); | |
| } | |
| private function parseRawBlock(mixed $payload): AstNode | |
| { | |
| $items = $this->expectTuple($payload, 2, 'RawBlock'); | |
| $format = $this->parseFormat($items[0]); | |
| $text = $this->expectString($items[1], 'RawBlock text'); | |
| return match ($format) { | |
| 'html' => new AstNode('raw_html', ['html' => $text]), | |
| 'tex' => new AstNode('raw_tex', ['tex' => $text]), | |
| default => new AstNode('raw_block', ['format' => $format, 'text' => $text]), | |
| }; | |
| } | |
| private function parseOrderedList(mixed $payload): AstNode | |
| { | |
| $items = $this->expectTuple($payload, 2, 'OrderedList'); | |
| $attrs = $this->parseListAttributes($items[0]); | |
| $children = []; | |
| foreach ($this->expectList($items[1], 'OrderedList items') as $item) { | |
| $children[] = new AstNode('list_item', [], $this->parseBlockList($item)); | |
| } | |
| return new AstNode('ordered_list', $attrs, $children); | |
| } | |
| private function parseBulletList(mixed $payload): AstNode | |
| { | |
| $children = []; | |
| foreach ($this->expectList($payload, 'BulletList') as $item) { | |
| $children[] = new AstNode('list_item', [], $this->parseBlockList($item)); | |
| } | |
| return new AstNode('bullet_list', [], $children); | |
| } | |
| private function parseDefinitionList(mixed $payload): AstNode | |
| { | |
| $items = []; | |
| foreach ($this->expectList($payload, 'DefinitionList') as $item) { | |
| [$termValue, $definitionsValue] = $this->expectTuple($item, 2, 'DefinitionList item'); | |
| $termInlines = $this->parseInlineList($termValue); | |
| $definitions = []; | |
| foreach ($this->expectList($definitionsValue, 'DefinitionList definitions') as $definition) { | |
| $definitions[] = new AstNode('definition', [], $this->parseBlockList($definition)); | |
| } | |
| $items[] = new AstNode('definition_item', ['term' => $this->plainInlineText($termInlines)], array_merge([ | |
| new AstNode('term', ['text' => $this->plainInlineText($termInlines)], $termInlines), | |
| ], $definitions)); | |
| } | |
| return new AstNode('definition_list', [], $items); | |
| } | |
| private function parseHeader(mixed $payload): AstNode | |
| { | |
| $items = $this->expectTuple($payload, 3, 'Header'); | |
| $level = max(1, min(6, (int) $items[0])); | |
| $attrs = $this->parseAttr($items[1]); | |
| $inlines = $this->parseInlineList($items[2]); | |
| $attrs['level'] = $level; | |
| $attrs['text'] = $this->plainInlineText($inlines); | |
| return new AstNode('heading', $attrs, $inlines); | |
| } | |
| private function parseTable(mixed $payload): AstNode | |
| { | |
| $items = $this->expectTuple($payload, 6, 'Table'); | |
| $attrs = $this->parseAttr($items[0]); | |
| $attrs = array_replace($attrs, $this->parseCaptionAttrs($items[1])); | |
| [$alignments, $widths] = $this->parseTableColSpecs($items[2]); | |
| if ($alignments !== []) { | |
| $attrs['alignments'] = $alignments; | |
| } | |
| if ($widths !== []) { | |
| $attrs['widths'] = $widths; | |
| } | |
| $children = [$this->parseTableHead($items[3])]; | |
| array_push($children, ...array_map(fn (mixed $body): AstNode => $this->parseTableBody($body), $this->expectList($items[4], 'Table bodies'))); | |
| $children[] = $this->parseTableFoot($items[5]); | |
| return new AstNode('table', $attrs, $children); | |
| } | |
| private function parseFigure(mixed $payload): AstNode | |
| { | |
| $items = $this->expectTuple($payload, 3, 'Figure'); | |
| $attrs = array_replace($this->parseAttr($items[0]), $this->parseCaptionAttrs($items[1])); | |
| return new AstNode('figure', $attrs, $this->figureChildrenFromNativeBlocks($this->parseBlockList($items[2]))); | |
| } | |
| private function parseDiv(mixed $payload): AstNode | |
| { | |
| $items = $this->expectTuple($payload, 2, 'Div'); | |
| return new AstNode('div', $this->parseAttr($items[0]), $this->parseBlockList($items[1])); | |
| } | |
| /** | |
| * @return list<AstNode> | |
| */ | |
| private function parseInlineList(mixed $value): array | |
| { | |
| return array_map(fn (mixed $inline): AstNode => $this->parseInline($inline), $this->expectList($value, 'inline list')); | |
| } | |
| private function parseInline(mixed $value): AstNode | |
| { | |
| [$type, $payload] = $this->tagged($value, 'inline'); | |
| return match ($type) { | |
| 'Str' => new AstNode('text', ['text' => $this->expectString($payload, 'Str')]), | |
| 'Space' => new AstNode('text', ['text' => ' ']), | |
| 'SoftBreak' => new AstNode('softbreak'), | |
| 'LineBreak' => new AstNode('linebreak'), | |
| 'Emph' => new AstNode('emph', [], $this->parseInlineList($payload)), | |
| 'Underline' => new AstNode('underline', [], $this->parseInlineList($payload)), | |
| 'Strong' => new AstNode('strong', [], $this->parseInlineList($payload)), | |
| 'Strikeout' => new AstNode('strikeout', [], $this->parseInlineList($payload)), | |
| 'Superscript' => new AstNode('superscript', [], $this->parseInlineList($payload)), | |
| 'Subscript' => new AstNode('subscript', [], $this->parseInlineList($payload)), | |
| 'SmallCaps' => new AstNode('small_caps', [], $this->parseInlineList($payload)), | |
| 'Quoted' => $this->parseQuotedInline($payload), | |
| 'Cite' => $this->parseCitationInline($payload), | |
| 'Code' => $this->parseCodeInline($payload), | |
| 'Math' => $this->parseMathInline($payload), | |
| 'RawInline' => $this->parseRawInline($payload), | |
| 'Link' => $this->parseLinkInline($payload), | |
| 'Image' => $this->parseImageInline($payload), | |
| 'Note' => new AstNode('note', [], $this->parseBlockList($payload)), | |
| 'Span' => $this->parseSpanInline($payload), | |
| default => throw new \InvalidArgumentException("Unsupported Pandoc JSON inline '{$type}'"), | |
| }; | |
| } | |
| private function parseQuotedInline(mixed $payload): AstNode | |
| { | |
| $items = $this->expectTuple($payload, 2, 'Quoted'); | |
| [$quoteType] = $this->tagged($items[0], 'QuoteType'); | |
| return new AstNode('quoted', ['kind' => $quoteType === 'SingleQuote' ? 'single' : 'double'], $this->parseInlineList($items[1])); | |
| } | |
| private function parseCitationInline(mixed $payload): AstNode | |
| { | |
| $items = $this->expectTuple($payload, 2, 'Cite'); | |
| $citations = array_map(fn (mixed $citation): array => $this->parseCitationRecord($citation), $this->expectList($items[0], 'Cite citations')); | |
| $display = $this->parseInlineList($items[1]); | |
| return new AstNode('citation', [ | |
| 'citations' => $citations, | |
| 'text' => $this->plainInlineText($display), | |
| ], $display); | |
| } | |
| /** | |
| * @return array<string, mixed> | |
| */ | |
| private function parseCitationRecord(mixed $value): array | |
| { | |
| $record = $this->expectMap($value, 'Citation'); | |
| return [ | |
| 'id' => $this->expectString($record['citationId'] ?? '', 'citationId'), | |
| 'prefix' => $this->parseInlineList($record['citationPrefix'] ?? []), | |
| 'suffix' => $this->parseInlineList($record['citationSuffix'] ?? []), | |
| 'mode' => $this->parseCitationMode($record['citationMode'] ?? ['t' => 'NormalCitation']), | |
| 'noteNum' => (int) ($record['citationNoteNum'] ?? 1), | |
| 'hash' => (int) ($record['citationHash'] ?? 0), | |
| ]; | |
| } | |
| private function parseCodeInline(mixed $payload): AstNode | |
| { | |
| $items = $this->expectTuple($payload, 2, 'Code'); | |
| $attrs = $this->parseAttr($items[0]); | |
| $attrs['text'] = $this->expectString($items[1], 'Code text'); | |
| return new AstNode('code', $attrs); | |
| } | |
| private function parseMathInline(mixed $payload): AstNode | |
| { | |
| $items = $this->expectTuple($payload, 2, 'Math'); | |
| [$mathType] = $this->tagged($items[0], 'MathType'); | |
| return new AstNode('math', [ | |
| 'display' => $mathType === 'DisplayMath', | |
| 'text' => $this->expectString($items[1], 'Math text'), | |
| ]); | |
| } | |
| private function parseRawInline(mixed $payload): AstNode | |
| { | |
| $items = $this->expectTuple($payload, 2, 'RawInline'); | |
| $format = $this->parseFormat($items[0]); | |
| $text = $this->expectString($items[1], 'RawInline text'); | |
| return match ($format) { | |
| 'html' => new AstNode('raw_html_inline', ['html' => $text]), | |
| 'tex' => new AstNode('raw_tex_inline', ['tex' => $text]), | |
| default => new AstNode('raw_inline', ['format' => $format, 'text' => $text]), | |
| }; | |
| } | |
| private function parseLinkInline(mixed $payload): AstNode | |
| { | |
| $items = $this->expectTuple($payload, 3, 'Link'); | |
| $attrs = $this->parseAttr($items[0]); | |
| [$url, $title] = $this->parseTarget($items[2]); | |
| $attrs['url'] = $url; | |
| $attrs['title'] = $title; | |
| return new AstNode('link', $attrs, $this->parseInlineList($items[1])); | |
| } | |
| private function parseImageInline(mixed $payload): AstNode | |
| { | |
| $items = $this->expectTuple($payload, 3, 'Image'); | |
| $attrs = $this->parseAttr($items[0]); | |
| $inlines = $this->parseInlineList($items[1]); | |
| [$url, $title] = $this->parseTarget($items[2]); | |
| $attrs['url'] = $url; | |
| $attrs['title'] = $title; | |
| $attrs['alt'] = $this->plainInlineText($inlines); | |
| return new AstNode('image', $attrs, $inlines); | |
| } | |
| private function parseSpanInline(mixed $payload): AstNode | |
| { | |
| $items = $this->expectTuple($payload, 2, 'Span'); | |
| return new AstNode('span', $this->parseAttr($items[0]), $this->parseInlineList($items[1])); | |
| } | |
| /** | |
| * @return array<string, mixed> | |
| */ | |
| private function parseAttr(mixed $value): array | |
| { | |
| [$idValue, $classesValue, $attributesValue] = $this->expectTuple($value, 3, 'Attr'); | |
| $id = $this->expectString($idValue, 'Attr identifier'); | |
| $classes = array_map(fn (mixed $class): string => $this->expectString($class, 'Attr class'), $this->expectList($classesValue, 'Attr classes')); | |
| $pairs = []; | |
| foreach ($this->expectList($attributesValue, 'Attr key-value pairs') as $pair) { | |
| [$key, $pairValue] = $this->expectTuple($pair, 2, 'Attr key-value pair'); | |
| $pairs[$this->expectString($key, 'Attr key')] = $this->expectString($pairValue, 'Attr value'); | |
| } | |
| $attrs = []; | |
| if ($id !== '') { | |
| $attrs['id'] = $id; | |
| } | |
| if ($classes !== []) { | |
| $attrs['classes'] = $classes; | |
| } | |
| if ($pairs !== []) { | |
| $attrs['attributes'] = $pairs; | |
| } | |
| return $attrs; | |
| } | |
| /** | |
| * @return array<string, mixed> | |
| */ | |
| private function parseCaptionAttrs(mixed $value): array | |
| { | |
| [$type, $payload] = $this->tagged($value, 'Caption'); | |
| if ($type !== 'Caption') { | |
| throw new \InvalidArgumentException("Expected Caption, got '{$type}'"); | |
| } | |
| [$shortValue, $blocksValue] = $this->expectTuple($payload, 2, 'Caption payload'); | |
| $attrs = []; | |
| if ($shortValue !== null) { | |
| $short = $this->parseInlineList($shortValue); | |
| $attrs['shortCaptionInlines'] = $short; | |
| $attrs['shortCaption'] = $this->plainInlineText($short); | |
| } | |
| $blocks = $this->parseBlockList($blocksValue); | |
| if ($blocks !== []) { | |
| $attrs['captionBlocks'] = $blocks; | |
| $attrs['caption'] = $this->plainBlockText($blocks); | |
| $captionInlines = $this->captionInlinesFromBlocks($blocks); | |
| if ($captionInlines !== []) { | |
| $attrs['captionInlines'] = $captionInlines; | |
| } | |
| } | |
| return $attrs; | |
| } | |
| /** | |
| * @return array{0:list<string>, 1:list<float>} | |
| */ | |
| private function parseTableColSpecs(mixed $value): array | |
| { | |
| $alignments = []; | |
| $widths = []; | |
| foreach ($this->expectList($value, 'Table column specs') as $spec) { | |
| [$alignment, $width] = $this->expectTuple($spec, 2, 'Table column spec'); | |
| $alignments[] = $this->parseAlignment($alignment); | |
| $widths[] = $this->parseColumnWidth($width); | |
| } | |
| return [$alignments, $widths]; | |
| } | |
| private function parseTableHead(mixed $value): AstNode | |
| { | |
| [$type, $payload] = $this->tagged($value, 'TableHead'); | |
| if ($type !== 'TableHead') { | |
| throw new \InvalidArgumentException("Expected TableHead, got '{$type}'"); | |
| } | |
| [$attrs, $rows] = $this->expectTuple($payload, 2, 'TableHead payload'); | |
| return new AstNode('table_head', $this->parseAttr($attrs), $this->parseTableRows($rows)); | |
| } | |
| private function parseTableBody(mixed $value): AstNode | |
| { | |
| [$type, $payload] = $this->tagged($value, 'TableBody'); | |
| if ($type !== 'TableBody') { | |
| throw new \InvalidArgumentException("Expected TableBody, got '{$type}'"); | |
| } | |
| [$attrsValue, $rowHeadColumnsValue, $headRowsValue, $bodyRowsValue] = $this->expectTuple($payload, 4, 'TableBody payload'); | |
| $attrs = $this->parseAttr($attrsValue); | |
| $attrs['rowHeadColumns'] = (int) $rowHeadColumnsValue; | |
| $headRows = $this->parseTableRows($headRowsValue); | |
| if ($headRows !== []) { | |
| $attrs['headRows'] = $headRows; | |
| } | |
| return new AstNode('table_body', $attrs, $this->parseTableRows($bodyRowsValue)); | |
| } | |
| private function parseTableFoot(mixed $value): AstNode | |
| { | |
| [$type, $payload] = $this->tagged($value, 'TableFoot'); | |
| if ($type !== 'TableFoot') { | |
| throw new \InvalidArgumentException("Expected TableFoot, got '{$type}'"); | |
| } | |
| [$attrs, $rows] = $this->expectTuple($payload, 2, 'TableFoot payload'); | |
| return new AstNode('table_foot', $this->parseAttr($attrs), $this->parseTableRows($rows)); | |
| } | |
| /** | |
| * @return list<AstNode> | |
| */ | |
| private function parseTableRows(mixed $value): array | |
| { | |
| return array_map(fn (mixed $row): AstNode => $this->parseTableRow($row), $this->expectList($value, 'Table rows')); | |
| } | |
| private function parseTableRow(mixed $value): AstNode | |
| { | |
| [$type, $payload] = $this->tagged($value, 'Row'); | |
| if ($type !== 'Row') { | |
| throw new \InvalidArgumentException("Expected Row, got '{$type}'"); | |
| } | |
| [$attrs, $cells] = $this->expectTuple($payload, 2, 'Row payload'); | |
| return new AstNode('table_row', $this->parseAttr($attrs), array_map(fn (mixed $cell): AstNode => $this->parseTableCell($cell), $this->expectList($cells, 'Row cells'))); | |
| } | |
| private function parseTableCell(mixed $value): AstNode | |
| { | |
| [$type, $payload] = $this->tagged($value, 'Cell'); | |
| if ($type !== 'Cell') { | |
| throw new \InvalidArgumentException("Expected Cell, got '{$type}'"); | |
| } | |
| [$attrsValue, $alignmentValue, $rowspanValue, $colspanValue, $blocksValue] = $this->expectTuple($payload, 5, 'Cell payload'); | |
| $attrs = $this->parseAttr($attrsValue); | |
| $alignment = $this->parseAlignment($alignmentValue); | |
| if ($alignment !== 'default') { | |
| $attrs['align'] = $alignment; | |
| } | |
| $rowspan = (int) $rowspanValue; | |
| if ($rowspan > 1) { | |
| $attrs['rowspan'] = $rowspan; | |
| } | |
| $colspan = (int) $colspanValue; | |
| if ($colspan > 1) { | |
| $attrs['colspan'] = $colspan; | |
| } | |
| return new AstNode('table_cell', $attrs, $this->parseBlockList($blocksValue)); | |
| } | |
| /** | |
| * @return array{0:string, 1:string} | |
| */ | |
| private function parseTarget(mixed $value): array | |
| { | |
| [$url, $title] = $this->expectTuple($value, 2, 'Target'); | |
| return [ | |
| $this->expectString($url, 'Target URL'), | |
| $this->expectString($title, 'Target title'), | |
| ]; | |
| } | |
| /** | |
| * @return array<string, mixed> | |
| */ | |
| private function parseListAttributes(mixed $value): array | |
| { | |
| [$start, $style, $delimiter] = $this->expectTuple($value, 3, 'ListAttributes'); | |
| return [ | |
| 'start' => (int) $start, | |
| 'style' => $this->parseOrderedListStyle($style), | |
| 'delimiter' => $this->parseOrderedListDelimiter($delimiter), | |
| ]; | |
| } | |
| private function parseOrderedListStyle(mixed $value): string | |
| { | |
| [$style] = $this->tagged($value, 'ListNumberStyle'); | |
| return match ($style) { | |
| 'DefaultStyle' => 'default', | |
| 'Example' => 'example', | |
| 'Decimal' => 'decimal', | |
| 'LowerRoman' => 'lower_roman', | |
| 'UpperRoman' => 'upper_roman', | |
| 'LowerAlpha' => 'lower_alpha', | |
| 'UpperAlpha' => 'upper_alpha', | |
| default => 'default', | |
| }; | |
| } | |
| private function parseOrderedListDelimiter(mixed $value): string | |
| { | |
| [$delimiter] = $this->tagged($value, 'ListNumberDelim'); | |
| return match ($delimiter) { | |
| 'DefaultDelim' => 'default', | |
| 'OneParen' => 'one_paren', | |
| 'TwoParens' => 'two_parens', | |
| default => 'period', | |
| }; | |
| } | |
| private function parseAlignment(mixed $value): string | |
| { | |
| [$alignment] = $this->tagged($value, 'Alignment'); | |
| return match ($alignment) { | |
| 'AlignLeft' => 'left', | |
| 'AlignRight' => 'right', | |
| 'AlignCenter' => 'center', | |
| default => 'default', | |
| }; | |
| } | |
| private function parseColumnWidth(mixed $value): float | |
| { | |
| [$type, $payload] = $this->tagged($value, 'ColWidth'); | |
| if ($type === 'ColWidthDefault') { | |
| return 0.0; | |
| } | |
| if ($type !== 'ColWidth') { | |
| throw new \InvalidArgumentException("Unsupported column width '{$type}'"); | |
| } | |
| return (float) $payload; | |
| } | |
| private function parseCitationMode(mixed $value): string | |
| { | |
| [$mode] = $this->tagged($value, 'CitationMode'); | |
| return match ($mode) { | |
| 'AuthorInText' => 'author_in_text', | |
| 'SuppressAuthor' => 'suppress_author', | |
| default => 'normal', | |
| }; | |
| } | |
| private function parseFormat(mixed $value): string | |
| { | |
| return strtolower($this->expectString($value, 'Format')); | |
| } | |
| /** | |
| * @param list<AstNode> $blocks | |
| * @return list<AstNode> | |
| */ | |
| private function captionInlinesFromBlocks(array $blocks): array | |
| { | |
| if (count($blocks) !== 1) { | |
| return []; | |
| } | |
| $block = $blocks[0]; | |
| if (!in_array($block->type, ['paragraph', 'plain'], true)) { | |
| return []; | |
| } | |
| return $block->children; | |
| } | |
| /** | |
| * @param list<AstNode> $blocks | |
| * @return list<AstNode> | |
| */ | |
| private function figureChildrenFromNativeBlocks(array $blocks): array | |
| { | |
| $children = []; | |
| foreach ($blocks as $block) { | |
| if ($block->type === 'plain' && count($block->children) === 1 && $block->children[0]->type === 'image') { | |
| $children[] = $block->children[0]; | |
| continue; | |
| } | |
| $children[] = $block; | |
| } | |
| return $children; | |
| } | |
| /** | |
| * @param list<AstNode> $blocks | |
| */ | |
| private function plainBlockText(array $blocks): string | |
| { | |
| $parts = []; | |
| foreach ($blocks as $block) { | |
| if ($block->children !== []) { | |
| $parts[] = $this->plainInlineText($block->children); | |
| } else { | |
| $parts[] = (string) $block->attr('text', ''); | |
| } | |
| } | |
| return trim(implode(' ', array_filter($parts, static fn (string $part): bool => $part !== ''))); | |
| } | |
| /** | |
| * @param list<AstNode> $inlines | |
| */ | |
| private function plainInlineText(array $inlines): string | |
| { | |
| $text = ''; | |
| foreach ($inlines as $inline) { | |
| $text .= match ($inline->type) { | |
| 'text', 'code' => (string) $inline->attr('text', ''), | |
| 'softbreak', 'linebreak' => ' ', | |
| default => $this->plainInlineText($inline->children), | |
| }; | |
| } | |
| return trim(preg_replace('/\s+/u', ' ', $text) ?? $text); | |
| } | |
| private function isTypedValue(mixed $value, string $type): bool | |
| { | |
| return is_array($value) | |
| && ($value['type'] ?? null) === $type | |
| && array_key_exists('value', $value); | |
| } | |
| /** | |
| * @return array{0:string, 1:mixed} | |
| */ | |
| private function tagged(mixed $value, string $context): array | |
| { | |
| $node = $this->expectMap($value, $context); | |
| if (!isset($node['t']) || !is_string($node['t']) || $node['t'] === '') { | |
| throw new \InvalidArgumentException("Expected tagged Pandoc JSON {$context}"); | |
| } | |
| return [$node['t'], $node['c'] ?? null]; | |
| } | |
| /** | |
| * @return array<int, mixed> | |
| */ | |
| private function expectTuple(mixed $value, int $length, string $context): array | |
| { | |
| $items = $this->expectList($value, $context); | |
| if (count($items) !== $length) { | |
| throw new \InvalidArgumentException("Expected {$context} to contain {$length} entries"); | |
| } | |
| return $items; | |
| } | |
| /** | |
| * @return array<int, mixed> | |
| */ | |
| private function expectList(mixed $value, string $context): array | |
| { | |
| if (!is_array($value) || !$this->isList($value)) { | |
| throw new \InvalidArgumentException("Expected {$context} to be a JSON array"); | |
| } | |
| return array_values($value); | |
| } | |
| /** | |
| * @return array<string, mixed> | |
| */ | |
| private function expectMap(mixed $value, string $context): array | |
| { | |
| if (!is_array($value)) { | |
| throw new \InvalidArgumentException("Expected {$context} to be a JSON object"); | |
| } | |
| if ($this->isList($value) && $value !== []) { | |
| throw new \InvalidArgumentException("Expected {$context} to be a JSON object"); | |
| } | |
| return $value; | |
| } | |
| private function expectString(mixed $value, string $context): string | |
| { | |
| if (!is_string($value)) { | |
| throw new \InvalidArgumentException("Expected {$context} to be a string"); | |
| } | |
| return $value; | |
| } | |
| /** | |
| * @param array<mixed> $value | |
| */ | |
| private function isList(array $value): bool | |
| { | |
| if ($value === []) { | |
| return true; | |
| } | |
| return array_keys($value) === range(0, count($value) - 1); | |
| } | |
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| <?php | |
| declare(strict_types=1); | |
| namespace PortLibs\Pandoc; | |
| final class JsonWriter | |
| { | |
| public function write(AstNode $document): string | |
| { | |
| if ($document->type !== 'document') { | |
| throw new \InvalidArgumentException('JSON writer expects a document node'); | |
| } | |
| return json_encode([ | |
| 'pandoc-api-version' => JsonReader::PANDOC_API_VERSION, | |
| 'meta' => $this->metaData($document->attr('meta', [])), | |
| 'blocks' => $this->blockListData($document->children), | |
| ], JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT) . "\n"; | |
| } | |
| /** | |
| * @param mixed $meta | |
| * @return array<string, mixed> | |
| */ | |
| private function metaData(mixed $meta): array | |
| { | |
| if (!is_array($meta)) { | |
| return []; | |
| } | |
| $data = []; | |
| foreach ($this->normalizedMetaEntries($meta) as $key => $value) { | |
| $data[$key] = $this->metaValueData($value); | |
| } | |
| return $data; | |
| } | |
| /** | |
| * @param array<string, mixed> $meta | |
| * @return array<string, mixed> | |
| */ | |
| private function normalizedMetaEntries(array $meta): array | |
| { | |
| $entries = []; | |
| foreach ($meta as $key => $value) { | |
| if (in_array($key, ['titleInlines', 'authorInlines', 'dateInlines', 'authors'], true)) { | |
| continue; | |
| } | |
| $entries[(string) $key] = $value; | |
| } | |
| if (isset($meta['titleInlines']) && is_array($meta['titleInlines'])) { | |
| $entries['title'] = ['type' => 'MetaInlines', 'value' => $meta['titleInlines']]; | |
| } elseif (isset($meta['title'])) { | |
| $entries['title'] = ['type' => 'MetaInlines', 'value' => $this->textInlines((string) $meta['title'])]; | |
| } | |
| if (isset($meta['authorInlines']) && is_array($meta['authorInlines'])) { | |
| $authors = []; | |
| foreach ($meta['authorInlines'] as $author) { | |
| if (is_array($author)) { | |
| $authors[] = ['type' => 'MetaInlines', 'value' => $author]; | |
| } | |
| } | |
| if ($authors !== []) { | |
| $entries['author'] = ['type' => 'MetaList', 'value' => $authors]; | |
| } | |
| } elseif (isset($meta['author']) && is_array($meta['author']) && !isset($meta['author']['type'])) { | |
| $authors = []; | |
| foreach ($meta['author'] as $author) { | |
| $authors[] = ['type' => 'MetaInlines', 'value' => $this->textInlines((string) $author)]; | |
| } | |
| if ($authors !== []) { | |
| $entries['author'] = ['type' => 'MetaList', 'value' => $authors]; | |
| } | |
| } | |
| if (isset($meta['dateInlines']) && is_array($meta['dateInlines'])) { | |
| $entries['date'] = ['type' => 'MetaInlines', 'value' => $meta['dateInlines']]; | |
| } elseif (isset($meta['date'])) { | |
| $entries['date'] = ['type' => 'MetaInlines', 'value' => $this->textInlines((string) $meta['date'])]; | |
| } | |
| ksort($entries); | |
| return $entries; | |
| } | |
| private function metaValueData(mixed $value): array | |
| { | |
| if (is_array($value) && isset($value['type'])) { | |
| $type = (string) $value['type']; | |
| $payload = $value['value'] ?? null; | |
| return match ($type) { | |
| 'MetaMap' => $this->tagged('MetaMap', $this->metaMapData(is_array($payload) ? $payload : [])), | |
| 'MetaList' => $this->tagged('MetaList', array_map(fn (mixed $item): array => $this->metaValueData($item), $this->listData(is_array($payload) ? $payload : []))), | |
| 'MetaBool' => $this->tagged('MetaBool', (bool) $payload), | |
| 'MetaString' => $this->tagged('MetaString', (string) $payload), | |
| 'MetaInlines' => $this->tagged('MetaInlines', $this->inlineListData(is_array($payload) ? $payload : [])), | |
| 'MetaBlocks' => $this->tagged('MetaBlocks', $this->blockListData(is_array($payload) ? $payload : [])), | |
| default => $this->tagged('MetaString', (string) $payload), | |
| }; | |
| } | |
| if ($value instanceof AstNode) { | |
| if ($this->isInlineNode($value)) { | |
| return $this->tagged('MetaInlines', $this->inlineListData([$value])); | |
| } | |
| return $this->tagged('MetaBlocks', $this->blockListData([$value])); | |
| } | |
| if (is_bool($value)) { | |
| return $this->tagged('MetaBool', $value); | |
| } | |
| if (is_string($value) || is_int($value) || is_float($value)) { | |
| return $this->tagged('MetaString', (string) $value); | |
| } | |
| if (is_array($value)) { | |
| if ($this->isAstNodeList($value)) { | |
| $inlineNodes = array_filter($value, fn (mixed $node): bool => $node instanceof AstNode && $this->isInlineNode($node)); | |
| if (count($inlineNodes) === count($value)) { | |
| return $this->tagged('MetaInlines', $this->inlineListData($value)); | |
| } | |
| return $this->tagged('MetaBlocks', $this->blockListData($value)); | |
| } | |
| if ($this->isList($value)) { | |
| return $this->tagged('MetaList', array_map(fn (mixed $item): array => $this->metaValueData($item), $value)); | |
| } | |
| return $this->tagged('MetaMap', $this->metaMapData($value)); | |
| } | |
| return $this->tagged('MetaString', (string) $value); | |
| } | |
| /** | |
| * @param array<string, mixed> $value | |
| * @return array<string, mixed> | |
| */ | |
| private function metaMapData(array $value): array | |
| { | |
| ksort($value); | |
| $data = []; | |
| foreach ($value as $key => $item) { | |
| $data[(string) $key] = $this->metaValueData($item); | |
| } | |
| return $data; | |
| } | |
| /** | |
| * @param list<AstNode> $blocks | |
| * @return list<array<string, mixed>> | |
| */ | |
| private function blockListData(array $blocks): array | |
| { | |
| return array_map(fn (AstNode $block): array => $this->blockData($block), array_values($blocks)); | |
| } | |
| /** | |
| * @return array<string, mixed> | |
| */ | |
| private function blockData(AstNode $node): array | |
| { | |
| return match ($node->type) { | |
| 'plain' => $this->tagged('Plain', $this->inlineListData($node->children)), | |
| 'paragraph' => $this->tagged('Para', $this->inlineListData($node->children)), | |
| 'line_block' => $this->tagged('LineBlock', array_map(fn (AstNode $line): array => $this->inlineListData($this->lineInlines($line)), $node->children)), | |
| 'code_block' => $this->tagged('CodeBlock', [$this->attrData($node), (string) $node->attr('text', '')]), | |
| 'raw_html' => $this->tagged('RawBlock', ['html', (string) $node->attr('html', $node->attr('text', ''))]), | |
| 'raw_tex' => $this->tagged('RawBlock', ['tex', (string) $node->attr('tex', $node->attr('text', ''))]), | |
| 'raw_block', 'raw_markdown' => $this->tagged('RawBlock', [(string) $node->attr('format', 'markdown'), (string) $node->attr('text', '')]), | |
| 'blockquote' => $this->tagged('BlockQuote', $this->blockListData($node->children)), | |
| 'ordered_list' => $this->tagged('OrderedList', [$this->listAttributesData($node), $this->listItemsData($node->children)]), | |
| 'bullet_list' => $this->tagged('BulletList', $this->listItemsData($node->children)), | |
| 'definition_list' => $this->tagged('DefinitionList', $this->definitionItemsData($node->children)), | |
| 'heading' => $this->tagged('Header', [ | |
| max(1, min(6, (int) $node->attr('level', 1))), | |
| $this->attrData($node), | |
| $this->inlineListData($node->children === [] ? $this->textInlines((string) $node->attr('text', '')) : $node->children), | |
| ]), | |
| 'horizontal_rule' => $this->tagged('HorizontalRule'), | |
| 'table' => $this->tableData($node), | |
| 'figure' => $this->tagged('Figure', [$this->attrData($node), $this->captionData($node), $this->blockListData($this->figureBlocks($node))]), | |
| 'div' => $this->tagged('Div', [$this->attrData($node), $this->blockListData($node->children)]), | |
| default => throw new \InvalidArgumentException("JSON writer does not support block node '{$node->type}'"), | |
| }; | |
| } | |
| /** | |
| * @param list<AstNode> $items | |
| * @return list<list<array<string, mixed>>> | |
| */ | |
| private function listItemsData(array $items): array | |
| { | |
| $data = []; | |
| foreach ($items as $item) { | |
| if ($item->type === 'list_item') { | |
| $data[] = $this->blockListData($this->listItemBlocks($item)); | |
| } | |
| } | |
| return $data; | |
| } | |
| /** | |
| * @return list<AstNode> | |
| */ | |
| private function listItemBlocks(AstNode $item): array | |
| { | |
| $blocks = []; | |
| $inlines = []; | |
| foreach ($item->children as $child) { | |
| if ($this->isInlineNode($child)) { | |
| $inlines[] = $child; | |
| continue; | |
| } | |
| if ($inlines !== []) { | |
| $blocks[] = new AstNode('plain', [], $inlines); | |
| $inlines = []; | |
| } | |
| $blocks[] = $child; | |
| } | |
| if ($inlines !== []) { | |
| $blocks[] = new AstNode('plain', [], $inlines); | |
| } | |
| return $blocks; | |
| } | |
| /** | |
| * @param list<AstNode> $items | |
| * @return list<array{0:list<array<string, mixed>>, 1:list<list<array<string, mixed>>>}> | |
| */ | |
| private function definitionItemsData(array $items): array | |
| { | |
| $data = []; | |
| foreach ($items as $item) { | |
| if ($item->type !== 'definition_item') { | |
| continue; | |
| } | |
| $term = null; | |
| $definitions = []; | |
| foreach ($item->children as $child) { | |
| if ($child->type === 'term') { | |
| $term = $child; | |
| } elseif ($child->type === 'definition') { | |
| $definitions[] = $this->blockListData($child->children); | |
| } | |
| } | |
| $termInlines = $term instanceof AstNode | |
| ? ($term->children === [] ? $this->textInlines((string) $term->attr('text', $item->attr('term', ''))) : $term->children) | |
| : $this->textInlines((string) $item->attr('term', '')); | |
| $data[] = [$this->inlineListData($termInlines), $definitions]; | |
| } | |
| return $data; | |
| } | |
| /** | |
| * @return array<string, mixed> | |
| */ | |
| private function tableData(AstNode $table): array | |
| { | |
| $columnCount = $this->tableColumnCount($table); | |
| return $this->tagged('Table', [ | |
| $this->attrData($table), | |
| $this->captionData($table), | |
| $this->tableColSpecsData($table, $columnCount), | |
| $this->tableHeadData($this->tableSection($table, 'table_head')), | |
| $this->tableBodiesData($table), | |
| $this->tableFootData($this->tableSection($table, 'table_foot')), | |
| ]); | |
| } | |
| /** | |
| * @return list<array{0:array<string, mixed>, 1:array<string, mixed>}> | |
| */ | |
| private function tableColSpecsData(AstNode $table, int $columnCount): array | |
| { | |
| if ($columnCount <= 0) { | |
| return []; | |
| } | |
| $alignments = $this->tableAlignments($table, $columnCount); | |
| $widths = $this->tableWidths($table, $columnCount); | |
| $specs = []; | |
| for ($index = 0; $index < $columnCount; $index++) { | |
| $specs[] = [ | |
| $this->alignmentData($alignments[$index] ?? 'default'), | |
| $this->columnWidthData($widths[$index] ?? null), | |
| ]; | |
| } | |
| return $specs; | |
| } | |
| /** | |
| * @return array<string, mixed> | |
| */ | |
| private function tableHeadData(?AstNode $head): array | |
| { | |
| return $this->tagged('TableHead', [ | |
| $this->attrData($head ?? new AstNode('table_head')), | |
| $this->tableRowsData($head instanceof AstNode ? $this->tableRowsFromChildren($head->children) : []), | |
| ]); | |
| } | |
| /** | |
| * @return list<array<string, mixed>> | |
| */ | |
| private function tableBodiesData(AstNode $table): array | |
| { | |
| $data = []; | |
| foreach ($this->tableBodies($table) as $body) { | |
| $data[] = $this->tagged('TableBody', [ | |
| $this->attrData($body), | |
| max(0, (int) $body->attr('rowHeadColumns', 0)), | |
| $this->tableRowsData($this->tableBodyHeadRows($body)), | |
| $this->tableRowsData($this->tableRowsFromChildren($body->children)), | |
| ]); | |
| } | |
| return $data; | |
| } | |
| /** | |
| * @return array<string, mixed> | |
| */ | |
| private function tableFootData(?AstNode $foot): array | |
| { | |
| return $this->tagged('TableFoot', [ | |
| $this->attrData($foot ?? new AstNode('table_foot')), | |
| $this->tableRowsData($foot instanceof AstNode ? $this->tableRowsFromChildren($foot->children) : []), | |
| ]); | |
| } | |
| /** | |
| * @param list<AstNode> $rows | |
| * @return list<array<string, mixed>> | |
| */ | |
| private function tableRowsData(array $rows): array | |
| { | |
| $data = []; | |
| foreach ($rows as $row) { | |
| if ($row->type === 'table_row') { | |
| $data[] = $this->tagged('Row', [$this->attrData($row), $this->tableCellsData($row->children)]); | |
| } | |
| } | |
| return $data; | |
| } | |
| /** | |
| * @param list<AstNode> $cells | |
| * @return list<array<string, mixed>> | |
| */ | |
| private function tableCellsData(array $cells): array | |
| { | |
| $data = []; | |
| foreach ($cells as $cell) { | |
| if ($cell->type !== 'table_cell') { | |
| continue; | |
| } | |
| $data[] = $this->tagged('Cell', [ | |
| $this->attrData($cell), | |
| $this->alignmentData((string) $cell->attr('align', 'default')), | |
| max(1, (int) $cell->attr('rowspan', 1)), | |
| max(1, (int) $cell->attr('colspan', 1)), | |
| $this->blockListData($this->tableCellBlocks($cell)), | |
| ]); | |
| } | |
| return $data; | |
| } | |
| /** | |
| * @return list<AstNode> | |
| */ | |
| private function tableCellBlocks(AstNode $cell): array | |
| { | |
| if ($cell->children === []) { | |
| $text = (string) $cell->attr('text', ''); | |
| return $text === '' ? [] : [new AstNode('plain', [], $this->textInlines($text))]; | |
| } | |
| $blocks = []; | |
| $inlines = []; | |
| foreach ($cell->children as $child) { | |
| if ($this->isInlineNode($child)) { | |
| $inlines[] = $child; | |
| continue; | |
| } | |
| if ($inlines !== []) { | |
| $blocks[] = new AstNode('plain', [], $inlines); | |
| $inlines = []; | |
| } | |
| $blocks[] = $child; | |
| } | |
| if ($inlines !== []) { | |
| $blocks[] = new AstNode('plain', [], $inlines); | |
| } | |
| return $blocks; | |
| } | |
| /** | |
| * @param list<AstNode> $nodes | |
| * @return list<array<string, mixed>> | |
| */ | |
| private function inlineListData(array $nodes): array | |
| { | |
| $data = []; | |
| foreach ($nodes as $node) { | |
| array_push($data, ...$this->inlineData($node)); | |
| } | |
| return $data; | |
| } | |
| /** | |
| * @return list<array<string, mixed>> | |
| */ | |
| private function inlineData(AstNode $node): array | |
| { | |
| return match ($node->type) { | |
| 'text' => $this->textInlineData((string) $node->attr('text', '')), | |
| 'softbreak' => [$this->tagged('SoftBreak')], | |
| 'linebreak' => [$this->tagged('LineBreak')], | |
| 'emph' => [$this->tagged('Emph', $this->inlineListData($node->children))], | |
| 'underline' => [$this->tagged('Underline', $this->inlineListData($node->children))], | |
| 'strong' => [$this->tagged('Strong', $this->inlineListData($node->children))], | |
| 'strikeout' => [$this->tagged('Strikeout', $this->inlineListData($node->children))], | |
| 'superscript' => [$this->tagged('Superscript', $this->inlineListData($node->children))], | |
| 'subscript' => [$this->tagged('Subscript', $this->inlineListData($node->children))], | |
| 'small_caps' => [$this->tagged('SmallCaps', $this->inlineListData($node->children))], | |
| 'quoted' => [$this->tagged('Quoted', [$this->tagged(((string) $node->attr('kind', 'double')) === 'single' ? 'SingleQuote' : 'DoubleQuote'), $this->inlineListData($node->children)])], | |
| 'citation' => [$this->citationData($node)], | |
| 'code' => [$this->tagged('Code', [$this->attrData($node), (string) $node->attr('text', '')])], | |
| 'math' => [$this->tagged('Math', [$this->tagged($this->mathIsDisplay($node) ? 'DisplayMath' : 'InlineMath'), (string) $node->attr('text', '')])], | |
| 'raw_html_inline' => [$this->tagged('RawInline', ['html', (string) $node->attr('html', $node->attr('text', ''))])], | |
| 'raw_tex_inline' => [$this->tagged('RawInline', ['tex', (string) $node->attr('tex', $node->attr('text', ''))])], | |
| 'raw_inline' => [$this->tagged('RawInline', [(string) $node->attr('format', 'markdown'), (string) $node->attr('text', '')])], | |
| 'link' => [$this->tagged('Link', [$this->attrData($node), $this->inlineListData($node->children), [(string) $node->attr('url', ''), (string) $node->attr('title', '')]])], | |
| 'image' => [$this->tagged('Image', [ | |
| $this->attrData($node), | |
| $this->inlineListData($node->children === [] ? $this->textInlines((string) $node->attr('alt', '')) : $node->children), | |
| [(string) $node->attr('url', $node->attr('src', '')), (string) $node->attr('title', '')], | |
| ])], | |
| 'note' => [$this->tagged('Note', $this->blockListData($node->children))], | |
| 'span' => [$this->tagged('Span', [$this->attrData($node), $this->inlineListData($node->children)])], | |
| default => throw new \InvalidArgumentException("JSON writer does not support inline node '{$node->type}'"), | |
| }; | |
| } | |
| /** | |
| * @return array<string, mixed> | |
| */ | |
| private function citationData(AstNode $node): array | |
| { | |
| $citations = $this->citationEntries($node); | |
| $display = $node->children; | |
| if ($display === []) { | |
| $display = $this->textInlines((string) $node->attr('text', $this->citationDisplayText($citations))); | |
| } | |
| return $this->tagged('Cite', [ | |
| array_map(fn (array $citation): array => $this->citationEntryData($citation), $citations), | |
| $this->inlineListData($display), | |
| ]); | |
| } | |
| /** | |
| * @return list<array<string, mixed>> | |
| */ | |
| private function citationEntries(AstNode $node): array | |
| { | |
| $citations = $node->attr('citations', null); | |
| if (is_array($citations) && $citations !== []) { | |
| $entries = []; | |
| foreach ($citations as $citation) { | |
| if ($citation instanceof AstNode) { | |
| $entries[] = $this->citationEntryFromNode($citation); | |
| } elseif (is_array($citation)) { | |
| $entries[] = $citation; | |
| } | |
| } | |
| return $entries; | |
| } | |
| return [$this->citationEntryFromNode($node)]; | |
| } | |
| /** | |
| * @return array<string, mixed> | |
| */ | |
| private function citationEntryFromNode(AstNode $node): array | |
| { | |
| return [ | |
| 'id' => (string) $node->attr('id', ''), | |
| 'prefix' => $node->attr('prefix', []), | |
| 'suffix' => $node->attr('suffix', []), | |
| 'mode' => (string) $node->attr('mode', 'normal'), | |
| 'noteNum' => $node->attr('noteNum', $node->attr('citationNoteNum', 1)), | |
| 'hash' => $node->attr('hash', $node->attr('citationHash', 0)), | |
| ]; | |
| } | |
| /** | |
| * @param array<string, mixed> $citation | |
| * @return array<string, mixed> | |
| */ | |
| private function citationEntryData(array $citation): array | |
| { | |
| return [ | |
| 'citationId' => (string) ($citation['id'] ?? $citation['citationId'] ?? ''), | |
| 'citationPrefix' => $this->citationAffixData($citation['prefix'] ?? []), | |
| 'citationSuffix' => $this->citationAffixData($citation['suffix'] ?? []), | |
| 'citationMode' => $this->citationModeData((string) ($citation['mode'] ?? 'normal')), | |
| 'citationNoteNum' => (int) ($citation['noteNum'] ?? $citation['citationNoteNum'] ?? 1), | |
| 'citationHash' => (int) ($citation['hash'] ?? $citation['citationHash'] ?? 0), | |
| ]; | |
| } | |
| /** | |
| * @return list<array<string, mixed>> | |
| */ | |
| private function citationAffixData(mixed $value): array | |
| { | |
| if (is_string($value)) { | |
| return $value === '' ? [] : $this->inlineListData($this->textInlines($value)); | |
| } | |
| if (!is_array($value)) { | |
| return []; | |
| } | |
| $nodes = []; | |
| foreach ($value as $inline) { | |
| if ($inline instanceof AstNode) { | |
| $nodes[] = $inline; | |
| } elseif (is_string($inline)) { | |
| array_push($nodes, ...$this->textInlines($inline)); | |
| } | |
| } | |
| return $nodes === [] ? [] : $this->inlineListData($nodes); | |
| } | |
| /** | |
| * @return array<string, mixed> | |
| */ | |
| private function citationModeData(string $mode): array | |
| { | |
| return $this->tagged(match (strtolower(str_replace(['-', '_'], '', $mode))) { | |
| 'authorintext' => 'AuthorInText', | |
| 'suppressauthor' => 'SuppressAuthor', | |
| default => 'NormalCitation', | |
| }); | |
| } | |
| private function mathIsDisplay(AstNode $node): bool | |
| { | |
| $display = $node->attr('display', null); | |
| if (is_bool($display)) { | |
| return $display; | |
| } | |
| return (string) $node->attr('kind', $display ?? 'inline') === 'display'; | |
| } | |
| /** | |
| * @return array<string, mixed> | |
| */ | |
| private function captionData(AstNode $node): array | |
| { | |
| $shortCaption = $this->captionInlines($node->attr('shortCaptionInlines', null), $node->attr('shortCaption', null)); | |
| $longBlocks = $this->captionBlocks($node); | |
| return $this->tagged('Caption', [ | |
| $shortCaption === null ? null : $this->inlineListData($shortCaption), | |
| $this->blockListData($longBlocks), | |
| ]); | |
| } | |
| /** | |
| * @return list<AstNode> | |
| */ | |
| private function captionBlocks(AstNode $node): array | |
| { | |
| $captionBlocks = $node->attr('captionBlocks', null); | |
| if (is_array($captionBlocks) && $this->isAstNodeList($captionBlocks)) { | |
| return $captionBlocks; | |
| } | |
| $captionInlines = $this->captionInlines($node->attr('captionInlines', null), $node->attr('caption', null)); | |
| if ($captionInlines === null) { | |
| return []; | |
| } | |
| return [new AstNode('plain', [], $captionInlines)]; | |
| } | |
| /** | |
| * @return list<AstNode>|null | |
| */ | |
| private function captionInlines(mixed $inlines, mixed $fallback): ?array | |
| { | |
| if (is_array($inlines) && $this->isAstNodeList($inlines)) { | |
| return $inlines; | |
| } | |
| if ($fallback instanceof AstNode) { | |
| return $this->isInlineNode($fallback) ? [$fallback] : null; | |
| } | |
| if (is_string($fallback) && $fallback !== '') { | |
| return $this->textInlines($fallback); | |
| } | |
| return null; | |
| } | |
| /** | |
| * @return list<AstNode> | |
| */ | |
| private function figureBlocks(AstNode $node): array | |
| { | |
| $blocks = []; | |
| $inlines = []; | |
| foreach ($node->children as $child) { | |
| if ($this->isInlineNode($child)) { | |
| $inlines[] = $child; | |
| continue; | |
| } | |
| if ($inlines !== []) { | |
| $blocks[] = new AstNode('plain', [], $inlines); | |
| $inlines = []; | |
| } | |
| $blocks[] = $child; | |
| } | |
| if ($inlines !== []) { | |
| $blocks[] = new AstNode('plain', [], $inlines); | |
| } | |
| return $blocks; | |
| } | |
| /** | |
| * @return list<AstNode> | |
| */ | |
| private function lineInlines(AstNode $line): array | |
| { | |
| return $line->children === [] ? $this->textInlines((string) $line->attr('text', '')) : $line->children; | |
| } | |
| /** | |
| * @return list<AstNode> | |
| */ | |
| private function textInlines(string $text): array | |
| { | |
| $nodes = []; | |
| foreach ($this->textParts($text) as $part) { | |
| $nodes[] = new AstNode('text', ['text' => $part]); | |
| } | |
| return $nodes; | |
| } | |
| /** | |
| * @return list<array<string, mixed>> | |
| */ | |
| private function textInlineData(string $text): array | |
| { | |
| $parts = []; | |
| foreach ($this->textParts($text) as $part) { | |
| if (preg_match('/^[ \t]+$/', $part) === 1) { | |
| $parts[] = $this->tagged('Space'); | |
| } elseif ($part === "\n") { | |
| $parts[] = $this->tagged('SoftBreak'); | |
| } else { | |
| $parts[] = $this->tagged('Str', $part); | |
| } | |
| } | |
| return $parts; | |
| } | |
| /** | |
| * @return list<string> | |
| */ | |
| private function textParts(string $text): array | |
| { | |
| if ($text === '') { | |
| return []; | |
| } | |
| $parts = preg_split('/([ \t]+|\n)/u', $text, -1, PREG_SPLIT_DELIM_CAPTURE | PREG_SPLIT_NO_EMPTY); | |
| return $parts === false ? [$text] : $parts; | |
| } | |
| /** | |
| * @return array{0:string, 1:list<string>, 2:list<array{0:string, 1:string}>} | |
| */ | |
| private function attrData(AstNode $node): array | |
| { | |
| $classes = $node->attr('classes', []); | |
| $attributes = $node->attr('attributes', []); | |
| if (!is_array($classes)) { | |
| $classes = []; | |
| } | |
| if (!is_array($attributes)) { | |
| $attributes = []; | |
| } | |
| ksort($attributes); | |
| $pairs = []; | |
| foreach ($attributes as $key => $value) { | |
| $pairs[] = [(string) $key, (string) $value]; | |
| } | |
| return [ | |
| (string) $node->attr('id', ''), | |
| array_map(static fn (mixed $class): string => (string) $class, array_values($classes)), | |
| $pairs, | |
| ]; | |
| } | |
| /** | |
| * @return array{0:int, 1:array<string, mixed>, 2:array<string, mixed>} | |
| */ | |
| private function listAttributesData(AstNode $node): array | |
| { | |
| return [ | |
| (int) $node->attr('start', 1), | |
| $this->listStyleData((string) $node->attr('style', 'decimal')), | |
| $this->listDelimiterData((string) $node->attr('delimiter', 'period')), | |
| ]; | |
| } | |
| /** | |
| * @return array<string, mixed> | |
| */ | |
| private function listStyleData(string $style): array | |
| { | |
| return $this->tagged(match ($style) { | |
| 'default' => 'DefaultStyle', | |
| 'lower_alpha' => 'LowerAlpha', | |
| 'upper_alpha' => 'UpperAlpha', | |
| 'lower_roman' => 'LowerRoman', | |
| 'upper_roman' => 'UpperRoman', | |
| 'example' => 'Example', | |
| default => 'Decimal', | |
| }); | |
| } | |
| /** | |
| * @return array<string, mixed> | |
| */ | |
| private function listDelimiterData(string $delimiter): array | |
| { | |
| return $this->tagged(match ($delimiter) { | |
| 'default' => 'DefaultDelim', | |
| 'one_paren' => 'OneParen', | |
| 'two_parens' => 'TwoParens', | |
| default => 'Period', | |
| }); | |
| } | |
| /** | |
| * @return array<string, mixed> | |
| */ | |
| private function alignmentData(string $alignment): array | |
| { | |
| return $this->tagged(match ($alignment) { | |
| 'left' => 'AlignLeft', | |
| 'right' => 'AlignRight', | |
| 'center' => 'AlignCenter', | |
| default => 'AlignDefault', | |
| }); | |
| } | |
| /** | |
| * @return array<string, mixed> | |
| */ | |
| private function columnWidthData(mixed $width): array | |
| { | |
| if (is_numeric($width) && (float) $width > 0.0) { | |
| return $this->tagged('ColWidth', (float) $width); | |
| } | |
| return $this->tagged('ColWidthDefault'); | |
| } | |
| private function tableColumnCount(AstNode $table): int | |
| { | |
| $max = max(count($this->tableAlignments($table, 0)), count($this->tableWidths($table, 0))); | |
| foreach ($this->tableAllRows($table) as $row) { | |
| $columns = 0; | |
| foreach ($row->children as $cell) { | |
| if ($cell->type === 'table_cell') { | |
| $columns += max(1, (int) $cell->attr('colspan', 1)); | |
| } | |
| } | |
| $max = max($max, $columns); | |
| } | |
| return $max; | |
| } | |
| /** | |
| * @return list<string> | |
| */ | |
| private function tableAlignments(AstNode $table, int $columnCount): array | |
| { | |
| $alignments = $table->attr('alignments', []); | |
| if (!is_array($alignments)) { | |
| $alignments = []; | |
| } | |
| $normalized = []; | |
| foreach ($alignments as $alignment) { | |
| $normalized[] = in_array($alignment, ['left', 'right', 'center'], true) ? (string) $alignment : 'default'; | |
| } | |
| while ($columnCount > 0 && count($normalized) < $columnCount) { | |
| $normalized[] = 'default'; | |
| } | |
| return $columnCount > 0 ? array_slice($normalized, 0, $columnCount) : $normalized; | |
| } | |
| /** | |
| * @return list<float> | |
| */ | |
| private function tableWidths(AstNode $table, int $columnCount): array | |
| { | |
| $widths = $table->attr('widths', []); | |
| if (!is_array($widths)) { | |
| $widths = []; | |
| } | |
| $normalized = []; | |
| foreach ($widths as $width) { | |
| $normalized[] = is_numeric($width) ? max(0.0, (float) $width) : 0.0; | |
| } | |
| while ($columnCount > 0 && count($normalized) < $columnCount) { | |
| $normalized[] = 0.0; | |
| } | |
| return $columnCount > 0 ? array_slice($normalized, 0, $columnCount) : $normalized; | |
| } | |
| private function tableSection(AstNode $table, string $type): ?AstNode | |
| { | |
| foreach ($table->children as $child) { | |
| if ($child->type === $type) { | |
| return $child; | |
| } | |
| } | |
| return null; | |
| } | |
| /** | |
| * @return list<AstNode> | |
| */ | |
| private function tableBodies(AstNode $table): array | |
| { | |
| $bodies = []; | |
| foreach ($table->children as $child) { | |
| if ($child->type === 'table_body') { | |
| $bodies[] = $child; | |
| } | |
| } | |
| return $bodies; | |
| } | |
| /** | |
| * @return list<AstNode> | |
| */ | |
| private function tableBodyHeadRows(AstNode $body): array | |
| { | |
| $headRows = $body->attr('headRows', []); | |
| if (!is_array($headRows)) { | |
| return []; | |
| } | |
| return array_values(array_filter($headRows, static fn (mixed $row): bool => $row instanceof AstNode && $row->type === 'table_row')); | |
| } | |
| /** | |
| * @param list<AstNode> $children | |
| * @return list<AstNode> | |
| */ | |
| private function tableRowsFromChildren(array $children): array | |
| { | |
| return array_values(array_filter($children, static fn (AstNode $node): bool => $node->type === 'table_row')); | |
| } | |
| /** | |
| * @return list<AstNode> | |
| */ | |
| private function tableAllRows(AstNode $table): array | |
| { | |
| $rows = []; | |
| $head = $this->tableSection($table, 'table_head'); | |
| if ($head instanceof AstNode) { | |
| array_push($rows, ...$this->tableRowsFromChildren($head->children)); | |
| } | |
| foreach ($this->tableBodies($table) as $body) { | |
| array_push($rows, ...$this->tableBodyHeadRows($body), ...$this->tableRowsFromChildren($body->children)); | |
| } | |
| $foot = $this->tableSection($table, 'table_foot'); | |
| if ($foot instanceof AstNode) { | |
| array_push($rows, ...$this->tableRowsFromChildren($foot->children)); | |
| } | |
| return $rows; | |
| } | |
| /** | |
| * @param list<mixed> $value | |
| * @return list<mixed> | |
| */ | |
| private function listData(array $value): array | |
| { | |
| return array_values($value); | |
| } | |
| /** | |
| * @return array<string, mixed> | |
| */ | |
| private function tagged(string $tag, mixed $contents = null): array | |
| { | |
| $data = ['t' => $tag]; | |
| if (func_num_args() > 1) { | |
| $data['c'] = $contents; | |
| } | |
| return $data; | |
| } | |
| /** | |
| * @param array<mixed> $value | |
| */ | |
| private function isAstNodeList(array $value): bool | |
| { | |
| if ($value === []) { | |
| return false; | |
| } | |
| foreach ($value as $item) { | |
| if (!$item instanceof AstNode) { | |
| return false; | |
| } | |
| } | |
| return true; | |
| } | |
| private function isInlineNode(AstNode $node): bool | |
| { | |
| return in_array($node->type, [ | |
| 'text', | |
| 'softbreak', | |
| 'linebreak', | |
| 'emph', | |
| 'strong', | |
| 'strikeout', | |
| 'superscript', | |
| 'subscript', | |
| 'underline', | |
| 'small_caps', | |
| 'code', | |
| 'link', | |
| 'image', | |
| 'note', | |
| 'quoted', | |
| 'math', | |
| 'citation', | |
| 'raw_html_inline', | |
| 'raw_tex_inline', | |
| 'raw_inline', | |
| 'span', | |
| ], true); | |
| } | |
| /** | |
| * @param array<mixed> $value | |
| */ | |
| private function isList(array $value): bool | |
| { | |
| if ($value === []) { | |
| return true; | |
| } | |
| return array_keys($value) === range(0, count($value) - 1); | |
| } | |
| /** | |
| * @param list<array<string, mixed>> $citations | |
| */ | |
| private function citationDisplayText(array $citations): string | |
| { | |
| $parts = []; | |
| foreach ($citations as $citation) { | |
| $id = (string) ($citation['id'] ?? $citation['citationId'] ?? ''); | |
| if ($id !== '') { | |
| $parts[] = '@' . $id; | |
| } | |
| } | |
| return $parts === [] ? '[]' : '[' . implode('; ', $parts) . ']'; | |
| } | |
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| <?php | |
| declare(strict_types=1); | |
| namespace PortLibs\Pandoc; | |
| final class MarkdownReader | |
| { | |
| private const MARKDOWN_ESCAPABLE_ASCII_PUNCTUATION = "!\"#$%&'()*+,-./:;<=>?@[\\]^_`{|}~"; | |
| /** | |
| * Mirrored from upstream pandoc data/abbreviations for the bounded | |
| * markdown abbreviation spacing slice. | |
| * | |
| * @var array<string, true> | |
| */ | |
| private const ABBREVIATIONS = [ | |
| 'aet.' => true, | |
| 'aetat.' => true, | |
| 'al.' => true, | |
| 'Apr.' => true, | |
| 'Aug.' => true, | |
| 'bk.' => true, | |
| 'Bros.' => true, | |
| 'c.' => true, | |
| 'Capt.' => true, | |
| 'cf.' => true, | |
| 'ch.' => true, | |
| 'chap.' => true, | |
| 'chs.' => true, | |
| 'Co.' => true, | |
| 'col.' => true, | |
| 'Corp.' => true, | |
| 'cp.' => true, | |
| 'd.' => true, | |
| 'Dec.' => true, | |
| 'Dr.' => true, | |
| 'e.g.' => true, | |
| 'ed.' => true, | |
| 'eds.' => true, | |
| 'esp.' => true, | |
| 'f.' => true, | |
| 'fasc.' => true, | |
| 'Feb.' => true, | |
| 'ff.' => true, | |
| 'fig.' => true, | |
| 'fl.' => true, | |
| 'fol.' => true, | |
| 'fols.' => true, | |
| 'Fr.' => true, | |
| 'Gen.' => true, | |
| 'Gov.' => true, | |
| 'Hon.' => true, | |
| 'i.e.' => true, | |
| 'ill.' => true, | |
| 'Inc.' => true, | |
| 'incl.' => true, | |
| 'Jan.' => true, | |
| 'Jr.' => true, | |
| 'Jul.' => true, | |
| 'Jun.' => true, | |
| 'Ltd.' => true, | |
| 'M.A.' => true, | |
| 'M.D.' => true, | |
| 'Mar.' => true, | |
| 'Mr.' => true, | |
| 'Mrs.' => true, | |
| 'Ms.' => true, | |
| 'n.' => true, | |
| 'n.b.' => true, | |
| 'nn.' => true, | |
| 'No.' => true, | |
| 'Nov.' => true, | |
| 'Oct.' => true, | |
| 'p.' => true, | |
| 'Ph.D.' => true, | |
| 'pp.' => true, | |
| 'Pres.' => true, | |
| 'Prof.' => true, | |
| 'pt.' => true, | |
| 'q.v.' => true, | |
| 'Rep.' => true, | |
| 'Rev.' => true, | |
| 's.v.' => true, | |
| 's.vv.' => true, | |
| 'saec.' => true, | |
| 'sec.' => true, | |
| 'Sen.' => true, | |
| 'Sep.' => true, | |
| 'Sept.' => true, | |
| 'Sgt.' => true, | |
| 'Sr.' => true, | |
| 'St.' => true, | |
| 'univ.' => true, | |
| 'viz.' => true, | |
| 'vol.' => true, | |
| 'vs.' => true, | |
| ]; | |
| /** @var array<string, array{url:string, title:string}> */ | |
| private array $referenceLinks = []; | |
| /** @var array<string, string> */ | |
| private array $footnoteDefinitions = []; | |
| /** @var array<string, int> */ | |
| private array $exampleReferences = []; | |
| /** @var array<int, int> */ | |
| private array $exampleNumbersByLine = []; | |
| /** @var array<string, array{arity:int, template:string}> */ | |
| private array $rawTexMacros = []; | |
| private ?string $htmlBaseHref = null; | |
| /** @var array<string, list<AstNode>> */ | |
| private array $htmlFootnoteDefinitions = []; | |
| private bool $resolveFootnoteReferences = true; | |
| private int $htmlQuoteDepth = 0; | |
| /** | |
| * @param array{ | |
| * literateHaskell?: bool, | |
| * htmlNativeDivs?: bool, | |
| * htmlRawHtml?: bool, | |
| * rawHtml?: bool, | |
| * htmlIframeResources?: array<string, string|array{mime?: string, contentType?: string, body?: string, content?: string}> | |
| * } $options | |
| */ | |
| public function __construct(private readonly array $options = []) | |
| { | |
| } | |
| public function read(string $markdown): AstNode | |
| { | |
| $blocks = []; | |
| $paragraph = []; | |
| $listStack = []; | |
| $lines = preg_split('/\R/u', rtrim($markdown, "\r\n")) ?: []; | |
| $previousReferenceLinks = $this->referenceLinks; | |
| $previousFootnoteDefinitions = $this->footnoteDefinitions; | |
| $previousExampleReferences = $this->exampleReferences; | |
| $previousExampleNumbersByLine = $this->exampleNumbersByLine; | |
| $previousRawTexMacros = $this->rawTexMacros; | |
| $previousHtmlQuoteDepth = $this->htmlQuoteDepth; | |
| $this->htmlQuoteDepth = 0; | |
| $documentAttrs = []; | |
| [$lines, $titleBlock] = $this->extractTitleBlock($lines); | |
| [$lines, $references, $footnotes] = $this->extractReferenceDefinitions($lines); | |
| $lines = $this->splitMixedHtmlFlowLines($lines); | |
| [$exampleReferences, $exampleNumbersByLine] = $this->collectNumberedExampleReferences($lines); | |
| [$markdownHeadingIds, $implicitHeadingReferences] = $this->collectMarkdownHeadingReferences($lines); | |
| $this->referenceLinks = array_replace($previousReferenceLinks, $implicitHeadingReferences, $references); | |
| $this->footnoteDefinitions = array_replace($previousFootnoteDefinitions, $footnotes); | |
| $this->exampleReferences = array_replace($previousExampleReferences, $exampleReferences); | |
| $this->exampleNumbersByLine = $exampleNumbersByLine; | |
| if ($titleBlock !== null) { | |
| $documentAttrs = array_replace_recursive($documentAttrs, $this->buildTitleBlockAttrs($titleBlock)); | |
| } | |
| for ($index = 0, $count = count($lines); $index < $count; $index++) { | |
| $line = $lines[$index]; | |
| $codeBlock = $this->tryReadFencedCodeBlock($lines, $index); | |
| if ($codeBlock !== null) { | |
| $this->flushParagraph($paragraph, $blocks); | |
| $this->flushListStack($listStack, $blocks); | |
| $blocks[] = $codeBlock; | |
| continue; | |
| } | |
| $literateHaskellCodeBlock = $paragraph === [] && $listStack === [] ? $this->tryReadLiterateHaskellCodeBlock($lines, $index) : null; | |
| if ($literateHaskellCodeBlock !== null) { | |
| $blocks[] = $literateHaskellCodeBlock; | |
| continue; | |
| } | |
| $blockQuote = $paragraph === [] && $listStack === [] ? $this->tryReadBlockQuote($lines, $index) : null; | |
| if ($blockQuote !== null) { | |
| $blocks[] = $blockQuote; | |
| continue; | |
| } | |
| $divBlock = $paragraph === [] && $listStack === [] ? $this->tryReadDivBlock($lines, $index) : null; | |
| if ($divBlock !== null) { | |
| $blocks[] = $divBlock; | |
| continue; | |
| } | |
| $docBookTable = $paragraph === [] && $listStack === [] ? $this->tryReadDocBookTableBlock($lines, $index) : null; | |
| if ($docBookTable !== null) { | |
| $blocks[] = $docBookTable; | |
| continue; | |
| } | |
| $htmlDocument = $paragraph === [] && $listStack === [] ? $this->tryReadHtmlDocumentBlock($lines, $index) : null; | |
| if ($htmlDocument !== null) { | |
| $documentAttrs = array_replace($documentAttrs, $htmlDocument->attrs); | |
| array_push($blocks, ...$htmlDocument->children); | |
| continue; | |
| } | |
| if ($paragraph === [] && $listStack === [] && $this->tryReadEmptyHtmlTableBlock($lines, $index)) { | |
| continue; | |
| } | |
| $nestedHtmlDocumentTable = $paragraph === [] && $listStack === [] ? $this->tryReadStructuredHtmlDocumentTableBlock($lines, $index) : null; | |
| if ($nestedHtmlDocumentTable !== null) { | |
| $blocks[] = $nestedHtmlDocumentTable; | |
| continue; | |
| } | |
| $nestedHtmlTable = $paragraph === [] && $listStack === [] ? $this->tryReadStructuredHtmlTableBlock($lines, $index) : null; | |
| if ($nestedHtmlTable !== null) { | |
| $blocks[] = $nestedHtmlTable; | |
| continue; | |
| } | |
| $htmlCodeBlock = $paragraph === [] && $listStack === [] ? $this->tryReadHtmlPreCodeBlock($lines, $index) : null; | |
| if ($htmlCodeBlock !== null) { | |
| $blocks[] = $htmlCodeBlock; | |
| continue; | |
| } | |
| $htmlBlockQuote = $paragraph === [] && $listStack === [] ? $this->tryReadHtmlBlockQuoteBlock($lines, $index) : null; | |
| if ($htmlBlockQuote !== null) { | |
| $blocks[] = $htmlBlockQuote; | |
| continue; | |
| } | |
| $htmlFigure = $paragraph === [] && $listStack === [] ? $this->tryReadHtmlFigureBlock($lines, $index) : null; | |
| if ($htmlFigure !== null) { | |
| $blocks[] = $htmlFigure; | |
| continue; | |
| } | |
| $htmlIframe = $paragraph === [] && $listStack === [] ? $this->tryReadHtmlIframeBlock($lines, $index) : null; | |
| if ($htmlIframe !== null) { | |
| $blocks[] = $htmlIframe; | |
| continue; | |
| } | |
| $htmlHeading = $paragraph === [] && $listStack === [] ? $this->tryReadHtmlHeadingBlock($lines, $index) : null; | |
| if ($htmlHeading !== null) { | |
| $blocks[] = $htmlHeading; | |
| continue; | |
| } | |
| $htmlList = $paragraph === [] && $listStack === [] ? $this->tryReadHtmlListBlock($lines, $index) : null; | |
| if ($htmlList !== null) { | |
| $blocks[] = $htmlList; | |
| continue; | |
| } | |
| $htmlDefinitionList = $paragraph === [] && $listStack === [] ? $this->tryReadHtmlDefinitionListBlock($lines, $index) : null; | |
| if ($htmlDefinitionList !== null) { | |
| $blocks[] = $htmlDefinitionList; | |
| continue; | |
| } | |
| $htmlNativeDivsMain = $paragraph === [] && $listStack === [] ? $this->tryReadHtmlNativeDivsMainBlock($lines, $index) : null; | |
| if ($htmlNativeDivsMain !== null) { | |
| array_push($blocks, ...$htmlNativeDivsMain->children); | |
| continue; | |
| } | |
| $htmlNativeDivsHeader = $paragraph === [] && $listStack === [] ? $this->tryReadHtmlNativeDivsHeaderBlock($lines, $index) : null; | |
| if ($htmlNativeDivsHeader !== null) { | |
| $blocks[] = $htmlNativeDivsHeader; | |
| continue; | |
| } | |
| $htmlNativeDivsContainer = $paragraph === [] && $listStack === [] ? $this->tryReadHtmlNativeDivsContainerBlock($lines, $index) : null; | |
| if ($htmlNativeDivsContainer !== null) { | |
| $blocks[] = $htmlNativeDivsContainer; | |
| continue; | |
| } | |
| $htmlInlineFragment = $paragraph === [] && $listStack === [] ? $this->tryReadHtmlInlineFragmentBlock($lines, $index) : null; | |
| if ($htmlInlineFragment !== null) { | |
| $blocks[] = $htmlInlineFragment; | |
| continue; | |
| } | |
| $htmlParagraph = $paragraph === [] && $listStack === [] ? $this->tryReadHtmlParagraphBlock($lines, $index) : null; | |
| if ($htmlParagraph !== null) { | |
| $blocks[] = $htmlParagraph; | |
| continue; | |
| } | |
| $htmlHorizontalRule = $paragraph === [] && $listStack === [] ? $this->tryReadHtmlHorizontalRuleBlock($lines, $index) : null; | |
| if ($htmlHorizontalRule !== null) { | |
| $blocks[] = $htmlHorizontalRule; | |
| continue; | |
| } | |
| $rawHtmlDetails = $paragraph === [] && $listStack === [] ? $this->tryReadRawHtmlDetailsBlock($lines, $index) : null; | |
| if ($rawHtmlDetails !== null) { | |
| array_push($blocks, ...$rawHtmlDetails); | |
| continue; | |
| } | |
| $rawHtmlContainer = $paragraph === [] && $listStack === [] ? $this->tryReadRawHtmlSingleLineContainerBlock($lines, $index) : null; | |
| if ($rawHtmlContainer !== null) { | |
| array_push($blocks, ...$rawHtmlContainer); | |
| continue; | |
| } | |
| $rawHtmlBlock = $paragraph === [] && $listStack === [] ? $this->tryReadRawHtmlBlock($lines, $index) : null; | |
| if ($rawHtmlBlock !== null) { | |
| $blocks[] = $rawHtmlBlock; | |
| continue; | |
| } | |
| $latexTableBlock = $paragraph === [] && $listStack === [] ? $this->tryReadLatexTableBlock($lines, $index) : null; | |
| if ($latexTableBlock !== null) { | |
| $blocks[] = $latexTableBlock; | |
| continue; | |
| } | |
| $rawTexBlock = $paragraph === [] && $listStack === [] ? $this->tryReadRawTexBlock($lines, $index) : null; | |
| if ($rawTexBlock !== null) { | |
| $blocks[] = $rawTexBlock; | |
| continue; | |
| } | |
| $gridTable = $this->tryReadGridTable($lines, $index); | |
| if ($gridTable !== null) { | |
| $this->flushParagraph($paragraph, $blocks); | |
| $this->flushListStack($listStack, $blocks); | |
| $blocks[] = $gridTable; | |
| continue; | |
| } | |
| $simpleTable = $this->tryReadSimpleTable($lines, $index); | |
| if ($simpleTable !== null) { | |
| $this->flushParagraph($paragraph, $blocks); | |
| $this->flushListStack($listStack, $blocks); | |
| $blocks[] = $simpleTable; | |
| continue; | |
| } | |
| $pipeTable = $this->tryReadPipeTable($lines, $index); | |
| if ($pipeTable !== null) { | |
| $this->flushParagraph($paragraph, $blocks); | |
| $this->flushListStack($listStack, $blocks); | |
| $blocks[] = $pipeTable; | |
| continue; | |
| } | |
| if ($this->isHorizontalRule($line)) { | |
| $this->flushParagraph($paragraph, $blocks); | |
| $this->flushListStack($listStack, $blocks); | |
| $blocks[] = new AstNode('horizontal_rule'); | |
| continue; | |
| } | |
| $lineBlock = $paragraph === [] && $listStack === [] ? $this->tryReadLineBlock($lines, $index) : null; | |
| if ($lineBlock !== null) { | |
| $blocks[] = $lineBlock; | |
| continue; | |
| } | |
| $setextHeading = $paragraph === [] && $listStack === [] ? $this->tryParseSetextMarkdownHeading($lines, $index) : null; | |
| if ($setextHeading !== null) { | |
| $text = $setextHeading['text']; | |
| $attrs = [ | |
| 'level' => $setextHeading['level'], | |
| 'text' => $text, | |
| 'id' => $markdownHeadingIds[$index] ?? $setextHeading['id'] ?? '', | |
| ]; | |
| if ($setextHeading['classes'] !== []) { | |
| $attrs['classes'] = $setextHeading['classes']; | |
| } | |
| if ($setextHeading['attributes'] !== []) { | |
| $attrs['attributes'] = $setextHeading['attributes']; | |
| } | |
| $blocks[] = new AstNode( | |
| 'heading', | |
| $attrs, | |
| $this->parseInlines($text) | |
| ); | |
| $index++; | |
| continue; | |
| } | |
| $markdownHeading = $this->tryParseMarkdownHeading($line); | |
| if ($markdownHeading !== null) { | |
| $this->flushParagraph($paragraph, $blocks); | |
| $this->flushListStack($listStack, $blocks); | |
| $text = $markdownHeading['text']; | |
| $attrs = [ | |
| 'level' => $markdownHeading['level'], | |
| 'text' => $text, | |
| 'id' => $markdownHeadingIds[$index] ?? $markdownHeading['id'] ?? '', | |
| ]; | |
| if ($markdownHeading['classes'] !== []) { | |
| $attrs['classes'] = $markdownHeading['classes']; | |
| } | |
| if ($markdownHeading['attributes'] !== []) { | |
| $attrs['attributes'] = $markdownHeading['attributes']; | |
| } | |
| $blocks[] = new AstNode( | |
| 'heading', | |
| $attrs, | |
| $this->parseInlines($text) | |
| ); | |
| continue; | |
| } | |
| $listBlock = $paragraph === [] ? $this->tryReadListBlock($lines, $index) : null; | |
| if ($listBlock !== null) { | |
| $this->flushParagraph($paragraph, $blocks); | |
| $this->flushListStack($listStack, $blocks); | |
| $blocks[] = $listBlock; | |
| continue; | |
| } | |
| $indentedCodeBlock = $listStack === [] ? $this->tryReadIndentedCodeBlock($lines, $index) : null; | |
| if ($indentedCodeBlock !== null) { | |
| $this->flushParagraph($paragraph, $blocks); | |
| $blocks[] = $indentedCodeBlock; | |
| continue; | |
| } | |
| $definitionList = $this->tryReadDefinitionList($lines, $index); | |
| if ($definitionList !== null) { | |
| $this->flushParagraph($paragraph, $blocks); | |
| $this->flushListStack($listStack, $blocks); | |
| $blocks[] = $definitionList; | |
| continue; | |
| } | |
| if (trim($line) === '') { | |
| $this->flushParagraph($paragraph, $blocks); | |
| $this->flushListStack($listStack, $blocks); | |
| continue; | |
| } | |
| $this->flushListStack($listStack, $blocks); | |
| $paragraph[] = trim($line); | |
| } | |
| $this->flushParagraph($paragraph, $blocks); | |
| $this->flushListStack($listStack, $blocks); | |
| $document = new AstNode('document', $documentAttrs, $blocks); | |
| $this->referenceLinks = $previousReferenceLinks; | |
| $this->footnoteDefinitions = $previousFootnoteDefinitions; | |
| $this->exampleReferences = $previousExampleReferences; | |
| $this->exampleNumbersByLine = $previousExampleNumbersByLine; | |
| $this->rawTexMacros = $previousRawTexMacros; | |
| $this->htmlQuoteDepth = $previousHtmlQuoteDepth; | |
| return $document; | |
| } | |
| /** | |
| * @param list<string> $lines | |
| * @return array{0:list<string>, 1:array{title:list<string>, author:list<string>, date:list<string>}|null} | |
| */ | |
| private function extractTitleBlock(array $lines): array | |
| { | |
| if (($lines[0] ?? '') === '' || preg_match('/^%[ \t]?(.*)$/', $lines[0]) !== 1) { | |
| return [$lines, null]; | |
| } | |
| $fields = [ | |
| 'title' => [], | |
| 'author' => [], | |
| 'date' => [], | |
| ]; | |
| $fieldNames = array_keys($fields); | |
| $cursor = 0; | |
| $count = count($lines); | |
| foreach ($fieldNames as $fieldName) { | |
| if ($cursor >= $count || preg_match('/^%[ \t]?(.*)$/', $lines[$cursor], $m) !== 1) { | |
| break; | |
| } | |
| $fieldLines = [rtrim($m[1])]; | |
| $cursor++; | |
| while ($cursor < $count && trim($lines[$cursor]) !== '' && preg_match('/^[ \t]+(.*)$/', $lines[$cursor], $continuation) === 1) { | |
| $fieldLines[] = rtrim($continuation[1]); | |
| $cursor++; | |
| } | |
| $fields[$fieldName] = $fieldLines; | |
| } | |
| while ($cursor < $count && trim($lines[$cursor]) === '') { | |
| $cursor++; | |
| } | |
| return [array_slice($lines, $cursor), $fields]; | |
| } | |
| /** | |
| * @param array{title:list<string>, author:list<string>, date:list<string>} $titleBlock | |
| * @return array<string, mixed> | |
| */ | |
| private function buildTitleBlockAttrs(array $titleBlock): array | |
| { | |
| $meta = []; | |
| if ($this->metadataPlainText($titleBlock['title']) !== '') { | |
| $meta['title'] = $this->metadataPlainText($titleBlock['title']); | |
| $meta['titleInlines'] = $this->metadataInlines($titleBlock['title']); | |
| } | |
| $authors = $this->metadataAuthors($titleBlock['author']); | |
| if ($authors !== []) { | |
| $meta['author'] = $authors; | |
| $meta['authors'] = $authors; | |
| $meta['authorInlines'] = array_map( | |
| fn (string $author): array => $this->parseInlines($author), | |
| $authors | |
| ); | |
| } | |
| if ($this->metadataPlainText($titleBlock['date']) !== '') { | |
| $meta['date'] = $this->metadataPlainText($titleBlock['date']); | |
| $meta['dateInlines'] = $this->metadataInlines($titleBlock['date']); | |
| } | |
| return $meta === [] ? [] : ['meta' => $meta]; | |
| } | |
| /** | |
| * @param list<string> $lines | |
| * @return list<AstNode> | |
| */ | |
| private function metadataInlines(array $lines): array | |
| { | |
| return $this->parseInlines(implode("\n", $lines)); | |
| } | |
| /** | |
| * @param list<string> $lines | |
| */ | |
| private function metadataPlainText(array $lines): string | |
| { | |
| return trim(preg_replace('/[ \t]*\n[ \t]*/', ' ', implode("\n", $lines)) ?? ''); | |
| } | |
| /** | |
| * @param list<string> $lines | |
| * @return list<string> | |
| */ | |
| private function metadataAuthors(array $lines): array | |
| { | |
| $authors = []; | |
| foreach ($lines as $line) { | |
| foreach (explode(';', $line) as $author) { | |
| $author = trim($author); | |
| if ($author !== '') { | |
| $authors[] = $author; | |
| } | |
| } | |
| } | |
| return $authors; | |
| } | |
| /** | |
| * @param list<string> $lines | |
| * @return array{0:list<string>, 1:array<string, array{url:string, title:string}>, 2:array<string, string>} | |
| */ | |
| private function extractReferenceDefinitions(array $lines): array | |
| { | |
| $content = []; | |
| $references = []; | |
| $footnotes = []; | |
| $fenceChar = null; | |
| $fenceLength = 0; | |
| $count = count($lines); | |
| for ($index = 0; $index < $count; $index++) { | |
| $line = $lines[$index]; | |
| if ($fenceChar !== null) { | |
| $content[] = $line; | |
| if ($this->isClosingCodeFence($line, $fenceChar, $fenceLength)) { | |
| $fenceChar = null; | |
| $fenceLength = 0; | |
| } | |
| continue; | |
| } | |
| if (preg_match('/^ {0,3}(`{3,}|~{3,})/', $line, $fence) === 1) { | |
| $fenceChar = $fence[1][0]; | |
| $fenceLength = strlen($fence[1]); | |
| $content[] = $line; | |
| continue; | |
| } | |
| $expanded = $this->expandTabsToSpaces($line); | |
| $footnote = $this->tryParseFootnoteDefinitionStart($expanded); | |
| if ($footnote !== null) { | |
| [$body, $nextIndex] = $this->collectFootnoteDefinitionBody($lines, $index, $footnote['content']); | |
| $footnotes[$this->normalizeReferenceLabel($footnote['label'])] = implode("\n", $body); | |
| $index = $nextIndex - 1; | |
| continue; | |
| } | |
| $reference = $this->tryParseReferenceDefinitionStart($expanded); | |
| if ($reference !== null) { | |
| [$targetSource, $nextIndex] = $this->collectReferenceDefinitionTarget($lines, $index, $reference['content']); | |
| $target = $this->parseLinkDestinationAndTitle($targetSource); | |
| if ($target !== null) { | |
| $references[$this->normalizeReferenceLabel($reference['label'])] = [ | |
| 'url' => $target['url'], | |
| 'title' => $target['title'], | |
| ]; | |
| $index = $nextIndex - 1; | |
| continue; | |
| } | |
| } | |
| $content[] = $line; | |
| } | |
| return [$content, $references, $footnotes]; | |
| } | |
| /** | |
| * @param list<string> $lines | |
| * @return list<string> | |
| */ | |
| private function splitMixedHtmlFlowLines(array $lines): array | |
| { | |
| $normalized = []; | |
| $fenceChar = null; | |
| $fenceLength = 0; | |
| foreach ($lines as $line) { | |
| if ($fenceChar !== null) { | |
| $normalized[] = $line; | |
| if ($this->isClosingCodeFence($line, $fenceChar, $fenceLength)) { | |
| $fenceChar = null; | |
| $fenceLength = 0; | |
| } | |
| continue; | |
| } | |
| if (preg_match('/^ {0,3}(`{3,}|~{3,})/', $line, $fence) === 1) { | |
| $fenceChar = $fence[1][0]; | |
| $fenceLength = strlen($fence[1]); | |
| $normalized[] = $line; | |
| continue; | |
| } | |
| array_push($normalized, ...$this->splitMixedHtmlFlowLine($line)); | |
| } | |
| return $normalized; | |
| } | |
| /** | |
| * @return list<string> | |
| */ | |
| private function splitMixedHtmlFlowLine(string $line): array | |
| { | |
| if ($this->countIndentColumns($line) > 3) { | |
| return [$line]; | |
| } | |
| if (preg_match('/^[ \t]{0,3}(?:[-*+]|\d{1,9}[.)]|#[.)]|\([A-Za-z0-9]+\)|[A-Za-z]+[.)])[ \t]+/', $line) === 1) { | |
| return [$line]; | |
| } | |
| if (preg_match('/^[ \t]*</', $line) === 1) { | |
| return [$line]; | |
| } | |
| if (preg_match('/^([ \t]*[^<\r\n]*\S[^<\r\n]*)(<(?:p|blockquote|h[1-6]|ul|ol|dl|pre|table|div|figure|hr)\b.*)$/i', $line, $m) !== 1) { | |
| return [$line]; | |
| } | |
| $prefix = trim($m[1]); | |
| $suffix = $m[2]; | |
| if ($prefix === '') { | |
| return [$line]; | |
| } | |
| return ['<p>' . $prefix . '</p>', $suffix]; | |
| } | |
| private function normalizeReferenceLabel(string $label): string | |
| { | |
| return strtolower(trim(preg_replace('/\s+/', ' ', $label) ?? $label)); | |
| } | |
| /** | |
| * @param list<string> $lines | |
| * @return array{0:array<string, int>, 1:array<int, int>} | |
| */ | |
| private function collectNumberedExampleReferences(array $lines): array | |
| { | |
| $references = []; | |
| $numbersByLine = []; | |
| $nextNumber = 1; | |
| $fenceChar = null; | |
| $fenceLength = 0; | |
| foreach ($lines as $index => $line) { | |
| if ($fenceChar !== null) { | |
| if ($this->isClosingCodeFence($line, $fenceChar, $fenceLength)) { | |
| $fenceChar = null; | |
| $fenceLength = 0; | |
| } | |
| continue; | |
| } | |
| if (preg_match('/^ {0,3}(`{3,}|~{3,})/', $line, $fence) === 1) { | |
| $fenceChar = $fence[1][0]; | |
| $fenceLength = strlen($fence[1]); | |
| continue; | |
| } | |
| $marker = $this->matchNumberedExampleMarker($line); | |
| if ($marker === null || $marker['indent'] > 3) { | |
| continue; | |
| } | |
| $numbersByLine[$index] = $nextNumber; | |
| if ($marker['label'] !== '') { | |
| $references[$marker['label']] = $nextNumber; | |
| } | |
| $nextNumber++; | |
| } | |
| return [$references, $numbersByLine]; | |
| } | |
| /** | |
| * @param list<string> $lines | |
| * @return array{0:array<int, string>, 1:array<string, array{url:string, title:string}>} | |
| */ | |
| private function collectMarkdownHeadingReferences(array $lines): array | |
| { | |
| $idsByLine = []; | |
| $references = []; | |
| $usedIds = []; | |
| for ($index = 0, $count = count($lines); $index < $count; $index++) { | |
| $line = $lines[$index]; | |
| $heading = $this->tryParseMarkdownHeading($line); | |
| $setext = false; | |
| if ($heading === null) { | |
| $heading = $this->tryParseSetextMarkdownHeading($lines, $index); | |
| $setext = $heading !== null; | |
| } | |
| if ($heading === null) { | |
| continue; | |
| } | |
| $id = $heading['id'] ?? $this->uniqueMarkdownHeadingId( | |
| $this->slugifyMarkdownHeading($heading['text']), | |
| $usedIds | |
| ); | |
| if (isset($heading['id'])) { | |
| $usedIds[$heading['id']] = ($usedIds[$heading['id']] ?? 0) + 1; | |
| } | |
| $idsByLine[$index] = $id; | |
| $label = $this->normalizeReferenceLabel($this->plainMarkdownHeadingText($heading['text'])); | |
| if ($label !== '' && !isset($references[$label])) { | |
| $references[$label] = ['url' => '#' . $id, 'title' => '']; | |
| } | |
| if ($setext) { | |
| $index++; | |
| } | |
| } | |
| return [$idsByLine, $references]; | |
| } | |
| /** | |
| * @return array{level:int, text:string, id?:string, classes:list<string>, attributes:array<string, string>}|null | |
| */ | |
| private function tryParseMarkdownHeading(string $line): ?array | |
| { | |
| if (preg_match('/^ {0,3}(#{1,6})[ \t]+(.+)$/', $line, $m) !== 1) { | |
| return null; | |
| } | |
| $text = $this->stripClosingAtxHeadingFence(trim($m[2])); | |
| return $this->buildMarkdownHeading(strlen($m[1]), $text); | |
| } | |
| /** | |
| * @param list<string> $lines | |
| * @return array{level:int, text:string, id?:string, classes:list<string>, attributes:array<string, string>}|null | |
| */ | |
| private function tryParseSetextMarkdownHeading(array $lines, int $index): ?array | |
| { | |
| if (!isset($lines[$index + 1])) { | |
| return null; | |
| } | |
| $line = $this->expandTabsToSpaces($lines[$index]); | |
| if ($this->countIndentColumns($line) > 3) { | |
| return null; | |
| } | |
| $text = trim($line); | |
| if ($text === '' || $this->tryParseMarkdownHeading($line) !== null) { | |
| return null; | |
| } | |
| $marker = $this->expandTabsToSpaces($lines[$index + 1]); | |
| if (preg_match('/^ {0,3}(=+|-+)[ \t]*$/', $marker, $m) !== 1) { | |
| return null; | |
| } | |
| return $this->buildMarkdownHeading($m[1][0] === '=' ? 1 : 2, $text); | |
| } | |
| private function stripClosingAtxHeadingFence(string $text): string | |
| { | |
| return rtrim(preg_replace('/[ \t]+#+[ \t]*$/', '', $text) ?? $text); | |
| } | |
| /** | |
| * @return array{level:int, text:string, id?:string, classes:list<string>, attributes:array<string, string>} | |
| */ | |
| private function buildMarkdownHeading(int $level, string $text): array | |
| { | |
| $id = null; | |
| $classes = []; | |
| $attributes = []; | |
| if (preg_match('/^(.*?)[ \t]*\{([^{}]+)\}[ \t]*$/', $text, $attrs) === 1) { | |
| $text = rtrim($attrs[1]); | |
| [$id, $classes, $attributes] = $this->parseMarkdownAttributeSpec($attrs[2]); | |
| } | |
| $heading = [ | |
| 'level' => $level, | |
| 'text' => $text, | |
| 'classes' => $classes, | |
| 'attributes' => $attributes, | |
| ]; | |
| if ($id !== null && $id !== '') { | |
| $heading['id'] = $id; | |
| } | |
| return $heading; | |
| } | |
| /** | |
| * @return array{0:string|null, 1:list<string>, 2:array<string, string>} | |
| */ | |
| private function parseMarkdownAttributeSpec(string $source): array | |
| { | |
| $id = null; | |
| $classes = []; | |
| $attributes = []; | |
| preg_match_all('/(?:^|\s)(#[^\s]+|\.[^\s]+|[A-Za-z_:][A-Za-z0-9_.:-]*=(?:"[^"]*"|\'[^\']*\'|[^\s]+))/', $source, $matches); | |
| foreach ($matches[1] as $token) { | |
| if ($token[0] === '#') { | |
| $id = substr($token, 1); | |
| continue; | |
| } | |
| if ($token[0] === '.') { | |
| $classes[] = substr($token, 1); | |
| continue; | |
| } | |
| [$name, $value] = explode('=', $token, 2); | |
| $value = trim($value); | |
| if ($value !== '' && (($value[0] === '"' && str_ends_with($value, '"')) || ($value[0] === "'" && str_ends_with($value, "'")))) { | |
| $value = substr($value, 1, -1); | |
| } | |
| $attributes[$name] = $this->decodeHtmlEntities($this->unescapeLinkComponent($value)); | |
| } | |
| return [$id, $classes, $attributes]; | |
| } | |
| /** | |
| * @param array<string, int> $usedIds | |
| */ | |
| private function uniqueMarkdownHeadingId(string $base, array &$usedIds): string | |
| { | |
| $base = $base === '' ? 'section' : $base; | |
| $count = $usedIds[$base] ?? 0; | |
| $usedIds[$base] = $count + 1; | |
| return $count === 0 ? $base : $base . '-' . $count; | |
| } | |
| private function slugifyMarkdownHeading(string $text): string | |
| { | |
| $plain = $this->plainMarkdownHeadingText($text); | |
| $plain = function_exists('mb_strtolower') ? mb_strtolower($plain, 'UTF-8') : strtolower($plain); | |
| $plain = str_replace(["'", "\u{2019}"], '', $plain); | |
| $slug = preg_replace('/[^\pL\pN]+/u', '-', $plain) ?? $plain; | |
| return trim($slug, '-'); | |
| } | |
| private function plainMarkdownHeadingText(string $text): string | |
| { | |
| $text = preg_replace('/!\[([^\]]*)\]\([^)]+\)/', '$1', $text) ?? $text; | |
| $text = preg_replace('/\[([^\]]+)\]\([^)]+\)/', '$1', $text) ?? $text; | |
| $text = preg_replace('/`+([^`]*)`+/', '$1', $text) ?? $text; | |
| $text = str_replace(['*', '_', '~', '^'], '', $text); | |
| return $this->decodeHtmlEntities(trim($text)); | |
| } | |
| /** | |
| * @return array{label:string, url:string, title:string}|null | |
| */ | |
| private function tryParseReferenceDefinition(string $line): ?array | |
| { | |
| $reference = $this->tryParseReferenceDefinitionStart($line); | |
| if ($reference === null) { | |
| return null; | |
| } | |
| $target = $this->parseLinkDestinationAndTitle($reference['content']); | |
| if ($target === null) { | |
| return null; | |
| } | |
| return [ | |
| 'label' => $reference['label'], | |
| 'url' => $target['url'], | |
| 'title' => $target['title'], | |
| ]; | |
| } | |
| /** | |
| * @return array{label:string, content:string}|null | |
| */ | |
| private function tryParseReferenceDefinitionStart(string $line): ?array | |
| { | |
| if (preg_match('/^ {0,3}\[(?!\^)([^\]\r\n]+)\]:[ \t]*(.*)$/', $line, $m) !== 1) { | |
| return null; | |
| } | |
| return [ | |
| 'label' => $m[1], | |
| 'content' => rtrim($m[2]), | |
| ]; | |
| } | |
| /** | |
| * @param list<string> $lines | |
| * @return array{0:string, 1:int} | |
| */ | |
| private function collectReferenceDefinitionTarget(array $lines, int $index, string $firstLine): array | |
| { | |
| $target = trim($firstLine); | |
| $cursor = $index + 1; | |
| $count = count($lines); | |
| if ($target === '') { | |
| if ($cursor < $count && trim($lines[$cursor]) !== '') { | |
| $target = trim($this->expandTabsToSpaces($lines[$cursor])); | |
| $cursor++; | |
| } | |
| } | |
| if ($cursor < $count) { | |
| $candidate = trim($this->expandTabsToSpaces($lines[$cursor])); | |
| if ($this->parseLinkTitle($candidate) !== null) { | |
| $target .= ' ' . $candidate; | |
| $cursor++; | |
| } | |
| } | |
| return [$target, $cursor]; | |
| } | |
| /** | |
| * @return array{label:string, content:string}|null | |
| */ | |
| private function tryParseFootnoteDefinitionStart(string $line): ?array | |
| { | |
| if (preg_match('/^ {0,3}\[\^([^\]\s]+)\]:[ \t]*(.*)$/', $line, $m) !== 1) { | |
| return null; | |
| } | |
| return [ | |
| 'label' => $m[1], | |
| 'content' => rtrim($m[2]), | |
| ]; | |
| } | |
| /** | |
| * @param list<string> $lines | |
| * @return array{0:list<string>, 1:int} | |
| */ | |
| private function collectFootnoteDefinitionBody(array $lines, int $index, string $firstLine): array | |
| { | |
| $body = []; | |
| if ($firstLine !== '') { | |
| $body[] = $firstLine; | |
| } | |
| $cursor = $index + 1; | |
| $count = count($lines); | |
| $afterBlank = false; | |
| $insideIndentedBlock = false; | |
| while ($cursor < $count) { | |
| $line = $lines[$cursor]; | |
| $expanded = $this->expandTabsToSpaces($line); | |
| if (trim($expanded) === '') { | |
| $nextNonBlank = $this->nextNonBlankLineIndex($lines, $cursor + 1); | |
| if ( | |
| $nextNonBlank !== null | |
| && $this->isFootnoteIndentedContinuation($lines[$nextNonBlank]) | |
| ) { | |
| $body[] = ''; | |
| $afterBlank = true; | |
| $insideIndentedBlock = false; | |
| $cursor++; | |
| continue; | |
| } | |
| $cursor = $nextNonBlank ?? $count; | |
| break; | |
| } | |
| if ($this->tryParseFootnoteDefinitionStart($expanded) !== null || $this->tryParseReferenceDefinition($expanded) !== null) { | |
| break; | |
| } | |
| if ($afterBlank && !$insideIndentedBlock) { | |
| if (!$this->isFootnoteIndentedContinuation($line)) { | |
| break; | |
| } | |
| $body[] = rtrim($this->stripIndentColumns($line, 4)); | |
| $insideIndentedBlock = true; | |
| $cursor++; | |
| continue; | |
| } | |
| if ($afterBlank && $this->isFootnoteIndentedContinuation($line)) { | |
| $body[] = rtrim($this->stripIndentColumns($line, 4)); | |
| $cursor++; | |
| continue; | |
| } | |
| $body[] = rtrim($line); | |
| $cursor++; | |
| } | |
| while ($body !== [] && end($body) === '') { | |
| array_pop($body); | |
| } | |
| return [$body, $cursor]; | |
| } | |
| /** | |
| * @param list<string> $lines | |
| */ | |
| private function nextNonBlankLineIndex(array $lines, int $cursor): ?int | |
| { | |
| $count = count($lines); | |
| while ($cursor < $count) { | |
| if (trim($lines[$cursor]) !== '') { | |
| return $cursor; | |
| } | |
| $cursor++; | |
| } | |
| return null; | |
| } | |
| private function isFootnoteIndentedContinuation(string $line): bool | |
| { | |
| return $this->countIndentColumns($line) >= 4; | |
| } | |
| /** | |
| * @param list<string> $lines | |
| */ | |
| private function tryReadFencedCodeBlock(array $lines, int &$index): ?AstNode | |
| { | |
| $line = $lines[$index] ?? ''; | |
| if (preg_match('/^( {0,3})(`{3,}|~{3,})[ \t]*(.*)$/', $line, $m) !== 1) { | |
| return null; | |
| } | |
| $indent = strlen($m[1]); | |
| $fence = $m[2]; | |
| $fenceChar = $fence[0]; | |
| $fenceLength = strlen($fence); | |
| $info = trim($m[3]); | |
| if ($fenceChar === '`' && str_contains($info, '`')) { | |
| return null; | |
| } | |
| $content = []; | |
| $cursor = $index + 1; | |
| $count = count($lines); | |
| while ($cursor < $count) { | |
| if ($this->isClosingCodeFence($lines[$cursor], $fenceChar, $fenceLength)) { | |
| $attrs = $this->parseCodeInfo($info); | |
| $attrs['text'] = implode("\n", $content); | |
| $rawAttribute = $this->tryParseRawAttributeSpec($info, 0); | |
| if ($rawAttribute !== null && $rawAttribute['next'] === strlen($info)) { | |
| $index = $cursor; | |
| return $this->rawBlockNode($rawAttribute['format'], $attrs['text']); | |
| } | |
| if ($info !== '') { | |
| $attrs['info'] = $info; | |
| } | |
| $index = $cursor; | |
| return new AstNode('code_block', $attrs); | |
| } | |
| $content[] = $this->stripFenceContentIndent($lines[$cursor], $indent); | |
| $cursor++; | |
| } | |
| $attrs = $this->parseCodeInfo($info); | |
| $attrs['text'] = implode("\n", $content); | |
| $rawAttribute = $this->tryParseRawAttributeSpec($info, 0); | |
| if ($rawAttribute !== null && $rawAttribute['next'] === strlen($info)) { | |
| $index = $cursor - 1; | |
| return $this->rawBlockNode($rawAttribute['format'], $attrs['text']); | |
| } | |
| if ($info !== '') { | |
| $attrs['info'] = $info; | |
| } | |
| $index = $cursor - 1; | |
| return new AstNode('code_block', $attrs); | |
| } | |
| /** | |
| * @param list<string> $lines | |
| */ | |
| private function tryReadLiterateHaskellCodeBlock(array $lines, int &$index): ?AstNode | |
| { | |
| if (($this->options['literateHaskell'] ?? false) !== true) { | |
| return null; | |
| } | |
| $first = $this->tryParseLiterateHaskellCodeLine($lines[$index] ?? ''); | |
| if ($first === null) { | |
| return null; | |
| } | |
| $marker = $first['marker']; | |
| $content = [$first['text']]; | |
| $cursor = $index + 1; | |
| $count = count($lines); | |
| while ($cursor < $count) { | |
| $next = $this->tryParseLiterateHaskellCodeLine($lines[$cursor]); | |
| if ($next === null || $next['marker'] !== $marker) { | |
| break; | |
| } | |
| $content[] = $next['text']; | |
| $cursor++; | |
| } | |
| $index = $cursor - 1; | |
| return new AstNode('code_block', [ | |
| 'classes' => $marker === '>' ? ['haskell', 'literate'] : ['haskell'], | |
| 'attributes' => [], | |
| 'text' => implode("\n", $content), | |
| ]); | |
| } | |
| /** | |
| * @return array{marker: string, text: string}|null | |
| */ | |
| private function tryParseLiterateHaskellCodeLine(string $line): ?array | |
| { | |
| $expanded = $this->expandTabsToSpaces($line); | |
| if (preg_match('/^([<>])(?: (.*)|)$/', $expanded, $m) !== 1) { | |
| return null; | |
| } | |
| return [ | |
| 'marker' => $m[1], | |
| 'text' => $m[2] ?? '', | |
| ]; | |
| } | |
| /** | |
| * @param list<string> $lines | |
| */ | |
| private function tryReadBlockQuote(array $lines, int &$index): ?AstNode | |
| { | |
| if (!$this->isBlockQuoteLine($lines[$index] ?? '')) { | |
| return null; | |
| } | |
| $content = []; | |
| $cursor = $index; | |
| $count = count($lines); | |
| while ($cursor < $count && $this->isBlockQuoteLine($lines[$cursor])) { | |
| $content[] = $this->stripBlockQuoteMarker($lines[$cursor]); | |
| $cursor++; | |
| } | |
| $index = $cursor - 1; | |
| $inner = $this->read(implode("\n", $content)); | |
| return new AstNode('blockquote', [], $inner->children); | |
| } | |
| /** | |
| * @param list<string> $lines | |
| */ | |
| private function tryReadLineBlock(array $lines, int &$index): ?AstNode | |
| { | |
| $line = $lines[$index] ?? ''; | |
| if (preg_match('/^ {0,3}\|(.*)$/', $this->expandTabsToSpaces($line), $m) !== 1) { | |
| return null; | |
| } | |
| $lineNodes = []; | |
| $currentText = null; | |
| $cursor = $index; | |
| $count = count($lines); | |
| while ($cursor < $count) { | |
| $expanded = $this->expandTabsToSpaces($lines[$cursor]); | |
| if (preg_match('/^ {0,3}\|(.*)$/', $expanded, $marker) === 1) { | |
| if ($currentText !== null) { | |
| $lineNodes[] = $this->buildLineBlockLine($currentText); | |
| } | |
| $currentText = $this->normalizeLineBlockMarkerContent($marker[1]); | |
| $cursor++; | |
| continue; | |
| } | |
| if ($currentText !== null && trim($expanded) !== '' && preg_match('/^ +\S/', $expanded) === 1) { | |
| $currentText .= ' ' . trim($expanded); | |
| $cursor++; | |
| continue; | |
| } | |
| break; | |
| } | |
| if ($currentText !== null) { | |
| $lineNodes[] = $this->buildLineBlockLine($currentText); | |
| } | |
| if ($lineNodes === []) { | |
| return null; | |
| } | |
| $index = $cursor - 1; | |
| return new AstNode('line_block', [], $lineNodes); | |
| } | |
| private function normalizeLineBlockMarkerContent(string $content): string | |
| { | |
| if (($content[0] ?? '') === ' ') { | |
| $content = substr($content, 1); | |
| } | |
| $leading = strspn($content, ' '); | |
| if ($leading === 0) { | |
| return rtrim($content); | |
| } | |
| return str_repeat("\xC2\xA0", $leading) . rtrim(substr($content, $leading)); | |
| } | |
| private function buildLineBlockLine(string $text): AstNode | |
| { | |
| return new AstNode('line', ['text' => $text], $text === '' ? [] : $this->parseInlines($text)); | |
| } | |
| /** | |
| * @param list<string> $lines | |
| */ | |
| private function tryReadDivBlock(array $lines, int &$index): ?AstNode | |
| { | |
| $line = $lines[$index] ?? ''; | |
| if (preg_match('/^ {0,3}<div(?:\s+[^>]*)?>/i', $line, $m, PREG_OFFSET_CAPTURE) !== 1) { | |
| return null; | |
| } | |
| $content = []; | |
| $depth = 1; | |
| $openingIndex = $index; | |
| $cursor = $index; | |
| $count = count($lines); | |
| $firstLineOffset = $m[0][1] + strlen($m[0][0]); | |
| while ($cursor < $count) { | |
| $segment = $cursor === $index ? substr($lines[$cursor], $firstLineOffset) : $lines[$cursor]; | |
| $lineContent = ''; | |
| $offset = 0; | |
| while (true) { | |
| $nextOpen = $this->findHtmlTag($segment, 'div', $offset, false); | |
| $nextClose = $this->findHtmlTag($segment, 'div', $offset, true); | |
| if ($nextOpen === null && $nextClose === null) { | |
| $lineContent .= substr($segment, $offset); | |
| break; | |
| } | |
| if ($nextOpen !== null && ($nextClose === null || $nextOpen['offset'] < $nextClose['offset'])) { | |
| $depth++; | |
| $lineContent .= substr($segment, $offset, $nextOpen['offset'] + $nextOpen['length'] - $offset); | |
| $offset = $nextOpen['offset'] + $nextOpen['length']; | |
| continue; | |
| } | |
| if ($nextClose === null) { | |
| break; | |
| } | |
| $depth--; | |
| if ($depth === 0) { | |
| $lineContent .= substr($segment, $offset, $nextClose['offset'] - $offset); | |
| $content[] = $lineContent; | |
| $closedOnOpeningLine = $cursor === $openingIndex; | |
| $index = $cursor; | |
| return $this->buildDivBlock($content, $closedOnOpeningLine, $this->isHtmlLineBlockOpeningTag($lines[$openingIndex])); | |
| } | |
| $lineContent .= substr($segment, $offset, $nextClose['offset'] + $nextClose['length'] - $offset); | |
| $offset = $nextClose['offset'] + $nextClose['length']; | |
| } | |
| $content[] = $lineContent; | |
| $cursor++; | |
| } | |
| return null; | |
| } | |
| /** | |
| * @return array{offset:int, length:int}|null | |
| */ | |
| private function findHtmlTag(string $line, string $tag, int $offset, bool $closing): ?array | |
| { | |
| $pattern = $closing | |
| ? '/<\/' . preg_quote($tag, '/') . '\s*>/i' | |
| : '/<' . preg_quote($tag, '/') . '(?:\s+[^>]*)?>/i'; | |
| if (preg_match($pattern, $line, $m, PREG_OFFSET_CAPTURE, $offset) !== 1) { | |
| return null; | |
| } | |
| return ['offset' => $m[0][1], 'length' => strlen($m[0][0])]; | |
| } | |
| /** | |
| * @param list<string> $content | |
| */ | |
| private function buildDivBlock(array $content, bool $closedOnOpeningLine, bool $lineBlock = false): AstNode | |
| { | |
| while ($content !== [] && trim($content[0]) === '') { | |
| array_shift($content); | |
| } | |
| while ($content !== [] && trim($content[array_key_last($content)]) === '') { | |
| array_pop($content); | |
| } | |
| if ($lineBlock) { | |
| return $this->buildHtmlLineBlockFromInlines( | |
| $this->parseHtmlInlineFragmentNodes(implode("\n", $content)) | |
| ); | |
| } | |
| if ( | |
| $closedOnOpeningLine | |
| && count($content) === 1 | |
| && trim($content[0]) !== '' | |
| && stripos($content[0], '<div') === false | |
| ) { | |
| $text = trim($content[0]); | |
| return new AstNode('div', [], [ | |
| new AstNode('plain', ['text' => $text], $this->parseInlines($text)), | |
| ]); | |
| } | |
| $inner = $this->read(implode("\n", $content)); | |
| return new AstNode('div', [], $inner->children); | |
| } | |
| private function isHtmlLineBlockOpeningTag(string $line): bool | |
| { | |
| if (preg_match('/^ {0,3}<div\b([^>]*)>/i', $line, $m) !== 1) { | |
| return false; | |
| } | |
| if (preg_match('/\bclass\s*=\s*(?:"line-block"|\'line-block\'|line-block)(?:\s|$)/i', $m[1]) !== 1) { | |
| return false; | |
| } | |
| $withoutClass = preg_replace('/\s*\bclass\s*=\s*(?:"line-block"|\'line-block\'|line-block)\s*/i', ' ', $m[1]) ?? $m[1]; | |
| return trim($withoutClass) === ''; | |
| } | |
| /** | |
| * @param list<string> $lines | |
| */ | |
| private function tryReadRawHtmlBlock(array $lines, int &$index): ?AstNode | |
| { | |
| $line = $lines[$index] ?? ''; | |
| if (preg_match('/^ {0,3}<!--/', $line) === 1) { | |
| return $this->readHtmlCommentBlock($lines, $index); | |
| } | |
| if (preg_match('/^ {0,3}<(script|style|textarea)(?:\s+[^>]*)?>/i', $line, $m) === 1) { | |
| return $this->readRawHtmlUntilClosingTag($lines, $index, strtolower($m[1])); | |
| } | |
| if (preg_match('/^ {0,3}<table(?:\s+[^>]*)?>/i', $line) === 1) { | |
| return $this->readRawHtmlUntilClosingTag($lines, $index, 'table', true); | |
| } | |
| if (preg_match('/^ {0,3}<hr(?:\s+[^>]*)?\/?>[ \t]*$/i', $line) === 1) { | |
| return new AstNode('raw_html', ['html' => trim($line)]); | |
| } | |
| return null; | |
| } | |
| /** | |
| * @param list<string> $lines | |
| * @return list<AstNode>|null | |
| */ | |
| private function tryReadRawHtmlDetailsBlock(array $lines, int &$index): ?array | |
| { | |
| $line = $lines[$index] ?? ''; | |
| if (preg_match('/^ {0,3}<details\b/i', $line) !== 1) { | |
| return null; | |
| } | |
| $collected = $this->collectBalancedHtmlElementBlock($lines, $index, 'details'); | |
| if ($collected === null) { | |
| return null; | |
| } | |
| [$html, $endIndex] = $collected; | |
| $blocks = $this->buildRawHtmlDetailsBlocks($html); | |
| if ($blocks === []) { | |
| return null; | |
| } | |
| $index = $endIndex; | |
| return $blocks; | |
| } | |
| /** | |
| * @return list<AstNode> | |
| */ | |
| private function buildRawHtmlDetailsBlocks(string $html): array | |
| { | |
| if (preg_match('/^\s*(<details\b[^>]*>)(.*)(<\/details\s*>)\s*$/isu', $html, $match) !== 1) { | |
| return []; | |
| } | |
| $rawHtmlEnabled = $this->htmlRawHtmlEnabled(); | |
| $inner = (string) $match[2]; | |
| $blocks = []; | |
| if ($rawHtmlEnabled) { | |
| $blocks[] = new AstNode('raw_html', ['html' => trim($match[1])]); | |
| } | |
| [$summary, $body] = $this->extractRawHtmlDetailsSummary($inner); | |
| if ($summary !== '') { | |
| if ($rawHtmlEnabled) { | |
| $blocks[] = new AstNode('raw_html', ['html' => trim($summary)]); | |
| } else { | |
| $summaryText = trim(preg_replace('/\s+/', ' ', strip_tags($summary)) ?? ''); | |
| if ($summaryText !== '') { | |
| $inlines = $this->parseInlines($summaryText); | |
| $blocks[] = new AstNode('plain', ['text' => $this->plainTextFromInlines($inlines)], $inlines); | |
| } | |
| } | |
| } | |
| $body = trim($body, "\r\n"); | |
| if ($body !== '') { | |
| $bodyDocument = (new self($this->options))->read($body); | |
| array_push($blocks, ...$bodyDocument->children); | |
| } | |
| if ($rawHtmlEnabled) { | |
| $blocks[] = new AstNode('raw_html', ['html' => trim($match[3])]); | |
| } | |
| return $blocks; | |
| } | |
| /** | |
| * @return array{0:string, 1:string} | |
| */ | |
| private function extractRawHtmlDetailsSummary(string $inner): array | |
| { | |
| if (preg_match('/<summary\b[^>]*>.*?<\/summary\s*>/isu', $inner, $match, PREG_OFFSET_CAPTURE) !== 1) { | |
| return ['', $inner]; | |
| } | |
| $summary = $match[0][0]; | |
| $offset = $match[0][1]; | |
| $body = substr($inner, 0, $offset) . substr($inner, $offset + strlen($summary)); | |
| return [$summary, $body]; | |
| } | |
| /** | |
| * @param list<string> $lines | |
| * @return list<AstNode>|null | |
| */ | |
| private function tryReadRawHtmlSingleLineContainerBlock(array $lines, int &$index): ?array | |
| { | |
| $line = $lines[$index] ?? ''; | |
| $tags = ($this->options['suppressStandaloneButtonInline'] ?? false) ? 'del|button' : 'del'; | |
| if (preg_match('/^ {0,3}(<(' . $tags . ')(?:\s+[^>]*)?>)(.*)(<\/\2\s*>)[ \t]*$/iu', $line, $m) !== 1) { | |
| return null; | |
| } | |
| $blocks = [ | |
| new AstNode('raw_html', ['html' => $m[1]]), | |
| ]; | |
| if ($m[3] !== '') { | |
| $inlines = $this->parseInlines($m[3]); | |
| $blocks[] = new AstNode('plain', ['text' => $this->plainTextFromInlines($inlines)], $inlines); | |
| } | |
| $blocks[] = new AstNode('raw_html', ['html' => $m[4]]); | |
| return $blocks; | |
| } | |
| /** | |
| * @param list<string> $lines | |
| */ | |
| private function tryReadLatexTableBlock(array $lines, int &$index): ?AstNode | |
| { | |
| $line = $lines[$index] ?? ''; | |
| if (preg_match('/^ {0,3}\\\\begin\{table\}(?:\[[^\]\r\n]*\])?[ \t]*$/', $line) !== 1) { | |
| return null; | |
| } | |
| $content = []; | |
| $cursor = $index + 1; | |
| $count = count($lines); | |
| while ($cursor < $count) { | |
| if (preg_match('/^ {0,3}\\\\end\{table\}[ \t]*$/', $lines[$cursor]) === 1) { | |
| $table = $this->parseLatexTableEnvironment($content); | |
| if ($table === null) { | |
| return null; | |
| } | |
| $index = $cursor; | |
| return $table; | |
| } | |
| $content[] = rtrim($lines[$cursor]); | |
| $cursor++; | |
| } | |
| return null; | |
| } | |
| /** | |
| * @param list<string> $content | |
| */ | |
| private function parseLatexTableEnvironment(array $content): ?AstNode | |
| { | |
| $caption = ''; | |
| $shortCaption = ''; | |
| $tabularStart = null; | |
| $alignments = []; | |
| foreach ($content as $lineIndex => $line) { | |
| if ($caption === '' && preg_match('/\\\\caption(?:\[((?:\\\\.|[^\]\\\\])*)\])?\{((?:\\\\.|[^{}\\\\])*)\}/', $line, $captionMatch) === 1) { | |
| $shortCaption = isset($captionMatch[1]) ? $this->normalizeLatexText($captionMatch[1]) : ''; | |
| $caption = $this->normalizeLatexText($captionMatch[2]); | |
| continue; | |
| } | |
| if (preg_match('/\\\\begin\{tabular\}\{([^}]*)\}/', $line, $tabularMatch) === 1) { | |
| $tabularStart = $lineIndex + 1; | |
| $alignments = $this->parseLatexTabularAlignments($tabularMatch[1]); | |
| break; | |
| } | |
| } | |
| if ($tabularStart === null) { | |
| return null; | |
| } | |
| $rows = []; | |
| for ($cursor = $tabularStart, $count = count($content); $cursor < $count; $cursor++) { | |
| $line = trim($content[$cursor]); | |
| if (preg_match('/\\\\end\{tabular\}/', $line) === 1) { | |
| break; | |
| } | |
| foreach ($this->parseLatexTabularRows($line) as $row) { | |
| $rows[] = $row; | |
| } | |
| } | |
| if ($rows === []) { | |
| return null; | |
| } | |
| $columnCount = max(count($alignments), count($rows[0])); | |
| while (count($alignments) < $columnCount) { | |
| $alignments[] = 'default'; | |
| } | |
| $bodyRows = []; | |
| foreach ($rows as $row) { | |
| $bodyRows[] = $this->buildTableRow($this->normalizePipeTableRow($row, $columnCount), false); | |
| } | |
| $attrs = [ | |
| 'caption' => $caption, | |
| 'alignments' => $alignments, | |
| ]; | |
| if ($caption !== '') { | |
| $attrs['captionInlines'] = $this->parseInlines($caption); | |
| } | |
| if ($shortCaption !== '') { | |
| $attrs['shortCaption'] = $shortCaption; | |
| $attrs['shortCaptionInlines'] = $this->parseInlines($shortCaption); | |
| } | |
| return new AstNode('table', $attrs, [ | |
| new AstNode('table_head'), | |
| new AstNode('table_body', [], $bodyRows), | |
| ]); | |
| } | |
| /** | |
| * @return list<string> | |
| */ | |
| private function parseLatexTabularAlignments(string $spec): array | |
| { | |
| $alignments = []; | |
| $length = strlen($spec); | |
| for ($offset = 0; $offset < $length; $offset++) { | |
| $char = $spec[$offset]; | |
| if ($char === 'l') { | |
| $alignments[] = 'left'; | |
| continue; | |
| } | |
| if ($char === 'r') { | |
| $alignments[] = 'right'; | |
| continue; | |
| } | |
| if ($char === 'c') { | |
| $alignments[] = 'center'; | |
| continue; | |
| } | |
| } | |
| return $alignments; | |
| } | |
| /** | |
| * @return list<list<string>> | |
| */ | |
| private function parseLatexTabularRows(string $line): array | |
| { | |
| $line = trim(str_replace('\hline', '', $line)); | |
| if ($line === '') { | |
| return []; | |
| } | |
| $rows = []; | |
| foreach (preg_split('/\\\\\\\\/', $line) ?: [] as $rowText) { | |
| $rowText = trim($rowText); | |
| if ($rowText === '') { | |
| continue; | |
| } | |
| $rows[] = array_map( | |
| fn (string $cell): string => $this->normalizeLatexText($cell), | |
| $this->splitLatexTabularCells($rowText) | |
| ); | |
| } | |
| return $rows; | |
| } | |
| /** | |
| * @return list<string> | |
| */ | |
| private function splitLatexTabularCells(string $row): array | |
| { | |
| $cells = []; | |
| $cell = ''; | |
| $length = strlen($row); | |
| for ($offset = 0; $offset < $length; $offset++) { | |
| if ($row[$offset] === '\\') { | |
| $cell .= $row[$offset]; | |
| if ($offset + 1 < $length) { | |
| $offset++; | |
| $cell .= $row[$offset]; | |
| } | |
| continue; | |
| } | |
| if ($row[$offset] === '&') { | |
| $cells[] = $cell; | |
| $cell = ''; | |
| continue; | |
| } | |
| $cell .= $row[$offset]; | |
| } | |
| $cells[] = $cell; | |
| return $cells; | |
| } | |
| private function normalizeLatexText(string $text): string | |
| { | |
| $text = trim($text); | |
| $text = preg_replace('/\\\\([#$%&_{}])/', '$1', $text) ?? $text; | |
| $text = preg_replace('/\s+/', ' ', $text) ?? $text; | |
| return trim($text); | |
| } | |
| /** | |
| * @param list<string> $lines | |
| */ | |
| private function tryReadDocBookTableBlock(array $lines, int &$index): ?AstNode | |
| { | |
| $line = $lines[$index] ?? ''; | |
| if (preg_match('/^ {0,3}<informaltable(?:\s+[^>]*)?>/i', $line) !== 1) { | |
| return null; | |
| } | |
| $content = []; | |
| $cursor = $index; | |
| $count = count($lines); | |
| while ($cursor < $count) { | |
| $content[] = trim($lines[$cursor]); | |
| if (preg_match('/<\/informaltable\s*>/i', $lines[$cursor]) === 1) { | |
| $table = $this->parseDocBookInformalTable(implode("\n", $content)); | |
| if ($table === null) { | |
| return null; | |
| } | |
| $index = $cursor; | |
| return $table; | |
| } | |
| $cursor++; | |
| } | |
| return null; | |
| } | |
| private function parseDocBookInformalTable(string $xml): ?AstNode | |
| { | |
| $previous = libxml_use_internal_errors(true); | |
| $dom = new \DOMDocument('1.0', 'UTF-8'); | |
| $loaded = $dom->loadXML($xml, LIBXML_NONET | LIBXML_NOERROR | LIBXML_NOWARNING); | |
| libxml_clear_errors(); | |
| libxml_use_internal_errors($previous); | |
| if (!$loaded || !$dom->documentElement instanceof \DOMElement) { | |
| return null; | |
| } | |
| $root = $dom->documentElement; | |
| if (strtolower($root->localName) !== 'informaltable') { | |
| return null; | |
| } | |
| $tgroup = $this->firstDescendantElement($root, 'tgroup'); | |
| $sectionRoot = $tgroup instanceof \DOMElement ? $tgroup : $root; | |
| $thead = $this->firstChildElement($sectionRoot, 'thead'); | |
| $tbody = $this->firstChildElement($sectionRoot, 'tbody'); | |
| $tfoot = $this->firstChildElement($sectionRoot, 'tfoot'); | |
| if (!$thead instanceof \DOMElement && !$tbody instanceof \DOMElement && !$tfoot instanceof \DOMElement) { | |
| return null; | |
| } | |
| [$columnNames, $widths] = $tgroup instanceof \DOMElement | |
| ? $this->readDocBookColumnSpecs($tgroup) | |
| : [[], null]; | |
| $maxColumns = count($columnNames); | |
| $headRows = $thead instanceof \DOMElement | |
| ? $this->readDocBookTableRows($thead, $columnNames, true, $maxColumns) | |
| : []; | |
| $bodyRows = $tbody instanceof \DOMElement | |
| ? $this->readDocBookTableRows($tbody, $columnNames, false, $maxColumns) | |
| : []; | |
| $footRows = $tfoot instanceof \DOMElement | |
| ? $this->readDocBookTableRows($tfoot, $columnNames, false, $maxColumns) | |
| : []; | |
| if ($headRows === [] && $bodyRows === [] && $footRows === []) { | |
| return null; | |
| } | |
| if ($maxColumns === 0) { | |
| foreach ([...$headRows, ...$bodyRows, ...$footRows] as $row) { | |
| $maxColumns = max($maxColumns, count($row->children)); | |
| } | |
| } | |
| $attrs = [ | |
| 'caption' => '', | |
| 'alignments' => array_fill(0, $maxColumns, 'default'), | |
| ]; | |
| if ($widths !== null) { | |
| $attrs['widths'] = $widths; | |
| } | |
| $children = [ | |
| new AstNode('table_head', [], $headRows), | |
| new AstNode('table_body', [], $bodyRows), | |
| ]; | |
| if ($footRows !== []) { | |
| $children[] = new AstNode('table_foot', [], $footRows); | |
| } | |
| return new AstNode('table', $attrs, $children); | |
| } | |
| /** | |
| * @param list<string> $lines | |
| */ | |
| private function tryReadStructuredHtmlDocumentTableBlock(array $lines, int &$index): ?AstNode | |
| { | |
| $line = $lines[$index] ?? ''; | |
| if (preg_match('/^ {0,3}(?:<!doctype\s+html\b|<html\b)/i', $line) !== 1) { | |
| return null; | |
| } | |
| $content = []; | |
| $count = count($lines); | |
| for ($cursor = $index; $cursor < $count; $cursor++) { | |
| $content[] = $this->normalizeRawHtmlLine($lines[$cursor]); | |
| if (preg_match('/<\/html\s*>/i', $lines[$cursor]) === 1) { | |
| $table = $this->parseStructuredHtmlTable(implode("\n", $content)); | |
| if ($table === null) { | |
| return null; | |
| } | |
| $index = $cursor; | |
| return $table; | |
| } | |
| } | |
| return null; | |
| } | |
| /** | |
| * @param list<string> $lines | |
| */ | |
| private function tryReadHtmlDocumentBlock(array $lines, int &$index): ?AstNode | |
| { | |
| $line = $lines[$index] ?? ''; | |
| if (preg_match('/^ {0,3}(?:<!doctype\s+html\b|<html\b)/i', $line) !== 1) { | |
| return null; | |
| } | |
| $content = []; | |
| $count = count($lines); | |
| for ($cursor = $index; $cursor < $count; $cursor++) { | |
| $content[] = $this->normalizeRawHtmlLine($lines[$cursor]); | |
| if (preg_match('/<\/html\s*>/i', $lines[$cursor]) === 1) { | |
| $document = $this->parseHtmlDocument(implode("\n", $content)); | |
| if ($document === null) { | |
| return null; | |
| } | |
| $index = $cursor; | |
| return $document; | |
| } | |
| } | |
| $document = $content === [] ? null : $this->parseHtmlDocument(implode("\n", $content)); | |
| if ($document === null) { | |
| return null; | |
| } | |
| $index = $count - 1; | |
| return $document; | |
| } | |
| private function parseHtmlDocument(string $html): ?AstNode | |
| { | |
| $previous = libxml_use_internal_errors(true); | |
| $dom = new \DOMDocument('1.0', 'UTF-8'); | |
| $loaded = $dom->loadHTML( | |
| '<?xml encoding="UTF-8">' . $html, | |
| LIBXML_NONET | LIBXML_NOERROR | LIBXML_NOWARNING | |
| ); | |
| libxml_clear_errors(); | |
| libxml_use_internal_errors($previous); | |
| if (!$loaded) { | |
| return null; | |
| } | |
| $body = $dom->getElementsByTagName('body')->item(0); | |
| if (!$body instanceof \DOMElement) { | |
| return null; | |
| } | |
| $previousHtmlBaseHref = $this->htmlBaseHref; | |
| $previousHtmlFootnoteDefinitions = $this->htmlFootnoteDefinitions; | |
| $this->htmlBaseHref = $this->htmlDocumentBaseHref($dom); | |
| $this->htmlFootnoteDefinitions = $this->collectHtmlFootnoteDefinitions($body); | |
| try { | |
| $attrs = $this->htmlDocumentAttrs($dom); | |
| if ($this->htmlNativeDivsEnabled()) { | |
| $main = $this->firstHtmlMainElement($body); | |
| if ($main instanceof \DOMElement) { | |
| return new AstNode('document', $attrs, $this->htmlNativeDivsMainBlocks($main)); | |
| } | |
| } | |
| return new AstNode('document', $attrs, $this->parseHtmlBlockChildren($body)); | |
| } finally { | |
| $this->htmlBaseHref = $previousHtmlBaseHref; | |
| $this->htmlFootnoteDefinitions = $previousHtmlFootnoteDefinitions; | |
| } | |
| } | |
| /** | |
| * @return array<string, list<AstNode>> | |
| */ | |
| private function collectHtmlFootnoteDefinitions(\DOMElement $body): array | |
| { | |
| $definitions = []; | |
| foreach ($this->htmlFootnoteContainers($body) as $container) { | |
| foreach ($container->getElementsByTagName('*') as $candidate) { | |
| if (!$candidate instanceof \DOMElement || !$this->isHtmlFootnoteItemElement($candidate)) { | |
| continue; | |
| } | |
| $id = trim($candidate->getAttribute('id')); | |
| if ($id === '' || isset($definitions[$id])) { | |
| continue; | |
| } | |
| $clone = $candidate->cloneNode(true); | |
| if (!$clone instanceof \DOMElement) { | |
| continue; | |
| } | |
| $this->removeHtmlFootnoteBacklinks($clone); | |
| $definitions[$id] = $this->parseHtmlBlockChildren($clone); | |
| } | |
| } | |
| return $definitions; | |
| } | |
| /** | |
| * @return list<\DOMElement> | |
| */ | |
| private function htmlFootnoteContainers(\DOMElement $root): array | |
| { | |
| $containers = []; | |
| if ($this->isHtmlFootnoteContainer($root)) { | |
| $containers[] = $root; | |
| } | |
| foreach ($root->getElementsByTagName('*') as $candidate) { | |
| if ($candidate instanceof \DOMElement && $this->isHtmlFootnoteContainer($candidate)) { | |
| $containers[] = $candidate; | |
| } | |
| } | |
| return $containers; | |
| } | |
| private function isHtmlFootnoteContainer(\DOMElement $element): bool | |
| { | |
| if (strtolower(trim($element->getAttribute('role'))) === 'doc-endnotes') { | |
| return true; | |
| } | |
| return in_array($this->htmlSemanticType($element), ['footnotes', 'rearnotes'], true); | |
| } | |
| private function isHtmlFootnoteItemElement(\DOMElement $element): bool | |
| { | |
| if (trim($element->getAttribute('id')) === '') { | |
| return false; | |
| } | |
| $name = strtolower($element->localName); | |
| if ($name === 'li') { | |
| return true; | |
| } | |
| return in_array($this->htmlSemanticType($element), ['footnote', 'rearnote'], true); | |
| } | |
| private function isHtmlNoteReferenceElement(\DOMElement $element): bool | |
| { | |
| if (strtolower(trim($element->getAttribute('role'))) === 'doc-noteref') { | |
| return true; | |
| } | |
| return $this->htmlSemanticType($element) === 'noteref'; | |
| } | |
| private function htmlSemanticType(\DOMElement $element): string | |
| { | |
| $type = trim($element->getAttribute('type')); | |
| if ($type === '') { | |
| $type = trim($element->getAttribute('epub:type')); | |
| } | |
| return strtolower($type); | |
| } | |
| private function removeHtmlFootnoteBacklinks(\DOMElement $root): void | |
| { | |
| $links = []; | |
| foreach ($root->getElementsByTagName('a') as $link) { | |
| if ($link instanceof \DOMElement && strtolower(trim($link->getAttribute('role'))) === 'doc-backlink') { | |
| $links[] = $link; | |
| } | |
| } | |
| foreach ($links as $link) { | |
| $link->parentNode?->removeChild($link); | |
| } | |
| } | |
| private function htmlNativeDivsEnabled(): bool | |
| { | |
| return (bool) ($this->options['htmlNativeDivs'] ?? false); | |
| } | |
| /** | |
| * @param list<string> $lines | |
| */ | |
| private function tryReadHtmlNativeDivsMainBlock(array $lines, int &$index): ?AstNode | |
| { | |
| if (!$this->htmlNativeDivsEnabled()) { | |
| return null; | |
| } | |
| $line = $lines[$index] ?? ''; | |
| if (preg_match('/<main\b/i', $line) !== 1) { | |
| return null; | |
| } | |
| $content = []; | |
| $count = count($lines); | |
| for ($cursor = $index; $cursor < $count; $cursor++) { | |
| $line = $this->normalizeRawHtmlLine($lines[$cursor]); | |
| if ($cursor > $index && trim($line) === '') { | |
| break; | |
| } | |
| $content[] = $line; | |
| [$started, $depth] = $this->htmlElementBalance(implode("\n", $content), 'main'); | |
| if ($started && $depth === 0) { | |
| break; | |
| } | |
| } | |
| if ($content === []) { | |
| return null; | |
| } | |
| $document = $this->parseHtmlNativeDivsFragment(implode("\n", $content)); | |
| if (!$document instanceof AstNode) { | |
| return null; | |
| } | |
| $index += count($content) - 1; | |
| return $document; | |
| } | |
| private function parseHtmlNativeDivsFragment(string $html): ?AstNode | |
| { | |
| $previous = libxml_use_internal_errors(true); | |
| $dom = new \DOMDocument('1.0', 'UTF-8'); | |
| $loaded = $dom->loadHTML( | |
| '<?xml encoding="UTF-8"><!doctype html><html><body>' . $html . '</body></html>', | |
| LIBXML_NONET | LIBXML_NOERROR | LIBXML_NOWARNING | |
| ); | |
| libxml_clear_errors(); | |
| libxml_use_internal_errors($previous); | |
| if (!$loaded) { | |
| return null; | |
| } | |
| $body = $dom->getElementsByTagName('body')->item(0); | |
| if (!$body instanceof \DOMElement) { | |
| return null; | |
| } | |
| $main = $this->firstHtmlMainElement($body); | |
| if (!$main instanceof \DOMElement) { | |
| return null; | |
| } | |
| return new AstNode('document', [], $this->htmlNativeDivsMainBlocks($main)); | |
| } | |
| /** | |
| * @param list<string> $lines | |
| */ | |
| private function tryReadHtmlNativeDivsHeaderBlock(array $lines, int &$index): ?AstNode | |
| { | |
| if (!$this->htmlNativeDivsEnabled()) { | |
| return null; | |
| } | |
| $line = $lines[$index] ?? ''; | |
| if (preg_match('/^ {0,3}<header\b/i', $line) !== 1) { | |
| return null; | |
| } | |
| $collected = $this->collectBalancedHtmlElementBlock($lines, $index, 'header'); | |
| if ($collected === null) { | |
| return null; | |
| } | |
| [$html, $endIndex] = $collected; | |
| $header = $this->parseHtmlNativeDivsHeaderFragment($html); | |
| if ($header === null) { | |
| return null; | |
| } | |
| $index = $endIndex; | |
| return $header; | |
| } | |
| private function parseHtmlNativeDivsHeaderFragment(string $html): ?AstNode | |
| { | |
| $previous = libxml_use_internal_errors(true); | |
| $dom = new \DOMDocument('1.0', 'UTF-8'); | |
| $loaded = $dom->loadHTML( | |
| '<?xml encoding="UTF-8"><!doctype html><html><body>' . $html . '</body></html>', | |
| LIBXML_NONET | LIBXML_NOERROR | LIBXML_NOWARNING | |
| ); | |
| libxml_clear_errors(); | |
| libxml_use_internal_errors($previous); | |
| if (!$loaded) { | |
| return null; | |
| } | |
| $body = $dom->getElementsByTagName('body')->item(0); | |
| if (!$body instanceof \DOMElement) { | |
| return null; | |
| } | |
| $header = $this->firstChildElement($body, 'header'); | |
| if (!$header instanceof \DOMElement) { | |
| return null; | |
| } | |
| return $this->buildHtmlNativeHeaderDivNode($header); | |
| } | |
| /** | |
| * @param list<string> $lines | |
| */ | |
| private function tryReadHtmlNativeDivsContainerBlock(array $lines, int &$index): ?AstNode | |
| { | |
| if (!$this->htmlNativeDivsEnabled()) { | |
| return null; | |
| } | |
| $line = $lines[$index] ?? ''; | |
| if (preg_match('/^ {0,3}<(section|aside)\b/i', $line, $match) !== 1) { | |
| return null; | |
| } | |
| $tag = strtolower($match[1]); | |
| if ($tag === 'button' && ($this->options['suppressStandaloneButtonInline'] ?? false)) { | |
| return null; | |
| } | |
| $collected = $this->collectBalancedHtmlElementBlock($lines, $index, $tag); | |
| if ($collected === null) { | |
| return null; | |
| } | |
| [$html, $endIndex] = $collected; | |
| $container = $this->parseHtmlNativeDivsContainerFragment($html, $tag); | |
| if ($container === null) { | |
| return null; | |
| } | |
| $index = $endIndex; | |
| return $container; | |
| } | |
| private function parseHtmlNativeDivsContainerFragment(string $html, string $tag): ?AstNode | |
| { | |
| $previous = libxml_use_internal_errors(true); | |
| $dom = new \DOMDocument('1.0', 'UTF-8'); | |
| $loaded = $dom->loadHTML( | |
| '<?xml encoding="UTF-8"><!doctype html><html><body>' . $html . '</body></html>', | |
| LIBXML_NONET | LIBXML_NOERROR | LIBXML_NOWARNING | |
| ); | |
| libxml_clear_errors(); | |
| libxml_use_internal_errors($previous); | |
| if (!$loaded) { | |
| return null; | |
| } | |
| $body = $dom->getElementsByTagName('body')->item(0); | |
| if (!$body instanceof \DOMElement) { | |
| return null; | |
| } | |
| $container = $this->firstChildElement($body, $tag); | |
| if (!$container instanceof \DOMElement) { | |
| return null; | |
| } | |
| return $this->buildHtmlNativeDivNode($container); | |
| } | |
| private function firstHtmlMainElement(\DOMElement $root): ?\DOMElement | |
| { | |
| if (strtolower($root->localName) === 'main') { | |
| return $root; | |
| } | |
| foreach ($root->getElementsByTagName('main') as $main) { | |
| if ($main instanceof \DOMElement) { | |
| return $main; | |
| } | |
| } | |
| return null; | |
| } | |
| /** | |
| * @return list<AstNode> | |
| */ | |
| private function htmlNativeDivsMainBlocks(\DOMElement $main): array | |
| { | |
| $attrs = $this->htmlNativeMainAttrs($main); | |
| $children = $this->htmlNativeDivChildren($main, (string) ($attrs['id'] ?? '')); | |
| if ($attrs === []) { | |
| return $children; | |
| } | |
| return [new AstNode('div', $attrs, $children)]; | |
| } | |
| /** | |
| * @return array<string, mixed> | |
| */ | |
| private function htmlNativeMainAttrs(\DOMElement $main): array | |
| { | |
| $attrs = $this->htmlElementPandocAttrs($main); | |
| if ($attrs !== [] && !$main->hasAttribute('role')) { | |
| $attributes = $attrs['attributes'] ?? []; | |
| if (!is_array($attributes)) { | |
| $attributes = []; | |
| } | |
| $attributes['role'] = 'main'; | |
| $attrs['attributes'] = $attributes; | |
| $htmlAttributes = $attrs['htmlAttributes'] ?? []; | |
| if (!is_array($htmlAttributes)) { | |
| $htmlAttributes = []; | |
| } | |
| $htmlAttributes['role'] = 'main'; | |
| $attrs['htmlAttributes'] = $htmlAttributes; | |
| } | |
| return $attrs; | |
| } | |
| /** | |
| * @return array<string, mixed> | |
| */ | |
| private function htmlDocumentAttrs(\DOMDocument $dom): array | |
| { | |
| $meta = []; | |
| $lang = $this->htmlDocumentLang($dom); | |
| if ($lang !== '') { | |
| $meta['lang'] = $lang; | |
| } | |
| $titles = $dom->getElementsByTagName('title'); | |
| $title = $titles->item(0); | |
| if ($title instanceof \DOMElement) { | |
| $text = trim(preg_replace('/\s+/', ' ', $title->textContent) ?? $title->textContent); | |
| if ($text !== '') { | |
| $meta['title'] = $text; | |
| } | |
| } | |
| foreach ($dom->getElementsByTagName('meta') as $node) { | |
| if (!$node instanceof \DOMElement) { | |
| continue; | |
| } | |
| $name = strtolower(trim($node->getAttribute('name'))); | |
| if ($name === '') { | |
| continue; | |
| } | |
| $content = trim($node->getAttribute('content')); | |
| if ($content !== '') { | |
| $meta[$name] = $content; | |
| } | |
| } | |
| return $meta === [] ? [] : ['meta' => $meta]; | |
| } | |
| private function htmlDocumentLang(\DOMDocument $dom): string | |
| { | |
| $html = $dom->getElementsByTagName('html')->item(0); | |
| if (!$html instanceof \DOMElement) { | |
| return ''; | |
| } | |
| $lang = trim($html->getAttribute('lang')); | |
| if ($lang !== '') { | |
| return $lang; | |
| } | |
| $xmlLang = $html->attributes?->getNamedItem('xml:lang'); | |
| if ($xmlLang instanceof \DOMNode) { | |
| return trim($xmlLang->nodeValue ?? ''); | |
| } | |
| return ''; | |
| } | |
| private function htmlDocumentBaseHref(\DOMDocument $dom): ?string | |
| { | |
| foreach ($dom->getElementsByTagName('base') as $node) { | |
| if (!$node instanceof \DOMElement) { | |
| continue; | |
| } | |
| $href = trim($node->getAttribute('href')); | |
| if ($href !== '') { | |
| return $href; | |
| } | |
| } | |
| return null; | |
| } | |
| /** | |
| * @param list<string> $lines | |
| */ | |
| private function tryReadStructuredHtmlTableBlock(array $lines, int &$index): ?AstNode | |
| { | |
| $line = $lines[$index] ?? ''; | |
| if (preg_match('/^ {0,3}<table(?:\s+[^>]*)?>/i', $line) !== 1) { | |
| return null; | |
| } | |
| $balanced = $this->collectBalancedHtmlTableBlock($lines, $index); | |
| if ($balanced === null) { | |
| return null; | |
| } | |
| [$html, $endIndex] = $balanced; | |
| $table = $this->parseStructuredHtmlTable($html); | |
| if ($table === null) { | |
| return null; | |
| } | |
| $index = $endIndex; | |
| return $table; | |
| } | |
| /** | |
| * @param list<string> $lines | |
| */ | |
| private function tryReadEmptyHtmlTableBlock(array $lines, int &$index): bool | |
| { | |
| $line = $lines[$index] ?? ''; | |
| if (preg_match('/^ {0,3}<table(?:\s+[^>]*)?>/i', $line) !== 1) { | |
| return false; | |
| } | |
| $balanced = $this->collectBalancedHtmlTableBlock($lines, $index); | |
| if ($balanced === null) { | |
| return false; | |
| } | |
| [$html, $endIndex] = $balanced; | |
| if (!$this->htmlTableBlockIsEmpty($html)) { | |
| return false; | |
| } | |
| $index = $endIndex; | |
| return true; | |
| } | |
| /** | |
| * @param list<string> $lines | |
| */ | |
| private function tryReadHtmlParagraphBlock(array $lines, int &$index): ?AstNode | |
| { | |
| $line = $lines[$index] ?? ''; | |
| if (preg_match('/^ {0,3}<p(?:\s+[^>]*)?>/i', $line) !== 1) { | |
| return null; | |
| } | |
| $collected = $this->collectHtmlParagraphBlock($lines, $index); | |
| if ($collected === null) { | |
| return null; | |
| } | |
| [$html, $endIndex] = $collected; | |
| $paragraph = $this->parseHtmlParagraphElement($html); | |
| if ($paragraph === null) { | |
| return null; | |
| } | |
| $index = $endIndex; | |
| return $paragraph; | |
| } | |
| /** | |
| * @param list<string> $lines | |
| * @return array{0:string, 1:int}|null | |
| */ | |
| private function collectHtmlParagraphBlock(array $lines, int $index): ?array | |
| { | |
| $content = []; | |
| $count = count($lines); | |
| for ($cursor = $index; $cursor < $count; $cursor++) { | |
| $line = $this->normalizeRawHtmlLine($lines[$cursor]); | |
| if ($cursor > $index && $this->htmlLineStartsImplicitParagraphClose($line)) { | |
| return [implode("\n", $content), $cursor - 1]; | |
| } | |
| $content[] = $line; | |
| if (preg_match('/<\/p\s*>/i', $line) === 1) { | |
| return [implode("\n", $content), $cursor]; | |
| } | |
| } | |
| if ($content === []) { | |
| return null; | |
| } | |
| return [implode("\n", $content), $count - 1]; | |
| } | |
| private function htmlLineStartsImplicitParagraphClose(string $line): bool | |
| { | |
| return preg_match( | |
| '/^ {0,3}<(?:p|h[1-6]|ul|ol|dl|blockquote|pre|table|div|figure|hr)\b/i', | |
| $line | |
| ) === 1; | |
| } | |
| /** | |
| * @param list<string> $lines | |
| * @return array{0:string, 1:int}|null | |
| */ | |
| private function collectHtmlBlockUntilClosingTag(array $lines, int $index, string $tag): ?array | |
| { | |
| $content = []; | |
| $count = count($lines); | |
| $closingPattern = '/<\/' . preg_quote($tag, '/') . '\s*>/i'; | |
| for ($cursor = $index; $cursor < $count; $cursor++) { | |
| $line = $this->normalizeRawHtmlLine($lines[$cursor]); | |
| $content[] = $line; | |
| if (preg_match($closingPattern, $line) === 1) { | |
| return [implode("\n", $content), $cursor]; | |
| } | |
| } | |
| return null; | |
| } | |
| private function parseHtmlParagraphElement(string $html): ?AstNode | |
| { | |
| $previous = libxml_use_internal_errors(true); | |
| $dom = new \DOMDocument('1.0', 'UTF-8'); | |
| $loaded = $dom->loadHTML( | |
| '<?xml encoding="UTF-8"><!doctype html><html><body>' . $html . '</body></html>', | |
| LIBXML_NONET | LIBXML_NOERROR | LIBXML_NOWARNING | |
| ); | |
| libxml_clear_errors(); | |
| libxml_use_internal_errors($previous); | |
| if (!$loaded) { | |
| return null; | |
| } | |
| $body = $dom->getElementsByTagName('body')->item(0); | |
| if (!$body instanceof \DOMElement) { | |
| return null; | |
| } | |
| $paragraph = $this->firstChildElement($body, 'p'); | |
| if (!$paragraph instanceof \DOMElement) { | |
| return null; | |
| } | |
| return $this->buildHtmlParagraphNode($paragraph); | |
| } | |
| /** | |
| * @param list<string> $lines | |
| */ | |
| private function tryReadHtmlInlineFragmentBlock(array $lines, int &$index): ?AstNode | |
| { | |
| $line = $lines[$index] ?? ''; | |
| if (preg_match('/^ {0,3}<br\b[^>]*>/i', $line) === 1) { | |
| return $this->parseHtmlInlineFragmentParagraph(trim($line)); | |
| } | |
| if (preg_match('/^ {0,3}<(abbr|applet|audio|b|bdo|button|cite|code|del|dfn|em|i|ins|kbd|map|mark|noscript|object|progress|q|s|samp|small|source|span|strike|strong|sub|sup|svg|time|track|tt|u|var|video|embed)\b/i', $line, $match) !== 1) { | |
| return null; | |
| } | |
| $tag = strtolower($match[1]); | |
| if ($tag === 'button' && ($this->options['suppressStandaloneButtonInline'] ?? false)) { | |
| return null; | |
| } | |
| $collected = $this->collectBalancedHtmlElementBlock($lines, $index, $tag); | |
| if ($collected === null) { | |
| return null; | |
| } | |
| [$html, $endIndex] = $collected; | |
| $paragraph = $this->parseHtmlInlineFragmentParagraph($html); | |
| if ($paragraph === null) { | |
| return null; | |
| } | |
| $index = $endIndex; | |
| return $paragraph; | |
| } | |
| private function parseHtmlInlineFragmentParagraph(string $html): ?AstNode | |
| { | |
| $previous = libxml_use_internal_errors(true); | |
| $dom = new \DOMDocument('1.0', 'UTF-8'); | |
| $loaded = $dom->loadHTML( | |
| '<?xml encoding="UTF-8"><!doctype html><html><body>' . $html . '</body></html>', | |
| LIBXML_NONET | LIBXML_NOERROR | LIBXML_NOWARNING | |
| ); | |
| libxml_clear_errors(); | |
| libxml_use_internal_errors($previous); | |
| if (!$loaded) { | |
| return null; | |
| } | |
| $body = $dom->getElementsByTagName('body')->item(0); | |
| if (!$body instanceof \DOMElement) { | |
| return null; | |
| } | |
| $blocks = $this->parseHtmlBlockChildren($body); | |
| if (count($blocks) !== 1 || $blocks[0]->type !== 'paragraph') { | |
| return null; | |
| } | |
| return $blocks[0]; | |
| } | |
| /** | |
| * @return list<AstNode> | |
| */ | |
| private function parseHtmlInlineFragmentNodes(string $html): array | |
| { | |
| $previous = libxml_use_internal_errors(true); | |
| $dom = new \DOMDocument('1.0', 'UTF-8'); | |
| $loaded = $dom->loadHTML( | |
| '<?xml encoding="UTF-8"><!doctype html><html><body>' . $html . '</body></html>', | |
| LIBXML_NONET | LIBXML_NOERROR | LIBXML_NOWARNING | |
| ); | |
| libxml_clear_errors(); | |
| libxml_use_internal_errors($previous); | |
| if (!$loaded) { | |
| return []; | |
| } | |
| $body = $dom->getElementsByTagName('body')->item(0); | |
| if (!$body instanceof \DOMElement) { | |
| return []; | |
| } | |
| return $this->parseHtmlInlineChildren($body); | |
| } | |
| /** | |
| * @param list<string> $lines | |
| */ | |
| private function tryReadHtmlHorizontalRuleBlock(array $lines, int &$index): ?AstNode | |
| { | |
| $line = $this->normalizeRawHtmlLine($lines[$index] ?? ''); | |
| if (preg_match('/^ {0,3}<hr\s*\/>[ \t]*$/i', $line) !== 1) { | |
| return null; | |
| } | |
| return new AstNode('horizontal_rule'); | |
| } | |
| /** | |
| * @param list<string> $lines | |
| */ | |
| private function tryReadHtmlBlockQuoteBlock(array $lines, int &$index): ?AstNode | |
| { | |
| $line = $lines[$index] ?? ''; | |
| if (preg_match('/^ {0,3}<blockquote(?:\s+[^>]*)?>/i', $line) !== 1) { | |
| return null; | |
| } | |
| $collected = $this->collectBalancedHtmlElementBlock($lines, $index, 'blockquote'); | |
| if ($collected === null) { | |
| return null; | |
| } | |
| [$html, $endIndex] = $collected; | |
| $quote = $this->parseHtmlBlockQuoteBlock($html); | |
| if ($quote === null) { | |
| return null; | |
| } | |
| $index = $endIndex; | |
| return $quote; | |
| } | |
| /** | |
| * @param list<string> $lines | |
| */ | |
| private function tryReadHtmlFigureBlock(array $lines, int &$index): ?AstNode | |
| { | |
| $line = $lines[$index] ?? ''; | |
| if (preg_match('/^ {0,3}<figure\b/i', $line) !== 1) { | |
| return null; | |
| } | |
| $collected = $this->collectBalancedHtmlElementBlock($lines, $index, 'figure'); | |
| if ($collected === null) { | |
| return null; | |
| } | |
| [$html, $endIndex] = $collected; | |
| $figure = $this->parseHtmlFigureBlock($html); | |
| if ($figure === null) { | |
| return null; | |
| } | |
| $index = $endIndex; | |
| return $figure; | |
| } | |
| /** | |
| * @param list<string> $lines | |
| */ | |
| private function tryReadHtmlIframeBlock(array $lines, int &$index): ?AstNode | |
| { | |
| $line = $lines[$index] ?? ''; | |
| if (preg_match('/^ {0,3}<iframe\b/i', $line) !== 1) { | |
| return null; | |
| } | |
| $collected = $this->collectBalancedHtmlElementBlock($lines, $index, 'iframe'); | |
| if ($collected === null) { | |
| return null; | |
| } | |
| [$html, $endIndex] = $collected; | |
| $iframe = $this->parseHtmlIframeBlock($html); | |
| if ($iframe === null) { | |
| return null; | |
| } | |
| $index = $endIndex; | |
| return $iframe; | |
| } | |
| private function parseHtmlIframeBlock(string $html): ?AstNode | |
| { | |
| $previous = libxml_use_internal_errors(true); | |
| $dom = new \DOMDocument('1.0', 'UTF-8'); | |
| $loaded = $dom->loadHTML( | |
| '<?xml encoding="UTF-8"><!doctype html><html><body>' . $html . '</body></html>', | |
| LIBXML_NONET | LIBXML_NOERROR | LIBXML_NOWARNING | |
| ); | |
| libxml_clear_errors(); | |
| libxml_use_internal_errors($previous); | |
| if (!$loaded) { | |
| return null; | |
| } | |
| $body = $dom->getElementsByTagName('body')->item(0); | |
| if (!$body instanceof \DOMElement) { | |
| return null; | |
| } | |
| $iframe = $this->firstChildElement($body, 'iframe'); | |
| if (!$iframe instanceof \DOMElement) { | |
| return null; | |
| } | |
| return $this->buildHtmlIframeNode($iframe); | |
| } | |
| /** | |
| * @param list<string> $lines | |
| */ | |
| private function tryReadHtmlHeadingBlock(array $lines, int &$index): ?AstNode | |
| { | |
| $line = $lines[$index] ?? ''; | |
| if (preg_match('/^ {0,3}<h([1-6])\b/i', $line, $m) !== 1) { | |
| return null; | |
| } | |
| $level = (int) $m[1]; | |
| $collected = $this->collectHtmlBlockUntilClosingTag($lines, $index, 'h' . $level); | |
| if ($collected === null) { | |
| return null; | |
| } | |
| [$html, $endIndex] = $collected; | |
| $heading = $this->parseHtmlHeadingElement($html, $level); | |
| if ($heading === null) { | |
| return null; | |
| } | |
| $index = $endIndex; | |
| return $heading; | |
| } | |
| private function parseHtmlHeadingElement(string $html, int $level): ?AstNode | |
| { | |
| $previous = libxml_use_internal_errors(true); | |
| $dom = new \DOMDocument('1.0', 'UTF-8'); | |
| $loaded = $dom->loadHTML( | |
| '<?xml encoding="UTF-8"><!doctype html><html><body>' . $html . '</body></html>', | |
| LIBXML_NONET | LIBXML_NOERROR | LIBXML_NOWARNING | |
| ); | |
| libxml_clear_errors(); | |
| libxml_use_internal_errors($previous); | |
| if (!$loaded) { | |
| return null; | |
| } | |
| $body = $dom->getElementsByTagName('body')->item(0); | |
| if (!$body instanceof \DOMElement) { | |
| return null; | |
| } | |
| $heading = $this->firstChildElement($body, 'h' . $level); | |
| if (!$heading instanceof \DOMElement) { | |
| return null; | |
| } | |
| return $this->buildHtmlHeadingNode($heading, $level); | |
| } | |
| private function buildHtmlHeadingNode(\DOMElement $heading, int $level): AstNode | |
| { | |
| $children = $this->parseHtmlInlineChildren($heading); | |
| $text = trim(preg_replace('/\s+/', ' ', $this->plainTextFromInlines($children)) ?? ''); | |
| $attrs = array_merge($this->htmlElementPandocAttrs($heading), [ | |
| 'level' => $level, | |
| 'text' => $text, | |
| ]); | |
| if (!isset($attrs['id'])) { | |
| $identifier = $this->htmlHeadingIdentifier($text); | |
| if ($identifier !== '') { | |
| $attrs['id'] = $identifier; | |
| } | |
| } | |
| return new AstNode('heading', $attrs, $children); | |
| } | |
| private function htmlHeadingIdentifier(string $text): string | |
| { | |
| $identifier = strtolower($text); | |
| $identifier = preg_replace('/[^a-z0-9]+/', '-', $identifier) ?? ''; | |
| return trim($identifier, '-'); | |
| } | |
| /** | |
| * @param list<string> $lines | |
| */ | |
| private function tryReadHtmlListBlock(array $lines, int &$index): ?AstNode | |
| { | |
| $line = $lines[$index] ?? ''; | |
| if (preg_match('/^ {0,3}<(ol|ul)\b/i', $line, $m) !== 1) { | |
| return null; | |
| } | |
| $tag = strtolower($m[1]); | |
| $collected = $this->collectBalancedHtmlElementBlock($lines, $index, $tag); | |
| if ($collected === null) { | |
| return null; | |
| } | |
| [$html, $endIndex] = $collected; | |
| $list = $this->parseHtmlListBlock($html, $tag); | |
| if ($list === null) { | |
| return null; | |
| } | |
| $index = $endIndex; | |
| return $list; | |
| } | |
| /** | |
| * @param list<string> $lines | |
| */ | |
| private function tryReadHtmlDefinitionListBlock(array $lines, int &$index): ?AstNode | |
| { | |
| $line = $lines[$index] ?? ''; | |
| if (preg_match('/^ {0,3}<dl\b/i', $line) !== 1) { | |
| return null; | |
| } | |
| $collected = $this->collectBalancedHtmlElementBlock($lines, $index, 'dl'); | |
| if ($collected === null) { | |
| return null; | |
| } | |
| [$html, $endIndex] = $collected; | |
| $list = $this->parseHtmlDefinitionListBlock($html); | |
| if ($list === null) { | |
| return null; | |
| } | |
| $index = $endIndex; | |
| return $list; | |
| } | |
| /** | |
| * @param list<string> $lines | |
| */ | |
| private function tryReadHtmlPreCodeBlock(array $lines, int &$index): ?AstNode | |
| { | |
| $line = $lines[$index] ?? ''; | |
| if (preg_match('/^ {0,3}<pre(?:\s+[^>]*)?>/i', $line) !== 1) { | |
| return null; | |
| } | |
| $collected = $this->collectHtmlBlockUntilClosingTag($lines, $index, 'pre'); | |
| if ($collected === null) { | |
| return null; | |
| } | |
| [$html, $endIndex] = $collected; | |
| $codeBlock = $this->parseHtmlPreCodeBlock($html); | |
| if ($codeBlock === null) { | |
| return null; | |
| } | |
| $index = $endIndex; | |
| return $codeBlock; | |
| } | |
| private function parseHtmlPreCodeBlock(string $html): ?AstNode | |
| { | |
| $previous = libxml_use_internal_errors(true); | |
| $dom = new \DOMDocument('1.0', 'UTF-8'); | |
| $loaded = $dom->loadHTML( | |
| '<?xml encoding="UTF-8"><!doctype html><html><body>' . $html . '</body></html>', | |
| LIBXML_NONET | LIBXML_NOERROR | LIBXML_NOWARNING | |
| ); | |
| libxml_clear_errors(); | |
| libxml_use_internal_errors($previous); | |
| if (!$loaded) { | |
| return null; | |
| } | |
| $body = $dom->getElementsByTagName('body')->item(0); | |
| if (!$body instanceof \DOMElement) { | |
| return null; | |
| } | |
| $pre = $this->firstChildElement($body, 'pre'); | |
| if (!$pre instanceof \DOMElement) { | |
| return null; | |
| } | |
| return $this->buildHtmlPreCodeBlock($pre); | |
| } | |
| private function buildHtmlPreCodeBlock(\DOMElement $pre): ?AstNode | |
| { | |
| $code = $this->firstChildElement($pre, 'code'); | |
| $text = str_replace(["\r\n", "\r"], "\n", $this->htmlPreCodeText($pre)); | |
| if (str_ends_with($text, "\n")) { | |
| $text = substr($text, 0, -1); | |
| } | |
| $preAttrs = $this->htmlElementPandocAttrs($pre); | |
| if ($preAttrs !== [] || !$code instanceof \DOMElement) { | |
| $attrs = $preAttrs; | |
| } else { | |
| $attrs = $this->htmlCodeBlockAttrs($code); | |
| } | |
| if (!isset($attrs['attributes'])) { | |
| $attrs['attributes'] = []; | |
| } | |
| $attrs['text'] = $text; | |
| return new AstNode('code_block', $attrs); | |
| } | |
| private function htmlPreCodeText(\DOMNode $node): string | |
| { | |
| $text = ''; | |
| foreach ($node->childNodes as $child) { | |
| if ($child instanceof \DOMText || $child instanceof \DOMCdataSection) { | |
| $text .= $child->nodeValue ?? ''; | |
| continue; | |
| } | |
| if ($child instanceof \DOMElement) { | |
| if (strtolower($child->localName) === 'br') { | |
| $text .= "\n"; | |
| continue; | |
| } | |
| $text .= $this->htmlPreCodeText($child); | |
| } | |
| } | |
| return $text; | |
| } | |
| private function parseHtmlBlockQuoteBlock(string $html): ?AstNode | |
| { | |
| $previous = libxml_use_internal_errors(true); | |
| $dom = new \DOMDocument('1.0', 'UTF-8'); | |
| $loaded = $dom->loadHTML( | |
| '<?xml encoding="UTF-8"><!doctype html><html><body>' . $html . '</body></html>', | |
| LIBXML_NONET | LIBXML_NOERROR | LIBXML_NOWARNING | |
| ); | |
| libxml_clear_errors(); | |
| libxml_use_internal_errors($previous); | |
| if (!$loaded) { | |
| return null; | |
| } | |
| $body = $dom->getElementsByTagName('body')->item(0); | |
| if (!$body instanceof \DOMElement) { | |
| return null; | |
| } | |
| $quote = $this->firstChildElement($body, 'blockquote'); | |
| if (!$quote instanceof \DOMElement) { | |
| return null; | |
| } | |
| return $this->buildHtmlBlockQuoteNode($quote); | |
| } | |
| private function parseHtmlFigureBlock(string $html): ?AstNode | |
| { | |
| $previous = libxml_use_internal_errors(true); | |
| $dom = new \DOMDocument('1.0', 'UTF-8'); | |
| $loaded = $dom->loadHTML( | |
| '<?xml encoding="UTF-8"><!doctype html><html><body>' . $html . '</body></html>', | |
| LIBXML_NONET | LIBXML_NOERROR | LIBXML_NOWARNING | |
| ); | |
| libxml_clear_errors(); | |
| libxml_use_internal_errors($previous); | |
| if (!$loaded) { | |
| return null; | |
| } | |
| $body = $dom->getElementsByTagName('body')->item(0); | |
| if (!$body instanceof \DOMElement) { | |
| return null; | |
| } | |
| $figure = $this->firstChildElement($body, 'figure'); | |
| if (!$figure instanceof \DOMElement) { | |
| return null; | |
| } | |
| return $this->buildHtmlFigureNode($figure); | |
| } | |
| private function parseHtmlListBlock(string $html, string $tag): ?AstNode | |
| { | |
| $previous = libxml_use_internal_errors(true); | |
| $dom = new \DOMDocument('1.0', 'UTF-8'); | |
| $loaded = $dom->loadHTML( | |
| '<?xml encoding="UTF-8"><!doctype html><html><body>' . $html . '</body></html>', | |
| LIBXML_NONET | LIBXML_NOERROR | LIBXML_NOWARNING | |
| ); | |
| libxml_clear_errors(); | |
| libxml_use_internal_errors($previous); | |
| if (!$loaded) { | |
| return null; | |
| } | |
| $body = $dom->getElementsByTagName('body')->item(0); | |
| if (!$body instanceof \DOMElement) { | |
| return null; | |
| } | |
| $list = $this->firstChildElement($body, $tag); | |
| if (!$list instanceof \DOMElement) { | |
| return null; | |
| } | |
| return $this->parseHtmlListElement($list); | |
| } | |
| private function parseHtmlDefinitionListBlock(string $html): ?AstNode | |
| { | |
| $previous = libxml_use_internal_errors(true); | |
| $dom = new \DOMDocument('1.0', 'UTF-8'); | |
| $loaded = $dom->loadHTML( | |
| '<?xml encoding="UTF-8"><!doctype html><html><body>' . $html . '</body></html>', | |
| LIBXML_NONET | LIBXML_NOERROR | LIBXML_NOWARNING | |
| ); | |
| libxml_clear_errors(); | |
| libxml_use_internal_errors($previous); | |
| if (!$loaded) { | |
| return null; | |
| } | |
| $body = $dom->getElementsByTagName('body')->item(0); | |
| if (!$body instanceof \DOMElement) { | |
| return null; | |
| } | |
| $list = $this->firstChildElement($body, 'dl'); | |
| if (!$list instanceof \DOMElement) { | |
| return null; | |
| } | |
| return $this->parseHtmlDefinitionListElement($list); | |
| } | |
| private function buildHtmlBlockQuoteNode(\DOMElement $quote): AstNode | |
| { | |
| return new AstNode('blockquote', $this->htmlElementPandocAttrs($quote), $this->parseHtmlBlockChildren($quote)); | |
| } | |
| /** | |
| * @return list<AstNode> | |
| */ | |
| private function parseHtmlBlockChildren(\DOMElement $element): array | |
| { | |
| $blocks = []; | |
| $inlines = []; | |
| foreach ($element->childNodes as $child) { | |
| if ($child instanceof \DOMElement && $this->isHtmlFootnoteContainer($child)) { | |
| continue; | |
| } | |
| if ($child instanceof \DOMElement && $this->isHtmlBlockElement($child)) { | |
| $this->flushHtmlInlineParagraph($inlines, $blocks); | |
| $block = $this->parseHtmlBlockElement($child); | |
| if ($block instanceof AstNode) { | |
| $blocks[] = $block; | |
| } | |
| continue; | |
| } | |
| $this->appendHtmlInlineNodes($inlines, $this->parseHtmlInlineNode($child)); | |
| } | |
| $this->flushHtmlInlineParagraph($inlines, $blocks); | |
| return $blocks; | |
| } | |
| private function isHtmlBlockElement(\DOMElement $element): bool | |
| { | |
| return in_array(strtolower($element->localName), [ | |
| 'blockquote', | |
| 'div', | |
| 'dl', | |
| 'figure', | |
| 'iframe', | |
| ...($this->htmlNativeDivsEnabled() ? ['aside', 'header', 'main', 'section'] : []), | |
| 'h1', | |
| 'h2', | |
| 'h3', | |
| 'h4', | |
| 'h5', | |
| 'h6', | |
| 'hr', | |
| 'ol', | |
| 'p', | |
| 'pre', | |
| 'script', | |
| 'style', | |
| 'table', | |
| 'textarea', | |
| 'ul', | |
| ], true); | |
| } | |
| private function parseHtmlBlockElement(\DOMElement $element): ?AstNode | |
| { | |
| $name = strtolower($element->localName); | |
| if ($name === 'p') { | |
| return $this->buildHtmlParagraphNode($element); | |
| } | |
| if ($name === 'pre') { | |
| return $this->buildHtmlPreCodeBlock($element); | |
| } | |
| if ($name === 'blockquote') { | |
| return $this->buildHtmlBlockQuoteNode($element); | |
| } | |
| if ($name === 'ol' || $name === 'ul') { | |
| return $this->parseHtmlListElement($element); | |
| } | |
| if ($name === 'dl') { | |
| return $this->parseHtmlDefinitionListElement($element); | |
| } | |
| if ($name === 'table') { | |
| return $this->parseHtmlTableElement($element); | |
| } | |
| if ($name === 'figure') { | |
| return $this->buildHtmlFigureNode($element); | |
| } | |
| if ($name === 'iframe') { | |
| return $this->buildHtmlIframeNode($element); | |
| } | |
| if ($name === 'script') { | |
| $math = $this->buildHtmlScriptMathNode($element); | |
| if ($math instanceof AstNode) { | |
| return new AstNode('plain', ['text' => (string) $math->attr('text', '')], [$math]); | |
| } | |
| if (!$this->htmlRawHtmlEnabled()) { | |
| return null; | |
| } | |
| return $this->buildHtmlRawBlockNode($element); | |
| } | |
| if ($name === 'textarea') { | |
| if (!$this->htmlRawHtmlEnabled()) { | |
| return null; | |
| } | |
| return $this->buildHtmlRawBlockNode($element); | |
| } | |
| if ($name === 'style') { | |
| if (!$this->htmlRawHtmlEnabled()) { | |
| return null; | |
| } | |
| return $this->buildHtmlRawBlockNode($element); | |
| } | |
| if ($name === 'div' && $this->isHtmlLineBlockDiv($element)) { | |
| return $this->buildHtmlLineBlockNode($element); | |
| } | |
| if ($name === 'div') { | |
| return new AstNode('div', $this->htmlElementPandocAttrs($element), $this->parseHtmlBlockChildren($element)); | |
| } | |
| if ($this->htmlNativeDivsEnabled() && in_array($name, ['aside', 'header', 'main', 'section'], true)) { | |
| return $this->buildHtmlNativeDivNode($element); | |
| } | |
| if ($name === 'hr') { | |
| return new AstNode('horizontal_rule'); | |
| } | |
| if (preg_match('/^h([1-6])$/', $name, $m) === 1) { | |
| return $this->buildHtmlHeadingNode($element, (int) $m[1]); | |
| } | |
| $children = $this->parseHtmlInlineChildren($element); | |
| if ($children === []) { | |
| return null; | |
| } | |
| return new AstNode( | |
| 'paragraph', | |
| ['text' => $this->plainTextFromInlines($children)], | |
| $children | |
| ); | |
| } | |
| private function buildHtmlParagraphNode(\DOMElement $paragraph): AstNode | |
| { | |
| $children = $this->parseHtmlInlineChildren($paragraph); | |
| return new AstNode( | |
| 'paragraph', | |
| ['text' => $this->plainTextFromInlines($children)], | |
| $children | |
| ); | |
| } | |
| private function isHtmlLineBlockDiv(\DOMElement $element): bool | |
| { | |
| if (strtolower($element->localName) !== 'div' || trim($element->getAttribute('class')) !== 'line-block') { | |
| return false; | |
| } | |
| foreach ($element->attributes as $attribute) { | |
| if ($attribute instanceof \DOMAttr && strtolower($attribute->name) !== 'class') { | |
| return false; | |
| } | |
| } | |
| return true; | |
| } | |
| private function buildHtmlLineBlockNode(\DOMElement $element): AstNode | |
| { | |
| return $this->buildHtmlLineBlockFromInlines($this->parseHtmlInlineChildren($element)); | |
| } | |
| /** | |
| * @param list<AstNode> $inlines | |
| */ | |
| private function buildHtmlLineBlockFromInlines(array $inlines): AstNode | |
| { | |
| $lines = []; | |
| $current = []; | |
| $lastWasLineBreak = false; | |
| foreach ($this->trimHtmlLineBlockInlines($inlines) as $inline) { | |
| if ($inline->type === 'softbreak') { | |
| continue; | |
| } | |
| if ($inline->type === 'linebreak') { | |
| $lines[] = $this->buildHtmlLineBlockLine($current); | |
| $current = []; | |
| $lastWasLineBreak = true; | |
| continue; | |
| } | |
| $current[] = $inline; | |
| $lastWasLineBreak = false; | |
| } | |
| if ($current !== [] || !$lastWasLineBreak || $lines === []) { | |
| $lines[] = $this->buildHtmlLineBlockLine($current); | |
| } | |
| return new AstNode('line_block', [], $lines); | |
| } | |
| /** | |
| * @param list<AstNode> $inlines | |
| */ | |
| private function buildHtmlLineBlockLine(array $inlines): AstNode | |
| { | |
| return new AstNode('line', ['text' => $this->plainTextFromInlines($inlines)], $inlines); | |
| } | |
| /** | |
| * @param list<AstNode> $inlines | |
| * @return list<AstNode> | |
| */ | |
| private function trimHtmlLineBlockInlines(array $inlines): array | |
| { | |
| while ($inlines !== []) { | |
| $first = $inlines[array_key_first($inlines)]; | |
| if ($first->type !== 'text') { | |
| break; | |
| } | |
| $text = ltrim((string) $first->attr('text', ''), " \t\r\n"); | |
| if ($text !== '') { | |
| $inlines[array_key_first($inlines)] = new AstNode('text', array_merge($first->attrs, ['text' => $text]), $first->children); | |
| break; | |
| } | |
| array_shift($inlines); | |
| } | |
| while ($inlines !== []) { | |
| $lastKey = array_key_last($inlines); | |
| $last = $inlines[$lastKey]; | |
| if ($last->type !== 'text') { | |
| break; | |
| } | |
| $text = rtrim((string) $last->attr('text', ''), " \t\r\n"); | |
| if ($text !== '') { | |
| $inlines[$lastKey] = new AstNode('text', array_merge($last->attrs, ['text' => $text]), $last->children); | |
| break; | |
| } | |
| array_pop($inlines); | |
| } | |
| return array_values($inlines); | |
| } | |
| private function buildHtmlFigureNode(\DOMElement $figure): AstNode | |
| { | |
| $captionBlocks = []; | |
| $bodyBlocks = []; | |
| $inlines = []; | |
| foreach ($figure->childNodes as $child) { | |
| if ($child instanceof \DOMElement && strtolower($child->localName) === 'figcaption') { | |
| $this->flushHtmlFigureInlines($inlines, $bodyBlocks); | |
| array_push($captionBlocks, ...$this->parseHtmlFigureCaptionBlocks($child)); | |
| continue; | |
| } | |
| if ($child instanceof \DOMElement && $this->isHtmlBlockElement($child)) { | |
| $this->flushHtmlFigureInlines($inlines, $bodyBlocks); | |
| $block = $this->parseHtmlBlockElement($child); | |
| if ($block instanceof AstNode) { | |
| $bodyBlocks[] = $block; | |
| } | |
| continue; | |
| } | |
| $this->appendHtmlInlineNodes($inlines, $this->parseHtmlInlineNode($child)); | |
| } | |
| $this->flushHtmlFigureInlines($inlines, $bodyBlocks); | |
| $attrs = $this->htmlElementPandocAttrs($figure); | |
| $attrs['caption'] = $this->plainTextFromBlocks($captionBlocks); | |
| if ($captionBlocks !== []) { | |
| $attrs['captionBlocks'] = $captionBlocks; | |
| $captionInlines = $this->captionInlinesFromBlocks($captionBlocks); | |
| if ($captionInlines !== []) { | |
| $attrs['captionInlines'] = $captionInlines; | |
| } | |
| } | |
| return new AstNode('figure', $attrs, $bodyBlocks); | |
| } | |
| private function buildHtmlIframeNode(\DOMElement $iframe): ?AstNode | |
| { | |
| $rawUrl = trim($iframe->getAttribute('src')); | |
| if ($rawUrl === '') { | |
| return null; | |
| } | |
| $url = $this->resolveHtmlUrl($rawUrl); | |
| $resource = $this->htmlIframeResource($url) ?? $this->htmlIframeResource($rawUrl); | |
| if ($resource === null) { | |
| return null; | |
| } | |
| $mime = strtolower(trim($resource['mime'])); | |
| if (str_starts_with($mime, 'text/html')) { | |
| $document = $this->parseHtmlDocument($resource['body']); | |
| return new AstNode('div', $this->htmlIframeDivAttrs(), $document?->children ?? []); | |
| } | |
| if (str_starts_with($mime, 'image/')) { | |
| return new AstNode('div', $this->htmlIframeDivAttrs(), [ | |
| new AstNode('plain', [], [ | |
| new AstNode('image', [ | |
| 'url' => $url, | |
| 'title' => '', | |
| 'alt' => '', | |
| ]), | |
| ]), | |
| ]); | |
| } | |
| return new AstNode('div', $this->htmlIframeDivAttrs(['src' => $url])); | |
| } | |
| /** | |
| * @return array{mime:string, body:string}|null | |
| */ | |
| private function htmlIframeResource(string $url): ?array | |
| { | |
| $resources = $this->options['htmlIframeResources'] ?? []; | |
| if (!is_array($resources) || !array_key_exists($url, $resources)) { | |
| return null; | |
| } | |
| $resource = $resources[$url]; | |
| if (is_string($resource)) { | |
| return [ | |
| 'mime' => 'text/html', | |
| 'body' => $resource, | |
| ]; | |
| } | |
| if (!is_array($resource)) { | |
| return null; | |
| } | |
| $body = $resource['body'] ?? $resource['content'] ?? ''; | |
| if (!is_string($body)) { | |
| return null; | |
| } | |
| $mime = $resource['mime'] ?? $resource['contentType'] ?? 'application/octet-stream'; | |
| if (!is_string($mime) || trim($mime) === '') { | |
| $mime = 'application/octet-stream'; | |
| } | |
| return [ | |
| 'mime' => $mime, | |
| 'body' => $body, | |
| ]; | |
| } | |
| /** | |
| * @param array<string, string> $attributes | |
| * @return array<string, mixed> | |
| */ | |
| private function htmlIframeDivAttrs(array $attributes = []): array | |
| { | |
| $htmlAttributes = ['class' => 'iframe']; | |
| foreach ($attributes as $name => $value) { | |
| $htmlAttributes[$name] = $value; | |
| } | |
| $attrs = [ | |
| 'classes' => ['iframe'], | |
| 'htmlAttributes' => $htmlAttributes, | |
| ]; | |
| if ($attributes !== []) { | |
| $attrs['attributes'] = $attributes; | |
| } | |
| return $attrs; | |
| } | |
| /** | |
| * @return list<AstNode> | |
| */ | |
| private function parseHtmlFigureCaptionBlocks(\DOMElement $caption): array | |
| { | |
| $blocks = []; | |
| $inlines = []; | |
| foreach ($caption->childNodes as $child) { | |
| if ($child instanceof \DOMElement && $this->isHtmlBlockElement($child)) { | |
| $this->flushHtmlFigureInlines($inlines, $blocks); | |
| $block = $this->parseHtmlBlockElement($child); | |
| if ($block instanceof AstNode) { | |
| $blocks[] = $block; | |
| } | |
| continue; | |
| } | |
| $this->appendHtmlInlineNodes($inlines, $this->parseHtmlInlineNode($child)); | |
| } | |
| $this->flushHtmlFigureInlines($inlines, $blocks); | |
| return $blocks; | |
| } | |
| /** | |
| * @param list<AstNode> $inlines | |
| * @param list<AstNode> $blocks | |
| */ | |
| private function flushHtmlFigureInlines(array &$inlines, array &$blocks): void | |
| { | |
| $text = trim(preg_replace('/\s+/', ' ', $this->plainTextFromInlines($inlines)) ?? ''); | |
| if ($text !== '' || $this->htmlInlinesContainNonText($inlines)) { | |
| $blocks[] = new AstNode('plain', ['text' => $text], $inlines); | |
| } | |
| $inlines = []; | |
| } | |
| /** | |
| * @param list<AstNode> $inlines | |
| */ | |
| private function htmlInlinesContainNonText(array $inlines): bool | |
| { | |
| foreach ($inlines as $inline) { | |
| if (!in_array($inline->type, ['text', 'softbreak', 'linebreak'], true)) { | |
| return true; | |
| } | |
| } | |
| return false; | |
| } | |
| /** | |
| * @param list<AstNode> $blocks | |
| */ | |
| private function plainTextFromBlocks(array $blocks): string | |
| { | |
| $parts = []; | |
| foreach ($blocks as $block) { | |
| $text = $block->children === [] | |
| ? (string) $block->attr('text', '') | |
| : $this->plainTextFromInlines($block->children); | |
| $text = trim(preg_replace('/\s+/', ' ', $text) ?? $text); | |
| if ($text !== '') { | |
| $parts[] = $text; | |
| } | |
| } | |
| return implode(' ', $parts); | |
| } | |
| /** | |
| * @param list<AstNode> $blocks | |
| * @return list<AstNode> | |
| */ | |
| private function captionInlinesFromBlocks(array $blocks): array | |
| { | |
| $inlines = []; | |
| foreach ($blocks as $index => $block) { | |
| if ($index > 0) { | |
| $inlines[] = new AstNode('softbreak'); | |
| } | |
| if ($block->children !== []) { | |
| array_push($inlines, ...$block->children); | |
| continue; | |
| } | |
| $text = (string) $block->attr('text', ''); | |
| if ($text !== '') { | |
| $inlines[] = new AstNode('text', ['text' => $text]); | |
| } | |
| } | |
| return $inlines; | |
| } | |
| private function buildHtmlNativeHeaderDivNode(\DOMElement $header): AstNode | |
| { | |
| return $this->buildHtmlNativeDivNode($header); | |
| } | |
| private function buildHtmlNativeDivNode(\DOMElement $element): AstNode | |
| { | |
| $attrs = $this->htmlNativeDivAttrs($element); | |
| return new AstNode( | |
| 'div', | |
| $attrs, | |
| $this->htmlNativeDivChildren($element, (string) ($attrs['id'] ?? '')) | |
| ); | |
| } | |
| /** | |
| * @return array<string, mixed> | |
| */ | |
| private function htmlNativeDivAttrs(\DOMElement $element): array | |
| { | |
| return match (strtolower($element->localName)) { | |
| 'aside' => $this->htmlNativeClassedDivAttrs($element, 'aside'), | |
| 'header' => $this->htmlNativeHeaderAttrs($element), | |
| 'main' => $this->htmlNativeMainAttrs($element), | |
| 'section' => $this->htmlNativeClassedDivAttrs($element, 'section'), | |
| default => $this->htmlElementPandocAttrs($element), | |
| }; | |
| } | |
| /** | |
| * @return list<AstNode> | |
| */ | |
| private function htmlNativeDivChildren(\DOMElement $element, string $containerId): array | |
| { | |
| $children = $this->parseHtmlBlockChildren($element); | |
| $first = $children[0] ?? null; | |
| if ($containerId === '' || !$first instanceof AstNode || $first->type !== 'heading' || $first->attr('id', '') !== $containerId) { | |
| return $children; | |
| } | |
| $attrs = $first->attrs; | |
| unset($attrs['id']); | |
| $htmlAttributes = $attrs['htmlAttributes'] ?? null; | |
| if (is_array($htmlAttributes)) { | |
| unset($htmlAttributes['id']); | |
| if ($htmlAttributes === []) { | |
| unset($attrs['htmlAttributes']); | |
| } else { | |
| $attrs['htmlAttributes'] = $htmlAttributes; | |
| } | |
| } | |
| $children[0] = new AstNode($first->type, $attrs, $first->children); | |
| return $children; | |
| } | |
| /** | |
| * @return array<string, mixed> | |
| */ | |
| private function htmlNativeClassedDivAttrs(\DOMElement $element, string $nativeClass): array | |
| { | |
| $attrs = $this->htmlElementPandocAttrs($element); | |
| $classes = $attrs['classes'] ?? []; | |
| if (!is_array($classes)) { | |
| $classes = []; | |
| } | |
| if (!in_array($nativeClass, $classes, true)) { | |
| array_unshift($classes, $nativeClass); | |
| } | |
| $attrs['classes'] = $classes; | |
| $htmlAttributes = $attrs['htmlAttributes'] ?? []; | |
| if (!is_array($htmlAttributes)) { | |
| $htmlAttributes = []; | |
| } | |
| $orderedHtmlAttributes = []; | |
| if (isset($htmlAttributes['id'])) { | |
| $orderedHtmlAttributes['id'] = $htmlAttributes['id']; | |
| } | |
| $orderedHtmlAttributes['class'] = implode(' ', $classes); | |
| foreach ($htmlAttributes as $name => $value) { | |
| if ($name === 'id' || $name === 'class') { | |
| continue; | |
| } | |
| $orderedHtmlAttributes[$name] = $value; | |
| } | |
| $attrs['htmlAttributes'] = $orderedHtmlAttributes; | |
| return $attrs; | |
| } | |
| /** | |
| * @return array<string, mixed> | |
| */ | |
| private function htmlNativeHeaderAttrs(\DOMElement $header): array | |
| { | |
| $attrs = $this->htmlElementPandocAttrs($header); | |
| $classes = $attrs['classes'] ?? []; | |
| if (!is_array($classes)) { | |
| $classes = []; | |
| } | |
| if (!in_array('header', $classes, true)) { | |
| $classes[] = 'header'; | |
| } | |
| $attrs['classes'] = $classes; | |
| $htmlAttributes = $attrs['htmlAttributes'] ?? []; | |
| if (!is_array($htmlAttributes)) { | |
| $htmlAttributes = []; | |
| } | |
| $orderedHtmlAttributes = []; | |
| if (isset($htmlAttributes['id'])) { | |
| $orderedHtmlAttributes['id'] = $htmlAttributes['id']; | |
| } | |
| $orderedHtmlAttributes['class'] = implode(' ', $classes); | |
| foreach ($htmlAttributes as $name => $value) { | |
| if ($name === 'id' || $name === 'class') { | |
| continue; | |
| } | |
| $orderedHtmlAttributes[$name] = $value; | |
| } | |
| $attrs['htmlAttributes'] = $orderedHtmlAttributes; | |
| return $attrs; | |
| } | |
| /** | |
| * @param list<AstNode> $inlines | |
| * @param list<AstNode> $blocks | |
| */ | |
| private function flushHtmlInlineParagraph(array &$inlines, array &$blocks): void | |
| { | |
| $text = $this->plainTextFromInlines($inlines); | |
| $normalized = trim(preg_replace('/\s+/', ' ', $text) ?? $text); | |
| if ($normalized !== '' || $this->htmlInlineParagraphHasLineBreak($inlines)) { | |
| $blocks[] = new AstNode('paragraph', ['text' => $normalized], $inlines); | |
| } | |
| $inlines = []; | |
| } | |
| /** | |
| * @param list<AstNode> $inlines | |
| */ | |
| private function htmlInlineParagraphHasLineBreak(array $inlines): bool | |
| { | |
| foreach ($inlines as $inline) { | |
| if ($inline->type === 'linebreak') { | |
| return true; | |
| } | |
| } | |
| return false; | |
| } | |
| private function parseHtmlListElement(\DOMElement $list): AstNode | |
| { | |
| $ordered = strtolower($list->localName) === 'ol'; | |
| $items = []; | |
| $loose = false; | |
| $leadingOrphans = []; | |
| foreach ($list->childNodes as $child) { | |
| if ($child instanceof \DOMElement && strtolower($child->localName) === 'li') { | |
| if ($leadingOrphans !== []) { | |
| $items[] = $this->buildHtmlListItemNode($leadingOrphans); | |
| $leadingOrphans = []; | |
| } | |
| $children = $this->parseHtmlListItemChildren($child); | |
| [$children, $taskChecked] = $this->extractHtmlListItemTaskMarker($children); | |
| $children = $this->applyHtmlListItemId($child, $children); | |
| $items[] = $this->buildHtmlListItemNode($children, $taskChecked); | |
| continue; | |
| } | |
| $orphans = $this->parseHtmlListOrphanChild($child); | |
| if ($orphans === []) { | |
| continue; | |
| } | |
| $lastIndex = array_key_last($items); | |
| if ($lastIndex === null) { | |
| array_push($leadingOrphans, ...$orphans); | |
| continue; | |
| } | |
| $item = $items[$lastIndex]; | |
| $taskChecked = $item->attr('taskChecked', null); | |
| $items[$lastIndex] = $this->buildHtmlListItemNode( | |
| [...$item->children, ...$orphans], | |
| is_bool($taskChecked) ? $taskChecked : null | |
| ); | |
| } | |
| if ($leadingOrphans !== []) { | |
| $items[] = $this->buildHtmlListItemNode($leadingOrphans); | |
| } | |
| foreach ($items as $item) { | |
| $loose = $loose || (bool) $item->attr('loose', false); | |
| } | |
| $attrs = ['loose' => $loose]; | |
| if ($ordered) { | |
| $attrs = array_merge($attrs, $this->htmlOrderedListAttrs($list)); | |
| } elseif ($this->allListItemsAreTasks($items)) { | |
| $attrs['taskList'] = true; | |
| } | |
| return new AstNode($ordered ? 'ordered_list' : 'bullet_list', $attrs, $items); | |
| } | |
| /** | |
| * @param list<AstNode> $children | |
| */ | |
| private function buildHtmlListItemNode(array $children, ?bool $taskChecked = null): AstNode | |
| { | |
| $itemLoose = $this->htmlListItemIsLoose($children); | |
| $attrs = [ | |
| 'text' => $this->plainTextFromListItemChildren($children), | |
| 'loose' => $itemLoose, | |
| ]; | |
| if ($taskChecked !== null) { | |
| $attrs['taskChecked'] = $taskChecked; | |
| } | |
| return new AstNode('list_item', $attrs, $children); | |
| } | |
| /** | |
| * @return list<AstNode> | |
| */ | |
| private function parseHtmlListOrphanChild(\DOMNode $child): array | |
| { | |
| if ($child instanceof \DOMText && trim($child->wholeText) === '') { | |
| return []; | |
| } | |
| if ($child instanceof \DOMElement && $this->isHtmlFootnoteContainer($child)) { | |
| return []; | |
| } | |
| if ($child instanceof \DOMElement && $this->isHtmlBlockElement($child)) { | |
| $block = $this->parseHtmlBlockElement($child); | |
| return $block instanceof AstNode ? [$block] : []; | |
| } | |
| $inlines = $this->parseHtmlInlineNode($child); | |
| $text = $this->plainTextFromInlines($inlines); | |
| if (trim(preg_replace('/\s+/', ' ', $text) ?? $text) === '') { | |
| return []; | |
| } | |
| return $inlines; | |
| } | |
| /** | |
| * @param list<AstNode> $children | |
| * @return list<AstNode> | |
| */ | |
| private function applyHtmlListItemId(\DOMElement $item, array $children): array | |
| { | |
| $id = trim($item->getAttribute('id')); | |
| if ($id === '') { | |
| return $children; | |
| } | |
| $inlineRun = []; | |
| $cursor = 0; | |
| $count = count($children); | |
| while ($cursor < $count && $this->htmlListChildIsInline($children[$cursor])) { | |
| $inlineRun[] = $children[$cursor]; | |
| $cursor++; | |
| } | |
| if ($inlineRun !== []) { | |
| return [ | |
| new AstNode('span', [ | |
| 'id' => $id, | |
| 'htmlAttributes' => ['id' => $id], | |
| ], $inlineRun), | |
| ...array_slice($children, $cursor), | |
| ]; | |
| } | |
| return [new AstNode('div', [ | |
| 'id' => $id, | |
| 'htmlAttributes' => ['id' => $id], | |
| ], $children)]; | |
| } | |
| private function parseHtmlDefinitionListElement(\DOMElement $list): AstNode | |
| { | |
| $items = []; | |
| $termInlines = []; | |
| $termTexts = []; | |
| $definitions = []; | |
| foreach ($list->childNodes as $child) { | |
| if (!$child instanceof \DOMElement) { | |
| continue; | |
| } | |
| $name = strtolower($child->localName); | |
| if ($name === 'dt') { | |
| if ($termInlines !== [] && $definitions !== []) { | |
| $this->flushHtmlDefinitionItem($items, $termInlines, $termTexts, $definitions); | |
| } elseif ($termInlines !== []) { | |
| $termInlines[] = new AstNode('linebreak'); | |
| } | |
| $inlines = $this->parseHtmlInlineChildren($child); | |
| array_push($termInlines, ...$inlines); | |
| $termTexts[] = trim(preg_replace('/\s+/', ' ', $this->plainTextFromInlines($inlines)) ?? ''); | |
| continue; | |
| } | |
| if ($name === 'dd' && $termInlines !== []) { | |
| $definitions[] = $this->buildHtmlDefinitionNode($child); | |
| } | |
| } | |
| $this->flushHtmlDefinitionItem($items, $termInlines, $termTexts, $definitions); | |
| return new AstNode('definition_list', $this->htmlElementPandocAttrs($list), $items); | |
| } | |
| /** | |
| * @param list<AstNode> $items | |
| * @param list<AstNode> $termInlines | |
| * @param list<string> $termTexts | |
| * @param list<AstNode> $definitions | |
| */ | |
| private function flushHtmlDefinitionItem( | |
| array &$items, | |
| array &$termInlines, | |
| array &$termTexts, | |
| array &$definitions | |
| ): void { | |
| if ($termInlines === []) { | |
| $termTexts = []; | |
| $definitions = []; | |
| return; | |
| } | |
| $termText = implode("\n", $termTexts); | |
| $term = new AstNode('term', ['text' => $termText], $termInlines); | |
| $items[] = new AstNode('definition_item', ['term' => $termText], array_merge([$term], $definitions)); | |
| $termInlines = []; | |
| $termTexts = []; | |
| $definitions = []; | |
| } | |
| private function buildHtmlDefinitionNode(\DOMElement $definition): AstNode | |
| { | |
| return new AstNode('definition', ['loose' => false], $this->parseHtmlBlockChildren($definition)); | |
| } | |
| /** | |
| * @return list<AstNode> | |
| */ | |
| private function parseHtmlListItemChildren(\DOMElement $item): array | |
| { | |
| $children = []; | |
| $inlines = []; | |
| foreach ($item->childNodes as $child) { | |
| if ($child instanceof \DOMElement && $this->isHtmlBlockElement($child)) { | |
| $this->flushHtmlListItemInlines($inlines, $children); | |
| $block = $this->parseHtmlBlockElement($child); | |
| if ($block instanceof AstNode) { | |
| $children[] = $block; | |
| } | |
| continue; | |
| } | |
| $this->appendHtmlInlineNodes($inlines, $this->parseHtmlInlineNode($child)); | |
| } | |
| $this->flushHtmlListItemInlines($inlines, $children); | |
| return $children; | |
| } | |
| /** | |
| * @param list<AstNode> $children | |
| */ | |
| private function htmlListItemIsLoose(array $children): bool | |
| { | |
| foreach ($children as $child) { | |
| if ($child->type === 'paragraph') { | |
| return true; | |
| } | |
| if (!$this->htmlListChildIsInline($child) && !in_array($child->type, ['bullet_list', 'ordered_list'], true)) { | |
| return true; | |
| } | |
| } | |
| return false; | |
| } | |
| private function htmlListChildIsInline(AstNode $node): bool | |
| { | |
| return in_array($node->type, [ | |
| 'text', | |
| 'emph', | |
| 'strong', | |
| 'small_caps', | |
| 'underline', | |
| 'strikeout', | |
| 'superscript', | |
| 'subscript', | |
| 'softbreak', | |
| 'linebreak', | |
| 'span', | |
| 'quoted', | |
| 'math', | |
| 'raw_tex', | |
| 'code', | |
| 'link', | |
| 'image', | |
| 'note', | |
| ], true); | |
| } | |
| /** | |
| * @param list<AstNode> $inlines | |
| * @param list<AstNode> $children | |
| */ | |
| private function flushHtmlListItemInlines(array &$inlines, array &$children): void | |
| { | |
| $text = $this->plainTextFromInlines($inlines); | |
| if (trim(preg_replace('/\s+/', ' ', $text) ?? $text) !== '') { | |
| array_push($children, ...$inlines); | |
| } | |
| $inlines = []; | |
| } | |
| /** | |
| * @param list<AstNode> $children | |
| * @return array{0:list<AstNode>, 1:bool|null} | |
| */ | |
| private function extractHtmlListItemTaskMarker(array $children): array | |
| { | |
| if ($children === []) { | |
| return [$children, null]; | |
| } | |
| $first = $children[0]; | |
| if ($first->type === 'paragraph' || $first->type === 'plain') { | |
| [$inlines, $taskChecked] = $this->stripLeadingHtmlTaskGlyph($first->children); | |
| if ($taskChecked === null) { | |
| return [$children, null]; | |
| } | |
| $attrs = array_merge($first->attrs, ['text' => $this->plainTextFromInlines($inlines)]); | |
| $children[0] = new AstNode($first->type, $attrs, $inlines); | |
| return [$children, $taskChecked]; | |
| } | |
| return $this->stripLeadingHtmlTaskGlyph($children); | |
| } | |
| /** | |
| * @param list<AstNode> $nodes | |
| * @return array{0:list<AstNode>, 1:bool|null} | |
| */ | |
| private function stripLeadingHtmlTaskGlyph(array $nodes): array | |
| { | |
| $taskChecked = null; | |
| $strippedGlyph = false; | |
| $stripFollowingSpace = false; | |
| $result = []; | |
| foreach ($nodes as $node) { | |
| if (!$strippedGlyph && $node->type === 'text' && $node->attr('htmlCheckboxMarker', false) === true) { | |
| $text = (string) $node->attr('text', ''); | |
| if (preg_match('/^(\x{2610}|\x{2612})(.*)$/u', $text, $m) === 1) { | |
| $taskChecked = $m[1] === "\u{2612}"; | |
| $strippedGlyph = true; | |
| $stripFollowingSpace = $m[2] === ''; | |
| $rest = ltrim($m[2]); | |
| if ($rest !== '') { | |
| $result[] = new AstNode('text', array_merge($node->attrs, ['text' => $rest]), $node->children); | |
| } | |
| continue; | |
| } | |
| } | |
| if ($stripFollowingSpace && $node->type === 'text') { | |
| $text = ltrim((string) $node->attr('text', '')); | |
| $stripFollowingSpace = false; | |
| if ($text === '') { | |
| continue; | |
| } | |
| $result[] = new AstNode('text', array_merge($node->attrs, ['text' => $text]), $node->children); | |
| continue; | |
| } | |
| $stripFollowingSpace = false; | |
| $result[] = $node; | |
| } | |
| return [$taskChecked === null ? $nodes : $result, $taskChecked]; | |
| } | |
| /** | |
| * @param list<AstNode> $children | |
| */ | |
| private function plainTextFromListItemChildren(array $children): string | |
| { | |
| $parts = []; | |
| foreach ($children as $child) { | |
| if ($this->htmlListChildIsInline($child)) { | |
| $parts[] = $this->plainTextFromInlines([$child]); | |
| continue; | |
| } | |
| $parts[] = (string) $child->attr('text', $this->plainTextFromInlines($child->children)); | |
| } | |
| return trim(preg_replace('/\s+/', ' ', implode(' ', array_filter($parts, static fn (string $part): bool => $part !== ''))) ?? ''); | |
| } | |
| /** | |
| * @return array{start:int, style:string, delimiter:string} | |
| */ | |
| private function htmlOrderedListAttrs(\DOMElement $list): array | |
| { | |
| $start = 1; | |
| $rawStart = trim($list->getAttribute('start')); | |
| if (preg_match('/^\d+$/', $rawStart) === 1 && (int) $rawStart > 0) { | |
| $start = (int) $rawStart; | |
| } | |
| return [ | |
| 'start' => $start, | |
| 'style' => $this->htmlOrderedListStyle($list), | |
| 'delimiter' => 'default', | |
| ]; | |
| } | |
| private function htmlOrderedListStyle(\DOMElement $list): string | |
| { | |
| $type = trim($list->getAttribute('type')); | |
| if ($type === 'a') { | |
| return 'lower_alpha'; | |
| } | |
| if ($type === 'A') { | |
| return 'upper_alpha'; | |
| } | |
| if ($type === 'i') { | |
| return 'lower_roman'; | |
| } | |
| if ($type === 'I') { | |
| return 'upper_roman'; | |
| } | |
| foreach (preg_split('/\s+/', strtolower(trim($list->getAttribute('class'))), -1, PREG_SPLIT_NO_EMPTY) ?: [] as $class) { | |
| $mapped = $this->htmlOrderedListStyleName($class); | |
| if ($mapped !== null) { | |
| return $mapped; | |
| } | |
| } | |
| foreach (preg_split('/;/', strtolower($list->getAttribute('style'))) ?: [] as $declaration) { | |
| if (preg_match('/^\s*list-style(?:-type)?\s*:\s*([a-z-]+)\b/', $declaration, $m) !== 1) { | |
| continue; | |
| } | |
| $mapped = $this->htmlOrderedListStyleName($m[1]); | |
| if ($mapped !== null) { | |
| return $mapped; | |
| } | |
| } | |
| return 'default'; | |
| } | |
| private function htmlOrderedListStyleName(string $style): ?string | |
| { | |
| return match ($style) { | |
| 'decimal' => 'decimal', | |
| 'lower-alpha' => 'lower_alpha', | |
| 'upper-alpha' => 'upper_alpha', | |
| 'lower-roman' => 'lower_roman', | |
| 'upper-roman' => 'upper_roman', | |
| default => null, | |
| }; | |
| } | |
| /** | |
| * @return list<string> | |
| */ | |
| private function htmlCodeBlockClasses(\DOMElement $code): array | |
| { | |
| $classes = []; | |
| foreach (preg_split('/\s+/', trim($code->getAttribute('class')), -1, PREG_SPLIT_NO_EMPTY) ?: [] as $class) { | |
| $class = trim($class); | |
| if ($class === '') { | |
| continue; | |
| } | |
| if (str_starts_with($class, 'language-')) { | |
| $class = substr($class, strlen('language-')); | |
| } | |
| if ($class !== '') { | |
| $classes[] = $class; | |
| } | |
| } | |
| return $classes; | |
| } | |
| /** | |
| * @return array<string, mixed> | |
| */ | |
| private function htmlCodeBlockAttrs(\DOMElement $code): array | |
| { | |
| $attrs = $this->htmlElementPandocAttrs($code); | |
| $classes = $this->htmlCodeBlockClasses($code); | |
| if ($classes !== []) { | |
| $attrs['classes'] = $classes; | |
| $htmlAttributes = $attrs['htmlAttributes'] ?? []; | |
| if (!is_array($htmlAttributes)) { | |
| $htmlAttributes = []; | |
| } | |
| $htmlAttributes['class'] = implode(' ', $classes); | |
| $attrs['htmlAttributes'] = $htmlAttributes; | |
| } | |
| return $attrs; | |
| } | |
| /** | |
| * @param list<string> $lines | |
| * @return array{0:string, 1:int}|null | |
| */ | |
| private function collectBalancedHtmlTableBlock(array $lines, int $index): ?array | |
| { | |
| $content = []; | |
| $depth = 0; | |
| $started = false; | |
| $count = count($lines); | |
| for ($cursor = $index; $cursor < $count; $cursor++) { | |
| $line = $this->normalizeRawHtmlLine($lines[$cursor]); | |
| $content[] = $line; | |
| preg_match_all('/<\/?table\b[^>]*>/i', $line, $matches, PREG_OFFSET_CAPTURE); | |
| foreach ($matches[0] as $match) { | |
| $tag = strtolower($match[0]); | |
| if (str_starts_with($tag, '</table')) { | |
| $depth--; | |
| if ($started && $depth === 0) { | |
| return [implode("\n", $content), $cursor]; | |
| } | |
| continue; | |
| } | |
| $started = true; | |
| $depth++; | |
| } | |
| } | |
| return null; | |
| } | |
| /** | |
| * @param list<string> $lines | |
| * @return array{0:string, 1:int}|null | |
| */ | |
| private function collectBalancedHtmlElementBlock(array $lines, int $index, string $tag): ?array | |
| { | |
| $content = []; | |
| $count = count($lines); | |
| for ($cursor = $index; $cursor < $count; $cursor++) { | |
| $line = $this->normalizeRawHtmlLine($lines[$cursor]); | |
| $content[] = $line; | |
| [$started, $depth] = $this->htmlElementBalance(implode("\n", $content), $tag); | |
| if ($started && $depth === 0) { | |
| return [implode("\n", $content), $cursor]; | |
| } | |
| } | |
| return null; | |
| } | |
| /** | |
| * @return array{0:bool, 1:int} | |
| */ | |
| private function htmlElementBalance(string $html, string $tag): array | |
| { | |
| $depth = 0; | |
| $started = false; | |
| $pattern = '/<\/?' . preg_quote($tag, '/') . '\b[^>]*>/i'; | |
| preg_match_all($pattern, $html, $matches); | |
| foreach ($matches[0] as $matchedTag) { | |
| $matchedTag = strtolower(trim($matchedTag)); | |
| if (str_starts_with($matchedTag, '</')) { | |
| if ($started) { | |
| $depth--; | |
| } | |
| continue; | |
| } | |
| $started = true; | |
| if (!str_ends_with(rtrim($matchedTag), '/>')) { | |
| $depth++; | |
| } | |
| } | |
| return [$started, $depth]; | |
| } | |
| private function htmlTableBlockIsEmpty(string $html): bool | |
| { | |
| $previous = libxml_use_internal_errors(true); | |
| $dom = new \DOMDocument('1.0', 'UTF-8'); | |
| $loaded = $dom->loadHTML( | |
| '<?xml encoding="UTF-8"><!doctype html><html><body>' . $html . '</body></html>', | |
| LIBXML_NONET | LIBXML_NOERROR | LIBXML_NOWARNING | |
| ); | |
| libxml_clear_errors(); | |
| libxml_use_internal_errors($previous); | |
| if (!$loaded) { | |
| return false; | |
| } | |
| $body = $dom->getElementsByTagName('body')->item(0); | |
| if (!$body instanceof \DOMElement) { | |
| return false; | |
| } | |
| $table = $this->firstDescendantElement($body, 'table'); | |
| if (!$table instanceof \DOMElement) { | |
| return false; | |
| } | |
| if (trim($this->directHtmlTableCaptionText($table)) !== '') { | |
| return false; | |
| } | |
| foreach (['td', 'th'] as $name) { | |
| foreach ($table->getElementsByTagName($name) as $cell) { | |
| if ($cell instanceof \DOMElement) { | |
| return false; | |
| } | |
| } | |
| } | |
| return true; | |
| } | |
| private function directHtmlTableCaptionText(\DOMElement $table): string | |
| { | |
| foreach ($this->childElements($table, 'caption') as $caption) { | |
| return $caption->textContent; | |
| } | |
| return ''; | |
| } | |
| private function parseStructuredHtmlTable(string $html): ?AstNode | |
| { | |
| $previous = libxml_use_internal_errors(true); | |
| $dom = new \DOMDocument('1.0', 'UTF-8'); | |
| $source = preg_match('/^\s*(?:<!doctype\s+html\b|<html\b)/i', $html) === 1 | |
| ? '<?xml encoding="UTF-8">' . $html | |
| : '<?xml encoding="UTF-8"><!doctype html><html><body>' . $html . '</body></html>'; | |
| $loaded = $dom->loadHTML( | |
| $source, | |
| LIBXML_NONET | LIBXML_NOERROR | LIBXML_NOWARNING | |
| ); | |
| libxml_clear_errors(); | |
| libxml_use_internal_errors($previous); | |
| if (!$loaded) { | |
| return null; | |
| } | |
| $body = $dom->getElementsByTagName('body')->item(0); | |
| if (!$body instanceof \DOMElement) { | |
| return null; | |
| } | |
| $table = $this->firstDescendantElement($body, 'table'); | |
| if ( | |
| !$table instanceof \DOMElement | |
| || (!$this->htmlTableContainsNestedTable($table) && !$this->htmlTableHasStructuredBoundary($table)) | |
| ) { | |
| return null; | |
| } | |
| return $this->parseHtmlTableElement($table); | |
| } | |
| private function htmlTableContainsNestedTable(\DOMElement $table): bool | |
| { | |
| foreach ($table->getElementsByTagName('table') as $nested) { | |
| if ($nested instanceof \DOMElement && !$nested->isSameNode($table)) { | |
| return true; | |
| } | |
| } | |
| return false; | |
| } | |
| private function htmlTableHasStructuredBoundary(\DOMElement $table): bool | |
| { | |
| foreach (['caption', 'colgroup', 'thead', 'tfoot'] as $name) { | |
| if ($this->firstChildElement($table, $name) instanceof \DOMElement) { | |
| return true; | |
| } | |
| } | |
| return $this->htmlTableHasHeaderCells($table) | |
| || $this->htmlTableHasSpans($table) | |
| || $this->htmlTableHasPlainBodyRows($table); | |
| } | |
| private function htmlTableHasHeaderCells(\DOMElement $table): bool | |
| { | |
| foreach ($table->getElementsByTagName('th') as $cell) { | |
| if ($cell instanceof \DOMElement) { | |
| return true; | |
| } | |
| } | |
| return false; | |
| } | |
| private function htmlTableHasSpans(\DOMElement $table): bool | |
| { | |
| foreach (['td', 'th'] as $name) { | |
| foreach ($table->getElementsByTagName($name) as $cell) { | |
| if (!$cell instanceof \DOMElement) { | |
| continue; | |
| } | |
| if ( | |
| $this->positiveHtmlSpan($cell->getAttribute('colspan')) > 1 | |
| || $this->positiveHtmlSpan($cell->getAttribute('rowspan')) > 1 | |
| ) { | |
| return true; | |
| } | |
| } | |
| } | |
| return false; | |
| } | |
| private function htmlTableHasPlainBodyRows(\DOMElement $table): bool | |
| { | |
| $sections = $this->childElements($table, 'tbody'); | |
| if ($sections === []) { | |
| $sections = [$table]; | |
| } | |
| $rowCount = 0; | |
| foreach ($sections as $section) { | |
| foreach ($this->childElements($section, 'tr') as $row) { | |
| $cellCount = 0; | |
| foreach ($row->childNodes as $child) { | |
| if (!$child instanceof \DOMElement) { | |
| continue; | |
| } | |
| if (strtolower($child->localName) !== 'td' || !$this->htmlTableCellIsPlainScalar($child)) { | |
| return false; | |
| } | |
| $cellCount++; | |
| } | |
| if ($cellCount > 0) { | |
| $rowCount++; | |
| } | |
| } | |
| } | |
| return $rowCount > 0; | |
| } | |
| private function htmlTableCellIsPlainScalar(\DOMElement $cell): bool | |
| { | |
| foreach ($cell->childNodes as $child) { | |
| if ($child instanceof \DOMText || $child instanceof \DOMCdataSection) { | |
| continue; | |
| } | |
| if ($child instanceof \DOMComment) { | |
| continue; | |
| } | |
| return false; | |
| } | |
| $text = trim(preg_replace('/\s+/', ' ', $cell->textContent) ?? $cell->textContent); | |
| if ($text === '') { | |
| return false; | |
| } | |
| return preg_match('/[*_`~\[\]\\\\$]/', $text) !== 1; | |
| } | |
| private function parseHtmlTableElement(\DOMElement $table): AstNode | |
| { | |
| $maxColumns = 0; | |
| $caption = $this->firstChildElement($table, 'caption'); | |
| $thead = $this->firstChildElement($table, 'thead'); | |
| $tfoot = $this->firstChildElement($table, 'tfoot'); | |
| $bodySections = $this->childElements($table, 'tbody'); | |
| $headRows = $thead instanceof \DOMElement ? $this->readHtmlTableRows($thead, true, $maxColumns) : []; | |
| $bodyNodes = []; | |
| if ($bodySections !== []) { | |
| foreach ($bodySections as $tbody) { | |
| $rows = $this->readHtmlTableRows($tbody, false, $maxColumns); | |
| [$bodyHeadRows, $bodyRows] = $this->splitHtmlTableBodyRows($rows); | |
| $bodyNodes[] = new AstNode( | |
| 'table_body', | |
| array_merge($this->htmlElementPandocAttrs($tbody), $this->htmlTableBodyAttrs($bodyRows, $bodyHeadRows)), | |
| $bodyRows | |
| ); | |
| } | |
| } else { | |
| $bodyRows = $this->readHtmlTableRows($table, false, $maxColumns); | |
| if ($headRows === [] && $this->firstHtmlTableRowIsHeader($bodyRows)) { | |
| $headRow = array_shift($bodyRows); | |
| if ($headRow instanceof AstNode) { | |
| $headRows[] = $this->markHtmlTableRowAsHeader($headRow); | |
| } | |
| } | |
| [$bodyHeadRows, $bodyRows] = $this->splitHtmlTableBodyRows($bodyRows); | |
| $bodyNodes[] = new AstNode('table_body', $this->htmlTableBodyAttrs($bodyRows, $bodyHeadRows), $bodyRows); | |
| } | |
| $footRows = $tfoot instanceof \DOMElement ? $this->readHtmlTableRows($tfoot, false, $maxColumns) : []; | |
| $captionInlines = $caption instanceof \DOMElement ? $this->parseHtmlInlineChildren($caption) : []; | |
| $attrs = array_merge($this->htmlElementPandocAttrs($table), [ | |
| 'caption' => $captionInlines === [] ? '' : trim(preg_replace('/\s+/', ' ', $this->plainTextFromInlines($captionInlines)) ?? ''), | |
| 'alignments' => array_fill(0, $maxColumns, 'default'), | |
| ]); | |
| if ($captionInlines !== []) { | |
| $attrs['captionInlines'] = $captionInlines; | |
| } | |
| $widths = $this->readHtmlTableColumnWidths($table, $maxColumns); | |
| if ($widths !== null) { | |
| $attrs['widths'] = $widths; | |
| } elseif ($maxColumns > 0) { | |
| $attrs['widths'] = array_fill(0, $maxColumns, 1 / $maxColumns); | |
| } | |
| $headAttrs = $thead instanceof \DOMElement ? $this->htmlElementPandocAttrs($thead) : []; | |
| $footAttrs = $tfoot instanceof \DOMElement ? $this->htmlElementPandocAttrs($tfoot) : []; | |
| $children = [ | |
| new AstNode('table_head', $headAttrs, $headRows), | |
| ...$bodyNodes, | |
| ]; | |
| if ($footRows !== []) { | |
| $children[] = new AstNode('table_foot', $footAttrs, $footRows); | |
| } | |
| return new AstNode('table', $attrs, $children); | |
| } | |
| /** | |
| * @param list<AstNode> $rows | |
| */ | |
| private function firstHtmlTableRowIsHeader(array $rows): bool | |
| { | |
| $first = $rows[0] ?? null; | |
| return $first instanceof AstNode && $this->htmlTableRowIsAllHeaders($first); | |
| } | |
| /** | |
| * @param list<AstNode> $rows | |
| * @return array{0:list<AstNode>, 1:list<AstNode>} | |
| */ | |
| private function splitHtmlTableBodyRows(array $rows): array | |
| { | |
| $headRows = []; | |
| while ($rows !== [] && $this->htmlTableRowIsAllHeaders($rows[0])) { | |
| $headRow = array_shift($rows); | |
| if ($headRow instanceof AstNode) { | |
| $headRows[] = $this->markHtmlTableRowAsHeader($headRow); | |
| } | |
| } | |
| return [$headRows, $rows]; | |
| } | |
| private function htmlTableRowIsAllHeaders(AstNode $row): bool | |
| { | |
| if ($row->children === []) { | |
| return false; | |
| } | |
| foreach ($row->children as $cell) { | |
| if ($cell->type !== 'table_cell' || $cell->attr('header') !== true) { | |
| return false; | |
| } | |
| } | |
| return true; | |
| } | |
| private function markHtmlTableRowAsHeader(AstNode $row): AstNode | |
| { | |
| return new AstNode( | |
| $row->type, | |
| array_merge($row->attrs, ['header' => true]), | |
| $row->children | |
| ); | |
| } | |
| /** | |
| * @param list<AstNode> $rows | |
| * @param list<AstNode> $headRows | |
| * @return array<string, mixed> | |
| */ | |
| private function htmlTableBodyAttrs(array $rows, array $headRows = []): array | |
| { | |
| $rowHeadColumns = $this->countHtmlTableRowHeadColumns($rows); | |
| $attrs = $rowHeadColumns > 0 ? ['rowHeadColumns' => $rowHeadColumns] : []; | |
| if ($headRows !== []) { | |
| $attrs['headRows'] = $headRows; | |
| $attrs['headRowCount'] = count($headRows); | |
| } | |
| return $attrs; | |
| } | |
| /** | |
| * @param list<AstNode> $rows | |
| */ | |
| private function countHtmlTableRowHeadColumns(array $rows): int | |
| { | |
| if ($rows === []) { | |
| return 0; | |
| } | |
| $minimum = null; | |
| foreach ($rows as $row) { | |
| $count = 0; | |
| foreach ($row->children as $cell) { | |
| if ($cell->type !== 'table_cell' || $cell->attr('header') !== true) { | |
| break; | |
| } | |
| $count++; | |
| } | |
| $minimum = $minimum === null ? $count : min($minimum, $count); | |
| } | |
| return $minimum ?? 0; | |
| } | |
| /** | |
| * @return list<float>|null | |
| */ | |
| private function readHtmlTableColumnWidths(\DOMElement $table, int $maxColumns): ?array | |
| { | |
| $colgroup = $this->firstChildElement($table, 'colgroup'); | |
| if (!$colgroup instanceof \DOMElement) { | |
| return null; | |
| } | |
| $widths = []; | |
| foreach ($this->childElements($colgroup, 'col') as $col) { | |
| $width = $this->htmlColumnWidthPercent($col); | |
| if ($width === null) { | |
| return null; | |
| } | |
| $widths[] = $width; | |
| } | |
| if ($widths === [] || ($maxColumns > 0 && count($widths) !== $maxColumns)) { | |
| return null; | |
| } | |
| return $widths; | |
| } | |
| private function htmlColumnWidthPercent(\DOMElement $col): ?float | |
| { | |
| $style = strtolower($col->getAttribute('style')); | |
| if (preg_match('/width\s*:\s*([0-9]+(?:\.[0-9]+)?)\s*%/', $style, $m) === 1) { | |
| return (float) $m[1] / 100; | |
| } | |
| $width = trim($col->getAttribute('width')); | |
| if (preg_match('/^([0-9]+(?:\.[0-9]+)?)\s*%$/', $width, $m) === 1) { | |
| return (float) $m[1] / 100; | |
| } | |
| return null; | |
| } | |
| /** | |
| * @return list<AstNode> | |
| */ | |
| private function readHtmlTableRows(\DOMElement $section, bool $header, int &$maxColumns): array | |
| { | |
| $rows = []; | |
| foreach ($this->childElements($section, 'tr') as $rowElement) { | |
| $cells = []; | |
| $rowColumns = 0; | |
| foreach ($rowElement->childNodes as $child) { | |
| if (!$child instanceof \DOMElement || !in_array(strtolower($child->localName), ['td', 'th'], true)) { | |
| continue; | |
| } | |
| $cell = $this->buildHtmlTableCell($child, $header || strtolower($child->localName) === 'th'); | |
| $cells[] = $cell; | |
| $rowColumns += max(1, (int) $cell->attr('colspan', 1)); | |
| } | |
| if ($cells === []) { | |
| continue; | |
| } | |
| $maxColumns = max($maxColumns, $rowColumns); | |
| $rows[] = new AstNode( | |
| 'table_row', | |
| array_merge($this->htmlElementPandocAttrs($rowElement), ['header' => $header]), | |
| $cells | |
| ); | |
| } | |
| return $rows; | |
| } | |
| private function buildHtmlTableCell(\DOMElement $cell, bool $header): AstNode | |
| { | |
| $children = $this->parseHtmlTableCellChildren($cell); | |
| $attrs = array_merge($this->htmlElementPandocAttrs($cell, ['align', 'colspan', 'rowspan']), [ | |
| 'text' => trim(preg_replace('/\s+/', ' ', $cell->textContent) ?? $cell->textContent), | |
| 'header' => $header, | |
| ]); | |
| $colspan = $this->positiveHtmlSpan($cell->getAttribute('colspan')); | |
| if ($colspan > 1) { | |
| $attrs['colspan'] = $colspan; | |
| } | |
| $rowspan = $this->positiveHtmlSpan($cell->getAttribute('rowspan')); | |
| if ($rowspan > 1) { | |
| $attrs['rowspan'] = $rowspan; | |
| } | |
| $alignment = $this->normalizeHtmlTableAlignment($cell); | |
| if ($alignment !== 'default') { | |
| $attrs['align'] = $alignment; | |
| } | |
| return new AstNode('table_cell', $attrs, $children); | |
| } | |
| /** | |
| * @param list<string> $skip | |
| * @return array<string, mixed> | |
| */ | |
| private function htmlElementPandocAttrs(\DOMElement $element, array $skip = []): array | |
| { | |
| $id = ''; | |
| $classes = []; | |
| $attributes = []; | |
| $htmlAttributes = []; | |
| foreach ($element->attributes as $attribute) { | |
| if (!$attribute instanceof \DOMAttr) { | |
| continue; | |
| } | |
| $name = strtolower($attribute->name); | |
| if (in_array($name, $skip, true)) { | |
| continue; | |
| } | |
| $value = trim($attribute->value); | |
| if ($name === 'id') { | |
| $id = $value; | |
| if ($value !== '') { | |
| $htmlAttributes['id'] = $value; | |
| } | |
| continue; | |
| } | |
| if ($name === 'class') { | |
| $classes = preg_split('/\s+/', $value, -1, PREG_SPLIT_NO_EMPTY) ?: []; | |
| if ($classes !== []) { | |
| $htmlAttributes['class'] = implode(' ', $classes); | |
| } | |
| continue; | |
| } | |
| if ($name === 'style') { | |
| $value = $this->htmlTableNonAlignmentStyle($value); | |
| if ($value === '') { | |
| continue; | |
| } | |
| } | |
| $key = str_starts_with($name, 'data-') ? substr($name, 5) : $name; | |
| if ($key === '') { | |
| continue; | |
| } | |
| $attributes[$key] = $value; | |
| $htmlAttributes[$name] = $value; | |
| } | |
| $attrs = []; | |
| if ($id !== '') { | |
| $attrs['id'] = $id; | |
| } | |
| if ($classes !== []) { | |
| $attrs['classes'] = $classes; | |
| } | |
| if ($attributes !== []) { | |
| $attrs['attributes'] = $attributes; | |
| } | |
| if ($htmlAttributes !== []) { | |
| $attrs['htmlAttributes'] = $htmlAttributes; | |
| } | |
| return $attrs; | |
| } | |
| private function htmlTableNonAlignmentStyle(string $style): string | |
| { | |
| $kept = []; | |
| foreach (preg_split('/;/', $style) ?: [] as $declaration) { | |
| $declaration = trim($declaration); | |
| if ($declaration === '' || preg_match('/^text-align\s*:/i', $declaration) === 1) { | |
| continue; | |
| } | |
| $kept[] = $declaration; | |
| } | |
| return implode('; ', $kept); | |
| } | |
| private function positiveHtmlSpan(string $value): int | |
| { | |
| $value = trim($value); | |
| return preg_match('/^\d+$/', $value) === 1 ? max(1, (int) $value) : 1; | |
| } | |
| private function normalizeHtmlTableAlignment(\DOMElement $cell): string | |
| { | |
| $align = strtolower(trim($cell->getAttribute('align'))); | |
| if (in_array($align, ['left', 'right', 'center'], true)) { | |
| return $align; | |
| } | |
| $style = strtolower($cell->getAttribute('style')); | |
| if (preg_match('/text-align\s*:\s*(left|right|center)\b/', $style, $m) === 1) { | |
| return $m[1]; | |
| } | |
| return 'default'; | |
| } | |
| /** | |
| * @return list<AstNode> | |
| */ | |
| private function parseHtmlTableCellChildren(\DOMElement $cell): array | |
| { | |
| $children = []; | |
| foreach ($cell->childNodes as $child) { | |
| if ($child instanceof \DOMElement && strtolower($child->localName) === 'table') { | |
| $children[] = $this->parseHtmlTableElement($child); | |
| continue; | |
| } | |
| if ($child instanceof \DOMElement && strtolower($child->localName) === 'p') { | |
| $inlines = $this->parseHtmlInlineChildren($child); | |
| if ($inlines !== []) { | |
| $children[] = new AstNode( | |
| 'paragraph', | |
| ['text' => trim(preg_replace('/\s+/', ' ', $child->textContent) ?? $child->textContent)], | |
| $inlines | |
| ); | |
| } | |
| continue; | |
| } | |
| array_push($children, ...$this->parseHtmlInlineNode($child)); | |
| } | |
| return $children; | |
| } | |
| /** | |
| * @return list<AstNode> | |
| */ | |
| private function parseHtmlInlineNode(\DOMNode $node): array | |
| { | |
| if ($node instanceof \DOMText || $node instanceof \DOMCdataSection) { | |
| $raw = $node->wholeText; | |
| $trimmed = trim($raw); | |
| if ($trimmed === '') { | |
| return []; | |
| } | |
| $text = trim(preg_replace('/\s+/', ' ', $raw) ?? $raw); | |
| if (preg_match('/^\s/u', $raw) === 1) { | |
| $text = ' ' . $text; | |
| } | |
| if (preg_match('/\s$/u', $raw) === 1) { | |
| $text .= ' '; | |
| } | |
| return [new AstNode('text', ['text' => $text])]; | |
| } | |
| if ($node instanceof \DOMComment) { | |
| return $this->htmlRawHtmlEnabled() | |
| ? [new AstNode('raw_html_inline', ['html' => '<!--' . $node->nodeValue . '-->'])] | |
| : []; | |
| } | |
| if (!$node instanceof \DOMElement) { | |
| return []; | |
| } | |
| $name = strtolower($node->localName); | |
| if ($name === 'svg') { | |
| if (!$this->htmlRawHtmlEnabled()) { | |
| return [$this->buildHtmlSvgImageNode($node)]; | |
| } | |
| return [$this->buildHtmlRawInlineNode($node)]; | |
| } | |
| if ($name === 'style') { | |
| return $this->htmlRawHtmlEnabled() ? [$this->buildHtmlRawInlineNode($node)] : []; | |
| } | |
| if ($name === 'script') { | |
| $math = $this->buildHtmlScriptMathNode($node); | |
| if ($math instanceof AstNode) { | |
| return [$math]; | |
| } | |
| return $this->htmlRawHtmlEnabled() ? [$this->buildHtmlRawInlineNode($node)] : []; | |
| } | |
| if ($name === 'input') { | |
| if ($this->htmlElementIsCheckboxInput($node) && $this->htmlElementIsInsideListItem($node)) { | |
| return [ | |
| new AstNode('text', [ | |
| 'text' => $node->hasAttribute('checked') ? "\u{2612}" : "\u{2610}", | |
| 'htmlCheckboxMarker' => true, | |
| ]), | |
| new AstNode('text', ['text' => ' ']), | |
| ]; | |
| } | |
| return []; | |
| } | |
| if ($name === 'math') { | |
| return [$this->buildHtmlMathNode($node)]; | |
| } | |
| if ($name === 'q') { | |
| return $this->parseHtmlQuoteInline($node); | |
| } | |
| if ($name === 'span' && $this->htmlElementIsSkippedMathRendererSpan($node)) { | |
| return []; | |
| } | |
| if ($name === 'span' && $this->htmlElementHasClass($node, 'MJX_Assistive_MathML')) { | |
| return $this->parseHtmlInlineChildren($node); | |
| } | |
| $children = $this->parseHtmlInlineChildren($node); | |
| if (in_array($name, ['strong', 'b'], true)) { | |
| return $this->wrapHtmlInlineWithBoundaryWhitespace('strong', [], $children); | |
| } | |
| if (in_array($name, ['em', 'i'], true)) { | |
| return $this->wrapHtmlInlineWithBoundaryWhitespace('emph', [], $children); | |
| } | |
| if ($name === 'sup') { | |
| return $this->wrapHtmlInlineWithBoundaryWhitespace('superscript', [], $children); | |
| } | |
| if ($name === 'sub') { | |
| return $this->wrapHtmlInlineWithBoundaryWhitespace('subscript', [], $children); | |
| } | |
| if ($name === 'span' && $this->htmlElementIsSmallCapsSpan($node)) { | |
| return $this->wrapHtmlInlineWithBoundaryWhitespace('small_caps', [], $children); | |
| } | |
| if ($name === 'span' && $this->htmlElementHasExactClass($node, 'strikeout')) { | |
| return $this->wrapHtmlInlineWithBoundaryWhitespace('strikeout', [], $children); | |
| } | |
| if ($name === 'small') { | |
| return $this->wrapHtmlInlineWithBoundaryWhitespace('span', ['classes' => ['small']], $children); | |
| } | |
| if ($name === 'bdo') { | |
| return $this->parseHtmlBdoInline($node, $children); | |
| } | |
| if (in_array($name, ['u', 'ins'], true)) { | |
| return $this->wrapHtmlInlineWithBoundaryWhitespace('underline', [], $children); | |
| } | |
| if (in_array($name, ['s', 'strike', 'del'], true)) { | |
| return $this->wrapHtmlInlineWithBoundaryWhitespace('strikeout', [], $children); | |
| } | |
| if (in_array($name, ['code', 'tt', 'samp', 'var'], true)) { | |
| return [$this->buildHtmlInlineCodeNode($node, $this->htmlInlineCodeElementClasses($name))]; | |
| } | |
| if ($this->isHtmlSpanLikeElement($name)) { | |
| return [new AstNode('span', $this->htmlSpanLikeElementAttrs($node, $name), $children)]; | |
| } | |
| if ($name === 'span') { | |
| $attrs = $this->htmlElementPandocAttrs($node); | |
| if ($attrs !== []) { | |
| return [new AstNode('span', $attrs, $children)]; | |
| } | |
| return $children; | |
| } | |
| if ($name === 'a') { | |
| $note = $this->buildHtmlNoteReferenceNode($node); | |
| if ($note instanceof AstNode) { | |
| return [$note]; | |
| } | |
| if (!$node->hasAttribute('href')) { | |
| $attrs = $this->htmlElementPandocAttrs($node, ['name']); | |
| $nameAttr = trim($node->getAttribute('name')); | |
| if ($nameAttr !== '' && (string) ($attrs['id'] ?? '') === '') { | |
| $attrs['id'] = $nameAttr; | |
| $htmlAttributes = $attrs['htmlAttributes'] ?? []; | |
| if (!is_array($htmlAttributes)) { | |
| $htmlAttributes = []; | |
| } | |
| $htmlAttributes['id'] = $nameAttr; | |
| $attrs['htmlAttributes'] = $htmlAttributes; | |
| } | |
| if ($attrs !== []) { | |
| return [new AstNode('span', $attrs, $children)]; | |
| } | |
| return $children; | |
| } | |
| return [new AstNode('link', [ | |
| 'url' => $this->resolveHtmlUrl($node->getAttribute('href')), | |
| 'title' => $node->getAttribute('title'), | |
| ], $children)]; | |
| } | |
| if ($name === 'img') { | |
| return [$this->buildHtmlImageNode($node)]; | |
| } | |
| if ($name === 'br') { | |
| return [new AstNode('linebreak')]; | |
| } | |
| if ($this->htmlRawHtmlEnabled() && $this->htmlElementUsesGenericRawInlineFallback($node)) { | |
| return $this->wrapHtmlRawInlineElement($node, $children); | |
| } | |
| return $children; | |
| } | |
| private function htmlElementUsesGenericRawInlineFallback(\DOMElement $element): bool | |
| { | |
| return in_array(strtolower($element->localName), ['applet', 'area', 'audio', 'blink', 'button', 'cite', 'embed', 'map', 'noscript', 'object', 'progress', 'source', 'time', 'track', 'video', 'wbr'], true); | |
| } | |
| /** | |
| * @param list<AstNode> $children | |
| * @return list<AstNode> | |
| */ | |
| private function wrapHtmlRawInlineElement(\DOMElement $element, array $children): array | |
| { | |
| if ($this->htmlElementIsVoid($element)) { | |
| return [ | |
| new AstNode('raw_html_inline', ['html' => $this->renderHtmlOpeningTag($element)]), | |
| ...$children, | |
| ]; | |
| } | |
| return [ | |
| new AstNode('raw_html_inline', ['html' => $this->renderHtmlOpeningTag($element)]), | |
| ...$children, | |
| new AstNode('raw_html_inline', ['html' => '</' . strtolower($element->localName) . '>']), | |
| ]; | |
| } | |
| private function htmlElementIsVoid(\DOMElement $element): bool | |
| { | |
| return in_array(strtolower($element->localName), [ | |
| 'area', | |
| 'base', | |
| 'br', | |
| 'col', | |
| 'embed', | |
| 'hr', | |
| 'img', | |
| 'input', | |
| 'link', | |
| 'meta', | |
| 'param', | |
| 'source', | |
| 'track', | |
| 'wbr', | |
| ], true); | |
| } | |
| private function renderHtmlOpeningTag(\DOMElement $element): string | |
| { | |
| $tag = '<' . strtolower($element->localName); | |
| foreach ($element->attributes as $attribute) { | |
| if (!$attribute instanceof \DOMAttr) { | |
| continue; | |
| } | |
| $tag .= ' ' . strtolower($attribute->name) . '="' . htmlspecialchars($attribute->value, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . '"'; | |
| } | |
| return $tag . '>'; | |
| } | |
| private function buildHtmlNoteReferenceNode(\DOMElement $link): ?AstNode | |
| { | |
| if (!$this->isHtmlNoteReferenceElement($link)) { | |
| return null; | |
| } | |
| $href = trim($link->getAttribute('href')); | |
| if (!str_starts_with($href, '#')) { | |
| return null; | |
| } | |
| $id = substr($href, 1); | |
| if ($id === '') { | |
| return null; | |
| } | |
| return new AstNode('note', [], $this->htmlFootnoteDefinitions[$id] ?? []); | |
| } | |
| /** | |
| * @return list<AstNode> | |
| */ | |
| private function parseHtmlQuoteInline(\DOMElement $node): array | |
| { | |
| $kind = $this->htmlQuoteDepth % 2 === 0 ? 'double' : 'single'; | |
| $this->htmlQuoteDepth++; | |
| try { | |
| $children = $this->parseHtmlInlineChildren($node); | |
| } finally { | |
| $this->htmlQuoteDepth--; | |
| } | |
| $attrs = $this->htmlQuoteCiteSpanAttrs($node); | |
| if ($attrs !== []) { | |
| $children = [new AstNode('span', $attrs, $children)]; | |
| } | |
| return $this->wrapHtmlInlineWithBoundaryWhitespace('quoted', ['kind' => $kind], $children); | |
| } | |
| /** | |
| * @return array<string, mixed> | |
| */ | |
| private function htmlQuoteCiteSpanAttrs(\DOMElement $node): array | |
| { | |
| if (!$node->hasAttribute('cite')) { | |
| return []; | |
| } | |
| $attrs = [ | |
| 'attributes' => [ | |
| 'cite' => $this->resolveHtmlUrl($node->getAttribute('cite')), | |
| ], | |
| ]; | |
| $id = trim($node->getAttribute('name')); | |
| if ($id === '') { | |
| $id = trim($node->getAttribute('id')); | |
| } | |
| if ($id !== '') { | |
| $attrs['id'] = $id; | |
| } | |
| $classes = preg_split('/\s+/', trim($node->getAttribute('class')), -1, PREG_SPLIT_NO_EMPTY) ?: []; | |
| if ($classes !== []) { | |
| $attrs['classes'] = array_values($classes); | |
| } | |
| return $attrs; | |
| } | |
| /** | |
| * @param list<AstNode> $children | |
| * @return list<AstNode> | |
| */ | |
| private function parseHtmlBdoInline(\DOMElement $node, array $children): array | |
| { | |
| if (!$node->hasAttribute('dir')) { | |
| return $children; | |
| } | |
| $direction = strtolower(trim($node->getAttribute('dir'))); | |
| return [new AstNode('span', ['attributes' => ['dir' => $direction]], $children)]; | |
| } | |
| private function htmlRawHtmlEnabled(): bool | |
| { | |
| return (bool) ($this->options['htmlRawHtml'] ?? $this->options['rawHtml'] ?? true); | |
| } | |
| private function htmlElementIsCheckboxInput(\DOMElement $element): bool | |
| { | |
| return strtolower($element->localName) === 'input' | |
| && strtolower(trim($element->getAttribute('type'))) === 'checkbox'; | |
| } | |
| private function htmlElementIsInsideListItem(\DOMElement $element): bool | |
| { | |
| for ($parent = $element->parentNode; $parent instanceof \DOMElement; $parent = $parent->parentNode) { | |
| if (strtolower($parent->localName) === 'li') { | |
| return true; | |
| } | |
| } | |
| return false; | |
| } | |
| private function buildHtmlSvgImageNode(\DOMElement $svg): AstNode | |
| { | |
| $raw = $svg->ownerDocument instanceof \DOMDocument | |
| ? $svg->ownerDocument->saveHTML($svg) | |
| : ''; | |
| if (!is_string($raw) || $raw === '') { | |
| $raw = '<svg></svg>'; | |
| } | |
| $attrs = [ | |
| 'url' => 'data:image/svg+xml;base64,' . base64_encode(trim($raw)), | |
| 'alt' => '', | |
| 'title' => '', | |
| ]; | |
| $id = trim($svg->getAttribute('id')); | |
| if ($id !== '') { | |
| $attrs['id'] = $id; | |
| } | |
| $classes = preg_split('/\s+/', trim($svg->getAttribute('class')), -1, PREG_SPLIT_NO_EMPTY) ?: []; | |
| if ($classes !== []) { | |
| $attrs['classes'] = array_values($classes); | |
| } | |
| if (array_intersect($classes, ['fa-w-14', 'fa-w-16', 'fa-fw']) !== []) { | |
| $attrs['attributes'] = ['width' => '1em']; | |
| } | |
| return new AstNode('image', $attrs); | |
| } | |
| private function buildHtmlRawInlineNode(\DOMElement $element): AstNode | |
| { | |
| $raw = $element->ownerDocument instanceof \DOMDocument | |
| ? $element->ownerDocument->saveHTML($element) | |
| : ''; | |
| if (!is_string($raw) || $raw === '') { | |
| $raw = '<' . strtolower($element->localName) . '></' . strtolower($element->localName) . '>'; | |
| } | |
| return new AstNode('raw_html_inline', ['html' => trim($raw)]); | |
| } | |
| private function buildHtmlRawBlockNode(\DOMElement $element): AstNode | |
| { | |
| return new AstNode('raw_html', ['html' => (string) $this->buildHtmlRawInlineNode($element)->attr('html', '')]); | |
| } | |
| private function buildHtmlScriptMathNode(\DOMElement $script): ?AstNode | |
| { | |
| $type = trim($script->getAttribute('type')); | |
| $normalized = strtolower($type); | |
| if (!str_starts_with($normalized, 'math/tex')) { | |
| return null; | |
| } | |
| return new AstNode('math', [ | |
| 'display' => str_ends_with($normalized, 'display'), | |
| 'text' => $script->textContent, | |
| ]); | |
| } | |
| private function buildHtmlMathNode(\DOMElement $math): AstNode | |
| { | |
| $tex = $this->htmlMathTexAnnotation($math); | |
| if ($tex !== null) { | |
| return new AstNode('math', [ | |
| 'display' => strtolower(trim($math->getAttribute('display'))) === 'block', | |
| 'text' => $tex, | |
| ]); | |
| } | |
| $attrs = $this->htmlElementPandocAttrs($math); | |
| $classes = $attrs['classes'] ?? []; | |
| if (!is_array($classes)) { | |
| $classes = []; | |
| } | |
| if (!in_array('math', $classes, true)) { | |
| array_unshift($classes, 'math'); | |
| } | |
| $attrs['classes'] = array_values($classes); | |
| $htmlAttributes = $attrs['htmlAttributes'] ?? []; | |
| if (!is_array($htmlAttributes)) { | |
| $htmlAttributes = []; | |
| } | |
| $htmlAttributes['class'] = implode(' ', $attrs['classes']); | |
| $attrs['htmlAttributes'] = $htmlAttributes; | |
| return new AstNode('span', $attrs, [ | |
| new AstNode('text', ['text' => trim(preg_replace('/\s+/', ' ', $math->textContent) ?? $math->textContent)]), | |
| ]); | |
| } | |
| private function htmlMathTexAnnotation(\DOMElement $math): ?string | |
| { | |
| foreach ($math->getElementsByTagName('*') as $candidate) { | |
| if (!$candidate instanceof \DOMElement || strtolower($candidate->localName) !== 'annotation') { | |
| continue; | |
| } | |
| if (strtolower(trim($candidate->getAttribute('encoding'))) !== 'application/x-tex') { | |
| continue; | |
| } | |
| return $candidate->textContent; | |
| } | |
| return null; | |
| } | |
| /** | |
| * @param list<string> $classes | |
| */ | |
| private function buildHtmlInlineCodeNode(\DOMElement $code, array $classes = []): AstNode | |
| { | |
| $attrs = $this->htmlElementPandocAttrs($code); | |
| if ($classes !== []) { | |
| $existingClasses = $attrs['classes'] ?? []; | |
| if (!is_array($existingClasses)) { | |
| $existingClasses = []; | |
| } | |
| foreach ($classes as $class) { | |
| if (!in_array($class, $existingClasses, true)) { | |
| $existingClasses[] = $class; | |
| } | |
| } | |
| $attrs['classes'] = $existingClasses; | |
| $htmlAttributes = $attrs['htmlAttributes'] ?? []; | |
| if (!is_array($htmlAttributes)) { | |
| $htmlAttributes = []; | |
| } | |
| $htmlAttributes['class'] = implode(' ', $existingClasses); | |
| $attrs['htmlAttributes'] = $htmlAttributes; | |
| } | |
| $attrs['text'] = trim(preg_replace('/\s+/', ' ', $code->textContent) ?? $code->textContent); | |
| return new AstNode('code', $attrs); | |
| } | |
| /** | |
| * @return list<string> | |
| */ | |
| private function htmlInlineCodeElementClasses(string $name): array | |
| { | |
| return match ($name) { | |
| 'samp' => ['sample'], | |
| 'var' => ['variable'], | |
| default => [], | |
| }; | |
| } | |
| private function isHtmlSpanLikeElement(string $name): bool | |
| { | |
| return in_array($name, ['kbd', 'mark', 'dfn', 'abbr'], true); | |
| } | |
| /** | |
| * @return array<string, mixed> | |
| */ | |
| private function htmlSpanLikeElementAttrs(\DOMElement $element, string $name): array | |
| { | |
| $attrs = $this->htmlElementPandocAttrs($element); | |
| $classes = $attrs['classes'] ?? []; | |
| if (!is_array($classes)) { | |
| $classes = []; | |
| } | |
| array_unshift($classes, $name); | |
| $attrs['classes'] = array_values($classes); | |
| $htmlAttributes = $attrs['htmlAttributes'] ?? []; | |
| if (!is_array($htmlAttributes)) { | |
| $htmlAttributes = []; | |
| } | |
| $orderedHtmlAttributes = []; | |
| if (isset($htmlAttributes['id'])) { | |
| $orderedHtmlAttributes['id'] = $htmlAttributes['id']; | |
| } | |
| $orderedHtmlAttributes['class'] = implode(' ', $attrs['classes']); | |
| foreach ($htmlAttributes as $attributeName => $value) { | |
| if ($attributeName === 'id' || $attributeName === 'class') { | |
| continue; | |
| } | |
| $orderedHtmlAttributes[$attributeName] = $value; | |
| } | |
| $attrs['htmlAttributes'] = $orderedHtmlAttributes; | |
| return $attrs; | |
| } | |
| /** | |
| * @param array<string, mixed> $attrs | |
| * @param list<AstNode> $children | |
| * @return list<AstNode> | |
| */ | |
| private function wrapHtmlInlineWithBoundaryWhitespace(string $type, array $attrs, array $children): array | |
| { | |
| $prefix = ''; | |
| $suffix = ''; | |
| $firstKey = array_key_first($children); | |
| if ($firstKey !== null) { | |
| $first = $children[$firstKey]; | |
| if ($first->type === 'text') { | |
| $text = (string) $first->attr('text', ''); | |
| if (preg_match('/^\s/u', $text) === 1) { | |
| $prefix = ' '; | |
| $text = ltrim($text); | |
| if ($text === '') { | |
| unset($children[$firstKey]); | |
| } else { | |
| $children[$firstKey] = new AstNode('text', ['text' => $text]); | |
| } | |
| } | |
| } | |
| } | |
| $lastKey = array_key_last($children); | |
| if ($lastKey !== null) { | |
| $last = $children[$lastKey]; | |
| if ($last->type === 'text') { | |
| $text = (string) $last->attr('text', ''); | |
| if (preg_match('/\s$/u', $text) === 1) { | |
| $suffix = ' '; | |
| $text = rtrim($text); | |
| if ($text === '') { | |
| unset($children[$lastKey]); | |
| } else { | |
| $children[$lastKey] = new AstNode('text', ['text' => $text]); | |
| } | |
| } | |
| } | |
| } | |
| $wrapped = []; | |
| if ($prefix !== '') { | |
| $wrapped[] = new AstNode('text', ['text' => $prefix]); | |
| } | |
| $wrapped[] = new AstNode($type, $attrs, array_values($children)); | |
| if ($suffix !== '') { | |
| $wrapped[] = new AstNode('text', ['text' => $suffix]); | |
| } | |
| return $wrapped; | |
| } | |
| private function buildHtmlImageNode(\DOMElement $image): AstNode | |
| { | |
| $alt = $image->getAttribute('alt'); | |
| $attrs = $this->htmlElementPandocAttrs($image, ['src', 'alt', 'title']); | |
| $attrs['url'] = $this->resolveHtmlUrl($image->getAttribute('src')); | |
| $attrs['alt'] = $alt; | |
| $title = $image->getAttribute('title'); | |
| if ($title !== '') { | |
| $attrs['title'] = $title; | |
| } | |
| $children = $alt === '' ? [] : [new AstNode('text', ['text' => $alt])]; | |
| return new AstNode('image', $attrs, $children); | |
| } | |
| private function resolveHtmlUrl(string $url): string | |
| { | |
| if ($url === '' || $this->htmlBaseHref === null || $this->htmlBaseHref === '') { | |
| return $url; | |
| } | |
| if (preg_match('/^[a-z][a-z0-9+.-]*:/i', $url) === 1 || str_starts_with($url, '//')) { | |
| return $url; | |
| } | |
| $base = parse_url($this->htmlBaseHref); | |
| if (!is_array($base) || !isset($base['scheme'], $base['host'])) { | |
| return $url; | |
| } | |
| $authority = $base['scheme'] . '://'; | |
| if (isset($base['user'])) { | |
| $authority .= $base['user']; | |
| if (isset($base['pass'])) { | |
| $authority .= ':' . $base['pass']; | |
| } | |
| $authority .= '@'; | |
| } | |
| $authority .= $base['host']; | |
| if (isset($base['port'])) { | |
| $authority .= ':' . $base['port']; | |
| } | |
| if (str_starts_with($url, '/')) { | |
| return $authority . $this->normalizeHtmlUrlPath($url); | |
| } | |
| $basePath = (string) ($base['path'] ?? '/'); | |
| $directory = str_ends_with($basePath, '/') | |
| ? $basePath | |
| : preg_replace('~[^/]*$~', '', $basePath); | |
| if (!is_string($directory) || $directory === '') { | |
| $directory = '/'; | |
| } | |
| return $authority . $this->normalizeHtmlUrlPath($directory . $url); | |
| } | |
| private function normalizeHtmlUrlPath(string $path): string | |
| { | |
| $prefixSlash = str_starts_with($path, '/'); | |
| $suffixSlash = str_ends_with($path, '/'); | |
| $segments = []; | |
| foreach (explode('/', $path) as $segment) { | |
| if ($segment === '' || $segment === '.') { | |
| continue; | |
| } | |
| if ($segment === '..') { | |
| array_pop($segments); | |
| continue; | |
| } | |
| $segments[] = $segment; | |
| } | |
| $normalized = ($prefixSlash ? '/' : '') . implode('/', $segments); | |
| if ($suffixSlash && $normalized !== '/') { | |
| $normalized .= '/'; | |
| } | |
| return $normalized === '' ? '/' : $normalized; | |
| } | |
| private function htmlElementHasSmallCapsStyle(\DOMElement $element): bool | |
| { | |
| $style = strtolower($element->getAttribute('style')); | |
| if ($style === '') { | |
| return false; | |
| } | |
| return preg_match('/(?:^|;)\s*font-variant\s*:\s*small-caps\b/', $style) === 1 | |
| || preg_match('/(?:^|;)\s*font-variant-caps\s*:\s*small-caps\b/', $style) === 1; | |
| } | |
| private function htmlElementIsSmallCapsSpan(\DOMElement $element): bool | |
| { | |
| return $this->htmlElementHasSmallCapsStyle($element) | |
| || $this->htmlElementHasClass($element, 'smallcaps'); | |
| } | |
| private function htmlElementHasExactClass(\DOMElement $element, string $class): bool | |
| { | |
| return trim($element->getAttribute('class')) === $class; | |
| } | |
| private function htmlElementHasClass(\DOMElement $element, string $class): bool | |
| { | |
| $classes = preg_split('/\s+/', trim($element->getAttribute('class')), -1, PREG_SPLIT_NO_EMPTY) ?: []; | |
| return in_array($class, $classes, true); | |
| } | |
| private function htmlElementIsSkippedMathRendererSpan(\DOMElement $element): bool | |
| { | |
| $classes = preg_split('/\s+/', trim($element->getAttribute('class')), -1, PREG_SPLIT_NO_EMPTY) ?: []; | |
| if ($classes === ['katex-html']) { | |
| return true; | |
| } | |
| return array_intersect($classes, ['mjx-chtml', 'MathJax_CHTML', 'MathJax_Preview']) !== []; | |
| } | |
| /** | |
| * @return list<AstNode> | |
| */ | |
| private function parseHtmlInlineChildren(\DOMElement $element): array | |
| { | |
| $children = []; | |
| foreach ($element->childNodes as $child) { | |
| $this->appendHtmlInlineNodes($children, $this->parseHtmlInlineNode($child)); | |
| } | |
| return $children; | |
| } | |
| /** | |
| * @param list<AstNode> $children | |
| * @param list<AstNode> $nodes | |
| */ | |
| private function appendHtmlInlineNodes(array &$children, array $nodes): void | |
| { | |
| foreach ($nodes as $node) { | |
| $lastKey = array_key_last($children); | |
| $last = $lastKey === null ? null : $children[$lastKey]; | |
| if ($last instanceof AstNode && $last->type === 'linebreak' && $node->type === 'text') { | |
| $text = ltrim((string) $node->attr('text', '')); | |
| if ($text === '') { | |
| continue; | |
| } | |
| $node = new AstNode('text', array_merge($node->attrs, ['text' => $text]), $node->children); | |
| } | |
| $children[] = $node; | |
| } | |
| } | |
| /** | |
| * @param list<string> $columnNames | |
| * @return list<AstNode> | |
| */ | |
| private function readDocBookTableRows(\DOMElement $section, array $columnNames, bool $header, int &$maxColumns): array | |
| { | |
| $rows = []; | |
| foreach ($this->childElements($section, 'row') as $rowElement) { | |
| $cells = []; | |
| $rowColumns = 0; | |
| foreach ($this->childElements($rowElement, 'entry') as $entry) { | |
| $cell = $this->buildDocBookTableCell($entry, $columnNames, $header); | |
| $cells[] = $cell; | |
| $rowColumns += max(1, (int) $cell->attr('colspan', 1)); | |
| } | |
| if ($cells !== []) { | |
| $rows[] = new AstNode('table_row', ['header' => $header], $cells); | |
| $maxColumns = max($maxColumns, $rowColumns); | |
| } | |
| } | |
| return $rows; | |
| } | |
| /** | |
| * @return array{0:list<string>, 1:list<float>|null} | |
| */ | |
| private function readDocBookColumnSpecs(\DOMElement $tgroup): array | |
| { | |
| $names = []; | |
| $widthParts = []; | |
| foreach ($this->childElements($tgroup, 'colspec') as $index => $colspec) { | |
| $name = trim($colspec->getAttribute('colname')); | |
| $names[] = $name !== '' ? $name : 'col_' . ($index + 1); | |
| $width = trim($colspec->getAttribute('colwidth')); | |
| $widthParts[] = preg_match('/^([0-9]+(?:\.[0-9]+)?)\*$/', $width, $m) === 1 | |
| ? (float) $m[1] | |
| : null; | |
| } | |
| $widths = null; | |
| if ($widthParts !== [] && !in_array(null, $widthParts, true)) { | |
| $total = array_sum($widthParts); | |
| if ($total > 0.0) { | |
| $widths = array_map( | |
| static fn (float $width): float => $width / $total, | |
| $widthParts | |
| ); | |
| } | |
| } | |
| return [$names, $widths]; | |
| } | |
| /** | |
| * @param list<string> $columnNames | |
| */ | |
| private function buildDocBookTableCell(\DOMElement $entry, array $columnNames, bool $header): AstNode | |
| { | |
| $children = $this->parseDocBookInlineNodes($entry); | |
| $attrs = [ | |
| 'text' => $this->plainTextFromInlines($children), | |
| 'header' => $header, | |
| ]; | |
| $align = $this->normalizeDocBookAlignment($entry->getAttribute('align')); | |
| if ($align !== 'default') { | |
| $attrs['align'] = $align; | |
| } | |
| $colSpan = $this->docBookColumnSpan($entry, $columnNames); | |
| if ($colSpan > 1) { | |
| $attrs['colspan'] = $colSpan; | |
| } | |
| $moreRows = trim($entry->getAttribute('morerows')); | |
| if (preg_match('/^\d+$/', $moreRows) === 1 && (int) $moreRows > 0) { | |
| $attrs['rowspan'] = (int) $moreRows + 1; | |
| } | |
| return new AstNode('table_cell', $attrs, $children); | |
| } | |
| /** | |
| * @param list<string> $columnNames | |
| */ | |
| private function docBookColumnSpan(\DOMElement $entry, array $columnNames): int | |
| { | |
| $startName = trim($entry->getAttribute('namest')); | |
| $endName = trim($entry->getAttribute('nameend')); | |
| if ($startName === '' || $endName === '') { | |
| return 1; | |
| } | |
| $start = array_search($startName, $columnNames, true); | |
| $end = array_search($endName, $columnNames, true); | |
| if (!is_int($start) || !is_int($end) || $end < $start) { | |
| return 1; | |
| } | |
| return $end - $start + 1; | |
| } | |
| private function normalizeDocBookAlignment(string $alignment): string | |
| { | |
| return match (strtolower(trim($alignment))) { | |
| 'left' => 'left', | |
| 'right' => 'right', | |
| 'center' => 'center', | |
| default => 'default', | |
| }; | |
| } | |
| /** | |
| * @return list<AstNode> | |
| */ | |
| private function parseDocBookInlineNodes(\DOMNode $node): array | |
| { | |
| $nodes = []; | |
| foreach ($node->childNodes as $child) { | |
| if ($child instanceof \DOMText || $child instanceof \DOMCdataSection) { | |
| $this->appendDocBookTextNode($nodes, $child->wholeText); | |
| continue; | |
| } | |
| if (!$child instanceof \DOMElement) { | |
| continue; | |
| } | |
| $name = strtolower($child->localName); | |
| $children = $this->parseDocBookInlineNodes($child); | |
| if ($name === 'emphasis') { | |
| $role = strtolower(trim($child->getAttribute('role'))); | |
| $nodes[] = new AstNode($role === 'strong' || $role === 'bold' ? 'strong' : 'emph', [], $children); | |
| continue; | |
| } | |
| if (in_array($name, ['code', 'command', 'filename', 'literal'], true)) { | |
| $nodes[] = new AstNode('code', ['text' => trim(preg_replace('/\s+/', ' ', $child->textContent) ?? $child->textContent)]); | |
| continue; | |
| } | |
| if ($name === 'ulink' || $name === 'link') { | |
| $url = $child->getAttribute('url') ?: $child->getAttribute('href'); | |
| $nodes[] = new AstNode('link', ['url' => $url, 'title' => ''], $children); | |
| continue; | |
| } | |
| array_push($nodes, ...$children); | |
| } | |
| return $nodes; | |
| } | |
| /** | |
| * @param list<AstNode> $nodes | |
| */ | |
| private function appendDocBookTextNode(array &$nodes, string $text): void | |
| { | |
| $text = preg_replace('/\s+/', ' ', $text) ?? $text; | |
| $text = trim($text); | |
| if ($text === '') { | |
| return; | |
| } | |
| $nodes[] = new AstNode('text', ['text' => $text]); | |
| } | |
| private function firstDescendantElement(\DOMElement $root, string $name): ?\DOMElement | |
| { | |
| foreach ($root->getElementsByTagName($name) as $element) { | |
| if ($element instanceof \DOMElement) { | |
| return $element; | |
| } | |
| } | |
| return null; | |
| } | |
| private function firstChildElement(\DOMElement $root, string $name): ?\DOMElement | |
| { | |
| foreach ($root->childNodes as $child) { | |
| if ($child instanceof \DOMElement && strtolower($child->localName) === $name) { | |
| return $child; | |
| } | |
| } | |
| return null; | |
| } | |
| /** | |
| * @return list<\DOMElement> | |
| */ | |
| private function childElements(\DOMElement $root, string $name): array | |
| { | |
| $children = []; | |
| foreach ($root->childNodes as $child) { | |
| if ($child instanceof \DOMElement && strtolower($child->localName) === $name) { | |
| $children[] = $child; | |
| } | |
| } | |
| return $children; | |
| } | |
| /** | |
| * @param list<string> $lines | |
| */ | |
| private function tryReadRawTexBlock(array $lines, int &$index): ?AstNode | |
| { | |
| $line = $lines[$index] ?? ''; | |
| $macro = $this->tryReadRawTexMacroDefinition($line); | |
| if ($macro !== null) { | |
| $this->rawTexMacros[$macro['name']] = [ | |
| 'arity' => $macro['arity'], | |
| 'template' => $macro['template'], | |
| ]; | |
| return new AstNode('raw_tex', [ | |
| 'tex' => trim($line), | |
| 'command' => $macro['command'], | |
| ]); | |
| } | |
| if (preg_match('/^ {0,3}\\\\placeformula\s+\\\\startformula(?:\s.*)?$/', $line) === 1) { | |
| return new AstNode('raw_tex', [ | |
| 'tex' => trim($line), | |
| 'environment' => 'context-formula', | |
| ]); | |
| } | |
| $contextBlock = $this->tryReadContextStartStopBlock($lines, $index); | |
| if ($contextBlock !== null) { | |
| return $contextBlock; | |
| } | |
| if (preg_match('/^ {0,3}\\\\begin\{([^}\s]+)\}/', $line, $m) !== 1) { | |
| return null; | |
| } | |
| $environment = $m[1]; | |
| $content = []; | |
| $cursor = $index; | |
| $count = count($lines); | |
| $closingPattern = '/\\\\end\{' . preg_quote($environment, '/') . '\}/'; | |
| while ($cursor < $count) { | |
| $content[] = rtrim($lines[$cursor]); | |
| if (preg_match($closingPattern, $lines[$cursor]) === 1) { | |
| $index = $cursor; | |
| return new AstNode('raw_tex', [ | |
| 'tex' => implode("\n", $content), | |
| 'environment' => $environment, | |
| ]); | |
| } | |
| $cursor++; | |
| } | |
| return null; | |
| } | |
| /** | |
| * @return array{command:string, name:string, arity:int, template:string}|null | |
| */ | |
| private function tryReadRawTexMacroDefinition(string $line): ?array | |
| { | |
| if ( | |
| preg_match( | |
| '/^ {0,3}\\\\((?:re)?newcommand|providecommand)\{\\\\([A-Za-z]+)\}(?:\[(\d+)])?(?:\[[^\]\r\n]*])?\{((?:\\\\.|[^{}])*)\}[ \t]*$/', | |
| $line, | |
| $m | |
| ) !== 1 | |
| ) { | |
| return null; | |
| } | |
| return [ | |
| 'command' => $m[1], | |
| 'name' => $m[2], | |
| 'arity' => isset($m[3]) && $m[3] !== '' ? (int) $m[3] : 0, | |
| 'template' => $m[4], | |
| ]; | |
| } | |
| /** | |
| * @param list<string> $lines | |
| */ | |
| private function tryReadContextStartStopBlock(array $lines, int &$index): ?AstNode | |
| { | |
| $line = $lines[$index] ?? ''; | |
| if (preg_match('/^ {0,3}\\\\start\[([^\]\r\n]+)]\s*$/', $line, $m) !== 1) { | |
| return null; | |
| } | |
| $environment = $m[1]; | |
| $content = []; | |
| $depth = 0; | |
| $cursor = $index; | |
| $count = count($lines); | |
| $startPattern = '/^ {0,3}\\\\start\[' . preg_quote($environment, '/') . ']\s*$/'; | |
| $stopPattern = '/^ {0,3}\\\\stop\[' . preg_quote($environment, '/') . ']\s*$/'; | |
| while ($cursor < $count) { | |
| $current = rtrim($lines[$cursor]); | |
| if (preg_match($startPattern, $current) === 1) { | |
| $depth++; | |
| } | |
| $content[] = $current; | |
| if (preg_match($stopPattern, $current) === 1) { | |
| $depth--; | |
| if ($depth === 0) { | |
| $index = $cursor; | |
| return new AstNode('raw_tex', [ | |
| 'tex' => implode("\n", $content), | |
| 'environment' => 'context:' . $environment, | |
| ]); | |
| } | |
| } | |
| $cursor++; | |
| } | |
| return null; | |
| } | |
| /** | |
| * @param list<string> $lines | |
| */ | |
| private function tryReadGridTable(array $lines, int &$index): ?AstNode | |
| { | |
| $rectangular = $this->tryReadRectangularGridTable($lines, $index); | |
| if ($rectangular instanceof AstNode) { | |
| return $rectangular; | |
| } | |
| return $this->tryReadSpannedGridTable($lines, $index); | |
| } | |
| /** | |
| * @param list<string> $lines | |
| */ | |
| private function tryReadRectangularGridTable(array $lines, int &$index): ?AstNode | |
| { | |
| $firstBoundary = $this->parseGridTableBoundary($lines[$index] ?? ''); | |
| if ($firstBoundary === null) { | |
| return null; | |
| } | |
| $cursor = $index + 1; | |
| $count = count($lines); | |
| $columnCount = count($firstBoundary['widths']); | |
| $alignments = $firstBoundary['alignments']; | |
| $widths = $firstBoundary['widths']; | |
| $headerRow = null; | |
| $bodyRows = []; | |
| $sawHeader = false; | |
| while ($cursor < $count) { | |
| $sectionLines = []; | |
| while ($cursor < $count && $this->isGridTableContentLine($lines[$cursor])) { | |
| $sectionLines[] = $lines[$cursor]; | |
| $cursor++; | |
| } | |
| if ($sectionLines === [] || $cursor >= $count) { | |
| return null; | |
| } | |
| $boundary = $this->parseGridTableBoundary($lines[$cursor]); | |
| if ($boundary === null || count($boundary['widths']) !== $columnCount) { | |
| return null; | |
| } | |
| $cellLines = $this->splitGridTableSectionCellLines($sectionLines, $columnCount); | |
| if ($cellLines === null) { | |
| return null; | |
| } | |
| if (!$sawHeader && $boundary['header']) { | |
| $headerRow = $this->buildGridTableRow($cellLines, true); | |
| $alignments = $boundary['alignments']; | |
| $widths = $boundary['widths']; | |
| $sawHeader = true; | |
| $cursor++; | |
| continue; | |
| } | |
| $bodyRows[] = $this->buildGridTableRow($cellLines, false); | |
| $cursor++; | |
| if ($cursor < $count && $this->isGridTableContentLine($lines[$cursor])) { | |
| continue; | |
| } | |
| [$caption, $next] = $this->readTableCaption($lines, $cursor); | |
| $index = $next - 1; | |
| return $this->buildGridTable($headerRow, $bodyRows, $alignments, $caption, $widths); | |
| } | |
| return null; | |
| } | |
| /** | |
| * @param list<string> $lines | |
| */ | |
| private function tryReadSpannedGridTable(array $lines, int &$index): ?AstNode | |
| { | |
| $tableLines = $this->collectSpannedGridTableLines($lines, $index); | |
| if ($tableLines === null || !$this->gridTableLinesContainSpans($tableLines)) { | |
| return null; | |
| } | |
| $positions = $this->spannedGridTableColumnPositions($tableLines); | |
| if (count($positions) < 3) { | |
| return null; | |
| } | |
| $events = $this->spannedGridTableEvents($tableLines, $positions); | |
| if (count($events) < 2 || $events[0]['line'] !== 0) { | |
| return null; | |
| } | |
| $lastEvent = $events[count($events) - 1]; | |
| if ($lastEvent['line'] !== count($tableLines) - 1) { | |
| return null; | |
| } | |
| $headerBoundaryIndex = null; | |
| $alignments = array_fill(0, count($positions) - 1, 'default'); | |
| foreach ($events as $eventIndex => $event) { | |
| if ($eventIndex === 0 || !$this->spannedGridEventCoversAllColumns($event)) { | |
| continue; | |
| } | |
| if (in_array('header', $event['markers'], true)) { | |
| $headerBoundaryIndex = $eventIndex; | |
| $alignments = $this->spannedGridTableAlignments($tableLines[$event['line']], $positions); | |
| break; | |
| } | |
| } | |
| if ($headerBoundaryIndex === null) { | |
| return null; | |
| } | |
| $headerRows = []; | |
| $bodyRows = []; | |
| $covered = []; | |
| $rowBandCount = count($events) - 1; | |
| $columnCount = count($positions) - 1; | |
| for ($rowIndex = 0; $rowIndex < $rowBandCount; $rowIndex++) { | |
| $isHeader = $rowIndex < $headerBoundaryIndex; | |
| $rowCells = []; | |
| for ($columnIndex = 0; $columnIndex < $columnCount; $columnIndex++) { | |
| if (($covered[$rowIndex][$columnIndex] ?? false) === true) { | |
| continue; | |
| } | |
| if (!$events[$rowIndex]['coverage'][$columnIndex]) { | |
| return null; | |
| } | |
| $colspan = $this->spannedGridTableCellColspan($tableLines[$events[$rowIndex]['line']], $positions, $columnIndex); | |
| $rowspan = $this->spannedGridTableCellRowspan($events, $rowIndex, $columnIndex, $colspan); | |
| for ($coveredRow = $rowIndex + 1; $coveredRow < $rowIndex + $rowspan; $coveredRow++) { | |
| for ($coveredColumn = $columnIndex; $coveredColumn < $columnIndex + $colspan; $coveredColumn++) { | |
| $covered[$coveredRow][$coveredColumn] = true; | |
| } | |
| } | |
| $cellLines = $this->spannedGridTableCellLines($tableLines, $events, $positions, $rowIndex, $columnIndex, $rowspan, $colspan); | |
| $rowCells[] = $this->buildGridTableCell($cellLines, $isHeader, $rowspan, $colspan); | |
| $columnIndex += $colspan - 1; | |
| } | |
| $row = new AstNode('table_row', ['header' => $isHeader], $rowCells); | |
| if ($isHeader) { | |
| $headerRows[] = $row; | |
| } else { | |
| $bodyRows[] = $row; | |
| } | |
| } | |
| $cursor = $index + count($tableLines); | |
| [$caption, $next] = $this->readTableCaption($lines, $cursor); | |
| $index = $next - 1; | |
| return $this->buildGridTableRows($headerRows, $bodyRows, $alignments, $caption, $this->spannedGridTableWidths($positions)); | |
| } | |
| /** | |
| * @return array{alignments:list<string>, widths:list<float>, header:bool}|null | |
| */ | |
| private function parseGridTableBoundary(string $line): ?array | |
| { | |
| $line = rtrim($this->expandTabsToSpaces($line)); | |
| if (preg_match('/^ {0,3}(\+[-=:]+(?:\+[-=:]+)+\+)$/', $line, $m) !== 1) { | |
| return null; | |
| } | |
| $parts = explode('+', substr($m[1], 1, -1)); | |
| if ($parts === []) { | |
| return null; | |
| } | |
| $header = false; | |
| $alignments = []; | |
| $widths = []; | |
| foreach ($parts as $part) { | |
| if ($part === '' || preg_match('/^:?[-=]+:?$/', $part) !== 1) { | |
| return null; | |
| } | |
| $usesHeaderMarker = str_contains($part, '='); | |
| $usesBodyMarker = str_contains($part, '-'); | |
| if ($usesHeaderMarker && $usesBodyMarker) { | |
| return null; | |
| } | |
| $header = $header || $usesHeaderMarker; | |
| $left = str_starts_with($part, ':'); | |
| $right = str_ends_with($part, ':'); | |
| $alignments[] = match (true) { | |
| $left && $right => 'center', | |
| $left => 'left', | |
| $right => 'right', | |
| default => 'default', | |
| }; | |
| $widths[] = (strlen($part) + 1) / 72; | |
| } | |
| if ($header) { | |
| foreach ($parts as $part) { | |
| if (!str_contains($part, '=')) { | |
| return null; | |
| } | |
| } | |
| } | |
| return [ | |
| 'alignments' => $alignments, | |
| 'widths' => $widths, | |
| 'header' => $header, | |
| ]; | |
| } | |
| /** | |
| * @param list<string> $lines | |
| * @return list<string>|null | |
| */ | |
| private function collectSpannedGridTableLines(array $lines, int $index): ?array | |
| { | |
| if ($this->parseGridTableBoundary($lines[$index] ?? '') === null) { | |
| return null; | |
| } | |
| $tableLines = []; | |
| $cursor = $index; | |
| $count = count($lines); | |
| while ($cursor < $count) { | |
| $line = $this->normalizeSpannedGridTableLine($lines[$cursor]); | |
| if (!$this->isSpannedGridTableLine($line)) { | |
| break; | |
| } | |
| $tableLines[] = $line; | |
| $cursor++; | |
| } | |
| return count($tableLines) >= 3 ? $tableLines : null; | |
| } | |
| private function normalizeSpannedGridTableLine(string $line): string | |
| { | |
| $line = rtrim($this->expandTabsToSpaces($line)); | |
| return preg_replace('/^ {0,3}/', '', $line, 1) ?? $line; | |
| } | |
| private function isSpannedGridTableLine(string $line): bool | |
| { | |
| if ($line === '') { | |
| return false; | |
| } | |
| if ($line[0] === '+') { | |
| return preg_match('/^\+(?:[-=:]+\+)+$/', $line) === 1; | |
| } | |
| return $line[0] === '|' && preg_match('/[|+]\s*$/', $line) === 1; | |
| } | |
| /** | |
| * @param list<string> $lines | |
| */ | |
| private function gridTableLinesContainSpans(array $lines): bool | |
| { | |
| $positionSets = []; | |
| foreach ($lines as $line) { | |
| if ($line[0] === '|' && preg_match('/\+[-=:]+\+/', $line) === 1) { | |
| return true; | |
| } | |
| $positions = $this->spannedGridTableHorizontalPlusPositions($line); | |
| if ($positions !== []) { | |
| $positionSets[implode(',', $positions)] = true; | |
| } | |
| } | |
| return count($positionSets) > 1; | |
| } | |
| /** | |
| * @param list<string> $lines | |
| * @return list<int> | |
| */ | |
| private function spannedGridTableColumnPositions(array $lines): array | |
| { | |
| $positions = []; | |
| foreach ($lines as $line) { | |
| foreach ($this->spannedGridTableHorizontalPlusPositions($line) as $position) { | |
| $positions[$position] = true; | |
| } | |
| } | |
| $positions = array_keys($positions); | |
| sort($positions, SORT_NUMERIC); | |
| return array_values($positions); | |
| } | |
| /** | |
| * @return list<int> | |
| */ | |
| private function spannedGridTableHorizontalPlusPositions(string $line): array | |
| { | |
| $positions = []; | |
| $chars = $this->unicodeChars($line); | |
| $count = count($chars); | |
| for ($offset = 0; $offset < $count; $offset++) { | |
| if ($chars[$offset] !== '+') { | |
| continue; | |
| } | |
| $before = $chars[$offset - 1] ?? ''; | |
| $after = $chars[$offset + 1] ?? ''; | |
| if ($this->isGridHorizontalChar($before) || $this->isGridHorizontalChar($after)) { | |
| $positions[] = $offset; | |
| } | |
| } | |
| return $positions; | |
| } | |
| /** | |
| * @param list<string> $lines | |
| * @param list<int> $positions | |
| * @return list<array{line:int, coverage:list<bool>, markers:list<string>}> | |
| */ | |
| private function spannedGridTableEvents(array $lines, array $positions): array | |
| { | |
| $events = []; | |
| foreach ($lines as $lineIndex => $line) { | |
| $coverage = []; | |
| $markers = []; | |
| $sawCoverage = false; | |
| for ($columnIndex = 0; $columnIndex < count($positions) - 1; $columnIndex++) { | |
| $marker = $this->spannedGridTableBoundaryMarker($line, $positions[$columnIndex], $positions[$columnIndex + 1]); | |
| $coverage[] = $marker !== null; | |
| $markers[] = $marker ?? 'none'; | |
| $sawCoverage = $sawCoverage || $marker !== null; | |
| } | |
| if ($sawCoverage) { | |
| $events[] = [ | |
| 'line' => $lineIndex, | |
| 'coverage' => $coverage, | |
| 'markers' => $markers, | |
| ]; | |
| } | |
| } | |
| return $events; | |
| } | |
| private function spannedGridTableBoundaryMarker(string $line, int $start, int $end): ?string | |
| { | |
| if ($end <= $start + 1) { | |
| return null; | |
| } | |
| $inner = $this->spannedGridTableInnerSegment($line, $start, $end); | |
| if ($inner === '' || preg_match('/^[-=:]+$/', $inner) !== 1 || preg_match('/[-=]/', $inner) !== 1) { | |
| return null; | |
| } | |
| return str_contains($inner, '=') ? 'header' : 'body'; | |
| } | |
| private function isGridHorizontalChar(string $char): bool | |
| { | |
| return $char === '-' || $char === '=' || $char === ':'; | |
| } | |
| /** | |
| * @param array{line:int, coverage:list<bool>, markers:list<string>} $event | |
| */ | |
| private function spannedGridEventCoversAllColumns(array $event): bool | |
| { | |
| foreach ($event['coverage'] as $covered) { | |
| if (!$covered) { | |
| return false; | |
| } | |
| } | |
| return true; | |
| } | |
| /** | |
| * @param list<int> $positions | |
| * @return list<string> | |
| */ | |
| private function spannedGridTableAlignments(string $line, array $positions): array | |
| { | |
| $alignments = []; | |
| for ($columnIndex = 0; $columnIndex < count($positions) - 1; $columnIndex++) { | |
| $part = $this->spannedGridTableInnerSegment($line, $positions[$columnIndex], $positions[$columnIndex + 1]); | |
| $left = str_starts_with($part, ':'); | |
| $right = str_ends_with($part, ':'); | |
| $alignments[] = match (true) { | |
| $left && $right => 'center', | |
| $left => 'left', | |
| $right => 'right', | |
| default => 'default', | |
| }; | |
| } | |
| return $alignments; | |
| } | |
| /** | |
| * @param list<int> $positions | |
| */ | |
| private function spannedGridTableCellColspan(string $eventLine, array $positions, int $columnIndex): int | |
| { | |
| $chars = $this->unicodeChars($eventLine); | |
| $columnCount = count($positions) - 1; | |
| $colspan = 1; | |
| while ( | |
| $columnIndex + $colspan < $columnCount | |
| && ($chars[$positions[$columnIndex + $colspan]] ?? '') !== '+' | |
| ) { | |
| $colspan++; | |
| } | |
| return $colspan; | |
| } | |
| /** | |
| * @param list<array{line:int, coverage:list<bool>, markers:list<string>}> $events | |
| */ | |
| private function spannedGridTableCellRowspan(array $events, int $rowIndex, int $columnIndex, int $colspan): int | |
| { | |
| $rowspan = 1; | |
| while ($rowIndex + $rowspan < count($events) - 1) { | |
| $boundary = $events[$rowIndex + $rowspan]; | |
| $covered = true; | |
| for ($column = $columnIndex; $column < $columnIndex + $colspan; $column++) { | |
| if (!$boundary['coverage'][$column]) { | |
| $covered = false; | |
| break; | |
| } | |
| } | |
| if ($covered) { | |
| break; | |
| } | |
| $rowspan++; | |
| } | |
| return $rowspan; | |
| } | |
| /** | |
| * @param list<string> $tableLines | |
| * @param list<array{line:int, coverage:list<bool>, markers:list<string>}> $events | |
| * @param list<int> $positions | |
| * @return list<string> | |
| */ | |
| private function spannedGridTableCellLines(array $tableLines, array $events, array $positions, int $rowIndex, int $columnIndex, int $rowspan, int $colspan): array | |
| { | |
| $lines = []; | |
| $top = $events[$rowIndex]['line']; | |
| $bottom = $events[$rowIndex + $rowspan]['line']; | |
| $start = $positions[$columnIndex]; | |
| $end = $positions[$columnIndex + $colspan]; | |
| for ($lineIndex = $top + 1; $lineIndex < $bottom; $lineIndex++) { | |
| $lines[] = $this->spannedGridTableSlice($tableLines[$lineIndex], $start, $end); | |
| } | |
| return $lines; | |
| } | |
| private function spannedGridTableSlice(string $line, int $start, int $end): string | |
| { | |
| $chars = $this->unicodeChars($line); | |
| $slice = ''; | |
| for ($offset = $start + 1; $offset < $end; $offset++) { | |
| $slice .= $chars[$offset] ?? ' '; | |
| } | |
| return $slice; | |
| } | |
| private function spannedGridTableInnerSegment(string $line, int $start, int $end): string | |
| { | |
| $chars = $this->unicodeChars($line); | |
| $segment = ''; | |
| for ($offset = $start + 1; $offset < $end; $offset++) { | |
| $segment .= $chars[$offset] ?? ' '; | |
| } | |
| return $segment; | |
| } | |
| /** | |
| * @param list<int> $positions | |
| * @return list<float> | |
| */ | |
| private function spannedGridTableWidths(array $positions): array | |
| { | |
| $widths = []; | |
| for ($index = 0; $index < count($positions) - 1; $index++) { | |
| $widths[] = ($positions[$index + 1] - $positions[$index]) / 72; | |
| } | |
| return $widths; | |
| } | |
| /** | |
| * @return list<string> | |
| */ | |
| private function unicodeChars(string $line): array | |
| { | |
| $chars = preg_split('//u', $line, -1, PREG_SPLIT_NO_EMPTY); | |
| return $chars === false ? str_split($line) : $chars; | |
| } | |
| private function isGridTableContentLine(string $line): bool | |
| { | |
| return preg_match('/^ {0,3}\|.*\|\s*$/', $this->expandTabsToSpaces($line)) === 1; | |
| } | |
| /** | |
| * @param list<string> $lines | |
| * @return list<list<string>>|null | |
| */ | |
| private function splitGridTableSectionCellLines(array $lines, int $columnCount): ?array | |
| { | |
| $cells = array_fill(0, $columnCount, []); | |
| foreach ($lines as $line) { | |
| $parts = $this->splitGridTableContentLine($line, $columnCount); | |
| if ($parts === null) { | |
| return null; | |
| } | |
| foreach ($parts as $cellIndex => $part) { | |
| $cells[$cellIndex][] = $part; | |
| } | |
| } | |
| return $cells; | |
| } | |
| /** | |
| * @return list<string>|null | |
| */ | |
| private function splitGridTableContentLine(string $line, int $columnCount): ?array | |
| { | |
| $line = rtrim($this->expandTabsToSpaces($line)); | |
| $line = preg_replace('/^ {0,3}/', '', $line, 1) ?? $line; | |
| if ($line === '' || $line[0] !== '|' || $line[strlen($line) - 1] !== '|') { | |
| return null; | |
| } | |
| $parts = explode('|', substr($line, 1, -1)); | |
| if (count($parts) !== $columnCount) { | |
| return null; | |
| } | |
| return array_map(static fn (string $part): string => trim($part), $parts); | |
| } | |
| /** | |
| * @param list<string> $lines | |
| */ | |
| private function mergeGridTableCellLines(array $lines): string | |
| { | |
| $cell = ''; | |
| foreach ($lines as $line) { | |
| $line = trim($line); | |
| if ($line === '') { | |
| continue; | |
| } | |
| $cell = $cell === '' ? $line : $cell . "\n" . $line; | |
| } | |
| return $cell; | |
| } | |
| /** | |
| * @param list<string> $lines | |
| */ | |
| private function gridTableCellNeedsBlockParsing(array $lines): bool | |
| { | |
| $sawContent = false; | |
| $sawBlankAfterContent = false; | |
| foreach ($lines as $line) { | |
| $line = trim($line); | |
| if ($line === '') { | |
| if ($sawContent) { | |
| $sawBlankAfterContent = true; | |
| } | |
| continue; | |
| } | |
| if (preg_match('/^(?:#{1,6}\s+|[-*+]\s+|\d{1,9}[.)]\s+)/', $line) === 1) { | |
| return true; | |
| } | |
| if ($sawBlankAfterContent) { | |
| return true; | |
| } | |
| $sawContent = true; | |
| } | |
| return false; | |
| } | |
| /** | |
| * @param list<string> $lines | |
| * @return list<AstNode> | |
| */ | |
| private function readGridTableCellBlocks(array $lines): array | |
| { | |
| while ($lines !== [] && trim($lines[0]) === '') { | |
| array_shift($lines); | |
| } | |
| while ($lines !== [] && trim($lines[count($lines) - 1]) === '') { | |
| array_pop($lines); | |
| } | |
| if ($lines === []) { | |
| return []; | |
| } | |
| return $this->read(implode("\n", $lines))->children; | |
| } | |
| /** | |
| * @param list<string> $lines | |
| */ | |
| private function tryReadSimpleTable(array $lines, int &$index): ?AstNode | |
| { | |
| if (!isset($lines[$index + 1])) { | |
| return null; | |
| } | |
| $multilineTable = $this->tryReadMultilineSimpleTableWithHeader($lines, $index); | |
| if ($multilineTable !== null) { | |
| return $multilineTable; | |
| } | |
| $headerColumns = $this->parseSimpleTableDelimiter($lines[$index + 1]); | |
| if ($headerColumns !== null) { | |
| return $this->readSimpleTableWithHeader($lines, $index, $headerColumns); | |
| } | |
| $bodyColumns = $this->parseSimpleTableDelimiter($lines[$index]); | |
| if ($bodyColumns !== null) { | |
| return $this->readSimpleTableWithoutHeader($lines, $index, $bodyColumns); | |
| } | |
| return null; | |
| } | |
| /** | |
| * @param list<string> $lines | |
| */ | |
| private function tryReadMultilineSimpleTableWithHeader(array $lines, int &$index): ?AstNode | |
| { | |
| if (!$this->isSimpleTableBoundary($lines[$index] ?? '')) { | |
| return null; | |
| } | |
| $cursor = $index + 1; | |
| $count = count($lines); | |
| $headerLines = []; | |
| $columns = null; | |
| while ($cursor < $count) { | |
| if (trim($lines[$cursor]) === '' || $this->isSimpleTableBoundary($lines[$cursor])) { | |
| return null; | |
| } | |
| $columns = $this->parseSimpleTableDelimiter($lines[$cursor]); | |
| if ($columns !== null) { | |
| break; | |
| } | |
| $headerLines[] = $lines[$cursor]; | |
| $cursor++; | |
| } | |
| if ($columns === null || $headerLines === []) { | |
| return null; | |
| } | |
| [$bodyRows, $closingIndex] = $this->readSimpleTableRowsUntilBoundary($lines, $cursor + 1, $columns); | |
| if ($closingIndex === null || $bodyRows === []) { | |
| return null; | |
| } | |
| [$caption, $next] = $this->readTableCaption($lines, $closingIndex + 1); | |
| $index = $next - 1; | |
| return $this->buildSimpleTable( | |
| $this->mergeSimpleTableLines($headerLines, $columns), | |
| $bodyRows, | |
| $this->detectMultilineSimpleTableAlignments($headerLines, $columns), | |
| $caption, | |
| $this->detectSimpleTableWidths($columns) | |
| ); | |
| } | |
| /** | |
| * @param list<string> $lines | |
| * @param list<array{start:int, length:int}> $columns | |
| */ | |
| private function readSimpleTableWithHeader(array $lines, int &$index, array $columns): ?AstNode | |
| { | |
| $headerLine = $lines[$index]; | |
| if (trim($headerLine) === '' || $this->parseSimpleTableDelimiter($headerLine) !== null) { | |
| return null; | |
| } | |
| $cursor = $index + 2; | |
| $bodyRows = []; | |
| $count = count($lines); | |
| while ($cursor < $count && trim($lines[$cursor]) !== '') { | |
| if ($this->parseSimpleTableDelimiter($lines[$cursor]) !== null) { | |
| break; | |
| } | |
| $bodyRows[] = $this->splitSimpleTableLine($lines[$cursor], $columns); | |
| $cursor++; | |
| } | |
| if ($bodyRows === []) { | |
| return null; | |
| } | |
| [$caption, $next] = $this->readTableCaption($lines, $cursor); | |
| $index = $next - 1; | |
| return $this->buildSimpleTable( | |
| $this->splitSimpleTableLine($headerLine, $columns), | |
| $bodyRows, | |
| $this->detectSimpleTableAlignments($headerLine, $columns), | |
| $caption | |
| ); | |
| } | |
| /** | |
| * @param list<string> $lines | |
| * @param list<array{start:int, length:int}> $columns | |
| */ | |
| private function readSimpleTableWithoutHeader(array $lines, int &$index, array $columns): ?AstNode | |
| { | |
| [$bodyRows, $closingIndex] = $this->readSimpleTableRowsUntilColumnDelimiter($lines, $index + 1, $columns); | |
| if ($closingIndex === null || $bodyRows === []) { | |
| return null; | |
| } | |
| $alignments = $this->detectSimpleTableAlignments($lines[$index + 1], $columns); | |
| [$caption, $next] = $this->readTableCaption($lines, $closingIndex + 1); | |
| $index = $next - 1; | |
| return $this->buildSimpleTable( | |
| null, | |
| $bodyRows, | |
| $alignments, | |
| $caption, | |
| $this->detectSimpleTableWidths($columns) | |
| ); | |
| } | |
| /** | |
| * @return list<array{start:int, length:int}>|null | |
| */ | |
| private function parseSimpleTableDelimiter(string $line): ?array | |
| { | |
| if (preg_match('/^[ \t-]+$/', $line) !== 1) { | |
| return null; | |
| } | |
| preg_match_all('/-+/', $line, $matches, PREG_OFFSET_CAPTURE); | |
| $columns = []; | |
| foreach ($matches[0] as $match) { | |
| $marker = $match[0]; | |
| if (strlen($marker) < 3) { | |
| return null; | |
| } | |
| $columns[] = [ | |
| 'start' => (int) $match[1], | |
| 'length' => strlen($marker), | |
| ]; | |
| } | |
| return count($columns) >= 2 ? $columns : null; | |
| } | |
| private function isSimpleTableBoundary(string $line): bool | |
| { | |
| return preg_match('/^[ \t]*-{3,}[ \t]*$/', $line) === 1; | |
| } | |
| /** | |
| * @param list<string> $lines | |
| * @param list<array{start:int, length:int}> $columns | |
| * @return array{0:list<list<string>>, 1:int|null} | |
| */ | |
| private function readSimpleTableRowsUntilBoundary(array $lines, int $cursor, array $columns): array | |
| { | |
| $rows = []; | |
| $current = null; | |
| $count = count($lines); | |
| while ($cursor < $count) { | |
| if ($this->isSimpleTableBoundary($lines[$cursor])) { | |
| $this->flushSimpleTableRow($current, $rows); | |
| return [$rows, $cursor]; | |
| } | |
| $this->appendSimpleTableBodyLine($lines[$cursor], $columns, $current, $rows); | |
| $cursor++; | |
| } | |
| return [$rows, null]; | |
| } | |
| /** | |
| * @param list<string> $lines | |
| * @param list<array{start:int, length:int}> $columns | |
| * @return array{0:list<list<string>>, 1:int|null} | |
| */ | |
| private function readSimpleTableRowsUntilColumnDelimiter(array $lines, int $cursor, array $columns): array | |
| { | |
| $rows = []; | |
| $current = null; | |
| $count = count($lines); | |
| while ($cursor < $count) { | |
| $closingColumns = $this->parseSimpleTableDelimiter($lines[$cursor]); | |
| if ($closingColumns !== null && count($closingColumns) === count($columns)) { | |
| $this->flushSimpleTableRow($current, $rows); | |
| return [$rows, $cursor]; | |
| } | |
| $this->appendSimpleTableBodyLine($lines[$cursor], $columns, $current, $rows); | |
| $cursor++; | |
| } | |
| return [$rows, null]; | |
| } | |
| /** | |
| * @param list<array{start:int, length:int}> $columns | |
| * @param list<string>|null $current | |
| * @param list<list<string>> $rows | |
| */ | |
| private function appendSimpleTableBodyLine(string $line, array $columns, ?array &$current, array &$rows): void | |
| { | |
| if (trim($line) === '') { | |
| $this->flushSimpleTableRow($current, $rows); | |
| return; | |
| } | |
| $cells = $this->splitSimpleTableLine($line, $columns); | |
| $firstNonEmpty = $this->firstNonEmptyCellIndex($cells); | |
| if ($firstNonEmpty === null) { | |
| return; | |
| } | |
| if ($current !== null && $firstNonEmpty === 0) { | |
| $this->flushSimpleTableRow($current, $rows); | |
| } | |
| $current ??= array_fill(0, count($columns), ''); | |
| $this->mergeSimpleTableCellsInto($current, $cells); | |
| } | |
| /** | |
| * @param list<string>|null $current | |
| * @param list<list<string>> $rows | |
| */ | |
| private function flushSimpleTableRow(?array &$current, array &$rows): void | |
| { | |
| if ($current === null) { | |
| return; | |
| } | |
| $rows[] = $current; | |
| $current = null; | |
| } | |
| /** | |
| * @param list<string> $cells | |
| */ | |
| private function firstNonEmptyCellIndex(array $cells): ?int | |
| { | |
| foreach ($cells as $index => $cell) { | |
| if (trim($cell) !== '') { | |
| return $index; | |
| } | |
| } | |
| return null; | |
| } | |
| /** | |
| * @param list<string> $current | |
| * @param list<string> $cells | |
| */ | |
| private function mergeSimpleTableCellsInto(array &$current, array $cells): void | |
| { | |
| foreach ($cells as $index => $cell) { | |
| $cell = trim($cell); | |
| if ($cell === '') { | |
| continue; | |
| } | |
| $current[$index] = $current[$index] === '' ? $cell : $current[$index] . "\n" . $cell; | |
| } | |
| } | |
| /** | |
| * @param list<string> $lines | |
| * @param list<array{start:int, length:int}> $columns | |
| * @return list<string> | |
| */ | |
| private function mergeSimpleTableLines(array $lines, array $columns): array | |
| { | |
| $merged = array_fill(0, count($columns), ''); | |
| foreach ($lines as $line) { | |
| $this->mergeSimpleTableCellsInto($merged, $this->splitSimpleTableLine($line, $columns)); | |
| } | |
| return $merged; | |
| } | |
| /** | |
| * @param list<array{start:int, length:int}> $columns | |
| * @return list<string> | |
| */ | |
| private function splitSimpleTableLine(string $line, array $columns): array | |
| { | |
| $cells = []; | |
| $lineLength = strlen($line); | |
| foreach ($columns as $column) { | |
| $start = $column['start']; | |
| $length = $column['length']; | |
| $cell = $start < $lineLength ? substr($line, $start, $length) : ''; | |
| $cells[] = trim($cell); | |
| } | |
| return $cells; | |
| } | |
| /** | |
| * @param array{start:int, length:int} $column | |
| */ | |
| private function simpleTableSegment(string $line, array $column): string | |
| { | |
| $lineLength = strlen($line); | |
| $start = $column['start']; | |
| $segment = $start < $lineLength ? substr($line, $start, $column['length']) : ''; | |
| return str_pad($segment, $column['length'], ' '); | |
| } | |
| /** | |
| * @param list<array{start:int, length:int}> $columns | |
| * @return list<string> | |
| */ | |
| private function detectSimpleTableAlignments(string $sampleLine, array $columns): array | |
| { | |
| $alignments = []; | |
| $lineLength = strlen($sampleLine); | |
| foreach ($columns as $column) { | |
| $start = $column['start']; | |
| if ($start >= $lineLength) { | |
| $alignments[] = 'default'; | |
| continue; | |
| } | |
| $segment = substr($sampleLine, $start, $column['length']); | |
| if (trim($segment) === '') { | |
| $alignments[] = 'default'; | |
| continue; | |
| } | |
| $leftPad = strlen($segment) - strlen(ltrim($segment, ' ')); | |
| $rightPad = strlen($segment) - strlen(rtrim($segment, ' ')); | |
| $alignments[] = match (true) { | |
| $leftPad > 0 && $rightPad === 0 => 'right', | |
| $leftPad === 0 && $rightPad > 0 => 'left', | |
| $leftPad > 0 && $rightPad > 0 => 'center', | |
| default => 'default', | |
| }; | |
| } | |
| return $alignments; | |
| } | |
| /** | |
| * @param list<string> $headerLines | |
| * @param list<array{start:int, length:int}> $columns | |
| * @return list<string> | |
| */ | |
| private function detectMultilineSimpleTableAlignments(array $headerLines, array $columns): array | |
| { | |
| $alignments = []; | |
| foreach ($columns as $column) { | |
| $alignment = 'default'; | |
| foreach ($headerLines as $line) { | |
| $segment = $this->simpleTableSegment($line, $column); | |
| if (trim($segment) === '') { | |
| continue; | |
| } | |
| $leftPad = strlen($segment) - strlen(ltrim($segment, ' ')); | |
| $rightPad = strlen($segment) - strlen(rtrim($segment, ' ')); | |
| $alignment = match (true) { | |
| $leftPad > 0 && $rightPad === 0 => 'right', | |
| $leftPad === 0 && $rightPad > 0 => 'left', | |
| $leftPad > 0 && $rightPad > 0 => 'center', | |
| default => 'default', | |
| }; | |
| break; | |
| } | |
| $alignments[] = $alignment; | |
| } | |
| return $alignments; | |
| } | |
| /** | |
| * @param list<array{start:int, length:int}> $columns | |
| * @return list<float>|null | |
| */ | |
| private function detectSimpleTableWidths(array $columns): ?array | |
| { | |
| $maxWidth = 0; | |
| foreach ($columns as $column) { | |
| $maxWidth = max($maxWidth, $column['length']); | |
| } | |
| if ($maxWidth < 20) { | |
| return null; | |
| } | |
| return array_map( | |
| static fn (array $column): float => ($column['length'] + 1) / 80, | |
| $columns | |
| ); | |
| } | |
| /** | |
| * @param list<string>|null $headerCells | |
| * @param list<list<string>> $bodyRows | |
| * @param list<string> $alignments | |
| * @param list<float>|null $widths | |
| */ | |
| private function buildSimpleTable(?array $headerCells, array $bodyRows, array $alignments, string $caption, ?array $widths = null): AstNode | |
| { | |
| $children = []; | |
| if ($headerCells !== null) { | |
| $children[] = new AstNode('table_head', [], [ | |
| $this->buildTableRow($headerCells, true), | |
| ]); | |
| } else { | |
| $children[] = new AstNode('table_head'); | |
| } | |
| $bodyChildren = []; | |
| foreach ($bodyRows as $row) { | |
| $bodyChildren[] = $this->buildTableRow($row, false); | |
| } | |
| $children[] = new AstNode('table_body', [], $bodyChildren); | |
| $attrs = [ | |
| 'caption' => $caption, | |
| 'alignments' => $alignments, | |
| ]; | |
| if ($caption !== '') { | |
| $attrs['captionInlines'] = $this->parseInlines($caption); | |
| } | |
| if ($widths !== null) { | |
| $attrs['widths'] = $widths; | |
| } | |
| return new AstNode('table', $attrs, $children); | |
| } | |
| /** | |
| * @param AstNode|null $headerRow | |
| * @param list<AstNode> $bodyRows | |
| * @param list<string> $alignments | |
| * @param list<float>|null $widths | |
| */ | |
| private function buildGridTable(?AstNode $headerRow, array $bodyRows, array $alignments, string $caption, ?array $widths): AstNode | |
| { | |
| return $this->buildGridTableRows($headerRow instanceof AstNode ? [$headerRow] : [], $bodyRows, $alignments, $caption, $widths); | |
| } | |
| /** | |
| * @param list<AstNode> $headerRows | |
| * @param list<AstNode> $bodyRows | |
| * @param list<string> $alignments | |
| * @param list<float>|null $widths | |
| */ | |
| private function buildGridTableRows(array $headerRows, array $bodyRows, array $alignments, string $caption, ?array $widths): AstNode | |
| { | |
| $children = []; | |
| if ($headerRows !== []) { | |
| $children[] = new AstNode('table_head', [], $headerRows); | |
| } else { | |
| $children[] = new AstNode('table_head'); | |
| } | |
| $children[] = new AstNode('table_body', [], $bodyRows); | |
| $attrs = [ | |
| 'caption' => $caption, | |
| 'alignments' => $alignments, | |
| ]; | |
| if ($caption !== '') { | |
| $attrs['captionInlines'] = $this->parseInlines($caption); | |
| } | |
| if ($widths !== null) { | |
| $attrs['widths'] = $widths; | |
| } | |
| return new AstNode('table', $attrs, $children); | |
| } | |
| /** | |
| * @param list<string> $cellLines | |
| */ | |
| private function buildGridTableCell(array $cellLines, bool $header, int $rowspan = 1, int $colspan = 1): AstNode | |
| { | |
| $text = $this->mergeGridTableCellLines($cellLines); | |
| $attrs = [ | |
| 'text' => $text, | |
| 'header' => $header, | |
| ]; | |
| if ($rowspan > 1) { | |
| $attrs['rowspan'] = $rowspan; | |
| } | |
| if ($colspan > 1) { | |
| $attrs['colspan'] = $colspan; | |
| } | |
| return new AstNode( | |
| 'table_cell', | |
| $attrs, | |
| $this->gridTableCellNeedsBlockParsing($cellLines) | |
| ? $this->readGridTableCellBlocks($cellLines) | |
| : $this->parseInlines($text) | |
| ); | |
| } | |
| /** | |
| * @param list<list<string>> $cellLinesByColumn | |
| */ | |
| private function buildGridTableRow(array $cellLinesByColumn, bool $header): AstNode | |
| { | |
| $children = []; | |
| foreach ($cellLinesByColumn as $cellLines) { | |
| $children[] = $this->buildGridTableCell($cellLines, $header); | |
| } | |
| return new AstNode('table_row', ['header' => $header], $children); | |
| } | |
| /** | |
| * @param list<string> $lines | |
| * @return array{0:string, 1:int} | |
| */ | |
| private function readTableCaption(array $lines, int $cursor): array | |
| { | |
| $count = count($lines); | |
| $captionCursor = $cursor; | |
| while ($captionCursor < $count && trim($lines[$captionCursor]) === '') { | |
| $captionCursor++; | |
| } | |
| if ($captionCursor < $count && preg_match('/^ {0,3}:\s*(.*)$/', $lines[$captionCursor], $m) === 1) { | |
| $caption = [trim($m[1])]; | |
| $next = $captionCursor + 1; | |
| while ( | |
| $next < $count | |
| && trim($lines[$next]) !== '' | |
| && $this->countIndentColumns($lines[$next]) >= 2 | |
| && $this->parseSimpleTableDelimiter($lines[$next]) === null | |
| && !$this->isSimpleTableBoundary($lines[$next]) | |
| ) { | |
| $caption[] = trim($lines[$next]); | |
| $next++; | |
| } | |
| return [implode("\n", $caption), $next]; | |
| } | |
| return ['', $cursor]; | |
| } | |
| /** | |
| * @param list<string> $lines | |
| */ | |
| private function tryReadPipeTable(array $lines, int &$index): ?AstNode | |
| { | |
| if (!isset($lines[$index + 1])) { | |
| return null; | |
| } | |
| $headerCells = $this->splitPipeTableRow($lines[$index]); | |
| if ($headerCells === null) { | |
| return null; | |
| } | |
| $delimiterCells = $this->splitPipeTableRow($lines[$index + 1]); | |
| if ($delimiterCells === null || count($delimiterCells) !== count($headerCells)) { | |
| return null; | |
| } | |
| $delimiter = $this->parsePipeTableDelimiter($delimiterCells); | |
| if ($delimiter === null) { | |
| return null; | |
| } | |
| $columnCount = count($delimiter['alignments']); | |
| $cursor = $index + 2; | |
| $bodyRows = []; | |
| $count = count($lines); | |
| while ($cursor < $count && trim($lines[$cursor]) !== '') { | |
| $row = $this->splitPipeTableRow($lines[$cursor]); | |
| if ($row === null) { | |
| break; | |
| } | |
| $bodyRows[] = $this->normalizePipeTableRow($row, $columnCount); | |
| $cursor++; | |
| } | |
| [$caption, $cursor] = $this->readTableCaption($lines, $cursor); | |
| $headerIsEmpty = true; | |
| foreach ($headerCells as $cell) { | |
| if (trim($cell) !== '') { | |
| $headerIsEmpty = false; | |
| break; | |
| } | |
| } | |
| $children = []; | |
| if (!$headerIsEmpty) { | |
| $children[] = new AstNode('table_head', [], [ | |
| $this->buildTableRow($this->normalizePipeTableRow($headerCells, $columnCount), true), | |
| ]); | |
| } else { | |
| $children[] = new AstNode('table_head'); | |
| } | |
| $bodyChildren = []; | |
| foreach ($bodyRows as $row) { | |
| $bodyChildren[] = $this->buildTableRow($row, false); | |
| } | |
| $children[] = new AstNode('table_body', [], $bodyChildren); | |
| $attrs = [ | |
| 'caption' => $caption, | |
| 'alignments' => $delimiter['alignments'], | |
| ]; | |
| if ($caption !== '') { | |
| $attrs['captionInlines'] = $this->parseInlines($caption); | |
| } | |
| if ($delimiter['widths'] !== null) { | |
| $attrs['widths'] = $delimiter['widths']; | |
| } | |
| $index = $cursor - 1; | |
| return new AstNode('table', $attrs, $children); | |
| } | |
| /** | |
| * @return list<string>|null | |
| */ | |
| private function splitPipeTableRow(string $line): ?array | |
| { | |
| $line = trim($line); | |
| if ($line === '' || !str_contains($line, '|')) { | |
| return null; | |
| } | |
| $cells = []; | |
| $cell = ''; | |
| $codeFenceLength = null; | |
| $length = strlen($line); | |
| for ($offset = 0; $offset < $length; $offset++) { | |
| $char = $line[$offset]; | |
| if ($char === '\\') { | |
| $cell .= $char; | |
| if ($offset + 1 < $length) { | |
| $offset++; | |
| $cell .= $line[$offset]; | |
| } | |
| continue; | |
| } | |
| if ($char === '`') { | |
| $tickCount = $this->countBackticks($line, $offset); | |
| if ($codeFenceLength === null) { | |
| $codeFenceLength = $tickCount; | |
| } elseif ($tickCount === $codeFenceLength) { | |
| $codeFenceLength = null; | |
| } | |
| $cell .= str_repeat('`', $tickCount); | |
| $offset += $tickCount - 1; | |
| continue; | |
| } | |
| if ($char === '|' && $codeFenceLength === null) { | |
| $cells[] = $cell; | |
| $cell = ''; | |
| continue; | |
| } | |
| $cell .= $char; | |
| } | |
| $cells[] = $cell; | |
| if ($line[0] === '|') { | |
| array_shift($cells); | |
| } | |
| if ($line !== '' && $line[strlen($line) - 1] === '|') { | |
| array_pop($cells); | |
| } | |
| return $cells === [] ? null : $cells; | |
| } | |
| /** | |
| * @param list<string> $cells | |
| * @return array{alignments:list<string>, widths:list<float>|null}|null | |
| */ | |
| private function parsePipeTableDelimiter(array $cells): ?array | |
| { | |
| $alignments = []; | |
| $widthParts = []; | |
| foreach ($cells as $cell) { | |
| $marker = trim($cell); | |
| if (preg_match('/^(:?)(-+)(:?)$/', $marker, $m) !== 1) { | |
| return null; | |
| } | |
| $alignments[] = match (true) { | |
| $m[1] === ':' && $m[3] === ':' => 'center', | |
| $m[1] === ':' => 'left', | |
| $m[3] === ':' => 'right', | |
| default => 'default', | |
| }; | |
| $widthParts[] = strlen($m[2]); | |
| } | |
| $widths = null; | |
| if (max($widthParts) >= 20) { | |
| $total = array_sum($widthParts); | |
| $widths = array_map( | |
| static fn (int $width): float => $total === 0 ? 0.0 : $width / $total, | |
| $widthParts | |
| ); | |
| } | |
| return [ | |
| 'alignments' => $alignments, | |
| 'widths' => $widths, | |
| ]; | |
| } | |
| /** | |
| * @param list<string> $cells | |
| * @return list<string> | |
| */ | |
| private function normalizePipeTableRow(array $cells, int $columnCount): array | |
| { | |
| $cells = array_slice($cells, 0, $columnCount); | |
| while (count($cells) < $columnCount) { | |
| $cells[] = ''; | |
| } | |
| return array_map(static fn (string $cell): string => trim($cell), $cells); | |
| } | |
| /** | |
| * @param list<string> $cells | |
| */ | |
| private function buildTableRow(array $cells, bool $header): AstNode | |
| { | |
| $children = []; | |
| foreach ($cells as $cell) { | |
| $children[] = new AstNode( | |
| 'table_cell', | |
| ['text' => $cell, 'header' => $header], | |
| $this->parseInlines($cell) | |
| ); | |
| } | |
| return new AstNode('table_row', ['header' => $header], $children); | |
| } | |
| /** | |
| * @param list<string> $lines | |
| */ | |
| private function readHtmlCommentBlock(array $lines, int &$index): AstNode | |
| { | |
| $content = []; | |
| $cursor = $index; | |
| $count = count($lines); | |
| while ($cursor < $count) { | |
| $content[] = $this->normalizeRawHtmlLine($lines[$cursor]); | |
| if (str_contains($lines[$cursor], '-->')) { | |
| break; | |
| } | |
| $cursor++; | |
| } | |
| $index = min($cursor, $count - 1); | |
| return new AstNode('raw_html', ['html' => implode("\n", $content)]); | |
| } | |
| /** | |
| * @param list<string> $lines | |
| */ | |
| private function readRawHtmlUntilClosingTag(array $lines, int &$index, string $tag, bool $interpretTableCells = false): AstNode | |
| { | |
| $content = []; | |
| $cursor = $index; | |
| $count = count($lines); | |
| $closingPattern = '/<\/' . preg_quote($tag, '/') . '\s*>/i'; | |
| while ($cursor < $count) { | |
| $line = $this->normalizeRawHtmlLine($lines[$cursor]); | |
| $content[] = $interpretTableCells ? $this->renderMarkdownInTableCells($line) : $line; | |
| if (preg_match($closingPattern, $line) === 1) { | |
| break; | |
| } | |
| $cursor++; | |
| } | |
| $index = min($cursor, $count - 1); | |
| return new AstNode('raw_html', ['html' => implode("\n", $content)]); | |
| } | |
| private function normalizeRawHtmlLine(string $line): string | |
| { | |
| return rtrim($this->expandTabsToSpaces($line)); | |
| } | |
| private function renderMarkdownInTableCells(string $line): string | |
| { | |
| return preg_replace_callback( | |
| '/(<t[dh](?:\s+[^>]*)?>)(.*?)(<\/t[dh]>)/i', | |
| function (array $matches): string { | |
| return $matches[1] . $this->renderInlineHtml($this->parseInlines($matches[2])) . $matches[3]; | |
| }, | |
| $line | |
| ) ?? $line; | |
| } | |
| /** | |
| * @param list<AstNode> $nodes | |
| */ | |
| private function renderInlineHtml(array $nodes): string | |
| { | |
| $html = ''; | |
| foreach ($nodes as $node) { | |
| $html .= match ($node->type) { | |
| 'text' => $this->escapeHtml((string) $node->attr('text', '')), | |
| 'emph' => '<em>' . $this->renderInlineHtml($node->children) . '</em>', | |
| 'strong' => '<strong>' . $this->renderInlineHtml($node->children) . '</strong>', | |
| 'small_caps' => '<span style="font-variant:small-caps">' | |
| . $this->renderInlineHtml($node->children) . '</span>', | |
| 'underline' => '<u>' . $this->renderInlineHtml($node->children) . '</u>', | |
| 'strikeout' => '<del>' . $this->renderInlineHtml($node->children) . '</del>', | |
| 'superscript' => '<sup>' . $this->renderInlineHtml($node->children) . '</sup>', | |
| 'subscript' => '<sub>' . $this->renderInlineHtml($node->children) . '</sub>', | |
| 'softbreak' => "\n", | |
| 'linebreak' => '<br/>', | |
| 'span' => '<span' . $this->renderInlineSpanAttributesHtml($node) . '>' | |
| . $this->renderInlineHtml($node->children) . '</span>', | |
| 'quoted' => $this->renderQuotedInlineHtml($node), | |
| 'math' => $this->renderMathInlineHtml($node), | |
| 'raw_tex' => '<span class="pandoc-raw-tex">' . $this->escapeHtml((string) $node->attr('tex', '')) . '</span>', | |
| 'code' => '<code>' . $this->escapeHtml((string) $node->attr('text', '')) . '</code>', | |
| 'link' => '<a' . $this->renderLinkAttributesHtml($node) . '>' | |
| . $this->renderInlineHtml($node->children) . '</a>', | |
| default => $this->renderInlineHtml($node->children), | |
| }; | |
| } | |
| return $html; | |
| } | |
| private function renderInlineSpanAttributesHtml(AstNode $node): string | |
| { | |
| $htmlAttributes = $node->attr('htmlAttributes', []); | |
| if (!is_array($htmlAttributes) || $htmlAttributes === []) { | |
| return ''; | |
| } | |
| $attrs = ''; | |
| foreach ($htmlAttributes as $name => $value) { | |
| $name = strtolower((string) $name); | |
| if (!$this->isAllowedInlineHtmlAttr($name)) { | |
| continue; | |
| } | |
| $attrs .= ' ' . $name . '="' . $this->escapeHtml((string) $value) . '"'; | |
| } | |
| return $attrs; | |
| } | |
| private function isAllowedInlineHtmlAttr(string $name): bool | |
| { | |
| if (preg_match('/^[a-z][a-z0-9_.:-]*$/', $name) !== 1 || str_starts_with($name, 'on')) { | |
| return false; | |
| } | |
| return str_starts_with($name, 'data-') | |
| || str_starts_with($name, 'aria-') | |
| || in_array($name, ['cite', 'class', 'dir', 'id', 'lang', 'title'], true); | |
| } | |
| private function renderLinkAttributesHtml(AstNode $node): string | |
| { | |
| $attrs = ' href="' . $this->escapeHtml((string) $node->attr('url', '')) . '"'; | |
| $title = (string) $node->attr('title', ''); | |
| if ($title !== '') { | |
| $attrs .= ' title="' . $this->escapeHtml($title) . '"'; | |
| } | |
| return $attrs; | |
| } | |
| private function renderMathInlineHtml(AstNode $node): string | |
| { | |
| $open = $node->attr('display') === true ? '\\[' : '\\('; | |
| $close = $node->attr('display') === true ? '\\]' : '\\)'; | |
| $class = $node->attr('display') === true ? 'display' : 'inline'; | |
| return '<span class="math ' . $class . '">' | |
| . $this->escapeHtml($open . (string) $node->attr('text', '') . $close) | |
| . '</span>'; | |
| } | |
| private function renderQuotedInlineHtml(AstNode $node): string | |
| { | |
| if ($node->attr('kind') === 'single') { | |
| return "\u{2018}" . $this->renderInlineHtml($node->children) . "\u{2019}"; | |
| } | |
| return "\u{201C}" . $this->renderInlineHtml($node->children) . "\u{201D}"; | |
| } | |
| private function escapeHtml(string $value): string | |
| { | |
| return htmlspecialchars($value, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'); | |
| } | |
| private function isBlockQuoteLine(string $line): bool | |
| { | |
| return preg_match('/^ {0,3}>/', $line) === 1; | |
| } | |
| private function stripBlockQuoteMarker(string $line): string | |
| { | |
| return preg_replace('/^ {0,3}>[ \t]?/', '', $line, 1) ?? $line; | |
| } | |
| private function isHorizontalRule(string $line): bool | |
| { | |
| return preg_match('/^ {0,3}(?:\*[ \t]*){3,}$/', $line) === 1 | |
| || preg_match('/^ {0,3}(?:-[ \t]*){3,}$/', $line) === 1 | |
| || preg_match('/^ {0,3}(?:_[ \t]*){3,}$/', $line) === 1; | |
| } | |
| /** | |
| * @param list<string> $lines | |
| */ | |
| private function tryReadListBlock(array $lines, int &$index): ?AstNode | |
| { | |
| $marker = $this->matchListMarker($lines[$index] ?? '', $index); | |
| if ($marker === null || $marker['indent'] > 3) { | |
| return null; | |
| } | |
| $result = $this->parseList($lines, $index, $marker); | |
| if ($result === null) { | |
| return null; | |
| } | |
| $index = $result['next'] - 1; | |
| return $result['node']; | |
| } | |
| /** | |
| * @param list<string> $lines | |
| * @param array{indent:int, ordered:bool, start:int|null, text:string, contentIndent:int, padding:int, style:string|null, delimiter:string|null} $firstMarker | |
| * @return array{node: AstNode, next: int}|null | |
| */ | |
| private function parseList(array $lines, int $cursor, array $firstMarker): ?array | |
| { | |
| $count = count($lines); | |
| $items = []; | |
| $start = null; | |
| $listLoose = false; | |
| $baseIndent = $firstMarker['indent']; | |
| $ordered = $firstMarker['ordered']; | |
| $style = $firstMarker['style']; | |
| $delimiter = $firstMarker['delimiter']; | |
| while ($cursor < $count) { | |
| $marker = $this->matchListMarker($lines[$cursor], $cursor); | |
| if (!$this->isSameListMarker($marker, $baseIndent, $ordered, $style, $delimiter)) { | |
| break; | |
| } | |
| $start ??= $marker['start']; | |
| $item = $this->parseListItem($lines, $cursor, $marker, $ordered, $style, $delimiter); | |
| $items[] = $item; | |
| $cursor = $item['next']; | |
| $listLoose = $listLoose || $item['loose']; | |
| $blankCursor = $cursor; | |
| while ($blankCursor < $count && trim($lines[$blankCursor]) === '') { | |
| $blankCursor++; | |
| } | |
| if ($blankCursor > $cursor) { | |
| $nextMarker = $blankCursor < $count ? $this->matchListMarker($lines[$blankCursor], $blankCursor) : null; | |
| if ($this->isSameListMarker($nextMarker, $baseIndent, $ordered, $style, $delimiter)) { | |
| $listLoose = true; | |
| $cursor = $blankCursor; | |
| continue; | |
| } | |
| } | |
| } | |
| if ($items === []) { | |
| return null; | |
| } | |
| $children = []; | |
| foreach ($items as $item) { | |
| $children[] = $this->buildListItem($item, $listLoose || $item['loose']); | |
| } | |
| $attrs = ['loose' => $listLoose]; | |
| if ($ordered) { | |
| $attrs['start'] = $start ?? 1; | |
| $attrs['style'] = $style ?? 'decimal'; | |
| $attrs['delimiter'] = $delimiter ?? 'period'; | |
| } elseif ($this->allListItemsAreTasks($children)) { | |
| $attrs['taskList'] = true; | |
| } | |
| return [ | |
| 'node' => new AstNode($ordered ? 'ordered_list' : 'bullet_list', $attrs, $children), | |
| 'next' => $cursor, | |
| ]; | |
| } | |
| /** | |
| * @param list<string> $lines | |
| * @param array{indent:int, ordered:bool, start:int|null, text:string, contentIndent:int, padding:int, style:string|null, delimiter:string|null} $marker | |
| * @return array{parts:list<array{type:string, text:string}|AstNode>, next:int, loose:bool, text:string, number:int|null, taskChecked:bool|null} | |
| */ | |
| private function parseListItem( | |
| array $lines, | |
| int $cursor, | |
| array $marker, | |
| bool $ordered, | |
| ?string $style, | |
| ?string $delimiter | |
| ): array { | |
| $count = count($lines); | |
| $baseIndent = $marker['indent']; | |
| $contentIndent = $marker['contentIndent']; | |
| $parts = []; | |
| $paragraph = []; | |
| $loose = false; | |
| $firstText = trim($marker['text']); | |
| $taskChecked = null; | |
| if ($firstText !== '' && $this->isListItemBlockHtmlStart($firstText)) { | |
| return $this->parseBlockHtmlListItem($lines, $cursor, $marker); | |
| } | |
| if ($firstText !== '' && $this->isListItemInitialCodeBlock($marker)) { | |
| [$codeBlock, $cursor] = $this->readListItemInitialCodeBlock($lines, $cursor + 1, $contentIndent, $firstText); | |
| $parts[] = $codeBlock; | |
| } elseif ($firstText !== '') { | |
| $task = $this->stripTaskListMarker($firstText); | |
| if ($task !== null) { | |
| $taskChecked = $task['checked']; | |
| $firstText = $task['text']; | |
| } | |
| $paragraph[] = $firstText; | |
| $cursor++; | |
| } else { | |
| $cursor++; | |
| } | |
| while ($cursor < $count) { | |
| $line = $lines[$cursor]; | |
| if (trim($line) === '') { | |
| $next = $cursor; | |
| while ($next < $count && trim($lines[$next]) === '') { | |
| $next++; | |
| } | |
| if ($next >= $count) { | |
| break; | |
| } | |
| if ($this->isHorizontalRule($lines[$next])) { | |
| break; | |
| } | |
| $nextMarker = $this->matchListMarker($lines[$next], $next); | |
| if ($nextMarker !== null && $nextMarker['indent'] <= $baseIndent) { | |
| break; | |
| } | |
| $nextIndent = $this->countIndentColumns($lines[$next]); | |
| if ($this->isNestedListMarker($nextMarker, $baseIndent, $contentIndent) || $nextIndent >= $contentIndent) { | |
| $this->flushListItemParagraph($paragraph, $parts); | |
| $loose = true; | |
| $cursor = $next; | |
| continue; | |
| } | |
| break; | |
| } | |
| if ($this->isHorizontalRule($line)) { | |
| break; | |
| } | |
| $lineMarker = $this->matchListMarker($line, $cursor); | |
| if ($lineMarker !== null) { | |
| if ($this->isSameListMarker($lineMarker, $baseIndent, $ordered, $style, $delimiter)) { | |
| break; | |
| } | |
| if (!$this->isNestedListMarker($lineMarker, $baseIndent, $contentIndent)) { | |
| break; | |
| } | |
| $this->flushListItemParagraph($paragraph, $parts); | |
| $nested = $this->parseList($lines, $cursor, $lineMarker); | |
| if ($nested === null) { | |
| break; | |
| } | |
| $parts[] = $nested['node']; | |
| $cursor = $nested['next']; | |
| continue; | |
| } | |
| $indent = $this->countIndentColumns($line); | |
| if ($indent >= $contentIndent) { | |
| $paragraph[] = trim($this->stripIndentColumns($line, $contentIndent)); | |
| $cursor++; | |
| continue; | |
| } | |
| if ($paragraph !== [] && $this->isLazyListContinuation($line)) { | |
| $paragraph[] = trim($line); | |
| $cursor++; | |
| continue; | |
| } | |
| break; | |
| } | |
| $this->flushListItemParagraph($paragraph, $parts); | |
| return [ | |
| 'parts' => $parts, | |
| 'next' => $cursor, | |
| 'loose' => $loose, | |
| 'text' => $firstText, | |
| 'number' => $marker['start'], | |
| 'taskChecked' => $taskChecked, | |
| ]; | |
| } | |
| /** | |
| * @param list<string> $lines | |
| * @param array{indent:int, ordered:bool, start:int|null, text:string, contentIndent:int, padding:int, style:string|null, delimiter:string|null} $marker | |
| * @return array{parts:list<array{type:string, text:string}|AstNode>, next:int, loose:bool, text:string, number:int|null, taskChecked:bool|null} | |
| */ | |
| private function parseBlockHtmlListItem(array $lines, int $cursor, array $marker): array | |
| { | |
| $count = count($lines); | |
| $baseIndent = $marker['indent']; | |
| $contentIndent = $marker['contentIndent']; | |
| $content = [trim($marker['text'])]; | |
| $loose = false; | |
| $cursor++; | |
| while ($cursor < $count) { | |
| $line = $lines[$cursor]; | |
| if (trim($line) === '') { | |
| $next = $cursor; | |
| while ($next < $count && trim($lines[$next]) === '') { | |
| $next++; | |
| } | |
| if ($next >= $count) { | |
| break; | |
| } | |
| $nextMarker = $this->matchListMarker($lines[$next], $next); | |
| if ($nextMarker !== null && $nextMarker['indent'] <= $baseIndent) { | |
| break; | |
| } | |
| if ($this->countIndentColumns($lines[$next]) >= $contentIndent) { | |
| $content[] = ''; | |
| $loose = true; | |
| $cursor = $next; | |
| continue; | |
| } | |
| break; | |
| } | |
| $lineMarker = $this->matchListMarker($line, $cursor); | |
| if ($lineMarker !== null && $lineMarker['indent'] <= $baseIndent) { | |
| break; | |
| } | |
| if ($this->countIndentColumns($line) < $contentIndent) { | |
| break; | |
| } | |
| $content[] = rtrim($this->stripIndentColumns($line, $contentIndent)); | |
| $cursor++; | |
| } | |
| while ($content !== [] && trim($content[array_key_last($content)]) === '') { | |
| array_pop($content); | |
| } | |
| return [ | |
| 'parts' => (new self($this->options + ['suppressStandaloneButtonInline' => true])) | |
| ->read(implode("\n", $content)) | |
| ->children, | |
| 'next' => $cursor, | |
| 'loose' => $loose, | |
| 'text' => trim($marker['text']), | |
| 'number' => $marker['start'], | |
| 'taskChecked' => null, | |
| ]; | |
| } | |
| private function isListItemBlockHtmlStart(string $text): bool | |
| { | |
| return preg_match('/^<(?:div|button)(?:\s+[^>]*)?>/i', $text) === 1; | |
| } | |
| /** | |
| * @param array{padding:int} $marker | |
| */ | |
| private function isListItemInitialCodeBlock(array $marker): bool | |
| { | |
| return $marker['padding'] >= 5; | |
| } | |
| /** | |
| * @param list<string> $lines | |
| * @return array{0: AstNode, 1: int} | |
| */ | |
| private function readListItemInitialCodeBlock(array $lines, int $cursor, int $contentIndent, string $firstText): array | |
| { | |
| $content = [rtrim($firstText)]; | |
| $count = count($lines); | |
| while ($cursor < $count) { | |
| $line = $lines[$cursor]; | |
| if (trim($line) === '') { | |
| $next = $cursor; | |
| while ($next < $count && trim($lines[$next]) === '') { | |
| $next++; | |
| } | |
| if ( | |
| $next < $count | |
| && $this->countIndentColumns($lines[$next]) >= $contentIndent | |
| && $this->matchListMarker($lines[$next], $next) === null | |
| ) { | |
| $content[] = ''; | |
| $cursor++; | |
| continue; | |
| } | |
| break; | |
| } | |
| if ($this->countIndentColumns($line) < $contentIndent) { | |
| break; | |
| } | |
| $content[] = rtrim($this->stripIndentColumns($line, $contentIndent)); | |
| $cursor++; | |
| } | |
| while ($content !== [] && end($content) === '') { | |
| array_pop($content); | |
| } | |
| return [ | |
| new AstNode('code_block', [ | |
| 'classes' => [], | |
| 'attributes' => [], | |
| 'text' => implode("\n", $content), | |
| ]), | |
| $cursor, | |
| ]; | |
| } | |
| private function isLazyListContinuation(string $line): bool | |
| { | |
| return trim($line) !== '' | |
| && !$this->isHorizontalRule($line) | |
| && !$this->isBlockQuoteLine($line) | |
| && !$this->isDefinitionMarker($line) | |
| && preg_match('/^(#{1,6})\s+/', $line) !== 1; | |
| } | |
| /** | |
| * @param list<string> $paragraph | |
| * @param list<array{type:string, text:string}|AstNode> $parts | |
| */ | |
| private function flushListItemParagraph(array &$paragraph, array &$parts): void | |
| { | |
| if ($paragraph === []) { | |
| return; | |
| } | |
| $parts[] = ['type' => 'paragraph', 'text' => implode(' ', $paragraph)]; | |
| $paragraph = []; | |
| } | |
| /** | |
| * @param array{parts:list<array{type:string, text:string}|AstNode>, next:int, loose:bool, text:string, number:int|null, taskChecked:bool|null} $item | |
| */ | |
| private function buildListItem(array $item, bool $loose): AstNode | |
| { | |
| $paragraphCount = 0; | |
| foreach ($item['parts'] as $part) { | |
| if (is_array($part) && ($part['type'] ?? null) === 'paragraph') { | |
| $paragraphCount++; | |
| } | |
| } | |
| $forceParagraphBlocks = $loose || $paragraphCount > 1; | |
| $children = []; | |
| foreach ($item['parts'] as $part) { | |
| if ($part instanceof AstNode) { | |
| $children[] = $part; | |
| continue; | |
| } | |
| $text = $part['text']; | |
| if ($forceParagraphBlocks) { | |
| $children[] = new AstNode('paragraph', ['text' => $text], $this->parseInlines($text)); | |
| continue; | |
| } | |
| foreach ($this->parseInlines($text) as $inline) { | |
| $children[] = $inline; | |
| } | |
| } | |
| $attrs = ['text' => $item['text'], 'loose' => $forceParagraphBlocks]; | |
| if ($item['number'] !== null) { | |
| $attrs['number'] = $item['number']; | |
| } | |
| if ($item['taskChecked'] !== null) { | |
| $attrs['taskChecked'] = $item['taskChecked']; | |
| } | |
| return new AstNode('list_item', $attrs, $children); | |
| } | |
| /** | |
| * @return array{checked:bool, text:string}|null | |
| */ | |
| private function stripTaskListMarker(string $text): ?array | |
| { | |
| if (preg_match('/^\[([ xX])\](?:[ \t]+|$)(.*)$/s', $text, $m) !== 1) { | |
| return null; | |
| } | |
| return [ | |
| 'checked' => strtolower($m[1]) === 'x', | |
| 'text' => ltrim($m[2]), | |
| ]; | |
| } | |
| /** | |
| * @param list<AstNode> $children | |
| */ | |
| private function allListItemsAreTasks(array $children): bool | |
| { | |
| if ($children === []) { | |
| return false; | |
| } | |
| foreach ($children as $child) { | |
| if ($child->type !== 'list_item' || !is_bool($child->attr('taskChecked', null))) { | |
| return false; | |
| } | |
| } | |
| return true; | |
| } | |
| /** | |
| * @return array{indent:int, ordered:bool, start:int|null, text:string, contentIndent:int, padding:int, style:string|null, delimiter:string|null}|null | |
| */ | |
| private function matchListMarker(string $line, ?int $lineIndex = null): ?array | |
| { | |
| if ($this->isHorizontalRule($line)) { | |
| return null; | |
| } | |
| $expanded = $this->expandTabsToSpaces($line); | |
| $example = $this->matchNumberedExampleMarker($expanded); | |
| if ($example !== null) { | |
| return [ | |
| 'indent' => $example['indent'], | |
| 'ordered' => true, | |
| 'start' => $lineIndex !== null ? ($this->exampleNumbersByLine[$lineIndex] ?? 1) : 1, | |
| 'text' => $example['text'], | |
| 'contentIndent' => $example['contentIndent'], | |
| 'padding' => $example['padding'], | |
| 'style' => 'example', | |
| 'delimiter' => 'two_parens', | |
| ]; | |
| } | |
| if (preg_match('/^( *)([-*+])( +)(.*)$/', $expanded, $m) === 1) { | |
| return [ | |
| 'indent' => strlen($m[1]), | |
| 'ordered' => false, | |
| 'start' => null, | |
| 'text' => $m[4], | |
| 'contentIndent' => strlen($m[1]) + 1 + strlen($m[3]), | |
| 'padding' => strlen($m[3]), | |
| 'style' => null, | |
| 'delimiter' => null, | |
| ]; | |
| } | |
| if (preg_match('/^( *)#([.)])( +)(.*)$/', $expanded, $m) === 1) { | |
| return [ | |
| 'indent' => strlen($m[1]), | |
| 'ordered' => true, | |
| 'start' => 1, | |
| 'text' => $m[4], | |
| 'contentIndent' => strlen($m[1]) + 2 + strlen($m[3]), | |
| 'padding' => strlen($m[3]), | |
| 'style' => 'default', | |
| 'delimiter' => 'default', | |
| ]; | |
| } | |
| if (preg_match('/^( *)\(([0-9]{1,9}|[A-Za-z]+)\)( +)(.*)$/', $expanded, $m) === 1) { | |
| $ordinal = $this->parseOrderedMarkerOrdinal($m[2], 'two_parens', strlen($m[3]), strlen($m[1])); | |
| if ($ordinal === null) { | |
| return null; | |
| } | |
| return [ | |
| 'indent' => strlen($m[1]), | |
| 'ordered' => true, | |
| 'start' => $ordinal['start'], | |
| 'text' => $m[4], | |
| 'contentIndent' => strlen($m[1]) + strlen($m[2]) + 2 + strlen($m[3]), | |
| 'padding' => strlen($m[3]), | |
| 'style' => $ordinal['style'], | |
| 'delimiter' => 'two_parens', | |
| ]; | |
| } | |
| if (preg_match('/^( *)(\d{1,9})([.)])( +)(.*)$/', $expanded, $m) === 1) { | |
| return [ | |
| 'indent' => strlen($m[1]), | |
| 'ordered' => true, | |
| 'start' => (int) $m[2], | |
| 'text' => $m[5], | |
| 'contentIndent' => strlen($m[1]) + strlen($m[2]) + 1 + strlen($m[4]), | |
| 'padding' => strlen($m[4]), | |
| 'style' => 'decimal', | |
| 'delimiter' => $m[3] === ')' ? 'one_paren' : 'period', | |
| ]; | |
| } | |
| if (preg_match('/^( *)([A-Za-z]+)([.)])( +)(.*)$/', $expanded, $m) === 1) { | |
| $delimiter = $m[3] === ')' ? 'one_paren' : 'period'; | |
| $ordinal = $this->parseOrderedMarkerOrdinal($m[2], $delimiter, strlen($m[4]), strlen($m[1])); | |
| if ($ordinal === null) { | |
| return null; | |
| } | |
| return [ | |
| 'indent' => strlen($m[1]), | |
| 'ordered' => true, | |
| 'start' => $ordinal['start'], | |
| 'text' => $m[5], | |
| 'contentIndent' => strlen($m[1]) + strlen($m[2]) + 1 + strlen($m[4]), | |
| 'padding' => strlen($m[4]), | |
| 'style' => $ordinal['style'], | |
| 'delimiter' => $delimiter, | |
| ]; | |
| } | |
| return null; | |
| } | |
| /** | |
| * @return array{indent:int, label:string, text:string, contentIndent:int, padding:int}|null | |
| */ | |
| private function matchNumberedExampleMarker(string $line): ?array | |
| { | |
| $expanded = $this->expandTabsToSpaces($line); | |
| if (preg_match('/^( *)\(@([A-Za-z0-9_-]*)\)( +)(.*)$/', $expanded, $m) !== 1) { | |
| return null; | |
| } | |
| return [ | |
| 'indent' => strlen($m[1]), | |
| 'label' => $m[2], | |
| 'text' => $m[4], | |
| 'contentIndent' => strlen($m[1]) + strlen($m[2]) + 3 + strlen($m[3]), | |
| 'padding' => strlen($m[3]), | |
| ]; | |
| } | |
| private function isSameListMarker( | |
| ?array $marker, | |
| int $baseIndent, | |
| bool $ordered, | |
| ?string $style, | |
| ?string $delimiter | |
| ): bool { | |
| return $marker !== null | |
| && $marker['indent'] === $baseIndent | |
| && $marker['ordered'] === $ordered | |
| && $marker['style'] === $style | |
| && $marker['delimiter'] === $delimiter; | |
| } | |
| private function isNestedListMarker(?array $marker, int $baseIndent, int $contentIndent): bool | |
| { | |
| return $marker !== null | |
| && $marker['indent'] > $baseIndent | |
| && ($marker['indent'] >= $contentIndent || $marker['indent'] - $baseIndent >= 2); | |
| } | |
| /** | |
| * @return array{start:int, style:string}|null | |
| */ | |
| private function parseOrderedMarkerOrdinal(string $token, string $delimiter, int $spacesAfterMarker, int $indent): ?array | |
| { | |
| if (ctype_digit($token)) { | |
| return ['start' => (int) $token, 'style' => 'decimal']; | |
| } | |
| if (!ctype_alpha($token)) { | |
| return null; | |
| } | |
| $roman = $delimiter === 'period' ? $this->romanToInt($token) : null; | |
| if ($roman !== null && (strlen($token) > 1 || $spacesAfterMarker >= 2 || $indent > 0)) { | |
| return [ | |
| 'start' => $roman, | |
| 'style' => ctype_upper($token) ? 'upper_roman' : 'lower_roman', | |
| ]; | |
| } | |
| if (strlen($token) === 1 && ($spacesAfterMarker >= 2 || $indent > 0)) { | |
| $start = ord(strtolower($token)) - ord('a') + 1; | |
| return [ | |
| 'start' => $start, | |
| 'style' => ctype_upper($token) ? 'upper_alpha' : 'lower_alpha', | |
| ]; | |
| } | |
| return null; | |
| } | |
| private function romanToInt(string $token): ?int | |
| { | |
| $roman = strtoupper($token); | |
| if (preg_match('/^(?=[MDCLXVI]+$)M{0,4}(CM|CD|D?C{0,3})(XC|XL|L?X{0,3})(IX|IV|V?I{0,3})$/', $roman) !== 1) { | |
| return null; | |
| } | |
| $values = [ | |
| 'I' => 1, | |
| 'V' => 5, | |
| 'X' => 10, | |
| 'L' => 50, | |
| 'C' => 100, | |
| 'D' => 500, | |
| 'M' => 1000, | |
| ]; | |
| $total = 0; | |
| $previous = 0; | |
| for ($offset = strlen($roman) - 1; $offset >= 0; $offset--) { | |
| $value = $values[$roman[$offset]]; | |
| if ($value < $previous) { | |
| $total -= $value; | |
| } else { | |
| $total += $value; | |
| $previous = $value; | |
| } | |
| } | |
| return $total; | |
| } | |
| private function countIndentColumns(string $line): int | |
| { | |
| return strspn($this->expandTabsToSpaces($line), ' '); | |
| } | |
| private function stripIndentColumns(string $line, int $columns): string | |
| { | |
| return substr($this->expandTabsToSpaces($line), $columns); | |
| } | |
| private function expandTabsToSpaces(string $line): string | |
| { | |
| $expanded = ''; | |
| $column = 0; | |
| $length = strlen($line); | |
| for ($offset = 0; $offset < $length; $offset++) { | |
| if ($line[$offset] === "\t") { | |
| $spaces = 4 - ($column % 4); | |
| $expanded .= str_repeat(' ', $spaces); | |
| $column += $spaces; | |
| continue; | |
| } | |
| $expanded .= $line[$offset]; | |
| $column++; | |
| } | |
| return $expanded; | |
| } | |
| /** | |
| * @param list<string> $lines | |
| */ | |
| private function tryReadIndentedCodeBlock(array $lines, int &$index): ?AstNode | |
| { | |
| if (!$this->isIndentedCodeLine($lines[$index] ?? '')) { | |
| return null; | |
| } | |
| $content = []; | |
| $cursor = $index; | |
| $count = count($lines); | |
| while ($cursor < $count) { | |
| $line = $lines[$cursor]; | |
| if ($this->isIndentedCodeLine($line)) { | |
| $content[] = $this->stripCodeIndent($line); | |
| $cursor++; | |
| continue; | |
| } | |
| if (trim($line) === '') { | |
| $content[] = ''; | |
| $cursor++; | |
| continue; | |
| } | |
| break; | |
| } | |
| while ($content !== [] && end($content) === '') { | |
| array_pop($content); | |
| } | |
| $index = $cursor - 1; | |
| return new AstNode('code_block', [ | |
| 'classes' => [], | |
| 'attributes' => [], | |
| 'text' => implode("\n", $content), | |
| ]); | |
| } | |
| private function isIndentedCodeLine(string $line): bool | |
| { | |
| return str_starts_with($line, ' ') || str_starts_with($line, "\t"); | |
| } | |
| private function stripCodeIndent(string $line): string | |
| { | |
| if (str_starts_with($line, "\t")) { | |
| return $this->expandTabs(substr($line, 1)); | |
| } | |
| return $this->expandTabs(substr($line, 4)); | |
| } | |
| private function expandTabs(string $line): string | |
| { | |
| return str_replace("\t", ' ', $line); | |
| } | |
| private function isClosingCodeFence(string $line, string $fenceChar, int $fenceLength): bool | |
| { | |
| return preg_match('/^ {0,3}' . preg_quote($fenceChar, '/') . '{' . $fenceLength . ',}[ \t]*$/', $line) === 1; | |
| } | |
| private function stripFenceContentIndent(string $line, int $indent): string | |
| { | |
| if ($indent === 0) { | |
| return $line; | |
| } | |
| $spaces = min($indent, strspn($line, ' ')); | |
| return substr($line, $spaces); | |
| } | |
| /** | |
| * @return array<string, mixed> | |
| */ | |
| private function parseCodeInfo(string $info): array | |
| { | |
| $info = trim($info); | |
| if ($info === '') { | |
| return ['classes' => [], 'attributes' => []]; | |
| } | |
| $classes = []; | |
| $attributes = []; | |
| $id = null; | |
| if (str_starts_with($info, '{') && str_ends_with($info, '}')) { | |
| $inside = trim(substr($info, 1, -1)); | |
| $tokens = preg_split('/\s+/', $inside, -1, PREG_SPLIT_NO_EMPTY) ?: []; | |
| foreach ($tokens as $token) { | |
| if (str_starts_with($token, '.')) { | |
| $classes[] = substr($token, 1); | |
| continue; | |
| } | |
| if (str_starts_with($token, '#')) { | |
| $id = substr($token, 1); | |
| continue; | |
| } | |
| if (str_contains($token, '=')) { | |
| [$name, $value] = explode('=', $token, 2); | |
| $attributes[$name] = trim($value, "\"'"); | |
| } | |
| } | |
| } else { | |
| $tokens = preg_split('/\s+/', $info, -1, PREG_SPLIT_NO_EMPTY) ?: []; | |
| if ($tokens !== []) { | |
| $classes[] = $tokens[0]; | |
| } | |
| } | |
| $attrs = ['classes' => $classes, 'attributes' => $attributes]; | |
| if ($id !== null) { | |
| $attrs['id'] = $id; | |
| } | |
| return $attrs; | |
| } | |
| /** | |
| * @param list<string> $lines | |
| */ | |
| private function tryReadDefinitionList(array $lines, int &$index): ?AstNode | |
| { | |
| $cursor = $index; | |
| $count = count($lines); | |
| $items = []; | |
| while ($cursor < $count) { | |
| while ($items !== [] && $cursor < $count && trim($lines[$cursor]) === '') { | |
| $cursor++; | |
| } | |
| if ($cursor >= $count || !$this->canStartDefinitionTerm($lines[$cursor])) { | |
| break; | |
| } | |
| $termText = trim($lines[$cursor]); | |
| $definitionCursor = $cursor + 1; | |
| $looseFirstDefinition = false; | |
| if ($definitionCursor < $count && trim($lines[$definitionCursor]) === '') { | |
| $looseFirstDefinition = true; | |
| $definitionCursor++; | |
| } | |
| if ($definitionCursor >= $count || !$this->isDefinitionMarker($lines[$definitionCursor])) { | |
| break; | |
| } | |
| $cursor = $definitionCursor; | |
| $definitions = []; | |
| $looseDefinition = false; | |
| $blankBeforeLaterDefinition = false; | |
| while ($cursor < $count) { | |
| if (trim($lines[$cursor]) === '') { | |
| $next = $cursor + 1; | |
| if ($next < $count && $this->isDefinitionMarker($lines[$next])) { | |
| $blankBeforeLaterDefinition = true; | |
| $cursor = $next; | |
| continue; | |
| } | |
| break; | |
| } | |
| if (!$this->isDefinitionMarker($lines[$cursor])) { | |
| break; | |
| } | |
| $blankBeforeNextDefinition = false; | |
| $definitions[] = $this->readDefinition($lines, $cursor, $looseDefinition, $blankBeforeNextDefinition); | |
| $blankBeforeLaterDefinition = $blankBeforeLaterDefinition || $blankBeforeNextDefinition; | |
| $looseDefinition = false; | |
| } | |
| if ($looseFirstDefinition && $definitions !== []) { | |
| $indexes = $blankBeforeLaterDefinition ? array_keys($definitions) : [array_key_last($definitions)]; | |
| foreach ($indexes as $definitionIndex) { | |
| if ($definitionIndex === null) { | |
| continue; | |
| } | |
| $definition = $definitions[$definitionIndex]; | |
| $definitions[$definitionIndex] = new AstNode( | |
| $definition->type, | |
| array_merge($definition->attrs, ['loose' => true]), | |
| $definition->children | |
| ); | |
| } | |
| } | |
| $term = new AstNode('term', ['text' => $termText], $this->parseInlines($termText)); | |
| $items[] = new AstNode('definition_item', ['term' => $termText], array_merge([$term], $definitions)); | |
| } | |
| if ($items === []) { | |
| return null; | |
| } | |
| $index = $cursor - 1; | |
| return new AstNode('definition_list', [], $items); | |
| } | |
| private function canStartDefinitionTerm(string $line): bool | |
| { | |
| $trimmed = trim($line); | |
| if ($trimmed === '') { | |
| return false; | |
| } | |
| if (preg_match('/^ {0,3}<\/?[A-Za-z][^>]*>/', $line) === 1) { | |
| return false; | |
| } | |
| return !preg_match('/^(#{1,6})\s+|^[-*+]\s+|^\d+[.)]\s+|^\s{0,4}[:~]/', $line); | |
| } | |
| private function isDefinitionMarker(string $line): bool | |
| { | |
| return $this->matchDefinitionMarker($line) !== null; | |
| } | |
| /** | |
| * @return array{marker:string, content:string}|null | |
| */ | |
| private function matchDefinitionMarker(string $line): ?array | |
| { | |
| if (preg_match('/^\s{0,4}([:~])\s*(.*)$/', $line, $m) !== 1) { | |
| return null; | |
| } | |
| return ['marker' => $m[1], 'content' => $m[2]]; | |
| } | |
| /** | |
| * @param list<string> $lines | |
| */ | |
| private function readDefinition(array $lines, int &$cursor, bool $loose, bool &$blankBeforeNextDefinition): AstNode | |
| { | |
| $blankBeforeNextDefinition = false; | |
| $marker = $this->matchDefinitionMarker($lines[$cursor]); | |
| $blocks = $marker === null ? [] : $this->parseDefinitionBlocks(trim($marker['content'])); | |
| $cursor++; | |
| $count = count($lines); | |
| while ($cursor < $count) { | |
| $line = $lines[$cursor]; | |
| if (trim($line) === '') { | |
| $next = $cursor + 1; | |
| if ($next < $count && $this->isDefinitionMarker($lines[$next])) { | |
| $blankBeforeNextDefinition = true; | |
| $cursor = $next; | |
| break; | |
| } | |
| if ($next < $count && $this->isIndentedDefinitionContinuation($lines[$next])) { | |
| $cursor = $next; | |
| foreach ($this->readDefinitionContinuationBlock($lines, $cursor) as $block) { | |
| $blocks[] = $block; | |
| } | |
| continue; | |
| } | |
| break; | |
| } | |
| if ($this->isDefinitionMarker($line)) { | |
| break; | |
| } | |
| if ($this->isIndentedDefinitionContinuation($line)) { | |
| foreach ($this->readDefinitionContinuationBlock($lines, $cursor) as $block) { | |
| $blocks[] = $block; | |
| } | |
| continue; | |
| } else { | |
| $this->appendLazyDefinitionLine($blocks, trim($line)); | |
| } | |
| $cursor++; | |
| } | |
| return new AstNode('definition', ['loose' => $loose], $blocks); | |
| } | |
| /** | |
| * @param list<string> $lines | |
| * @return list<AstNode> | |
| */ | |
| private function readDefinitionContinuationBlock(array $lines, int &$cursor): array | |
| { | |
| $content = []; | |
| $count = count($lines); | |
| while ($cursor < $count) { | |
| $line = $lines[$cursor]; | |
| if (trim($line) === '') { | |
| $next = $cursor + 1; | |
| if ($next < $count && $this->isIndentedDefinitionContinuation($lines[$next])) { | |
| $content[] = ''; | |
| $cursor = $next; | |
| continue; | |
| } | |
| break; | |
| } | |
| if (!$this->isIndentedDefinitionContinuation($line)) { | |
| break; | |
| } | |
| $content[] = $this->stripDefinitionContinuationIndent($line); | |
| $cursor++; | |
| } | |
| while ($content !== [] && end($content) === '') { | |
| array_pop($content); | |
| } | |
| if ($content === []) { | |
| return []; | |
| } | |
| return $this->read(implode("\n", $content))->children; | |
| } | |
| private function isIndentedDefinitionContinuation(string $line): bool | |
| { | |
| return str_starts_with($line, ' ') || str_starts_with($line, "\t"); | |
| } | |
| private function stripDefinitionContinuationIndent(string $line): string | |
| { | |
| if (str_starts_with($line, "\t")) { | |
| return substr($line, 1); | |
| } | |
| return substr($line, 4); | |
| } | |
| /** | |
| * @param list<AstNode> $blocks | |
| */ | |
| private function appendLazyDefinitionLine(array &$blocks, string $text): void | |
| { | |
| if ($text === '') { | |
| return; | |
| } | |
| $lastIndex = array_key_last($blocks); | |
| if ($lastIndex !== null && $blocks[$lastIndex]->type === 'paragraph') { | |
| $current = (string) $blocks[$lastIndex]->attr('text', ''); | |
| $combined = $current === '' ? $text : $current . ' ' . $text; | |
| $blocks[$lastIndex] = new AstNode('paragraph', ['text' => $combined], $this->parseInlines($combined)); | |
| return; | |
| } | |
| $blocks[] = new AstNode('paragraph', ['text' => $text], $this->parseInlines($text)); | |
| } | |
| /** | |
| * @return list<AstNode> | |
| */ | |
| private function parseDefinitionBlocks(string $content): array | |
| { | |
| if ($content === '') { | |
| return []; | |
| } | |
| if (preg_match('/^(?:[-*+]|\d{1,9}[.)]|#\.)\s+/', $content) === 1) { | |
| return $this->read($content)->children; | |
| } | |
| return [new AstNode('paragraph', ['text' => $content], $this->parseInlines($content))]; | |
| } | |
| /** | |
| * @param list<string> $paragraph | |
| * @param list<AstNode> $blocks | |
| */ | |
| private function flushParagraph(array &$paragraph, array &$blocks): void | |
| { | |
| if ($paragraph === []) { | |
| return; | |
| } | |
| $text = $this->joinParagraphLines($paragraph); | |
| $children = $this->parseInlines($text); | |
| $plainText = $this->paragraphTextFromInlines($children); | |
| if (count($children) === 1 && $children[0]->type === 'image') { | |
| $figureAttrs = $children[0]->attr('figureAttributes', []); | |
| if (!is_array($figureAttrs)) { | |
| $figureAttrs = []; | |
| } | |
| $figureAttrs['caption'] = (string) $children[0]->attr('caption', $children[0]->attr('alt', '')); | |
| $blocks[] = new AstNode( | |
| 'figure', | |
| $figureAttrs, | |
| [$children[0]] | |
| ); | |
| $paragraph = []; | |
| return; | |
| } | |
| $blocks[] = new AstNode('paragraph', ['text' => $plainText], $children); | |
| $paragraph = []; | |
| } | |
| /** | |
| * @param list<string> $paragraph | |
| */ | |
| private function joinParagraphLines(array $paragraph): string | |
| { | |
| return implode("\n", $paragraph); | |
| } | |
| /** | |
| * @param list<array{indent:int, ordered: bool, start: int|null, items: list<AstNode>}> $listStack | |
| * @param list<AstNode> $blocks | |
| */ | |
| private function appendListItem(array &$listStack, array &$blocks, bool $ordered, ?int $number, int $indent, string $text): void | |
| { | |
| while ($listStack !== [] && $indent < $listStack[array_key_last($listStack)]['indent']) { | |
| $this->closeLastList($listStack, $blocks); | |
| } | |
| if ($listStack !== []) { | |
| $top = $listStack[array_key_last($listStack)]; | |
| if ($indent === $top['indent'] && $top['ordered'] !== $ordered) { | |
| $this->closeLastList($listStack, $blocks); | |
| } | |
| } | |
| if ($listStack === [] || $indent > $listStack[array_key_last($listStack)]['indent']) { | |
| $listStack[] = [ | |
| 'indent' => $indent, | |
| 'ordered' => $ordered, | |
| 'start' => $ordered ? $number : null, | |
| 'items' => [], | |
| ]; | |
| } | |
| $attrs = ['text' => $text]; | |
| if ($number !== null) { | |
| $attrs['number'] = $number; | |
| } | |
| $lastIndex = array_key_last($listStack); | |
| $listStack[$lastIndex]['items'][] = new AstNode('list_item', $attrs, $this->parseInlines($text)); | |
| } | |
| /** | |
| * @param list<array{indent:int, ordered: bool, start: int|null, items: list<AstNode>}> $listStack | |
| * @param list<AstNode> $blocks | |
| */ | |
| private function flushListStack(array &$listStack, array &$blocks): void | |
| { | |
| while ($listStack !== []) { | |
| $this->closeLastList($listStack, $blocks); | |
| } | |
| } | |
| /** | |
| * @param list<array{indent:int, ordered: bool, start: int|null, items: list<AstNode>}> $listStack | |
| * @param list<AstNode> $blocks | |
| */ | |
| private function closeLastList(array &$listStack, array &$blocks): void | |
| { | |
| $list = array_pop($listStack); | |
| if ($list === null) { | |
| return; | |
| } | |
| $attrs = $list['ordered'] ? ['start' => $list['start'] ?? 1] : []; | |
| $node = new AstNode($list['ordered'] ? 'ordered_list' : 'bullet_list', $attrs, $list['items']); | |
| if ($listStack === []) { | |
| $blocks[] = $node; | |
| return; | |
| } | |
| $parentIndex = array_key_last($listStack); | |
| $itemIndex = array_key_last($listStack[$parentIndex]['items']); | |
| if ($itemIndex === null) { | |
| $blocks[] = $node; | |
| return; | |
| } | |
| $item = $listStack[$parentIndex]['items'][$itemIndex]; | |
| $children = $item->children; | |
| $children[] = $node; | |
| $listStack[$parentIndex]['items'][$itemIndex] = new AstNode($item->type, $item->attrs, $children); | |
| } | |
| /** | |
| * @return list<AstNode> | |
| */ | |
| private function parseInlines(string $text, bool $allowLinks = true, bool $allowBareCitations = true): array | |
| { | |
| $nodes = []; | |
| $buffer = ''; | |
| $length = strlen($text); | |
| $offset = 0; | |
| while ($offset < $length) { | |
| if ($text[$offset] === "\n") { | |
| $this->flushText($buffer, $nodes); | |
| $nodes[] = new AstNode('softbreak'); | |
| $offset++; | |
| continue; | |
| } | |
| if ( | |
| $text[$offset] === '\\' | |
| && ($text[$offset + 1] ?? '') === "\n" | |
| && !$this->isEscapedInlinePosition($text, $offset) | |
| ) { | |
| $this->flushText($buffer, $nodes); | |
| $nodes[] = new AstNode('linebreak'); | |
| $offset += 2; | |
| continue; | |
| } | |
| if ($text[$offset] === '`') { | |
| $tickCount = $this->countBackticks($text, $offset); | |
| $end = $this->findMatchingBacktickRun($text, $offset + $tickCount, $tickCount); | |
| if ($end !== null && $end > $offset + $tickCount) { | |
| $code = substr($text, $offset + $tickCount, $end - $offset - $tickCount); | |
| $code = str_replace(["\r\n", "\r", "\n"], ' ', $code); | |
| if (strlen($code) >= 2 && $code[0] === ' ' && $code[strlen($code) - 1] === ' ' && trim($code) !== '') { | |
| $code = substr($code, 1, -1); | |
| } | |
| $next = $end + $tickCount; | |
| $rawAttribute = $this->tryParseRawAttributeSpec($text, $next); | |
| if ($rawAttribute !== null) { | |
| $this->flushText($buffer, $nodes); | |
| $nodes[] = $this->rawInlineNode($rawAttribute['format'], $code); | |
| $offset = $rawAttribute['next']; | |
| continue; | |
| } | |
| $attrs = ['text' => $code]; | |
| $attribute = $this->tryParseInlineAttributeSpec($text, $next); | |
| $literalAttribute = null; | |
| if ($attribute !== null) { | |
| $attrs = array_replace($attrs, $attribute['attrs']); | |
| $next = $attribute['next']; | |
| } else { | |
| $literalAttribute = $this->tryParseSpacedInlineAttributeLiteral($text, $next); | |
| if ($literalAttribute !== null) { | |
| $next = $literalAttribute['next']; | |
| } | |
| } | |
| $this->flushText($buffer, $nodes); | |
| $nodes[] = new AstNode('code', $attrs); | |
| if ($literalAttribute !== null) { | |
| $nodes[] = new AstNode('text', ['text' => $literalAttribute['text']]); | |
| } | |
| $offset = $next; | |
| continue; | |
| } | |
| } | |
| $inlineNote = $this->resolveFootnoteReferences ? $this->tryParseInlineNote($text, $offset) : null; | |
| if ($inlineNote !== null) { | |
| $this->flushText($buffer, $nodes); | |
| $nodes[] = $inlineNote['node']; | |
| $offset = $inlineNote['next']; | |
| continue; | |
| } | |
| $math = $this->tryParseMath($text, $offset); | |
| if ($math !== null) { | |
| $this->flushText($buffer, $nodes); | |
| $nodes[] = $math['node']; | |
| $offset = $math['next']; | |
| continue; | |
| } | |
| $rawTex = $this->tryParseRawTexInline($text, $offset); | |
| if ($rawTex !== null) { | |
| $this->flushText($buffer, $nodes); | |
| $nodes[] = $rawTex['node']; | |
| $offset = $rawTex['next']; | |
| continue; | |
| } | |
| $strikeout = $this->tryParseStrikeout($text, $offset); | |
| if ($strikeout !== null) { | |
| $this->flushText($buffer, $nodes); | |
| $nodes[] = $strikeout['node']; | |
| $offset = $strikeout['next']; | |
| continue; | |
| } | |
| $script = $this->tryParseScript($text, $offset); | |
| if ($script !== null) { | |
| $this->flushText($buffer, $nodes); | |
| $nodes[] = $script['node']; | |
| $offset = $script['next']; | |
| continue; | |
| } | |
| $quote = $this->tryParseSmartQuote($text, $offset); | |
| if ($quote !== null) { | |
| $this->flushText($buffer, $nodes); | |
| $nodes[] = $quote['node']; | |
| $offset = $quote['next']; | |
| continue; | |
| } | |
| $emphasis = $this->tryParseEmphasisDelimiter($text, $offset); | |
| if ($emphasis !== null) { | |
| $this->flushText($buffer, $nodes); | |
| $nodes[] = $emphasis['node']; | |
| $offset = $emphasis['next']; | |
| continue; | |
| } | |
| $footnoteReference = $this->resolveFootnoteReferences ? $this->tryParseFootnoteReference($text, $offset) : null; | |
| if ($footnoteReference !== null) { | |
| $this->flushText($buffer, $nodes); | |
| $nodes[] = $footnoteReference['node']; | |
| $offset = $footnoteReference['next']; | |
| continue; | |
| } | |
| $citation = $allowLinks ? $this->tryParseCitation($text, $offset, $allowBareCitations) : null; | |
| if ($citation !== null) { | |
| $this->flushText($buffer, $nodes); | |
| $nodes[] = $citation['node']; | |
| $offset = $citation['next']; | |
| continue; | |
| } | |
| $wikiLink = $allowLinks ? $this->tryParseWikiLink($text, $offset) : null; | |
| if ($wikiLink !== null) { | |
| $this->flushText($buffer, $nodes); | |
| $nodes[] = $wikiLink['node']; | |
| $offset = $wikiLink['next']; | |
| continue; | |
| } | |
| $image = $allowLinks ? $this->tryParseImage($text, $offset) : null; | |
| if ($image !== null) { | |
| $this->flushText($buffer, $nodes); | |
| $nodes[] = $image['node']; | |
| $offset = $image['next']; | |
| continue; | |
| } | |
| $inlineLink = $allowLinks ? $this->tryParseInlineLink($text, $offset) : null; | |
| if ($inlineLink !== null) { | |
| $this->flushText($buffer, $nodes); | |
| $nodes[] = $inlineLink['node']; | |
| $offset = $inlineLink['next']; | |
| continue; | |
| } | |
| $span = $this->tryParseBracketedSpan($text, $offset); | |
| if ($span !== null) { | |
| $this->flushText($buffer, $nodes); | |
| $nodes[] = $span['node']; | |
| $offset = $span['next']; | |
| continue; | |
| } | |
| $emoji = $this->tryParseEmojiAlias($text, $offset); | |
| if ($emoji !== null) { | |
| $this->flushText($buffer, $nodes); | |
| $nodes[] = $emoji['node']; | |
| $offset = $emoji['next']; | |
| continue; | |
| } | |
| $referenceLink = $allowLinks ? $this->tryParseReferenceLink($text, $offset) : null; | |
| if ($referenceLink !== null) { | |
| $this->flushText($buffer, $nodes); | |
| $nodes[] = $referenceLink['node']; | |
| $offset = $referenceLink['next']; | |
| continue; | |
| } | |
| $autolink = $allowLinks ? $this->tryParseAutolink($text, $offset) : null; | |
| if ($autolink !== null) { | |
| $this->flushText($buffer, $nodes); | |
| $nodes[] = $autolink['node']; | |
| if (isset($autolink['literalAttribute'])) { | |
| $nodes[] = new AstNode('text', ['text' => $autolink['literalAttribute']]); | |
| } | |
| $offset = $autolink['next']; | |
| continue; | |
| } | |
| $rawHtmlInline = $allowLinks ? $this->tryParseRawHtmlInline($text, $offset) : null; | |
| if ($rawHtmlInline !== null) { | |
| $this->flushText($buffer, $nodes); | |
| $nodes[] = $rawHtmlInline['node']; | |
| $offset = $rawHtmlInline['next']; | |
| continue; | |
| } | |
| $bareUriAutolink = $allowLinks ? $this->tryParseBareUriAutolink($text, $offset) : null; | |
| if ($bareUriAutolink !== null) { | |
| $this->flushText($buffer, $nodes); | |
| $nodes[] = $bareUriAutolink['node']; | |
| $offset = $bareUriAutolink['next']; | |
| continue; | |
| } | |
| $exampleReference = $this->tryParseNumberedExampleReference($text, $offset); | |
| if ($exampleReference !== null) { | |
| $buffer .= $exampleReference['text']; | |
| $offset = $exampleReference['next']; | |
| continue; | |
| } | |
| $replacement = $this->tryReadSmartTextReplacement($text, $offset); | |
| if ($replacement !== null) { | |
| $buffer .= $replacement['text']; | |
| $offset = $replacement['next']; | |
| continue; | |
| } | |
| $abbreviationSpace = $this->tryReadAbbreviationSpace($text, $offset, $buffer); | |
| if ($abbreviationSpace !== null) { | |
| $buffer .= $abbreviationSpace['text']; | |
| $offset = $abbreviationSpace['next']; | |
| continue; | |
| } | |
| $escaped = $this->tryReadBackslashEscape($text, $offset); | |
| if ($escaped !== null) { | |
| $buffer .= $escaped['text']; | |
| $offset = $escaped['next']; | |
| continue; | |
| } | |
| $buffer .= $text[$offset]; | |
| $offset++; | |
| } | |
| $this->flushText($buffer, $nodes); | |
| return $nodes; | |
| } | |
| /** | |
| * @return array{node: AstNode, next: int}|null | |
| */ | |
| private function tryParseInlineNote(string $text, int $offset): ?array | |
| { | |
| if (substr($text, $offset, 2) !== '^[' || $this->isEscapedInlinePosition($text, $offset)) { | |
| return null; | |
| } | |
| $end = $this->findClosingInlineNoteBracket($text, $offset + 2); | |
| if ($end === null) { | |
| return null; | |
| } | |
| return [ | |
| 'node' => new AstNode('note', [], $this->parseFootnoteBlocks(substr($text, $offset + 2, $end - $offset - 2))), | |
| 'next' => $end + 1, | |
| ]; | |
| } | |
| /** | |
| * @return array{node: AstNode, next: int}|null | |
| */ | |
| private function tryParseFootnoteReference(string $text, int $offset): ?array | |
| { | |
| if (($text[$offset] ?? '') !== '[' || $this->isEscapedInlinePosition($text, $offset)) { | |
| return null; | |
| } | |
| if (preg_match('/\G\[\^([^\]\s]+)\]/', $text, $m, 0, $offset) !== 1) { | |
| return null; | |
| } | |
| $definition = $this->footnoteDefinitions[$this->normalizeReferenceLabel($m[1])] ?? null; | |
| if ($definition === null) { | |
| return null; | |
| } | |
| return [ | |
| 'node' => new AstNode('note', ['label' => $m[1]], $this->parseFootnoteBlocks($definition)), | |
| 'next' => $offset + strlen($m[0]), | |
| ]; | |
| } | |
| /** | |
| * @return list<AstNode> | |
| */ | |
| private function parseFootnoteBlocks(string $markdown): array | |
| { | |
| $markdown = trim($markdown, "\r\n"); | |
| if (trim($markdown) === '') { | |
| return []; | |
| } | |
| $previous = $this->resolveFootnoteReferences; | |
| $this->resolveFootnoteReferences = false; | |
| try { | |
| return $this->read($markdown)->children; | |
| } finally { | |
| $this->resolveFootnoteReferences = $previous; | |
| } | |
| } | |
| private function findClosingInlineNoteBracket(string $text, int $offset): ?int | |
| { | |
| $depth = 0; | |
| $length = strlen($text); | |
| for ($cursor = $offset; $cursor < $length; $cursor++) { | |
| if ($text[$cursor] === '\\') { | |
| $cursor++; | |
| continue; | |
| } | |
| if ($text[$cursor] === '`') { | |
| $tickCount = $this->countBackticks($text, $cursor); | |
| $end = $this->findMatchingBacktickRun($text, $cursor + $tickCount, $tickCount); | |
| if ($end !== null) { | |
| $cursor = $end + $tickCount - 1; | |
| } | |
| continue; | |
| } | |
| if ($text[$cursor] === '[') { | |
| $depth++; | |
| continue; | |
| } | |
| if ($text[$cursor] !== ']') { | |
| continue; | |
| } | |
| if ($depth === 0) { | |
| return $cursor; | |
| } | |
| $depth--; | |
| } | |
| return null; | |
| } | |
| /** | |
| * @return array{node: AstNode, next: int}|null | |
| */ | |
| private function tryParseCitation(string $text, int $offset, bool $allowBareCitation): ?array | |
| { | |
| if ($this->isEscapedInlinePosition($text, $offset)) { | |
| return null; | |
| } | |
| if (($text[$offset] ?? '') === '[') { | |
| if (preg_match('/\G\[@([A-Za-z0-9_:.#\/$%&+?<>~|-]+)\]/u', $text, $m, 0, $offset) !== 1) { | |
| return null; | |
| } | |
| return [ | |
| 'node' => new AstNode( | |
| 'citation', | |
| ['id' => $m[1], 'text' => $m[0], 'mode' => 'normal'], | |
| [new AstNode('text', ['text' => $m[0]])] | |
| ), | |
| 'next' => $offset + strlen($m[0]), | |
| ]; | |
| } | |
| if (!$allowBareCitation || ($text[$offset] ?? '') !== '@') { | |
| return null; | |
| } | |
| $previous = $offset === 0 ? '' : $text[$offset - 1]; | |
| if ($previous !== '' && preg_match('/[A-Za-z0-9_@.\/-]/', $previous) === 1) { | |
| return null; | |
| } | |
| if (preg_match('/\G@([A-Za-z0-9_:.#\/$%&+?<>~|-]*[A-Za-z0-9_#\/$%&+?<>~|-])/u', $text, $m, 0, $offset) !== 1) { | |
| return null; | |
| } | |
| $next = $offset + strlen($m[0]); | |
| $citationText = $m[0]; | |
| $attrs = ['id' => $m[1], 'text' => $citationText, 'mode' => 'author_in_text']; | |
| $suffix = $this->tryParseBareCitationSuffix($text, $next); | |
| if ($suffix !== null) { | |
| $next = $suffix['next']; | |
| $citationText .= $suffix['source']; | |
| $attrs['text'] = $citationText; | |
| $attrs['suffix'] = $suffix['label']; | |
| } | |
| return [ | |
| 'node' => new AstNode( | |
| 'citation', | |
| $attrs, | |
| [new AstNode('text', ['text' => $citationText])] | |
| ), | |
| 'next' => $next, | |
| ]; | |
| } | |
| /** | |
| * @return array{source:string, label:string, next:int}|null | |
| */ | |
| private function tryParseBareCitationSuffix(string $text, int $offset): ?array | |
| { | |
| if (preg_match('/\G[ \t]+/', $text, $space, 0, $offset) !== 1) { | |
| return null; | |
| } | |
| $label = $this->parseBracketedLabel($text, $offset + strlen($space[0])); | |
| if ($label === null || $label['text'] === '' || str_starts_with($label['text'], '^')) { | |
| return null; | |
| } | |
| $next = $label['next']; | |
| $following = $text[$next] ?? ''; | |
| if ($following === '(' || $following === '[') { | |
| return null; | |
| } | |
| if (isset($this->referenceLinks[$this->normalizeReferenceLabel($label['text'])])) { | |
| return null; | |
| } | |
| return [ | |
| 'source' => substr($text, $offset, $next - $offset), | |
| 'label' => $label['text'], | |
| 'next' => $next, | |
| ]; | |
| } | |
| /** | |
| * @return array{node: AstNode, next: int}|null | |
| */ | |
| private function tryParseImage(string $text, int $offset): ?array | |
| { | |
| if (substr($text, $offset, 2) !== '![' || $this->isEscapedInlinePosition($text, $offset)) { | |
| return null; | |
| } | |
| $label = $this->parseBracketedLabel($text, $offset + 1); | |
| if ($label === null) { | |
| return null; | |
| } | |
| $next = $label['next']; | |
| if (($text[$next] ?? '') === '(') { | |
| $target = $this->parseInlineLinkTarget($text, $next); | |
| if ($target === null) { | |
| return null; | |
| } | |
| [$node, $next] = $this->buildImageNodeWithTrailingAttributes( | |
| $text, | |
| $label['text'], | |
| $target['url'], | |
| $target['title'], | |
| $target['next'] | |
| ); | |
| return [ | |
| 'node' => $node, | |
| 'next' => $next, | |
| ]; | |
| } | |
| $referenceLabel = $label['text']; | |
| if (($text[$next] ?? '') === '[') { | |
| $reference = $this->parseBracketedLabel($text, $next); | |
| if ($reference === null) { | |
| return null; | |
| } | |
| $referenceLabel = $reference['text'] === '' ? $label['text'] : $reference['text']; | |
| $next = $reference['next']; | |
| } | |
| $target = $this->referenceLinks[$this->normalizeReferenceLabel($referenceLabel)] ?? null; | |
| if ($target === null) { | |
| return null; | |
| } | |
| [$node, $next] = $this->buildImageNodeWithTrailingAttributes( | |
| $text, | |
| $label['text'], | |
| $target['url'], | |
| $target['title'], | |
| $next | |
| ); | |
| return [ | |
| 'node' => $node, | |
| 'next' => $next, | |
| ]; | |
| } | |
| /** | |
| * @return array{0: AstNode, 1: int} | |
| */ | |
| private function buildImageNodeWithTrailingAttributes( | |
| string $source, | |
| string $label, | |
| string $url, | |
| string $title, | |
| int $offset | |
| ): array { | |
| $attrs = []; | |
| $attribute = $this->tryParseInlineAttributeSpec($source, $offset); | |
| if ($attribute !== null) { | |
| $attrs = $attribute['attrs']; | |
| $offset = $attribute['next']; | |
| } | |
| $alt = null; | |
| if (isset($attrs['attributes']) && is_array($attrs['attributes']) && array_key_exists('alt', $attrs['attributes'])) { | |
| $alt = (string) $attrs['attributes']['alt']; | |
| unset($attrs['attributes']['alt']); | |
| if ($attrs['attributes'] === []) { | |
| unset($attrs['attributes']); | |
| } | |
| } | |
| if (isset($attrs['htmlAttributes']) && is_array($attrs['htmlAttributes']) && array_key_exists('alt', $attrs['htmlAttributes'])) { | |
| unset($attrs['htmlAttributes']['alt']); | |
| if ($attrs['htmlAttributes'] === []) { | |
| unset($attrs['htmlAttributes']); | |
| } | |
| } | |
| return [$this->buildImageNode($label, $url, $title, $attrs, $alt), $offset]; | |
| } | |
| /** | |
| * @param array<string, mixed> $figureAttributes | |
| */ | |
| private function buildImageNode( | |
| string $label, | |
| string $url, | |
| string $title, | |
| array $figureAttributes = [], | |
| ?string $altOverride = null | |
| ): AstNode | |
| { | |
| $labelInlines = $this->parseLinkLabelInlines($label); | |
| $caption = $this->plainTextFromInlines($labelInlines); | |
| $attrs = [ | |
| 'url' => $url, | |
| 'alt' => $altOverride ?? $caption, | |
| 'caption' => $caption, | |
| ]; | |
| if ($title !== '') { | |
| $attrs['title'] = $title; | |
| } | |
| if ($figureAttributes !== []) { | |
| $attrs['figureAttributes'] = $figureAttributes; | |
| } | |
| return new AstNode('image', $attrs, $labelInlines); | |
| } | |
| /** | |
| * @return list<AstNode> | |
| */ | |
| private function parseLinkLabelInlines(string $label): array | |
| { | |
| return $this->parseInlines($label, false); | |
| } | |
| /** | |
| * @return array{node: AstNode, next: int}|null | |
| */ | |
| private function tryParseWikiLink(string $text, int $offset): ?array | |
| { | |
| if (substr($text, $offset, 2) !== '[[' || $this->isEscapedInlinePosition($text, $offset)) { | |
| return null; | |
| } | |
| $end = $this->findClosingWikiLink($text, $offset + 2); | |
| if ($end === null) { | |
| return null; | |
| } | |
| $content = substr($text, $offset + 2, $end - $offset - 2); | |
| if ($content === '') { | |
| return null; | |
| } | |
| [$url, $label] = $this->splitWikiLinkContent($content); | |
| $url = $this->decodeHtmlEntities($this->unescapeLinkComponent($url)); | |
| $label = $this->decodeHtmlEntities($this->unescapeLinkComponent($label)); | |
| return [ | |
| 'node' => new AstNode( | |
| 'link', | |
| [ | |
| 'url' => $url, | |
| 'classes' => ['wikilink'], | |
| ], | |
| [new AstNode('text', ['text' => $label])] | |
| ), | |
| 'next' => $end + 2, | |
| ]; | |
| } | |
| private function findClosingWikiLink(string $text, int $offset): ?int | |
| { | |
| $length = strlen($text); | |
| for ($cursor = $offset; $cursor < $length - 1; $cursor++) { | |
| if ($text[$cursor] === '\\') { | |
| $cursor++; | |
| continue; | |
| } | |
| if ($text[$cursor] === ']' && $text[$cursor + 1] === ']') { | |
| return $cursor; | |
| } | |
| } | |
| return null; | |
| } | |
| /** | |
| * @return array{0:string, 1:string} | |
| */ | |
| private function splitWikiLinkContent(string $content): array | |
| { | |
| $length = strlen($content); | |
| for ($cursor = 0; $cursor < $length; $cursor++) { | |
| if ($content[$cursor] === '\\') { | |
| $cursor++; | |
| continue; | |
| } | |
| if ($content[$cursor] !== '|') { | |
| continue; | |
| } | |
| $label = trim(substr($content, 0, $cursor)); | |
| $url = trim(substr($content, $cursor + 1)); | |
| if ($label === '' || $url === '') { | |
| break; | |
| } | |
| return [$url, $label]; | |
| } | |
| $content = trim($content); | |
| return [$content, $content]; | |
| } | |
| /** | |
| * @param list<AstNode> $nodes | |
| */ | |
| private function plainTextFromInlines(array $nodes): string | |
| { | |
| $text = ''; | |
| foreach ($nodes as $node) { | |
| if ($node->type === 'text' || $node->type === 'code' || $node->type === 'math') { | |
| $text .= (string) $node->attr('text', ''); | |
| continue; | |
| } | |
| if ($node->type === 'raw_tex') { | |
| $text .= (string) $node->attr('tex', ''); | |
| continue; | |
| } | |
| if ($node->type === 'softbreak') { | |
| $text .= "\n"; | |
| continue; | |
| } | |
| if ($node->type === 'linebreak') { | |
| $text .= "\n"; | |
| continue; | |
| } | |
| $text .= $this->plainTextFromInlines($node->children); | |
| } | |
| return $text; | |
| } | |
| /** | |
| * @param list<AstNode> $nodes | |
| */ | |
| private function paragraphTextFromInlines(array $nodes): string | |
| { | |
| $text = ''; | |
| foreach ($nodes as $node) { | |
| if ($node->type === 'text' || $node->type === 'code' || $node->type === 'math') { | |
| $text .= (string) $node->attr('text', ''); | |
| continue; | |
| } | |
| if ($node->type === 'raw_tex') { | |
| $text .= (string) $node->attr('tex', ''); | |
| continue; | |
| } | |
| if ($node->type === 'softbreak') { | |
| $text .= ' '; | |
| continue; | |
| } | |
| if ($node->type === 'linebreak') { | |
| $text .= "\n"; | |
| continue; | |
| } | |
| $text .= $this->paragraphTextFromInlines($node->children); | |
| } | |
| return $text; | |
| } | |
| /** | |
| * @return array{node: AstNode, next: int}|null | |
| */ | |
| private function tryParseInlineLink(string $text, int $offset): ?array | |
| { | |
| $label = $this->parseBracketedLabel($text, $offset); | |
| if ($label === null || ($text[$label['next']] ?? '') !== '(') { | |
| return null; | |
| } | |
| $target = $this->parseInlineLinkTarget($text, $label['next']); | |
| if ($target === null) { | |
| return null; | |
| } | |
| $attrs = ['url' => $target['url']]; | |
| if ($target['title'] !== '') { | |
| $attrs['title'] = $target['title']; | |
| } | |
| return [ | |
| 'node' => new AstNode('link', $attrs, $this->parseLinkLabelInlines($label['text'])), | |
| 'next' => $target['next'], | |
| ]; | |
| } | |
| /** | |
| * @return array{node: AstNode, next: int}|null | |
| */ | |
| private function tryParseReferenceLink(string $text, int $offset): ?array | |
| { | |
| $label = $this->parseBracketedLabel($text, $offset); | |
| if ($label === null) { | |
| return null; | |
| } | |
| $next = $label['next']; | |
| $referenceLabel = $label['text']; | |
| if (($text[$next] ?? '') === '[') { | |
| $reference = $this->parseBracketedLabel($text, $next); | |
| if ($reference === null) { | |
| return null; | |
| } | |
| $referenceLabel = $reference['text'] === '' ? $label['text'] : $reference['text']; | |
| $next = $reference['next']; | |
| } | |
| $target = $this->referenceLinks[$this->normalizeReferenceLabel($referenceLabel)] ?? null; | |
| if ($target === null) { | |
| return null; | |
| } | |
| $attrs = ['url' => $target['url']]; | |
| if ($target['title'] !== '') { | |
| $attrs['title'] = $target['title']; | |
| } | |
| return [ | |
| 'node' => new AstNode('link', $attrs, $this->parseLinkLabelInlines($label['text'])), | |
| 'next' => $next, | |
| ]; | |
| } | |
| /** | |
| * @return array{node: AstNode, next: int}|null | |
| */ | |
| private function tryParseBracketedSpan(string $text, int $offset): ?array | |
| { | |
| $label = $this->parseBracketedLabel($text, $offset); | |
| if ($label === null || ($text[$label['next']] ?? '') !== '{') { | |
| return null; | |
| } | |
| $end = $this->findUnescapedCharacter($text, '}', $label['next'] + 1); | |
| if ($end === null) { | |
| return null; | |
| } | |
| [$id, $classes, $attributes] = $this->parseMarkdownAttributeSpec(substr($text, $label['next'] + 1, $end - $label['next'] - 1)); | |
| if ($id === null && $classes === [] && $attributes === []) { | |
| return null; | |
| } | |
| return [ | |
| 'node' => new AstNode( | |
| 'span', | |
| $this->markdownAttributeAstAttrs($id, $classes, $attributes), | |
| $this->parseInlines($label['text']) | |
| ), | |
| 'next' => $end + 1, | |
| ]; | |
| } | |
| /** | |
| * @return array{node: AstNode, next: int}|null | |
| */ | |
| private function tryParseEmojiAlias(string $text, int $offset): ?array | |
| { | |
| if (($text[$offset] ?? '') !== ':' || $this->isEscapedInlinePosition($text, $offset)) { | |
| return null; | |
| } | |
| if (preg_match('/\G:([A-Za-z0-9_+-]+):/', $text, $m, 0, $offset) !== 1) { | |
| return null; | |
| } | |
| $emoji = match ($m[1]) { | |
| 'smile' => "\u{1F604}", | |
| '+1' => "\u{1F44D}", | |
| default => null, | |
| }; | |
| if ($emoji === null) { | |
| return null; | |
| } | |
| return [ | |
| 'node' => new AstNode( | |
| 'span', | |
| $this->markdownAttributeAstAttrs(null, ['emoji'], ['data-emoji' => $m[1]]), | |
| [new AstNode('text', ['text' => $emoji])] | |
| ), | |
| 'next' => $offset + strlen($m[0]), | |
| ]; | |
| } | |
| /** | |
| * @param list<string> $classes | |
| * @param array<string, string> $attributes | |
| * @return array<string, mixed> | |
| */ | |
| private function markdownAttributeAstAttrs(?string $id, array $classes, array $attributes): array | |
| { | |
| $attrs = []; | |
| $htmlAttributes = []; | |
| if ($id !== null && $id !== '') { | |
| $attrs['id'] = $id; | |
| $htmlAttributes['id'] = $id; | |
| } | |
| if ($classes !== []) { | |
| $attrs['classes'] = $classes; | |
| $htmlAttributes['class'] = implode(' ', $classes); | |
| } | |
| if ($attributes !== []) { | |
| $attrs['attributes'] = $attributes; | |
| foreach ($attributes as $name => $value) { | |
| $htmlAttributes[$name] = $value; | |
| } | |
| } | |
| if ($htmlAttributes !== []) { | |
| $attrs['htmlAttributes'] = $htmlAttributes; | |
| } | |
| return $attrs; | |
| } | |
| /** | |
| * @return array{attrs: array<string, mixed>, next: int}|null | |
| */ | |
| private function tryParseInlineAttributeSpec(string $text, int $offset): ?array | |
| { | |
| if (($text[$offset] ?? '') !== '{') { | |
| return null; | |
| } | |
| $end = $this->findUnescapedCharacter($text, '}', $offset + 1); | |
| if ($end === null) { | |
| return null; | |
| } | |
| [$id, $classes, $attributes] = $this->parseMarkdownAttributeSpec(substr($text, $offset + 1, $end - $offset - 1)); | |
| if ($id === null && $classes === [] && $attributes === []) { | |
| return null; | |
| } | |
| return [ | |
| 'attrs' => $this->markdownAttributeAstAttrs($id, $classes, $attributes), | |
| 'next' => $end + 1, | |
| ]; | |
| } | |
| /** | |
| * @return array{format: string, next: int}|null | |
| */ | |
| private function tryParseRawAttributeSpec(string $text, int $offset): ?array | |
| { | |
| if (($text[$offset] ?? '') !== '{') { | |
| return null; | |
| } | |
| $end = $this->findUnescapedCharacter($text, '}', $offset + 1); | |
| if ($end === null) { | |
| return null; | |
| } | |
| $inside = trim(substr($text, $offset + 1, $end - $offset - 1)); | |
| if (preg_match('/^=([A-Za-z0-9_.:+-]+)$/', $inside, $match) !== 1) { | |
| return null; | |
| } | |
| return [ | |
| 'format' => $match[1], | |
| 'next' => $end + 1, | |
| ]; | |
| } | |
| private function rawInlineNode(string $format, string $text): AstNode | |
| { | |
| $normalized = strtolower($format); | |
| if (in_array($normalized, ['html', 'html4', 'html5'], true)) { | |
| return new AstNode('raw_html_inline', [ | |
| 'format' => $format, | |
| 'html' => $text, | |
| 'text' => $text, | |
| ]); | |
| } | |
| if ($normalized === 'tex') { | |
| return new AstNode('raw_tex_inline', [ | |
| 'format' => $format, | |
| 'tex' => $text, | |
| 'text' => $text, | |
| ]); | |
| } | |
| return new AstNode('raw_inline', [ | |
| 'format' => $format, | |
| 'text' => $text, | |
| ]); | |
| } | |
| private function rawBlockNode(string $format, string $text): AstNode | |
| { | |
| $normalized = strtolower($format); | |
| if (in_array($normalized, ['html', 'html4', 'html5'], true)) { | |
| return new AstNode('raw_html', [ | |
| 'format' => $format, | |
| 'html' => $text, | |
| 'text' => $text, | |
| ]); | |
| } | |
| if ($normalized === 'tex') { | |
| return new AstNode('raw_tex', [ | |
| 'format' => $format, | |
| 'tex' => $text, | |
| 'text' => $text, | |
| ]); | |
| } | |
| return new AstNode('raw_block', [ | |
| 'format' => $format, | |
| 'text' => $text, | |
| ]); | |
| } | |
| /** | |
| * @return array{text: string, next: int}|null | |
| */ | |
| private function tryParseSpacedInlineAttributeLiteral(string $text, int $offset): ?array | |
| { | |
| if (preg_match('/\G[ \t]+\{/', $text, $space, 0, $offset) !== 1) { | |
| return null; | |
| } | |
| $start = $offset + strlen($space[0]) - 1; | |
| $end = $this->findUnescapedCharacter($text, '}', $start + 1); | |
| if ($end === null) { | |
| return null; | |
| } | |
| [$id, $classes, $attributes] = $this->parseMarkdownAttributeSpec(substr($text, $start + 1, $end - $start - 1)); | |
| if ($id === null && $classes === [] && $attributes === []) { | |
| return null; | |
| } | |
| return [ | |
| 'text' => substr($text, $offset, $end - $offset + 1), | |
| 'next' => $end + 1, | |
| ]; | |
| } | |
| /** | |
| * @return array{node: AstNode, next: int, literalAttribute?: string}|null | |
| */ | |
| private function tryParseAutolink(string $text, int $offset): ?array | |
| { | |
| if (($text[$offset] ?? '') !== '<' || $this->isEscapedInlinePosition($text, $offset)) { | |
| return null; | |
| } | |
| if (preg_match('/\G<((?:https?|ftp):\/\/[^<>\s]+)>/i', $text, $m, 0, $offset) === 1) { | |
| $url = $this->normalizeLinkDestination($m[1]); | |
| $next = $offset + strlen($m[0]); | |
| [$attrs, $next, $literalAttribute] = $this->readTrailingAutolinkAttributes($text, $next, [ | |
| 'url' => $url, | |
| 'classes' => ['uri'], | |
| ]); | |
| $result = [ | |
| 'node' => new AstNode( | |
| 'link', | |
| $attrs, | |
| [new AstNode('text', ['text' => $url])] | |
| ), | |
| 'next' => $next, | |
| ]; | |
| if ($literalAttribute !== null) { | |
| $result['literalAttribute'] = $literalAttribute; | |
| } | |
| return $result; | |
| } | |
| if ( | |
| preg_match( | |
| '/\G<([^\s<>@]+@[^\s<>@]+\.[^\s<>@]+)>/u', | |
| $text, | |
| $m, | |
| 0, | |
| $offset | |
| ) === 1 | |
| ) { | |
| $address = $this->decodeHtmlEntities($this->unescapeLinkComponent($m[1])); | |
| $next = $offset + strlen($m[0]); | |
| [$attrs, $next, $literalAttribute] = $this->readTrailingAutolinkAttributes($text, $next, [ | |
| 'url' => 'mailto:' . $address, | |
| 'classes' => ['email'], | |
| ]); | |
| $result = [ | |
| 'node' => new AstNode( | |
| 'link', | |
| $attrs, | |
| [new AstNode('text', ['text' => $address])] | |
| ), | |
| 'next' => $next, | |
| ]; | |
| if ($literalAttribute !== null) { | |
| $result['literalAttribute'] = $literalAttribute; | |
| } | |
| return $result; | |
| } | |
| return null; | |
| } | |
| /** | |
| * @param array<string, mixed> $attrs | |
| * @return array{0:array<string, mixed>, 1:int, 2:string|null} | |
| */ | |
| private function readTrailingAutolinkAttributes(string $text, int $offset, array $attrs): array | |
| { | |
| $attribute = $this->tryParseInlineAttributeSpec($text, $offset); | |
| if ($attribute !== null) { | |
| $merged = array_replace($attrs, $attribute['attrs']); | |
| if (isset($attrs['classes']) && !array_key_exists('classes', $attribute['attrs'])) { | |
| unset($merged['classes']); | |
| } | |
| return [$merged, $attribute['next'], null]; | |
| } | |
| $literalAttribute = $this->tryParseSpacedInlineAttributeLiteral($text, $offset); | |
| if ($literalAttribute !== null) { | |
| return [$attrs, $literalAttribute['next'], $literalAttribute['text']]; | |
| } | |
| return [$attrs, $offset, null]; | |
| } | |
| /** | |
| * @return array{node: AstNode, next: int}|null | |
| */ | |
| private function tryParseRawHtmlInline(string $text, int $offset): ?array | |
| { | |
| if (($text[$offset] ?? '') !== '<' || $this->isEscapedInlinePosition($text, $offset)) { | |
| return null; | |
| } | |
| if (preg_match('~\G</a\s*>~iu', $text, $close, 0, $offset) === 1) { | |
| return [ | |
| 'node' => new AstNode('raw_html_inline', ['html' => $close[0]]), | |
| 'next' => $offset + strlen($close[0]), | |
| ]; | |
| } | |
| if (preg_match('~\G<a(?=\s|>)(?:\s+(?:"[^"]*"|\'[^\']*\'|[^\'"<>])*)?>~iu', $text, $open, 0, $offset) === 1) { | |
| $afterOpen = $offset + strlen($open[0]); | |
| if (preg_match('~\G</a\s*>~iu', $text, $close, 0, $afterOpen) === 1) { | |
| return [ | |
| 'node' => new AstNode('raw_html_inline', ['html' => $open[0]]), | |
| 'next' => $afterOpen, | |
| ]; | |
| } | |
| if (preg_match('~</a\s*>~iu', $text, $close, PREG_OFFSET_CAPTURE, $afterOpen) === 1) { | |
| $end = $close[0][1] + strlen($close[0][0]); | |
| return [ | |
| 'node' => new AstNode('raw_html_inline', ['html' => substr($text, $offset, $end - $offset)]), | |
| 'next' => $end, | |
| ]; | |
| } | |
| } | |
| return null; | |
| } | |
| /** | |
| * @return array{node: AstNode, next: int}|null | |
| */ | |
| private function tryParseBareUriAutolink(string $text, int $offset): ?array | |
| { | |
| if ($this->isEscapedInlinePosition($text, $offset)) { | |
| return null; | |
| } | |
| $previous = $offset === 0 ? '' : $text[$offset - 1]; | |
| if ($previous !== '' && preg_match('/[A-Za-z0-9_@.\/-]/', $previous) === 1) { | |
| return null; | |
| } | |
| if ( | |
| preg_match( | |
| '~\G(?:(?:https?|git|file)://[^\s<>"\']+|mailto:[^\s<>"\']+|doi:10\.[^\s<>"\']+)~iu', | |
| $text, | |
| $m, | |
| 0, | |
| $offset | |
| ) !== 1 | |
| ) { | |
| return null; | |
| } | |
| $candidate = $this->trimBareUriAutolinkCandidate($m[0]); | |
| if ($candidate === '') { | |
| return null; | |
| } | |
| $url = $this->normalizeBareUriDestination($candidate); | |
| $display = $this->decodeHtmlEntities($this->unescapeLinkComponent($candidate)); | |
| return [ | |
| 'node' => new AstNode( | |
| 'link', | |
| [ | |
| 'url' => $url, | |
| 'classes' => ['uri'], | |
| ], | |
| [new AstNode('text', ['text' => $display])] | |
| ), | |
| 'next' => $offset + strlen($candidate), | |
| ]; | |
| } | |
| private function trimBareUriAutolinkCandidate(string $candidate): string | |
| { | |
| do { | |
| $previous = $candidate; | |
| $candidate = rtrim($candidate, ".,;:!?"); | |
| foreach ([['(', ')'], ['[', ']'], ['{', '}']] as [$open, $close]) { | |
| while ( | |
| str_ends_with($candidate, $close) | |
| && substr_count($candidate, $close) > substr_count($candidate, $open) | |
| ) { | |
| $candidate = substr($candidate, 0, -1); | |
| } | |
| } | |
| } while ($candidate !== $previous); | |
| return $candidate; | |
| } | |
| private function normalizeBareUriDestination(string $destination): string | |
| { | |
| $destination = $this->normalizeLinkDestination($destination); | |
| return strtr($destination, [ | |
| '[' => '%5B', | |
| ']' => '%5D', | |
| '{' => '%7B', | |
| '}' => '%7D', | |
| ]); | |
| } | |
| /** | |
| * @return array{text:string, next:int}|null | |
| */ | |
| private function tryParseNumberedExampleReference(string $text, int $offset): ?array | |
| { | |
| if (($text[$offset] ?? '') !== '(' || $this->isEscapedInlinePosition($text, $offset)) { | |
| return null; | |
| } | |
| if (preg_match('/\G\(@([A-Za-z0-9_-]+)\)/', $text, $m, 0, $offset) !== 1) { | |
| return null; | |
| } | |
| $number = $this->exampleReferences[$m[1]] ?? null; | |
| if ($number === null) { | |
| return null; | |
| } | |
| return [ | |
| 'text' => '(' . $number . ')', | |
| 'next' => $offset + strlen($m[0]), | |
| ]; | |
| } | |
| /** | |
| * @return array{text:string, next:int}|null | |
| */ | |
| private function parseBracketedLabel(string $text, int $offset): ?array | |
| { | |
| if (($text[$offset] ?? '') !== '[') { | |
| return null; | |
| } | |
| $start = $offset + 1; | |
| $depth = 0; | |
| $length = strlen($text); | |
| for ($cursor = $start; $cursor < $length; $cursor++) { | |
| if ($text[$cursor] === '\\') { | |
| $cursor++; | |
| continue; | |
| } | |
| if ($text[$cursor] === '[') { | |
| $depth++; | |
| continue; | |
| } | |
| if ($text[$cursor] !== ']') { | |
| continue; | |
| } | |
| if ($depth > 0) { | |
| $depth--; | |
| continue; | |
| } | |
| return [ | |
| 'text' => substr($text, $start, $cursor - $start), | |
| 'next' => $cursor + 1, | |
| ]; | |
| } | |
| return null; | |
| } | |
| /** | |
| * @return array{url:string, title:string, next:int}|null | |
| */ | |
| private function parseInlineLinkTarget(string $text, int $openParenOffset): ?array | |
| { | |
| if (($text[$openParenOffset] ?? '') !== '(') { | |
| return null; | |
| } | |
| $close = $this->findInlineLinkClosingParen($text, $openParenOffset + 1); | |
| if ($close === null) { | |
| return null; | |
| } | |
| $target = $this->parseLinkDestinationAndTitle(substr($text, $openParenOffset + 1, $close - $openParenOffset - 1)); | |
| if ($target === null) { | |
| return null; | |
| } | |
| return [ | |
| 'url' => $target['url'], | |
| 'title' => $target['title'], | |
| 'next' => $close + 1, | |
| ]; | |
| } | |
| private function findInlineLinkClosingParen(string $text, int $offset): ?int | |
| { | |
| $length = strlen($text); | |
| $quote = null; | |
| $angleDestination = false; | |
| $parenDepth = 0; | |
| for ($cursor = $offset; $cursor < $length; $cursor++) { | |
| if ($text[$cursor] === '\\') { | |
| $cursor++; | |
| continue; | |
| } | |
| if ($angleDestination) { | |
| if ($text[$cursor] === '>') { | |
| $angleDestination = false; | |
| } | |
| continue; | |
| } | |
| if ($quote !== null) { | |
| if ($text[$cursor] === $quote) { | |
| $quote = null; | |
| } | |
| continue; | |
| } | |
| if ($text[$cursor] === '<') { | |
| $angleDestination = true; | |
| continue; | |
| } | |
| if ($text[$cursor] === '"' || $text[$cursor] === "'") { | |
| $quote = $text[$cursor]; | |
| continue; | |
| } | |
| if ($text[$cursor] === '(') { | |
| $parenDepth++; | |
| continue; | |
| } | |
| if ($text[$cursor] === ')') { | |
| if ($parenDepth > 0) { | |
| $parenDepth--; | |
| continue; | |
| } | |
| return $cursor; | |
| } | |
| } | |
| return null; | |
| } | |
| /** | |
| * @return array{url:string, title:string}|null | |
| */ | |
| private function parseLinkDestinationAndTitle(string $content): ?array | |
| { | |
| $content = trim($content); | |
| if ($content === '') { | |
| return ['url' => '', 'title' => '']; | |
| } | |
| if ($content[0] === '<') { | |
| [$destination, $rest] = $this->readLinkDestination($content); | |
| if ($destination === null) { | |
| return null; | |
| } | |
| $title = ''; | |
| $rest = trim($rest); | |
| if ($rest !== '') { | |
| $title = $this->parseLinkTitle($rest); | |
| if ($title === null) { | |
| return null; | |
| } | |
| } | |
| return [ | |
| 'url' => $this->normalizeLinkDestination($destination), | |
| 'title' => $title, | |
| ]; | |
| } | |
| [$destination, $titleSource] = $this->splitBareLinkDestinationAndTitle($content); | |
| $destination = trim($destination); | |
| if ($destination === '') { | |
| return null; | |
| } | |
| $title = ''; | |
| if ($titleSource !== null) { | |
| $title = $this->parseLinkTitle($titleSource); | |
| if ($title === null) { | |
| return null; | |
| } | |
| } | |
| return [ | |
| 'url' => $this->normalizeLinkDestination($destination), | |
| 'title' => $title, | |
| ]; | |
| } | |
| /** | |
| * @return array{0:string, 1:string|null} | |
| */ | |
| private function splitBareLinkDestinationAndTitle(string $content): array | |
| { | |
| $length = strlen($content); | |
| for ($cursor = 0; $cursor < $length; $cursor++) { | |
| if (!ctype_space($content[$cursor])) { | |
| continue; | |
| } | |
| $title = ltrim(substr($content, $cursor + 1)); | |
| if ($title === '' || $this->parseLinkTitle($title) === null) { | |
| continue; | |
| } | |
| $destination = rtrim(substr($content, 0, $cursor)); | |
| if ($destination !== '') { | |
| return [$destination, $title]; | |
| } | |
| } | |
| return [$content, null]; | |
| } | |
| /** | |
| * @return array{0:string|null, 1:string} | |
| */ | |
| private function readLinkDestination(string $content): array | |
| { | |
| if ($content[0] === '<') { | |
| $end = $this->findUnescapedCharacter($content, '>', 1); | |
| if ($end === null) { | |
| return [null, '']; | |
| } | |
| return [substr($content, 1, $end - 1), substr($content, $end + 1)]; | |
| } | |
| $length = strlen($content); | |
| for ($cursor = 0; $cursor < $length; $cursor++) { | |
| if ($content[$cursor] === '\\') { | |
| $cursor++; | |
| continue; | |
| } | |
| if (ctype_space($content[$cursor])) { | |
| return [substr($content, 0, $cursor), substr($content, $cursor + 1)]; | |
| } | |
| } | |
| return [$content, '']; | |
| } | |
| private function findUnescapedCharacter(string $text, string $character, int $offset): ?int | |
| { | |
| $length = strlen($text); | |
| for ($cursor = $offset; $cursor < $length; $cursor++) { | |
| if ($text[$cursor] === '\\') { | |
| $cursor++; | |
| continue; | |
| } | |
| if ($text[$cursor] === $character) { | |
| return $cursor; | |
| } | |
| } | |
| return null; | |
| } | |
| private function parseLinkTitle(string $text): ?string | |
| { | |
| $text = trim($text); | |
| if ($text === '') { | |
| return ''; | |
| } | |
| $open = $text[0]; | |
| $close = match ($open) { | |
| '"' => '"', | |
| "'" => "'", | |
| '(' => ')', | |
| default => null, | |
| }; | |
| if ($close === null || !str_ends_with($text, $close)) { | |
| return null; | |
| } | |
| return $this->decodeHtmlEntities($this->unescapeLinkComponent(substr($text, 1, -1))); | |
| } | |
| private function unescapeLinkComponent(string $text): string | |
| { | |
| $unescaped = ''; | |
| $length = strlen($text); | |
| for ($offset = 0; $offset < $length; $offset++) { | |
| $char = $text[$offset]; | |
| $next = $text[$offset + 1] ?? ''; | |
| if ($char === '\\' && $this->isMarkdownEscapablePunctuation($next)) { | |
| $unescaped .= $next; | |
| $offset++; | |
| continue; | |
| } | |
| $unescaped .= $char; | |
| } | |
| return $unescaped; | |
| } | |
| private function normalizeLinkDestination(string $destination): string | |
| { | |
| $destination = $this->decodeHtmlEntities($this->unescapeLinkComponent($destination)); | |
| $destination = trim(preg_replace('/\s+/', ' ', $destination) ?? $destination); | |
| return str_replace(' ', '%20', $destination); | |
| } | |
| /** | |
| * @return array{node: AstNode, next: int}|null | |
| */ | |
| private function tryParseMath(string $text, int $offset): ?array | |
| { | |
| if (($text[$offset] ?? '') !== '$' || $this->isEscapedInlinePosition($text, $offset)) { | |
| return null; | |
| } | |
| if (substr($text, $offset, 2) === '$$') { | |
| $end = $this->findClosingDisplayMath($text, $offset + 2); | |
| if ($end === null || $end === $offset + 2) { | |
| return null; | |
| } | |
| return [ | |
| 'node' => new AstNode('math', [ | |
| 'text' => $this->expandRawTexMathMacros(trim(substr($text, $offset + 2, $end - $offset - 2))), | |
| 'display' => true, | |
| ]), | |
| 'next' => $end + 2, | |
| ]; | |
| } | |
| $next = $text[$offset + 1] ?? ''; | |
| if ($next === '' || ctype_space($next)) { | |
| return null; | |
| } | |
| $end = $this->findClosingInlineMath($text, $offset + 1); | |
| if ($end === null || $end === $offset + 1) { | |
| return null; | |
| } | |
| return [ | |
| 'node' => new AstNode('math', [ | |
| 'text' => $this->expandRawTexMathMacros(trim(substr($text, $offset + 1, $end - $offset - 1))), | |
| 'display' => false, | |
| ]), | |
| 'next' => $end + 1, | |
| ]; | |
| } | |
| private function findClosingDisplayMath(string $text, int $offset): ?int | |
| { | |
| $position = strpos($text, '$$', $offset); | |
| while ($position !== false) { | |
| if (!$this->isEscapedInlinePosition($text, $position)) { | |
| return $position; | |
| } | |
| $position = strpos($text, '$$', $position + 2); | |
| } | |
| return null; | |
| } | |
| private function findClosingInlineMath(string $text, int $offset): ?int | |
| { | |
| $length = strlen($text); | |
| $braceDepth = 0; | |
| for ($position = $offset; $position < $length; $position++) { | |
| $char = $text[$position]; | |
| if ($char === '\\') { | |
| $position++; | |
| continue; | |
| } | |
| if ($char === '{') { | |
| $braceDepth++; | |
| continue; | |
| } | |
| if ($char === '}') { | |
| $braceDepth = max(0, $braceDepth - 1); | |
| continue; | |
| } | |
| if ($char !== '$' || $braceDepth > 0) { | |
| continue; | |
| } | |
| if ( | |
| substr($text, $position, 2) !== '$$' | |
| && !$this->isInvalidClosingInlineMathDollar($text, $position) | |
| ) { | |
| return $position; | |
| } | |
| } | |
| return null; | |
| } | |
| private function isInvalidClosingInlineMathDollar(string $text, int $offset): bool | |
| { | |
| $previous = $offset > 0 ? $text[$offset - 1] : ''; | |
| $next = $text[$offset + 1] ?? ''; | |
| return $previous === '' || ctype_space($previous) || ctype_digit($next); | |
| } | |
| private function expandRawTexMathMacros(string $math): string | |
| { | |
| if ($this->rawTexMacros === []) { | |
| return $math; | |
| } | |
| $expanded = $math; | |
| for ($iteration = 0; $iteration < 5; $iteration++) { | |
| $next = $this->expandRawTexMathMacrosOnce($expanded); | |
| if ($next === $expanded) { | |
| break; | |
| } | |
| $expanded = $next; | |
| } | |
| return $expanded; | |
| } | |
| private function expandRawTexMathMacrosOnce(string $math): string | |
| { | |
| $output = ''; | |
| $offset = 0; | |
| $length = strlen($math); | |
| while ($offset < $length) { | |
| if ( | |
| ($math[$offset] ?? '') === '\\' | |
| && preg_match('/\G\\\\([A-Za-z]+)/', $math, $m, 0, $offset) === 1 | |
| && isset($this->rawTexMacros[$m[1]]) | |
| ) { | |
| $macro = $this->rawTexMacros[$m[1]]; | |
| $cursor = $offset + strlen($m[0]); | |
| $args = []; | |
| for ($argument = 0; $argument < $macro['arity']; $argument++) { | |
| $parsed = $this->readTexBraceArgument($math, $cursor); | |
| if ($parsed === null) { | |
| break; | |
| } | |
| $args[] = $parsed['value']; | |
| $cursor = $parsed['next']; | |
| } | |
| if (count($args) === $macro['arity']) { | |
| $output .= $this->renderRawTexMacroTemplate($macro['template'], $args); | |
| $offset = $cursor; | |
| continue; | |
| } | |
| } | |
| $output .= $math[$offset]; | |
| $offset++; | |
| } | |
| return $output; | |
| } | |
| /** | |
| * @return array{value:string, next:int}|null | |
| */ | |
| private function readTexBraceArgument(string $text, int $offset): ?array | |
| { | |
| if (($text[$offset] ?? '') !== '{') { | |
| return null; | |
| } | |
| $depth = 0; | |
| $length = strlen($text); | |
| for ($cursor = $offset; $cursor < $length; $cursor++) { | |
| if ($text[$cursor] === '\\') { | |
| $cursor++; | |
| continue; | |
| } | |
| if ($text[$cursor] === '{') { | |
| $depth++; | |
| continue; | |
| } | |
| if ($text[$cursor] !== '}') { | |
| continue; | |
| } | |
| $depth--; | |
| if ($depth === 0) { | |
| return [ | |
| 'value' => substr($text, $offset + 1, $cursor - $offset - 1), | |
| 'next' => $cursor + 1, | |
| ]; | |
| } | |
| } | |
| return null; | |
| } | |
| /** | |
| * @param list<string> $args | |
| */ | |
| private function renderRawTexMacroTemplate(string $template, array $args): string | |
| { | |
| foreach ($args as $index => $argument) { | |
| $template = str_replace('#' . ($index + 1), $argument, $template); | |
| } | |
| return $template; | |
| } | |
| /** | |
| * @return array{node: AstNode, next: int}|null | |
| */ | |
| private function tryParseRawTexInline(string $text, int $offset): ?array | |
| { | |
| if (($text[$offset] ?? '') !== '\\' || $this->isEscapedInlinePosition($text, $offset)) { | |
| return null; | |
| } | |
| if (preg_match('/\G\\\\(stopformula)\b/', $text, $m, 0, $offset) === 1) { | |
| return [ | |
| 'node' => new AstNode('raw_tex', ['tex' => $m[0], 'command' => $m[1]]), | |
| 'next' => $offset + strlen($m[0]), | |
| ]; | |
| } | |
| if (preg_match('/\G\\\\([A-Za-z])\b/', $text, $m, 0, $offset) === 1) { | |
| return [ | |
| 'node' => new AstNode('raw_tex', ['tex' => $m[0], 'command' => $m[1]]), | |
| 'next' => $offset + strlen($m[0]), | |
| ]; | |
| } | |
| // Pandoc leaves bare environment commands such as "\begin" as text. | |
| if (preg_match('/\G\\\\(?:begin|end)\b/', $text, $m, 0, $offset) === 1) { | |
| $next = $text[$offset + strlen($m[0])] ?? ''; | |
| if ($next !== '{' && $next !== '[') { | |
| return null; | |
| } | |
| } | |
| if ( | |
| preg_match( | |
| '/\G\\\\([A-Za-z]+)(?:\[[^\]\r\n]*\])?(?:\{[^{}\r\n]*\})+/', | |
| $text, | |
| $m, | |
| 0, | |
| $offset | |
| ) !== 1 | |
| ) { | |
| return null; | |
| } | |
| return [ | |
| 'node' => new AstNode('raw_tex', ['tex' => $m[0], 'command' => $m[1]]), | |
| 'next' => $offset + strlen($m[0]), | |
| ]; | |
| } | |
| /** | |
| * @return array{node: AstNode, next: int}|null | |
| */ | |
| private function tryParseSmartQuote(string $text, int $offset): ?array | |
| { | |
| $delimiter = $text[$offset] ?? ''; | |
| if ($delimiter !== '"' && $delimiter !== "'") { | |
| return null; | |
| } | |
| if (!$this->canOpenSmartQuote($text, $offset, $delimiter)) { | |
| return null; | |
| } | |
| $end = $this->findClosingSmartQuote($text, $offset + 1, $delimiter); | |
| if ($end === null || $end === $offset + 1) { | |
| return null; | |
| } | |
| return [ | |
| 'node' => new AstNode( | |
| 'quoted', | |
| ['kind' => $delimiter === "'" ? 'single' : 'double'], | |
| $this->parseInlines(substr($text, $offset + 1, $end - $offset - 1)) | |
| ), | |
| 'next' => $end + 1, | |
| ]; | |
| } | |
| private function canOpenSmartQuote(string $text, int $offset, string $delimiter): bool | |
| { | |
| if ($this->isEscapedInlinePosition($text, $offset)) { | |
| return false; | |
| } | |
| $next = $text[$offset + 1] ?? ''; | |
| if ($next === '' || ctype_space($next)) { | |
| return false; | |
| } | |
| if ($delimiter === "'" && $this->isApostrophe($text, $offset)) { | |
| return false; | |
| } | |
| return !$this->hasWordCharacterBeforeOffset($text, $offset); | |
| } | |
| private function findClosingSmartQuote(string $text, int $offset, string $delimiter): ?int | |
| { | |
| $length = strlen($text); | |
| for ($cursor = $offset; $cursor < $length; $cursor++) { | |
| if ($text[$cursor] === '\\') { | |
| $cursor++; | |
| continue; | |
| } | |
| if ($text[$cursor] === '`') { | |
| $tickCount = $this->countBackticks($text, $cursor); | |
| $end = $this->findMatchingBacktickRun($text, $cursor + $tickCount, $tickCount); | |
| if ($end !== null) { | |
| $cursor = $end + $tickCount - 1; | |
| } | |
| continue; | |
| } | |
| if (substr($text, $cursor, 2) === '^[' && !$this->isEscapedInlinePosition($text, $cursor)) { | |
| $end = $this->findClosingInlineNoteBracket($text, $cursor + 2); | |
| if ($end !== null) { | |
| $cursor = $end; | |
| } | |
| continue; | |
| } | |
| if ($text[$cursor] === $delimiter && $this->canCloseSmartQuote($text, $cursor, $delimiter)) { | |
| return $cursor; | |
| } | |
| } | |
| return null; | |
| } | |
| private function canCloseSmartQuote(string $text, int $offset, string $delimiter): bool | |
| { | |
| if ($this->isEscapedInlinePosition($text, $offset)) { | |
| return false; | |
| } | |
| $previous = $offset > 0 ? $text[$offset - 1] : ''; | |
| if ($previous === '' || ctype_space($previous)) { | |
| return false; | |
| } | |
| return !($delimiter === "'" && $this->isApostrophe($text, $offset)); | |
| } | |
| /** | |
| * @return array{text:string, next:int}|null | |
| */ | |
| private function tryReadSmartTextReplacement(string $text, int $offset): ?array | |
| { | |
| if (substr($text, $offset, 3) === '...') { | |
| return ['text' => "\u{2026}", 'next' => $offset + 3]; | |
| } | |
| if (substr($text, $offset, 3) === '---') { | |
| return ['text' => "\u{2014}", 'next' => $offset + 3]; | |
| } | |
| if (substr($text, $offset, 2) === '--') { | |
| return ['text' => "\u{2013}", 'next' => $offset + 2]; | |
| } | |
| if (($text[$offset] ?? '') === "'" && $this->isRightSingleQuoteReplacement($text, $offset)) { | |
| return ['text' => "\u{2019}", 'next' => $offset + 1]; | |
| } | |
| $unclosedQuote = $this->tryReadUnclosedSmartQuoteReplacement($text, $offset); | |
| if ($unclosedQuote !== null) { | |
| return $unclosedQuote; | |
| } | |
| return null; | |
| } | |
| /** | |
| * @return array{text:string, next:int}|null | |
| */ | |
| private function tryReadAbbreviationSpace(string $text, int $offset, string $buffer): ?array | |
| { | |
| if (($text[$offset] ?? '') !== '.' || $this->isEscapedInlinePosition($text, $offset)) { | |
| return null; | |
| } | |
| $next = $text[$offset + 1] ?? ''; | |
| if ($next !== ' ' && $next !== "\t") { | |
| return null; | |
| } | |
| if (preg_match('/(?:^|[^\pL\pN])([\pL](?:[\pL]|\.)*)$/u', $buffer, $match) !== 1) { | |
| return null; | |
| } | |
| $abbreviation = $match[1] . '.'; | |
| if (!isset(self::ABBREVIATIONS[$abbreviation])) { | |
| return null; | |
| } | |
| $tail = substr($text, $offset + 2); | |
| if ($tail === '' || preg_match('/\A\pL/u', $tail) !== 1) { | |
| return null; | |
| } | |
| return [ | |
| 'text' => ".\u{00A0}", | |
| 'next' => $offset + 2, | |
| ]; | |
| } | |
| /** | |
| * @return array{text:string, next:int}|null | |
| */ | |
| private function tryReadUnclosedSmartQuoteReplacement(string $text, int $offset): ?array | |
| { | |
| $delimiter = $text[$offset] ?? ''; | |
| if ($delimiter !== '"' && $delimiter !== "'") { | |
| return null; | |
| } | |
| if (!$this->canOpenSmartQuote($text, $offset, $delimiter)) { | |
| return null; | |
| } | |
| if ($this->findClosingSmartQuote($text, $offset + 1, $delimiter) !== null) { | |
| return null; | |
| } | |
| return [ | |
| 'text' => $delimiter === "'" ? "\u{2018}" : "\u{201C}", | |
| 'next' => $offset + 1, | |
| ]; | |
| } | |
| private function isRightSingleQuoteReplacement(string $text, int $offset): bool | |
| { | |
| if ($this->isApostrophe($text, $offset)) { | |
| return true; | |
| } | |
| return $this->hasWordCharacterBeforeOffset($text, $offset) | |
| && !$this->hasWordCharacterAfterOffset($text, $offset); | |
| } | |
| private function isApostrophe(string $text, int $offset): bool | |
| { | |
| $previousIsWord = $this->hasWordCharacterBeforeOffset($text, $offset); | |
| if (!$previousIsWord && ($text[$offset - 1] ?? '') === '$') { | |
| $previousIsWord = $this->hasWordCharacterBeforeOffset($text, $offset - 1); | |
| } | |
| return $previousIsWord && $this->hasWordCharacterAfterOffset($text, $offset); | |
| } | |
| private function hasWordCharacterBeforeOffset(string $text, int $offset): bool | |
| { | |
| if ($offset <= 0) { | |
| return false; | |
| } | |
| return preg_match('/[\pL\pN]\z/u', substr($text, 0, $offset)) === 1; | |
| } | |
| private function hasWordCharacterAfterOffset(string $text, int $offset): bool | |
| { | |
| if ($offset + 1 >= strlen($text)) { | |
| return false; | |
| } | |
| return preg_match('/\A[\pL\pN]/u', substr($text, $offset + 1)) === 1; | |
| } | |
| /** | |
| * @return array{text:string, next:int}|null | |
| */ | |
| private function tryReadBackslashEscape(string $text, int $offset): ?array | |
| { | |
| if (($text[$offset] ?? '') !== '\\' || $this->isEscapedInlinePosition($text, $offset)) { | |
| return null; | |
| } | |
| $next = $text[$offset + 1] ?? ''; | |
| if (!$this->isMarkdownEscapablePunctuation($next)) { | |
| return null; | |
| } | |
| return ['text' => $next, 'next' => $offset + 2]; | |
| } | |
| private function isMarkdownEscapablePunctuation(string $char): bool | |
| { | |
| return $char !== '' && str_contains(self::MARKDOWN_ESCAPABLE_ASCII_PUNCTUATION, $char); | |
| } | |
| /** | |
| * @return array{node: AstNode, next: int}|null | |
| */ | |
| private function tryParseStrikeout(string $text, int $offset): ?array | |
| { | |
| if (substr($text, $offset, 2) !== '~~') { | |
| return null; | |
| } | |
| $end = strpos($text, '~~', $offset + 2); | |
| if ($end === false || $end === $offset + 2) { | |
| return null; | |
| } | |
| $inner = substr($text, $offset + 2, $end - $offset - 2); | |
| return [ | |
| 'node' => new AstNode('strikeout', [], $this->parseInlines($inner)), | |
| 'next' => $end + 2, | |
| ]; | |
| } | |
| /** | |
| * @return array{node: AstNode, next: int}|null | |
| */ | |
| private function tryParseScript(string $text, int $offset): ?array | |
| { | |
| $delimiter = $text[$offset] ?? ''; | |
| if ($delimiter !== '^' && $delimiter !== '~') { | |
| return null; | |
| } | |
| if ($delimiter === '~' && ($text[$offset + 1] ?? '') === '~') { | |
| return null; | |
| } | |
| $end = $this->findClosingScriptDelimiter($text, $offset + 1, $delimiter); | |
| if ($end === null) { | |
| return $this->tryParseShortScript($text, $offset, $delimiter); | |
| } | |
| if ($end === $offset + 1) { | |
| return null; | |
| } | |
| $inner = substr($text, $offset + 1, $end - $offset - 1); | |
| if ($this->hasUnescapedScriptWhitespace($inner)) { | |
| return null; | |
| } | |
| return [ | |
| 'node' => new AstNode( | |
| $delimiter === '^' ? 'superscript' : 'subscript', | |
| [], | |
| $this->parseInlines($this->normalizeScriptContent($inner)) | |
| ), | |
| 'next' => $end + 1, | |
| ]; | |
| } | |
| /** | |
| * @return array{node: AstNode, next: int}|null | |
| */ | |
| private function tryParseShortScript(string $text, int $offset, string $delimiter): ?array | |
| { | |
| $previous = $offset > 0 ? $text[$offset - 1] : ''; | |
| if (!$this->isAsciiAlnum($previous)) { | |
| return null; | |
| } | |
| if (preg_match('/\G\d+/', $text, $m, 0, $offset + 1) !== 1) { | |
| return null; | |
| } | |
| return [ | |
| 'node' => new AstNode( | |
| $delimiter === '^' ? 'superscript' : 'subscript', | |
| [], | |
| [new AstNode('text', ['text' => $m[0]])] | |
| ), | |
| 'next' => $offset + 1 + strlen($m[0]), | |
| ]; | |
| } | |
| private function findClosingScriptDelimiter(string $text, int $offset, string $delimiter): ?int | |
| { | |
| $position = strpos($text, $delimiter, $offset); | |
| while ($position !== false) { | |
| if (!$this->isEscapedInlinePosition($text, $position)) { | |
| return $position; | |
| } | |
| $position = strpos($text, $delimiter, $position + 1); | |
| } | |
| return null; | |
| } | |
| private function hasUnescapedScriptWhitespace(string $text): bool | |
| { | |
| $length = strlen($text); | |
| for ($offset = 0; $offset < $length; $offset++) { | |
| if ($text[$offset] === '\\') { | |
| $offset++; | |
| continue; | |
| } | |
| if (ctype_space($text[$offset])) { | |
| return true; | |
| } | |
| } | |
| return false; | |
| } | |
| private function normalizeScriptContent(string $text): string | |
| { | |
| $normalized = ''; | |
| $length = strlen($text); | |
| for ($offset = 0; $offset < $length; $offset++) { | |
| if ($text[$offset] === '\\' && ($text[$offset + 1] ?? '') === ' ') { | |
| $normalized .= "\xC2\xA0"; | |
| $offset++; | |
| continue; | |
| } | |
| $normalized .= $text[$offset]; | |
| } | |
| return $normalized; | |
| } | |
| private function isEscapedInlinePosition(string $text, int $offset): bool | |
| { | |
| $slashes = 0; | |
| for ($cursor = $offset - 1; $cursor >= 0 && $text[$cursor] === '\\'; $cursor--) { | |
| $slashes++; | |
| } | |
| return $slashes % 2 === 1; | |
| } | |
| /** | |
| * @return array{node: AstNode, next: int}|null | |
| */ | |
| private function tryParseEmphasisDelimiter(string $text, int $offset): ?array | |
| { | |
| $char = $text[$offset] ?? ''; | |
| if ($char !== '*' && $char !== '_') { | |
| return null; | |
| } | |
| $runLength = $this->countDelimiterRun($text, $offset, $char); | |
| $sizes = $runLength >= 3 ? [3, 1, 2] : ($runLength >= 2 ? [2, 1] : [1]); | |
| foreach ($sizes as $size) { | |
| if ($runLength < $size || !$this->canOpenInlineDelimiter($text, $offset, $char, $size)) { | |
| continue; | |
| } | |
| $end = $this->findClosingInlineDelimiter($text, $offset + $size, $char, $size); | |
| if ($end === null || $end <= $offset + $size) { | |
| continue; | |
| } | |
| $inner = $this->parseInlines(substr($text, $offset + $size, $end - $offset - $size), true, false); | |
| $node = match ($size) { | |
| 3 => new AstNode('strong', [], [new AstNode('emph', [], $inner)]), | |
| 2 => new AstNode('strong', [], $inner), | |
| default => new AstNode('emph', [], $inner), | |
| }; | |
| return ['node' => $node, 'next' => $end + $size]; | |
| } | |
| return null; | |
| } | |
| private function countDelimiterRun(string $text, int $offset, string $char): int | |
| { | |
| $count = 0; | |
| $length = strlen($text); | |
| while ($offset + $count < $length && $text[$offset + $count] === $char) { | |
| $count++; | |
| } | |
| return $count; | |
| } | |
| private function findClosingInlineDelimiter(string $text, int $offset, string $char, int $size): ?int | |
| { | |
| $needle = str_repeat($char, $size); | |
| $position = strpos($text, $needle, $offset); | |
| while ($position !== false) { | |
| $runLength = $this->countDelimiterRun($text, $position, $char); | |
| if ($size === 1 && (($text[$position - 1] ?? '') === $char || $runLength > 1)) { | |
| $position = strpos($text, $needle, $position + 1); | |
| continue; | |
| } | |
| if ($runLength >= $size | |
| && $this->canCloseInlineDelimiter($text, $position, $char, $size) | |
| ) { | |
| return $position; | |
| } | |
| $position = strpos($text, $needle, $position + 1); | |
| } | |
| return null; | |
| } | |
| private function canOpenInlineDelimiter(string $text, int $offset, string $char, int $size): bool | |
| { | |
| if ($char !== '_') { | |
| return true; | |
| } | |
| $previous = $offset > 0 ? $text[$offset - 1] : ''; | |
| $nextOffset = $offset + $size; | |
| $next = $nextOffset < strlen($text) ? $text[$nextOffset] : ''; | |
| if ($this->isAsciiAlnum($previous) && ($next === '{' || $next === '}')) { | |
| return false; | |
| } | |
| if ($this->isIntrawordUnderscoreBoundary($previous, $next)) { | |
| return false; | |
| } | |
| return true; | |
| } | |
| private function canCloseInlineDelimiter(string $text, int $offset, string $char, int $size): bool | |
| { | |
| if ($char !== '_') { | |
| return true; | |
| } | |
| $previous = $offset > 0 ? $text[$offset - 1] : ''; | |
| $nextOffset = $offset + $size; | |
| $next = $nextOffset < strlen($text) ? $text[$nextOffset] : ''; | |
| if (($previous === '{' || $previous === '}') && $this->isAsciiAlnum($next)) { | |
| return false; | |
| } | |
| if ($this->isIntrawordUnderscoreBoundary($previous, $next)) { | |
| return false; | |
| } | |
| return true; | |
| } | |
| private function isIntrawordUnderscoreBoundary(string $previous, string $next): bool | |
| { | |
| return $this->isAsciiAlnum($previous) && $this->isAsciiAlnum($next); | |
| } | |
| private function isAsciiAlnum(string $char): bool | |
| { | |
| return $char !== '' && preg_match('/[A-Za-z0-9]/', $char) === 1; | |
| } | |
| private function countBackticks(string $text, int $offset): int | |
| { | |
| $count = 0; | |
| $length = strlen($text); | |
| while ($offset + $count < $length && $text[$offset + $count] === '`') { | |
| $count++; | |
| } | |
| return $count; | |
| } | |
| private function findMatchingBacktickRun(string $text, int $offset, int $tickCount): ?int | |
| { | |
| $needle = str_repeat('`', $tickCount); | |
| $position = strpos($text, $needle, $offset); | |
| while ($position !== false) { | |
| $runLength = $this->countBackticks($text, $position); | |
| if ($runLength === $tickCount) { | |
| return $position; | |
| } | |
| $position = strpos($text, $needle, $position + $runLength); | |
| } | |
| return null; | |
| } | |
| /** | |
| * @param list<AstNode> $nodes | |
| */ | |
| private function flushText(string &$buffer, array &$nodes): void | |
| { | |
| if ($buffer === '') { | |
| return; | |
| } | |
| $nodes[] = new AstNode('text', ['text' => $this->decodeHtmlEntities($buffer)]); | |
| $buffer = ''; | |
| } | |
| private function decodeHtmlEntities(string $text): string | |
| { | |
| return html_entity_decode($text, ENT_QUOTES | ENT_SUBSTITUTE | ENT_HTML5, 'UTF-8'); | |
| } | |
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| <?php | |
| declare(strict_types=1); | |
| namespace PortLibs\Pandoc; | |
| final class MarkdownWriter | |
| { | |
| private const MAX_TEMPLATE_PARTIAL_DEPTH = 50; | |
| private const TEMPLATE_BREAKABLE_SPACE = "\x1F"; | |
| private const TEMPLATE_BLOCK_KEY = "\0pandoc_plain_template_block"; | |
| /** @var list<array{number:int, node:AstNode}> */ | |
| private array $notes = []; | |
| /** @var list<array{label:string, url:string, title:string, attrs:array<string, mixed>}> */ | |
| private array $references = []; | |
| /** @var array<string, int> */ | |
| private array $referenceLabelUses = []; | |
| /** @var array<string, bool> */ | |
| private array $referenceUsedLabels = []; | |
| /** @var array<string, string> */ | |
| private array $referenceTargetLabels = []; | |
| /** @var array<string, int> */ | |
| private array $headingAutoIdUses = []; | |
| private int $nextNoteNumber = 1; | |
| private int $lastReferenceIndex = 0; | |
| private bool $escapeInlineSpaces = false; | |
| /** @var list<string> */ | |
| private array $plainTemplatePartialStack = []; | |
| /** | |
| * @param array{variant?: string, setextHeadings?: bool, referenceLinks?: bool, referenceLocation?: string, linkAttributes?: bool, headerAttributes?: bool, fencedCodeBlocks?: bool, backtickCodeBlocks?: bool, fencedCodeAttributes?: bool, definitionLists?: bool, lineBlocks?: bool, bracketedSpans?: bool, nativeSpans?: bool, fencedDivs?: bool, nativeDivs?: bool, implicitFigures?: bool, markdownInHtmlBlocks?: bool, markdownAttribute?: bool, rawAttribute?: bool, rawHtml?: bool, rawTex?: bool, simpleTables?: bool, pipeTables?: bool, multilineTables?: bool, gridTables?: bool, tableCaptions?: bool, columns?: int, tabStop?: int, wrap?: string, strikeout?: bool, superscript?: bool, subscript?: bool, preferAscii?: bool, smart?: bool, escapedLineBreaks?: bool, hardLineBreaks?: bool, wikilinksTitleAfterPipe?: bool, wikilinksTitleBeforePipe?: bool, gutenberg?: bool, template?: bool|string, templatePath?: string, standalone?: bool, tableOfContents?: bool, toc?: bool, tocDepth?: int, numberSections?: bool, variables?: array<string, mixed>, partials?: array<string, string>, headerIncludes?: mixed, includeBefore?: mixed, includeAfter?: mixed} $options | |
| */ | |
| public function __construct(private readonly array $options = []) | |
| { | |
| } | |
| public function write(AstNode $document): string | |
| { | |
| if ($document->type !== 'document') { | |
| throw new \InvalidArgumentException('Markdown writer expects a document node'); | |
| } | |
| $this->notes = []; | |
| $this->references = []; | |
| $this->referenceLabelUses = []; | |
| $this->referenceUsedLabels = []; | |
| $this->referenceTargetLabels = []; | |
| $this->headingAutoIdUses = []; | |
| $this->nextNoteNumber = 1; | |
| $this->lastReferenceIndex = 0; | |
| $this->escapeInlineSpaces = false; | |
| $this->plainTemplatePartialStack = []; | |
| $customPlainTemplate = $this->customPlainTemplateSource(); | |
| if ($customPlainTemplate !== null) { | |
| return $this->renderCustomPlainTemplate($document, $customPlainTemplate); | |
| } | |
| $blocks = []; | |
| $titleBlock = $this->renderPlainTemplateTitleBlock($document); | |
| if ($titleBlock !== '') { | |
| $this->appendBlockEntry($blocks, $titleBlock); | |
| } | |
| foreach ($this->renderPlainTemplateVariableBlocks($document, 'header-includes') as $block) { | |
| $this->appendBlockEntry($blocks, $block); | |
| } | |
| foreach ($this->renderPlainTemplateVariableBlocks($document, 'include-before') as $block) { | |
| $this->appendBlockEntry($blocks, $block); | |
| } | |
| $tableOfContents = $this->renderStandaloneTableOfContents($document); | |
| if ($tableOfContents !== '') { | |
| $this->appendBlockEntry($blocks, $tableOfContents); | |
| } | |
| $bodyOverride = $this->renderPlainTemplateBodyOverride($document); | |
| if ($bodyOverride !== null) { | |
| $this->appendBlockEntry($blocks, $bodyOverride); | |
| } else { | |
| foreach ($document->children as $index => $node) { | |
| if ($this->referenceLocation() === 'end_of_section' && $node->type === 'heading' && $index > 0) { | |
| $this->appendPendingDefinitionEntries($blocks); | |
| } | |
| if ( | |
| $index > 0 | |
| && $this->needsAdjacentListBlockSeparator($document->children[$index - 1], $node) | |
| ) { | |
| $this->appendBlockEntry($blocks, $this->listSeparatorBlock()); | |
| } | |
| if ( | |
| $node->type === 'code_block' | |
| && $index > 0 | |
| && $this->isListBlock($document->children[$index - 1]) | |
| && $this->codeBlockRendersIndented($node) | |
| ) { | |
| $this->appendBlockEntry($blocks, '<!-- -->'); | |
| } | |
| $lines = $this->renderBlock($node, 0); | |
| if ($lines !== []) { | |
| $this->appendBlockEntry($blocks, implode("\n", $lines), $node); | |
| } | |
| if ($this->referenceLocation() === 'end_of_block') { | |
| $this->appendPendingDefinitionEntries($blocks); | |
| } | |
| } | |
| $this->appendPendingDefinitionEntries($blocks); | |
| } | |
| foreach ($this->renderPlainTemplateVariableBlocks($document, 'include-after') as $block) { | |
| $this->appendBlockEntry($blocks, $block); | |
| } | |
| return $this->joinBlockEntries($blocks); | |
| } | |
| /** | |
| * @return list<string> | |
| */ | |
| private function renderBlock(AstNode $node, int $indent): array | |
| { | |
| return match ($node->type) { | |
| 'paragraph', 'plain' => $this->renderPlainOrParagraphBlock($node, $indent), | |
| 'heading' => $this->renderHeading($node, $indent), | |
| 'figure' => $this->renderFigure($node, $indent), | |
| 'bullet_list' => $this->renderList($node, false, $indent), | |
| 'ordered_list' => $this->renderList($node, true, $indent), | |
| 'definition_list' => $this->renderDefinitionList($node, $indent), | |
| 'line_block' => $this->renderLineBlock($node, $indent), | |
| 'blockquote' => $this->renderBlockQuote($node, $indent), | |
| 'code_block' => $this->renderCodeBlock($node, $indent), | |
| 'horizontal_rule' => $this->renderHorizontalRule($indent), | |
| 'table' => $this->renderTable($node, $indent), | |
| 'raw_html', 'raw_tex', 'raw_block', 'raw_markdown' => $this->renderRawBlock($node, $indent), | |
| 'div' => $this->renderDiv($node, $indent), | |
| default => [], | |
| }; | |
| } | |
| /** | |
| * @return list<string> | |
| */ | |
| private function renderPlainOrParagraphBlock(AstNode $node, int $indent): array | |
| { | |
| $contents = $this->renderBlockInlines($node->children, true); | |
| return [str_repeat(' ', $indent) . $contents]; | |
| } | |
| /** | |
| * @return list<string> | |
| */ | |
| private function renderHeading(AstNode $node, int $indent): array | |
| { | |
| if ($this->isPlainTextVariant()) { | |
| return $this->renderPlainTextHeading($node, $indent); | |
| } | |
| $level = max(1, min(6, (int) $node->attr('level', 1))); | |
| $text = $this->renderHeadingText($node); | |
| $plainText = $node->children === [] ? (string) $node->attr('text', '') : $this->plainInlineText($node->children); | |
| $autoId = $this->uniqueHeadingAutoIdentifier($plainText); | |
| $attrs = $this->renderHeadingAttributes($node, $autoId); | |
| $prefix = str_repeat(' ', $indent); | |
| if ($indent === 0 && (bool) ($this->options['setextHeadings'] ?? false) && ($level === 1 || $level === 2)) { | |
| $headingText = $text . ($attrs === '' ? '' : ' ' . $attrs); | |
| return [ | |
| $headingText, | |
| str_repeat($level === 1 ? '=' : '-', max(1, strlen($text))), | |
| ]; | |
| } | |
| return [$prefix . str_repeat('#', $level) . ' ' . $text . ($attrs === '' ? '' : ' ' . $attrs)]; | |
| } | |
| /** | |
| * @return list<string> | |
| */ | |
| private function renderPlainTextHeading(AstNode $node, int $indent): array | |
| { | |
| $text = $node->children === [] | |
| ? $this->renderPlainText((string) $node->attr('text', '')) | |
| : $this->renderPlainInlines($node->children); | |
| $text = trim(preg_replace('/[ \t]*\R[ \t]*/u', ' ', $text) ?? $text); | |
| return [str_repeat(' ', $indent) . $text]; | |
| } | |
| private function renderPlainTemplateTitleBlock(AstNode $document): string | |
| { | |
| if (!$this->isPlainTextVariant() || !$this->templateEnabled()) { | |
| return ''; | |
| } | |
| $titleblockOverride = $this->renderPlainTemplateContextOverride($document, 'titleblock'); | |
| if ($titleblockOverride !== null) { | |
| return $titleblockOverride; | |
| } | |
| $meta = $document->attr('meta', []); | |
| if (!is_array($meta) || $meta === []) { | |
| return ''; | |
| } | |
| $title = $this->renderPlainMetaInlines($meta['titleInlines'] ?? null, (string) ($meta['title'] ?? '')); | |
| $authors = $this->renderPlainMetaAuthors($meta); | |
| $date = $this->renderPlainMetaInlines($meta['dateInlines'] ?? null, (string) ($meta['date'] ?? '')); | |
| if ($title === '' && $authors === [] && $date === '') { | |
| return ''; | |
| } | |
| return implode("\n", [ | |
| $title, | |
| implode('; ', $authors), | |
| $date, | |
| ]); | |
| } | |
| private function renderPlainTemplateBodyOverride(AstNode $document): ?string | |
| { | |
| if (!$this->isPlainTextVariant() || !$this->templateEnabled()) { | |
| return null; | |
| } | |
| return $this->renderPlainTemplateContextOverride($document, 'body'); | |
| } | |
| private function renderCustomPlainTemplate(AstNode $document, string $template): string | |
| { | |
| $this->assertPlainTemplateCompiles($template, (string) ($this->options['templatePath'] ?? '')); | |
| $bodyOverride = $this->renderPlainTemplateBodyOverride($document); | |
| $body = $bodyOverride ?? $this->renderPlainTemplateAutomaticBody($document); | |
| $titleBlock = $this->renderPlainTemplateTitleBlock($document); | |
| $tableOfContents = $this->renderPlainTemplateTableOfContents($document); | |
| $context = $this->plainTemplateContextMap($document, $body, $titleBlock, $tableOfContents); | |
| return trim($this->renderPlainTemplateOutput($this->renderPlainTemplateString($template, $context)), "\r\n"); | |
| } | |
| /** | |
| * @param list<string> $stack | |
| * @param list<string> $partialStack | |
| */ | |
| private function assertPlainTemplateCompiles( | |
| string $template, | |
| string $path = '', | |
| int $partialDepth = 1, | |
| array $stack = [], | |
| array $partialStack = [] | |
| ): void { | |
| $offset = 0; | |
| while (true) { | |
| $token = $this->nextPlainTemplateToken($template, $offset); | |
| $nextDollar = strpos($template, '$', $offset); | |
| if ($token === null) { | |
| if ($nextDollar !== false) { | |
| $this->throwPlainTemplateUnclosedDollarDiagnostic($template, $path, $nextDollar); | |
| } | |
| return; | |
| } | |
| if ($nextDollar !== false && $nextDollar < $token['start']) { | |
| $this->throwPlainTemplateUnclosedDollarDiagnostic($template, $path, $nextDollar); | |
| } | |
| $offset = $token['end']; | |
| if ($token['kind'] !== 'command') { | |
| continue; | |
| } | |
| $this->assertPlainTemplateCommandSyntax($template, $path, $token); | |
| $command = $this->parsePlainTemplateCommand($token['content']); | |
| if ($command === null) { | |
| $this->throwPlainTemplateCompileError( | |
| $template, | |
| $path, | |
| $token['end'] - 1, | |
| '"' . substr($template, $token['end'] - 1, 1) . '"', | |
| 'letter or digit or "()"' | |
| ); | |
| } | |
| if ($command['kind'] === 'if' || $command['kind'] === 'for') { | |
| $stack[] = $command['kind']; | |
| continue; | |
| } | |
| if ($command['kind'] === 'endif' || $command['kind'] === 'endfor') { | |
| $expected = $command['kind'] === 'endif' ? 'if' : 'for'; | |
| if (end($stack) === $expected) { | |
| array_pop($stack); | |
| } | |
| continue; | |
| } | |
| if ($command['kind'] === 'sep' && !in_array('for', $stack, true)) { | |
| $this->throwPlainTemplateCompileError( | |
| $template, | |
| $path, | |
| $token['end'] - 1, | |
| '"' . substr($template, $token['end'] - 1, 1) . '"', | |
| 'letter or digit or "()"' | |
| ); | |
| } | |
| if (($command['kind'] === 'else' || $command['kind'] === 'elseif') && !in_array('if', $stack, true)) { | |
| $this->throwPlainTemplateCompileError( | |
| $template, | |
| $path, | |
| $token['end'] - 1, | |
| '"' . substr($template, $token['end'] - 1, 1) . '"', | |
| 'letter or digit or "()"' | |
| ); | |
| } | |
| if ($command['kind'] === 'partial' || $command['kind'] === 'applied_partial') { | |
| $partialName = $command['partial']; | |
| $partial = $this->plainTemplatePartialSource($partialName); | |
| if ($partial === null || $partialDepth > self::MAX_TEMPLATE_PARTIAL_DEPTH) { | |
| continue; | |
| } | |
| if (in_array($partialName, $partialStack, true)) { | |
| continue; | |
| } | |
| $partialStack[] = $partialName; | |
| $this->assertPlainTemplateCompiles( | |
| $partial, | |
| $this->plainTemplatePartialDiagnosticPath($partialName, $path), | |
| $partialDepth + 1, | |
| [], | |
| $partialStack | |
| ); | |
| array_pop($partialStack); | |
| } | |
| } | |
| } | |
| /** | |
| * @param array{kind:string, start:int, end:int, source:string, content:string} $token | |
| */ | |
| private function assertPlainTemplateCommandSyntax(string $template, string $path, array $token): void | |
| { | |
| $contentOffset = $token['start'] + (str_starts_with($token['source'], '${') ? 2 : 1); | |
| $raw = $token['content']; | |
| $leading = strlen($raw) - strlen(ltrim($raw, " \t")); | |
| $content = trim($raw); | |
| $trimmedOffset = $contentOffset + $leading; | |
| if ( | |
| preg_match('/^(if|elseif|for)\(/', $content) === 1 | |
| && preg_match('/^(if|elseif|for)\(.*\)$/s', $content) !== 1 | |
| ) { | |
| $this->throwPlainTemplateCompileError( | |
| $template, | |
| $path, | |
| $token['end'] - 1, | |
| '"' . substr($template, $token['end'] - 1, 1) . '"', | |
| '".", "/" or ")"' | |
| ); | |
| } | |
| $expression = null; | |
| $expressionOffset = $trimmedOffset; | |
| if (preg_match('/^(if|elseif|for)\(\s*(.*?)\s*\)$/s', $content, $match, PREG_OFFSET_CAPTURE) === 1) { | |
| $expression = $match[2][0]; | |
| $expressionOffset = $trimmedOffset + $match[2][1]; | |
| } else { | |
| [$body] = $this->plainTemplateSplitTrailingSeparator($content); | |
| if (preg_match('/^(.*?)\s*:\s*[A-Za-z][A-Za-z0-9_.-]*\(\)(.*)$/s', $body, $match, PREG_OFFSET_CAPTURE) === 1) { | |
| $this->assertPlainTemplatePipeExpressionSyntax($match[1][0], $trimmedOffset + $match[1][1], $template, $path, $token); | |
| $suffix = $match[2][0]; | |
| if ($suffix !== '') { | |
| $this->assertPlainTemplatePipeExpressionSyntax($suffix, $trimmedOffset + $match[2][1], $template, $path, $token); | |
| } | |
| return; | |
| } | |
| if (preg_match('/^[A-Za-z][A-Za-z0-9_.-]*\(\)(.*)$/s', $body, $match, PREG_OFFSET_CAPTURE) === 1) { | |
| $suffix = $match[1][0]; | |
| if ($suffix !== '') { | |
| $this->assertPlainTemplatePipeExpressionSyntax($suffix, $trimmedOffset + $match[1][1], $template, $path, $token); | |
| } | |
| return; | |
| } | |
| $expression = $body; | |
| } | |
| $this->assertPlainTemplatePipeExpressionSyntax($expression, $expressionOffset, $template, $path, $token); | |
| } | |
| /** | |
| * @param array{kind:string, start:int, end:int, source:string, content:string} $token | |
| */ | |
| private function assertPlainTemplatePipeExpressionSyntax( | |
| string $expression, | |
| int $expressionOffset, | |
| string $template, | |
| string $path, | |
| array $token | |
| ): void { | |
| $parts = $this->plainTemplateSplitPipeExpressionParts($expression); | |
| if ($parts === null || $parts === [] || count($parts) === 1) { | |
| return; | |
| } | |
| foreach (array_slice($parts, 1) as $part) { | |
| $pipe = $part['value']; | |
| if ($pipe === '' || $this->plainTemplatePipeSpec($pipe) !== null) { | |
| continue; | |
| } | |
| if (preg_match('/^([A-Za-z][A-Za-z0-9_-]*)(.*)$/s', $pipe, $match) !== 1) { | |
| $this->throwPlainTemplateCompileError( | |
| $template, | |
| $path, | |
| $token['end'] - 1, | |
| '"' . substr($template, $token['end'] - 1, 1) . '"', | |
| 'letter, letter or digit or "()"' | |
| ); | |
| } | |
| $name = $match[1]; | |
| $args = $match[2]; | |
| if (!in_array($name, $this->plainTemplateKnownPipeNames(), true)) { | |
| $this->throwPlainTemplateCompileError( | |
| $template, | |
| $path, | |
| $token['end'] - 1, | |
| '"' . substr($template, $token['end'] - 1, 1) . '"', | |
| 'letter, letter or digit or "()"', | |
| 'Unknown pipe ' . $name | |
| ); | |
| } | |
| if (in_array($name, ['left', 'right', 'center'], true)) { | |
| if (trim($args) === '') { | |
| $this->throwPlainTemplateCompileError( | |
| $template, | |
| $path, | |
| $token['end'] - 1, | |
| '"' . substr($template, $token['end'] - 1, 1) . '"', | |
| 'letter, integer parameter for pipe, letter or digit or "()"' | |
| ); | |
| } | |
| $spaces = strlen($args) - strlen(ltrim($args)); | |
| $argumentOffset = $expressionOffset + $part['start'] + strlen($name) + $spaces; | |
| $unexpected = substr($template, $argumentOffset, 1); | |
| if ($unexpected !== '' && !ctype_digit($unexpected)) { | |
| $this->throwPlainTemplateCompileError( | |
| $template, | |
| $path, | |
| $argumentOffset, | |
| '"' . $unexpected . '"', | |
| 'integer parameter for pipe' | |
| ); | |
| } | |
| } | |
| } | |
| } | |
| private function throwPlainTemplateUnclosedDollarDiagnostic(string $template, string $path, int $dollarOffset): void | |
| { | |
| $cursor = $dollarOffset + 1; | |
| $length = strlen($template); | |
| if ($cursor < $length && preg_match('/[A-Za-z]/', $template[$cursor]) === 1) { | |
| $cursor++; | |
| while ( | |
| $cursor < $length | |
| && preg_match('/[A-Za-z0-9_.-]/', $template[$cursor]) === 1 | |
| ) { | |
| $cursor++; | |
| } | |
| while ($cursor < $length && ($template[$cursor] === ' ' || $template[$cursor] === "\t")) { | |
| $cursor++; | |
| } | |
| } | |
| $unexpected = $cursor < $length ? '"' . $template[$cursor] . '"' : 'end of input'; | |
| $this->throwPlainTemplateCompileError($template, $path, min($cursor, max(0, $length - 1)), $unexpected, '"$"'); | |
| } | |
| private function throwPlainTemplateCompileError( | |
| string $template, | |
| string $path, | |
| int $offset, | |
| string $unexpected, | |
| string $expecting, | |
| ?string $extra = null | |
| ): never { | |
| [$line, $column] = $this->plainTemplateLineColumn($template, $offset); | |
| $prefix = $path === '' | |
| ? "(line {$line}, column {$column}):" | |
| : "\"{$path}\" (line {$line}, column {$column}):"; | |
| $message = $prefix . "\nunexpected " . $unexpected . "\nexpecting " . $expecting; | |
| if ($extra !== null) { | |
| $message .= "\n" . $extra; | |
| } | |
| throw new \InvalidArgumentException($message); | |
| } | |
| /** | |
| * @return array{0:int, 1:int} | |
| */ | |
| private function plainTemplateLineColumn(string $template, int $offset): array | |
| { | |
| $offset = max(0, min($offset, max(0, strlen($template) - 1))); | |
| $prefix = substr($template, 0, $offset); | |
| $line = substr_count(str_replace("\r\n", "\n", $prefix), "\n") + 1; | |
| $lineFeed = strrpos($prefix, "\n"); | |
| $carriageReturn = strrpos($prefix, "\r"); | |
| $lineStart = max($lineFeed === false ? -1 : $lineFeed, $carriageReturn === false ? -1 : $carriageReturn) + 1; | |
| return [$line, $offset - $lineStart + 1]; | |
| } | |
| private function plainTemplatePartialDiagnosticPath(string $partialName, string $currentPath): string | |
| { | |
| $path = str_contains($partialName, '.') ? $partialName : $partialName . '.txt'; | |
| if ($currentPath === '' || str_contains($partialName, '/') || str_contains($partialName, '\\')) { | |
| return $path; | |
| } | |
| $directory = dirname($currentPath); | |
| return $directory === '.' ? $path : $directory . '/' . $path; | |
| } | |
| private function renderPlainTemplateAutomaticBody(AstNode $document): string | |
| { | |
| $blocks = []; | |
| foreach ($document->children as $index => $node) { | |
| if ($this->referenceLocation() === 'end_of_section' && $node->type === 'heading' && $index > 0) { | |
| $this->appendPendingDefinitionEntries($blocks); | |
| } | |
| if ( | |
| $index > 0 | |
| && $this->needsAdjacentListBlockSeparator($document->children[$index - 1], $node) | |
| ) { | |
| $this->appendBlockEntry($blocks, $this->listSeparatorBlock()); | |
| } | |
| if ( | |
| $node->type === 'code_block' | |
| && $index > 0 | |
| && $this->isListBlock($document->children[$index - 1]) | |
| && $this->codeBlockRendersIndented($node) | |
| ) { | |
| $this->appendBlockEntry($blocks, '<!-- -->'); | |
| } | |
| $lines = $this->renderBlock($node, 0); | |
| if ($lines !== []) { | |
| $this->appendBlockEntry($blocks, implode("\n", $lines), $node); | |
| } | |
| if ($this->referenceLocation() === 'end_of_block') { | |
| $this->appendPendingDefinitionEntries($blocks); | |
| } | |
| } | |
| $this->appendPendingDefinitionEntries($blocks); | |
| return $this->joinBlockEntries($blocks); | |
| } | |
| /** | |
| * @return array<string, mixed> | |
| */ | |
| private function plainTemplateContextMap(AstNode $document, string $body, string $titleBlock, string $tableOfContents): array | |
| { | |
| $meta = $document->attr('meta', []); | |
| $context = is_array($meta) ? $meta : []; | |
| foreach ($this->plainTemplateOptionContextValues() as $key => $value) { | |
| $context[$key] = $value; | |
| } | |
| $variables = $this->options['variables'] ?? []; | |
| if (is_array($variables)) { | |
| foreach ($variables as $key => $value) { | |
| $context[(string) $key] = $value; | |
| } | |
| } | |
| $context['meta-json'] = $this->plainTemplateMetaJson($meta); | |
| if (!$this->plainTemplateContextHas($context, 'titleblock') && $titleBlock !== '') { | |
| $context['titleblock'] = $titleBlock; | |
| } | |
| if (!$this->plainTemplateContextHas($context, 'table-of-contents') && $tableOfContents !== '') { | |
| $context['table-of-contents'] = $tableOfContents; | |
| } | |
| if (!$this->plainTemplateContextHas($context, 'toc') && $tableOfContents !== '') { | |
| $context['toc'] = $tableOfContents; | |
| } | |
| if (!$this->plainTemplateContextHas($context, 'body')) { | |
| $context['body'] = $body; | |
| } | |
| return $context; | |
| } | |
| /** | |
| * @return array<string, mixed> | |
| */ | |
| private function plainTemplateOptionContextValues(): array | |
| { | |
| $context = []; | |
| foreach ([ | |
| 'body', | |
| 'titleblock', | |
| 'header-includes', | |
| 'headerIncludes', | |
| 'include-before', | |
| 'includeBefore', | |
| 'include-after', | |
| 'includeAfter', | |
| ] as $key) { | |
| if (array_key_exists($key, $this->options)) { | |
| $context[$key] = $this->options[$key]; | |
| } | |
| } | |
| return $context; | |
| } | |
| /** | |
| * @param array<string, mixed> $context | |
| */ | |
| private function plainTemplateContextHas(array $context, string $name): bool | |
| { | |
| [$found] = $this->plainTemplateContextLookup($context, $name); | |
| return $found; | |
| } | |
| /** | |
| * @param array<string, mixed> $context | |
| * @return array{0:bool, 1:mixed} | |
| */ | |
| private function plainTemplateContextLookup(array $context, string $name): array | |
| { | |
| $camelName = $this->templateVariableCamelName($name); | |
| foreach ([$name, $camelName] as $key) { | |
| if (array_key_exists($key, $context)) { | |
| return [true, $context[$key]]; | |
| } | |
| } | |
| if (str_contains($name, '.')) { | |
| $prefixLookup = $this->plainTemplateDottedPrefixContextLookup($context, $name); | |
| if ($prefixLookup[0]) { | |
| return $prefixLookup; | |
| } | |
| return $this->plainTemplateDottedContextLookup($context, explode('.', $name)); | |
| } | |
| return [false, null]; | |
| } | |
| /** | |
| * @return array{0:bool, 1:mixed} | |
| */ | |
| private function plainTemplateDottedPrefixContextLookup(array $context, string $name): array | |
| { | |
| $segments = explode('.', $name); | |
| for ($length = count($segments) - 1; $length > 0; $length--) { | |
| $prefix = implode('.', array_slice($segments, 0, $length)); | |
| $camelPrefix = $this->templateVariableCamelName($prefix); | |
| foreach ([$prefix, $camelPrefix] as $key) { | |
| if (!array_key_exists($key, $context)) { | |
| continue; | |
| } | |
| return $this->plainTemplateDottedContextLookup( | |
| $context[$key], | |
| array_slice($segments, $length) | |
| ); | |
| } | |
| } | |
| return [false, null]; | |
| } | |
| /** | |
| * @param list<string> $segments | |
| * @return array{0:bool, 1:mixed} | |
| */ | |
| private function plainTemplateDottedContextLookup(mixed $value, array $segments): array | |
| { | |
| foreach ($segments as $segment) { | |
| if (!is_array($value)) { | |
| return [false, null]; | |
| } | |
| $camelSegment = $this->templateVariableCamelName($segment); | |
| $found = false; | |
| foreach ([$segment, $camelSegment] as $key) { | |
| if (array_key_exists($key, $value)) { | |
| $value = $value[$key]; | |
| $found = true; | |
| break; | |
| } | |
| } | |
| if (!$found) { | |
| return [false, null]; | |
| } | |
| } | |
| return [true, $value]; | |
| } | |
| /** | |
| * @param array<string, mixed> $context | |
| */ | |
| private function renderPlainTemplateString(string $template, array $context, int $partialDepth = 0): string | |
| { | |
| $rendered = ''; | |
| $lineParts = []; | |
| $offset = 0; | |
| $breakableSpaces = false; | |
| $nesting = null; | |
| $suppressLeadingLineBreak = false; | |
| while (($token = $this->nextPlainTemplateToken($template, $offset)) !== null) { | |
| $literalSegment = substr($template, $offset, $token['start'] - $offset); | |
| $offset = $token['end']; | |
| if ($suppressLeadingLineBreak) { | |
| $literalSegment = $this->plainTemplateRemoveLeadingLineBreak($literalSegment); | |
| $suppressLeadingLineBreak = false; | |
| } | |
| if ($token['kind'] === 'literal') { | |
| $this->appendPlainTemplateRenderable($rendered, $lineParts, $this->applyPlainTemplateNestingToLiteral( | |
| $this->renderPlainTemplateLiteralSegment($literalSegment, $breakableSpaces), | |
| $nesting | |
| )); | |
| $literalDollar = $this->applyPlainTemplateNestingToValue('$', $nesting); | |
| $this->appendPlainTemplateRenderable($rendered, $lineParts, $literalDollar); | |
| $suppressLeadingLineBreak = $this->plainTemplateRenderedValueEndsWithLineBreak($literalDollar); | |
| continue; | |
| } | |
| if ($token['kind'] === 'comment') { | |
| $this->appendPlainTemplateRenderable($rendered, $lineParts, $this->applyPlainTemplateNestingToLiteral( | |
| $this->renderPlainTemplateLiteralSegment($literalSegment, $breakableSpaces), | |
| $nesting | |
| )); | |
| continue; | |
| } | |
| $command = $this->parsePlainTemplateCommand($token['content']); | |
| $tokenLinePrefixColumn = $this->plainTemplateTokenLinePrefixColumn($template, $token); | |
| if ( | |
| $nesting !== null | |
| && $tokenLinePrefixColumn !== null | |
| && $tokenLinePrefixColumn < $nesting['sourceColumn'] | |
| ) { | |
| $nesting = null; | |
| } | |
| if ( | |
| $command !== null | |
| && ($command['kind'] === 'if' || $command['kind'] === 'for') | |
| && $this->plainTemplateCommandStartsOwnLine($template, $token) | |
| ) { | |
| $literalSegment = $this->removePlainTemplateStandaloneIndent($literalSegment); | |
| } | |
| $this->appendPlainTemplateRenderable($rendered, $lineParts, $this->applyPlainTemplateNestingToLiteral( | |
| $this->renderPlainTemplateLiteralSegment($literalSegment, $breakableSpaces), | |
| $nesting | |
| )); | |
| if ($command === null) { | |
| $this->appendPlainTemplateRenderable($rendered, $lineParts, $token['source']); | |
| continue; | |
| } | |
| if ($command['kind'] === 'breakable_space') { | |
| $breakableSpaces = !$breakableSpaces; | |
| continue; | |
| } | |
| if ($command['kind'] === 'nest') { | |
| $nesting = [ | |
| 'indent' => str_repeat(' ', $this->plainTemplateCurrentLineDisplayWidth( | |
| $this->plainTemplateRenderedOutput($rendered, $lineParts, false) | |
| )), | |
| 'sourceColumn' => $this->plainTemplateSourceColumn($template, $token['start']), | |
| ]; | |
| continue; | |
| } | |
| if ($command['kind'] === 'variable') { | |
| [$found, $value] = $this->plainTemplateContextLookup($context, $command['name']); | |
| $pipes = $command['pipes'] ?? []; | |
| if ($found || $this->plainTemplatePipesContainAlignment($pipes)) { | |
| $value = $this->applyPlainTemplatePipes($value, $command['pipes'] ?? []); | |
| } | |
| $output = $found || $this->plainTemplateIsBlock($value) | |
| ? $this->renderPlainTemplateInterpolatedValue($value, $command['separator'] ?? null) | |
| : ''; | |
| $automaticNesting = $nesting ?? $this->plainTemplateAutomaticNesting( | |
| $template, | |
| $token, | |
| $this->plainTemplateRenderedOutput($rendered, $lineParts, false) | |
| ); | |
| $nestedOutput = $this->plainTemplateIsBlock($output) | |
| ? $output | |
| : $this->applyPlainTemplateNestingToValue($output, $automaticNesting); | |
| $this->appendPlainTemplateRenderable($rendered, $lineParts, $nestedOutput); | |
| if (!$this->plainTemplateRenderableIsEmpty($nestedOutput)) { | |
| $suppressLeadingLineBreak = $this->plainTemplateRenderedValueEndsWithLineBreak($nestedOutput); | |
| } | |
| continue; | |
| } | |
| if ($command['kind'] === 'partial') { | |
| $partial = $this->renderPlainTemplatePartial($command['partial'], $context, $partialDepth); | |
| if ($partial !== null) { | |
| $partial = $this->renderPlainTemplateVariableValue( | |
| $this->applyPlainTemplatePipes($partial, $command['pipes'] ?? []) | |
| ); | |
| } | |
| $partialOutput = $partial ?? $token['source']; | |
| $automaticNesting = $nesting ?? $this->plainTemplateAutomaticNesting( | |
| $template, | |
| $token, | |
| $this->plainTemplateRenderedOutput($rendered, $lineParts, false) | |
| ); | |
| $nestedOutput = $this->plainTemplateIsBlock($partialOutput) | |
| ? $partialOutput | |
| : $this->applyPlainTemplateNestingToValue($partialOutput, $automaticNesting); | |
| $this->appendPlainTemplateRenderable($rendered, $lineParts, $nestedOutput); | |
| if (!$this->plainTemplateRenderableIsEmpty($nestedOutput)) { | |
| $suppressLeadingLineBreak = $this->plainTemplateRenderedValueEndsWithLineBreak($nestedOutput); | |
| } | |
| continue; | |
| } | |
| if ($command['kind'] === 'applied_partial') { | |
| $partial = $this->renderPlainTemplateAppliedPartial( | |
| $command['name'], | |
| $command['namePipes'] ?? [], | |
| $command['partial'], | |
| $command['separator'] ?? null, | |
| $context, | |
| $partialDepth | |
| ); | |
| if ($partial !== null) { | |
| $partial = $this->renderPlainTemplateVariableValue( | |
| $this->applyPlainTemplatePipes($partial, $command['pipes'] ?? []) | |
| ); | |
| } | |
| $partialOutput = $partial ?? $token['source']; | |
| $automaticNesting = $nesting ?? $this->plainTemplateAutomaticNesting( | |
| $template, | |
| $token, | |
| $this->plainTemplateRenderedOutput($rendered, $lineParts, false) | |
| ); | |
| $nestedOutput = $this->plainTemplateIsBlock($partialOutput) | |
| ? $partialOutput | |
| : $this->applyPlainTemplateNestingToValue($partialOutput, $automaticNesting); | |
| $this->appendPlainTemplateRenderable($rendered, $lineParts, $nestedOutput); | |
| if ( | |
| !$this->plainTemplateRenderableIsEmpty($nestedOutput) | |
| && !$this->plainTemplateRenderedValueEndsWithLineBreak($nestedOutput) | |
| ) { | |
| $suppressLeadingLineBreak = false; | |
| } | |
| continue; | |
| } | |
| if ($command['kind'] === 'if' || $command['kind'] === 'for') { | |
| $block = $this->extractPlainTemplateBlock($template, $offset, $command['kind']); | |
| if ($block === null) { | |
| $this->appendPlainTemplateRenderable( | |
| $rendered, | |
| $lineParts, | |
| $this->applyPlainTemplateNestingToValue($token['source'], $nesting) | |
| ); | |
| continue; | |
| } | |
| $output = $command['kind'] === 'if' | |
| ? $this->renderPlainTemplateConditional($command['name'], $block['body'], $context, $partialDepth, $command['pipes'] ?? [], $block['multiline']) | |
| : $this->renderPlainTemplateLoop($command['name'], $block['body'], $context, $partialDepth, $command['pipes'] ?? []); | |
| $nestedOutput = $this->applyPlainTemplateNestingToValue($output, $nesting, $block['multiline']); | |
| $this->appendPlainTemplateRenderable($rendered, $lineParts, $nestedOutput); | |
| if ($nestedOutput !== '' && !$block['multiline']) { | |
| $suppressLeadingLineBreak = $this->plainTemplateRenderedValueEndsWithLineBreak($nestedOutput); | |
| } | |
| $offset = $block['offset']; | |
| continue; | |
| } | |
| $nestedOutput = $this->applyPlainTemplateNestingToValue($token['source'], $nesting); | |
| $this->appendPlainTemplateRenderable($rendered, $lineParts, $nestedOutput); | |
| if ($nestedOutput !== '') { | |
| $suppressLeadingLineBreak = $this->plainTemplateRenderedValueEndsWithLineBreak($nestedOutput); | |
| } | |
| } | |
| $tail = substr($template, $offset); | |
| if ($suppressLeadingLineBreak) { | |
| $tail = $this->plainTemplateRemoveLeadingLineBreak($tail); | |
| } | |
| $this->appendPlainTemplateRenderable($rendered, $lineParts, $this->applyPlainTemplateNestingToLiteral( | |
| $this->renderPlainTemplateLiteralSegment($tail, $breakableSpaces), | |
| $nesting | |
| )); | |
| return $this->plainTemplateRenderedOutput($rendered, $lineParts); | |
| } | |
| /** | |
| * @param list<array<string, mixed>|string> $lineParts | |
| */ | |
| private function appendPlainTemplateRenderable(string &$rendered, array &$lineParts, mixed $value): void | |
| { | |
| if ($this->plainTemplateIsBlock($value)) { | |
| $lineParts[] = $value; | |
| return; | |
| } | |
| $this->appendPlainTemplateText($rendered, $lineParts, (string) $value); | |
| } | |
| /** | |
| * @param list<array<string, mixed>|string> $lineParts | |
| */ | |
| private function appendPlainTemplateText(string &$rendered, array &$lineParts, string $text): void | |
| { | |
| if ($text === '') { | |
| return; | |
| } | |
| $text = str_replace(["\r\n", "\r"], "\n", $text); | |
| $offset = 0; | |
| while (($lineBreak = strpos($text, "\n", $offset)) !== false) { | |
| $segment = substr($text, $offset, $lineBreak - $offset); | |
| if ($segment !== '') { | |
| $lineParts[] = $segment; | |
| } | |
| $rendered .= $this->renderPlainTemplateLineParts($lineParts) . "\n"; | |
| $lineParts = []; | |
| $offset = $lineBreak + 1; | |
| } | |
| $tail = substr($text, $offset); | |
| if ($tail !== '') { | |
| $lineParts[] = $tail; | |
| } | |
| } | |
| /** | |
| * @param list<array<string, mixed>|string> $lineParts | |
| */ | |
| private function plainTemplateRenderedOutput(string $rendered, array $lineParts, bool $trimBlockTrailingSpaces = true): string | |
| { | |
| if ($lineParts === []) { | |
| return $rendered; | |
| } | |
| return $rendered . $this->renderPlainTemplateLineParts($lineParts, $trimBlockTrailingSpaces); | |
| } | |
| /** | |
| * @param list<array<string, mixed>|string> $lineParts | |
| */ | |
| private function renderPlainTemplateLineParts(array $lineParts, bool $trimBlockTrailingSpaces = true): string | |
| { | |
| if ($lineParts === []) { | |
| return ''; | |
| } | |
| $height = 1; | |
| $hasBlock = false; | |
| foreach ($lineParts as $part) { | |
| if ($this->plainTemplateIsBlock($part)) { | |
| $hasBlock = true; | |
| $height = max($height, count($part['lines'])); | |
| } | |
| } | |
| $lines = array_fill(0, $height, ''); | |
| foreach ($lineParts as $part) { | |
| if ($this->plainTemplateIsBlock($part)) { | |
| for ($index = 0; $index < $height; $index++) { | |
| $lines[$index] .= $part['lines'][$index] ?? $part['fillLine']; | |
| } | |
| continue; | |
| } | |
| $text = (string) $part; | |
| $fill = str_repeat(' ', $this->markdownDisplayWidth(str_replace(self::TEMPLATE_BREAKABLE_SPACE, ' ', $text))); | |
| for ($index = 0; $index < $height; $index++) { | |
| $lines[$index] .= $index === 0 ? $text : $fill; | |
| } | |
| } | |
| if ($hasBlock && $trimBlockTrailingSpaces) { | |
| $lines = array_map(static fn (string $line): string => rtrim($line, " \t"), $lines); | |
| } | |
| return implode("\n", $lines); | |
| } | |
| /** | |
| * @return array<string, mixed> | |
| */ | |
| private function plainTemplateBlock(array $lines, string $fillLine): array | |
| { | |
| return [ | |
| self::TEMPLATE_BLOCK_KEY => true, | |
| 'lines' => $lines === [] ? [''] : array_values($lines), | |
| 'fillLine' => $fillLine, | |
| ]; | |
| } | |
| private function plainTemplateIsBlock(mixed $value): bool | |
| { | |
| return is_array($value) && ($value[self::TEMPLATE_BLOCK_KEY] ?? false) === true; | |
| } | |
| private function plainTemplateRenderableIsEmpty(mixed $value): bool | |
| { | |
| if ($this->plainTemplateIsBlock($value)) { | |
| foreach ($value['lines'] as $line) { | |
| if ($line !== '') { | |
| return false; | |
| } | |
| } | |
| return $value['fillLine'] === ''; | |
| } | |
| return $value === ''; | |
| } | |
| private function plainTemplateRenderedValueEndsWithLineBreak(mixed $text): bool | |
| { | |
| if ($this->plainTemplateIsBlock($text)) { | |
| return false; | |
| } | |
| return $text !== '' && in_array(substr($text, -1), ["\n", "\r"], true); | |
| } | |
| private function plainTemplateRemoveLeadingLineBreak(string $text): string | |
| { | |
| if (str_starts_with($text, "\r\n")) { | |
| return substr($text, 2); | |
| } | |
| if ($text !== '' && in_array($text[0], ["\n", "\r"], true)) { | |
| return substr($text, 1); | |
| } | |
| return $text; | |
| } | |
| /** | |
| * @param array{start:int, end:int} $token | |
| */ | |
| private function plainTemplateTokenLinePrefixColumn(string $template, array $token): ?int | |
| { | |
| $prefix = substr($template, 0, $token['start']); | |
| $lineFeed = strrpos($prefix, "\n"); | |
| $carriageReturn = strrpos($prefix, "\r"); | |
| $lineStart = max($lineFeed === false ? -1 : $lineFeed, $carriageReturn === false ? -1 : $carriageReturn) + 1; | |
| $linePrefix = substr($template, $lineStart, $token['start'] - $lineStart); | |
| if (preg_match('/^[ \t]*$/', $linePrefix) !== 1) { | |
| return null; | |
| } | |
| return strlen(str_replace("\t", ' ', $linePrefix)); | |
| } | |
| /** | |
| * @param array{start:int, end:int} $token | |
| */ | |
| private function plainTemplateCommandStartsOwnLine(string $template, array $token): bool | |
| { | |
| if ($this->plainTemplateLineBreakLengthAt($template, $token['end']) === 0) { | |
| return false; | |
| } | |
| return $this->plainTemplateTokenLinePrefixColumn($template, $token) !== null; | |
| } | |
| private function removePlainTemplateStandaloneIndent(string $segment): string | |
| { | |
| return preg_replace('/[ \t]+$/', '', $segment) ?? $segment; | |
| } | |
| private function renderPlainTemplateLiteralSegment(string $segment, bool $breakableSpaces): string | |
| { | |
| if (!$breakableSpaces || $segment === '') { | |
| return $segment; | |
| } | |
| return preg_replace('/[ \t\r\n]+/u', self::TEMPLATE_BREAKABLE_SPACE, $segment) ?? $segment; | |
| } | |
| private function renderPlainTemplateOutput(string $text): string | |
| { | |
| if (!str_contains($text, self::TEMPLATE_BREAKABLE_SPACE)) { | |
| return $text; | |
| } | |
| if ($this->writerWrapText() !== 'auto') { | |
| return str_replace(self::TEMPLATE_BREAKABLE_SPACE, ' ', $text); | |
| } | |
| $lines = explode("\n", str_replace(["\r\n", "\r"], "\n", $text)); | |
| foreach ($lines as $index => $line) { | |
| $lines[$index] = $this->wrapPlainTemplateBreakableLine($line); | |
| } | |
| return implode("\n", $lines); | |
| } | |
| private function wrapPlainTemplateBreakableLine(string $line): string | |
| { | |
| if (!str_contains($line, self::TEMPLATE_BREAKABLE_SPACE)) { | |
| return $line; | |
| } | |
| $columns = $this->writerColumns(); | |
| $parts = explode(self::TEMPLATE_BREAKABLE_SPACE, $line); | |
| $current = array_shift($parts) ?? ''; | |
| $wrapped = []; | |
| foreach ($parts as $part) { | |
| if ($current === '') { | |
| $current = $part; | |
| continue; | |
| } | |
| $candidate = $current . ' ' . $part; | |
| if ($part !== '' && $this->markdownDisplayWidth($candidate) > $columns) { | |
| $wrapped[] = $current; | |
| $current = $part; | |
| continue; | |
| } | |
| $current = $candidate; | |
| } | |
| $wrapped[] = $current; | |
| return implode("\n", $wrapped); | |
| } | |
| /** | |
| * @param array{indent:string, sourceColumn:int}|null $nesting | |
| */ | |
| private function applyPlainTemplateNestingToValue(string $text, ?array $nesting, bool $stripSourceIndent = false): string | |
| { | |
| if ($nesting === null || $text === '' || !strpbrk($text, "\r\n")) { | |
| return $text; | |
| } | |
| $lines = explode("\n", str_replace(["\r\n", "\r"], "\n", $text)); | |
| foreach ($lines as $index => $line) { | |
| if ($line === '' || ($index === 0 && !$stripSourceIndent)) { | |
| continue; | |
| } | |
| if ($stripSourceIndent) { | |
| $line = $this->stripPlainTemplateSourceIndent($line, $nesting['sourceColumn']); | |
| if ($line === '') { | |
| $lines[$index] = ''; | |
| continue; | |
| } | |
| } | |
| $lines[$index] = $nesting['indent'] . $line; | |
| } | |
| return implode("\n", $lines); | |
| } | |
| private function stripPlainTemplateSourceIndent(string $line, int $sourceColumn): string | |
| { | |
| if ($sourceColumn <= 0) { | |
| return $line; | |
| } | |
| $consumed = $this->plainTemplateAlignedIndentLength($line, 0, $sourceColumn); | |
| if ($consumed === null) { | |
| return $line; | |
| } | |
| return substr($line, $consumed); | |
| } | |
| /** | |
| * @param array{indent:string, sourceColumn:int}|null $nesting | |
| */ | |
| private function applyPlainTemplateNestingToLiteral(string $text, ?array &$nesting): string | |
| { | |
| if ($nesting === null || $text === '' || !strpbrk($text, "\r\n")) { | |
| return $text; | |
| } | |
| $text = str_replace(["\r\n", "\r"], "\n", $text); | |
| $result = ''; | |
| $offset = 0; | |
| $length = strlen($text); | |
| while (($newline = strpos($text, "\n", $offset)) !== false) { | |
| $result .= substr($text, $offset, $newline - $offset + 1); | |
| $lineOffset = $newline + 1; | |
| $lineEnd = strpos($text, "\n", $lineOffset); | |
| $lineEnd = $lineEnd === false ? $length : $lineEnd; | |
| $line = substr($text, $lineOffset, $lineEnd - $lineOffset); | |
| if ($line === '' || preg_match('/^[ \t]+$/', $line) === 1) { | |
| $offset = $lineEnd; | |
| continue; | |
| } | |
| $consumed = $this->plainTemplateAlignedIndentLength($text, $lineOffset, $nesting['sourceColumn']); | |
| if ($consumed === null) { | |
| $nesting = null; | |
| return $result . substr($text, $lineOffset); | |
| } | |
| $result .= $nesting['indent']; | |
| $offset = $lineOffset + $consumed; | |
| if ($offset > $length) { | |
| break; | |
| } | |
| } | |
| return $result . substr($text, $offset); | |
| } | |
| private function plainTemplateCurrentLineDisplayWidth(string $rendered): int | |
| { | |
| $rendered = str_replace(["\r\n", "\r", self::TEMPLATE_BREAKABLE_SPACE], ["\n", "\n", ' '], $rendered); | |
| $lineStart = strrpos($rendered, "\n"); | |
| $line = $lineStart === false ? $rendered : substr($rendered, $lineStart + 1); | |
| return $this->markdownDisplayWidth($line); | |
| } | |
| private function plainTemplateSourceColumn(string $template, int $offset): int | |
| { | |
| $prefix = substr($template, 0, $offset); | |
| $lastLineFeed = strrpos($prefix, "\n"); | |
| $lastCarriageReturn = strrpos($prefix, "\r"); | |
| $lineStart = max($lastLineFeed === false ? -1 : $lastLineFeed, $lastCarriageReturn === false ? -1 : $lastCarriageReturn) + 1; | |
| return strlen(str_replace("\t", ' ', substr($template, $lineStart, $offset - $lineStart))); | |
| } | |
| private function plainTemplateAlignedIndentLength(string $text, int $offset, int $sourceColumn): ?int | |
| { | |
| $column = 0; | |
| $cursor = $offset; | |
| $length = strlen($text); | |
| while ($cursor < $length && $column < $sourceColumn) { | |
| $char = $text[$cursor]; | |
| if ($char !== ' ' && $char !== "\t") { | |
| return null; | |
| } | |
| $column++; | |
| $cursor++; | |
| } | |
| return $column === $sourceColumn ? $cursor - $offset : null; | |
| } | |
| /** | |
| * @param array{kind:string, start:int, end:int, source:string, content:string} $token | |
| * @return array{indent:string, sourceColumn:int}|null | |
| */ | |
| private function plainTemplateAutomaticNesting(string $template, array $token, string $rendered): ?array | |
| { | |
| $prefixStart = max( | |
| strrpos(substr($template, 0, $token['start']), "\n") === false ? -1 : (int) strrpos(substr($template, 0, $token['start']), "\n"), | |
| strrpos(substr($template, 0, $token['start']), "\r") === false ? -1 : (int) strrpos(substr($template, 0, $token['start']), "\r") | |
| ) + 1; | |
| $prefix = substr($template, $prefixStart, $token['start'] - $prefixStart); | |
| if ($prefix === '' || preg_match('/^[ \t]+$/', $prefix) !== 1) { | |
| return null; | |
| } | |
| $lineEnd = strcspn($template, "\r\n", $token['end']); | |
| $suffix = substr($template, $token['end'], $lineEnd); | |
| if (preg_match('/^[ \t]*$/', $suffix) !== 1) { | |
| return null; | |
| } | |
| return [ | |
| 'indent' => str_repeat(' ', $this->plainTemplateCurrentLineDisplayWidth($rendered)), | |
| 'sourceColumn' => strlen(str_replace("\t", ' ', $prefix)), | |
| ]; | |
| } | |
| /** | |
| * @param list<string> $pipes | |
| * @param array<string, mixed> $context | |
| */ | |
| private function renderPlainTemplateConditional( | |
| string $name, | |
| string $body, | |
| array $context, | |
| int $partialDepth = 0, | |
| array $pipes = [], | |
| bool $multiline = false | |
| ): string | |
| { | |
| [$trueTemplate, $falseTemplate, $elseifExpression] = $this->splitPlainTemplateConditionalBody($body, $multiline); | |
| [$found, $value] = $this->plainTemplateContextLookup($context, $name); | |
| if ($found) { | |
| $value = $this->applyPlainTemplatePipes($value, $pipes); | |
| } | |
| if ($found && $this->plainTemplateValueIsTruthy($value)) { | |
| return $this->renderPlainTemplateString($trueTemplate, $context, $partialDepth); | |
| } | |
| if ($elseifExpression !== null) { | |
| return $this->renderPlainTemplateConditional( | |
| $elseifExpression['name'], | |
| $falseTemplate ?? '', | |
| $context, | |
| $partialDepth, | |
| $elseifExpression['pipes'], | |
| $elseifExpression['multiline'] | |
| ); | |
| } | |
| return $falseTemplate === null ? '' : $this->renderPlainTemplateString($falseTemplate, $context, $partialDepth); | |
| } | |
| /** | |
| * @param list<string> $pipes | |
| * @param array<string, mixed> $context | |
| */ | |
| private function renderPlainTemplateLoop(string $name, string $body, array $context, int $partialDepth = 0, array $pipes = []): string | |
| { | |
| [$itemTemplate, $separatorTemplate] = $this->splitPlainTemplateLoopBody($body); | |
| [$found, $value] = $this->plainTemplateContextLookup($context, $name); | |
| if (!$found) { | |
| return ''; | |
| } | |
| $value = $this->applyPlainTemplatePipes($value, $pipes); | |
| $items = $this->templateVariableValueList($value); | |
| $rendered = ''; | |
| foreach ($items as $index => $item) { | |
| $loopContext = $context; | |
| $loopContext[$name] = $item; | |
| $loopContext['it'] = $item; | |
| $rendered .= $this->renderPlainTemplateString($itemTemplate, $loopContext, $partialDepth); | |
| if ($separatorTemplate !== null && $index < count($items) - 1) { | |
| $rendered .= $this->renderPlainTemplateString($separatorTemplate, $loopContext, $partialDepth); | |
| } | |
| } | |
| return $rendered; | |
| } | |
| /** | |
| * @param array<string, mixed> $context | |
| */ | |
| private function renderPlainTemplatePartial(string $name, array $context, int $partialDepth, bool $stripAllFinalLineBreaks = true): ?string | |
| { | |
| if ($partialDepth > self::MAX_TEMPLATE_PARTIAL_DEPTH) { | |
| return '(loop)'; | |
| } | |
| $partial = $this->plainTemplatePartialSource($name); | |
| if ($partial === null) { | |
| return null; | |
| } | |
| if (in_array($name, $this->plainTemplatePartialStack, true)) { | |
| return '(loop)'; | |
| } | |
| $this->plainTemplatePartialStack[] = $name; | |
| try { | |
| $rendered = $this->renderPlainTemplateString($partial, $context, $partialDepth + 1); | |
| return $stripAllFinalLineBreaks | |
| ? rtrim($rendered, "\r\n") | |
| : $this->plainTemplateRemoveFinalLineBreak($rendered); | |
| } finally { | |
| array_pop($this->plainTemplatePartialStack); | |
| } | |
| } | |
| /** | |
| * @param array<string, mixed> $context | |
| */ | |
| private function renderPlainTemplateAppliedPartial( | |
| string $name, | |
| array $namePipes, | |
| string $partialName, | |
| ?string $separator, | |
| array $context, | |
| int $partialDepth | |
| ): ?string { | |
| $partial = $this->plainTemplatePartialSource($partialName); | |
| if ($partial === null) { | |
| return null; | |
| } | |
| [$found, $value] = $this->plainTemplateContextLookup($context, $name); | |
| if (!$found) { | |
| return ''; | |
| } | |
| $value = $this->applyPlainTemplatePipes($value, $namePipes); | |
| $rendered = []; | |
| foreach ($this->templateVariableValueList($value) as $item) { | |
| $itemContext = $context; | |
| $itemContext[$name] = $item; | |
| $itemContext['it'] = $item; | |
| $rendered[] = $this->renderPlainTemplatePartial($partialName, $itemContext, $partialDepth, false) ?? ''; | |
| } | |
| return implode($separator ?? '', $rendered); | |
| } | |
| private function plainTemplatePartialSource(string $name): ?string | |
| { | |
| $partials = $this->options['partials'] ?? []; | |
| if (!is_array($partials)) { | |
| return null; | |
| } | |
| $candidates = [$name]; | |
| if (!str_contains($name, '.')) { | |
| $candidates[] = $name . '.txt'; | |
| $candidates[] = $name . '.plain'; | |
| } | |
| foreach ($candidates as $candidate) { | |
| if (array_key_exists($candidate, $partials) && is_string($partials[$candidate])) { | |
| return $partials[$candidate]; | |
| } | |
| } | |
| return null; | |
| } | |
| /** | |
| * @return array{body:string, offset:int, multiline:bool}|null | |
| */ | |
| private function extractPlainTemplateBlock(string $template, int $offset, string $kind): ?array | |
| { | |
| $stack = [$kind]; | |
| $cursor = $offset; | |
| $openingLineBreakLength = $this->plainTemplateLineBreakLengthAt($template, $offset); | |
| $bodyOffset = $openingLineBreakLength > 0 ? $offset + $openingLineBreakLength : $offset; | |
| while (($token = $this->nextPlainTemplateToken($template, $cursor)) !== null) { | |
| $command = $token['kind'] === 'command' ? $this->parsePlainTemplateCommand($token['content']) : null; | |
| if ($command !== null && ($command['kind'] === 'if' || $command['kind'] === 'for')) { | |
| $stack[] = $command['kind']; | |
| } elseif ($command !== null && ($command['kind'] === 'endif' || $command['kind'] === 'endfor')) { | |
| $endKind = $command['kind'] === 'endif' ? 'if' : 'for'; | |
| if (end($stack) === $endKind) { | |
| array_pop($stack); | |
| if ($stack === []) { | |
| $nextOffset = $token['end']; | |
| if ($openingLineBreakLength > 0) { | |
| $closingLineBreakLength = $this->plainTemplateLineBreakLengthAt($template, $nextOffset); | |
| if ($closingLineBreakLength > 0) { | |
| $nextOffset += $closingLineBreakLength; | |
| } | |
| } | |
| return [ | |
| 'body' => substr($template, $bodyOffset, $token['start'] - $bodyOffset), | |
| 'offset' => $nextOffset, | |
| 'multiline' => $openingLineBreakLength > 0, | |
| ]; | |
| } | |
| } | |
| } | |
| $cursor = $token['end']; | |
| } | |
| return null; | |
| } | |
| private function plainTemplateLineBreakLengthAt(string $template, int $offset): int | |
| { | |
| return match (substr($template, $offset, 2)) { | |
| "\r\n" => 2, | |
| default => in_array($template[$offset] ?? '', ["\n", "\r"], true) ? 1 : 0, | |
| }; | |
| } | |
| private function plainTemplateRemoveFinalLineBreak(string $text): string | |
| { | |
| if (str_ends_with($text, "\r\n")) { | |
| return substr($text, 0, -2); | |
| } | |
| if (str_ends_with($text, "\n") || str_ends_with($text, "\r")) { | |
| return substr($text, 0, -1); | |
| } | |
| return $text; | |
| } | |
| /** | |
| * @return array{0:string, 1:?string} | |
| */ | |
| private function splitPlainTemplateLoopBody(string $body): array | |
| { | |
| $cursor = 0; | |
| $depth = 0; | |
| while (($token = $this->nextPlainTemplateToken($body, $cursor)) !== null) { | |
| $command = $token['kind'] === 'command' ? $this->parsePlainTemplateCommand($token['content']) : null; | |
| if ($command === null) { | |
| $cursor = $token['end']; | |
| continue; | |
| } | |
| if ($depth === 0 && $command['kind'] === 'sep') { | |
| return [ | |
| substr($body, 0, $token['start']), | |
| substr($body, $token['end']), | |
| ]; | |
| } | |
| if ($command['kind'] === 'if' || $command['kind'] === 'for') { | |
| $depth++; | |
| } elseif (($command['kind'] === 'endif' || $command['kind'] === 'endfor') && $depth > 0) { | |
| $depth--; | |
| } | |
| $cursor = $token['end']; | |
| } | |
| return [$body, null]; | |
| } | |
| /** | |
| * @return array{0:string, 1:?string, 2:?array{name:string, pipes:list<string>, multiline:bool}} | |
| */ | |
| private function splitPlainTemplateConditionalBody(string $body, bool $multiline): array | |
| { | |
| $cursor = 0; | |
| $depth = 0; | |
| while (($token = $this->nextPlainTemplateToken($body, $cursor)) !== null) { | |
| $command = $token['kind'] === 'command' ? $this->parsePlainTemplateCommand($token['content']) : null; | |
| if ($command === null) { | |
| $cursor = $token['end']; | |
| continue; | |
| } | |
| if ($depth === 0 && $command['kind'] === 'else') { | |
| return [ | |
| substr($body, 0, $token['start']), | |
| substr($body, $this->plainTemplateBranchBodyOffset($body, $token['end'], $multiline)), | |
| null, | |
| ]; | |
| } | |
| if ($depth === 0 && $command['kind'] === 'elseif') { | |
| $branchOffset = $this->plainTemplateBranchBodyOffset($body, $token['end'], true); | |
| return [ | |
| substr($body, 0, $token['start']), | |
| substr($body, $branchOffset), | |
| [ | |
| 'name' => $command['name'], | |
| 'pipes' => $command['pipes'] ?? [], | |
| 'multiline' => $branchOffset > $token['end'], | |
| ], | |
| ]; | |
| } | |
| if ($command['kind'] === 'if' || $command['kind'] === 'for') { | |
| $depth++; | |
| } elseif (($command['kind'] === 'endif' || $command['kind'] === 'endfor') && $depth > 0) { | |
| $depth--; | |
| } | |
| $cursor = $token['end']; | |
| } | |
| return [$body, null, null]; | |
| } | |
| private function plainTemplateBranchBodyOffset(string $template, int $offset, bool $swallowLineBreak): int | |
| { | |
| if (!$swallowLineBreak) { | |
| return $offset; | |
| } | |
| return $offset + $this->plainTemplateLineBreakLengthAt($template, $offset); | |
| } | |
| /** | |
| * @return array{kind:string, start:int, end:int, source:string, content:string}|null | |
| */ | |
| private function nextPlainTemplateToken(string $template, int $offset): ?array | |
| { | |
| $length = strlen($template); | |
| for ($cursor = $offset; $cursor < $length; $cursor++) { | |
| if ($template[$cursor] !== '$') { | |
| continue; | |
| } | |
| if (substr($template, $cursor, 3) === '$--') { | |
| $lineEnd = $length; | |
| foreach (["\n", "\r"] as $lineBreak) { | |
| $candidate = strpos($template, $lineBreak, $cursor + 3); | |
| if ($candidate !== false) { | |
| $lineEnd = min($lineEnd, $candidate); | |
| } | |
| } | |
| return [ | |
| 'kind' => 'comment', | |
| 'start' => $cursor, | |
| 'end' => $lineEnd, | |
| 'source' => substr($template, $cursor, $lineEnd - $cursor), | |
| 'content' => '', | |
| ]; | |
| } | |
| if (substr($template, $cursor, 2) === '$$') { | |
| return [ | |
| 'kind' => 'literal', | |
| 'start' => $cursor, | |
| 'end' => $cursor + 2, | |
| 'source' => '$$', | |
| 'content' => '', | |
| ]; | |
| } | |
| if (substr($template, $cursor, 2) === '${') { | |
| $end = strpos($template, '}', $cursor + 2); | |
| if ($end === false) { | |
| continue; | |
| } | |
| return [ | |
| 'kind' => 'command', | |
| 'start' => $cursor, | |
| 'end' => $end + 1, | |
| 'source' => substr($template, $cursor, $end + 1 - $cursor), | |
| 'content' => substr($template, $cursor + 2, $end - $cursor - 2), | |
| ]; | |
| } | |
| $end = strpos($template, '$', $cursor + 1); | |
| if ($end === false) { | |
| continue; | |
| } | |
| return [ | |
| 'kind' => 'command', | |
| 'start' => $cursor, | |
| 'end' => $end + 1, | |
| 'source' => substr($template, $cursor, $end + 1 - $cursor), | |
| 'content' => substr($template, $cursor + 1, $end - $cursor - 1), | |
| ]; | |
| } | |
| return null; | |
| } | |
| /** | |
| * @return array{kind:string, name?:string, namePipes?:list<string>, partial?:string, separator?:string|null, pipes?:list<string>}|null | |
| */ | |
| private function parsePlainTemplateCommand(string $content): ?array | |
| { | |
| $content = trim($content); | |
| $variableName = '[A-Za-z][A-Za-z0-9_.-]*'; | |
| $reserved = ['if', 'else', 'elseif', 'endif', 'for', 'sep', 'endfor']; | |
| if ($content === '~') { | |
| return ['kind' => 'breakable_space']; | |
| } | |
| if ($content === '^') { | |
| return ['kind' => 'nest']; | |
| } | |
| if (preg_match('/^if\\(\\s*(.*?)\\s*\\)$/s', $content, $match) === 1) { | |
| $expression = $this->parsePlainTemplateExpression($match[1]); | |
| return $expression === null ? null : [ | |
| 'kind' => 'if', | |
| 'name' => $expression['name'], | |
| 'pipes' => $expression['pipes'], | |
| ]; | |
| } | |
| if (preg_match('/^elseif\\(\\s*(.*?)\\s*\\)$/s', $content, $match) === 1) { | |
| $expression = $this->parsePlainTemplateExpression($match[1]); | |
| return $expression === null ? null : [ | |
| 'kind' => 'elseif', | |
| 'name' => $expression['name'], | |
| 'pipes' => $expression['pipes'], | |
| ]; | |
| } | |
| if (preg_match('/^for\\(\\s*(.*?)\\s*\\)$/s', $content, $match) === 1) { | |
| $expression = $this->parsePlainTemplateExpression($match[1]); | |
| return $expression === null ? null : [ | |
| 'kind' => 'for', | |
| 'name' => $expression['name'], | |
| 'pipes' => $expression['pipes'], | |
| ]; | |
| } | |
| if (in_array($content, ['else', 'endif', 'sep', 'endfor'], true)) { | |
| return ['kind' => $content]; | |
| } | |
| [$body, $separatorValue] = $this->plainTemplateSplitTrailingSeparator($content); | |
| if (preg_match('/^(.*?)\\s*:\\s*(' . $variableName . ')\\(\\)(.*)$/s', $body, $match) === 1) { | |
| $expression = $this->parsePlainTemplateExpression($match[1]); | |
| $pipes = $this->plainTemplateParsePipeSuffix($match[3]); | |
| if ($expression === null || $pipes === null) { | |
| return null; | |
| } | |
| return [ | |
| 'kind' => 'applied_partial', | |
| 'name' => $expression['name'], | |
| 'namePipes' => $expression['pipes'], | |
| 'partial' => $match[2], | |
| 'separator' => $separatorValue, | |
| 'pipes' => $pipes, | |
| ]; | |
| } | |
| if ( | |
| preg_match('/^(' . $variableName . ')\\(\\)(.*)$/s', $body, $match) === 1 | |
| && !in_array($match[1], $reserved, true) | |
| ) { | |
| $pipes = $this->plainTemplateParsePipeSuffix($match[2]); | |
| if ($pipes === null) { | |
| return null; | |
| } | |
| return [ | |
| 'kind' => 'partial', | |
| 'partial' => $match[1], | |
| 'separator' => $separatorValue, | |
| 'pipes' => $pipes, | |
| ]; | |
| } | |
| $expression = $this->parsePlainTemplateExpression($body); | |
| if ($expression !== null && !in_array($expression['name'], $reserved, true)) { | |
| return [ | |
| 'kind' => 'variable', | |
| 'name' => $expression['name'], | |
| 'separator' => $separatorValue, | |
| 'pipes' => $expression['pipes'], | |
| ]; | |
| } | |
| return null; | |
| } | |
| /** | |
| * @return array{name:string, pipes:list<string>}|null | |
| */ | |
| private function parsePlainTemplateExpression(string $expression): ?array | |
| { | |
| $parts = $this->plainTemplateSplitPipeExpression(trim($expression)); | |
| if ($parts === null || $parts === [] || $parts[0] === '') { | |
| return null; | |
| } | |
| $variableName = '/^[A-Za-z][A-Za-z0-9_.-]*$/'; | |
| if (preg_match($variableName, $parts[0]) !== 1) { | |
| return null; | |
| } | |
| $pipes = []; | |
| foreach (array_slice($parts, 1) as $pipe) { | |
| if (!$this->plainTemplatePipeIsValid($pipe)) { | |
| return null; | |
| } | |
| $pipes[] = $pipe; | |
| } | |
| return [ | |
| 'name' => $parts[0], | |
| 'pipes' => $pipes, | |
| ]; | |
| } | |
| /** | |
| * @return list<string>|null | |
| */ | |
| private function plainTemplateParsePipeSuffix(string $suffix): ?array | |
| { | |
| $suffix = trim($suffix); | |
| if ($suffix === '') { | |
| return []; | |
| } | |
| if (!str_starts_with($suffix, '/')) { | |
| return null; | |
| } | |
| $parts = $this->plainTemplateSplitPipeExpression($suffix); | |
| if ($parts === null || $parts === [] || $parts[0] !== '') { | |
| return null; | |
| } | |
| $pipes = []; | |
| foreach (array_slice($parts, 1) as $pipe) { | |
| if ($pipe === '') { | |
| return null; | |
| } | |