Second Brain documentation

ADR 0073 – Surlignage dans les PDF : annotations de fichiers

Accepté (2026-10-16, mode autonome)

← Toutes les décisions

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 (migration V14__annotations_fichiers.sql) : une ligne par surlignage, rattachée à la ligne node_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 (yellow par défaut, green, blue, pink), created_at, updated_at, deleted_at, actor. Index (node_file_id, page, created_at).
  • Rectangles normalisés : rects est 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], w et h strictement 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 (tag annotations) :
    • GET (fileAnnotations) : annotations non retirées, par page puis par création ;
    • POST (addFileAnnotation) : création, 201 avec Location ;
    • 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.note ou errors.color sinon.
  • Événements : chaque écriture verrouille le nœud et enregistre un node_event dans la même transaction : annotation.added (page, citation, couleur, note), annotation.updated (before et after avec note et couleur ; rien si rien ne change), annotation.removed, annotation.restored. Le payload porte fileId et annotationId.
  • Retrait doux : deleted_at comme le journal ; l’annotation retirée disparaît de la liste mais se remet par restore (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 par pdfjs-engine.ts, lui-même atteint par un import() dynamique depuis le jeton PDF_ENGINE (pdf-engine.ts). Le moteur et son worker (pdf.worker.ts, qui importe pdfjs-dist/build/pdf.worker.min.mjs, lancé par new 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 @defer du panneau : le bundle initial ne bouge pas (1,09 Mo brut, ~237 Ko transférés, main inchangé à 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-dist sont copiés par angular.json dans /pdfjs/ (assets du build, hors bundle) et renseignés par standardFontDataUrl, cMapUrl et wasmUrl ; 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’option isEvalSupported : son moteur de scripts est fourni en wasm (quickjs-eval.wasm, copié avec les autres).
  • Dépendance optionnelle @napi-rs/canvas : pdfjs-dist la déclare en dépendance optionnelle native (rendu côté Node). Elle est inutile ici (rendu dans le navigateur), n’entre dans aucun chunk, mais npm install peut la télécharger selon la plateforme ; elle explique les ~280 lignes ajoutées à package-lock.json. Le composant ne connaît que les interfaces PdfEngine, PdfDocumentView et PdfPageView : 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 de pdf_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).