# 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 = """ """ 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 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