480 lines
11 KiB
Python
480 lines
11 KiB
Python
"""Pydantic models matching the frontend slide element types."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from enum import Enum
|
|
from typing import Annotated, Literal, Optional, TypeAlias, Union, List
|
|
|
|
from pydantic import BaseModel, Field, field_validator, model_validator
|
|
|
|
|
|
def _validate_min_max(
|
|
min_value: int | None,
|
|
max_value: int | None,
|
|
*,
|
|
min_name: str,
|
|
max_name: str,
|
|
) -> None:
|
|
if min_value is None or max_value is None:
|
|
return
|
|
|
|
expected_min = (max_value + 1) // 2
|
|
if min_value != expected_min:
|
|
raise ValueError(
|
|
f"{min_name} must equal half of {max_name}, rounded up ({expected_min})"
|
|
)
|
|
|
|
|
|
class HorizontalAlignment(str, Enum):
|
|
LEFT = "left"
|
|
CENTER = "center"
|
|
RIGHT = "right"
|
|
|
|
|
|
class VerticalAlignment(str, Enum):
|
|
TOP = "top"
|
|
MIDDLE = "middle"
|
|
BOTTOM = "bottom"
|
|
|
|
|
|
class LayoutAlignment(str, Enum):
|
|
FLEX_START = "flex-start"
|
|
FLEX_END = "flex-end"
|
|
CENTER = "center"
|
|
STRETCH = "stretch"
|
|
|
|
|
|
class Marker(str, Enum):
|
|
BULLET = "bullet"
|
|
NUMBER = "number"
|
|
NONE = "none"
|
|
|
|
|
|
class FlexDirection(str, Enum):
|
|
ROW = "row"
|
|
COLUMN = "column"
|
|
|
|
|
|
class ImageFit(str, Enum):
|
|
CONTAIN = "contain"
|
|
COVER = "cover"
|
|
FILL = "fill"
|
|
|
|
|
|
class ChartType(str, Enum):
|
|
BAR = "bar"
|
|
HORIZONTAL_BAR = "horizontal_bar"
|
|
LINE = "line"
|
|
AREA = "area"
|
|
PIE = "pie"
|
|
DONUT = "donut"
|
|
STACKED_BAR = "stacked_bar"
|
|
HORIZONTAL_STACKED_BAR = "horizontal_stacked_bar"
|
|
SCATTER = "scatter"
|
|
RADAR = "radar"
|
|
POLAR_AREA = "polar_area"
|
|
|
|
|
|
class DataLabelPosition(str, Enum):
|
|
BASE = "base"
|
|
MID = "mid"
|
|
TOP = "top"
|
|
OUTSIDE = "outside"
|
|
|
|
|
|
class Position(BaseModel):
|
|
x: float
|
|
y: float
|
|
|
|
|
|
class Size(BaseModel):
|
|
width: float
|
|
height: float
|
|
|
|
|
|
class Padding(BaseModel):
|
|
top: float
|
|
right: float
|
|
bottom: float
|
|
left: float
|
|
|
|
|
|
class Alignment(BaseModel):
|
|
horizontal: Optional[HorizontalAlignment] = None
|
|
vertical: Optional[VerticalAlignment] = None
|
|
|
|
|
|
class Font(BaseModel):
|
|
size: Optional[float] = None
|
|
family: Optional[str] = None
|
|
color: Optional[str] = None
|
|
bold: Optional[bool] = None
|
|
italic: Optional[bool] = None
|
|
line_height: Optional[float] = None
|
|
letter_spacing: Optional[float] = None
|
|
ellipsis: Optional[bool] = None
|
|
opacity: Optional[float] = None
|
|
|
|
|
|
class Fill(BaseModel):
|
|
color: str
|
|
opacity: Optional[float] = None
|
|
|
|
|
|
class Stroke(BaseModel):
|
|
color: str
|
|
opacity: Optional[float] = None
|
|
width: float
|
|
dash: Optional[list[float]] = None
|
|
|
|
|
|
class BorderRadius(BaseModel):
|
|
tl: float
|
|
tr: float
|
|
bl: float
|
|
br: float
|
|
|
|
|
|
class Shadow(BaseModel):
|
|
color: str
|
|
blur: Optional[float] = None
|
|
opacity: Optional[float] = None
|
|
offset_x: Optional[float] = None
|
|
offset_y: Optional[float] = None
|
|
|
|
|
|
class ChartSeries(BaseModel):
|
|
name: str
|
|
values: list[float]
|
|
|
|
|
|
class TextRun(BaseModel):
|
|
text: str
|
|
font: Optional[Font] = None
|
|
|
|
|
|
class Text(BaseModel): # Konva Text
|
|
type: Literal["text"]
|
|
position: Optional[Position] = None
|
|
size: Optional[Size] = None
|
|
rotation: Optional[float] = None
|
|
font: Optional[Font] = None
|
|
alignment: Optional[Alignment] = None
|
|
fill: Optional[Fill] = None
|
|
stroke: Optional[Stroke] = None
|
|
shadow: Optional[Shadow] = None
|
|
runs: list[TextRun]
|
|
|
|
# Schema
|
|
decorative: bool
|
|
name: str
|
|
max_length: int
|
|
min_length: int
|
|
|
|
|
|
class Container(BaseModel): # Konva Group
|
|
type: Literal["container"]
|
|
position: Optional[Position] = None
|
|
size: Optional[Size] = None
|
|
rotation: Optional[float] = None
|
|
alignment: Optional[Alignment] = None
|
|
fill: Optional[Fill] = None
|
|
stroke: Optional[Stroke] = None
|
|
border_radius: Optional[BorderRadius] = None
|
|
shadow: Optional[Shadow] = None
|
|
padding: Optional[Padding] = None
|
|
child: Optional[SlideElement] = None
|
|
|
|
|
|
class Image(BaseModel): # Konva Image
|
|
type: Literal["image"]
|
|
position: Optional[Position] = None
|
|
size: Optional[Size] = None
|
|
rotation: Optional[float] = None
|
|
flip_h: Optional[bool] = None
|
|
flip_v: Optional[bool] = None
|
|
opacity: Optional[float] = None
|
|
data: str
|
|
fit: Optional[ImageFit] = None
|
|
focus_x: Optional[float] = None
|
|
focus_y: Optional[float] = None
|
|
crop_scale: Optional[float] = None
|
|
border_radius: Optional[BorderRadius] = None
|
|
clip_path: Optional[str] = None
|
|
color: Optional[str] = None
|
|
|
|
# Schema
|
|
decorative: bool
|
|
name: str
|
|
prompt: Optional[str] = None
|
|
is_icon: bool
|
|
|
|
|
|
class TextList(BaseModel): # Konva Group
|
|
type: Literal["text-list"]
|
|
position: Optional[Position] = None
|
|
size: Optional[Size] = None
|
|
rotation: Optional[float] = None
|
|
font: Optional[Font] = None
|
|
marker: Optional[Marker] = None
|
|
items: list[list[TextRun]]
|
|
|
|
# Schema
|
|
decorative: bool
|
|
name: str
|
|
max_items: int
|
|
min_items: int
|
|
max_item_length: int
|
|
min_item_length: int
|
|
|
|
|
|
class TableCell(BaseModel):
|
|
color: Optional[Fill] = None
|
|
font: Optional[Font] = None
|
|
alignment: Optional[HorizontalAlignment] = None
|
|
runs: List[TextRun]
|
|
|
|
|
|
class Table(BaseModel):
|
|
type: Literal["table"]
|
|
position: Optional[Position] = None
|
|
size: Optional[Size] = None
|
|
rotation: Optional[float] = None
|
|
columns: list[TableCell]
|
|
rows: list[list[TableCell]]
|
|
|
|
# Schema
|
|
decorative: bool
|
|
name: str
|
|
max_columns: int
|
|
min_columns: int
|
|
max_rows: int
|
|
min_rows: int
|
|
|
|
|
|
class Rectangle(BaseModel):
|
|
type: Literal["rectangle"]
|
|
position: Optional[Position] = None
|
|
size: Optional[Size] = None
|
|
rotation: Optional[float] = None
|
|
fill: Optional[Fill] = None
|
|
stroke: Optional[Stroke] = None
|
|
border_radius: Optional[BorderRadius] = None
|
|
shadow: Optional[Shadow] = None
|
|
|
|
|
|
class Ellipse(BaseModel):
|
|
type: Literal["ellipse"]
|
|
position: Optional[Position] = None
|
|
size: Optional[Size] = None
|
|
rotation: Optional[float] = None
|
|
fill: Optional[Fill] = None
|
|
stroke: Optional[Stroke] = None
|
|
shadow: Optional[Shadow] = None
|
|
|
|
|
|
class Line(BaseModel):
|
|
type: Literal["line"]
|
|
position: Optional[Position] = None
|
|
size: Optional[Size] = None
|
|
rotation: Optional[float] = None
|
|
stroke: Stroke
|
|
shadow: Optional[Shadow] = None
|
|
|
|
|
|
class Chart(BaseModel):
|
|
type: Literal["chart"]
|
|
position: Optional[Position] = None
|
|
size: Optional[Size] = None
|
|
rotation: Optional[float] = None
|
|
chart_type: ChartType
|
|
title: Optional[str] = None
|
|
title_color: Optional[str] = None
|
|
|
|
# PPTX chart model emitted by the template-v2 converter.
|
|
colors: Optional[list[str]] = None
|
|
x_axis: Optional[bool] = None
|
|
y_axis: Optional[bool] = None
|
|
x_axis_title: Optional[str] = None
|
|
y_axis_title: Optional[str] = None
|
|
axis_color: Optional[str] = None
|
|
categories: Optional[list[str]] = None
|
|
series: Optional[list[ChartSeries]] = None
|
|
data_labels: Optional[DataLabelPosition] = None
|
|
legend: Optional[bool] = None
|
|
x_axis_grid: Optional[bool] = None
|
|
y_axis_grid: Optional[bool] = None
|
|
grid_color: Optional[str] = None
|
|
source: Optional[str] = None
|
|
|
|
# Schema
|
|
decorative: bool
|
|
name: str
|
|
|
|
@model_validator(mode="after")
|
|
def _pie_and_donut_use_only_first_series(self) -> "Chart":
|
|
if (
|
|
self.chart_type in {ChartType.PIE, ChartType.DONUT}
|
|
and self.series
|
|
and len(self.series) > 1
|
|
):
|
|
self.series = self.series[:1]
|
|
return self
|
|
|
|
@field_validator("data_labels", mode="before")
|
|
@classmethod
|
|
def _coerce_legacy_data_labels(cls, value: object) -> object:
|
|
if value is True:
|
|
return DataLabelPosition.TOP
|
|
if value is False or value is None:
|
|
return None
|
|
if isinstance(value, str):
|
|
normalized = value.strip().lower()
|
|
if normalized in {position.value for position in DataLabelPosition}:
|
|
return normalized
|
|
return value
|
|
|
|
@model_validator(mode="after")
|
|
def _size_must_be_visible_when_explicit(self) -> "Chart":
|
|
if self.size is None:
|
|
return self
|
|
if self.size.width < 80 or self.size.height < 60:
|
|
raise ValueError("chart size must be at least 80x60 px")
|
|
return self
|
|
|
|
|
|
class InfographicType(str, Enum):
|
|
PROGRESS_BAR = "progress_bar"
|
|
GAUGE = "gauge"
|
|
|
|
|
|
class Infographic(BaseModel):
|
|
type: Literal["infographic"]
|
|
position: Optional[Position] = None
|
|
size: Optional[Size] = None
|
|
rotation: Optional[float] = None
|
|
infographic_type: InfographicType
|
|
max_value: float
|
|
min_value: float
|
|
value: float
|
|
|
|
# Design
|
|
base_color: Optional[str] = None
|
|
highlight_color: Optional[str] = None
|
|
|
|
# Schema
|
|
decorative: bool
|
|
name: str
|
|
|
|
|
|
class Flex(BaseModel):
|
|
type: Literal["flex"]
|
|
position: Optional[Position] = None
|
|
size: Optional[Size] = None
|
|
rotation: Optional[float] = None
|
|
direction: FlexDirection
|
|
wrap: Optional[bool] = None
|
|
align_items: Optional[LayoutAlignment] = None
|
|
justify_content: Optional[LayoutAlignment] = None
|
|
gap: Optional[float] = None
|
|
column_gap: Optional[float] = None
|
|
row_gap: Optional[float] = None
|
|
children: list[SlideElement]
|
|
|
|
# Schema
|
|
name: str
|
|
max_children: int
|
|
min_children: int
|
|
|
|
|
|
class Grid(BaseModel):
|
|
type: Literal["grid"]
|
|
position: Optional[Position] = None
|
|
size: Optional[Size] = None
|
|
rotation: Optional[float] = None
|
|
columns: int
|
|
rows: Optional[int] = None
|
|
gap: Optional[float] = None
|
|
column_gap: Optional[float] = None
|
|
row_gap: Optional[float] = None
|
|
align_items: Optional[LayoutAlignment] = None
|
|
justify_items: Optional[LayoutAlignment] = None
|
|
children: list[SlideElement]
|
|
|
|
# Schema
|
|
name: str
|
|
max_children: int
|
|
min_children: int
|
|
|
|
|
|
class Group(BaseModel):
|
|
type: Literal["group"]
|
|
position: Optional[Position] = None
|
|
size: Optional[Size] = None
|
|
children: list[SlideElement]
|
|
|
|
# Schema
|
|
name: str
|
|
|
|
|
|
SlideElement: TypeAlias = Annotated[
|
|
Union[
|
|
Text,
|
|
Container,
|
|
Image,
|
|
TextList,
|
|
Table,
|
|
Rectangle,
|
|
Ellipse,
|
|
Line,
|
|
Chart,
|
|
Infographic,
|
|
Flex,
|
|
Grid,
|
|
Group,
|
|
],
|
|
Field(discriminator="type"),
|
|
]
|
|
|
|
|
|
for _model in (Container, Flex, Grid, Group):
|
|
_model.model_rebuild()
|
|
|
|
|
|
__all__ = [
|
|
"Alignment",
|
|
"BorderRadius",
|
|
"Chart",
|
|
"ChartSeries",
|
|
"ChartType",
|
|
"Container",
|
|
"Ellipse",
|
|
"Fill",
|
|
"Flex",
|
|
"FlexDirection",
|
|
"Font",
|
|
"Grid",
|
|
"HorizontalAlignment",
|
|
"Image",
|
|
"ImageFit",
|
|
"Infographic",
|
|
"InfographicType",
|
|
"LayoutAlignment",
|
|
"Line",
|
|
"Marker",
|
|
"Padding",
|
|
"Position",
|
|
"Rectangle",
|
|
"Shadow",
|
|
"Size",
|
|
"SlideElement",
|
|
"Group",
|
|
"Stroke",
|
|
"Table",
|
|
"TableCell",
|
|
"Text",
|
|
"TextList",
|
|
"TextRun",
|
|
"VerticalAlignment",
|
|
]
|