fix: bound memory, harden rendering, make assets path-independent

Memory: rendered page canvases were never released, so flipping through a
document accumulated one supersampled canvas per page for the lifetime of the
page. Measured on a 30-page PDF this reached 174 MB and stayed there; the figure
grows linearly with page count, which puts a mid-sized catalogue past what a
mobile browser will tolerate. Pages outside a window around the current spread
now have their canvas released (dimensions zeroed so the backing store goes
immediately) and their placeholder restored. Same document now plateaus at
64 MB regardless of reading position.

Supersampling was pinned at 2x even on 1x displays, which costs four times the
memory of a 1x canvas for no visible gain there. It now follows the device pixel
ratio between 1.5x and 2x.

Startup: buildPageDescriptors() walked the entire document calling getPage() on
every page, including when spread splitting was disabled -- the default. On long
PDFs that is hundreds of sequential PDF.js round trips before the first paint.
The walk now happens only when splitting is actually on.

Rendering races: a resize landing while a page was rasterising left that page
with a canvas sized for the previous layout, marked as rendered and therefore
never corrected. Renders now carry a generation stamp and are discarded if the
layout moved underneath them, and the in-flight task is cancelled rather than
left to finish work that is already void. Not reproduced from outside -- the
window is narrow -- but the sequence is plain in the code.

Deployment: asset URLs were absolute from the web root, so the bundle broke
whenever Contao ran in a subdirectory. They now derive from import.meta.url.
Verified under /kunde/shop/ with no failed requests.

Assets moved from the template into the controller via TL_HEAD, keyed so several
flipbooks on one page emit one copy each, in the head rather than after the
content.

Also:
- Front-end strings were hardcoded German despite the bundle shipping de/en.
  They now come from TL_LANG and reach the module as data attributes.
- Swallowed errors now reach the console; a failing PDF left no trace before.
- Canvas text is invisible to assistive technology: pages carry aria-labels and
  the source PDF is offered as an equivalent download. Honours reduced motion.
- No teardown existed; destroy() plus a MummertPdfFlipbook API for AJAX pages.
- Pages deviating from the document's dominant format were stretched. The
  layout ratio is now the median of a leading sample, with object-fit: contain.
- Aspect ratio, pointerdown stealing focus from buttons, single Audio element
  cutting itself off on fast flips.
- Vendored PDF.js cmaps and standard fonts: without them, PDFs using CID
  encodings or relying on the base-14 fonts render blank.
- Dropped pdf.min.mjs and pdf.worker.min.mjs -- byte-identical copies of the
  .js files that nothing referenced (1.7 MB).
- Third-party licence texts were referenced but never shipped. Added, including
  the separate Foxit and Liberation terms that come with the fonts. Recorded
  that flipbook-js publishes no LICENSE file upstream.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Jürgen Mummert
