Last active
December 9, 2025 12:01
-
-
Save LucasRoesler/230dbc2b2d676616cdec39794cb4b2cc to your computer and use it in GitHub Desktop.
a tool for converting json canvas files to svg
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/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) |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/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("<", "<").replace(">", ">") | |
| 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