PluginProbe ʕ •ᴥ•ʔ
EmbedPress – PDF Embedder, 3D PDF FlipBook, Google Reviews, YouTube Videos, Upload & Embed PDF documents / 4.6.3
EmbedPress – PDF Embedder, 3D PDF FlipBook, Google Reviews, YouTube Videos, Upload & Embed PDF documents v4.6.3
4.6.3 4.6.2 4.6.1 4.6.0 4.5.6 4.5.5 4.5.4 4.5.3 4.5.2 trunk 1.0.0 1.1.0 1.1.1 1.1.2 1.1.3 1.2.0 1.3.0 1.3.1 1.4.0 1.4.1 1.4.2 1.4.3 1.4.4 1.5.0 1.6.0 1.6.1 1.6.2 1.6.3 1.7.0 1.7.1 1.7.2 1.7.3 1.7.4 1.7.5 2.0.0 2.0.1 2.0.2 2.0.3 2.1.0 2.1.1 2.1.2 2.1.3 2.1.4 2.1.5 2.1.6 2.2.0 2.2.1 2.2.2 2.3.0 2.3.1 2.3.2 2.3.3 2.4.0 2.4.1 2.5.0 2.5.1 2.5.2 2.5.3 2.5.4 2.5.5 2.6.0 2.6.1 2.6.2 2.7.0 2.7.1 2.7.2 2.7.3 2.7.4 2.7.5 2.7.6 2.7.7 3.0.0 3.0.1 3.0.2 3.0.3 3.0.4 3.1.0 3.1.1 3.1.2 3.1.3 3.2.0 3.2.1 3.3.0 3.3.1 3.3.2 3.3.3 3.3.4 3.3.5 3.3.6 3.3.7 3.4.0 3.4.1 3.4.2 3.4.3 3.5.0 3.5.1 3.5.2 3.5.3 3.6.0 3.6.1 3.6.2 3.6.3 3.6.4 3.6.5 3.6.6 3.6.7 3.6.8 3.7.0 3.7.1 3.7.2 3.7.3 3.8.0 3.8.1 3.8.2 3.8.3 3.8.4 3.8.5 3.9.0 3.9.1 3.9.10 3.9.11 3.9.12 3.9.13 3.9.14 3.9.15 3.9.16 3.9.17 3.9.2 3.9.3 3.9.4 3.9.5 3.9.6 3.9.7 3.9.8 3.9.9 4.0.0 4.0.1 4.0.10 4.0.11 4.0.12 4.0.13 4.0.14 4.0.2 4.0.3 4.0.4 4.0.5 4.0.6 4.0.7 4.0.8 4.0.9 4.1.0 4.1.1 4.1.10 4.1.2 4.1.3 4.1.4 4.1.5 4.1.6 4.1.7 4.1.8 4.1.9 4.2.0 4.2.1 4.2.2 4.2.3 4.2.4 4.2.5 4.2.6 4.2.7 4.2.8 4.2.9 4.3.0 4.3.1 4.4.0 4.4.1 4.4.10 4.4.11 4.4.2 4.4.3 4.4.4 4.4.5 4.4.6 4.4.7 4.4.8 4.4.9 4.5.0 4.5.1
embedpress / EmbedPress / Includes / Classes / Pdf_Thumbnail_Handler.php
embedpress / EmbedPress / Includes / Classes Last commit date
Analytics 1 month ago Database 3 months ago DynamicFieldResolver.php 2 months ago Elementor_Enhancer.php 7 months ago EmbedPress_Core_Installer.php 6 years ago EmbedPress_Notice.php 4 months ago EmbedPress_Plugin_Usage_Tracker.php 2 months ago Extend_CustomPlayer_Controls.php 3 months ago Extend_Elementor_Controls.php 1 year ago FeatureNoticeManager.php 1 month ago FeatureNotices.php 2 weeks ago FeaturePreviewModal.php 1 month ago Feature_Enhancer.php 2 months ago GoogleReviewsAdminPage.php 1 month ago GoogleReviewsApify.php 1 month ago GoogleReviewsManaged.php 5 days ago GoogleReviewsRenderer.php 5 days ago GoogleReviewsRestController.php 5 days ago GoogleReviewsStore.php 5 days ago Helper.php 5 days ago Pdf_Thumbnail_Handler.php 5 days ago PermalinkHelper.php 11 months ago View_Count_Display.php 1 month ago
Pdf_Thumbnail_Handler.php
527 lines
1 <?php
2
3 namespace EmbedPress\Includes\Classes;
4
5 (defined('ABSPATH')) or die("No direct script access allowed.");
6
7 /**
8 * Handles PDF thumbnail generation and upload via AJAX.
9 * Extracted from Embedpress_Pdf_Gallery to avoid Elementor dependency.
10 */
11 class Pdf_Thumbnail_Handler
12 {
13 /**
14 * Attachment meta recording the last failed poster attempt.
15 */
16 const FAILED_META = '_ep_pdf_thumbnail_failed';
17
18 /**
19 * Transient caching whether this host's Imagick can rasterise PDFs.
20 */
21 const SUPPORT_TRANSIENT = 'embedpress_pdf_rasterise_support';
22
23 /**
24 * How long to wait before retrying a failed poster generation.
25 */
26 const RETRY_AFTER = WEEK_IN_SECONDS;
27
28 /**
29 * Cron hook that generates a poster out of band.
30 */
31 const BACKFILL_HOOK = 'embedpress_generate_pdf_poster';
32
33 /**
34 * Attachment meta marking a backfill as already queued.
35 */
36 const QUEUED_META = '_ep_pdf_thumbnail_queued';
37
38 /**
39 * Register the upload-time hook.
40 *
41 * Generating the poster once, when the PDF is uploaded, is what lets every
42 * render surface (Gutenberg block, Elementor widget, shortcode, gallery,
43 * lightbox) just read a cached image instead of each solving the problem
44 * itself. Before this, nothing generated a poster at upload: the front end
45 * fell back to rendering page 1 with PDF.js in the visitor's browser on
46 * every page load — measured at 18 requests / 19.16MB / ~7s for a single
47 * 18MB PDF, with no cache. See FB #84167.
48 *
49 * @return void
50 */
51 public static function init()
52 {
53 // Priority 20: after WP has built its own attachment metadata (which
54 // already rasterises page 1 for PDFs on most hosts), so the common case
55 // is a no-op and we only do work when WP produced nothing usable.
56 add_filter('wp_generate_attachment_metadata', [__CLASS__, 'ensure_pdf_poster'], 20, 2);
57
58 // Backfill for PDFs that predate upload-time generation. Runs out of
59 // band so no visitor ever waits on a rasterise.
60 add_action(self::BACKFILL_HOOK, [__CLASS__, 'generate_poster_attachment']);
61 }
62
63 /**
64 * Ensure an uploaded PDF has a usable poster image.
65 *
66 * Hooked to wp_generate_attachment_metadata, so it runs once per upload
67 * rather than per page view. Returns $metadata untouched in every case —
68 * this only ever adds a cached thumbnail alongside it, and must never
69 * interfere with WP's own metadata.
70 *
71 * @param array $metadata Attachment metadata as built by WordPress.
72 * @param int $attachment_id Attachment being processed.
73 * @return array The unmodified metadata.
74 */
75 public static function ensure_pdf_poster($metadata, $attachment_id)
76 {
77 if ('application/pdf' !== get_post_mime_type($attachment_id)) {
78 return $metadata;
79 }
80
81 // WordPress already rasterised page 1 (Imagick present, PDF readable).
82 // That is the normal path and needs nothing from us.
83 if (!empty($metadata['sizes'])) {
84 return $metadata;
85 }
86
87 // Already generated on a previous run.
88 if (get_post_meta($attachment_id, '_ep_pdf_thumbnail_id', true)) {
89 return $metadata;
90 }
91
92 self::generate_poster_attachment($attachment_id);
93
94 return $metadata;
95 }
96
97 /**
98 * Return an existing poster attachment ID, generating one if needed.
99 *
100 * The upload-time hook only covers PDFs uploaded after this feature
101 * shipped. Every PDF already in the media library — and any whose upload
102 * ran on a host where rasterising was momentarily unavailable — would
103 * otherwise never get a poster, and every render surface would fall back
104 * to rendering page 1 with PDF.js in the visitor's browser. That fallback
105 * is what produced the torn/vertically-flipped preview in FB #83295 on
106 * large documents, so backfilling here fixes the reported bug for existing
107 * content rather than only for new uploads.
108 *
109 * Generation itself is NOT performed inline. Rasterising a large PDF takes
110 * seconds (measured at 8.2s for an 18MB/29-page document), and this runs
111 * during a front-end render, so doing it here would stall the visitor's
112 * page load — a worse symptom than the flipped preview it fixes. Instead
113 * the work is queued and this returns false for that first render, which
114 * simply keeps the existing canvas fallback for one page view.
115 *
116 * Queueing is attempted at most once per RETRY_AFTER per attachment, so a
117 * host that genuinely cannot rasterise PDFs does not schedule an event on
118 * every page view.
119 *
120 * @param int $attachment_id PDF attachment ID.
121 * @return int|false Poster attachment ID when one already exists, else false.
122 */
123 public static function get_or_create_poster($attachment_id)
124 {
125 $existing = get_post_meta($attachment_id, '_ep_pdf_thumbnail_id', true);
126 if ($existing && wp_get_attachment_url($existing)) {
127 return (int) $existing;
128 }
129
130 if ($existing) {
131 // Stale pointer — the poster attachment was deleted.
132 delete_post_meta($attachment_id, '_ep_pdf_thumbnail_id');
133 }
134
135 if ('application/pdf' !== get_post_mime_type($attachment_id)) {
136 return false;
137 }
138
139 self::queue_backfill($attachment_id);
140
141 return false;
142 }
143
144 /**
145 * Schedule out-of-band poster generation for an existing PDF.
146 *
147 * @param int $attachment_id PDF attachment ID.
148 * @return void
149 */
150 private static function queue_backfill($attachment_id)
151 {
152 $failed = get_post_meta($attachment_id, self::FAILED_META, true);
153 if (!empty($failed['time']) && (time() - (int) $failed['time']) < self::RETRY_AFTER) {
154 return;
155 }
156
157 $queued = (int) get_post_meta($attachment_id, self::QUEUED_META, true);
158 if ($queued && (time() - $queued) < HOUR_IN_SECONDS) {
159 return;
160 }
161
162 if (wp_next_scheduled(self::BACKFILL_HOOK, [$attachment_id])) {
163 return;
164 }
165
166 // Cheap enough to run inline (cached for a day) and avoids scheduling
167 // work that can never succeed on this host.
168 if (!self::can_rasterise_pdf()) {
169 return;
170 }
171
172 update_post_meta($attachment_id, self::QUEUED_META, time());
173 wp_schedule_single_event(time(), self::BACKFILL_HOOK, [$attachment_id]);
174 }
175
176 /**
177 * Rasterise page 1 of a PDF to a PNG attachment and cache its ID.
178 *
179 * This is the Imagick fallback that previously lived inline in
180 * ajax_generate_pdf_thumbnail(), lifted out so it can be called from the
181 * upload hook as well as the AJAX endpoint. Unlike the AJAX version it
182 * returns a value instead of emitting JSON, and performs no capability or
183 * nonce check — callers are responsible for authorisation. The upload hook
184 * is inherently authorised (the user just uploaded the file).
185 *
186 * @param int $attachment_id PDF attachment ID.
187 * @return int|false Thumbnail attachment ID, or false on failure.
188 */
189 public static function generate_poster_attachment($attachment_id)
190 {
191 if (!self::can_rasterise_pdf()) {
192 self::mark_failed($attachment_id, 'Imagick cannot decode PDF (missing Ghostscript delegate or blocked by policy.xml)');
193 return false;
194 }
195
196 $file_path = get_attached_file($attachment_id);
197 if (!$file_path || !file_exists($file_path)) {
198 self::mark_failed($attachment_id, 'Source file missing on disk');
199 return false;
200 }
201
202 try {
203 $imagick = new \Imagick();
204 $imagick->setResolution(200, 200);
205 $imagick->readImage($file_path . '[0]');
206 $imagick->setImageFormat('png');
207 $imagick->setImageAlphaChannel(\Imagick::ALPHACHANNEL_REMOVE);
208 $imagick->setImageBackgroundColor('white');
209 $imagick = self::flatten($imagick);
210 $imagick->thumbnailImage(800, 0);
211
212 $upload_dir = wp_upload_dir();
213 $base_name = pathinfo(basename($file_path), PATHINFO_FILENAME);
214 $thumb_file = 'pdf-thumb-' . sanitize_file_name($base_name) . '.png';
215 $thumb_path = trailingslashit($upload_dir['path']) . $thumb_file;
216
217 $imagick->writeImage($thumb_path);
218 $imagick->destroy();
219
220 $thumb_id = wp_insert_attachment([
221 'post_mime_type' => 'image/png',
222 'post_title' => sanitize_file_name($base_name) . ' - PDF Thumbnail',
223 'post_content' => '',
224 'post_status' => 'inherit',
225 ], $thumb_path);
226
227 if (is_wp_error($thumb_id)) {
228 self::mark_failed($attachment_id, 'wp_insert_attachment failed: ' . $thumb_id->get_error_message());
229 return false;
230 }
231
232 require_once ABSPATH . 'wp-admin/includes/image.php';
233 wp_update_attachment_metadata($thumb_id, wp_generate_attachment_metadata($thumb_id, $thumb_path));
234 update_post_meta($attachment_id, '_ep_pdf_thumbnail_id', $thumb_id);
235 delete_post_meta($attachment_id, self::FAILED_META);
236 delete_post_meta($attachment_id, self::QUEUED_META);
237
238 return $thumb_id;
239 } catch (\Exception $e) {
240 self::mark_failed($attachment_id, $e->getMessage());
241 return false;
242 }
243 }
244
245 /**
246 * Flatten the page onto its background.
247 *
248 * Imagick::flattenImages() is deprecated in ImageMagick 7 and emits a
249 * deprecation notice on some builds; mergeImageLayers() is the supported
250 * equivalent. Both are kept because the minimum supported Imagick version
251 * predates mergeImageLayers().
252 *
253 * @param \Imagick $imagick
254 * @return \Imagick
255 */
256 private static function flatten($imagick)
257 {
258 // setIteratorIndex is required before merging: readImage('file[0]') can
259 // leave the iterator past page 0 on some Ghostscript builds, which
260 // merges the wrong page.
261 if (method_exists($imagick, 'setIteratorIndex')) {
262 $imagick->setIteratorIndex(0);
263 }
264
265 if (method_exists($imagick, 'mergeImageLayers')) {
266 return $imagick->mergeImageLayers(\Imagick::LAYERMETHOD_FLATTEN);
267 }
268
269 return $imagick->flattenImages();
270 }
271
272 /**
273 * Whether this host's Imagick can actually rasterise a PDF.
274 *
275 * class_exists('Imagick') is not sufficient: rasterising a PDF needs the
276 * Ghostscript delegate, and many shared hosts ship Imagick but disable PDF
277 * decoding in policy.xml (the CVE-2018-16509 hardening). On those hosts
278 * readImage() throws NoDecodeDelegateForThisImageFormat, so every PDF fell
279 * through to the browser <canvas> renderer. Result is cached for a day —
280 * this shells out through Ghostscript and is far too slow to run per call.
281 *
282 * @return bool
283 */
284 public static function can_rasterise_pdf()
285 {
286 if (!class_exists('Imagick')) {
287 return false;
288 }
289
290 $cached = get_transient(self::SUPPORT_TRANSIENT);
291 if (false !== $cached) {
292 return 'yes' === $cached;
293 }
294
295 $supported = false;
296 try {
297 $formats = \Imagick::queryFormats('PDF');
298 if (!empty($formats)) {
299 // queryFormats() only reports the coder is compiled in, not that
300 // the delegate works, so actually rasterise a minimal PDF.
301 $probe = new \Imagick();
302 $probe->setResolution(18, 18);
303 $probe->readImageBlob(self::probe_pdf());
304 $supported = $probe->getImageWidth() > 0;
305 $probe->destroy();
306 }
307 } catch (\Exception $e) {
308 $supported = false;
309 }
310
311 set_transient(self::SUPPORT_TRANSIENT, $supported ? 'yes' : 'no', DAY_IN_SECONDS);
312
313 return $supported;
314 }
315
316 /**
317 * Smallest valid one-page PDF, used to probe the Ghostscript delegate.
318 *
319 * @return string
320 */
321 private static function probe_pdf()
322 {
323 return "%PDF-1.4\n"
324 . "1 0 obj<</Type/Catalog/Pages 2 0 R>>endobj\n"
325 . "2 0 obj<</Type/Pages/Kids[3 0 R]/Count 1>>endobj\n"
326 . "3 0 obj<</Type/Page/Parent 2 0 R/MediaBox[0 0 9 9]>>endobj\n"
327 . "trailer<</Root 1 0 R>>\n";
328 }
329
330 /**
331 * Record why poster generation failed.
332 *
333 * Stored on the attachment (not just logged) so the retry path can tell
334 * "never attempted" from "attempted and impossible on this host" and stop
335 * re-running an expensive rasterise on every page view.
336 *
337 * @param int $attachment_id
338 * @param string $reason
339 * @return void
340 */
341 private static function mark_failed($attachment_id, $reason)
342 {
343 update_post_meta($attachment_id, self::FAILED_META, [
344 'reason' => substr((string) $reason, 0, 500),
345 'time' => time(),
346 ]);
347
348 if (defined('WP_DEBUG') && WP_DEBUG) {
349 error_log(sprintf('EmbedPress: PDF poster generation failed for attachment %d — %s', $attachment_id, $reason));
350 }
351 }
352
353 /**
354 * AJAX handler: generate a thumbnail for a PDF attachment.
355 */
356 public static function ajax_generate_pdf_thumbnail()
357 {
358 check_ajax_referer('ep_pdf_gallery_nonce', 'nonce');
359
360 if (!current_user_can('upload_files')) {
361 wp_send_json_error(['message' => 'Insufficient permissions']);
362 }
363
364 $attachment_id = isset($_POST['attachment_id']) ? intval($_POST['attachment_id']) : 0;
365 if (!$attachment_id) {
366 wp_send_json_error(['message' => 'Invalid attachment ID']);
367 }
368
369 // Verify it's a PDF
370 if (get_post_mime_type($attachment_id) !== 'application/pdf') {
371 wp_send_json_error(['message' => 'Not a PDF file']);
372 }
373
374 // Check cached thumbnail first (stored as post meta on the PDF attachment)
375 $cached_thumb_id = get_post_meta($attachment_id, '_ep_pdf_thumbnail_id', true);
376 if ($cached_thumb_id) {
377 $cached_url = wp_get_attachment_url($cached_thumb_id);
378 if ($cached_url) {
379 wp_send_json_success([
380 'url' => $cached_url,
381 'id' => (int) $cached_thumb_id,
382 ]);
383 return;
384 }
385 // Cached thumbnail attachment no longer exists, clear stale meta
386 delete_post_meta($attachment_id, '_ep_pdf_thumbnail_id');
387 }
388
389 // Method 1: WordPress already generated a preview during upload
390 $thumb_src = wp_get_attachment_image_src($attachment_id, 'medium');
391 if ($thumb_src && !empty($thumb_src[0])) {
392 wp_send_json_success([
393 'url' => $thumb_src[0],
394 'id' => $attachment_id,
395 ]);
396 return;
397 }
398
399 // Method 2: Try regenerating attachment metadata (triggers WP PDF preview)
400 $file_path = get_attached_file($attachment_id);
401 if ($file_path && file_exists($file_path)) {
402 require_once ABSPATH . 'wp-admin/includes/image.php';
403 $metadata = wp_generate_attachment_metadata($attachment_id, $file_path);
404 if (!empty($metadata)) {
405 wp_update_attachment_metadata($attachment_id, $metadata);
406 $thumb_src = wp_get_attachment_image_src($attachment_id, 'medium');
407 if ($thumb_src && !empty($thumb_src[0])) {
408 wp_send_json_success([
409 'url' => $thumb_src[0],
410 'id' => $attachment_id,
411 ]);
412 return;
413 }
414 }
415 }
416
417 // Method 3: Direct Imagick rendering as last resort. Shares the single
418 // generator so hardening applied there (delegate probe, iterator reset,
419 // failure logging) covers this path too.
420 $thumb_id = self::generate_poster_attachment($attachment_id);
421 if ($thumb_id) {
422 wp_send_json_success([
423 'url' => wp_get_attachment_url($thumb_id),
424 'id' => $thumb_id,
425 ]);
426 return;
427 }
428
429 wp_send_json_error(['message' => 'Could not generate thumbnail']);
430 }
431
432 /**
433 * AJAX handler: receive a client-rendered thumbnail (base64 PNG) and save as WP attachment.
434 */
435 public static function ajax_upload_pdf_thumbnail()
436 {
437 check_ajax_referer('ep_pdf_gallery_nonce', 'nonce');
438
439 if (!current_user_can('upload_files')) {
440 wp_send_json_error(['message' => 'Insufficient permissions']);
441 }
442
443 $image_data = isset($_POST['image_data']) ? $_POST['image_data'] : '';
444 $pdf_url = isset($_POST['pdf_url']) ? esc_url_raw($_POST['pdf_url']) : '';
445 $file_name = isset($_POST['file_name']) ? sanitize_file_name($_POST['file_name']) : '';
446 $attachment_id = isset($_POST['attachment_id']) ? intval($_POST['attachment_id']) : 0;
447
448 if (empty($image_data) || empty($pdf_url)) {
449 wp_send_json_error(['message' => 'Missing data']);
450 }
451
452 // Check cached thumbnail for this PDF attachment
453 if ($attachment_id) {
454 $cached_thumb_id = get_post_meta($attachment_id, '_ep_pdf_thumbnail_id', true);
455 if ($cached_thumb_id) {
456 $cached_url = wp_get_attachment_url($cached_thumb_id);
457 if ($cached_url) {
458 wp_send_json_success([
459 'url' => $cached_url,
460 'id' => (int) $cached_thumb_id,
461 ]);
462 return;
463 }
464 // Stale meta, clear it
465 delete_post_meta($attachment_id, '_ep_pdf_thumbnail_id');
466 }
467 }
468
469 // Strip data-URI prefix
470 $image_data = preg_replace('/^data:image\/\w+;base64,/', '', $image_data);
471 $decoded = base64_decode($image_data);
472 if (!$decoded || strlen($decoded) < 8) {
473 wp_send_json_error(['message' => 'Invalid image data']);
474 }
475
476 // Detect MIME from raw bytes
477 $finfo = new \finfo(FILEINFO_MIME_TYPE);
478 $mime = $finfo->buffer($decoded);
479 if ($mime !== 'image/png' && $mime !== 'image/jpeg') {
480 wp_send_json_error(['message' => 'Invalid image format']);
481 }
482
483 $ext = ($mime === 'image/jpeg') ? '.jpg' : '.png';
484 $upload_dir = wp_upload_dir();
485 $base_name = $file_name ? pathinfo($file_name, PATHINFO_FILENAME) : md5($pdf_url);
486 $thumb_file = 'pdf-thumb-' . sanitize_file_name($base_name) . $ext;
487 $thumb_path = trailingslashit($upload_dir['path']) . $thumb_file;
488
489 // Avoid overwriting existing files
490 $counter = 0;
491 while (file_exists($thumb_path)) {
492 $counter++;
493 $thumb_file = 'pdf-thumb-' . sanitize_file_name($base_name) . '-' . $counter . $ext;
494 $thumb_path = trailingslashit($upload_dir['path']) . $thumb_file;
495 }
496
497 // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents
498 file_put_contents($thumb_path, $decoded);
499
500 $attachment = [
501 'post_mime_type' => $mime,
502 'post_title' => sanitize_file_name($base_name) . ' - PDF Thumbnail',
503 'post_content' => '',
504 'post_status' => 'inherit',
505 ];
506
507 $thumb_id = wp_insert_attachment($attachment, $thumb_path);
508 if (is_wp_error($thumb_id)) {
509 wp_send_json_error(['message' => 'Failed to create attachment']);
510 }
511
512 require_once ABSPATH . 'wp-admin/includes/image.php';
513 $meta = wp_generate_attachment_metadata($thumb_id, $thumb_path);
514 wp_update_attachment_metadata($thumb_id, $meta);
515
516 // Cache thumbnail ID on the PDF attachment for future lookups
517 if ($attachment_id) {
518 update_post_meta($attachment_id, '_ep_pdf_thumbnail_id', $thumb_id);
519 }
520
521 wp_send_json_success([
522 'url' => wp_get_attachment_url($thumb_id),
523 'id' => $thumb_id,
524 ]);
525 }
526 }
527