Skip to content

Commit 579eca2

Browse files
authored
Fix Yii 2 guide PDF generation (#1283)
1 parent 8a11623 commit 579eca2

4 files changed

Lines changed: 284 additions & 3 deletions

File tree

‎apidoc/PdfGuideRenderer.php‎

Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,150 @@
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+
}

‎apidoc/PdfMarkdownLaTeX.php‎

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
<?php
2+
3+
namespace app\apidoc;
4+
5+
use DOMDocument;
6+
use yii\apidoc\helpers\EncodingHelper;
7+
use yii\helpers\Markdown;
8+
9+
/**
10+
* Prevents recoverable HTML parser diagnostics from aborting PDF generation.
11+
*/
12+
class PdfMarkdownLaTeX extends \yii\apidoc\helpers\ApiMarkdownLaTeX
13+
{
14+
protected function renderApiLinkText($title)
15+
{
16+
// Some Russian API-link titles become empty during Markdown processing
17+
// or entity conversion. The upstream implementation passes that value
18+
// to DOMDocument::loadHTML(), and the production error handler promotes
19+
// its "Empty string supplied as input" warning to an exception.
20+
if (!$title) {
21+
return $title;
22+
}
23+
24+
$title = Markdown::process($title);
25+
if ($title === '') {
26+
return '';
27+
}
28+
29+
$title = EncodingHelper::convertToUtf8WithHtmlEntities($title);
30+
if ($title === '') {
31+
return '';
32+
}
33+
$useInternalErrors = libxml_use_internal_errors(true);
34+
35+
try {
36+
// Guide snippets are not necessarily valid standalone HTML. Keep
37+
// recoverable libxml diagnostics local so production does not turn
38+
// them into exceptions while extracting the link label.
39+
$doc = new DOMDocument();
40+
$doc->loadHTML($title);
41+
42+
return $doc->getElementsByTagName('p')[0]->childNodes[0]->c14n();
43+
} finally {
44+
libxml_clear_errors();
45+
libxml_use_internal_errors($useInternalErrors);
46+
}
47+
}
48+
}

‎commands/GuideController.php‎

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
use yii\console\ExitCode;
99
use yii\helpers\Console;
1010
use app\apidoc\GuideRenderer;
11+
use app\apidoc\PdfGuideRenderer;
1112
use yii\helpers\FileHelper;
1213
use yii\helpers\Inflector;
1314
use yii\helpers\Json;
@@ -125,7 +126,12 @@ public function actionGenerate($version, $languageOnly = null)
125126
if (file_exists("$pdfTarget/fail.log")) {
126127
unlink("$pdfTarget/fail.log");
127128
}
128-
exec('cd ' . escapeshellarg($pdfTarget) . ' && make pdf', $output, $ret);
129+
// exec() appends to an existing output array. Reset it for
130+
// every language or fail.log contains all earlier builds
131+
// and grows to several megabytes. Capture stderr as well;
132+
// pdflatex writes the useful failure reason there.
133+
$output = [];
134+
exec('cd ' . escapeshellarg($pdfTarget) . ' && make pdf 2>&1', $output, $ret);
129135
if ($ret === 0) {
130136
$this->stdout("\nFinished guide $version PDF in $name.\n\n", Console::FG_CYAN);
131137
} else {
@@ -326,8 +332,10 @@ public static function normalizeGuideDirectory($source, $language)
326332
public function findRenderer($template)
327333
{
328334
if ($template === 'pdf') {
329-
$rendererClass = 'yii\\apidoc\\templates\\' . $template . '\\GuideRenderer';
330-
return new $rendererClass();
335+
// Keep compatibility fixes here rather than in data/yii-2.0 or the
336+
// generated .tex files: the former is replaced by `git pull` on
337+
// every docs build and the latter is disposable output.
338+
return new PdfGuideRenderer();
331339
}
332340

333341
if ($template === 'extension') {
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
<?php
2+
3+
use app\apidoc\PdfGuideRenderer;
4+
5+
class PdfGuideRendererTest extends \Codeception\Test\Unit
6+
{
7+
public function testAddsBlankLineBeforeFencedCodeBlock()
8+
{
9+
$markdown = "To delete it use\n```php\necho 'ok';\n```\n";
10+
$expected = "To delete it use\n\n```php\necho 'ok';\n```\n";
11+
12+
self::assertSame($expected, PdfGuideRenderer::normalizeMarkdown($markdown));
13+
}
14+
15+
public function testAddsBlankLineBeforeLanguageLessFencedCodeBlock()
16+
{
17+
$markdown = "Example\n```\nplain text\n```\n";
18+
$expected = "Example\n\n```\nplain text\n```\n";
19+
20+
self::assertSame($expected, PdfGuideRenderer::normalizeMarkdown($markdown));
21+
}
22+
23+
public function testAddsQuotedBlankLineBeforeFencedCodeBlockInBlockquote()
24+
{
25+
$markdown = "> Example\n> ```php\n> echo 'ok';\n> ```\n";
26+
$expected = "> Example\n>\n> ```php\n> echo 'ok';\n> ```\n";
27+
28+
self::assertSame($expected, PdfGuideRenderer::normalizeMarkdown($markdown));
29+
}
30+
31+
public function testPreservesCyrillicText()
32+
{
33+
$markdown = "Иерархия и данные в файлах.\n";
34+
35+
self::assertSame($markdown, PdfGuideRenderer::normalizeMarkdown($markdown));
36+
}
37+
38+
public function testNormalizesMisparsedFencedCodeBlock()
39+
{
40+
$latex = <<<'LATEX'
41+
\mintinline{text}{`}php
42+
Yii::\$app->cache->delete(['yii\widgets\FragmentCache', $id]);
43+
\mintinline{text}{`}
44+
LATEX;
45+
46+
$expected = <<<'LATEX'
47+
\begin{minted}{php}
48+
Yii::$app->cache->delete(['yii\widgets\FragmentCache', $id]);
49+
\end{minted}
50+
LATEX;
51+
52+
self::assertSame($expected, PdfGuideRenderer::normalizeLatex($latex));
53+
}
54+
55+
public function testReplacesInlineMintedInsideTableOnly()
56+
{
57+
$latex = <<<'LATEX'
58+
\mintinline{text}{outside}
59+
\begin{tabularx}{\textwidth}{|c|c|}
60+
\mintinline{text}{lt} & \mintinline{text}{<}\\ \hline
61+
\mintinline{text}{lte} & \mintinline{text}{<=}\\ \hline
62+
\end{tabularx}
63+
LATEX;
64+
65+
$expected = <<<'LATEX'
66+
\mintinline{text}{outside}
67+
\begin{tabularx}{\textwidth}{|c|c|}
68+
\texttt{\detokenize{lt}} & \texttt{\detokenize{<}}\\ \hline
69+
\texttt{\detokenize{lte}} & \texttt{\detokenize{<=}}\\ \hline
70+
\end{tabularx}
71+
LATEX;
72+
73+
self::assertSame($expected, PdfGuideRenderer::normalizeLatex($latex));
74+
}
75+
}

0 commit comments

Comments
 (0)