File size: 20,883 Bytes
ea4c81b
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
"""Import de corpus depuis des manifestes IIIF v2 et v3.

Fonctionnement
--------------
1. Téléchargement et parsing du manifeste JSON (v2 ou v3 auto-détecté)
2. Extraction de la liste des canvases (pages) avec leurs URL d'image
3. Sélection optionnelle d'un sous-ensemble de pages (ex : ``--pages 1-10``)
4. Téléchargement des images dans un dossier local
5. Création de fichiers GT vides (``.gt.txt``) à remplir manuellement,
   OU chargement des annotations de transcription si présentes dans le manifeste
6. Construction et retour d'un objet ``Corpus``

Compatibilité
-------------
- IIIF Image API v2 et v3
- Manifestes Presentation API v2 et v3
- Instances : Gallica (BnF), Bodleian, British Library, BSB, e-codices,
  Europeana, et tout entrepôt IIIF-compliant

Utilisation
-----------
>>> from picarones.importers.iiif import IIIFImporter
>>> importer = IIIFImporter("https://gallica.bnf.fr/ark:/12148/xxx/manifest.json")
>>> corpus = importer.import_corpus(pages="1-10", output_dir="./corpus/")
>>> print(f"{len(corpus)} documents téléchargés")

Ou via la fonction de commodité :
>>> from picarones.importers.iiif import import_iiif_manifest
>>> corpus = import_iiif_manifest("https://...", pages="1-5", output_dir="./corpus/")
"""

from __future__ import annotations

import json
import logging
import re
import time
import urllib.error
import urllib.request
from dataclasses import dataclass, field
from pathlib import Path
from typing import Iterator, Optional

from picarones.core.corpus import Corpus, Document

logger = logging.getLogger(__name__)


# ---------------------------------------------------------------------------
# Parsing du sélecteur de pages
# ---------------------------------------------------------------------------

def parse_page_selector(pages: str, total: int) -> list[int]:
    """Parse un sélecteur de pages en liste d'indices 0-based.

    Formats acceptés :
    - ``"1-10"``        → pages 1 à 10 (1-based)
    - ``"1,3,5"``       → pages 1, 3 et 5
    - ``"1-5,10,15-20"`` → combinaison
    - ``"all"`` / ``""`` → toutes les pages

    Parameters
    ----------
    pages:
        Sélecteur de pages en chaîne de caractères.
    total:
        Nombre total de pages dans le manifeste.

    Returns
    -------
    list[int]
        Indices 0-based des pages sélectionnées, triés et dédoublonnés.

    Raises
    ------
    ValueError
        Si la syntaxe est invalide ou les numéros hors bornes.
    """
    if not pages or pages.strip().lower() == "all":
        return list(range(total))

    indices: set[int] = set()
    for part in pages.split(","):
        part = part.strip()
        if "-" in part:
            m = re.fullmatch(r"(\d+)-(\d+)", part)
            if not m:
                raise ValueError(f"Sélecteur de pages invalide : '{part}'")
            start, end = int(m.group(1)), int(m.group(2))
            if start < 1 or end > total or start > end:
                raise ValueError(
                    f"Plage {start}-{end} hors bornes (1–{total})"
                )
            indices.update(range(start - 1, end))
        else:
            n = int(part)
            if n < 1 or n > total:
                raise ValueError(f"Page {n} hors bornes (1–{total})")
            indices.add(n - 1)
    return sorted(indices)


# ---------------------------------------------------------------------------
# Données d'un canvas IIIF
# ---------------------------------------------------------------------------

@dataclass
class IIIFCanvas:
    """Représente un canvas (page) dans un manifeste IIIF."""

    index: int          # position 0-based dans le manifeste
    label: str          # étiquette lisible (ex : "f. 1r", "Page 1")
    image_url: str      # URL de l'image pleine résolution
    width: Optional[int] = None
    height: Optional[int] = None
    transcription: Optional[str] = None  # texte GT si annoté dans le manifeste


# ---------------------------------------------------------------------------
# Parseur de manifeste IIIF
# ---------------------------------------------------------------------------

