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>
This commit is contained in:
Artemia
2026-10-18 14:40:18 +02:00
co-authored by Claude Opus 5.5
parent 343a557332
commit 8012ce98b3
2 changed files with 172 additions and 0 deletions
Binary file not shown.
+172
View File
@@ -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()