Version initiale
This commit is contained in:
commit
f8152a4ece
15
.gitignore
vendored
Normal file
15
.gitignore
vendored
Normal 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
220
README.md
Normal 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
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
| 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
107
deploy.ps1
Normal 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
BIN
doc/patern.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 46 KiB |
BIN
docs/exemple.png
Normal file
BIN
docs/exemple.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 325 KiB |
BIN
docs/parametres.png
Normal file
BIN
docs/parametres.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 330 KiB |
393
docs/schema_parametres.py
Normal file
393
docs/schema_parametres.py
Normal 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
304
i18n.py
Normal 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
BIN
images/parameters_en.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 217 KiB |
BIN
locale/en/LC_MESSAGES/wavyribbonfill.mo
Normal file
BIN
locale/en/LC_MESSAGES/wavyribbonfill.mo
Normal file
Binary file not shown.
BIN
locale/fr/LC_MESSAGES/wavyribbonfill.mo
Normal file
BIN
locale/fr/LC_MESSAGES/wavyribbonfill.mo
Normal file
Binary file not shown.
164
po/en.po
Normal file
164
po/en.po
Normal 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
164
po/fr.po
Normal 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
155
po/wavyribbonfill.pot
Normal 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
430
test_wavy_ribbon_fill.py
Normal 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
13
tests/data/shapes.svg
Normal 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
95
wavy_ribbon_fill.inx
Normal 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
162
wavy_ribbon_fill.py
Normal 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
500
wavy_ribbon_fill_core.py
Normal 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)]
|
||||||
Loading…
Reference in New Issue
Block a user