class IIIFManifestParser:
    """Parse un manifeste IIIF Presentation API v2 ou v3."""

    def __init__(self, manifest: dict) -> None:
        self._manifest = manifest
        self._version = self._detect_version()

    def _detect_version(self) -> int:
        """Détecte la version du manifeste (2 ou 3)."""
        context = self._manifest.get("@context", "")
        if isinstance(context, list):
            context = " ".join(context)
        if "presentation/3" in context or self._manifest.get("type") == "Manifest":
            return 3
        return 2

    @property
    def version(self) -> int:
        return self._version

    @property
    def label(self) -> str:
        """Titre du manifeste."""
        raw = self._manifest.get("label", "")
        return _extract_label(raw)

    @property
    def attribution(self) -> str:
        raw = self._manifest.get("attribution", self._manifest.get("requiredStatement", ""))
        return _extract_label(raw)

    def canvases(self) -> list[IIIFCanvas]:
        """Retourne la liste des canvases du manifeste."""
        if self._version == 3:
            return self._parse_v3_canvases()
        return self._parse_v2_canvases()

    def _parse_v2_canvases(self) -> list[IIIFCanvas]:
        canvases: list[IIIFCanvas] = []
        sequences = self._manifest.get("sequences", [])
        if not sequences:
            return canvases
        raw_canvases = sequences[0].get("canvases", [])
        for i, canvas in enumerate(raw_canvases):
            label = _extract_label(canvas.get("label", f"canvas_{i+1}"))
            # Image principale : images[0].resource.@id ou service
            images = canvas.get("images", [])
            image_url = ""
            if images:
                resource = images[0].get("resource", {})
                image_url = _best_image_url_v2(resource, canvas)

            # Annotations de transcription (OA annotations)
            transcription = _extract_v2_transcription(canvas)

            canvases.append(IIIFCanvas(
                index=i,
                label=label,
                image_url=image_url,
                width=canvas.get("width"),
                height=canvas.get("height"),
                transcription=transcription,
            ))
        return canvases

    def _parse_v3_canvases(self) -> list[IIIFCanvas]:
        canvases: list[IIIFCanvas] = []
        items = self._manifest.get("items", [])
        for i, canvas in enumerate(items):
            label = _extract_label(canvas.get("label", f"canvas_{i+1}"))
            image_url = _best_image_url_v3(canvas)
            transcription = _extract_v3_transcription(canvas)
            canvases.append(IIIFCanvas(
                index=i,
                label=label,
                image_url=image_url,
                width=canvas.get("width"),
                height=canvas.get("height"),
                transcription=transcription,
            ))
        return canvases


# ---------------------------------------------------------------------------
# Helpers extraction URL et label
# ---------------------------------------------------------------------------

def _extract_label(raw: object) -> str:
    """Extrait une chaîne lisible depuis les différents formats de label IIIF."""
    if isinstance(raw, str):
        return raw
    if isinstance(raw, list) and raw:
        return _extract_label(raw[0])
    if isinstance(raw, dict):
        # IIIF v3 : {"fr": ["titre"], "en": ["title"]}
        for lang in ("fr", "en", "none", "@value"):
            val = raw.get(lang, "")
            if val:
                if isinstance(val, list):
                    return val[0] if val else ""
                return str(val)
        # Fallback: première valeur
        for v in raw.values():
            return _extract_label(v)
    return str(raw) if raw else ""


def _best_image_url_v2(resource: dict, canvas: dict) -> str:
    """Construit l'URL d'image optimale depuis une ressource IIIF v2."""
    # 1. URL directe de la ressource
    direct = resource.get("@id", "")
    if direct and not direct.endswith("/info.json"):
        return direct

    # 2. Via le service IIIF Image API
    service = resource.get("service", {})
    if isinstance(service, list) and service:
        service = service[0]
    service_id = service.get("@id", service.get("id", ""))
    if service_id:
        return f"{service_id.rstrip('/')}/full/max/0/default.jpg"

    return direct


def _best_image_url_v3(canvas: dict) -> str:
    """Extrait l'URL d'image depuis un canvas IIIF v3."""
    items = canvas.get("items", [])
    for annotation_page in items:
        for annotation in annotation_page.get("items", []):
            body = annotation.get("body", {})
            if isinstance(body, list):
                body = body[0] if body else {}
            # URL directe
            url = body.get("id", body.get("@id", ""))
            if url and body.get("type", "") == "Image":
                return url
            # Via service IIIF Image API
            service = body.get("service", [])
            if isinstance(service, dict):
                service = [service]
            for svc in service:
                svc_id = svc.get("id", svc.get("@id", ""))
                if svc_id:
                    return f"{svc_id.rstrip('/')}/full/max/0/default.jpg"
            if url:
                return url
    return ""


def _extract_v2_transcription(canvas: dict) -> Optional[str]:
    """Tente d'extraire le texte GT depuis les annotations OA d'un canvas v2."""
    other_content = canvas.get("otherContent", [])
    for oc in other_content:
        if not isinstance(oc, dict):
            continue
        motivation = oc.get("motivation", "")
        if "transcrib" in motivation.lower() or "supplementing" in motivation.lower():
            resources = oc.get("resources", [])
            texts = []
            for res in resources:
                body = res.get("resource", {})
                if body.get("@type") == "cnt:ContentAsText":
                    texts.append(body.get("chars", ""))
            if texts:
                return "\n".join(texts)
    return None


