PluginProbe
WCPOS – Point of Sale (POS) plugin for WooCommerce / 1.10.16
WCPOS – Point of Sale (POS) plugin for WooCommerce v1.10.16
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 1.9.13 All 162 releases
woocommerce-pos / includes / Services / Pdf_Renderer.php

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

306 lines 10.1 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 = ( new Local_Image_Resolver() )->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 * Build and render a Dompdf instance.
180 *
181 * @param string $html HTML document to render.
182 * @param array $opts Render options.
183 *
184 * @return mixed Rendered Dompdf instance.
185 */
186 private function build( string $html, array $opts ) {
187 $temp_dir = $this->writable_dir();
188
189 $options = new Options();
190 $options->set( 'isRemoteEnabled', false );
191 $options->set( 'isPhpEnabled', false );
192 $options->set( 'isJavascriptEnabled', false );
193 $options->set( 'defaultFont', isset( $opts['default_font'] ) ? (string) $opts['default_font'] : 'dejavu sans' );
194 // Keep Dompdf's bundled fonts as the font source (default fontDir), but
195 // direct its writable caches at a WCPOS-owned temp dir.
196 $options->set( 'fontCache', $temp_dir );
197 $options->set( 'tempDir', $temp_dir );
198 // chroot only gates Dompdf's file:// local-URI access (e.g. <img src> /
199 // @import to local paths); Dompdf still loads its bundled fonts from its
200 // own rootDir/fontDir, so confining chroot to $temp_dir does not break
201 // font rendering. With isRemoteEnabled false and images embedded as data
202 // URIs, no local file access is needed anyway.
203 $options->set( 'chroot', array( $temp_dir ) );
204
205 $dompdf = new Dompdf( $options );
206 $dompdf->loadHtml( $html );
207
208 $paper = isset( $opts['paper'] ) ? $opts['paper'] : 'A4';
209 $orientation = isset( $opts['orientation'] ) ? (string) $opts['orientation'] : 'portrait';
210 $dompdf->setPaper( $paper, $orientation );
211
212 $this->render_dompdf( $dompdf );
213
214 return $dompdf;
215 }
216
217 /**
218 * Render a Dompdf instance while ignoring PHP 8.5 vendor deprecations.
219 *
220 * @param mixed $dompdf Dompdf instance.
221 */
222 private function render_dompdf( $dompdf ): void {
223 $previous_handler = null;
224 $previous_handler = set_error_handler(
225 static function ( int $errno, string $errstr, string $errfile = '', int $errline = 0 ) use ( &$previous_handler ): bool {
226 if (
227 0 !== ( $errno & ( E_DEPRECATED | E_USER_DEPRECATED ) )
228 && false !== strpos( str_replace( '\\', '/', $errfile ), '/vendor_prefixed/' )
229 ) {
230 return true;
231 }
232
233 if ( \is_callable( $previous_handler ) ) {
234 return (bool) \call_user_func( $previous_handler, $errno, $errstr, $errfile, $errline );
235 }
236
237 return false;
238 }
239 );
240
241 try {
242 $dompdf->render();
243 } finally {
244 restore_error_handler();
245 }
246 }
247
248 /**
249 * Fit a custom paper box to the smallest height that keeps content on one page.
250 *
251 * The vendored Dompdf frame tree does not expose reliable post-render content
252 * frames for this path, so use the documented Canvas page count as the signal.
253 *
254 * @param string $html HTML document to render.
255 * @param array $opts Render options.
256 * @param array $paper Custom paper box [x0,y0,x1,y1].
257 *
258 * @return array Fitted custom paper box.
259 */
260 private function fit_height_paper( string $html, array $opts, array $paper ): array {
261 $probe_paper = array( $paper[0], $paper[1], $paper[2], self::FIT_HEIGHT_PROBE_PT );
262 $probe_opts = $opts;
263 unset( $probe_opts['fit_height'] );
264 $probe_opts['paper'] = $probe_paper;
265
266 $high = self::FIT_HEIGHT_PROBE_PT;
267 $low = 1.0;
268
269 $probe = $this->build( $html, $probe_opts );
270 if ( $probe->getCanvas()->get_page_count() > 1 ) {
271 return $probe_paper;
272 }
273
274 for ( $i = 0; $i < self::FIT_HEIGHT_SEARCH_STEPS; $i++ ) {
275 $mid = ( $low + $high ) / 2;
276 $test_opts = $probe_opts;
277 $test_opts['paper'] = array( $paper[0], $paper[1], $paper[2], $mid );
278
279 if ( $this->build( $html, $test_opts )->getCanvas()->get_page_count() <= 1 ) {
280 $high = $mid;
281 } else {
282 $low = $mid;
283 }
284 }
285
286 return array( $paper[0], $paper[1], $paper[2], ceil( $high ) + self::FIT_HEIGHT_MARGIN_PT );
287 }
288
289 /**
290 * Resolve a writable directory for Dompdf's font cache and temp files.
291 *
292 * @return string Absolute path to a writable WCPOS-owned temp directory.
293 */
294 private function writable_dir(): string {
295 $dir = rtrim( get_temp_dir(), '/\\' ) . '/wcpos-dompdf';
296 if ( ! is_dir( $dir ) ) {
297 wp_mkdir_p( $dir );
298 // Restrict the freshly created cache dir to the owner; it holds
299 // rendered font caches and temp PDFs that need not be world-readable.
300 @chmod( $dir, 0700 ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
301 }
302
303 return $dir;
304 }
305 }
306