from __future__ import annotations import copy import json import math import re from typing import Any _PATH_SEGMENT_RE = re.compile(r"^(?Pcomponents|elements|children)\[(?P\d+)\]$") CONTENT_EDITABLE_ELEMENT_TYPES = {"text", "text-list", "table", "image", "chart"} VISIBLE_ELEMENT_TYPES = CONTENT_EDITABLE_ELEMENT_TYPES | { "container", "rectangle", "ellipse", "line", "infographic", "flex", "grid", "grid-view", "group", } SUPPORTED_CHART_TYPES = { "area", "bar", "bubble", "donut", "horizontal_bar", "horizontal_stacked_bar", "line", "pie", "polar_area", "radar", "scatter", "stacked_bar", } DATA_LABEL_POSITIONS = {"base", "mid", "top", "outside"} DEFAULT_CHART_COLORS = [ "#7F22FE", "#155DFC", "#F59E0B", "#12B76A", "#EF4444", "#06B6D4", "#8B5CF6", "#64748B", ] THEME_GRAPH_COLOR_KEYS = tuple(f"graph_{index}" for index in range(10)) CHART_UPDATE_KEYS = { "axis_color", "categories", "chart_type", "colors", "data_labels", "grid_color", "legend", "series", "title", "title_color", "x_axis", "x_axis_grid", "x_axis_title", "y_axis", "y_axis_grid", "y_axis_title", } def _normalize_chart_tree(node: Any, theme: dict[str, Any] | None = None) -> None: if isinstance(node, dict): if node.get("type") == "chart": _normalize_chart_element(node, theme) for value in node.values(): _normalize_chart_tree(value, theme) elif isinstance(node, list): for value in node: _normalize_chart_tree(value, theme) def _normalize_chart_element( element: dict[str, Any], theme: dict[str, Any] | None = None, ) -> None: element.pop("data_labels_color", None) element.pop("dataLabelsColor", None) element.pop("grid", None) if "title_color" not in element and "titleColor" in element: element["title_color"] = element.get("titleColor") chart_type = _normalize_chart_type( element.get("chart_type"), fallback=str(element.get("chart_type") or "bar"), ) element["chart_type"] = chart_type legacy_data = _normalize_legacy_chart_data(element.get("data")) series = _normalize_chart_series( element.get("series"), fallback_name=str(element.get("title") or "Series 1"), ) if not series and legacy_data: series = [ { "name": str(element.get("title") or "Series 1"), "values": [item["value"] for item in legacy_data], } ] if not series: series = [{"name": "Series 1", "values": [0]}] legacy_categories = [item["label"] for item in legacy_data] categories = _normalize_chart_categories( element.get("categories") or legacy_categories, _max_chart_value_length(series), ) _validate_chart_shape(chart_type, categories, series) category_count = max(1, len(categories), _max_chart_value_length(series)) categories = _normalize_chart_categories(categories, category_count) for item in series: item["values"] = _pad_chart_values( item.get("values"), category_count, ) if not _read_chart_colors(element.get("colors")): legacy_colors = [ color for item in legacy_data if (color := _normalize_chart_color(item.get("color"))) ] if legacy_colors: element["colors"] = legacy_colors color_count = _chart_color_target_count(chart_type, categories, series) colors = _resolve_chart_colors( element, theme=theme, count=color_count, ) element["colors"] = colors element["color"] = colors[0] element["categories"] = categories element["series"] = series element["data"] = _chart_data_from_series( categories=categories, series=series, colors=colors, chart_type=chart_type, ) theme_colors = _theme_colors(theme) if not _normalize_chart_color(element.get("axis_color")): element["axis_color"] = ( _normalize_chart_color(theme_colors.get("background_text")) or _normalize_chart_color(theme_colors.get("primary")) or "#475467" ) else: element["axis_color"] = _normalize_chart_color(element.get("axis_color")) if not _normalize_chart_color(element.get("grid_color")): element["grid_color"] = ( _normalize_chart_color(theme_colors.get("stroke")) or _normalize_chart_color(theme_colors.get("card")) or "#D0D5DD" ) else: element["grid_color"] = _normalize_chart_color(element.get("grid_color")) if _normalize_chart_color(element.get("title_color")): element["title_color"] = _normalize_chart_color(element.get("title_color")) if ( "x_axis" not in element and chart_type not in {"pie", "donut", "polar_area", "radar"} ): element["x_axis"] = True if ( "y_axis" not in element and chart_type not in {"pie", "donut", "polar_area", "radar"} ): element["y_axis"] = True if "x_axis_grid" not in element and chart_type not in {"pie", "donut"}: element["x_axis_grid"] = True if "y_axis_grid" not in element and chart_type not in {"pie", "donut"}: element["y_axis_grid"] = True if "legend" not in element: element["legend"] = chart_type in {"pie", "donut"} or len(series) > 1 element["data_labels"] = _normalize_chart_data_labels(element.get("data_labels")) def _apply_chart_content_update( element: dict[str, Any], chart: dict[str, Any], theme: dict[str, Any] | None = None, ) -> None: for source_key, target_key in ( ("chart_type", "chart_type"), ("title", "title"), ("title_color", "title_color"), ("categories", "categories"), ("series", "series"), ("colors", "colors"), ("axis_color", "axis_color"), ("grid_color", "grid_color"), ("x_axis_title", "x_axis_title"), ("y_axis_title", "y_axis_title"), ): if source_key in chart: element[target_key] = copy.deepcopy(chart[source_key]) for source_key, target_key in ( ("x_axis", "x_axis"), ("y_axis", "y_axis"), ("x_axis_grid", "x_axis_grid"), ("y_axis_grid", "y_axis_grid"), ("data_labels", "data_labels"), ("legend", "legend"), ): if source_key in chart: element[target_key] = chart[source_key] _normalize_chart_element(element, theme) def _normalize_chart_data_labels(value: Any) -> str | None: if value is True: return "top" if value is False or value is None: return None if isinstance(value, str): normalized = value.strip().lower() if normalized in DATA_LABEL_POSITIONS: return normalized return None def _validate_visual_insert_tree(node: Any) -> None: if isinstance(node, dict): if node.get("type") == "chart" and not _chart_element_has_explicit_data(node): raise ValueError( "Chart elements must include numeric data via series.values or " "data before they can be added." ) if node.get("type") == "table" and not _table_element_has_explicit_data(node): raise ValueError( "Table elements must include headers/columns and rows before " "they can be added." ) if node.get("type") == "image" and not _image_element_has_explicit_data(node): raise ValueError( "Image elements must include an image/icon URL in data before " "they can be added." ) for value in node.values(): _validate_visual_insert_tree(value) elif isinstance(node, list): for value in node: _validate_visual_insert_tree(value) def _validate_chart_insert_tree(node: Any) -> None: _validate_visual_insert_tree(node) def _chart_element_has_explicit_data(element: dict[str, Any]) -> bool: return bool( _normalize_chart_series( element.get("series"), fallback_name=str(element.get("title") or "Series 1"), ) or _normalize_legacy_chart_data(element.get("data")) ) def _table_element_has_explicit_data(element: dict[str, Any]) -> bool: columns = element.get("columns") headers = element.get("headers") rows = element.get("rows") has_columns = isinstance(columns, list) and len(columns) > 0 has_headers = isinstance(headers, list) and len(headers) > 0 has_rows = isinstance(rows, list) and len(rows) > 0 return (has_columns or has_headers) and has_rows def _image_element_has_explicit_data(element: dict[str, Any]) -> bool: asset_url = _template_v2_asset_url(element) return bool(asset_url and _looks_like_asset_reference(asset_url)) def _normalize_image_tree(node: Any) -> None: if isinstance(node, dict): if node.get("type") == "image": _normalize_image_element(node) for value in node.values(): _normalize_image_tree(value) elif isinstance(node, list): for value in node: _normalize_image_tree(value) def _normalize_image_element(element: dict[str, Any]) -> None: asset_url = _template_v2_asset_url(element) if asset_url and _looks_like_asset_reference(asset_url): element["data"] = asset_url element.setdefault("is_icon", False) prompt = _template_v2_asset_prompt( element, is_icon=element.get("is_icon") is True, ) if prompt: element.setdefault("prompt", prompt) def _normalize_chart_type(value: Any, *, fallback: str = "bar") -> str: raw = value if isinstance(value, str) else fallback normalized = raw.strip().lower() return normalized if normalized in SUPPORTED_CHART_TYPES else "bar" def _normalize_chart_series( value: Any, *, fallback_name: str, ) -> list[dict[str, Any]]: if not isinstance(value, list): return [] series: list[dict[str, Any]] = [] for index, item in enumerate(value[:20]): if isinstance(item, dict): raw_values = item.get("values") if not isinstance(raw_values, list): raw_values = item.get("data") if not isinstance(raw_values, list): continue name = item.get("name") series_name = ( str(name).strip() if isinstance(name, str) and name.strip() else fallback_name if index == 0 and fallback_name else f"Series {index + 1}" ) elif isinstance(item, list): raw_values = item series_name = ( fallback_name if index == 0 and fallback_name else f"Series {index + 1}" ) else: continue values = [ number for raw_value in raw_values[:100] if (number := _chart_number(raw_value)) is not None ] if values: series.append({"name": series_name, "values": values}) return series def _chart_number(value: Any) -> float | int | None: if isinstance(value, bool): return None if isinstance(value, (int, float)) and math.isfinite(float(value)): number = float(value) return int(number) if number.is_integer() else number if isinstance(value, str): match = re.search( r"[-+]?(?:\d[\d,]*(?:\.\d+)?|\.\d+)(?:[eE][-+]?\d+)?", value.strip(), ) if not match: return None try: number = float(match.group(0).replace(",", "")) except ValueError: return None if math.isfinite(number): return int(number) if number.is_integer() else number return None def _normalize_legacy_chart_data(value: Any) -> list[dict[str, Any]]: if not isinstance(value, list): return [] rows: list[dict[str, Any]] = [] for item in value[:100]: direct_number = _chart_number(item) if direct_number is not None: rows.append({"label": "", "value": direct_number}) continue if not isinstance(item, dict): continue number = _first_chart_number(item.get("value"), item.get("data"), item.get("y")) if number is None: continue row: dict[str, Any] = { "label": _first_chart_label( item.get("label"), item.get("name"), item.get("category"), item.get("x"), ), "value": number, } color = _normalize_chart_color(item.get("color")) if color: row["color"] = color rows.append(row) return rows def _first_chart_number(*values: Any) -> float | int | None: for value in values: number = _chart_number(value) if number is not None: return number return None def _first_chart_label(*values: Any) -> str: for value in values: if value is None: continue label = str(value).strip() if label: return label return "" def _normalize_chart_categories( value: Any, length: int, ) -> list[str]: raw_values = value if isinstance(value, list) and value else [] target_length = max(1, length, len(raw_values)) return [ str(raw_values[index]).strip() if index < len(raw_values) and str(raw_values[index]).strip() else f"Item {index + 1}" for index in range(target_length) ] def _max_chart_value_length(series: list[dict[str, Any]]) -> int: return max( 0, *[ len(item.get("values")) for item in series if isinstance(item.get("values"), list) ], ) def _validate_chart_shape( chart_type: str, categories: list[str], series: list[dict[str, Any]], ) -> None: if chart_type in {"pie", "donut"} and len(series) > 1: raise ValueError("Pie and donut charts support exactly one series.") if not categories: return category_count = len(categories) for item in series: values = item.get("values") if isinstance(values, list) and len(values) != category_count: raise ValueError("Each chart series must match the category count.") def _pad_chart_values(value: Any, length: int) -> list[Any]: values = value if isinstance(value, list) else [] padded = values[:length] while len(padded) < length: padded.append(0) return padded def _resolve_chart_colors( element: dict[str, Any], *, theme: dict[str, Any] | None, count: int, ) -> list[str]: source_colors = _read_chart_colors(element.get("colors")) if not source_colors: source_colors = _theme_chart_palette(theme) if not source_colors: source_colors = [ _normalize_chart_color(element.get("color")) or DEFAULT_CHART_COLORS[0] ] target_count = min(12, max(1, count, len(source_colors))) return [ source_colors[index % len(source_colors)] for index in range(target_count) ] def _read_chart_colors(value: Any) -> list[str]: if not isinstance(value, list): return [] colors = [_normalize_chart_color(item) for item in value] return [color for color in colors if color] def _chart_color_target_count( chart_type: str, categories: list[str], series: list[dict[str, Any]], ) -> int: if chart_type not in {"pie", "donut"} and len(series) > 1: return len(series) return len(categories) def _chart_data_from_series( *, categories: list[str], series: list[dict[str, Any]], colors: list[str], chart_type: str, ) -> list[dict[str, Any]]: first = series[0] if series else {"values": [0]} values = first.get("values") if isinstance(first.get("values"), list) else [0] category_colors = chart_type in {"pie", "donut"} or len(series) == 1 return [ { "label": ( categories[index] if index < len(categories) else f"Item {index + 1}" ), "value": _chart_number(values[index] if index < len(values) else 0) or 0, "color": colors[index % len(colors)] if category_colors else colors[0], } for index in range(min(8, max(len(categories), len(values), 1))) ] def _theme_chart_palette(theme: dict[str, Any] | None) -> list[str]: colors = _theme_colors(theme) palette = [ color for color in ( _normalize_chart_color(colors.get(key)) for key in THEME_GRAPH_COLOR_KEYS ) if color ] if palette: return palette fallback_keys = ("primary", "card", "stroke", "background_text", "primary_text") return [ color for color in (_normalize_chart_color(colors.get(key)) for key in fallback_keys) if color ] def _theme_colors(theme: dict[str, Any] | None) -> dict[str, Any]: if not isinstance(theme, dict): return {} data = theme.get("data") if isinstance(data, dict): colors = data.get("colors") if isinstance(colors, dict): return colors colors = theme.get("colors") return colors if isinstance(colors, dict) else {} def _normalize_chart_color(value: Any) -> str | None: if not isinstance(value, str): return None color = value.strip() if not color: return None hex_match = re.match(r"^#?([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$", color) if hex_match: raw = hex_match.group(1) if len(raw) == 3: raw = "".join(char + char for char in raw) return f"#{raw.upper()}" if re.match(r"^rgba?\([^)]+\)$", color, re.IGNORECASE): return color return color def _model_dict(value: Any) -> dict[str, Any]: if hasattr(value, "model_dump"): return value.model_dump(mode="json", exclude_none=True) return json.loads(json.dumps(value, ensure_ascii=False)) def _ungrouped_components_from_component( component: dict[str, Any], component_index: int, *, used_ids: set[str], ) -> list[dict[str, Any]]: component_box = _required_box(component, label="component") id_base = _normalize_component_id( str( component.get("id") or component.get("name") or component.get("description") or f"component_{component_index + 1}" ) ) entries: list[dict[str, Any]] = [] elements = component.get("elements") if not isinstance(elements, list): return [] valid_elements = [element for element in elements if isinstance(element, dict)] if len(valid_elements) == 1: element = valid_elements[0] box = _box_or_default( element, { "x": 0.0, "y": 0.0, "width": component_box["width"], "height": component_box["height"], }, ) entries.extend( _ungroup_element_one_level( element, { "x": component_box["x"] + box["x"], "y": component_box["y"] + box["y"], "width": box["width"], "height": box["height"], }, ) ) else: for element in valid_elements: box = _box_or_default( element, { "x": 0.0, "y": 0.0, "width": component_box["width"], "height": component_box["height"], }, ) entries.append( { "element": element, "box": { "x": component_box["x"] + box["x"], "y": component_box["y"] + box["y"], "width": box["width"], "height": box["height"], }, } ) parts: list[dict[str, Any]] = [] for index, entry in enumerate(entries): box = entry["box"] element = copy.deepcopy(entry["element"]) element["position"] = {"x": 0, "y": 0} element["size"] = {"width": box["width"], "height": box["height"]} component_id = _unique_component_id(f"{id_base}_part_{index + 1}", used_ids) parts.append( { "id": component_id, "description": "Ungrouped component element", "position": {"x": box["x"], "y": box["y"]}, "size": {"width": box["width"], "height": box["height"]}, "elements": [element], } ) return parts def _ungroup_element_one_level( element: dict[str, Any], box: dict[str, float], ) -> list[dict[str, Any]]: children = _child_items(element) if not children: return [] entries = [] for child, child_box in _layout_child_boxes(element, children, box): entries.append( { "element": child, "box": { "x": box["x"] + child_box["x"], "y": box["y"] + child_box["y"], "width": child_box["width"], "height": child_box["height"], }, } ) return entries def _child_items(element: dict[str, Any]) -> list[dict[str, Any]]: for key in ("children", "elements"): value = element.get(key) if isinstance(value, list): return [item for item in value if isinstance(item, dict)] child = element.get("child") return [child] if isinstance(child, dict) else [] def _layout_child_boxes( parent: dict[str, Any], children: list[dict[str, Any]], parent_box: dict[str, float], ) -> list[tuple[dict[str, Any], dict[str, float]]]: parent_type = str(parent.get("type") or "") if parent_type in {"flex", "list-view"}: return _layout_flex_child_boxes(parent, children, parent_box) if parent_type in {"grid", "grid-view"}: return _layout_grid_child_boxes(parent, children, parent_box) padding = _padding(parent.get("padding")) if parent_type == "container" else {} content = { "x": float(padding.get("left", 0.0)), "y": float(padding.get("top", 0.0)), "width": max( 1.0, parent_box["width"] - float(padding.get("left", 0.0)) - float(padding.get("right", 0.0)), ), "height": max( 1.0, parent_box["height"] - float(padding.get("top", 0.0)) - float(padding.get("bottom", 0.0)), ), } return [ ( child, _box_or_default( child, { "x": content["x"], "y": content["y"], "width": content["width"], "height": content["height"], }, ), ) for child in children ] def _layout_flex_child_boxes( parent: dict[str, Any], children: list[dict[str, Any]], parent_box: dict[str, float], ) -> list[tuple[dict[str, Any], dict[str, float]]]: padding = _padding(parent.get("padding")) direction = "row" if parent.get("direction") == "row" else "column" gap = _number(parent.get("gap")) or 0.0 content_x = padding["left"] content_y = padding["top"] content_w = max(1.0, parent_box["width"] - padding["left"] - padding["right"]) content_h = max(1.0, parent_box["height"] - padding["top"] - padding["bottom"]) count = max(1, len(children)) if direction == "row": width = max(1.0, (content_w - gap * (count - 1)) / count) return [ ( child, _box_or_default( child, { "x": content_x + index * (width + gap), "y": content_y, "width": width, "height": content_h, }, ), ) for index, child in enumerate(children) ] height = max(1.0, (content_h - gap * (count - 1)) / count) return [ ( child, _box_or_default( child, { "x": content_x, "y": content_y + index * (height + gap), "width": content_w, "height": height, }, ), ) for index, child in enumerate(children) ] def _layout_grid_child_boxes( parent: dict[str, Any], children: list[dict[str, Any]], parent_box: dict[str, float], ) -> list[tuple[dict[str, Any], dict[str, float]]]: padding = _padding(parent.get("padding")) gap = _number(parent.get("gap")) or 0.0 column_gap = _number(parent.get("column_gap")) row_gap = _number(parent.get("row_gap")) column_gap = gap if column_gap is None else column_gap row_gap = gap if row_gap is None else row_gap column_count = _number(parent.get("columns")) if column_count is None and isinstance(parent.get("columns"), list): column_count = len(parent["columns"]) columns = max(1, int(column_count or 1)) rows = max(1, math.ceil(len(children) / columns)) content_w = max(1.0, parent_box["width"] - padding["left"] - padding["right"]) content_h = max(1.0, parent_box["height"] - padding["top"] - padding["bottom"]) cell_w = max(1.0, (content_w - column_gap * (columns - 1)) / columns) cell_h = max(1.0, (content_h - row_gap * (rows - 1)) / rows) child_sizes = [_element_size_or_default(child, cell_w, cell_h) for child in children] column_widths = [cell_w for _ in range(columns)] row_heights = [cell_h for _ in range(rows)] for index, child_size in enumerate(child_sizes): column_index = index % columns row_index = index // columns column_widths[column_index] = max(column_widths[column_index], child_size["width"]) row_heights[row_index] = max(row_heights[row_index], child_size["height"]) column_offsets = [padding["left"]] for index in range(1, columns): column_offsets.append(column_offsets[index - 1] + column_widths[index - 1] + column_gap) row_offsets = [padding["top"]] for index in range(1, rows): row_offsets.append(row_offsets[index - 1] + row_heights[index - 1] + row_gap) return [ ( child, _box_or_default( child, { "x": column_offsets[index % columns], "y": row_offsets[index // columns], "width": child_sizes[index]["width"], "height": child_sizes[index]["height"], }, ), ) for index, child in enumerate(children) ] def _element_size_or_default( element: dict[str, Any], default_width: float, default_height: float, ) -> dict[str, float]: size = element.get("size") width = _finite_number(size.get("width")) if isinstance(size, dict) else None height = _finite_number(size.get("height")) if isinstance(size, dict) else None return { "width": width if width is not None and width > 0 else default_width, "height": height if height is not None and height > 0 else default_height, } def _required_box(value: dict[str, Any], *, label: str) -> dict[str, float]: box = _optional_box(value) if box is None: raise ValueError(f"Cannot safely ungroup {label} without position and size.") return box def _box_or_default( value: dict[str, Any], default: dict[str, float], ) -> dict[str, float]: position = value.get("position") size = value.get("size") x = _finite_number(position.get("x")) if isinstance(position, dict) else None y = _finite_number(position.get("y")) if isinstance(position, dict) else None width = _finite_number(size.get("width")) if isinstance(size, dict) else None height = _finite_number(size.get("height")) if isinstance(size, dict) else None return { "x": x if x is not None else default["x"], "y": y if y is not None else default["y"], "width": width if width is not None and width > 0 else default["width"], "height": height if height is not None and height > 0 else default["height"], } def _optional_box(value: dict[str, Any]) -> dict[str, float] | None: position = value.get("position") size = value.get("size") if not isinstance(position, dict) or not isinstance(size, dict): return None x = _finite_number(position.get("x")) y = _finite_number(position.get("y")) width = _finite_number(size.get("width")) height = _finite_number(size.get("height")) if x is None or y is None or width is None or height is None: return None if width <= 0 or height <= 0: return None return {"x": x, "y": y, "width": width, "height": height} def _finite_number(value: Any) -> float | None: if isinstance(value, (int, float)) and not isinstance(value, bool): number = float(value) return number if math.isfinite(number) else None return None def _number(value: Any) -> float | None: return _finite_number(value) def _padding(value: Any) -> dict[str, float]: if isinstance(value, (int, float)) and not isinstance(value, bool): number = float(value) return {"top": number, "right": number, "bottom": number, "left": number} if not isinstance(value, dict): return {"top": 0.0, "right": 0.0, "bottom": 0.0, "left": 0.0} x = _number(value.get("x")) or _number(value.get("horizontal")) or 0.0 y = _number(value.get("y")) or _number(value.get("vertical")) or 0.0 return { "top": _number(value.get("top")) or y, "right": _number(value.get("right")) or x, "bottom": _number(value.get("bottom")) or y, "left": _number(value.get("left")) or x, } def _normalize_component_id(value: str) -> str: normalized = re.sub(r"[^a-z0-9]+", "_", value.strip().lower()).strip("_") return normalized or "component" def _unique_component_id(base: str, used_ids: set[str]) -> str: stem = base[:80].strip("_") or "component" candidate = stem suffix = 2 while candidate in used_ids: suffix_text = f"_{suffix}" candidate = f"{stem[: 80 - len(suffix_text)]}{suffix_text}" suffix += 1 used_ids.add(candidate) return candidate def _compact_components(layout_dict: dict[str, Any]) -> list[dict[str, Any]]: components: list[dict[str, Any]] = [] for component in layout_dict.get("components", []): if not isinstance(component, dict): continue components.append( { "component_id": component.get("id"), "description": component.get("description"), "element_count": len(component.get("elements", [])) if isinstance(component.get("elements"), list) else 0, "element_types": [ element.get("type") for element in component.get("elements", []) if isinstance(element, dict) ], } ) return components def _collect_editable_elements( layout_dict: dict[str, Any], *, include_visual_elements: bool = False, ) -> list[dict[str, Any]]: editable: list[dict[str, Any]] = [] root_elements = layout_dict.get("elements") if isinstance(root_elements, list): for element_index, element in enumerate(root_elements): if isinstance(element, dict): _visit_editable_element( element=element, path=f"elements[{element_index}]", component_id="", editable=editable, include_visual_elements=include_visual_elements, ) components = layout_dict.get("components", []) if not isinstance(components, list): return editable for component_index, component in enumerate(components): if not isinstance(component, dict): continue component_id = str(component.get("id") or "") elements = component.get("elements", []) if not isinstance(elements, list): continue for element_index, element in enumerate(elements): if isinstance(element, dict): _visit_editable_element( element=element, path=f"components[{component_index}].elements[{element_index}]", component_id=component_id, editable=editable, include_visual_elements=include_visual_elements, ) return editable def _visit_editable_element( *, element: dict[str, Any], path: str, component_id: str, editable: list[dict[str, Any]], include_visual_elements: bool, ) -> None: element_type = str(element.get("type") or "") is_content_editable = element_type in CONTENT_EDITABLE_ELEMENT_TYPES if is_content_editable or ( include_visual_elements and element_type in VISIBLE_ELEMENT_TYPES ): editable.append( { "path": path, "component_id": component_id, "type": element_type, "name": element.get("name"), "decorative": element.get("decorative"), "content_editable": is_content_editable, "geometry_editable": True, "content": _element_content(element), "style": _element_style(element), "limits": _element_limits(element), } ) child = element.get("child") if isinstance(child, dict): _visit_editable_element( element=child, path=f"{path}.child", component_id=component_id, editable=editable, include_visual_elements=include_visual_elements, ) children = element.get("children") if isinstance(children, list): for index, nested in enumerate(children): if isinstance(nested, dict): _visit_editable_element( element=nested, path=f"{path}.children[{index}]", component_id=component_id, editable=editable, include_visual_elements=include_visual_elements, ) def _element_limits(element: dict[str, Any]) -> dict[str, Any]: keys = ( "max_length", "min_length", "max_items", "min_items", "max_item_length", "min_item_length", "max_columns", "min_columns", "max_rows", "min_rows", "max_children", "min_children", "max_value", "min_value", ) return {key: element[key] for key in keys if key in element} def _element_content(element: dict[str, Any]) -> Any: element_type = element.get("type") if element_type == "text": return {"text": _runs_text(element.get("runs"))} if element_type == "text-list": items = element.get("items") if isinstance(element.get("items"), list) else [] return {"items": [_runs_text(item) for item in items]} if element_type == "table": columns = element.get("columns") if not isinstance(columns, list) and isinstance(element.get("headers"), list): columns = element["headers"] if not isinstance(columns, list): columns = [] return { "columns": [_table_value_text(cell) for cell in columns], "rows": [ [_table_value_text(cell) for cell in row] for row in element.get("rows", []) if isinstance(row, list) ], } if element_type == "chart": return { "chart_type": element.get("chart_type"), "title": element.get("title"), "title_color": element.get("title_color"), "categories": element.get("categories"), "series": element.get("series"), "colors": element.get("colors"), "axis_color": element.get("axis_color"), "grid_color": element.get("grid_color"), "x_axis": element.get("x_axis"), "y_axis": element.get("y_axis"), "x_axis_grid": element.get("x_axis_grid"), "y_axis_grid": element.get("y_axis_grid"), "x_axis_title": element.get("x_axis_title"), "y_axis_title": element.get("y_axis_title"), "data_labels": element.get("data_labels"), "legend": element.get("legend"), } if element_type == "image": return { "data": element.get("data"), "is_icon": element.get("is_icon"), } return None def _element_style(element: dict[str, Any]) -> dict[str, Any]: keys = ( "font", "alignment", "fill", "stroke", "color", "opacity", "shadow", "border_radius", "borderRadius", "padding", "marker", "fit", "focus_x", "focus_y", "crop_scale", ) style = {key: copy.deepcopy(element[key]) for key in keys if key in element} base_font = style.get("font") if isinstance(style.get("font"), dict) else {} run_font = _first_text_font(element) or {} if base_font or run_font: style["font"] = {**copy.deepcopy(base_font), **run_font} return style def _first_text_font(element: dict[str, Any]) -> dict[str, Any] | None: element_type = element.get("type") if element_type == "text": return _first_run_font(element.get("runs")) if element_type == "text-list": items = element.get("items") if not isinstance(items, list): return None for item in items: if isinstance(item, list): font = _first_run_font(item) elif isinstance(item, dict): font = ( copy.deepcopy(item["font"]) if isinstance(item.get("font"), dict) else _first_run_font(item.get("runs")) ) else: font = None if font: return font if element_type == "table": columns = element.get("columns") if isinstance(columns, list): for cell in columns: font = _table_cell_font(cell) if font: return font rows = element.get("rows") if isinstance(rows, list): for row in rows: if not isinstance(row, list): continue for cell in row: font = _table_cell_font(cell) if font: return font return None def _table_cell_font(cell: Any) -> dict[str, Any] | None: if not isinstance(cell, dict): return None if isinstance(cell.get("font"), dict): return copy.deepcopy(cell["font"]) return _first_run_font(cell.get("runs")) def _first_run_font(runs: Any) -> dict[str, Any] | None: if not isinstance(runs, list): return None for run in runs: if isinstance(run, dict) and isinstance(run.get("font"), dict): return copy.deepcopy(run["font"]) return None def _dicts(value: Any) -> list[dict[str, Any]]: if not isinstance(value, list): return [] return [item for item in value if isinstance(item, dict)] def _runs_text(value: Any) -> str: if not isinstance(value, list): return "" return "".join(str(run.get("text") or "") for run in value if isinstance(run, dict)) def _searchable_text(content: Any) -> str: if content is None: return "" if isinstance(content, str): return content try: return json.dumps(content, ensure_ascii=False) except Exception: return str(content) def _snippet(text: str, query: str, limit: int = 220) -> str: normalized = text.replace("\n", " ") index = normalized.casefold().find(query.casefold()) if index < 0: return normalized[:limit] start = max(0, index - 70) end = min(len(normalized), index + len(query) + 140) prefix = "..." if start > 0 else "" suffix = "..." if end < len(normalized) else "" return f"{prefix}{normalized[start:end]}{suffix}" def _resolve_element_path(layout_dict: dict[str, Any], path: str) -> dict[str, Any]: current: Any = layout_dict for segment in path.split("."): if segment == "child": if not isinstance(current, dict) or not isinstance(current.get("child"), dict): raise ValueError(f"Invalid element path segment: {segment}") current = current["child"] continue match = _PATH_SEGMENT_RE.match(segment) if not match: raise ValueError(f"Invalid element path segment: {segment}") key = match.group("key") index = int(match.group("index")) if not isinstance(current, dict) or not isinstance(current.get(key), list): raise ValueError(f"Invalid element path segment: {segment}") values = current[key] if index >= len(values) or not isinstance(values[index], dict): raise ValueError(f"Invalid element path index: {segment}") current = values[index] if not isinstance(current, dict) or not isinstance(current.get("type"), str): raise ValueError("Path does not resolve to an element.") return current def _resolve_layout_item_parent( layout_dict: dict[str, Any], path: str, ) -> tuple[list[Any], int]: current: Any = layout_dict segments = path.split(".") if not segments: raise ValueError("Layout item path is required.") for segment in segments[:-1]: if segment == "child": if not isinstance(current, dict) or not isinstance(current.get("child"), dict): raise ValueError(f"Invalid layout item path segment: {segment}") current = current["child"] continue match = _PATH_SEGMENT_RE.match(segment) if not match: raise ValueError(f"Invalid layout item path segment: {segment}") key = match.group("key") index = int(match.group("index")) if not isinstance(current, dict) or not isinstance(current.get(key), list): raise ValueError(f"Invalid layout item path segment: {segment}") values = current[key] if index >= len(values) or not isinstance(values[index], dict): raise ValueError(f"Invalid layout item path index: {segment}") current = values[index] match = _PATH_SEGMENT_RE.match(segments[-1]) if not match: raise ValueError(f"Invalid layout item path segment: {segments[-1]}") key = match.group("key") index = int(match.group("index")) if not isinstance(current, dict) or not isinstance(current.get(key), list): raise ValueError(f"Invalid layout item path segment: {segments[-1]}") values = current[key] if index >= len(values): raise ValueError(f"Invalid layout item path index: {segments[-1]}") return values, index def _preserve_slot_fields( target: dict[str, Any], slot: dict[str, Any], *, keep_id: bool, ) -> None: for key in ("position", "size", "rotation"): if key in slot: target[key] = copy.deepcopy(slot[key]) else: target.pop(key, None) if keep_id: target["id"] = slot.get("id") def _is_top_level_component_path(path: str) -> bool: match = _PATH_SEGMENT_RE.match(path) return bool( match and match.group("key") == "components" and match.group(0) == path ) def _component_id_for_path(layout_dict: dict[str, Any], path: str) -> str | None: first = path.split(".", 1)[0] match = _PATH_SEGMENT_RE.match(first) if not match or match.group("key") != "components": return None index = int(match.group("index")) components = layout_dict.get("components") if not isinstance(components, list) or index >= len(components): return None component = components[index] return str(component.get("id")) if isinstance(component, dict) else None def _looks_like_chart_request(value: str) -> bool: if _looks_like_asset_reference(value): return False normalized = " ".join(value.casefold().split()) if not normalized: return False chart_phrases = ( "pie chart", "bar chart", "line chart", "area chart", "donut chart", "horizontal bar", "horizontal stack bar", "horizontal stacked bar", "stacked bar", "radial chart", "chart with", "chart showing", "dummy metrics", "dummy chart", ) if any(phrase in normalized for phrase in chart_phrases): return True if "chart" not in normalized: return False return any( token in normalized for token in ("pie", "bar", "line", "donut", "metrics", "graph", "visualization") ) def _chart_request_on_image_error() -> ValueError: return ValueError( "Chart requests must use a chart element, not an image. If the target is an " "image/icon, delete that component and addComponent with type chart " "(chart_type bar|horizontal_bar|stacked_bar|line|area|pie|donut|" "scatter|bubble|radar|polar_area, title, categories, series with values, " "and optional colors). If the target is already chart, use updateElement " "with chart." ) def _looks_like_asset_reference(value: str) -> bool: stripped = value.strip() return stripped.startswith( ("http://", "https://", "/app_data/", "/static/", "data:", "blob:") ) def _chart_update_has_content(chart: dict[str, Any] | None) -> bool: if chart is None: return False return any(key in chart and chart.get(key) is not None for key in CHART_UPDATE_KEYS) def _resolve_image_update_payload( text: str | None, items: list[str] | None, ) -> str | dict[str, Any] | None: if text is not None: stripped = text.strip() if not stripped: return None if stripped.startswith("{") and stripped.endswith("}"): try: parsed = json.loads(stripped) except json.JSONDecodeError: parsed = None if isinstance(parsed, dict): return parsed return text if isinstance(items, list) and len(items) == 1: candidate = str(items[0] or "").strip() if candidate: return candidate return None def _template_v2_asset_url(value: Any) -> str | None: from utils.asset_directory_utils import normalize_slide_asset_url if isinstance(value, str): return normalize_slide_asset_url(value) if not isinstance(value, dict): return None fallback_url: str | None = None for key in ( "data", "url", "image_url", "icon_url", "__image_url__", "__icon_url__", ): asset_url = value.get(key) if isinstance(asset_url, str) and asset_url.strip(): normalized_url = normalize_slide_asset_url(asset_url) if _looks_like_asset_reference(normalized_url): return normalized_url if fallback_url is None: fallback_url = normalized_url return fallback_url def _template_v2_asset_prompt(value: Any, *, is_icon: bool) -> str | None: if not isinstance(value, dict): return None prompt_keys = ( ("icon_query", "__icon_query__", "query", "prompt") if is_icon else ("image_prompt", "__image_prompt__", "prompt", "query") ) for key in prompt_keys: prompt = value.get(key) if isinstance(prompt, str) and prompt.strip(): return prompt return None def _apply_image_element_value(element: dict[str, Any], value: Any) -> None: asset_url = _template_v2_asset_url(value) if not asset_url: raise ValueError( "Image/icon updates require `text` with an image or icon URL." ) element["data"] = asset_url prompt = _template_v2_asset_prompt( value, is_icon=element.get("is_icon") is True, ) if prompt: element["prompt"] = prompt def _apply_image_element_update( element: dict[str, Any], *, text: str | None, items: list[str] | None, ) -> None: payload = _resolve_image_update_payload(text, items) if payload is None: raise ValueError( "Image/icon updates require `text` with an image or icon URL." ) if isinstance(payload, str) and _looks_like_chart_request(payload): raise _chart_request_on_image_error() _apply_image_element_value(element, payload) def _content_update_requested_for_type( element_type: str, *, text: str | None, items: list[str] | None, table_cell: dict[str, Any] | None, table: dict[str, Any] | None, chart: dict[str, Any] | None, ) -> bool: if element_type == "text": return text is not None if element_type == "text-list": return items is not None if element_type == "table": return table is not None or table_cell is not None if element_type == "chart": return _chart_update_has_content(chart) if element_type == "image": return _resolve_image_update_payload(text, items) is not None return any( value is not None for value in (text, items, table_cell, table, chart) ) def _update_text_element(element: dict[str, Any], text: str) -> None: _validate_text_length( text, min_length=element.get("min_length"), max_length=element.get("max_length"), label=str(element.get("name") or "text"), ) element["runs"] = _replacement_runs( existing_runs=element.get("runs"), text=text, fallback_font=element.get("font"), ) # The Konva renderer reads the flattened top-level `text` in preference to # `runs` (see rawTextContent in TemplateV2KonvaSlide.tsx), and the frontend # inline editor always writes both. Keep them in sync so edits are visible. element["text"] = text def _update_text_list_element(element: dict[str, Any], items: list[str]) -> None: min_items = _int_or_none(element.get("min_items")) max_items = _int_or_none(element.get("max_items")) if min_items is not None and len(items) < min_items: raise ValueError(f"Text list requires at least {min_items} item(s).") if max_items is not None and len(items) > max_items: raise ValueError(f"Text list allows at most {max_items} item(s).") for index, item in enumerate(items): _validate_text_length( item, min_length=element.get("min_item_length"), max_length=element.get("max_item_length"), label=f"list item {index + 1}", ) source_items = element.get("items") if isinstance(element.get("items"), list) else [] element["items"] = [ _replacement_runs( existing_runs=source_items[index] if index < len(source_items) else None, text=item, fallback_font=element.get("font"), ) for index, item in enumerate(items) ] def _update_table_cell(element: dict[str, Any], table_cell: dict[str, Any]) -> None: section = table_cell["section"] column_index = int(table_cell["column_index"]) text = str(table_cell.get("text") or "") if section == "columns": columns = element.get("columns") if not isinstance(columns, list) or column_index >= len(columns): raise ValueError("Invalid table column index.") target = columns[column_index] else: row_index = table_cell.get("row_index") rows = element.get("rows") if not isinstance(row_index, int) or not isinstance(rows, list) or row_index >= len(rows): raise ValueError("Invalid table row index.") row = rows[row_index] if not isinstance(row, list) or column_index >= len(row): raise ValueError("Invalid table cell column index.") target = row[column_index] if not isinstance(target, dict): raise ValueError("Target table cell is invalid.") target["runs"] = _replacement_runs( existing_runs=target.get("runs"), text=text, fallback_font=target.get("font"), ) def _update_table_element(element: dict[str, Any], table: dict[str, Any]) -> None: columns = table.get("columns") or table.get("headers") rows = table.get("rows") if not isinstance(columns, list) or not isinstance(rows, list): raise ValueError("table update requires columns/headers and rows.") if not all(isinstance(row, list) for row in rows): raise ValueError("table rows must be lists.") column_count = len(columns) if column_count == 0: raise ValueError("table must contain at least one column.") if any(len(row) != column_count for row in rows): raise ValueError("each table row must match the column count.") min_columns = int(element.get("min_columns") or 0) max_columns = int(element.get("max_columns") or max(column_count, 100)) min_rows = int(element.get("min_rows") or 0) max_rows = int(element.get("max_rows") or max(len(rows), 100)) if column_count < min_columns or column_count > max_columns: raise ValueError("table column count is outside this element's limits.") if len(rows) < min_rows or len(rows) > max_rows: raise ValueError("table row count is outside this element's limits.") existing_columns = _dicts(element.get("columns")) existing_rows = [ _dicts(row) for row in element.get("rows", []) if isinstance(row, list) ] element["columns"] = [ _replacement_table_cell( value=value, existing=existing_columns[index] if index < len(existing_columns) else None, ) for index, value in enumerate(columns) ] element["rows"] = [ [ _replacement_table_cell( value=value, existing=( existing_rows[row_index][column_index] if row_index < len(existing_rows) and column_index < len(existing_rows[row_index]) else None ), ) for column_index, value in enumerate(row) ] for row_index, row in enumerate(rows) ] def _replacement_table_cell(value: Any, existing: dict[str, Any] | None) -> dict[str, Any]: cell = copy.deepcopy(existing) if isinstance(existing, dict) else {} cell["runs"] = _replacement_runs( existing_runs=cell.get("runs"), text=_table_value_text(value), fallback_font=cell.get("font"), ) return cell def _table_value_text(value: Any) -> str: if isinstance(value, dict): runs = value.get("runs") if isinstance(runs, list): text = _runs_text(runs) if text: return text for key in ("text", "content", "value", "label", "data"): if key in value: return _table_value_text(value[key]) return "" if isinstance(value, list): return "".join(_table_value_text(item) for item in value) if value is None: return "" return str(value) def _update_chart_element( element: dict[str, Any], chart: dict[str, Any], theme: dict[str, Any] | None = None, ) -> None: _apply_chart_content_update(element, chart, theme) categories = element.get("categories") series = element.get("series") if isinstance(categories, list) and isinstance(series, list): category_count = len(categories) for item in series: if not isinstance(item, dict): continue values = item.get("values") if isinstance(values, list) and len(values) != category_count: raise ValueError("Each chart series must match the category count.") TEXT_STYLE_ELEMENT_TYPES = {"text", "text-list", "table"} def _apply_element_style_patch( element: dict[str, Any], patch: dict[str, Any], ) -> None: element_type = str(element.get("type") or "") if "color" in patch: _apply_direct_color_patch(element, element_type, patch.get("color"), patch) font_patch = _text_font_patch_from_element_patch(element_type, patch) if not font_patch: return if element_type == "text": _apply_font_patch_to_text_element(element, font_patch) elif element_type == "text-list": _apply_font_patch_to_text_list_element(element, font_patch) elif element_type == "table": _apply_font_patch_to_table_element(element, font_patch) def _apply_direct_color_patch( element: dict[str, Any], element_type: str, value: Any, patch: dict[str, Any], ) -> None: if not isinstance(value, str) or not value.strip(): return if element_type in TEXT_STYLE_ELEMENT_TYPES: font = element.get("font") if isinstance(element.get("font"), dict) else {} element["font"] = {**font, "color": value} return if element_type == "line": stroke = element.get("stroke") if isinstance(element.get("stroke"), dict) else {} element["stroke"] = {**stroke, "color": value} return if "fill" not in patch and element_type in { "container", "ellipse", "flex", "grid", "grid-view", "group", "infographic", "rectangle", }: fill = element.get("fill") if isinstance(element.get("fill"), dict) else {} element["fill"] = {**fill, "color": value} def _text_font_patch_from_element_patch( element_type: str, patch: dict[str, Any], ) -> dict[str, Any]: if element_type not in TEXT_STYLE_ELEMENT_TYPES: return {} font_patch: dict[str, Any] = {} raw_font = patch.get("font") if isinstance(raw_font, dict): font_patch.update(_normalize_font_patch(raw_font)) direct_color = patch.get("color") if isinstance(direct_color, str) and direct_color.strip(): font_patch["color"] = direct_color return { key: copy.deepcopy(value) for key, value in font_patch.items() if value is not None } def _normalize_font_patch(value: dict[str, Any]) -> dict[str, Any]: normalized: dict[str, Any] = {} aliases = { "family": "family", "fontFamily": "family", "font_family": "family", "fontName": "family", "font_name": "family", "name": "family", "size": "size", "fontSize": "size", "font_size": "size", "color": "color", "fontColor": "color", "font_color": "color", "textColor": "color", "text_color": "color", "bold": "bold", "italic": "italic", "underline": "underline", "line_height": "line_height", "lineHeight": "line_height", "letter_spacing": "letter_spacing", "letterSpacing": "letter_spacing", "wrap": "wrap", "ellipsis": "ellipsis", "opacity": "opacity", } for source_key, target_key in aliases.items(): if source_key in value: normalized[target_key] = value[source_key] return normalized def _apply_font_patch_to_text_element( element: dict[str, Any], font_patch: dict[str, Any], ) -> None: _merge_font_patch(element, font_patch) runs = element.get("runs") if not isinstance(runs, list) or not runs: text = str(element.get("text") or "") element["runs"] = [{"text": text, "font": copy.deepcopy(element["font"])}] return for run in runs: if isinstance(run, dict): _merge_font_patch(run, font_patch) def _apply_font_patch_to_text_list_element( element: dict[str, Any], font_patch: dict[str, Any], ) -> None: _merge_font_patch(element, font_patch) items = element.get("items") if not isinstance(items, list): return for index, item in enumerate(items): if isinstance(item, list): for run in item: if isinstance(run, dict): _merge_font_patch(run, font_patch) elif isinstance(item, dict): _merge_font_patch(item, font_patch) runs = item.get("runs") if isinstance(runs, list): for run in runs: if isinstance(run, dict): _merge_font_patch(run, font_patch) elif isinstance(item, str): items[index] = [{"text": item, "font": copy.deepcopy(element["font"])}] def _apply_font_patch_to_table_element( element: dict[str, Any], font_patch: dict[str, Any], ) -> None: _merge_font_patch(element, font_patch) columns = element.get("columns") if isinstance(columns, list): for cell in columns: _apply_font_patch_to_table_cell(cell, font_patch) rows = element.get("rows") if isinstance(rows, list): for row in rows: if isinstance(row, list): for cell in row: _apply_font_patch_to_table_cell(cell, font_patch) def _apply_font_patch_to_table_cell( cell: Any, font_patch: dict[str, Any], ) -> None: if not isinstance(cell, dict): return _merge_font_patch(cell, font_patch) runs = cell.get("runs") if not isinstance(runs, list) or not runs: return for run in runs: if isinstance(run, dict): _merge_font_patch(run, font_patch) def _merge_font_patch(target: dict[str, Any], font_patch: dict[str, Any]) -> None: source = target.get("font") if isinstance(target.get("font"), dict) else {} target["font"] = { **source, **copy.deepcopy(font_patch), } def _replacement_runs( *, existing_runs: Any, text: str, fallback_font: Any, ) -> list[dict[str, Any]]: if isinstance(existing_runs, list) and existing_runs: first = existing_runs[0] if isinstance(first, dict): run = copy.deepcopy(first) run["text"] = text return [run] run: dict[str, Any] = {"text": text} if isinstance(fallback_font, dict): run["font"] = copy.deepcopy(fallback_font) return [run] def _validate_text_length( text: str, *, min_length: Any, max_length: Any, label: str, ) -> None: min_value = _int_or_none(min_length) max_value = _int_or_none(max_length) if min_value is not None and len(text) < min_value: raise ValueError(f"{label} must be at least {min_value} character(s).") if max_value is not None and len(text) > max_value: raise ValueError(f"{label} must be at most {max_value} character(s).") def _int_or_none(value: Any) -> int | None: return value if isinstance(value, int) else None