diff --git a/Module_03_PWM_ADC.odt b/Module_03_PWM_ADC.odt index 29e587b..28400af 100644 Binary files a/Module_03_PWM_ADC.odt and b/Module_03_PWM_ADC.odt differ diff --git a/schemas/inserer.py b/schemas/inserer.py new file mode 100644 index 0000000..c9a11e9 --- /dev/null +++ b/schemas/inserer.py @@ -0,0 +1,172 @@ +"""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()