inkscape.isoCoucheGreyScale/test_gray_iso_layers.py
2026-10-01 19:12:46 +02:00

791 lines
34 KiB
Python

# coding=utf-8
"""Tests de l'extension « Gray Iso-Layers » (pytest)."""
import base64
import io
import math
import os
import re
import xml.etree.ElementTree as ET
import numpy as np
import pytest
from gray_iso_layers_core import (
box_blur, contour_lines, contour_rings, edge_preserving_blur, gaussian_blur,
gray_to_hex, image_ring, iso_boards, iso_layers, lines_to_d, normalize,
parse_color, ring_area, rings_to_d, scale_lines, scale_rings, simplify_chain,
simplify_ring, smooth, thresholds)
HERE = os.path.dirname(os.path.abspath(__file__))
def cone(size=101, radius=40.0):
"""Champ en cone : 1 au centre, 0 a `radius` echantillons et au-dela."""
y, x = np.mgrid[0:size, 0:size]
distance = np.hypot(x - (size - 1) / 2.0, y - (size - 1) / 2.0)
return np.clip(1.0 - distance / radius, 0.0, 1.0)
def ramp(width=60, height=40):
"""Rampe horizontale de 0 (gauche) a 1 (droite)."""
return np.tile(np.linspace(0.0, 1.0, width), (height, 1))
def evenodd_area(rings):
"""Aire d'un contour a trous imbriques simples : exterieur moins trous."""
areas = sorted((ring_area(ring) for ring in rings), reverse=True)
return sum(a if k % 2 == 0 else -a for k, a in enumerate(areas))
def contains(rings, x, y):
"""Le point est-il dans le contour, selon la regle pair-impair ?"""
crossings = 0
for ring in rings:
x0, y0 = ring[:, 0], ring[:, 1]
x1, y1 = np.roll(x0, -1), np.roll(y0, -1)
straddle = (y0 > y) != (y1 > y)
with np.errstate(divide="ignore", invalid="ignore"):
at = x0 + (y - y0) * (x1 - x0) / (y1 - y0)
crossings += int(np.count_nonzero(straddle & (at > x)))
return crossings % 2 == 1
def bilinear(field, x, y):
j, i = min(int(x), field.shape[1] - 2), min(int(y), field.shape[0] - 2)
u, v = x - j, y - i
return ((1 - u) * (1 - v) * field[i, j] + u * (1 - v) * field[i, j + 1]
+ (1 - u) * v * field[i + 1, j] + u * v * field[i + 1, j + 1])
def relief(seed=1, width=90, height=60, blur=4.0):
"""Relief aleatoire lisse, tel que l'extension le quantifie (deja floute
et etire : a passer a iso_layers avec blur=0)."""
rng = np.random.default_rng(seed)
return normalize(gaussian_blur(rng.random((height, width)), blur))
# --------------------------------------------------------------------------
# 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_gray_to_hex():
assert gray_to_hex(0) == "#000000"
assert gray_to_hex(1) == "#ffffff"
assert gray_to_hex(0.5) == "#808080"
assert gray_to_hex(2) == "#ffffff"
def test_thresholds_are_regular():
assert thresholds(4) == [0.25, 0.5, 0.75]
assert thresholds(2) == [0.5]
def test_normalize_stretches_and_handles_uniform():
field = normalize(np.array([[0.2, 0.4], [0.3, 0.6]]))
assert field.min() == 0.0 and field.max() == 1.0
assert not normalize(np.full((3, 3), 0.7)).any()
def test_gaussian_blur_keeps_mean_and_uniform_field():
uniform = np.full((20, 30), 0.4)
assert np.allclose(gaussian_blur(uniform, 3.0), 0.4)
spot = np.zeros((41, 41))
spot[20, 20] = 1.0
blurred = gaussian_blur(spot, 2.0)
assert blurred.max() < 0.1
assert math.isclose(blurred.sum(), 1.0, rel_tol=1e-6)
assert np.array_equal(gaussian_blur(spot, 0), spot)
def test_box_blur_is_a_window_mean():
assert np.allclose(box_blur(np.full((9, 12), 0.3), 2), 0.3)
spot = np.zeros((9, 9))
spot[4, 4] = 1.0
blurred = box_blur(spot, 1)
assert np.allclose(blurred[3:6, 3:6], 1 / 9.0) and blurred[2, 4] == 0
assert np.array_equal(box_blur(spot, 0), spot)
def noisy_step(seed=3):
"""Deux aplats (0.25 et 0.75) separes par un bord vertical net, avec du grain."""
rng = np.random.default_rng(seed)
field = np.where(np.arange(80) < 40, 0.25, 0.75) * np.ones((60, 1))
return field + rng.normal(0, 0.03, field.shape)
def test_edge_preserving_blur_flattens_grain_but_keeps_edges():
field = noisy_step()
kept = edge_preserving_blur(field, 6)
plain = gaussian_blur(field, 6)
# Le grain des aplats est aplani...
assert kept[:, 5:30].std() < 0.01 and kept[:, 50:75].std() < 0.01
# ... mais le bord reste net, la ou le flou gaussien l'etale.
assert kept[:, 38].mean() < 0.3 and kept[:, 41].mean() > 0.7
assert plain[:, 38].mean() > 0.4 and plain[:, 41].mean() < 0.6
assert np.allclose(edge_preserving_blur(np.full((20, 20), 0.6), 4), 0.6)
assert np.array_equal(edge_preserving_blur(field, 0), field)
def test_smooth_stretches_and_selects_the_filter():
field = noisy_step()
for preserve in (False, True):
result = smooth(field, 5.0, preserve)
assert result.min() == 0.0 and result.max() == 1.0
assert np.array_equal(smooth(field, 0.0, True), normalize(field))
# Largeur de la transition entre les deux aplats, en echantillons.
def width(result):
row = result.mean(axis=0)
return int(np.count_nonzero((row > 0.1) & (row < 0.9)))
assert width(smooth(field, 5.0, True)) < width(smooth(field, 5.0, False)) / 2.0
def test_iso_layers_preserve_edges_keeps_a_thin_feature():
# Un trait sombre fin sur fond clair (une bouche, une branche de
# lunettes) : le flou gaussien l'etale, le lissage qui respecte les
# contours lui garde sa largeur.
field = np.full((80, 80), 0.8)
field[38:42, 15:65] = 0.1
field += np.random.default_rng(2).normal(0, 0.02, field.shape)
def thickness(result):
"""Epaisseur du trait a mi-hauteur, en echantillons."""
return int(np.count_nonzero(result[:, 35:45].mean(axis=1) < 0.5))
assert thickness(smooth(field, 6.0, False)) >= 10
assert thickness(smooth(field, 6.0, True)) <= 6
# Le niveau le plus sombre reste une bande fine autour du trait.
dark = iso_layers(field, levels=2, blur=6.0, preserve_edges=True)[0][2]
assert len(dark) == 1
assert dark[0][:, 1].max() - dark[0][:, 1].min() <= 7
def test_contour_of_cone_is_a_circle():
# Seuil 0.5 sur un cone de rayon 40 : cercle de rayon 20.
rings = contour_rings(cone(), 0.5)
assert len(rings) == 1
assert math.isclose(ring_area(rings[0]), math.pi * 20 ** 2, rel_tol=0.01)
radii = np.hypot(rings[0][:, 0] - 50, rings[0][:, 1] - 50)
assert np.allclose(radii, 20, atol=0.3)
def test_contour_below_threshold_has_a_hole():
# Region « sous le seuil » : le rectangle entier perce du cercle.
rings = contour_rings(cone(), 0.5, above=False)
assert len(rings) == 2
assert math.isclose(evenodd_area(rings), 100 * 100 - math.pi * 20 ** 2, rel_tol=0.01)
def test_contour_closes_along_the_image_border():
# Rampe : la region >= 0.5 est la moitie droite, fermee par les bords.
rings = contour_rings(ramp(), 0.5)
assert len(rings) == 1
ring = rings[0]
assert math.isclose(ring_area(ring), 29.5 * 39, rel_tol=1e-6)
assert math.isclose(ring[:, 0].min(), 29.5, abs_tol=1e-6)
assert ring[:, 0].max() == 59 and ring[:, 1].min() == 0 and ring[:, 1].max() == 39
def test_contour_saddle_and_empty_cases():
checker = np.array([[1.0, 0.0, 1.0], [0.0, 1.0, 0.0], [1.0, 0.0, 1.0]])
for ring in contour_rings(checker, 0.5):
assert len(ring) >= 3
assert contour_rings(np.zeros((5, 5)), 0.5) == []
assert contour_rings(np.zeros((1, 5)), 0.5) == []
full = contour_rings(np.ones((5, 7)), 0.5)
assert len(full) == 1 and math.isclose(ring_area(full[0]), 6 * 4)
def test_simplify_ring_removes_aligned_points_only():
square = []
for k in range(10):
square.append((k, 0))
for k in range(10):
square.append((10, k))
for k in range(10):
square.append((10 - k, 10))
for k in range(10):
square.append((0, 10 - k))
simplified = simplify_ring(np.array(square, dtype=float), 0.1)
assert len(simplified) == 4
assert math.isclose(ring_area(simplified), 100.0)
circle = contour_rings(cone(), 0.5)[0]
light = simplify_ring(circle, 0.3)
assert len(light) < len(circle)
assert math.isclose(ring_area(light), ring_area(circle), rel_tol=0.02)
assert len(simplify_ring(circle, 0)) == len(circle)
def test_simplify_chain_keeps_both_ends():
chain = np.array([(0, 0), (1, 0.01), (2, 0), (3, 2), (4, 4.01), (5, 6)], dtype=float)
assert simplify_chain(chain, 0.1).tolist() == [[0, 0], [2, 0], [5, 6]]
assert len(simplify_chain(chain, 0)) == 6
def test_contour_lines_split_closed_and_open():
closed, opened = contour_lines(cone(), 0.5)
assert len(closed) == 1 and opened == []
# Rampe : une ligne ouverte, du bord haut au bord bas.
closed, opened = contour_lines(ramp(), 0.5, tolerance=0.1)
assert closed == [] and len(opened) == 1
assert sorted(opened[0].tolist()) == [[29.5, 0.0], [29.5, 39.0]]
# Une ligne que la simplification aplatit sur le bord est ecartee.
bump = np.zeros((40, 60))
bump[0, 20:30] = 0.6
assert len(contour_lines(bump, 0.5)[1]) == 1
assert contour_lines(bump, 0.5, tolerance=2.0) == ([], [])
# Aire trop petite : boucle et ligne ecartees.
assert contour_lines(cone(), 0.5, min_area=2000) == ([], [])
assert contour_lines(ramp(), 0.9, min_area=500) == ([], [])
def test_iso_layers_bands_of_cone_are_rings():
layers = iso_layers(cone(), levels=4)
assert [level for level, _gray, _rings in layers] == [0, 1, 2, 3]
assert [gray for _level, gray, _rings in layers] == [0.0, 1 / 3.0, 2 / 3.0, 1.0]
areas = [evenodd_area(rings) for _level, _gray, rings in layers]
# Seuils 1/4, 1/2, 3/4 : cercles de rayons 30, 20 et 10. Chaque aplat est
# la couronne entre deux cercles, bordee des deux cotes.
disc = [math.pi * r ** 2 for r in (30, 20, 10)]
expected = [100 * 100 - disc[0], disc[0] - disc[1], disc[1] - disc[2], disc[2]]
for area, wanted in zip(areas, expected):
assert math.isclose(area, wanted, rel_tol=0.02)
assert [len(rings) for _level, _gray, rings in layers] == [2, 2, 2, 1]
assert math.isclose(sum(areas), 100 * 100, rel_tol=1e-9)
@pytest.mark.parametrize("tolerance", [0.0, 0.4])
def test_iso_layers_bands_are_the_quantized_image(tolerance):
"""Chaque point de l'image est dans l'aplat de son niveau quantifie, et
dans aucun autre : les contours sont bien les frontieres des niveaux."""
levels = 8
field = relief()
layers = iso_layers(field, levels=levels, tolerance=tolerance)
assert len(layers) == levels
rng = np.random.default_rng(5)
checked = 0
for _ in range(600):
x, y = rng.uniform(0, 89), rng.uniform(0, 59)
value = bilinear(field, x, y) * levels
if abs(value - round(value)) < 0.15:
continue # trop pres d'un seuil : interpolation et simplification
inside = [level for level, _gray, rings in layers if contains(rings, x, y)]
assert inside == [min(int(value), levels - 1)], (x, y, value)
checked += 1
assert checked > 300
def test_iso_layers_bands_never_overlap_when_small_shapes_are_dropped():
"""Avec `min_area`, des formes disparaissent, mais aucun point ne se
retrouve dans deux aplats et les grands aplats restent a leur place."""
levels = 5
rng = np.random.default_rng(7)
# Relief a grain fin : beaucoup de petites formes, y compris sur le bord.
field = normalize(gaussian_blur(rng.random((121, 161)), 2.0))
for seed in range(4):
field = normalize(field + 0.6 * relief(seed, 161, 121, 12.0))
layers = iso_layers(field, levels=levels, tolerance=0.3, min_area=0.002)
probes = np.random.default_rng(seed)
wrong = 0
for _ in range(400):
x, y = probes.uniform(0, 160), probes.uniform(0, 120)
inside = [level for level, _gray, rings in layers if contains(rings, x, y)]
assert len(inside) <= 1, (x, y, inside)
value = bilinear(field, x, y) * levels
wrong += inside != [min(int(value), levels - 1)]
# Seuls les points des petites formes ecartees changent de niveau.
assert wrong < 60, wrong
def test_iso_layers_dropped_speck_on_the_border_does_not_flip_a_band():
# Coin sombre en haut a gauche, et un eclat sombre pose sur le bord pile a
# l'oppose (coin bas-droit) : une fois l'eclat ecarte, l'aplat sombre doit
# rester le coin, pas son complementaire.
field = np.ones((60, 60))
field[:20, :20] = 0.0
field[59, 59] = 0.0
dark, light = iso_layers(field, levels=2, min_area=0.001)
assert len(dark[2]) == 1 and len(light[2]) == 1
assert math.isclose(ring_area(dark[2][0]), 19.5 ** 2, rel_tol=0.01)
assert math.isclose(ring_area(light[2][0]), 59 ** 2 - 19.5 ** 2, rel_tol=0.01)
def test_iso_layers_neighbouring_bands_share_their_border():
layers = iso_layers(relief(), levels=6, tolerance=0.4)
def interior(rings):
points = np.vstack(rings)
edge = ((points[:, 0] < 1e-6) | (points[:, 0] > 89 - 1e-6)
| (points[:, 1] < 1e-6) | (points[:, 1] > 59 - 1e-6))
return {tuple(point) for point in points[~edge].round(9).tolist()}
sets = [interior(rings) for _level, _gray, rings in layers]
for k, points in enumerate(sets):
neighbours = set()
for n in (k - 1, k + 1):
if 0 <= n < len(sets):
neighbours |= sets[n]
# Tout point de contour interieur appartient aussi a un aplat voisin.
assert points <= neighbours
assert sum(1 for points in sets if points) >= 4
def test_iso_layers_light_sheets_are_nested_discs():
layers = iso_layers(cone(), levels=4, shapes="light")
assert [level for level, _gray, _rings in layers] == [0, 1, 2, 3]
areas = [evenodd_area(rings) for _level, _gray, rings in layers]
assert math.isclose(areas[0], 100 * 100)
for area, radius in zip(areas[1:], (30, 20, 10)):
assert math.isclose(area, math.pi * radius ** 2, rel_tol=0.02)
def test_iso_layers_dark_sheets():
layers = iso_layers(cone(), levels=4, shapes="dark")
assert [level for level, _gray, _rings in layers] == [3, 2, 1, 0]
areas = [evenodd_area(rings) for _level, _gray, rings in layers]
assert math.isclose(areas[0], 100 * 100)
for area, radius in zip(areas[1:], (10, 20, 30)):
assert math.isclose(area, 100 * 100 - math.pi * radius ** 2, rel_tol=0.02)
def test_iso_layers_close_along_the_image_border():
# Rampe en 3 niveaux : trois bandes verticales, des rectangles.
layers = iso_layers(ramp(), levels=3, tolerance=0.1)
for (_level, _gray, rings), (left, right) in zip(
layers, ((0, 59 / 3.0), (59 / 3.0, 118 / 3.0), (118 / 3.0, 59))):
assert len(rings) == 1 and len(rings[0]) == 4
assert math.isclose(rings[0][:, 0].min(), left, abs_tol=1e-6)
assert math.isclose(rings[0][:, 0].max(), right, abs_tol=1e-6)
assert math.isclose(ring_area(rings[0]), (right - left) * 39, rel_tol=1e-6)
def test_iso_layers_uniform_image_gives_base_only():
layers = iso_layers(np.full((20, 30), 0.5), levels=8)
assert len(layers) == 8
assert np.array_equal(layers[0][2][0], image_ring(30, 20))
assert all(rings == [] for _level, _gray, rings in layers[1:])
def test_iso_layers_min_area_removes_specks():
field = np.zeros((80, 80))
field[10:50, 10:50] = 1.0
field[70, 70] = 1.0
assert len(iso_layers(field, levels=2)[1][2]) == 2
assert len(iso_layers(field, levels=2, min_area=0.01)[1][2]) == 1
@pytest.mark.parametrize("shapes", ["band", "light", "dark"])
def test_iso_layers_always_one_layer_per_level(shapes):
rng = np.random.default_rng(1)
field = rng.random((60, 90))
for levels in (2, 5, 16):
layers = iso_layers(field, levels=levels, blur=4.0, tolerance=0.4,
min_area=0.002, shapes=shapes)
assert len(layers) == levels
assert sorted(level for level, _gray, _rings in layers) == list(range(levels))
for _level, _gray, rings in layers:
for ring in rings:
assert len(ring) >= 3
assert ring[:, 0].min() >= 0 and ring[:, 0].max() <= 89
assert ring[:, 1].min() >= 0 and ring[:, 1].max() <= 59
def numbers(d):
"""Ensemble des points "x,y" d'une donnee de chemin."""
return set(re.findall(r"-?[\d.]+,-?[\d.]+", d))
def test_iso_boards_carry_the_outline_of_the_next_board():
# Empilees, claire devant : chaque planche porte le bord de la suivante.
boards = iso_boards(cone(), levels=4, shapes="light")
assert [board[0] for board in boards] == [0, 1, 2, 3]
for board, following in zip(boards, boards[1:]):
closed, opened = board[3]
assert opened == [] and len(closed) == 1
assert np.array_equal(closed[0], following[2][0])
assert boards[-1][3] is None
# Sombre devant : la pile part de la planche la plus claire.
boards = iso_boards(cone(), levels=4, shapes="dark")
assert [board[0] for board in boards] == [3, 2, 1, 0]
for board, following in zip(boards, boards[1:]):
closed, _opened = board[3]
# Le bord de la planche suivante est le trou qu'elle laisse au centre.
assert any(np.array_equal(closed[0], ring) for ring in following[2])
assert boards[-1][3] is None
# Aplats cote a cote : rien a empiler, donc aucun repere.
assert all(board[3] is None for board in iso_boards(cone(), levels=4, shapes="band"))
# iso_layers : les memes planches, sans les reperes.
layers = iso_layers(cone(), levels=4, shapes="light")
assert [len(layer) for layer in layers] == [3, 3, 3, 3]
assert all(np.array_equal(layer[2][0], board[2][0])
for layer, board in zip(layers, iso_boards(cone(), levels=4)))
def test_iso_boards_marks_skip_the_image_border():
# Rampe : la planche suivante touche trois bords de l'image, mais seul son
# bord interieur (une ligne ouverte) est reporte.
boards = iso_boards(ramp(), levels=3, tolerance=0.1, shapes="light")
closed, opened = boards[0][3]
assert closed == [] and len(opened) == 1
assert sorted(opened[0].tolist()) == [[59 / 3.0, 0.0], [59 / 3.0, 39.0]]
box = (0, 0, 59, 39)
mark = lines_to_d(boards[0][3], box=box)
assert mark.startswith("M ") and "Z" not in mark and mark.count("M ") == 1
# Meme trace que la decoupe de la planche suivante.
assert numbers(mark) <= numbers(rings_to_d(boards[1][2], box=box))
def test_lines_to_d_matches_the_cut_of_the_next_board():
field = relief()
boards = iso_boards(field, levels=6, tolerance=0.4, shapes="light")
box = (0, 0, 89, 59)
seen = 0
for board, following in zip(boards, boards[1:]):
mark = lines_to_d(board[3], box=box)
cut = rings_to_d(following[2], box=box)
assert numbers(mark) <= numbers(cut)
seen += bool(mark)
assert seen >= 4
assert lines_to_d(([], [])) == ""
polygon = lines_to_d(([image_ring(11, 6)], [np.array([(0, 0), (4, 3)], dtype=float)]),
smooth=False, precision=0)
assert polygon == "M 0,0 L 10,0 L 10,5 L 0,5 Z M 0,0 L 4,3"
def test_scale_lines():
closed, opened = scale_lines(([image_ring(3, 3)], [np.array([(1.0, 2.0)])]),
2.0, 3.0, 10.0, 20.0)
assert closed[0].tolist()[2] == [14, 26] and opened[0].tolist() == [[12, 26]]
def test_scale_rings():
scaled = scale_rings([image_ring(11, 6)], 2.0, 3.0, 100.0, 200.0)
assert scaled[0].tolist() == [[100, 200], [120, 200], [120, 215], [100, 215]]
def test_rings_to_d_polygon():
d = rings_to_d([image_ring(11, 6)], smooth=False, precision=0)
assert d == "M 0,0 L 10,0 L 10,5 L 0,5 Z"
assert rings_to_d([np.zeros((2, 2))]) == ""
# Transformation affine appliquee a l'ecriture : ici x' = 2x + 100, y' = y.
d = rings_to_d([image_ring(11, 6)], smooth=False, precision=0,
matrix=((2, 0, 100), (0, 1, 0)))
assert d == "M 100,0 L 120,0 L 120,5 L 100,5 Z"
def test_rings_to_d_keeps_the_image_border_straight():
box = (0, 0, 59, 39)
half = simplify_ring(contour_rings(ramp(), 0.5)[0], 0.1)
assert rings_to_d([half], smooth=True, box=box, precision=1) == "M 29.5,0.0 L 59.0,0.0 L 59.0,39.0 L 29.5,39.0 C 29.5,32.5 29.5,6.5 29.5,0.0 Z"
# Demi-disque colle au bord gauche : un cote droit, le reste en courbes
# qui ne sortent pas de l'image.
y, x = np.mgrid[0:40, 0:60]
bump = np.clip(1.0 - np.hypot(x, y - 19.5) / 30.0, 0.0, 1.0)
d = rings_to_d(contour_rings(bump, 0.5), smooth=True, box=box)
assert "L " in d and d.count("C ") > 10 and "-" not in d
circle = contour_rings(cone(), 0.5)[0]
d = rings_to_d([circle], smooth=True, box=(0, 0, 100, 100))
assert "L " not in d and d.count("C ") == len(circle)
# --------------------------------------------------------------------------
# Bout en bout (necessite inkex et Pillow)
# --------------------------------------------------------------------------
SVG = """<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
width="200mm" height="200mm" viewBox="0 0 200 200">
<g id="layer1" inkscape:groupmode="layer" inkscape:label="Photo"
transform="translate(10,20)">
<image id="img1" x="5" y="5" width="100" height="100"
transform="{transform}" preserveAspectRatio="none" xlink:href="{href}"/>
<image id="img2" x="120" y="5" width="50" height="50"
preserveAspectRatio="none" xlink:href="{href}"/>
<rect id="rect1" x="0" y="0" width="10" height="10"/>
</g>
<g id="layer2" inkscape:groupmode="layer" inkscape:label="Dessus"/>
</svg>
"""
def make_document(tmp_path, linked=False, transform=""):
"""Document de test : une image en cone, incorporee ou liee."""
image_module = pytest.importorskip("PIL.Image")
picture = image_module.fromarray((cone(64, 28.0) * 255).astype("uint8"), "L")
if linked:
picture.save(str(tmp_path / "cone.png"))
href = "cone.png"
else:
buffer = io.BytesIO()
picture.save(buffer, "PNG")
href = "data:image/png;base64," + base64.b64encode(buffer.getvalue()).decode()
path = tmp_path / "document.svg"
path.write_text(SVG.format(href=href, transform=transform), encoding="utf-8")
return str(path)
def run_extension(tmp_path, *args, linked=False, document=None, transform=""):
pytest.importorskip("inkex")
from gray_iso_layers import GrayIsoLayers
document = document or make_document(tmp_path, linked, transform)
out = tmp_path / "out.svg"
GrayIsoLayers().run([*args, "--output={}".format(out), document])
# inkex n'ecrit rien quand le document n'a pas change.
return out.read_text(encoding="utf-8") if out.exists() else ""
def paths(svg):
return re.findall(r"<(?:svg:)?path\b[^>]*>", svg)
def layers_of(svg):
"""Calques de premier niveau du document : (nom, nombre de chemins)."""
root = ET.fromstring(svg)
ink = "{http://www.inkscape.org/namespaces/inkscape}"
return [(child.get(ink + "label"),
sum(1 for elem in child.iter() if elem.tag.endswith("}path")))
for child in root if child.get(ink + "groupmode") == "layer"]
def test_end_to_end_side_by_side_boards(tmp_path):
svg = run_extension(tmp_path, "--id=img1", "--levels=4", "--blur=0",
"--shapes=band")
assert 'id="img1"' in svg
# Un calque par niveau, au premier plan, juste au-dessus du calque de
# l'image et dans l'ordre des gris.
assert layers_of(svg) == [("Photo", 0), ("Board no. 1", 1), ("Board no. 2", 1),
("Board no. 3", 1), ("Board no. 4", 1), ("Dessus", 0)]
found = paths(svg)
assert "fill:#000000" in found[0] and "fill:#ffffff" in found[3]
# Aplats : le plus sombre est le rectangle de l'image perce d'un trou,
# les suivants des couronnes, le plus clair un disque.
assert [path.count("M ") for path in found] == [2, 2, 2, 1]
assert "M 15.000,25.000 L 115.000,25.000 L 115.000,125.000 L 15.000,125.000 Z" \
in found[0]
assert all("L " not in path for path in found[1:])
assert svg.count("transform=") == 1 # celle du calque d'origine
def test_end_to_end_default(tmp_path):
svg = run_extension(tmp_path, "--id=img1", "--levels=4", "--blur=0")
# Planches a empiler : chaque calque porte sa decoupe et, sauf le
# dernier, le repere de la planche suivante.
assert layers_of(svg) == [("Photo", 0), ("Board no. 1", 2), ("Board no. 2", 2),
("Board no. 3", 2), ("Board no. 4", 1), ("Dessus", 0)]
found = paths(svg)
cuts = [path for path in found if "stroke:#ff0000" in path]
marks = [path for path in found if "stroke:#000000" in path]
assert len(cuts) == 4 and len(marks) == 3
# Decoupes : le rectangle de l'image en coordonnees du document (calque
# translate de 10, 20), puis des disques de plus en plus petits.
assert ' d="M 15.000,25.000 L 115.000,25.000 L 115.000,125.000 L 15.000,125.000 Z"' \
in cuts[0]
assert "fill:#000000" in cuts[0] and "fill:#ffffff" in cuts[3]
assert [path.count("M ") for path in cuts] == [1, 1, 1, 1]
# Reperes : un trait noir sans remplissage, au trace de la decoupe suivante.
for mark, following in zip(marks, cuts[1:]):
assert "fill:none" in mark
assert numbers(mark) and numbers(mark) <= numbers(following)
assert "Marking of board no. 2" in marks[0] and "Cut" in cuts[0]
assert svg.count("transform=") == 1 # celle du calque d'origine
def test_end_to_end_marking_options(tmp_path):
svg = run_extension(tmp_path, "--id=img1", "--levels=4", "--blur=0",
"--mark=false")
assert [count for name, count in layers_of(svg) if name.startswith("Board")] \
== [1, 1, 1, 1]
svg = run_extension(tmp_path, "--id=img1", "--levels=4", "--blur=0",
"--stroke_color={}".format(0x0000FFFF),
"--mark_color={}".format(0x00AA00FF))
found = paths(svg)
assert sum("stroke:#0000ff" in path for path in found) == 4
assert sum("stroke:#00aa00" in path for path in found) == 3
# Sombre devant : les reperes suivent la pile dans l'autre sens.
svg = run_extension(tmp_path, "--id=img1", "--levels=4", "--blur=0",
"--shapes=dark")
assert [count for name, count in layers_of(svg) if name.startswith("Board")] \
== [2, 2, 2, 1]
# Sans trait, pas de repere non plus.
svg = run_extension(tmp_path, "--id=img1", "--levels=4", "--blur=0",
"--stroke_width=0")
assert len(paths(svg)) == 4
def test_end_to_end_boards_are_numbered_from_the_back(tmp_path):
# La planche n° 1 est toujours celle du fond : la plus sombre, sauf en
# « sombre devant » ou c'est la plus claire, qui couvre toute l'image.
for shapes, back, front in (("band", "#000000", "#ffffff"),
("light", "#000000", "#ffffff"),
("dark", "#ffffff", "#000000")):
svg = run_extension(tmp_path, "--id=img1", "--levels=4", "--blur=0",
"--mark=false", "--shapes=" + shapes)
root = ET.fromstring(svg)
ink = "{http://www.inkscape.org/namespaces/inkscape}"
fills = {}
for layer in root:
label = layer.get(ink + "label") or ""
for elem in layer.iter():
if label.startswith("Board") and elem.tag.endswith("}path"):
fills[label] = re.search(r"fill:(#\w+)", elem.get("style")).group(1)
assert fills["Board no. 1"] == back, shapes
assert fills["Board no. 4"] == front, shapes
def test_end_to_end_one_layer_per_level_even_if_empty(tmp_path):
# Seuil d'aire enorme : seules les grandes formes restent, mais il y a
# toujours autant de calques que de niveaux.
svg = run_extension(tmp_path, "--id=img1", "--levels=6", "--blur=0",
"--min_area=10", "--mark=false")
created = [item for item in layers_of(svg) if item[0].startswith("Board")]
assert [name for name, _count in created] == [
"Board no. {}".format(k) for k in range(1, 7)]
assert created[0][1] == 1 and created[-1][1] == 0
def test_end_to_end_rotated_image(tmp_path):
svg = run_extension(tmp_path, "--id=img1", "--levels=2", "--blur=0",
"--shapes=light", transform="rotate(90)")
# rotate(90) : (x, y) -> (-y, x), puis translation du calque (10, 20).
assert ' d="M 5.000,25.000 L 5.000,125.000 L -95.000,125.000 L -95.000,25.000 Z"' \
in paths(svg)[0]
def test_end_to_end_linked_image_in_selected_group(tmp_path, monkeypatch):
# Lien relatif : resolu depuis le dossier du document.
monkeypatch.setenv("DOCUMENT_PATH", str(tmp_path / "document.svg"))
svg = run_extension(tmp_path, "--id=layer1", "--levels=3", linked=True)
# Deux images dans le groupe : elles partagent les trois calques
# (une decoupe et un repere par image, sauf sur la derniere planche).
assert layers_of(svg) == [("Photo", 0), ("Board no. 1", 4), ("Board no. 2", 4),
("Board no. 3", 2), ("Dessus", 0)]
def test_end_to_end_removes_original(tmp_path):
svg = run_extension(tmp_path, "--id=img1", "--keep_original=false")
assert 'id="img1"' not in svg
def test_end_to_end_styles(tmp_path):
svg = run_extension(tmp_path, "--id=img1", "--levels=3", "--fill_mode=color",
"--mark=false",
"--fill_color={}".format(0x336699FF),
"--stroke_color={}".format(0xCC0000FF),
"--stroke_width=1", "--unit=px")
assert all("fill:#336699" in path and "stroke:#cc0000" in path
for path in paths(svg))
svg = run_extension(tmp_path, "--id=img1", "--levels=3", "--fill_mode=none",
"--stroke_width=0")
assert all("fill:none" in path and "stroke:none" in path for path in paths(svg))
def test_end_to_end_plain_blur(tmp_path):
# Flou simple a la place du lissage qui respecte les contours : le bord
# du cone s'etale, ses disques n'ont plus le meme trace.
kept = run_extension(tmp_path, "--id=img1", "--levels=3", "--blur=8",
"--mark=false")
plain = run_extension(tmp_path, "--id=img1", "--levels=3", "--blur=8",
"--mark=false", "--edges=false")
assert len(paths(kept)) == len(paths(plain)) == 3
assert paths(kept) != paths(plain)
def test_end_to_end_without_image(tmp_path, capsys):
svg = run_extension(tmp_path, "--id=rect1")
assert "Board no. 1" not in svg
assert "Select at least one bitmap image" in capsys.readouterr().err
def test_end_to_end_broken_link(tmp_path, capsys):
pytest.importorskip("PIL.Image")
document = tmp_path / "broken.svg"
document.write_text(SVG.format(href="absente.png", transform=""),
encoding="utf-8")
svg = run_extension(tmp_path, "--id=img1", document=str(document))
assert "Board no. 1" not in svg
assert "Cannot read the image" 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 "Gray Iso-Layers" in msgids
assert "mm" not in msgids # unites marquees translatable="no"
for language in i18n.LANGUAGES:
entries = i18n.read_po(i18n.po_path(language))
missing = [m for m in msgids if not entries.get(m, ("", False))[0]]
assert not missing, "{}.po incomplet : {}".format(language, missing)
catalog = gettext.translation(i18n.DOMAIN, i18n.LOCALE_DIR, [language])
for msgid in msgids:
assert catalog.gettext(msgid) == entries[msgid][0], \
"{} : .mo a recompiler (python i18n.py)".format(language)
def test_po_roundtrip(tmp_path):
import i18n
messages = [("Simple", ["a"]), ('Quote "x" and \\ back', ["a"]),
("Two\nlines", ["a"]), ("Tab\tend\n", ["a"])]
path = str(tmp_path / "xx.po")
i18n.write_po(path, "fr", messages,
{m: ("<" + m + ">", False) for m, _r in messages})
entries = i18n.read_po(path)
for msgid, _refs in messages:
assert entries[msgid] == ("<" + msgid + ">", False)
def test_inx_matches_arguments():
"""Chaque <param> du .inx a son add_argument, avec la meme valeur par defaut."""
root = ET.parse(os.path.join(HERE, "gray_iso_layers.inx")).getroot()
params = {elem.get("name"): (elem.text or "").strip() for elem in root.iter()
if elem.tag.rsplit("}", 1)[-1] == "param"}
with open(os.path.join(HERE, "gray_iso_layers.py"), encoding="utf-8") as handle:
source = handle.read()
arguments = set(re.findall(r'add_argument\("--(\w+)"', source))
assert set(params) == arguments
pytest.importorskip("inkex")
from gray_iso_layers import GrayIsoLayers
options = GrayIsoLayers().arg_parser.parse_args([])
for name, text in params.items():
default = getattr(options, name)
if name in ("tab", "shapes", "fill_mode", "unit"):
continue # listes : la valeur par defaut est la premiere option
if isinstance(default, bool):
assert text == str(default).lower(), name
elif isinstance(default, (int, float)):
assert float(text) == float(default), name
else:
assert text == default, name
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, "gray_iso_layers.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