|
| 1 | +"""Blender 5.2 coverage for SVG raster images produced by Typst.""" |
| 2 | + |
| 3 | +from __future__ import annotations |
| 4 | + |
| 5 | +import base64 |
| 6 | +from pathlib import Path |
| 7 | +import struct |
| 8 | +import zlib |
| 9 | + |
| 10 | +import bpy |
| 11 | +import pytest |
| 12 | + |
| 13 | +from typst_importer.image_import import extract_svg_images |
| 14 | +from typst_importer.svg_preprocessing import SVG_NS, preprocess_svg |
| 15 | +from typst_importer.typst_to_svg import typst_to_blender_curves |
| 16 | + |
| 17 | + |
| 18 | +pytestmark = pytest.mark.skipif( |
| 19 | + bpy.app.version < (5, 2, 0), |
| 20 | + reason="These workflows require Blender 5.2 or newer", |
| 21 | +) |
| 22 | + |
| 23 | + |
| 24 | +def _png_bytes(width=1, height=1): |
| 25 | + def chunk(kind, data): |
| 26 | + checksum = zlib.crc32(kind + data) & 0xFFFFFFFF |
| 27 | + return struct.pack(">I", len(data)) + kind + data + struct.pack(">I", checksum) |
| 28 | + |
| 29 | + row = b"\x00" + b"\xff\x40\x20\xff" * width |
| 30 | + return ( |
| 31 | + b"\x89PNG\r\n\x1a\n" |
| 32 | + + chunk("IHDR".encode(), struct.pack(">IIBBBBB", width, height, 8, 6, 0, 0, 0)) |
| 33 | + + chunk("IDAT".encode(), zlib.compress(row * height)) |
| 34 | + + chunk("IEND".encode(), b"") |
| 35 | + ) |
| 36 | + |
| 37 | + |
| 38 | +def _data_uri(width=1, height=1): |
| 39 | + return "data:image/png;base64," + base64.b64encode( |
| 40 | + _png_bytes(width, height) |
| 41 | + ).decode("ascii") |
| 42 | + |
| 43 | + |
| 44 | +def test_preprocessing_extracts_embedded_use_image_with_viewport_geometry(): |
| 45 | + svg = f'''<svg xmlns="{SVG_NS}" width="100" height="100"> |
| 46 | + <defs><symbol id="asset" viewBox="0 0 10 20" |
| 47 | + preserveAspectRatio="none"> |
| 48 | + <image width="10" height="20" preserveAspectRatio="none" |
| 49 | + href="{_data_uri()}"/> |
| 50 | + </symbol></defs> |
| 51 | + <use href="#asset" x="4" y="5" width="40" height="60"/> |
| 52 | + </svg>''' |
| 53 | + |
| 54 | + images, warnings = extract_svg_images(preprocess_svg(svg)) |
| 55 | + |
| 56 | + assert warnings == [] |
| 57 | + assert len(images) == 1 |
| 58 | + xs = [corner[0] for corner in images[0]["corners"]] |
| 59 | + ys = [corner[1] for corner in images[0]["corners"]] |
| 60 | + assert min(xs) == pytest.approx(4.0) |
| 61 | + assert max(xs) == pytest.approx(44.0) |
| 62 | + assert min(ys) == pytest.approx(5.0) |
| 63 | + assert max(ys) == pytest.approx(65.0) |
| 64 | + |
| 65 | + |
| 66 | +def test_external_images_are_contained_to_the_typst_source_folder(tmp_path: Path): |
| 67 | + source_dir = tmp_path / "source" |
| 68 | + source_dir.mkdir() |
| 69 | + outside_image = tmp_path / "outside.png" |
| 70 | + outside_image.write_bytes(_png_bytes()) |
| 71 | + svg = f'''<svg xmlns="{SVG_NS}" width="10" height="10"> |
| 72 | + <image width="10" height="10" href="../outside.png"/> |
| 73 | + </svg>''' |
| 74 | + |
| 75 | + images, warnings = extract_svg_images( |
| 76 | + preprocess_svg(svg), |
| 77 | + svg_dir=source_dir, |
| 78 | + ) |
| 79 | + |
| 80 | + assert images == [] |
| 81 | + assert any("outside the SVG directory" in warning for warning in warnings) |
| 82 | + |
| 83 | + |
| 84 | +def test_preprocessing_bounds_recursive_use_expansion(): |
| 85 | + svg = f'''<svg xmlns="{SVG_NS}" width="10" height="10"> |
| 86 | + <defs><g id="loop"> |
| 87 | + <use href="#loop"/><use href="#loop"/><use href="#loop"/> |
| 88 | + </g></defs> |
| 89 | + <use href="#loop"/> |
| 90 | + </svg>''' |
| 91 | + |
| 92 | + with pytest.raises(ValueError, match="expansion limit"): |
| 93 | + preprocess_svg(svg) |
| 94 | + |
| 95 | + |
| 96 | +def test_typst_image_becomes_a_packed_textured_plane(tmp_path: Path): |
| 97 | + image_file = tmp_path / "pixel.png" |
| 98 | + image_file.write_bytes(_png_bytes()) |
| 99 | + typst_file = tmp_path / "image.typ" |
| 100 | + typst_file.write_text( |
| 101 | + "#set page(width: auto, height: auto, margin: 0pt, fill: none)\n" |
| 102 | + '#rect(width: 20pt, height: 20pt, fill: rgb("#336699"))\n' |
| 103 | + '#image("pixel.png", width: 20pt)\n', |
| 104 | + encoding="utf-8", |
| 105 | + ) |
| 106 | + |
| 107 | + collection = typst_to_blender_curves( |
| 108 | + typst_file, |
| 109 | + scale_factor=100.0, |
| 110 | + convert_to_mesh=False, |
| 111 | + ) |
| 112 | + |
| 113 | + planes = [obj for obj in collection.objects if obj.type == "MESH"] |
| 114 | + assert len(planes) == 1 |
| 115 | + plane = planes[0] |
| 116 | + assert plane.get("typst_svg_image_object") is True |
| 117 | + curves = [obj for obj in collection.objects if obj.type == "CURVE"] |
| 118 | + assert len(curves) == 1 |
| 119 | + assert plane.get("svg_paint_index") > curves[0].get("svg_paint_index") |
| 120 | + assert plane.dimensions.x == pytest.approx(curves[0].dimensions.x, abs=2e-6) |
| 121 | + assert plane.dimensions.y == pytest.approx(curves[0].dimensions.y, abs=2e-6) |
| 122 | + material = plane.data.materials[0] |
| 123 | + texture = next( |
| 124 | + node |
| 125 | + for node in material.node_tree.nodes |
| 126 | + if node.bl_idname == "ShaderNodeTexImage" |
| 127 | + ) |
| 128 | + assert texture.image.packed_file |
| 129 | + assert not any( |
| 130 | + obj.name.startswith("__ESVG_IMG_") for obj in bpy.data.objects |
| 131 | + ) |
| 132 | + assert not any( |
| 133 | + material.get("typst_svg_blender_material") and material.users == 0 |
| 134 | + for material in bpy.data.materials |
| 135 | + ) |
0 commit comments