Version initiale

This commit is contained in:
Alex 2026-10-01 16:08:38 +02:00
commit f8152a4ece
19 changed files with 2722 additions and 0 deletions

15
.gitignore vendored Normal file
View File

@ -0,0 +1,15 @@
# Cache Python et outils
__pycache__/
*.pyc
.pytest_cache/
.venv/
# Archives de distribution
*.zip
# SVG intermediaires du schema (regeneres par docs/schema_parametres.py)
docs/*.svg
# Page de presentation : hors depot (publiee sur le NAS par site/deploy.ps1 ;
# sa configuration contient hote, compte et chemin de cle SSH)
site/

220
README.md Normal file
View File

@ -0,0 +1,220 @@
# Remplissage motif rubans ondulés — extension Inkscape
<table>
<tr>
<td width="50%" valign="top">
<img src="docs/exemple.png" alt="Cinq formes (rectangle, cercle, chemin courbe, rectangle troué, ellipse) remplies du motif de rubans ondulés" width="420">
</td>
<td valign="top">
Extension Inkscape 1.x qui remplit les formes sélectionnées d'un motif de
tapisserie : un fond de traits verticaux traversé par des rubans en S, rangés en
quinconce et penchant tour à tour à droite et à gauche, ce qui donne un effet de
tressage. Le motif reproduit le modèle [`doc/patern.jpg`](doc/patern.jpg) ; il est
découpé le long du contour de chaque forme, trous compris, et livré sous la forme
d'un seul chemin sans remplissage, prêt pour la gravure, le traçage ou la découpe.
</td>
</tr>
</table>
## 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
- Remplit rectangles, cercles, ellipses, polygones, chemins et groupes (toutes les
formes d'un groupe sont remplies ensemble) ; les textes, images et clones sont
ignorés (convertir un texte avec **Chemin > Objet en chemin**).
- Respecte les trous d'un chemin (règle pair-impair) et les transformations des
formes et de leurs groupes parents.
- Motif entièrement réglable : pas des traits, nombre de traits par ruban,
décalage, hauteur et rayon de l'onde, quinconce, rotation.
- Origine du motif au centre de chaque forme, ou à l'origine du document pour un
motif continu d'une forme à l'autre.
- SVG propre : un groupe par forme, contenant un seul chemin ; les traits qui se
suivent sont soudés en tracés continus.
- Rapide : une page A3 au pas de 1 mm (environ 110 000 points) se calcule en
3 secondes environ.
- Interface traduite en français et en anglais, selon la langue d'Inkscape
(anglais pour toute autre langue).
- Script de déploiement et de désinstallation pour Windows.
## 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 dans %APPDATA%\inkscape\extensions\wavyRibbonFill
.\deploy.ps1 -Uninstall # désinstalle
.\deploy.ps1 -Force # installe même si Inkscape est ouvert
```
### Installation manuelle
Copier dans un sous-dossier du dossier des extensions utilisateur d'Inkscape :
- `wavy_ribbon_fill.inx`, `wavy_ribbon_fill.py`, `wavy_ribbon_fill_core.py` ;
- le dossier `images/` (schéma de l'onglet « Aide ») ;
- le dossier `locale/` (traductions compilées).
| Système | Dossier des extensions |
| ------- | --------------------------------------------------- |
| Windows | `%APPDATA%\inkscape\extensions` |
| Linux | `~/.config/inkscape/extensions` |
| macOS | `~/Library/Application Support/org.inkscape.Inkscape/config/inkscape/extensions` |
Redémarrer Inkscape : les extensions ne sont lues qu'au démarrage.
## Utilisation
1. Sélectionner une ou plusieurs formes fermées.
2. Ouvrir **Extensions > AlexDesign > Remplissage motif rubans ondulés…**
(*Wavy Ribbon Pattern Fill*).
3. Régler le motif (onglet « Motif ») et son rendu (onglet « Remplissage ») ;
cocher « Aperçu en direct » pour voir le résultat.
4. Valider avec **Appliquer**.
Chaque forme reçoit un groupe « Motif rubans ondulés », placé juste au-dessus
d'elle, qui contient un seul chemin sans remplissage.
### Conseils
- **Motif trop dense ou trop lâche** : changer le pas des traits ; tout le motif
suit, les autres réglages étant comptés en pas.
- **Rubans plus larges** : augmenter le nombre de traits par ruban, et le pas des
colonnes d'autant.
- **Ondes plus douces** : augmenter la hauteur d'onde et le rayon de virage (le
rayon est limité à la moitié de la hauteur).
- **Traits coupés entre deux rubans** : les rubans se chevauchent ; augmenter le
pas des colonnes ou le pas des rangées.
- **Motif qui doit se raccorder entre plusieurs formes** : choisir l'origine
« Origine du document ».
- **Message « demanderait plus de 3000 traits »** : augmenter le pas des traits.
## Paramètres
![Schéma des paramètres](docs/parametres.png)
| Paramètre | Défaut | Rôle |
| ---------------------------- | ------- | -------------------------------------------------------------------------------------- |
| Pas des traits | 2 mm | Distance entre deux traits voisins ; unité de toutes les autres dimensions du motif. |
| Unité | mm | Unité du pas et de l'épaisseur du trait (mm, cm, px, pt, in). |
| Traits par ruban | 5 | Nombre de traits d'un ruban ; sa largeur vaut (traits − 1) pas. |
| Décalage du ruban (pas) | 6 | Déplacement latéral d'un ruban le long de son onde (nombre entier de pas). |
| Hauteur d'onde (pas) | 7 | Hauteur sur laquelle un trait passe de sa position basse à sa position haute. |
| Rayon de virage (pas) | 2,8 | Rayon des deux virages d'une onde, limité à la moitié de la hauteur ; 0 = traits droits. |
| Décalage des traits (pas) | 1 | Décalage vertical d'un trait du ruban au suivant ; 0 resserre le ruban dans l'oblique. |
| Pas des colonnes (pas) | 8 | Décalage latéral d'une rangée de rubans à la suivante ; période = 2 pas de colonne. |
| Pas des rangées (pas) | 7,75 | Distance verticale d'une rangée à la suivante, qui penche de l'autre côté. |
| Rotation du motif (°) | 0 | Tourne le motif autour de son origine, sens horaire. |
| Origine du motif | Centre de chaque forme | Ou origine du document : motif continu d'une forme à l'autre. |
| Épaisseur du trait | 0,25 | Dans l'unité choisie. |
| Couleur du trait | noir | Couleur et opacité du chemin produit. |
| Conserver la forme d'origine | oui | Décocher pour ne garder que le motif. |
Les valeurs par défaut reproduisent les proportions du modèle `doc/patern.jpg`.
## Structure du projet
```text
wavy_ribbon_fill.inx Boîte de dialogue (textes source en anglais)
wavy_ribbon_fill.py Couche inkex : sélection, unités, écriture SVG
wavy_ribbon_fill_core.py Calcul du motif, sans inkex
i18n.py Chaîne de traduction (extract / update / compile)
po/ Modèle .pot et catalogues en.po, fr.po
locale/ Catalogues compilés .mo (déployés)
images/parameters_en.png Schéma de l'onglet « Aide » (déployé)
doc/patern.jpg Modèle du motif
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
docs/exemple.png Exemple de résultat (README)
tests/data/shapes.svg Formes d'exemple pour les tests de bout en bout
test_wavy_ribbon_fill.py Tests pytest
deploy.ps1 Déploiement / désinstallation Windows
```
### API du noyau (`wavy_ribbon_fill_core.py`)
Les contours sont des listes d'anneaux (listes de points, trous en pair-impair),
dans le repère SVG (y vers le bas). `shift`, `height`, `radius`, `stagger`,
`columns` et `rows` sont exprimés en nombre de pas.
| Fonction | Rôle |
| --------------------------------------------------------------- | -------------------------------------------------------------------- |
| `fill_polylines(rings, spacing, lines, shift, height, radius, stagger, columns, rows, angle, origin)` | Motif découpé par le contour, rotation comprise : liste de polylignes. |
| `RibbonPattern(spacing, …, origin)` | Géométrie du motif pour un jeu de réglages. |
| `RibbonPattern.fill(rings)` | Polylignes du motif (traits du fond et rubans) dans le contour. |
| `RibbonPattern.pieces(bounds)` | Rubans dont l'onde touche une boîte englobante. |
| `RibbonPattern.piece_curves(piece)` | Les traits d'un ruban, du bas vers le haut. |
| `RibbonPattern.piece_region(piece, inset)` | Polygone couvert par l'onde d'un ruban. |
| `RibbonPattern.hidden_interval(piece, index)` | Hauteur sur laquelle un trait du fond passe sous un ruban. |
| `wave_profile(shift, height, radius, tolerance)` | Profil en S d'un trait : arc, droite, arc. |
| `EdgeIndex(rings)` : `inside(x, y)`, `crossings(p, q)`, `clip(polyline, keep_inside)` | Découpe de polylignes par un contour. |
| `join_polylines(polylines)` | Soude les polylignes bout à bout et retire les points alignés. |
| `subtract_intervals(intervals, holes)` | Différence d'intervalles (traits du fond masqués). |
| `rotate_points(points, angle, center)`, `rings_bounds(rings)` | Rotation (sens horaire à l'écran) et boîte englobante. |
| `parse_color(value)`, `polylines_to_d(polylines)` | Couleur Inkscape → CSS et opacité ; polylignes → données `d`. |
## Développement
```powershell
python -m venv .venv
.venv\Scripts\Activate.ps1
pip install pytest
python -m pytest -q
```
Sans `inkex`, les tests de bout en bout sont ignorés : **31 réussis, 9 ignorés**.
Avec `inkex` (celui d'Inkscape), **40 réussis** :
```powershell
pip install lxml tinycss2 cssselect2 cssselect
$env:PYTHONPATH = 'C:\Program Files\Inkscape\share\inkscape\extensions'
python -m pytest -q
```
Les tests couvrent le noyau (profil de l'onde, découpe, trous, périodicité,
miroir, rotation, chevauchement des rubans, entrées vides ou invalides),
l'extension de bout en bout (formes courbes, trous, groupe transformé, unités,
couleur) et la cohérence entre le `.inx`, les arguments Python, le schéma et les
traductions.
### Traductions
Les textes source (`.inx` et appels `_()` des `.py`) sont en anglais.
```powershell
python i18n.py # extract + update + compile ; liste les chaînes à traduire
python i18n.py extract # po/wavyribbonfill.pot
python i18n.py update # reporte le modèle dans po/en.po et po/fr.po
python i18n.py compile # locale/<langue>/LC_MESSAGES/wavyribbonfill.mo
```
Après toute modification d'un texte : lancer `python i18n.py`, compléter les
`msgstr` vides de `po/fr.po`, relancer `python i18n.py` (`en.po` se remplit tout
seul). Ne jamais modifier les `.mo`. Pour ajouter une langue, l'ajouter à
`LANGUAGES` dans `i18n.py` et traduire le nouveau `po/<langue>.po`.
### Schéma des paramètres
```powershell
python docs/schema_parametres.py # les deux versions
python docs/schema_parametres.py fr # docs/parametres.png (1400 px, README)
python docs/schema_parametres.py en # images/parameters_en.png (900 px, onglet « Aide »)
```
Le schéma est dessiné avec les fonctions du noyau, il reste donc fidèle au rendu
réel. L'export PNG passe par `inkscape.com`. La version anglaise sert à la boîte
de dialogue, Inkscape ne traduisant pas le chemin d'une image ; elle y est
affichée aux 3/4 de sa taille (675 × 617).

107
deploy.ps1 Normal file
View File

@ -0,0 +1,107 @@
<#
.SYNOPSIS
Déploie l'extension « Remplissage motif rubans ondulés » dans Inkscape.
.DESCRIPTION
Copie wavy_ribbon_fill.inx, wavy_ribbon_fill.py, wavy_ribbon_fill_core.py, le schéma de l'onglet
« Help » (images\parameters_en.png) et les traductions
compilées (locale\<langue>\LC_MESSAGES\wavyribbonfill.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 : wavyRibbonFill
.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 = 'wavyRibbonFill',
[switch] $Uninstall,
[switch] $Force
)
$ErrorActionPreference = 'Stop'
$files = @('wavy_ribbon_fill.inx', 'wavy_ribbon_fill.py', 'wavy_ribbon_fill_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 rubans ondulés..."

BIN
doc/patern.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

BIN
docs/exemple.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 325 KiB

BIN
docs/parametres.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 330 KiB

393
docs/schema_parametres.py Normal file
View File

@ -0,0 +1,393 @@
#!/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 wavy_ribbon_fill_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 wavy_ribbon_fill_core as core # noqa: E402
W, H = 1400, 1280 # 4 panneaux de 700 x 640
INK = "#343a40" # element principal
GHOST = "#c9ced4" # 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": "Traits et ruban",
"spacing": "Pas",
"lines": "5 traits par ruban",
"shift": "Décalage du ruban = 6 pas",
"note1": ["Le ruban glisse sur les traits du fond et retombe dessus :",
"son décalage est un nombre entier de pas."],
"title2": "Forme de l'onde",
"height": "Hauteur d'onde",
"height_value": "7 pas",
"radius": "Rayon de virage",
"radius_value": "2,8 pas",
"stagger": "Décalage",
"stagger_2": "des traits",
"stagger_value": "1 pas",
"note2": ["Chaque trait suit la même onde (virage, droite, virage),",
"décalée d'un pas sur le côté et du décalage vers le bas."],
"title3": "Quinconce des rubans",
"columns": "Pas des colonnes = 8 pas",
"rows": "Pas des rangées",
"rows_value": "7,75 pas",
"note3": ["Une rangée sur deux penche de l'autre côté.",
"Le motif se répète toutes les deux colonnes et deux rangées."],
"title4": "Rotation et origine",
"angle": "Rotation 30°",
"origin": "Origine",
"note4": ["Origine au centre de chaque forme, ou à l'origine du document",
"pour un motif continu d'une forme à l'autre."],
},
},
"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": "Lines and ribbon",
"spacing": "Spacing",
"lines": "5 lines per ribbon",
"shift": "Ribbon shift = 6 spacings",
"note1": ["The ribbon slides over the background lines and",
"lands on them: a whole number of spacings."],
"title2": "Wave shape",
"height": "Wave height",
"height_value": "7 spacings",
"radius": "Bend radius",
"radius_value": "2.8 spacings",
"stagger": "Line",
"stagger_2": "stagger",
"stagger_value": "1 spacing",
"note2": ["Every line follows the same wave (bend, straight,",
"bend), moved one spacing sideways and down."],
"title3": "Staggered ribbons",
"columns": "Column step = 8 spacings",
"rows": "Row step",
"rows_value": "7.75 spacings",
"note3": ["Every other row leans the other way. The pattern",
"repeats every two columns and two rows."],
"title4": "Rotation and origin",
"angle": "Rotation 30°",
"origin": "Origin",
"note4": ["Origin at the centre of each shape, or at the document",
"origin for a pattern continuous across shapes."],
},
},
}
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="miter" {}/>'.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 panel_title(x, y, number, title):
text(x, y, "{} {}".format(number, title), size=22, weight="bold", halo=False)
# --------------------------------------------------------------------------
# Panneaux : un par groupe de parametres, numerotes 1 a 4. Le motif est
# calcule par wavy_ribbon_fill_core, puis cote avec dimension() / arc().
# --------------------------------------------------------------------------
def rect_ring(x1, y1, x2, y2):
return [(x1, y1), (x2, y1), (x2, y2), (x1, y2)]
def dot(point, color, radius=4.5):
out.append('<circle cx="{}" cy="{}" r="{}" fill="{}"/>'.format(
fmt(point[0]), fmt(point[1]), fmt(radius), color))
def guide(p, q, color):
polyline([p, q], color, 1, 'stroke-dasharray="3,3"')
def vertical_dimension(x, y1, y2, labels, color, side):
"""Cote verticale ; textes a gauche (side = -1) ou a droite (side = 1)."""
polyline([(x, y1), (x, y2)], color, 1.6)
arrow_head((x, y1), (0, -1), color)
arrow_head((x, y2), (0, 1), color)
step = 22 * FS
y = (y1 + y2) / 2.0 - (len(labels) - 1) * step / 2.0 + 6 * FS
for k, label in enumerate(labels):
text(x + side * 12, y + k * step, label, color=color,
anchor="end" if side < 0 else "start", weight="bold")
def panel_1():
panel_title(30, 50, 1, T["title1"])
s = 30.0
pattern = core.RibbonPattern(s, origin=(200.0, 315.0))
window = rect_ring(155, 110, 545, 480)
motif(pattern.fill([window]), GHOST, 2.2)
piece = (0, 0, 0, 315.0 - pattern.region_height / 2, 315.0 + pattern.region_height / 2, False)
curves = pattern.piece_curves(piece)
motif(curves[1:], INK, 2.6)
motif(curves[:1], DIM, 3.2)
dimension((170, 110), (200, 110), -22, T["spacing"], color=DIM2, label_shift=16)
dimension((380, 110), (500, 110), -22, T["lines"], label_shift=16)
dimension((200, 480), (380, 480), 22, T["shift"], label_shift=20)
note(30, 610, T["note1"])
def panel_2():
ox = 700
panel_title(ox + 30, 50, 2, T["title2"])
s = 30.0
top = 125.0
pattern = core.RibbonPattern(s, origin=(ox + 225.0, top + 165.0))
piece = (0, 0, 0, top, top + pattern.region_height, False)
curves = pattern.piece_curves(piece)
left, low = pattern.grid_x(0), top + pattern.height
radius = 2.8 * s
# Cercle du virage bas du premier trait.
out.append('<circle cx="{}" cy="{}" r="{}" fill="none" stroke="{}" stroke-width="1" '
'stroke-dasharray="4,4"/>'.format(fmt(left + radius), fmt(low), fmt(radius), DIM2))
motif(curves[1:], INK, 2.6)
motif(curves[:1], DIM, 3.2)
center = (left + radius, low)
ux, uy = _unit(150)
tip = (center[0] + radius * ux, center[1] + radius * uy)
polyline([center, tip], DIM2, 1.6)
arrow_head(tip, (ux, uy), DIM2)
dot(center, DIM2, 3.5)
text(center[0] + 12, center[1] + 30 * FS, T["radius"], color=DIM2, weight="bold")
text(center[0] + 12, center[1] + 52 * FS, T["radius_value"], color=DIM2, weight="bold")
# Hauteur d'onde du premier trait.
guide((left - 62, top), (pattern.grid_x(6), top), DIM)
guide((left - 62, low), (left, low), DIM)
vertical_dimension(left - 50, top, low, [T["height"], T["height_value"]], DIM, -1)
# Decalage entre les deux derniers traits.
right = pattern.grid_x(10)
y3, y4 = top + 3 * pattern.stagger, top + 4 * pattern.stagger
dot((pattern.grid_x(9), y3), DIM)
dot((right, y4), DIM)
guide((pattern.grid_x(9), y3), (right + 42, y3), DIM)
guide((right, y4), (right + 42, y4), DIM)
vertical_dimension(right + 30, y3, y4, [T["stagger"], T["stagger_2"], T["stagger_value"]],
DIM, 1)
note(ox + 30, 610, T["note2"])
def panel_3():
oy = 640
panel_title(30, oy + 50, 3, T["title3"])
s = 15.0
pattern = core.RibbonPattern(s, origin=(150.0, oy + 215.0))
window = rect_ring(60, oy + 95, 640, oy + 500)
motif(pattern.fill([window]), GHOST, 1.6)
half = pattern.region_height / 2
centers = []
for row in (0, 1):
y = oy + 215.0 + row * pattern.row_step
piece = (row, 0, row * pattern.columns, y - half, y + half, bool(row))
motif(pattern.piece_curves(piece), INK, 2.2)
centers.append((pattern.grid_x(piece[2]) + pattern.span * s / 2, y))
(x0, y0), (x1, y1) = centers
for center in centers:
dot(center, DIM)
guide((x0, y0), (x0, y1 + 100), DIM)
guide((x1, y1), (x1, y1 + 100), DIM)
guide((x0, y0), (x1 + 150, y0), DIM)
guide((x1, y1), (x1 + 150, y1), DIM)
dimension((x0, y1 + 100), (x1, y1 + 100), 0, T["columns"], label_shift=22)
vertical_dimension(x1 + 135, y0, y1, [T["rows"], T["rows_value"]], DIM, 1)
note(30, oy + 610, T["note3"])
def panel_4():
ox, oy = 700, 640
panel_title(ox + 30, oy + 50, 4, T["title4"])
center = (ox + 350.0, oy + 315.0)
shape = [circle_ring(center[0], center[1], 205)]
motif(core.fill_polylines(shape, 13.0, angle=30.0, origin=center), INK, 1.5)
polyline(shape[0] + shape[0][:1], EDGE, 2)
ux, uy = _unit(60)
guide((center[0], center[1]), (center[0], center[1] - 240), DIM2)
polyline([center, (center[0] + 240 * ux, center[1] + 240 * uy)], DIM2, 1.6)
arc(center, 225, 90, 60, DIM2)
text(center[0] + 150, center[1] - 212, T["angle"], color=DIM2, weight="bold")
dot(center, DIM, 6)
text(center[0] + 14, center[1] + 6 * FS, T["origin"], color=DIM, weight="bold")
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: 217 KiB

Binary file not shown.

Binary file not shown.

164
po/en.po Normal file
View File

@ -0,0 +1,164 @@
# Traduction (en) de l'extension Inkscape « Wavy Ribbon Pattern Fill ».
# Genere par i18n.py ; ne modifier que les msgstr.
msgid ""
msgstr ""
"Project-Id-Version: wavyribbonfill\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"
#: wavy_ribbon_fill.inx
msgid "Wavy Ribbon Pattern Fill"
msgstr "Wavy Ribbon Pattern Fill"
#: wavy_ribbon_fill.inx
msgid "Pattern"
msgstr "Pattern"
#: wavy_ribbon_fill.inx
msgid "Line spacing:"
msgstr "Line spacing:"
#: wavy_ribbon_fill.inx
msgid "Distance between two neighbouring lines. Every other pattern dimension is a multiple of it."
msgstr "Distance between two neighbouring lines. Every other pattern dimension is a multiple of it."
#: wavy_ribbon_fill.inx
msgid "Unit:"
msgstr "Unit:"
#: wavy_ribbon_fill.inx
msgid "Lines per ribbon:"
msgstr "Lines per ribbon:"
#: wavy_ribbon_fill.inx
msgid "Ribbon shift (line spacings):"
msgstr "Ribbon shift (line spacings):"
#: wavy_ribbon_fill.inx
msgid "How far a ribbon moves sideways along its wave."
msgstr "How far a ribbon moves sideways along its wave."
#: wavy_ribbon_fill.inx
msgid "Wave height (line spacings):"
msgstr "Wave height (line spacings):"
#: wavy_ribbon_fill.inx
msgid "Height over which one line goes from its low position to its high position."
msgstr "Height over which one line goes from its low position to its high position."
#: wavy_ribbon_fill.inx
msgid "Bend radius (line spacings):"
msgstr "Bend radius (line spacings):"
#: wavy_ribbon_fill.inx
msgid "Radius of the two bends of a wave, limited to half the wave height. 0 gives straight slanted lines."
msgstr "Radius of the two bends of a wave, limited to half the wave height. 0 gives straight slanted lines."
#: wavy_ribbon_fill.inx
msgid "Line stagger (line spacings):"
msgstr "Line stagger (line spacings):"
#: wavy_ribbon_fill.inx
msgid "Vertical offset between one line of a ribbon and the next. 0 makes the ribbon pinch in its slanted part."
msgstr "Vertical offset between one line of a ribbon and the next. 0 makes the ribbon pinch in its slanted part."
#: wavy_ribbon_fill.inx
msgid "Column step (line spacings):"
msgstr "Column step (line spacings):"
#: wavy_ribbon_fill.inx
msgid "Sideways offset between a row of ribbons and the next one; the pattern repeats every two steps."
msgstr "Sideways offset between a row of ribbons and the next one; the pattern repeats every two steps."
#: wavy_ribbon_fill.inx
msgid "Row step (line spacings):"
msgstr "Row step (line spacings):"
#: wavy_ribbon_fill.inx
msgid "Vertical distance between a row of ribbons and the next one, which leans the other way."
msgstr "Vertical distance between a row of ribbons and the next one, which leans the other way."
#: wavy_ribbon_fill.inx
msgid "Fill"
msgstr "Fill"
#: wavy_ribbon_fill.inx
msgid "Pattern rotation (°):"
msgstr "Pattern rotation (°):"
#: wavy_ribbon_fill.inx
msgid "0 keeps the background lines vertical; positive values turn the pattern clockwise."
msgstr "0 keeps the background lines vertical; positive values turn the pattern clockwise."
#: wavy_ribbon_fill.inx
msgid "Pattern origin:"
msgstr "Pattern origin:"
#: wavy_ribbon_fill.inx
msgid "Centre of each shape (pattern centred)"
msgstr "Centre of each shape (pattern centred)"
#: wavy_ribbon_fill.inx
msgid "Document origin (pattern continuous across shapes)"
msgstr "Document origin (pattern continuous across shapes)"
#: wavy_ribbon_fill.inx
msgid "Stroke width (same unit):"
msgstr "Stroke width (same unit):"
#: wavy_ribbon_fill.inx
msgid "Stroke color:"
msgstr "Stroke color:"
#: wavy_ribbon_fill.inx
msgid "Keep the original shape"
msgstr "Keep the original shape"
#: wavy_ribbon_fill.inx
msgid "Help"
msgstr "Help"
#: wavy_ribbon_fill.inx
msgid ""
"Select one or more closed shapes (rectangles, ellipses, paths, groups), then run the extension. Each shape is filled with vertical lines crossed by S-shaped ribbons leaning alternately to the right and to the left, cut along the outline of the shape; the holes of a path stay empty.\n"
"\n"
"The line spacing sets the scale: the other pattern settings are counted in line spacings. A ribbon hides the background lines only over its wave, so the ribbon shift and the column step are whole numbers of spacings.\n"
"\n"
"The column step and the row step set how tightly the ribbons are packed. If they end up overlapping, each row passes over the previous one and lines get cut: increase one of the two steps to keep the ribbons apart.\n"
"\n"
"The result is a group named “Wavy ribbon pattern”, placed just above the shape and holding a single path with no fill."
msgstr ""
"Select one or more closed shapes (rectangles, ellipses, paths, groups), then run the extension. Each shape is filled with vertical lines crossed by S-shaped ribbons leaning alternately to the right and to the left, cut along the outline of the shape; the holes of a path stay empty.\n"
"\n"
"The line spacing sets the scale: the other pattern settings are counted in line spacings. A ribbon hides the background lines only over its wave, so the ribbon shift and the column step are whole numbers of spacings.\n"
"\n"
"The column step and the row step set how tightly the ribbons are packed. If they end up overlapping, each row passes over the previous one and lines get cut: increase one of the two steps to keep the ribbons apart.\n"
"\n"
"The result is a group named “Wavy ribbon pattern”, placed just above the shape and holding a single path with no fill."
#: wavy_ribbon_fill.py
msgid "Select at least one shape."
msgstr "Select at least one shape."
#: wavy_ribbon_fill.py
msgid "The line spacing must be strictly positive."
msgstr "The line spacing must be strictly positive."
#: wavy_ribbon_fill.py
msgid "“{}” has no usable outline: select closed shapes or paths (convert texts with Path > Object to Path)."
msgstr "“{}” has no usable outline: select closed shapes or paths (convert texts with Path > Object to Path)."
#: wavy_ribbon_fill.py
msgid "“{}” would need more than {} lines: increase the line spacing."
msgstr "“{}” would need more than {} lines: increase the line spacing."
#: wavy_ribbon_fill.py
msgid "“{}” is too small for the pattern: reduce the line spacing."
msgstr "“{}” is too small for the pattern: reduce the line spacing."
#: wavy_ribbon_fill.py
msgid "Wavy ribbon pattern"
msgstr "Wavy ribbon pattern"

164
po/fr.po Normal file
View File

@ -0,0 +1,164 @@
# Traduction (fr) de l'extension Inkscape « Wavy Ribbon Pattern Fill ».
# Genere par i18n.py ; ne modifier que les msgstr.
msgid ""
msgstr ""
"Project-Id-Version: wavyribbonfill\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"
#: wavy_ribbon_fill.inx
msgid "Wavy Ribbon Pattern Fill"
msgstr "Remplissage motif rubans ondulés"
#: wavy_ribbon_fill.inx
msgid "Pattern"
msgstr "Motif"
#: wavy_ribbon_fill.inx
msgid "Line spacing:"
msgstr "Pas des traits :"
#: wavy_ribbon_fill.inx
msgid "Distance between two neighbouring lines. Every other pattern dimension is a multiple of it."
msgstr "Distance entre deux traits voisins. Toutes les autres dimensions du motif en sont des multiples."
#: wavy_ribbon_fill.inx
msgid "Unit:"
msgstr "Unité :"
#: wavy_ribbon_fill.inx
msgid "Lines per ribbon:"
msgstr "Traits par ruban :"
#: wavy_ribbon_fill.inx
msgid "Ribbon shift (line spacings):"
msgstr "Décalage du ruban (pas) :"
#: wavy_ribbon_fill.inx
msgid "How far a ribbon moves sideways along its wave."
msgstr "Déplacement latéral d'un ruban le long de son onde."
#: wavy_ribbon_fill.inx
msgid "Wave height (line spacings):"
msgstr "Hauteur d'onde (pas) :"
#: wavy_ribbon_fill.inx
msgid "Height over which one line goes from its low position to its high position."
msgstr "Hauteur sur laquelle un trait passe de sa position basse à sa position haute."
#: wavy_ribbon_fill.inx
msgid "Bend radius (line spacings):"
msgstr "Rayon de virage (pas) :"
#: wavy_ribbon_fill.inx
msgid "Radius of the two bends of a wave, limited to half the wave height. 0 gives straight slanted lines."
msgstr "Rayon des deux virages d'une onde, limité à la moitié de la hauteur d'onde. 0 donne des traits obliques droits."
#: wavy_ribbon_fill.inx
msgid "Line stagger (line spacings):"
msgstr "Décalage des traits (pas) :"
#: wavy_ribbon_fill.inx
msgid "Vertical offset between one line of a ribbon and the next. 0 makes the ribbon pinch in its slanted part."
msgstr "Décalage vertical entre un trait du ruban et le suivant. 0 resserre le ruban dans sa partie oblique."
#: wavy_ribbon_fill.inx
msgid "Column step (line spacings):"
msgstr "Pas des colonnes (pas) :"
#: wavy_ribbon_fill.inx
msgid "Sideways offset between a row of ribbons and the next one; the pattern repeats every two steps."
msgstr "Décalage latéral entre une rangée de rubans et la suivante ; le motif se répète tous les deux pas."
#: wavy_ribbon_fill.inx
msgid "Row step (line spacings):"
msgstr "Pas des rangées (pas) :"
#: wavy_ribbon_fill.inx
msgid "Vertical distance between a row of ribbons and the next one, which leans the other way."
msgstr "Distance verticale entre une rangée de rubans et la suivante, qui penche de l'autre côté."
#: wavy_ribbon_fill.inx
msgid "Fill"
msgstr "Remplissage"
#: wavy_ribbon_fill.inx
msgid "Pattern rotation (°):"
msgstr "Rotation du motif (°) :"
#: wavy_ribbon_fill.inx
msgid "0 keeps the background lines vertical; positive values turn the pattern clockwise."
msgstr "0 garde les traits du fond verticaux ; une valeur positive tourne le motif dans le sens horaire."
#: wavy_ribbon_fill.inx
msgid "Pattern origin:"
msgstr "Origine du motif :"
#: wavy_ribbon_fill.inx
msgid "Centre of each shape (pattern centred)"
msgstr "Centre de chaque forme (motif centré)"
#: wavy_ribbon_fill.inx
msgid "Document origin (pattern continuous across shapes)"
msgstr "Origine du document (motif continu d'une forme à l'autre)"
#: wavy_ribbon_fill.inx
msgid "Stroke width (same unit):"
msgstr "Épaisseur du trait (même unité) :"
#: wavy_ribbon_fill.inx
msgid "Stroke color:"
msgstr "Couleur du trait :"
#: wavy_ribbon_fill.inx
msgid "Keep the original shape"
msgstr "Conserver la forme d'origine"
#: wavy_ribbon_fill.inx
msgid "Help"
msgstr "Aide"
#: wavy_ribbon_fill.inx
msgid ""
"Select one or more closed shapes (rectangles, ellipses, paths, groups), then run the extension. Each shape is filled with vertical lines crossed by S-shaped ribbons leaning alternately to the right and to the left, cut along the outline of the shape; the holes of a path stay empty.\n"
"\n"
"The line spacing sets the scale: the other pattern settings are counted in line spacings. A ribbon hides the background lines only over its wave, so the ribbon shift and the column step are whole numbers of spacings.\n"
"\n"
"The column step and the row step set how tightly the ribbons are packed. If they end up overlapping, each row passes over the previous one and lines get cut: increase one of the two steps to keep the ribbons apart.\n"
"\n"
"The result is a group named “Wavy ribbon pattern”, placed just above the shape and holding a single path with no fill."
msgstr ""
"Sélectionnez une ou plusieurs formes fermées (rectangles, ellipses, chemins, groupes), puis lancez l'extension. Chaque forme est remplie de traits verticaux traversés par des rubans en S penchant tour à tour à droite et à gauche, découpés le long du contour de la forme ; les trous d'un chemin restent vides.\n"
"\n"
"Le pas des traits fixe l'échelle : les autres réglages du motif se comptent en pas. Un ruban ne masque les traits du fond que sur la hauteur de son onde ; le décalage du ruban et le pas des colonnes sont donc des nombres entiers de pas.\n"
"\n"
"Le pas des colonnes et le pas des rangées règlent le serrage des rubans. S'ils finissent par se chevaucher, chaque rangée passe sur la précédente et des traits sont coupés : augmentez l'un des deux pas pour écarter les rubans.\n"
"\n"
"Le résultat est un groupe nommé « Motif rubans ondulés », placé juste au-dessus de la forme et contenant un seul chemin sans remplissage."
#: wavy_ribbon_fill.py
msgid "Select at least one shape."
msgstr "Sélectionnez au moins une forme."
#: wavy_ribbon_fill.py
msgid "The line spacing must be strictly positive."
msgstr "Le pas des traits doit être strictement positif."
#: wavy_ribbon_fill.py
msgid "“{}” has no usable outline: select closed shapes or paths (convert texts with Path > Object to Path)."
msgstr "« {} » n'a pas de contour utilisable : sélectionnez des formes ou des chemins fermés (convertissez les textes avec Chemin > Objet en chemin)."
#: wavy_ribbon_fill.py
msgid "“{}” would need more than {} lines: increase the line spacing."
msgstr "« {} » demanderait plus de {} traits : augmentez le pas des traits."
#: wavy_ribbon_fill.py
msgid "“{}” is too small for the pattern: reduce the line spacing."
msgstr "« {} » est trop petite pour le motif : réduisez le pas des traits."
#: wavy_ribbon_fill.py
msgid "Wavy ribbon pattern"
msgstr "Motif rubans ondulés"

155
po/wavyribbonfill.pot Normal file
View File

@ -0,0 +1,155 @@
# Modele de traduction de l'extension Inkscape « Wavy Ribbon Pattern Fill ».
# Genere par i18n.py ; ne modifier que les msgstr.
msgid ""
msgstr ""
"Project-Id-Version: wavyribbonfill\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
#: wavy_ribbon_fill.inx
msgid "Wavy Ribbon Pattern Fill"
msgstr ""
#: wavy_ribbon_fill.inx
msgid "Pattern"
msgstr ""
#: wavy_ribbon_fill.inx
msgid "Line spacing:"
msgstr ""
#: wavy_ribbon_fill.inx
msgid "Distance between two neighbouring lines. Every other pattern dimension is a multiple of it."
msgstr ""
#: wavy_ribbon_fill.inx
msgid "Unit:"
msgstr ""
#: wavy_ribbon_fill.inx
msgid "Lines per ribbon:"
msgstr ""
#: wavy_ribbon_fill.inx
msgid "Ribbon shift (line spacings):"
msgstr ""
#: wavy_ribbon_fill.inx
msgid "How far a ribbon moves sideways along its wave."
msgstr ""
#: wavy_ribbon_fill.inx
msgid "Wave height (line spacings):"
msgstr ""
#: wavy_ribbon_fill.inx
msgid "Height over which one line goes from its low position to its high position."
msgstr ""
#: wavy_ribbon_fill.inx
msgid "Bend radius (line spacings):"
msgstr ""
#: wavy_ribbon_fill.inx
msgid "Radius of the two bends of a wave, limited to half the wave height. 0 gives straight slanted lines."
msgstr ""
#: wavy_ribbon_fill.inx
msgid "Line stagger (line spacings):"
msgstr ""
#: wavy_ribbon_fill.inx
msgid "Vertical offset between one line of a ribbon and the next. 0 makes the ribbon pinch in its slanted part."
msgstr ""
#: wavy_ribbon_fill.inx
msgid "Column step (line spacings):"
msgstr ""
#: wavy_ribbon_fill.inx
msgid "Sideways offset between a row of ribbons and the next one; the pattern repeats every two steps."
msgstr ""
#: wavy_ribbon_fill.inx
msgid "Row step (line spacings):"
msgstr ""
#: wavy_ribbon_fill.inx
msgid "Vertical distance between a row of ribbons and the next one, which leans the other way."
msgstr ""
#: wavy_ribbon_fill.inx
msgid "Fill"
msgstr ""
#: wavy_ribbon_fill.inx
msgid "Pattern rotation (°):"
msgstr ""
#: wavy_ribbon_fill.inx
msgid "0 keeps the background lines vertical; positive values turn the pattern clockwise."
msgstr ""
#: wavy_ribbon_fill.inx
msgid "Pattern origin:"
msgstr ""
#: wavy_ribbon_fill.inx
msgid "Centre of each shape (pattern centred)"
msgstr ""
#: wavy_ribbon_fill.inx
msgid "Document origin (pattern continuous across shapes)"
msgstr ""
#: wavy_ribbon_fill.inx
msgid "Stroke width (same unit):"
msgstr ""
#: wavy_ribbon_fill.inx
msgid "Stroke color:"
msgstr ""
#: wavy_ribbon_fill.inx
msgid "Keep the original shape"
msgstr ""
#: wavy_ribbon_fill.inx
msgid "Help"
msgstr ""
#: wavy_ribbon_fill.inx
msgid ""
"Select one or more closed shapes (rectangles, ellipses, paths, groups), then run the extension. Each shape is filled with vertical lines crossed by S-shaped ribbons leaning alternately to the right and to the left, cut along the outline of the shape; the holes of a path stay empty.\n"
"\n"
"The line spacing sets the scale: the other pattern settings are counted in line spacings. A ribbon hides the background lines only over its wave, so the ribbon shift and the column step are whole numbers of spacings.\n"
"\n"
"The column step and the row step set how tightly the ribbons are packed. If they end up overlapping, each row passes over the previous one and lines get cut: increase one of the two steps to keep the ribbons apart.\n"
"\n"
"The result is a group named “Wavy ribbon pattern”, placed just above the shape and holding a single path with no fill."
msgstr ""
#: wavy_ribbon_fill.py
msgid "Select at least one shape."
msgstr ""
#: wavy_ribbon_fill.py
msgid "The line spacing must be strictly positive."
msgstr ""
#: wavy_ribbon_fill.py
msgid "“{}” has no usable outline: select closed shapes or paths (convert texts with Path > Object to Path)."
msgstr ""
#: wavy_ribbon_fill.py
msgid "“{}” would need more than {} lines: increase the line spacing."
msgstr ""
#: wavy_ribbon_fill.py
msgid "“{}” is too small for the pattern: reduce the line spacing."
msgstr ""
#: wavy_ribbon_fill.py
msgid "Wavy ribbon pattern"
msgstr ""

430
test_wavy_ribbon_fill.py Normal file
View File

@ -0,0 +1,430 @@
# coding=utf-8
"""Tests de l'extension « Wavy Ribbon Pattern Fill » (pytest)."""
import math
import os
import re
import xml.etree.ElementTree as ET
import pytest
from wavy_ribbon_fill_core import (
EdgeIndex, RibbonPattern, fill_polylines, join_polylines, parse_color,
polylines_to_d, rings_bounds, rotate_points, subtract_intervals, wave_profile)
HERE = os.path.dirname(os.path.abspath(__file__))
SHAPES = os.path.join(HERE, "tests", "data", "shapes.svg")
SQUARE = [(0.0, 0.0), (200.0, 0.0), (200.0, 200.0), (0.0, 200.0)]
def total_length(polylines):
return sum(math.hypot(b[0] - a[0], b[1] - a[1])
for polyline in polylines for a, b in zip(polyline, polyline[1:]))
def segments(polylines, digits=6):
"""Segments normalises, pour comparer deux traces sans tenir compte du
sens ni du decoupage en polylignes."""
found = set()
for polyline in polylines:
for a, b in zip(polyline, polyline[1:]):
a = (round(a[0], digits), round(a[1], digits))
b = (round(b[0], digits), round(b[1], digits))
found.add((min(a, b), max(a, b)))
return found
# --------------------------------------------------------------------------
# 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))
assert parse_color("255") == ("#000000", 1.0)
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"
def test_rotate_points_clockwise_on_screen():
(x, y), = rotate_points([(1.0, 0.0)], 90)
assert (round(x, 9), round(y, 9)) == (0.0, 1.0) # y vers le bas
(x, y), = rotate_points([(3.0, 2.0)], 180, center=(2.0, 2.0))
assert (round(x, 9), round(y, 9)) == (1.0, 2.0)
def test_rings_bounds():
assert rings_bounds([SQUARE, [(-5.0, 10.0), (3.0, 250.0)]]) == (-5.0, 0.0, 200.0, 250.0)
assert rings_bounds([]) is None
def test_wave_profile_ends_and_monotony():
profile = wave_profile(12.0, 14.0, 5.6, 0.01)
assert profile[0] == (0.0, 0.0) and profile[-1] == (12.0, 14.0)
for (x0, v0), (x1, v1) in zip(profile, profile[1:]):
assert x1 > x0 and v1 > v0
# Tangente verticale aux deux bouts : le premier pas monte bien plus qu'il n'avance.
(x0, v0), (x1, v1) = profile[0], profile[1]
assert (v1 - v0) > 5 * (x1 - x0)
# Symetrie centrale du S.
for (x, v), (mx, mv) in zip(profile, reversed(profile)):
assert abs(x + mx - 12.0) < 1e-9 and abs(v + mv - 14.0) < 1e-9
def test_wave_profile_radius_limits():
assert wave_profile(12.0, 14.0, 0.0, 0.01) == [(0.0, 0.0), (12.0, 14.0)]
# Rayon borne a la moitie de la hauteur : le profil reste une fonction de x.
profile = wave_profile(12.0, 6.0, 50.0, 0.01)
assert profile[-1] == (12.0, 6.0)
assert all(b[0] > a[0] and b[1] >= a[1] for a, b in zip(profile, profile[1:]))
def test_wave_profile_stays_on_its_arcs():
radius = 5.6
profile = wave_profile(12.0, 14.0, radius, 0.001)
first = [p for p in profile if p[1] < 2.0]
assert len(first) > 3
for x, v in first:
assert abs(math.hypot(x - radius, v) - radius) < 1e-9
def test_subtract_intervals():
assert subtract_intervals([(0, 10)], [(2, 3), (5, 20)]) == [(0, 2), (3, 5)]
assert subtract_intervals([(0, 10)], [(-5, 0), (10, 12)]) == [(0, 10)]
assert subtract_intervals([(0, 10)], [(-1, 11)]) == []
assert subtract_intervals([], [(0, 1)]) == []
def test_edge_index_clip_inside_and_outside():
index = EdgeIndex([[(0, 0), (10, 0), (10, 10), (0, 10)]])
line = [(-5.0, 5.0), (15.0, 5.0)]
assert index.clip(line) == [[(0.0, 5.0), (10.0, 5.0)]]
assert index.clip(line, keep_inside=False) == [[(-5.0, 5.0), (0.0, 5.0)],
[(10.0, 5.0), (15.0, 5.0)]]
assert index.clip([(20.0, 20.0), (30.0, 25.0)]) == []
def test_edge_index_even_odd_hole():
index = EdgeIndex([[(0, 0), (30, 0), (30, 30), (0, 30)],
[(10, 10), (20, 10), (20, 20), (10, 20)]])
assert index.inside(5, 15) and not index.inside(15, 15) and not index.inside(40, 15)
pieces = index.clip([(-5.0, 15.0), (35.0, 15.0)])
assert pieces == [[(0.0, 15.0), (10.0, 15.0)], [(20.0, 15.0), (30.0, 15.0)]]
def test_join_polylines():
joined = join_polylines([[(0.0, 0.0), (0.0, 5.0)], [(0.0, 10.0), (0.0, 5.0)],
[(0.0, 10.0), (4.0, 12.0)], [(9.0, 9.0), (8.0, 8.0)]])
# Deux traits soudes bout a bout, point aligne retire ; le sens est libre.
assert sorted(min(line, line[::-1]) for line in joined) == [
[(0.0, 0.0), (0.0, 10.0), (4.0, 12.0)], [(8.0, 8.0), (9.0, 9.0)]]
def test_join_polylines_leaves_three_way_junctions():
lines = [[(0.0, 0.0), (5.0, 5.0)], [(5.0, 5.0), (9.0, 5.0)], [(5.0, 5.0), (5.0, 9.0)]]
assert len(join_polylines(lines)) == 3
def test_pattern_rejects_bad_settings():
for kwargs in ({"spacing": 0}, {"spacing": 2, "lines": 1}, {"spacing": 2, "shift": 0},
{"spacing": 2, "height": 0}, {"spacing": 2, "stagger": -1},
{"spacing": 2, "columns": 0}, {"spacing": 2, "rows": 0}):
with pytest.raises(ValueError):
RibbonPattern(**kwargs)
def test_fill_empty_input():
assert fill_polylines([], 2.0) == []
assert fill_polylines([[(0, 0), (1, 1)]], 2.0) == []
def test_fill_stays_inside_shape():
polylines = fill_polylines([SQUARE], 2.0, origin=(100.0, 100.0))
assert polylines
for polyline in polylines:
assert len(polyline) >= 2
for x, y in polyline:
assert -1e-9 <= x <= 200 + 1e-9 and -1e-9 <= y <= 200 + 1e-9
def test_fill_leaves_holes_empty():
hole = [(60.0, 60.0), (140.0, 60.0), (140.0, 140.0), (60.0, 140.0)]
polylines = fill_polylines([SQUARE, hole], 2.0)
assert polylines
for polyline in polylines:
for a, b in zip(polyline, polyline[1:]):
mx, my = (a[0] + b[0]) / 2, (a[1] + b[1]) / 2
assert not (60 + 1e-6 < mx < 140 - 1e-6 and 60 + 1e-6 < my < 140 - 1e-6)
# Le trou retire de la matiere.
assert total_length(polylines) < total_length(fill_polylines([SQUARE], 2.0))
def test_background_lines_are_on_the_grid():
spacing, origin = 2.0, (3.0, 50.0)
polylines = fill_polylines([SQUARE], spacing, origin=origin)
vertical = [(a, b) for polyline in polylines for a, b in zip(polyline, polyline[1:])
if a[0] == b[0]]
assert len(vertical) > 50
for a, _b in vertical:
steps = (a[0] - origin[0]) / spacing
assert abs(steps - round(steps)) < 1e-9
def test_hidden_interval_matches_ribbon_edges():
pattern = RibbonPattern(2.0)
piece = (0, 0, 0, 10.0, 10.0 + pattern.region_height, False)
assert pattern.region_height == pytest.approx(2.0 * (7.0 + 4 * 1.0))
assert pattern.hidden_interval(piece, -1) is None
assert pattern.hidden_interval(piece, pattern.span + 1) is None
# Au milieu du ruban, le fond est masque sur toute la hauteur de l'ondulation.
assert pattern.hidden_interval(piece, 5) == pytest.approx((10.0 + 14.0 - pattern._rise(10.0),
32.0 - pattern._rise(2.0)))
# Rive du premier trait : masquee sous le bas de son onde seulement.
assert pattern.hidden_interval(piece, 0) == pytest.approx((24.0, 32.0))
# Le miroir echange les rives.
mirrored = piece[:5] + (True,)
assert pattern.hidden_interval(mirrored, pattern.span) == pytest.approx((24.0, 32.0))
def test_ribbon_lines_are_translated_copies():
pattern = RibbonPattern(2.0, stagger=1.5)
piece = (0, 0, 0, 0.0, pattern.region_height, False)
curves = pattern.piece_curves(piece)
assert len(curves) == 5
# Sans les prolongements verticaux, chaque trait = le premier + k (pas, decalage).
wave = len(pattern.profile)
bodies = [curve[1:1 + wave] if k < 4 else curve[:wave] for k, curve in enumerate(curves)]
for k, curve in enumerate(curves):
assert len(bodies[k]) == wave
for (x, y), (x0, y0) in zip(bodies[k], bodies[0]):
assert x == pytest.approx(x0 + k * 2.0) and y == pytest.approx(y0 + k * 3.0)
# Les deux bouts tombent sur des traits du fond, aux bords du ruban.
assert curve[0] == (pattern.grid_x(k), pattern.region_height)
assert curve[-1] == (pattern.grid_x(k + 6), 0.0)
def test_mirrored_ribbon_is_the_mirror_image():
pattern = RibbonPattern(2.0)
plain = pattern.piece_curves((0, 0, 0, 0.0, pattern.region_height, False))
mirrored = pattern.piece_curves((1, 0, 0, 0.0, pattern.region_height, True))
width = pattern.span * 2.0
for curve, other in zip(plain, mirrored):
assert [(pytest.approx(width - x), y) for x, y in curve] == other
def test_pattern_is_periodic():
spacing = 2.0
window = [(20.0, 20.0), (120.0, 20.0), (120.0, 110.0), (20.0, 110.0)]
dx, dy = 2 * 8 * spacing, 2 * 7.75 * spacing
moved = [(x + dx, y + dy) for x, y in window]
first = segments(fill_polylines([window], spacing))
second = segments([[(x - dx, y - dy) for x, y in polyline]
for polyline in fill_polylines([moved], spacing)])
assert first == second and len(first) > 500
def test_default_ribbons_do_not_overlap():
"""Avec les reglages par defaut, aucun ruban n'en recouvre un autre : le
decoupage entre rubans ne retire rien."""
pattern = RibbonPattern(2.0)
reference = segments(pattern.fill([SQUARE]))
pattern.pieces_overlap = lambda: False
assert segments(pattern.fill([SQUARE])) == reference
def test_overlapping_ribbons_are_cut():
tight = RibbonPattern(2.0, rows=4.0)
cut = total_length(tight.fill([SQUARE]))
tight.pieces_overlap = lambda: False
assert cut < total_length(tight.fill([SQUARE])) - 1.0
def test_rotation_turns_the_whole_pattern():
circle = [(60 * math.cos(math.radians(a)), 60 * math.sin(math.radians(a)))
for a in range(360)]
plain = fill_polylines([circle], 2.0)
turned = fill_polylines([circle], 2.0, angle=30.0)
# Le cercle a 360 sommets est invariant par rotation de 30 degres.
assert segments(rotate_points(p, 30.0) for p in plain) == segments(turned)
def test_origin_moves_the_pattern():
here = segments(fill_polylines([SQUARE], 2.0, origin=(0.0, 0.0)))
there = segments(fill_polylines([SQUARE], 2.0, origin=(1.0, 0.0)))
assert here != there
def test_stagger_zero_and_straight_lines():
polylines = fill_polylines([SQUARE], 2.0, stagger=0.0, radius=0.0)
assert polylines
assert max(len(polyline) for polyline in polylines) < 20
# --------------------------------------------------------------------------
# Bout en bout (necessite inkex)
# --------------------------------------------------------------------------
def run_extension(tmp_path, *args):
pytest.importorskip("inkex")
from wavy_ribbon_fill import WavyRibbonFill
out = tmp_path / "out.svg"
WavyRibbonFill().run([*args, "--output={}".format(out), SHAPES])
return out.read_text(encoding="utf-8")
def pattern_points(svg):
"""Points du premier chemin genere."""
d = re.search(r'<path[^>]*\bd="(M [^"]*)"[^>]*fill:none|<path[^>]*fill:none[^>]*\bd="(M [^"]*)"',
svg)
assert d, "chemin du motif introuvable"
data = d.group(1) or d.group(2)
return [(float(x), float(y)) for x, y in re.findall(r"(-?[\d.]+),(-?[\d.]+)", data)]
def test_end_to_end_default(tmp_path):
svg = run_extension(tmp_path, "--id=rect1")
assert "Wavy ribbon pattern" in svg
assert 'id="rect1"' in svg
assert "fill:none" in svg and "stroke:#000000" in svg
points = pattern_points(svg)
assert len(points) > 100
assert all(10 - 1e-3 <= x <= 60 + 1e-3 and 10 - 1e-3 <= y <= 50 + 1e-3 for x, y in points)
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
assert "Wavy ribbon pattern" in svg
def test_end_to_end_color_and_width(tmp_path):
svg = run_extension(tmp_path, "--id=rect1", "--stroke_color={}".format(0x336699FF),
"--stroke_width=0.5")
assert re.search(r"stroke:#336699", svg)
assert re.search(r"stroke-width:0\.5\b", svg)
def test_end_to_end_curved_shapes(tmp_path):
svg = run_extension(tmp_path, "--id=circle1", "--id=path1")
assert svg.count("Wavy ribbon pattern") == 2
for x, y in pattern_points(svg):
assert math.hypot(x - 100, y - 30) <= 22 + 0.05
def test_end_to_end_hole_stays_empty(tmp_path):
svg = run_extension(tmp_path, "--id=path2", "--spacing=1")
points = pattern_points(svg)
assert points
assert not [p for p in points if 35.01 < p[0] < 64.99 and 80.01 < p[1] < 99.99]
def test_end_to_end_transformed_parent(tmp_path):
"""Forme dans un groupe deplace : motif en coordonnees absolues, le
groupe produit annule la transformation du parent."""
svg = run_extension(tmp_path, "--id=ellipse1", "--spacing=1")
assert re.search(r'transform="translate\(-100,? -65\)"', svg)
for x, y in pattern_points(svg):
assert ((x - 145) / 40.0) ** 2 + ((y - 90) / 20.0) ** 2 <= 1.01
def test_end_to_end_group_selection(tmp_path):
svg = run_extension(tmp_path, "--id=group1", "--spacing=1")
assert svg.count("Wavy ribbon pattern") == 1
def test_end_to_end_units(tmp_path):
millimetres = pattern_points(run_extension(tmp_path, "--id=rect1", "--spacing=5"))
centimetres = pattern_points(run_extension(tmp_path, "--id=rect1", "--spacing=0.5",
"--unit=cm"))
assert millimetres == centimetres
def test_end_to_end_no_selection(tmp_path, capsys):
"""Sans selection : un message, et le document n'est pas modifie."""
pytest.importorskip("inkex")
from wavy_ribbon_fill import WavyRibbonFill
out = tmp_path / "out.svg"
WavyRibbonFill().run(["--output={}".format(out), SHAPES])
assert "Select at least one shape." in capsys.readouterr().err
assert not out.exists() or "Wavy ribbon pattern" not in out.read_text(encoding="utf-8")
# --------------------------------------------------------------------------
# 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 "Wavy Ribbon 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, avec la meme valeur par defaut."""
root = ET.parse(os.path.join(HERE, "wavy_ribbon_fill.inx")).getroot()
params = {elem.get("name"): (elem.text or "").strip() for elem in root.iter()
if elem.tag.rsplit("}", 1)[-1] == "param"}
with open(os.path.join(HERE, "wavy_ribbon_fill.py"), encoding="utf-8") as handle:
source = handle.read()
arguments = dict(re.findall(r'add_argument\("--(\w+)".*?default=([^)]+)\)', source))
assert set(params) == set(arguments)
for name, default in arguments.items():
if params[name]: # notebook et listes : pas de valeur dans le texte
value = default.strip('"')
if value in ("True", "False"):
assert params[name] == value.lower()
else:
assert float(params[name]) == float(value), name
def test_inx_image_size_is_three_quarters_of_the_png():
"""Schema de l'onglet Help present, affiche aux 3/4 de sa taille."""
import struct
root = ET.parse(os.path.join(HERE, "wavy_ribbon_fill.inx")).getroot()
images = [elem for elem in root.iter() if elem.tag.rsplit("}", 1)[-1] == "image"]
assert images
for elem in images:
path = os.path.join(HERE, elem.text.strip())
assert os.path.isfile(path), elem.text
with open(path, "rb") as handle:
width, height = struct.unpack(">II", handle.read(24)[16:24])
assert int(elem.get("width")) == round(width * 0.75)
assert int(elem.get("height")) == round(height * 0.75)

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

95
wavy_ribbon_fill.inx Normal file
View File

@ -0,0 +1,95 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
Textes source en anglais, traduits par le catalogue « wavyribbonfill »
(locale/<langue>/LC_MESSAGES/wavyribbonfill.mo). Après toute modification
d'un texte : python i18n.py, puis compléter po/fr.po et relancer.
-->
<inkscape-extension translationdomain="wavyribbonfill"
xmlns="http://www.inkscape.org/namespace/inkscape/extension">
<name>Wavy Ribbon Pattern Fill</name>
<id>fr.alexp.wavyribbonfill</id>
<param name="tab" type="notebook">
<page name="pattern" gui-text="Pattern">
<param name="spacing" type="float" precision="3" min="0.05" max="1000"
gui-text="Line spacing:"
gui-description="Distance between two neighbouring lines. Every other pattern dimension is a multiple of it.">2.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>
<separator/>
<param name="lines" type="int" min="2" max="30"
gui-text="Lines per ribbon:">5</param>
<param name="shift" type="int" min="1" max="60"
gui-text="Ribbon shift (line spacings):"
gui-description="How far a ribbon moves sideways along its wave.">6</param>
<param name="height" type="float" precision="2" min="0.5" max="100"
gui-text="Wave height (line spacings):"
gui-description="Height over which one line goes from its low position to its high position.">7.0</param>
<param name="radius" type="float" precision="2" min="0" max="50"
gui-text="Bend radius (line spacings):"
gui-description="Radius of the two bends of a wave, limited to half the wave height. 0 gives straight slanted lines.">2.8</param>
<param name="stagger" type="float" precision="2" min="0" max="10"
gui-text="Line stagger (line spacings):"
gui-description="Vertical offset between one line of a ribbon and the next. 0 makes the ribbon pinch in its slanted part.">1.0</param>
<separator/>
<param name="columns" type="int" min="1" max="200"
gui-text="Column step (line spacings):"
gui-description="Sideways offset between a row of ribbons and the next one; the pattern repeats every two steps.">8</param>
<param name="rows" type="float" precision="2" min="0.5" max="200"
gui-text="Row step (line spacings):"
gui-description="Vertical distance between a row of ribbons and the next one, which leans the other way.">7.75</param>
</page>
<page name="fill" gui-text="Fill">
<param name="angle" type="float" precision="1" min="-180" max="180"
gui-text="Pattern rotation (°):"
gui-description="0 keeps the background lines vertical; positive values turn the pattern clockwise.">0.0</param>
<param name="origin" type="optiongroup" appearance="combo" gui-text="Pattern origin:">
<option value="shape">Centre of each shape (pattern centred)</option>
<option value="document">Document origin (pattern continuous across shapes)</option>
</param>
<separator/>
<param name="stroke_width" type="float" precision="3" min="0.001" max="100"
gui-text="Stroke width (same unit):">0.25</param>
<param name="stroke_color" type="color" appearance="colorbutton"
gui-text="Stroke color:">255</param>
<separator/>
<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 closed shapes (rectangles, ellipses, paths, groups), then run the extension. Each shape is filled with vertical lines crossed by S-shaped ribbons leaning alternately to the right and to the left, cut along the outline of the shape; the holes of a path stay empty.
The line spacing sets the scale: the other pattern settings are counted in line spacings. A ribbon hides the background lines only over its wave, so the ribbon shift and the column step are whole numbers of spacings.
The column step and the row step set how tightly the ribbons are packed. If they end up overlapping, each row passes over the previous one and lines get cut: increase one of the two steps to keep the ribbons apart.
The result is a group named “Wavy ribbon pattern”, placed just above the shape and holding a single path with no fill.</label>
</page>
</param>
<effect>
<object-type>all</object-type>
<effects-menu>
<submenu name="AlexDesign"/>
</effects-menu>
</effect>
<script>
<command location="inx" interpreter="python">wavy_ribbon_fill.py</command>
</script>
</inkscape-extension>

162
wavy_ribbon_fill.py Normal file
View File

@ -0,0 +1,162 @@
#!/usr/bin/env python3
# coding=utf-8
"""
Extension Inkscape : remplit les formes selectionnees d'un motif de rubans ondules.
Le motif est un fond de traits verticaux sur lequel passent des rubans en S,
ranges en quinconce et alternativement inclines a droite et a gauche (effet de
tressage). Il est decoupe par le contour de chaque forme, trous compris.
La logique vit dans `wavy_ribbon_fill_core`, sans dependance a inkex.
"""
import os
import sys
import inkex
from inkex import bezier
from inkex.localization import localize
# 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 wavy_ribbon_fill_core.
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from wavy_ribbon_fill_core import ( # noqa: E402
fill_polylines, parse_color, polylines_to_d, rings_bounds)
# Installe _() dans les builtins : catalogue « wavyribbonfill » de la langue
# de l'interface (Inkscape transmet domaine et dossier par l'environnement),
# textes anglais d'origine sinon.
localize()
# Au-dela, le calcul et le SVG produit deviennent trop lourds pour Inkscape.
MAX_LINES = 3000
class WavyRibbonFill(inkex.EffectExtension):
"""Pose un groupe « motif » au-dessus de chaque element selectionne."""
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("--spacing", type=float, default=2.0)
pars.add_argument("--unit", default="mm")
pars.add_argument("--lines", type=int, default=5)
pars.add_argument("--shift", type=int, default=6)
pars.add_argument("--height", type=float, default=7.0)
pars.add_argument("--radius", type=float, default=2.8)
pars.add_argument("--stagger", type=float, default=1.0)
pars.add_argument("--columns", type=int, default=8)
pars.add_argument("--rows", type=float, default=7.75)
pars.add_argument("--angle", type=float, default=0.0)
pars.add_argument("--origin", default="shape")
pars.add_argument("--stroke_width", type=float, default=0.25)
pars.add_argument("--stroke_color", default="255")
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
spacing = self.svg.unittouu("{}{}".format(opt.spacing, opt.unit))
if spacing <= 0:
inkex.errormsg(_("The line spacing must be strictly positive."))
return
color, opacity = parse_color(opt.stroke_color, ("#000000", 1.0))
style = inkex.Style({
"fill": "none",
"stroke": color,
"stroke-opacity": str(opacity),
"stroke-width": str(self.svg.unittouu("{}{}".format(opt.stroke_width, opt.unit))),
"stroke-linecap": "round",
"stroke-linejoin": "round",
})
for elem in elements:
self.process(elem, spacing, style)
def element_rings(self, elem, flatness):
"""Contours de l'element (ou des formes d'un groupe) en coordonnees
absolues du document, courbes aplaties."""
if isinstance(elem, inkex.Group):
shapes = [node for node in elem.descendants()
if isinstance(node, inkex.ShapeElement) and not isinstance(node, inkex.Group)]
else:
shapes = [elem]
rings = []
for shape in shapes:
if not isinstance(shape, inkex.ShapeElement) or isinstance(
shape, (inkex.TextElement, inkex.Image, inkex.Use)):
continue
path = shape.path.transform(shape.composed_transform())
superpath = path.to_superpath()
bezier.cspsubdiv(superpath, flatness)
for subpath in superpath:
ring = [(node[1][0], node[1][1]) for node in subpath]
if len(ring) >= 3:
rings.append(ring)
return rings
def process(self, elem, spacing, style):
"""Traite un element : calcul dans wavy_ribbon_fill_core, ecriture dans le SVG."""
opt = self.options
name = elem.label or elem.get_id()
rings = self.element_rings(elem, spacing / 20.0)
bounds = rings_bounds(rings)
if bounds is None:
inkex.errormsg(_("“{}” has no usable outline: select closed shapes or paths "
"(convert texts with Path > Object to Path).").format(name))
return
xmin, ymin, xmax, ymax = bounds
if max(xmax - xmin, ymax - ymin) / spacing > MAX_LINES:
inkex.errormsg(_("“{}” would need more than {} lines: increase the line "
"spacing.").format(name, MAX_LINES))
return
# Origine du motif : centre de la forme (motif centre), ou origine du
# document (motif continu d'une forme a l'autre).
origin = (0.0, 0.0)
if opt.origin == "shape":
origin = ((xmin + xmax) / 2.0, (ymin + ymax) / 2.0)
polylines = fill_polylines(
rings, spacing, lines=max(2, opt.lines), shift=max(1, opt.shift),
height=opt.height, radius=max(0.0, opt.radius), stagger=max(0.0, opt.stagger),
columns=max(1, opt.columns), rows=opt.rows, angle=opt.angle, origin=origin)
if not polylines:
inkex.errormsg(_("“{}” is too small for the pattern: reduce the line "
"spacing.").format(name))
return
parent = elem.getparent()
group = inkex.Group()
group.label = _("Wavy ribbon pattern")
if parent is not None:
# Motif 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)
# Un seul chemin : une polyligne par sous-chemin.
node = inkex.PathElement()
node.set("d", polylines_to_d(polylines))
node.style = style
group.add(node)
if not opt.keep_original:
elem.delete()
if __name__ == "__main__":
WavyRibbonFill().run()

500
wavy_ribbon_fill_core.py Normal file
View File

@ -0,0 +1,500 @@
# coding=utf-8
"""
Noyau de calcul de l'extension « Wavy Ribbon Pattern Fill », sans dependance a inkex.
Testable avec pytest seul et reutilisable hors Inkscape (scripts, schema
des parametres).
Le motif : un fond de traits verticaux au pas `spacing`, sur lequel sont poses
des rubans en S. Un ruban compte `lines` traits ; chaque trait est le meme
profil en S (arc, droite, arc) translate de (1 pas, `stagger` pas). Un ruban se
decale lateralement de `shift` pas : ses extremites retombent donc sur les
traits du fond, qu'il masque seulement sur la hauteur de son ondulation. Les
rubans sont ranges en quinconce, une rangee sur deux etant le miroir de l'autre.
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 ;
- une polyligne est une liste de points (x, y) ;
- l'axe y est oriente vers le bas (repere SVG) ;
- les longueurs du motif (`shift`, `height`, `radius`, `stagger`, `columns`,
`rows`) sont exprimees en nombre de pas (`spacing`).
"""
import math
from bisect import bisect_right
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)
def rotate_points(points, angle, center=(0.0, 0.0)):
"""Rotation de `angle` degres autour de `center` (sens horaire a l'ecran,
comme rotate() en SVG)."""
a = math.radians(angle)
cos_a, sin_a = math.cos(a), math.sin(a)
cx, cy = center
return [(cx + (x - cx) * cos_a - (y - cy) * sin_a,
cy + (x - cx) * sin_a + (y - cy) * cos_a) for x, y in points]
def rings_bounds(rings):
"""Boite englobante (xmin, ymin, xmax, ymax), None si aucun point."""
xs = [x for ring in rings for x, _y in ring]
ys = [y for ring in rings for _x, y in ring]
if not xs:
return None
return min(xs), min(ys), max(xs), max(ys)
def wave_profile(shift, height, radius, tolerance):
"""Profil en S d'un trait : arc, droite, arc.
Points (x, v) de (0, 0) a (shift, height), v compte vers le haut ; la
tangente est verticale aux deux bouts. x et v sont croissants : le profil
est une fonction v(x), ce dont depend le calcul des traits masques. Le
rayon est donc borne a height / 2 (au-dela la courbe reviendrait en arriere).
"""
radius = max(0.0, min(radius, height / 2.0))
if radius < tolerance:
return [(0.0, 0.0), (shift, height)]
a, b = shift - 2.0 * radius, height
distance = math.hypot(a, b)
# Angle de la partie droite avec la verticale : tangente interieure commune
# aux deux cercles de rayon `radius` centres en (radius, 0) et
# (shift - radius, height).
alpha = math.atan2(a, b) + math.asin(min(1.0, 2.0 * radius / distance))
step = 2.0 * math.acos(max(-1.0, 1.0 - tolerance / radius))
steps = max(2, int(math.ceil(alpha / step)))
points = []
for k in range(steps + 1):
theta = alpha * k / steps
points.append((radius - radius * math.cos(theta), radius * math.sin(theta)))
for k in range(steps, -1, -1):
theta = alpha * k / steps
point = (shift - radius + radius * math.cos(theta),
height - radius * math.sin(theta))
# Sans partie droite, les deux arcs se rejoignent au meme point.
if math.hypot(point[0] - points[-1][0], point[1] - points[-1][1]) > tolerance * 1e-3:
points.append(point)
points[0] = (0.0, 0.0)
points[-1] = (shift, height)
return points
class EdgeIndex(object):
"""Aretes d'un contour rangees par bandes horizontales, pour decouper des
polylignes sans tester chaque arete."""
def __init__(self, rings):
self.edges = []
for ring in rings:
for k in range(len(ring)):
(x1, y1), (x2, y2) = ring[k - 1], ring[k]
if x1 != x2 or y1 != y2:
self.edges.append((x1, y1, x2, y2))
ys = [e[1] for e in self.edges] + [e[3] for e in self.edges]
self.ymin = min(ys) if ys else 0.0
self.ymax = max(ys) if ys else 0.0
count = max(1, min(1024, len(self.edges) // 2))
self.band = (self.ymax - self.ymin) / count or 1.0
self.bands = [[] for _ in range(count)]
for index, (_x1, y1, _x2, y2) in enumerate(self.edges):
for band in range(self._band(min(y1, y2)), self._band(max(y1, y2)) + 1):
self.bands[band].append(index)
def _band(self, y):
return max(0, min(len(self.bands) - 1, int((y - self.ymin) / self.band)))
def inside(self, x, y):
"""Regle pair-impair."""
if y < self.ymin or y > self.ymax:
return False
inside = False
for index in self.bands[self._band(y)]:
x1, y1, x2, y2 = self.edges[index]
if (y1 > y) != (y2 > y) and x < x1 + (y - y1) * (x2 - x1) / (y2 - y1):
inside = not inside
return inside
def crossings(self, p, q):
"""Positions (entre 0 et 1) ou le segment p-q coupe une arete."""
low, high = min(p[1], q[1]), max(p[1], q[1])
if high < self.ymin or low > self.ymax:
return []
first, last = self._band(low), self._band(high)
if first == last:
candidates = self.bands[first]
else:
candidates = set()
for band in range(first, last + 1):
candidates.update(self.bands[band])
rx, ry = q[0] - p[0], q[1] - p[1]
found = []
for index in candidates:
x1, y1, x2, y2 = self.edges[index]
ex, ey = x2 - x1, y2 - y1
denominator = rx * ey - ry * ex
if denominator == 0.0:
continue
t = ((x1 - p[0]) * ey - (y1 - p[1]) * ex) / denominator
u = ((x1 - p[0]) * ry - (y1 - p[1]) * rx) / denominator
if 0.0 <= t <= 1.0 and 0.0 <= u <= 1.0:
found.append(t)
return sorted(found)
def clip(self, polyline, keep_inside=True):
"""Morceaux de la polyligne situes dans le contour (ou hors de lui)."""
pieces, current, state = [], [], None
for k in range(len(polyline) - 1):
p, q = polyline[k], polyline[k + 1]
if p == q:
continue
cuts = self.crossings(p, q)
if not cuts and state is not None:
spans = [(0.0, 1.0, state)]
else:
# L'etat n'est reteste qu'aux croisements : le milieu de chaque
# troncon decide, ce qui absorbe les contacts sans traversee.
spans, bounds = [], [0.0] + cuts + [1.0]
for t0, t1 in zip(bounds, bounds[1:]):
if t1 - t0 > 1e-9:
tm = (t0 + t1) / 2.0
spans.append((t0, t1, self.inside(p[0] + tm * (q[0] - p[0]),
p[1] + tm * (q[1] - p[1]))))
for t0, t1, inside in spans:
state = inside
if inside != keep_inside:
if len(current) > 1:
pieces.append(current)
current = []
continue
start = p if t0 == 0.0 else (p[0] + t0 * (q[0] - p[0]), p[1] + t0 * (q[1] - p[1]))
end = q if t1 == 1.0 else (p[0] + t1 * (q[0] - p[0]), p[1] + t1 * (q[1] - p[1]))
if not current:
current = [start]
current.append(end)
if len(current) > 1:
pieces.append(current)
return pieces
def subtract_intervals(intervals, holes):
"""Intervalles (a, b) prives des intervalles `holes`."""
for low, high in holes:
kept = []
for a, b in intervals:
if high <= a or low >= b:
kept.append((a, b))
continue
if low > a:
kept.append((a, low))
if high < b:
kept.append((high, b))
intervals = kept
return intervals
def join_polylines(polylines, digits=9):
"""Soude les polylignes qui se suivent (deux extremites exactement au meme
point) et retire les points alignes : moins de sous-chemins, traces continus."""
def key(point):
return round(point[0], digits), round(point[1], digits)
lines = [list(polyline) for polyline in polylines]
owner = list(range(len(lines)))
ends = {}
for index, line in enumerate(lines):
ends.setdefault(key(line[0]), []).append(index)
ends.setdefault(key(line[-1]), []).append(index)
def find(index):
while owner[index] != index:
owner[index] = owner[owner[index]]
index = owner[index]
return index
for point, indexes in ends.items():
if len(indexes) != 2:
continue # bout libre, ou jonction a trois traits
a, b = find(indexes[0]), find(indexes[1])
if a == b:
continue
if key(lines[a][-1]) != point:
lines[a].reverse()
if key(lines[b][0]) != point:
lines[b].reverse()
lines[a].extend(lines[b][1:])
lines[b] = None
owner[b] = a
joined = []
for line in lines:
if line is None:
continue
kept = [line[0]]
for k in range(1, len(line) - 1):
(ax, ay), (bx, by), (cx, cy) = kept[-1], line[k], line[k + 1]
cross = (bx - ax) * (cy - by) - (by - ay) * (cx - bx)
dot = (bx - ax) * (cx - bx) + (by - ay) * (cy - by)
if abs(cross) > 1e-12 * max(1.0, abs(dot)) or dot <= 0:
kept.append(line[k])
kept.append(line[-1])
joined.append(kept)
return joined
class RibbonPattern(object):
"""Geometrie du motif pour un jeu de parametres.
`spacing` est le pas des traits (unites du document) ; `lines` le nombre de
traits d'un ruban ; `shift` son decalage lateral ; `height` la hauteur du S
d'un trait ; `radius` le rayon de ses deux virages ; `stagger` le decalage
vertical d'un trait au suivant ; `columns` et `rows` les pas du quinconce.
Tout sauf `spacing` est en nombre de pas. `origin` cale la grille.
"""
def __init__(self, spacing, lines=5, shift=6, height=7.0, radius=2.8,
stagger=1.0, columns=8, rows=7.75, origin=(0.0, 0.0), tolerance=None):
if spacing <= 0 or height <= 0 or rows <= 0:
raise ValueError("spacing, height et rows doivent etre strictement positifs")
if int(lines) < 2 or int(shift) < 1 or int(columns) < 1:
raise ValueError("lines >= 2, shift >= 1 et columns >= 1")
if stagger < 0 or radius < 0:
# Un decalage negatif ferait se croiser les traits d'un meme ruban.
raise ValueError("stagger et radius doivent etre positifs ou nuls")
self.spacing = float(spacing)
self.lines = int(lines)
self.shift = int(shift)
self.columns = int(columns)
self.height = height * self.spacing
self.stagger = stagger * self.spacing
self.row_step = rows * self.spacing
self.origin = origin
self.span = self.shift + self.lines - 1 # largeur d'un ruban, en pas
self.region_height = self.height + (self.lines - 1) * self.stagger
self.tolerance = tolerance or self.spacing / 200.0
self.profile = wave_profile(self.shift * self.spacing, self.height,
radius * self.spacing, self.tolerance)
self._profile_x = [x for x, _v in self.profile]
def grid_x(self, index):
"""Abscisse du trait de fond numero `index`."""
return self.origin[0] + index * self.spacing
def _rise(self, x):
"""Hauteur v du profil a l'abscisse x (interpolation)."""
xs = self._profile_x
if x <= xs[0]:
return 0.0
if x >= xs[-1]:
return self.height
k = bisect_right(xs, x)
(x0, v0), (x1, v1) = self.profile[k - 1], self.profile[k]
return v0 + (v1 - v0) * (x - x0) / (x1 - x0)
def pieces(self, bounds):
"""Rubans dont l'ondulation touche `bounds` = (xmin, ymin, xmax, ymax).
Un ruban = (rangee, colonne, premier trait de fond, y haut, y bas,
miroir). Les rangees impaires sont en miroir et decalees de `columns`.
"""
xmin, ymin, xmax, ymax = bounds
ox, oy = self.origin
half = self.region_height / 2.0
period = 2 * self.columns
found = []
first_row = int(math.ceil((ymin - half - oy) / self.row_step))
last_row = int(math.floor((ymax + half - oy) / self.row_step))
for row in range(first_row, last_row + 1):
offset = self.columns if row % 2 else 0
top = oy + row * self.row_step - half
first = int(math.ceil(((xmin - ox) / self.spacing - self.span - offset) / period))
last = int(math.floor(((xmax - ox) / self.spacing - offset) / period))
for column in range(first, last + 1):
found.append((row, column, column * period + offset,
top, top + self.region_height, bool(row % 2)))
return found
def _x(self, piece, rel, dx=0.0):
"""Abscisse a `dx` du trait de rang `rel` dans le ruban (miroir gere).
Avec dx nul, c'est exactement grid_x : les bouts se soudent au fond."""
_row, _column, left, _top, _bottom, mirror = piece
if mirror:
return self.grid_x(left + self.span - rel) - dx
return self.grid_x(left + rel) + dx
def piece_curves(self, piece):
"""Les `lines` traits d'un ruban, du bas vers le haut.
Chaque trait est prolonge a la verticale jusqu'aux bords haut et bas
du ruban : sur cette hauteur le fond est masque (hidden_interval), le
ruban porte donc lui-meme ses traits et s'y soude au fond.
"""
top, bottom = piece[3], piece[4]
curves = []
for k in range(self.lines):
low = top + self.height + k * self.stagger
high = top + k * self.stagger
points = [(self._x(piece, k, x), low - v) for x, v in self.profile]
points[0] = (self._x(piece, k), low)
points[-1] = (self._x(piece, k + self.shift), high)
if low < bottom:
points.insert(0, (self._x(piece, k), bottom))
if high > top:
points.append((self._x(piece, k + self.shift), top))
curves.append(points)
return curves
def piece_region(self, piece, inset=0.0):
"""Polygone couvert par l'ondulation d'un ruban, resserre de `inset`
(elargi si negatif)."""
top, bottom = piece[3], piece[4]
dx = -inset if piece[5] else inset
# Rive du premier trait (montante), puis rive du dernier (descendante).
first = [(self._x(piece, 0, x) + dx, top + self.height - v) for x, v in self.profile]
last = [(self._x(piece, self.lines - 1, x) - dx, bottom - v) for x, v in self.profile]
top, bottom = top + inset, bottom - inset
ring = [(first[0][0], bottom)]
ring += [point for point in first if top < point[1] < bottom]
ring += [(first[-1][0], top), (last[-1][0], top)]
ring += [point for point in reversed(last) if top < point[1] < bottom]
ring.append((last[0][0], bottom))
return ring
def hidden_interval(self, piece, index):
"""Intervalle (y haut, y bas) ou le trait de fond `index` passe sous le
ruban, None s'il n'est pas masque."""
_row, _column, left, top, bottom, mirror = piece
rel = index - left
if mirror:
rel = self.span - rel
if rel < 0 or rel > self.span:
return None
last = self.lines - 1
# Masque entre le premier trait et le dernier du ruban, rives comprises
# la ou elles sont verticales (le ruban les trace lui-meme).
low = top if rel >= self.shift else top + self.height - self._rise(rel * self.spacing)
high = bottom if rel <= last else bottom - self._rise((rel - last) * self.spacing)
return (low, high) if high > low else None
def pieces_overlap(self):
"""Vrai si deux rubans peuvent se recouvrir avec ces reglages."""
return (self.region_height > 2 * self.row_step
or self.span > 2 * self.columns
or (self.region_height > self.row_step and self.span > self.columns))
def fill(self, rings):
"""Polylignes du motif decoupees par le contour `rings`."""
rings = [ring for ring in rings if len(ring) >= 3]
bounds = rings_bounds(rings)
if bounds is None:
return []
xmin, ymin, xmax, ymax = bounds
pieces = self.pieces(bounds)
index = EdgeIndex(rings)
polylines = []
# --- Traits de fond : hachures verticales, moins les parties masquees.
covering = {}
for piece in pieces:
for grid in range(piece[2], piece[2] + self.span + 1):
covering.setdefault(grid, []).append(piece)
crossings = {}
ox = self.origin[0]
for x1, y1, x2, y2 in index.edges:
low, high = (x1, x2) if x1 < x2 else (x2, x1)
for grid in range(int(math.floor((low - ox) / self.spacing)),
int(math.ceil((high - ox) / self.spacing)) + 1):
x = self.grid_x(grid)
if (x1 > x) != (x2 > x):
crossings.setdefault(grid, []).append(y1 + (x - x1) * (y2 - y1) / (x2 - x1))
for grid, ys in crossings.items():
ys.sort()
inside = [(a, b) for a, b in zip(ys[0::2], ys[1::2]) if b > a]
hidden = [h for h in (self.hidden_interval(piece, grid)
for piece in covering.get(grid, ())) if h]
x = self.grid_x(grid)
for a, b in subtract_intervals(inside, hidden):
if b - a > self.tolerance * 1e-3:
polylines.append([(x, a), (x, b)])
# --- Rubans : un ruban passe sous ceux des rangees suivantes.
overlap = self.pieces_overlap()
if overlap:
# Elargi d'un rien : un trait confondu avec la rive du ruban du
# dessus est masque, ce ruban le trace deja.
regions = [(piece, EdgeIndex([self.piece_region(piece, -self.tolerance * 1e-2)]))
for piece in pieces]
for piece in pieces:
left, right = sorted((self._x(piece, 0), self._x(piece, self.span)))
if right < xmin or left > xmax or piece[4] < ymin or piece[3] > ymax:
continue
curves = self.piece_curves(piece)
if overlap:
for other, region in regions:
if other[:2] <= piece[:2] or other[3] >= piece[4] or other[4] <= piece[3]:
continue
o_left, o_right = sorted((self._x(other, 0), self._x(other, self.span)))
if o_right <= left or o_left >= right:
continue
curves = [part for curve in curves
for part in region.clip(curve, keep_inside=False)]
for curve in curves:
polylines.extend(index.clip(curve))
return join_polylines(polylines)
def fill_polylines(rings, spacing, lines=5, shift=6, height=7.0, radius=2.8,
stagger=1.0, columns=8, rows=7.75, angle=0.0, origin=(0.0, 0.0)):
"""Motif de rubans ondules decoupe par `rings`.
`angle` (degres, sens horaire a l'ecran) tourne le motif autour de `origin`,
point ou passe un trait de fond et ou est centree une rangee de rubans.
"""
pattern = RibbonPattern(spacing, lines, shift, height, radius, stagger,
columns, rows, origin)
if not angle:
return pattern.fill(rings)
# On calcule dans le repere du motif (traits verticaux), puis on retourne.
local = [rotate_points(ring, -angle, origin) for ring in rings]
return [rotate_points(polyline, angle, origin) for polyline in pattern.fill(local)]