4 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
11 changed files with 224 additions and 33 deletions
+14 -16
View File
@@ -1,21 +1,19 @@
MIT License All rights reserved.
Copyright (c) 2026 Mummert Copyright (c) 2026 Mummert
Permission is hereby granted, free of charge, to any person obtaining a copy This source code is publicly visible for informational and deployment purposes only.
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:
The above copyright notice and this permission notice shall be included in all Permission is NOT granted to:
copies or substantial portions of the Software. - 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 Any use of this code without explicit written permission from the author is strictly prohibited.
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED,
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
SOFTWARE. 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`. - Das Inhaltselement rendert PDF-Seiten als `<canvas>` innerhalb von `.c-flipbook__page`.
- Die benoetigten Open-Source-Dateien sind lokal im Bundle enthalten. - 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. - Siehe `THIRD_PARTY_LICENSES.md` fuer Abhaengigkeiten.
## Release ## Release
+1 -1
View File
@@ -2,7 +2,7 @@
"name": "mummert/flipbook-bundle", "name": "mummert/flipbook-bundle",
"description": "PDF flipbook content element for Contao 5.7 using pdfjs-dist and flipbook-js.", "description": "PDF flipbook content element for Contao 5.7 using pdfjs-dist and flipbook-js.",
"type": "contao-bundle", "type": "contao-bundle",
"license": "MIT", "license": "proprietary",
"keywords": [ "keywords": [
"contao", "contao",
"contao-bundle", "contao-bundle",
+9 -1
View File
@@ -2,7 +2,7 @@
declare(strict_types=1); 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'] = [ $GLOBALS['TL_DCA']['tl_content']['fields']['flipbookPdfSrc'] = [
'label' => &$GLOBALS['TL_LANG']['tl_content']['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'], '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'] = [ $GLOBALS['TL_DCA']['tl_content']['fields']['flipbookShowNavigation'] = [
'label' => &$GLOBALS['TL_LANG']['tl_content']['flipbookShowNavigation'], 'label' => &$GLOBALS['TL_LANG']['tl_content']['flipbookShowNavigation'],
'exclude' => true, 'exclude' => true,
+1
View File
@@ -15,5 +15,6 @@ $GLOBALS['TL_LANG']['tl_content']['flipbookStartModeOptions'] = [
'center' => 'Zentriert starten', 'center' => 'Zentriert starten',
'spread' => 'als Doppelseite 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']['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.']; $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', 'center' => 'Start centered',
'spread' => 'Start with double-page spread', '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']['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.']; $GLOBALS['TL_LANG']['tl_content']['flipbookPlaySound'] = ['Play page-turn sound', 'Plays a sound effect while turning pages.'];
@@ -6,6 +6,7 @@
data-pdf-url="{{ pdfUrl|default('')|e('html_attr') }}" data-pdf-url="{{ pdfUrl|default('')|e('html_attr') }}"
data-initial-pages="{{ initialRenderPages|default(4)|e('html_attr') }}" data-initial-pages="{{ initialRenderPages|default(4)|e('html_attr') }}"
data-start-mode="{{ startMode|default('center')|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-show-navigation="{{ showNavigation ? '1' : '0' }}"
data-play-turn-sound="{{ ((playTurnSound is defined) ? playTurnSound : true) ? '1' : '0' }}" data-play-turn-sound="{{ ((playTurnSound is defined) ? playTurnSound : true) ? '1' : '0' }}"
tabindex="0" tabindex="0"
+139 -15
View File
@@ -1,10 +1,11 @@
const MODULE_SELECTOR = '[data-pdf-flipbook-element="1"]'; const MODULE_SELECTOR = '[data-pdf-flipbook-element="1"]';
const PDF_MODULE_URL = '/bundles/flipbook/assets/vendor/pdf.min.mjs'; const PDF_MODULE_URL = '/bundles/flipbook/assets/vendor/pdf.min.js';
const PDF_WORKER_URL = '/bundles/flipbook/assets/vendor/pdf.worker.min.mjs'; 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 FLIPBOOK_MODULE_URL = '/bundles/flipbook/assets/vendor/flipbook.esm.min.js';
const TURN_SOUND_URL = '/bundles/flipbook/assets/audio/turn.mp3'; const TURN_SOUND_URL = '/bundles/flipbook/assets/audio/turn.mp3';
const INIT_MARKER = 'pdfFlipbookInitialized'; const INIT_MARKER = 'pdfFlipbookInitialized';
const BOOTSTRAP_MARKER = '__mummertPdfFlipbookBootstrapBound'; const BOOTSTRAP_MARKER = '__mummertPdfFlipbookBootstrapBound';
const MIN_OUTPUT_SCALE = 2;
let dependenciesPromise; let dependenciesPromise;
let moduleCounter = 0; let moduleCounter = 0;
@@ -41,6 +42,7 @@ class PdfFlipbookModule {
this.initialPages = Number.isFinite(parsedInitialPages) this.initialPages = Number.isFinite(parsedInitialPages)
? Math.min(4, Math.max(2, parsedInitialPages)) ? Math.min(4, Math.max(2, parsedInitialPages))
: 4; : 4;
this.splitSpreads = root.dataset.splitSpreads === '1';
this.showNavigation = root.dataset.showNavigation === '1'; this.showNavigation = root.dataset.showNavigation === '1';
this.playTurnSoundEnabled = root.dataset.playTurnSound !== '0'; this.playTurnSoundEnabled = root.dataset.playTurnSound !== '0';
this.startMode = (root.dataset.startMode === 'spread' || root.dataset.startMode === 'cover') this.startMode = (root.dataset.startMode === 'spread' || root.dataset.startMode === 'cover')
@@ -67,7 +69,9 @@ class PdfFlipbookModule {
this.pageWidth = 0; this.pageWidth = 0;
this.pageHeight = 0; this.pageHeight = 0;
this.pageGap = 2; this.pageGap = 2;
this.sourcePageCount = 0;
this.totalPages = 0; this.totalPages = 0;
this.pageDescriptors = [];
this.instanceId = `pdf-flipbook-${++moduleCounter}`; this.instanceId = `pdf-flipbook-${++moduleCounter}`;
} }
@@ -136,6 +140,8 @@ class PdfFlipbookModule {
if (this.controlsElement) { if (this.controlsElement) {
this.controlsElement.style.opacity = '1'; this.controlsElement.style.opacity = '1';
} }
this.updateNavigationState();
}); });
} }
@@ -147,19 +153,84 @@ class PdfFlipbookModule {
}); });
this.pdf = await loadingTask.promise; 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.'); 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() { async resolveInitialAspectRatio() {
const firstPage = await this.pdf.getPage(1); const firstDescriptor = this.getDescriptor(1);
const viewport = firstPage.getViewport({ scale: 1 });
if (viewport.width > 0 && viewport.height > 0) { if (!firstDescriptor) {
this.aspectRatio = viewport.height / viewport.width; 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;
} }
} }
@@ -231,8 +302,42 @@ class PdfFlipbookModule {
onPageTurn: () => { onPageTurn: () => {
this.playTurnSound(); this.playTurnSound();
this.queuePages(this.getLazyCandidates()); 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() { createTurnSound() {
@@ -449,17 +554,21 @@ class PdfFlipbookModule {
} }
const pageElement = this.pageElements.get(pageNumber); const pageElement = this.pageElements.get(pageNumber);
const descriptor = this.getDescriptor(pageNumber);
if (!pageElement) { if (!pageElement || !descriptor) {
return; return;
} }
const page = await this.pdf.getPage(pageNumber); const page = await this.pdf.getPage(descriptor.sourcePageNumber);
const viewportAtScale1 = page.getViewport({ scale: 1 }); 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 viewport = page.getViewport({ scale });
const devicePixelRatio = window.devicePixelRatio || 1; const devicePixelRatio = window.devicePixelRatio || 1;
const outputScale = Math.max(1, devicePixelRatio); const outputScale = Math.max(MIN_OUTPUT_SCALE, devicePixelRatio);
const existingCanvas = pageElement.querySelector('canvas'); const existingCanvas = pageElement.querySelector('canvas');
if (existingCanvas) { if (existingCanvas) {
@@ -473,15 +582,30 @@ class PdfFlipbookModule {
throw new Error('Could not create canvas 2D context.'); 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.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`; 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({ const renderTask = page.render({
canvasContext: context, canvasContext: context,
viewport, viewport,
transform: outputScale === 1 ? null : [outputScale, 0, 0, outputScale, 0, 0], transform,
intent: 'display', 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('pdfUrl', $this->resolvePdfUrl($model));
$template->set('showNavigation', '1' === (string) ($model->flipbookShowNavigation ?? '1')); $template->set('showNavigation', '1' === (string) ($model->flipbookShowNavigation ?? '1'));
$template->set('playTurnSound', '1' === (string) ($model->flipbookPlaySound ?? '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('initialRenderPages', $this->normalizeInitialRenderPages((string) ($model->flipbookInitialPages ?? '4')));
$template->set('startMode', $this->normalizeStartMode((string) ($model->flipbookStartMode ?? 'center'))); $template->set('startMode', $this->normalizeStartMode((string) ($model->flipbookStartMode ?? 'center')));