Web-Print/extensions.php
2026-10-16 16:33:25 -04:00

209 lines
12 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?php
/**
* Optional add-ons for print.php. Keep this file next to print.php (index.php).
* Delete it and the portal still works; set any switch below to false to turn a feature off.
*
* Needs for Word/Excel/PowerPoint: sudo apt install libreoffice-writer libreoffice-calc libreoffice-impress
* Needs for preview: pdf.js files in ./vendor/pdfjs/ (see the setup commands). Without them,
* preview falls back to opening the file in a new browser tab.
*/
declare(strict_types=1);
const EXT_OFFICE = true; // convert Word/Excel/PowerPoint/OpenDocument files to PDF, then print
const EXT_OFFICE_TYPES = ['docx', 'doc', 'odt', 'rtf', 'xlsx', 'xls', 'ods', 'pptx', 'ppt', 'odp'];
const EXT_SOFFICE = '/usr/bin/libreoffice';
const EXT_TIMEOUT = 90; // seconds allowed per conversion
const EXT_PREVIEW = true; // in-page preview (PDF, photos, text) before printing
const EXT_VENDOR = __DIR__ . '/vendor/pdfjs';
const EXT_PAGES_PER_SHEET = true; // "Pages per sheet" option (saves paper)
function ext_boot(): void {
if (!EXT_PREVIEW) { return; }
// Widens the page policy just enough for the bundled PDF viewer. Keep in step with print.php.
// header("Content-Security-Policy: default-src 'none'; style-src 'unsafe-inline'; script-src 'unsafe-inline'; form-action 'self'; base-uri 'none'; frame-ancestors 'self'");
if (isset($_GET['view'])) { ext_serve((string)$_GET['view']); }
if (isset($_GET['asset'])) { ext_asset((string)$_GET['asset']); }
}
function ext_accept(): string { return EXT_OFFICE ? ',.' . implode(',.', EXT_OFFICE_TYPES) : ''; }
function ext_hint(): string { return EXT_OFFICE ? ', Word, Excel, slides' : ''; }
function ext_form_html(): string {
if (!EXT_PAGES_PER_SHEET) { return ''; }
$opts = '';
foreach ([1 => '1 (normal)', 2 => '2', 4 => '4', 6 => '6', 9 => '9'] as $n => $label) {
$opts .= '<option value="' . $n . '">' . $label . '</option>';
}
return '<label class="f">Pages per sheet<select name="nup">' . $opts . '</select></label>';
}
function ext_lp_args(string $ext): array {
$n = (int)($_POST['nup'] ?? 1);
return (EXT_PAGES_PER_SHEET && in_array($n, [2, 4, 6, 9], true)) ? ['-o', 'number-up=' . $n] : [];
}
function ext_file_html(string $name): string {
if (!EXT_PREVIEW) { return ''; }
return '<a class="mini" href="?view=' . rawurlencode($name) . '" data-preview="' . htmlspecialchars($name, ENT_QUOTES) . '" target="_blank" rel="noopener">Preview</a>';
}
/**
* Called for every upload. Returns null to leave the file alone, ['error' => msg] on failure,
* or ['path' => converted pdf, 'name' => new filename] after converting an Office file.
*/
function ext_convert(string $tmp, string $orig): ?array {
$ext = strtolower(pathinfo($orig, PATHINFO_EXTENSION));
if (!EXT_OFFICE || !in_array($ext, EXT_OFFICE_TYPES, true)) { return null; }
if (!is_executable(EXT_SOFFICE)) { return ['error' => 'can’t be converted: LibreOffice isn’t installed on the server.']; }
@set_time_limit(EXT_TIMEOUT * 2);
$tmpdir = sys_get_temp_dir();
$profile = $tmpdir . '/print-lo-profile';
$work = $tmpdir . '/print-lo-' . bin2hex(random_bytes(6));
$lock = fopen($tmpdir . '/print-lo.lock', 'c');
if ($lock) { flock($lock, LOCK_EX); } // LibreOffice allows one run per profile at a time
$result = ['error' => 'could not be converted to PDF. Try saving it as a PDF first.'];
if (@mkdir($work, 0700, true) && copy($tmp, $work . '/document.' . $ext)) {
$cmd = ['/usr/bin/timeout', '-k', '5', (string)EXT_TIMEOUT, EXT_SOFFICE,
'-env:UserInstallation=file://' . $profile, '--headless', '--norestore',
'--convert-to', 'pdf', '--outdir', $work, $work . '/document.' . $ext];
$p = @proc_open($cmd, [1 => ['pipe', 'w'], 2 => ['pipe', 'w']], $pipes, $work,
['PATH' => '/usr/bin:/bin', 'HOME' => $work, 'LC_ALL' => 'C.UTF-8']);
if (is_resource($p)) {
stream_get_contents($pipes[1]); stream_get_contents($pipes[2]);
fclose($pipes[1]); fclose($pipes[2]); proc_close($p);
}
$pdf = $work . '/document.pdf';
$dest = tempnam($tmpdir, 'prt');
if (is_file($pdf) && filesize($pdf) > 0 && $dest !== false && copy($pdf, $dest)) {
$result = ['path' => $dest, 'name' => cut(pathinfo($orig, PATHINFO_FILENAME), 80) . '.pdf'];
} else {
ext_rmdir($profile); // a crashed run can leave a broken profile; start fresh next time
}
}
ext_rmdir($work);
if ($lock) { flock($lock, LOCK_UN); fclose($lock); }
return $result;
}
function ext_css(): string {
return '[hidden]{display:none!important}'
. '@media (max-width:480px){.file{flex-wrap:wrap}.fn{flex-basis:100%}}'
. '#pv{position:fixed;inset:0;z-index:50;display:flex;flex-direction:column;background:var(--bg)}'
. '.pvbar{display:flex;align-items:center;gap:8px;padding:max(10px,env(safe-area-inset-top)) max(12px,env(safe-area-inset-right)) 10px max(12px,env(safe-area-inset-left));background:var(--card);border-bottom:1px solid var(--line)}'
. '.pvbar b{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}'
. '.pvbar span{color:var(--mute);font-size:.88rem;white-space:nowrap}'
. '#pvbody{flex:1;overflow:auto;padding:12px;text-align:center;-webkit-overflow-scrolling:touch}'
. '#pvbody canvas,#pvbody img{display:block;height:auto;max-width:100%;margin:0 auto 12px;background:#fff;border:1px solid var(--line)}'
. '#pvbody pre{margin:0 auto;max-width:90ch;text-align:left;white-space:pre-wrap;overflow-wrap:anywhere;font:.9rem/1.45 ui-monospace,monospace;color:var(--ink)}';
}
/** Preview overlay + small scripts (remembers each device's option choices). */
function ext_page_html(): string {
$preview = !EXT_PREVIEW ? '' : <<<'HTML'
<div id="pv" hidden role="dialog" aria-modal="true" aria-label="Preview">
<div class="pvbar"><b id="pvname"></b><span id="pvinfo"></span>
<button type="submit" class="mini p" id="pvprint" form="f" name="reprint" value="" hidden>Print</button>
<button type="button" class="mini" id="pvclose">Close</button></div>
<div id="pvbody"></div>
</div>
<script>
(function(){
var pv=document.getElementById('pv'), body=document.getElementById('pvbody'), nm=document.getElementById('pvname'),
info=document.getElementById('pvinfo'), pp=document.getElementById('pvprint'), MAXP=40, lib=null, blobUrl=null, token=0;
function abs(q){return new URL(q,location.href).href;}
function kind(n){var e=(n.split('.').pop()||'').toLowerCase();return e==='pdf'?'pdf':e==='txt'?'txt':/^(jpe?g|png|gif)$/.test(e)?'img':'';}
function pdfjs(){if(!lib){lib=import(abs('?asset=pdf.min.mjs')).then(function(m){m.GlobalWorkerOptions.workerSrc=abs('?asset=pdf.worker.min.mjs');return m;});}return lib;}
function close(){token++;pv.hidden=true;body.innerHTML='';document.body.style.overflow='';if(blobUrl){URL.revokeObjectURL(blobUrl);blobUrl=null;}}
function fail(t,url,saved){
if(t!==token)return; lib=null; body.innerHTML='<p>Preview isn’t available for this file.</p>';
if(saved){var a=document.createElement('a');a.className='mini';a.href=url;a.target='_blank';a.rel='noopener';a.textContent='Open in a new tab';body.appendChild(a);}
}
function show(name,url,saved){
var k=kind(name),t=++token;
nm.textContent=name;info.textContent='';body.innerHTML='';pp.value=saved||'';pp.hidden=!saved;
pv.hidden=false;document.body.style.overflow='hidden';
if(k==='img'){var im=new Image();im.alt=name;im.src=url;body.appendChild(im);return;}
if(k==='txt'){fetch(url).then(function(r){return r.text();}).then(function(s){if(t!==token)return;var pre=document.createElement('pre');pre.textContent=s.slice(0,200000);body.appendChild(pre);}).catch(function(){fail(t,url,saved);});return;}
pdfjs().then(function(m){return m.getDocument({url:url}).promise;}).then(async function(doc){
var n=Math.min(doc.numPages,MAXP);
info.textContent=doc.numPages+(doc.numPages===1?' page':' pages')+(doc.numPages>n?' (showing first '+n+')':'');
for(var i=1;i<=n;i++){
var pg=await doc.getPage(i); if(t!==token)return;
var d=Math.min(window.devicePixelRatio||1,2),w=Math.min(body.clientWidth-24,900),b=pg.getViewport({scale:1}),
vp=pg.getViewport({scale:(w/b.width)*d}),c=document.createElement('canvas');
c.width=vp.width;c.height=vp.height;c.style.width=(vp.width/d)+'px';body.appendChild(c);
await pg.render({canvasContext:c.getContext('2d'),viewport:vp}).promise;
}
}).catch(function(){fail(t,url,saved);});
}
document.addEventListener('click',function(e){
var a=e.target.closest&&e.target.closest('[data-preview]'); if(!a)return;
e.preventDefault(); var n=a.getAttribute('data-preview'); show(n,abs('?view='+encodeURIComponent(n)),n);
});
document.getElementById('pvclose').addEventListener('click',close);
document.addEventListener('keydown',function(e){if(e.key==='Escape'&&!pv.hidden)close();});
var inp=document.getElementById('files'),list=document.getElementById('picked');
if(inp&&list)inp.addEventListener('change',function(){
Array.prototype.forEach.call(inp.files,function(file,i){
var li=list.children[i]; if(!li||!kind(file.name))return;
var b=document.createElement('button');b.type='button';b.className='mini';b.style.marginLeft='8px';b.textContent='Preview';
b.addEventListener('click',function(){if(blobUrl)URL.revokeObjectURL(blobUrl);blobUrl=URL.createObjectURL(file);show(file.name,blobUrl,'');});
li.appendChild(b);
});
});
})();
</script>
HTML;
$remember = <<<'HTML'
<script>
(function(){
var F=document.getElementById('f'); if(!F)return;
try{
['duplex','mono'].forEach(function(n){var el=F.elements[n]; if(!el)return;
var v=localStorage.getItem('lanprint:'+n); if(v!==null)el.checked=(v==='1');
el.addEventListener('change',function(){try{localStorage.setItem('lanprint:'+n,el.checked?'1':'0');}catch(e){}});});
['nup','printer'].forEach(function(n){var el=F.elements[n]; if(!el)return;
var v=localStorage.getItem('lanprint:'+n);
if(v!==null)Array.prototype.forEach.call(el.options,function(o){if(o.value===v)el.value=v;});
el.addEventListener('change',function(){try{localStorage.setItem('lanprint:'+n,el.value);}catch(e){}});});
}catch(e){}
})();
</script>
HTML;
return $preview . "\n" . $remember;
}
// ---------- Internals ----------
function ext_serve(string $name): void {
$path = stored_path($name);
$ext = $path === null ? '' : strtolower(pathinfo($path, PATHINFO_EXTENSION));
if ($path === null || !isset(ALLOWED[$ext])) { http_response_code(404); exit('Not found'); }
header_remove('Content-Security-Policy');
header('Content-Type: ' . ALLOWED[$ext][0] . ($ext === 'txt' ? '; charset=utf-8' : ''));
header('Content-Length: ' . (string)filesize($path));
header("Content-Disposition: inline; filename*=UTF-8''" . rawurlencode(basename($path)));
readfile($path);
exit;
}
function ext_asset(string $name): void {
$file = EXT_VENDOR . '/' . $name;
if (!in_array($name, ['pdf.min.mjs', 'pdf.worker.min.mjs'], true) || !is_file($file)) { http_response_code(404); exit('Not found'); }
header_remove('Content-Security-Policy');
header('Content-Type: text/javascript; charset=utf-8');
header('Cache-Control: public, max-age=86400');
header('Content-Length: ' . (string)filesize($file));
readfile($file);
exit;
}
function ext_rmdir(string $dir): void {
if (!is_dir($dir)) { return; }
$it = new RecursiveIteratorIterator(new RecursiveDirectoryIterator($dir, FilesystemIterator::SKIP_DOTS), RecursiveIteratorIterator::CHILD_FIRST);
foreach ($it as $f) { ($f->isDir() && !$f->isLink()) ? @rmdir($f->getPathname()) : @unlink($f->getPathname()); }
@rmdir($dir);
}