Claude
feat: IIIF-native Sprints 4-5-6 โ€” tiled zoom, exports, cleanup
6736e17 unverified
Raw
History Blame Contribute Delete
11.6 kB
"""
Gรฉnรฉrateur METS v1.12 depuis une liste de PageMaster (R02).
Source canonique : liste de PageMaster uniquement โ€” jamais les rรฉponses brutes.
Les 6 sections obligatoires : metsHdr, dmdSec, amdSec, fileSec,
structMap PHYSICAL, structMap LOGICAL.
fileSec โ†’ 3 fileGrp par manuscrit :
master = URL image originale (master.image["original_url"])
derivative_web = dรฉrivรฉ JPEG (master.image["derivative_web"])
alto = chemin attendu (data/corpora/{slug}/pages/{folio}/alto.xml)
structMap PHYSICAL = sรฉquence des pages triรฉes par master.sequence.
structMap LOGICAL = 1 seul div TYPE="manuscript" (MVP).
"""
# 1. stdlib
import logging
from datetime import datetime, timezone
from pathlib import Path
# 2. third-party
from lxml import etree
# 3. local
from app.schemas.page_master import PageMaster
logger = logging.getLogger(__name__)
# โ”€โ”€ Namespaces METS โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
_METS_NS = "http://www.loc.gov/METS/"
_MODS_NS = "http://www.loc.gov/mods/v3"
_DC_NS = "http://purl.org/dc/elements/1.1/"
_XLINK_NS = "http://www.w3.org/1999/xlink"
_XSI_NS = "http://www.w3.org/2001/XMLSchema-instance"
_SCHEMA_LOC = (
"http://www.loc.gov/METS/ "
"https://www.loc.gov/standards/mets/mets.xsd"
)
_M = f"{{{_METS_NS}}}"
_DC = f"{{{_DC_NS}}}"
_XL = f"{{{_XLINK_NS}}}"
def _el(parent: etree._Element, tag: str, attrib: dict | None = None,
text: str | None = None) -> etree._Element:
el = etree.SubElement(parent, tag, attrib or {})
if text is not None:
el.text = text
return el
def _safe_id(value: str) -> str:
"""Remplace les caractรจres non autorisรฉs dans un ID XML par '_'."""
return value.replace("-", "_").replace(".", "_").replace(" ", "_")
def _alto_path(corpus_slug: str, folio_label: str, base_data_dir: Path) -> str:
"""Retourne le chemin attendu de l'alto.xml pour une page."""
return str(base_data_dir / "corpora" / corpus_slug / "pages" / folio_label / "alto.xml")
def generate_mets(
masters: list[PageMaster],
manuscript_meta: dict,
base_data_dir: Path = Path("data"),
) -> str:
"""Gรฉnรจre le XML METS v1.12 complet pour un manuscrit.
Args:
masters: liste des PageMaster du manuscrit (au moins 1).
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_data_dir: racine du dossier data (pour construire les chemins ALTO).
Returns:
Chaรฎne UTF-8 contenant le XML METS (avec dรฉclaration XML).
Raises:
ValueError: si masters est vide ou si un champ obligatoire est absent.
"""
# โ”€โ”€ Validation des entrรฉes โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
if not masters:
raise ValueError(
"generate_mets : 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_mets : champ obligatoire manquant dans manuscript_meta : ยซ{key}ยป"
)
manuscript_id = manuscript_meta["manuscript_id"]
label = manuscript_meta["label"]
corpus_slug = manuscript_meta["corpus_slug"]
language = manuscript_meta.get("language", "")
repository = manuscript_meta.get("repository", "")
shelfmark = manuscript_meta.get("shelfmark", "")
date_label = manuscript_meta.get("date_label", "")
institution = manuscript_meta.get("institution", "")
# Pages triรฉes par sรฉquence (R02 โ€” structMap PHYSICAL doit respecter sequence)
pages = sorted(masters, key=lambda m: m.sequence)
now_iso = datetime.now(tz=timezone.utc).isoformat()
# โ”€โ”€ Racine โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
nsmap = {
"mets": _METS_NS,
"dc": _DC_NS,
"xlink": _XLINK_NS,
"xsi": _XSI_NS,
}
root = etree.Element(
f"{_M}mets",
{
"OBJID": manuscript_id,
"TYPE": "Manuscript",
"LABEL": label,
f"{{{_XSI_NS}}}schemaLocation": _SCHEMA_LOC,
},
nsmap=nsmap,
)
# โ”€โ”€ 1. metsHdr โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
hdr = _el(root, f"{_M}metsHdr", {"CREATEDATE": now_iso})
agent = _el(hdr, f"{_M}agent", {"ROLE": "CREATOR", "TYPE": "ORGANIZATION"})
_el(agent, f"{_M}name", text="IIIF Studio")
# โ”€โ”€ 2. dmdSec โ€” Dublin Core โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
dmd = _el(root, f"{_M}dmdSec", {"ID": "DMD_1"})
wrap = _el(dmd, f"{_M}mdWrap", {"MDTYPE": "DC"})
xml_data = _el(wrap, f"{_M}xmlData")
_el(xml_data, f"{_DC}title", text=label)
_el(xml_data, f"{_DC}identifier", text=manuscript_id)
if language:
_el(xml_data, f"{_DC}language", text=language)
if repository:
_el(xml_data, f"{_DC}source", text=repository)
if shelfmark:
_el(xml_data, f"{_DC}relation", text=shelfmark)
if date_label:
_el(xml_data, f"{_DC}date", text=date_label)
if institution:
_el(xml_data, f"{_DC}publisher", text=institution)
_el(xml_data, f"{_DC}format", text="application/mets+xml")
# โ”€โ”€ 3. amdSec โ€” techMD global โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
amd = _el(root, f"{_M}amdSec")
tech = _el(amd, f"{_M}techMD", {"ID": "AMD_1"})
amd_wrap = _el(tech, f"{_M}mdWrap", {"MDTYPE": "OTHER", "OTHERMDTYPE": "IIIFStudio"})
amd_data = _el(amd_wrap, f"{_M}xmlData")
# Premier processing trouvรฉ parmi les pages
first_processing = next(
(m.processing for m in pages if m.processing is not None), None
)
amd_root = etree.SubElement(amd_data, "iiifStudioProcessing")
_el(amd_root, "generator", text="IIIF Studio")
_el(amd_root, "pageCount", text=str(len(pages)))
_el(amd_root, "corpusSlug", text=corpus_slug)
if first_processing:
_el(amd_root, "modelId", text=first_processing.model_id)
_el(amd_root, "modelDisplayName", text=first_processing.model_display_name)
_el(amd_root, "processedAt", text=first_processing.processed_at.isoformat())
# โ”€โ”€ 4. fileSec โ€” 3 fileGrp โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
file_sec = _el(root, f"{_M}fileSec")
grp_master = _el(file_sec, f"{_M}fileGrp", {"USE": "master"})
grp_deriv = _el(file_sec, f"{_M}fileGrp", {"USE": "derivative_web"})
grp_alto = _el(file_sec, f"{_M}fileGrp", {"USE": "alto"})
for page in pages:
sid = _safe_id(page.page_id)
# master image (IIIF service URL ou URL statique)
master_url = page.image.iiif_service_url or page.image.master or ""
if page.image.iiif_service_url:
master_url = f"{page.image.iiif_service_url}/full/max/0/default.jpg"
f_master = _el(grp_master, f"{_M}file", {"ID": f"IMG_MASTER_{sid}", "MIMETYPE": "image/jpeg"})
_el(f_master, f"{_M}FLocat", {
"LOCTYPE": "URL",
f"{_XL}href": master_url,
f"{_XL}type": "simple",
})
# dรฉrivรฉ web (URL IIIF 1500px ou chemin local legacy)
if page.image.iiif_service_url:
deriv_href = f"{page.image.iiif_service_url}/full/!1500,1500/0/default.jpg"
deriv_loctype_attrs = {"LOCTYPE": "URL"}
else:
deriv_href = page.image.derivative_web or ""
deriv_loctype_attrs = {"LOCTYPE": "OTHER", "OTHERLOCTYPE": "filepath"}
f_deriv = _el(grp_deriv, f"{_M}file", {"ID": f"IMG_DERIV_{sid}", "MIMETYPE": "image/jpeg"})
_el(f_deriv, f"{_M}FLocat", {
**deriv_loctype_attrs,
f"{_XL}href": deriv_href,
f"{_XL}type": "simple",
})
# ALTO (rรฉfรฉrence conditionnelle โ€” warning si le fichier n'existe pas encore)
alto_p = _alto_path(corpus_slug, page.folio_label, base_data_dir)
if not Path(alto_p).exists():
logger.warning(
"Fichier ALTO absent โ€” la rรฉfรฉrence METS sera cassรฉe tant que l'ALTO n'est pas gรฉnรฉrรฉ",
extra={"alto_path": alto_p, "page_id": page.page_id},
)
f_alto = _el(grp_alto, f"{_M}file", {"ID": f"ALTO_{sid}", "MIMETYPE": "text/xml"})
_el(f_alto, f"{_M}FLocat", {
"LOCTYPE": "OTHER",
"OTHERLOCTYPE": "filepath",
f"{_XL}href": alto_p,
f"{_XL}type": "simple",
})
# โ”€โ”€ 5. structMap PHYSICAL โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
sm_phys = _el(root, f"{_M}structMap", {"TYPE": "PHYSICAL"})
div_seq = _el(sm_phys, f"{_M}div", {"TYPE": "physSequence", "LABEL": label})
for page in pages:
sid = _safe_id(page.page_id)
div_page = _el(div_seq, f"{_M}div", {
"TYPE": "page",
"ORDER": str(page.sequence),
"ORDERLABEL": page.folio_label,
"LABEL": f"Folio {page.folio_label}",
"DMDID": "DMD_1",
})
_el(div_page, f"{_M}fptr", {"FILEID": f"IMG_MASTER_{sid}"})
_el(div_page, f"{_M}fptr", {"FILEID": f"IMG_DERIV_{sid}"})
_el(div_page, f"{_M}fptr", {"FILEID": f"ALTO_{sid}"})
# โ”€โ”€ 6. structMap LOGICAL โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
sm_log = _el(root, f"{_M}structMap", {"TYPE": "LOGICAL"})
_el(sm_log, f"{_M}div", {
"TYPE": "manuscript",
"LABEL": label,
"DMDID": "DMD_1",
})
logger.info(
"METS gรฉnรฉrรฉ",
extra={"manuscript_id": manuscript_id, "pages": len(pages)},
)
return etree.tostring(
root,
xml_declaration=True,
encoding="UTF-8",
pretty_print=True,
).decode("utf-8")
def write_mets(
mets_xml: str,
corpus_slug: str,
base_data_dir: Path = Path("data"),
) -> None:
"""ร‰crit le XML METS dans data/corpora/{corpus_slug}/mets.xml.
Crรฉe les dossiers parents si nรฉcessaire.
Args:
mets_xml: chaรฎne XML retournรฉe par generate_mets().
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 / "mets.xml"
output_path.parent.mkdir(parents=True, exist_ok=True)
output_path.write_text(mets_xml, encoding="utf-8")
logger.info("mets.xml รฉcrit", extra={"path": str(output_path)})