def _extract_v3_transcription(canvas: dict) -> Optional[str]:
    """Tente d'extraire le texte GT depuis les annotations d'un canvas v3."""
    annotations = canvas.get("annotations", [])
    for ann_page in annotations:
        items = ann_page.get("items", [])
        for ann in items:
            motivation = ann.get("motivation", "")
            if "transcrib" in motivation.lower() or "supplementing" in motivation.lower():
                body = ann.get("body", {})
                if isinstance(body, dict) and body.get("type") == "TextualBody":
                    return body.get("value", "")
    return None


# ---------------------------------------------------------------------------
# Téléchargement avec retry
# ---------------------------------------------------------------------------

def _download_url(
    url: str,
    retries: int = 4,
    backoff: float = 2.0,
    timeout: int = 60,
) -> bytes:
    """Télécharge une URL avec retry exponentiel."""
    headers = {
        "User-Agent": "Picarones/1.0 (BnF OCR benchmark platform; https://github.com/bnf/picarones)"
    }
    last_exc: Optional[Exception] = None
    for attempt in range(retries):
        if attempt > 0:
            wait = backoff ** attempt
            logger.debug("Retry %d/%d dans %.1fs — %s", attempt, retries - 1, wait, url)
            time.sleep(wait)
        try:
            req = urllib.request.Request(url, headers=headers)
            with urllib.request.urlopen(req, timeout=timeout) as resp:
                return resp.read()
        except (urllib.error.URLError, urllib.error.HTTPError) as exc:
            last_exc = exc
            logger.warning("Erreur téléchargement %s : %s", url, exc)
    raise RuntimeError(f"Impossible de télécharger {url} après {retries} tentatives") from last_exc


def _fetch_manifest(url: str) -> dict:
    """Télécharge et parse un manifeste IIIF JSON."""
    data = _download_url(url)
    try:
        return json.loads(data.decode("utf-8"))
    except json.JSONDecodeError as exc:
        raise ValueError(f"Manifeste IIIF invalide (JSON mal formé) : {url}") from exc


# ---------------------------------------------------------------------------
# Importeur principal
# ---------------------------------------------------------------------------

