first commit

This commit is contained in:
Alex 2026-10-19 20:51:52 +02:00
commit 4993833586
17 changed files with 2215 additions and 0 deletions

175
README.md Normal file
View File

@ -0,0 +1,175 @@
# Remplissage motif relief — extension Inkscape
<table>
<tr>
<td width="50%" valign="top">
<img src="doc/a57cd56c50f5c5bb0b0601f17f08704a.jpg" alt="Motif de référence : figures en Y en relief sur une grille isométrique" width="420">
</td>
<td valign="top">
Extension Inkscape 1.x qui remplit l'**intérieur** des formes sélectionnées avec un
motif en relief : des figures en « Y » dessinées sur une grille isométrique
(arêtes verticales et à ±30°), relevé fidèle de l'image de référence du dossier
`doc/`. Les traits sont coupés exactement au bord de la forme, trous compris, et le
résultat tient dans un seul chemin par forme.
</td>
</tr>
</table>
> Une capture du résultat dans Inkscape (`docs/capture-desktop.png`) reste à ajouter.
## Sommaire
- [Fonctionnalités](#fonctionnalités)
- [Installation](#installation)
- [Utilisation](#utilisation)
- [Paramètres](#paramètres)
- [Structure du projet](#structure-du-projet)
- [Développement](#développement)
## Fonctionnalités
- Motif périodique (maille hexagonale de 5 arêtes) calculé, pas importé : taille,
rotation et origine réglables.
- Formes gérées : chemins (courbes aplaties), rectangles, cercles, ellipses,
polygones, polylignes, et groupes de ces éléments (transformations prises en
compte). Les chemins composés avec trous restent vides à l'intérieur des trous.
- Deux modes d'origine : centre de chaque forme (motif symétrique) ou origine du
document (motif continu d'une forme à l'autre).
- Segments alignés fusionnés : peu de nœuds, un seul chemin par forme, pas de
`transform` sur les chemins produits.
- Option : tracer aussi le contour de la forme ; option : conserver ou supprimer
la forme d'origine.
- Garde-fou : refus au-delà de 250 000 segments, avec un message d'aide.
- Interface traduite en français et en anglais, selon la langue d'Inkscape
(anglais pour toute autre langue).
- Script de déploiement Windows (`deploy.ps1`).
## Installation
Aucune dépendance externe : l'extension utilise `inkex`, fourni avec Inkscape.
### Windows
Fermer Inkscape, puis, dans PowerShell, depuis le dossier du projet :
```powershell
.\deploy.ps1 # installe
.\deploy.ps1 -Uninstall # désinstalle
.\deploy.ps1 -Force # installe même si Inkscape est ouvert
```
Les fichiers sont copiés dans `%APPDATA%\inkscape\extensions\Remplissage motif relief`.
### Manuelle
Copier `relief_fill.inx`, `relief_fill.py`, `relief_core.py`, `images/` et `locale/`
dans un sous-dossier du dossier d'extensions utilisateur :
| Système | Dossier |
| ------- | ------- |
| Windows | `%APPDATA%\inkscape\extensions\` |
| Linux | `~/.config/inkscape/extensions/` |
| macOS | `~/Library/Application Support/org.inkscape.Inkscape/config/inkscape/extensions/` |
Redémarrer Inkscape après l'installation.
## Utilisation
1. Sélectionner une ou plusieurs formes.
2. Menu **Extensions > AlexDesign > Remplissage motif relief…** (*Relief Pattern Fill*).
3. Régler la longueur d'arête, la rotation et l'origine, puis **Appliquer**.
Le résultat est un groupe « Remplissage relief » placé juste au-dessus de chaque
forme sélectionnée.
**Conseils**
| Symptôme | Réglage |
| -------- | ------- |
| Motif trop fin ou trop dense | Augmenter la longueur d'arête |
| « Trop de traits » | Augmenter la longueur d'arête ou sélectionner une forme plus petite |
| « Aucun trait n'y tient » | Réduire la longueur d'arête |
| Motif discontinu entre deux formes voisines | Choisir l'origine « Origine du document » |
| Texte non rempli | Le convertir d'abord en chemins (Chemin > Objet en chemin) |
## Paramètres
![Schéma des paramètres](docs/parametres.png)
| Paramètre | Défaut | Rôle |
| --------- | ------ | ---- |
| Longueur d'arête | 5 mm | Arête unitaire de la grille ; le motif se répète toutes les 5 arêtes |
| Unité | mm | Unité de la longueur d'arête et de l'épaisseur des traits (mm, cm, px, pt, in) |
| Rotation (degrés, sens horaire) | 0 | Fait tourner le motif autour de l'origine du motif |
| Origine du motif | Centre de chaque forme | Nœud de la grille au centre de chaque forme, ou à l'origine du document |
| Couleur des traits | noir | Couleur (et opacité) des traits |
| Épaisseur des traits | 0,3 | Dans l'unité de l'onglet Motif |
| Dessiner aussi le contour de la forme | non | Ajoute un chemin avec le contour de la forme |
| Conserver la forme d'origine | oui | Sinon la forme sélectionnée est supprimée |
## Structure du projet
```text
relief_fill.inx Boîte de dialogue (textes source en anglais)
relief_fill.py Couche inkex : sélection, unités, aplatissement, écriture SVG
relief_core.py Logique pure, sans inkex : motif, découpe
i18n.py Extraction / mise à jour / compilation des traductions
po/ relief_fill.pot, en.po, fr.po
locale/<lang>/LC_MESSAGES/relief_fill.mo (générés, déployés)
images/parameters_en.png Schéma de l'onglet « Help » (déployé)
docs/schema_parametres.py Générateur du schéma (fr pour le README, en pour le dialogue)
docs/parametres.png Schéma du README
doc/ Image de référence du motif
tests/data/ SVG d'exemple pour les tests de bout en bout
test_relief_fill.py Tests pytest
deploy.ps1 Déploiement / désinstallation Windows
```
API du noyau (`relief_core.py`) :
| Fonction | Rôle |
| -------- | ---- |
| `fill_rings(rings, edge, angle=0, anchor=(0, 0))` | Segments du motif à l'intérieur d'un contour (pair-impair) |
| `pattern_segments(bbox, edge)` | Segments du motif couvrant une boîte, nœud (0, 0) à l'origine |
| `clip_segment(p, q, rings)` | Parties d'un segment situées dans le contour |
| `point_in_rings(point, rings)` | Appartenance au contour, règle pair-impair |
| `rotate_point(point, angle, center)` | Rotation (sens horaire à l'écran) |
| `rings_bbox(rings)` | Boîte englobante |
| `segments_to_polylines(segments)` / `polylines_to_d(polylines)` | Conversion vers les données `d` d'un chemin SVG |
| `parse_color(value)` | Couleur Inkscape (entier RGBA) → `#rrggbb` + opacité |
## Développement
```powershell
python -m venv .venv ; .\.venv\Scripts\Activate.ps1
pip install pytest lxml tinycss2 cssselect2 cssselect
python -m pytest -q # 23 tests passés, 11 ignorés (sans inkex)
$env:PYTHONPATH = 'C:\Program Files\Inkscape\share\inkscape\extensions'
python -m pytest -q # 34 tests passés (avec inkex)
```
Les tests couvrent le noyau (directions, périodicité, fusion des arêtes, découpe,
trous, rotation, ancrage), le bout en bout (formes, courbes, trou, groupe
transformé, échelle de page non standard, options) et la cohérence des
traductions et du `.inx`.
### Traductions
```powershell
python i18n.py # extrait, met à jour et compile ; liste les msgstr manquants
```
Après modification d'un texte du `.inx` ou d'un `_("...")` : lancer `python i18n.py`,
compléter les `msgstr` vides de `po/fr.po`, relancer. Ne jamais éditer les `.mo`.
Pour ajouter une langue : l'ajouter à `LANGUAGES` dans `i18n.py`.
### Schéma des paramètres
```powershell
python docs\schema_parametres.py # fr -> docs/parametres.png, en -> images/parameters_en.png
```
L'export PNG passe par `inkscape.com`. Le schéma est calculé avec les vraies
fonctions de `relief_core`.

107
deploy.ps1 Normal file
View File

@ -0,0 +1,107 @@
<#
.SYNOPSIS
Déploie l'extension « Remplissage motif relief » dans Inkscape.
.DESCRIPTION
Copie relief_fill.inx, relief_fill.py, relief_core.py, le schéma de l'onglet
« Help » (images\parameters_en.png) et les traductions
compilées (locale\<langue>\LC_MESSAGES\relief_fill.mo) dans un
sous-dossier dédié du répertoire des extensions utilisateur d'Inkscape
(Inkscape explore récursivement ce répertoire). Refuse de s'exécuter si
Inkscape est ouvert : les extensions ne sont chargées qu'au démarrage.
.PARAMETER ExtensionsDir
Répertoire des extensions Inkscape. Par défaut : %APPDATA%\inkscape\extensions
.PARAMETER FolderName
Nom du sous-dossier de l'extension. Par défaut : Remplissage motif relief
.PARAMETER Uninstall
Supprime le sous-dossier de l'extension au lieu de le créer.
.PARAMETER Force
Déploie même si Inkscape est en cours d'exécution.
.EXAMPLE
.\deploy.ps1
.\deploy.ps1 -Uninstall
.\deploy.ps1 -FolderName 'autre-nom'
#>
[CmdletBinding()]
param(
[string] $ExtensionsDir = (Join-Path $env:APPDATA 'inkscape\extensions'),
[string] $FolderName = 'Remplissage motif relief',
[switch] $Uninstall,
[switch] $Force
)
$ErrorActionPreference = 'Stop'
$files = @('relief_fill.inx', 'relief_fill.py', 'relief_core.py', 'images\parameters_en.png')
$localeDir = 'locale'
$source = $PSScriptRoot
$destination = Join-Path $ExtensionsDir $FolderName
# --- Inkscape doit être fermé : les extensions sont lues au démarrage ---------
$running = Get-Process -Name 'inkscape' -ErrorAction SilentlyContinue
if ($running -and -not $Force) {
Write-Error "Inkscape est ouvert. Fermez-le puis relancez le script (ou utilisez -Force)."
exit 1
}
# --- Désinstallation ---------------------------------------------------------
if ($Uninstall) {
if (Test-Path -LiteralPath $destination) {
Remove-Item -LiteralPath $destination -Recurse -Force
Write-Host "Supprimé : $destination" -ForegroundColor Green
}
else {
Write-Host "Rien à supprimer : $destination n'existe pas." -ForegroundColor Yellow
}
exit 0
}
# --- Vérification des sources ------------------------------------------------
$missing = $files | Where-Object { -not (Test-Path -LiteralPath (Join-Path $source $_)) }
if (-not (Test-Path -LiteralPath (Join-Path $source $localeDir))) {
$missing = @($missing) + "$localeDir\ (lancer : python i18n.py)"
}
if ($missing) {
Write-Error "Fichier(s) introuvable(s) dans $source : $($missing -join ', ')"
exit 1
}
# --- Copie -------------------------------------------------------------------
if (-not (Test-Path -LiteralPath $destination)) {
New-Item -ItemType Directory -Path $destination -Force | Out-Null
Write-Host "Dossier créé : $destination"
}
foreach ($file in $files) {
$from = Join-Path $source $file
$to = Join-Path $destination $file
$action = if (Test-Path -LiteralPath $to) { 'remplacé' } else { 'copié ' }
New-Item -ItemType Directory -Path (Split-Path $to) -Force | Out-Null
Copy-Item -LiteralPath $from -Destination $to -Force
Write-Host " $action $file"
}
# Traductions : le dossier est remplacé en entier (langue retirée => supprimée)
$localeTo = Join-Path $destination $localeDir
if (Test-Path -LiteralPath $localeTo) {
Remove-Item -LiteralPath $localeTo -Recurse -Force
}
Copy-Item -LiteralPath (Join-Path $source $localeDir) -Destination $localeTo -Recurse
$languages = Get-ChildItem -LiteralPath $localeTo -Directory | ForEach-Object Name
Write-Host " copié $localeDir\ ($($languages -join ', '))"
# Le cache Python d'une version précédente peut masquer les fichiers copiés
$pycache = Join-Path $destination '__pycache__'
if (Test-Path -LiteralPath $pycache) {
Remove-Item -LiteralPath $pycache -Recurse -Force
}
Write-Host ""
Write-Host "Extension déployée dans $destination" -ForegroundColor Green
Write-Host "Redémarrez Inkscape, puis : Extensions > AlexDesign > Remplissage motif relief..."

BIN
docs/parametres.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 450 KiB

363
docs/schema_parametres.py Normal file
View File

@ -0,0 +1,363 @@
#!/usr/bin/env python3
# coding=utf-8
"""
Genere le schema explicatif des parametres de l'extension.
python docs/schema_parametres.py # les deux versions
python docs/schema_parametres.py fr # docs/parametres.svg + .png (README)
python docs/schema_parametres.py en # docs/parameters_en.svg
# + images/parameters_en.png (onglet
# « Help » de la boite de dialogue)
Les formes sont calculees par relief_core (memes fonctions que l'extension),
le schema reste donc fidele au resultat reel. L'export PNG passe par
inkscape.com, qui attend la fin de l'export (contrairement au raccourci
Chocolatey « inkscape »).
"""
import math
import os
import subprocess
import sys
HERE = os.path.dirname(os.path.abspath(__file__))
ROOT = os.path.dirname(HERE)
sys.path.insert(0, ROOT)
import relief_core # noqa: E402
W, H = 1400, 1280 # 4 panneaux de 700 x 640
INK = "#343a40" # element principal
GHOST = "#dde1e5" # elements secondaires / voisins
DIM = "#d9480f" # cotes
DIM2 = "#1971c2" # cotes secondaires
EDGE = "#212529" # contour des formes
FONT = "font-family:Arial,Helvetica,sans-serif"
INKSCAPE = [r"C:\Program Files\Inkscape\bin\inkscape.com", "/usr/bin/inkscape",
"/Applications/Inkscape.app/Contents/MacOS/inkscape"]
# Version par langue : textes, fichiers produits, largeur du PNG et agrandissement
# des textes (la version anglaise est affichee reduite dans la boite de dialogue :
# 900 px de large, textes x 1.25 pour rester lisibles).
VERSIONS = {
"fr": {
"svg": os.path.join(HERE, "parametres.svg"),
"png": os.path.join(HERE, "parametres.png"),
"png_width": 1400,
"font_scale": 1.0,
"text": {
"title1": "Longueur d'arête",
"edge": "arête",
"period": "5 arêtes (période)",
"note1": ["Le motif se répète toutes les 5 arêtes :",
"plus l'arête est grande, plus il est grossier."],
"title2": "Rotation",
"angle": "angle",
"ref": "avant",
"note2": ["Le motif tourne dans le sens horaire,",
"autour de l'origine du motif."],
"title3": "Origine du motif",
"row_shape": "Centre de chaque forme",
"row_doc": "Origine du document",
"note3": ["Centre : un nœud de la grille au milieu de chaque forme.",
"Document : un seul motif continu d'une forme à l'autre."],
"title4": "Résultat",
"hole": "le trou reste vide",
"outline": "contour (option)",
"lines": "traits coupés au bord",
"note4": ["Un groupe au-dessus de la forme, avec un chemin",
"par forme ; formes à trous prises en charge."],
},
},
"en": {
"svg": os.path.join(HERE, "parameters_en.svg"),
"png": os.path.join(ROOT, "images", "parameters_en.png"),
"png_width": 900,
"font_scale": 1.25,
"text": {
"title1": "Edge length",
"edge": "edge",
"period": "5 edges (period)",
"note1": ["The pattern repeats every 5 edges:",
"a longer edge gives a coarser pattern."],
"title2": "Rotation",
"angle": "angle",
"ref": "before",
"note2": ["The pattern turns clockwise,",
"around the pattern origin."],
"title3": "Pattern origin",
"row_shape": "Center of each shape",
"row_doc": "Document origin",
"note3": ["Center: a grid node in the middle of each shape.",
"Document: one continuous pattern across shapes."],
"title4": "Result",
"hole": "the hole stays empty",
"outline": "outline (option)",
"lines": "lines cut at the edge",
"note4": ["A group above the shape, with one path",
"per shape; shapes with holes are supported."],
},
},
}
def _unit(angle):
"""Vecteur unitaire, angle en degres, sens anti-horaire a l'ecran (y vers le bas)."""
a = math.radians(angle)
return math.cos(a), -math.sin(a)
out = []
T = {}
FS = 1.0
def fmt(v):
return "{:.2f}".format(v).rstrip("0").rstrip(".")
def pts(polyline):
return " ".join("{},{}".format(fmt(x), fmt(y)) for x, y in polyline)
def polyline(points, color, width, extra=""):
out.append('<polyline points="{}" fill="none" stroke="{}" stroke-width="{}" '
'stroke-linejoin="round" stroke-linecap="round" {}/>'.format(
pts(points), color, fmt(width), extra))
def motif(polylines, color, width):
for pl in polylines:
polyline(pl, color, width)
def text(x, y, s, size=17, color=INK, anchor="start", weight="normal", halo=True):
style = "{};font-size:{}px;font-weight:{};fill:{}".format(
FONT, fmt(size * FS), weight, color)
if halo:
style += ";paint-order:stroke;stroke:#ffffff;stroke-width:5px;stroke-linejoin:round"
out.append('<text x="{}" y="{}" text-anchor="{}" style="{}">{}</text>'.format(
fmt(x), fmt(y), anchor, style, s))
def note(x, y, lines):
"""Legende de bas de panneau, derniere ligne sur la ligne de base `y`."""
step = 22 * FS
for k, line in enumerate(lines):
text(x, y - (len(lines) - 1 - k) * step, line, size=16, halo=False)
def arrow_head(tip, direction, color, size=9):
ux, uy = direction
nx, ny = -uy, ux
base = (tip[0] - size * ux, tip[1] - size * uy)
out.append('<polygon points="{}" fill="{}"/>'.format(pts([
tip, (base[0] + 0.45 * size * nx, base[1] + 0.45 * size * ny),
(base[0] - 0.45 * size * nx, base[1] - 0.45 * size * ny)]), color))
def dimension(p, q, offset, label, color=DIM, label_pos=0.5, label_shift=18, size=17):
"""Cote entre p et q, decalee de `offset` perpendiculairement."""
dx, dy = q[0] - p[0], q[1] - p[1]
length = math.hypot(dx, dy)
ux, uy = dx / length, dy / length
nx, ny = -uy, ux
a = (p[0] + offset * nx, p[1] + offset * ny)
b = (q[0] + offset * nx, q[1] + offset * ny)
sign = 1 if offset >= 0 else -1
for base, end in ((p, a), (q, b)):
polyline([(base[0] + sign * 4 * nx, base[1] + sign * 4 * ny),
(end[0] + sign * 6 * nx, end[1] + sign * 6 * ny)], color, 1,
'stroke-dasharray="3,3"')
polyline([a, b], color, 1.6)
arrow_head(a, (-ux, -uy), color)
arrow_head(b, (ux, uy), color)
lx = a[0] + label_pos * (b[0] - a[0]) + sign * label_shift * FS * nx
ly = a[1] + label_pos * (b[1] - a[1]) + sign * label_shift * FS * ny + 6 * FS
text(lx, ly, label, size=size, color=color, anchor="middle", weight="bold")
def arc(center, radius, a0, a1, color, label=None, label_radius=None, size=16):
"""Arc de a0 a a1 degres (sens anti-horaire a l'ecran), fleche au bout."""
steps = 40
points = []
for k in range(steps + 1):
ux, uy = _unit(a0 + (a1 - a0) * k / steps)
points.append((center[0] + radius * ux, center[1] + radius * uy))
polyline(points[:-2], color, 1.6)
last, before = points[-1], points[-3]
d = math.hypot(last[0] - before[0], last[1] - before[1])
arrow_head(last, ((last[0] - before[0]) / d, (last[1] - before[1]) / d), color, 8)
if label:
ux, uy = _unit((a0 + a1) / 2)
r = label_radius or radius + 24
text(center[0] + r * ux, center[1] + r * uy + 6 * FS, label, size=size,
color=color, anchor="middle", weight="bold")
def circle_ring(cx, cy, r, n=180):
return [(cx + r * math.cos(2 * math.pi * k / n), cy + r * math.sin(2 * math.pi * k / n))
for k in range(n)]
def rect_ring(x, y, w, h):
return [(x, y), (x + w, y), (x + w, y + h), (x, y + h)]
def panel_title(x, y, number, title):
text(x, y, "{} {}".format(number, title), size=22, weight="bold", halo=False)
def fill(rings, edge, angle, anchor, color, width):
"""Dessine le motif de relief_core dans `rings`."""
segments = relief_core.fill_rings(rings, edge, angle, anchor)
motif(relief_core.segments_to_polylines(segments), color, width)
def outline(rings, color, width, extra=""):
for ring in rings:
polyline(ring + [ring[0]], color, width, extra)
def marker(point, color=DIM):
out.append('<circle cx="{}" cy="{}" r="6" fill="{}" stroke="#ffffff" '
'stroke-width="2"/>'.format(fmt(point[0]), fmt(point[1]), color))
def label_with_leader(anchor_point, label_point, label, color=DIM, align="start"):
polyline([anchor_point, label_point], color, 1.4)
out.append('<circle cx="{}" cy="{}" r="3.5" fill="{}"/>'.format(
fmt(anchor_point[0]), fmt(anchor_point[1]), color))
dx = 8 if align == "start" else -8
text(label_point[0] + dx, label_point[1] + 5 * FS, label, size=16, color=color,
anchor=align, weight="bold")
# --------------------------------------------------------------------------
# Panneaux : un par parametre (ou groupe de parametres), numerotes 1 a 4.
# --------------------------------------------------------------------------
def panel_1():
panel_title(30, 50, 1, T["title1"])
box = rect_ring(60, 90, 580, 470)
cx, cy = 350.0, 325.0
edge = 44.0
fill([box], edge, 0, (cx, cy), INK, 2.2)
outline([box], EDGE, 2.6)
hx = edge * relief_core.COS30
# une arete verticale presente du motif (noeud (0,2) -> (0,4)) et la periode
p, q = (cx, cy + edge), (cx, cy + 2 * edge)
dimension(p, q, -34, T["edge"], DIM, label_shift=24)
dimension((cx, cy - 3 * edge), (cx, cy + 2 * edge), 36,
T["period"], DIM2, label_shift=18)
note(30, 610, T["note1"])
def panel_2():
ox = 700
panel_title(ox + 30, 50, 2, T["title2"])
cx, cy = ox + 350.0, 325.0
shape = [circle_ring(cx, cy, 225)]
edge = 44.0
fill(shape, edge, 0, (cx, cy), GHOST, 2.0)
fill(shape, edge, 20, (cx, cy), INK, 2.2)
outline(shape, EDGE, 2.6)
# reference verticale avant rotation, et verticale tournee
ux, uy = _unit(90)
polyline([(cx, cy), (cx + 190 * ux, cy + 190 * uy)], DIM2, 1.6, 'stroke-dasharray="6,4"')
ux, uy = _unit(70)
polyline([(cx, cy), (cx + 190 * ux, cy + 190 * uy)], DIM, 1.8)
arc((cx, cy), 120, 90, 70, DIM, T["angle"], label_radius=146)
marker((cx, cy))
text(cx - 10, cy - 196, T["ref"], size=16, color=DIM2, anchor="end", weight="bold")
note(ox + 30, 610, T["note2"])
def panel_3():
oy = 640
panel_title(30, oy + 50, 3, T["title3"])
edge = 26.0
rects = [rect_ring(70, oy + 120, 250, 150), rect_ring(350, oy + 120, 250, 150)]
rects2 = [rect_ring(70, oy + 340, 250, 150), rect_ring(350, oy + 340, 250, 150)]
# rangee A : un noeud au centre de chaque forme
text(70, oy + 100, T["row_shape"], size=17, color=DIM, weight="bold", halo=False)
for ring in rects:
center = (ring[0][0] + 125, ring[0][1] + 75)
fill([ring], edge, 0, center, INK, 1.8)
outline([ring], EDGE, 2.4)
marker(center)
# rangee B : grille ancree au coin de la page, motif continu
text(70, oy + 320, T["row_doc"], size=17, color=DIM2, weight="bold", halo=False)
band = rect_ring(40, oy + 330, 590, 170)
fill([band], edge, 0, (0.0, 0.0), GHOST, 1.6)
for ring in rects2:
fill([ring], edge, 0, (0.0, 0.0), INK, 1.8)
outline([ring], EDGE, 2.4)
note(30, oy + 610, T["note3"])
def panel_4():
ox, oy = 700, 640
panel_title(ox + 30, oy + 50, 4, T["title4"])
cx, cy = ox + 280.0, oy + 330.0
# forme a trou : cercle exterieur, cercle interieur decale
outer = circle_ring(cx, cy, 170)
inner = circle_ring(cx + 45, cy + 20, 55)
rings = [outer, inner]
fill(rings, 26.0, 0, (cx, cy), INK, 2.0)
outline(rings, DIM, 3.2)
label_with_leader((cx + 45 + 55, cy + 20), (cx + 185, cy + 20), T["hole"], DIM, "start")
label_with_leader((cx - 170 * 0.866, cy - 170 * 0.5), (ox + 40, cy - 190),
T["outline"], DIM, "start")
label_with_leader((cx - 60, cy + 95), (ox + 40, cy + 215), T["lines"], DIM2, "start")
note(ox + 30, oy + 610, T["note4"])
def export_png(svg_path, png_path, width):
exe = next((path for path in INKSCAPE if os.path.exists(path)), None)
if exe is None:
print("Inkscape introuvable : exporter {} a la main".format(svg_path))
return
os.makedirs(os.path.dirname(png_path), exist_ok=True)
subprocess.run([exe, svg_path, "--export-type=png",
"--export-width={}".format(width),
"--export-filename={}".format(png_path)],
check=True, stderr=subprocess.DEVNULL)
print(png_path)
def build(language):
global FS
version = VERSIONS[language]
out.clear()
T.clear()
T.update(version["text"])
FS = version["font_scale"]
out.append('<rect width="{}" height="{}" fill="#ffffff"/>'.format(W, H))
for x1, y1, x2, y2 in ((700, 20, 700, H - 20), (20, 640, W - 20, 640)):
out.append('<line x1="{}" y1="{}" x2="{}" y2="{}" stroke="#dee2e6" '
'stroke-width="1.5"/>'.format(x1, y1, x2, y2))
panel_1()
panel_2()
panel_3()
panel_4()
svg = ('<?xml version="1.0" encoding="UTF-8"?>\n'
'<svg xmlns="http://www.w3.org/2000/svg" width="{0}" height="{1}" '
'viewBox="0 0 {0} {1}">\n{2}\n</svg>\n').format(W, H, "\n".join(out))
with open(version["svg"], "w", encoding="utf-8", newline="\n") as handle:
handle.write(svg)
print(version["svg"])
export_png(version["svg"], version["png"], version["png_width"])
def main(argv):
languages = argv or sorted(VERSIONS)
for language in languages:
build(language)
if __name__ == "__main__":
main(sys.argv[1:])

304
i18n.py Normal file
View File

@ -0,0 +1,304 @@
#!/usr/bin/env python3
# coding=utf-8
"""
Chaine de traduction de l'extension, sans dependance a gettext.
python i18n.py # extract + update + compile
python i18n.py extract # po/<domaine>.pot depuis le .inx et les .py
python i18n.py update # reporte le modele dans po/<langue>.po
python i18n.py compile # po/<langue>.po -> locale/<langue>/LC_MESSAGES/*.mo
Le domaine est lu dans l'attribut translationdomain du .inx ; les sources sont
le .inx et tous les .py du dossier (hors i18n.py et tests).
Les textes source (.inx et appels _() des .py) sont en anglais : c'est ce
qu'Inkscape affiche pour une langue sans catalogue. Le catalogue anglais est
rempli automatiquement avec les textes source ; seul fr.po se traduit a la main.
Les chaines retenues dans le .inx suivent les regles d'Inkscape (inx.its) :
<name>, attributs gui-text et gui-description, contenu de <option>, <item> et
<label>, sauf translatable="no". Les espaces sont normalises, sauf sous
xml:space="preserve".
"""
import ast
import os
import re
import struct
import sys
import xml.etree.ElementTree as ET
HERE = os.path.dirname(os.path.abspath(__file__))
LANGUAGES = ["en", "fr"]
SOURCE_LANGUAGE = "en"
PO_DIR = os.path.join(HERE, "po")
LOCALE_DIR = os.path.join(HERE, "locale")
XML_SPACE = "{http://www.w3.org/XML/1998/namespace}space"
INX_NS = "{http://www.inkscape.org/namespace/inkscape/extension}"
def _find_inx():
found = sorted(name for name in os.listdir(HERE) if name.endswith(".inx"))
if len(found) != 1:
sys.exit("i18n.py attend un seul fichier .inx dans {} (trouve : {})".format(
HERE, found))
return found[0]
INX = _find_inx()
_INX_ROOT = ET.parse(os.path.join(HERE, INX)).getroot()
DOMAIN = _INX_ROOT.get("translationdomain")
if not DOMAIN:
sys.exit("Attribut translationdomain absent de " + INX)
EXTENSION_NAME = (_INX_ROOT.findtext(INX_NS + "name") or DOMAIN).strip()
SOURCES = [INX] + sorted(
name for name in os.listdir(HERE)
if name.endswith(".py") and name != "i18n.py" and not name.startswith("test"))
# --------------------------------------------------------------------------
# Extraction
# --------------------------------------------------------------------------
def _local(tag):
return tag.rsplit("}", 1)[-1]
def _normalize(text, preserve):
return text if preserve else " ".join(text.split())
def extract_inx(path):
"""Chaines traduisibles d'un fichier .inx, dans l'ordre du document."""
messages = []
def walk(elem, preserve):
preserve = elem.get(XML_SPACE, "preserve" if preserve else "default") == "preserve"
if elem.get("translatable") != "no":
for attr in ("gui-text", "gui-description"):
if elem.get(attr):
messages.append(_normalize(elem.get(attr), preserve))
if _local(elem.tag) in ("name", "option", "item", "label") and elem.text:
messages.append(_normalize(elem.text, preserve))
for child in elem:
walk(child, preserve)
walk(ET.parse(path).getroot(), False)
return [m for m in messages if m]
def extract_py(path):
"""Arguments litteraux des appels _("...") d'un script Python."""
with open(path, encoding="utf-8") as handle:
tree = ast.parse(handle.read(), path)
calls = [node for node in ast.walk(tree)
if isinstance(node, ast.Call) and isinstance(node.func, ast.Name)
and node.func.id == "_" and len(node.args) == 1
and isinstance(node.args[0], ast.Constant)
and isinstance(node.args[0].value, str)]
calls.sort(key=lambda node: (node.lineno, node.col_offset))
return [node.args[0].value for node in calls]
def extract():
"""Liste ordonnee et sans doublon de (msgid, [fichiers sources])."""
found = {}
for name in SOURCES:
path = os.path.join(HERE, name)
strings = extract_inx(path) if name.endswith(".inx") else extract_py(path)
for msgid in strings:
found.setdefault(msgid, [])
if name not in found[msgid]:
found[msgid].append(name)
return list(found.items())
# --------------------------------------------------------------------------
# Lecture / ecriture des fichiers .po
# --------------------------------------------------------------------------
_ESCAPES = {"n": "\n", "t": "\t", '"': '"', "\\": "\\"}
def _unquote(line):
body = line.strip()[1:-1]
return re.sub(r'\\(.)', lambda m: _ESCAPES.get(m.group(1), m.group(1)), body)
def _quote(text):
text = text.replace("\\", "\\\\").replace('"', '\\"').replace("\t", "\\t")
lines = text.split("\n")
parts = [line + "\\n" for line in lines[:-1]]
if lines[-1]:
parts.append(lines[-1])
if len(parts) <= 1:
return '"{}"'.format(parts[0] if parts else "")
return '""\n' + "\n".join('"{}"'.format(part) for part in parts)
def read_po(path):
"""Dictionnaire msgid -> (msgstr, fuzzy). L'en-tete a pour cle ""."""
entries = {}
if not os.path.exists(path):
return entries
msgid = msgstr = None
field = None
fuzzy = False
def flush():
if msgid is not None:
entries[msgid] = (msgstr or "", fuzzy)
with open(path, encoding="utf-8") as handle:
for raw in handle:
line = raw.strip()
if line.startswith("#,") and "fuzzy" in line:
flush()
msgid = msgstr = field = None
fuzzy = True
elif line.startswith("msgid "):
if field == "msgstr":
flush()
fuzzy = False
msgid, msgstr, field = _unquote(line[6:]), None, "msgid"
elif line.startswith("msgstr "):
msgstr, field = _unquote(line[7:]), "msgstr"
elif line.startswith('"') and field == "msgid":
msgid += _unquote(line)
elif line.startswith('"') and field == "msgstr":
msgstr += _unquote(line)
elif not line and field == "msgstr":
flush()
msgid = msgstr = field = None
fuzzy = False
flush()
return entries
def _header(language):
fields = [
("Project-Id-Version", DOMAIN),
("Language", language or ""),
("MIME-Version", "1.0"),
("Content-Type", "text/plain; charset=UTF-8"),
("Content-Transfer-Encoding", "8bit"),
("Plural-Forms", {"fr": "nplurals=2; plural=(n > 1);",
"en": "nplurals=2; plural=(n != 1);"}.get(language, "")),
]
return "".join("{}: {}\n".format(key, value) for key, value in fields if value)
def write_po(path, language, messages, translations):
title = ("Modele de traduction" if language is None
else "Traduction ({})".format(language))
out = ["# {} de l'extension Inkscape « {} ».".format(title, EXTENSION_NAME),
"# Genere par i18n.py ; ne modifier que les msgstr.",
"msgid \"\"",
"msgstr " + _quote(_header(language)),
""]
for msgid, refs in messages:
msgstr, fuzzy = translations.get(msgid, ("", False))
out.append("#: " + " ".join(refs))
if fuzzy:
out.append("#, fuzzy")
out.append("msgid " + _quote(msgid))
out.append("msgstr " + _quote(msgstr))
out.append("")
with open(path, "w", encoding="utf-8", newline="\n") as handle:
handle.write("\n".join(out))
# --------------------------------------------------------------------------
# Compilation .mo (format GNU gettext)
# --------------------------------------------------------------------------
def write_mo(path, catalog):
"""Ecrit un catalogue {msgid: msgstr} au format .mo, cles triees."""
keys = sorted(catalog)
ids = [key.encode("utf-8") for key in keys]
strs = [catalog[key].encode("utf-8") for key in keys]
count = len(keys)
ids_start = 7 * 4 + 16 * count
strs_start = ids_start + sum(len(b) + 1 for b in ids)
table_ids, table_strs = [], []
offset = ids_start
for data in ids:
table_ids += [len(data), offset]
offset += len(data) + 1
offset = strs_start
for data in strs:
table_strs += [len(data), offset]
offset += len(data) + 1
output = struct.pack("<7I", 0x950412DE, 0, count, 7 * 4, 7 * 4 + 8 * count, 0, 0)
output += struct.pack("<{}I".format(2 * count), *table_ids)
output += struct.pack("<{}I".format(2 * count), *table_strs)
output += b"".join(data + b"\0" for data in ids)
output += b"".join(data + b"\0" for data in strs)
os.makedirs(os.path.dirname(path), exist_ok=True)
with open(path, "wb") as handle:
handle.write(output)
# --------------------------------------------------------------------------
# Commandes
# --------------------------------------------------------------------------
def po_path(language):
return os.path.join(PO_DIR, "{}.po".format(language))
def mo_path(language):
return os.path.join(LOCALE_DIR, language, "LC_MESSAGES", DOMAIN + ".mo")
def cmd_extract():
os.makedirs(PO_DIR, exist_ok=True)
messages = extract()
write_po(os.path.join(PO_DIR, DOMAIN + ".pot"), None, messages, {})
print("{} chaines -> po/{}.pot".format(len(messages), DOMAIN))
def cmd_update():
messages = extract()
for language in LANGUAGES:
translations = read_po(po_path(language))
if language == SOURCE_LANGUAGE:
translations = {msgid: (msgid, False) for msgid, _refs in messages}
write_po(po_path(language), language, messages, translations)
missing = [m for m, _r in messages if not translations.get(m, ("", False))[0]]
print("po/{}.po : {} a traduire".format(language, len(missing)))
for msgid in missing:
print(" " + msgid.splitlines()[0])
def cmd_compile():
for language in LANGUAGES:
entries = read_po(po_path(language))
catalog = {msgid: msgstr for msgid, (msgstr, fuzzy) in entries.items()
if msgid and msgstr and not fuzzy}
catalog[""] = _header(language)
write_mo(mo_path(language), catalog)
print("{} -> {} ({} chaines)".format(
os.path.relpath(po_path(language), HERE),
os.path.relpath(mo_path(language), HERE), len(catalog) - 1))
def main(argv):
commands = {"extract": [cmd_extract], "update": [cmd_update],
"compile": [cmd_compile],
"all": [cmd_extract, cmd_update, cmd_compile]}
name = argv[0] if argv else "all"
if name not in commands:
print(__doc__)
return 1
for command in commands[name]:
command()
return 0
if __name__ == "__main__":
sys.exit(main(sys.argv[1:]))

BIN
images/parameters_en.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 261 KiB

Binary file not shown.

Binary file not shown.

116
po/en.po Normal file
View File

@ -0,0 +1,116 @@
# Traduction (en) de l'extension Inkscape « Relief Pattern Fill ».
# Genere par i18n.py ; ne modifier que les msgstr.
msgid ""
msgstr ""
"Project-Id-Version: relief_fill\n"
"Language: en\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
#: relief_fill.inx
msgid "Relief Pattern Fill"
msgstr "Relief Pattern Fill"
#: relief_fill.inx
msgid "Pattern"
msgstr "Pattern"
#: relief_fill.inx
msgid "Edge length:"
msgstr "Edge length:"
#: relief_fill.inx
msgid "Length of the unit edge of the pattern. One repeat of the pattern is 5 edges wide."
msgstr "Length of the unit edge of the pattern. One repeat of the pattern is 5 edges wide."
#: relief_fill.inx
msgid "Unit:"
msgstr "Unit:"
#: relief_fill.inx
msgid "Rotation (degrees, clockwise):"
msgstr "Rotation (degrees, clockwise):"
#: relief_fill.inx
msgid "Pattern origin:"
msgstr "Pattern origin:"
#: relief_fill.inx
msgid "Center of each shape (pattern starts in the middle)"
msgstr "Center of each shape (pattern starts in the middle)"
#: relief_fill.inx
msgid "Document origin (pattern continuous across shapes)"
msgstr "Document origin (pattern continuous across shapes)"
#: relief_fill.inx
msgid "Lines"
msgstr "Lines"
#: relief_fill.inx
msgid "Line color:"
msgstr "Line color:"
#: relief_fill.inx
msgid "Line width (in the unit of the Pattern tab):"
msgstr "Line width (in the unit of the Pattern tab):"
#: relief_fill.inx
msgid "Also draw the outline of the shape"
msgstr "Also draw the outline of the shape"
#: relief_fill.inx
msgid "Keep the original shape"
msgstr "Keep the original shape"
#: relief_fill.inx
msgid "Help"
msgstr "Help"
#: relief_fill.inx
msgid ""
"Select one or more shapes (paths, rectangles, circles, groups of them), then run the extension. Each shape is filled with a relief pattern of \"Y\" figures drawn on an isometric grid, and the lines are cut exactly at the edge of the shape. Holes (compound paths) stay empty.\n"
"\n"
"Edge length: size of the unit edge of the grid. The pattern repeats every 5 edges, so a larger value gives a coarser pattern. Rotation turns the whole pattern around the pattern origin.\n"
"\n"
"Pattern origin: \"Center of each shape\" puts a grid node at the center of every shape, so each shape shows a symmetrical pattern. \"Document origin\" anchors the grid at the top-left corner of the page, so neighbouring shapes show one continuous pattern.\n"
"\n"
"The result is a group placed just above the shape, holding one path per shape. Tip: with a very small edge length on a large shape the pattern has many lines; the extension refuses more than 250 000 segments."
msgstr ""
"Select one or more shapes (paths, rectangles, circles, groups of them), then run the extension. Each shape is filled with a relief pattern of \"Y\" figures drawn on an isometric grid, and the lines are cut exactly at the edge of the shape. Holes (compound paths) stay empty.\n"
"\n"
"Edge length: size of the unit edge of the grid. The pattern repeats every 5 edges, so a larger value gives a coarser pattern. Rotation turns the whole pattern around the pattern origin.\n"
"\n"
"Pattern origin: \"Center of each shape\" puts a grid node at the center of every shape, so each shape shows a symmetrical pattern. \"Document origin\" anchors the grid at the top-left corner of the page, so neighbouring shapes show one continuous pattern.\n"
"\n"
"The result is a group placed just above the shape, holding one path per shape. Tip: with a very small edge length on a large shape the pattern has many lines; the extension refuses more than 250 000 segments."
#: relief_fill.py
msgid "Select at least one shape."
msgstr "Select at least one shape."
#: relief_fill.py
msgid "The edge length must be strictly positive."
msgstr "The edge length must be strictly positive."
#: relief_fill.py
msgid "The stroke width cannot be negative."
msgstr "The stroke width cannot be negative."
#: relief_fill.py
msgid "The selection contains no shape with an outline (path, rectangle, circle...). Convert text to paths first."
msgstr "The selection contains no shape with an outline (path, rectangle, circle...). Convert text to paths first."
#: relief_fill.py
msgid "The pattern would have too many lines. Increase the edge length or select a smaller shape."
msgstr "The pattern would have too many lines. Increase the edge length or select a smaller shape."
#: relief_fill.py
msgid "The edge length is too large for this shape: no line fits inside. Reduce the edge length."
msgstr "The edge length is too large for this shape: no line fits inside. Reduce the edge length."
#: relief_fill.py
msgid "Relief fill"
msgstr "Relief fill"

116
po/fr.po Normal file
View File

@ -0,0 +1,116 @@
# Traduction (fr) de l'extension Inkscape « Relief Pattern Fill ».
# Genere par i18n.py ; ne modifier que les msgstr.
msgid ""
msgstr ""
"Project-Id-Version: relief_fill\n"
"Language: fr\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=2; plural=(n > 1);\n"
#: relief_fill.inx
msgid "Relief Pattern Fill"
msgstr "Remplissage motif relief"
#: relief_fill.inx
msgid "Pattern"
msgstr "Motif"
#: relief_fill.inx
msgid "Edge length:"
msgstr "Longueur d'arête :"
#: relief_fill.inx
msgid "Length of the unit edge of the pattern. One repeat of the pattern is 5 edges wide."
msgstr "Longueur de l'arête unitaire du motif. Une répétition du motif mesure 5 arêtes de large."
#: relief_fill.inx
msgid "Unit:"
msgstr "Unité :"
#: relief_fill.inx
msgid "Rotation (degrees, clockwise):"
msgstr "Rotation (degrés, sens horaire) :"
#: relief_fill.inx
msgid "Pattern origin:"
msgstr "Origine du motif :"
#: relief_fill.inx
msgid "Center of each shape (pattern starts in the middle)"
msgstr "Centre de chaque forme (le motif part du milieu)"
#: relief_fill.inx
msgid "Document origin (pattern continuous across shapes)"
msgstr "Origine du document (motif continu d'une forme à l'autre)"
#: relief_fill.inx
msgid "Lines"
msgstr "Traits"
#: relief_fill.inx
msgid "Line color:"
msgstr "Couleur des traits :"
#: relief_fill.inx
msgid "Line width (in the unit of the Pattern tab):"
msgstr "Épaisseur des traits (dans l'unité de l'onglet Motif) :"
#: relief_fill.inx
msgid "Also draw the outline of the shape"
msgstr "Dessiner aussi le contour de la forme"
#: relief_fill.inx
msgid "Keep the original shape"
msgstr "Conserver la forme d'origine"
#: relief_fill.inx
msgid "Help"
msgstr "Aide"
#: relief_fill.inx
msgid ""
"Select one or more shapes (paths, rectangles, circles, groups of them), then run the extension. Each shape is filled with a relief pattern of \"Y\" figures drawn on an isometric grid, and the lines are cut exactly at the edge of the shape. Holes (compound paths) stay empty.\n"
"\n"
"Edge length: size of the unit edge of the grid. The pattern repeats every 5 edges, so a larger value gives a coarser pattern. Rotation turns the whole pattern around the pattern origin.\n"
"\n"
"Pattern origin: \"Center of each shape\" puts a grid node at the center of every shape, so each shape shows a symmetrical pattern. \"Document origin\" anchors the grid at the top-left corner of the page, so neighbouring shapes show one continuous pattern.\n"
"\n"
"The result is a group placed just above the shape, holding one path per shape. Tip: with a very small edge length on a large shape the pattern has many lines; the extension refuses more than 250 000 segments."
msgstr ""
"Sélectionnez une ou plusieurs formes (chemins, rectangles, cercles, groupes de ces éléments), puis lancez l'extension. Chaque forme est remplie par un motif en relief de figures en « Y » tracées sur une grille isométrique, et les traits sont coupés exactement au bord de la forme. Les trous (chemins composés) restent vides.\n"
"\n"
"Longueur d'arête : taille de l'arête unitaire de la grille. Le motif se répète toutes les 5 arêtes : plus la valeur est grande, plus le motif est grossier. La rotation fait tourner tout le motif autour de l'origine du motif.\n"
"\n"
"Origine du motif : « Centre de chaque forme » place un nœud de la grille au centre de chaque forme, qui montre alors un motif symétrique. « Origine du document » ancre la grille au coin supérieur gauche de la page : les formes voisines montrent un seul motif continu.\n"
"\n"
"Le résultat est un groupe placé juste au-dessus de la forme, contenant un chemin par forme. Astuce : avec une arête très petite sur une grande forme, le motif compte beaucoup de traits ; l'extension refuse plus de 250 000 segments."
#: relief_fill.py
msgid "Select at least one shape."
msgstr "Sélectionnez au moins une forme."
#: relief_fill.py
msgid "The edge length must be strictly positive."
msgstr "La longueur d'arête doit être strictement positive."
#: relief_fill.py
msgid "The stroke width cannot be negative."
msgstr "L'épaisseur des traits ne peut pas être négative."
#: relief_fill.py
msgid "The selection contains no shape with an outline (path, rectangle, circle...). Convert text to paths first."
msgstr "La sélection ne contient aucune forme avec un contour (chemin, rectangle, cercle...). Convertissez d'abord le texte en chemins."
#: relief_fill.py
msgid "The pattern would have too many lines. Increase the edge length or select a smaller shape."
msgstr "Le motif aurait trop de traits. Augmentez la longueur d'arête ou sélectionnez une forme plus petite."
#: relief_fill.py
msgid "The edge length is too large for this shape: no line fits inside. Reduce the edge length."
msgstr "La longueur d'arête est trop grande pour cette forme : aucun trait n'y tient. Réduisez la longueur d'arête."
#: relief_fill.py
msgid "Relief fill"
msgstr "Remplissage relief"

107
po/relief_fill.pot Normal file
View File

@ -0,0 +1,107 @@
# Modele de traduction de l'extension Inkscape « Relief Pattern Fill ».
# Genere par i18n.py ; ne modifier que les msgstr.
msgid ""
msgstr ""
"Project-Id-Version: relief_fill\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
#: relief_fill.inx
msgid "Relief Pattern Fill"
msgstr ""
#: relief_fill.inx
msgid "Pattern"
msgstr ""
#: relief_fill.inx
msgid "Edge length:"
msgstr ""
#: relief_fill.inx
msgid "Length of the unit edge of the pattern. One repeat of the pattern is 5 edges wide."
msgstr ""
#: relief_fill.inx
msgid "Unit:"
msgstr ""
#: relief_fill.inx
msgid "Rotation (degrees, clockwise):"
msgstr ""
#: relief_fill.inx
msgid "Pattern origin:"
msgstr ""
#: relief_fill.inx
msgid "Center of each shape (pattern starts in the middle)"
msgstr ""
#: relief_fill.inx
msgid "Document origin (pattern continuous across shapes)"
msgstr ""
#: relief_fill.inx
msgid "Lines"
msgstr ""
#: relief_fill.inx
msgid "Line color:"
msgstr ""
#: relief_fill.inx
msgid "Line width (in the unit of the Pattern tab):"
msgstr ""
#: relief_fill.inx
msgid "Also draw the outline of the shape"
msgstr ""
#: relief_fill.inx
msgid "Keep the original shape"
msgstr ""
#: relief_fill.inx
msgid "Help"
msgstr ""
#: relief_fill.inx
msgid ""
"Select one or more shapes (paths, rectangles, circles, groups of them), then run the extension. Each shape is filled with a relief pattern of \"Y\" figures drawn on an isometric grid, and the lines are cut exactly at the edge of the shape. Holes (compound paths) stay empty.\n"
"\n"
"Edge length: size of the unit edge of the grid. The pattern repeats every 5 edges, so a larger value gives a coarser pattern. Rotation turns the whole pattern around the pattern origin.\n"
"\n"
"Pattern origin: \"Center of each shape\" puts a grid node at the center of every shape, so each shape shows a symmetrical pattern. \"Document origin\" anchors the grid at the top-left corner of the page, so neighbouring shapes show one continuous pattern.\n"
"\n"
"The result is a group placed just above the shape, holding one path per shape. Tip: with a very small edge length on a large shape the pattern has many lines; the extension refuses more than 250 000 segments."
msgstr ""
#: relief_fill.py
msgid "Select at least one shape."
msgstr ""
#: relief_fill.py
msgid "The edge length must be strictly positive."
msgstr ""
#: relief_fill.py
msgid "The stroke width cannot be negative."
msgstr ""
#: relief_fill.py
msgid "The selection contains no shape with an outline (path, rectangle, circle...). Convert text to paths first."
msgstr ""
#: relief_fill.py
msgid "The pattern would have too many lines. Increase the edge length or select a smaller shape."
msgstr ""
#: relief_fill.py
msgid "The edge length is too large for this shape: no line fits inside. Reduce the edge length."
msgstr ""
#: relief_fill.py
msgid "Relief fill"
msgstr ""

295
relief_core.py Normal file
View File

@ -0,0 +1,295 @@
# coding=utf-8
"""
Noyau de calcul de l'extension « Relief Pattern Fill », sans dependance a inkex.
Testable avec pytest seul et reutilisable hors Inkscape (scripts, schema
des parametres).
Le motif est un pavage isometrique (aretes verticales et a +/-30 degres) de
« Y » en relief, releve sur l'image du dossier doc/. Il vit sur un reseau de
noeuds triangulaire : le noeud (i, j) est en x = i * edge * cos(30), y = j *
edge / 2 (les noeuds valides ont i + j pair). Une arete unitaire va d'un noeud
a un voisin situe a une distance `edge` ; elle est notee (i, j, di, dj),
(di, dj) valant (0, 2), (1, 1) ou (1, -1).
Le motif se repete par translation de 5 aretes : vecteurs (0, 10) et (5, 5)
en coordonnees (i, j), soit une maille hexagonale de cote 5 * edge.
Conventions :
- un contour (`rings`) est une liste d'anneaux, chaque anneau etant une liste
de points (x, y) fermee implicitement ; les trous suivent la regle pair-impair ;
- un segment est un couple de points ((x1, y1), (x2, y2)) ;
- l'axe y est oriente vers le bas (repere SVG).
"""
import math
COS30 = math.cos(math.radians(30))
# Nombre maximal de segments avant de refuser le calcul (document fige sinon).
MAX_SEGMENTS = 250000
# Aretes unitaires de la maille de base (39 sur les 75 possibles).
CELL_EDGES = (
(0, 0, 1, 1), (0, 2, 0, 2), (0, 2, 1, 1), (0, 4, 1, 1),
(0, 6, 0, 2), (0, 6, 1, 1), (0, 8, 0, 2), (1, 1, 1, 1),
(1, 3, 0, 2), (1, 3, 1, -1), (1, 5, 1, -1), (1, 5, 1, 1),
(1, 7, 0, 2), (1, 7, 1, -1), (1, 9, 1, -1), (1, 9, 1, 1),
(2, 0, 0, 2), (2, 0, 1, -1), (2, 4, 0, 2), (2, 4, 1, -1),
(2, 4, 1, 1), (2, 6, 0, 2), (2, 8, 1, 1), (3, 1, 0, 2),
(3, 1, 1, -1), (3, 1, 1, 1), (3, 3, 1, 1), (3, 5, 0, 2),
(3, 5, 1, -1), (3, 7, 1, -1), (3, 7, 1, 1), (3, 9, 0, 2),
(3, 9, 1, 1), (4, 0, 1, -1), (4, 2, 0, 2), (4, 2, 1, -1),
(4, 6, 1, -1), (4, 8, 0, 2), (4, 8, 1, -1),
)
PERIOD_EDGES = 5 # periode du motif, en nombre d'aretes
EPS = 1e-9
def parse_color(value, default=("#b3b3b3", 1.0)):
"""Couleur Inkscape (entier RGBA decimal ou 0x..., ou #rrggbb[aa]).
Renvoie (couleur CSS #rrggbb, opacite entre 0 et 1). Le parametre
« color » d'Inkscape arrive sous forme d'entier RGBA ; on le decode
nous-memes pour ne pas dependre de l'API couleur d'inkex, qui a change
entre les versions 1.x.
"""
text = str(value).strip()
try:
if text.startswith("#"):
digits = text[1:]
if len(digits) == 3:
digits = "".join(c * 2 for c in digits)
if len(digits) == 6:
digits += "ff"
if len(digits) != 8:
return default
number = int(digits, 16)
else:
number = int(text, 0)
except ValueError:
return default
number &= 0xFFFFFFFF
red, green, blue = (number >> 24) & 255, (number >> 16) & 255, (number >> 8) & 255
alpha = (number & 255) / 255.0
return "#{:02x}{:02x}{:02x}".format(red, green, blue), round(alpha, 4)
def polylines_to_d(polylines, precision=4):
"""Donnees `d` d'un chemin SVG : une polyligne par sous-chemin."""
fmt = "{:.%df},{:.%df}" % (precision, precision)
parts = []
for polyline in polylines:
if len(polyline) < 2:
continue
parts.append("M " + fmt.format(*polyline[0]))
parts.extend("L " + fmt.format(*point) for point in polyline[1:])
return " ".join(parts)
# --------------------------------------------------------------------------
# Geometrie de base
# --------------------------------------------------------------------------
def rings_bbox(rings):
"""Boite englobante (xmin, ymin, xmax, ymax) d'un contour, None si vide."""
points = [p for ring in rings for p in ring]
if not points:
return None
xs = [p[0] for p in points]
ys = [p[1] for p in points]
return min(xs), min(ys), max(xs), max(ys)
def rotate_point(point, angle, center=(0.0, 0.0)):
"""Rotation de `angle` degres autour de `center` (sens horaire a l'ecran)."""
a = math.radians(angle)
c, s = math.cos(a), math.sin(a)
x, y = point[0] - center[0], point[1] - center[1]
return center[0] + c * x - s * y, center[1] + s * x + c * y
def point_in_rings(point, rings):
"""Appartenance au contour, regle pair-impair (trous compris)."""
x, y = point
inside = False
for ring in rings:
n = len(ring)
for k in range(n):
x1, y1 = ring[k]
x2, y2 = ring[(k + 1) % n]
if (y1 > y) != (y2 > y):
if x < x1 + (y - y1) * (x2 - x1) / (y2 - y1):
inside = not inside
return inside
# --------------------------------------------------------------------------
# Le motif
# --------------------------------------------------------------------------
def _node(i, j, edge):
return i * edge * COS30, j * edge / 2.0
def _merged_runs(origin_cells):
"""Aretes des mailles donnees, fusionnees en segments rectilignes maximaux.
`origin_cells` : iterable de decalages (di0, dj0) de mailles entieres.
Deux aretes bout a bout sur la meme droite deviennent un seul segment :
moins de sous-chemins dans le SVG, et pas de fente aux jonctions.
"""
runs = {} # (direction, droite) -> {debut: fin}
for di0, dj0 in origin_cells:
for i, j, di, dj in CELL_EDGES:
i, j = i + di0, j + dj0
if di == 0:
key, start, end = (0, i), j, j + 2
elif dj == 1:
key, start, end = (1, j - i), i, i + 1
else:
key, start, end = (2, j + i), i, i + 1
runs.setdefault(key, {})[start] = end
for (direction, line), spans in runs.items():
begin = None
previous_end = None
for start in sorted(spans):
if begin is not None and start == previous_end:
previous_end = spans[start]
continue
if begin is not None:
yield direction, line, begin, previous_end
begin, previous_end = start, spans[start]
if begin is not None:
yield direction, line, begin, previous_end
def pattern_segments(bbox, edge):
"""Segments du motif couvrant `bbox` (xmin, ymin, xmax, ymax), cote d'arete `edge`.
Le noeud (0, 0) du reseau est en (0, 0). Les segments debordent un peu de
la boite (on garde ceux qui la touchent) : c'est a l'appelant de decouper.
"""
if edge <= 0:
raise ValueError("edge must be positive")
xmin, ymin, xmax, ymax = bbox
hx, hy = edge * COS30, edge / 2.0
i_lo, i_hi = int(math.floor(xmin / hx)) - 1, int(math.ceil(xmax / hx)) + 1
j_lo, j_hi = int(math.floor(ymin / hy)) - 1, int(math.ceil(ymax / hy)) + 1
# Estimation avant de construire quoi que ce soit.
cells = ((i_hi - i_lo) / PERIOD_EDGES + 3) * ((j_hi - j_lo) / (2.0 * PERIOD_EDGES) + 3)
if cells * len(CELL_EDGES) > MAX_SEGMENTS * 1.5:
raise OverflowError("too many segments")
# Mailles (m, n) : decalage (5 m, 5 m + 10 n) en (i, j). On garde celles
# dont le carre englobe croise la boite.
span = PERIOD_EDGES
origins = []
m_lo, m_hi = i_lo // span - 1, i_hi // span + 1
for m in range(m_lo, m_hi + 1):
n_lo = (j_lo - span * m) // (2 * span) - 1
n_hi = (j_hi - span * m) // (2 * span) + 1
for n in range(n_lo, n_hi + 1):
origins.append((span * m, span * m + 2 * span * n))
segments = []
for direction, line, begin, end in _merged_runs(origins):
if direction == 0:
p, q = _node(line, begin, edge), _node(line, end, edge)
elif direction == 1:
p, q = _node(begin, line + begin, edge), _node(end, line + end, edge)
else:
p, q = _node(begin, line - begin, edge), _node(end, line - end, edge)
if (max(p[0], q[0]) < xmin or min(p[0], q[0]) > xmax
or max(p[1], q[1]) < ymin or min(p[1], q[1]) > ymax):
continue
segments.append((p, q))
if len(segments) > MAX_SEGMENTS:
raise OverflowError("too many segments")
return segments
# --------------------------------------------------------------------------
# Decoupe
# --------------------------------------------------------------------------
def clip_segment(p, q, rings):
"""Parties du segment p-q situees a l'interieur du contour (pair-impair).
On coupe le segment a chaque croisement d'un cote du contour, puis on
garde les morceaux dont le milieu est a l'interieur.
"""
px, py = p
dx, dy = q[0] - px, q[1] - py
sx0, sx1 = min(px, q[0]), max(px, q[0])
sy0, sy1 = min(py, q[1]), max(py, q[1])
cuts = [0.0, 1.0]
for ring in rings:
n = len(ring)
for k in range(n):
ax, ay = ring[k]
bx, by = ring[(k + 1) % n]
if (max(ax, bx) < sx0 or min(ax, bx) > sx1
or max(ay, by) < sy0 or min(ay, by) > sy1):
continue
ex, ey = bx - ax, by - ay
denom = dx * ey - dy * ex
if abs(denom) < EPS:
continue # paralleles : pas de croisement franc
t = ((ax - px) * ey - (ay - py) * ex) / denom
u = ((ax - px) * dy - (ay - py) * dx) / denom
if 0.0 < t < 1.0 and -EPS <= u <= 1.0 + EPS:
cuts.append(t)
cuts.sort()
pieces = []
for t0, t1 in zip(cuts, cuts[1:]):
if t1 - t0 < 1e-7:
continue
mid = (px + dx * (t0 + t1) / 2.0, py + dy * (t0 + t1) / 2.0)
if not point_in_rings(mid, rings):
continue
a = (px + dx * t0, py + dy * t0)
b = (px + dx * t1, py + dy * t1)
if pieces and pieces[-1][1] == a:
pieces[-1] = (pieces[-1][0], b) # morceaux contigus : un seul
else:
pieces.append((a, b))
return pieces
def fill_rings(rings, edge, angle=0.0, anchor=(0.0, 0.0)):
"""Remplit l'interieur du contour avec le motif ; renvoie des segments.
`edge` : longueur de l'arete unitaire du motif ;
`angle` : rotation du motif en degres (sens horaire a l'ecran) ;
`anchor` : point du plan ou tombe un noeud du reseau (centre de rotation).
Leve OverflowError si le motif compterait trop de segments (arete trop
petite pour la forme).
"""
rings = [r for r in rings if len(r) >= 3]
box = rings_bbox(rings)
if box is None:
return []
# On travaille dans le repere du motif (tourne puis translate) : le motif
# est alors aligne sur les axes, et le contour est ramene dans ce repere.
local = [[rotate_point((x - anchor[0], y - anchor[1]), -angle) for x, y in ring]
for ring in rings]
local_box = rings_bbox(local)
out = []
for p, q in pattern_segments(local_box, edge):
for a, b in clip_segment(p, q, local):
out.append((_to_world(a, angle, anchor), _to_world(b, angle, anchor)))
return out
def _to_world(point, angle, anchor):
x, y = rotate_point(point, angle)
return x + anchor[0], y + anchor[1]
def segments_to_polylines(segments):
"""Segments -> polylines a deux points (pour polylines_to_d)."""
return [[p, q] for p, q in segments]

73
relief_fill.inx Normal file
View File

@ -0,0 +1,73 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
Textes source en anglais, traduits par le catalogue « relief_fill »
(locale/<langue>/LC_MESSAGES/relief_fill.mo). Après toute modification
d'un texte : python i18n.py, puis compléter po/fr.po et relancer.
-->
<inkscape-extension translationdomain="relief_fill"
xmlns="http://www.inkscape.org/namespace/inkscape/extension">
<name>Relief Pattern Fill</name>
<id>fr.alexp.relief_fill</id>
<param name="tab" type="notebook">
<page name="pattern" gui-text="Pattern">
<param name="edge" type="float" precision="3" min="0.1" max="1000"
gui-text="Edge length:"
gui-description="Length of the unit edge of the pattern. One repeat of the pattern is 5 edges wide.">5.0</param>
<param name="unit" type="optiongroup" appearance="combo" gui-text="Unit:">
<!-- Les unités ne se traduisent pas -->
<option translatable="no" value="mm">mm</option>
<option translatable="no" value="cm">cm</option>
<option translatable="no" value="px">px</option>
<option translatable="no" value="pt">pt</option>
<option translatable="no" value="in">in</option>
</param>
<param name="angle" type="float" precision="1" min="-360" max="360"
gui-text="Rotation (degrees, clockwise):">0.0</param>
<param name="origin" type="optiongroup" appearance="combo"
gui-text="Pattern origin:">
<option value="shape">Center of each shape (pattern starts in the middle)</option>
<option value="document">Document origin (pattern continuous across shapes)</option>
</param>
</page>
<page name="style" gui-text="Lines">
<param name="stroke_color" type="color" appearance="colorbutton"
gui-text="Line color:">255</param>
<param name="stroke_width" type="float" precision="3" min="0" max="100"
gui-text="Line width (in the unit of the Pattern tab):">0.3</param>
<param name="draw_outline" type="bool"
gui-text="Also draw the outline of the shape">false</param>
<param name="keep_original" type="bool"
gui-text="Keep the original shape">true</param>
</page>
<page name="help" gui-text="Help">
<!-- Schéma généré par docs/schema_parametres.py, en anglais :
Inkscape ne traduit pas le chemin d'une image. Affiché aux
3/4 du PNG (900 px) pour limiter la hauteur de l'onglet. -->
<image width="675" height="617">images/parameters_en.png</image>
<separator/>
<!-- Un paragraphe par ligne : Inkscape le replie à la largeur de
l'onglet ; des retours à la ligne forcés le laisseraient étroit. -->
<label xml:space="preserve">Select one or more shapes (paths, rectangles, circles, groups of them), then run the extension. Each shape is filled with a relief pattern of "Y" figures drawn on an isometric grid, and the lines are cut exactly at the edge of the shape. Holes (compound paths) stay empty.
Edge length: size of the unit edge of the grid. The pattern repeats every 5 edges, so a larger value gives a coarser pattern. Rotation turns the whole pattern around the pattern origin.
Pattern origin: "Center of each shape" puts a grid node at the center of every shape, so each shape shows a symmetrical pattern. "Document origin" anchors the grid at the top-left corner of the page, so neighbouring shapes show one continuous pattern.
The result is a group placed just above the shape, holding one path per shape. Tip: with a very small edge length on a large shape the pattern has many lines; the extension refuses more than 250 000 segments.</label>
</page>
</param>
<effect>
<object-type>all</object-type>
<effects-menu>
<submenu name="AlexDesign"/>
</effects-menu>
</effect>
<script>
<command location="inx" interpreter="python">relief_fill.py</command>
</script>
</inkscape-extension>

220
relief_fill.py Normal file
View File

@ -0,0 +1,220 @@
#!/usr/bin/env python3
# coding=utf-8
"""
Extension Inkscape : remplit l'interieur des formes selectionnees avec un
motif isometrique de « Y » en relief.
Chaque forme (chemin, rectangle, cercle... ; les groupes sont parcourus) est
aplatie en polygone, puis le motif, calcule par `relief_core`, est decoupe a
l'interieur de ce polygone (trous compris). Le resultat est un groupe place
juste au-dessus de la forme, avec un seul chemin par forme.
La logique vit dans `relief_core`, sans dependance a inkex.
"""
import os
import sys
import inkex
from inkex.bezier import cspsubdiv
from inkex.localization import localize
from inkex.units import convert_unit
# L'extension vit dans son propre sous-dossier des extensions Inkscape, qui
# n'est pas forcement dans sys.path : on l'y ajoute pour trouver relief_core.
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from relief_core import (fill_rings, parse_color, polylines_to_d, # noqa: E402
rings_bbox, segments_to_polylines)
def use_system_language():
"""Renseigne LANGUAGE avec la langue du systeme si rien ne la fixe.
Sous Windows, Inkscape regle sur « langue du systeme » ne transmet aucune
variable de langue au script : gettext retomberait sur l'anglais alors que
la boite de dialogue, traduite par Inkscape lui-meme, est dans la langue
de l'utilisateur.
"""
if any(os.environ.get(name) for name in ("LANGUAGE", "LC_ALL", "LC_MESSAGES", "LANG")):
return
try:
import ctypes
import locale
language = locale.windows_locale.get(
ctypes.windll.kernel32.GetUserDefaultUILanguage())
except Exception: # pylint: disable=broad-except
return # hors Windows : LANG est toujours defini
if language:
os.environ["LANGUAGE"] = language
# Installe _() dans les builtins : catalogue « relief_fill » de la langue
# de l'interface (Inkscape transmet domaine et dossier par l'environnement),
# textes anglais d'origine sinon.
use_system_language()
localize()
def uu_per_mm(svg):
"""Unites utilisateur par millimetre, comme Inkscape les affiche.
svg.unittouu() devine l'unite du document par arrondi a l'unite connue la
plus proche : il se trompe des que l'echelle n'est pas standard (page
redimensionnee). On prend le rapport reel page / viewBox ; Inkscape
applique une echelle uniforme (preserveAspectRatio « meet »), soit la
plus petite des deux.
"""
try:
vb_w, vb_h = svg.viewbox_width, svg.viewbox_height
page_w = svg.to_dimensional(svg.viewport_width, "mm")
page_h = svg.to_dimensional(svg.viewport_height, "mm")
except AttributeError: # inkex anterieur a 1.2
return svg.unittouu("1mm")
if not (vb_w and vb_h and page_w and page_h):
return svg.unittouu("1mm")
return 1 / min(page_w / vb_w, page_h / vb_h)
def length_to_uu(svg, value, unit="mm"):
"""Longueur reelle (valeur + unite) -> unites utilisateur du document."""
return convert_unit("{}{}".format(value, unit), "mm") * uu_per_mm(svg)
# Formes dont on sait lire le contour ; les groupes sont parcourus.
SHAPE_TYPES = tuple(
getattr(inkex, name) for name in
("PathElement", "Rectangle", "Circle", "Ellipse", "Polygon", "Polyline", "Line")
if hasattr(inkex, name))
def shape_rings(elem, flatness):
"""Contour de `elem` en coordonnees absolues : liste d'anneaux de points.
Les courbes sont aplaties a la tolerance `flatness` (unites utilisateur).
Un sous-chemin ouvert est traite comme ferme, comme le fait un remplissage.
"""
path = elem.path.transform(elem.composed_transform())
csp = path.to_superpath()
cspsubdiv(csp, flatness)
rings = []
for sub in csp:
ring = [(node[1][0], node[1][1]) for node in sub]
if len(ring) > 1 and ring[0] == ring[-1]:
ring.pop()
if len(ring) >= 3:
rings.append(ring)
return rings
class ReliefFill(inkex.EffectExtension):
"""Remplit chaque forme selectionnee avec le motif de Y en relief."""
def add_arguments(self, pars):
# Un argument par <param> du .inx, memes noms et memes valeurs par defaut.
pars.add_argument("--tab", default="pattern")
pars.add_argument("--edge", type=float, default=5.0)
pars.add_argument("--unit", default="mm")
pars.add_argument("--angle", type=float, default=0.0)
pars.add_argument("--origin", default="shape")
pars.add_argument("--stroke_color", default="255")
pars.add_argument("--stroke_width", type=float, default=0.3)
pars.add_argument("--draw_outline", type=inkex.Boolean, default=False)
pars.add_argument("--keep_original", type=inkex.Boolean, default=True)
def effect(self):
opt = self.options
elements = list(self.svg.selection.values())
if not elements:
inkex.errormsg(_("Select at least one shape."))
return
edge = length_to_uu(self.svg, opt.edge, opt.unit)
if edge <= 0:
inkex.errormsg(_("The edge length must be strictly positive."))
return
width = length_to_uu(self.svg, opt.stroke_width, opt.unit)
if width < 0:
inkex.errormsg(_("The stroke width cannot be negative."))
return
color, opacity = parse_color(opt.stroke_color, default=("#000000", 1.0))
style = inkex.Style({
"fill": "none",
"stroke": color,
"stroke-opacity": str(opacity),
"stroke-width": str(width),
"stroke-linecap": "round",
"stroke-linejoin": "round",
})
flatness = length_to_uu(self.svg, 0.02) # 0.02 mm : invisible a l'impression
for elem in elements:
shapes = [e for e in elem.descendants() if isinstance(e, SHAPE_TYPES)]
if not shapes:
inkex.errormsg(_("The selection contains no shape with an outline "
"(path, rectangle, circle...). Convert text to "
"paths first."))
continue
if not self.process(elem, shapes, edge, style, flatness):
return
def process(self, elem, shapes, edge, style, flatness):
"""Remplit les `shapes` de `elem` ; renvoie False en cas d'erreur fatale."""
opt = self.options
results = [] # (anneaux, segments) par forme
for shape in shapes:
rings = shape_rings(shape, flatness)
if not rings:
continue
if opt.origin == "document":
anchor = (0.0, 0.0)
else:
xmin, ymin, xmax, ymax = rings_bbox(rings)
anchor = ((xmin + xmax) / 2.0, (ymin + ymax) / 2.0)
try:
segments = fill_rings(rings, edge, opt.angle, anchor)
except OverflowError:
inkex.errormsg(_("The pattern would have too many lines. "
"Increase the edge length or select a smaller shape."))
return False
results.append((rings, segments))
if not any(segments for _rings, segments in results):
inkex.errormsg(_("The edge length is too large for this shape: no line "
"fits inside. Reduce the edge length."))
return True
parent = elem.getparent()
group = inkex.Group()
group.label = _("Relief fill")
if parent is not None:
# Resultat calcule en coordonnees absolues du document : on
# neutralise la transformation heritee du parent d'accueil.
parent_transform = parent.composed_transform()
if parent_transform != inkex.Transform():
group.transform = -parent_transform
parent.insert(parent.index(elem) + 1, group)
else:
self.svg.add(group)
for rings, segments in results:
if opt.draw_outline:
outline = inkex.PathElement()
outline.path = polylines_to_d([ring + [ring[0]] for ring in rings])
outline.style = style
group.add(outline)
if segments:
node = inkex.PathElement()
node.path = polylines_to_d(segments_to_polylines(segments), precision=3)
node.style = style
group.add(node)
if not opt.keep_original:
elem.delete()
return True
if __name__ == "__main__":
ReliefFill().run()

320
test_relief_fill.py Normal file
View File

@ -0,0 +1,320 @@
# coding=utf-8
"""Tests de l'extension « Relief Pattern Fill » (pytest)."""
import os
import re
import xml.etree.ElementTree as ET
import pytest
import math
from relief_core import (CELL_EDGES, clip_segment, fill_rings, parse_color,
pattern_segments, point_in_rings, polylines_to_d,
rotate_point)
HERE = os.path.dirname(os.path.abspath(__file__))
SHAPES = os.path.join(HERE, "tests", "data", "shapes.svg")
SCALED = os.path.join(HERE, "tests", "data", "scaled.svg")
# --------------------------------------------------------------------------
# Noyau (sans inkex)
# --------------------------------------------------------------------------
def test_parse_color_inkscape_integer():
assert parse_color("3014898687") == ("#b3b3b3", 1.0)
assert parse_color(str(0xFF000080)) == ("#ff0000", round(0x80 / 255, 4))
def test_parse_color_hex_and_invalid():
assert parse_color("#123456") == ("#123456", 1.0)
assert parse_color("#abc") == ("#aabbcc", 1.0)
assert parse_color("pas une couleur") == ("#b3b3b3", 1.0)
def test_polylines_to_d():
d = polylines_to_d([[(0, 0), (1, 2)], [(3, 4), (5, 6)]], precision=1)
assert d == "M 0.0,0.0 L 1.0,2.0 M 3.0,4.0 L 5.0,6.0"
SQUARE = [(0, 0), (100, 0), (100, 100), (0, 100)]
HOLED = [SQUARE, [(25, 25), (75, 25), (75, 75), (25, 75)]]
def length(segment):
(x1, y1), (x2, y2) = segment
return math.hypot(x2 - x1, y2 - y1)
def key(segment):
(x1, y1), (x2, y2) = segment
return round(x1, 3), round(y1, 3), round(x2, 3), round(y2, 3)
def test_cell_edges_are_valid_lattice_edges():
assert len(set(CELL_EDGES)) == len(CELL_EDGES) == 39
for i, j, di, dj in CELL_EDGES:
assert (i + j) % 2 == 0
assert (di, dj) in ((0, 2), (1, 1), (1, -1))
def test_pattern_directions_and_periodicity():
"""Segments verticaux ou a +/-30 degres ; motif invariant par translation."""
edge = 10.0
segments = pattern_segments((0, 0, 200, 200), edge)
for (x1, y1), (x2, y2) in segments:
angle = round(math.degrees(math.atan2(y2 - y1, x2 - x1))) % 180
assert angle in (30, 90, 150)
keys = {key(s) for s in segments}
inner = [k for k in keys if all(40 < k[n] < 160 for n in (0, 2))
and all(40 < k[n] < 150 for n in (1, 3))]
assert inner
for x1, y1, x2, y2 in inner:
assert (x1, round(y1 + 5 * edge, 3), x2, round(y2 + 5 * edge, 3)) in keys
def test_pattern_merges_collinear_edges():
"""Deux aretes bout a bout sur la meme droite forment un seul segment."""
edge = 10.0
segments = pattern_segments((0, 0, 200, 200), edge)
assert max(length(s) for s in segments) > 1.5 * edge
at_node = {}
for p, q in segments:
for a, b in ((p, q), (q, p)):
at_node.setdefault((round(a[0], 3), round(a[1], 3)), []).append(b)
for point, others in at_node.items():
for k, a in enumerate(others):
for b in others[k + 1:]:
cross = ((a[0] - point[0]) * (b[1] - point[1])
- (a[1] - point[1]) * (b[0] - point[0]))
dot = ((a[0] - point[0]) * (b[0] - point[0])
+ (a[1] - point[1]) * (b[1] - point[1]))
assert not (abs(cross) < 1e-6 and dot < 0)
def test_pattern_rejects_bad_edge():
with pytest.raises(ValueError):
pattern_segments((0, 0, 10, 10), 0)
def test_pattern_too_many_segments():
with pytest.raises(OverflowError):
pattern_segments((0, 0, 1e6, 1e6), 1.0)
def test_point_in_rings_with_hole():
assert point_in_rings((10, 10), HOLED)
assert not point_in_rings((50, 50), HOLED)
assert not point_in_rings((150, 50), HOLED)
def test_clip_segment_square():
pieces = clip_segment((-50, 50), (150, 50), [SQUARE])
assert len(pieces) == 1
(x1, y1), (x2, y2) = pieces[0]
assert (round(x1, 6), round(x2, 6), y1, y2) == (0, 100, 50, 50)
assert clip_segment((200, 0), (300, 100), [SQUARE]) == []
def test_clip_segment_hole_splits_segment():
pieces = clip_segment((-10, 50), (110, 50), HOLED)
xs = sorted(round(p[0], 6) for piece in pieces for p in piece)
assert xs == [0, 25, 75, 100]
@pytest.mark.parametrize("angle", [0, 17, 90])
def test_fill_stays_inside_shape(angle):
triangle = [(0, 0), (120, 10), (40, 90)]
segments = fill_rings([triangle], 6.0, angle, (50, 30))
assert len(segments) > 50
for a, b in segments:
mid = ((a[0] + b[0]) / 2, (a[1] + b[1]) / 2)
assert point_in_rings(mid, [triangle])
def test_fill_avoids_hole():
segments = fill_rings(HOLED, 4.0)
assert segments
for a, b in segments:
mid = ((a[0] + b[0]) / 2, (a[1] + b[1]) / 2)
assert not (25 < mid[0] < 75 and 25 < mid[1] < 75)
def test_fill_rotation_turns_directions():
segments = fill_rings([SQUARE], 6.0, 90.0, (50, 50))
angles = {round(math.degrees(math.atan2(b[1] - a[1], b[0] - a[0]))) % 180
for a, b in segments}
assert angles == {0, 60, 120}
def test_fill_anchor_is_a_node():
"""Meme motif, relativement a l'ancre, quand la forme est translatee."""
base = {key(s) for s in fill_rings([SQUARE], 6.0, 0.0, (50, 50))}
shifted = [[(x + 7, y + 3) for x, y in SQUARE]]
moved = {key(((a[0] - 7, a[1] - 3), (b[0] - 7, b[1] - 3)))
for a, b in fill_rings(shifted, 6.0, 0.0, (57, 53))}
assert base == moved
def test_fill_degenerate_inputs():
assert fill_rings([], 5.0) == []
assert fill_rings([[(0, 0), (1, 1)]], 5.0) == []
fill_rings([SQUARE], 500.0) # arete enorme : pas d'exception
def test_rotate_point_clockwise_on_screen():
x, y = rotate_point((1, 0), 90)
assert (round(x, 6), round(y, 6)) == (0, 1)
# --------------------------------------------------------------------------
# Bout en bout (necessite inkex)
# --------------------------------------------------------------------------
def run_extension(tmp_path, *args):
pytest.importorskip("inkex")
from relief_fill import ReliefFill
out = tmp_path / "out.svg"
ReliefFill().run([*args, "--output={}".format(out), SHAPES])
return out.read_text(encoding="utf-8")
def test_end_to_end_default(tmp_path):
svg = run_extension(tmp_path, "--id=rect1")
assert "Relief fill" in svg
assert 'id="rect1"' in svg
assert len(list(generated_paths(svg))) == 1 # un seul chemin pour un rectangle
def test_end_to_end_non_standard_document_scale(tmp_path):
"""Page de 400 mm pour 538,72 unites : svg.unittouu() s'y tromperait."""
pytest.importorskip("inkex")
from relief_fill import ReliefFill
out = tmp_path / "out.svg"
ReliefFill().run(["--id=rect1", "--output={}".format(out), SCALED])
width = re.search(r"stroke-width:([\d.]+)", out.read_text(encoding="utf-8"))
assert float(width.group(1)) == pytest.approx(0.3 * 538.72053 / 400, abs=0.001)
def test_end_to_end_removes_original(tmp_path):
svg = run_extension(tmp_path, "--id=rect1", "--keep_original=false")
assert 'id="rect1"' not in svg
def test_end_to_end_color(tmp_path):
svg = run_extension(tmp_path, "--id=rect1", "--stroke_color={}".format(0x336699FF))
assert re.search(r"stroke:#336699", svg)
def generated_paths(svg_text):
"""Points (x, y) de chaque chemin du groupe « Relief fill »."""
root = ET.fromstring(svg_text)
for group in root.iter("{http://www.w3.org/2000/svg}g"):
if "Relief fill" in group.attrib.values():
for path in group.iter("{http://www.w3.org/2000/svg}path"):
nums = [float(n) for n in re.findall(r"-?\d+\.?\d*", path.get("d"))]
yield [(nums[k], nums[k + 1]) for k in range(0, len(nums) - 1, 2)]
@pytest.mark.parametrize("shape,box", [
("rect1", (10, 10, 60, 50)),
("circle1", (78, 8, 122, 52)),
("path1", (120, 0, 200, 60)),
])
def test_end_to_end_stays_in_bounding_box(tmp_path, shape, box):
svg = run_extension(tmp_path, "--id=" + shape, "--edge=3")
points = [p for pts in generated_paths(svg) for p in pts]
assert points
for x, y in points:
assert box[0] - 0.01 <= x <= box[2] + 0.01
assert box[1] - 0.01 <= y <= box[3] + 0.01
def test_end_to_end_hole_stays_empty(tmp_path):
svg = run_extension(tmp_path, "--id=path2", "--edge=3")
count = 0
for pts in generated_paths(svg):
for k in range(0, len(pts) - 1, 2):
count += 1
mid = ((pts[k][0] + pts[k + 1][0]) / 2, (pts[k][1] + pts[k + 1][1]) / 2)
assert not (36 < mid[0] < 64 and 81 < mid[1] < 99)
assert count > 20
def test_end_to_end_group_with_transform(tmp_path):
"""Groupe transforme : le motif suit l'ellipse, decalee de (100, 65)."""
svg = run_extension(tmp_path, "--id=group1", "--edge=3")
points = [p for pts in generated_paths(svg) for p in pts]
assert points
for x, y in points:
assert 105 - 0.01 <= x <= 185 + 0.01
assert 70 - 0.01 <= y <= 110 + 0.01
def test_end_to_end_outline_option(tmp_path):
svg = run_extension(tmp_path, "--id=rect1", "--draw_outline=true")
assert len(list(generated_paths(svg))) == 2 # contour + trait
def test_end_to_end_too_many_segments(tmp_path, capsys):
pytest.importorskip("inkex")
from relief_fill import ReliefFill
ReliefFill().run(["--id=rect1", "--edge=0.01",
"--output={}".format(tmp_path / "out.svg"), SHAPES])
assert "too many lines" in capsys.readouterr().err
# --------------------------------------------------------------------------
# Traductions
# --------------------------------------------------------------------------
def test_translations_up_to_date_and_complete():
"""Chaque texte du .inx et des .py a sa traduction dans chaque catalogue."""
import gettext
import i18n
msgids = [msgid for msgid, _refs in i18n.extract()]
assert "Relief Pattern Fill" in msgids
assert "mm" not in msgids # unites marquees translatable="no"
for language in i18n.LANGUAGES:
entries = i18n.read_po(i18n.po_path(language))
missing = [m for m in msgids if not entries.get(m, ("", False))[0]]
assert not missing, "{}.po incomplet : {}".format(language, missing)
catalog = gettext.translation(i18n.DOMAIN, i18n.LOCALE_DIR, [language])
for msgid in msgids:
assert catalog.gettext(msgid) == entries[msgid][0], \
"{} : .mo a recompiler (python i18n.py)".format(language)
def test_po_roundtrip(tmp_path):
import i18n
messages = [("Simple", ["a"]), ('Quote "x" and \\ back', ["a"]),
("Two\nlines", ["a"]), ("Tab\tend\n", ["a"])]
path = str(tmp_path / "xx.po")
i18n.write_po(path, "fr", messages,
{m: ("<" + m + ">", False) for m, _r in messages})
entries = i18n.read_po(path)
for msgid, _refs in messages:
assert entries[msgid] == ("<" + msgid + ">", False)
def test_inx_matches_arguments():
"""Chaque <param> du .inx a son add_argument, et inversement."""
root = ET.parse(os.path.join(HERE, "relief_fill.inx")).getroot()
params = {elem.get("name") for elem in root.iter()
if elem.tag.rsplit("}", 1)[-1] == "param"}
with open(os.path.join(HERE, "relief_fill.py"), encoding="utf-8") as handle:
arguments = set(re.findall(r'add_argument\("--(\w+)"', handle.read()))
assert params == arguments
def test_inx_images_exist():
"""Les images de la boite de dialogue sont presentes (chemin relatif au .inx)."""
root = ET.parse(os.path.join(HERE, "relief_fill.inx")).getroot()
for elem in root.iter():
if elem.tag.rsplit("}", 1)[-1] == "image":
assert os.path.isfile(os.path.join(HERE, elem.text.strip())), elem.text

6
tests/data/scaled.svg Normal file
View File

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<!-- Echelle non standard (page redimensionnee) : 1 mm = 1,3468 unite utilisateur -->
<svg xmlns="http://www.w3.org/2000/svg" width="400mm" height="400mm"
viewBox="0 0 538.72053 538.72054">
<rect id="rect1" x="100" y="100" width="200" height="100" fill="#e9ecef" stroke="#212529"/>
</svg>

After

Width:  |  Height:  |  Size: 334 B

13
tests/data/shapes.svg Normal file
View File

@ -0,0 +1,13 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" width="200mm" height="120mm" viewBox="0 0 200 120">
<rect id="rect1" x="10" y="10" width="50" height="40" fill="#e9ecef" stroke="#212529"/>
<circle id="circle1" cx="100" cy="30" r="22" fill="#e9ecef" stroke="#212529"/>
<path id="path1" d="M 140,10 C 190,0 200,60 160,55 C 130,52 120,30 140,10 Z"
fill="#e9ecef" stroke="#212529"/>
<!-- Forme trouee : le sous-chemin interne est un trou (pair-impair) -->
<path id="path2" d="M 10,65 H 90 V 115 H 10 Z M 35,80 H 65 V 100 H 35 Z"
fill="#e9ecef" fill-rule="evenodd" stroke="#212529"/>
<g id="group1" transform="translate(100,65)">
<ellipse id="ellipse1" cx="45" cy="25" rx="40" ry="20" fill="#e9ecef" stroke="#212529"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 790 B