esc_url( home_url( '/' ) ) ) ); } /** * Set the image property for all templates that display a single post object. * * Hooks into the "template_redirect" action. * * @since Duotone v2.0 */ public static function setup_single_post_template() { global $wp_query; if ( 0 == $wp_query->post_count ) return; if ( is_archive() || is_search() || is_404() ) return; self::$image = self::get_image_data(); self::set_themecolors(); add_action( 'wp_head', array( __class__, 'styles' ) ); } /** * Return an image tag for display in archive templates. * * @since Duotone v2.0 */ public static function get_archive_image() { $url = self::get_the_image_url_for_display(); $url = apply_filters( 'duotone_archive_image_url', $url, self::$image ); // VideoPress images don't support ImgPress if ( self::is_videopress_image( $url ) ) { $url = remove_query_arg( 'w', $url ); $url = remove_query_arg( 'h', $url ); } if ( ! empty( $url ) ) return ''; return ''; } /** * Return an image tag for display in templates that display a single post object. * * @since Duotone v2.0 */ public static function get_singular_image() { $url = self::get_the_image_url_for_display(); $url = apply_filters( 'duotone_singular_image_url', $url, self::$image ); if ( ! empty( $url ) ) { if ( self::is_videopress_image( $url ) ) { // It's a VideoPress image - replace with the video $html = self::get_videopress_html( $url ); if ( ! empty( $html ) ) return $html; } return ''; } return ''; } /** * Markup permitted in the return value of get_archive_image(). * * That method builds a single img tag, so the list is deliberately narrow. * Anything else it might return in future is stripped rather than trusted. * * @since Duotone v2.0 */ public static function get_allowed_archive_image_html() { return array( 'img' => array( 'class' => array(), 'src' => array(), 'alt' => array(), ), ); } /** * Markup permitted in the return value of get_singular_image(). * * Wider than the archive list because the VideoPress branch returns a style * block followed by the output of the [wpvideo] shortcode. The post list * already covers img, div, span, a and video; style, iframe and source are * added for the embed itself. script is deliberately not permitted. * * @since Duotone v2.0 */ public static function get_allowed_singular_image_html() { $allowed = wp_kses_allowed_html( 'post' ); $allowed['style'] = array( 'type' => array(), 'media' => array(), ); $allowed['iframe'] = array( 'src' => array(), 'width' => array(), 'height' => array(), 'title' => array(), 'class' => array(), 'id' => array(), 'style' => array(), 'name' => array(), 'loading' => array(), 'frameborder' => array(), 'allow' => array(), 'allowfullscreen' => array(), 'sandbox' => array(), ); $allowed['source'] = array( 'src' => array(), 'type' => array(), 'media' => array(), ); return $allowed; } public static function is_videopress_image( $url ) { $vp = strpos( $url, 'http://videos.videopress.com/' ); $vps = strpos( $url, 'https://videos.files.wordpress.com/' ); return ( 0 === $vp || 0 === $vps ); } public static function get_videopress_html( $url ) { $matches = array(); preg_match( '#^http(s)?://videos.(files.word|video)press.com/([[:alnum:]]+)/#i', $url, $matches ); if ( empty( $matches[3] ) ) return ''; $guid = $matches[3] ; $style = ''; return $style . do_shortcode( "[wpvideo $guid]" ); } /** * Get the image url for display in a template. * * Since the url is being plucked from the post_content we * need to ensure that it is only displayed where appropriate. * * @since Duotone v2.0 */ private static function get_the_image_url_for_display() { if ( post_password_required() ) return ''; if ( is_home() || is_singular() ) { $image = self::$image; $size = 'duotone_singular'; } else { $image = self::get_image_data( 0, false ); $size = 'duotone_archive'; } $url = ''; if ( ! empty( $image['image_id'] ) ) { $src = wp_get_attachment_image_src( $image['image_id'], $size ); if ( isset( $src[0] ) ) $url = $src[0]; } if ( empty( $url ) && ! empty( $image['url'] ) ) $url = $image['url']; return $url; } /** * Remove the first image from the post_content. * * Hooks into the "the_content" filter as early as possible. */ public static function content_setup( $entry ) { if ( is_feed() ) return $entry; if ( 0 != get_query_var( 'page' ) ) return $entry; /* Remove first image tag. */ $count = 0; $entry = preg_replace( '/]*src=(\"|\').+?(\1)[^>]*\/*>/','', $entry, 1, $count ); // If no image was removed, remove the first video instead if ( ! $count ) { $regex = get_shortcode_regex( array( 'wpvideo', 'videopress' ) ); $entry = preg_replace( '/'. $regex .'/s','', $entry, 1, $count ); } $entry = wp_kses_post( $entry ); return $entry; } /** * Adjust the main query. * * Show only a single post where is_home() returns true. * Show 27 posts in archive and search results. * * Hooks into the "request" action. * * @since Duotone v2.0 */ public static function modify_request( $request ) { $q = new WP_Query(); $q->parse_query( $request ); if ( $q->is_home() ) { $request['posts_per_page'] = 1; $sticky_posts = get_option( 'sticky_posts' ); $request['post__not_in'] = is_array( $sticky_posts ) ? $sticky_posts : array(); } else if ( $q->is_archive() || $q->is_search() ) { $request['posts_per_page'] = 27; } return $request; } /** * @since Duotone v2.0 */ public static function styles() { extract( self::$image ); $background_color = get_background_color(); if ( empty( $background_color ) && isset( $background['+2'] ) ) $background_color = $background['+2']; ?> ]*src=(\"|\')(.+?)(\1)[^>]*\/*>/i', $content, $matches ) ) { $url = self::normalize_scraped_url( $matches[2] ); $scheme = wp_parse_url( $url, PHP_URL_SCHEME ); if ( empty( $scheme ) || in_array( strtolower( $scheme ), array( 'http', 'https' ), true ) ) return $url; } if( preg_match( '/\[(wpvideo|videopress) ([[:alnum:]]+)/', $content, $matches ) && function_exists( 'video_image_url_by_guid' ) ) return video_image_url_by_guid( $matches[2] ); return ''; } /** * Tidy up a url taken from a post's markup. * * @param string $url Raw attribute value. * @return string */ private static function normalize_scraped_url( $url ) { return trim( html_entity_decode( (string) $url, ENT_QUOTES ) ); } /** * Host and port of a url, as "host" or "host:port". * * Default ports are dropped so that they compare equal to a url * that leaves them implicit. * * @param string $url Url to read. * @return string Lowercased origin, or an empty string when there is no host. */ private static function get_url_origin( $url ) { $host = wp_parse_url( $url, PHP_URL_HOST ); if ( empty( $host ) ) return ''; $origin = strtolower( $host ); $port = absint( wp_parse_url( $url, PHP_URL_PORT ) ); if ( empty( $port ) ) return $origin; $scheme = strtolower( (string) wp_parse_url( $url, PHP_URL_SCHEME ) ); if ( ( 'http' === $scheme && 80 === $port ) || ( 'https' === $scheme && 443 === $port ) ) return $origin; return $origin . ':' . $port; } /** * Origins that serve this site and its uploads. * * The port is part of the comparison so that installs running on a * non-default port keep working, without other ports on the same host * being treated as local. * * @return array Lowercased origins. */ private static function get_local_origins() { $origins = array(); $uploads = wp_get_upload_dir(); $urls = array( home_url(), site_url() ); if ( ! empty( $uploads['baseurl'] ) ) $urls[] = $uploads['baseurl']; foreach ( $urls as $url ) { $origin = self::get_url_origin( $url ); if ( '' !== $origin ) $origins[] = $origin; } return array_unique( $origins ); } /** * Whether a url is served by this site. * * Root relative urls always are. Absolute urls must use http or https * and must match one of the origins this site is served from. * * @param string $url Url to test. * @return bool */ private static function is_local_url( $url ) { $url = trim( (string) $url ); if ( '' === $url ) return false; if ( 0 === strpos( $url, '/' ) && 0 !== strpos( $url, '//' ) ) return true; $scheme = wp_parse_url( $url, PHP_URL_SCHEME ); if ( ! empty( $scheme ) && ! in_array( strtolower( $scheme ), array( 'http', 'https' ), true ) ) return false; $origin = self::get_url_origin( $url ); if ( '' === $origin ) return false; return in_array( $origin, self::get_local_origins(), true ); } /** * Match an image url against this site's media library. * * @param string $url Url to an image. * @return int Attachment ID, or zero when the url is not an image in the media library. */ private static function get_attachment_id_from_url( $url ) { if ( ! self::is_local_url( $url ) ) return 0; list( $url ) = explode( '#', $url ); list( $url ) = explode( '?', $url ); if ( 0 === strpos( $url, '/' ) && 0 !== strpos( $url, '//' ) ) $url = home_url( $url ); $candidates = array( $url ); /* * Post content usually references an intermediate size. Only the full * size url is stored against the attachment, and uploads over the big * image threshold are stored with a "-scaled" suffix. */ $full_url = preg_replace( '/-\d+x\d+(?=\.[a-zA-Z0-9]{2,5}$)/', '', $url ); if ( $full_url !== $url ) { $candidates[] = $full_url; $candidates[] = preg_replace( '/(\.[a-zA-Z0-9]{2,5})$/', '-scaled$1', $full_url ); } $attachment_id = 0; foreach ( $candidates as $candidate ) { $attachment_id = absint( attachment_url_to_postid( $candidate ) ); if ( ! empty( $attachment_id ) ) break; } if ( empty( $attachment_id ) ) return 0; if ( 'attachment' !== get_post_type( $attachment_id ) ) return 0; if ( 0 !== strpos( (string) get_post_mime_type( $attachment_id ), 'image/' ) ) return 0; return $attachment_id; } /** * Confirm that a path points at a readable file inside the uploads directory. * * @param string $file Path to check. * @return string Real path to the file, or an empty string. */ private static function get_readable_uploads_path( $file ) { $uploads = wp_get_upload_dir(); if ( empty( $file ) || empty( $uploads['basedir'] ) ) return ''; $path = realpath( $file ); $basedir = realpath( $uploads['basedir'] ); if ( false === $path || false === $basedir ) return ''; if ( 0 !== strpos( $path, rtrim( $basedir, DIRECTORY_SEPARATOR ) . DIRECTORY_SEPARATOR ) ) return ''; if ( ! is_file( $path ) || ! is_readable( $path ) ) return ''; return $path; } /** * Readable filesystem path of an attachment stored in the uploads directory. * * @param int $attachment_id Attachment ID. * @return string Path to the file, or an empty string when it cannot be read. */ private static function get_attachment_path( $attachment_id ) { $attachment_id = absint( $attachment_id ); if ( empty( $attachment_id ) ) return ''; return self::get_readable_uploads_path( get_attached_file( $attachment_id ) ); } /** * Dimensions of an attachment, in the shape returned by getimagesize(). * * @param int $attachment_id Attachment ID. * @return array Width and height, or an empty array when unavailable. */ private static function get_attachment_size( $attachment_id ) { $attachment_id = absint( $attachment_id ); if ( empty( $attachment_id ) ) return array(); $meta = wp_get_attachment_metadata( $attachment_id ); if ( ! empty( $meta['width'] ) && ! empty( $meta['height'] ) ) return array( absint( $meta['width'] ), absint( $meta['height'] ) ); $path = self::get_attachment_path( $attachment_id ); if ( empty( $path ) ) return array(); $size = wp_getimagesize( $path ); return is_array( $size ) ? $size : array(); } /** * Save Image Data. * * @since Duotone v2.0 */ public static function save_image_data( $ID ) { $post = get_post( $ID ); $image_url = self::scrape_first_image_url( $post->post_content ); /* * If there is no img tag in the post_content we will * clear all image related post meta data and return early. */ if ( empty( $image_url ) ) { self::flush_image_data( $ID ); return false; } $saved = self::get_image_data( $ID ); if ( $image_url == $saved['url'] && ! empty( $saved['image_url'] ) ) return false; self::flush_image_data( $ID ); $image_id = self::get_the_image_id( $image_url ); $properties = self::get_image_properties( $image_id, $image_url ); include_once( get_template_directory() . '/inc/csscolor.php' ); $color = new Duotone_CSS_Color( $properties['color'] ); $is_vertical = ( self::is_vertical( $properties['size'] ) ) ? 1 : 0; $post_meta = array( 'background' => $color->bg, 'foreground' => $color->fg, 'url' => esc_url_raw( $image_url ), 'is_vertical' => absint( $is_vertical ), 'image_id' => absint( $image_id ), ); add_post_meta( $ID, '_duotone', $post_meta ); } /** * Return the ID of the first image in a post. * * The image must already exist in this site's Media Library. * Zero is returned for anything else. * * @uses Duotone::get_attachment_id_from_url() * * @param string $url Full url to the image. * @return int Image $ID. * * @access private * @since Duotone v2.0 */ private static function get_the_image_id( $url ) { return self::get_attachment_id_from_url( $url ); } /** * Get Image Data. * * Retrives postmeta from database, merged with default values. * This function cas the potential to be pretty resource intensive * and therefore should only be called once per document. * * @since Duotone v2.0 */ public static function get_image_data( $ID = 0, $scan_image = true ) { $defaults = array( 'background' => array(), 'foreground' => array(), 'url' => '', 'is_vertical' => 0, 'image_id' => 0, ); if ( empty( $ID ) ) $ID = get_the_ID(); $meta = get_post_meta( $ID, '_duotone', true ); /* * COMPAT: Allow deprecated post meta to override defaults. */ if ( empty( $meta ) ) $defaults = wp_parse_args( self::get_deprecated_meta( $ID ), $defaults ); /* * Scrape first image if no value for url is stored. */ if ( empty( $defaults['url'] ) ) { $current_post = get_post( $ID ); $scraped = ( $current_post ) ? self::scrape_first_image_url( $current_post->post_content ) : ''; if ( ! empty( $scraped ) ) $defaults['url'] = $scraped; } /* * Return early in cases where multiple images are * displayed: search, category, archives. */ if ( ! $scan_image ) { $data = wp_parse_args( $meta, $defaults ); return $data; } $image_url = ( ! empty( $meta['url'] ) ) ? $meta['url'] : $defaults['url']; /* * Stored meta wins in the merge below, so only go looking for what it * does not already hold. Resolving an attachment id and reading an * image are both too expensive to repeat on every page view. */ $saved_background = isset( $meta['background'] ) ? $meta['background'] : $defaults['background']; $saved_foreground = isset( $meta['foreground'] ) ? $meta['foreground'] : $defaults['foreground']; $needs_colors = empty( $saved_background ) || empty( $saved_foreground ); $needs_size = ! isset( $meta['is_vertical'] ); $image_id = 0; /* * Read the image once and take everything that is missing from it. */ if ( ! empty( $image_url ) && ( $needs_colors || $needs_size ) ) { $image_id = ( ! empty( $meta['image_id'] ) ) ? absint( $meta['image_id'] ) : self::get_attachment_id_from_url( $image_url ); if ( ! empty( $image_id ) ) $defaults['image_id'] = $image_id; $properties = self::get_image_properties( $image_id, $image_url ); if ( $needs_colors ) { include_once( get_template_directory() . '/inc/csscolor.php' ); $colors = new Duotone_CSS_Color( $properties['color'] ); if ( empty( $defaults['background'] ) ) $defaults['background'] = $colors->bg; if ( empty( $defaults['foreground'] ) ) $defaults['foreground'] = $colors->fg; } if ( $needs_size && self::is_vertical( $properties['size'] ) ) $defaults['is_vertical'] = 1; } $data = wp_parse_args( $meta, $defaults ); /* * Still no colors? Use default values. Applied after the merge so that * a partial meta row cannot leave a template without them. */ if ( empty( $data['background'] ) ) $data['background'] = self::get_colors( 'background' ); if ( empty( $data['foreground'] ) ) $data['foreground'] = self::get_colors( 'foreground' ); /* * Meta written before this theme resolved attachment ids holds a zero. * Prefer an id found above so those posts are displayed at the same * sizes as anything published since. */ if ( empty( $data['image_id'] ) && ! empty( $image_id ) ) $data['image_id'] = absint( $image_id ); return $data; } /** * Map an uploads url to its path inside the uploads directory. * * @param string $url Url to a file. * @return string Path to the file, or an empty string when the url is not * a readable file in the uploads directory. */ public static function get_image_path( $url ) { $uploads = wp_get_upload_dir(); if ( empty( $url ) || empty( $uploads['baseurl'] ) || empty( $uploads['basedir'] ) ) return ''; list( $url ) = explode( '#', $url ); list( $url ) = explode( '?', $url ); if ( 0 === strpos( $url, '/' ) && 0 !== strpos( $url, '//' ) ) $url = home_url( $url ); $path = str_replace( $uploads['baseurl'], $uploads['basedir'], $url ); if ( $path === $url ) return ''; return self::get_readable_uploads_path( $path ); } /** * Get deprecated meta data stored by Duotone v1.1. * * @param int $ID Unique id of a WordPress post object. * @return array * * @since Duotone v2.0 */ private static function get_deprecated_meta( $ID ) { $defaults = []; $meta = array(); $background = get_post_meta( $ID, 'image_colors_bg', true ); if ( is_array( $background ) ) $meta['background'] = $background; $foreground = get_post_meta( $ID, 'image_colors_fg', true ); if ( is_array( $foreground ) ) $meta['foreground'] = $foreground; $url = get_post_meta( $ID, 'url', true ); if ( ! empty( $url ) ) $meta['url'] = esc_url_raw( $url ); $size = get_post_meta( $ID, 'image_size', true ); if ( is_array( $size ) && self::is_vertical( $size ) ) $defaults['is_vertical'] = 1; return $meta; } /** * Get colors. * * The color arrays returned by Duotone_CSS_Color possess * keys with both numeric and string types making it inappropriate * to merge it's values with wp_parse_args(). This function will * manually merge it's values with defaults. * * @since Duotone v2.0 */ public static function get_colors( $area = 'background', $merge = array() ) { include_once( get_template_directory() . '/inc/csscolor.php' ); $defaults = new Duotone_CSS_Color( 'ffffff' ); if ( 'background' == $area ) $colors = $defaults->bg; else $colors = $defaults->fg; foreach ( $colors as $k => $color ) { if ( isset( $merge[$k] ) ) $colors[$k] = $merge[$k]; } return $colors; } /** * Flush post meta. * * Delete all post metadata that this theme may have * ever stored for a post including currently supported * and deprecated keys. * * @param int $ID Unique id of a WordPress post object. * * @since Duotone v2.0 */ public static function flush_image_data( $ID ) { delete_post_meta( $ID, '_duotone' ); $deprecated = array( 'image_url', 'image_size', 'image_tag', 'image_colors_bg', 'image_colors_fg', 'image_md5', 'image_colors', 'image_color_base' ); foreach ( $deprecated as $key ) { delete_post_meta( $ID, $key ); } } /** * DEBUG: Dump Image Data. * * @since Duotone v2.0 */ public static function dump_image_data( $ID = 0 ) { if ( empty( $ID ) ) $ID = get_the_ID(); $meta = self::get_image_data( $ID ); self::dump_colors( __( 'Foreground' , 'duotone' ), $meta['foreground'] ); self::dump_colors( __( 'Background' , 'duotone' ), $meta['background'] ); echo '
';
		echo 'image_id: ' . absint( $meta['image_id'] );
		echo "\n" . 'is_vertical: ' . absint( $meta['is_vertical'] );
		echo "\n" . 'url: ' . esc_url( $meta['url'] );
		echo '
'; } /** * DEBUG: Dump Colors. * * @since Duotone v2.0 */ public static function dump_colors( $label, $color ) { echo "\n\n" . '' . esc_html( $label ) . ''; if ( empty( $color ) ) { echo '

' . esc_html__( 'empty', 'duotone' ) . '

'; return; } echo "\n" . '

'; echo "\n\t" . '-5'; echo "\n\t" . '-4'; echo "\n\t" . '-3'; echo "\n\t" . '-2'; echo "\n\t" . '-1'; echo "\n\t" . '0'; echo "\n\t" . '+1'; echo "\n\t" . '+2'; echo "\n\t" . '+3'; echo "\n\t" . '+4'; echo "\n\t" . '+5'; echo "\n" . '

'; } /** * @since Duotone v2.0 */ public static function is_vertical( $size ) { if ( ! is_array( $size ) || ! isset( $size[0] ) || ! isset( $size[1] ) ) return false; if ( $size[0] <= $size[1] || $size[0] < MIN_WIDTH ) return true; return false; } /** * @since Duotone v2.0 */ public static function rgbhex( $red, $green, $blue ) { return sprintf( '%02X%02X%02X', $red, $green, $blue ); } /** * @since Duotone v2.0 */ public static function hsv( $r, $g, $b ) { $h = null; $max = max( $r, $g, $b ); $min = min( $r, $g, $b ); $delta = $max - $min; $v = round( ( $max / 255 ) * 100 ); $s = ( $max != 0 ) ? ( round( $delta / $max * 100 ) ) : 0; if ( $s == 0 ) { $h = false; } else { if ( $r == $max ) $h = ( $g - $b ) / $delta; else if ( $g == $max ) $h = 2 + ( $b - $r ) / $delta; else if ( $b == $max ) $h = 4 + ( $r - $g ) / $delta; $h = round( $h * 60 ); if ( $h > 360 ) $h = 360; if ( $h < 0 ) $h += 360; } return array( $h, $s, $v ); } /** * Sample the dominant color of an image in the media library. * * @param int $attachment_id Attachment ID. * @return string Color in hexadecimal notation. */ /** * Image types this theme is prepared to decode. * * @return array IMAGETYPE_* constants. */ private static function get_supported_image_types() { $types = array( IMAGETYPE_GIF, IMAGETYPE_PNG, IMAGETYPE_JPEG ); if ( defined( 'IMAGETYPE_WEBP' ) ) $types[] = IMAGETYPE_WEBP; return $types; } /** * Whether an image is small enough to be worth decoding. * * Decoding costs memory per pixel rather than per byte, so a modest file * can expand well past what this process is allowed to allocate. Only the * dimensions are needed to tell, and they are known before decoding. * * @param array $size Result of getimagesize() or getimagesizefromstring(). * @return bool */ private static function is_decodable_image_size( $size ) { if ( ! is_array( $size ) || empty( $size[0] ) || empty( $size[1] ) ) return false; return ( (float) $size[0] * (float) $size[1] ) <= self::IMAGE_MAX_PIXELS; } /** * Build an image resource from a file in the uploads directory. * * @param string $path Path to the file. * @return resource|GdImage|false */ private static function create_image_from_path( $path ) { /* wp_getimagesize() identifies the format without tripping over noisy files. */ $size = wp_getimagesize( $path ); if ( ! is_array( $size ) || empty( $size[2] ) ) return false; if ( ! in_array( $size[2], self::get_supported_image_types(), true ) ) return false; if ( ! self::is_decodable_image_size( $size ) ) return false; switch ( $size[2] ) { case IMAGETYPE_GIF : return function_exists( 'imagecreatefromgif' ) ? imagecreatefromgif( $path ) : false; case IMAGETYPE_PNG : return function_exists( 'imagecreatefrompng' ) ? imagecreatefrompng( $path ) : false; case IMAGETYPE_JPEG : return function_exists( 'imagecreatefromjpeg' ) ? imagecreatefromjpeg( $path ) : false; } if ( function_exists( 'imagecreatefromwebp' ) ) return imagecreatefromwebp( $path ); return false; } /** * Whether data begins with the signature of a format this theme decodes. * * Checking up front means getimagesizefromstring() and the decoders are * only ever handed data they can be expected to understand. * * @param string $body Raw image data. * @return bool */ private static function is_supported_image_data( $body ) { if ( ! is_string( $body ) || strlen( $body ) < 12 ) return false; /* JPEG */ if ( "\xFF\xD8\xFF" === substr( $body, 0, 3 ) ) return true; /* PNG */ if ( "\x89PNG\r\n\x1A\n" === substr( $body, 0, 8 ) ) return true; /* GIF */ if ( 'GIF87a' === substr( $body, 0, 6 ) || 'GIF89a' === substr( $body, 0, 6 ) ) return true; /* WebP */ if ( 'RIFF' === substr( $body, 0, 4 ) && 'WEBP' === substr( $body, 8, 4 ) ) return true; return false; } /** * Build an image resource from data already held in memory. * * @param string $body Raw image data. * @param array $size Result of getimagesizefromstring() for that data. * @return resource|GdImage|false */ private static function create_image_from_string( $body, $size ) { if ( ! is_array( $size ) || empty( $size[2] ) ) return false; if ( ! in_array( $size[2], self::get_supported_image_types(), true ) ) return false; if ( ! self::is_decodable_image_size( $size ) ) return false; if ( ! function_exists( 'imagecreatefromstring' ) ) return false; return imagecreatefromstring( $body ); } /** * Prepare an image url for an http request. * * Relative urls are resolved against this site. On WordPress.com private * files are signed, and a small copy is requested so that the host does * the resizing rather than this server holding a full size image. * * @param string $url Image url. * @return string Url to request, or an empty string. */ private static function get_remote_image_url( $url ) { $url = trim( (string) $url ); if ( '' === $url ) return ''; if ( 0 === strpos( $url, '//' ) ) $url = ( is_ssl() ? 'https:' : 'http:' ) . $url; elseif ( 0 === strpos( $url, '/' ) ) $url = home_url( $url ); if ( defined( 'IS_WPCOM' ) && IS_WPCOM ) { global $current_blog; if ( isset( $current_blog->public ) && -1 == $current_blog->public ) $url = apply_filters( 'wpcom_get_private_file', $url ); // VideoPress images don't support ImgPress if ( ! self::is_videopress_image( $url ) ) $url = add_query_arg( array( 'w' => 300 ), $url ); } return $url; } /** * Fetch an image over http. * * The response is capped and is only ever held in memory - nothing is * written to disk or added to the media library. * * @param string $url Url to request. * @return string Raw image data, or an empty string. */ private static function get_remote_image_body( $url ) { $response = wp_safe_remote_get( $url, array( 'timeout' => self::REMOTE_IMAGE_TIMEOUT, 'redirection' => 3, 'limit_response_size' => self::REMOTE_IMAGE_MAX_BYTES, ) ); if ( is_wp_error( $response ) ) return ''; if ( 200 != wp_remote_retrieve_response_code( $response ) ) return ''; $type = wp_remote_retrieve_header( $response, 'content-type' ); if ( is_array( $type ) ) $type = reset( $type ); if ( 0 !== strpos( strtolower( (string) $type ), 'image/' ) ) return ''; $body = wp_remote_retrieve_body( $response ); if ( ! is_string( $body ) || '' === $body ) return ''; /* A response that reached the cap was cut short and cannot be trusted. */ if ( strlen( $body ) >= self::REMOTE_IMAGE_MAX_BYTES ) return ''; return $body; } /** * Color and dimensions of an image that is not stored on this server. * * Results are cached so that a template render does not repeat the * request on every page view. * * @param string $url Image url. * @return array Color in hexadecimal notation and getimagesize() style dimensions. */ private static function get_remote_image_properties( $url ) { $properties = array( 'color' => '', 'size' => array(), ); $url = trim( (string) $url ); if ( '' === $url ) return $properties; /* * Keyed on the url as it appears in the post rather than the url that * is requested. On WordPress.com the latter carries a signature that * changes between page views, which would never produce a cache hit. */ $cache_key = 'duotone_image_' . md5( $url ); $cached = get_transient( $cache_key ); if ( is_array( $cached ) ) return wp_parse_args( $cached, $properties ); $request_url = self::get_remote_image_url( $url ); if ( '' === $request_url ) return $properties; $body = self::get_remote_image_body( $request_url ); if ( self::is_supported_image_data( $body ) ) { $size = getimagesizefromstring( $body ); if ( is_array( $size ) ) { $properties['size'] = $size; $image = self::create_image_from_string( $body, $size ); if ( $image ) { $properties['color'] = self::sample_color( $image ); imagedestroy( $image ); } } } unset( $body ); /* * Hold on to whatever came back, but only briefly when it told us * nothing, so a request that failed once is not baked in for a day. */ $expiration = ( '' === $properties['color'] ) ? HOUR_IN_SECONDS : DAY_IN_SECONDS; set_transient( $cache_key, $properties, $expiration ); return $properties; } /** * Color and dimensions of the image a post is built around. * * Images in the media library are read from disk. Everything else, * including a media library that is not stored on this server, falls * back to a capped http request. * * @param int $attachment_id Attachment ID, or zero. * @param string $url Image url. * @return array Color in hexadecimal notation and getimagesize() style dimensions. */ private static function get_image_properties( $attachment_id, $url ) { $color = ''; $size = array(); $path = ''; if ( ! empty( $attachment_id ) ) { $size = self::get_attachment_size( $attachment_id ); $path = self::get_attachment_path( $attachment_id ); } /* * An image can sit in the uploads directory without being in the media * library - a deleted attachment, or a url that is not shaped the way * attachment_url_to_postid() expects. Read those from disk as well. * Asking this server for its own files over http would fail anyway on * an install that wp_safe_remote_get() sees as a private address. */ if ( '' === $path ) $path = self::get_image_path( $url ); if ( '' !== $path ) { $image = self::create_image_from_path( $path ); if ( $image ) { $color = self::sample_color( $image ); if ( empty( $size ) ) $size = array( imagesx( $image ), imagesy( $image ) ); imagedestroy( $image ); } } /* * Only reach for the network when there is no file to read here. A * local file that will not decode would not decode over http either. */ if ( '' === $path ) { $remote = self::get_remote_image_properties( $url ); $color = $remote['color']; if ( empty( $size ) ) $size = $remote['size']; } return array( 'color' => ( '' === $color ) ? 'ffffff' : $color, 'size' => $size, ); } /** * Sample the dominant color of an image resource. * * @param resource|GdImage $im Image resource. * @return string Color in hexadecimal notation, or an empty string. */ private static function sample_color( $im ) { $r = []; $g = []; $b = []; $colors = []; $height = imagesy( $im ); $width = imagesx( $im ); if ( $width < 1 || $height < 1 ) return ''; // sample five points in the image, based on rule of thirds and center $topy = min( $height - 1, round( $height / 3 ) ); $bottomy = min( $height - 1, round( ( $height / 3 ) * 2 ) ); $leftx = min( $width - 1, round( $width / 3 ) ); $rightx = min( $width - 1, round( ( $width / 3 ) * 2 ) ); $centery = min( $height - 1, round( $height / 2 ) ); $centerx = min( $width - 1, round( $width / 2 ) ); // grab those colors $rgb = array( imagecolorat( $im, $leftx, $topy ), imagecolorat( $im, $rightx, $topy ), imagecolorat( $im, $leftx, $bottomy ), imagecolorat( $im, $rightx, $bottomy ), imagecolorat( $im, $centerx, $centery ), ); foreach ( $rgb as $value ) { if ( false === $value ) return ''; } // process points for ( $i = 0; $i <= count( $rgb ) - 1; $i++ ) { $r[$i] = ( $rgb[$i] >> 16 ) & 0xFF; $g[$i] = ( $rgb[$i] >> 8 ) & 0xFF; $b[$i] = $rgb[$i] & 0xFF; /* rgb */ list( $colors[$i]['r'], $colors[$i]['g'], $colors[$i]['b'] ) = array( $r[$i], $g[$i], $b[$i] ); /* hsv */ list( $colors[$i]['h'], $colors[$i]['s'], $colors[$i]['v']) = Duotone::hsv( $r[$i], $g[$i], $b[$i] ); /* hex */ $colors[$i]['hex'] = Duotone::rgbhex( $r[$i], $g[$i], $b[$i] ); } $best_saturation = $best_brightness = 0; $the_best_s = $the_best_v = array( 'v' => 0 ); foreach ( $colors as $color => $value ) { if ( $value['s'] > $best_saturation ) { $best_saturation = $value['s']; $the_best_s = $value; } if ( $value['v'] > $best_brightness ) { $best_brightness = $value['v']; $the_best_v = $value; } } // is brightest the same as most saturated? $the_best = ( $the_best_s['v'] >= ( $the_best_v['v'] - ( $the_best_v['v'] / 2 ) ) ) ? $the_best_s : $the_best_v; return isset( $the_best['hex'] ) ? $the_best['hex'] : ''; } public static function exif_table() { $images = array_values( get_children( array( 'post_parent' => get_the_ID(), 'post_status' => 'inherit', 'post_type' => 'attachment', 'post_mime_type' => 'image' ) ) ); if ( ! $images ) return; $image = array_shift( $images ); $meta = wp_get_attachment_metadata( $image->ID ); if ( ! isset( $meta['image_meta'] ) ) return; $exif = wp_parse_args( $meta['image_meta'], array( 'aperture' => '', 'focal_length' => '', 'iso' => '', 'shutter_speed' => '', 'camera' => '', ) ); $rows = array(); if ( ! empty( $exif['aperture'] ) ) $rows[] = '' . esc_html__( 'Aperture:', 'duotone' ) . '' . sprintf( esc_html__( 'f/%1$s', 'duotone' ), esc_html( $exif['aperture'] ) ) . ''; if ( ! empty( $exif['focal_length'] ) ) $rows[] = '' . esc_html__( 'Focal Length:', 'duotone' ) . '' . sprintf( esc_html__( '%1$smm', 'duotone' ), esc_html( $exif['focal_length'] ) ) . ''; if ( ! empty( $exif['iso'] ) ) $rows[] = '' . esc_html__( 'ISO:', 'duotone' ) . '' . esc_html( $exif['iso'] ) . ''; if ( ! empty( $exif['shutter_speed'] ) ) $rows[] = '' . esc_html__( 'Shutter:', 'duotone' ) . '' . sprintf( esc_html__( '%1$s sec', 'duotone' ), esc_html( self::dec2frac( $exif['shutter_speed'] ) ) ) . ''; if ( ! empty( $exif['iso'] ) ) $rows[] = '' . esc_html__( 'Camera:', 'duotone' ) . '' . esc_html( $exif['camera'] ) . ''; if ( empty( $rows ) ) return; echo "\n" . ''; foreach ( $rows as $row ) { echo "\n\t" . '' . $row . ''; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- $row is assembled from esc_html() values above. } echo "\n" . '
'; } /** * Used only by Duotone::exif_table(). */ public static function dec2frac( $dec ) { global $duotone_result; if ( (int) $dec > 1 ) return $dec; $count = 0; $duotone_result = array(); self::decimalToFraction( $dec, $count, $duotone_result ); # $count = count( $dec ); return self::simplifyFraction( $duotone_result, $count, 1, $duotone_result[$count] ?? null ); } /** * Used only by Duotone::dec2frac(). */ public static function decimalToFraction( $decimal, $count, $duotone_result ) { global $duotone_result; $a = 0; if ( is_numeric( $decimal ) ) { $a = ( 1 / $decimal ); } $b = ( $a - floor( $a ) ); $count++; if ( $b > .01 && $count <= 5 ) self::decimalToFraction( $b, $count, $duotone_result ); $duotone_result[$count] = floor( $a ); } /* * Simplifies a fraction in an array form that is returned from * Duotone::decimalToFraction() * * Used only by Duotone::decimalToFraction(). */ public static function simplifyFraction( $fraction, $count, $top, $bottom ) { $next = 0; if ( isset( $fraction[$count-1] ) ) $next = $fraction[$count-1]; $a = ( $bottom * $next ) + $top; $top = $bottom; $bottom = $a; $count--; if ( $count > 0 ) self::simplifyFraction( $fraction, $count, $top, $bottom ); else return sprintf( __( '%1$d/%2$d', 'duotone' ), $bottom, $top ); } /** * Ensure that a string representing a color in hexadecimal * notation is safe for use in css and database saves. * * @param string Color in hexadecimal notation. "#" may or may not be prepended to the string. * @return string Color in hexadecimal notation on success - the string "transparent" otherwise. * * @since Duotone v2.0 */ public static function sanitize_color_hex( $hex, $prefix = '#' ) { $hex = trim( (string) $hex ); /* sanitize_hex_color_no_hash() strips a leading "#" but not the encoded form. */ if ( 0 === strpos( $hex, '%23' ) ) $hex = substr( $hex, 3 ); $hex = sanitize_hex_color_no_hash( $hex ); /* * Core returns null for anything it does not recognize. Fall back to a * keyword rather than an empty string so the value stays valid wherever * it is interpolated into CSS. */ if ( ! empty( $hex ) ) return $prefix . $hex; return 'transparent'; } /** * COMPAT: Allow deprecated `background_color` option to temporarily sub for theme modification. * * Prior to version 2.0, Duotone had a custom Theme Options screen * which allowed users to define a custom background color to override * automatic color generation. For complinace with the WPTRT guidelines, * this custom implementation has been replaced with WordPress core * background functionality. * * Users should see the deprecated background color in Appearance -> Background * as well as in all template files. We will need to override get_theme_mod() * when used by get_background_color(). * * Hooks in the `theme_mod_background_color` filter. * * @since Duotone v2.0 */ public static function deprecated_background_color_override( $color ) { $deprecated_color = self::sanitize_color_hex( get_option( 'background_color' ), '' ); if ( 'transparent' != $deprecated_color ) { set_theme_mod( 'background_color', $deprecated_color ); return $deprecated_color; } return $color; } /** * WPCOM: Add crop parameters to image urls for archive templates. * * @since Duotone v2.0 */ public static function wpcom_archive_image_url( $url ) { if ( empty( $url ) ) return $url; if ( defined( 'IS_WPCOM' ) && IS_WPCOM ) $url = add_query_arg( array( 'w' => 75, 'h' => 75, 'crop' => 1 ), $url ); return $url; } /** * WPCOM: Add width query arg to image urls for singular templates. * * @since Duotone v2.0 */ public static function wpcom_singular_image_url( $url, $image ) { if ( empty( $url ) ) return $url; if ( defined( 'IS_WPCOM' ) && IS_WPCOM ) { $is_vertical = ( isset( $image['is_vertical'] ) ) ? $image['is_vertical'] : 1; $new_width = ( $image['is_vertical'] ) ? MIN_WIDTH : MAX_WIDTH; $url = add_query_arg( array( 'w' => absint( $new_width ) ), $url ); } return $url; } /** * WPCOM: Set $themecolors global. * * @since Duotone v2.0 */ public static function set_themecolors() { $background = []; $foreground = []; if ( defined( 'IS_WPCOM' ) && IS_WPCOM ) { extract( self::$image ); global $themecolors; $themecolors = array( 'bg' => self::sanitize_color_hex( $background['-2'], '' ), 'border' => self::sanitize_color_hex( $background['-1'], '' ), 'text' => self::sanitize_color_hex( $foreground['-2'], '' ), 'link' => self::sanitize_color_hex( $foreground['-3'], '' ), 'url' => self::sanitize_color_hex( $foreground['-4'], '' ), ); } } }