1938 lines
62 KiB
Python
1938 lines
62 KiB
Python
from __future__ import annotations
|
|
|
|
import copy
|
|
import json
|
|
import math
|
|
import re
|
|
from typing import Any
|
|
|
|
_PATH_SEGMENT_RE = re.compile(r"^(?P<key>components|elements|children)\[(?P<index>\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
|