5 Commits

Author SHA1 Message Date
Jürgen Mummert 583fa81eb8 fix: reduce split-page hairline artifacts in canvas rendering 2026-04-14 15:23:39 +02:00
Jürgen Mummert a6c9aef952 feat: support PDF spread splitting and nav button states 2026-04-14 15:04:57 +02:00
Jürgen Mummert be27ce1340 Fix shared-hosting MIME issue for PDF.js assets 2026-04-14 12:02:17 +02:00
Juergen 97d5f447e1 chore: switch package license to proprietary 2026-04-13 22:26:13 +02:00
Juergen 1564d96b01 fix: polish initial flipbook reveal and update third-party metadata 2026-04-13 22:18:45 +02:00
13 changed files with 303 additions and 40 deletions
+14 -16
View File
@@ -1,21 +1,19 @@
MIT License
All rights reserved.
Copyright (c) 2026 Mummert
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
This source code is publicly visible for informational and deployment purposes only.
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
Permission is NOT granted to:
- use this code for any commercial or non-commercial purpose
- copy, modify, merge, publish, distribute, sublicense, or sell copies of the Software
- use this code in any other project
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
Any use of this code without explicit written permission from the author is strictly prohibited.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED,
INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR
PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE
FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
DEALINGS IN THE SOFTWARE.
+1
View File
@@ -33,6 +33,7 @@ Felder:
- 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.
## Release
+1 -1
View File
@@ -6,7 +6,7 @@ This bundle contains the following third-party open source libraries in `public/
- Project: https://github.com/mozilla/pdf.js
- Package: `pdfjs-dist`
- Version: `4.9.155`
- Version: `5.6.205`
- License: Apache-2.0
## flipbook-js
+1 -1
View File
@@ -2,7 +2,7 @@
"name": "mummert/flipbook-bundle",
"description": "PDF flipbook content element for Contao 5.7 using pdfjs-dist and flipbook-js.",
"type": "contao-bundle",
"license": "MIT",
"license": "proprietary",
"keywords": [
"contao",
"contao-bundle",
+9 -1
View File
@@ -2,7 +2,7 @@
declare(strict_types=1);
$GLOBALS['TL_DCA']['tl_content']['palettes']['blatterbares_pdf'] = '{type_legend},type,headline;{flipbook_legend},flipbookPdfSrc,flipbookInitialPages,flipbookStartMode,flipbookShowNavigation,flipbookPlaySound;{template_legend:hide},customTpl;{protected_legend:hide},protected;{expert_legend:hide},cssID;{invisible_legend:hide},invisible,start,stop';
$GLOBALS['TL_DCA']['tl_content']['palettes']['blatterbares_pdf'] = '{type_legend},type,headline;{flipbook_legend},flipbookPdfSrc,flipbookInitialPages,flipbookStartMode,flipbookSplitSpreads,flipbookShowNavigation,flipbookPlaySound;{template_legend:hide},customTpl;{protected_legend:hide},protected;{expert_legend:hide},cssID;{invisible_legend:hide},invisible,start,stop';
$GLOBALS['TL_DCA']['tl_content']['fields']['flipbookPdfSrc'] = [
'label' => &$GLOBALS['TL_LANG']['tl_content']['flipbookPdfSrc'],
@@ -32,6 +32,14 @@ $GLOBALS['TL_DCA']['tl_content']['fields']['flipbookStartMode'] = [
'sql' => ['type' => 'string', 'length' => 16, 'default' => 'center'],
];
$GLOBALS['TL_DCA']['tl_content']['fields']['flipbookSplitSpreads'] = [
'label' => &$GLOBALS['TL_LANG']['tl_content']['flipbookSplitSpreads'],
'exclude' => true,
'inputType' => 'checkbox',
'eval' => ['tl_class' => 'w50 m12'],
'sql' => ['type' => 'string', 'length' => 1, 'fixed' => true, 'default' => '0'],
];
$GLOBALS['TL_DCA']['tl_content']['fields']['flipbookShowNavigation'] = [
'label' => &$GLOBALS['TL_LANG']['tl_content']['flipbookShowNavigation'],
'exclude' => true,
+1
View File
@@ -15,5 +15,6 @@ $GLOBALS['TL_LANG']['tl_content']['flipbookStartModeOptions'] = [
'center' => 'Zentriert starten',
'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']['flipbookPlaySound'] = ['Blättersound abspielen', 'Spielt beim Blättern einen Sound ab.'];
+1
View File
@@ -15,5 +15,6 @@ $GLOBALS['TL_LANG']['tl_content']['flipbookStartModeOptions'] = [
'center' => 'Start centered',
'spread' => 'Start with double-page spread',
];
$GLOBALS['TL_LANG']['tl_content']['flipbookSplitSpreads'] = ['Split double-page spreads', 'Automatically splits wide PDF pages from page 2 onwards into left and right single pages.'];
$GLOBALS['TL_LANG']['tl_content']['flipbookShowNavigation'] = ['Show navigation', 'Displays previous/next buttons below the flipbook.'];
$GLOBALS['TL_LANG']['tl_content']['flipbookPlaySound'] = ['Play page-turn sound', 'Plays a sound effect while turning pages.'];
@@ -1,23 +1,24 @@
{% set hasPdf = pdfUrl|default('') is not empty %}
<div
class="ce-blatterbares-pdf mod-pdf-flipbook"
class="ce-blatterbares-pdf mod-pdf-flipbook is-loading"
data-pdf-flipbook-element="1"
data-pdf-url="{{ pdfUrl|default('')|e('html_attr') }}"
data-initial-pages="{{ initialRenderPages|default(4)|e('html_attr') }}"
data-start-mode="{{ startMode|default('center')|e('html_attr') }}"
data-split-spreads="{{ splitSpreads ? '1' : '0' }}"
data-show-navigation="{{ showNavigation ? '1' : '0' }}"
data-play-turn-sound="{{ ((playTurnSound is defined) ? playTurnSound : true) ? '1' : '0' }}"
tabindex="0"
>
<div class="mod-pdf-flipbook__status" data-flipbook-loader="1" aria-live="polite">PDF wird geladen ...</div>
<div class="mod-pdf-flipbook__stage" data-flipbook-stage="1">
<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>
{% if showNavigation %}
<div class="mod-pdf-flipbook__controls" aria-label="Flipbook Navigation">
<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>
@@ -28,6 +29,6 @@
{% endif %}
</div>
<link rel="stylesheet" href="/bundles/flipbook/assets/vendor/flipbook.min.css?v=20260414f">
<link rel="stylesheet" href="/bundles/flipbook/assets/flipbook-module.css?v=20260414f">
<script type="module" src="/bundles/flipbook/assets/flipbook-module.js?v=20260414f"></script>
<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>
+31
View File
@@ -13,11 +13,24 @@
}
.mod-pdf-flipbook__status {
display: inline-flex;
align-items: center;
gap: 0.55rem;
margin-bottom: 0.8rem;
font-size: 0.92rem;
color: var(--flipbook-text);
}
.mod-pdf-flipbook__status::before {
content: '';
width: 0.9rem;
height: 0.9rem;
border: 2px solid rgba(48, 45, 41, 0.25);
border-top-color: rgba(48, 45, 41, 0.85);
border-radius: 50%;
animation: mod-pdf-flipbook-spin 0.75s linear infinite;
}
.mod-pdf-flipbook__status.is-hidden {
display: none;
}
@@ -26,18 +39,29 @@
color: #9f1f1f;
}
.mod-pdf-flipbook__status.is-error::before {
display: none;
}
.mod-pdf-flipbook__stage {
position: relative;
width: 100%;
margin: 0 auto;
background: #e0e0e0;
z-index: 1;
transition: opacity 1.3s ease 0.5s;
}
.mod-pdf-flipbook .c-flipbook {
max-width: 100%;
}
.mod-pdf-flipbook .c-flipbook.is-booting,
.mod-pdf-flipbook .c-flipbook.is-booting .c-flipbook__page,
.mod-pdf-flipbook .c-flipbook.is-booting .c-flipbook__page::before {
transition: none !important;
}
.mod-pdf-flipbook .c-flipbook__page {
background: #fefdf9;
overflow: hidden;
@@ -73,6 +97,7 @@
display: flex;
gap: 0.6rem;
margin-top: 0.9rem;
transition: opacity 1.3s ease 0.5s;
}
.mod-pdf-flipbook__error {
@@ -85,3 +110,9 @@
justify-content: space-between;
}
}
@keyframes mod-pdf-flipbook-spin {
to {
transform: rotate(360deg);
}
}
+180 -15
View File
@@ -1,10 +1,11 @@
const MODULE_SELECTOR = '[data-pdf-flipbook-element="1"]';
const PDF_MODULE_URL = '/bundles/flipbook/assets/vendor/pdf.min.mjs';
const PDF_WORKER_URL = '/bundles/flipbook/assets/vendor/pdf.worker.min.mjs';
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;
let dependenciesPromise;
let moduleCounter = 0;
@@ -36,9 +37,12 @@ class PdfFlipbookModule {
this.root = root;
this.dependencies = dependencies;
this.pdfUrl = (root.dataset.pdfUrl || '').trim();
this.root.classList.add('is-loading');
this.root.classList.remove('is-ready');
this.initialPages = Number.isFinite(parsedInitialPages)
? Math.min(4, Math.max(2, parsedInitialPages))
: 4;
this.splitSpreads = root.dataset.splitSpreads === '1';
this.showNavigation = root.dataset.showNavigation === '1';
this.playTurnSoundEnabled = root.dataset.playTurnSound !== '0';
this.startMode = (root.dataset.startMode === 'spread' || root.dataset.startMode === 'cover')
@@ -47,6 +51,7 @@ class PdfFlipbookModule {
this.loader = root.querySelector('[data-flipbook-loader="1"]');
this.stage = root.querySelector('[data-flipbook-stage="1"]');
this.bookElement = root.querySelector('[data-flipbook-book="1"]');
this.controlsElement = root.querySelector('[data-flipbook-controls="1"]');
this.nextButton = this.showNavigation ? root.querySelector('[data-flipbook-next="1"]') : null;
this.prevButton = this.showNavigation ? root.querySelector('[data-flipbook-prev="1"]') : null;
@@ -64,7 +69,9 @@ class PdfFlipbookModule {
this.pageWidth = 0;
this.pageHeight = 0;
this.pageGap = 2;
this.sourcePageCount = 0;
this.totalPages = 0;
this.pageDescriptors = [];
this.instanceId = `pdf-flipbook-${++moduleCounter}`;
}
@@ -76,6 +83,7 @@ class PdfFlipbookModule {
if (!this.pdfUrl) {
this.setStatus('Keine PDF-Datei gefunden.', true);
this.root.classList.remove('is-loading');
return;
}
@@ -94,12 +102,49 @@ class PdfFlipbookModule {
this.bindTouchSwipe();
this.bindResize();
this.queuePages(this.getLazyCandidates());
await this.showReadyState();
this.setStatus('');
} catch {
this.setStatus('PDF konnte nicht geladen werden.', true);
this.root.classList.remove('is-loading');
}
}
async showReadyState() {
await new Promise((resolve) => {
window.requestAnimationFrame(() => {
window.requestAnimationFrame(resolve);
});
});
if (this.bookElement) {
this.bookElement.classList.remove('is-booting');
}
this.root.classList.remove('is-loading');
this.root.classList.add('is-ready');
if (this.stage) {
this.stage.style.visibility = 'visible';
}
if (this.controlsElement) {
this.controlsElement.style.visibility = 'visible';
}
window.requestAnimationFrame(() => {
if (this.stage) {
this.stage.style.opacity = '1';
}
if (this.controlsElement) {
this.controlsElement.style.opacity = '1';
}
this.updateNavigationState();
});
}
async loadPdf() {
const loadingTask = this.dependencies.pdfjsLib.getDocument({
url: this.pdfUrl,
@@ -108,19 +153,84 @@ class PdfFlipbookModule {
});
this.pdf = await loadingTask.promise;
this.totalPages = Number(this.pdf.numPages || 0);
this.sourcePageCount = Number(this.pdf.numPages || 0);
if (this.totalPages <= 0) {
if (this.sourcePageCount <= 0) {
throw new Error('The selected PDF has no pages.');
}
await this.buildPageDescriptors();
this.totalPages = this.pageDescriptors.length;
}
async buildPageDescriptors() {
const descriptors = [];
for (let sourcePageNumber = 1; sourcePageNumber <= this.sourcePageCount; sourcePageNumber += 1) {
const page = await this.pdf.getPage(sourcePageNumber);
const viewport = page.getViewport({ scale: 1 });
if (this.shouldSplitSpread(sourcePageNumber, viewport)) {
descriptors.push({
sourcePageNumber,
segment: 'left',
});
descriptors.push({
sourcePageNumber,
segment: 'right',
});
} else {
descriptors.push({
sourcePageNumber,
segment: 'full',
});
}
}
this.pageDescriptors = descriptors;
}
shouldSplitSpread(sourcePageNumber, viewport) {
if (!this.splitSpreads) {
return false;
}
if (sourcePageNumber <= 1) {
return false;
}
const width = Number(viewport.width || 0);
const height = Number(viewport.height || 0);
if (width <= 0 || height <= 0) {
return false;
}
return width >= (height * 1.2);
}
getDescriptor(pageNumber) {
if (!Number.isInteger(pageNumber) || pageNumber < 1) {
return null;
}
return this.pageDescriptors[pageNumber - 1] || null;
}
async resolveInitialAspectRatio() {
const firstPage = await this.pdf.getPage(1);
const viewport = firstPage.getViewport({ scale: 1 });
const firstDescriptor = this.getDescriptor(1);
if (viewport.width > 0 && viewport.height > 0) {
this.aspectRatio = viewport.height / viewport.width;
if (!firstDescriptor) {
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;
}
}
@@ -178,6 +288,8 @@ class PdfFlipbookModule {
initializeFlipbook() {
const startsWithDoubleSpread = this.startMode === 'spread';
this.bookElement.classList.add('is-booting');
this.flipbook = new this.dependencies.FlipBook(this.bookElement, {
nextButton: this.nextButton,
previousButton: this.prevButton,
@@ -190,8 +302,42 @@ class PdfFlipbookModule {
onPageTurn: () => {
this.playTurnSound();
this.queuePages(this.getLazyCandidates());
this.updateNavigationState();
},
});
window.requestAnimationFrame(() => {
this.updateNavigationState();
});
}
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);
let disablePrev = false;
let disableNext = false;
if (activePages.length === 0) {
disablePrev = true;
disableNext = this.totalPages <= 1;
} else {
const minActivePage = Math.min(...activePages);
const maxActivePage = Math.max(...activePages);
disablePrev = minActivePage <= 1;
disableNext = maxActivePage >= this.totalPages;
}
this.prevButton.disabled = disablePrev;
this.nextButton.disabled = disableNext;
this.prevButton.setAttribute('aria-disabled', disablePrev ? 'true' : 'false');
this.nextButton.setAttribute('aria-disabled', disableNext ? 'true' : 'false');
}
createTurnSound() {
@@ -408,17 +554,21 @@ class PdfFlipbookModule {
}
const pageElement = this.pageElements.get(pageNumber);
const descriptor = this.getDescriptor(pageNumber);
if (!pageElement) {
if (!pageElement || !descriptor) {
return;
}
const page = await this.pdf.getPage(pageNumber);
const page = await this.pdf.getPage(descriptor.sourcePageNumber);
const viewportAtScale1 = page.getViewport({ scale: 1 });
const scale = this.pageWidth / viewportAtScale1.width;
const renderWidth = descriptor.segment === 'full'
? viewportAtScale1.width
: viewportAtScale1.width / 2;
const scale = this.pageWidth / renderWidth;
const viewport = page.getViewport({ scale });
const devicePixelRatio = window.devicePixelRatio || 1;
const outputScale = Math.max(1, devicePixelRatio);
const outputScale = Math.max(MIN_OUTPUT_SCALE, devicePixelRatio);
const existingCanvas = pageElement.querySelector('canvas');
if (existingCanvas) {
@@ -432,15 +582,30 @@ class PdfFlipbookModule {
throw new Error('Could not create canvas 2D context.');
}
canvas.width = Math.floor(viewport.width * outputScale);
const canvasCssWidth = descriptor.segment === 'full'
? Math.floor(viewport.width)
: Math.floor(viewport.width / 2);
canvas.width = Math.floor(canvasCssWidth * outputScale);
canvas.height = Math.floor(viewport.height * outputScale);
canvas.style.width = `${Math.floor(viewport.width)}px`;
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;
if (descriptor.segment !== 'full' && baseTransform) {
const segmentOffsetX = descriptor.segment === 'left'
? 0
: -Math.floor((viewport.width * outputScale) / 2);
transform = [outputScale, 0, 0, outputScale, segmentOffsetX, 0];
}
const renderTask = page.render({
canvasContext: context,
viewport,
transform: outputScale === 1 ? null : [outputScale, 0, 0, outputScale, 0, 0],
transform,
intent: 'display',
});
+28
View File
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -20,6 +20,7 @@ class BlatterbaresPdfController extends AbstractContentElementController
$template->set('pdfUrl', $this->resolvePdfUrl($model));
$template->set('showNavigation', '1' === (string) ($model->flipbookShowNavigation ?? '1'));
$template->set('playTurnSound', '1' === (string) ($model->flipbookPlaySound ?? '1'));
$template->set('splitSpreads', '1' === (string) ($model->flipbookSplitSpreads ?? '0'));
$template->set('initialRenderPages', $this->normalizeInitialRenderPages((string) ($model->flipbookInitialPages ?? '4')));
$template->set('startMode', $this->normalizeStartMode((string) ($model->flipbookStartMode ?? 'center')));