2026-08-04 21:39:32 +02:00
co-authored by Claude Opus 5
parent 583fa81eb8
commit 0e7996df01
196 changed files with 765 additions and 169 deletions
+49 -11
View File
@@ -1,16 +1,20 @@
# Flipbook Bundle (Contao 5.7)
Contao Inhaltselement zum Darstellen von PDFs als blaetterbares Flipbook.
Contao Inhaltselement zum Darstellen von PDFs als blätterbares Flipbook.
## Features
- Reines JavaScript (ES Modules)
- `pdfjs-dist` fuer PDF-Rendering in `<canvas>`
- `flipbook-js` fuer den Blaettereffekt
- Lazy Rendering: 2-4 Seiten sofort, Rest on-demand
- Optional Vor-/Zurueck-Navigation im Modul
- Reines JavaScript (ES Modules), keine Build-Kette
- `pdfjs-dist` für PDF-Rendering in `<canvas>`
- `flipbook-js` für den Blättereffekt
- Lazy Rendering: 24 Seiten sofort, Rest on-demand
- Automatisches Freigeben weit entfernter Seiten, damit der Speicher auch bei
mehrhundertseitigen PDFs beschränkt bleibt
- Optionales Aufteilen von Doppelseiten in linke und rechte Einzelseite
- Optionale Vor-/Zurück-Navigation, optionaler Blättersound
- Tastatursteuerung (Pfeiltasten) und Touch-Swipe
- Responsive Breite mit beibehaltenem Seitenverhaeltnis
- Responsive Breite mit beibehaltenem Seitenverhältnis
- Frontend-Texte übersetzbar (DE/EN mitgeliefert)
## Installation (VCS oder Paket)
@@ -24,17 +28,51 @@ Elementtyp: `Blätterbares PDF`
Felder:
- `PDF-Datei` (singleSRC / UUID)
- `PDF-Datei` (fileTree / UUID)
- `Initial geladene Seiten` (2, 3 oder 4)
- `Startmodus` (zentriert oder als Doppelseite)
- `Doppelseiten aufteilen` (optional)
- `Navigation anzeigen` (optional)
- `Blättersound abspielen` (optional)
## Hinweise
- Das Inhaltselement rendert PDF-Seiten als `<canvas>` innerhalb von `.c-flipbook__page`.
- Die benoetigten Open-Source-Dateien sind lokal im Bundle enthalten.
- Fuer Shared-Hosting ohne konfigurierbare MIME-Types werden PDF.js-Module als `.js` ausgeliefert (statt `.mjs`).
- Siehe `THIRD_PARTY_LICENSES.md` fuer Abhaengigkeiten.
- Alle benötigten Open-Source-Dateien liegen lokal im Bundle; es gibt keine
Requests an externe CDNs.
- CSS und Modul-Script werden vom Controller über `$GLOBALS['TL_HEAD']`
registriert und landen damit einmal pro Seite im Head — auch wenn mehrere
Flipbooks auf derselben Seite stehen.
- Asset-Pfade werden zur Laufzeit aus `import.meta.url` abgeleitet. Das Bundle
funktioniert dadurch auch, wenn Contao in einem Unterverzeichnis läuft.
- `vendor/cmaps/` und `vendor/standard_fonts/` werden von PDF.js nur bei Bedarf
nachgeladen (CID-Encodings bzw. nicht eingebettete Standard-Fonts).
- Siehe `THIRD_PARTY_LICENSES.md` für Abhängigkeiten und `licenses/` für die
Lizenztexte.
### Barrierefreiheit
Canvas-Inhalte sind für Screenreader und die Seitensuche des Browsers nicht
zugänglich. Das Element gibt deshalb pro Seite ein `aria-label` aus und blendet
unter dem Flipbook einen Download-Link auf die Original-PDF ein — das ist die
gleichwertige Alternative im Sinne von WCAG. Wird der Link per CSS ausgeblendet,
entfällt diese Gleichwertigkeit.
### Geschützte Ordner
Contao liefert Dateien unter `files/` nur aus, wenn der Ordner öffentlich
freigegeben ist. Liegt die gewählte PDF in einem geschützten Ordner, lädt das
Flipbook nicht. Der Controller prüft nur, ob die Datei überhaupt auf der Platte
existiert — die Freigabe selbst muss im Dateimanager gesetzt sein.
### JavaScript-API
Für AJAX- oder Turbo-getriebene Seiten:
```js
window.MummertPdfFlipbook.scan(); // neue Elemente initialisieren
window.MummertPdfFlipbook.destroyAll(); // Instanzen abbauen, Speicher freigeben
```
## Release
+42 -3
View File
@@ -1,17 +1,56 @@
# Third-Party Licenses
This bundle contains the following third-party open source libraries in `public/assets/vendor/`:
This bundle redistributes the following third-party open source components in
`public/assets/vendor/`. Both licenses permit commercial use; both require that
their notices are preserved in redistributions.
## pdfjs-dist
- Project: https://github.com/mozilla/pdf.js
- Package: `pdfjs-dist`
- Version: `5.6.205`
- License: Apache-2.0
- License: Apache-2.0, © Mozilla Foundation
- License text: [`licenses/pdf.js-Apache-2.0.txt`](licenses/pdf.js-Apache-2.0.txt)
Redistributed files:
- `vendor/pdf.min.js`
- `vendor/pdf.worker.min.js`
- `vendor/cmaps/` — CID character maps, fetched on demand for PDFs using CJK or
other CID-keyed encodings
- `vendor/standard_fonts/` — replacements for the 14 standard PDF fonts, used
when a document does not embed them
The upstream package ships no `NOTICE` file, so the attribution requirement of
Apache-2.0 section 4(d) does not apply.
### Fonts inside `vendor/standard_fonts/`
The replacement fonts are not covered by Apache-2.0 and carry their own terms.
Both license texts ship alongside the fonts and must stay in place:
| Files | License | Text |
| --- | --- | --- |
| `Foxit*.pfb` | BSD-3-Clause, © 2014 PDFium Authors | `vendor/standard_fonts/LICENSE_FOXIT` |
| `LiberationSans-*.ttf` | SIL Open Font License 1.1, © 2010 Google, © 2012 Red Hat | `vendor/standard_fonts/LICENSE_LIBERATION` |
## flipbook-js
- Project: https://github.com/taufiqelrahman/flipbook-js
- Package: `flipbook-js`
- Version: `1.1.1`
- License: MIT
- Author: Taufiq (npm/GitHub: `taufiqelrahman`)
- License: MIT, as declared in the `license` field of the published
`package.json`
Redistributed files:
- `vendor/flipbook.esm.min.js`
- `vendor/flipbook.min.css`
**Note on provenance:** upstream publishes no `LICENSE` file — neither in the
npm tarball nor in the GitHub repository (checked 2026-08-04; GitHub's own
license detection reports none). The MIT grant therefore rests solely on the
`package.json` declaration, and there is no upstream copyright notice to carry
over. No license text is reproduced here, because inventing one would
misrepresent what the author actually published.
+14
View File
@@ -3,3 +3,17 @@
declare(strict_types=1);
$GLOBALS['TL_LANG']['CTE']['blatterbares_pdf'] = ['Blätterbares PDF', 'Zeigt ein PDF als blätterbares Flipbook mit Lazy-Rendering an.'];
$GLOBALS['TL_LANG']['MSC']['flipbookRegionLabel'] = 'Blätterbares PDF';
$GLOBALS['TL_LANG']['MSC']['flipbookLoading'] = 'PDF wird geladen ...';
$GLOBALS['TL_LANG']['MSC']['flipbookError'] = 'PDF konnte nicht geladen werden.';
$GLOBALS['TL_LANG']['MSC']['flipbookMissing'] = 'Keine PDF-Datei gefunden.';
$GLOBALS['TL_LANG']['MSC']['flipbookNotConfigured'] = 'Keine PDF-Datei konfiguriert.';
$GLOBALS['TL_LANG']['MSC']['flipbookNotPublic'] = 'Die gewählte PDF-Datei liegt in einem geschützten Ordner und ist im Frontend nicht abrufbar.';
$GLOBALS['TL_LANG']['MSC']['flipbookRendering'] = 'Seite {page} wird gerendert ...';
$GLOBALS['TL_LANG']['MSC']['flipbookBlank'] = 'Leere Seite';
$GLOBALS['TL_LANG']['MSC']['flipbookPageLabel'] = 'Seite {page} von {total}';
$GLOBALS['TL_LANG']['MSC']['flipbookNavLabel'] = 'Flipbook-Navigation';
$GLOBALS['TL_LANG']['MSC']['flipbookPrev'] = 'Zurück';
$GLOBALS['TL_LANG']['MSC']['flipbookNext'] = 'Weiter';
$GLOBALS['TL_LANG']['MSC']['flipbookDownload'] = 'PDF herunterladen';
+2 -2
View File
@@ -3,7 +3,7 @@
declare(strict_types=1);
$GLOBALS['TL_LANG']['tl_content']['flipbook_legend'] = 'Blätterbares PDF';
$GLOBALS['TL_LANG']['tl_content']['flipbookPdfSrc'] = ['PDF-Datei', 'Bitte waehlen Sie die PDF-Datei aus, die als Flipbook angezeigt werden soll.'];
$GLOBALS['TL_LANG']['tl_content']['flipbookPdfSrc'] = ['PDF-Datei', 'Bitte wählen Sie die PDF-Datei aus, die als Flipbook angezeigt werden soll.'];
$GLOBALS['TL_LANG']['tl_content']['flipbookInitialPages'] = ['Initial geladene Seiten', 'Anzahl der Seiten, die beim Laden direkt gerendert werden (Rest lazy).'];
$GLOBALS['TL_LANG']['tl_content']['flipbookInitialPagesOptions'] = [
'2' => '2 Seiten',
@@ -16,5 +16,5 @@ $GLOBALS['TL_LANG']['tl_content']['flipbookStartModeOptions'] = [
'spread' => 'als Doppelseite starten',
];
$GLOBALS['TL_LANG']['tl_content']['flipbookSplitSpreads'] = ['Doppelseiten aufteilen', 'Teilt breite PDF-Seiten ab Seite 2 automatisch in linke und rechte Einzelseite.'];
$GLOBALS['TL_LANG']['tl_content']['flipbookShowNavigation'] = ['Navigation anzeigen', 'Zeigt Vor-/Zurueck-Buttons unter dem Flipbook an.'];
$GLOBALS['TL_LANG']['tl_content']['flipbookShowNavigation'] = ['Navigation anzeigen', 'Zeigt Vor-/Zurück-Buttons unter dem Flipbook an.'];
$GLOBALS['TL_LANG']['tl_content']['flipbookPlaySound'] = ['Blättersound abspielen', 'Spielt beim Blättern einen Sound ab.'];
+14
View File
@@ -3,3 +3,17 @@
declare(strict_types=1);
$GLOBALS['TL_LANG']['CTE']['blatterbares_pdf'] = ['Flippable PDF', 'Displays a PDF as a page-turning flipbook with lazy rendering.'];
$GLOBALS['TL_LANG']['MSC']['flipbookRegionLabel'] = 'Flippable PDF';
$GLOBALS['TL_LANG']['MSC']['flipbookLoading'] = 'Loading PDF ...';
$GLOBALS['TL_LANG']['MSC']['flipbookError'] = 'The PDF could not be loaded.';
$GLOBALS['TL_LANG']['MSC']['flipbookMissing'] = 'No PDF file found.';
$GLOBALS['TL_LANG']['MSC']['flipbookNotConfigured'] = 'No PDF file configured.';
$GLOBALS['TL_LANG']['MSC']['flipbookNotPublic'] = 'The selected PDF file is stored in a protected folder and cannot be served in the front end.';
$GLOBALS['TL_LANG']['MSC']['flipbookRendering'] = 'Rendering page {page} ...';
$GLOBALS['TL_LANG']['MSC']['flipbookBlank'] = 'Blank page';
$GLOBALS['TL_LANG']['MSC']['flipbookPageLabel'] = 'Page {page} of {total}';
$GLOBALS['TL_LANG']['MSC']['flipbookNavLabel'] = 'Flipbook navigation';
$GLOBALS['TL_LANG']['MSC']['flipbookPrev'] = 'Previous';
$GLOBALS['TL_LANG']['MSC']['flipbookNext'] = 'Next';
$GLOBALS['TL_LANG']['MSC']['flipbookDownload'] = 'Download PDF';
@@ -1,5 +1,7 @@
{% set hasPdf = pdfUrl|default('') is not empty %}
{# Stylesheets and the module script are registered by the controller so they
land in the document head once, no matter how many flipbooks a page holds. #}
<div
class="ce-blatterbares-pdf mod-pdf-flipbook is-loading"
data-pdf-flipbook-element="1"
@@ -9,26 +11,34 @@
data-split-spreads="{{ splitSpreads ? '1' : '0' }}"
data-show-navigation="{{ showNavigation ? '1' : '0' }}"
data-play-turn-sound="{{ ((playTurnSound is defined) ? playTurnSound : true) ? '1' : '0' }}"
data-i18n-loading="{{ 'MSC.flipbookLoading'|trans([], 'contao_default')|e('html_attr') }}"
data-i18n-error="{{ 'MSC.flipbookError'|trans([], 'contao_default')|e('html_attr') }}"
data-i18n-missing="{{ 'MSC.flipbookMissing'|trans([], 'contao_default')|e('html_attr') }}"
data-i18n-rendering="{{ 'MSC.flipbookRendering'|trans([], 'contao_default')|e('html_attr') }}"
data-i18n-blank="{{ 'MSC.flipbookBlank'|trans([], 'contao_default')|e('html_attr') }}"
data-i18n-page-label="{{ 'MSC.flipbookPageLabel'|trans([], 'contao_default')|e('html_attr') }}"
role="region"
aria-label="{{ 'MSC.flipbookRegionLabel'|trans([], 'contao_default')|e('html_attr') }}"
tabindex="0"
>
<div class="mod-pdf-flipbook__status" data-flipbook-loader="1" aria-live="polite">PDF wird geladen ...</div>
<div class="mod-pdf-flipbook__status" data-flipbook-loader="1" aria-live="polite">{{ 'MSC.flipbookLoading'|trans([], 'contao_default') }}</div>
<div class="mod-pdf-flipbook__stage" data-flipbook-stage="1" style="visibility:hidden;opacity:0">
<div id="flipbook-{{ data.id|default(random()) }}" class="c-flipbook" data-flipbook-book="1"></div>
<div class="c-flipbook" data-flipbook-book="1"></div>
</div>
{% if showNavigation %}
<div class="mod-pdf-flipbook__controls" data-flipbook-controls="1" aria-label="Flipbook Navigation" style="visibility:hidden;opacity:0">
<button type="button" data-flipbook-prev="1">Zurück</button>
<button type="button" data-flipbook-next="1">Weiter</button>
<div class="mod-pdf-flipbook__controls" data-flipbook-controls="1" role="group" aria-label="{{ 'MSC.flipbookNavLabel'|trans([], 'contao_default')|e('html_attr') }}" style="visibility:hidden;opacity:0">
<button type="button" data-flipbook-prev="1">{{ 'MSC.flipbookPrev'|trans([], 'contao_default') }}</button>
<button type="button" data-flipbook-next="1">{{ 'MSC.flipbookNext'|trans([], 'contao_default') }}</button>
</div>
{% endif %}
{% if not hasPdf %}
<p class="mod-pdf-flipbook__error">Keine PDF-Datei konfiguriert.</p>
{% if hasPdf %}
{# Canvas content is opaque to assistive technology and to the browser's
find-in-page, so the source file stays available as an equivalent. #}
<a class="mod-pdf-flipbook__download" href="{{ pdfUrl|e('html_attr') }}" download>{{ 'MSC.flipbookDownload'|trans([], 'contao_default') }}</a>
{% else %}
<p class="mod-pdf-flipbook__error">{{ 'MSC.flipbookNotConfigured'|trans([], 'contao_default') }}</p>
{% endif %}
</div>
<link rel="stylesheet" href="/bundles/flipbook/assets/vendor/flipbook.min.css?v=20260414j">
<link rel="stylesheet" href="/bundles/flipbook/assets/flipbook-module.css?v=20260414j">
<script type="module" src="/bundles/flipbook/assets/flipbook-module.js?v=20260414j"></script>
+35
View File
@@ -72,6 +72,9 @@
display: block;
width: 100%;
height: 100%;
/* Pages that deviate from the document's dominant format are letterboxed
inside the uniform page box instead of being stretched to fit it. */
object-fit: contain;
}
.mod-pdf-flipbook__page-loader {
@@ -91,6 +94,12 @@
);
}
/* Set while a canvas is mounted. Evicted pages show the placeholder again
rather than a blank page. */
.mod-pdf-flipbook__page-loader.is-hidden {
display: none;
}
.mod-pdf-flipbook__controls {
position: relative;
z-index: 20;
@@ -105,6 +114,16 @@
color: #9f1f1f;
}
/* Text in a canvas is invisible to assistive technology and cannot be selected
or searched, so the original file stays reachable as an equivalent
alternative. */
.mod-pdf-flipbook__download {
display: inline-block;
margin-top: 0.9rem;
font-size: 0.92rem;
color: var(--flipbook-text);
}
@media (max-width: 767px) {
.mod-pdf-flipbook__controls {
justify-content: space-between;
@@ -116,3 +135,19 @@
transform: rotate(360deg);
}
}
@media (prefers-reduced-motion: reduce) {
.mod-pdf-flipbook__status::before {
animation: none;
}
.mod-pdf-flipbook__stage,
.mod-pdf-flipbook__controls {
transition: none;
}
.mod-pdf-flipbook .c-flipbook__page,
.mod-pdf-flipbook .c-flipbook__page::before {
transition-duration: 1ms !important;
}
}
+355 -86
View File
@@ -1,15 +1,46 @@
const assetUrl = (relativePath) => new URL(relativePath, import.meta.url).href;
const PDF_MODULE_URL = assetUrl('./vendor/pdf.min.js');
const PDF_WORKER_URL = assetUrl('./vendor/pdf.worker.min.js');
const FLIPBOOK_MODULE_URL = assetUrl('./vendor/flipbook.esm.min.js');
const CMAP_URL = assetUrl('./vendor/cmaps/');
const STANDARD_FONT_URL = assetUrl('./vendor/standard_fonts/');
const TURN_SOUND_URL = assetUrl('./audio/turn.mp3');
const MODULE_SELECTOR = '[data-pdf-flipbook-element="1"]';
const PDF_MODULE_URL = '/bundles/flipbook/assets/vendor/pdf.min.js';
const PDF_WORKER_URL = '/bundles/flipbook/assets/vendor/pdf.worker.min.js';
const FLIPBOOK_MODULE_URL = '/bundles/flipbook/assets/vendor/flipbook.esm.min.js';
const TURN_SOUND_URL = '/bundles/flipbook/assets/audio/turn.mp3';
const INIT_MARKER = 'pdfFlipbookInitialized';
const BOOTSTRAP_MARKER = '__mummertPdfFlipbookBootstrapBound';
const MIN_OUTPUT_SCALE = 2;
// Supersampling factor for the page canvas. The canvas is stretched to the page
// box via CSS, so rendering above 1x keeps text crisp. Capped at 2 because the
// memory cost grows quadratically -- 2x already costs four times a 1x canvas.
const MIN_OUTPUT_SCALE = 1.5;
const MAX_OUTPUT_SCALE = 2;
// Pages further than this from the current spread get their canvas released.
// Must stay above the lazy-render window (see getLazyCandidates) so pages are
// not evicted and re-rendered in a loop.
const EVICTION_RADIUS = 6;
// Number of leading pages sampled to determine the layout aspect ratio.
const ASPECT_SAMPLE_SIZE = 8;
const TURN_SOUND_POOL_SIZE = 3;
const DEFAULT_TEXTS = {
loading: 'PDF wird geladen ...',
error: 'PDF konnte nicht geladen werden.',
missing: 'Keine PDF-Datei gefunden.',
rendering: 'Seite {page} wird gerendert ...',
blank: 'Leere Seite',
pageLabel: 'Seite {page} von {total}',
};
let dependenciesPromise;
let moduleCounter = 0;
const instances = new Set();
const loadDependencies = async () => {
if (dependenciesPromise) {
return dependenciesPromise;
@@ -30,6 +61,9 @@ const loadDependencies = async () => {
return dependenciesPromise;
};
const interpolate = (template, replacements) => Object.entries(replacements)
.reduce((carry, [key, value]) => carry.replaceAll(`{${key}}`, String(value)), template);
class PdfFlipbookModule {
constructor(root, dependencies) {
const parsedInitialPages = Number.parseInt(root.dataset.initialPages || '4', 10);
@@ -48,6 +82,14 @@ class PdfFlipbookModule {
this.startMode = (root.dataset.startMode === 'spread' || root.dataset.startMode === 'cover')
? 'spread'
: 'center';
this.texts = {
loading: root.dataset.i18nLoading || DEFAULT_TEXTS.loading,
error: root.dataset.i18nError || DEFAULT_TEXTS.error,
missing: root.dataset.i18nMissing || DEFAULT_TEXTS.missing,
rendering: root.dataset.i18nRendering || DEFAULT_TEXTS.rendering,
blank: root.dataset.i18nBlank || DEFAULT_TEXTS.blank,
pageLabel: root.dataset.i18nPageLabel || DEFAULT_TEXTS.pageLabel,
};
this.loader = root.querySelector('[data-flipbook-loader="1"]');
this.stage = root.querySelector('[data-flipbook-stage="1"]');
this.bookElement = root.querySelector('[data-flipbook-book="1"]');
@@ -58,13 +100,15 @@ class PdfFlipbookModule {
this.pdf = null;
this.flipbook = null;
this.pageElements = new Map();
this.pageLoaders = new Map();
this.renderedPages = new Set();
this.pendingPages = new Set();
this.renderQueue = [];
this.renderInProgress = false;
this.resizeTimer = null;
this.touchStart = null;
this.turnSound = this.playTurnSoundEnabled ? this.createTurnSound() : null;
this.turnSounds = this.playTurnSoundEnabled ? this.createTurnSounds() : [];
this.turnSoundIndex = 0;
this.aspectRatio = 1.4142;
this.pageWidth = 0;
this.pageHeight = 0;
@@ -73,6 +117,14 @@ class PdfFlipbookModule {
this.totalPages = 0;
this.pageDescriptors = [];
// Bumped whenever the layout changes. Renders that finish after a bump
// were produced for a stale page size and must be discarded.
this.renderGeneration = 0;
this.activeRenderTask = null;
this.resizeObserver = null;
this.abortController = new AbortController();
this.destroyed = false;
this.instanceId = `pdf-flipbook-${++moduleCounter}`;
}
@@ -82,7 +134,7 @@ class PdfFlipbookModule {
}
if (!this.pdfUrl) {
this.setStatus('Keine PDF-Datei gefunden.', true);
this.setStatus(this.texts.missing, true);
this.root.classList.remove('is-loading');
return;
@@ -91,9 +143,9 @@ class PdfFlipbookModule {
this.bookElement.id = this.instanceId;
try {
this.setStatus('PDF wird geladen ...');
this.setStatus(this.texts.loading);
await this.loadPdf();
await this.resolveInitialAspectRatio();
await this.resolveAspectRatio();
this.buildPageSkeleton();
this.updateLayout();
await this.renderInitialPages();
@@ -104,8 +156,13 @@ class PdfFlipbookModule {
this.queuePages(this.getLazyCandidates());
await this.showReadyState();
this.setStatus('');
} catch {
this.setStatus('PDF konnte nicht geladen werden.', true);
} catch (error) {
if (this.destroyed) {
return;
}
console.error('[flipbook] Initialisierung fehlgeschlagen:', error);
this.setStatus(this.texts.error, true);
this.root.classList.remove('is-loading');
}
}
@@ -150,6 +207,11 @@ class PdfFlipbookModule {
url: this.pdfUrl,
useWorkerFetch: true,
isEvalSupported: false,
// Without these, PDFs relying on CID encodings or on the 14 standard
// fonts (rather than embedding them) render blank or with fallbacks.
cMapUrl: CMAP_URL,
cMapPacked: true,
standardFontDataUrl: STANDARD_FONT_URL,
});
this.pdf = await loadingTask.promise;
@@ -164,25 +226,37 @@ class PdfFlipbookModule {
}
async buildPageDescriptors() {
// Splitting is the only reason to inspect every page up front. With it
// disabled, walking the whole document would stall the first paint on
// long PDFs for no gain.
if (!this.splitSpreads) {
this.pageDescriptors = Array.from({ length: this.sourcePageCount }, (unused, index) => ({
sourcePageNumber: index + 1,
segment: 'full',
aspectRatio: 0,
}));
return;
}
const descriptors = [];
for (let sourcePageNumber = 1; sourcePageNumber <= this.sourcePageCount; sourcePageNumber += 1) {
const page = await this.pdf.getPage(sourcePageNumber);
const viewport = page.getViewport({ scale: 1 });
const width = Number(viewport.width || 0);
const height = Number(viewport.height || 0);
if (this.shouldSplitSpread(sourcePageNumber, viewport)) {
descriptors.push({
sourcePageNumber,
segment: 'left',
});
descriptors.push({
sourcePageNumber,
segment: 'right',
});
const aspectRatio = width > 0 ? height / (width / 2) : 0;
descriptors.push({ sourcePageNumber, segment: 'left', aspectRatio });
descriptors.push({ sourcePageNumber, segment: 'right', aspectRatio });
} else {
descriptors.push({
sourcePageNumber,
segment: 'full',
aspectRatio: width > 0 ? height / width : 0,
});
}
}
@@ -217,21 +291,43 @@ class PdfFlipbookModule {
return this.pageDescriptors[pageNumber - 1] || null;
}
async resolveInitialAspectRatio() {
const firstDescriptor = this.getDescriptor(1);
async resolveAspectRatio() {
// The flip engine needs one uniform page box, so a document with mixed
// page formats gets the median of a leading sample rather than whatever
// the cover page happens to be. Odd pages out are letterboxed by CSS.
const sampleSize = Math.min(ASPECT_SAMPLE_SIZE, this.pageDescriptors.length);
const ratios = [];
if (!firstDescriptor) {
for (let pageNumber = 1; pageNumber <= sampleSize; pageNumber += 1) {
const descriptor = this.getDescriptor(pageNumber);
if (!descriptor) {
continue;
}
if (descriptor.aspectRatio > 0) {
ratios.push(descriptor.aspectRatio);
continue;
}
const page = await this.pdf.getPage(descriptor.sourcePageNumber);
const viewport = page.getViewport({ scale: 1 });
const divisor = descriptor.segment === 'full' ? 1 : 2;
const width = viewport.width / divisor;
if (width > 0 && viewport.height > 0) {
descriptor.aspectRatio = viewport.height / width;
ratios.push(descriptor.aspectRatio);
}
}
if (ratios.length === 0) {
return;
}
const firstPage = await this.pdf.getPage(firstDescriptor.sourcePageNumber);
const viewport = firstPage.getViewport({ scale: 1 });
const divisor = firstDescriptor.segment === 'full' ? 1 : 2;
const width = viewport.width / divisor;
if (width > 0 && viewport.height > 0) {
this.aspectRatio = viewport.height / width;
}
ratios.sort((a, b) => a - b);
this.aspectRatio = ratios[Math.floor(ratios.length / 2)];
}
buildPageSkeleton() {
@@ -239,26 +335,38 @@ class PdfFlipbookModule {
this.bookElement.innerHTML = '';
this.pageElements.clear();
this.pageLoaders.clear();
for (let pageNumber = 1; pageNumber <= visualPageCount; pageNumber += 1) {
const page = document.createElement('div');
const loader = document.createElement('div');
const isContentPage = pageNumber <= this.totalPages;
page.className = 'c-flipbook__page';
page.dataset.pageNumber = String(pageNumber);
loader.className = 'mod-pdf-flipbook__page-loader';
loader.textContent = pageNumber <= this.totalPages ? `Seite ${pageNumber} wird gerendert ...` : 'Leere Seite';
loader.textContent = isContentPage
? interpolate(this.texts.rendering, { page: pageNumber })
: this.texts.blank;
page.appendChild(loader);
if (pageNumber > this.totalPages) {
if (isContentPage) {
page.setAttribute('role', 'img');
page.setAttribute('aria-label', interpolate(this.texts.pageLabel, {
page: pageNumber,
total: this.totalPages,
}));
} else {
page.dataset.empty = '1';
page.setAttribute('aria-hidden', 'true');
}
this.bookElement.appendChild(page);
if (pageNumber <= this.totalPages) {
if (isContentPage) {
this.pageElements.set(pageNumber, page);
this.pageLoaders.set(pageNumber, loader);
}
}
}
@@ -302,6 +410,7 @@ class PdfFlipbookModule {
onPageTurn: () => {
this.playTurnSound();
this.queuePages(this.getLazyCandidates());
this.evictDistantPages();
this.updateNavigationState();
},
});
@@ -311,14 +420,18 @@ class PdfFlipbookModule {
});
}
getActivePageNumbers() {
return Array.from(this.bookElement.querySelectorAll('.c-flipbook__page.is-active'))
.map((element) => Number(element.dataset.pageNumber || 0))
.filter((pageNumber) => Number.isInteger(pageNumber) && pageNumber > 0);
}
updateNavigationState() {
if (!this.showNavigation || !this.prevButton || !this.nextButton) {
return;
}
const activePages = Array.from(this.bookElement.querySelectorAll('.c-flipbook__page.is-active'))
.map((element) => Number(element.dataset.pageNumber || 0))
.filter((pageNumber) => Number.isInteger(pageNumber) && pageNumber > 0);
const activePages = this.getActivePageNumbers();
let disablePrev = false;
let disableNext = false;
@@ -340,28 +453,40 @@ class PdfFlipbookModule {
this.nextButton.setAttribute('aria-disabled', disableNext ? 'true' : 'false');
}
createTurnSound() {
const audio = new Audio(TURN_SOUND_URL);
audio.preload = 'auto';
createTurnSounds() {
// A single element would cut itself off when pages are flipped quickly.
return Array.from({ length: TURN_SOUND_POOL_SIZE }, () => {
const audio = new Audio(TURN_SOUND_URL);
audio.preload = 'auto';
return audio;
return audio;
});
}
playTurnSound() {
if (!this.playTurnSoundEnabled || !this.turnSound) {
if (!this.playTurnSoundEnabled || this.turnSounds.length === 0) {
return;
}
this.turnSound.currentTime = 0;
const audio = this.turnSounds[this.turnSoundIndex];
this.turnSoundIndex = (this.turnSoundIndex + 1) % this.turnSounds.length;
this.turnSound.play().catch(() => {
audio.currentTime = 0;
audio.play().catch(() => {
});
}
bindKeyboard() {
this.root.addEventListener('pointerdown', () => {
const { signal } = this.abortController;
this.root.addEventListener('pointerdown', (event) => {
// Buttons and links must keep the focus they just received.
if (event.target instanceof Element && event.target.closest('button, a, input, select, textarea')) {
return;
}
this.root.focus();
});
}, { signal });
this.root.addEventListener('keydown', (event) => {
if (!this.flipbook) {
@@ -377,10 +502,12 @@ class PdfFlipbookModule {
this.flipbook.turnPage('back');
event.preventDefault();
}
});
}, { signal });
}
bindTouchSwipe() {
const { signal } = this.abortController;
this.bookElement.addEventListener('touchstart', (event) => {
const touch = event.changedTouches && event.changedTouches[0];
@@ -392,7 +519,7 @@ class PdfFlipbookModule {
x: touch.clientX,
y: touch.clientY,
};
}, { passive: true });
}, { passive: true, signal });
this.bookElement.addEventListener('touchend', (event) => {
if (!this.flipbook || !this.touchStart) {
@@ -423,39 +550,37 @@ class PdfFlipbookModule {
}
this.touchStart = null;
}, { passive: true });
}, { passive: true, signal });
}
bindResize() {
const onResize = () => {
window.clearTimeout(this.resizeTimer);
this.resizeTimer = window.setTimeout(() => {
if (!this.pdf) {
if (!this.pdf || this.destroyed) {
return;
}
const previousWidth = this.pageWidth;
const previousHeight = this.pageHeight;
this.updateLayout();
if (Math.abs(previousHeight - this.pageHeight) < 2) {
if (Math.abs(previousHeight - this.pageHeight) < 2 && Math.abs(previousWidth - this.pageWidth) < 2) {
return;
}
// Invalidate everything rendered for the old page size, including
// a render that may still be in flight.
this.renderGeneration += 1;
this.cancelActiveRender();
this.bookElement.style.height = `${this.pageHeight}px`;
const rerender = Array.from(this.renderedPages);
this.renderedPages.clear();
rerender.forEach((pageNumber) => {
const page = this.pageElements.get(pageNumber);
if (page) {
const oldCanvas = page.querySelector('canvas');
if (oldCanvas) {
oldCanvas.remove();
}
}
this.releasePageCanvas(pageNumber);
});
this.queuePages(rerender, true);
@@ -463,15 +588,29 @@ class PdfFlipbookModule {
};
if ('ResizeObserver' in window) {
const observer = new ResizeObserver(onResize);
observer.observe(this.stage);
this.resizeObserver = new ResizeObserver(onResize);
this.resizeObserver.observe(this.stage);
} else {
window.addEventListener('resize', onResize, { passive: true });
window.addEventListener('resize', onResize, { passive: true, signal: this.abortController.signal });
}
}
cancelActiveRender() {
if (!this.activeRenderTask) {
return;
}
try {
this.activeRenderTask.cancel();
} catch {
// A task that already settled cannot be cancelled -- nothing to do.
}
this.activeRenderTask = null;
}
getLazyCandidates() {
const activePages = Array.from(this.bookElement.querySelectorAll('.c-flipbook__page.is-active'));
const activePages = this.getActivePageNumbers();
const candidates = new Set();
if (activePages.length === 0) {
@@ -485,9 +624,7 @@ class PdfFlipbookModule {
return Array.from(candidates);
}
activePages.forEach((activePage) => {
const currentPage = Number(activePage.dataset.pageNumber || 0);
activePages.forEach((currentPage) => {
for (let offset = -1; offset <= 3; offset += 1) {
const pageNumber = currentPage + offset;
@@ -500,6 +637,59 @@ class PdfFlipbookModule {
return Array.from(candidates);
}
getEvictionAnchor() {
const activePages = this.getActivePageNumbers();
if (activePages.length === 0) {
return 1;
}
return Math.min(...activePages);
}
/**
* Releases canvases outside the current reading window. Without this the
* rendered pages accumulate for the lifetime of the page: a few hundred
* supersampled canvases are enough to exhaust the memory budget of a mobile
* browser and have the tab killed.
*/
evictDistantPages() {
const anchor = this.getEvictionAnchor();
Array.from(this.renderedPages).forEach((pageNumber) => {
if (Math.abs(pageNumber - anchor) <= EVICTION_RADIUS) {
return;
}
this.releasePageCanvas(pageNumber);
this.renderedPages.delete(pageNumber);
});
}
releasePageCanvas(pageNumber) {
const pageElement = this.pageElements.get(pageNumber);
if (!pageElement) {
return;
}
const canvas = pageElement.querySelector('canvas');
if (canvas) {
// Zeroing the dimensions frees the backing store immediately instead
// of waiting for the element to be collected.
canvas.width = 0;
canvas.height = 0;
canvas.remove();
}
const loader = this.pageLoaders.get(pageNumber);
if (loader) {
loader.classList.remove('is-hidden');
}
}
queuePages(pageNumbers, prioritize = false) {
for (const pageNumber of pageNumbers) {
if (!Number.isInteger(pageNumber) || pageNumber < 1 || pageNumber > this.totalPages) {
@@ -529,7 +719,7 @@ class PdfFlipbookModule {
this.renderInProgress = true;
while (this.renderQueue.length > 0) {
while (this.renderQueue.length > 0 && !this.destroyed) {
const pageNumber = this.renderQueue.shift();
if (!pageNumber) {
@@ -540,8 +730,8 @@ class PdfFlipbookModule {
try {
await this.renderPage(pageNumber);
} catch {
continue;
} catch (error) {
console.error(`[flipbook] Seite ${pageNumber} konnte nicht gerendert werden:`, error);
}
}
@@ -549,7 +739,7 @@ class PdfFlipbookModule {
}
async renderPage(pageNumber) {
if (this.renderedPages.has(pageNumber)) {
if (this.renderedPages.has(pageNumber) || this.destroyed) {
return;
}
@@ -560,7 +750,13 @@ class PdfFlipbookModule {
return;
}
const generation = this.renderGeneration;
const page = await this.pdf.getPage(descriptor.sourcePageNumber);
if (generation !== this.renderGeneration || this.destroyed) {
return;
}
const viewportAtScale1 = page.getViewport({ scale: 1 });
const renderWidth = descriptor.segment === 'full'
? viewportAtScale1.width
@@ -568,10 +764,12 @@ class PdfFlipbookModule {
const scale = this.pageWidth / renderWidth;
const viewport = page.getViewport({ scale });
const devicePixelRatio = window.devicePixelRatio || 1;
const outputScale = Math.max(MIN_OUTPUT_SCALE, devicePixelRatio);
const outputScale = Math.min(MAX_OUTPUT_SCALE, Math.max(MIN_OUTPUT_SCALE, devicePixelRatio));
const existingCanvas = pageElement.querySelector('canvas');
if (existingCanvas) {
existingCanvas.width = 0;
existingCanvas.height = 0;
existingCanvas.remove();
}
@@ -591,10 +789,9 @@ class PdfFlipbookModule {
canvas.style.width = `${canvasCssWidth}px`;
canvas.style.height = `${Math.floor(viewport.height)}px`;
const baseTransform = outputScale === 1 ? null : [outputScale, 0, 0, outputScale, 0, 0];
let transform = baseTransform;
let transform = [outputScale, 0, 0, outputScale, 0, 0];
if (descriptor.segment !== 'full' && baseTransform) {
if (descriptor.segment !== 'full') {
const segmentOffsetX = descriptor.segment === 'left'
? 0
: -Math.floor((viewport.width * outputScale) / 2);
@@ -609,12 +806,39 @@ class PdfFlipbookModule {
intent: 'display',
});
await renderTask.promise;
this.activeRenderTask = renderTask;
const loader = pageElement.querySelector('.mod-pdf-flipbook__page-loader');
try {
await renderTask.promise;
} catch (error) {
canvas.width = 0;
canvas.height = 0;
if (error && error.name === 'RenderingCancelledException') {
return;
}
throw error;
} finally {
if (this.activeRenderTask === renderTask) {
this.activeRenderTask = null;
}
}
// The layout may have changed while this page was rasterising. A canvas
// for the previous page size would be stretched by the CSS and stay that
// way, because the page counts as rendered from here on.
if (generation !== this.renderGeneration || this.destroyed) {
canvas.width = 0;
canvas.height = 0;
return;
}
const loader = this.pageLoaders.get(pageNumber);
if (loader) {
loader.remove();
loader.classList.add('is-hidden');
}
pageElement.appendChild(canvas);
@@ -638,6 +862,42 @@ class PdfFlipbookModule {
this.loader.classList.toggle('is-error', isError);
this.loader.textContent = message;
}
destroy() {
if (this.destroyed) {
return;
}
this.destroyed = true;
this.renderGeneration += 1;
this.cancelActiveRender();
window.clearTimeout(this.resizeTimer);
this.abortController.abort();
if (this.resizeObserver) {
this.resizeObserver.disconnect();
this.resizeObserver = null;
}
this.renderQueue.length = 0;
this.pendingPages.clear();
Array.from(this.renderedPages).forEach((pageNumber) => this.releasePageCanvas(pageNumber));
this.renderedPages.clear();
this.pageElements.clear();
this.pageLoaders.clear();
this.turnSounds = [];
if (this.pdf) {
this.pdf.destroy().catch(() => {
});
this.pdf = null;
}
this.flipbook = null;
delete this.root.dataset[INIT_MARKER];
instances.delete(this);
}
}
const run = async () => {
@@ -654,24 +914,33 @@ const run = async () => {
moduleElement.dataset[INIT_MARKER] = '1';
const module = new PdfFlipbookModule(moduleElement, dependencies);
instances.add(module);
await module.init();
}));
};
if (window[BOOTSTRAP_MARKER]) {
run().catch(() => {
});
const destroyAll = () => {
Array.from(instances).forEach((module) => module.destroy());
};
// Exposed so AJAX- or Turbo-driven pages can re-scan for new elements and tear
// down the ones they are about to discard.
window.MummertPdfFlipbook = { scan: run, destroyAll };
const bootstrap = () => {
run().catch((error) => {
console.error('[flipbook] Bootstrap fehlgeschlagen:', error);
});
};
if (window[BOOTSTRAP_MARKER]) {
bootstrap();
} else {
window[BOOTSTRAP_MARKER] = true;
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', () => {
run().catch(() => {
});
}, { once: true });
document.addEventListener('DOMContentLoaded', bootstrap, { once: true });
} else {
run().catch(() => {
});
bootstrap();
}
}
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+3
View File
@@ -0,0 +1,3 @@
àRCopyright 1990-2009 Adobe Systems Incorporated.
All rights reserved.
See ./LICENSEáCNS2-H
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+3
View File
@@ -0,0 +1,3 @@
àRCopyright 1990-2009 Adobe Systems Incorporated.
All rights reserved.
See ./LICENSEá ETen-B5-H` ^
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+4
View File
@@ -0,0 +1,4 @@
àRCopyright 1990-2009 Adobe Systems Incorporated.
All rights reserved.
See ./LICENSE!!º]aX!!]`21> p z$]"Rd-Uƒ7*„␍4„%+ „Z „{/%…<9K…b1]†." ‰`]‡,"]ˆ
"]ˆh"]‰F"]Š$"]"]`"]Œ>"]"]z"]ŽX"]6"]"]r"]P"]."]“ "]“j"]”H"]•&"]"]b"]—@"]˜"]˜|"]™Z"]š8"]"]t"]œR"]0"]ž"]žl"]ŸJ"] ("]¡"]¡d"]¢B"]£ "X£~']¤W"]¥5"]¦"]¦q"]§O"]¨-"]© "]©i"]ªG"]«%"]¬"]¬a"]­?"]®"]®{"]¯Y"]°7"]±"]±s"]²Q"]³/"]´␍"]´k"]µI"]¶'"]·"]·c"]¸A"]¹"]¹}"]º["]»9
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.

Some files were not shown because too many files have changed in this diff Show More