test: Wire post-processing into /engine/trace, add output_format routin…

- "engine/api/routes.py"
- "engine/tests/test_api.py"

GSD-Task: S02/T03
This commit is contained in:
jlightner 2026-03-26 04:39:52 +00:00
parent 6d51628ce8
commit 0c197f5497
8 changed files with 919 additions and 66 deletions

View file

@ -1,40 +1,66 @@
"""API routes for the Kerf Engine trace endpoint.""" """API routes for the Kerf Engine trace and simplify endpoints."""
import json import json
import re
import time import time
from fastapi import APIRouter, File, Form, HTTPException, UploadFile from fastapi import APIRouter, File, Form, HTTPException, UploadFile
from fastapi.responses import Response
from output import generate_dxf, generate_json, generate_svg
from pipeline.postprocess import postprocess_svg
from pipeline.preprocessing import preprocess from pipeline.preprocessing import preprocess
from pipeline.vectorize import potrace_trace, vtracer_trace from pipeline.vectorize import potrace_trace, vtracer_trace
router = APIRouter() router = APIRouter()
VALID_MODES = {"potrace", "vtracer"} VALID_MODES = {"potrace", "vtracer"}
VALID_OUTPUT_FORMATS = {"svg", "dxf", "json"}
def _extract_svg_metadata(svg: str) -> dict: def _format_response(
"""Extract basic metadata from an SVG string.""" result,
path_matches = re.findall(r'<path\b[^>]*\bd="([^"]*)"', svg) output_format: str,
path_count = len(path_matches) warnings: list[str],
processing_ms: float,
):
"""Build a standardized response from a PostProcessResult and output format.
node_count_total = 0 SVG and JSON return a JSON envelope with output + metadata.
open_paths = 0 DXF returns raw bytes with application/dxf content type.
for d_attr in path_matches: """
# Count SVG path commands (M, L, C, Q, A, Z, etc.) metadata = {
commands = re.findall(r"[MLHVCSQTAZ]", d_attr, re.IGNORECASE) "format": output_format,
node_count_total += len(commands) "path_count": len(result.paths),
# A path is "open" if it doesn't end with Z "node_count_total": result.total_nodes,
if not d_attr.rstrip().upper().endswith("Z"): "open_paths": result.open_path_count,
open_paths += 1 "island_count": result.island_count,
"warnings": warnings,
return { "processing_ms": processing_ms,
"path_count": path_count,
"node_count_total": node_count_total,
"open_paths": open_paths,
} }
if output_format == "dxf":
dxf_bytes = generate_dxf(result)
return Response(
content=dxf_bytes,
media_type="application/dxf",
headers={
"Content-Disposition": "attachment; filename=output.dxf",
"X-Kerf-Metadata": json.dumps(metadata),
},
)
elif output_format == "json":
return {
"output": json.loads(generate_json(result)),
"format": "json",
"metadata": metadata,
}
else:
return {
"output": generate_svg(result),
"format": "svg",
"metadata": metadata,
}
@router.post("/engine/trace") @router.post("/engine/trace")
async def trace( async def trace(
@ -44,17 +70,20 @@ async def trace(
preset: str = Form("default"), preset: str = Form("default"),
params: str = Form("{}"), params: str = Form("{}"),
): ):
"""Convert a raster image to SVG via the preprocessing + vectorization pipeline.""" """Convert a raster image to vector via the preprocessing + vectorization + post-processing pipeline.
Supports three output formats: svg (default), dxf, json.
"""
if mode not in VALID_MODES: if mode not in VALID_MODES:
raise HTTPException( raise HTTPException(
status_code=422, status_code=422,
detail=f"Invalid mode '{mode}'. Must be one of: {', '.join(sorted(VALID_MODES))}", detail=f"Invalid mode '{mode}'. Must be one of: {', '.join(sorted(VALID_MODES))}",
) )
if output_format != "svg": if output_format not in VALID_OUTPUT_FORMATS:
raise HTTPException( raise HTTPException(
status_code=422, status_code=422,
detail=f"Unsupported output_format '{output_format}'. Only 'svg' is supported.", detail=f"Unsupported output_format '{output_format}'. Must be one of: {', '.join(sorted(VALID_OUTPUT_FORMATS))}",
) )
try: try:
@ -92,14 +121,55 @@ async def trace(
except Exception as exc: except Exception as exc:
raise HTTPException(status_code=500, detail=f"Vectorization failed: {exc}") raise HTTPException(status_code=500, detail=f"Vectorization failed: {exc}")
# Post-processing: RDP simplification, island detection, open path analysis
epsilon = float(user_params.get("epsilon", 1.0))
try:
result = postprocess_svg(svg_output, epsilon=epsilon)
except Exception as exc:
raise HTTPException(status_code=500, detail=f"Post-processing failed: {exc}")
processing_ms = round((time.perf_counter() - start) * 1000, 2) processing_ms = round((time.perf_counter() - start) * 1000, 2)
metadata = _extract_svg_metadata(svg_output) return _format_response(result, output_format, warnings, processing_ms)
metadata["warnings"] = warnings
metadata["processing_ms"] = processing_ms
return {
"output": svg_output, @router.post("/engine/simplify")
"format": "svg", async def simplify(
"metadata": metadata, file: UploadFile = File(...),
} epsilon: float = Form(1.0),
output_format: str = Form("svg"),
):
"""Simplify an existing SVG using RDP path simplification.
Accepts an SVG file upload, applies Ramer-Douglas-Peucker simplification
with the given epsilon, and returns the result in the requested format.
"""
if output_format not in VALID_OUTPUT_FORMATS:
raise HTTPException(
status_code=422,
detail=f"Unsupported output_format '{output_format}'. Must be one of: {', '.join(sorted(VALID_OUTPUT_FORMATS))}",
)
raw_bytes = await file.read()
if not raw_bytes:
raise HTTPException(status_code=422, detail="Uploaded file is empty")
try:
svg_str = raw_bytes.decode("utf-8")
except UnicodeDecodeError:
raise HTTPException(status_code=422, detail="File is not valid UTF-8 text")
if "<svg" not in svg_str.lower():
raise HTTPException(status_code=422, detail="File does not appear to be a valid SVG")
warnings: list[str] = []
start = time.perf_counter()
try:
result = postprocess_svg(svg_str, epsilon=epsilon)
except Exception as exc:
raise HTTPException(status_code=500, detail=f"Simplification failed: {exc}")
processing_ms = round((time.perf_counter() - start) * 1000, 2)
return _format_response(result, output_format, warnings, processing_ms)

View file

@ -0,0 +1,7 @@
"""Output format generators for the Kerf Engine pipeline."""
from output.dxf import generate_dxf
from output.json_output import generate_json
from output.svg import generate_svg
__all__ = ["generate_dxf", "generate_json", "generate_svg"]

66
engine/output/dxf.py Normal file
View file

@ -0,0 +1,66 @@
"""DXF output generator — AC1015+ DXF from PostProcessResult using ezdxf."""
from __future__ import annotations
import io
import ezdxf
from pipeline.postprocess import PathInfo, PostProcessResult
def _add_path_to_msp(
msp: ezdxf.layouts.BaseLayout,
path: PathInfo,
layer: str = "0",
) -> None:
"""Add a single PathInfo as an LWPOLYLINE entity to the modelspace.
Closed paths get the LWPOLYLINE close flag set.
Islands are placed on a separate "ISLANDS" layer for downstream CAM tools.
"""
coords = path.simplified_coords
if len(coords) < 2:
return
target_layer = "ISLANDS" if path.is_island else layer
# LWPOLYLINE uses (x, y[, start_width, end_width, bulge]) tuples
points = [(x, y) for x, y in coords]
# Remove duplicate close point if the polyline close flag handles it
if path.is_closed and len(points) > 1 and points[0] == points[-1]:
points = points[:-1]
msp.add_lwpolyline(
points,
dxfattribs={"layer": target_layer},
close=path.is_closed,
)
def generate_dxf(result: PostProcessResult) -> bytes:
"""Generate an AC1015 (AutoCAD R2000) DXF file from post-processed path data.
Creates LWPOLYLINE entities from simplified coordinate data. Islands (holes)
are placed on an "ISLANDS" layer; outer contours on the default "0" layer.
Args:
result: PostProcessResult from the post-processing pipeline.
Returns:
DXF file content as bytes.
"""
doc = ezdxf.new(dxfversion="R2000") # AC1015
msp = doc.modelspace()
# Create ISLANDS layer for hole/island paths
doc.layers.add("ISLANDS", color=1) # color 1 = red in AutoCAD
for path in result.paths:
_add_path_to_msp(msp, path)
# Write to string buffer, then encode to bytes
stream = io.StringIO()
doc.write(stream)
return stream.getvalue().encode("utf-8")

View file

@ -0,0 +1,76 @@
"""JSON output generator — structured path data from PostProcessResult."""
from __future__ import annotations
import json
from pipeline.postprocess import PathInfo, PostProcessResult
def _path_to_dict(path: PathInfo) -> dict:
"""Convert a PathInfo to a JSON-serializable dict with path commands."""
commands = []
coords = path.simplified_coords
if not coords:
return {"commands": [], "properties": {}}
# Move to start
commands.append({"type": "M", "x": coords[0][0], "y": coords[0][1]})
# Line to each subsequent point
for x, y in coords[1:]:
commands.append({"type": "L", "x": x, "y": y})
# Close if applicable
if path.is_closed:
commands.append({"type": "Z"})
properties = {
"is_closed": path.is_closed,
"is_island": path.is_island,
"node_count": path.node_count,
"original_node_count": path.original_node_count,
"area": round(path.area, 4),
}
return {"commands": commands, "properties": properties}
def generate_json(result: PostProcessResult) -> str:
"""Generate a JSON string from post-processed path data.
Output format:
{
"paths": [
{
"commands": [{"type": "M", "x": 0, "y": 0}, {"type": "L", "x": 10, "y": 0}, ...],
"properties": {"is_closed": true, "is_island": false, ...}
},
...
],
"metadata": {
"path_count": 2,
"total_nodes": 10,
"total_original_nodes": 50,
"open_path_count": 0,
"island_count": 1
}
}
Args:
result: PostProcessResult from the post-processing pipeline.
Returns:
JSON string.
"""
output = {
"paths": [_path_to_dict(p) for p in result.paths],
"metadata": {
"path_count": len(result.paths),
"total_nodes": result.total_nodes,
"total_original_nodes": result.total_original_nodes,
"open_path_count": result.open_path_count,
"island_count": result.island_count,
},
}
return json.dumps(output, indent=2)

22
engine/output/svg.py Normal file
View file

@ -0,0 +1,22 @@
"""SVG output generator — clean SVG serialization from PostProcessResult."""
from __future__ import annotations
from pipeline.postprocess import PostProcessResult
def generate_svg(result: PostProcessResult) -> str:
"""Generate a clean SVG string from post-processed path data.
Re-serializes paths from the PostProcessResult into a minimal SVG document
with a single compound path using fill-rule="evenodd" for proper island rendering.
Args:
result: PostProcessResult from the post-processing pipeline.
Returns:
SVG string with simplified paths.
"""
# The postprocess pipeline already rebuilds SVG; return it directly
# if caller just wants the default output.
return result.svg

View file

@ -15,6 +15,7 @@ dependencies = [
"vtracer>=0.6", "vtracer>=0.6",
"python-multipart>=0.0.9", "python-multipart>=0.0.9",
"Pillow>=10.2", "Pillow>=10.2",
"ezdxf>=1.0",
] ]
[tool.setuptools.packages.find] [tool.setuptools.packages.find]

View file

@ -1,4 +1,4 @@
"""Integration tests for the /engine/trace endpoint.""" """Integration tests for /engine/trace and /engine/simplify endpoints."""
import json import json
@ -21,13 +21,49 @@ def _make_test_png(width: int = 100, height: int = 100) -> bytes:
return buf.tobytes() return buf.tobytes()
def _make_test_svg() -> str:
"""Create a simple SVG with a rectangular path for simplify tests."""
return (
'<svg xmlns="http://www.w3.org/2000/svg" width="100" height="100" viewBox="0 0 100 100">'
'<path d="M 10,10 L 90,10 L 90,90 L 10,90 Z" fill="black"/>'
"</svg>"
)
def _make_complex_svg() -> str:
"""Create an SVG with many intermediate points (suitable for RDP reduction)."""
# A path with extra collinear intermediate points that RDP can remove
points = " ".join(
f"L {x},{10}" for x in range(11, 91)
)
return (
'<svg xmlns="http://www.w3.org/2000/svg" width="100" height="100" viewBox="0 0 100 100">'
f'<path d="M 10,10 {points} L 90,90 L 10,90 Z" fill="black"/>'
"</svg>"
)
@pytest.fixture @pytest.fixture
def test_png() -> bytes: def test_png() -> bytes:
return _make_test_png() return _make_test_png()
class TestTraceEndpointPotrace: @pytest.fixture
"""Tests for /engine/trace with mode=potrace.""" def test_svg() -> bytes:
return _make_test_svg().encode("utf-8")
@pytest.fixture
def complex_svg() -> bytes:
return _make_complex_svg().encode("utf-8")
# -----------------------------------------------------------------------
# /engine/trace — SVG output (default)
# -----------------------------------------------------------------------
class TestTraceEndpointSVG:
"""Tests for /engine/trace with SVG output format."""
def test_basic_trace(self, test_png): def test_basic_trace(self, test_png):
resp = client.post( resp = client.post(
@ -49,12 +85,15 @@ class TestTraceEndpointPotrace:
) )
body = resp.json() body = resp.json()
meta = body["metadata"] meta = body["metadata"]
assert meta["format"] == "svg"
assert "path_count" in meta assert "path_count" in meta
assert "node_count_total" in meta assert "node_count_total" in meta
assert "open_paths" in meta assert "open_paths" in meta
assert "island_count" in meta
assert "warnings" in meta assert "warnings" in meta
assert "processing_ms" in meta assert "processing_ms" in meta
assert isinstance(meta["path_count"], int) assert isinstance(meta["path_count"], int)
assert isinstance(meta["node_count_total"], int)
assert isinstance(meta["processing_ms"], float) assert isinstance(meta["processing_ms"], float)
def test_svg_has_paths(self, test_png): def test_svg_has_paths(self, test_png):
@ -78,43 +117,138 @@ class TestTraceEndpointPotrace:
assert resp.status_code == 200 assert resp.status_code == 200
assert "<svg" in resp.json()["output"] assert "<svg" in resp.json()["output"]
def test_defaults_to_potrace_svg(self, test_png):
class TestTraceEndpointVtracer: """Mode defaults to potrace, format defaults to svg."""
"""Tests for /engine/trace with mode=vtracer."""
def test_basic_trace(self, test_png):
resp = client.post( resp = client.post(
"/engine/trace", "/engine/trace",
files={"file": ("test.png", test_png, "image/png")}, files={"file": ("test.png", test_png, "image/png")},
data={"mode": "vtracer"},
) )
assert resp.status_code == 200 assert resp.status_code == 200
body = resp.json() body = resp.json()
assert body["format"] == "svg" assert body["format"] == "svg"
assert "<svg" in body["output"].lower() or "svg" in body["output"] assert "<svg" in body["output"]
def test_metadata_present(self, test_png):
# -----------------------------------------------------------------------
# /engine/trace — JSON output
# -----------------------------------------------------------------------
class TestTraceEndpointJSON:
"""Tests for /engine/trace with JSON output format."""
def test_json_output(self, test_png):
resp = client.post( resp = client.post(
"/engine/trace", "/engine/trace",
files={"file": ("test.png", test_png, "image/png")}, files={"file": ("test.png", test_png, "image/png")},
data={"mode": "vtracer"}, data={"mode": "potrace", "output_format": "json"},
)
body = resp.json()
meta = body["metadata"]
assert isinstance(meta["path_count"], int)
assert isinstance(meta["processing_ms"], float)
def test_custom_params(self, test_png):
resp = client.post(
"/engine/trace",
files={"file": ("test.png", test_png, "image/png")},
data={
"mode": "vtracer",
"params": json.dumps({"filter_speckle": 10, "corner_threshold": 90}),
},
) )
assert resp.status_code == 200 assert resp.status_code == 200
body = resp.json()
assert body["format"] == "json"
assert "output" in body
assert "paths" in body["output"]
assert "metadata" in body["output"]
def test_json_metadata_fields(self, test_png):
resp = client.post(
"/engine/trace",
files={"file": ("test.png", test_png, "image/png")},
data={"mode": "potrace", "output_format": "json"},
)
body = resp.json()
# Envelope metadata
assert body["metadata"]["format"] == "json"
assert isinstance(body["metadata"]["node_count_total"], int)
# Inline JSON metadata
inner_meta = body["output"]["metadata"]
assert "path_count" in inner_meta
assert "total_nodes" in inner_meta
def test_json_path_structure(self, test_png):
resp = client.post(
"/engine/trace",
files={"file": ("test.png", test_png, "image/png")},
data={"mode": "potrace", "output_format": "json"},
)
body = resp.json()
paths = body["output"]["paths"]
assert len(paths) >= 1
p = paths[0]
assert "commands" in p
assert "properties" in p
assert p["commands"][0]["type"] == "M" # starts with MoveTo
def test_json_vtracer(self, test_png):
resp = client.post(
"/engine/trace",
files={"file": ("test.png", test_png, "image/png")},
data={"mode": "vtracer", "output_format": "json"},
)
assert resp.status_code == 200
assert resp.json()["format"] == "json"
# -----------------------------------------------------------------------
# /engine/trace — DXF output
# -----------------------------------------------------------------------
class TestTraceEndpointDXF:
"""Tests for /engine/trace with DXF output format."""
def test_dxf_output(self, test_png):
resp = client.post(
"/engine/trace",
files={"file": ("test.png", test_png, "image/png")},
data={"mode": "potrace", "output_format": "dxf"},
)
assert resp.status_code == 200
assert resp.headers["content-type"] == "application/dxf"
assert "Content-Disposition" in resp.headers
assert len(resp.content) > 0
def test_dxf_metadata_header(self, test_png):
resp = client.post(
"/engine/trace",
files={"file": ("test.png", test_png, "image/png")},
data={"mode": "potrace", "output_format": "dxf"},
)
meta_header = resp.headers.get("X-Kerf-Metadata")
assert meta_header is not None
meta = json.loads(meta_header)
assert meta["format"] == "dxf"
assert "path_count" in meta
assert "node_count_total" in meta
def test_dxf_is_valid_ac1015(self, test_png):
"""DXF output should be parseable by ezdxf as AC1015."""
import io
import ezdxf
resp = client.post(
"/engine/trace",
files={"file": ("test.png", test_png, "image/png")},
data={"mode": "potrace", "output_format": "dxf"},
)
stream = io.StringIO(resp.content.decode("utf-8"))
doc = ezdxf.read(stream)
assert doc.dxfversion == "AC1015"
msp = doc.modelspace()
entities = list(msp)
assert len(entities) >= 1
def test_dxf_vtracer(self, test_png):
resp = client.post(
"/engine/trace",
files={"file": ("test.png", test_png, "image/png")},
data={"mode": "vtracer", "output_format": "dxf"},
)
assert resp.status_code == 200
assert resp.headers["content-type"] == "application/dxf"
# -----------------------------------------------------------------------
# /engine/trace — Validation
# -----------------------------------------------------------------------
class TestTraceEndpointValidation: class TestTraceEndpointValidation:
"""Tests for input validation on /engine/trace.""" """Tests for input validation on /engine/trace."""
@ -159,15 +293,6 @@ class TestTraceEndpointValidation:
) )
assert resp.status_code == 422 assert resp.status_code == 422
def test_defaults_to_potrace(self, test_png):
"""Mode defaults to potrace when not specified."""
resp = client.post(
"/engine/trace",
files={"file": ("test.png", test_png, "image/png")},
)
assert resp.status_code == 200
assert "<svg" in resp.json()["output"]
def test_preset_ignored(self, test_png): def test_preset_ignored(self, test_png):
"""Preset param is accepted but ignored for now.""" """Preset param is accepted but ignored for now."""
resp = client.post( resp = client.post(
@ -176,3 +301,215 @@ class TestTraceEndpointValidation:
data={"mode": "potrace", "preset": "logo"}, data={"mode": "potrace", "preset": "logo"},
) )
assert resp.status_code == 200 assert resp.status_code == 200
# -----------------------------------------------------------------------
# /engine/trace — VTracer mode
# -----------------------------------------------------------------------
class TestTraceEndpointVtracer:
"""Tests for /engine/trace with mode=vtracer."""
def test_basic_trace(self, test_png):
resp = client.post(
"/engine/trace",
files={"file": ("test.png", test_png, "image/png")},
data={"mode": "vtracer"},
)
assert resp.status_code == 200
body = resp.json()
assert body["format"] == "svg"
def test_metadata_present(self, test_png):
resp = client.post(
"/engine/trace",
files={"file": ("test.png", test_png, "image/png")},
data={"mode": "vtracer"},
)
body = resp.json()
meta = body["metadata"]
assert isinstance(meta["path_count"], int)
assert isinstance(meta["processing_ms"], float)
def test_custom_params(self, test_png):
resp = client.post(
"/engine/trace",
files={"file": ("test.png", test_png, "image/png")},
data={
"mode": "vtracer",
"params": json.dumps({"filter_speckle": 10, "corner_threshold": 90}),
},
)
assert resp.status_code == 200
# -----------------------------------------------------------------------
# /engine/simplify — SVG output
# -----------------------------------------------------------------------
class TestSimplifyEndpointSVG:
"""Tests for /engine/simplify with SVG output format."""
def test_basic_simplify(self, test_svg):
resp = client.post(
"/engine/simplify",
files={"file": ("test.svg", test_svg, "image/svg+xml")},
data={"epsilon": "1.0"},
)
assert resp.status_code == 200
body = resp.json()
assert body["format"] == "svg"
assert "<svg" in body["output"]
def test_simplify_metadata(self, test_svg):
resp = client.post(
"/engine/simplify",
files={"file": ("test.svg", test_svg, "image/svg+xml")},
data={"epsilon": "1.0"},
)
body = resp.json()
meta = body["metadata"]
assert meta["format"] == "svg"
assert "path_count" in meta
assert "node_count_total" in meta
assert "open_paths" in meta
assert "island_count" in meta
assert "processing_ms" in meta
def test_simplify_reduces_nodes(self, complex_svg):
"""Higher epsilon should reduce node count on complex paths."""
resp_low = client.post(
"/engine/simplify",
files={"file": ("test.svg", complex_svg, "image/svg+xml")},
data={"epsilon": "0.01"},
)
resp_high = client.post(
"/engine/simplify",
files={"file": ("test.svg", complex_svg, "image/svg+xml")},
data={"epsilon": "10.0"},
)
low_nodes = resp_low.json()["metadata"]["node_count_total"]
high_nodes = resp_high.json()["metadata"]["node_count_total"]
assert high_nodes <= low_nodes
# -----------------------------------------------------------------------
# /engine/simplify — JSON output
# -----------------------------------------------------------------------
class TestSimplifyEndpointJSON:
"""Tests for /engine/simplify with JSON output format."""
def test_json_output(self, test_svg):
resp = client.post(
"/engine/simplify",
files={"file": ("test.svg", test_svg, "image/svg+xml")},
data={"epsilon": "1.0", "output_format": "json"},
)
assert resp.status_code == 200
body = resp.json()
assert body["format"] == "json"
assert "paths" in body["output"]
def test_json_path_commands(self, test_svg):
resp = client.post(
"/engine/simplify",
files={"file": ("test.svg", test_svg, "image/svg+xml")},
data={"epsilon": "1.0", "output_format": "json"},
)
body = resp.json()
paths = body["output"]["paths"]
assert len(paths) >= 1
assert paths[0]["commands"][0]["type"] == "M"
# -----------------------------------------------------------------------
# /engine/simplify — DXF output
# -----------------------------------------------------------------------
class TestSimplifyEndpointDXF:
"""Tests for /engine/simplify with DXF output format."""
def test_dxf_output(self, test_svg):
resp = client.post(
"/engine/simplify",
files={"file": ("test.svg", test_svg, "image/svg+xml")},
data={"epsilon": "1.0", "output_format": "dxf"},
)
assert resp.status_code == 200
assert resp.headers["content-type"] == "application/dxf"
assert len(resp.content) > 0
def test_dxf_metadata_header(self, test_svg):
resp = client.post(
"/engine/simplify",
files={"file": ("test.svg", test_svg, "image/svg+xml")},
data={"epsilon": "1.0", "output_format": "dxf"},
)
meta = json.loads(resp.headers["X-Kerf-Metadata"])
assert meta["format"] == "dxf"
assert isinstance(meta["path_count"], int)
def test_dxf_is_valid(self, test_svg):
"""DXF output from simplify should be parseable."""
import io
import ezdxf
resp = client.post(
"/engine/simplify",
files={"file": ("test.svg", test_svg, "image/svg+xml")},
data={"epsilon": "1.0", "output_format": "dxf"},
)
stream = io.StringIO(resp.content.decode("utf-8"))
doc = ezdxf.read(stream)
assert doc.dxfversion == "AC1015"
# -----------------------------------------------------------------------
# /engine/simplify — Validation
# -----------------------------------------------------------------------
class TestSimplifyEndpointValidation:
"""Tests for input validation on /engine/simplify."""
def test_empty_file(self):
resp = client.post(
"/engine/simplify",
files={"file": ("empty.svg", b"", "image/svg+xml")},
data={"epsilon": "1.0"},
)
assert resp.status_code == 422
def test_not_svg(self):
resp = client.post(
"/engine/simplify",
files={"file": ("test.txt", b"hello world", "text/plain")},
data={"epsilon": "1.0"},
)
assert resp.status_code == 422
def test_invalid_output_format(self, test_svg):
resp = client.post(
"/engine/simplify",
files={"file": ("test.svg", test_svg, "image/svg+xml")},
data={"epsilon": "1.0", "output_format": "pdf"},
)
assert resp.status_code == 422
def test_default_epsilon(self, test_svg):
"""Epsilon defaults to 1.0 when not specified."""
resp = client.post(
"/engine/simplify",
files={"file": ("test.svg", test_svg, "image/svg+xml")},
)
assert resp.status_code == 200
assert resp.json()["format"] == "svg"
def test_binary_file_rejected(self):
"""Binary (non-UTF-8) file should be rejected."""
resp = client.post(
"/engine/simplify",
files={"file": ("test.svg", b"\x80\x81\x82\xff", "image/svg+xml")},
data={"epsilon": "1.0"},
)
assert resp.status_code == 422

