PluginProbe
WCPOS – Point of Sale (POS) plugin for WooCommerce / 1.9.14
WCPOS – Point of Sale (POS) plugin for WooCommerce v1.9.14
1.10.19 1.10.18 1.10.17 1.10.16 1.10.15 1.10.13 1.10.14 1.10.12 1.10.11 1.10.10 1.10.9 1.10.8 untagged-3d9b7ccddc54df87c672 1.10.7 1.10.6 1.10.5 1.10.3 1.10.4 1.10.2 1.10.1 1.10.0 1.9.17 1.9.15 1.9.16 1.9.14 All 163 releases
woocommerce-pos / includes / Services / Pdf_Renderer.php

Pdf_Renderer.php in WCPOS – Point of Sale (POS) plugin for WooCommerce 1.9.14, at includes/Services/Pdf_Renderer.php

481 lines 14.4 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Renders HTML to PDF bytes via the prefixed Dompdf library.
4 *
5 * Thin wrapper around WCPOS\Vendor\Dompdf\Dompdf. Remote/PHP/JS are disabled for
6 * safety; local WordPress/plugin images are embedded as data URIs before Dompdf
7 * sees the HTML. Dompdf's writable font cache and temp dir are pointed at a
8 * WCPOS-owned subdirectory of the system temp so nothing is written into the
9 * read-only, committed vendor_prefixed/ tree.
10 *
11 * @package WCPOS\WooCommercePOS\Services
12 */
13
14 namespace WCPOS\WooCommercePOS\Services;
15
16 use WCPOS\Vendor\Dompdf\Dompdf;
17 use WCPOS\Vendor\Dompdf\Options;
18
19 /**
20 * Pdf_Renderer class.
21 */
22 class Pdf_Renderer {
23 /**
24 * Tall probe page height used when fitting continuous-roll receipt PDFs.
25 */
26 private const FIT_HEIGHT_PROBE_PT = 14000.0;
27
28 /**
29 * Bottom breathing room added to the fitted receipt PDF page.
30 */
31 private const FIT_HEIGHT_MARGIN_PT = 12.0;
32
33 /**
34 * Binary-search render passes for the smallest single-page height.
35 */
36 private const FIT_HEIGHT_SEARCH_STEPS = 18;
37
38 /**
39 * Render an HTML document to PDF bytes.
40 *
41 * @param string $html HTML document to render.
42 * @param array $opts Optional: 'paper' (size name or [x0,y0,x1,y1]), 'orientation'
43 * ('portrait'|'landscape'), 'default_font', 'fit_height',
44 * 'receipt_layout' (rewrite receipt flex/grid markup for Dompdf
45 * and lift the root padding into @page margins).
46 *
47 * @return string The PDF document bytes (begins with '%PDF-').
48 */
49 public function render_html( string $html, array $opts = array() ): string {
50 $html = $this->prepare_html_for_render( $html, $opts );
51
52 $paper = isset( $opts['paper'] ) ? $opts['paper'] : 'A4';
53 if ( ! empty( $opts['fit_height'] ) && \is_array( $paper ) ) {
54 $opts['paper'] = $this->fit_height_paper( $html, $opts, $paper );
55 }
56
57 return (string) $this->build( $html, $opts )->output();
58 }
59
60 /**
61 * Prepare receipt HTML for Dompdf's locked-down render environment.
62 *
63 * @param string $html HTML document to render.
64 * @param array $opts Render options.
65 *
66 * @return string Prepared HTML.
67 */
68 private function prepare_html_for_render( string $html, array $opts ): string {
69 // Opt-in: the rewrite encodes receipt-layout knowledge (including legacy
70 // template class names), so it is only applied when the caller asks for it
71 // (receipt rendering) rather than for every generic HTML document.
72 if ( ! empty( $opts['receipt_layout'] ) ) {
73 $preprocessor = new Pdf_Layout_Preprocessor();
74 $html = $preprocessor->process( $html );
75
76 // The preprocessor owns full-document detection so the two sides
77 // can never disagree about which treatment an input received.
78 if ( $preprocessor->is_full_document() ) {
79 // Full HTML documents (the legacy-php receipt template) keep
80 // their own <head> stylesheet, charset and Dompdf's default
81 // page margins; the preprocessor rewrote their flex containers
82 // (inline-styled and known legacy classes) in place.
83 return $this->embed_local_images( $html );
84 }
85
86 // Match the browser preview: the template's own root padding is
87 // the only whitespace around the receipt, so it replaces Dompdf's
88 // default 1.2cm page margin (and keeps later pages consistent
89 // with page one).
90 $margins = $preprocessor->get_page_margins_pt();
91
92 $page_style = '<style>@page { margin: '
93 . implode(
94 ' ',
95 array_map(
96 static function ( float $pt ): string {
97 return self::css_number( $pt ) . 'pt';
98 },
99 $margins
100 )
101 )
102 . '; } body { margin: 0; padding: 0; }</style>';
103
104 // Receipts are UTF-8 fragments with no charset declaration;
105 // without one Dompdf sniffs the encoding and mostly-ASCII
106 // receipts with a stray multibyte character (e.g. an em dash)
107 // get mis-decoded.
108 $charset_meta = '<meta http-equiv="Content-Type" content="text/html; charset=utf-8">';
109
110 $html = $this->inject_head_styles( $html, $charset_meta . $page_style );
111 }
112
113 return $this->embed_local_images( $html );
114 }
115
116 /**
117 * Format a float for CSS output, immune to LC_NUMERIC comma locales.
118 *
119 * @param float $value The value to format.
120 *
121 * @return string The formatted number.
122 */
123 private static function css_number( float $value ): string {
124 $formatted = rtrim( rtrim( number_format( $value, 2, '.', '' ), '0' ), '.' );
125
126 return '' === $formatted ? '0' : $formatted;
127 }
128
129 /**
130 * Prepend compatibility stylesheets to the receipt HTML.
131 *
132 * Inserted just before `</head>` when the HTML is a full document, otherwise
133 * prepended to the fragment (Dompdf wraps loose markup in html/body itself).
134 * Placing the stylesheet last in the head lets its `!important` rules win the
135 * cascade over template styles.
136 *
137 * @param string $html The receipt HTML.
138 * @param string $styles The <style> block(s) to inject.
139 *
140 * @return string The HTML with the stylesheets injected.
141 */
142 private function inject_head_styles( string $html, string $styles ): string {
143 $head_close = stripos( $html, '</head>' );
144 if ( false !== $head_close ) {
145 return substr_replace( $html, $styles, $head_close, 0 );
146 }
147
148 return $styles . $html;
149 }
150
151 /**
152 * Embed local WordPress image URLs as data URIs.
153 *
154 * Dompdf remote loading and local file access are intentionally disabled, so
155 * receipt logos and bundled assets must be inlined. Only URLs that resolve to
156 * known local WordPress/plugin paths are embedded; external URLs are left
157 * untouched.
158 *
159 * @param string $html HTML document.
160 *
161 * @return string HTML with local image sources embedded.
162 */
163 private function embed_local_images( string $html ): string {
164 return (string) preg_replace_callback(
165 '/(<img\b[^>]*\bsrc\s*=\s*["\'])([^"\']+)(["\'][^>]*>)/i',
166 function ( array $matches ): string {
167 $data_uri = $this->image_src_to_data_uri( html_entity_decode( $matches[2], ENT_QUOTES, 'UTF-8' ) );
168 if ( null === $data_uri ) {
169 return $matches[0];
170 }
171
172 return $matches[1] . esc_attr( $data_uri ) . $matches[3];
173 },
174 $html
175 );
176 }
177
178 /**
179 * Convert a local image source to a data URI.
180 *
181 * @param string $src Image source.
182 *
183 * @return string|null Data URI, or null when the source is not embeddable.
184 */
185 private function image_src_to_data_uri( string $src ): ?string {
186 if ( '' === $src || 0 === strpos( $src, 'data:' ) ) {
187 return null;
188 }
189
190 $path = $this->local_image_path_from_src( $src );
191 if ( null === $path || ! is_readable( $path ) || ! is_file( $path ) ) {
192 return null;
193 }
194
195 $bytes = file_get_contents( $path );
196 if ( false === $bytes || '' === $bytes ) {
197 return null;
198 }
199
200 $mime = $this->image_mime_type( $path );
201 if ( null === $mime ) {
202 return null;
203 }
204
205 return 'data:' . $mime . ';base64,' . base64_encode( $bytes );
206 }
207
208 /**
209 * Resolve an image src to a safe local filesystem path.
210 *
211 * @param string $src Image source.
212 *
213 * @return string|null Local path, or null when the src is external/unknown.
214 */
215 private function local_image_path_from_src( string $src ): ?string {
216 $src = trim( $src );
217 $src = explode( '#', $src, 2 )[0];
218 $src = explode( '?', $src, 2 )[0];
219
220 if ( 0 === strpos( $src, '/' ) && 0 !== strpos( $src, '//' ) && \defined( 'ABSPATH' ) ) {
221 $path = wp_normalize_path( ABSPATH . ltrim( $src, '/' ) );
222 return $this->is_allowed_local_image_path( $path ) ? $path : null;
223 }
224
225 $mappings = $this->local_url_path_mappings();
226 foreach ( $mappings as $url_base => $path_base ) {
227 if ( 0 !== strpos( $src, $url_base ) ) {
228 continue;
229 }
230
231 $relative = ltrim( substr( $src, \strlen( $url_base ) ), '/\\' );
232 $path = wp_normalize_path( trailingslashit( $path_base ) . $relative );
233
234 return $this->is_allowed_local_image_path( $path ) ? $path : null;
235 }
236
237 return null;
238 }
239
240 /**
241 * Build URL-to-path mappings for local WordPress assets.
242 *
243 * @return array<string,string>
244 */
245 private function local_url_path_mappings(): array {
246 $uploads = wp_upload_dir();
247 $plugin = dirname( __DIR__, 2 );
248
249 $mappings = array(
250 $uploads['baseurl'] => $uploads['basedir'],
251 content_url() => \defined( 'WP_CONTENT_DIR' ) ? WP_CONTENT_DIR : '',
252 plugins_url( '', $plugin . '/woocommerce-pos.php' ) => $plugin,
253 );
254
255 $normalized = array();
256 foreach ( $mappings as $url => $path ) {
257 if ( '' === $url || '' === $path ) {
258 continue;
259 }
260
261 $normalized[ trailingslashit( $url ) ] = wp_normalize_path( $path );
262 }
263
264 uksort(
265 $normalized,
266 static function ( string $a, string $b ): int {
267 return \strlen( $b ) <=> \strlen( $a );
268 }
269 );
270
271 return $normalized;
272 }
273
274 /**
275 * Check that a resolved path stays within known local asset roots.
276 *
277 * @param string $path Resolved path.
278 *
279 * @return bool
280 */
281 private function is_allowed_local_image_path( string $path ): bool {
282 $real_path = realpath( $path );
283 if ( false === $real_path ) {
284 return false;
285 }
286
287 $real_path = wp_normalize_path( $real_path );
288 foreach ( $this->allowed_local_image_roots() as $root ) {
289 if ( 0 === strpos( $real_path, trailingslashit( $root ) ) || $real_path === $root ) {
290 return true;
291 }
292 }
293
294 return false;
295 }
296
297 /**
298 * Allowed local image roots.
299 *
300 * @return string[]
301 */
302 private function allowed_local_image_roots(): array {
303 $uploads = wp_upload_dir();
304 $roots = array(
305 $uploads['basedir'],
306 \defined( 'WP_CONTENT_DIR' ) ? WP_CONTENT_DIR : '',
307 dirname( __DIR__, 2 ),
308 );
309
310 return array_values(
311 array_filter(
312 array_map(
313 static function ( string $root ): string {
314 $real = realpath( $root );
315 return false === $real ? '' : wp_normalize_path( $real );
316 },
317 $roots
318 )
319 )
320 );
321 }
322
323 /**
324 * Determine a supported image MIME type from path.
325 *
326 * @param string $path Local image path.
327 *
328 * @return string|null MIME type.
329 */
330 private function image_mime_type( string $path ): ?string {
331 $type = wp_check_filetype( $path );
332 $mime = false !== $type['type'] ? (string) $type['type'] : '';
333
334 if ( '' === $mime ) {
335 $extension = strtolower( pathinfo( $path, PATHINFO_EXTENSION ) );
336 $mime = array(
337 'gif' => 'image/gif',
338 'jpg' => 'image/jpeg',
339 'jpeg' => 'image/jpeg',
340 'png' => 'image/png',
341 'svg' => 'image/svg+xml',
342 'webp' => 'image/webp',
343 )[ $extension ] ?? '';
344 }
345
346 if ( 0 !== strpos( $mime, 'image/' ) ) {
347 return null;
348 }
349
350 return $mime;
351 }
352
353 /**
354 * Build and render a Dompdf instance.
355 *
356 * @param string $html HTML document to render.
357 * @param array $opts Render options.
358 *
359 * @return mixed Rendered Dompdf instance.
360 */
361 private function build( string $html, array $opts ) {
362 $temp_dir = $this->writable_dir();
363
364 $options = new Options();
365 $options->set( 'isRemoteEnabled', false );
366 $options->set( 'isPhpEnabled', false );
367 $options->set( 'isJavascriptEnabled', false );
368 $options->set( 'defaultFont', isset( $opts['default_font'] ) ? (string) $opts['default_font'] : 'dejavu sans' );
369 // Keep Dompdf's bundled fonts as the font source (default fontDir), but
370 // direct its writable caches at a WCPOS-owned temp dir.
371 $options->set( 'fontCache', $temp_dir );
372 $options->set( 'tempDir', $temp_dir );
373 // chroot only gates Dompdf's file:// local-URI access (e.g. <img src> /
374 // @import to local paths); Dompdf still loads its bundled fonts from its
375 // own rootDir/fontDir, so confining chroot to $temp_dir does not break
376 // font rendering. With isRemoteEnabled false and images embedded as data
377 // URIs, no local file access is needed anyway.
378 $options->set( 'chroot', array( $temp_dir ) );
379
380 $dompdf = new Dompdf( $options );
381 $dompdf->loadHtml( $html );
382
383 $paper = isset( $opts['paper'] ) ? $opts['paper'] : 'A4';
384 $orientation = isset( $opts['orientation'] ) ? (string) $opts['orientation'] : 'portrait';
385 $dompdf->setPaper( $paper, $orientation );
386
387 $this->render_dompdf( $dompdf );
388
389 return $dompdf;
390 }
391
392 /**
393 * Render a Dompdf instance while ignoring PHP 8.5 vendor deprecations.
394 *
395 * @param mixed $dompdf Dompdf instance.
396 */
397 private function render_dompdf( $dompdf ): void {
398 $previous_handler = null;
399 $previous_handler = set_error_handler(
400 static function ( int $errno, string $errstr, string $errfile = '', int $errline = 0 ) use ( &$previous_handler ): bool {
401 if (
402 0 !== ( $errno & ( E_DEPRECATED | E_USER_DEPRECATED ) )
403 && false !== strpos( str_replace( '\\', '/', $errfile ), '/vendor_prefixed/' )
404 ) {
405 return true;
406 }
407
408 if ( \is_callable( $previous_handler ) ) {
409 return (bool) \call_user_func( $previous_handler, $errno, $errstr, $errfile, $errline );
410 }
411
412 return false;
413 }
414 );
415
416 try {
417 $dompdf->render();
418 } finally {
419 restore_error_handler();
420 }
421 }
422
423 /**
424 * Fit a custom paper box to the smallest height that keeps content on one page.
425 *
426 * The vendored Dompdf frame tree does not expose reliable post-render content
427 * frames for this path, so use the documented Canvas page count as the signal.
428 *
429 * @param string $html HTML document to render.
430 * @param array $opts Render options.
431 * @param array $paper Custom paper box [x0,y0,x1,y1].
432 *
433 * @return array Fitted custom paper box.
434 */
435 private function fit_height_paper( string $html, array $opts, array $paper ): array {
436 $probe_paper = array( $paper[0], $paper[1], $paper[2], self::FIT_HEIGHT_PROBE_PT );
437 $probe_opts = $opts;
438 unset( $probe_opts['fit_height'] );
439 $probe_opts['paper'] = $probe_paper;
440
441 $high = self::FIT_HEIGHT_PROBE_PT;
442 $low = 1.0;
443
444 $probe = $this->build( $html, $probe_opts );
445 if ( $probe->getCanvas()->get_page_count() > 1 ) {
446 return $probe_paper;
447 }
448
449 for ( $i = 0; $i < self::FIT_HEIGHT_SEARCH_STEPS; $i++ ) {
450 $mid = ( $low + $high ) / 2;
451 $test_opts = $probe_opts;
452 $test_opts['paper'] = array( $paper[0], $paper[1], $paper[2], $mid );
453
454 if ( $this->build( $html, $test_opts )->getCanvas()->get_page_count() <= 1 ) {
455 $high = $mid;
456 } else {
457 $low = $mid;
458 }
459 }
460
461 return array( $paper[0], $paper[1], $paper[2], ceil( $high ) + self::FIT_HEIGHT_MARGIN_PT );
462 }
463
464 /**
465 * Resolve a writable directory for Dompdf's font cache and temp files.
466 *
467 * @return string Absolute path to a writable WCPOS-owned temp directory.
468 */
469 private function writable_dir(): string {
470 $dir = rtrim( get_temp_dir(), '/\\' ) . '/wcpos-dompdf';
471 if ( ! is_dir( $dir ) ) {
472 wp_mkdir_p( $dir );
473 // Restrict the freshly created cache dir to the owner; it holds
474 // rendered font caches and temp PDFs that need not be world-readable.
475 @chmod( $dir, 0700 ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
476 }
477
478 return $dir;
479 }
480 }
481