| 1 |
<?php |
| 2 |
|
| 3 |
namespace WPDeveloper\BetterDocs\FrontEnd; |
| 4 |
|
| 5 |
/** |
| 6 |
* Print template — the logo header and page footer shown when a visitor prints |
| 7 |
* (or saves as PDF) a page. |
| 8 |
* |
| 9 |
* There are two ways a doc gets printed, and this class feeds both from the |
| 10 |
* same settings so they render identically: |
| 11 |
* |
| 12 |
* 1. The browser's own Print command (Ctrl/Cmd + P) on the live page. Handled |
| 13 |
* here: the markup is emitted on `wp_footer` and pushed into the `@page` |
| 14 |
* margin box with `position: fixed`, which browsers repeat on every printed |
| 15 |
* page. This is the only route available on pages that carry no BetterDocs |
| 16 |
* print button — the API Reference pages, FSE templates, archives, and any |
| 17 |
* other page a reader decides to print. |
| 18 |
* 2. BetterDocs' own print icon, which opens a popup holding just the doc |
| 19 |
* content. That route builds its own header/footer in `betterdocs.js` from |
| 20 |
* `betterdocsConfig.print`, using a table `<tfoot>` — it owns the whole |
| 21 |
* document there, and `<tfoot>` reserves space more reliably than a fixed |
| 22 |
* element when the footer text wraps to several lines. |
| 23 |
* |
| 24 |
* The popup appends its own `@page` rule *after* the styles it copies from this |
| 25 |
* page, so route 2's margins keep winning inside the popup and the two |
| 26 |
* mechanisms never fight. |
| 27 |
* |
| 28 |
* @since 4.9.1 |
| 29 |
*/ |
| 30 |
class PrintTemplate { |
| 31 |
/** |
| 32 |
* Vertical space reserved inside the printed page margins, in millimetres. |
| 33 |
* Keep these in sync with the offsets in {@see self::styles()}. |
| 34 |
*/ |
| 35 |
const HEADER_SPACE = 22; |
| 36 |
const FOOTER_SPACE = 18; |
| 37 |
const EDGE_SPACE = 12; |
| 38 |
|
| 39 |
public function __construct() { |
| 40 |
add_action( 'wp_footer', [ $this, 'render' ], 99 ); |
| 41 |
} |
| 42 |
|
| 43 |
/** |
| 44 |
* Whether the print template should be emitted on the current request. |
| 45 |
* |
| 46 |
* Defaults to every front-end page — a reader can print any page, and the |
| 47 |
* whole point of this route is to cover the ones without a print button. |
| 48 |
* Filter `betterdocs_print_template_enabled` to narrow it, e.g. to docs |
| 49 |
* only: |
| 50 |
* |
| 51 |
* add_filter( 'betterdocs_print_template_enabled', function ( $on ) { |
| 52 |
* return $on && ( is_singular( 'docs' ) || is_post_type_archive( 'docs' ) ); |
| 53 |
* } ); |
| 54 |
* |
| 55 |
* @return bool |
| 56 |
*/ |
| 57 |
public static function is_enabled() { |
| 58 |
$enabled = ! is_admin() && ! is_feed() && ! is_embed(); |
| 59 |
|
| 60 |
return (bool) apply_filters( 'betterdocs_print_template_enabled', $enabled ); |
| 61 |
} |
| 62 |
|
| 63 |
/** |
| 64 |
* Resolved logo URL for the printed page, or '' when disabled/unavailable. |
| 65 |
* |
| 66 |
* Resolution order: the `print_logo` setting, the theme's Custom Logo, the |
| 67 |
* Elementor kit's site logo (themes without custom-logo support keep theirs |
| 68 |
* there), then the Site Icon. |
| 69 |
* |
| 70 |
* @return string |
| 71 |
*/ |
| 72 |
public static function get_logo() { |
| 73 |
$enabled = apply_filters( |
| 74 |
'betterdocs_print_enable_logo', |
| 75 |
betterdocs()->settings->get( 'print_enable_logo', false ) |
| 76 |
); |
| 77 |
|
| 78 |
if ( ! $enabled ) { |
| 79 |
return ''; |
| 80 |
} |
| 81 |
|
| 82 |
$logo = ''; |
| 83 |
|
| 84 |
$setting = betterdocs()->settings->get( 'print_logo' ); |
| 85 |
if ( ! empty( $setting['url'] ) ) { |
| 86 |
$logo = (string) $setting['url']; |
| 87 |
} |
| 88 |
|
| 89 |
if ( '' === $logo ) { |
| 90 |
$logo_id = (int) get_theme_mod( 'custom_logo' ); |
| 91 |
|
| 92 |
if ( ! $logo_id ) { |
| 93 |
$kit_id = (int) get_option( 'elementor_active_kit' ); |
| 94 |
if ( $kit_id ) { |
| 95 |
$kit_settings = get_post_meta( $kit_id, '_elementor_page_settings', true ); |
| 96 |
if ( ! empty( $kit_settings['site_logo']['id'] ) ) { |
| 97 |
$logo_id = (int) $kit_settings['site_logo']['id']; |
| 98 |
} |
| 99 |
} |
| 100 |
} |
| 101 |
|
| 102 |
if ( $logo_id ) { |
| 103 |
$logo = (string) wp_get_attachment_image_url( $logo_id, 'medium' ); |
| 104 |
} |
| 105 |
} |
| 106 |
|
| 107 |
if ( '' === $logo && has_site_icon() ) { |
| 108 |
$logo = get_site_icon_url( 512 ); |
| 109 |
} |
| 110 |
|
| 111 |
return (string) apply_filters( 'betterdocs_print_logo', $logo ); |
| 112 |
} |
| 113 |
|
| 114 |
/** |
| 115 |
* Resolved footer markup for the printed page, or '' when disabled. |
| 116 |
* |
| 117 |
* Falls back to "© <year> <site name>" when the setting is left empty. |
| 118 |
* |
| 119 |
* @return string |
| 120 |
*/ |
| 121 |
public static function get_footer() { |
| 122 |
$enabled = apply_filters( |
| 123 |
'betterdocs_print_enable_footer', |
| 124 |
betterdocs()->settings->get( 'print_enable_footer', false ) |
| 125 |
); |
| 126 |
|
| 127 |
if ( ! $enabled ) { |
| 128 |
return ''; |
| 129 |
} |
| 130 |
|
| 131 |
$footer = (string) betterdocs()->settings->get( 'print_footer_text', '' ); |
| 132 |
|
| 133 |
if ( '' === trim( $footer ) ) { |
| 134 |
$footer = sprintf( '© %1$s %2$s', gmdate( 'Y' ), get_bloginfo( 'name' ) ); |
| 135 |
} |
| 136 |
|
| 137 |
return (string) apply_filters( 'betterdocs_print_footer', $footer ); |
| 138 |
} |
| 139 |
|
| 140 |
/** |
| 141 |
* Fallback print CSS, used when the table wrap below never runs. |
| 142 |
* |
| 143 |
* `position: fixed` is what makes a browser repeat an element on every |
| 144 |
* printed page, and the negative offsets lift the boxes out of the content |
| 145 |
* flow into the margin `@page` reserves for them. |
| 146 |
* |
| 147 |
* This is a fallback and not the primary mechanism because Chrome drops one |
| 148 |
* instance at each end: measured over an 8-page print, the header rendered |
| 149 |
* on pages 1-7 and the footer on pages 2-8 — the last page lost its logo and |
| 150 |
* the first page lost its footer. Laying the boxes out inside the page area |
| 151 |
* instead (`top:0`/`bottom:0`) does repeat on every page, but then the text |
| 152 |
* runs underneath them. `transform: translateY()` in place of the negative |
| 153 |
* offsets behaves identically to the negative offsets. The correct CSS |
| 154 |
* answer — `@page` margin boxes such as `@top-center` — is implemented by no |
| 155 |
* browser, so {@see self::script()} does the job properly and this rule set |
| 156 |
* only covers the case where its events never fire. |
| 157 |
* |
| 158 |
* @param bool $has_logo |
| 159 |
* @param bool $has_footer |
| 160 |
* @return string |
| 161 |
*/ |
| 162 |
protected static function styles( $has_logo, $has_footer ) { |
| 163 |
$top = $has_logo ? self::HEADER_SPACE : self::EDGE_SPACE; |
| 164 |
$bottom = $has_footer ? self::FOOTER_SPACE : self::EDGE_SPACE; |
| 165 |
|
| 166 |
$css = '.betterdocs-print-running-header,.betterdocs-print-running-footer{display:none}' |
| 167 |
. '@media print{' |
| 168 |
. sprintf( '@page{margin:%1$dmm %2$dmm %3$dmm}', $top, self::EDGE_SPACE, $bottom ) |
| 169 |
. '.betterdocs-print-running-header,.betterdocs-print-running-footer{' |
| 170 |
. 'display:block;position:fixed;left:0;right:0;margin:0;padding:0;' |
| 171 |
. 'text-align:center;background:none;border:0}'; |
| 172 |
|
| 173 |
if ( $has_logo ) { |
| 174 |
$css .= '.betterdocs-print-running-header{top:-16mm;height:14mm;line-height:0}' |
| 175 |
. '.betterdocs-print-running-header img{' |
| 176 |
. 'max-height:14mm;max-width:60%;width:auto;height:auto;display:inline-block}'; |
| 177 |
} |
| 178 |
|
| 179 |
if ( $has_footer ) { |
| 180 |
$css .= '.betterdocs-print-running-footer{' |
| 181 |
. 'bottom:-13mm;height:11mm;overflow:hidden;' |
| 182 |
. 'font-size:9pt;line-height:1.35;color:#555}' |
| 183 |
. '.betterdocs-print-running-footer p{margin:0;font-size:inherit;line-height:inherit}'; |
| 184 |
} |
| 185 |
|
| 186 |
return $css . '}'; |
| 187 |
} |
| 188 |
|
| 189 |
/** |
| 190 |
* Print CSS for the wrapped (table) layout. |
| 191 |
* |
| 192 |
* Emitted inert as `media="not all"`; {@see self::script()} flips it to |
| 193 |
* `print` once it has actually built the table, and back again afterwards. |
| 194 |
* Because this sheet comes after the fallback one, its `@page` margin and |
| 195 |
* its `position: static` reset both win while the wrap is in place. |
| 196 |
* |
| 197 |
* Browsers repeat `table-header-group` / `table-footer-group` on every page |
| 198 |
* *and* keep the flowing content clear of them, which is the behaviour the |
| 199 |
* fixed-position fallback cannot deliver. |
| 200 |
* |
| 201 |
* @param bool $has_logo |
| 202 |
* @param bool $has_footer |
| 203 |
* @return string |
| 204 |
*/ |
| 205 |
protected static function wrapped_styles( $has_logo, $has_footer ) { |
| 206 |
$css = sprintf( '@page{margin:%1$dmm}', self::EDGE_SPACE ) |
| 207 |
. '.betterdocs-print-running-header,.betterdocs-print-running-footer{' |
| 208 |
. 'display:block;position:static;top:auto;bottom:auto;left:auto;right:auto;' |
| 209 |
. 'height:auto;transform:none;text-align:center}' |
| 210 |
. '#betterdocs-print-layout{width:100%;border-collapse:collapse;border:0}' |
| 211 |
. '#betterdocs-print-layout>thead>tr>td,' |
| 212 |
. '#betterdocs-print-layout>tfoot>tr>td,' |
| 213 |
. '#betterdocs-print-layout>tbody>tr>td{padding:0;border:0}' |
| 214 |
. '#betterdocs-print-layout>thead{display:table-header-group}' |
| 215 |
. '#betterdocs-print-layout>tfoot{display:table-footer-group}'; |
| 216 |
|
| 217 |
if ( $has_logo ) { |
| 218 |
$css .= '.betterdocs-print-running-header{padding:0 0 6mm;line-height:0}' |
| 219 |
. '.betterdocs-print-running-header img{' |
| 220 |
. 'max-height:16mm;max-width:60%;width:auto;height:auto;display:inline-block}'; |
| 221 |
} |
| 222 |
|
| 223 |
if ( $has_footer ) { |
| 224 |
$css .= '.betterdocs-print-running-footer{' |
| 225 |
. 'padding:5mm 0 0;font-size:9pt;line-height:1.35;color:#555}' |
| 226 |
. '.betterdocs-print-running-footer p{margin:0;font-size:inherit;line-height:inherit}'; |
| 227 |
} |
| 228 |
|
| 229 |
return $css; |
| 230 |
} |
| 231 |
|
| 232 |
/** |
| 233 |
* The wrap/unwrap helper. |
| 234 |
* |
| 235 |
* Inlined rather than shipped as a bundle on purpose: it has to be present |
| 236 |
* on every front-end page, and a separate request site-wide for a |
| 237 |
* print-only helper of this size costs more than it saves. |
| 238 |
* |
| 239 |
* On `beforeprint` it moves the page's rendered content into a single table |
| 240 |
* cell, with the logo as `<thead>` and the footer as `<tfoot>`, then |
| 241 |
* activates {@see self::wrapped_styles()}. On `afterprint` it puts |
| 242 |
* everything back. `matchMedia('print')` covers Safari, which has no |
| 243 |
* `beforeprint`; the `state` guard makes a double fire harmless. |
| 244 |
* |
| 245 |
* @return string |
| 246 |
*/ |
| 247 |
protected static function script() { |
| 248 |
return <<<'JS' |
| 249 |
(function(){ |
| 250 |
"use strict"; |
| 251 |
var SKIP={SCRIPT:1,STYLE:1,LINK:1,TEMPLATE:1,NOSCRIPT:1},state=null; |
| 252 |
function cell(parent,tag){ |
| 253 |
var section=document.createElement(tag),row=document.createElement("tr"),td=document.createElement("td"); |
| 254 |
row.appendChild(td);section.appendChild(row);parent.appendChild(section);return td; |
| 255 |
} |
| 256 |
function wrap(){ |
| 257 |
if(state||!document.body){return;} |
| 258 |
var header=document.querySelector(".betterdocs-print-running-header"); |
| 259 |
var footer=document.querySelector(".betterdocs-print-running-footer"); |
| 260 |
if(!header&&!footer){return;} |
| 261 |
var body=document.body,content=[],node=body.firstChild; |
| 262 |
while(node){ |
| 263 |
if(node!==header&&node!==footer&&!(node.nodeType===1&&SKIP[node.tagName])){content.push(node);} |
| 264 |
node=node.nextSibling; |
| 265 |
} |
| 266 |
if(!content.length){return;} |
| 267 |
var table=document.createElement("table"); |
| 268 |
table.id="betterdocs-print-layout"; |
| 269 |
if(header){cell(table,"thead").appendChild(header);} |
| 270 |
if(footer){cell(table,"tfoot").appendChild(footer);} |
| 271 |
var host=cell(table,"tbody"); |
| 272 |
body.insertBefore(table,content[0]); |
| 273 |
for(var i=0;i<content.length;i++){host.appendChild(content[i]);} |
| 274 |
var sheet=document.getElementById("betterdocs-print-template-wrapped"); |
| 275 |
if(sheet){sheet.media="print";} |
| 276 |
state={table:table,host:host,header:header,footer:footer,sheet:sheet}; |
| 277 |
} |
| 278 |
function unwrap(){ |
| 279 |
if(!state){return;} |
| 280 |
var body=document.body,table=state.table; |
| 281 |
while(state.host.firstChild){body.insertBefore(state.host.firstChild,table);} |
| 282 |
if(state.header){body.insertBefore(state.header,table);} |
| 283 |
if(state.footer){body.insertBefore(state.footer,table);} |
| 284 |
body.removeChild(table); |
| 285 |
if(state.sheet){state.sheet.media="not all";} |
| 286 |
state=null; |
| 287 |
} |
| 288 |
window.addEventListener("beforeprint",wrap); |
| 289 |
window.addEventListener("afterprint",unwrap); |
| 290 |
if(window.matchMedia){ |
| 291 |
var mq=window.matchMedia("print"),onChange=function(e){e.matches?wrap():unwrap();}; |
| 292 |
if(mq.addEventListener){mq.addEventListener("change",onChange);} |
| 293 |
else if(mq.addListener){mq.addListener(onChange);} |
| 294 |
} |
| 295 |
})(); |
| 296 |
JS; |
| 297 |
} |
| 298 |
|
| 299 |
/** |
| 300 |
* Emit the running header/footer, their print styles, and the wrap helper |
| 301 |
* just before </body>. |
| 302 |
* |
| 303 |
* @return void |
| 304 |
*/ |
| 305 |
public function render() { |
| 306 |
if ( ! self::is_enabled() ) { |
| 307 |
return; |
| 308 |
} |
| 309 |
|
| 310 |
$logo = self::get_logo(); |
| 311 |
$footer = self::get_footer(); |
| 312 |
|
| 313 |
if ( '' === $logo && '' === $footer ) { |
| 314 |
return; |
| 315 |
} |
| 316 |
|
| 317 |
$has_logo = '' !== $logo; |
| 318 |
$has_footer = '' !== $footer; |
| 319 |
|
| 320 |
if ( $has_logo ) { |
| 321 |
printf( |
| 322 |
'<div class="betterdocs-print-running-header" aria-hidden="true"><img src="%s" alt="" /></div>', |
| 323 |
esc_url( $logo ) |
| 324 |
); |
| 325 |
} |
| 326 |
|
| 327 |
if ( $has_footer ) { |
| 328 |
printf( |
| 329 |
'<div class="betterdocs-print-running-footer" aria-hidden="true">%s</div>', |
| 330 |
wp_kses_post( $footer ) |
| 331 |
); |
| 332 |
} |
| 333 |
|
| 334 |
printf( |
| 335 |
'<style id="betterdocs-print-template">%s</style>', |
| 336 |
self::styles( $has_logo, $has_footer ) |
| 337 |
); |
| 338 |
|
| 339 |
printf( |
| 340 |
'<style id="betterdocs-print-template-wrapped" media="not all">%s</style>', |
| 341 |
self::wrapped_styles( $has_logo, $has_footer ) |
| 342 |
); |
| 343 |
|
| 344 |
wp_print_inline_script_tag( self::script(), [ 'id' => 'betterdocs-print-template-js' ] ); |
| 345 |
} |
| 346 |
} |
| 347 |
|