false, 'format' => 'pdf', 'selftest' => 'not installed or safe execution unavailable', ); if ( ! class_exists( '\\Imagick' ) || ! extension_loaded( 'imagick' ) || ! \Imagick::queryFormats( 'PDF' ) ) { return $out; } $image = new \Imagick(); try { $image->setResolution( 72, 72 ); $image->readImageBlob( PDF_Copy::sample() ); $ok = 1 === $image->getNumberImages() && 200 === $image->getImageWidth() && 100 === $image->getImageHeight(); $out['available'] = $ok; $out['selftest'] = $ok ? 'pdf page read passed' : 'output rejected'; } catch ( \Throwable $e ) { // Without Ghostscript (or where the host's policy forbids PDF) Imagick cannot read the page at all. $out['selftest'] = 'not installed or safe execution unavailable'; } finally { $image->clear(); } return $out; } /** * Draw one page as a private PNG preview. * * @param int $id The PDF attachment. * @param int $page The page, 1 to 500. * @param int $edge The longest side of the picture, 320 to 2,560 pixels. * @param bool $confirm The owner's confirmation. * @return array The draft. * @throws \RuntimeException With a reason code as its message when the step cannot be done. */ public static function prepare( int $id, int $page, int $edge, bool $confirm ): array { $im_reviewed_owner_lock = Reviewed_Store::enter(); try { if ( ! $confirm ) { throw new \RuntimeException( 'media_pdf_confirm' ); } if ( $page < 1 || $page > 500 || $edge < 320 || $edge > 2560 ) { throw new \RuntimeException( 'media_pdf_limits' ); } if ( ! self::available() ) { throw new \RuntimeException( 'media_pdf_backend' ); } return Media_Actions::locked( 'attachment:' . $id, static function () use ( $id, $page, $edge ) { $source = Media_Actions::source( $id, 'application/pdf' ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- Capability-checked PDF conversion uses bounded private local staging for the host image library; only validated generated output is published. if ( '%PDF-' !== file_get_contents( $source['path'], false, null, 0, 5 ) ) { throw new \RuntimeException( 'media_pdf_invalid' ); } $temp = Media_Actions::directory() . '/render-' . bin2hex( random_bytes( 16 ) ) . '.png'; $image = new \Imagick(); $limits = array(); // Respect tighter host limits and restore request-global settings afterwards. No TIME limit (1.9.0, // F-IM-36): ImageMagick counts it from the start of the PHP process, so in a web server's PHP worker // that has run longer than the limit every render failed; PHP's own time limit still applies. foreach ( array( 'MEMORY' => 134217728, 'MAP' => 134217728, 'DISK' => 268435456, 'AREA' => 24000000, 'THREAD' => 1, 'WIDTH' => 16384, 'HEIGHT' => 16384, 'LISTLENGTH' => 4, ) as $name => $max ) { $constant = '\\Imagick::RESOURCETYPE_' . $name; if ( ! defined( $constant ) ) { continue; } $type = constant( $constant ); $old = \Imagick::getResourceLimit( $type ); $limits[ $type ] = $old; \Imagick::setResourceLimit( $type, $old > 0 ? min( $old, $max ) : $max ); } try { $image->setResolution( 144, 144 ); $image->setOption( 'pdf:use-cropbox', 'true' ); // The source is an existing local attachment; only the bounded page selector is appended. $image->readImage( $source['path'] . '[' . ( $page - 1 ) . ']' ); if ( $image->getNumberImages() !== 1 || $image->getImageWidth() * $image->getImageHeight() > 24000000 ) { throw new \RuntimeException( 'media_pdf_limits' ); } $image->setImageBackgroundColor( 'white' ); $flat = $image->mergeImageLayers( \Imagick::LAYERMETHOD_FLATTEN ); $image->clear(); $image = $flat; if ( $image->getImageColorspace() !== \Imagick::COLORSPACE_SRGB ) { $image->transformImageColorspace( \Imagick::COLORSPACE_SRGB ); } if ( max( $image->getImageWidth(), $image->getImageHeight() ) > $edge ) { $image->thumbnailImage( $edge, $edge, true ); } $image->setImagePage( 0, 0, 0, 0 ); $image->stripImage(); $image->setImageFormat( 'png' ); if ( ! $image->writeImage( $temp ) ) { throw new \RuntimeException( 'media_pdf_backend' ); } // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_chmod -- Capability-checked PDF conversion uses bounded private local staging for the host image library; only validated generated output is published. @chmod( $temp, 0600 ); return Media_Draft::store( $id, 'convert', $temp, $source, array( 'operation' => 'pdf_page', 'page' => $page, 'maximum_edge' => $edge, 'background' => 'white', 'format' => 'png', ) ); } catch ( \ImagickException $e ) { throw new \RuntimeException( 'media_pdf_backend' ); } finally { $image->clear(); foreach ( $limits as $type => $limit ) { \Imagick::setResourceLimit( $type, $limit ); } if ( is_file( $temp ) ) { Reviewed_Store::wp_delete_file( $temp ); } } } ); } finally { Reviewed_Store::leave( $im_reviewed_owner_lock ); } } /** * Save the prepared page as a new PNG media item beside the PDF, with its caption, folders and collections. * * @param int $id The PDF attachment. * @param string $token The draft. * @param bool $confirm The owner's confirmation. * @return int The new attachment. * @throws \RuntimeException With a reason code as its message when the step cannot be done. */ public static function save( int $id, string $token, bool $confirm ): int { $im_reviewed_owner_lock = Reviewed_Store::enter(); try { if ( ! $confirm ) { throw new \RuntimeException( 'media_pdf_confirm' ); } return Media_Actions::locked( 'attachment:' . $id, static function () use ( $id, $token ) { $draft = Media_Draft::checked( $id, 'convert', $token ); if ( 'saved' === $draft['state'] ) { if ( 'attachment' !== get_post_type( (int) $draft['result'] ) ) { throw new \RuntimeException( 'media_draft_missing' ); } return (int) $draft['result']; } Media_Actions::unchanged( $id, $draft['source'] ); $path = Media_Draft::path( $draft ); require_once ABSPATH . 'wp-admin/includes/image.php'; $receipt = $id . ':' . get_current_user_id() . ':' . $token; // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key,WordPress.DB.SlowDBQuery.slow_db_query_meta_value -- Bounded administrator media listing or per-file upload receipt lookup; taxonomy/metadata selection is required for the requested folder/collection and retry identity. $existing = get_posts( array( 'post_type' => 'attachment', 'post_status' => 'inherit', 'posts_per_page' => 1, 'fields' => 'ids', // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key -- Bounded administrator media listing or per-file upload receipt lookup; taxonomy/metadata selection is required for the requested folder/collection and retry identity. 'meta_key' => '_afsd_im_pdf_receipt', // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_value -- Bounded administrator media listing or per-file upload receipt lookup; taxonomy/metadata selection is required for the requested folder/collection and retry identity. 'meta_value' => $receipt, ) ); $new = $existing ? (int) $existing[0] : 0; if ( $new && ! current_user_can( 'edit_post', $new ) ) { throw new \RuntimeException( 'media_action_access' ); } if ( ! $new ) { $uploads = wp_upload_dir(); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_is_writable -- Capability-checked PDF conversion uses bounded private local staging for the host image library; only validated generated output is published. if ( ! empty( $uploads['error'] ) || ! is_writable( $uploads['path'] ) ) { throw new \RuntimeException( 'media_action_upload' ); } $name = sanitize_file_name( pathinfo( $draft['source']['path'], PATHINFO_FILENAME ) . '-page-' . (int) $draft['recipe']['page'] . '.png' ); $dest = trailingslashit( $uploads['path'] ) . wp_unique_filename( $uploads['path'], $name ); // phpcs:ignore WordPress.WP.AlternativeFunctions.unlink_unlink -- Capability-checked PDF conversion uses bounded private local staging for the host image library; only validated generated output is published. if ( ! Reviewed_Store::copy( $path, $dest ) || hash_file( 'sha256', $dest ) !== $draft['image']['sha256'] ) { @Reviewed_Store::unlink( $dest ); throw new \RuntimeException( 'media_draft_write' ); } // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_chmod -- Capability-checked PDF conversion uses bounded private local staging for the host image library; only validated generated output is published. @chmod( $dest, 0644 ); $post = get_post( $id ); $new = Reviewed_Store::wp_insert_attachment( wp_slash( array( 'post_title' => $post->post_title . ' — page ' . (int) $draft['recipe']['page'], 'post_mime_type' => 'image/png', 'post_status' => 'inherit', 'post_excerpt' => $post->post_excerpt, 'meta_input' => array( '_afsd_im_pdf_receipt' => $receipt, Editor::SOURCE_META => $id, Editor::RECIPE_META => $draft['recipe'], ), ) ), $dest, 0, true ); if ( is_wp_error( $new ) || ! $new ) { Reviewed_Store::wp_delete_file( $dest ); throw new \RuntimeException( 'media_draft_write' ); } } $meta = Reviewed_Store::wp_generate_attachment_metadata( $new, get_attached_file( $new ) ); if ( ! is_array( $meta ) || empty( $meta['width'] ) || empty( $meta['height'] ) ) { throw new \RuntimeException( 'media_pdf_metadata' ); } Reviewed_Store::wp_update_attachment_metadata( $new, $meta ); foreach ( array( Library::FOLDER, Library::COLLECTION ) as $taxonomy ) { $terms = Library::term_ids( $id, $taxonomy ); $set = Reviewed_Store::wp_set_object_terms( $new, $terms, $taxonomy, false ); if ( is_wp_error( $set ) ) { throw new \RuntimeException( 'media_pdf_metadata' ); } } Media_Actions::unchanged( $id, $draft['source'] ); $draft['state'] = 'saved'; $draft['result'] = (int) $new; Media_Draft::update( $id, 'convert', $draft ); return (int) $new; } ); } finally { Reviewed_Store::leave( $im_reviewed_owner_lock ); } } }