1: <?php
2:
3: namespace WkHtmlToPdf;
4:
5: use Exception;
6: use GuzzleHttp\Client;
7: use GuzzleHttp\Psr7\Uri;
8: use Symfony\Component\DomCrawler\Crawler;
9: use Symfony\Component\Process\ProcessBuilder;
10:
11: 12: 13: 14: 15:
16: class WkHtmlToPdf
17: {
18: use Options\GlobalOptionsTrait,
19: Options\OutlineOptionsTrait,
20: Options\PageOptionsTrait,
21: Options\HeaderFooterOptionsTrait,
22: Options\TocOptionsTrait;
23:
24: private $processBuilder = null;
25: private $isQtPatched;
26: private $arguments = [];
27: private $inputPath;
28: private $outputPath;
29: private $isPrefetchEnabled = false;
30: private $output;
31: private $tmpDir;
32: private $debug = false;
33:
34: 35: 36: 37: 38: 39: 40:
41: public function __construct($wkhtml2pdf, $tmpDir = '/tmp', $debug = false)
42: {
43: if (!is_executable($wkhtml2pdf)) {
44: throw new Exception('wkhtml2pdf binary doesn\'t exist or is not executable.');
45: }
46:
47: $this->debug = $debug;
48: $this->tmpDir = $tmpDir;
49:
50: $this->processBuilder = ProcessBuilder::create()
51: ->setPrefix($wkhtml2pdf);
52:
53: $isQtPatched = false;
54: $localpb = clone $this->processBuilder;
55: $localpb->add('-V')
56: ->getProcess()
57: ->run(function ($t, $buffer) use ($isQtPatched) {
58: $isQtPatched = strpos($buffer, 'with patched qt') !== false;
59: });
60: $this->isQtPatched = $isQtPatched;
61: }
62:
63: 64: 65: 66: 67: 68: 69:
70: public function setInputPath($path)
71: {
72: $this->inputPath = $path;
73:
74: return $this;
75: }
76:
77: 78: 79: 80: 81: 82: 83:
84: public function setInputHtml($html)
85: {
86: $tmpFile = join_path($this->tmpDir, md5(time()) . '.html');
87: if (!is_writable(dirname($tmpFile))) {
88: throw new Exception('Temporary dir is not writable.');
89: }
90: file_put_contents($tmpFile, $html);
91: $this->inputPath = $tmpFile;
92:
93: return $this;
94: }
95:
96: 97: 98: 99: 100: 101: 102:
103: public function setOutputPath($path)
104: {
105: $this->outputPath = $path;
106:
107: return $this;
108: }
109:
110: 111: 112: 113: 114:
115: public function getOutput()
116: {
117: if (!$this->output) {
118: throw new Exception('Html is not converted to pdf yet. Call convert() first!');
119: }
120:
121: return $this->output;
122: }
123:
124: 125: 126: 127: 128: 129:
130: public function useRecommendedOptions()
131: {
132: $this->setMarginsAll('6.3mm')
133: ->setViewportSize('800x1280')
134: ->enableLowQuality()
135: ->setLoadErrorHandling('ignore')
136: ->setLoadMediaErrorHandling('ignore');
137: if ($this->isQtPatched) {
138: $this->enablePrintMediaType();
139: }
140:
141: return $this;
142: }
143:
144: 145: 146: 147: 148: 149: 150:
151: public function convert()
152: {
153: $uri = new Uri($this->inputPath);
154: $isRemote = in_array($uri->getScheme(), ['http', 'https']);
155:
156: if (!$isRemote && !is_readable($this->inputPath)) {
157: throw new Exception('Input file is not readable.');
158: }
159:
160: $outputPath = $this->outputPath ?: join_path($this->tmpDir, md5(time()) . '.pdf');
161:
162: if (!is_writable(dirname($outputPath))) {
163: throw new Exception('Output file is not writable.');
164: }
165:
166: $inputPath = $this->inputPath;
167: if ($isRemote && $this->isPrefetchEnabled) {
168: $tmpInput = join_path($this->tmpDir, time() . '.html');
169: $inputPath = $tmpInput;
170:
171: $combined = $this->combineAssets($this->inputPath);
172: file_put_contents($tmpInput, $combined);
173: }
174:
175: $pb = $this->processBuilder;
176:
177: foreach ($this->arguments as $arg => $value) {
178: if (is_array($value)) {
179: foreach ($value as $name => $val) {
180:
181:
182: $pb->add("--$arg");
183: if (is_string($name)) {
184: $pb->add($name);
185: }
186: $pb->add("$val");
187: }
188: } else {
189: $pb->add("--$arg");
190: if ($value) {
191: $pb->add($value);
192: }
193: }
194: }
195:
196: $process = $pb->add($inputPath)
197: ->add($outputPath)
198: ->getProcess();
199:
200: $process->run(function ($t, $buffer) {
201: if ($this->debug) {
202: fwrite(STDERR, $buffer);
203: }
204: });
205:
206: $this->output = file_get_contents($outputPath);
207: if (!$this->outputPath) {
208: unlink($outputPath);
209: }
210:
211: if (isset($tmpInput) && is_writable($tmpInput)) {
212: unlink($tmpInput);
213: }
214:
215: return $this;
216: }
217:
218: 219: 220: 221: 222:
223: public function enablePrefetch()
224: {
225: $this->isPrefetchEnabled = true;
226:
227: return $this;
228: }
229:
230: 231: 232: 233: 234:
235: public function disablePrefetch()
236: {
237: $this->isPrefetchEnabled = false;
238:
239: return $this;
240: }
241:
242: 243: 244: 245: 246: 247: 248: 249: 250:
251: public function setArg($arg, $val = null)
252: {
253: if ($this->debug) {
254: $this->arguments[$arg] = $val;
255: }
256:
257: return $this;
258: }
259:
260: 261: 262: 263: 264: 265: 266: 267:
268: public function unsetArg($arg)
269: {
270: if ($this->debug && array_key_exists($arg, $this->arguments)) {
271: unset($this->arguments[$arg]);
272: }
273:
274: return $this;
275: }
276:
277: 278: 279: 280: 281: 282: 283: 284:
285: private function combineAssets($path)
286: {
287: $remoteUri = new Uri($path);
288: $baseUri = $remoteUri->getScheme() . '://' . $remoteUri->getHost();
289: $client = new Client([
290: 'base_uri' => $baseUri,
291: ]);
292:
293: $htmlContents = $client->get($path)
294: ->getBody()
295: ->getContents();
296:
297: $crawler = new Crawler();
298: $crawler->addHtmlContent($htmlContents);
299:
300: $crawler->filter('link')->each(function ($node) use ($client) {
301:
302:
303: if ('stylesheet' !== $node->attr('rel')) {
304: return;
305: }
306:
307: $href = $node->attr('href');
308:
309: $newNodeContents = $client
310: ->get($href)
311: ->getBody()
312: ->getContents();
313:
314:
315: preg_replace_callback("/url\(([A-Za-z0-9\:\.\-_\/]+)\);/", function ($matches) use ($client, $href) {
316: try {
317: $resource = $client->get(dirname($href) . DIRECTORY_SEPARATOR . end($matches));
318: $mime = $resource->getHeader('Content-Type')[0];
319:
320: return sprintf('url(data:%s;base64,%s)', $mime, base64_encode($resource->getBody()->getContents()));
321: } catch (Exception $e) {
322:
323: }
324: }, $newNodeContents);
325:
326: $oldNode = $node->getNode(0);
327: $newNode = $oldNode
328: ->ownerDocument
329: ->createElement('style', $newNodeContents);
330:
331: $node->parents()
332: ->getNode(0)
333: ->replaceChild($newNode, $oldNode);
334: });
335:
336: $crawler->filter('script')->each(function ($node) use ($client) {
337:
338:
339: $src = $node->attr('src');
340:
341: if (trim($node->text()) || !$src) {
342: $isGa = false !== strpos($node->text(), 'GoogleAnalyticsObject');
343:
344: if ($isGa) {
345: $nativeNode = $node->getNode(0);
346: $node->getNode(0)->parentNode->removeChild($nativeNode);
347: }
348:
349: return;
350: }
351:
352: $newNodeContents = $client
353: ->get($src)
354: ->getBody()
355: ->getContents();
356:
357: $oldNode = $node->getNode(0);
358: $newNode = $oldNode
359: ->ownerDocument
360: ->createElement('script', htmlspecialchars($newNodeContents));
361:
362: $node->parents()
363: ->getNode(0)
364: ->replaceChild($newNode, $oldNode);
365: });
366:
367: 368: 369: 370: 371: 372: 373: 374: 375: 376:
377:
378: $crawler->filter('img')->each(function ($node) use ($client) {
379:
380:
381: $src = $node->attr('src');
382:
383: $resource = $client->get($src);
384: $mime = $resource->getHeader('Content-Type')[0];
385: $contents = $resource->getBody()->getContents();
386:
387: $node->getNode(0)->setAttribute('src', sprintf('url(data:%s;base64,%s)', $mime, base64_encode($contents)));
388: });
389:
390: return $crawler->html();
391: }
392: }
393: