ADR 0073 – Surlignage dans les PDF : annotations de fichiers
Accepté (2026-10-16, mode autonome)
ADR 0073 – Surlignage dans les PDF : annotations de fichiers
Statut : Accepté (2026-10-16, mode autonome)
Vague 4 du document d’idées : surligner un passage d’un PDF rattaché à un nœud, y joindre un commentaire, et retrouver ces passages plus tard. La première tranche a livré le modèle et l’API ; la seconde, la visionneuse pdf.js (section « Visionneuse »).
Contexte
Un nœud peut porter plusieurs fichiers (ADR 0032). Les PDF (factures, articles, contrats) sont aujourd’hui téléchargés ou ouverts dans le navigateur, sans moyen de garder trace de ce qui compte dans le document. Le journal de suivi (ADR 0057) fournit le modèle à suivre : sous-ressource d’un nœud, retrait doux récupérable, événements dans la même transaction.
Décision
- Table
file_annotation(migrationV14__annotations_fichiers.sql) : une ligne par surlignage, rattachée à la lignenode_file(on delete cascade: retirer le fichier du nœud supprime ses annotations). Colonnes :page(>= 1),rects(JSONB),quote(texte surligné, 1 à 2000 caractères),note(facultative, 5000 au plus),color(yellowpar défaut,green,blue,pink),created_at,updated_at,deleted_at,actor. Index(node_file_id, page, created_at). - Rectangles normalisés :
rectsest une liste de 1 à 50 rectangles{x, y, w, h}, fractions de la largeur et de la hauteur de la page (origine en haut à gauche), coordonnées finies dans [0, 1],wethstrictement positifs. Un surlignage sur plusieurs lignes donne un rectangle par ligne. Les fractions ne dépendent ni du zoom ni de la résolution d’affichage, ni de la bibliothèque de rendu. Numéro de page au plus 100 000. - API sous
/api/nodes/{id}/files/{fileId}/annotations(tagannotations) :GET(fileAnnotations) : annotations non retirées, par page puis par création ;POST(addFileAnnotation) : création, 201 avecLocation;PUT /{annotationId}(updateFileAnnotation) : note et couleur seulement (une note vide la retire, une couleur absente reste) ; la page, les rectangles et la citation ne changent pas : on supprime puis on recrée ;DELETE /{annotationId}(removeFileAnnotation) : retrait doux, 204 ;POST /{annotationId}/restore(restoreFileAnnotation) : Annuler.- 404 si le nœud est absent ou en corbeille, si le fichier n’est pas à ce nœud ou si l’annotation n’est pas à ce fichier ; 400
errors.page,errors.rects,errors.quote,errors.noteouerrors.colorsinon.
- Événements : chaque écriture verrouille le nœud et enregistre un
node_eventdans la même transaction :annotation.added(page, citation, couleur, note),annotation.updated(beforeetafteravec note et couleur ; rien si rien ne change),annotation.removed,annotation.restored. Le payload portefileIdetannotationId. - Retrait doux :
deleted_atcomme le journal ; l’annotation retirée disparaît de la liste mais se remet parrestore(bouton Annuler de l’interface). - Export : les annotations non retirées suivent le nœud dans le frontmatter Markdown (
annotations: fichier, page, citation, note, couleur), comme le journal. Les rectangles n’y figurent pas.
Limites assumées
- Les citations et les notes ne sont pas indexées en recherche plein texte (hors périmètre ; une colonne de texte dérivée, comme
journal_text, pourra s’ajouter). - Le serveur ne contrôle pas le type du fichier annoté : la visionneuse ne proposera le surlignage que pour les PDF.
- Les rectangles ne sont pas réexportés ni réimportés : l’export est une lecture, pas une sauvegarde (la sauvegarde de la base les contient).
- Un PDF remplacé par un autre contenu n’est pas suivi : les annotations restent sur l’ancienne ligne
node_file. Le même contenu rattaché deux fois à un nœud sous un autre nom aurait deux jeux d’annotations.
Visionneuse (tranche front)
- Ouverture : bouton « Annoter » (
aria-label« Annoter») sur chaque fichier PDF du panneau « Fichiers » d’un nœud (pas dans la section compacte du panneau de contexte). La visionneuse PdfAnnotator(shared/pdf/) prend la place de l’aperçu ; « Fermer » ou un second clic sur « Annoter » le rétablit. Si pdf.js ne parvient pas à ouvrir le fichier, l’aperçu iframe actuel reste (ou revient) avec un message. - pdf.js chargé à la demande :
pdfjs-dist(6.3.289, version épinglée) n’est importé que parpdfjs-engine.ts, lui-même atteint par unimport()dynamique depuis le jetonPDF_ENGINE(pdf-engine.ts). Le moteur et son worker (pdf.worker.ts, qui importepdfjs-dist/build/pdf.worker.min.mjs, lancé parnew Worker(new URL('./pdf.worker', import.meta.url), { type: 'module' })et empaqueté par le build Angular) forment des chunks à part, et la visionneuse elle-même (composant, styles, géométrie) est chargée par un@deferdu panneau : le bundle initial ne bouge pas (1,09 Mo brut, ~237 Ko transférés,maininchangé à 0,7 Ko près). Le worker est partagé par tous les documents de la session et n’est jamais arrêté ; il n’est mémorisé qu’une fois créé, et une erreur de démarrage l’oublie pour que l’ouverture suivante en recrée un. - Assets de pdf.js : polices standard, CMaps et wasm de
pdfjs-distsont copiés parangular.jsondans/pdfjs/(assets du build, hors bundle) et renseignés parstandardFontDataUrl,cMapUrletwasmUrl; sans eux, les PDF sans polices intégrées ou en CJK s’affichent mal. La version 6 de pdf.js n’a plus l’optionisEvalSupported: son moteur de scripts est fourni en wasm (quickjs-eval.wasm, copié avec les autres). - Dépendance optionnelle
@napi-rs/canvas:pdfjs-distla déclare en dépendance optionnelle native (rendu côté Node). Elle est inutile ici (rendu dans le navigateur), n’entre dans aucun chunk, maisnpm installpeut la télécharger selon la plateforme ; elle explique les ~280 lignes ajoutées àpackage-lock.json. Le composant ne connaît que les interfacesPdfEngine,PdfDocumentViewetPdfPageView: les tests injectent un faux moteur (pdf.js ne tourne pas sous jsdom). - Rendu : une page = un canvas (résolution écran, plafonnée à 2x) + la couche de texte de pdf.js (transparente, sélectionnable) + une surcouche des surlignages. Échelle choisie une fois, à la largeur de la zone ; les pages ne sont dessinées que lorsqu’elles approchent de la zone visible (
IntersectionObserver, marge de 600 px) et libérées quand elles s’en éloignent (canvas ramené à 0 x 0, couche de texte vidée,page.cleanup()) : la mémoire reste bornée sur un long document. La résolution d’un canvas est plafonnée à 16 Mpx (la taille affichée ne change pas). La couche de texte reprend les règles depdf_viewer.css(taille de police, étirement horizontal), indispensables à l’exactitude des rectangles de sélection. - Création : à la fin d’une sélection (souris, ou Maj + flèches), les rectangles de la sélection sont rognés à la page, fusionnés par ligne, normalisés (fractions de la page) et validés (
annotation-geometry.ts, fonctions pures testées : 1 à 50 rectangles, largeur et hauteur positives, valeurs dans [0, 1], citation de 2000 caractères au plus) ; une barre flottante propose les quatre couleurs. La création ouvre aussitôt la fenêtre d’édition pour y saisir une note. - Édition : un clic sur un surlignage ouvre une fenêtre (note, couleur, Enregistrer, Supprimer, Fermer). La suppression retire l’annotation et propose « Annuler » pendant 10 s (
restore), comme les autres suppressions (ADR 0049) ; la remise part même si la visionneuse a été fermée entre-temps. - Volet latéral : liste de toutes les annotations du fichier (page, citation, note, couleur) avec un bouton « Aller à la page », un bouton « Modifier » et un bouton « Supprimer », tous nommés. C’est la voie accessible au clavier et au lecteur d’écran : la sélection de texte à la souris reste le seul moyen de créer. Échap ferme la fenêtre ou la barre ouverte ; flèches, Début et Fin déplacent le focus entre les couleurs de la barre ; le focus revient au déclencheur à la fermeture de la fenêtre et passe à l’annotation voisine après une suppression. Retirer un fichier PDF du nœud supprime ses annotations (cascade) : la confirmation le dit.
- Thème : couleurs de surlignage définies avec
light-dark(), fenêtres sur les variables--sb-*; la page du PDF reste blanche dans les deux thèmes.
Limites de la visionneuse
- Les PDF scannés (images sans couche de texte) ne sont pas annotables : la page est signalée comme sans texte sélectionnable. Pas de reconnaissance de caractères.
- Annotations de texte seulement : pas de dessin à main levée, de zone libre, de note épinglée ni de soulignement.
- Une sélection ne peut pas couvrir plusieurs pages : il faut surligner page par page.
- Au tactile, la sélection de texte (poignées) est lue après un court délai via
selectionchange; non éprouvée sur tous les navigateurs mobiles, à valider sur appareil. - Le worker n’est jamais arrêté pendant la session (un seul, partagé).
- Pas de zoom ni de recalcul au redimensionnement de la fenêtre (l’échelle est fixée à l’ouverture) ; les pages sont supposées de taille voisine tant qu’elles ne sont pas rendues (la hauteur de défilement se corrige au fil du rendu).
- Les surlignages sont des rectangles par ligne : un texte en colonnes ou en rotation est approximé par les boîtes de la couche de texte de pdf.js.
- Les citations ne sont toujours pas indexées en recherche plein texte.
Alternatives écartées
- Annotations dans le PDF lui-même (réécriture du fichier) : modifie le contenu haché, casse l’identité du fichier (SHA-256) et la déduplication.
- Coordonnées en unités PDF ou en pixels : dépendent du zoom, de la rotation et de la bibliothèque de rendu.
- Aspect YAML porté par le nœud : un aspect est lié au nœud, pas à un fichier précis ni à une page ; il faudrait un tableau d’objets, que les aspects ne gèrent pas (premier niveau seulement).
- Table liée au nœud plutôt qu’au fichier : la cascade à la suppression du fichier serait à écrire à la main.
Conséquences
Tests : AnnotationsIT (création, ordre, mise à jour avec avant/après, retrait et restauration, validations, 404, cascade, export). Contrat régénéré : AnnotationsService et les modèles FileAnnotation, AnnotationRect, AnnotationCreateRequest, AnnotationUpdateRequest. Front : annotation-geometry.spec.ts (fusion par ligne, normalisation, validation), pdf-annotator.spec.ts (liste, dialogue, enregistrement, suppression et Annuler, repli) et node-files-panel.spec.ts (bouton « Annoter » réservé aux PDF, repli sur l’aperçu).