Claude
feat: IIIF-native Sprints 4-5-6 β€” tiled zoom, exports, cleanup
6736e17 unverified
Raw
History Blame Contribute Delete
7.03 kB
"""
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)})