#!/usr/bin/env bash # ============================================================================= # pdf_to_svg.sh # 1. Convertit le PDF en SVG via pdftocairo (poppler) # 2. Regroupe tous les éléments graphiques en un unique # 3. Interroge Inkscape pour obtenir la bbox exacte du groupe # 4. Positionne le groupe à (1 mm, 1 mm) et recadre la page à # (largeur_contenu + 2 mm) × (hauteur_contenu + 2 mm) # 5. Exporte le SVG final # # Usage : ./pdf_to_svg.sh [sortie.svg] # Dépendances : pdftocairo (poppler-utils), inkscape >= 0.91, python3 # ============================================================================= set -euo pipefail # ── Arguments ──────────────────────────────────────────────────────────────── [[ $# -lt 1 ]] && { echo "Usage : $0 [sortie.svg]" >&2; exit 1; } INPUT_PDF="$1" [[ ! -f "$INPUT_PDF" ]] && { echo "Erreur : fichier introuvable : $INPUT_PDF" >&2; exit 1; } OUTPUT_SVG="${2:-${INPUT_PDF%.pdf}.svg}" for cmd in pdftocairo inkscape python3; do command -v "$cmd" &>/dev/null || { echo "Erreur : $cmd introuvable." >&2; exit 1; } done echo "Inkscape : $(inkscape --version 2>/dev/null | head -n1)" TMP_DIR=$(mktemp -d) trap 'rm -rf "$TMP_DIR"' EXIT TMP_CAIRO="$TMP_DIR/cairo.svg" TMP_GROUPED="$TMP_DIR/grouped.svg" # ── Étape 1 : PDF → SVG via pdftocairo ────────────────────────────────────── echo "" echo "=== Étape 1 : PDF → SVG (pdftocairo) ===" pdftocairo -svg "$INPUT_PDF" "$TMP_CAIRO" [[ ! -f "$TMP_CAIRO" ]] && { echo "Erreur : pdftocairo a échoué." >&2; exit 1; } echo "OK : $TMP_CAIRO" # ── Étape 2 : Grouper tous les éléments graphiques (Python) ───────────────── echo "" echo "=== Étape 2 : Groupage en un seul ===" python3 - "$TMP_CAIRO" "$TMP_GROUPED" << 'PYEOF' import sys, re import xml.etree.ElementTree as ET SVG_NS = 'http://www.w3.org/2000/svg' NS_MAP = { 'svg': SVG_NS, 'xlink': 'http://www.w3.org/1999/xlink', 'dc': 'http://purl.org/dc/elements/1.1/', 'cc': 'http://creativecommons.org/ns#', 'rdf': 'http://www.w3.org/1999/02/22-rdf-syntax-ns#', 'inkscape': 'http://www.inkscape.org/namespaces/inkscape', 'sodipodi': 'http://sodipodi.sourceforge.net/DTD/sodipodi-0.0.dtd', } for pfx, uri in NS_MAP.items(): ET.register_namespace(pfx, uri) # pdftocairo écrit width/height/viewBox en points (72 dpi) sans unité. # On convertit tout en px (96 dpi) en appliquant scale(96/72) sur le groupe # et en mettant à jour le viewBox/width/height de la racine. PT2PX = 96.0 / 72.0 MM2PX = 3.7795275591 input_svg, output_svg = sys.argv[1], sys.argv[2] tree = ET.parse(input_svg) root = tree.getroot() # Lire et convertir le viewBox (pts → px) def parse_num(s): m = re.match(r'([+-]?[\d.]+(?:e[+-]?\d+)?)', (s or '').strip(), re.I) return float(m.group(1)) if m else 0.0 w_pt = parse_num(root.get('width', '0')) h_pt = parse_num(root.get('height', '0')) vb = root.get('viewBox', f'0 0 {w_pt} {h_pt}') vbx, vby, vbw, vbh = [float(v) for v in re.split(r'[\s,]+', vb.strip())] w_px = vbw * PT2PX h_px = vbh * PT2PX print(f" Page : {vbw:.3f} × {vbh:.3f} pts → {w_px:.3f} × {h_px:.3f} px" f" ({w_px/MM2PX:.3f} × {h_px/MM2PX:.3f} mm)") root.set('width', f'{w_px/MM2PX:.6f}mm') root.set('height', f'{h_px/MM2PX:.6f}mm') root.set('viewBox', f'{vbx*PT2PX:.6f} {vby*PT2PX:.6f} {w_px:.6f} {h_px:.6f}') # Séparer non-graphiques (defs, metadata…) et graphiques SKIP = {'namedview', 'defs', 'metadata', 'title', 'desc'} graphic = [] for child in list(root): local = child.tag.split('}')[-1] if local not in SKIP: graphic.append(child) root.remove(child) print(f" {len(graphic)} éléments graphiques regroupés") # Créer le groupe avec scale(PT2PX) pour convertir les coords internes pts→px grp = ET.Element(f'{{{SVG_NS}}}g') grp.set('id', 'content-group') grp.set('transform', f'scale({PT2PX})') for c in graphic: grp.append(c) root.append(grp) tree.write(output_svg, xml_declaration=True, encoding='UTF-8') print(f" Écrit : {output_svg}") PYEOF [[ ! -f "$TMP_GROUPED" ]] && { echo "Erreur : groupage échoué." >&2; exit 1; } # ── Étape 3 : Bbox via Inkscape (plusieurs méthodes en fallback) ───────────── echo "" echo "=== Étape 3 : Bbox du groupe via Inkscape ===" BBOX_X="" ; BBOX_Y="" ; BBOX_W="" ; BBOX_H="" # Méthode A : --query-id (Inkscape 0.91+) echo " Méthode A : --query-id=content-group" RAW=$(inkscape "$TMP_GROUPED" --query-id=content-group 2>/dev/null || true) echo " Résultat brut : '$RAW'" if [[ -n "$RAW" ]]; then CLEAN=$(echo "$RAW" | sed 's/^content-group,//' | tr -d ' \r') BBOX_X=$(echo "$CLEAN" | cut -d',' -f1) BBOX_Y=$(echo "$CLEAN" | cut -d',' -f2) BBOX_W=$(echo "$CLEAN" | cut -d',' -f3) BBOX_H=$(echo "$CLEAN" | cut -d',' -f4) fi # Méthode B : -X -Y -W -H (flags legacy, Inkscape 0.91-1.x) if [[ -z "$BBOX_W" || "$BBOX_W" == "0" ]]; then echo " Méthode B : flags -X -Y -W -H" BBOX_X=$(inkscape "$TMP_GROUPED" -X 2>/dev/null | tr -d ' \r\n' || true) BBOX_Y=$(inkscape "$TMP_GROUPED" -Y 2>/dev/null | tr -d ' \r\n' || true) BBOX_W=$(inkscape "$TMP_GROUPED" -W 2>/dev/null | tr -d ' \r\n' || true) BBOX_H=$(inkscape "$TMP_GROUPED" -H 2>/dev/null | tr -d ' \r\n' || true) echo " X=$BBOX_X Y=$BBOX_Y W=$BBOX_W H=$BBOX_H" fi # Méthode C : --actions avec query-x/y/width/height (Inkscape 1.0+) if [[ -z "$BBOX_W" || "$BBOX_W" == "0" ]]; then echo " Méthode C : --actions query" BBOX_X=$(inkscape --actions="select-by-id:content-group;query-x" "$TMP_GROUPED" 2>/dev/null | grep -v '^$' | tail -1 | tr -d ' \r\n' || true) BBOX_Y=$(inkscape --actions="select-by-id:content-group;query-y" "$TMP_GROUPED" 2>/dev/null | grep -v '^$' | tail -1 | tr -d ' \r\n' || true) BBOX_W=$(inkscape --actions="select-by-id:content-group;query-width" "$TMP_GROUPED" 2>/dev/null | grep -v '^$' | tail -1 | tr -d ' \r\n' || true) BBOX_H=$(inkscape --actions="select-by-id:content-group;query-height" "$TMP_GROUPED" 2>/dev/null | grep -v '^$' | tail -1 | tr -d ' \r\n' || true) echo " X=$BBOX_X Y=$BBOX_Y W=$BBOX_W H=$BBOX_H" fi # Méthode D : --query-all (toutes versions) if [[ -z "$BBOX_W" || "$BBOX_W" == "0" ]]; then echo " Méthode D : --query-all" RAW=$(inkscape "$TMP_GROUPED" --query-all 2>/dev/null | grep '^content-group,' || true) echo " Résultat brut : '$RAW'" if [[ -n "$RAW" ]]; then BBOX_X=$(echo "$RAW" | cut -d',' -f2) BBOX_Y=$(echo "$RAW" | cut -d',' -f3) BBOX_W=$(echo "$RAW" | cut -d',' -f4) BBOX_H=$(echo "$RAW" | cut -d',' -f5) fi fi if [[ -z "$BBOX_W" || "$BBOX_W" == "0" ]]; then echo "Erreur : aucune méthode Inkscape n'a retourné de bbox valide." >&2 echo "Versions testées :" >&2 inkscape --version 2>&1 >&2 || true echo "Essayez manuellement : inkscape '$TMP_GROUPED' --query-id=content-group" >&2 exit 1 fi echo " Bbox retenue : x=$BBOX_X y=$BBOX_Y w=$BBOX_W h=$BBOX_H (px)" # ── Étape 4 : Repositionner + recadrer (Python) ────────────────────────────── echo "" echo "=== Étape 4 : Repositionnement et recadrage ===" python3 - "$TMP_GROUPED" "$OUTPUT_SVG" "$BBOX_X" "$BBOX_Y" "$BBOX_W" "$BBOX_H" << 'PYEOF' import sys, re import xml.etree.ElementTree as ET SVG_NS = 'http://www.w3.org/2000/svg' NS_MAP = { 'svg': SVG_NS, 'xlink': 'http://www.w3.org/1999/xlink', 'dc': 'http://purl.org/dc/elements/1.1/', 'cc': 'http://creativecommons.org/ns#', 'rdf': 'http://www.w3.org/1999/02/22-rdf-syntax-ns#', 'inkscape': 'http://www.inkscape.org/namespaces/inkscape', 'sodipodi': 'http://sodipodi.sourceforge.net/DTD/sodipodi-0.0.dtd', } for pfx, uri in NS_MAP.items(): ET.register_namespace(pfx, uri) MM2PX = 3.7795275591 # px à 96 dpi par mm MARGIN = 1.0 * MM2PX # 1 mm en px input_svg, output_svg = sys.argv[1], sys.argv[2] bbox_x = float(sys.argv[3]) bbox_y = float(sys.argv[4]) bbox_w = float(sys.argv[5]) bbox_h = float(sys.argv[6]) print(f" Contenu : {bbox_w:.4f} × {bbox_h:.4f} px" f" = {bbox_w/MM2PX:.4f} × {bbox_h/MM2PX:.4f} mm") tree = ET.parse(input_svg) root = tree.getroot() grp = root.find(f'.//{{{SVG_NS}}}g[@id="content-group"]') if grp is None: print("ERREUR : content-group introuvable", file=sys.stderr) sys.exit(1) # Le groupe a transform="scale(PT2PX)" posé à l'étape 2. # Inkscape a mesuré la bbox EN PX (après scale). # On veut que le bord gauche/haut du contenu soit à MARGIN px. # Le nouveau transform doit être : translate(MARGIN - bbox_x, MARGIN - bbox_y) scale(PT2PX) # (en SVG les transforms s'appliquent de droite à gauche : # d'abord scale sur les coords internes pts, puis translate en px) PT2PX = 96.0 / 72.0 new_tx = MARGIN - bbox_x new_ty = MARGIN - bbox_y grp.set('transform', f'translate({new_tx:.6f},{new_ty:.6f}) scale({PT2PX})') print(f" Transform : translate({new_tx:.4f},{new_ty:.4f}) scale({PT2PX:.6f})") # Nouvelle page new_w_px = bbox_w + 2 * MARGIN new_h_px = bbox_h + 2 * MARGIN new_w_mm = new_w_px / MM2PX new_h_mm = new_h_px / MM2PX print(f" Page : {new_w_px:.4f} × {new_h_px:.4f} px" f" = {new_w_mm:.4f} × {new_h_mm:.4f} mm") root.set('width', f'{new_w_mm:.6f}mm') root.set('height', f'{new_h_mm:.6f}mm') root.set('viewBox', f'0 0 {new_w_px:.6f} {new_h_px:.6f}') # namedview Inkscape (unités) for child in root: if child.tag.endswith('}namedview'): child.set('{http://www.inkscape.org/namespaces/inkscape}document-units', 'mm') tree.write(output_svg, xml_declaration=True, encoding='UTF-8') print(f" Écrit : {output_svg}") PYEOF [[ ! -f "$OUTPUT_SVG" ]] && { echo "Erreur : SVG final non créé." >&2; exit 1; } echo "" echo "=== Terminé ===" echo " Sortie : $OUTPUT_SVG"