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