← Retour au dossier Signature Électronique
Frontend TypeScript & Génération Documentaire

Rendu dynamique de documents signables avec TypeScript, PDF.js et Dompdf : du Canvas interactif à la signature manuscrite

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.