'/printer', 'small' => '/ebook', ); /** The largest PDF read (64 MB, as the other PDF and media tools) and the most pages. */ const MAX_BYTES = 67108864; const MAX_PAGES = 300; /** Ghostscript's time limit for one step. */ const SECONDS = 90; /** The largest average difference (of 255, per colour) a page may show at 36 dpi. */ const DIFFERENCE = 10; /** A pixel changed by more than this (of 255) in a colour counts as changed; at most this share of a page's pixels may be. */ const STRONG = 128; const STRONG_SHARE = 0.0002; /** Parts of a PDF a rewritten copy could lose; such a PDF is kept as it is. */ const FEATURES = array( 'encrypted' => '/\/Encrypt\b/', // A signature is kept in a form field: it comes first, so a signed PDF gets its own reason. 'signature' => '/\/ByteRange\b/', 'form' => '/\/AcroForm\b/', 'attachment' => '/\/EmbeddedFiles?\b|\/FileAttachment\b/', 'script' => '/\/JavaScript\b|\/JS\b/', 'layers' => '/\/OCProperties\b/', 'multimedia' => '/\/RichMedia\b|\/Movie\b|\/Sound\b|\/3D\b|\/Screen\b/', ); /** * Whether this host makes PDF copies (its Ghostscript passed the self-test). * * @return bool */ public static function available(): bool { return ! empty( Compression::capabilities()['ghostscript-pdf']['available'] ); } /** * The self-test for the encoder list: a copy of a one-page PDF, read back. * * @return array{available:bool,format:string,selftest:string} */ public static function selftest(): array { $out = array( 'available' => false, 'format' => 'pdf', 'selftest' => 'not installed or safe execution unavailable', ); if ( ! Ghostscript::path() || ! function_exists( 'imagecreatefrompng' ) ) { return $out; } $dir = ''; try { $dir = self::work(); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents -- a fixed one-page test PDF in Image Manager's private folder. Reviewed_Store::file_put_contents( $dir . '/in.pdf', self::sample() ); self::write( $dir . '/in.pdf', $dir . '/out.pdf', 'quality', $dir, self::MAX_BYTES ); $ok = 1 === self::pages( $dir . '/out.pdf', $dir ) && 'Image Manager' === self::text( $dir . '/out.pdf', $dir ) && self::alike( self::compare( self::render( $dir . '/in.pdf', $dir, 'a' ), self::render( $dir . '/out.pdf', $dir, 'b' ) ) ); $out['available'] = $ok; $out['selftest'] = $ok ? 'pdf copy passed' : 'output rejected'; } catch ( \Throwable $e ) { $out['selftest'] = 'not installed or safe execution unavailable'; } finally { if ( '' !== $dir ) { self::clean( $dir ); } } return $out; } /** * A one-page PDF with a line of text (the self-tests' input; no customer media). * * @return string */ public static function sample(): string { $stream = 'BT /F1 18 Tf 20 40 Td (Image Manager) Tj ET'; $objects = array( '<< /Type /Catalog /Pages 2 0 R >>', '<< /Type /Pages /Kids [3 0 R] /Count 1 >>', '<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 100] /Resources << /Font << /F1 4 0 R >> >> /Contents 5 0 R >>', '<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>', '<< /Length ' . strlen( $stream ) . " >>\nstream\n" . $stream . "\nendstream", ); $pdf = "%PDF-1.4\n"; $offsets = array(); foreach ( $objects as $n => $body ) { $offsets[] = strlen( $pdf ); $pdf .= ( $n + 1 ) . " 0 obj\n" . $body . "\nendobj\n"; } $xref = strlen( $pdf ); $pdf .= "xref\n0 " . ( count( $objects ) + 1 ) . "\n0000000000 65535 f \n"; foreach ( $offsets as $offset ) { $pdf .= sprintf( "%010d 00000 n \n", $offset ); } return $pdf . 'trailer << /Size ' . ( count( $objects ) + 1 ) . " /Root 1 0 R >>\nstartxref\n" . $xref . "\n%%EOF\n"; } /** * What a PDF holds, read from its own syntax (its objects without their stream data) and from its compressed object * streams (where many PDF writers keep the catalog, forms and annotations): the parts a copy could lose, and the * links, comments and titles to compare. Stream data (pictures, fonts, page drawing) is not read (1.9.0, F-IM-35): * it can hold any bytes, and a short name such as /JS turned up by chance in the compressed pictures of about half * of the photo PDFs tried. * * @param string $bytes The PDF. * @return array{features:string[],links:int,comments:int,titles:int} */ public static function scan( string $bytes ): array { $all = self::syntax( $bytes ); $offset = 0; for ( $n = 0; $n < 5000 && preg_match( '/\/Type\s*\/ObjStm\b/', $bytes, $m, PREG_OFFSET_CAPTURE, $offset ); $n++ ) { $at = (int) $m[0][1]; $start = strpos( $bytes, 'stream', $at ); if ( false === $start ) { break; } $dict = substr( $bytes, max( 0, $at - 1024 ), $start - max( 0, $at - 1024 ) ); $start = $start + 6 + ( "\r\n" === substr( $bytes, $start + 6, 2 ) ? 2 : 1 ); $end = strpos( $bytes, 'endstream', $start ); if ( false === $end ) { break; } if ( false !== strpos( substr( $dict, (int) strrpos( $dict, '<<' ) ), '/FlateDecode' ) ) { $inflated = @gzuncompress( rtrim( substr( $bytes, $start, $end - $start ), "\r\n" ), self::MAX_BYTES ); if ( is_string( $inflated ) ) { $all .= "\n" . $inflated; } } $offset = $end; } $features = array(); foreach ( self::FEATURES as $key => $pattern ) { if ( preg_match( $pattern, $all ) ) { $features[] = $key; } } return array( 'features' => $features, 'links' => (int) preg_match_all( '/\/Subtype\s*\/Link\b/', $all ), 'comments' => (int) preg_match_all( '/\/Subtype\s*\/(?:Text|FreeText|Highlight|Underline|StrikeOut|Squiggly|Ink|Stamp|Square|Circle|Line|Polygon|PolyLine|Caret)\b/', $all ), 'titles' => (int) preg_match_all( '/\/Title\s*[(<]/', $all ), ); } /** * A PDF's own syntax: its bytes with the data of every stream left out (each stream keyword and its dictionary * kept, so a stream's /Type, /Subtype or /JS key is still read). * * @param string $bytes The PDF. * @return string The PDF without its stream data. */ private static function syntax( string $bytes ): string { $out = ''; $pos = 0; $len = strlen( $bytes ); while ( $pos < $len ) { $at = strpos( $bytes, 'stream', $pos ); if ( false === $at ) { break; } $after = $at + 6; // The keyword itself (not the end of endstream), followed by its end of line; the data starts after it. if ( 'end' === substr( $bytes, max( 0, $at - 3 ), 3 ) || ! in_array( substr( $bytes, $after, 1 ), array( "\r", "\n" ), true ) ) { $out .= substr( $bytes, $pos, $after - $pos ); $pos = $after; continue; } $data = $after + ( "\r\n" === substr( $bytes, $after, 2 ) ? 2 : 1 ); $end = strpos( $bytes, 'endstream', $data ); $out .= substr( $bytes, $pos, $data - $pos ) . "\n"; if ( false === $end ) { return $out; } $pos = $end; } return $out . substr( $bytes, $pos ); } /** * Prepare a private preview of the smaller copy. * * @param int $id The PDF attachment. * @param string $preset quality or small. * @param bool $confirm The owner's confirmation. * @return array The draft. * @throws \RuntimeException With a media_pdf_ or media_ reason code when the copy cannot be made or does not pass its * checks. */ public static function prepare( int $id, string $preset, bool $confirm ): array { $im_reviewed_owner_lock = Reviewed_Store::enter(); try { if ( ! $confirm ) { throw new \RuntimeException( 'media_pdf_confirm' ); } if ( ! isset( self::PRESETS[ $preset ] ) ) { throw new \RuntimeException( 'media_pdf_copy_preset' ); } if ( ! self::available() ) { throw new \RuntimeException( 'media_pdf_copy_backend' ); } return Media_Actions::locked( 'attachment:' . $id, static function () use ( $id, $preset ) { $source = Media_Actions::source( $id, 'application/pdf' ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- the capability-checked local PDF, read to check what it holds before Ghostscript copies it. $bytes = (string) file_get_contents( $source['path'] ); if ( ! str_starts_with( $bytes, '%PDF-' ) ) { throw new \RuntimeException( 'media_pdf_invalid' ); } $before = self::scan( $bytes ); if ( $before['features'] ) { // A reason code from self::FEATURES; escaping leaves it as it is. throw new \RuntimeException( esc_html( 'media_pdf_copy_' . $before['features'][0] ) ); } $dir = self::work(); try { $in = $dir . '/in.pdf'; $out = $dir . '/out.pdf'; if ( ! Reviewed_Store::copy( $source['path'], $in ) || hash_file( 'sha256', $in ) !== $source['sha256'] ) { throw new \RuntimeException( 'media_draft_write' ); } $pages = self::pages( $in, $dir ); if ( $pages < 1 ) { throw new \RuntimeException( 'media_pdf_invalid' ); } if ( $pages > self::MAX_PAGES ) { throw new \RuntimeException( 'media_pdf_copy_limits' ); } $saving = max( 1, (int) Settings::get()['min_saving'] ); $limit = (int) floor( $source['bytes'] * ( 100 - $saving ) / 100 ); try { self::write( $in, $out, $preset, $dir, $source['bytes'] ); } catch ( \RuntimeException $e ) { throw new \RuntimeException( 'ghostscript_output_limit' === $e->getMessage() ? 'media_pdf_copy_no_saving' : 'media_pdf_copy_backend' ); } clearstatcache( true, $out ); $size = is_file( $out ) ? (int) filesize( $out ) : 0; // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- Ghostscript's copy in Image Manager's private folder, read to check it. $copy = $size ? (string) file_get_contents( $out ) : ''; if ( ! str_starts_with( $copy, '%PDF-' ) ) { throw new \RuntimeException( 'media_pdf_copy_backend' ); } if ( $size > $limit ) { throw new \RuntimeException( 'media_pdf_copy_no_saving' ); } $after = self::scan( $copy ); $diff = null; if ( self::pages( $out, $dir ) === $pages && self::text( $out, $dir ) === self::text( $in, $dir ) && array( $before['links'], $before['comments'], $before['titles'] ) === array( $after['links'], $after['comments'], $after['titles'] ) ) { $diff = self::compare( self::render( $in, $dir, 'a' ), self::render( $out, $dir, 'b' ) ); } if ( ! self::alike( $diff ) ) { throw new \RuntimeException( 'media_pdf_copy_changed' ); } return Media_Draft::store( $id, 'compress', $out, $source, array( 'operation' => 'pdf_copy', 'preset' => $preset, 'pages' => $pages, 'bytes' => $source['bytes'], 'copy_bytes' => $size, 'difference' => round( $diff['mean'], 2 ), 'changed' => round( $diff['strong'], 6 ), 'links' => $before['links'], 'comments' => $before['comments'], ) ); } catch ( \RuntimeException $e ) { // Ghostscript's own limits (time, output) while it counts, reads or draws the pages. if ( str_starts_with( $e->getMessage(), 'ghostscript_' ) ) { throw new \RuntimeException( 'media_pdf_copy_backend' ); } throw $e; } finally { self::clean( $dir ); } } ); } finally { Reviewed_Store::leave( $im_reviewed_owner_lock ); } } /** * Save the prepared copy as a new media item beside the PDF. * * @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 media_ reason code when the copy cannot be saved. */ 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, 'compress', $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 ); $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 -- one upload receipt lookup, so a repeated save returns the copy it made. $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 -- one upload receipt lookup, so a repeated save returns the copy it made. 'meta_key' => '_afsd_im_pdf_copy_receipt', // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_value -- one upload receipt lookup, so a repeated save returns the copy it made. '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 -- the uploads folder WordPress names for this month, checked before the copy is written there. if ( ! empty( $uploads['error'] ) || ! is_writable( $uploads['path'] ) ) { throw new \RuntimeException( 'media_action_upload' ); } $name = sanitize_file_name( pathinfo( $draft['source']['path'], PATHINFO_FILENAME ) . '-smaller.pdf' ); $dest = trailingslashit( $uploads['path'] ) . wp_unique_filename( $uploads['path'], $name ); if ( ! Reviewed_Store::copy( $path, $dest ) || hash_file( 'sha256', $dest ) !== $draft['image']['sha256'] ) { // phpcs:ignore WordPress.WP.AlternativeFunctions.unlink_unlink -- removes only the copy this step just wrote, when it does not match the preview. @Reviewed_Store::unlink( $dest ); throw new \RuntimeException( 'media_draft_write' ); } // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_chmod -- the new upload gets the uploads folder's usual permissions. @chmod( $dest, 0644 ); $post = get_post( $id ); /* translators: %s: the PDF's title. */ $title = sprintf( __( '%s (smaller copy)', 'affstackdev-image-manager' ), $post->post_title ); $new = Reviewed_Store::wp_insert_attachment( wp_slash( array( 'post_title' => $title, 'post_mime_type' => 'application/pdf', 'post_status' => 'inherit', 'post_excerpt' => $post->post_excerpt, 'post_content' => $post->post_content, 'meta_input' => array( '_afsd_im_pdf_copy_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' ); } } require_once ABSPATH . 'wp-admin/includes/image.php'; // WordPress's own PDF preview pictures, where its image editor reads PDF; the copy is saved either way. $meta = Reviewed_Store::wp_generate_attachment_metadata( $new, get_attached_file( $new ) ); if ( is_array( $meta ) ) { Reviewed_Store::wp_update_attachment_metadata( $new, $meta ); } foreach ( array( Library::FOLDER, Library::COLLECTION ) as $taxonomy ) { $set = Reviewed_Store::wp_set_object_terms( $new, Library::term_ids( $id, $taxonomy ), $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, 'compress', $draft ); return (int) $new; } ); } finally { Reviewed_Store::leave( $im_reviewed_owner_lock ); } } /** * The draft file's record: type, hash, size and pages. * * @param string $path The copy. * @return array{mime:string,sha256:string,bytes:int,pages:int} * @throws \RuntimeException With media_draft_write when the file is not a PDF. */ public static function info( string $path ): array { clearstatcache( true, $path ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- the first bytes of a PDF in Image Manager's private folder, read to check its type. if ( ! is_file( $path ) || '%PDF-' !== file_get_contents( $path, false, null, 0, 5 ) ) { throw new \RuntimeException( 'media_draft_write' ); } $dir = self::work(); try { $pages = self::pages( $path, $dir ); } finally { self::clean( $dir ); } return array( 'mime' => 'application/pdf', 'sha256' => hash_file( 'sha256', $path ), 'bytes' => (int) filesize( $path ), 'pages' => $pages, ); } /** * A new private working folder. * * @return string * @throws \RuntimeException With media_action_storage when the folder cannot be made. */ private static function work(): string { $dir = Media_Actions::directory() . '/pdf-' . bin2hex( random_bytes( 12 ) ); if ( ! wp_mkdir_p( $dir ) ) { throw new \RuntimeException( 'media_action_storage' ); } // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_chmod -- Image Manager's private working folder for one PDF step. @chmod( $dir, 0700 ); return $dir; } /** * Remove a working folder and the files Image Manager and Ghostscript put in it. * * @param string $dir The folder. * @return void */ private static function clean( string $dir ): void { if ( ! preg_match( '/\/pdf-[a-f0-9]{24}$/D', $dir ) || ! is_dir( $dir ) || is_link( $dir ) ) { return; } foreach ( (array) glob( $dir . '/*' ) as $file ) { if ( is_string( $file ) && is_file( $file ) && ! is_link( $file ) ) { Reviewed_Store::wp_delete_file( $file ); } } // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_rmdir -- the empty private working folder of one PDF step. @rmdir( $dir ); } /** * Ghostscript's pdfwrite: the copy with the chosen setting. * * @param string $in The PDF. * @param string $out The copy. * @param string $preset quality or small. * @param string $dir The working folder. * @param int $limit The copy's largest size while it is written. * @return void * @throws \RuntimeException With media_pdf_copy_backend when Ghostscript fails, or a ghostscript_ reason code for its * limits. */ private static function write( string $in, string $out, string $preset, string $dir, int $limit ): void { $args = array( '-q', '-dSAFER', '-dBATCH', '-dNOPAUSE', '-dNOPROMPT', '-sDEVICE=pdfwrite', '-dCompatibilityLevel=1.5', '-dPDFSETTINGS=' . self::PRESETS[ $preset ], '-dDetectDuplicateImages=true', '-dAutoRotatePages=/None' ); if ( 'quality' === $preset ) { $args[] = '-sColorConversionStrategy=LeaveColorUnchanged'; } $r = Ghostscript::run( array_merge( $args, array( '-sOutputFile=' . $out, $in ) ), $dir, self::SECONDS, $out, $limit ); if ( 0 !== $r['exit'] ) { throw new \RuntimeException( 'media_pdf_copy_backend' ); } } /** * The number of pages Ghostscript reads in a PDF (0 when it reads none). * * @param string $file A PDF in the working folder (a path with no PostScript string characters). * @param string $dir The working folder. * @return int * @throws \RuntimeException With media_action_storage for a path PostScript could misread. */ private static function pages( string $file, string $dir ): int { if ( preg_match( '/[()\\\\%\s]/', $file ) ) { throw new \RuntimeException( 'media_action_storage' ); } $r = Ghostscript::run( array( '-q', '-dNODISPLAY', '-dSAFER', '--permit-file-read=' . $file, '-c', '(' . $file . ') (r) file runpdfbegin pdfpagecount = quit' ), $dir, self::SECONDS ); return 0 === $r['exit'] && preg_match( '/^\s*(\d+)\s*$/D', $r['out'], $m ) ? (int) $m[1] : 0; } /** * A PDF's text as Ghostscript reads it, with runs of white space as one space. * * @param string $file The PDF. * @param string $dir The working folder. * @return string */ private static function text( string $file, string $dir ): string { $txt = $dir . '/text-' . bin2hex( random_bytes( 6 ) ) . '.txt'; Ghostscript::run( array( '-q', '-dSAFER', '-dBATCH', '-dNOPAUSE', '-sDEVICE=txtwrite', '-sOutputFile=' . $txt, $file ), $dir, self::SECONDS, $txt, self::MAX_BYTES ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- Ghostscript's text of a PDF in Image Manager's private folder. $text = is_file( $txt ) ? (string) file_get_contents( $txt ) : ''; Reviewed_Store::wp_delete_file( $txt ); return trim( (string) preg_replace( '/\s+/u', ' ', $text ) ); } /** * Every page drawn at 36 dpi, as PNG files in the working folder. * * @param string $file The PDF. * @param string $dir The working folder. * @param string $prefix The files' prefix. * @return string[] */ private static function render( string $file, string $dir, string $prefix ): array { Ghostscript::run( array( '-q', '-dSAFER', '-dBATCH', '-dNOPAUSE', '-sDEVICE=png16m', '-r36', '-sOutputFile=' . $dir . '/' . $prefix . '-%03d.png', $file ), $dir, self::SECONDS ); $pages = glob( $dir . '/' . $prefix . '-*.png' ); return is_array( $pages ) ? $pages : array(); } /** * Whether a comparison shows the pages alike (within both limits). * * @param array{mean:float,strong:float}|null $diff The comparison. * @return bool */ private static function alike( ?array $diff ): bool { return null !== $diff && $diff['mean'] <= self::DIFFERENCE && $diff['strong'] <= self::STRONG_SHARE; } /** * Two sets of pages compared, the worst page counting: its average difference (of 255, per colour) and its share of * pixels changed by more than STRONG in a colour; null when their number or a page's size differs. * * @param string[] $a Pages of the PDF. * @param string[] $b Pages of the copy. * @return array{mean:float,strong:float}|null */ private static function compare( array $a, array $b ): ?array { if ( ! $a || count( $a ) !== count( $b ) ) { return null; } $worst = 0.0; $strong = 0.0; foreach ( $a as $k => $file ) { $x = @imagecreatefrompng( $file ); $y = @imagecreatefrompng( $b[ $k ] ); if ( ! $x || ! $y || imagesx( $x ) !== imagesx( $y ) || imagesy( $x ) !== imagesy( $y ) ) { return null; } $w = imagesx( $x ); $h = imagesy( $y ); $sum = 0; $many = 0; for ( $j = 0; $j < $h; $j++ ) { for ( $i = 0; $i < $w; $i++ ) { $p = imagecolorat( $x, $i, $j ); $q = imagecolorat( $y, $i, $j ); $r = abs( ( ( $p >> 16 ) & 255 ) - ( ( $q >> 16 ) & 255 ) ); $g = abs( ( ( $p >> 8 ) & 255 ) - ( ( $q >> 8 ) & 255 ) ); $b2 = abs( ( $p & 255 ) - ( $q & 255 ) ); $sum += $r + $g + $b2; if ( max( $r, $g, $b2 ) > self::STRONG ) { ++$many; } } } imagedestroy( $x ); imagedestroy( $y ); $worst = max( $worst, $sum / max( 1, $w * $h * 3 ) ); $strong = max( $strong, $many / max( 1, $w * $h ) ); } return array( 'mean' => $worst, 'strong' => $strong, ); } }