Spaces:
Build error
Build error
| """ | |
| 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)}) | |