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