Les points clés de ce retour d'expérience :
- Composant frontend modulaire : Conception de SignablePdfRenderer en TypeScript permettant la consultation interactive page par page sur Canvas HTML5 sans dépendance iframe lourde.
- Gestion du worker PDF.js : Intégration du worker asynchrone avec Webpack et gestion du redimensionnement dynamique du viewport.
- Sécurisation du chroot Dompdf : Configuration explicite des dossiers d'assets autorisés dans PdfGenerator pour charger les polices locales TTF.
- Estampillage manuscrit dynamique : Utilisation de la police cursive Birthstone dans le template Twig pour restituer les noms des signataires validés.
1. Exigences produit et défi du rendu documentaire en ligne
Côté UX et produit, permettre à un utilisateur de signer un bail exige qu'il puisse en lire l'intégralité avec une fidélité visuelle parfaite sur mobile et desktop.
L'utilisation classique d'une balise <iframe> ou du lecteur PDF natif des navigateurs entraînait des comportements chaotiques : scroll bloqué sur iOS, impossibilité de contrôler l'interface et difficulté à synchroniser l'affichage avec le bouton de validation OTP.
Pour lever ce problème ergonomique, j'ai développé un visualiseur sur mesure basé sur la bibliothèque pdfjs-dist et un composant TypeScript réutilisable PdfJsRenderer.
Note importante et confidentialité : Pour respecter mes obligations de confidentialité et préserver la propriété intellectuelle, l'ensemble des extraits et exemples de code présentés ici a été entièrement recréé et anonymisé. De ce fait, des imprécisions ou de légères erreurs peuvent exister dans les exemples : le but de cet article est d'illustrer la démarche d'architecture et la réflexion technique, et non de fournir du code prêt à l'emploi.
2. Architecture du visualiseur TypeScript (PdfJsRenderer)
J'ai conçu le composant PdfJsRenderer pour télécharger le flux binaire PDF, instancier le worker PDF.js et exécuter le rendu dynamique sur un élément HTML5 <canvas> :
import Loader from './Loader'
import type { PDFDocumentLoadingTask, PDFDocumentProxy } from 'pdfjs-dist'
import * as pdfjsLib from 'pdfjs-dist/build/pdf.mjs'
pdfjsLib.GlobalWorkerOptions.workerSrc = __webpack_public_path__ + 'pdf.worker.min.js'
export interface PdfJsRendererOption {
container: string
canvas: string
btnPrev: string
btnNext: string
pageNumEl: string
pageCountEl: string
}
export default class PdfJsRenderer {
private loadingTask: PDFDocumentLoadingTask
private context: CanvasRenderingContext2D
private pdf: PDFDocumentProxy
private totalPages: number
private currentPage: number = 1
private rendering: boolean = false
private _container: HTMLDivElement | null
private _canvas: HTMLCanvasElement | null
private _btnPrev: HTMLButtonElement | null
private _btnNext: HTMLButtonElement | null
private _pageNumEl: HTMLSpanElement | null
private _pageCountEl: HTMLSpanElement | null
constructor(options: PdfJsRendererOption) {
this._container = document.getElementById(options.container) as HTMLDivElement | null
this._canvas = document.getElementById(options.canvas) as HTMLCanvasElement | null
this._btnPrev = document.getElementById(options.btnPrev) as HTMLButtonElement | null
this._btnNext = document.getElementById(options.btnNext) as HTMLButtonElement | null
this._pageNumEl = document.getElementById(options.pageNumEl) as HTMLSpanElement | null
this._pageCountEl = document.getElementById(options.pageCountEl) as HTMLSpanElement | null
}
async renderPdfSlider(url: string): Promise<void> {
if (!url || !this._container || !this._canvas) {
return
}
this.context = this._canvas.getContext('2d') as CanvasRenderingContext2D
Loader.on()
this.loadingTask = pdfjsLib.getDocument({ url })
this.pdf = await this.loadingTask.promise
this.totalPages = this.pdf.numPages
if (this._pageCountEl) {
this._pageCountEl.textContent = String(this.totalPages)
}
if (this._btnPrev) this._btnPrev.onclick = (): void => this.goTo(this.currentPage - 1)
if (this._btnNext) this._btnNext.onclick = (): void => this.goTo(this.currentPage + 1)
try {
await this.renderPage(this.currentPage)
} finally {
Loader.off()
}
}
private async renderPage(pageNumber: number): Promise<void> {
this.rendering = true
const page = await this.pdf.getPage(pageNumber)
const initial = page.getViewport({ scale: 1 })
const availableWidth = this._container?.clientWidth || initial.width
const scale = Math.max(Math.min(availableWidth / initial.width, 1.5), 0.5)
const viewport = page.getViewport({ scale })
if (this._canvas) {
this._canvas.width = viewport.width
this._canvas.height = viewport.height
this.context.clearRect(0, 0, this._canvas.width, this._canvas.height)
await page.render({
canvasContext: this.context,
viewport: viewport
}).promise
}
if (this._pageNumEl) {
this._pageNumEl.textContent = String(pageNumber)
}
this.rendering = false
}
private goTo(pageNumber: number): void {
if (pageNumber < 1 || pageNumber > this.totalPages) {
return
}
this.currentPage = pageNumber
this.renderPage(this.currentPage)
}
}
3. Configuration du moteur Dompdf et sécurité chroot
Côté backend, la génération binaire du document scellé repose sur Dompdf. Pour afficher une signature manuscrite vectorielle sans dépendre des polices du serveur host, j'ai intégré la typographie Birthstone sous licence libre.
Dompdf impose une sécurité stricte sur les répertoires locaux (chroot). J'ai configuré le dossier des assets compilés dans les options de mon service :
<?php
declare(strict_types=1);
namespace App\Infrastructure\Lib;
use Dompdf\Dompdf;
use Dompdf\Options;
class PdfGenerator
{
public function createDompdf(): Dompdf
{
$options = new Options();
$options->setIsRemoteEnabled(true);
$options->setIsPhpEnabled(false);
$options->setChroot([
'assets/build/fonts',
]);
return new Dompdf($options);
}
public function streamPdfAndExit(string $html, string $filename): void
{
$dompdf = $this->createDompdf();
$dompdf->loadHtml($html);
$dompdf->setPaper('A4', 'portrait');
$dompdf->render();
$dompdf->stream($filename . '.pdf', ['Attachment' => false]);
exit;
}
}
4. Template Twig et rendu de la signature
Dans le template Twig du bail, j'ai inséré une règle @font-face locale appliquant la police Birthstone sur les signatures validées :
{# Extrait de contract-pdf-template.html.twig #}
{% if signable is defined and signable %}
<div class="page-break"></div>
<h1>Signatures du bail</h1>
<table style="width: 100%; border-collapse: collapse; margin-top: 2rem;">
<tr>
<td style="width: 50%; text-align: left; font-weight: bold;">Le Locataire</td>
<td style="width: 50%; text-align: right; font-weight: bold;">L'Hôte</td>
</tr>
<tr class="signature-row" style="height: 80px;">
<td style="width: 50%; text-align: left;" class="signature-text">
{% if guestSigned and relation.guestSignature %}
{{ relation.guestSignature.name }}<br>
<span style="font-size: 10px; font-family: sans-serif; color: #666;">
Signé le {{ relation.guestSignature.dateMod|date('d/m/Y à H:i') }}
</span>
{% endif %}
</td>
<td style="width: 50%; text-align: right;" class="signature-text">
{% if hostSigned and relation.hostSignature %}
{{ relation.hostSignature.name }}<br>
<span style="font-size: 10px; font-family: sans-serif; color: #666;">
Signé le {{ relation.hostSignature.dateMod|date('d/m/Y à H:i') }}
</span>
{% endif %}
</td>
</tr>
</table>
<style>
@font-face {
font-family: 'Birthstone';
font-style: normal;
font-weight: normal;
src: url('assets/build/fonts/birthstone_regular.ttf') format('truetype');
}
.signature-text {
font-family: 'Birthstone', cursive;
font-size: 28px;
color: #1a365d;
}
</style>
{% endif %}
5. Encapsulation dans le contrôleur Stimulus
J'ai encapsulé le visualiseur Canvas et la gestion du formulaire d'OTP SMS dans un contrôleur Stimulus lisible et réutilisable :
import { Application, Controller } from 'stimulus'
import SignablePdfRenderer from '@TS/module/SignablePdfRenderer'
import { Mode } from '@TS/utils/PdfJsRenderer'
class ContractShow extends Controller {
async connect(): Promise<void> {
const pdfRenderer = new SignablePdfRenderer({
mode: Mode.scroll,
container: 'js-pdf-container',
signActionElementId: 'js-contract-sign-button',
resendActionElementId: 'js-contract-resend-code',
codeValidationFormId: 'js-contract-validate-verification-code-form',
smsSentLabelId: 'js-contract-mobile-sms-sent-label',
codeInputElementId: 'js-contract-validation-code',
codeValidationButtonElementId: 'js-contract-validate-code',
codeValidationErrorElementId: 'js-error-for-contract-validation-code',
timerElementId: 'js-sms-timer',
pdfControlElementId: 'js-contract-sign-control',
})
await pdfRenderer.init()
}
}
Application.start().register('entry_myspace_inbox_contract_show', ContractShow)
6. Enseignements et arbitrages
Dans mon installation, la combinaison de PDF.js et de Dompdf s'est révélée idéale :
- Lecture fluide sans iframe : Le rendu sur Canvas garantit un comportement identique sur Safari iOS et Chrome Android.
- Empreinte serveur minimale : Le document est généré à la volée par Dompdf sans saturer le stockage disque avec des fichiers PDF inutiles.