class IIIFImporter:
    """Importe un corpus depuis un manifeste IIIF.

    Parameters
    ----------
    manifest_url:
        URL du manifeste IIIF (Presentation API v2 ou v3).
    max_resolution:
        Résolution maximale des images téléchargées (largeur en pixels).
        0 = résolution maximale disponible.
    """

    def __init__(
        self,
        manifest_url: str,
        max_resolution: int = 0,
    ) -> None:
        self.manifest_url = manifest_url
        self.max_resolution = max_resolution
        self._manifest: Optional[dict] = None
        self._parser: Optional[IIIFManifestParser] = None

    def load(self) -> "IIIFImporter":
        """Télécharge et parse le manifeste."""
        logger.info("Téléchargement du manifeste IIIF : %s", self.manifest_url)
        self._manifest = _fetch_manifest(self.manifest_url)
        self._parser = IIIFManifestParser(self._manifest)
        logger.info(
            "Manifeste chargé — version IIIF %d — titre : %s — %d canvas",
            self._parser.version,
            self._parser.label,
            len(self._parser.canvases()),
        )
        return self

    @property
    def parser(self) -> IIIFManifestParser:
        if self._parser is None:
            self.load()
        return self._parser  # type: ignore[return-value]

    def list_canvases(self, pages: str = "all") -> list[IIIFCanvas]:
        """Retourne la liste des canvases sélectionnés."""
        all_canvases = self.parser.canvases()
        indices = parse_page_selector(pages, len(all_canvases))
        return [all_canvases[i] for i in indices]

    def import_corpus(
        self,
        pages: str = "all",
        output_dir: Optional[str | Path] = None,
        show_progress: bool = True,
    ) -> Corpus:
        """Télécharge les images et construit un corpus Picarones.

        Si les canvases contiennent des annotations de transcription (GT),
        elles sont automatiquement sauvegardées dans les fichiers ``.gt.txt``.
        Sinon, des fichiers ``.gt.txt`` vides sont créés.

        Parameters
        ----------
        pages:
            Sélecteur de pages (ex : ``"1-10"``, ``"1,3,5"``).
        output_dir:
            Dossier de destination pour les images et les GT.
            Si None, le corpus est retourné en mémoire sans écriture disque.
        show_progress:
            Affiche une barre de progression tqdm.

        Returns
        -------
        Corpus
            Corpus prêt à être utilisé dans ``run_benchmark``.
        """
        canvases = self.list_canvases(pages)
        if not canvases:
            raise ValueError("Aucun canvas sélectionné.")

        out_dir: Optional[Path] = Path(output_dir) if output_dir else None
        if out_dir:
            out_dir.mkdir(parents=True, exist_ok=True)

        # Nom du corpus depuis le titre du manifeste
        corpus_name = self.parser.label or "iiif_corpus"

        documents: list[Document] = []
        iterator: Iterator[IIIFCanvas] = iter(canvases)

        if show_progress:
            try:
                from tqdm import tqdm
                iterator = tqdm(canvases, desc="Import IIIF", unit="page")
            except ImportError:
                pass

        for canvas in iterator:
            doc_id = f"{_slugify(canvas.label) or f'canvas_{canvas.index+1:04d}'}"

            if not canvas.image_url:
                logger.warning("Canvas %s : pas d'URL d'image — ignoré.", canvas.label)
                continue

            # Ajuster la résolution si max_resolution est défini
            image_url = self._adjust_resolution(canvas.image_url, canvas.width)

            # Téléchargement de l'image
            try:
                image_bytes = _download_url(image_url)
            except RuntimeError as exc:
                logger.error("Canvas %s : erreur téléchargement : %s", canvas.label, exc)
                continue

            # Déterminer l'extension de l'image
            ext = _guess_extension(image_url)

            if out_dir:
                # Sauvegarde sur disque
                image_path = out_dir / f"{doc_id}{ext}"
                image_path.write_bytes(image_bytes)

                gt_path = out_dir / f"{doc_id}.gt.txt"
                gt_text = canvas.transcription or ""
                gt_path.write_text(gt_text, encoding="utf-8")

                documents.append(Document(
                    image_path=image_path,
                    ground_truth=gt_text,
                    doc_id=doc_id,
                    metadata={"iiif_label": canvas.label, "canvas_index": canvas.index},
                ))
            else:
                # Corpus en mémoire (image stockée comme chemin temporaire virtuel)
                import tempfile
                tmp = tempfile.NamedTemporaryFile(suffix=ext, delete=False)
                tmp.write(image_bytes)
                tmp.close()
                documents.append(Document(
                    image_path=Path(tmp.name),
                    ground_truth=canvas.transcription or "",
                    doc_id=doc_id,
                    metadata={"iiif_label": canvas.label, "canvas_index": canvas.index},
                ))

        if not documents:
            raise ValueError("Aucun document importé depuis le manifeste IIIF.")

        logger.info("Import IIIF terminé : %d documents.", len(documents))

        return Corpus(
            name=corpus_name,
            documents=documents,
            source_path=self.manifest_url,
            metadata={
                "iiif_manifest_url": self.manifest_url,
                "iiif_version": self.parser.version,
                "iiif_attribution": self.parser.attribution,
                "pages_selected": pages,
            },
        )

    def _adjust_resolution(self, image_url: str, canvas_width: Optional[int]) -> str:
        """Ajuste l'URL IIIF Image API pour respecter max_resolution."""
        if not self.max_resolution or not canvas_width:
            return image_url
        if canvas_width <= self.max_resolution:
            return image_url
        # Remplacer /full/max/ ou /full/full/ par /full/{w},/
        url = re.sub(
            r"/full/(max|full)/",
            f"/full/{self.max_resolution},/",
            image_url,
        )
        return url


# ---------------------------------------------------------------------------
# Helpers utilitaires
# ---------------------------------------------------------------------------

def _slugify(text: str) -> str:
    """Convertit un label IIIF en identifiant de fichier sûr."""
    text = re.sub(r"[^\w\s-]", "", text.strip())
    text = re.sub(r"[\s_-]+", "_", text)
    return text[:60]


def _guess_extension(url: str) -> str:
    """Détermine l'extension de l'image depuis l'URL."""
    url_lower = url.lower().split("?")[0]
    for ext in (".jpg", ".jpeg", ".png", ".tif", ".tiff", ".webp"):
        if url_lower.endswith(ext):
            return ext
    # Par défaut pour les URLs IIIF Image API
    if "/default." in url_lower or "/native." in url_lower:
        return ".jpg"
    return ".jpg"


# ---------------------------------------------------------------------------
# Fonction de commodité
# ---------------------------------------------------------------------------

def import_iiif_manifest(
    manifest_url: str,
    pages: str = "all",
    output_dir: Optional[str | Path] = None,
    max_resolution: int = 0,
    show_progress: bool = True,
) -> Corpus:
    """Importe un corpus depuis un manifeste IIIF en une seule ligne.

    Parameters
    ----------
    manifest_url:
        URL du manifeste IIIF (v2 ou v3).
    pages:
        Sélecteur de pages (ex : ``"1-10"``, ``"1,3,5"``). ``"all"`` par défaut.
    output_dir:
        Dossier de destination. Si None, corpus en mémoire.
    max_resolution:
        Résolution maximale (px). 0 = pas de limite.
    show_progress:
        Affiche une barre de progression.

    Returns
    -------
    Corpus
    """
    importer = IIIFImporter(manifest_url, max_resolution=max_resolution)
    importer.load()
    return importer.import_corpus(
        pages=pages,
        output_dir=output_dir,
        show_progress=show_progress,
    )