tonigi's picture
Add complete Gradio interface
3c2cd23 verified
Raw History Blame Contribute Delete
11 kB
from __future__ import annotations
from urllib.parse import quote
from xml.etree import ElementTree as ET
from .fonts import font_data_uri, get_face
from .models import (
BitmapRegion,
DocumentSpec,
EllipseGeometry,
LinearGradientPaint,
PathGeometry,
PolygonGeometry,
RectGeometry,
ShapeRegion,
SolidPaint,
TextRegion,
)
SVG_NS = "http://www.w3.org/2000/svg"
XLINK_NS = "http://www.w3.org/1999/xlink"
ET.register_namespace("", SVG_NS)
ET.register_namespace("xlink", XLINK_NS)
def build_svg(
document: DocumentSpec,
background_href: str | None = None,
background_fallback_href: str | None = None,
embed_fonts: bool = False,
bitmap_hrefs: dict[str, tuple[str, str]] | None = None,
) -> str:
root = ET.Element(
f"{{{SVG_NS}}}svg",
{
"width": str(document.canvas.width),
"height": str(document.canvas.height),
"viewBox": f"0 0 {document.canvas.width} {document.canvas.height}",
"version": "1.1",
},
)
bitmap_hrefs = bitmap_hrefs or {}
needs_markers = any(
isinstance(shape.geometry, PathGeometry)
and (shape.geometry.arrow_start or shape.geometry.arrow_end)
for shape in document.shapes
)
if embed_fonts or document.shapes or needs_markers:
definitions = ET.SubElement(root, f"{{{SVG_NS}}}defs")
else:
definitions = None
if embed_fonts:
used = {
(region.style.font_family, region.style.font_weight, region.style.italic)
for region in document.regions
if region.include_overlay and region.text
}
assert definitions is not None
style = ET.SubElement(definitions, f"{{{SVG_NS}}}style", {"type": "text/css"})
style.text = "\n" + "\n".join(font_rule(*item) for item in sorted(used)) + "\n"
if definitions is not None:
for shape in document.shapes:
if isinstance(shape.style.fill, LinearGradientPaint):
definitions.append(gradient_element(shape))
if needs_markers:
add_arrow_marker(definitions)
if background_href:
relative_href = encoded_image_href(background_href)
fallback_href = encoded_image_href(background_fallback_href or background_href)
ET.SubElement(
root,
f"{{{SVG_NS}}}image",
{
"id": "cleared-background",
"href": relative_href,
f"{{{XLINK_NS}}}href": fallback_href,
"x": "0",
"y": "0",
"width": str(document.canvas.width),
"height": str(document.canvas.height),
"preserveAspectRatio": "none",
},
)
layer = ET.SubElement(root, f"{{{SVG_NS}}}g", {"id": "editable-content"})
content: list[tuple[int, str, ET.Element]] = []
for shape in document.shapes:
if shape.include_overlay:
content.append((shape.z_index, shape.id, shape_element(shape)))
for bitmap in document.bitmap_regions:
hrefs = bitmap_hrefs.get(bitmap.id)
if hrefs:
content.append((bitmap.z_index, bitmap.id, bitmap_element(bitmap, *hrefs)))
for _, _, element in sorted(content, key=lambda item: (item[0], item[1])):
layer.append(element)
text_layer = ET.SubElement(root, f"{{{SVG_NS}}}g", {"id": "editable-text"})
for region in sorted(document.regions, key=lambda item: (item.z_index, item.id)):
if region.include_overlay and region.text:
text_layer.append(text_element(region))
return ET.tostring(root, encoding="unicode", xml_declaration=True)
def gradient_element(shape: ShapeRegion) -> ET.Element:
paint = shape.style.fill
assert isinstance(paint, LinearGradientPaint)
import math
radians = math.radians(paint.angle)
dx, dy = math.cos(radians) * 50, math.sin(radians) * 50
gradient = ET.Element(
f"{{{SVG_NS}}}linearGradient",
{
"id": f"gradient-{shape.id}",
"x1": f"{compact(50 - dx)}%",
"y1": f"{compact(50 - dy)}%",
"x2": f"{compact(50 + dx)}%",
"y2": f"{compact(50 + dy)}%",
},
)
for stop in paint.stops:
ET.SubElement(
gradient,
f"{{{SVG_NS}}}stop",
{
"offset": f"{compact(stop.offset * 100)}%",
"stop-color": stop.color,
"stop-opacity": compact(stop.opacity),
},
)
return gradient
def add_arrow_marker(definitions: ET.Element) -> None:
marker = ET.SubElement(
definitions,
f"{{{SVG_NS}}}marker",
{
"id": "editable-arrow",
"viewBox": "0 0 10 10",
"refX": "9",
"refY": "5",
"markerWidth": "6",
"markerHeight": "6",
"orient": "auto-start-reverse",
},
)
ET.SubElement(marker, f"{{{SVG_NS}}}path", {"d": "M 0 0 L 10 5 L 0 10 z", "fill": "context-stroke"})
def shape_element(shape: ShapeRegion) -> ET.Element:
geometry = shape.geometry
style = shape.style
fill = (
style.fill.color
if isinstance(style.fill, SolidPaint)
else f"url(#gradient-{shape.id})"
)
attributes = {
"id": shape.id,
"data-role": shape.semantic_role,
"fill": fill,
"fill-opacity": compact(style.fill_opacity),
"stroke": style.stroke,
"stroke-width": compact(style.stroke_width),
"stroke-opacity": compact(style.stroke_opacity),
"stroke-linecap": style.linecap,
"stroke-linejoin": style.linejoin,
"data-confidence": compact(shape.confidence),
"data-source-contours": " | ".join(
" ".join(f"{compact(point.x)},{compact(point.y)}" for point in contour)
for contour in shape.source_contours
),
}
if isinstance(geometry, RectGeometry):
box = geometry.box
attributes.update(
{
"x": compact(box.x),
"y": compact(box.y),
"width": compact(box.width),
"height": compact(box.height),
"rx": compact(geometry.rx),
"ry": compact(geometry.ry),
}
)
if abs(geometry.rotation) > 0.01:
cx, cy = box.x + box.width / 2, box.y + box.height / 2
attributes["transform"] = f"rotate({compact(geometry.rotation)} {compact(cx)} {compact(cy)})"
return ET.Element(f"{{{SVG_NS}}}rect", attributes)
if isinstance(geometry, EllipseGeometry):
attributes.update(
{
"cx": compact(geometry.cx),
"cy": compact(geometry.cy),
"rx": compact(geometry.rx),
"ry": compact(geometry.ry),
}
)
if abs(geometry.rotation) > 0.01:
attributes["transform"] = (
f"rotate({compact(geometry.rotation)} {compact(geometry.cx)} {compact(geometry.cy)})"
)
return ET.Element(f"{{{SVG_NS}}}ellipse", attributes)
if isinstance(geometry, PolygonGeometry):
attributes["points"] = " ".join(
f"{compact(point.x)},{compact(point.y)}" for point in geometry.points
)
return ET.Element(f"{{{SVG_NS}}}polygon", attributes)
assert isinstance(geometry, PathGeometry)
attributes["d"] = path_data(geometry)
if geometry.arrow_start:
attributes["marker-start"] = "url(#editable-arrow)"
if geometry.arrow_end:
attributes["marker-end"] = "url(#editable-arrow)"
if not geometry.closed and fill != "none":
attributes["fill"] = "none"
return ET.Element(f"{{{SVG_NS}}}path", attributes)
def path_data(geometry: PathGeometry) -> str:
points = geometry.points
if not geometry.smooth or len(points) < 3:
output = f"M {compact(points[0].x)} {compact(points[0].y)}"
output += "".join(f" L {compact(point.x)} {compact(point.y)}" for point in points[1:])
else:
output = f"M {compact(points[0].x)} {compact(points[0].y)}"
for current, following in zip(points[1:-1], points[2:]):
midpoint_x = (current.x + following.x) / 2
midpoint_y = (current.y + following.y) / 2
output += (
f" Q {compact(current.x)} {compact(current.y)}"
f" {compact(midpoint_x)} {compact(midpoint_y)}"
)
output += f" T {compact(points[-1].x)} {compact(points[-1].y)}"
if geometry.closed:
output += " Z"
return output
def bitmap_element(bitmap: BitmapRegion, href: str, fallback_href: str) -> ET.Element:
box = bitmap.box
return ET.Element(
f"{{{SVG_NS}}}image",
{
"id": bitmap.id,
"href": encoded_image_href(href),
"x": compact(box.x),
"y": compact(box.y),
"width": compact(box.width),
"height": compact(box.height),
"opacity": compact(bitmap.opacity),
"preserveAspectRatio": "none",
},
)
def font_rule(family: str, weight: int, italic: bool) -> str:
face = get_face(family, weight, italic)
style = "italic" if italic else "normal"
return (
f"@font-face {{font-family:'{face.family}';font-style:{style};"
f"font-weight:{face.weight};src:url('{font_data_uri(face.filename)}') format('woff2');}}"
)
def text_element(region: TextRegion) -> ET.Element:
box = region.overlay_box
style = region.style
anchor_x = {
"start": box.x,
"middle": box.x + box.width / 2,
"end": box.x + box.width,
}[style.anchor]
baseline = box.y + box.height * 0.82
attributes = {
"id": region.id,
"x": compact(anchor_x),
"y": compact(baseline),
"font-family": style.font_family,
"font-size": compact(style.font_size),
"font-weight": str(style.font_weight),
"font-style": "italic" if style.italic else "normal",
"fill": style.fill,
"fill-opacity": compact(style.opacity),
"stroke": style.stroke,
"stroke-width": compact(style.stroke_width),
"letter-spacing": compact(style.letter_spacing),
"text-anchor": style.anchor,
"data-confidence": compact(region.confidence),
"data-source-quad": " ".join(f"{compact(p.x)},{compact(p.y)}" for p in region.source_quad),
}
if abs(region.rotation) > 0.01:
attributes["transform"] = f"rotate({compact(region.rotation)} {compact(anchor_x)} {compact(baseline)})"
element = ET.Element(f"{{{SVG_NS}}}text", attributes)
element.text = region.text
return element
def encoded_image_href(href: str) -> str:
if href.startswith("data:image/png;base64,"):
return href
return quote(href, safe="/:%-._~")
def compact(value: float) -> str:
return f"{value:.3f}".rstrip("0").rstrip(".")