Skip to content

Instantly share code, notes, and snippets.

@LucasRoesler
Last active December 9, 2025 12:01
Show Gist options
  • Select an option

  • Save LucasRoesler/230dbc2b2d676616cdec39794cb4b2cc to your computer and use it in GitHub Desktop.

Select an option

Save LucasRoesler/230dbc2b2d676616cdec39794cb4b2cc to your computer and use it in GitHub Desktop.
a tool for converting json canvas files to svg
#!/usr/bin/env -S uv run --script
# /// script
# requires-python = ">=3.11"
# dependencies = [
# "pyjsoncanvas>=1.0.2",
# ]
# ///
"""
Validate JSON Canvas files against the spec.
Usage:
canvas-validate input.canvas
canvas-validate *.canvas
Exit codes:
0 - All files valid
1 - Validation errors found
"""
import argparse
import json
import logging
import re
import sys
from pathlib import Path
from pyjsoncanvas import Canvas
from pyjsoncanvas.exceptions import CanvasValidationError, InvalidJsonError
log = logging.getLogger(__name__)
def find_node_location(content: str, node_id: str, key: str) -> tuple[int, int] | None:
"""Find line and column for a node's key in the JSON content."""
# Find the node block by its id
node_pattern = rf'"id"\s*:\s*"{re.escape(node_id)}"'
node_match = re.search(node_pattern, content)
if not node_match:
return None
# Search for the key after the node id (within reasonable range)
search_start = node_match.start()
# Look within next 500 chars for the key
search_region = content[search_start : search_start + 500]
key_pattern = rf'"{key}"\s*:\s*([0-9.]+)'
key_match = re.search(key_pattern, search_region)
if not key_match:
return None
# Calculate absolute position of the value
value_pos = search_start + key_match.start(1)
# Convert position to line and column
line = content[:value_pos].count("\n") + 1
last_newline = content.rfind("\n", 0, value_pos)
col = value_pos - last_newline if last_newline >= 0 else value_pos + 1
return line, col
def validate_canvas(path: Path) -> list[str]:
"""Validate a canvas file and return list of errors (empty if valid)."""
errors: list[str] = []
try:
rel_path = path.relative_to(Path.cwd())
except ValueError:
rel_path = path
try:
content = path.read_text()
except OSError as e:
return [f"{rel_path} Error: Cannot read file: {e}"]
# Check JSON syntax
try:
data = json.loads(content)
except json.JSONDecodeError as e:
return [f"{rel_path}:{e.lineno}:{e.colno} Error: Invalid JSON: {e.msg}"]
# Check for float coordinates (spec requires integers)
for i, node in enumerate(data.get("nodes", [])):
node_id = node.get("id", f"index {i}")
for key in ("x", "y", "width", "height"):
value = node.get(key)
if isinstance(value, float) and not value.is_integer():
loc = find_node_location(content, node_id, key)
if loc:
errors.append(
f"{rel_path}:{loc[0]}:{loc[1]} Error: Node '{node_id}': {key} must be integer, got {value}"
)
else:
errors.append(
f"{rel_path} Error: Node '{node_id}': {key} must be integer, got {value}"
)
# Validate with pyjsoncanvas (skip if we already found float errors)
if not errors:
try:
canvas = Canvas.from_json(content)
canvas.validate()
except (InvalidJsonError, CanvasValidationError) as e:
errors.append(f"{rel_path} Error: {e}")
except Exception as e:
errors.append(f"{rel_path} Error: Validation error: {e}")
return errors
def cli() -> argparse.Namespace:
parser = argparse.ArgumentParser(
description="Validate JSON Canvas files against the spec."
)
parser.add_argument(
"files",
type=Path,
nargs="+",
help="Canvas file(s) to validate",
)
parser.add_argument(
"-v",
"--verbose",
action="count",
default=0,
help="Increase verbosity (-v for INFO, -vv for DEBUG)",
)
parser.add_argument(
"-q",
"--quiet",
action="store_true",
help="Only output errors, no success messages",
)
return parser.parse_args()
if __name__ == "__main__":
args = cli()
log_level = logging.WARNING
if args.verbose == 1:
log_level = logging.INFO
elif args.verbose >= 2:
log_level = logging.DEBUG
logging.basicConfig(level=log_level, format="%(levelname)s: %(message)s")
has_errors = False
for path in args.files:
if not path.exists():
try:
rel_path = path.relative_to(Path.cwd())
except ValueError:
rel_path = path
print(f"{rel_path} Error: File not found", file=sys.stderr)
has_errors = True
continue
errors = validate_canvas(path)
if errors:
has_errors = True
for error in errors:
print(error, file=sys.stderr)
elif not args.quiet:
try:
rel_path = path.relative_to(Path.cwd())
except ValueError:
rel_path = path
print(f"{rel_path}: OK")
sys.exit(1 if has_errors else 0)
#!/usr/bin/env -S uv run --script
# /// script
# requires-python = ">=3.11"
# dependencies = [
# "pyjsoncanvas>=1.0.2",
# ]
# ///
"""
Convert Obsidian .canvas files to SVG format.
Forked from: https://github.com/amaargiru/canvas2svg
Usage:
uv run canvas2svg.py input.canvas
Output: Creates input.svg in the same directory.
"""
import argparse
import logging
import sys
import textwrap
import xml.etree.ElementTree as XmlTree
from pathlib import Path
from xml.dom import minidom
from pyjsoncanvas import Canvas, GroupNode, TextNode
from pyjsoncanvas.models import Color, GenericNode
log = logging.getLogger(__name__)
def wrap_text_to_fit_rect(text: str, rect_width: float, font_size: int = 18) -> list[str]:
"""Split text into lines to fit within a rectangle of given width."""
if not text:
return [""]
# Approximate character count per line
# Assuming average character width is 0.6 of font size
chars_per_line = max(1, int(rect_width / (font_size * 0.6)))
# Check if text needs to be split
if len(text) <= chars_per_line or "a href" in text:
return [text]
lines = textwrap.wrap(
text, width=chars_per_line, break_long_words=True, break_on_hyphens=True
)
if not lines:
return [""]
return lines
def get_side_coordinates(
node: GenericNode, side: str | None, dx: float, dy: float
) -> tuple[float, float]:
"""Get coordinates for a specific side of a node."""
x = node.x + dx
y = node.y + dy
w, h = node.width, node.height
if side == "top":
return x + w / 2, y
elif side == "bottom":
return x + w / 2, y + h
elif side == "left":
return x, y + h / 2
elif side == "right":
return x + w, y + h / 2
else:
return x + w / 2, y + h / 2
def color_to_str(color: Color | None) -> str:
"""Convert pyjsoncanvas Color to string for color map lookup."""
if color is None:
return "default"
# Color can be a preset number ("1"-"6") or a hex color
return str(color)
def fix_html_links_in_svg(svg_content: str) -> str:
"""Replace escaped HTML tags in SVG links with actual tags."""
lines = svg_content.splitlines(keepends=True)
processed_lines = []
for line in lines:
if "a href" in line:
line = line.replace("&lt;", "<").replace("&gt;", ">")
processed_lines.append(line)
return "".join(processed_lines)
def preprocess_canvas_json(json_text: str) -> str:
"""Preprocess canvas JSON to fix float coordinates (pyjsoncanvas expects int)."""
import json
data = json.loads(json_text)
for node in data.get("nodes", []):
for key in ("x", "y", "width", "height"):
if key in node and isinstance(node[key], float):
node[key] = round(node[key])
return json.dumps(data)
def canvas_to_svg(input_path: Path) -> str:
"""Convert a canvas file to SVG and return the SVG content."""
canvas = Canvas.from_json(preprocess_canvas_json(input_path.read_text()))
nodes = canvas.nodes
edges = canvas.edges
min_x = min(node.x for node in nodes)
min_y = min(node.y for node in nodes)
max_x = max(node.x + node.width for node in nodes)
max_y = max(node.y + node.height for node in nodes)
padding = 20
dx = -min_x + padding
dy = -min_y + padding
svg_width = max_x - min_x + 2 * padding
svg_height = max_y - min_y + 2 * padding
svg = XmlTree.Element(
"svg",
xmlns="http://www.w3.org/2000/svg",
width=str(svg_width),
height=str(svg_height),
)
# Arrow marker definition
defs = XmlTree.SubElement(svg, "defs")
marker = XmlTree.SubElement(
defs,
"marker",
id="arrow",
viewBox="0 0 12 12",
refX="10",
refY="6",
markerWidth="8",
markerHeight="8",
orient="auto",
)
XmlTree.SubElement(marker, "path", d="M0,0 L12,6 L0,12 Z", fill="context-stroke")
color_map = {
"0": "#7e7e7e",
"1": "#aa363d",
"2": "#a56c3a",
"3": "#aba960",
"4": "#199e5c",
"5": "#249391",
"6": "#795fac",
"default": "#444444",
}
def get_color(color_code: str) -> str:
return color_map.get(color_code, color_map["default"])
# Draw groups
for node in nodes:
if not isinstance(node, GroupNode):
continue
x = node.x + dx
y = node.y + dy
width = node.width
height = node.height
XmlTree.SubElement(
svg,
"rect",
attrib={
"x": str(x),
"y": str(y),
"width": str(width),
"height": str(height),
"rx": "8",
"ry": "8",
"fill": "#EEEEEE",
"stroke": "#888888",
"fill-opacity": "0.25",
"stroke-width": "1",
},
)
if node.label:
text_elem = XmlTree.SubElement(
svg,
"text",
attrib={
"x": str(x),
"y": str(y - 20),
"text-anchor": "left",
"dominant-baseline": "hanging",
"font-family": "Arial",
"font-size": "18",
},
)
text_elem.text = node.label
# Draw text cards
for node in nodes:
if not isinstance(node, TextNode):
continue
x = node.x + dx
y = node.y + dy
width = node.width
height = node.height
color = get_color(color_to_str(node.color))
XmlTree.SubElement(
svg,
"rect",
attrib={
"x": str(x),
"y": str(y),
"width": str(width),
"height": str(height),
"rx": "8",
"ry": "8",
"fill": color,
"stroke": color,
"fill-opacity": "0.25",
"stroke-width": "2",
},
)
# Split text into lines to fit the rectangle
text_content = node.text or ""
font_size = 18
lines = wrap_text_to_fit_rect(text_content, width, font_size)
line_height = font_size * 1.2 # Line height ~1.2x font size
# Calculate total text height
total_text_height = len(lines) * line_height
# Calculate starting y position for vertical centering
start_y = y + (height - total_text_height) / 2 + line_height / 2
# Create text element for each line
for i, line in enumerate(lines):
line_y = start_y + i * line_height
text_elem = XmlTree.SubElement(
svg,
"text",
attrib={
"x": str(x + width / 2),
"y": str(line_y),
"text-anchor": "middle",
"dominant-baseline": "middle",
"font-family": "Arial",
"font-size": str(font_size),
},
)
text_elem.text = line
# Build node lookup by id
nodes_by_id: dict[str, GenericNode] = {node.id: node for node in nodes}
# Draw edges
for edge in edges:
from_node = nodes_by_id[edge.fromNode]
to_node = nodes_by_id[edge.toNode]
from_side = edge.fromSide.value if edge.fromSide else None
to_side = edge.toSide.value if edge.toSide else None
start_x, start_y = get_side_coordinates(from_node, from_side, dx, dy)
end_x, end_y = get_side_coordinates(to_node, to_side, dx, dy)
color = get_color(color_to_str(edge.color))
# Check for strict horizontal/vertical alignment
if start_x == end_x or start_y == end_y:
path_data = f"M {start_x} {start_y} L {end_x} {end_y}"
else:
dx_line = end_x - start_x
dy_line = end_y - start_y
length = (dx_line**2 + dy_line**2) ** 0.5
# Adaptive bend value
bend = max(10, min(155, length * 0.3))
# Determine dominant direction
if abs(dx_line) > abs(dy_line):
# Horizontally oriented line: bend vertically
cp1x = round(start_x + dx_line / 3)
cp1y = round(start_y + dy_line / 3 + bend)
cp2x = round(end_x - dx_line / 3)
cp2y = round(end_y - dy_line / 3 - bend)
else:
# Vertically oriented line: bend horizontally
cp1x = round(start_x + dx_line / 3 + bend)
cp1y = round(start_y + dy_line / 3)
cp2x = round(end_x - dx_line / 3 - bend)
cp2y = round(end_y - dy_line / 3)
path_data = (
f"M {start_x} {start_y} C {cp1x} {cp1y} {cp2x} {cp2y} {end_x} {end_y}"
)
XmlTree.SubElement(
svg,
"path",
attrib={
"d": path_data,
"stroke": color,
"fill": "none",
"marker-end": "url(#arrow)",
"stroke-width": "2",
},
)
# Format SVG output
rough_xml = XmlTree.tostring(svg, "utf-8")
parsed = minidom.parseString(rough_xml)
pretty_xml = parsed.toprettyxml(indent=" ")
return fix_html_links_in_svg(pretty_xml)
def cli() -> argparse.Namespace:
parser = argparse.ArgumentParser(
description="Convert Obsidian .canvas files to SVG format."
)
parser.add_argument("input", type=Path, help="Input .canvas file")
parser.add_argument(
"-o",
"--output",
default=None,
help="Output SVG file (default: input stem + .svg, use '-' for stdout)",
)
parser.add_argument(
"-v",
"--verbose",
action="count",
default=0,
help="Increase verbosity (-v for INFO, -vv for DEBUG)",
)
return parser.parse_args()
if __name__ == "__main__":
args = cli()
log_level = logging.WARNING
if args.verbose == 1:
log_level = logging.INFO
elif args.verbose >= 2:
log_level = logging.DEBUG
logging.basicConfig(level=log_level, format="%(levelname)s: %(message)s")
input_path: Path = args.input
if not input_path.exists():
raise SystemExit(f"File {input_path} doesn't exist")
svg_content = canvas_to_svg(input_path)
if args.output == "-":
sys.stdout.write(svg_content)
else:
output_path = Path(args.output) if args.output else input_path.with_suffix(".svg")
output_path.write_text(svg_content)
log.info("Created: %s", output_path)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment