array( 'name' => __( 'Mockups', 'printblob' ), 'singular_name' => __( 'Mockup', 'printblob' ), 'add_new' => __( 'Add mockup', 'printblob' ), 'add_new_item' => __( 'Add mockup', 'printblob' ), 'edit_item' => __( 'Edit mockup', 'printblob' ), 'new_item' => __( 'New mockup', 'printblob' ), 'search_items' => __( 'Search mockups', 'printblob' ), 'not_found' => __( 'No mockups yet.', 'printblob' ), 'not_found_in_trash' => __( 'No mockups in the trash.', 'printblob' ), 'menu_name' => __( 'Mockups', 'printblob' ), ), 'public' => false, 'show_ui' => false, 'show_in_menu' => false, 'supports' => array( 'title' ), 'capability_type' => 'product', 'map_meta_cap' => true, 'show_in_rest' => false, ) ); } /** * Default mockup data: the bundled t-shirt photo and its print areas, from assets/mockups/tee.json, which the * `printblob` package exports to the other platforms. */ /** The bundled photo of a side (the front's for a side the t-shirt has none of). */ private static function bundled_photo( string $side ): string { $sides = Designer_Data::tee()['sides']; return (string) ( $sides[ $side ]['photo'] ?? $sides['front']['photo'] ); } private static function bundled(): array { $tee = Designer_Data::tee(); $sides = array(); foreach ( (array) ( $tee['sides'] ?? array() ) as $key => $side ) { $sides[ $key ] = array( 'enabled' => true, 'image' => 0, 'area' => $side['area'], 'print_cm' => $side['print_cm'], ); } return array( 'recolor' => (bool) ( $tee['recolor'] ?? true ), 'sides' => $sides, ); } /** Creates the bundled t-shirt mockup the first time an administrator opens the dashboard. */ public static function ensure_default(): void { $current = (int) get_option( self::DEFAULT_OPTION ); if ( ( $current && self::POST_TYPE === get_post_type( $current ) ) || ! current_user_can( 'edit_products' ) ) { return; } self::install_default(); } public static function install_default(): int { require_once ABSPATH . 'wp-admin/includes/image.php'; require_once ABSPATH . 'wp-admin/includes/file.php'; require_once ABSPATH . 'wp-admin/includes/media.php'; $data = self::bundled(); foreach ( array_keys( $data['sides'] ) as $key ) { $image = self::sideload_bundled_photo( $key ); if ( ! $image ) { return 0; } $data['sides'][ $key ]['image'] = $image; } $id = wp_insert_post( array( 'post_type' => self::POST_TYPE, 'post_status' => 'publish', 'post_title' => __( 'T-shirt', 'printblob' ), ) ); if ( ! $id || is_wp_error( $id ) ) { return 0; } update_post_meta( $id, self::META, $data ); update_option( self::DEFAULT_OPTION, $id ); return (int) $id; } /** * Adds the bundled t-shirt photo of a side (WebP) to the media library. Attachment ID, or 0. * * @param string $side 'front' or 'back'. */ private static function sideload_bundled_photo( string $side ): int { require_once ABSPATH . 'wp-admin/includes/image.php'; require_once ABSPATH . 'wp-admin/includes/file.php'; require_once ABSPATH . 'wp-admin/includes/media.php'; $file = self::bundled_photo( $side ); $name = 'printblob-' . $file; $title = 'back' === $side ? __( 'T-shirt mockup (back)', 'printblob' ) : __( 'T-shirt mockup', 'printblob' ); $tmp = wp_tempnam( $name ); copy( dirname( FILE ) . '/assets/mockups/' . $file, $tmp ); $image = media_handle_sideload( array( 'name' => $name, 'tmp_name' => $tmp, ), 0, $title ); return is_wp_error( $image ) ? 0 : (int) $image; } /** * The bundled mockup's photos changed over time: up to 1.0.2 one 460 KB PNG, then the same photo as a * 48 KB WebP; up to 1.0.3 that front photo also served as the back. The default mockup still showing a * bundled photo gets the current ones once. Photos a shop uploaded itself are never touched. */ public static function upgrade_bundled_photo(): void { if ( self::BUNDLED_REVISION !== get_option( STORED['options']['bundled_photo'] ) && current_user_can( 'edit_products' ) ) { self::use_bundled_photos(); } } /** The upgrade itself, for setup scripts (WP-CLI) as well as the dashboard. */ public static function use_bundled_photos(): void { $id = (int) get_option( self::DEFAULT_OPTION ); if ( $id && self::POST_TYPE === get_post_type( $id ) ) { $stored = (array) get_post_meta( $id, self::META, true ); $changed = false; foreach ( self::data( $id )['sides'] as $key => $side ) { if ( ! isset( Designer_Data::tee()['sides'][ $key ] ) || ! $side['image'] ) { continue; } $file = wp_basename( (string) get_attached_file( $side['image'] ) ); // The bundled front photo, as PNG or WebP (WordPress adds -1, -2… to repeated uploads). $is_front_photo = (bool) preg_match( '/^printblob-tee(-\d+)?\.(png|webp)$/', $file ); $outdated = 'back' === $key ? $is_front_photo : ( $is_front_photo && str_ends_with( $file, '.png' ) ); if ( ! $outdated ) { continue; } $image = self::sideload_bundled_photo( $key ); if ( ! $image ) { return; } $stored['sides'][ $key ]['image'] = $image; $changed = true; } if ( $changed ) { update_post_meta( $id, self::META, $stored ); } } update_option( STORED['options']['bundled_photo'], self::BUNDLED_REVISION, false ); } /** Whether a mockup's front is the bundled t-shirt photo. */ public static function uses_bundled_front( int $id ): bool { $front = (int) ( self::data( $id )['sides']['front']['image'] ?? 0 ); return $front && (bool) preg_match( '/^printblob-tee(-\d+)?\.(png|webp)$/', wp_basename( (string) get_attached_file( $front ) ) ); } /** The photo a mockup has for a side, whether or not this site prints that side: its attachment ID, or 0. */ public static function photo( int $mockup_id, string $side ): int { $stored = get_post_meta( $mockup_id, self::META, true ); return is_array( $stored ) ? (int) ( $stored['sides'][ $side ]['image'] ?? 0 ) : 0; } /** Kept for setup scripts written for 1.0.2: the same upgrade as use_bundled_photos(). */ public static function use_bundled_webp(): void { self::use_bundled_photos(); } /** Stored data of a mockup, normalised. */ public static function data( int $mockup_id ): array { $stored = get_post_meta( $mockup_id, self::META, true ); $stored = is_array( $stored ) ? $stored : array(); $data = array( 'recolor' => ! empty( $stored['recolor'] ), 'sides' => array(), ); foreach ( self::sides() as $key ) { $side = $stored['sides'][ $key ] ?? array(); $default = self::side_defaults( $key ); $data['sides'][ $key ] = array( 'enabled' => ! empty( $side['enabled'] ), 'image' => (int) ( $side['image'] ?? 0 ), 'area' => array( 'x' => (float) ( $side['area']['x'] ?? $default['area']['x'] ), 'y' => (float) ( $side['area']['y'] ?? $default['area']['y'] ), 'w' => (float) ( $side['area']['w'] ?? $default['area']['w'] ), 'h' => (float) ( $side['area']['h'] ?? $default['area']['h'] ), ), 'print_cm' => array( 'w' => (float) ( $side['print_cm']['w'] ?? $default['print_cm']['w'] ), 'h' => (float) ( $side['print_cm']['h'] ?? $default['print_cm']['h'] ), ), ); } return $data; } /** The mockup a product uses: its own choice, else the default. */ public static function for_product( \WC_Product $product ): int { $parent = $product->get_parent_id() ? wc_get_product( $product->get_parent_id() ) : $product; $id = $parent ? (int) $parent->get_meta( self::PRODUCT_META ) : 0; if ( ! $id || self::POST_TYPE !== get_post_type( $id ) ) { $id = (int) get_option( self::DEFAULT_OPTION ); } return $id; } /** * The printable sides of a product, ready for the designer and for checking uploaded print files. * * @return array */ public static function sides_for( \WC_Product $product ): array { $mockup = self::for_product( $product ); if ( ! $mockup ) { return array(); } $data = self::data( $mockup ); $sides = array(); foreach ( $data['sides'] as $key => $side ) { $meta = $side['image'] ? wp_get_attachment_metadata( $side['image'] ) : false; if ( ! $side['enabled'] || empty( $meta['width'] ) || empty( $meta['height'] ) ) { continue; } $stage = array( 'width' => Designer_Data::numbers()['stage_width'], 'height' => (int) round( Designer_Data::numbers()['stage_width'] * $meta['height'] / $meta['width'] ), ); $sides[ $key ] = array( 'label' => self::side_label( $key ), 'image' => (string) wp_get_attachment_url( $side['image'] ), 'recolor' => $data['recolor'], 'stage' => $stage, 'area' => array( 'left' => round( $side['area']['x'] * $stage['width'], 2 ), 'top' => round( $side['area']['y'] * $stage['height'], 2 ), 'width' => round( $side['area']['w'] * $stage['width'], 2 ), 'height' => round( $side['area']['h'] * $stage['height'], 2 ), ), 'printCm' => array( 'width' => $side['print_cm']['w'], 'height' => $side['print_cm']['h'], ), 'printPx' => array( 'width' => Designer_Data::print_pixels( (float) $side['print_cm']['w'] ), 'height' => Designer_Data::print_pixels( (float) $side['print_cm']['h'] ), ), ); } return $sides; } public static function side_label( string $side ): string { $label = 'back' === $side ? __( 'Back', 'printblob' ) : __( 'Front', 'printblob' ); /** * Filters the name of a printable side shown to customers and shop managers. * * @param string $label Side name. * @param string $side Side key. */ return (string) apply_filters( 'printblob_side_label', $label, $side ); } /** * Starting print area and print size for a side that hasn't been set up yet. * * @return array{area:array{x:float,y:float,w:float,h:float}, print_cm:array{w:float,h:float}} */ public static function side_defaults( string $side ): array { $defaults = Designer_Data::numbers()['new_side']; /** * Filters the starting print area (fractions of the photo) and print size (cm) of a side. * * @param array $defaults Defaults. * @param string $side Side key. */ return (array) apply_filters( 'printblob_side_defaults', $defaults, $side ); } /** Mockups for a select field, ID => title. */ public static function choices(): array { $choices = array(); foreach ( get_posts( array( 'post_type' => self::POST_TYPE, 'post_status' => 'publish', 'numberposts' => -1, 'orderby' => 'title', 'order' => 'ASC', ) ) as $post ) { $choices[ $post->ID ] = $post->post_title; } return $choices; } /* ---------------------------------------------------------------- Admin */ /** The PrintBlob menu's Mockups entry: the list PrintBlob Pro's editor opens, else this plugin's own screen. */ public static function add_menu( string $menu ): void { $type = get_post_type_object( self::POST_TYPE ); if ( $type && $type->show_ui ) { $list = 'edit.php?post_type=' . self::POST_TYPE; add_submenu_page( $menu, $type->labels->name, $type->labels->menu_name, $type->cap->edit_posts, $list ); add_filter( 'parent_file', static function ( $file ) use ( $list, $menu ) { return $list === $file ? $menu : $file; } ); return; } $page = add_submenu_page( $menu, __( 'Mockups', 'printblob' ), __( 'Mockups', 'printblob' ), 'edit_products', self::PAGE, array( __CLASS__, 'render_page' ) ); self::$page = $page ? $page : ''; } /** Where the shop's mockups are shown: PrintBlob Pro's list, else this plugin's Mockups screen. */ public static function screen_url(): string { $type = get_post_type_object( self::POST_TYPE ); return admin_url( $type && $type->show_ui ? 'edit.php?post_type=' . self::POST_TYPE : 'admin.php?page=' . self::PAGE ); } public static function enqueue_admin( string $hook ): void { wp_register_style( self::STYLE, plugins_url( 'assets/admin/mockups.css', FILE ), array(), VERSION ); if ( '' !== self::$page && self::$page === $hook ) { wp_enqueue_style( self::STYLE ); } } /** A print size as shop managers read it, e.g. "28 × 36 cm". */ public static function print_size( array $print_cm ): string { /* translators: 1: width in cm, 2: height in cm. */ return sprintf( __( '%1$s × %2$s cm', 'printblob' ), wc_format_localized_decimal( (string) $print_cm['w'] ), wc_format_localized_decimal( (string) $print_cm['h'] ) ); } public static function render_page(): void { ?>

$title ) : ?>

$side ) : ?>