diora-web/static/js/app/54-issues.js
mrwnslz c2a9873b0d PDF-Reader: ein Zeichenweg, Seitenmodus neu, Seitensprung für beide Modi (WIP, ungetestet)
- 58-pdf.js: _pdfDraw – eine Zeichnung je Canvas (die vorige wird
  abgebrochen und abgewartet), Pixeldichte, höchstens 16,7 MP; Seiten, die
  gehen, geben auch pdf.js' Seitendaten frei; ein gemeinsamer pdf.js-Worker
  (_pdfLoad) auch für Uploads, Seitenzahl, Hefte und Cover; Inhalts-
  verzeichnis parallel aufgelöst, auch mit Seitenindex als Ziel;
  ResizeObserver: Drehen, Breite, Leisten – neu an derselben Seite.
- 66-pdf-paginated.js: Seitenmodus auf _pdfDraw, Zuschnitt in
  Bildschirmkoordinaten (gedrehte Seiten) und höchstens 2,5× der ganzen
  Seite (eine fast leere Seite wird nicht unendlich groß); nächste und
  vorige Seite vorgezeichnet; Tippen zum Blättern blendet die Leisten
  nicht mehr ein; Wischen nur deutlich seitwärts; Mausrad; Pinch setzt die
  Vorschau zurück; nach dem Blättern wird gespeichert und 'diora-pdf-turn'
  gemeldet (E-Ink-Refresh).
- pdfGoToPage für beide Modi: Inhaltsverzeichnis, Seitenzahl im Kopf,
  Lesezeichen, Tasten (Seitenmodus jetzt auch Leertaste, Bild auf/ab).
- Beim Scrollen wird die aktuelle Seite mitgeführt (Lesezeichen sagten
  immer „Seite 1“).
- Schalter Scrollen | Seiten im PDF-Panel; Zoom-Regler zeichnet erst neu,
  wenn er ruht.
- openBook hängt seine Listener über _readerListen an; closeReader nimmt
  sie ab (vorher sammelten sie sich bei jedem Öffnen an). Abbrechen und
  ein zweites Buch geben das alte Dokument frei; der entschlüsselte
  PDF-Puffer wird nicht mehr gehalten.
- Invertieren dreht die Farbtöne zurück (Fotos keine Negative).
- e2e/fixtures/make_test_pdf.py: Test-PDF mit gemischten Formaten,
  Outline, Links und einer fast leeren Seite.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 17:52:28 +01:00

1131 lines
54 KiB
JavaScript
Raw Permalink Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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.

