UNPKG

@xiee/utils

Version:

Miscellaneous tools and utilities to manipulate HTML pages

394 lines (379 loc) 16 kB
// move elements into page boxes such that each box contains as many elements as // possible and will not exceed the given size (e.g. A4 paper) (d => { function $(s, el = d) { return el?.querySelector(s); } function $$(s, el = d) { return el ? el.querySelectorAll(s) : []; } function nChild(el) { return el.childElementCount; } const tpl = d.createElement('div'), book = $$('h1').length > 1, boxes = [], fr_tag = ['TABLE', 'UL', 'OL', 'BLOCKQUOTE'], fr_cls = 'pagesjs-fragmented', fr_1 = 'fragment-first', fr_2 = 'fragment-last', tb = ['top', 'bottom'].map(i => parseFloat(getComputedStyle(d.documentElement).getPropertyValue(`--paper-margin-${i}`)) || 0 ); // top/bottom page margin tpl.className = 'pagesjs-page'; tpl.innerHTML = `<div class="pagesjs-header"></div> <div class="pagesjs-body"></div> <div class="pagesjs-footer"></div>`; let box, box_body, box_cls = [], H, H_min, h_code, l_code = []; function newPage(el) { el && !$('.pagesjs-body', el) && el.insertAdjacentHTML('afterbegin', tpl.innerHTML); box = el || tpl.cloneNode(true); box_body = box.children[1]; box.classList.add(...box_cls); boxes.includes(box) || boxes.push(box); // store new pages in boxes return box; } // start the next page and finish current page function nextPage(el, callback) { const cur = box; cur.after(newPage(el)); callback && callback(); finish(cur); } // compute page numbers and temporarily remove the box function finish(box) { const h = box.scrollHeight; if (h > H && !box.dataset.pagesOffset) { const n = calcPages(box, h); if (n > 1) box.dataset.pagesOffset = n; } box.remove(); } function removeBlank(el) { if (!el || el.tagName === 'BODY') return false; // for <p>, don't treat it as empty unless its HTML is empty const v = !(el.tagName === 'P' ? el.innerHTML : el.innerText).trim(); v && el.remove(); return v; } function fill(el) { // if the element is already a page, just use it as the box if (el.classList.contains('pagesjs-page')) { nextPage(el, () => nChild(el) > 3 && ( // if current element is not empty, fill its content into the box box_body.append(...[...el.children].slice(3)), // TODO: should we fragment this page if it's too long? nextPage() // create a new empty page )); } else { if (el.innerText.trim() && H - box.scrollHeight < H_min) nextPage(); box_body.append(el); if (box.scrollHeight > H) { // temporarily remove el from DOM if it can be fragmented, otherwise // simply move it to the next page breakable(el) ? (el.tagName !== 'P' && el.remove(), fragment(el)) : nextPage(0, () => box_body.append(el)); } else { // TODO: remove possibly duplicated citations: .citation + div > [id^="ref-"] } } } let fill_num; // record the number of lines filled last time function fillCode(el, i) { el.innerHTML = l_code.slice(0, i).join('\n'); fill_num = i; } function breakable(el) { l_code = []; const t = el.tagName, c = el.firstElementChild; if (t === 'P' || fr_tag.includes(t)) return true; if (t === 'DIV') { const cs = el.children; return (cs.length === 1 && fr_tag.includes(c?.tagName)) || [...cs].filter(c => c.tagName === 'TABLE').length; } if (t !== 'PRE') return false; if (c?.tagName !== 'CODE') return false; // store all lines in l_code (TODO: ensure complete tags on each line) l_code = c.innerHTML.replace(/\n$/, '').split('\n'); const n_code = l_code.length; if (n_code < 2) return false; h_code = c.offsetHeight / n_code; // approx line height c.innerHTML = ''; // temporarily empty <code>; will use l_code return true; } // break elements that are relatively easy to break (such as <ul>) function fragment(el, container, parent) { let cls = el.classList; const tag = el.tagName, frag = cls.contains(fr_cls); parent ? cls.add(fr_cls) : (frag ? cls.remove(fr_1) : cls.add(fr_cls, fr_1)); let el2 = el.cloneNode(); // shallow clone (wrapper only) (container || box_body).append(el2); if (tag === 'P') { // fragmentation occurs in el instead of the clone el2, so swap them splitP(el, el2) || ([el, el2] = [el2, el], cls = el.classList); } const prev = el2.previousElementSibling || container?.previousElementSibling; function fragChildren(action) { for (let item of [...el.children]) { el2.append(item); if (box.scrollHeight > H) { action(item); break; } } } if (tag === 'DIV') { // fragment <div>'s children (e.g., #TOC > ul) fragChildren(item => { // fragment item if it can be further fragmented, otherwise put it back el.prepend(item); fr_tag.includes(item.tagName) ? fragment(item, el2, el) : nChild(el2) && nextPage(); }); } else if (tag === 'PRE') { fragment(el.firstElementChild, el2, el); } else if (tag === 'CODE') { // split lines in <code> and try to move as many lines into el2 as possible const i = splitCode(el2); // i == 0 means not enough space to split code on current page i === fill_num || fillCode(el2, i); l_code.splice(0, i); if (i > 0) { l_code.join('').trim() === '' ? (l_code = []) : nextPage(); } } else if (tag === 'TABLE') { // when el has no rows left and el2 is not empty, clear el const has_rows = splitTable(el, el2, prev); el2.innerHTML && (has_rows ? nextPage() : (el.innerHTML = '')); } else { // keep moving el's first item to el2 until page height > H fr_tag.slice(1).includes(tag) && fragChildren(item => { // move item back to el if the clone el2 is not the only element on page or has more than one child (prev || nChild(el2) > 1) && el.prepend(item); // update the start number of <ol> on next page tag === 'OL' && (el.start += nChild(el2)); // don't open new page if el2 is empty (will open one below when testing el2_empty) nChild(el2) && nextPage(); }); } const el_empty = !l_code.length && removeBlank(el), el2_empty = removeBlank(el2); // add/remove the fragment-* class and exit if el has become empty if (el_empty) { const cls2 = el2.classList; if (parent) { container.classList.contains(fr_1) && cls2.remove(fr_cls); } else { cls2.contains(fr_1) ? cls2.remove(fr_cls, fr_1) : cls2.add(fr_2); } return; } // if el2 is empty, it means nothing in el fits the box; we have to start a new box if (el2_empty) { cls.remove(fr_cls, fr_1); parent || nextPage(); } // keep fragmenting the remaining top-level el when el2 is not empty or not first element if ((!el2_empty || prev) && !parent) fragment(el); } // split table rows function splitTable(el, el2, prev) { const tb = el.tBodies[0], tb2 = tb.cloneNode(), rows = [...tb.rows], th = el.tHead; el2.append(tb2); // copy table header and footer [th, el.tFoot].forEach(t => t && el2.append(t.cloneNode(true))); for (let i = 0; i < rows.length; i++) { const row = rows[i]; tb2.append(row); if (box.offsetHeight > H) { if (i > 0 || prev) tb.prepend(row); // clear the clone if current page can't fit even one row if (i === 0 && prev) el2.innerHTML = ''; break; } } return tb.rows.length; } // figure out how many lines can fit the box via bisection function splitCode(el) { let i = i1 = 1, n = l_code.length, i2 = n + 2; const sols = []; // solutions tried while (i2 > i1 + 1) { fillCode(el, i); const delta = H - box.offsetHeight; if (delta === 0) return i; if (delta < 0) { if (i <= 1) return 0; i2 = i; } else { if (i >= n) return i; i1 = i; } sols.push(i); // estimate the number of (more or less) lines needed const i3 = i + Math.round(delta / h_code); // if a solution has been tried, shorten step and (in/de)crement by 1 i = sols.includes(i3) ? i + (delta > 0 ? 1 : -1) : i3; (delta > 0 && i >= n) && (i2 = n + 2, i = n); // solution may be n } return i1; } // split <p> by testing if a range fits the current box function splitP(el, el2) { if (!el.parentNode) { box_body.append(el); // move el into DOM to measure chars if removed // see if we don't need to split it now if (box.scrollHeight <= H) return; } const r = d.createRange(), ends = [{node: el, i: 0}]; // find the break position of each line (exclude block elements like footnotes) const walker = d.createTreeWalker(el, NodeFilter.SHOW_TEXT | NodeFilter.SHOW_ELEMENT); let node, prev; function collectEnds(el, i) { const rect = el.getBoundingClientRect(); // if the previous el is at top-right of current el, assume line // ends before current el (TODO: how about RTL languages?) prev && prev.bottom <= rect.top && prev.right > rect.left && ends.push({node, i}); prev = rect; } while (node = walker.nextNode()) { const t = node.tagName; if (t) { if (['DIV', 'MJX-CONTAINER'].includes(t) || (t === 'SPAN' && $('.katex', node))) { node = walker.nextSibling(); // skip DIVs (footnotes) and math if (!node) break; } if (node.firstChild) continue; // for element nodes, traverse to bottom } if (node.tagName) { collectEnds(node, -1); } else { const txt = node.textContent; if (txt.trim()) for (let i = 0; i < txt.length; i++) { r.setStart(node, i); r.setEnd(node, i + 1); collectEnds(r, i); } } } if (ends.length < 2) { el.remove(); return 1; // single-line paragraph } el2.remove(); // remove lines from the end of paragraph for (let i = ends.length - 1; i >= 0; i--) { const loc = ends[i]; loc.i < 0 ? r.setStartBefore(loc.node) : r.setStart(loc.node, loc.i); r.setEnd(el, el.childNodes.length); el2.prepend(r.extractContents()); const last = el.lastChild; // strip trailing empty tags left by range extraction last?.innerHTML === '' && last.remove(); if (i > 0 && box.scrollHeight <= H) { nextPage(); break; } } } // use data-short-title of a header if exists, and fall back to inner text function shortTitle(h) { return h && (h.dataset.shortTitle || h.innerText); } const main = shortTitle($('h1.title, .frontmatter h1, .title, h1')), // main title ps = (book ? 'h1' : 'h2') + ':not(.frontmatter *)'; // page title selector // calculate how many new pages we need for overflowed content (this is just // an estimate; if not accurate, use <div class="pagesjs-page" data-pages-offset="N"> // to provide a number manually) function calcPages(box, h) { let n = +box.dataset.pagesOffset; if (n) return n; n = Math.ceil(h/H); if (n <= 1) return n; // consider top/bottom page margin and table headers (which may be repeated on each page) const m = tb.concat([...$$('thead', box)].map(el => +el.offsetHeight)).reduce((m1, m2) => m1 + m2); if (!m) return n; function nPages() { return Math.ceil((h + (n - 1) * m)/H); } let n2 = nPages(); while (n2 > n) { n = n2; n2 = nPages(); } return n; } function paginate() { // we need to wait for all resources to be fully loaded before paginating if (d.readyState !== 'complete') return addEventListener('load', paginate); const cls = d.body.classList; if (cls.contains('pagesjs')) return; // already paginated dispatchEvent(new Event('pagesjs:before')); cls.add('pagesjs'); d.body.insertAdjacentElement('afterbegin', newPage()); H = box.clientHeight || window.innerHeight; // use window height if box height not specified // remove possible classes on TOC/footnotes that we don't need for printing $$(':is(#TOC, .footnotes, .chapter-before, .chapter-after):is(.side-left, .side-right).side').forEach(el => { el.classList.remove('side', 'side-left', 'side-right'); }); cls.add('pagesjs-filling'); // test how much space a single-line paragraph needs const p = d.createElement('p'), H0 = box.scrollHeight; p.innerText = 'A'; box_body.append(p); H_min = box.scrollHeight - H0; p.remove(); // add dot leaders to TOC $$('#TOC a[href^="#"]').forEach(a => { const s = d.createElement('span'); // move TOC item content into a span s.className = 'toc-text'; a.append(s); let c; while (c = s.previousSibling) { // exclude section number spans if (c.classList?.contains('section-number')) break; s.prepend(c); } a.dataset.pageNumber = '000'; // placeholder for page numbers }); // temporarily move all elements out of DOM (to speed up rendering single pages) const els = []; $$('.body').forEach(el => { // move <style>/<link> into <head> so styles can be applied globally const ch = [...el.children] .filter(el => !['STYLE', 'LINK'].includes(el.tagName) || d.body.prepend(el)); els.push(ch); ch.forEach(el => el.remove()); }); // iteratively add elements to pages $$('.frontmatter, .abstract, #TOC:not(.chapter-toc)').forEach(el => { (fill(el), book && nextPage()); }); $$('.body').forEach((el, i) => { // preserve book chapter classes if exist box_cls = ['chapter', 'appendix'].filter(i => el.classList.contains(i)); book && (box.innerText === '' ? newPage(box) : nextPage()); els[i].forEach(fill); // clean up container and self if empty removeBlank(el.parentNode); removeBlank(el); }); finish(box); // finish the last box cls.remove('pagesjs-filling'); // add page number, title, etc. to data-* attributes of page elements let page_title, i = 0; boxes.forEach(box => { if (book) { if ($('.frontmatter', box)) return; // skip book frontmatter page $(ps, box) && (page_title = ''); // empty title for first page of chapter } const N = +box.dataset.pagesOffset || 1; if (N > 1) box.classList.add('page-multiple'); i += N; box.classList.add(`page-${i % 2 === 0 ? 'even' : 'odd'}`); const info = { 'pageNumber': i, 'mainTitle': main, 'pageTitle': page_title }; [box.children[0], box.children[2]].forEach(el => { for (const key in info) info[key] && (el.dataset[key] = info[key]); }); // find page title for next page page_title = shortTitle([...$$(ps, box)].pop()) || page_title; let ft; // first footnote on page // move all footnotes after the page body $$('.footnotes', box).forEach((el, i) => { i === 0 ? (ft = el, box.children[1].after(el)) : (ft.append(...el.children), el.remove()); }); }); // unhide all pages d.body.prepend(...boxes); // add page numbers to TOC with data-* attributes $$('#TOC a[href^="#"]').forEach(a => { const id = CSS.escape(a.getAttribute('href').replace(/^#/, '')), p = $(`.pagesjs-page:has(#${id}) .pagesjs-header`); a.dataset.pageNumber = p ? p.dataset.pageNumber : ''; }); dispatchEvent(new Event('pagesjs:after')); } addEventListener('beforeprint', paginate); // use query param ?paged=1 to indicate pagination (press p to toggle) const SP = new URLSearchParams(location.search); SP.get('paged') && paginate(); addEventListener('keypress', e => { if (e.key === 'p') { if (SP.get('paged')) { SP.delete('paged'); location.href = location.pathname + (SP.size ? `?${SP}` : ''); } else { paginate(); SP.set('paged', 1); history.replaceState({}, '', `${location.pathname}?${SP}`); } } }); })(document);