274
engine/tests/test_output.py Normal file
View file

@ -0,0 +1,274 @@
"""Tests for output format generators (SVG, DXF, JSON)."""
from __future__ import annotations
import json
import io
import ezdxf
import pytest
from pipeline.postprocess import PathInfo, PostProcessResult, postprocess_svg
from output.svg import generate_svg
from output.json_output import generate_json
from output.dxf import generate_dxf
def _read_dxf(dxf_bytes: bytes) -> ezdxf.document.Drawing:
"""Read DXF bytes back into an ezdxf document (needs StringIO, not BytesIO)."""
return ezdxf.read(io.StringIO(dxf_bytes.decode("utf-8")))
# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------
SIMPLE_SVG = (
'<svg xmlns="http://www.w3.org/2000/svg" width="100" height="100" viewBox="0 0 100 100">'
'<path d="M 10,10 L 90,10 L 90,90 L 10,90 Z" fill="black"/>'
"</svg>"
)
# SVG with an outer CCW rectangle and an inner CW rectangle (island/hole)
SVG_WITH_ISLAND = (
'<svg xmlns="http://www.w3.org/2000/svg" width="100" height="100" viewBox="0 0 100 100">'
'<path d="M 0,0 L 100,0 L 100,100 L 0,100 Z" fill="black"/>'
'<path d="M 30,30 L 30,70 L 70,70 L 70,30 Z" fill="black"/>'
"</svg>"
)
# SVG with an open path (no Z close)
SVG_OPEN_PATH = (
'<svg xmlns="http://www.w3.org/2000/svg" width="100" height="100" viewBox="0 0 100 100">'
'<path d="M 10,10 L 50,10 L 50,50" fill="none" stroke="black"/>'
"</svg>"
)
def _make_result(svg: str = SIMPLE_SVG, epsilon: float = 0.1) -> PostProcessResult:
"""Run postprocess pipeline and return result for testing output generators."""
return postprocess_svg(svg, epsilon=epsilon)
# ---------------------------------------------------------------------------
# SVG output tests
# ---------------------------------------------------------------------------
class TestSVGOutput:
def test_returns_string(self):
result = _make_result()
svg = generate_svg(result)
assert isinstance(svg, str)
def test_contains_svg_root(self):
result = _make_result()
svg = generate_svg(result)
assert "<svg" in svg
assert "</svg>" in svg
def test_contains_path_element(self):
result = _make_result()
svg = generate_svg(result)
assert "<path" in svg
def test_valid_xml(self):
import xml.etree.ElementTree as ET
result = _make_result()
svg = generate_svg(result)
root = ET.fromstring(svg)
assert root.tag == "{http://www.w3.org/2000/svg}svg"
# ---------------------------------------------------------------------------
# JSON output tests
# ---------------------------------------------------------------------------
class TestJSONOutput:
def test_returns_valid_json(self):
result = _make_result()
output = generate_json(result)
parsed = json.loads(output)
assert isinstance(parsed, dict)
def test_has_paths_and_metadata(self):
result = _make_result()
parsed = json.loads(generate_json(result))
assert "paths" in parsed
assert "metadata" in parsed
def test_path_has_commands_and_properties(self):
result = _make_result()
parsed = json.loads(generate_json(result))
assert len(parsed["paths"]) > 0
path = parsed["paths"][0]
assert "commands" in path
assert "properties" in path
def test_commands_have_correct_types(self):
result = _make_result()
parsed = json.loads(generate_json(result))
path = parsed["paths"][0]
types = [cmd["type"] for cmd in path["commands"]]
assert types[0] == "M" # starts with Move
assert "L" in types # has line commands
assert types[-1] == "Z" # closed path ends with Z
def test_properties_fields(self):
result = _make_result()
parsed = json.loads(generate_json(result))
props = parsed["paths"][0]["properties"]
assert "is_closed" in props
assert "is_island" in props
assert "node_count" in props
assert "original_node_count" in props
assert "area" in props
def test_metadata_fields(self):
result = _make_result()
parsed = json.loads(generate_json(result))
meta = parsed["metadata"]
assert "path_count" in meta
assert "total_nodes" in meta
assert "total_original_nodes" in meta
assert "open_path_count" in meta
assert "island_count" in meta
def test_metadata_path_count_matches(self):
result = _make_result()
parsed = json.loads(generate_json(result))
assert parsed["metadata"]["path_count"] == len(parsed["paths"])
def test_island_flagged_in_properties(self):
result = _make_result(SVG_WITH_ISLAND)
parsed = json.loads(generate_json(result))
island_flags = [p["properties"]["is_island"] for p in parsed["paths"]]
assert True in island_flags # at least one island detected
def test_open_path_not_closed(self):
result = _make_result(SVG_OPEN_PATH)
parsed = json.loads(generate_json(result))
# Open path should not end with Z
path = parsed["paths"][0]
types = [cmd["type"] for cmd in path["commands"]]
assert types[-1] != "Z"
# ---------------------------------------------------------------------------
# DXF output tests
# ---------------------------------------------------------------------------
class TestDXFOutput:
def test_returns_bytes(self):
result = _make_result()
dxf = generate_dxf(result)
assert isinstance(dxf, bytes)
def test_valid_dxf_structure(self):
result = _make_result()
dxf_bytes = generate_dxf(result)
# ezdxf can read it back
doc = _read_dxf(dxf_bytes)
assert doc.dxfversion >= "AC1015"
def test_contains_lwpolyline_entities(self):
result = _make_result()
dxf_bytes = generate_dxf(result)
doc = _read_dxf(dxf_bytes)
msp = doc.modelspace()
polylines = list(msp.query("LWPOLYLINE"))
assert len(polylines) > 0
def test_polyline_has_correct_point_count(self):
result = _make_result()
dxf_bytes = generate_dxf(result)
doc = _read_dxf(dxf_bytes)
msp = doc.modelspace()
polylines = list(msp.query("LWPOLYLINE"))
# Simple square: 4 unique points (close flag handles the fifth)
total_points = sum(len(list(pl.get_points())) for pl in polylines)
assert total_points >= 4
def test_closed_path_produces_closed_polyline(self):
result = _make_result()
dxf_bytes = generate_dxf(result)
doc = _read_dxf(dxf_bytes)
msp = doc.modelspace()
polylines = list(msp.query("LWPOLYLINE"))
# At least one closed polyline
assert any(pl.is_closed for pl in polylines)
def test_island_on_islands_layer(self):
result = _make_result(SVG_WITH_ISLAND)
dxf_bytes = generate_dxf(result)
doc = _read_dxf(dxf_bytes)
msp = doc.modelspace()
polylines = list(msp.query("LWPOLYLINE"))
layers = [pl.dxf.layer for pl in polylines]
assert "ISLANDS" in layers
def test_outer_contour_on_default_layer(self):
result = _make_result(SVG_WITH_ISLAND)
dxf_bytes = generate_dxf(result)
doc = _read_dxf(dxf_bytes)
msp = doc.modelspace()
polylines = list(msp.query("LWPOLYLINE"))
layers = [pl.dxf.layer for pl in polylines]
assert "0" in layers
def test_ac1015_version(self):
result = _make_result()
dxf_bytes = generate_dxf(result)
doc = _read_dxf(dxf_bytes)
assert doc.dxfversion == "AC1015"
def test_dxf_header_present(self):
result = _make_result()
dxf_bytes = generate_dxf(result)
# Raw bytes should contain DXF section markers
text = dxf_bytes.decode("ascii", errors="replace")
assert "HEADER" in text
assert "ENTITIES" in text
def test_open_path_produces_open_polyline(self):
result = _make_result(SVG_OPEN_PATH)
dxf_bytes = generate_dxf(result)
doc = _read_dxf(dxf_bytes)
msp = doc.modelspace()
polylines = list(msp.query("LWPOLYLINE"))
# Open path should produce non-closed polyline
assert any(not pl.is_closed for pl in polylines)
# ---------------------------------------------------------------------------
# Integration: round-trip through all formats
# ---------------------------------------------------------------------------
class TestRoundTrip:
def test_all_formats_from_same_input(self):
"""All three generators produce valid output from the same PostProcessResult."""
result = _make_result()
svg = generate_svg(result)
json_out = generate_json(result)
dxf = generate_dxf(result)
assert isinstance(svg, str) and "<svg" in svg
assert isinstance(json_out, str) and json.loads(json_out)
assert isinstance(dxf, bytes) and len(dxf) > 0
def test_path_count_consistent_across_formats(self):
"""JSON and DXF should have the same number of paths as the result."""
result = _make_result(SVG_WITH_ISLAND)
parsed_json = json.loads(generate_json(result))
doc = _read_dxf(generate_dxf(result))
msp = doc.modelspace()
dxf_polylines = list(msp.query("LWPOLYLINE"))
assert parsed_json["metadata"]["path_count"] == len(result.paths)
assert len(dxf_polylines) == len(result.paths)