'use strict';
// ---------------------------------------------------------------------------
// Zeitschriften: das Heft und seine Artikel (docs/library.md, "Heft und Artikel").
// The issue stays the document — read through in the editors' order — and the article
// is the unit one moves in: its own state (gelesen / übersprungen), reading time, a
// running head ("Artikel 8 von 23") and a pause at its end. Each determines the other:
// reading the issue through marks the articles one passes; reading articles moves the
// issue on ("7 von 23"); "Im Heft weiterlesen" goes to the next one not read yet.
//
// The index comes from the file, on the device (the server never sees it): an EPUB's
// table of contents (its leaves are the articles, their parents the Rubriken) or a PDF's
// outline (page ranges). It is kept in the book's encrypted meta (`issue`), the states
// too (`issueRead`, `issueAt`), so every device has them; a state that couldn't be saved
// (offline) waits in localStorage and goes with the next save. Chrome-61-safe (tolino).
// ---------------------------------------------------------------------------
const ISSUE_WPM = 200; // words per minute, for the reading times
const ISSUE_INDEX_V = 2; // an index of another version is built anew
const _issuePendingKey = 'diora_issue_pending';
function _issueEsc(s) {
return String(s == null ? '' : s).replace(/[&<>"']/g, c => ({'&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;'}[c]));
}
const _issueMeta = b => (b && (bookMetaCache[b.id] || {})) || {};
function _issueIndexOf(b) {
const m = _issueMeta(b);
const ix = m.issue || (b && b.issue);
return ix && ix.v === ISSUE_INDEX_V && ix.a && ix.a.length ? ix.a : null;
}
// An issue whose file has no usable table of contents (under this version's rules).
function _issueNone(b) {
const ix = _issueMeta(b).issue || (b && b.issue);
return !!(ix && ix.v === ISSUE_INDEX_V && ix.none);
}
// ---- state: {href: 'r' (gelesen) | 's' (übersprungen)} and the article one was in -----------
function _issuePending() {
try { return JSON.parse(localStorage.getItem(_issuePendingKey) || '{}') || {}; } catch (e) { return {}; }
}
function _issueSetPending(all) {
try { localStorage.setItem(_issuePendingKey, JSON.stringify(all)); } catch (e) {}
}
function _issueState(b) {
const m = _issueMeta(b);
const read = Object.assign({}, m.issueRead || b.issueRead || {});
let at = m.issueAt || b.issueAt || '';
const p = _issuePending()[b.id];
if (p) {
Object.keys(p.read || {}).forEach(h => { if (p.read[h]) read[h] = p.read[h]; else delete read[h]; });
if (p.at) at = p.at;
}
return {read, at};
}
// What the issue amounts to: counts, the next article to read, the time still to read.
function _issueSummary(b) {
const arts = _issueIndexOf(b);
if (!arts) return null;
const st = _issueState(b);
let read = 0, skipped = 0, left = 0;
arts.forEach(a => {
const s = st.read[a.h];
if (s === 'r') read++;
else if (s === 's') skipped++;
else left += a.m || 0;
});
const at = arts.findIndex(a => a.h === st.at);
const open = i => !st.read[arts[i].h];
let next = -1;
if (at >= 0 && open(at)) next = at;
for (let i = at + 1; next < 0 && i < arts.length; i++) if (open(i)) next = i;
for (let i = 0; next < 0 && i < arts.length; i++) if (open(i)) next = i;
return {arts, st, total: arts.length, read, skipped, left, next, at, started: at >= 0 && open(at)};
}
function _issueMinutes(m) {
if (m < 60) return Math.max(1, Math.round(m)) + ' min';
const h = Math.floor(m / 60);
const r = Math.round(m - h * 60);
return r >= 45 ? 'knapp ' + (h + 1) + ' h' : r >= 15 ? 'gut ' + h + ' h' : h + ' h';
}
// One bar for the issue, one segment per article: read (full), skipped (hatched), open
// (empty), the one one is in (accent). `now`: index of the article shown right now.
function _issueSegments(sum, now, cls) {
return `<span class="iss-seg${cls ? ' ' + cls : ''}" aria-label="${sum.read} von ${sum.total} Artikeln gelesen">` +
sum.arts.map((a, i) => {
const s = sum.st.read[a.h];
const k = i === now ? 'iss-s-now' : s === 'r' ? 'iss-s-read' : s === 's' ? 'iss-s-skip' : i === sum.at ? 'iss-s-at' : '';
return `<span class="${k}"></span>`;
}).join('') + '</span>';
}
function _issueCounts(sum) {
const parts = [`${sum.read} von ${sum.total} gelesen`];
if (sum.skipped) parts.push(`${sum.skipped} übersprungen`);
return parts.join(' · ');
}
let _issueSaveTimer = {};
function _issueMark(bookId, href, state, at) {
const all = _issuePending();
const p = all[bookId] || {read: {}};
if (href) p.read[href] = state || '';
if (at) p.at = at;
all[bookId] = p;
_issueSetPending(all);
clearTimeout(_issueSaveTimer[bookId]);
_issueSaveTimer[bookId] = setTimeout(() => _issueFlush(bookId), 2500);
}
async function _issueFlush(bookId) {
const all = _issuePending();
const p = all[bookId];
const b = _lastBookListData.find(x => x.id === bookId);
if (!p || !b) return;
const st = _issueState(b);
try {
await _updateBookMeta(bookId, {issueRead: st.read, issueAt: st.at});
const now = _issuePending();
if (JSON.stringify(now[bookId]) === JSON.stringify(p)) { delete now[bookId]; _issueSetPending(now); }
} catch (e) { /* offline: it waits and goes with the next save */ }
}
window.addEventListener('online', () => Object.keys(_issuePending()).forEach(id => _issueFlush(Number(id))));
// ---- the index -----------------------------------------------------------------------------------
// The articles of a table of contents. An EPUB's (byFile): an article is a file — the
// first entry pointing into it; the entries after it into the same file are its sections
// (whatever their depth), and an entry whose children point into other files is a Rubrik.
// Otherwise (one file for all, a PDF's outline): the leaves, each under the entry above it.
// Entries that are word processors' leftovers (_Hlk…, _Toc…) don't count. [{h, t, r}]
const _issueJunk = label => /^_?(Hlk|Toc|Ref|GoBack)\d*$/i.test(String(label || '').trim());
function _issueArticlesFromToc(toc, byFile) {
toc = toc.filter(e => !_issueJunk(e.label));
const fileOf = h => String(h || '').split('#')[0];
if (byFile) {
const out = [];
const seen = new Set();
for (let i = 0; i < toc.length; i++) {
const e = toc[i];
if (!e.href) continue;
const f = fileOf(e.href);
let container = false;
for (let k = i + 1; k < toc.length && toc[k].depth > e.depth; k++) {
if (toc[k].href && fileOf(toc[k].href) !== f) { container = true; break; }
}
if (container || seen.has(f)) continue;
seen.add(f);
let r = '';
for (let k = i - 1, d = e.depth; k >= 0 && d > 0; k--) {
if (toc[k].depth >= d) continue;
d = toc[k].depth;
if (!toc[k].href || fileOf(toc[k].href) !== f) { r = toc[k].label || ''; break; }
}
out.push({h: e.href, t: (e.label || '').replace(/\s+/g, ' ').trim() || 'Ohne Titel', r: r.replace(/\s+/g, ' ').trim()});
}
if (out.length >= 2) return out;
}
const out = [];
const seen = new Set();
const parents = [];
for (let i = 0; i < toc.length; i++) {
const e = toc[i];
parents[e.depth] = e.label;
parents.length = e.depth + 1;
const nxt = toc[i + 1];
const leaf = !nxt || nxt.depth <= e.depth;
if (!leaf || !e.href || seen.has(e.href)) continue;
seen.add(e.href);
out.push({h: e.href, t: (e.label || '').trim() || 'Ohne Titel', r: e.depth > 0 ? (parents[e.depth - 1] || '').trim() : ''});
}
return out;
}
function _issueTextStats(text, title) {
let t = String(text || '').replace(/\s+/g, ' ').trim();
const words = t ? t.split(' ').length : 0;
if (title && t.toLowerCase().indexOf(title.toLowerCase()) === 0) t = t.slice(title.length).trim();
const x = t.length > 110 ? t.slice(0, 110).replace(/\s+\S*$/, '') + ' …' : t;
return {x, m: Math.max(1, Math.round(words / ISSUE_WPM))};
}
const _issueHtmlText = html => String(html || '')
.replace(/<head\b[\s\S]*?<\/head>/gi, ' ')
.replace(/<(script|style)\b[\s\S]*?<\/\1>/gi, ' ')
.replace(/<[^>]+>/g, ' ')
.replace(/&#x([0-9a-f]+);/gi, (m, h) => String.fromCharCode(parseInt(h, 16)))
.replace(/&#(\d+);/g, (m, d) => String.fromCharCode(parseInt(d, 10)))
.replace(/ /g, ' ')
.replace(/&nbsp;/g, ' ').replace(/&amp;/g, '&').replace(/&lt;/g, '<').replace(/&gt;/g, '>').replace(/&quot;/g, '"').replace(/&#39;/g, "'");
async function _issueIndexEpub(bytes) {
const zip = await JSZip.loadAsync(bytes);
const container = zip.file('META-INF/container.xml');
if (!container) return null;
const cdoc = new DOMParser().parseFromString(await container.async('text'), 'application/xml');
const root = cdoc.querySelector('rootfile');
if (!root) return null;
const opfPath = root.getAttribute('full-path');
const opfDir = opfPath.indexOf('/') >= 0 ? opfPath.slice(0, opfPath.lastIndexOf('/') + 1) : '';
const opfDoc = new DOMParser().parseFromString(await zip.file(opfPath).async('text'), 'application/xml');
const manifest = {};
Array.prototype.forEach.call(opfDoc.querySelectorAll('manifest > item'), item => {
manifest[item.getAttribute('id')] = {href: opfDir + item.getAttribute('href'),
mediaType: item.getAttribute('media-type') || '', properties: item.getAttribute('properties') || ''};
});
const spine = Array.prototype.map.call(opfDoc.querySelectorAll('spine > itemref'), r => (manifest[r.getAttribute('idref')] || {}).href).filter(Boolean);
let arts = _issueArticlesFromToc(await _parseEpubToc(zip, opfDoc, manifest), true);
const files = {};
const fileText = async path => {
if (!(path in files)) {
const f = zip.file(path) || zip.file(decodeURIComponent(path));
files[path] = f ? await f.async('text') : '';
}
return files[path];
};
if (arts.length < 2) {
// No usable table of contents: one article per spine file with some text in it.
arts = [];
for (const path of spine) {
const html = await fileText(path);
const m = /<h[1-3][^>]*>([\s\S]*?)<\/h[1-3]>/i.exec(html);
const text = _issueHtmlText(html);
if (text.replace(/\s+/g, '').length < 300) continue;
arts.push({h: path, t: m ? _issueHtmlText(m[1]).replace(/\s+/g, ' ').trim() : 'Artikel ' + (arts.length + 1), r: ''});
}
}
// Each article's text: from its target to the next article's (within a file by the
// fragment's place, across files along the spine).
const split = h => { const k = h.indexOf('#'); return k >= 0 ? [h.slice(0, k), h.slice(k + 1)] : [h, '']; };
const posIn = (html, frag) => {
if (!frag) return 0;
const m = new RegExp('id\\s*=\\s*["\']' + frag.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + '["\']').exec(html);
if (!m) return 0;
return html.lastIndexOf('<', m.index);
};
for (let i = 0; i < arts.length; i++) {
const [file, frag] = split(arts[i].h);
const [nFile, nFrag] = i + 1 < arts.length ? split(arts[i + 1].h) : ['', ''];
let html = '';
const startIdx = spine.indexOf(file);
if (nFile === file) {
const whole = await fileText(file);
html = whole.slice(posIn(whole, frag), nFrag ? posIn(whole, nFrag) : whole.length);
} else {
const whole = await fileText(file);
html = whole.slice(posIn(whole, frag));
const endIdx = nFile ? spine.indexOf(nFile) : spine.length;
for (let s = startIdx + 1; startIdx >= 0 && s < (endIdx < 0 ? spine.length : endIdx); s++) html += await fileText(spine[s]);
if (nFile && nFrag) { const n = await fileText(nFile); html += n.slice(0, posIn(n, nFrag)); }
}
const st = _issueTextStats(_issueHtmlText(html), arts[i].t);
// The headings before the first paragraph that the title doesn't already say (a
// magazine's headline under its kicker): the teaser; else the text's beginning.
const body = html.replace(/<head\b[\s\S]*?<\/head>/i, '');
const firstP = body.search(/<p[\s>]/i);
const heads = [];
const re = /<h[1-4]\b[^>]*>([\s\S]*?)<\/h[1-4]>/gi;
let m;
while ((m = re.exec(body)) && (firstP < 0 || m.index < firstP)) {
const t = _issueHtmlText(m[1]).replace(/\s+/g, ' ').trim();
// Not one of the article's own numbered sections ("I. Verarmung …", "1. …").
if (t && !/^([IVXL]+|\d+)\.\s/.test(t) && _issueNorm(arts[i].t).indexOf(_issueNorm(t)) < 0) heads.push(t);
}
if (heads.length) st.x = heads.join(' – ');
else if (firstP >= 0) st.x = _issueTextStats(_issueHtmlText(body.slice(firstP)), '').x;
Object.assign(arts[i], st);
}
return arts;
}
const _issueNorm = s => String(s || '').toLowerCase().replace(/ß/g, 'ss').normalize('NFKD').replace(/[^a-z0-9]/g, '');
async function _issueIndexPdf(bytes) {
if (typeof pdfjsLib === 'undefined') return null;
const pdf = await _pdfLoad(new Uint8Array(bytes)).promise;
try {
return await _issuePdfArticles(pdf);
} finally {
try { pdf.destroy(); } catch (e) {}
}
}
// A PDF's articles: from its outline where it has a real one, else from the contents
// page printed in the issue (most magazines' PDFs have no outline).
async function _issuePdfArticles(pdf) {
const pageText = {};
const textOf = async n => {
if (!(n in pageText)) {
try {
const c = await (await pdf.getPage(n)).getTextContent();
pageText[n] = c.items.map(it => it.str).join(' ');
} catch (e) { pageText[n] = ''; }
}
return pageText[n];
};
let arts = [];
const outline = await pdf.getOutline().catch(() => null);
if (outline && outline.length && typeof _parsePdfOutline === 'function') {
arts = _issueArticlesFromToc(await _parsePdfOutline(pdf, outline))
.filter(a => /^#pdf-page-\d+$/.test(a.h))
.map(a => Object.assign(a, {p: parseInt(a.h.slice(10), 10)}));
}
if (arts.length < 3) arts = await _issuePrintedToc(pdf, textOf);
if (arts.length < 2) return [];
arts.sort((a, b) => a.p - b.p);
for (let i = 0; i < arts.length; i++) {
const a = arts[i];
a.h = '#pdf-page-' + a.p;
a.e = i + 1 < arts.length ? Math.max(a.p, arts[i + 1].p - 1) : pdf.numPages;
let text = '';
for (let n = a.p; n <= Math.min(a.e, a.p + 40); n++) text += ' ' + await textOf(n);
const s = _issueTextStats(text, '');
// From a printed contents page: no teaser (a page's running heads and captions come
// first in its text); the author stands there instead.
if (a.a !== undefined) s.x = a.x || '';
else if (!a.x) {
// The teaser: what follows the title on its first page, if it stands there.
const first = (await textOf(a.p)).replace(/\s+/g, ' ');
const k = _issueFind(first, a.t);
s.x = _issueTextStats(k >= 0 ? first.slice(k) : '', '').x;
} else s.x = a.x;
Object.assign(a, s);
}
return arts;
}
// Where `title` stands in `text` (letters and digits only, any case): the index in
// `text` just after it, or -1.
function _issueFind(text, title) {
const want = _issueNorm(title).slice(0, 40);
if (want.length < 4) return -1;
let norm = '';
const map = [];
for (let i = 0; i < text.length; i++) {
const c = _issueNorm(text[i]);
for (let k = 0; k < c.length; k++) { norm += c[k]; map.push(i + 1); }
}
const at = norm.indexOf(want);
return at < 0 ? -1 : map[at + want.length - 1];
}
function _issueSpan(text, title) {
const end = _issueFind(text, title);
if (end < 0) return '';
const want = _issueNorm(title).slice(0, 40);
let n = 0, i = end;
while (i > 0 && n < want.length) { i--; n += _issueNorm(text[i]).length; }
return text.slice(i, end);
}
// The lines of a page as pdf.js lays its text out: pieces on one baseline that sit close
// together make a segment; far apart (another column), two. [{x, y, size, text}]
async function _issuePdfSegments(pdf, n) {
const page = await pdf.getPage(n);
const c = await page.getTextContent();
const items = c.items.filter(it => it.str && it.str.trim())
.map(it => ({x: it.transform[4], y: it.transform[5], w: it.width, size: Math.hypot(it.transform[2], it.transform[3]) || it.height || 10, s: it.str}));
items.sort((a, b) => (b.y - a.y) || (a.x - b.x));
const segs = [];
let line = [];
const flush = () => {
if (!line.length) return;
line.sort((a, b) => a.x - b.x);
let seg = null;
for (const it of line) {
if (seg && it.x - seg.end > Math.max(24, it.size * 3)) { segs.push(seg); seg = null; }
if (!seg) seg = {x: it.x, y: it.y, size: it.size, text: it.s, end: it.x + it.w};
else {
seg.text += (it.x - seg.end > it.size * 0.15 && !/\s$/.test(seg.text) && !/^\s/.test(it.s) ? ' ' : '') + it.s;
seg.end = Math.max(seg.end, it.x + it.w);
seg.size = Math.max(seg.size, it.size);
}
}
if (seg) segs.push(seg);
line = [];
};
for (const it of items) {
if (line.length && Math.abs(line[0].y - it.y) > Math.min(line[0].size, it.size) * 0.5) flush();
line.push(it);
}
flush();
segs.forEach(s => { s.text = s.text.replace(/\s+/g, ' ').trim(); });
return segs.filter(s => s.text);
}
// The contents page printed in the issue. Its rows: a page number at the start
// ("14 Der Krieg gegen Palästina") or at the end, maybe with the author before the title
// in a column of its own ("Nathan Sperber | Beyond Neoliberalism? | 23"); the rows just
// under one, indented alike in its type size, continue it (more title, the author);
// capitals make a heading (the Rubrik). The printed numbers are matched to the file's
// pages by finding the titles there.
const _ISSUE_L = 'A-Za-zÀ-ÖØ-öø-ÿĀ-ſ';
const _issueNameLike = t => t.length <= 45 &&
/^[A-ZÀ-ÖØ-ÞĀ-ſ][A-Za-zÀ-ÖØ-öø-ÿĀ-ſ.'’-]*(\s+((und|and|&|von|van|de|der|del|y|bin)\s+)?[A-ZÀ-ÖØ-ÞĀ-ſ][A-Za-zÀ-ÖØ-öø-ÿĀ-ſ.'’-]*){1,4}$/.test(t);
function _issueTocRows(segs, N) {
const rows = [];
segs.slice().sort((a, b) => (b.y - a.y) || (a.x - b.x)).forEach(s => {
const row = rows.filter(r => Math.abs(r.y - s.y) < Math.min(r.size, s.size) * 0.4)[0];
if (row) { row.segs.push(s); row.size = Math.max(row.size, s.size); } else rows.push({y: s.y, size: s.size, segs: [s]});
});
const items = [];
const isNum = t => /^\d{1,3}$/.test(t) && parseInt(t, 10) >= 1 && parseInt(t, 10) <= N + 20;
const letters = t => new RegExp('[' + _ISSUE_L + ']{3}').test(t);
rows.forEach(r => {
const g = r.segs.sort((a, b) => a.x - b.x);
const last = g[g.length - 1];
const textSegs = g.filter(x => !isNum(x.text));
if (g.length >= 2 && isNum(last.text) && textSegs.length && letters(textSegs[textSegs.length - 1].text)) {
// … title | number (an author in a column before the title)
const title = textSegs[textSegs.length - 1];
items.push({entry: true, printed: parseInt(last.text, 10), lines: [title.text], lead: false,
author: textSegs.length > 1 ? textSegs.slice(0, -1).map(x => x.text).join(' ') : '',
x: title.x, y: r.y, y0: r.y, size: title.size});
return;
}
const tail = /^(.*\S)\s+(\d{1,3})$/.exec(last.text);
if (g.length >= 2 && tail && isNum(tail[2]) && letters(tail[1]) && letters(g[0].text)) {
// author | title 57
items.push({entry: true, printed: parseInt(tail[2], 10), lines: [tail[1]], lead: false,
author: g.slice(0, -1).map(x => x.text).join(' '), x: last.x, y: r.y, y0: r.y, size: last.size});
return;
}
if (g.length >= 2 && isNum(g[0].text) && letters(g[1].text)) {
items.push({entry: true, printed: parseInt(g[0].text, 10), lines: [g.slice(1).map(x => x.text).join(' ')], lead: true,
author: '', x: g[1].x, y: r.y, y0: r.y, size: g[1].size});
return;
}
g.forEach(seg => {
let m = /^(\d{1,3})\s+(\S.*)$/.exec(seg.text);
let lead = true;
if (!m) { m = /^(.*\S)\s+(\d{1,3})$/.exec(seg.text); lead = false; }
const n = m ? parseInt(lead ? m[1] : m[2], 10) : 0;
const t = m ? (lead ? m[2] : m[1]) : '';
if (m && n >= 1 && n <= N + 20 && letters(t)) {
items.push({entry: true, printed: n, lines: [t], lead, author: '', x: seg.x, y: seg.y, y0: seg.y, size: seg.size});
} else items.push({entry: false, seg});
});
});
return items;
}
async function _issuePrintedToc(pdf, textOf) {
const N = pdf.numPages;
const caps = t => t.length >= 3 && t.length <= 40 && !/\d/.test(t) &&
t.replace(new RegExp('[^' + _ISSUE_L + ']', 'g'), '').length >= 3 && t === t.toUpperCase();
// The contents page: among the first pages the one with most numbered rows whose
// numbers aren't a mere count (1, 2, 3 … is a list, not a contents).
const pages = [];
for (let n = 1; n <= Math.min(16, N); n++) {
const items = _issueTocRows(await _issuePdfSegments(pdf, n), N);
const nums = items.filter(x => x.entry);
const sorted = nums.map(x => x.printed).sort((a, b) => a - b);
let steps = 0;
for (let i = 1; i < sorted.length; i++) if (sorted[i] - sorted[i - 1] === 1) steps++;
pages.push({n, items, score: nums.length * (steps > sorted.length * 0.6 ? 0.2 : 1)});
}
let best = null;
pages.forEach(pg => { if (pg.score >= 4 && (!best || pg.score > best.score)) best = pg; });
if (!best) return [];
// A contents running over several pages: the neighbours with entries too.
best.best = true;
const use = [best];
for (let k = best.n; k < pages.length && pages[k].score >= 2; k++) use.push(pages[k]);
for (let k = best.n - 2; k >= 0 && pages[k].score >= 2; k--) use.unshift(pages[k]);
const isCaps = t => t === t.toUpperCase() && new RegExp('[' + _ISSUE_L + ']{3}').test(t);
const entries = [];
use.forEach(pg => {
const ents = [];
const headings = [];
const capsRun = []; // capitals just above, not used yet: a title's beginning or a heading
pg.items.forEach(it => {
if (it.entry) {
const e = it;
// A title in capitals may begin on the lines above its number ("DAS POLITISCHE FELD
// NACH LINKS / 14 VERSCHIEBEN"); a lone word on top of them is a heading (GESPRÄCH).
if (isCaps(e.lines[0])) {
const above = capsRun.filter(c => c.x >= e.x - 8 && c.x - e.x < 60 && c.y > e.y && c.y - e.y < c.size * 2.2 * (capsRun.length + 1));
above.sort((a, b) => b.y - a.y);
let prev = e.y;
const chain = [];
for (let k = above.length - 1; k >= 0; k--) {
if (above[k].y - prev > above[k].size * 2.4) break;
chain.unshift(above[k]);
prev = above[k].y;
}
if (chain.length >= 1 && !/\s/.test(chain[0].text)) { e.kicker = chain[0].text; chain.shift(); }
chain.forEach(c => { c.used = true; });
if (chain.length) e.lines[0] = chain.map(c => c.text).concat([e.lines[0]]).join(' ').replace(/(\w)- (?=\w)/g, '$1-');
}
e.fromNeighbour = !pg.best;
ents.push(e);
return;
}
const s = it.seg;
if (/^\d+$/.test(s.text)) return;
if (caps(s.text)) {
const c = {y: s.y, x: s.x, t: s.text, text: s.text, size: s.size};
headings.push(c);
capsRun.push(c);
return;
}
const e = ents.filter(x => s.x >= x.x - 8 && s.x - x.x < 60 && x.y - s.y > 0 && x.y - s.y < Math.max(x.size, s.size) * 3 && x.lines.length < 6).pop();
if (!e) return;
const by = /^(Von|Mit|By|With)\s+(.+)$/.exec(s.text);
if (by && !e.author) { e.author = by[2]; e.y = s.y; e.done = true; return; }
if (e.done && /(,|&|\bund)$/.test(e.author)) { e.author += ' ' + s.text; e.y = s.y; return; }
if (e.done) return;
if (Math.abs(s.size - e.size) < 2.5) e.lines.push(s.text); // more title, or the author
else (e.sub = e.sub || []).push(s.text); // a line under it in other type
e.y = s.y;
});
// Each entry's heading: the nearest capitals above it in the contents' type size (a
// title's beginning is no heading; a kicker on top of one is).
ents.forEach(e => {
const h = headings.filter(x => x.y > e.y0 && Math.abs(x.x - e.x) < 200 && x.size < e.size * 1.6 &&
!x.used).sort((a, b) => a.y - b.y)[0];
e.r = e.kicker || (h && !isCaps(e.lines[0]) ? h.t : '');
});
entries.push.apply(entries, ents);
});
const sizes = entries.map(e => e.size).sort((a, b) => a - b);
const median = sizes[Math.floor(sizes.length / 2)] || 10;
for (let i = entries.length - 1; i >= 0; i--) if (entries[i].size > median * 1.6) entries.splice(i, 1);
const capsShare = entries.filter(e => isCaps(e.lines[0])).length / entries.length;
for (let i = entries.length - 1; i >= 0; i--) {
if (capsShare < 0.5 && isCaps(entries[i].lines[0]) && !/\s/.test(entries[i].lines[0])) entries.splice(i, 1);
}
if (entries.length < 3) return [];
entries.forEach(e => {
// An author on the last line: the title is the lines before it.
if (!e.author && e.lines.length > 1 && _issueNameLike(e.lines[e.lines.length - 1])) e.author = e.lines.pop();
// "Name: Title" (manuskripte)
const m = /^([^:]{3,45}):\s+(.{3,})$/.exec(e.lines[0]);
if (!e.author && m && _issueNameLike(m[1])) { e.author = m[1]; e.lines[0] = m[2]; }
e.author = (e.author || '').replace(/\s+/g, ' ').trim();
});
// The printed numbers onto the file's pages: the offset most titles agree on.
const votes = {};
const found = {};
for (const e of entries) {
// The title as printed; where the number stands at the end of one run of text, maybe
// with a name before the title ("Marcus Verhagen Grenfell's Fire 121").
const t = e.lines.join(' ');
const words = t.split(' ');
const cands = [t].concat(!e.lead && !e.author && words.length > 3 ? [words.slice(2).join(' '), words.slice(3).join(' ')] : []);
for (let off = -2; off <= 8; off++) {
const p = e.printed + off;
if (p < 1 || p > N) continue;
const text = await textOf(p);
const hits = cands.filter(c => _issueNorm(c).length >= 8 && _issueFind(text, c) >= 0);
const upper = hits.filter(c => { const sp = _issueSpan(text, c); return sp && /[A-ZÄÖÜ]/.test(sp) && sp === sp.toUpperCase(); });
const hit = upper[0] || hits[0];
if (hit) {
votes[off] = (votes[off] || 0) + 1;
(found[off] = found[off] || []).push([e, hit]);
}
}
}
const offs = Object.keys(votes).sort((a, b) => votes[b] - votes[a]);
const off = offs.length ? parseInt(offs[0], 10) : 0;
const titled = new Map((found[off] || []).map(x => [x[0], x[1]]));
const runningHead = async t => {
let n = 0;
for (const q of [Math.round(N * 0.3), Math.round(N * 0.55), Math.round(N * 0.8)]) if (_issueFind(await textOf(q), t) >= 0) n++;
return n >= 2;
};
for (let i = entries.length - 1; i >= 0; i--) {
const e = entries[i];
const t = e.lines.join(' ');
// On a further contents page, an entry its page doesn't confirm must look like a title:
// capitals, or a few words without a comma (not a sentence running on).
const plausible = isCaps(t) || (t.length <= 80 && !/,/.test(t) && /\s/.test(t) && /^[^a-zäöüß]/.test(t));
if (await runningHead(t) || (e.fromNeighbour && !titled.get(e) && !plausible)) entries.splice(i, 1);
}
const out = [];
const seen = new Set();
entries.forEach(e => {
const p = e.printed + off;
if (p < 1 || p > N) return;
const full = e.lines.join(' ');
const t = titled.get(e) || full;
let author = e.author;
let title = t;
if (!author && t !== full && full.indexOf(t) > 0) author = full.slice(0, full.indexOf(t)).trim();
if (!author && !e.lead && !titled.get(e)) {
const w = full.split(' ');
for (const k of [2, 3]) {
if (w.length > k + 1 && _issueNameLike(w.slice(0, k).join(' '))) { author = w.slice(0, k).join(' '); title = w.slice(k).join(' '); break; }
}
}
const key = p + '|' + _issueNorm(title);
if (seen.has(key)) return;
seen.add(key);
out.push({p, t: title.replace(/\s+/g, ' ').trim(), r: (e.r || '').replace(/\s+/g, ' ').trim(), a: author,
x: e.sub ? e.sub.join(' ').replace(/(\w)- (?=[a-zäöüß])/g, '$1').replace(/\s+/g, ' ').trim() : ''});
});
return out;
}
// The file, decrypted (the browser's cache, or — in the apps — their store).
async function _issueBytes(id) {
let rec = typeof _getCachedBook === 'function' ? await _getCachedBook(id) : null;
if (!rec) {
const r = await fetch('/books/' + id + '/data/', {credentials: 'same-origin'});
if (r.ok) rec = await r.json();
}
if (!rec || !rec.data_ct) return null;
return decryptBytes(await getOrCreateEncKey(), rec.data_iv, rec.data_ct);
}
const _issueBuilding = new Map();
// The index of an issue, built once (and kept in its meta) if it has none yet.
function _issueEnsureIndex(bookId) {
const b = _lastBookListData.find(x => x.id === bookId);
if (!b) return Promise.resolve(null);
const have = _issueIndexOf(b);
if (have || _issueNone(b)) return Promise.resolve(have);
if (_issueBuilding.has(bookId)) return _issueBuilding.get(bookId);
const job = (async () => {
const bytes = await _issueBytes(bookId);
if (!bytes) return null;
const pdf = (_issueMeta(b).type || b.type) === 'pdf';
const arts = pdf ? await _issueIndexPdf(bytes) : await _issueIndexEpub(bytes);
if (!arts) return null;
// An issue without a usable table of contents stays a whole (and isn't tried again).
const issue = arts.length >= 2 ? {v: ISSUE_INDEX_V, a: arts} : {v: ISSUE_INDEX_V, a: [], none: true};
try { await _updateBookMeta(bookId, {issue}); } catch (e) {
bookMetaCache[bookId] = Object.assign({}, bookMetaCache[bookId] || {}, {issue});
}
return issue.a.length ? issue.a : null;
})().catch(() => null);
_issueBuilding.set(bookId, job);
job.then(() => _issueBuilding.delete(bookId));
return job;
}
// Issues still without an index: one after the other, when nothing else goes on (not on
// an e-reader: there an issue's index is built when it's opened, or comes from elsewhere).
let _issueQueueBusy = false;
function _issueIndexLater(books) {
if (_issueQueueBusy || (typeof _LIB_EINK === 'function' && _LIB_EINK())) return;
const todo = books.filter(b => _isMagazine(b) && b.keyOk !== false && !_issueIndexOf(b) && !_issueNone(b));
if (!todo.length) return;
_issueQueueBusy = true;
const idle = window.requestIdleCallback || (fn => setTimeout(fn, 1500));
idle(async () => {
const reader = document.getElementById('reader-overlay');
if (reader && reader.style.display !== 'none') { _issueQueueBusy = false; return; }
await _issueEnsureIndex(todo[0].id);
_issueQueueBusy = false;
if (typeof renderBookList === 'function') renderBookList(_lastBookListData);
});
}
// ---- the library: an issue opened (its contents) --------------------------------------------------
function _issueView(b) {
const sum = _issueSummary(b);
const back = `<div class="iss-top"><button type="button" class="iss-back" data-issue-act="up">‹ Zeitschriften</button>
<button type="button" class="lib-more iss-more" data-lib-more="${b.id}" aria-label="Optionen">⋮</button></div>`;
const head = `<div class="iss-head">${_libCover(b, 'iss-cover')}
<span class="iss-head-text"><span class="iss-title">${_issueEsc(b.title)}</span>
${b.author ? `<span class="lib-meta">${_issueEsc(b.author)}</span>` : ''}
${sum ? `<span class="lib-meta">${sum.total} Artikel · ${_issueMinutes(sum.arts.reduce((n, a) => n + (a.m || 0), 0))}</span>` : ''}</span></div>`;
if (!sum) {
const none = _issueNone(b);
return back + head + (none
? `<p class="lib-empty">Dieses Heft hat kein Inhaltsverzeichnis, aus dem sich Artikel ablesen lassen – es liest sich als Ganzes.</p>
<button type="button" class="iss-go" data-issue-act="read">▶ Lesen</button>`
: '<p class="lib-empty">Das Inhaltsverzeichnis wird gelesen …</p>');
}
const nx = sum.next >= 0 ? sum.arts[sum.next] : null;
let h = back + head + `<div class="iss-sum">${_issueSegments(sum, -1)}
<span class="lib-meta">${_issueCounts(sum)}${sum.left ? ' · noch ' + _issueMinutes(sum.left) : ''}</span>
<span class="iss-actions">
${nx ? `<button type="button" class="iss-go" data-issue-act="continue">▶ ${sum.started ? 'Im Heft weiterlesen' : 'Weiter mit Artikel ' + (sum.next + 1)}</button>`
: '<span class="lib-meta">Alles gelesen oder übersprungen.</span>'}
${nx ? '<button type="button" class="iss-btn" data-issue-act="finish">Heft abschließen</button>' : '<button type="button" class="iss-btn" data-issue-act="reopen">Wieder öffnen</button>'}
</span></div>`;
let rubrik = null;
sum.arts.forEach((a, i) => {
if (a.r !== rubrik) {
rubrik = a.r;
if (rubrik) h += `<h3 class="iss-rubrik">${_issueEsc(rubrik)}</h3>`;
}
const s = sum.st.read[a.h];
const mark = s === 'r' ? '✓' : s === 's' ? '–' : i === sum.at ? '◐' : '○';
const cls = s === 'r' ? ' iss-read' : s === 's' ? ' iss-skip' : i === sum.next ? ' iss-next' : '';
h += `<div class="iss-row${cls}">
<button type="button" class="iss-mark" data-issue-mark="${i}" aria-label="Artikel ${i + 1}: Stand ändern">${mark}</button>
<button type="button" class="iss-art" data-issue-art="${i}">
<span class="iss-n">${i + 1}</span>
<span class="iss-art-text"><span class="iss-art-title">${_issueEsc(a.t)}</span>
${a.a || a.x ? `<span class="iss-teaser">${_issueEsc([a.a, a.x].filter(Boolean).join(' · '))}</span>` : ''}</span>
<span class="iss-min">${a.m ? a.m + ' min' : ''}</span></button></div>`;
});
return h;
}
// What to open for "Im Heft weiterlesen": the article one was in (at its place), else the next one open.
function _issueContinue(bookId) {
const b = _lastBookListData.find(x => x.id === bookId);
const sum = b && _issueSummary(b);
if (!sum || sum.next < 0) { openBook(bookId); return; }
if (sum.started && sum.next === sum.at) { openBook(bookId); return; }
_issueOpenAt(bookId, sum.next);
}
async function _issueOpenAt(bookId, index) {
const b = _lastBookListData.find(x => x.id === bookId);
const arts = b && _issueIndexOf(b);
if (!arts || !arts[index]) { openBook(bookId); return; }
_issueMark(bookId, null, null, arts[index].h);
await openBook(bookId);
if (currentBookId !== bookId) return;
_issueJump(arts[index]);
}
function _issueJump(a) {
if (a.p && typeof currentPdfDoc !== 'undefined' && currentPdfDoc && readerSettings.pdfPaginated &&
typeof enterPdfPaginatedMode === 'function') {
if (typeof pdfGoToPage === 'function') pdfGoToPage(a.p);
else { pdfCurrentPage = a.p; enterPdfPaginatedMode(); }
return;
}
if (typeof jumpToTocEntry === 'function') jumpToTocEntry(a.h);
}
function _issueSheet(bookId, i) {
const b = _lastBookListData.find(x => x.id === bookId);
const sum = b && _issueSummary(b);
if (!sum) return;
const a = sum.arts[i];
const s = sum.st.read[a.h];
const set = v => { _issueMark(bookId, a.h, v); renderBookList(_lastBookListData); };
const items = [
['Lesen', () => _issueOpenAt(bookId, i)],
s === 'r' ? ['Als ungelesen markieren', () => set('')] : ['Als gelesen markieren', () => set('r')],
s === 's' ? ['Nicht mehr überspringen', () => set('')] : ['Überspringen', () => set('s')],
];
const wrap = document.createElement('div');
wrap.className = 'dfo-scrim';
wrap.innerHTML = `<div class="dfo-sheet" role="dialog"><div class="dfo-head"><strong>${i + 1} · ${_issueEsc(a.t)}</strong>
<button type="button" class="dfo-x" data-x="1" aria-label="Schließen">✕</button></div>
${items.map((it, k) => `<button type="button" class="lib-sheet-item" data-i="${k}">${it[0]}</button>`).join('')}</div>`;
wrap.addEventListener('click', e => {
const t = e.target.closest('[data-i]');
if (e.target === wrap || e.target.closest('[data-x]')) { wrap.remove(); return; }
if (t) { wrap.remove(); items[Number(t.dataset.i)][1](); }
});
document.body.appendChild(wrap);
}
function _issueFinish(bookId, reopen) {
const b = _lastBookListData.find(x => x.id === bookId);
const sum = b && _issueSummary(b);
if (!sum) return;
sum.arts.forEach(a => {
const s = sum.st.read[a.h];
if (!reopen && !s) _issueMark(bookId, a.h, 's');
if (reopen && s === 's') _issueMark(bookId, a.h, '');
});
renderBookList(_lastBookListData);
}
(function () {
const listEl = document.getElementById('book-list');
if (!listEl) return;
listEl.addEventListener('click', e => {
const id = _lib.issue;
if (id == null) return;
const act = e.target.closest('[data-issue-act]');
const mark = e.target.closest('[data-issue-mark]');
const art = e.target.closest('[data-issue-art]');
if (!act && !mark && !art) return;
e.stopPropagation();
if (art) { _issueOpenAt(id, Number(art.dataset.issueArt)); return; }
if (mark) { _issueSheet(id, Number(mark.dataset.issueMark)); return; }
const a = act.dataset.issueAct;
if (a === 'up') { _lib.issue = null; renderBookList(_lastBookListData); }
else if (a === 'continue') _issueContinue(id);
else if (a === 'read') openBook(id);
else if (a === 'finish') _issueFinish(id, false);
else if (a === 'reopen') _issueFinish(id, true);
});
})();
// Opened from the library: its contents (built first if needed).
function _issueOpen(bookId) {
_lib.issue = bookId;
renderBookList(_lastBookListData);
const sc = document.querySelector('.main-content');
if (sc) sc.scrollTop = 0;
window.scrollTo(0, 0);
_issueEnsureIndex(bookId).then(() => { if (_lib.issue === bookId) renderBookList(_lastBookListData); });
}
// ---- the reader: the running head, the article one is in, the pause at its end ----------------
let _issueR = null; // {bookId, pdf, arts, els, cur, seen}
function _issueRunLabel() {
if (!_issueR || _issueR.cur < 0) return '';
const a = _issueR.arts[_issueR.cur];
return `Artikel ${_issueR.cur + 1}/${_issueR.arts.length} · ${a.t}`;
}
window._issueRunLabel = _issueRunLabel;
async function _issueReaderOpened(bookId, isPdf) {
_issueReaderClosed();
const b = _lastBookListData.find(x => x.id === bookId);
if (!b || !_isMagazine(b)) return;
const arts = _issueIndexOf(b) || await _issueEnsureIndex(bookId);
if (!arts || currentBookId !== bookId) return;
const contentEl = document.getElementById('reader-content');
_issueR = {bookId, pdf: !!isPdf, arts, els: [], cur: -1, seen: new Set(), shown: new Set()};
if (!isPdf) {
const target = h => {
const k = h.indexOf('#');
const file = k >= 0 ? h.slice(0, k) : h;
const frag = k >= 0 ? h.slice(k + 1) : '';
let el = null;
if (frag) { try { el = contentEl.querySelector('#' + CSS.escape(frag)); } catch (e) {} }
if (!el) el = Array.prototype.find.call(contentEl.querySelectorAll('[data-epub-src]'), c => c.getAttribute('data-epub-src') === file) || null;
return el;
};
_issueR.els = arts.map(a => target(a.h));
// The pauses push the text down: the place shown stays where it is.
const anchor = typeof getPositionAnchor === 'function' ? getPositionAnchor(contentEl) : '';
// A pause at each article's end: before the next one starts (inside its chapter, so an
// e-reader showing one chapter at a time shows it with the article).
arts.forEach((a, i) => {
const el = _issueR.els[i];
if (!el) return;
const aside = document.createElement('aside');
aside.className = 'iss-end';
aside.dataset.issueEnd = i;
aside.innerHTML = _issueEndHtml(i);
const next = _issueR.els[i + 1];
if (next && !next.hasAttribute('data-epub-src')) next.parentNode.insertBefore(aside, next);
else {
// The chapter just before the next article; the last article: its own (what follows
// it — notices, the imprint — is no part of it).
let ch = next ? next.previousElementSibling : el.closest('[data-epub-src]');
while (ch && !ch.hasAttribute('data-epub-src')) ch = ch.previousElementSibling;
(ch || el.closest('[data-epub-src]') || contentEl).appendChild(aside);
}
});
contentEl.addEventListener('click', _issueEndClick);
if (anchor && typeof restoreFromAnchor === 'function') {
if (typeof _suppressScrollJumpDetect === 'function') _suppressScrollJumpDetect();
restoreFromAnchor(contentEl, anchor);
}
} else {
// Pages turned one by one (paginated PDF) don't scroll: look now and then.
_issueR.poll = setInterval(_issueTrack, 1200);
}
_issueEnsureHead();
contentEl.addEventListener('scroll', _issueScrollLater, {passive: true});
_issueTrack();
}
function _issueReaderClosed() {
if (!_issueR) return;
const contentEl = document.getElementById('reader-content');
if (contentEl) {
contentEl.removeEventListener('scroll', _issueScrollLater);
contentEl.removeEventListener('click', _issueEndClick);
}
const head = document.getElementById('iss-runhead');
if (head) head.remove();
const bar = document.getElementById('iss-pdf-end');
if (bar) bar.remove();
const id = _issueR.bookId;
clearInterval(_issueR.poll);
_issueR = null;
if (_issuePending()[id]) _issueFlush(id);
}
function _issueEnsureHead() {
let head = document.getElementById('iss-runhead');
if (!head) {
head = document.createElement('div');
head.id = 'iss-runhead';
const header = document.querySelector('#reader-overlay .reader-header');
if (header && header.parentNode) header.parentNode.insertBefore(head, header.nextSibling);
else return;
}
_issueRenderHead();
}
function _issueRenderHead() {
const head = document.getElementById('iss-runhead');
const b = _issueR && _lastBookListData.find(x => x.id === _issueR.bookId);
const sum = b && _issueSummary(b);
if (!head || !sum) return;
const a = _issueR.cur >= 0 ? sum.arts[_issueR.cur] : null;
head.innerHTML = `<span class="iss-rh-text">${a && a.r ? `<span class="iss-rh-rubrik">${_issueEsc(a.r)}</span>` : ''}
<span class="iss-rh-title">${a ? `Artikel ${_issueR.cur + 1} von ${sum.total} · ${_issueEsc(a.t)}` : `${sum.total} Artikel`}</span></span>
${_issueSegments(sum, _issueR.cur, 'iss-seg-thin')}`;
if (window.DioraUI && DioraUI.updateFooter) DioraUI.updateFooter();
}
let _issueScrollTimer = 0;
function _issueScrollLater() {
clearTimeout(_issueScrollTimer);
_issueScrollTimer = setTimeout(_issueTrack, 160);
}
// Which article is shown, which ends were reached: an article one was in and whose end
// came into view counts as read.
function _issueTrack() {
if (!_issueR) return;
const contentEl = document.getElementById('reader-content');
if (!contentEl) return;
let cur = _issueR.cur;
if (_issueR.pdf) {
const page = typeof pdfCurrentPage !== 'undefined' && readerSettings.pdfPaginated ? pdfCurrentPage : _issuePdfPage(contentEl);
let c = -1;
_issueR.arts.forEach((a, i) => { if (a.p <= page) c = i; });
const prev = _issueR.cur;
const last = _issueR.page;
cur = c;
// Read on from an article's last page onto the next one: that article was read
// (a jump — the contents, "Weiter" — reads nothing).
if (prev >= 0 && cur === prev + 1 && last != null && page === last + 1 &&
last >= (_issueR.arts[prev].e || last) && _issueR.seen.has(prev)) _issueEnded(prev);
_issueR.page = page;
} else {
const r = contentEl.getBoundingClientRect();
const top = r.top + 60;
let c = -1, best = -Infinity;
_issueR.els.forEach((el, i) => {
if (!el || !el.getClientRects().length) return;
const t = el.getBoundingClientRect().top;
if (t <= top && t > best) { best = t; c = i; }
});
if (c >= 0) cur = c;
contentEl.querySelectorAll('aside.iss-end').forEach(aside => {
const i = Number(aside.dataset.issueEnd);
if (!aside.getClientRects().length) return;
const ar = aside.getBoundingClientRect();
if (ar.top < r.bottom - 40 && ar.bottom > r.top) {
if (_issueR.seen.has(i) && !_issueR.shown.has(i)) { _issueR.shown.add(i); _issueEnded(i); }
else _issueRenderEnd(aside, i);
}
});
}
if (cur !== _issueR.cur && cur >= 0) {
_issueR.cur = cur;
_issueR.seen.add(cur);
_issueMark(_issueR.bookId, null, null, _issueR.arts[cur].h);
}
_issueRenderHead();
}
function _issuePdfPage(contentEl) {
const top = contentEl.getBoundingClientRect().top + 20;
const wrappers = contentEl.querySelectorAll('.pdf-page-wrapper');
for (const w of wrappers) {
if (w.getBoundingClientRect().bottom > top) return parseInt(w.id.replace('pdf-page-', ''), 10) || 1;
}
return 1;
}
// The end of article i reached: read (unless already decided otherwise).
function _issueEnded(i) {
const b = _lastBookListData.find(x => x.id === _issueR.bookId);
const st = b && _issueState(b);
const a = _issueR.arts[i];
if (st && !st.read[a.h]) _issueMark(_issueR.bookId, a.h, 'r');
if (_issueR.pdf) _issuePdfEnd(i);
else {
const aside = document.querySelector(`#reader-content aside.iss-end[data-issue-end="${i}"]`);
if (aside) _issueRenderEnd(aside, i);
}
}
// The next article to read after i, skipping those read or skipped.
function _issueNextAfter(sum, i) {
for (let k = i + 1; k < sum.arts.length; k++) if (!sum.st.read[sum.arts[k].h]) return k;
for (let k = 0; k < i; k++) if (!sum.st.read[sum.arts[k].h]) return k;
return -1;
}
function _issueEndHtml(i) {
const b = _lastBookListData.find(x => x.id === _issueR.bookId);
const sum = _issueSummary(b);
const a = sum.arts[i];
const s = sum.st.read[a.h];
const n = _issueNextAfter(sum, i);
// On a PDF the next article is usually the page one just turned to: no need to offer it.
const na = n >= 0 && !(_issueR.pdf && n === _issueR.cur) ? sum.arts[n] : null;
const jumped = n > i + 1 ? `<span class="iss-end-hint">„Weiter“ überspringt ${n - i - 1 === 1 ? 'Artikel ' + (i + 2) : 'die Artikel ' + (i + 2) + '–' + n} (schon gelesen oder übersprungen).</span>` : '';
return `<span class="iss-end-state"><strong>${s === 'r' ? '✓ ' : ''}„${_issueEsc(a.t)}“${s === 'r' ? ' gelesen' : s === 's' ? ' übersprungen' : ' – Ende'}</strong>
<span class="lib-meta">${_issueEsc(b.title)}: ${_issueCounts(sum)}${sum.left ? ' · noch ' + _issueMinutes(sum.left) : ''}</span></span>
${na ? `<button type="button" class="iss-end-next" data-issue-end-act="next" data-n="${n}">
<span class="iss-end-kicker">Weiter im Heft · Artikel ${n + 1}${na.r ? ' · ' + _issueEsc(na.r) : ''}${na.m ? ' · ' + na.m + ' min' : ''}</span>
<span class="iss-end-title">${_issueEsc(na.t)}</span></button>${jumped}`
: '<span class="lib-meta">Das Heft ist durch.</span>'}
<span class="iss-end-actions">
<button type="button" class="iss-btn" data-issue-end-act="toc">Zum Inhalt</button>
${na && n === i + 1 ? `<button type="button" class="iss-btn" data-issue-end-act="skip" data-n="${n}">Artikel ${n + 1} überspringen</button>` : ''}
${s === 'r' ? `<button type="button" class="iss-btn" data-issue-end-act="unread" data-i="${i}">Doch nicht gelesen</button>` : ''}
</span>`;
}
function _issueRenderEnd(aside, i) {
const html = _issueEndHtml(i);
if (aside.innerHTML !== html) aside.innerHTML = html;
}
function _issuePdfEnd(i) {
let bar = document.getElementById('iss-pdf-end');
if (!bar) {
bar = document.createElement('aside');
bar.id = 'iss-pdf-end';
bar.className = 'iss-end iss-end-float';
bar.addEventListener('click', _issueEndClick);
document.body.appendChild(bar);
}
bar.innerHTML = `<button type="button" class="dfo-x iss-end-x" data-issue-end-act="close" aria-label="Schließen">✕</button>` + _issueEndHtml(i);
clearTimeout(bar.timer);
bar.timer = setTimeout(() => bar.remove(), 12000);
}
function _issueEndClick(e) {
const t = e.target.closest('[data-issue-end-act]');
if (!t || !_issueR) return;
e.stopPropagation();
e.preventDefault();
const id = _issueR.bookId;
const act = t.dataset.issueEndAct;
const bar = document.getElementById('iss-pdf-end');
if (act === 'close') { if (bar) bar.remove(); return; }
if (act === 'next') {
if (bar) bar.remove();
const n = Number(t.dataset.n);
_issueR.seen.add(n);
_issueJump(_issueR.arts[n]);
} else if (act === 'toc') {
if (bar) bar.remove();
closeReader();
if (window.DioraNav) DioraNav.go('magazines');
_issueOpen(id);
} else if (act === 'skip') {
_issueMark(id, _issueR.arts[Number(t.dataset.n)].h, 's');
} else if (act === 'unread') {
_issueMark(id, _issueR.arts[Number(t.dataset.i)].h, '');
}
setTimeout(() => {
if (!_issueR) return;
document.querySelectorAll('aside.iss-end[data-issue-end]').forEach(a => _issueRenderEnd(a, Number(a.dataset.issueEnd)));
if (bar && bar.isConnected) bar.remove();
_issueRenderHead();
}, 0);
}
// ---- styles ---------------------------------------------------------------------------------------
(function () {
const style = document.createElement('style');
style.textContent = [
'.iss-seg { display: flex; gap: 2px; height: 8px; }',
'.iss-seg > span { flex: 1; min-width: 2px; border-radius: 2px; background: var(--border, #2a2a2a); }',
'.iss-seg > .iss-s-read { background: var(--fg, #eee); }',
'.iss-seg > .iss-s-skip { background: repeating-linear-gradient(135deg, var(--muted, #9a9a9a) 0 2px, var(--border, #2a2a2a) 2px 4px); }',
'.iss-seg > .iss-s-at { background: linear-gradient(90deg, var(--accent, #e63946) 45%, var(--border, #2a2a2a) 45%); }',
'.iss-seg > .iss-s-now { background: var(--accent, #e63946); flex: 2.5; }',
'.iss-seg-thin { height: 5px; }',
'#book-list .lib-card .iss-seg, #book-list .lib-row .iss-seg, #book-list .lib-continue .iss-seg { margin-top: 4px; }',
'#book-list .iss-top { display: flex; align-items: center; justify-content: space-between; margin: 0 0 8px; }',
'#book-list .iss-back { min-height: 44px; padding: 0 6px; border: none; background: none; color: inherit; font: inherit; font-size: 15px; cursor: pointer; }',
'#book-list .iss-top .iss-more { position: static; opacity: 1; border: none; background: none; color: inherit; font-size: 20px; width: 44px; height: 44px; }',
'#book-list.iss-open .lib-head, #book-list.iss-open .lib-chips { display: none; }',
'#book-list .iss-head { display: flex; gap: 14px; margin: 0 0 12px; }',
'#book-list .iss-cover { flex: none; width: 96px; height: 144px; border-radius: 4px; object-fit: cover; }',
'#book-list .iss-head-text { display: flex; flex-direction: column; gap: 4px; min-width: 0; }',
'#book-list .iss-title { font-size: 21px; font-weight: 700; line-height: 1.25; }',
'#book-list .iss-sum { display: flex; flex-direction: column; gap: 8px; margin: 0 0 8px; }',
'#book-list .iss-actions { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; }',
'.iss-go { min-height: 44px; padding: 0 18px; border: none; border-radius: 22px; background: var(--accent, #e63946); color: var(--on-accent, #fff); font: inherit; font-size: 14px; font-weight: 600; cursor: pointer; }',
'.iss-btn { min-height: 40px; padding: 0 14px; border: 1px solid var(--border-strong, #444); border-radius: 20px; background: none; color: inherit; font: inherit; font-size: 14px; cursor: pointer; }',
'#book-list .iss-rubrik { margin: 18px 0 2px; font-size: 12px; font-weight: 400; letter-spacing: .08em; text-transform: uppercase; color: var(--muted, #9a9a9a); }',
'#book-list .iss-row { display: flex; align-items: center; border-bottom: 1px solid var(--border, #2a2a2a); }',
'#book-list .iss-row.iss-next { background: var(--card, #0b0b0b); border: 1px solid var(--accent, #e63946); border-radius: 8px; }',
'#book-list .iss-mark { flex: none; width: 40px; min-height: 48px; border: none; background: none; color: var(--muted, #9a9a9a); font: inherit; font-size: 16px; cursor: pointer; }',
'#book-list .iss-read .iss-mark { color: var(--fg, #eee); }',
'#book-list .iss-next .iss-mark { color: var(--accent, #e63946); }',
'#book-list .iss-art { flex: 1; min-width: 0; display: flex; align-items: center; gap: 8px; padding: 9px 8px 9px 0; border: none; background: none; color: inherit; font: inherit; text-align: left; cursor: pointer; }',
'#book-list .iss-n { flex: none; width: 22px; font-size: 12px; color: var(--muted, #9a9a9a); text-align: right; }',
'#book-list .iss-art-text { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 2px; }',
'#book-list .iss-art-title { font-size: 15px; line-height: 1.3; }',
'#book-list .iss-next .iss-art-title { font-weight: 700; }',
'#book-list .iss-read .iss-art-title { color: var(--muted, #9a9a9a); }',
'#book-list .iss-skip .iss-art-title { color: var(--muted, #9a9a9a); text-decoration: line-through; }',
'#book-list .iss-teaser { font-size: 12px; color: var(--muted, #9a9a9a); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }',
'#book-list .iss-min { flex: none; font-size: 12px; color: var(--muted, #9a9a9a); }',
'#iss-runhead { display: flex; flex-direction: column; gap: 4px; padding: 4px 16px 6px; border-bottom: 1px solid var(--border, #2a2a2a); background: var(--bg, #000); }',
'body.reader-immersive #iss-runhead { display: none; }',
'.iss-rh-text { display: flex; flex-direction: column; min-width: 0; }',
'.iss-rh-rubrik { font-size: 11px; letter-spacing: .08em; text-transform: uppercase; color: var(--muted, #9a9a9a); }',
'.iss-rh-title { font-size: 13px; font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }',
'aside.iss-end { display: flex; flex-direction: column; gap: 12px; margin: 2.5em 0; padding: 16px; border: 1px solid currentColor; border-radius: 12px; font-family: system-ui, sans-serif; font-size: 15px; line-height: 1.35; text-indent: 0; }',
'.iss-end-state { display: flex; flex-direction: column; gap: 3px; }',
'.iss-end-next { display: flex; flex-direction: column; gap: 4px; padding: 12px 14px; border: none; border-radius: 10px; background: var(--accent, #e63946); color: var(--on-accent, #fff); font: inherit; text-align: left; cursor: pointer; }',
'.iss-end-kicker { font-size: 12px; opacity: .9; }',
'.iss-end-title { font-size: 17px; font-weight: 600; }',
'.iss-end-hint { font-size: 12px; opacity: .75; }',
'.iss-end-actions { display: flex; flex-wrap: wrap; gap: 8px; }',
'.iss-end-float { position: fixed; left: 12px; right: 12px; bottom: 16px; z-index: 960; margin: 0; background: var(--panel, #161616); color: var(--fg, #eee); box-shadow: 0 4px 18px rgba(0,0,0,.4); }',
'.iss-end-x { position: absolute; right: 6px; top: 6px; }',
'body.eink .iss-seg > span { background: #fff; outline: 1px solid #000; outline-offset: -1px; border-radius: 0; }',
'body.eink .iss-seg > .iss-s-read { background: #000; }',
'body.eink .iss-seg > .iss-s-skip { background: repeating-linear-gradient(135deg, #000 0 1px, #fff 1px 4px); }',
'body.eink .iss-seg > .iss-s-at, body.eink .iss-seg > .iss-s-now { background: linear-gradient(90deg, #000 50%, #fff 50%); }',
'body.eink .iss-go, body.eink .iss-end-next { background: #000; color: #fff; }',
'body.eink #book-list .iss-row.iss-next { background: #fff; border: 2px solid #000; }',
'body.eink #book-list .iss-row, body.eink #iss-runhead { border-color: #000; }',
'body.eink .iss-end-float { background: #fff; color: #000; border: 2px solid #000; box-shadow: none; }',
].join('\n');
document.head.appendChild(style);
})();