import streamlit as st import math import json import os import io from PIL import Image, ImageDraw, ImageFont # Set page config st.set_page_config(page_title="B&W Flowchart Sketcher", layout="wide") # ========================================== # Geometry & Math Helpers # ========================================== def intersect_segments(p1, p2, q1, q2): x1, y1 = p1 x2, y2 = p2 x3, y3 = q1 x4, y4 = q2 denom = (y4 - y3) * (x2 - x1) - (x4 - x3) * (y2 - y1) if denom == 0: return None # Parallel ua = ((x4 - x3) * (y1 - y3) - (y4 - y3) * (x1 - x3)) / denom ub = ((x2 - x1) * (y1 - y3) - (y2 - y1) * (x1 - x3)) / denom if 0 <= ua <= 1 and 0 <= ub <= 1: return (x1 + ua * (x2 - x1), y1 + ua * (y2 - y1)) return None def get_boundary_intersection(node, target): cx, cy = node.x, node.y tx, ty = target dx = tx - cx dy = ty - cy dist = math.hypot(dx, dy) if dist == 0: return (cx, cy) if node.shape == "circle": r = min(node.w, node.h) / 2 return (cx + r * dx / dist, cy + r * dy / dist) elif node.shape == "oval": a = node.w / 2 b = node.h / 2 t = 1.0 / math.sqrt((dx/a)**2 + (dy/b)**2 + 1e-9) return (cx + t * dx, cy + t * dy) else: if node.shape == "square": vertices = [ (cx - node.w/2, cy - node.h/2), (cx + node.w/2, cy - node.h/2), (cx + node.w/2, cy + node.h/2), (cx - node.w/2, cy + node.h/2) ] elif node.shape == "diamond": vertices = [ (cx, cy - node.h/2), (cx + node.w/2, cy), (cx, cy + node.h/2), (cx - node.w/2, cy) ] elif node.shape == "inverted triangle": vertices = [ (cx - node.w/2, cy - node.h/2), (cx + node.w/2, cy - node.h/2), (cx, cy + node.h/2) ] else: return (cx, cy) ray_end = (cx + 10000 * dx / dist, cy + 10000 * dy / dist) for i in range(len(vertices)): v1 = vertices[i] v2 = vertices[(i + 1) % len(vertices)] pt = intersect_segments((cx, cy), ray_end, v1, v2) if pt: return pt return (cx, cy) def get_quadratic_bezier_points(p0, p1, p2, num_steps=20): points = [] for i in range(num_steps + 1): t = i / num_steps x = (1-t)**2 * p0[0] + 2*(1-t)*t * p1[0] + t**2 * p2[0] y = (1-t)**2 * p0[1] + 2*(1-t)*t * p1[1] + t**2 * p2[1] points.append((x, y)) return points # ========================================== # Word Wrapping Helper # ========================================== def wrap_text_by_width(text, max_width, measure_fn): if not text: return "" words = text.split() lines = [] current_line = [] for word in words: test_line = " ".join(current_line + [word]) if measure_fn(test_line) <= max_width: current_line.append(word) else: if current_line: lines.append(" ".join(current_line)) current_line = [word] else: lines.append(word) current_line = [] if current_line: lines.append(" ".join(current_line)) return "\n".join(lines) # ========================================== # PIL Font Loader # ========================================== def get_pil_font(font_name, size, bold=False): win_font_dir = "C:\\Windows\\Fonts" paths = [] if bold: paths.append(os.path.join(win_font_dir, f"{font_name}bd.ttf")) paths.append(os.path.join(win_font_dir, f"{font_name}b.ttf")) paths.append(os.path.join(win_font_dir, "arialbd.ttf")) else: paths.append(os.path.join(win_font_dir, f"{font_name}.ttf")) paths.append(os.path.join(win_font_dir, "arial.ttf")) for p in paths: if os.path.exists(p): try: return ImageFont.truetype(p, size) except Exception: pass return ImageFont.load_default() # ========================================== # Model Classes # ========================================== class Node: def __init__(self, designation, shape, description="", x=100.0, y=100.0): self.id = designation.strip() self.shape = shape.lower() self.description = description.strip() self.x = float(x) self.y = float(y) self.w, self.h = self.default_sizes() def default_sizes(self): if self.shape == "circle": return 80.0, 80.0 elif self.shape == "oval": return 120.0, 60.0 elif self.shape == "square": return 110.0, 70.0 elif self.shape == "diamond": return 120.0, 90.0 elif self.shape == "inverted triangle": return 120.0, 90.0 return 100.0, 60.0 def get_vertices(self): cx, cy, w, h = self.x, self.y, self.w, self.h if self.shape == "square": return [ (cx - w/2, cy - h/2), (cx + w/2, cy - h/2), (cx + w/2, cy + h/2), (cx - w/2, cy + h/2) ] elif self.shape == "diamond": return [ (cx, cy - h/2), (cx + w/2, cy), (cx, cy + h/2), (cx - w/2, cy) ] elif self.shape == "inverted triangle": return [ (cx - w/2, cy - h/2), (cx + w/2, cy - h/2), (cx, cy + h/2) ] return [] def get_max_text_width(self): if self.shape == "circle": return self.w * 0.70 elif self.shape == "oval": return self.w * 0.75 elif self.shape == "square": return self.w * 0.82 elif self.shape == "diamond": return self.w * 0.58 elif self.shape == "inverted triangle": return self.w * 0.62 return self.w * 0.8 def to_dict(self): return { "id": self.id, "shape": self.shape, "description": self.description, "x": self.x, "y": self.y } @classmethod def from_dict(cls, d): return cls(d["id"], d["shape"], d.get("description", ""), d["x"], d["y"]) class Edge: def __init__(self, u, v, style="straight"): self.u = u.strip() self.v = v.strip() self.style = style.lower() def to_dict(self): return { "u": self.u, "v": self.v, "style": self.style } @classmethod def from_dict(cls, d): return cls(d["u"], d["v"], d["style"]) class FlowchartModel: def __init__(self): self.nodes = {} self.edges = [] def add_node(self, designation, shape, description="", x=100.0, y=100.0): designation = designation.strip() if not designation: return False, "Node designation cannot be empty." node = Node(designation, shape, description, x, y) self.nodes[designation] = node return True, node def delete_node(self, designation): designation = designation.strip() if designation in self.nodes: del self.nodes[designation] self.edges = [e for e in self.edges if e.u != designation and e.v != designation] return True return False def add_edge_path(self, path_str, style="straight"): if "->" in path_str or "-->" in path_str: s = path_str.replace("-->", "->") parts = [p.strip() for p in s.split("->") if p.strip()] elif "," in path_str: parts = [p.strip() for p in path_str.split(",") if p.strip()] else: parts = [p.strip() for p in path_str.split() if p.strip()] if len(parts) < 2: return False, "Invalid path. Enter at least two nodes (e.g. A -> B)." missing = [p for p in parts if p not in self.nodes] if missing: return False, f"Missing nodes: {', '.join(missing)}. Create them first." added_count = 0 for i in range(len(parts) - 1): u, v = parts[i], parts[i+1] exists = any(e.u == u and e.v == v for e in self.edges) if not exists: self.edges.append(Edge(u, v, style)) added_count += 1 return True, f"Added {added_count} edge(s)." def remove_edge(self, index): if 0 <= index < len(self.edges): self.edges.pop(index) return True return False def clear(self): self.nodes.clear() self.edges.clear() def auto_layout(self): if not self.nodes: return adj = {name: [] for name in self.nodes} in_degree = {name: 0 for name in self.nodes} for e in self.edges: if e.u in adj and e.v in adj: adj[e.u].append(e.v) in_degree[e.v] += 1 levels = {} queue = [] for name in self.nodes: if in_degree[name] == 0: levels[name] = 0 queue.append(name) if not queue: first_node = list(self.nodes.keys())[0] levels[first_node] = 0 queue.append(first_node) visited = set() while queue: curr = queue.pop(0) visited.add(curr) curr_level = levels.get(curr, 0) for neighbor in adj[curr]: old_lvl = levels.get(neighbor, -1) if curr_level + 1 > old_lvl: levels[neighbor] = curr_level + 1 if neighbor not in visited and neighbor not in queue: queue.append(neighbor) for name in self.nodes: if name not in levels: levels[name] = 0 nodes_by_level = {} for name, lvl in levels.items(): nodes_by_level.setdefault(lvl, []).append(name) canvas_width = 800 level_height = 140 horizontal_spacing = 150 for lvl, lvl_nodes in sorted(nodes_by_level.items()): lvl_nodes.sort() num_nodes = len(lvl_nodes) y = 100 + lvl * level_height for i, name in enumerate(lvl_nodes): x = (canvas_width / 2) + (i - (num_nodes - 1) / 2) * horizontal_spacing node = self.nodes[name] node.x = x node.y = y def save_to_json(self): data = { "nodes": [n.to_dict() for n in self.nodes.values()], "edges": [e.to_dict() for e in self.edges] } return json.dumps(data, indent=4) def load_from_json(self, json_str): data = json.loads(json_str) self.clear() for n_dict in data.get("nodes", []): node = Node.from_dict(n_dict) self.nodes[node.id] = node for e_dict in data.get("edges", []): self.edges.append(Edge.from_dict(e_dict)) # ========================================== # PIL Dashed Line Helper # ========================================== def draw_dashed_line_pil(draw, points, fill="black", width=2, dash_len=6, gap_len=6): if len(points) < 2: return current_dash_left = dash_len current_gap_left = 0 drawing = True for i in range(len(points) - 1): p1 = points[i] p2 = points[i+1] seg_dx = p2[0] - p1[0] seg_dy = p2[1] - p1[1] seg_len = math.hypot(seg_dx, seg_dy) if seg_len == 0: continue vx = seg_dx / seg_len vy = seg_dy / seg_len dist_moved = 0 curr_pt = p1 while dist_moved < seg_len: if drawing: step = min(seg_len - dist_moved, current_dash_left) next_pt = (curr_pt[0] + vx * step, curr_pt[1] + vy * step) draw.line([curr_pt, next_pt], fill=fill, width=width) dist_moved += step current_dash_left -= step curr_pt = next_pt if current_dash_left <= 0: drawing = False current_gap_left = gap_len else: step = min(seg_len - dist_moved, current_gap_left) next_pt = (curr_pt[0] + vx * step, curr_pt[1] + vy * step) dist_moved += step current_gap_left -= step curr_pt = next_pt if current_gap_left <= 0: drawing = True current_dash_left = dash_len # ========================================== # Flowchart Image Generator # ========================================== def render_flowchart_image(model): if not model.nodes: img = Image.new("RGB", (400, 300), "white") return img # Determine bounds min_x = min(n.x - n.w/2 for n in model.nodes.values()) max_x = max(n.x + n.w/2 for n in model.nodes.values()) min_y = min(n.y - n.h/2 for n in model.nodes.values()) max_y = max(n.y + n.h/2 for n in model.nodes.values()) margin = 50.0 crop_x1 = min_x - margin crop_y1 = min_y - margin crop_x2 = max_x + margin crop_y2 = max_y + margin img_w = max(100, int(crop_x2 - crop_x1)) img_h = max(100, int(crop_y2 - crop_y1)) img = Image.new("RGB", (img_w, img_h), "white") draw = ImageDraw.Draw(img) def get_pt(cx, cy): return int(cx - crop_x1), int(cy - crop_y1) font_bold = get_pil_font("segoeui", 10, bold=True) font_reg = get_pil_font("segoeui", 9, bold=False) if hasattr(draw, 'textlength'): measure_fn = lambda txt: draw.textlength(txt, font=font_reg) else: measure_fn = lambda txt: font_reg.getsize(txt)[0] def draw_pil_arrowhead(p_end, p_from): xe, ye = get_pt(p_end[0], p_end[1]) xf, yf = get_pt(p_from[0], p_from[1]) dx = xe - xf dy = ye - yf dist = math.hypot(dx, dy) if dist == 0: return ux = dx / dist uy = dy / dist arrow_l = 12 arrow_w = 8 bx = xe - ux * arrow_l by = ye - uy * arrow_l px = -uy * (arrow_w / 2) py = ux * (arrow_w / 2) pts = [(xe, ye), (bx + px, by + py), (bx - px, by - py)] draw.polygon(pts, fill="black", outline="black") def draw_centered_text_pil(xy, text, font, fill="black"): lines = text.split("\n") try: bbox = font.getbbox("Abgqp") line_h = bbox[3] - bbox[1] except: line_h = font.getsize("Abgqp")[1] line_widths = [] for line in lines: if hasattr(draw, 'textlength'): w = draw.textlength(line, font=font) else: w = font.getsize(line)[0] line_widths.append(w) total_height = line_h * len(lines) + 4 * (len(lines) - 1) curr_y = xy[1] - total_height / 2 for i, line in enumerate(lines): w = line_widths[i] draw.text((xy[0] - w/2, curr_y), line, font=font, fill=fill) curr_y += line_h + 4 # 1. Draw Edges for edge in model.edges: u_node = model.nodes.get(edge.u) v_node = model.nodes.get(edge.v) if not u_node or not v_node: continue if edge.style == "curved": dx = v_node.x - u_node.x dy = v_node.y - u_node.y dist = math.hypot(dx, dy) mx = (u_node.x + v_node.x) / 2 my = (u_node.y + v_node.y) / 2 if dist > 0: nx = -dy / dist ny = dx / dist p_ctrl = (mx + nx * 45, my + ny * 45) else: p_ctrl = (mx, my + 45) p_start = get_boundary_intersection(u_node, p_ctrl) p_end = get_boundary_intersection(v_node, p_ctrl) bezier_pts = get_quadratic_bezier_points(p_start, p_ctrl, p_end, 30) mapped_pts = [get_pt(px, py) for px, py in bezier_pts] if edge.style == "dotted": draw_dashed_line_pil(draw, mapped_pts, fill="black", width=2, dash_len=4, gap_len=4) else: draw.line(mapped_pts, fill="black", width=2) draw_pil_arrowhead(p_end, p_ctrl) else: p_start = get_boundary_intersection(u_node, (v_node.x, v_node.y)) p_end = get_boundary_intersection(v_node, (u_node.x, u_node.y)) p_start_m = get_pt(p_start[0], p_start[1]) p_end_m = get_pt(p_end[0], p_end[1]) if edge.style == "dotted": steps = int(math.hypot(p_end_m[0] - p_start_m[0], p_end_m[1] - p_start_m[1]) / 6) if steps < 2: steps = 2 pts = [] for s in range(steps + 1): t = s / steps pts.append((p_start_m[0] + t * (p_end_m[0] - p_start_m[0]), p_start_m[1] + t * (p_end_m[1] - p_start_m[1]))) draw_dashed_line_pil(draw, pts, fill="black", width=2, dash_len=4, gap_len=4) else: draw.line([p_start_m, p_end_m], fill="black", width=2) draw_pil_arrowhead(p_end, p_start) # 2. Draw Nodes for node in model.nodes.values(): cx, cy, w, h = node.x, node.y, node.w, node.h px, py = get_pt(cx, cy) # Shadow sh = 4 shadow_color = (233, 236, 239) if node.shape in ("circle", "oval"): draw.ellipse([px - w/2 + sh, py - h/2 + sh, px + w/2 + sh, py + h/2 + sh], fill=shadow_color, outline=None) elif node.shape == "square": draw.rectangle([px - w/2 + sh, py - h/2 + sh, px + w/2 + sh, py + h/2 + sh], fill=shadow_color, outline=None) else: sh_vertices = [(vx - crop_x1 + sh, vy - crop_y1 + sh) for vx, vy in node.get_vertices()] draw.polygon(sh_vertices, fill=shadow_color, outline=None) # Shape if node.shape in ("circle", "oval"): draw.ellipse([px - w/2, py - h/2, px + w/2, py + h/2], fill="white", outline="black", width=2) elif node.shape == "square": draw.rectangle([px - w/2, py - h/2, px + w/2, py + h/2], fill="white", outline="black", width=2) else: sh_vertices = [(vx - crop_x1, vy - crop_y1) for vx, vy in node.get_vertices()] draw.polygon(sh_vertices, fill="white") draw.line(sh_vertices + [sh_vertices[0]], fill="black", width=2, joint="curve") # Text max_w = node.get_max_text_width() if node.description: desc_wrapped = wrap_text_by_width(node.description, max_w, measure_fn) draw_centered_text_pil((px, py - 12), node.id, font_bold, fill="black") draw_centered_text_pil((px, py + 10), desc_wrapped, font_reg, fill=(73, 80, 87)) else: draw_centered_text_pil((px, py), node.id, font_bold, fill="black") return img # ========================================== # Streamlit App Logic # ========================================== st.title("📊 B&W Flowchart Sketcher") # Initialize Model in Session State if "model" not in st.session_state: model = FlowchartModel() # Load defaults model.add_node("Start", "Circle", "Start", 400.0, 80.0) model.add_node("Step 1", "Square", "Process Inputs", 400.0, 200.0) model.add_node("Check", "Diamond", "Valid?", 400.0, 330.0) model.add_node("Error", "Inverted Triangle", "Log Error", 220.0, 330.0) model.add_node("End", "Oval", "Success Finish", 400.0, 460.0) model.add_edge_path("Start -> Step 1", "straight") model.add_edge_path("Step 1 -> Check", "straight") model.add_edge_path("Check -> Error", "straight") model.add_edge_path("Check -> End", "straight") model.add_edge_path("Error -> Start", "curved") st.session_state.model = model else: model = st.session_state.model # Split Layout col_ctrl, col_canvas = st.columns([1, 2]) with col_ctrl: st.header("Controls") # --- Node Management --- with st.expander("Node Manager", expanded=True): node_id = st.text_input("Designation / Label (Short ID)", key="node_id") shape = st.selectbox("Icon Shape", ["Square", "Circle", "Oval", "Diamond", "Inverted Triangle"], key="shape") desc = st.text_input("Description (Inside Shape)", key="desc") col_node_btns = st.columns(2) with col_node_btns[0]: if st.button("Add/Save Node"): if node_id: is_update = node_id in model.nodes if is_update: n = model.nodes[node_id] n.shape = shape.lower() n.description = desc n.w, n.h = n.default_sizes() else: model.add_node(node_id, shape, desc, x=300.0 + 50.0 * len(model.nodes), y=200.0) st.rerun() else: st.error("Short ID designation required.") with col_node_btns[1]: if st.button("Delete Node"): if node_id: if model.delete_node(node_id): st.rerun() else: st.error(f"Node '{node_id}' not found.") else: st.error("Enter a Short ID designation to delete.") # --- Edge Management --- with st.expander("Edge Manager", expanded=True): edge_path = st.text_input("Path (e.g., A -> B -> C)", key="edge_path") edge_style = st.selectbox("Connector Line Style", ["Straight", "Curved", "Dotted"], key="edge_style") if st.button("Add Edges"): if edge_path: success, msg = model.add_edge_path(edge_path, edge_style) if success: st.rerun() else: st.error(msg) else: st.error("Enter an edge path.") # --- Manual Node Positioning --- with st.expander("Manual Node Positioning", expanded=False): if model.nodes: selected_pos_node = st.selectbox("Select Node to Position", list(model.nodes.keys())) node_to_move = model.nodes[selected_pos_node] new_x = st.slider("X Coordinate", 0.0, 1000.0, float(node_to_move.x), step=10.0) new_y = st.slider("Y Coordinate", 0.0, 1000.0, float(node_to_move.y), step=10.0) if new_x != node_to_move.x or new_y != node_to_move.y: node_to_move.x = new_x node_to_move.y = new_y st.rerun() else: st.info("Create nodes to enable manual positioning.") # --- Lists and Deletions --- with st.expander("Active Lists"): st.subheader("Nodes") if model.nodes: for k, v in model.nodes.items(): st.text(f"• {k} ({v.shape.title()}) - {v.description}") else: st.text("No nodes.") st.subheader("Edges") if model.edges: edge_labels = [f"{e.u} -> {e.v} ({e.style.title()})" for e in model.edges] del_edge_idx = st.selectbox("Select Edge to Delete", range(len(model.edges)), format_func=lambda i: edge_labels[i]) if st.button("Delete Selected Edge"): model.remove_edge(del_edge_idx) st.rerun() else: st.text("No edges.") # --- Actions --- with st.expander("Global Actions", expanded=True): col_actions = st.columns(2) with col_actions[0]: if st.button("Auto-Layout"): model.auto_layout() st.rerun() with col_actions[1]: if st.button("Clear All"): model.clear() st.rerun() # Save and Load st.subheader("Save / Load Project") json_data = model.save_to_json() st.download_button( label="Download JSON Project File", data=json_data, file_name="flowchart_project.json", mime="application/json" ) uploaded_file = st.file_uploader("Upload JSON Project", type="json") if uploaded_file is not None: try: json_str = uploaded_file.read().decode("utf-8") model.load_from_json(json_str) st.success("Project loaded successfully!") st.rerun() except Exception as e: st.error(f"Error loading project: {e}") with col_canvas: st.header("Sketch Canvas") # Render flowchart B&W image flowchart_img = render_flowchart_image(model) # Display in browser buffer = io.BytesIO() flowchart_img.save(buffer, format="PNG") img_bytes = buffer.getvalue() st.image(img_bytes, caption="Clean B&W Flowchart (Auto-cropped)", use_column_width=False) # Download Button st.download_button( label="📥 Download Flowchart Image (PNG)", data=img_bytes, file_name="flowchart_export.png", mime="image/png" )