|
| 1 | +<?php |
| 2 | + |
| 3 | +namespace app\apidoc; |
| 4 | + |
| 5 | +use Yii; |
| 6 | +use yii\helpers\Console; |
| 7 | + |
| 8 | +/** |
| 9 | + * Works around LaTeX output that cannot be compiled by the legacy PDF template. |
| 10 | + */ |
| 11 | +class PdfGuideRenderer extends \yii\apidoc\templates\pdf\GuideRenderer |
| 12 | +{ |
| 13 | + public function render($files, $targetDir) |
| 14 | + { |
| 15 | + $fileData = []; |
| 16 | + $chapters = $this->loadGuideStructure($files); |
| 17 | + // Progress is updated for local guide-structure entries below, not for |
| 18 | + // every discovered input file. Count the same entries so the bar does |
| 19 | + // not over/undershoot when the structure contains remote URLs. |
| 20 | + $fileCount = array_sum(array_map(static function (array $chapter) { |
| 21 | + return count(array_filter($chapter['content'], static function (array $content) { |
| 22 | + return strpos($content['file'], 'http://') !== 0 && strpos($content['file'], 'https://') !== 0; |
| 23 | + })); |
| 24 | + }, $chapters)) + 1; |
| 25 | + if ($this->controller !== null) { |
| 26 | + Console::startProgress(0, $fileCount, 'Rendering markdown files: ', false); |
| 27 | + } |
| 28 | + $done = 0; |
| 29 | + foreach ($files as $file) { |
| 30 | + $fileData[basename($file)] = self::normalizeMarkdown(file_get_contents($file)); |
| 31 | + } |
| 32 | + |
| 33 | + $md = new PdfMarkdownLaTeX(); |
| 34 | + $output = ''; |
| 35 | + foreach ($chapters as $chapter) { |
| 36 | + if (isset($chapter['headline'])) { |
| 37 | + $output .= '\chapter{' . $chapter['headline'] . "}\n"; |
| 38 | + } |
| 39 | + foreach ($chapter['content'] as $content) { |
| 40 | + if (strpos($content['file'], 'http://') === 0 || strpos($content['file'], 'https://') === 0) { |
| 41 | + continue; |
| 42 | + } |
| 43 | + $output .= '\label{' . $content['file'] . '}'; |
| 44 | + // loadGuideStructure() may retain a directory in the entry, |
| 45 | + // while $fileData is intentionally keyed by basename. Without |
| 46 | + // normalization a valid guide page becomes an "Error: not |
| 47 | + // existing file" page in the PDF. |
| 48 | + $fileName = basename($content['file']); |
| 49 | + if (isset($fileData[$fileName])) { |
| 50 | + $md->labelPrefix = $content['file'] . '#'; |
| 51 | + $output .= $md->parse($fileData[$fileName]) . "\n\n"; |
| 52 | + } else { |
| 53 | + $output .= '\newpage\textbf{Error: not existing file: ' . $content['file'] . '}\newpage' . "\n"; |
| 54 | + } |
| 55 | + |
| 56 | + if ($this->controller !== null) { |
| 57 | + Console::updateProgress(++$done, $fileCount); |
| 58 | + } |
| 59 | + } |
| 60 | + } |
| 61 | + |
| 62 | + file_put_contents($targetDir . '/guide.tex', self::normalizeLatex($output)); |
| 63 | + $templateDir = Yii::getAlias('@vendor/yiisoft/yii2-apidoc/templates/pdf'); |
| 64 | + copy($templateDir . '/main.tex', $targetDir . '/main.tex'); |
| 65 | + copy($templateDir . '/title.tex', $targetDir . '/title.tex'); |
| 66 | + copy($templateDir . '/Makefile', $targetDir . '/Makefile'); |
| 67 | + |
| 68 | + if ($this->controller !== null) { |
| 69 | + Console::updateProgress(++$done, $fileCount); |
| 70 | + Console::endProgress(true); |
| 71 | + $this->controller->stdout('done.' . PHP_EOL, Console::FG_GREEN); |
| 72 | + } |
| 73 | + |
| 74 | + echo "\nnow run `make` in $targetDir (you need pdflatex to compile pdf file)\n\n"; |
| 75 | + } |
| 76 | + |
| 77 | + public static function normalizeMarkdown($markdown) |
| 78 | + { |
| 79 | + // cebe/markdown-latex requires a blank line before a fenced block. In |
| 80 | + // caching-fragment.md the missing line made it emit inline backticks |
| 81 | + // and interpret yii\widgets\FragmentCache as a LaTeX command, aborting |
| 82 | + // the English and Japanese PDFs with "Undefined control sequence". |
| 83 | + // |
| 84 | + // Keep the blockquote prefix on the inserted blank line. The Russian |
| 85 | + // structure-applications.md contains a `> ```php` fence; inserting an |
| 86 | + // unquoted blank line makes the legacy parser nest minted environments |
| 87 | + // and pdflatex aborts with "Bad space factor (0)". |
| 88 | + // |
| 89 | + // The Unicode modifier is essential: without it PCRE treats byte 0x85 |
| 90 | + // as a newline, but 0x85 is also the trailing UTF-8 byte in Cyrillic х. |
| 91 | + // Splitting there corrupted Russian text and caused pdflatex's |
| 92 | + // "Invalid UTF-8 byte sequence" error. |
| 93 | + $lines = preg_split('~\R~u', $markdown); |
| 94 | + $result = []; |
| 95 | + $fencePrefix = null; |
| 96 | + |
| 97 | + foreach ($lines as $line) { |
| 98 | + if (preg_match('~^([ \t]*(?:>[ \t]*)*)```(?:[a-zA-Z0-9_+.-]+)?[ \t]*$~', $line, $matches)) { |
| 99 | + if ($fencePrefix === null) { |
| 100 | + $prefix = $matches[1]; |
| 101 | + $previousLine = end($result); |
| 102 | + $previousContent = preg_replace('~^' . preg_quote($prefix, '~') . '~', '', $previousLine); |
| 103 | + if ($previousLine !== false && trim($previousContent) !== '') { |
| 104 | + $result[] = rtrim($prefix); |
| 105 | + } |
| 106 | + $fencePrefix = $prefix; |
| 107 | + } elseif ($matches[1] === $fencePrefix) { |
| 108 | + $fencePrefix = null; |
| 109 | + } |
| 110 | + } |
| 111 | + |
| 112 | + $result[] = $line; |
| 113 | + } |
| 114 | + |
| 115 | + return implode("\n", $result); |
| 116 | + } |
| 117 | + |
| 118 | + public static function normalizeLatex($latex) |
| 119 | + { |
| 120 | + // Defense in depth for fences the Markdown normalization does not |
| 121 | + // recognize: convert the legacy parser's inline-backtick output into a |
| 122 | + // real minted block before PHP namespaces can become LaTeX commands. |
| 123 | + $latex = preg_replace_callback( |
| 124 | + '~\\\\mintinline\{text\}\{`\}([a-zA-Z0-9_+.-]+)\R(.+?)\R\\\\mintinline\{text\}\{`\}~s', |
| 125 | + static function (array $matches) { |
| 126 | + $code = str_replace('\\$', '$', $matches[2]); |
| 127 | + |
| 128 | + return "\\begin{minted}{{$matches[1]}}\n{$code}\n\\end{minted}"; |
| 129 | + }, |
| 130 | + $latex |
| 131 | + ); |
| 132 | + |
| 133 | + // minted invokes Pygments through shell escape for every inline value. |
| 134 | + // On the production TeX Live 2020 stack, the Russian filtering-keywords |
| 135 | + // table failed on cells such as < and <= with "Missing Pygments output". |
| 136 | + // These are plain-text cells, so \detokenize preserves their appearance |
| 137 | + // and literal characters without the fragile external invocation. |
| 138 | + return preg_replace_callback( |
| 139 | + '~\\\\begin\{tabularx\}.*?\\\\end\{tabularx\}~s', |
| 140 | + static function (array $table) { |
| 141 | + return preg_replace( |
| 142 | + '~\\\\mintinline\{text\}\{([^{}]*)\}~', |
| 143 | + '\\texttt{\\detokenize{$1}}', |
| 144 | + $table[0] |
| 145 | + ); |
| 146 | + }, |
| 147 | + $latex |
| 148 | + ); |
| 149 | + } |
| 150 | +} |
0 commit comments