first commit
This commit is contained in:
commit
4993833586
175
README.md
Normal file
175
README.md
Normal file
@ -0,0 +1,175 @@
|
|||||||
|
# Remplissage motif relief — extension Inkscape
|
||||||
|
|
||||||
|
<table>
|
||||||
|
<tr>
|
||||||
|
<td width="50%" valign="top">
|
||||||
|
<img src="doc/a57cd56c50f5c5bb0b0601f17f08704a.jpg" alt="Motif de référence : figures en Y en relief sur une grille isométrique" width="420">
|
||||||
|
</td>
|
||||||
|
<td valign="top">
|
||||||
|
|
||||||
|
Extension Inkscape 1.x qui remplit l'**intérieur** des formes sélectionnées avec un
|
||||||
|
motif en relief : des figures en « Y » dessinées sur une grille isométrique
|
||||||
|
(arêtes verticales et à ±30°), relevé fidèle de l'image de référence du dossier
|
||||||
|
`doc/`. Les traits sont coupés exactement au bord de la forme, trous compris, et le
|
||||||
|
résultat tient dans un seul chemin par forme.
|
||||||
|
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
> Une capture du résultat dans Inkscape (`docs/capture-desktop.png`) reste à ajouter.
|
||||||
|
|
||||||
|
## Sommaire
|
||||||
|
|
||||||
|
- [Fonctionnalités](#fonctionnalités)
|
||||||
|
- [Installation](#installation)
|
||||||
|
- [Utilisation](#utilisation)
|
||||||
|
- [Paramètres](#paramètres)
|
||||||
|
- [Structure du projet](#structure-du-projet)
|
||||||
|
- [Développement](#développement)
|
||||||
|
|
||||||
|
## Fonctionnalités
|
||||||
|
|
||||||
|
- Motif périodique (maille hexagonale de 5 arêtes) calculé, pas importé : taille,
|
||||||
|
rotation et origine réglables.
|
||||||
|
- Formes gérées : chemins (courbes aplaties), rectangles, cercles, ellipses,
|
||||||
|
polygones, polylignes, et groupes de ces éléments (transformations prises en
|
||||||
|
compte). Les chemins composés avec trous restent vides à l'intérieur des trous.
|
||||||
|
- Deux modes d'origine : centre de chaque forme (motif symétrique) ou origine du
|
||||||
|
document (motif continu d'une forme à l'autre).
|
||||||
|
- Segments alignés fusionnés : peu de nœuds, un seul chemin par forme, pas de
|
||||||
|
`transform` sur les chemins produits.
|
||||||
|
- Option : tracer aussi le contour de la forme ; option : conserver ou supprimer
|
||||||
|
la forme d'origine.
|
||||||
|
- Garde-fou : refus au-delà de 250 000 segments, avec un message d'aide.
|
||||||
|
- Interface traduite en français et en anglais, selon la langue d'Inkscape
|
||||||
|
(anglais pour toute autre langue).
|
||||||
|
- Script de déploiement Windows (`deploy.ps1`).
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
Aucune dépendance externe : l'extension utilise `inkex`, fourni avec Inkscape.
|
||||||
|
|
||||||
|
### Windows
|
||||||
|
|
||||||
|
Fermer Inkscape, puis, dans PowerShell, depuis le dossier du projet :
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
.\deploy.ps1 # installe
|
||||||
|
.\deploy.ps1 -Uninstall # désinstalle
|
||||||
|
.\deploy.ps1 -Force # installe même si Inkscape est ouvert
|
||||||
|
```
|
||||||
|
|
||||||
|
Les fichiers sont copiés dans `%APPDATA%\inkscape\extensions\Remplissage motif relief`.
|
||||||
|
|
||||||
|
### Manuelle
|
||||||
|
|
||||||
|
Copier `relief_fill.inx`, `relief_fill.py`, `relief_core.py`, `images/` et `locale/`
|
||||||
|
dans un sous-dossier du dossier d'extensions utilisateur :
|
||||||
|
|
||||||
|
| Système | Dossier |
|
||||||
|
| ------- | ------- |
|
||||||
|
| Windows | `%APPDATA%\inkscape\extensions\` |
|
||||||
|
| Linux | `~/.config/inkscape/extensions/` |
|
||||||
|
| macOS | `~/Library/Application Support/org.inkscape.Inkscape/config/inkscape/extensions/` |
|
||||||
|
|
||||||
|
Redémarrer Inkscape après l'installation.
|
||||||
|
|
||||||
|
## Utilisation
|
||||||
|
|
||||||
|
1. Sélectionner une ou plusieurs formes.
|
||||||
|
2. Menu **Extensions > AlexDesign > Remplissage motif relief…** (*Relief Pattern Fill*).
|
||||||
|
3. Régler la longueur d'arête, la rotation et l'origine, puis **Appliquer**.
|
||||||
|
|
||||||
|
Le résultat est un groupe « Remplissage relief » placé juste au-dessus de chaque
|
||||||
|
forme sélectionnée.
|
||||||
|
|
||||||
|
**Conseils**
|
||||||
|
|
||||||
|
| Symptôme | Réglage |
|
||||||
|
| -------- | ------- |
|
||||||
|
| Motif trop fin ou trop dense | Augmenter la longueur d'arête |
|
||||||
|
| « Trop de traits » | Augmenter la longueur d'arête ou sélectionner une forme plus petite |
|
||||||
|
| « Aucun trait n'y tient » | Réduire la longueur d'arête |
|
||||||
|
| Motif discontinu entre deux formes voisines | Choisir l'origine « Origine du document » |
|
||||||
|
| Texte non rempli | Le convertir d'abord en chemins (Chemin > Objet en chemin) |
|
||||||
|
|
||||||
|
## Paramètres
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
| Paramètre | Défaut | Rôle |
|
||||||
|
| --------- | ------ | ---- |
|
||||||
|
| Longueur d'arête | 5 mm | Arête unitaire de la grille ; le motif se répète toutes les 5 arêtes |
|
||||||
|
| Unité | mm | Unité de la longueur d'arête et de l'épaisseur des traits (mm, cm, px, pt, in) |
|
||||||
|
| Rotation (degrés, sens horaire) | 0 | Fait tourner le motif autour de l'origine du motif |
|
||||||
|
| Origine du motif | Centre de chaque forme | Nœud de la grille au centre de chaque forme, ou à l'origine du document |
|
||||||
|
| Couleur des traits | noir | Couleur (et opacité) des traits |
|
||||||
|
| Épaisseur des traits | 0,3 | Dans l'unité de l'onglet Motif |
|
||||||
|
| Dessiner aussi le contour de la forme | non | Ajoute un chemin avec le contour de la forme |
|
||||||
|
| Conserver la forme d'origine | oui | Sinon la forme sélectionnée est supprimée |
|
||||||
|
|
||||||
|
## Structure du projet
|
||||||
|
|
||||||
|
```text
|
||||||
|
relief_fill.inx Boîte de dialogue (textes source en anglais)
|
||||||
|
relief_fill.py Couche inkex : sélection, unités, aplatissement, écriture SVG
|
||||||
|
relief_core.py Logique pure, sans inkex : motif, découpe
|
||||||
|
i18n.py Extraction / mise à jour / compilation des traductions
|
||||||
|
po/ relief_fill.pot, en.po, fr.po
|
||||||
|
locale/<lang>/LC_MESSAGES/relief_fill.mo (générés, déployés)
|
||||||
|
images/parameters_en.png Schéma de l'onglet « Help » (déployé)
|
||||||
|
docs/schema_parametres.py Générateur du schéma (fr pour le README, en pour le dialogue)
|
||||||
|
docs/parametres.png Schéma du README
|
||||||
|
doc/ Image de référence du motif
|
||||||
|
tests/data/ SVG d'exemple pour les tests de bout en bout
|
||||||
|
test_relief_fill.py Tests pytest
|
||||||
|
deploy.ps1 Déploiement / désinstallation Windows
|
||||||
|
```
|
||||||
|
|
||||||
|
API du noyau (`relief_core.py`) :
|
||||||
|
|
||||||
|
| Fonction | Rôle |
|
||||||
|
| -------- | ---- |
|
||||||
|
| `fill_rings(rings, edge, angle=0, anchor=(0, 0))` | Segments du motif à l'intérieur d'un contour (pair-impair) |
|
||||||
|
| `pattern_segments(bbox, edge)` | Segments du motif couvrant une boîte, nœud (0, 0) à l'origine |
|
||||||
|
| `clip_segment(p, q, rings)` | Parties d'un segment situées dans le contour |
|
||||||
|
| `point_in_rings(point, rings)` | Appartenance au contour, règle pair-impair |
|
||||||
|
| `rotate_point(point, angle, center)` | Rotation (sens horaire à l'écran) |
|
||||||
|
| `rings_bbox(rings)` | Boîte englobante |
|
||||||
|
| `segments_to_polylines(segments)` / `polylines_to_d(polylines)` | Conversion vers les données `d` d'un chemin SVG |
|
||||||
|
| `parse_color(value)` | Couleur Inkscape (entier RGBA) → `#rrggbb` + opacité |
|
||||||
|
|
||||||
|
## Développement
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
python -m venv .venv ; .\.venv\Scripts\Activate.ps1
|
||||||
|
pip install pytest lxml tinycss2 cssselect2 cssselect
|
||||||
|
python -m pytest -q # 23 tests passés, 11 ignorés (sans inkex)
|
||||||
|
$env:PYTHONPATH = 'C:\Program Files\Inkscape\share\inkscape\extensions'
|
||||||
|
python -m pytest -q # 34 tests passés (avec inkex)
|
||||||
|
```
|
||||||
|
|
||||||
|
Les tests couvrent le noyau (directions, périodicité, fusion des arêtes, découpe,
|
||||||
|
trous, rotation, ancrage), le bout en bout (formes, courbes, trou, groupe
|
||||||
|
transformé, échelle de page non standard, options) et la cohérence des
|
||||||
|
traductions et du `.inx`.
|
||||||
|
|
||||||
|
### Traductions
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
python i18n.py # extrait, met à jour et compile ; liste les msgstr manquants
|
||||||
|
```
|
||||||
|
|
||||||
|
Après modification d'un texte du `.inx` ou d'un `_("...")` : lancer `python i18n.py`,
|
||||||
|
compléter les `msgstr` vides de `po/fr.po`, relancer. Ne jamais éditer les `.mo`.
|
||||||
|
Pour ajouter une langue : l'ajouter à `LANGUAGES` dans `i18n.py`.
|
||||||
|
|
||||||
|
### Schéma des paramètres
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
python docs\schema_parametres.py # fr -> docs/parametres.png, en -> images/parameters_en.png
|
||||||
|
```
|
||||||
|
|
||||||
|
L'export PNG passe par `inkscape.com`. Le schéma est calculé avec les vraies
|
||||||
|
fonctions de `relief_core`.
|
||||||
107
deploy.ps1
Normal file
107
deploy.ps1
Normal file
@ -0,0 +1,107 @@
|
|||||||
|
<#
|
||||||
|
.SYNOPSIS
|
||||||
|
Déploie l'extension « Remplissage motif relief » dans Inkscape.
|
||||||
|
|
||||||
|
.DESCRIPTION
|
||||||
|
Copie relief_fill.inx, relief_fill.py, relief_core.py, le schéma de l'onglet
|
||||||
|
« Help » (images\parameters_en.png) et les traductions
|
||||||
|
compilées (locale\<langue>\LC_MESSAGES\relief_fill.mo) dans un
|
||||||
|
sous-dossier dédié du répertoire des extensions utilisateur d'Inkscape
|
||||||
|
(Inkscape explore récursivement ce répertoire). Refuse de s'exécuter si
|
||||||
|
Inkscape est ouvert : les extensions ne sont chargées qu'au démarrage.
|
||||||
|
|
||||||
|
.PARAMETER ExtensionsDir
|
||||||
|
Répertoire des extensions Inkscape. Par défaut : %APPDATA%\inkscape\extensions
|
||||||
|
|
||||||
|
.PARAMETER FolderName
|
||||||
|
Nom du sous-dossier de l'extension. Par défaut : Remplissage motif relief
|
||||||
|
|
||||||
|
.PARAMETER Uninstall
|
||||||
|
Supprime le sous-dossier de l'extension au lieu de le créer.
|
||||||
|
|
||||||
|
.PARAMETER Force
|
||||||
|
Déploie même si Inkscape est en cours d'exécution.
|
||||||
|
|
||||||
|
.EXAMPLE
|
||||||
|
.\deploy.ps1
|
||||||
|
.\deploy.ps1 -Uninstall
|
||||||
|
.\deploy.ps1 -FolderName 'autre-nom'
|
||||||
|
#>
|
||||||
|
|
||||||
|
[CmdletBinding()]
|
||||||
|
param(
|
||||||
|
[string] $ExtensionsDir = (Join-Path $env:APPDATA 'inkscape\extensions'),
|
||||||
|
[string] $FolderName = 'Remplissage motif relief',
|
||||||
|
[switch] $Uninstall,
|
||||||
|
[switch] $Force
|
||||||
|
)
|
||||||
|
|
||||||
|
$ErrorActionPreference = 'Stop'
|
||||||
|
|
||||||
|
$files = @('relief_fill.inx', 'relief_fill.py', 'relief_core.py', 'images\parameters_en.png')
|
||||||
|
$localeDir = 'locale'
|
||||||
|
$source = $PSScriptRoot
|
||||||
|
$destination = Join-Path $ExtensionsDir $FolderName
|
||||||
|
|
||||||
|
# --- Inkscape doit être fermé : les extensions sont lues au démarrage ---------
|
||||||
|
$running = Get-Process -Name 'inkscape' -ErrorAction SilentlyContinue
|
||||||
|
if ($running -and -not $Force) {
|
||||||
|
Write-Error "Inkscape est ouvert. Fermez-le puis relancez le script (ou utilisez -Force)."
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
# --- Désinstallation ---------------------------------------------------------
|
||||||
|
if ($Uninstall) {
|
||||||
|
if (Test-Path -LiteralPath $destination) {
|
||||||
|
Remove-Item -LiteralPath $destination -Recurse -Force
|
||||||
|
Write-Host "Supprimé : $destination" -ForegroundColor Green
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
Write-Host "Rien à supprimer : $destination n'existe pas." -ForegroundColor Yellow
|
||||||
|
}
|
||||||
|
exit 0
|
||||||
|
}
|
||||||
|
|
||||||
|
# --- Vérification des sources ------------------------------------------------
|
||||||
|
$missing = $files | Where-Object { -not (Test-Path -LiteralPath (Join-Path $source $_)) }
|
||||||
|
if (-not (Test-Path -LiteralPath (Join-Path $source $localeDir))) {
|
||||||
|
$missing = @($missing) + "$localeDir\ (lancer : python i18n.py)"
|
||||||
|
}
|
||||||
|
if ($missing) {
|
||||||
|
Write-Error "Fichier(s) introuvable(s) dans $source : $($missing -join ', ')"
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
# --- Copie -------------------------------------------------------------------
|
||||||
|
if (-not (Test-Path -LiteralPath $destination)) {
|
||||||
|
New-Item -ItemType Directory -Path $destination -Force | Out-Null
|
||||||
|
Write-Host "Dossier créé : $destination"
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($file in $files) {
|
||||||
|
$from = Join-Path $source $file
|
||||||
|
$to = Join-Path $destination $file
|
||||||
|
$action = if (Test-Path -LiteralPath $to) { 'remplacé' } else { 'copié ' }
|
||||||
|
New-Item -ItemType Directory -Path (Split-Path $to) -Force | Out-Null
|
||||||
|
Copy-Item -LiteralPath $from -Destination $to -Force
|
||||||
|
Write-Host " $action $file"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Traductions : le dossier est remplacé en entier (langue retirée => supprimée)
|
||||||
|
$localeTo = Join-Path $destination $localeDir
|
||||||
|
if (Test-Path -LiteralPath $localeTo) {
|
||||||
|
Remove-Item -LiteralPath $localeTo -Recurse -Force
|
||||||
|
}
|
||||||
|
Copy-Item -LiteralPath (Join-Path $source $localeDir) -Destination $localeTo -Recurse
|
||||||
|
$languages = Get-ChildItem -LiteralPath $localeTo -Directory | ForEach-Object Name
|
||||||
|
Write-Host " copié $localeDir\ ($($languages -join ', '))"
|
||||||
|
|
||||||
|
# Le cache Python d'une version précédente peut masquer les fichiers copiés
|
||||||
|
$pycache = Join-Path $destination '__pycache__'
|
||||||
|
if (Test-Path -LiteralPath $pycache) {
|
||||||
|
Remove-Item -LiteralPath $pycache -Recurse -Force
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "Extension déployée dans $destination" -ForegroundColor Green
|
||||||
|
Write-Host "Redémarrez Inkscape, puis : Extensions > AlexDesign > Remplissage motif relief..."
|
||||||
BIN
docs/parametres.png
Normal file
BIN
docs/parametres.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 450 KiB |
363
docs/schema_parametres.py
Normal file
363
docs/schema_parametres.py
Normal file
@ -0,0 +1,363 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
# coding=utf-8
|
||||||
|
"""
|
||||||
|
Genere le schema explicatif des parametres de l'extension.
|
||||||
|
|
||||||
|
python docs/schema_parametres.py # les deux versions
|
||||||
|
python docs/schema_parametres.py fr # docs/parametres.svg + .png (README)
|
||||||
|
python docs/schema_parametres.py en # docs/parameters_en.svg
|
||||||
|
# + images/parameters_en.png (onglet
|
||||||
|
# « Help » de la boite de dialogue)
|
||||||
|
|
||||||
|
Les formes sont calculees par relief_core (memes fonctions que l'extension),
|
||||||
|
le schema reste donc fidele au resultat reel. L'export PNG passe par
|
||||||
|
inkscape.com, qui attend la fin de l'export (contrairement au raccourci
|
||||||
|
Chocolatey « inkscape »).
|
||||||
|
"""
|
||||||
|
|
||||||
|
import math
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
|
||||||
|
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||||
|
ROOT = os.path.dirname(HERE)
|
||||||
|
sys.path.insert(0, ROOT)
|
||||||
|
|
||||||
|
import relief_core # noqa: E402
|
||||||
|
|
||||||
|
W, H = 1400, 1280 # 4 panneaux de 700 x 640
|
||||||
|
|
||||||
|
INK = "#343a40" # element principal
|
||||||
|
GHOST = "#dde1e5" # elements secondaires / voisins
|
||||||
|
DIM = "#d9480f" # cotes
|
||||||
|
DIM2 = "#1971c2" # cotes secondaires
|
||||||
|
EDGE = "#212529" # contour des formes
|
||||||
|
FONT = "font-family:Arial,Helvetica,sans-serif"
|
||||||
|
|
||||||
|
INKSCAPE = [r"C:\Program Files\Inkscape\bin\inkscape.com", "/usr/bin/inkscape",
|
||||||
|
"/Applications/Inkscape.app/Contents/MacOS/inkscape"]
|
||||||
|
|
||||||
|
# Version par langue : textes, fichiers produits, largeur du PNG et agrandissement
|
||||||
|
# des textes (la version anglaise est affichee reduite dans la boite de dialogue :
|
||||||
|
# 900 px de large, textes x 1.25 pour rester lisibles).
|
||||||
|
VERSIONS = {
|
||||||
|
"fr": {
|
||||||
|
"svg": os.path.join(HERE, "parametres.svg"),
|
||||||
|
"png": os.path.join(HERE, "parametres.png"),
|
||||||
|
"png_width": 1400,
|
||||||
|
"font_scale": 1.0,
|
||||||
|
"text": {
|
||||||
|
"title1": "Longueur d'arête",
|
||||||
|
"edge": "arête",
|
||||||
|
"period": "5 arêtes (période)",
|
||||||
|
"note1": ["Le motif se répète toutes les 5 arêtes :",
|
||||||
|
"plus l'arête est grande, plus il est grossier."],
|
||||||
|
"title2": "Rotation",
|
||||||
|
"angle": "angle",
|
||||||
|
"ref": "avant",
|
||||||
|
"note2": ["Le motif tourne dans le sens horaire,",
|
||||||
|
"autour de l'origine du motif."],
|
||||||
|
"title3": "Origine du motif",
|
||||||
|
"row_shape": "Centre de chaque forme",
|
||||||
|
"row_doc": "Origine du document",
|
||||||
|
"note3": ["Centre : un nœud de la grille au milieu de chaque forme.",
|
||||||
|
"Document : un seul motif continu d'une forme à l'autre."],
|
||||||
|
"title4": "Résultat",
|
||||||
|
"hole": "le trou reste vide",
|
||||||
|
"outline": "contour (option)",
|
||||||
|
"lines": "traits coupés au bord",
|
||||||
|
"note4": ["Un groupe au-dessus de la forme, avec un chemin",
|
||||||
|
"par forme ; formes à trous prises en charge."],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"en": {
|
||||||
|
"svg": os.path.join(HERE, "parameters_en.svg"),
|
||||||
|
"png": os.path.join(ROOT, "images", "parameters_en.png"),
|
||||||
|
"png_width": 900,
|
||||||
|
"font_scale": 1.25,
|
||||||
|
"text": {
|
||||||
|
"title1": "Edge length",
|
||||||
|
"edge": "edge",
|
||||||
|
"period": "5 edges (period)",
|
||||||
|
"note1": ["The pattern repeats every 5 edges:",
|
||||||
|
"a longer edge gives a coarser pattern."],
|
||||||
|
"title2": "Rotation",
|
||||||
|
"angle": "angle",
|
||||||
|
"ref": "before",
|
||||||
|
"note2": ["The pattern turns clockwise,",
|
||||||
|
"around the pattern origin."],
|
||||||
|
"title3": "Pattern origin",
|
||||||
|
"row_shape": "Center of each shape",
|
||||||
|
"row_doc": "Document origin",
|
||||||
|
"note3": ["Center: a grid node in the middle of each shape.",
|
||||||
|
"Document: one continuous pattern across shapes."],
|
||||||
|
"title4": "Result",
|
||||||
|
"hole": "the hole stays empty",
|
||||||
|
"outline": "outline (option)",
|
||||||
|
"lines": "lines cut at the edge",
|
||||||
|
"note4": ["A group above the shape, with one path",
|
||||||
|
"per shape; shapes with holes are supported."],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _unit(angle):
|
||||||
|
"""Vecteur unitaire, angle en degres, sens anti-horaire a l'ecran (y vers le bas)."""
|
||||||
|
a = math.radians(angle)
|
||||||
|
return math.cos(a), -math.sin(a)
|
||||||
|
|
||||||
|
|
||||||
|
out = []
|
||||||
|
T = {}
|
||||||
|
FS = 1.0
|
||||||
|
|
||||||
|
|
||||||
|
def fmt(v):
|
||||||
|
return "{:.2f}".format(v).rstrip("0").rstrip(".")
|
||||||
|
|
||||||
|
|
||||||
|
def pts(polyline):
|
||||||
|
return " ".join("{},{}".format(fmt(x), fmt(y)) for x, y in polyline)
|
||||||
|
|
||||||
|
|
||||||
|
def polyline(points, color, width, extra=""):
|
||||||
|
out.append('<polyline points="{}" fill="none" stroke="{}" stroke-width="{}" '
|
||||||
|
'stroke-linejoin="round" stroke-linecap="round" {}/>'.format(
|
||||||
|
pts(points), color, fmt(width), extra))
|
||||||
|
|
||||||
|
|
||||||
|
def motif(polylines, color, width):
|
||||||
|
for pl in polylines:
|
||||||
|
polyline(pl, color, width)
|
||||||
|
|
||||||
|
|
||||||
|
def text(x, y, s, size=17, color=INK, anchor="start", weight="normal", halo=True):
|
||||||
|
style = "{};font-size:{}px;font-weight:{};fill:{}".format(
|
||||||
|
FONT, fmt(size * FS), weight, color)
|
||||||
|
if halo:
|
||||||
|
style += ";paint-order:stroke;stroke:#ffffff;stroke-width:5px;stroke-linejoin:round"
|
||||||
|
out.append('<text x="{}" y="{}" text-anchor="{}" style="{}">{}</text>'.format(
|
||||||
|
fmt(x), fmt(y), anchor, style, s))
|
||||||
|
|
||||||
|
|
||||||
|
def note(x, y, lines):
|
||||||
|
"""Legende de bas de panneau, derniere ligne sur la ligne de base `y`."""
|
||||||
|
step = 22 * FS
|
||||||
|
for k, line in enumerate(lines):
|
||||||
|
text(x, y - (len(lines) - 1 - k) * step, line, size=16, halo=False)
|
||||||
|
|
||||||
|
|
||||||
|
def arrow_head(tip, direction, color, size=9):
|
||||||
|
ux, uy = direction
|
||||||
|
nx, ny = -uy, ux
|
||||||
|
base = (tip[0] - size * ux, tip[1] - size * uy)
|
||||||
|
out.append('<polygon points="{}" fill="{}"/>'.format(pts([
|
||||||
|
tip, (base[0] + 0.45 * size * nx, base[1] + 0.45 * size * ny),
|
||||||
|
(base[0] - 0.45 * size * nx, base[1] - 0.45 * size * ny)]), color))
|
||||||
|
|
||||||
|
|
||||||
|
def dimension(p, q, offset, label, color=DIM, label_pos=0.5, label_shift=18, size=17):
|
||||||
|
"""Cote entre p et q, decalee de `offset` perpendiculairement."""
|
||||||
|
dx, dy = q[0] - p[0], q[1] - p[1]
|
||||||
|
length = math.hypot(dx, dy)
|
||||||
|
ux, uy = dx / length, dy / length
|
||||||
|
nx, ny = -uy, ux
|
||||||
|
a = (p[0] + offset * nx, p[1] + offset * ny)
|
||||||
|
b = (q[0] + offset * nx, q[1] + offset * ny)
|
||||||
|
sign = 1 if offset >= 0 else -1
|
||||||
|
for base, end in ((p, a), (q, b)):
|
||||||
|
polyline([(base[0] + sign * 4 * nx, base[1] + sign * 4 * ny),
|
||||||
|
(end[0] + sign * 6 * nx, end[1] + sign * 6 * ny)], color, 1,
|
||||||
|
'stroke-dasharray="3,3"')
|
||||||
|
polyline([a, b], color, 1.6)
|
||||||
|
arrow_head(a, (-ux, -uy), color)
|
||||||
|
arrow_head(b, (ux, uy), color)
|
||||||
|
lx = a[0] + label_pos * (b[0] - a[0]) + sign * label_shift * FS * nx
|
||||||
|
ly = a[1] + label_pos * (b[1] - a[1]) + sign * label_shift * FS * ny + 6 * FS
|
||||||
|
text(lx, ly, label, size=size, color=color, anchor="middle", weight="bold")
|
||||||
|
|
||||||
|
|
||||||
|
def arc(center, radius, a0, a1, color, label=None, label_radius=None, size=16):
|
||||||
|
"""Arc de a0 a a1 degres (sens anti-horaire a l'ecran), fleche au bout."""
|
||||||
|
steps = 40
|
||||||
|
points = []
|
||||||
|
for k in range(steps + 1):
|
||||||
|
ux, uy = _unit(a0 + (a1 - a0) * k / steps)
|
||||||
|
points.append((center[0] + radius * ux, center[1] + radius * uy))
|
||||||
|
polyline(points[:-2], color, 1.6)
|
||||||
|
last, before = points[-1], points[-3]
|
||||||
|
d = math.hypot(last[0] - before[0], last[1] - before[1])
|
||||||
|
arrow_head(last, ((last[0] - before[0]) / d, (last[1] - before[1]) / d), color, 8)
|
||||||
|
if label:
|
||||||
|
ux, uy = _unit((a0 + a1) / 2)
|
||||||
|
r = label_radius or radius + 24
|
||||||
|
text(center[0] + r * ux, center[1] + r * uy + 6 * FS, label, size=size,
|
||||||
|
color=color, anchor="middle", weight="bold")
|
||||||
|
|
||||||
|
|
||||||
|
def circle_ring(cx, cy, r, n=180):
|
||||||
|
return [(cx + r * math.cos(2 * math.pi * k / n), cy + r * math.sin(2 * math.pi * k / n))
|
||||||
|
for k in range(n)]
|
||||||
|
|
||||||
|
|
||||||
|
def rect_ring(x, y, w, h):
|
||||||
|
return [(x, y), (x + w, y), (x + w, y + h), (x, y + h)]
|
||||||
|
|
||||||
|
|
||||||
|
def panel_title(x, y, number, title):
|
||||||
|
text(x, y, "{} {}".format(number, title), size=22, weight="bold", halo=False)
|
||||||
|
|
||||||
|
|
||||||
|
def fill(rings, edge, angle, anchor, color, width):
|
||||||
|
"""Dessine le motif de relief_core dans `rings`."""
|
||||||
|
segments = relief_core.fill_rings(rings, edge, angle, anchor)
|
||||||
|
motif(relief_core.segments_to_polylines(segments), color, width)
|
||||||
|
|
||||||
|
|
||||||
|
def outline(rings, color, width, extra=""):
|
||||||
|
for ring in rings:
|
||||||
|
polyline(ring + [ring[0]], color, width, extra)
|
||||||
|
|
||||||
|
|
||||||
|
def marker(point, color=DIM):
|
||||||
|
out.append('<circle cx="{}" cy="{}" r="6" fill="{}" stroke="#ffffff" '
|
||||||
|
'stroke-width="2"/>'.format(fmt(point[0]), fmt(point[1]), color))
|
||||||
|
|
||||||
|
|
||||||
|
def label_with_leader(anchor_point, label_point, label, color=DIM, align="start"):
|
||||||
|
polyline([anchor_point, label_point], color, 1.4)
|
||||||
|
out.append('<circle cx="{}" cy="{}" r="3.5" fill="{}"/>'.format(
|
||||||
|
fmt(anchor_point[0]), fmt(anchor_point[1]), color))
|
||||||
|
dx = 8 if align == "start" else -8
|
||||||
|
text(label_point[0] + dx, label_point[1] + 5 * FS, label, size=16, color=color,
|
||||||
|
anchor=align, weight="bold")
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
# Panneaux : un par parametre (ou groupe de parametres), numerotes 1 a 4.
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def panel_1():
|
||||||
|
panel_title(30, 50, 1, T["title1"])
|
||||||
|
box = rect_ring(60, 90, 580, 470)
|
||||||
|
cx, cy = 350.0, 325.0
|
||||||
|
edge = 44.0
|
||||||
|
fill([box], edge, 0, (cx, cy), INK, 2.2)
|
||||||
|
outline([box], EDGE, 2.6)
|
||||||
|
hx = edge * relief_core.COS30
|
||||||
|
# une arete verticale presente du motif (noeud (0,2) -> (0,4)) et la periode
|
||||||
|
p, q = (cx, cy + edge), (cx, cy + 2 * edge)
|
||||||
|
dimension(p, q, -34, T["edge"], DIM, label_shift=24)
|
||||||
|
dimension((cx, cy - 3 * edge), (cx, cy + 2 * edge), 36,
|
||||||
|
T["period"], DIM2, label_shift=18)
|
||||||
|
note(30, 610, T["note1"])
|
||||||
|
|
||||||
|
|
||||||
|
def panel_2():
|
||||||
|
ox = 700
|
||||||
|
panel_title(ox + 30, 50, 2, T["title2"])
|
||||||
|
cx, cy = ox + 350.0, 325.0
|
||||||
|
shape = [circle_ring(cx, cy, 225)]
|
||||||
|
edge = 44.0
|
||||||
|
fill(shape, edge, 0, (cx, cy), GHOST, 2.0)
|
||||||
|
fill(shape, edge, 20, (cx, cy), INK, 2.2)
|
||||||
|
outline(shape, EDGE, 2.6)
|
||||||
|
# reference verticale avant rotation, et verticale tournee
|
||||||
|
ux, uy = _unit(90)
|
||||||
|
polyline([(cx, cy), (cx + 190 * ux, cy + 190 * uy)], DIM2, 1.6, 'stroke-dasharray="6,4"')
|
||||||
|
ux, uy = _unit(70)
|
||||||
|
polyline([(cx, cy), (cx + 190 * ux, cy + 190 * uy)], DIM, 1.8)
|
||||||
|
arc((cx, cy), 120, 90, 70, DIM, T["angle"], label_radius=146)
|
||||||
|
marker((cx, cy))
|
||||||
|
text(cx - 10, cy - 196, T["ref"], size=16, color=DIM2, anchor="end", weight="bold")
|
||||||
|
note(ox + 30, 610, T["note2"])
|
||||||
|
|
||||||
|
|
||||||
|
def panel_3():
|
||||||
|
oy = 640
|
||||||
|
panel_title(30, oy + 50, 3, T["title3"])
|
||||||
|
edge = 26.0
|
||||||
|
rects = [rect_ring(70, oy + 120, 250, 150), rect_ring(350, oy + 120, 250, 150)]
|
||||||
|
rects2 = [rect_ring(70, oy + 340, 250, 150), rect_ring(350, oy + 340, 250, 150)]
|
||||||
|
# rangee A : un noeud au centre de chaque forme
|
||||||
|
text(70, oy + 100, T["row_shape"], size=17, color=DIM, weight="bold", halo=False)
|
||||||
|
for ring in rects:
|
||||||
|
center = (ring[0][0] + 125, ring[0][1] + 75)
|
||||||
|
fill([ring], edge, 0, center, INK, 1.8)
|
||||||
|
outline([ring], EDGE, 2.4)
|
||||||
|
marker(center)
|
||||||
|
# rangee B : grille ancree au coin de la page, motif continu
|
||||||
|
text(70, oy + 320, T["row_doc"], size=17, color=DIM2, weight="bold", halo=False)
|
||||||
|
band = rect_ring(40, oy + 330, 590, 170)
|
||||||
|
fill([band], edge, 0, (0.0, 0.0), GHOST, 1.6)
|
||||||
|
for ring in rects2:
|
||||||
|
fill([ring], edge, 0, (0.0, 0.0), INK, 1.8)
|
||||||
|
outline([ring], EDGE, 2.4)
|
||||||
|
note(30, oy + 610, T["note3"])
|
||||||
|
|
||||||
|
|
||||||
|
def panel_4():
|
||||||
|
ox, oy = 700, 640
|
||||||
|
panel_title(ox + 30, oy + 50, 4, T["title4"])
|
||||||
|
cx, cy = ox + 280.0, oy + 330.0
|
||||||
|
# forme a trou : cercle exterieur, cercle interieur decale
|
||||||
|
outer = circle_ring(cx, cy, 170)
|
||||||
|
inner = circle_ring(cx + 45, cy + 20, 55)
|
||||||
|
rings = [outer, inner]
|
||||||
|
fill(rings, 26.0, 0, (cx, cy), INK, 2.0)
|
||||||
|
outline(rings, DIM, 3.2)
|
||||||
|
label_with_leader((cx + 45 + 55, cy + 20), (cx + 185, cy + 20), T["hole"], DIM, "start")
|
||||||
|
label_with_leader((cx - 170 * 0.866, cy - 170 * 0.5), (ox + 40, cy - 190),
|
||||||
|
T["outline"], DIM, "start")
|
||||||
|
label_with_leader((cx - 60, cy + 95), (ox + 40, cy + 215), T["lines"], DIM2, "start")
|
||||||
|
note(ox + 30, oy + 610, T["note4"])
|
||||||
|
|
||||||
|
|
||||||
|
def export_png(svg_path, png_path, width):
|
||||||
|
exe = next((path for path in INKSCAPE if os.path.exists(path)), None)
|
||||||
|
if exe is None:
|
||||||
|
print("Inkscape introuvable : exporter {} a la main".format(svg_path))
|
||||||
|
return
|
||||||
|
os.makedirs(os.path.dirname(png_path), exist_ok=True)
|
||||||
|
subprocess.run([exe, svg_path, "--export-type=png",
|
||||||
|
"--export-width={}".format(width),
|
||||||
|
"--export-filename={}".format(png_path)],
|
||||||
|
check=True, stderr=subprocess.DEVNULL)
|
||||||
|
print(png_path)
|
||||||
|
|
||||||
|
|
||||||
|
def build(language):
|
||||||
|
global FS
|
||||||
|
version = VERSIONS[language]
|
||||||
|
out.clear()
|
||||||
|
T.clear()
|
||||||
|
T.update(version["text"])
|
||||||
|
FS = version["font_scale"]
|
||||||
|
|
||||||
|
out.append('<rect width="{}" height="{}" fill="#ffffff"/>'.format(W, H))
|
||||||
|
for x1, y1, x2, y2 in ((700, 20, 700, H - 20), (20, 640, W - 20, 640)):
|
||||||
|
out.append('<line x1="{}" y1="{}" x2="{}" y2="{}" stroke="#dee2e6" '
|
||||||
|
'stroke-width="1.5"/>'.format(x1, y1, x2, y2))
|
||||||
|
panel_1()
|
||||||
|
panel_2()
|
||||||
|
panel_3()
|
||||||
|
panel_4()
|
||||||
|
svg = ('<?xml version="1.0" encoding="UTF-8"?>\n'
|
||||||
|
'<svg xmlns="http://www.w3.org/2000/svg" width="{0}" height="{1}" '
|
||||||
|
'viewBox="0 0 {0} {1}">\n{2}\n</svg>\n').format(W, H, "\n".join(out))
|
||||||
|
with open(version["svg"], "w", encoding="utf-8", newline="\n") as handle:
|
||||||
|
handle.write(svg)
|
||||||
|
print(version["svg"])
|
||||||
|
export_png(version["svg"], version["png"], version["png_width"])
|
||||||
|
|
||||||
|
|
||||||
|
def main(argv):
|
||||||
|
languages = argv or sorted(VERSIONS)
|
||||||
|
for language in languages:
|
||||||
|
build(language)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main(sys.argv[1:])
|
||||||
304
i18n.py
Normal file
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: 261 KiB |
BIN
locale/en/LC_MESSAGES/relief_fill.mo
Normal file
BIN
locale/en/LC_MESSAGES/relief_fill.mo
Normal file
Binary file not shown.
BIN
locale/fr/LC_MESSAGES/relief_fill.mo
Normal file
BIN
locale/fr/LC_MESSAGES/relief_fill.mo
Normal file
Binary file not shown.
116
po/en.po
Normal file
116
po/en.po
Normal file
@ -0,0 +1,116 @@
|
|||||||
|
# Traduction (en) de l'extension Inkscape « Relief Pattern Fill ».
|
||||||
|
# Genere par i18n.py ; ne modifier que les msgstr.
|
||||||
|
msgid ""
|
||||||
|
msgstr ""
|
||||||
|
"Project-Id-Version: relief_fill\n"
|
||||||
|
"Language: en\n"
|
||||||
|
"MIME-Version: 1.0\n"
|
||||||
|
"Content-Type: text/plain; charset=UTF-8\n"
|
||||||
|
"Content-Transfer-Encoding: 8bit\n"
|
||||||
|
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
|
||||||
|
|
||||||
|
#: relief_fill.inx
|
||||||
|
msgid "Relief Pattern Fill"
|
||||||
|
msgstr "Relief Pattern Fill"
|
||||||
|
|
||||||
|
#: relief_fill.inx
|
||||||
|
msgid "Pattern"
|
||||||
|
msgstr "Pattern"
|
||||||
|
|
||||||
|
#: relief_fill.inx
|
||||||
|
msgid "Edge length:"
|
||||||
|
msgstr "Edge length:"
|
||||||
|
|
||||||
|
#: relief_fill.inx
|
||||||
|
msgid "Length of the unit edge of the pattern. One repeat of the pattern is 5 edges wide."
|
||||||
|
msgstr "Length of the unit edge of the pattern. One repeat of the pattern is 5 edges wide."
|
||||||
|
|
||||||
|
#: relief_fill.inx
|
||||||
|
msgid "Unit:"
|
||||||
|
msgstr "Unit:"
|
||||||
|
|
||||||
|
#: relief_fill.inx
|
||||||
|
msgid "Rotation (degrees, clockwise):"
|
||||||
|
msgstr "Rotation (degrees, clockwise):"
|
||||||
|
|
||||||
|
#: relief_fill.inx
|
||||||
|
msgid "Pattern origin:"
|
||||||
|
msgstr "Pattern origin:"
|
||||||
|
|
||||||
|
#: relief_fill.inx
|
||||||
|
msgid "Center of each shape (pattern starts in the middle)"
|
||||||
|
msgstr "Center of each shape (pattern starts in the middle)"
|
||||||
|
|
||||||
|
#: relief_fill.inx
|
||||||
|
msgid "Document origin (pattern continuous across shapes)"
|
||||||
|
msgstr "Document origin (pattern continuous across shapes)"
|
||||||
|
|
||||||
|
#: relief_fill.inx
|
||||||
|
msgid "Lines"
|
||||||
|
msgstr "Lines"
|
||||||
|
|
||||||
|
#: relief_fill.inx
|
||||||
|
msgid "Line color:"
|
||||||
|
msgstr "Line color:"
|
||||||
|
|
||||||
|
#: relief_fill.inx
|
||||||
|
msgid "Line width (in the unit of the Pattern tab):"
|
||||||
|
msgstr "Line width (in the unit of the Pattern tab):"
|
||||||
|
|
||||||
|
#: relief_fill.inx
|
||||||
|
msgid "Also draw the outline of the shape"
|
||||||
|
msgstr "Also draw the outline of the shape"
|
||||||
|
|
||||||
|
#: relief_fill.inx
|
||||||
|
msgid "Keep the original shape"
|
||||||
|
msgstr "Keep the original shape"
|
||||||
|
|
||||||
|
#: relief_fill.inx
|
||||||
|
msgid "Help"
|
||||||
|
msgstr "Help"
|
||||||
|
|
||||||
|
#: relief_fill.inx
|
||||||
|
msgid ""
|
||||||
|
"Select one or more shapes (paths, rectangles, circles, groups of them), then run the extension. Each shape is filled with a relief pattern of \"Y\" figures drawn on an isometric grid, and the lines are cut exactly at the edge of the shape. Holes (compound paths) stay empty.\n"
|
||||||
|
"\n"
|
||||||
|
"Edge length: size of the unit edge of the grid. The pattern repeats every 5 edges, so a larger value gives a coarser pattern. Rotation turns the whole pattern around the pattern origin.\n"
|
||||||
|
"\n"
|
||||||
|
"Pattern origin: \"Center of each shape\" puts a grid node at the center of every shape, so each shape shows a symmetrical pattern. \"Document origin\" anchors the grid at the top-left corner of the page, so neighbouring shapes show one continuous pattern.\n"
|
||||||
|
"\n"
|
||||||
|
"The result is a group placed just above the shape, holding one path per shape. Tip: with a very small edge length on a large shape the pattern has many lines; the extension refuses more than 250 000 segments."
|
||||||
|
msgstr ""
|
||||||
|
"Select one or more shapes (paths, rectangles, circles, groups of them), then run the extension. Each shape is filled with a relief pattern of \"Y\" figures drawn on an isometric grid, and the lines are cut exactly at the edge of the shape. Holes (compound paths) stay empty.\n"
|
||||||
|
"\n"
|
||||||
|
"Edge length: size of the unit edge of the grid. The pattern repeats every 5 edges, so a larger value gives a coarser pattern. Rotation turns the whole pattern around the pattern origin.\n"
|
||||||
|
"\n"
|
||||||
|
"Pattern origin: \"Center of each shape\" puts a grid node at the center of every shape, so each shape shows a symmetrical pattern. \"Document origin\" anchors the grid at the top-left corner of the page, so neighbouring shapes show one continuous pattern.\n"
|
||||||
|
"\n"
|
||||||
|
"The result is a group placed just above the shape, holding one path per shape. Tip: with a very small edge length on a large shape the pattern has many lines; the extension refuses more than 250 000 segments."
|
||||||
|
|
||||||
|
#: relief_fill.py
|
||||||
|
msgid "Select at least one shape."
|
||||||
|
msgstr "Select at least one shape."
|
||||||
|
|
||||||
|
#: relief_fill.py
|
||||||
|
msgid "The edge length must be strictly positive."
|
||||||
|
msgstr "The edge length must be strictly positive."
|
||||||
|
|
||||||
|
#: relief_fill.py
|
||||||
|
msgid "The stroke width cannot be negative."
|
||||||
|
msgstr "The stroke width cannot be negative."
|
||||||
|
|
||||||
|
#: relief_fill.py
|
||||||
|
msgid "The selection contains no shape with an outline (path, rectangle, circle...). Convert text to paths first."
|
||||||
|
msgstr "The selection contains no shape with an outline (path, rectangle, circle...). Convert text to paths first."
|
||||||
|
|
||||||
|
#: relief_fill.py
|
||||||
|
msgid "The pattern would have too many lines. Increase the edge length or select a smaller shape."
|
||||||
|
msgstr "The pattern would have too many lines. Increase the edge length or select a smaller shape."
|
||||||
|
|
||||||
|
#: relief_fill.py
|
||||||
|
msgid "The edge length is too large for this shape: no line fits inside. Reduce the edge length."
|
||||||
|
msgstr "The edge length is too large for this shape: no line fits inside. Reduce the edge length."
|
||||||
|
|
||||||
|
#: relief_fill.py
|
||||||
|
msgid "Relief fill"
|
||||||
|
msgstr "Relief fill"
|
||||||
116
po/fr.po
Normal file
116
po/fr.po
Normal file
@ -0,0 +1,116 @@
|
|||||||
|
# Traduction (fr) de l'extension Inkscape « Relief Pattern Fill ».
|
||||||
|
# Genere par i18n.py ; ne modifier que les msgstr.
|
||||||
|
msgid ""
|
||||||
|
msgstr ""
|
||||||
|
"Project-Id-Version: relief_fill\n"
|
||||||
|
"Language: fr\n"
|
||||||
|
"MIME-Version: 1.0\n"
|
||||||
|
"Content-Type: text/plain; charset=UTF-8\n"
|
||||||
|
"Content-Transfer-Encoding: 8bit\n"
|
||||||
|
"Plural-Forms: nplurals=2; plural=(n > 1);\n"
|
||||||
|
|
||||||
|
#: relief_fill.inx
|
||||||
|
msgid "Relief Pattern Fill"
|
||||||
|
msgstr "Remplissage motif relief"
|
||||||
|
|
||||||
|
#: relief_fill.inx
|
||||||
|
msgid "Pattern"
|
||||||
|
msgstr "Motif"
|
||||||
|
|
||||||
|
#: relief_fill.inx
|
||||||
|
msgid "Edge length:"
|
||||||
|
msgstr "Longueur d'arête :"
|
||||||
|
|
||||||
|
#: relief_fill.inx
|
||||||
|
msgid "Length of the unit edge of the pattern. One repeat of the pattern is 5 edges wide."
|
||||||
|
msgstr "Longueur de l'arête unitaire du motif. Une répétition du motif mesure 5 arêtes de large."
|
||||||
|
|
||||||
|
#: relief_fill.inx
|
||||||
|
msgid "Unit:"
|
||||||
|
msgstr "Unité :"
|
||||||
|
|
||||||
|
#: relief_fill.inx
|
||||||
|
msgid "Rotation (degrees, clockwise):"
|
||||||
|
msgstr "Rotation (degrés, sens horaire) :"
|
||||||
|
|
||||||
|
#: relief_fill.inx
|
||||||
|
msgid "Pattern origin:"
|
||||||
|
msgstr "Origine du motif :"
|
||||||
|
|
||||||
|
#: relief_fill.inx
|
||||||
|
msgid "Center of each shape (pattern starts in the middle)"
|
||||||
|
msgstr "Centre de chaque forme (le motif part du milieu)"
|
||||||
|
|
||||||
|
#: relief_fill.inx
|
||||||
|
msgid "Document origin (pattern continuous across shapes)"
|
||||||
|
msgstr "Origine du document (motif continu d'une forme à l'autre)"
|
||||||
|
|
||||||
|
#: relief_fill.inx
|
||||||
|
msgid "Lines"
|
||||||
|
msgstr "Traits"
|
||||||
|
|
||||||
|
#: relief_fill.inx
|
||||||
|
msgid "Line color:"
|
||||||
|
msgstr "Couleur des traits :"
|
||||||
|
|
||||||
|
#: relief_fill.inx
|
||||||
|
msgid "Line width (in the unit of the Pattern tab):"
|
||||||
|
msgstr "Épaisseur des traits (dans l'unité de l'onglet Motif) :"
|
||||||
|
|
||||||
|
#: relief_fill.inx
|
||||||
|
msgid "Also draw the outline of the shape"
|
||||||
|
msgstr "Dessiner aussi le contour de la forme"
|
||||||
|
|
||||||
|
#: relief_fill.inx
|
||||||
|
msgid "Keep the original shape"
|
||||||
|
msgstr "Conserver la forme d'origine"
|
||||||
|
|
||||||
|
#: relief_fill.inx
|
||||||
|
msgid "Help"
|
||||||
|
msgstr "Aide"
|
||||||
|
|
||||||
|
#: relief_fill.inx
|
||||||
|
msgid ""
|
||||||
|
"Select one or more shapes (paths, rectangles, circles, groups of them), then run the extension. Each shape is filled with a relief pattern of \"Y\" figures drawn on an isometric grid, and the lines are cut exactly at the edge of the shape. Holes (compound paths) stay empty.\n"
|
||||||
|
"\n"
|
||||||
|
"Edge length: size of the unit edge of the grid. The pattern repeats every 5 edges, so a larger value gives a coarser pattern. Rotation turns the whole pattern around the pattern origin.\n"
|
||||||
|
"\n"
|
||||||
|
"Pattern origin: \"Center of each shape\" puts a grid node at the center of every shape, so each shape shows a symmetrical pattern. \"Document origin\" anchors the grid at the top-left corner of the page, so neighbouring shapes show one continuous pattern.\n"
|
||||||
|
"\n"
|
||||||
|
"The result is a group placed just above the shape, holding one path per shape. Tip: with a very small edge length on a large shape the pattern has many lines; the extension refuses more than 250 000 segments."
|
||||||
|
msgstr ""
|
||||||
|
"Sélectionnez une ou plusieurs formes (chemins, rectangles, cercles, groupes de ces éléments), puis lancez l'extension. Chaque forme est remplie par un motif en relief de figures en « Y » tracées sur une grille isométrique, et les traits sont coupés exactement au bord de la forme. Les trous (chemins composés) restent vides.\n"
|
||||||
|
"\n"
|
||||||
|
"Longueur d'arête : taille de l'arête unitaire de la grille. Le motif se répète toutes les 5 arêtes : plus la valeur est grande, plus le motif est grossier. La rotation fait tourner tout le motif autour de l'origine du motif.\n"
|
||||||
|
"\n"
|
||||||
|
"Origine du motif : « Centre de chaque forme » place un nœud de la grille au centre de chaque forme, qui montre alors un motif symétrique. « Origine du document » ancre la grille au coin supérieur gauche de la page : les formes voisines montrent un seul motif continu.\n"
|
||||||
|
"\n"
|
||||||
|
"Le résultat est un groupe placé juste au-dessus de la forme, contenant un chemin par forme. Astuce : avec une arête très petite sur une grande forme, le motif compte beaucoup de traits ; l'extension refuse plus de 250 000 segments."
|
||||||
|
|
||||||
|
#: relief_fill.py
|
||||||
|
msgid "Select at least one shape."
|
||||||
|
msgstr "Sélectionnez au moins une forme."
|
||||||
|
|
||||||
|
#: relief_fill.py
|
||||||
|
msgid "The edge length must be strictly positive."
|
||||||
|
msgstr "La longueur d'arête doit être strictement positive."
|
||||||
|
|
||||||
|
#: relief_fill.py
|
||||||
|
msgid "The stroke width cannot be negative."
|
||||||
|
msgstr "L'épaisseur des traits ne peut pas être négative."
|
||||||
|
|
||||||
|
#: relief_fill.py
|
||||||
|
msgid "The selection contains no shape with an outline (path, rectangle, circle...). Convert text to paths first."
|
||||||
|
msgstr "La sélection ne contient aucune forme avec un contour (chemin, rectangle, cercle...). Convertissez d'abord le texte en chemins."
|
||||||
|
|
||||||
|
#: relief_fill.py
|
||||||
|
msgid "The pattern would have too many lines. Increase the edge length or select a smaller shape."
|
||||||
|
msgstr "Le motif aurait trop de traits. Augmentez la longueur d'arête ou sélectionnez une forme plus petite."
|
||||||
|
|
||||||
|
#: relief_fill.py
|
||||||
|
msgid "The edge length is too large for this shape: no line fits inside. Reduce the edge length."
|
||||||
|
msgstr "La longueur d'arête est trop grande pour cette forme : aucun trait n'y tient. Réduisez la longueur d'arête."
|
||||||
|
|
||||||
|
#: relief_fill.py
|
||||||
|
msgid "Relief fill"
|
||||||
|
msgstr "Remplissage relief"
|
||||||
107
po/relief_fill.pot
Normal file
107
po/relief_fill.pot
Normal file
@ -0,0 +1,107 @@
|
|||||||
|
# Modele de traduction de l'extension Inkscape « Relief Pattern Fill ».
|
||||||
|
# Genere par i18n.py ; ne modifier que les msgstr.
|
||||||
|
msgid ""
|
||||||
|
msgstr ""
|
||||||
|
"Project-Id-Version: relief_fill\n"
|
||||||
|
"MIME-Version: 1.0\n"
|
||||||
|
"Content-Type: text/plain; charset=UTF-8\n"
|
||||||
|
"Content-Transfer-Encoding: 8bit\n"
|
||||||
|
|
||||||
|
#: relief_fill.inx
|
||||||
|
msgid "Relief Pattern Fill"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: relief_fill.inx
|
||||||
|
msgid "Pattern"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: relief_fill.inx
|
||||||
|
msgid "Edge length:"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: relief_fill.inx
|
||||||
|
msgid "Length of the unit edge of the pattern. One repeat of the pattern is 5 edges wide."
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: relief_fill.inx
|
||||||
|
msgid "Unit:"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: relief_fill.inx
|
||||||
|
msgid "Rotation (degrees, clockwise):"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: relief_fill.inx
|
||||||
|
msgid "Pattern origin:"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: relief_fill.inx
|
||||||
|
msgid "Center of each shape (pattern starts in the middle)"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: relief_fill.inx
|
||||||
|
msgid "Document origin (pattern continuous across shapes)"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: relief_fill.inx
|
||||||
|
msgid "Lines"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: relief_fill.inx
|
||||||
|
msgid "Line color:"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: relief_fill.inx
|
||||||
|
msgid "Line width (in the unit of the Pattern tab):"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: relief_fill.inx
|
||||||
|
msgid "Also draw the outline of the shape"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: relief_fill.inx
|
||||||
|
msgid "Keep the original shape"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: relief_fill.inx
|
||||||
|
msgid "Help"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: relief_fill.inx
|
||||||
|
msgid ""
|
||||||
|
"Select one or more shapes (paths, rectangles, circles, groups of them), then run the extension. Each shape is filled with a relief pattern of \"Y\" figures drawn on an isometric grid, and the lines are cut exactly at the edge of the shape. Holes (compound paths) stay empty.\n"
|
||||||
|
"\n"
|
||||||
|
"Edge length: size of the unit edge of the grid. The pattern repeats every 5 edges, so a larger value gives a coarser pattern. Rotation turns the whole pattern around the pattern origin.\n"
|
||||||
|
"\n"
|
||||||
|
"Pattern origin: \"Center of each shape\" puts a grid node at the center of every shape, so each shape shows a symmetrical pattern. \"Document origin\" anchors the grid at the top-left corner of the page, so neighbouring shapes show one continuous pattern.\n"
|
||||||
|
"\n"
|
||||||
|
"The result is a group placed just above the shape, holding one path per shape. Tip: with a very small edge length on a large shape the pattern has many lines; the extension refuses more than 250 000 segments."
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: relief_fill.py
|
||||||
|
msgid "Select at least one shape."
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: relief_fill.py
|
||||||
|
msgid "The edge length must be strictly positive."
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: relief_fill.py
|
||||||
|
msgid "The stroke width cannot be negative."
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: relief_fill.py
|
||||||
|
msgid "The selection contains no shape with an outline (path, rectangle, circle...). Convert text to paths first."
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: relief_fill.py
|
||||||
|
msgid "The pattern would have too many lines. Increase the edge length or select a smaller shape."
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: relief_fill.py
|
||||||
|
msgid "The edge length is too large for this shape: no line fits inside. Reduce the edge length."
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: relief_fill.py
|
||||||
|
msgid "Relief fill"
|
||||||
|
msgstr ""
|
||||||
295
relief_core.py
Normal file
295
relief_core.py
Normal file
@ -0,0 +1,295 @@
|
|||||||
|
# coding=utf-8
|
||||||
|
"""
|
||||||
|
Noyau de calcul de l'extension « Relief Pattern Fill », sans dependance a inkex.
|
||||||
|
|
||||||
|
Testable avec pytest seul et reutilisable hors Inkscape (scripts, schema
|
||||||
|
des parametres).
|
||||||
|
|
||||||
|
Le motif est un pavage isometrique (aretes verticales et a +/-30 degres) de
|
||||||
|
« Y » en relief, releve sur l'image du dossier doc/. Il vit sur un reseau de
|
||||||
|
noeuds triangulaire : le noeud (i, j) est en x = i * edge * cos(30), y = j *
|
||||||
|
edge / 2 (les noeuds valides ont i + j pair). Une arete unitaire va d'un noeud
|
||||||
|
a un voisin situe a une distance `edge` ; elle est notee (i, j, di, dj),
|
||||||
|
(di, dj) valant (0, 2), (1, 1) ou (1, -1).
|
||||||
|
|
||||||
|
Le motif se repete par translation de 5 aretes : vecteurs (0, 10) et (5, 5)
|
||||||
|
en coordonnees (i, j), soit une maille hexagonale de cote 5 * edge.
|
||||||
|
|
||||||
|
Conventions :
|
||||||
|
- un contour (`rings`) est une liste d'anneaux, chaque anneau etant une liste
|
||||||
|
de points (x, y) fermee implicitement ; les trous suivent la regle pair-impair ;
|
||||||
|
- un segment est un couple de points ((x1, y1), (x2, y2)) ;
|
||||||
|
- l'axe y est oriente vers le bas (repere SVG).
|
||||||
|
"""
|
||||||
|
|
||||||
|
import math
|
||||||
|
|
||||||
|
COS30 = math.cos(math.radians(30))
|
||||||
|
|
||||||
|
# Nombre maximal de segments avant de refuser le calcul (document fige sinon).
|
||||||
|
MAX_SEGMENTS = 250000
|
||||||
|
|
||||||
|
# Aretes unitaires de la maille de base (39 sur les 75 possibles).
|
||||||
|
CELL_EDGES = (
|
||||||
|
(0, 0, 1, 1), (0, 2, 0, 2), (0, 2, 1, 1), (0, 4, 1, 1),
|
||||||
|
(0, 6, 0, 2), (0, 6, 1, 1), (0, 8, 0, 2), (1, 1, 1, 1),
|
||||||
|
(1, 3, 0, 2), (1, 3, 1, -1), (1, 5, 1, -1), (1, 5, 1, 1),
|
||||||
|
(1, 7, 0, 2), (1, 7, 1, -1), (1, 9, 1, -1), (1, 9, 1, 1),
|
||||||
|
(2, 0, 0, 2), (2, 0, 1, -1), (2, 4, 0, 2), (2, 4, 1, -1),
|
||||||
|
(2, 4, 1, 1), (2, 6, 0, 2), (2, 8, 1, 1), (3, 1, 0, 2),
|
||||||
|
(3, 1, 1, -1), (3, 1, 1, 1), (3, 3, 1, 1), (3, 5, 0, 2),
|
||||||
|
(3, 5, 1, -1), (3, 7, 1, -1), (3, 7, 1, 1), (3, 9, 0, 2),
|
||||||
|
(3, 9, 1, 1), (4, 0, 1, -1), (4, 2, 0, 2), (4, 2, 1, -1),
|
||||||
|
(4, 6, 1, -1), (4, 8, 0, 2), (4, 8, 1, -1),
|
||||||
|
)
|
||||||
|
|
||||||
|
PERIOD_EDGES = 5 # periode du motif, en nombre d'aretes
|
||||||
|
EPS = 1e-9
|
||||||
|
|
||||||
|
|
||||||
|
def parse_color(value, default=("#b3b3b3", 1.0)):
|
||||||
|
"""Couleur Inkscape (entier RGBA decimal ou 0x..., ou #rrggbb[aa]).
|
||||||
|
|
||||||
|
Renvoie (couleur CSS #rrggbb, opacite entre 0 et 1). Le parametre
|
||||||
|
« color » d'Inkscape arrive sous forme d'entier RGBA ; on le decode
|
||||||
|
nous-memes pour ne pas dependre de l'API couleur d'inkex, qui a change
|
||||||
|
entre les versions 1.x.
|
||||||
|
"""
|
||||||
|
text = str(value).strip()
|
||||||
|
try:
|
||||||
|
if text.startswith("#"):
|
||||||
|
digits = text[1:]
|
||||||
|
if len(digits) == 3:
|
||||||
|
digits = "".join(c * 2 for c in digits)
|
||||||
|
if len(digits) == 6:
|
||||||
|
digits += "ff"
|
||||||
|
if len(digits) != 8:
|
||||||
|
return default
|
||||||
|
number = int(digits, 16)
|
||||||
|
else:
|
||||||
|
number = int(text, 0)
|
||||||
|
except ValueError:
|
||||||
|
return default
|
||||||
|
number &= 0xFFFFFFFF
|
||||||
|
red, green, blue = (number >> 24) & 255, (number >> 16) & 255, (number >> 8) & 255
|
||||||
|
alpha = (number & 255) / 255.0
|
||||||
|
return "#{:02x}{:02x}{:02x}".format(red, green, blue), round(alpha, 4)
|
||||||
|
|
||||||
|
|
||||||
|
def polylines_to_d(polylines, precision=4):
|
||||||
|
"""Donnees `d` d'un chemin SVG : une polyligne par sous-chemin."""
|
||||||
|
fmt = "{:.%df},{:.%df}" % (precision, precision)
|
||||||
|
parts = []
|
||||||
|
for polyline in polylines:
|
||||||
|
if len(polyline) < 2:
|
||||||
|
continue
|
||||||
|
parts.append("M " + fmt.format(*polyline[0]))
|
||||||
|
parts.extend("L " + fmt.format(*point) for point in polyline[1:])
|
||||||
|
return " ".join(parts)
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
# Geometrie de base
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def rings_bbox(rings):
|
||||||
|
"""Boite englobante (xmin, ymin, xmax, ymax) d'un contour, None si vide."""
|
||||||
|
points = [p for ring in rings for p in ring]
|
||||||
|
if not points:
|
||||||
|
return None
|
||||||
|
xs = [p[0] for p in points]
|
||||||
|
ys = [p[1] for p in points]
|
||||||
|
return min(xs), min(ys), max(xs), max(ys)
|
||||||
|
|
||||||
|
|
||||||
|
def rotate_point(point, angle, center=(0.0, 0.0)):
|
||||||
|
"""Rotation de `angle` degres autour de `center` (sens horaire a l'ecran)."""
|
||||||
|
a = math.radians(angle)
|
||||||
|
c, s = math.cos(a), math.sin(a)
|
||||||
|
x, y = point[0] - center[0], point[1] - center[1]
|
||||||
|
return center[0] + c * x - s * y, center[1] + s * x + c * y
|
||||||
|
|
||||||
|
|
||||||
|
def point_in_rings(point, rings):
|
||||||
|
"""Appartenance au contour, regle pair-impair (trous compris)."""
|
||||||
|
x, y = point
|
||||||
|
inside = False
|
||||||
|
for ring in rings:
|
||||||
|
n = len(ring)
|
||||||
|
for k in range(n):
|
||||||
|
x1, y1 = ring[k]
|
||||||
|
x2, y2 = ring[(k + 1) % n]
|
||||||
|
if (y1 > y) != (y2 > y):
|
||||||
|
if x < x1 + (y - y1) * (x2 - x1) / (y2 - y1):
|
||||||
|
inside = not inside
|
||||||
|
return inside
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
# Le motif
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _node(i, j, edge):
|
||||||
|
return i * edge * COS30, j * edge / 2.0
|
||||||
|
|
||||||
|
|
||||||
|
def _merged_runs(origin_cells):
|
||||||
|
"""Aretes des mailles donnees, fusionnees en segments rectilignes maximaux.
|
||||||
|
|
||||||
|
`origin_cells` : iterable de decalages (di0, dj0) de mailles entieres.
|
||||||
|
Deux aretes bout a bout sur la meme droite deviennent un seul segment :
|
||||||
|
moins de sous-chemins dans le SVG, et pas de fente aux jonctions.
|
||||||
|
"""
|
||||||
|
runs = {} # (direction, droite) -> {debut: fin}
|
||||||
|
for di0, dj0 in origin_cells:
|
||||||
|
for i, j, di, dj in CELL_EDGES:
|
||||||
|
i, j = i + di0, j + dj0
|
||||||
|
if di == 0:
|
||||||
|
key, start, end = (0, i), j, j + 2
|
||||||
|
elif dj == 1:
|
||||||
|
key, start, end = (1, j - i), i, i + 1
|
||||||
|
else:
|
||||||
|
key, start, end = (2, j + i), i, i + 1
|
||||||
|
runs.setdefault(key, {})[start] = end
|
||||||
|
for (direction, line), spans in runs.items():
|
||||||
|
begin = None
|
||||||
|
previous_end = None
|
||||||
|
for start in sorted(spans):
|
||||||
|
if begin is not None and start == previous_end:
|
||||||
|
previous_end = spans[start]
|
||||||
|
continue
|
||||||
|
if begin is not None:
|
||||||
|
yield direction, line, begin, previous_end
|
||||||
|
begin, previous_end = start, spans[start]
|
||||||
|
if begin is not None:
|
||||||
|
yield direction, line, begin, previous_end
|
||||||
|
|
||||||
|
|
||||||
|
def pattern_segments(bbox, edge):
|
||||||
|
"""Segments du motif couvrant `bbox` (xmin, ymin, xmax, ymax), cote d'arete `edge`.
|
||||||
|
|
||||||
|
Le noeud (0, 0) du reseau est en (0, 0). Les segments debordent un peu de
|
||||||
|
la boite (on garde ceux qui la touchent) : c'est a l'appelant de decouper.
|
||||||
|
"""
|
||||||
|
if edge <= 0:
|
||||||
|
raise ValueError("edge must be positive")
|
||||||
|
xmin, ymin, xmax, ymax = bbox
|
||||||
|
hx, hy = edge * COS30, edge / 2.0
|
||||||
|
i_lo, i_hi = int(math.floor(xmin / hx)) - 1, int(math.ceil(xmax / hx)) + 1
|
||||||
|
j_lo, j_hi = int(math.floor(ymin / hy)) - 1, int(math.ceil(ymax / hy)) + 1
|
||||||
|
|
||||||
|
# Estimation avant de construire quoi que ce soit.
|
||||||
|
cells = ((i_hi - i_lo) / PERIOD_EDGES + 3) * ((j_hi - j_lo) / (2.0 * PERIOD_EDGES) + 3)
|
||||||
|
if cells * len(CELL_EDGES) > MAX_SEGMENTS * 1.5:
|
||||||
|
raise OverflowError("too many segments")
|
||||||
|
|
||||||
|
# Mailles (m, n) : decalage (5 m, 5 m + 10 n) en (i, j). On garde celles
|
||||||
|
# dont le carre englobe croise la boite.
|
||||||
|
span = PERIOD_EDGES
|
||||||
|
origins = []
|
||||||
|
m_lo, m_hi = i_lo // span - 1, i_hi // span + 1
|
||||||
|
for m in range(m_lo, m_hi + 1):
|
||||||
|
n_lo = (j_lo - span * m) // (2 * span) - 1
|
||||||
|
n_hi = (j_hi - span * m) // (2 * span) + 1
|
||||||
|
for n in range(n_lo, n_hi + 1):
|
||||||
|
origins.append((span * m, span * m + 2 * span * n))
|
||||||
|
|
||||||
|
segments = []
|
||||||
|
for direction, line, begin, end in _merged_runs(origins):
|
||||||
|
if direction == 0:
|
||||||
|
p, q = _node(line, begin, edge), _node(line, end, edge)
|
||||||
|
elif direction == 1:
|
||||||
|
p, q = _node(begin, line + begin, edge), _node(end, line + end, edge)
|
||||||
|
else:
|
||||||
|
p, q = _node(begin, line - begin, edge), _node(end, line - end, edge)
|
||||||
|
if (max(p[0], q[0]) < xmin or min(p[0], q[0]) > xmax
|
||||||
|
or max(p[1], q[1]) < ymin or min(p[1], q[1]) > ymax):
|
||||||
|
continue
|
||||||
|
segments.append((p, q))
|
||||||
|
if len(segments) > MAX_SEGMENTS:
|
||||||
|
raise OverflowError("too many segments")
|
||||||
|
return segments
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
# Decoupe
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def clip_segment(p, q, rings):
|
||||||
|
"""Parties du segment p-q situees a l'interieur du contour (pair-impair).
|
||||||
|
|
||||||
|
On coupe le segment a chaque croisement d'un cote du contour, puis on
|
||||||
|
garde les morceaux dont le milieu est a l'interieur.
|
||||||
|
"""
|
||||||
|
px, py = p
|
||||||
|
dx, dy = q[0] - px, q[1] - py
|
||||||
|
sx0, sx1 = min(px, q[0]), max(px, q[0])
|
||||||
|
sy0, sy1 = min(py, q[1]), max(py, q[1])
|
||||||
|
cuts = [0.0, 1.0]
|
||||||
|
for ring in rings:
|
||||||
|
n = len(ring)
|
||||||
|
for k in range(n):
|
||||||
|
ax, ay = ring[k]
|
||||||
|
bx, by = ring[(k + 1) % n]
|
||||||
|
if (max(ax, bx) < sx0 or min(ax, bx) > sx1
|
||||||
|
or max(ay, by) < sy0 or min(ay, by) > sy1):
|
||||||
|
continue
|
||||||
|
ex, ey = bx - ax, by - ay
|
||||||
|
denom = dx * ey - dy * ex
|
||||||
|
if abs(denom) < EPS:
|
||||||
|
continue # paralleles : pas de croisement franc
|
||||||
|
t = ((ax - px) * ey - (ay - py) * ex) / denom
|
||||||
|
u = ((ax - px) * dy - (ay - py) * dx) / denom
|
||||||
|
if 0.0 < t < 1.0 and -EPS <= u <= 1.0 + EPS:
|
||||||
|
cuts.append(t)
|
||||||
|
cuts.sort()
|
||||||
|
pieces = []
|
||||||
|
for t0, t1 in zip(cuts, cuts[1:]):
|
||||||
|
if t1 - t0 < 1e-7:
|
||||||
|
continue
|
||||||
|
mid = (px + dx * (t0 + t1) / 2.0, py + dy * (t0 + t1) / 2.0)
|
||||||
|
if not point_in_rings(mid, rings):
|
||||||
|
continue
|
||||||
|
a = (px + dx * t0, py + dy * t0)
|
||||||
|
b = (px + dx * t1, py + dy * t1)
|
||||||
|
if pieces and pieces[-1][1] == a:
|
||||||
|
pieces[-1] = (pieces[-1][0], b) # morceaux contigus : un seul
|
||||||
|
else:
|
||||||
|
pieces.append((a, b))
|
||||||
|
return pieces
|
||||||
|
|
||||||
|
|
||||||
|
def fill_rings(rings, edge, angle=0.0, anchor=(0.0, 0.0)):
|
||||||
|
"""Remplit l'interieur du contour avec le motif ; renvoie des segments.
|
||||||
|
|
||||||
|
`edge` : longueur de l'arete unitaire du motif ;
|
||||||
|
`angle` : rotation du motif en degres (sens horaire a l'ecran) ;
|
||||||
|
`anchor` : point du plan ou tombe un noeud du reseau (centre de rotation).
|
||||||
|
|
||||||
|
Leve OverflowError si le motif compterait trop de segments (arete trop
|
||||||
|
petite pour la forme).
|
||||||
|
"""
|
||||||
|
rings = [r for r in rings if len(r) >= 3]
|
||||||
|
box = rings_bbox(rings)
|
||||||
|
if box is None:
|
||||||
|
return []
|
||||||
|
# On travaille dans le repere du motif (tourne puis translate) : le motif
|
||||||
|
# est alors aligne sur les axes, et le contour est ramene dans ce repere.
|
||||||
|
local = [[rotate_point((x - anchor[0], y - anchor[1]), -angle) for x, y in ring]
|
||||||
|
for ring in rings]
|
||||||
|
local_box = rings_bbox(local)
|
||||||
|
out = []
|
||||||
|
for p, q in pattern_segments(local_box, edge):
|
||||||
|
for a, b in clip_segment(p, q, local):
|
||||||
|
out.append((_to_world(a, angle, anchor), _to_world(b, angle, anchor)))
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _to_world(point, angle, anchor):
|
||||||
|
x, y = rotate_point(point, angle)
|
||||||
|
return x + anchor[0], y + anchor[1]
|
||||||
|
|
||||||
|
|
||||||
|
def segments_to_polylines(segments):
|
||||||
|
"""Segments -> polylines a deux points (pour polylines_to_d)."""
|
||||||
|
return [[p, q] for p, q in segments]
|
||||||
73
relief_fill.inx
Normal file
73
relief_fill.inx
Normal file
@ -0,0 +1,73 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!--
|
||||||
|
Textes source en anglais, traduits par le catalogue « relief_fill »
|
||||||
|
(locale/<langue>/LC_MESSAGES/relief_fill.mo). Après toute modification
|
||||||
|
d'un texte : python i18n.py, puis compléter po/fr.po et relancer.
|
||||||
|
-->
|
||||||
|
<inkscape-extension translationdomain="relief_fill"
|
||||||
|
xmlns="http://www.inkscape.org/namespace/inkscape/extension">
|
||||||
|
<name>Relief Pattern Fill</name>
|
||||||
|
<id>fr.alexp.relief_fill</id>
|
||||||
|
|
||||||
|
<param name="tab" type="notebook">
|
||||||
|
<page name="pattern" gui-text="Pattern">
|
||||||
|
<param name="edge" type="float" precision="3" min="0.1" max="1000"
|
||||||
|
gui-text="Edge length:"
|
||||||
|
gui-description="Length of the unit edge of the pattern. One repeat of the pattern is 5 edges wide.">5.0</param>
|
||||||
|
<param name="unit" type="optiongroup" appearance="combo" gui-text="Unit:">
|
||||||
|
<!-- Les unités ne se traduisent pas -->
|
||||||
|
<option translatable="no" value="mm">mm</option>
|
||||||
|
<option translatable="no" value="cm">cm</option>
|
||||||
|
<option translatable="no" value="px">px</option>
|
||||||
|
<option translatable="no" value="pt">pt</option>
|
||||||
|
<option translatable="no" value="in">in</option>
|
||||||
|
</param>
|
||||||
|
<param name="angle" type="float" precision="1" min="-360" max="360"
|
||||||
|
gui-text="Rotation (degrees, clockwise):">0.0</param>
|
||||||
|
<param name="origin" type="optiongroup" appearance="combo"
|
||||||
|
gui-text="Pattern origin:">
|
||||||
|
<option value="shape">Center of each shape (pattern starts in the middle)</option>
|
||||||
|
<option value="document">Document origin (pattern continuous across shapes)</option>
|
||||||
|
</param>
|
||||||
|
</page>
|
||||||
|
|
||||||
|
<page name="style" gui-text="Lines">
|
||||||
|
<param name="stroke_color" type="color" appearance="colorbutton"
|
||||||
|
gui-text="Line color:">255</param>
|
||||||
|
<param name="stroke_width" type="float" precision="3" min="0" max="100"
|
||||||
|
gui-text="Line width (in the unit of the Pattern tab):">0.3</param>
|
||||||
|
<param name="draw_outline" type="bool"
|
||||||
|
gui-text="Also draw the outline of the shape">false</param>
|
||||||
|
<param name="keep_original" type="bool"
|
||||||
|
gui-text="Keep the original shape">true</param>
|
||||||
|
</page>
|
||||||
|
|
||||||
|
<page name="help" gui-text="Help">
|
||||||
|
<!-- Schéma généré par docs/schema_parametres.py, en anglais :
|
||||||
|
Inkscape ne traduit pas le chemin d'une image. Affiché aux
|
||||||
|
3/4 du PNG (900 px) pour limiter la hauteur de l'onglet. -->
|
||||||
|
<image width="675" height="617">images/parameters_en.png</image>
|
||||||
|
<separator/>
|
||||||
|
<!-- Un paragraphe par ligne : Inkscape le replie à la largeur de
|
||||||
|
l'onglet ; des retours à la ligne forcés le laisseraient étroit. -->
|
||||||
|
<label xml:space="preserve">Select one or more shapes (paths, rectangles, circles, groups of them), then run the extension. Each shape is filled with a relief pattern of "Y" figures drawn on an isometric grid, and the lines are cut exactly at the edge of the shape. Holes (compound paths) stay empty.
|
||||||
|
|
||||||
|
Edge length: size of the unit edge of the grid. The pattern repeats every 5 edges, so a larger value gives a coarser pattern. Rotation turns the whole pattern around the pattern origin.
|
||||||
|
|
||||||
|
Pattern origin: "Center of each shape" puts a grid node at the center of every shape, so each shape shows a symmetrical pattern. "Document origin" anchors the grid at the top-left corner of the page, so neighbouring shapes show one continuous pattern.
|
||||||
|
|
||||||
|
The result is a group placed just above the shape, holding one path per shape. Tip: with a very small edge length on a large shape the pattern has many lines; the extension refuses more than 250 000 segments.</label>
|
||||||
|
</page>
|
||||||
|
</param>
|
||||||
|
|
||||||
|
<effect>
|
||||||
|
<object-type>all</object-type>
|
||||||
|
<effects-menu>
|
||||||
|
<submenu name="AlexDesign"/>
|
||||||
|
</effects-menu>
|
||||||
|
</effect>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
<command location="inx" interpreter="python">relief_fill.py</command>
|
||||||
|
</script>
|
||||||
|
</inkscape-extension>
|
||||||
220
relief_fill.py
Normal file
220
relief_fill.py
Normal file
@ -0,0 +1,220 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
# coding=utf-8
|
||||||
|
"""
|
||||||
|
Extension Inkscape : remplit l'interieur des formes selectionnees avec un
|
||||||
|
motif isometrique de « Y » en relief.
|
||||||
|
|
||||||
|
Chaque forme (chemin, rectangle, cercle... ; les groupes sont parcourus) est
|
||||||
|
aplatie en polygone, puis le motif, calcule par `relief_core`, est decoupe a
|
||||||
|
l'interieur de ce polygone (trous compris). Le resultat est un groupe place
|
||||||
|
juste au-dessus de la forme, avec un seul chemin par forme.
|
||||||
|
|
||||||
|
La logique vit dans `relief_core`, sans dependance a inkex.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
|
import inkex
|
||||||
|
from inkex.bezier import cspsubdiv
|
||||||
|
from inkex.localization import localize
|
||||||
|
from inkex.units import convert_unit
|
||||||
|
|
||||||
|
# L'extension vit dans son propre sous-dossier des extensions Inkscape, qui
|
||||||
|
# n'est pas forcement dans sys.path : on l'y ajoute pour trouver relief_core.
|
||||||
|
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||||
|
|
||||||
|
from relief_core import (fill_rings, parse_color, polylines_to_d, # noqa: E402
|
||||||
|
rings_bbox, segments_to_polylines)
|
||||||
|
|
||||||
|
|
||||||
|
def use_system_language():
|
||||||
|
"""Renseigne LANGUAGE avec la langue du systeme si rien ne la fixe.
|
||||||
|
|
||||||
|
Sous Windows, Inkscape regle sur « langue du systeme » ne transmet aucune
|
||||||
|
variable de langue au script : gettext retomberait sur l'anglais alors que
|
||||||
|
la boite de dialogue, traduite par Inkscape lui-meme, est dans la langue
|
||||||
|
de l'utilisateur.
|
||||||
|
"""
|
||||||
|
if any(os.environ.get(name) for name in ("LANGUAGE", "LC_ALL", "LC_MESSAGES", "LANG")):
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
import ctypes
|
||||||
|
import locale
|
||||||
|
language = locale.windows_locale.get(
|
||||||
|
ctypes.windll.kernel32.GetUserDefaultUILanguage())
|
||||||
|
except Exception: # pylint: disable=broad-except
|
||||||
|
return # hors Windows : LANG est toujours defini
|
||||||
|
if language:
|
||||||
|
os.environ["LANGUAGE"] = language
|
||||||
|
|
||||||
|
|
||||||
|
# Installe _() dans les builtins : catalogue « relief_fill » de la langue
|
||||||
|
# de l'interface (Inkscape transmet domaine et dossier par l'environnement),
|
||||||
|
# textes anglais d'origine sinon.
|
||||||
|
use_system_language()
|
||||||
|
localize()
|
||||||
|
|
||||||
|
|
||||||
|
def uu_per_mm(svg):
|
||||||
|
"""Unites utilisateur par millimetre, comme Inkscape les affiche.
|
||||||
|
|
||||||
|
svg.unittouu() devine l'unite du document par arrondi a l'unite connue la
|
||||||
|
plus proche : il se trompe des que l'echelle n'est pas standard (page
|
||||||
|
redimensionnee). On prend le rapport reel page / viewBox ; Inkscape
|
||||||
|
applique une echelle uniforme (preserveAspectRatio « meet »), soit la
|
||||||
|
plus petite des deux.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
vb_w, vb_h = svg.viewbox_width, svg.viewbox_height
|
||||||
|
page_w = svg.to_dimensional(svg.viewport_width, "mm")
|
||||||
|
page_h = svg.to_dimensional(svg.viewport_height, "mm")
|
||||||
|
except AttributeError: # inkex anterieur a 1.2
|
||||||
|
return svg.unittouu("1mm")
|
||||||
|
if not (vb_w and vb_h and page_w and page_h):
|
||||||
|
return svg.unittouu("1mm")
|
||||||
|
return 1 / min(page_w / vb_w, page_h / vb_h)
|
||||||
|
|
||||||
|
|
||||||
|
def length_to_uu(svg, value, unit="mm"):
|
||||||
|
"""Longueur reelle (valeur + unite) -> unites utilisateur du document."""
|
||||||
|
return convert_unit("{}{}".format(value, unit), "mm") * uu_per_mm(svg)
|
||||||
|
|
||||||
|
|
||||||
|
# Formes dont on sait lire le contour ; les groupes sont parcourus.
|
||||||
|
SHAPE_TYPES = tuple(
|
||||||
|
getattr(inkex, name) for name in
|
||||||
|
("PathElement", "Rectangle", "Circle", "Ellipse", "Polygon", "Polyline", "Line")
|
||||||
|
if hasattr(inkex, name))
|
||||||
|
|
||||||
|
|
||||||
|
def shape_rings(elem, flatness):
|
||||||
|
"""Contour de `elem` en coordonnees absolues : liste d'anneaux de points.
|
||||||
|
|
||||||
|
Les courbes sont aplaties a la tolerance `flatness` (unites utilisateur).
|
||||||
|
Un sous-chemin ouvert est traite comme ferme, comme le fait un remplissage.
|
||||||
|
"""
|
||||||
|
path = elem.path.transform(elem.composed_transform())
|
||||||
|
csp = path.to_superpath()
|
||||||
|
cspsubdiv(csp, flatness)
|
||||||
|
rings = []
|
||||||
|
for sub in csp:
|
||||||
|
ring = [(node[1][0], node[1][1]) for node in sub]
|
||||||
|
if len(ring) > 1 and ring[0] == ring[-1]:
|
||||||
|
ring.pop()
|
||||||
|
if len(ring) >= 3:
|
||||||
|
rings.append(ring)
|
||||||
|
return rings
|
||||||
|
|
||||||
|
|
||||||
|
class ReliefFill(inkex.EffectExtension):
|
||||||
|
"""Remplit chaque forme selectionnee avec le motif de Y en relief."""
|
||||||
|
|
||||||
|
def add_arguments(self, pars):
|
||||||
|
# Un argument par <param> du .inx, memes noms et memes valeurs par defaut.
|
||||||
|
pars.add_argument("--tab", default="pattern")
|
||||||
|
pars.add_argument("--edge", type=float, default=5.0)
|
||||||
|
pars.add_argument("--unit", default="mm")
|
||||||
|
pars.add_argument("--angle", type=float, default=0.0)
|
||||||
|
pars.add_argument("--origin", default="shape")
|
||||||
|
pars.add_argument("--stroke_color", default="255")
|
||||||
|
pars.add_argument("--stroke_width", type=float, default=0.3)
|
||||||
|
pars.add_argument("--draw_outline", type=inkex.Boolean, default=False)
|
||||||
|
pars.add_argument("--keep_original", type=inkex.Boolean, default=True)
|
||||||
|
|
||||||
|
def effect(self):
|
||||||
|
opt = self.options
|
||||||
|
|
||||||
|
elements = list(self.svg.selection.values())
|
||||||
|
if not elements:
|
||||||
|
inkex.errormsg(_("Select at least one shape."))
|
||||||
|
return
|
||||||
|
|
||||||
|
edge = length_to_uu(self.svg, opt.edge, opt.unit)
|
||||||
|
if edge <= 0:
|
||||||
|
inkex.errormsg(_("The edge length must be strictly positive."))
|
||||||
|
return
|
||||||
|
width = length_to_uu(self.svg, opt.stroke_width, opt.unit)
|
||||||
|
if width < 0:
|
||||||
|
inkex.errormsg(_("The stroke width cannot be negative."))
|
||||||
|
return
|
||||||
|
|
||||||
|
color, opacity = parse_color(opt.stroke_color, default=("#000000", 1.0))
|
||||||
|
style = inkex.Style({
|
||||||
|
"fill": "none",
|
||||||
|
"stroke": color,
|
||||||
|
"stroke-opacity": str(opacity),
|
||||||
|
"stroke-width": str(width),
|
||||||
|
"stroke-linecap": "round",
|
||||||
|
"stroke-linejoin": "round",
|
||||||
|
})
|
||||||
|
flatness = length_to_uu(self.svg, 0.02) # 0.02 mm : invisible a l'impression
|
||||||
|
|
||||||
|
for elem in elements:
|
||||||
|
shapes = [e for e in elem.descendants() if isinstance(e, SHAPE_TYPES)]
|
||||||
|
if not shapes:
|
||||||
|
inkex.errormsg(_("The selection contains no shape with an outline "
|
||||||
|
"(path, rectangle, circle...). Convert text to "
|
||||||
|
"paths first."))
|
||||||
|
continue
|
||||||
|
if not self.process(elem, shapes, edge, style, flatness):
|
||||||
|
return
|
||||||
|
|
||||||
|
def process(self, elem, shapes, edge, style, flatness):
|
||||||
|
"""Remplit les `shapes` de `elem` ; renvoie False en cas d'erreur fatale."""
|
||||||
|
opt = self.options
|
||||||
|
results = [] # (anneaux, segments) par forme
|
||||||
|
for shape in shapes:
|
||||||
|
rings = shape_rings(shape, flatness)
|
||||||
|
if not rings:
|
||||||
|
continue
|
||||||
|
if opt.origin == "document":
|
||||||
|
anchor = (0.0, 0.0)
|
||||||
|
else:
|
||||||
|
xmin, ymin, xmax, ymax = rings_bbox(rings)
|
||||||
|
anchor = ((xmin + xmax) / 2.0, (ymin + ymax) / 2.0)
|
||||||
|
try:
|
||||||
|
segments = fill_rings(rings, edge, opt.angle, anchor)
|
||||||
|
except OverflowError:
|
||||||
|
inkex.errormsg(_("The pattern would have too many lines. "
|
||||||
|
"Increase the edge length or select a smaller shape."))
|
||||||
|
return False
|
||||||
|
results.append((rings, segments))
|
||||||
|
|
||||||
|
if not any(segments for _rings, segments in results):
|
||||||
|
inkex.errormsg(_("The edge length is too large for this shape: no line "
|
||||||
|
"fits inside. Reduce the edge length."))
|
||||||
|
return True
|
||||||
|
|
||||||
|
parent = elem.getparent()
|
||||||
|
group = inkex.Group()
|
||||||
|
group.label = _("Relief fill")
|
||||||
|
if parent is not None:
|
||||||
|
# Resultat calcule en coordonnees absolues du document : on
|
||||||
|
# neutralise la transformation heritee du parent d'accueil.
|
||||||
|
parent_transform = parent.composed_transform()
|
||||||
|
if parent_transform != inkex.Transform():
|
||||||
|
group.transform = -parent_transform
|
||||||
|
parent.insert(parent.index(elem) + 1, group)
|
||||||
|
else:
|
||||||
|
self.svg.add(group)
|
||||||
|
|
||||||
|
for rings, segments in results:
|
||||||
|
if opt.draw_outline:
|
||||||
|
outline = inkex.PathElement()
|
||||||
|
outline.path = polylines_to_d([ring + [ring[0]] for ring in rings])
|
||||||
|
outline.style = style
|
||||||
|
group.add(outline)
|
||||||
|
if segments:
|
||||||
|
node = inkex.PathElement()
|
||||||
|
node.path = polylines_to_d(segments_to_polylines(segments), precision=3)
|
||||||
|
node.style = style
|
||||||
|
group.add(node)
|
||||||
|
|
||||||
|
if not opt.keep_original:
|
||||||
|
elem.delete()
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
ReliefFill().run()
|
||||||
320
test_relief_fill.py
Normal file
320
test_relief_fill.py
Normal file
@ -0,0 +1,320 @@
|
|||||||
|
# coding=utf-8
|
||||||
|
"""Tests de l'extension « Relief Pattern Fill » (pytest)."""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import xml.etree.ElementTree as ET
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
import math
|
||||||
|
|
||||||
|
from relief_core import (CELL_EDGES, clip_segment, fill_rings, parse_color,
|
||||||
|
pattern_segments, point_in_rings, polylines_to_d,
|
||||||
|
rotate_point)
|
||||||
|
|
||||||
|
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||||
|
SHAPES = os.path.join(HERE, "tests", "data", "shapes.svg")
|
||||||
|
SCALED = os.path.join(HERE, "tests", "data", "scaled.svg")
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
# Noyau (sans inkex)
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def test_parse_color_inkscape_integer():
|
||||||
|
assert parse_color("3014898687") == ("#b3b3b3", 1.0)
|
||||||
|
assert parse_color(str(0xFF000080)) == ("#ff0000", round(0x80 / 255, 4))
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_color_hex_and_invalid():
|
||||||
|
assert parse_color("#123456") == ("#123456", 1.0)
|
||||||
|
assert parse_color("#abc") == ("#aabbcc", 1.0)
|
||||||
|
assert parse_color("pas une couleur") == ("#b3b3b3", 1.0)
|
||||||
|
|
||||||
|
|
||||||
|
def test_polylines_to_d():
|
||||||
|
d = polylines_to_d([[(0, 0), (1, 2)], [(3, 4), (5, 6)]], precision=1)
|
||||||
|
assert d == "M 0.0,0.0 L 1.0,2.0 M 3.0,4.0 L 5.0,6.0"
|
||||||
|
|
||||||
|
|
||||||
|
SQUARE = [(0, 0), (100, 0), (100, 100), (0, 100)]
|
||||||
|
HOLED = [SQUARE, [(25, 25), (75, 25), (75, 75), (25, 75)]]
|
||||||
|
|
||||||
|
|
||||||
|
def length(segment):
|
||||||
|
(x1, y1), (x2, y2) = segment
|
||||||
|
return math.hypot(x2 - x1, y2 - y1)
|
||||||
|
|
||||||
|
|
||||||
|
def key(segment):
|
||||||
|
(x1, y1), (x2, y2) = segment
|
||||||
|
return round(x1, 3), round(y1, 3), round(x2, 3), round(y2, 3)
|
||||||
|
|
||||||
|
|
||||||
|
def test_cell_edges_are_valid_lattice_edges():
|
||||||
|
assert len(set(CELL_EDGES)) == len(CELL_EDGES) == 39
|
||||||
|
for i, j, di, dj in CELL_EDGES:
|
||||||
|
assert (i + j) % 2 == 0
|
||||||
|
assert (di, dj) in ((0, 2), (1, 1), (1, -1))
|
||||||
|
|
||||||
|
|
||||||
|
def test_pattern_directions_and_periodicity():
|
||||||
|
"""Segments verticaux ou a +/-30 degres ; motif invariant par translation."""
|
||||||
|
edge = 10.0
|
||||||
|
segments = pattern_segments((0, 0, 200, 200), edge)
|
||||||
|
for (x1, y1), (x2, y2) in segments:
|
||||||
|
angle = round(math.degrees(math.atan2(y2 - y1, x2 - x1))) % 180
|
||||||
|
assert angle in (30, 90, 150)
|
||||||
|
keys = {key(s) for s in segments}
|
||||||
|
inner = [k for k in keys if all(40 < k[n] < 160 for n in (0, 2))
|
||||||
|
and all(40 < k[n] < 150 for n in (1, 3))]
|
||||||
|
assert inner
|
||||||
|
for x1, y1, x2, y2 in inner:
|
||||||
|
assert (x1, round(y1 + 5 * edge, 3), x2, round(y2 + 5 * edge, 3)) in keys
|
||||||
|
|
||||||
|
|
||||||
|
def test_pattern_merges_collinear_edges():
|
||||||
|
"""Deux aretes bout a bout sur la meme droite forment un seul segment."""
|
||||||
|
edge = 10.0
|
||||||
|
segments = pattern_segments((0, 0, 200, 200), edge)
|
||||||
|
assert max(length(s) for s in segments) > 1.5 * edge
|
||||||
|
at_node = {}
|
||||||
|
for p, q in segments:
|
||||||
|
for a, b in ((p, q), (q, p)):
|
||||||
|
at_node.setdefault((round(a[0], 3), round(a[1], 3)), []).append(b)
|
||||||
|
for point, others in at_node.items():
|
||||||
|
for k, a in enumerate(others):
|
||||||
|
for b in others[k + 1:]:
|
||||||
|
cross = ((a[0] - point[0]) * (b[1] - point[1])
|
||||||
|
- (a[1] - point[1]) * (b[0] - point[0]))
|
||||||
|
dot = ((a[0] - point[0]) * (b[0] - point[0])
|
||||||
|
+ (a[1] - point[1]) * (b[1] - point[1]))
|
||||||
|
assert not (abs(cross) < 1e-6 and dot < 0)
|
||||||
|
|
||||||
|
|
||||||
|
def test_pattern_rejects_bad_edge():
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
pattern_segments((0, 0, 10, 10), 0)
|
||||||
|
|
||||||
|
|
||||||
|
def test_pattern_too_many_segments():
|
||||||
|
with pytest.raises(OverflowError):
|
||||||
|
pattern_segments((0, 0, 1e6, 1e6), 1.0)
|
||||||
|
|
||||||
|
|
||||||
|
def test_point_in_rings_with_hole():
|
||||||
|
assert point_in_rings((10, 10), HOLED)
|
||||||
|
assert not point_in_rings((50, 50), HOLED)
|
||||||
|
assert not point_in_rings((150, 50), HOLED)
|
||||||
|
|
||||||
|
|
||||||
|
def test_clip_segment_square():
|
||||||
|
pieces = clip_segment((-50, 50), (150, 50), [SQUARE])
|
||||||
|
assert len(pieces) == 1
|
||||||
|
(x1, y1), (x2, y2) = pieces[0]
|
||||||
|
assert (round(x1, 6), round(x2, 6), y1, y2) == (0, 100, 50, 50)
|
||||||
|
assert clip_segment((200, 0), (300, 100), [SQUARE]) == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_clip_segment_hole_splits_segment():
|
||||||
|
pieces = clip_segment((-10, 50), (110, 50), HOLED)
|
||||||
|
xs = sorted(round(p[0], 6) for piece in pieces for p in piece)
|
||||||
|
assert xs == [0, 25, 75, 100]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("angle", [0, 17, 90])
|
||||||
|
def test_fill_stays_inside_shape(angle):
|
||||||
|
triangle = [(0, 0), (120, 10), (40, 90)]
|
||||||
|
segments = fill_rings([triangle], 6.0, angle, (50, 30))
|
||||||
|
assert len(segments) > 50
|
||||||
|
for a, b in segments:
|
||||||
|
mid = ((a[0] + b[0]) / 2, (a[1] + b[1]) / 2)
|
||||||
|
assert point_in_rings(mid, [triangle])
|
||||||
|
|
||||||
|
|
||||||
|
def test_fill_avoids_hole():
|
||||||
|
segments = fill_rings(HOLED, 4.0)
|
||||||
|
assert segments
|
||||||
|
for a, b in segments:
|
||||||
|
mid = ((a[0] + b[0]) / 2, (a[1] + b[1]) / 2)
|
||||||
|
assert not (25 < mid[0] < 75 and 25 < mid[1] < 75)
|
||||||
|
|
||||||
|
|
||||||
|
def test_fill_rotation_turns_directions():
|
||||||
|
segments = fill_rings([SQUARE], 6.0, 90.0, (50, 50))
|
||||||
|
angles = {round(math.degrees(math.atan2(b[1] - a[1], b[0] - a[0]))) % 180
|
||||||
|
for a, b in segments}
|
||||||
|
assert angles == {0, 60, 120}
|
||||||
|
|
||||||
|
|
||||||
|
def test_fill_anchor_is_a_node():
|
||||||
|
"""Meme motif, relativement a l'ancre, quand la forme est translatee."""
|
||||||
|
base = {key(s) for s in fill_rings([SQUARE], 6.0, 0.0, (50, 50))}
|
||||||
|
shifted = [[(x + 7, y + 3) for x, y in SQUARE]]
|
||||||
|
moved = {key(((a[0] - 7, a[1] - 3), (b[0] - 7, b[1] - 3)))
|
||||||
|
for a, b in fill_rings(shifted, 6.0, 0.0, (57, 53))}
|
||||||
|
assert base == moved
|
||||||
|
|
||||||
|
|
||||||
|
def test_fill_degenerate_inputs():
|
||||||
|
assert fill_rings([], 5.0) == []
|
||||||
|
assert fill_rings([[(0, 0), (1, 1)]], 5.0) == []
|
||||||
|
fill_rings([SQUARE], 500.0) # arete enorme : pas d'exception
|
||||||
|
|
||||||
|
|
||||||
|
def test_rotate_point_clockwise_on_screen():
|
||||||
|
x, y = rotate_point((1, 0), 90)
|
||||||
|
assert (round(x, 6), round(y, 6)) == (0, 1)
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
# Bout en bout (necessite inkex)
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def run_extension(tmp_path, *args):
|
||||||
|
pytest.importorskip("inkex")
|
||||||
|
from relief_fill import ReliefFill
|
||||||
|
out = tmp_path / "out.svg"
|
||||||
|
ReliefFill().run([*args, "--output={}".format(out), SHAPES])
|
||||||
|
return out.read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
def test_end_to_end_default(tmp_path):
|
||||||
|
svg = run_extension(tmp_path, "--id=rect1")
|
||||||
|
assert "Relief fill" in svg
|
||||||
|
assert 'id="rect1"' in svg
|
||||||
|
assert len(list(generated_paths(svg))) == 1 # un seul chemin pour un rectangle
|
||||||
|
|
||||||
|
|
||||||
|
def test_end_to_end_non_standard_document_scale(tmp_path):
|
||||||
|
"""Page de 400 mm pour 538,72 unites : svg.unittouu() s'y tromperait."""
|
||||||
|
pytest.importorskip("inkex")
|
||||||
|
from relief_fill import ReliefFill
|
||||||
|
out = tmp_path / "out.svg"
|
||||||
|
ReliefFill().run(["--id=rect1", "--output={}".format(out), SCALED])
|
||||||
|
width = re.search(r"stroke-width:([\d.]+)", out.read_text(encoding="utf-8"))
|
||||||
|
assert float(width.group(1)) == pytest.approx(0.3 * 538.72053 / 400, abs=0.001)
|
||||||
|
|
||||||
|
|
||||||
|
def test_end_to_end_removes_original(tmp_path):
|
||||||
|
svg = run_extension(tmp_path, "--id=rect1", "--keep_original=false")
|
||||||
|
assert 'id="rect1"' not in svg
|
||||||
|
|
||||||
|
|
||||||
|
def test_end_to_end_color(tmp_path):
|
||||||
|
svg = run_extension(tmp_path, "--id=rect1", "--stroke_color={}".format(0x336699FF))
|
||||||
|
assert re.search(r"stroke:#336699", svg)
|
||||||
|
|
||||||
|
|
||||||
|
def generated_paths(svg_text):
|
||||||
|
"""Points (x, y) de chaque chemin du groupe « Relief fill »."""
|
||||||
|
root = ET.fromstring(svg_text)
|
||||||
|
for group in root.iter("{http://www.w3.org/2000/svg}g"):
|
||||||
|
if "Relief fill" in group.attrib.values():
|
||||||
|
for path in group.iter("{http://www.w3.org/2000/svg}path"):
|
||||||
|
nums = [float(n) for n in re.findall(r"-?\d+\.?\d*", path.get("d"))]
|
||||||
|
yield [(nums[k], nums[k + 1]) for k in range(0, len(nums) - 1, 2)]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("shape,box", [
|
||||||
|
("rect1", (10, 10, 60, 50)),
|
||||||
|
("circle1", (78, 8, 122, 52)),
|
||||||
|
("path1", (120, 0, 200, 60)),
|
||||||
|
])
|
||||||
|
def test_end_to_end_stays_in_bounding_box(tmp_path, shape, box):
|
||||||
|
svg = run_extension(tmp_path, "--id=" + shape, "--edge=3")
|
||||||
|
points = [p for pts in generated_paths(svg) for p in pts]
|
||||||
|
assert points
|
||||||
|
for x, y in points:
|
||||||
|
assert box[0] - 0.01 <= x <= box[2] + 0.01
|
||||||
|
assert box[1] - 0.01 <= y <= box[3] + 0.01
|
||||||
|
|
||||||
|
|
||||||
|
def test_end_to_end_hole_stays_empty(tmp_path):
|
||||||
|
svg = run_extension(tmp_path, "--id=path2", "--edge=3")
|
||||||
|
count = 0
|
||||||
|
for pts in generated_paths(svg):
|
||||||
|
for k in range(0, len(pts) - 1, 2):
|
||||||
|
count += 1
|
||||||
|
mid = ((pts[k][0] + pts[k + 1][0]) / 2, (pts[k][1] + pts[k + 1][1]) / 2)
|
||||||
|
assert not (36 < mid[0] < 64 and 81 < mid[1] < 99)
|
||||||
|
assert count > 20
|
||||||
|
|
||||||
|
|
||||||
|
def test_end_to_end_group_with_transform(tmp_path):
|
||||||
|
"""Groupe transforme : le motif suit l'ellipse, decalee de (100, 65)."""
|
||||||
|
svg = run_extension(tmp_path, "--id=group1", "--edge=3")
|
||||||
|
points = [p for pts in generated_paths(svg) for p in pts]
|
||||||
|
assert points
|
||||||
|
for x, y in points:
|
||||||
|
assert 105 - 0.01 <= x <= 185 + 0.01
|
||||||
|
assert 70 - 0.01 <= y <= 110 + 0.01
|
||||||
|
|
||||||
|
|
||||||
|
def test_end_to_end_outline_option(tmp_path):
|
||||||
|
svg = run_extension(tmp_path, "--id=rect1", "--draw_outline=true")
|
||||||
|
assert len(list(generated_paths(svg))) == 2 # contour + trait
|
||||||
|
|
||||||
|
|
||||||
|
def test_end_to_end_too_many_segments(tmp_path, capsys):
|
||||||
|
pytest.importorskip("inkex")
|
||||||
|
from relief_fill import ReliefFill
|
||||||
|
ReliefFill().run(["--id=rect1", "--edge=0.01",
|
||||||
|
"--output={}".format(tmp_path / "out.svg"), SHAPES])
|
||||||
|
assert "too many lines" in capsys.readouterr().err
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
# Traductions
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def test_translations_up_to_date_and_complete():
|
||||||
|
"""Chaque texte du .inx et des .py a sa traduction dans chaque catalogue."""
|
||||||
|
import gettext
|
||||||
|
import i18n
|
||||||
|
|
||||||
|
msgids = [msgid for msgid, _refs in i18n.extract()]
|
||||||
|
assert "Relief Pattern Fill" in msgids
|
||||||
|
assert "mm" not in msgids # unites marquees translatable="no"
|
||||||
|
|
||||||
|
for language in i18n.LANGUAGES:
|
||||||
|
entries = i18n.read_po(i18n.po_path(language))
|
||||||
|
missing = [m for m in msgids if not entries.get(m, ("", False))[0]]
|
||||||
|
assert not missing, "{}.po incomplet : {}".format(language, missing)
|
||||||
|
|
||||||
|
catalog = gettext.translation(i18n.DOMAIN, i18n.LOCALE_DIR, [language])
|
||||||
|
for msgid in msgids:
|
||||||
|
assert catalog.gettext(msgid) == entries[msgid][0], \
|
||||||
|
"{} : .mo a recompiler (python i18n.py)".format(language)
|
||||||
|
|
||||||
|
|
||||||
|
def test_po_roundtrip(tmp_path):
|
||||||
|
import i18n
|
||||||
|
|
||||||
|
messages = [("Simple", ["a"]), ('Quote "x" and \\ back', ["a"]),
|
||||||
|
("Two\nlines", ["a"]), ("Tab\tend\n", ["a"])]
|
||||||
|
path = str(tmp_path / "xx.po")
|
||||||
|
i18n.write_po(path, "fr", messages,
|
||||||
|
{m: ("<" + m + ">", False) for m, _r in messages})
|
||||||
|
entries = i18n.read_po(path)
|
||||||
|
for msgid, _refs in messages:
|
||||||
|
assert entries[msgid] == ("<" + msgid + ">", False)
|
||||||
|
|
||||||
|
|
||||||
|
def test_inx_matches_arguments():
|
||||||
|
"""Chaque <param> du .inx a son add_argument, et inversement."""
|
||||||
|
root = ET.parse(os.path.join(HERE, "relief_fill.inx")).getroot()
|
||||||
|
params = {elem.get("name") for elem in root.iter()
|
||||||
|
if elem.tag.rsplit("}", 1)[-1] == "param"}
|
||||||
|
with open(os.path.join(HERE, "relief_fill.py"), encoding="utf-8") as handle:
|
||||||
|
arguments = set(re.findall(r'add_argument\("--(\w+)"', handle.read()))
|
||||||
|
assert params == arguments
|
||||||
|
|
||||||
|
|
||||||
|
def test_inx_images_exist():
|
||||||
|
"""Les images de la boite de dialogue sont presentes (chemin relatif au .inx)."""
|
||||||
|
root = ET.parse(os.path.join(HERE, "relief_fill.inx")).getroot()
|
||||||
|
for elem in root.iter():
|
||||||
|
if elem.tag.rsplit("}", 1)[-1] == "image":
|
||||||
|
assert os.path.isfile(os.path.join(HERE, elem.text.strip())), elem.text
|
||||||
6
tests/data/scaled.svg
Normal file
6
tests/data/scaled.svg
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!-- Echelle non standard (page redimensionnee) : 1 mm = 1,3468 unite utilisateur -->
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="400mm" height="400mm"
|
||||||
|
viewBox="0 0 538.72053 538.72054">
|
||||||
|
<rect id="rect1" x="100" y="100" width="200" height="100" fill="#e9ecef" stroke="#212529"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 334 B |
13
tests/data/shapes.svg
Normal file
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 |
Loading…
Reference in New Issue
Block a user