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>
173 lines
7.4 KiB
Python
173 lines
7.4 KiB
Python
"""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()
|