"""Insère les schémas générés dans les supports .odt. Usage : python schemas/inserer.py [Module_03_PWM_ADC.odt ...] Sans argument : traite chaque support pour lequel un fichier mXX_*.py existe. Idempotent : les figures déjà insérées (cadres « schema-* ») sont retirées puis réinsérées. """ import os import re import struct import sys import tempfile import zipfile from pathlib import Path from lxml import etree from generer import DOSSIER_PNG, DOSSIER_SVG, modules_figures RACINE = Path(__file__).resolve().parent.parent NS = { "office": "urn:oasis:names:tc:opendocument:xmlns:office:1.0", "text": "urn:oasis:names:tc:opendocument:xmlns:text:1.0", "draw": "urn:oasis:names:tc:opendocument:xmlns:drawing:1.0", "style": "urn:oasis:names:tc:opendocument:xmlns:style:1.0", "fo": "urn:oasis:names:tc:opendocument:xmlns:xsl-fo-compatible:1.0", "svg": "urn:oasis:names:tc:opendocument:xmlns:svg-compatible:1.0", "xlink": "http://www.w3.org/1999/xlink", "manifest": "urn:oasis:names:tc:opendocument:xmlns:manifest:1.0", } STYLE_IMAGE = "Schema_Image" STYLE_LEGENDE = "Schema_Legende" STYLE_CADRE = "Schema_Cadre" def q(nom): prefixe, local = nom.split(":") return f"{{{NS[prefixe]}}}{local}" def normaliser(texte): return re.sub(r"\s+", " ", texte.replace("’", "'")).strip() def taille_png(chemin): with open(chemin, "rb") as f: entete = f.read(24) return struct.unpack(">II", entete[16:24]) def ajouter_styles(racine): auto = racine.find(q("office:automatic-styles")) existants = {s.get(q("style:name")) for s in auto} if STYLE_IMAGE not in existants: s = etree.SubElement(auto, q("style:style"), {q("style:name"): STYLE_IMAGE, q("style:family"): "paragraph"}) etree.SubElement(s, q("style:paragraph-properties"), { q("fo:text-align"): "center", q("fo:margin-top"): "0.3cm", q("fo:margin-bottom"): "0.1cm", q("fo:keep-with-next"): "always"}) if STYLE_LEGENDE not in existants: s = etree.SubElement(auto, q("style:style"), {q("style:name"): STYLE_LEGENDE, q("style:family"): "paragraph"}) etree.SubElement(s, q("style:paragraph-properties"), { q("fo:text-align"): "center", q("fo:margin-top"): "0cm", q("fo:margin-bottom"): "0.4cm"}) etree.SubElement(s, q("style:text-properties"), { q("fo:font-style"): "italic", q("fo:font-size"): "9pt", q("fo:color"): "#4b5563"}) if STYLE_CADRE not in existants: s = etree.SubElement(auto, q("style:style"), {q("style:name"): STYLE_CADRE, q("style:family"): "graphic"}) etree.SubElement(s, q("style:graphic-properties"), { q("style:vertical-pos"): "top", q("style:vertical-rel"): "baseline", q("fo:border"): "none", q("fo:padding"): "0cm"}) def retirer_figures(corps): styles = (STYLE_IMAGE, STYLE_LEGENDE) for p in list(corps.iter(q("text:p"))): if p.get(q("text:style-name")) in styles: p.getparent().remove(p) def trouver_point_insertion(corps, ancre): """Renvoie l'élément après lequel insérer : le paragraphe d'introduction qui suit le titre.""" cible = normaliser(ancre) for p in corps.iter(q("text:p"), q("text:h")): if normaliser("".join(p.itertext())) == cible: suivant = p.getnext() while suivant is not None and suivant.tag == q("text:p") and not "".join(suivant.itertext()).strip(): suivant = suivant.getnext() if suivant is not None and suivant.tag == q("text:p"): return suivant return p raise ValueError(f"ancre introuvable : {ancre!r}") def construire_figure(f, numero): largeur, hauteur = taille_png(DOSSIER_PNG / f"{f.id}.png") h_cm = f.largeur_cm * hauteur / largeur p_image = etree.Element(q("text:p"), {q("text:style-name"): STYLE_IMAGE}) cadre = etree.SubElement(p_image, q("draw:frame"), { q("draw:style-name"): STYLE_CADRE, q("draw:name"): f"schema-{f.id}", q("text:anchor-type"): "as-char", q("svg:width"): f"{f.largeur_cm:.2f}cm", q("svg:height"): f"{h_cm:.2f}cm", q("draw:z-index"): "0"}) for ext, mime in (("svg", "image/svg+xml"), ("png", "image/png")): etree.SubElement(cadre, q("draw:image"), { q("xlink:href"): f"Pictures/{f.id}.{ext}", q("xlink:type"): "simple", q("xlink:show"): "embed", q("xlink:actuate"): "onLoad", q("draw:mime-type"): mime}) etree.SubElement(cadre, q("svg:title")).text = f.legende p_legende = etree.Element(q("text:p"), {q("text:style-name"): STYLE_LEGENDE}) p_legende.text = f"Figure {numero} — {f.legende}" return p_image, p_legende def maj_manifest(xml, figures): racine = etree.fromstring(xml) for e in list(racine): if e.get(q("manifest:full-path"), "").startswith("Pictures/schema-") or \ re.match(r"Pictures/m\d\d-", e.get(q("manifest:full-path"), "")): racine.remove(e) for f in figures: for ext, mime in (("svg", "image/svg+xml"), ("png", "image/png")): etree.SubElement(racine, q("manifest:file-entry"), { q("manifest:full-path"): f"Pictures/{f.id}.{ext}", q("manifest:media-type"): mime}) return etree.tostring(racine, xml_declaration=True, encoding="UTF-8") def traiter(odt, figures): num_module = int(figures[0].id[1:3]) with zipfile.ZipFile(odt) as z: entrees = {i.filename: z.read(i.filename) for i in z.infolist()} ordre = [i.filename for i in z.infolist()] racine = etree.fromstring(entrees["content.xml"]) corps = racine.find(f"{q('office:body')}/{q('office:text')}") ajouter_styles(racine) retirer_figures(corps) for n, f in enumerate(figures, 1): point = trouver_point_insertion(corps, f.ancre) p_image, p_legende = construire_figure(f, f"{num_module}.{n}") point.addnext(p_image) p_image.addnext(p_legende) entrees["content.xml"] = etree.tostring(racine, xml_declaration=True, encoding="UTF-8") entrees["META-INF/manifest.xml"] = maj_manifest(entrees["META-INF/manifest.xml"], figures) # Images : on retire les anciennes versions puis on ajoute les nouvelles ordre = [n for n in ordre if not re.match(r"Pictures/m\d\d-", n)] for f in figures: entrees[f"Pictures/{f.id}.svg"] = (DOSSIER_SVG / f"{f.id}.svg").read_bytes() entrees[f"Pictures/{f.id}.png"] = (DOSSIER_PNG / f"{f.id}.png").read_bytes() ordre += [f"Pictures/{f.id}.svg", f"Pictures/{f.id}.png"] # Réécriture : mimetype en premier et non compressé (exigence ODF) fd, temp = tempfile.mkstemp(suffix=".odt", dir=odt.parent) os.close(fd) with zipfile.ZipFile(temp, "w") as z: z.writestr(zipfile.ZipInfo("mimetype"), entrees["mimetype"], compress_type=zipfile.ZIP_STORED) for nom in ordre: if nom != "mimetype": z.writestr(nom, entrees[nom], compress_type=zipfile.ZIP_DEFLATED) os.replace(temp, odt) print(f"{odt.name} : {len(figures)} figure(s) insérée(s)") def main(): par_module = modules_figures() cibles = [RACINE / a for a in sys.argv[1:]] or \ [p for p in sorted(RACINE.glob("Module_*.odt")) if f"m{p.name[7:9]}" in par_module] for odt in cibles: prefixe = f"m{odt.name[7:9]}" if prefixe not in par_module: print(f"{odt.name} : aucune figure déclarée, ignoré") continue traiter(odt, par_module[prefixe]) if __name__ == "__main__": main()