« Image vers ASCII vers SVG » : différence entre les versions
| Ligne 83 : | Ligne 83 : | ||
<syntaxhighlight lang="bash">sed '/<g id="lines"/,/<\/g>/d' picture.svg > picture-clean.svg</syntaxhighlight></li></ol> | <syntaxhighlight lang="bash">sed '/<g id="lines"/,/<\/g>/d' picture.svg > picture-clean.svg</syntaxhighlight></li></ol> | ||
== 🥈 Solution 3 : img2txt + scrypt Python 3 == | |||
On génère une version ASCII avec couleurs : | |||
<syntaxhighlight lang="bash"> | |||
img2txt -W 100 -x 1 -y 2 picture.png > picture.txt | |||
</syntaxhighlight> | |||
Exécution du script Python | |||
<syntaxhighlight lang="bash"> | |||
python3 ansi2svg_pure.py picture.txt picture.svg \ | |||
--font "DejaVu Sans Mono" \ | |||
--font-size 12 \ | |||
--line-height 1.2 \ | |||
--bg none \ | |||
--char-width-ratio 0.6 \ | |||
--margin 10 | |||
</syntaxhighlight> | |||
<syntaxhighlight lang="python" line> | |||
#!/usr/bin/env python3 | |||
# -*- coding: utf-8 -*- | |||
""" | |||
Convertit un ASCII art coloré (ANSI) en SVG coloré, sans dépendances externes. | |||
Usage: | |||
python3 ansi2svg_pure.py input.txt output.svg \ | |||
--font "DejaVu Sans Mono" --font-size 12 \ | |||
--line-height 1.2 --bg none --char-width-ratio 0.6 --margin 10 | |||
Conçu pour des sorties générées par `img2txt --ansi ...` (libcaca). | |||
Gère : SGR 0/1/3/4/22/23/24, 30-37/90-97 (fg), 40-47/100-107 (bg), | |||
38;5;n / 48;5;n (256c), 38;2;r;g;b / 48;2;r;g;b (truecolor). | |||
""" | |||
import argparse | |||
import html | |||
import re | |||
import sys | |||
from typing import Optional, Tuple, List | |||
CSI_SGR_RE = re.compile(r"\x1b\[((?:\d{1,3};?)*?)m") | |||
# Couleurs ANSI 16 de base (xterm) | |||
ANSI_16 = { | |||
# normal 30-37 / 40-47 | |||
30: (0, 0, 0), # black | |||
31: (205, 49, 49), # red | |||
32: (13, 188, 121), # green | |||
33: (229, 229, 16), # yellow | |||
34: (36, 114, 200), # blue | |||
35: (188, 63, 188), # magenta | |||
36: (17, 168, 205), # cyan | |||
37: (204, 204, 204), # white (light gray) | |||
# bright 90-97 / 100-107 | |||
90: (102, 102, 102), # bright black (gray) | |||
91: (241, 76, 76), # bright red | |||
92: (35, 209, 139), # bright green | |||
93: (245, 245, 67), # bright yellow | |||
94: (59, 142, 234), # bright blue | |||
95: (214, 112, 214), # bright magenta | |||
96: (41, 184, 219), # bright cyan | |||
97: (229, 229, 229), # bright white | |||
} | |||
def xterm256_to_rgb(n: int) -> Tuple[int, int, int]: | |||
"""Mappe un code couleur xterm 256 (0-255) vers (r,g,b).""" | |||
if n < 0: n = 0 | |||
if n > 255: n = 255 | |||
if n < 16: | |||
# 0-7 standard + 8-15 bright | |||
base = [ | |||
(0,0,0),(205,0,0),(0,205,0),(205,205,0), | |||
(0,0,238),(205,0,205),(0,205,205),(229,229,229), | |||
(127,127,127),(255,0,0),(0,255,0),(255,255,0), | |||
(92,92,255),(255,0,255),(0,255,255),(255,255,255), | |||
] | |||
return base[n] | |||
if 16 <= n <= 231: | |||
n -= 16 | |||
r = n // 36 | |||
g = (n % 36) // 6 | |||
b = n % 6 | |||
def level(v): | |||
return 55 + v * 40 if v > 0 else 0 | |||
return (level(r), level(g), level(b)) | |||
# 232..255 grayscale | |||
gray = 8 + (n - 232) * 10 | |||
return (gray, gray, gray) | |||
def rgb_to_hex(rgb: Optional[Tuple[int,int,int]]) -> str: | |||
if rgb is None: | |||
return "currentColor" # fallback | |||
r,g,b = rgb | |||
return f"#{r:02x}{g:02x}{b:02x}" | |||
class Style: | |||
__slots__ = ("fg", "bg", "bold", "italic", "underline") | |||
def __init__(self): | |||
self.fg: Optional[Tuple[int,int,int]] = (229,229,229) # défaut clair | |||
self.bg: Optional[Tuple[int,int,int]] = None # transparent | |||
self.bold = False | |||
self.italic = False | |||
self.underline = False | |||
def clone(self) -> 'Style': | |||
s = Style() | |||
s.fg = None if self.fg is None else tuple(self.fg) | |||
s.bg = None if self.bg is None else tuple(self.bg) | |||
s.bold = self.bold | |||
s.italic = self.italic | |||
s.underline = self.underline | |||
return s | |||
def apply_sgr(self, params: List[int]): | |||
if not params: | |||
params = [0] | |||
i = 0 | |||
while i < len(params): | |||
p = params[i] | |||
if p == 0: # reset | |||
self.__init__() | |||
elif p == 1: | |||
self.bold = True | |||
elif p == 3: | |||
self.italic = True | |||
elif p == 4: | |||
self.underline = True | |||
elif p == 22: | |||
self.bold = False | |||
elif p == 23: | |||
self.italic = False | |||
elif p == 24: | |||
self.underline = False | |||
elif p == 39: | |||
self.fg = (229,229,229) | |||
elif p == 49: | |||
self.bg = None | |||
elif (30 <= p <= 37) or (90 <= p <= 97): | |||
self.fg = ANSI_16.get(p) | |||
elif (40 <= p <= 47) or (100 <= p <= 107): | |||
self.bg = ANSI_16.get(p - 10) # bg code -> fg equivalent (30..) | |||
elif p in (38, 48): | |||
# 38 -> set fg ; 48 -> set bg | |||
is_fg = (p == 38) | |||
# expecting 5;n or 2;r;g;b | |||
if i+1 < len(params): | |||
mode = params[i+1] | |||
if mode == 5 and i+2 < len(params): | |||
n = params[i+2] | |||
rgb = xterm256_to_rgb(n) | |||
if is_fg: self.fg = rgb | |||
else: self.bg = rgb | |||
i += 2 | |||
elif mode == 2 and i+4 < len(params): | |||
r, g, b = params[i+2], params[i+3], params[i+4] | |||
rgb = (max(0,min(255,r)), max(0,min(255,g)), max(0,min(255,b))) | |||
if is_fg: self.fg = rgb | |||
else: self.bg = rgb | |||
i += 4 | |||
# autres codes ignorés | |||
i += 1 | |||
def parse_ansi_to_runs(line: str) -> List[Tuple[str, Style]]: | |||
""" | |||
Transforme une ligne ANSI en une liste de (texte_sans_ansi, style). | |||
Chaque élément est un run de même style. | |||
""" | |||
runs: List[Tuple[str, Style]] = [] | |||
style = Style() | |||
pos = 0 | |||
for m in CSI_SGR_RE.finditer(line): | |||
text_chunk = line[pos:m.start()] | |||
if text_chunk: | |||
# Ajouter le texte courant avec le style actuel | |||
runs.append((text_chunk, style.clone())) | |||
sgr_params = [int(x) for x in m.group(1).split(";") if x != ""] | |||
style.apply_sgr(sgr_params) | |||
pos = m.end() | |||
# Reste de la ligne | |||
tail = line[pos:] | |||
if tail: | |||
runs.append((tail, style.clone())) | |||
return runs | |||
def strip_ansi_visible_len(s: str) -> int: | |||
"""Longueur visible (sans séquences ANSI).""" | |||
return len(CSI_SGR_RE.sub("", s)) | |||
def main(): | |||
ap = argparse.ArgumentParser(description="ANSI colored text -> Colored SVG (no deps)") | |||
ap.add_argument("input", help="Fichier texte ANSI (ex: picture.txt)") | |||
ap.add_argument("output", help="Fichier SVG de sortie (ex: picture.svg)") | |||
ap.add_argument("--font", default="DejaVu Sans Mono", help="Police monospace (déf: DejaVu Sans Mono)") | |||
ap.add_argument("--font-size", type=float, default=12.0, help="Taille de police en px (déf: 12)") | |||
ap.add_argument("--line-height", type=float, default=1.2, help="Multiplicateur de hauteur de ligne (déf: 1.2)") | |||
ap.add_argument("--char-width-ratio", type=float, default=0.6, help="Largeur/FontSize pour monospace (déf: 0.6)") | |||
ap.add_argument("--margin", type=float, default=10.0, help="Marge en px autour (déf: 10)") | |||
ap.add_argument("--bg", default="none", help="Arrière-plan: 'none' (transparent) ou #rrggbb (déf: none)") | |||
args = ap.parse_args() | |||
try: | |||
with open(args.input, "r", encoding="utf-8", errors="replace") as f: | |||
raw_lines = f.read().splitlines() | |||
except Exception as e: | |||
print(f"Erreur: impossible de lire {args.input}: {e}", file=sys.stderr) | |||
sys.exit(1) | |||
# Mesures de grille | |||
fs = args.font_size | |||
lh = args.line_height * fs | |||
cw = args.char_width_ratio * fs | |||
margin = args.margin | |||
rows = len(raw_lines) | |||
cols = max((strip_ansi_visible_len(L) for L in raw_lines), default=0) | |||
width = 2*margin + cols * cw | |||
height = 2*margin + rows * lh | |||
# Prépare le SVG | |||
out = [] | |||
out.append('<?xml version="1.0" encoding="UTF-8"?>') | |||
out.append(f'<svg xmlns="http://www.w3.org/2000/svg" version="1.1" ' | |||
f'width="{width:.2f}" height="{height:.2f}" ' | |||
f'viewBox="0 0 {width:.2f} {height:.2f}">') | |||
# Fond | |||
if args.bg and args.bg.lower() != "none": | |||
out.append(f'<rect x="0" y="0" width="{width:.2f}" height="{height:.2f}" fill="{html.escape(args.bg)}"/>') | |||
# Style texte global | |||
out.append('<g>') | |||
out.append(f'<g font-family="{html.escape(args.font)}" font-size="{fs:.2f}px" ' | |||
f'xml:space="preserve">') | |||
# Dessine ligne par ligne | |||
# Pour placer le texte correctement: y = margin + (row+1)*lh - (lh - fs)/2 | |||
# (approximation: baseline ~ fs, visuellement suffisant) | |||
for r, line in enumerate(raw_lines): | |||
runs = parse_ansi_to_runs(line) | |||
x_cursor = 0 # colonne visible | |||
y = margin + (r + 1) * lh - (lh - fs) * 0.5 | |||
# On reconstruit la ligne en runs, en tenant compte des espaces | |||
for text, style in runs: | |||
if not text: | |||
continue | |||
# Avance la colonne pour les espaces "invisibles" avant le run ? | |||
# Ici, on place le run à la colonne courante et on dessine exactement son contenu. | |||
# Calcul du x | |||
x = margin + x_cursor * cw | |||
# Échappe le texte et remplace les tabulations par espaces (rare dans img2txt) | |||
safe_text = text.replace("\t", " ") | |||
safe_text = html.escape(safe_text) | |||
# Calcul des couleurs / décorations | |||
fill = rgb_to_hex(style.fg) | |||
# Background: on peut dessiner un rect derrière le run si besoin | |||
if style.bg is not None and safe_text: | |||
run_cols = len(text) | |||
rx = x | |||
ry = margin + r * lh | |||
rw = run_cols * cw | |||
rh = lh | |||
out.append(f'<rect x="{rx:.2f}" y="{ry:.2f}" width="{rw:.2f}" height="{rh:.2f}" ' | |||
f'fill="{rgb_to_hex(style.bg)}"/>') | |||
deco = [] | |||
if style.bold: | |||
deco.append("font-weight:bold") | |||
if style.italic: | |||
deco.append("font-style:italic") | |||
if style.underline: | |||
deco.append("text-decoration:underline") | |||
style_attr = f' fill="{fill}"' | |||
if deco: | |||
style_attr += f' style="{";".join(deco)}"' | |||
# Texte du run à la position calculée | |||
out.append(f'<text x="{x:.2f}" y="{y:.2f}"{style_attr}>{safe_text}</text>') | |||
# Avancer le curseur de colonnes visibles | |||
x_cursor += len(text) | |||
# S'il reste du vide en fin de ligne, rien à dessiner (fond transparent) | |||
out.append('</g>') | |||
out.append('</g>') | |||
out.append('</svg>') | |||
try: | |||
with open(args.output, "w", encoding="utf-8") as f: | |||
f.write("\n".join(out)) | |||
except Exception as e: | |||
print(f"Erreur: impossible d'écrire {args.output}: {e}", file=sys.stderr) | |||
sys.exit(1) | |||
print(f"[OK] SVG coloré généré : {args.output}") | |||
print(f" Dimensions: {width:.0f} x {height:.0f}px (cols={cols}, rows={rows}, fs={fs}px, cw≈{cw:.2f}, lh≈{lh:.2f})") | |||
if __name__ == "__main__": | |||
main() | |||
</syntaxhighlight> | |||
Suppresion du fond | |||
<syntaxhighlight lang="bash"> | |||
sed -i '/<rect/d' picture.svg | |||
</syntaxhighlight> | |||
----- | ----- | ||