admin_notice( sprintf( __( 'Flipset: Embed ID #%d not found.', 'flipset' ), $embed_id ) ); } } $atts = shortcode_atts( array( 'id' => '', 'file' => '', 'width' => $settings['default_width'] ?? '100%', 'height' => $settings['default_height'] ?? '600px', 'mode' => 'pdf', // Reserved for add-ons: read it in flipset/pre_render_viewer. 'page' => '1', 'title' => '', 'theme' => $settings['default_theme'] ?? 'light', 'download' => '', 'print' => '', 'search' => '', 'theme_btn' => '', 'share' => '', 'shortcuts' => '', 'presentation' => '', 'fullscreen' => '', 'loading' => ! empty( $settings['lazy_loading'] ) ? 'lazy' : 'eager', 'hide' => '', 'hash' => 'true', 'view' => 'page', 'resume' => 'true', ), $atts, 'flipset' ); /** * Filter: flipset/viewer_atts * * Override resolved shortcode/block attributes (every key of the * shortcode_atts() list: id, file, width, height, mode, page, title, theme, * download, print, search, theme_btn, share, shortcuts, presentation, * fullscreen, loading, hide, hash, view, resume) without replacing the * entire viewer. Runs after defaults are applied and before the * empty-file check. See docs/HOOKS.md for the value lists. * * @param array $atts Resolved attributes. */ $atts = apply_filters( 'flipset/viewer_atts', $atts ); if ( ! is_array( $atts ) ) { $atts = array(); } // width and height end up in an inline style attribute: allow only a // number with a CSS length unit, never free text. $atts['width'] = self::sanitize_dimension( $atts['width'] ?? '', self::sanitize_dimension( $settings['default_width'] ?? '', '100%' ) ); $atts['height'] = self::sanitize_dimension( $atts['height'] ?? '', self::sanitize_dimension( $settings['default_height'] ?? '', '600px' ) ); if ( empty( $atts['file'] ) ) { return $this->admin_notice( __( 'Flipset: no file URL was provided. Use [flipset file="https://example.com/document.pdf"].', 'flipset' ) ); } /** * Filter: flipset/pre_render_viewer * * Lets an add-on take over rendering completely — e.g. an EPUB * viewer, or the same PDF viewer wrapped in its bookmark / reader * toolbar — without this free class needing to know add-on * internals. Return any non-null value to short-circuit the * default PDF.js output below. * * @param string|null $output Default null (let free plugin render). * @param array $atts Resolved shortcode/block attributes. */ $pro_output = apply_filters( 'flipset/pre_render_viewer', null, $atts ); if ( null !== $pro_output ) { return wp_kses( (string) apply_filters( 'flipset/viewer_html', $pro_output, $atts ), self::allowed_html() ); } $this->enqueue_assets(); $instance_id = 'flipset-viewer-' . wp_unique_id(); /** * Action: flipset/before_viewer * * An add-on can hook here to inject a toolbar (bookmarks, reader * fonts, etc.) above the base viewer without free plugin code * needing to know the add-on exists beyond this hook. */ ob_start(); do_action( 'flipset/before_viewer', $instance_id, $atts ); $before = wp_kses( (string) ob_get_clean(), self::allowed_html() ); $toolbar = $this->render_toolbar( $instance_id, $atts, $settings ); $caption = ''; $title = isset( $atts['title'] ) ? sanitize_text_field( (string) $atts['title'] ) : ''; if ( '' !== $title ) { $caption = sprintf( '

%s

', esc_html( $title ) ); } $start_page = max( 1, absint( $atts['page'] ?? 1 ) ); $sync_hash = $this->att_flag( $atts['hash'] ?? 'true', true ); $resume = $this->att_flag( $atts['resume'] ?? 'true', true ); $view = strtolower( (string) ( $atts['view'] ?? 'page' ) ); if ( ! in_array( $view, array( 'page', 'scroll', 'spread', 'flip' ), true ) ) { $view = 'page'; } $initial_theme = strtolower( (string) ( $atts['theme'] ?? 'light' ) ); if ( ! in_array( $initial_theme, array( 'light', 'dark', 'sepia' ), true ) ) { $initial_theme = 'light'; } $is_lazy = 'lazy' === strtolower( (string) ( $atts['loading'] ?? 'eager' ) ); $branding = ''; // Off by default (WordPress.org guideline 10); an administrator opts in under Flipset → Settings. if ( apply_filters( 'flipset/show_branding', ! empty( $settings['show_branding'] ) ) ) { $branding = sprintf( '

%2$s

', esc_url( 'https://flipset.thereadscope.com' ), esc_html__( 'Powered by Flipset', 'flipset' ) ); } $file_src = apply_filters( 'flipset/viewer_file_url', $atts['file'], $atts ); $noscript = sprintf( '', esc_url( $file_src ), sprintf( /* translators: %s: document title or fallback */ esc_html__( 'Open document: %s (PDF)', 'flipset' ), esc_html( '' !== $title ? $title : wp_basename( (string) $file_src ) ) ) ); $modals = $this->render_modals(); $container = sprintf( '
%9$s
%3$s%4$s
%17$s
%16$s
%8$s
', esc_attr( $atts['width'] ), esc_attr( $atts['height'] ), $before, $toolbar, esc_attr( $instance_id ), esc_url( $file_src ), esc_attr__( 'PDF viewer', 'flipset' ), $branding, $caption, esc_attr( (string) $start_page ), $sync_hash ? '1' : '0', esc_attr( $view ), $resume ? '1' : '0', esc_attr( $initial_theme ), $is_lazy ? 'lazy' : 'eager', $modals, $noscript ); /** * Filter: flipset/viewer_html * * Last chance to wrap or replace the finished viewer markup. * Also applied to non-null `flipset/pre_render_viewer` output. * * @param string $html Rendered viewer HTML. * @param array $atts Resolved attributes. */ // Escape at the single point where the markup leaves the renderer, after // every filter has had its say. return wp_kses( (string) apply_filters( 'flipset/viewer_html', $container, $atts ), self::allowed_html() ); } /** * Chrome for the free PDF viewer. An add-on's toolbar is * printed into the same .flipset-chrome row via flipset/before_viewer. * * @param string $instance_id Viewer element id. * @param array $atts Resolved attributes. * @param array $settings Plugin settings. * @return string */ private function render_toolbar( $instance_id, $atts, $settings ) { $file_url = isset( $atts['file'] ) ? (string) $atts['file'] : ''; $hide_list = array_filter( array_map( 'trim', explode( ',', strtolower( (string) ( $atts['hide'] ?? '' ) ) ) ) ); $show_nav = ! in_array( 'nav', $hide_list, true ); $show_zoom = ! in_array( 'zoom', $hide_list, true ); $show_fit = ! in_array( 'fit', $hide_list, true ); $show_rotate = ! in_array( 'rotate', $hide_list, true ); $show_view = ! in_array( 'view', $hide_list, true ); $show_pan = ! in_array( 'pan', $hide_list, true ); $download = ! in_array( 'download', $hide_list, true ) && $this->att_flag( $atts['download'] ?? '', ! isset( $settings['allow_download'] ) || ! empty( $settings['allow_download'] ) ); $print = ! in_array( 'print', $hide_list, true ) && $this->att_flag( $atts['print'] ?? '', ! isset( $settings['allow_print'] ) || ! empty( $settings['allow_print'] ) ); $search = ! in_array( 'search', $hide_list, true ) && $this->att_flag( $atts['search'] ?? '', ! isset( $settings['allow_search'] ) || ! empty( $settings['allow_search'] ) ); $theme_btn = ! in_array( 'theme', $hide_list, true ) && $this->att_flag( $atts['theme_btn'] ?? '', ! isset( $settings['allow_theme'] ) || ! empty( $settings['allow_theme'] ) ); $share = ! in_array( 'share', $hide_list, true ) && $this->att_flag( $atts['share'] ?? '', ! isset( $settings['allow_share'] ) || ! empty( $settings['allow_share'] ) ); $shortcuts = ! in_array( 'shortcuts', $hide_list, true ) && $this->att_flag( $atts['shortcuts'] ?? '', ! isset( $settings['allow_shortcuts'] ) || ! empty( $settings['allow_shortcuts'] ) ); $present = ! in_array( 'presentation', $hide_list, true ) && $this->att_flag( $atts['presentation'] ?? '', ! isset( $settings['allow_presentation'] ) || ! empty( $settings['allow_presentation'] ) ); $fullscreen = ! in_array( 'fullscreen', $hide_list, true ) && $this->att_flag( $atts['fullscreen'] ?? '', ! isset( $settings['allow_fullscreen'] ) || ! empty( $settings['allow_fullscreen'] ) ); $download_url = apply_filters( 'flipset/download_url', $file_url, $atts ); ob_start(); ?> > */ public static function allowed_html() { $g = array( 'class' => true, 'id' => true, 'hidden' => true, 'role' => true, 'tabindex' => true, 'title' => true, 'style' => true, 'lang' => true, 'dir' => true, 'data-*' => true, 'aria-label' => true, 'aria-labelledby' => true, 'aria-describedby' => true, 'aria-hidden' => true, 'aria-live' => true, 'aria-busy' => true, 'aria-expanded' => true, 'aria-pressed' => true, 'aria-controls' => true, 'aria-haspopup' => true, 'aria-current' => true, 'aria-modal' => true, 'aria-valuemin' => true, 'aria-valuemax' => true, 'aria-valuenow' => true, ); $tags = array( 'div' => array(), 'span' => array(), 'p' => array(), 'nav' => array(), 'ul' => array(), 'li' => array(), 'h2' => array(), 'h3' => array(), 'strong' => array(), 'kbd' => array(), 'noscript' => array(), 'figure' => array(), 'a' => array( 'href' => true, 'target' => true, 'rel' => true, 'download' => true, ), 'button' => array( 'type' => true, 'disabled' => true, 'name' => true, 'value' => true, ), 'form' => array( 'method' => true, 'action' => true, ), 'label' => array( 'for' => true, ), 'input' => array( 'type' => true, 'name' => true, 'value' => true, 'placeholder' => true, 'min' => true, 'max' => true, 'checked' => true, 'readonly' => true, 'disabled' => true, 'autocomplete' => true, ), 'img' => array( 'src' => true, 'srcset' => true, 'sizes' => true, 'alt' => true, 'width' => true, 'height' => true, 'loading' => true, 'decoding' => true, ), ); $allowed = array(); foreach ( $tags as $tag => $attrs ) { $allowed[ $tag ] = array_merge( $g, $attrs ); } $allowed = array_merge( $allowed, self::svg_allowed_html() ); /** * Filter: flipset/allowed_html * * Add-ons that print extra markup inside the viewer or library output * (through flipset/before_viewer or flipset/viewer_html) extend this list. * * @param array $allowed Tag => attributes map used with wp_kses(). */ $filtered = apply_filters( 'flipset/allowed_html', $allowed ); return is_array( $filtered ) ? $filtered : $allowed; } /** * Tags and attributes of the static line icons (inline SVG). * * @return array> */ public static function svg_allowed_html() { $paint = array( 'class' => true, 'fill' => true, 'stroke' => true, 'stroke-width' => true, 'stroke-linecap' => true, 'stroke-linejoin' => true, 'aria-hidden' => true, ); return array( 'svg' => array_merge( $paint, array( 'xmlns' => true, 'width' => true, 'height' => true, 'viewbox' => true, 'focusable' => true, ) ), 'path' => array_merge( $paint, array( 'd' => true ) ), 'polyline' => array_merge( $paint, array( 'points' => true ) ), 'line' => array_merge( $paint, array( 'x1' => true, 'y1' => true, 'x2' => true, 'y2' => true, ) ), 'circle' => array_merge( $paint, array( 'cx' => true, 'cy' => true, 'r' => true, ) ), 'rect' => array_merge( $paint, array( 'x' => true, 'y' => true, 'width' => true, 'height' => true, 'rx' => true, 'ry' => true, ) ), ); } /** * Print one toolbar icon, escaped at output. * * @param string $name Icon key. */ private function the_icon( $name ) { echo wp_kses( $this->toolbar_icon( $name ), self::svg_allowed_html() ); } /** * Toolbar glyph markup for admin screens that show the same icons. * * @param string $name Icon key. * @return string SVG markup. */ public function get_toolbar_icon( $name ) { return $this->toolbar_icon( $name ); } /** * Inline 16px stroke icons for the viewer chrome. * * @param string $name Icon key. * @return string */ private function toolbar_icon( $name ) { $paths = array( 'prev' => '', 'next' => '', 'minus' => '', 'plus' => '', 'fit' => '', 'rotate' => '', 'search' => '', 'up' => '', 'down' => '', 'download' => '', 'print' => '', 'fs' => '', 'toc' => '', 'thumbs' => '', 'view' => '', 'pan' => '', 'more' => '', 'present' => '', 'theme' => '', 'share' => '', 'shortcuts' => '', 'close' => '', 'copy' => '', 'check' => '', ); $body = $paths[ $name ] ?? ''; return ''; } /** * Strings shared by the front-end viewer and the settings live preview. * * @return array */ public static function viewer_i18n() { return array( 'loading' => __( 'Loading document…', 'flipset' ), /* translators: %s: percent loaded. */ 'loadingPct' => __( 'Loading %s…', 'flipset' ), 'error' => __( 'This document could not be loaded.', 'flipset' ), /* translators: 1: current page number, 2: total pages. */ 'pageStatus' => __( 'Page %1$s of %2$s', 'flipset' ), 'passwordPrompt' => __( 'This document is password-protected.', 'flipset' ), 'passwordWrong' => __( 'Incorrect password.', 'flipset' ), 'passwordUnlock' => __( 'Unlock', 'flipset' ), 'passwordLabel' => __( 'Password', 'flipset' ), 'toc' => __( 'Table of contents', 'flipset' ), 'thumbs' => __( 'Page thumbnails', 'flipset' ), 'outlineNav' => __( 'Document outline', 'flipset' ), 'thumbsNav' => __( 'Page thumbnails', 'flipset' ), 'closePanel' => __( 'Close panel', 'flipset' ), 'fitPage' => __( 'Fit page', 'flipset' ), 'fitWidth' => __( 'Fit width', 'flipset' ), /* translators: 1: current match, 2: total matches. */ 'findStatus' => __( '%1$s of %2$s', 'flipset' ), 'findNone' => __( 'No matches', 'flipset' ), 'viewPage' => __( 'Single page', 'flipset' ), 'viewScroll' => __( 'Continuous scroll', 'flipset' ), 'viewSpread' => __( 'Two-page spread', 'flipset' ), 'viewFlip' => __( '3D FlipBook', 'flipset' ), 'pan' => __( 'Hand tool', 'flipset' ), 'select' => __( 'Text select', 'flipset' ), 'more' => __( 'More tools', 'flipset' ), 'present' => __( 'Presentation', 'flipset' ), 'copied' => __( 'Link copied!', 'flipset' ), 'clickToLoad' => __( 'Click to load document', 'flipset' ), 'corsTitle' => __( 'Unable to load document (Cross-Origin Restriction)', 'flipset' ), /* translators: %s: remote host name */ 'corsDesc' => __( 'This PDF is hosted on an external domain (%s) which restricts cross-origin access (CORS).', 'flipset' ), 'corsDownload' => __( 'Open / Download PDF directly', 'flipset' ), 'corsAdminHint' => __( 'Site Admin: To enable direct embedding, configure Access-Control-Allow-Origin headers on the host server or upload the PDF to your WordPress Media Library.', 'flipset' ), ); } /** * Version of the bundled assets/vendor/pdfjs build. The capture snippet only * accepts a global with this exact version as "ours", so a different * plugin's PDF.js is never mistaken for Flipset's. Update it together with * the vendor files. */ const PDFJS_VERSION = '3.11.174'; /** * Run just BEFORE the bundle: remember whatever PDF.js another plugin * already put on the page, because the bundle assigns window.pdfjsLib * unconditionally. */ const PDFJS_BEFORE = '(function(w){var f=w.Flipset=w.Flipset||{};f._prevPdfjs={g:w.pdfjsLib,u:w["pdfjs-dist/build/pdf"]};})(window);'; /** * Run just AFTER the bundle: keep a private reference to our own PDF.js * (window.Flipset.pdfjsLib) and put window.pdfjsLib back exactly as it was * before our bundle ran (another plugin's PDF.js, or unset). Flipset then * leaves no global footprint and other plugins that look for a global * PDF.js load their own. Each bundle is self-contained, so nothing is shared. */ const PDFJS_AFTER = '(function(w){var f=w.Flipset=w.Flipset||{},p=f._prevPdfjs||{},c=w.pdfjsLib,k="pdfjs-dist/build/pdf";delete f._prevPdfjs;if(!f.pdfjsLib&&c&&c!==p.g&&c.version==="' . self::PDFJS_VERSION . '"){f.pdfjsLib=c;if(p.g){w.pdfjsLib=p.g;}else{try{delete w.pdfjsLib;}catch(e){w.pdfjsLib=undefined;}}if(p.u){w[k]=p.u;}else{try{delete w[k];}catch(e){w[k]=undefined;}}}})(window);'; /** * Register the bundled PDF.js once, with the capture snippets attached. * Safe to call from any enqueue path; later calls are no-ops. */ public static function register_pdfjs() { if ( wp_script_is( 'flipset-pdfjs', 'registered' ) ) { return; } wp_register_script( 'flipset-pdfjs', FLIPSET_URL . 'assets/vendor/pdfjs/pdf.min.js', array(), FLIPSET_VERSION, true ); wp_add_inline_script( 'flipset-pdfjs', self::PDFJS_BEFORE, 'before' ); wp_add_inline_script( 'flipset-pdfjs', self::PDFJS_AFTER, 'after' ); } /** * Enqueue the bundled PDF.js build + our thin wrapper script once per * request. */ public function enqueue_assets() { if ( $this->assets_enqueued ) { return; } $this->assets_enqueued = true; wp_enqueue_style( 'flipset-viewer', FLIPSET_URL . 'assets/css/flipset-viewer.css', array(), FLIPSET_VERSION ); self::register_pdfjs(); wp_enqueue_script( 'flipset-pdfjs' ); wp_enqueue_script( 'flipset-viewer', FLIPSET_URL . 'assets/js/flipset-viewer.js', array( 'flipset-pdfjs' ), FLIPSET_VERSION, true ); wp_localize_script( 'flipset-viewer', 'FlipsetConfig', array( 'workerSrc' => FLIPSET_URL . 'assets/vendor/pdfjs/pdf.worker.min.js', 'isPro' => Flipset_Loader::is_pro_active(), 'i18n' => self::viewer_i18n(), ) ); } private function admin_notice( $message ) { if ( ! current_user_can( 'edit_posts' ) ) { return ''; } return '

' . esc_html( $message ) . '

'; } }