File size: 7,033 Bytes
c48eec0
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
e0fd571
c48eec0
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
cd353f9
c48eec0
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
193eb98
 
c48eec0
 
 
193eb98
6736e17
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
c48eec0
 
 
 
cd353f9
c48eec0
 
 
 
 
 
 
 
 
 
 
6736e17
 
c48eec0
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
"""
Générateur de Manifest IIIF Presentation API 3.0 par manuscrit (R02).

Source canonique : list[PageMaster] uniquement.
1 Canvas par page — règle absolue.
Les couches éditoriales (transcription, commentaire, iconographie) sont
hors périmètre MVP : elles seront ajoutées en Sprint 4 via des annotations.

Structure du manifest :
  @context  → http://iiif.io/api/presentation/3/context.json
  id        → {base_url}/api/v1/manuscripts/{manuscript_id}/iiif-manifest
  items     → Canvas par page (triés par sequence)
               └─ AnnotationPage
                   └─ Annotation painting (image originale)
"""
# 1. stdlib
import json
import logging
from pathlib import Path

# 3. local
from app.schemas.page_master import PageMaster

logger = logging.getLogger(__name__)

_IIIF_CONTEXT = "http://iiif.io/api/presentation/3/context.json"


def _meta_entry(label_en: str, value: str) -> dict:
    """Formate une entrée de métadonnée IIIF (label/value pair)."""
    return {
        "label": {"en": [label_en]},
        "value": {"none": [value]},
    }


def generate_manifest(
    masters: list[PageMaster],
    manuscript_meta: dict,
    base_url: str,
) -> dict:
    """Génère un Manifest IIIF Presentation API 3.0 pour un manuscrit.

    Le manifest est sérialisable en JSON sans perte (pas d'objets non-sérialisables).
    Le base_url est paramétrable pour permettre le déploiement sur différents domaines.

    Args:
        masters: liste des PageMaster du manuscrit (au moins 1, triés par sequence).
        manuscript_meta: dict avec les clés :
            Obligatoires : manuscript_id (str), label (str), corpus_slug (str)
            Optionnelles : language (str), repository (str), shelfmark (str),
                           date_label (str), institution (str)
        base_url: URL de base de la plateforme, sans slash final
                  (ex. "https://iiif-studio.hf.space").

    Returns:
        dict sérialisable en JSON contenant le Manifest IIIF 3.0.

    Raises:
        ValueError: si masters est vide ou si un champ obligatoire est absent.
    """
    # ── Validation ───────────────────────────────────────────────────────────
    if not masters:
        raise ValueError(
            "generate_manifest : la liste de PageMaster est vide — "
            "un manuscrit doit avoir au moins une page."
        )
    for key in ("manuscript_id", "label", "corpus_slug"):
        if not manuscript_meta.get(key):
            raise ValueError(
                f"generate_manifest : champ obligatoire manquant dans "
                f"manuscript_meta : «{key}»"
            )

    manuscript_id = manuscript_meta["manuscript_id"]
    label         = manuscript_meta["label"]
    language      = manuscript_meta.get("language") or "en"

    # Pages dans l'ordre de séquence (règle absolue — structMap PHYSICAL)
    pages = sorted(masters, key=lambda m: m.sequence)

    # ── IDs de base ─────────────────────────────────────────────────────────
    base_url = base_url.rstrip("/")
    manifest_id = f"{base_url}/api/v1/manuscripts/{manuscript_id}/iiif-manifest"

    # ── Métadonnées descriptives ─────────────────────────────────────────────
    metadata: list[dict] = []
    for field, meta_label in (
        ("repository", "Repository"),
        ("shelfmark",  "Shelfmark"),
        ("date_label", "Date"),
        ("institution","Institution"),
        ("language",   "Language"),
    ):
        value = manuscript_meta.get(field)
        if value:
            metadata.append(_meta_entry(meta_label, value))

    # ── Canvases (1 par page) ────────────────────────────────────────────────
    items: list[dict] = []
    for page in pages:
        canvas_id = (
            f"{base_url}/api/v1/manuscripts/{manuscript_id}/canvas/{page.page_id}"
        )
        width  = page.image.width
        height = page.image.height

        annotation_page_id = f"{canvas_id}/annotation-page/1"
        annotation_id      = f"{canvas_id}/annotation/painting"
        image_url          = page.image.master or ""
        iiif_svc           = page.image.iiif_service_url

        # Corps de l'annotation painting
        body: dict = {
            "id":     image_url,
            "type":   "Image",
            "format": "image/jpeg",
            "width":  width,
            "height": height,
        }
        # Si un IIIF Image Service est connu, le déclarer (zoom tuilé natif)
        if iiif_svc:
            body["service"] = [{
                "id":      iiif_svc,
                "type":    "ImageService3",
                "profile": "level2",
            }]

        canvas: dict = {
            "id":     canvas_id,
            "type":   "Canvas",
            "label":  {language: [f"Folio {page.folio_label}"]},
            "width":  width,
            "height": height,
            "items": [
                {
                    "id":   annotation_page_id,
                    "type": "AnnotationPage",
                    "items": [
                        {
                            "id":         annotation_id,
                            "type":       "Annotation",
                            "motivation": "painting",
                            "body":       body,
                            "target":     canvas_id,
                        }
                    ],
                }
            ],
        }
        items.append(canvas)

    manifest: dict = {
        "@context": _IIIF_CONTEXT,
        "id":       manifest_id,
        "type":     "Manifest",
        "label":    {language: [label]},
        "metadata": metadata,
        "items":    items,
    }

    logger.info(
        "Manifest IIIF généré",
        extra={"manuscript_id": manuscript_id, "canvases": len(items)},
    )
    return manifest


def write_manifest(
    manifest: dict,
    corpus_slug: str,
    base_data_dir: Path = Path("data"),
) -> None:
    """Écrit le Manifest IIIF dans data/corpora/{corpus_slug}/iiif/manifest.json.

    Crée les dossiers parents si nécessaire.

    Args:
        manifest: dict retourné par generate_manifest().
        corpus_slug: identifiant du corpus (détermine le répertoire de sortie).
        base_data_dir: racine du dossier data.
    """
    output_path = base_data_dir / "corpora" / corpus_slug / "iiif" / "manifest.json"
    output_path.parent.mkdir(parents=True, exist_ok=True)
    output_path.write_text(
        json.dumps(manifest, ensure_ascii=False, indent=2),
        encoding="utf-8",
    )
    logger.info("manifest.json écrit", extra={"path": str(output_path)})