Hello again,
I found a work-around using :
- Affinity Designer
- a-Shell
- the following python file (created and refined with ChatGPT)
- Install Affinity Designer, a-Shell, paste the python file at On my iPad/a-Shell.
Now we can actually start:
- Launch Affinity Designer
- Using the image, I highlight the texts using a pen tool on Affinity designer. I can group them as in Anki.
- Add a big rectangle with the exact dimensions on top of the whole thing, it will serve as a base for the relative coordinates of the highlights.
- Export the selection (rectangle + highlights but not the image) by SVG, enable « Relative coordinates » in the export UI.
- Copy the exported SVG file.
- Launch a-Shell
- type « pbpaste | python3 script_v13.py | pbcopy » and hit Enter
(There is no feedback message because the result got « injected » into your clipboard)
- paste that somewhere to not lose it (I paste it in Notes)
- Launch Anki
- Create new note using IO, select the image, make sure it has the same size as the « big rectangle » I talked about at step 3, else you will lose the scaling of the masks.
- Create a mask using IO interface, any! Then hit save. That way you can access the input fields to manually paste the result of the python script.
- Go fetch the pasted results from the python script and copy them. Paste them in the Masks field and hit save.
There you go!
↓ The said python script :
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
import sys, io, re
import xml.etree.ElementTree as ET
from math import fabs
svg_text = sys.stdin.read()
if not svg_text or not svg_text.strip():
raise RuntimeError("Aucune entrée SVG reçue (stdin vide).")
tree = ET.parse(io.StringIO(svg_text))
root = tree.getroot()
def tagname(node):
return node.tag.split("}")[-1]
# -------------------------
# Matrices SVG
# -------------------------
def parse_matrix(transform: str):
if not transform:
return (1.0,0.0,0.0,1.0,0.0,0.0)
m = re.search(r"matrix\(([^)]+)\)", transform)
if not m:
return (1.0,0.0,0.0,1.0,0.0,0.0)
parts = m.group(1).replace(" ", "").split(",")
if len(parts) != 6:
return (1.0,0.0,0.0,1.0,0.0,0.0)
return tuple(map(float, parts))
def mul(m1, m2):
a1,b1,c1,d1,e1,f1 = m1
a2,b2,c2,d2,e2,f2 = m2
return (
a1*a2 + c1*b2,
b1*a2 + d1*b2,
a1*c2 + c1*d2,
b1*c2 + d1*d2,
a1*e2 + c1*f2 + e1,
b1*e2 + d1*f2 + f1
)
def apply(m, x, y):
a,b,c,d,e,f = m
return (a*x + c*y + e, b*x + d*y + f)
# -------------------------
# Utilitaires shapes
# -------------------------
def parse_stroke_width(style: str):
if not style:
return None
m = re.search(r"stroke-width\s*:\s*([0-9.]+)", style)
return float(m.group(1)) if m else None
def extract_segment_from_d(d: str):
if not d:
return None
d = d.strip()
m = re.search(
r"[Mm]\s*([-0-9.]+)[,\s]+([-0-9.]+)\s*([lL])\s*([-0-9.]+)[,\s]+([-0-9.]+)",
d
)
if not m:
return None
x0 = float(m.group(1)); y0 = float(m.group(2))
cmd = m.group(3)
a_ = float(m.group(4)); b_ = float(m.group(5))
if cmd == "l":
x1 = x0 + a_
y1 = y0 + b_
else:
x1 = a_
y1 = b_
return (x0, y0, x1, y1)
def subtree_contains_reference_rect(node):
for n in node.iter():
if tagname(n) == "rect":
style = n.attrib.get("style","")
if ("fill:" in style) and ("stroke" not in style):
return True
return False
# -------------------------
# 1) Construire la matrice globale de chaque node
# -------------------------
node_global = {}
def build_globals(node, parent_matrix):
m = mul(parent_matrix, parse_matrix(node.attrib.get("transform")))
node_global[id(node)] = m
for ch in node:
build_globals(ch, m)
build_globals(root, (1.0,0.0,0.0,1.0,0.0,0.0))
# -------------------------
# 2) Référence: rect gris (dans le bon repère global)
# -------------------------
reference = None
for n in root.iter():
if tagname(n) != "rect":
continue
style = n.attrib.get("style","")
if ("fill:" in style) and ("stroke" not in style):
m = node_global[id(n)]
x = float(n.attrib["x"]); y = float(n.attrib["y"])
w = float(n.attrib["width"]); h = float(n.attrib["height"])
X0,Y0 = apply(m, x, y)
X1,Y1 = apply(m, x+w, y+h)
reference = (min(X0,X1), min(Y0,Y1), max(X0,X1), max(Y0,Y1))
break
if not reference:
raise RuntimeError("Rectangle de référence (rect gris) non trouvé.")
rx0, ry0, rx1, ry1 = reference
rw = rx1 - rx0
rh = ry1 - ry0
if abs(rw) < 1e-9 or abs(rh) < 1e-9:
raise RuntimeError("Rectangle de référence invalide (rw/rh ~ 0).")
# -------------------------
# Extraction des masques depuis un sous-arbre
# -------------------------
def extract_masks_from_subtree(subroot):
rects = []
def walk(node):
m = node_global[id(node)]
if tagname(node) == "path":
style = node.attrib.get("style","")
sw = parse_stroke_width(style)
if sw is None:
return
seg = extract_segment_from_d(node.attrib.get("d",""))
if seg is None:
return
x0,y0,x1,y1 = seg
X0,Y0 = apply(m, x0, y0)
X1,Y1 = apply(m, x1, y1)
a,b,c_,d,e,f = m
scale_x = fabs(a)
scale_y = fabs(d)
sw_world = sw * (scale_x + scale_y) / 2.0
x = min(X0, X1)
y = min(Y0, Y1) - sw_world/2.0
w = fabs(X1 - X0)
h = sw_world
rects.append((x,y,w,h))
for ch in node:
walk(ch)
walk(subroot)
return rects
# -------------------------
# Grouping "Affinity-like"
# -------------------------
drawing_root = None
for c in list(root):
if tagname(c) == "g":
drawing_root = c
break
if drawing_root is None:
raise RuntimeError("Aucun <g> racine trouvé sous <svg>.")
candidate_groups = [c for c in list(drawing_root) if tagname(c) == "g"]
candidate_groups = [g for g in candidate_groups if not subtree_contains_reference_rect(g)]
final_groups = []
for g in candidate_groups:
id_children = [x for x in list(g) if tagname(x) == "g" and x.attrib.get("id")]
final_groups.extend(id_children if id_children else [g])
# -------------------------
# Sortie Anki normalisée (doit retomber ~0..1)
# -------------------------
cid = 0
for grp in final_groups:
cid += 1
rects = extract_masks_from_subtree(grp)
for (x,y,w,h) in rects:
left = (x - rx0) / rw
top = (y - ry0) / rh
width = w / rw
height = h / rh
print(
f"{{{{c{cid}::image-occlusion:rect:"
f"left={left:.4f}:top={top:.4f}:"
f"width={width:.4f}:height={height:.4f}}}}}"
)