1<?php 2 3namespace dokuwiki\plugin\dw2pdf\test; 4 5use dokuwiki\plugin\dw2pdf\src\BookCreatorLiveSelectionCollector; 6use dokuwiki\plugin\dw2pdf\src\Cache; 7use dokuwiki\plugin\dw2pdf\src\Config; 8use dokuwiki\plugin\dw2pdf\src\PageCollector; 9use dokuwiki\plugin\dw2pdf\src\PdfExportService; 10use DOMWrap\Document; 11use DOMWrap\Element; 12 13/** 14 * End-to-end tests for the dw2pdf plugin 15 * 16 * @group plugin_dw2pdf 17 * @group plugins 18 */ 19class EndToEndTest extends \DokuWikiTest 20{ 21 protected $pluginsEnabled = ['dw2pdf']; 22 23 public function setUp(): void 24 { 25 parent::setUp(); 26 $_REQUEST = []; 27 } 28 29 30 /** 31 * Create the pages, render them through the PdfExportService in debug mode and return the resulting HTML. 32 * 33 * @param string|string[] $pages One or more pages to be included in the export 34 * @return string Rendered HTML output 35 */ 36 protected function getDebugHTML($pages, $conf = []): string 37 { 38 $pages = (array)$pages; 39 40 foreach ($pages as $page) { 41 $this->prepareFixturePage($page); 42 } 43 44 $config = new Config(array_merge( 45 $conf, 46 [ 47 'debug' => 1, 48 'liveselection' => json_encode($pages) 49 ] 50 )); 51 $collector = new BookCreatorLiveSelectionCollector($config); 52 $cache = new Cache($config, $collector); 53 $service = new PdfExportService($config, $collector, $cache, 'Contents', 'tester'); 54 return $service->getDebugHtml(); 55 } 56 57 /** 58 * Test that numbered headers are rendered correctly 59 */ 60 public function testNumberedHeaders(): void 61 { 62 $html = $this->getDebugHTML('headers', ['headernumber' => 1]); 63 64 $dom = (new Document())->html($html); 65 66 $dom->find('h1')->each(function ($h) { 67 $this->assertMatchesRegularExpression('/^1\. Header/', $h->text()); 68 }); 69 70 $dom->find('h2')->each(function ($h) { 71 $this->assertMatchesRegularExpression('/^\d\.\d\. Header/', $h->text()); 72 }); 73 74 $dom->find('h3')->each(function ($h) { 75 $this->assertMatchesRegularExpression('/^\d\.\d\.\d\. Header/', $h->text()); 76 }); 77 } 78 79 /** 80 * Test that numbered headers are rendered correctly across multiple pages 81 * 82 * Each new page should increase the top-level header number 83 */ 84 public function testNumberedHeadersMultipage(): void 85 { 86 $html = $this->getDebugHTML(['headers', 'simple'], ['headernumber' => 1]); 87 88 $dom = (new Document())->html($html); 89 90 $count = 1; 91 $dom->find('h1')->each(function ($h) use (&$count) { 92 $this->assertMatchesRegularExpression('/^' . ($count++) . '\. /', $h->text()); 93 }); 94 } 95 96 /** 97 * Ensure each rendered page begins with an anchor that namespaces intra-page links. 98 */ 99 public function testDocumentStartCreatesPageAnchors(): void 100 { 101 $html = $this->getDebugHTML(['renderer_features', 'target']); 102 103 $dom = (new Document())->html($html); 104 105 foreach (['renderer_features', 'target'] as $pageId) { 106 $anchors = $dom->find('a[name="' . $pageId . '__"]'); 107 $this->assertSame( 108 1, 109 count($anchors), 110 'Missing document_start anchor for ' . $pageId 111 ); 112 } 113 } 114 115 /** 116 * Bookmarks should only be produced up to the configured level and include numbering. 117 */ 118 public function testBookmarksRespectConfiguredLevels(): void 119 { 120 $html = $this->getDebugHTML('headers', ['headernumber' => 1, 'maxbookmarks' => 2]); 121 122 $dom = (new Document())->html($html); 123 $bookmarks = $dom->find('bookmark'); 124 125 $this->assertGreaterThan(0, count($bookmarks)); 126 127 foreach ($bookmarks as $bookmark) { 128 $this->assertLessThanOrEqual( 129 1, 130 (int)$bookmark->attr('level'), 131 'Bookmark level exceeded configured maximum' 132 ); 133 134 $content = trim((string)$bookmark->attr('content')); 135 $this->assertMatchesRegularExpression('/^\d+(?:\.\d+)*\.\s+Header/', $content); 136 } 137 138 $this->assertSame(count($dom->find('h1, h2')), count($bookmarks)); 139 } 140 141 /** 142 * Local section links should include the page-specific prefix. 143 */ 144 public function testLocallinksArePrefixedWithPageId(): void 145 { 146 $html = $this->getDebugHTML(['renderer_features', 'target']); 147 $dom = (new Document())->html($html); 148 149 $link = $this->findLinkByText($dom, 'Jump to Remote Section'); 150 $this->assertNotNull($link, 'Local section link missing'); 151 152 $this->assertSame('#renderer_features__remote_section', $link->attr('href')); 153 } 154 155 /** 156 * Internal links must expose dw2pdf data attributes so the writer can retarget them. 157 */ 158 public function testInternalLinksExposeDw2pdfMetadata(): void 159 { 160 $html = $this->getRawRendererHtml('renderer_features', [], ['target']); 161 $dom = (new Document())->html($html); 162 163 $pageLink = $this->findLinkByText($dom, 'Target page link'); 164 $this->assertNotNull($pageLink, 'Page link missing'); 165 $this->assertSame('target', $pageLink->attr('data-dw2pdf-target')); 166 $this->assertSame('', $pageLink->attr('data-dw2pdf-hash')); 167 168 $sectionLink = $this->findLinkByText($dom, 'Target section link'); 169 $this->assertNotNull($sectionLink, 'Section link missing'); 170 $this->assertSame('target', $sectionLink->attr('data-dw2pdf-target')); 171 $this->assertSame('sub_section', $sectionLink->attr('data-dw2pdf-hash')); 172 } 173 174 /** 175 * Centered media needs to be wrapped so CSS centering survives inside mPDF. 176 */ 177 public function testCenteredMediaIsWrapped(): void 178 { 179 $html = $this->getDebugHTML('renderer_features'); 180 $dom = (new Document())->html($html); 181 182 $wrappers = $dom->find('div[align="center"][style*="text-align: center"]'); 183 $this->assertGreaterThan(0, count($wrappers), 'Centered media wrapper missing'); 184 $this->assertGreaterThan(0, count($wrappers->first()->find('img'))); 185 } 186 187 /** 188 * Acronyms should render as plain text to avoid useless hover hints in PDFs. 189 */ 190 public function testAcronymOutputDropsHover(): void 191 { 192 $html = $this->getDebugHTML('renderer_features'); 193 $dom = (new Document())->html($html); 194 195 $this->assertSame(0, count($dom->find('acronym'))); 196 $this->assertStringContainsString('FAQ', $html); 197 $this->assertStringNotContainsString('Frequently Asked Questions', $html); 198 } 199 200 /** 201 * Email addresses must not be obfuscated so that mailto links remain readable. 202 */ 203 public function testEmailLinksStayReadable(): void 204 { 205 $html = $this->getDebugHTML('renderer_features'); 206 $dom = (new Document())->html($html); 207 208 $link = $this->findLinkByText($dom, 'test@example.com'); 209 $this->assertNotNull($link, 'Email link missing'); 210 $this->assertSame('mailto:test@example.com', $link->attr('href')); 211 } 212 213 /** 214 * Interwiki links should be prefixed with the respective icon. 215 */ 216 public function testInterwikiLinksArePrefixedWithIcon(): void 217 { 218 $html = $this->getDebugHTML('renderer_features'); 219 $dom = (new Document())->html($html); 220 221 $link = $dom->find('a.interwiki')->first(); 222 $this->assertNotNull($link, 'Interwiki link missing'); 223 224 $icon = $link->children()->first(); 225 $this->assertNotNull($icon, 'Interwiki icon missing'); 226 $this->assertSame('img', strtolower($icon->nodeName)); 227 $this->assertStringContainsString('iw_doku', $icon->attr('class')); 228 } 229 230 /** 231 * Render a single page through the dw2pdf renderer without writer post-processing. 232 * 233 * @param string $pageId 234 * @param array $conf 235 * @param string[] $additionalPages 236 * @return string 237 */ 238 protected function getRawRendererHtml(string $pageId, array $conf = [], array $additionalPages = []): string 239 { 240 $this->prepareFixturePage($pageId); 241 foreach ($additionalPages as $related) { 242 $this->prepareFixturePage($related); 243 } 244 245 /** @var \renderer_plugin_dw2pdf $renderer */ 246 $renderer = plugin_load('renderer', 'dw2pdf', true); 247 $renderer->setConfig(new Config($conf)); 248 249 global $ID; 250 $keep = $ID; 251 $ID = $pageId; 252 253 $file = wikiFN($pageId); 254 $instructions = p_get_instructions(io_readWikiPage($file, $pageId)); 255 $info = []; 256 $html = p_render('dw2pdf', $instructions, $info); 257 258 $ID = $keep; 259 260 return $html; 261 } 262 263 /** 264 * Persist the given fixture page into the wiki so that it can be rendered. 265 * 266 * @param string $pageId 267 * @return void 268 */ 269 protected function prepareFixturePage(string $pageId): void 270 { 271 $data = file_get_contents(__DIR__ . '/pages/' . $pageId . '.txt'); 272 saveWikiText($pageId, $data, 'dw2pdf renderer test'); 273 } 274 275 /** 276 * Locate the first hyperlink whose trimmed text matches the expected label. 277 * 278 * @param Document $dom 279 * @param string $text 280 * @return Element|null 281 */ 282 protected function findLinkByText(Document $dom, string $text): ?Element 283 { 284 foreach ($dom->find('a') as $anchor) { 285 if (trim($anchor->text()) === $text) { 286 return $anchor; 287 } 288 } 289 290 return null; 291 } 292} 293