Files
formation-arduino/schemas/inserer.py
T
ArtemiaandClaude Opus 5.5 8012ce98b3 Insère automatiquement les schémas dans les supports ; module 3 illustré
inserer.py ajoute chaque figure (SVG + repli PNG) après l'introduction de sa section, avec une légende numérotée. Il est idempotent (les cadres schema-* sont retirés puis réinsérés) et garde mimetype en tête du zip.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 14:40:18 +02:00

173 lines
7.4 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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()