From 9018fa5b4722ddf6a70f102590f1e3a5adfffe36 Mon Sep 17 00:00:00 2001 From: Daniel Robotics Date: Thu, 15 May 2025 16:09:06 +1000 Subject: [PATCH 1/7] Create Drawio structure --- .$result.drawio.dtmp | 94 +++++ .gitignore | 4 + image_processing/DrawioProcessing.py | 189 ++++++++++ image_processing/ImageProcessing.py | 518 +++++++++++++++++++++++++++ image_processing/__init__.py | 4 +- image_processing/imageDesign.py | 469 ++---------------------- pages/image_processing_page.py | 34 +- 7 files changed, 848 insertions(+), 464 deletions(-) create mode 100644 .$result.drawio.dtmp create mode 100644 image_processing/DrawioProcessing.py create mode 100644 image_processing/ImageProcessing.py diff --git a/.$result.drawio.dtmp b/.$result.drawio.dtmp new file mode 100644 index 0000000..6a915ea --- /dev/null +++ b/.$result.drawio.dtmp @@ -0,0 +1,94 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/.gitignore b/.gitignore index 696d197..32099c1 100644 --- a/.gitignore +++ b/.gitignore @@ -7,4 +7,8 @@ data/*.json data/*.pdf data/*.xlsx images/ + +test_code.py .pytest_cache +*.drawio + diff --git a/image_processing/DrawioProcessing.py b/image_processing/DrawioProcessing.py new file mode 100644 index 0000000..99cd5d2 --- /dev/null +++ b/image_processing/DrawioProcessing.py @@ -0,0 +1,189 @@ +import io, math, uuid, base64, xml.etree.ElementTree as ET +from pathlib import Path +from typing import Optional, Union + +from PIL import Image +from image_processing.enumerates import * +from image_processing.ImageProcessing import ImageProcessing, get_label + + +class DrawioImageDesign(ImageProcessing): + # ───────────────────────────────────────────────────────── # + # INIT + # ───────────────────────────────────────────────────────── # + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self._images = self._load_images(self.images_path) + + # ───────────────────────────────────────────────────────── # + # HELPERS + # ───────────────────────────────────────────────────────── # + @staticmethod + def _pil_to_b64(img: Image.Image) -> str: + buf = io.BytesIO() + img.save(buf, format="PNG") + return base64.b64encode(buf.getvalue()).decode("ascii") + + @staticmethod + def _add_geom(cell: ET.Element, **coords): + g = ET.SubElement(cell, "mxGeometry", **{k: str(v) for k, v in coords.items()}) + g.set("as", "geometry") + return g + + # толщина + цвет рамки + def _border_params(self) -> tuple[int, str]: + if isinstance(self.border_size, int): + bw = self.border_size + else: # (l,t,r,b) → max + bw = max(self.border_size) + color = (('#%02x%02x%02x' % self.border_fill) # RGB → #rrggbb + if isinstance(self.border_fill, tuple) + else self.border_fill) + return bw, color + + # ───────────────────────────────────────────────────────── # + # PRE-PROCESS (без прорисовки рамки!) + # ───────────────────────────────────────────────────────── # + def _draw_border(self, img: Image.Image) -> Image.Image: + return img # рамку добавит draw.io + + def preprocessing_image(self, idx: int, + width: int | None = None, + height: int | None = None) -> Image.Image: + img = self._images[idx] + return self._resize_proportional(img, width, height) + + # ───────────────────────────────────────────────────────── # + # LAYOUT + # ───────────────────────────────────────────────────────── # + def _layout_images(self, layout: Union[str, LayoutMode], + spacing: int, + cols: Optional[int], + rows: Optional[int]) -> list[tuple[int, int]]: + layout = layout.value if isinstance(layout, LayoutMode) else layout + pos: list[tuple[int, int]] = [] + + if layout == "row": + x = y = 0 + for im in self._images: + pos.append((x, y)) + x += im.width + spacing + + elif layout == "column": + x = y = 0 + for im in self._images: + pos.append((x, y)) + y += im.height + spacing + + elif layout == "grid": + cols = cols or math.ceil(math.sqrt(len(self._images))) + img_w = self._images[0].width + spacing + img_h = self._images[0].height + spacing + for i, _ in enumerate(self._images): + pos.append(((i % cols) * img_w, + (i // cols) * img_h)) + else: + raise ValueError(f"Unknown layout: {layout}") + + return pos + + # ───────────────────────────────────────────────────────── # + # LABEL & AXES (внутри группы) + # ───────────────────────────────────────────────────────── # + def _add_numbering(self, xml_root: ET.Element, parent_id: str, + text: str, dx: float, dy: float): + lbl = ET.SubElement(xml_root, "mxCell", + id=str(uuid.uuid4()), value=text, + style=f"shape=label;align=left;verticalAlign=top;" + f"fontSize={self.signature_font_size};" + f"fontColor={self.signature_label_color};" + f"fillColor=none;strokeColor=none;", + vertex="1", parent=parent_id) + self._add_geom(lbl, x=dx, y=dy, + width=self.signature_size[0], + height=self.signature_size[1]) + + def _add_axes(self, xml_root: ET.Element, parent_id: str, + w: int, h: int, + label_x: str, label_y: str): + off_x, off_y = (self.axis_offset if isinstance(self.axis_offset, tuple) + else (self.axis_offset, self.axis_offset)) + + # линии-стрелки + for _ in range(2): + line = ET.SubElement(xml_root, "mxCell", + id=str(uuid.uuid4()), + style=f"endArrow=block;strokeWidth={self.axis_width};", + edge="1", parent=parent_id) + self._add_geom(line, relative="1") + + # подписи + self._add_numbering(xml_root, parent_id, + label_x, off_x + self.axis_length, h - self.axis_font_size - 4) + self._add_numbering(xml_root, parent_id, + label_y, 4, off_y) + + # ───────────────────────────────────────────────────────── # + # BUILD XML + # ───────────────────────────────────────────────────────── # + def united_images(self, + layout: Union[str, LayoutMode] = "row", + spacing: int = 10, + grid_cols: Optional[int] = None, + grid_rows: Optional[int] = None, + width: int | None = None, + height: int | None = None) -> str: + + root = ET.Element("mxfile", host="app.diagrams.net") + diagram = ET.SubElement(root, "diagram", name="Page-1", id=str(uuid.uuid4())) + model = ET.SubElement(diagram, "mxGraphModel") + xml_root = ET.SubElement(model, "root") + ET.SubElement(xml_root, "mxCell", id="0") + ET.SubElement(xml_root, "mxCell", id="1", parent="0") + + bw, bc = self._border_params() # толщина и цвет рамки + positions = self._layout_images(layout, spacing, grid_cols, grid_rows) + + for idx, (px, py) in enumerate(positions): + img = self.preprocessing_image(idx, width, height) + + # Группа + g_id = str(uuid.uuid4()) + group = ET.SubElement(xml_root, "mxCell", + id=g_id, value="", vertex="1", parent="1") + self._add_geom(group, + x=px, y=py, + width=img.width + bw * 2, + height=img.height + bw * 2) + + # Изображение + style = (f"shape=image;strokeWidth={bw};strokeColor={bc};" + "aspect=fixed;imageAspect=0;" + f"image=data:image/png,{self._pil_to_b64(img)}") + img_cell = ET.SubElement(xml_root, "mxCell", + id=str(uuid.uuid4()), value="", + style=style, vertex="1", parent=g_id) + self._add_geom(img_cell, x=bw, y=bw, + width=img.width, height=img.height) + + # Подпись + if self.signature: + self._add_numbering(xml_root, g_id, + get_label(idx, self.signature_label), + dx=4, dy=4) + + # Оси + if self.draw_axis: + lx = (self.axis_labels[0] if isinstance(self.axis_labels[0], str) + else self.axis_labels[0][idx]) + ly = (self.axis_labels[1] if isinstance(self.axis_labels[1], str) + else self.axis_labels[1][idx]) + self._add_axes(xml_root, g_id, img.width, img.height, lx, ly) + + return ET.tostring(root, encoding="utf-8", xml_declaration=True).decode("utf-8") + + # ───────────────────────────────────────────────────────── # + # EXPORT + # ───────────────────────────────────────────────────────── # + def export_to_drawio(self, file: str | Path, **kwargs): + Path(file).write_text(self.united_images(**kwargs), encoding="utf-8") diff --git a/image_processing/ImageProcessing.py b/image_processing/ImageProcessing.py new file mode 100644 index 0000000..aafebcf --- /dev/null +++ b/image_processing/ImageProcessing.py @@ -0,0 +1,518 @@ +from abc import ABC, abstractmethod +from pathlib import Path +from typing import List, Optional, Tuple, Union +from PIL import Image, ImageOps, ImageDraw, ImageFont + +from image_processing.enumerates import * + + +def to_roman(n: int) -> str: + val = [ + 1000, 900, 500, 400, + 100, 90, 50, 40, + 10, 9, 5, 4, 1 + ] + syms = [ + 'M', 'CM', 'D', 'CD', + 'C', 'XC', 'L', 'XL', + 'X', 'IX', 'V', 'IV', 'I' + ] + roman = '' + for i in range(len(val)): + count = n // val[i] + roman += syms[i] * count + n -= val[i] * count + return roman + + +def get_label(index: int, mode: str | LabelMode = LabelMode.CYRILLIC_LOWER) -> str: + mode = mode.value if isinstance(mode, LabelMode) else mode + + match mode: + case "latin_lower": return chr(ord('a') + index) + case "latin_upper": return chr(ord('A') + index) + case "cyrillic_lower" | "cyrillic_upper": + base = ord('а') if mode == "cyrillic_lower" else ord('А') + if index >= 32: + raise ValueError(f"Индекс {index} выходит за пределы кириллического алфавита") + return chr(base + index) + case "arabic": return str(index + 1) + case "roman": return to_roman(index + 1) + case _: + available = ", ".join(m.value for m in LabelMode) + raise ValueError(f"Неверный режим: '{mode}'. Доступные режимы: {available}") + + +class ImageProcessing(ABC): + + def __init__(self, + images_path: Union[str, Path], + border_size: Union[int, Tuple[int, int, int, int], None] = 10, + border_fill: Union[str, Tuple[int, int, int]] = "black", + signature: bool = True, + signature_label: Union[str, Tuple[str], LabelMode, None] = "latin_lower", + signature_label_color: str = "white", + signature_pos: Union[str, SignaturePosition] = "top-left", + signature_size: Tuple[int, int] = (40, 40), + signature_color: str = "black", + signature_font_size: int = 24, + draw_axis: bool = False, + axis_labels: Union[Tuple[str, str], Tuple[Tuple[str], Tuple[str]]] = ("X", "Y"), + axis_offset: Union[int, Tuple[int, int]] = 20, + axis_length: int = 60, + axis_width: int = 3, + axis_font_size: int = 24, + font_family: str = "Arial", + ): + + """ + Initialize the class for processing and composing images with optional + borders, labels, and axis annotations. + + Args: + images_path (Union[str, Path]): Path to the folder containing image files (PNG, JPG, JPEG). + border_size (Union[int, Tuple[int, int, int, int], None], optional): + Border size around each image. Can be: + - int: uniform border on all sides, + - tuple: (left, top, right, bottom), + - None: no border. + Defaults to 10. + border_fill (Union[str, Tuple[int, int, int]], optional): + Color of the border. Can be a string color name (e.g., "black") or an RGB tuple. Defaults to "black". + signature (bool, optional): Whether to add a label/numbering on each image. Defaults to True. + signature_label (Union[str, Tuple[str], LabelMode, None], optional): + Labeling mode. Can be: + - str: mode name ("latin_lower", "roman", etc.), + - tuple of strings: custom labels per image, + - LabelMode enum, + - None: no labels. + Defaults to "latin_lower". + signature_label_color (str, optional): Color of the label text. Defaults to "white". + signature_pos (SignaturePosition, optional): Position of the label on the image (top-left, bottom-right, etc.). Defaults to SignaturePosition.TOP_LEFT. + signature_size (Tuple[int, int], optional): Size of the label box (width, height). Defaults to (40, 40). + signature_color (str, optional): Background color of the label box. Defaults to "black". + signature_font_size (int, optional): Font size for labels annotations. Defaults to 24. + draw_axis (bool, optional): Whether to draw X and Y axes on each image. Defaults to False. + axis_labels (Union[Tuple[str, str], Tuple[Tuple[str], Tuple[str]]], optional): + Labels for X and Y axes. Can be: + - Tuple of two strings: global labels, + - Tuple of two tuples: per-image labels. + Defaults to ("X", "Y"). + axis_offset (int, optional): Distance in pixels from the image edge to the axis origin. Defaults to 20. + axis_length (int, optional): Length of the drawn axes in pixels. Defaults to 60. + axis_font_size (int, optional): Font size for axis annotations. Defaults to 24. + + Raises: + TypeError: If any of the arguments are of incorrect type. + ValueError: If label or axis settings are out of bounds or improperly defined. + """ + + self._signature_font_size = signature_font_size + self._axis_font_size = axis_font_size + self._font_family = font_family + + self.signature = signature + self.draw_axis = draw_axis + self.images_path = images_path + self.border_size = border_size + self.border_fill = border_fill + self.axis_labels = axis_labels + self.axis_offset = axis_offset + self.axis_length = axis_length + self.axis_width = axis_width + self.signature_pos = signature_pos + self.signature_size = signature_size + self.signature_color = signature_color + self.signature_label = signature_label + self.signature_label_color = signature_label_color + + # Magic methods + def __str__(self) -> str: + return ( + f"ImagesDesign(\n" + f" images_path={self.images_path},\n" + f" border_size={self.border_size}, border_fill={self.border_fill},\n" + f" signature={self.signature}, signature_label={self.signature_label},\n" + f" signature_pos={self.signature_pos}, signature_size={self.signature_size},\n" + f" signature_font_size={self._signature_font_size},\n" + f" draw_axis={self.draw_axis}, axis_labels={self.axis_labels},\n" + f" axis_offset={self.axis_offset}, axis_length={self.axis_length},\n" + f" axis_font_size={self._axis_font_size}\n" + f")" + ) + + def __repr__(self) -> str: + return ( + f"ImagesDesign(images_path={repr(self.images_path)}, " + f"border_size={repr(self.border_size)}, border_fill={repr(self.border_fill)}, " + f"signature={self.signature}, signature_label={repr(self.signature_label)}, " + f"signature_label_color={repr(self.signature_label_color)}, " + f"signature_pos={repr(self.signature_pos)}, signature_size={repr(self.signature_size)}, " + f"signature_font_size={self._signature_font_size}, signature_color={repr(self.signature_color)}, " + f"draw_axis={self.draw_axis}, axis_labels={repr(self.axis_labels)}, " + f"axis_offset={self.axis_offset}, axis_length={self.axis_length}, axis_font_size={self._axis_font_size})" + f"" + ) + + + def __len__(self) -> int: + return len(self._images) + + def __getitem__(self, index: int) -> Image.Image: + if not isinstance(index, int): + raise TypeError("The index must be an integer") + return self._images[index] + + def __setitem__(self, index: int, value: Image.Image) -> None: + if not isinstance(index, int): + raise TypeError("The index must be an integer") + if not isinstance(value, Image.Image): + raise TypeError("The value must be an instance of PIL.Image.Image") + if not (0 <= index < len(self._images)): + raise IndexError(f"Index {index} outside the range of the image list") + + self._images[index] = value + + def __delitem__(self, index: int) -> None: + if not isinstance(index, int): + raise TypeError("The index must be an integer") + if not (0 <= index < len(self._images)): + raise IndexError(f"Index {index} outside the range of the image list") + del self._images[index] + + def __contains__(self, item: Image.Image) -> bool: + if not isinstance(item, Image.Image): + raise TypeError("Verification is only possible for objects of the PIL.Image type.Image") + return item in self._images + + def __iter__(self): + return iter(self._images) + + def __call__(self, + layout: Union[str, LayoutMode] = "row", + spacing: int = 10, + bg_color: str = "white", + grid_cols: Optional[int] = None, + grid_rows: Optional[int] = None) -> Image.Image: + + return self.united_images(layout=layout, + spacing=spacing, + bg_color=bg_color, + grid_cols=grid_cols, + grid_rows=grid_rows) + + # Validation and conversion + @staticmethod + def _validate_path(path): + if not isinstance(path, (str, Path)): + raise TypeError("images_path must be a str or Path") + return Path(path) + + @staticmethod + def _validate_border_size(border_size): + if isinstance(border_size, int): + return (border_size,) * 4 + if isinstance(border_size, (tuple, list)) and len(border_size) == 4: + return tuple(border_size) + if border_size is None: + return (0,) * 4 + raise TypeError("border_size must be int, tuple of 4 elements, or None") + + @staticmethod + def _validate_color(color): + if isinstance(color, str): + return color + if isinstance(color, tuple) and all(isinstance(c, int) for c in color): + return color + raise TypeError("Color must be a string or tuple of ints") + + @staticmethod + def _validate_tuple_pair(value, name): + if isinstance(value, tuple) and len(value) == 2: + return value + raise TypeError(f"{name} must be a tuple of length 2") + + @staticmethod + def _validate_font_family(font_family): + font_files = sorted([f.stem for f in Path("./fonts/").glob('*.ttf') if f.is_file()]) + + if font_family in font_files: + return font_family + + return "Arial" + + @classmethod + def from_images(cls, images: List[Image.Image], **kwargs): + obj = cls(images_path='.', **kwargs) + obj._images = images + return obj + + # Properties with setters and getters + @property + def images_path(self): + return self._images_path + + @images_path.setter + def images_path(self, value): + self._images_path = self._validate_path(value) + + @property + def border_size(self): + return self._border_size + + @border_size.setter + def border_size(self, value): + self._border_size = self._validate_border_size(value) + + @property + def border_fill(self): + return self._border_fill + + @border_fill.setter + def border_fill(self, value): + self._border_fill = self._validate_color(value) + + @property + def signature(self): + return self._signature + + @signature.setter + def signature(self, value): + if not isinstance(value, bool): + raise TypeError("signature must be a boolean") + self._signature = value + + @property + def signature_label(self): + return self._signature_label + + @signature_label.setter + def signature_label(self, value): + if not (isinstance(value, (str, tuple, LabelMode)) or value is None): + raise TypeError("signature_label must be str, tuple, LabelMode or None") + self._signature_label = value + + @property + def signature_label_color(self): + return self._signature_label_color + + @signature_label_color.setter + def signature_label_color(self, value): + self._signature_label_color = self._validate_color(value) + + @property + def signature_pos(self): + return self._signature_pos + + @signature_pos.setter + def signature_pos(self, value): + if not isinstance(value, (SignaturePosition, str)): + raise TypeError("signature_pos must be a SignaturePosition or string") + self._signature_pos = value + + @property + def signature_size(self): + return self._signature_size + + @signature_size.setter + def signature_size(self, value): + self._signature_size = self._validate_tuple_pair(value, "signature_size") + + @property + def signature_color(self): + return self._signature_color + + @signature_color.setter + def signature_color(self, value): + self._signature_color = self._validate_color(value) + + @property + def draw_axis(self): + return self._draw_axis + + @draw_axis.setter + def draw_axis(self, value): + if not isinstance(value, bool): + raise TypeError("draw_axis must be a boolean") + self._draw_axis = value + + @property + def axis_labels(self): + return self._axis_labels + + @axis_labels.setter + def axis_labels(self, value): + if isinstance(value, tuple) and len(value) == 2: + self._axis_labels = value + else: + raise TypeError("axis_labels must be a tuple of two strings or tuples") + + @property + def axis_offset(self): + return self._axis_offset + + @axis_offset.setter + def axis_offset(self, value): + if not isinstance(value, (int, tuple)): + raise TypeError("axis_offset must be an integer") + self._axis_offset = value + + @property + def axis_length(self): + return self._axis_length + + @axis_length.setter + def axis_length(self, value): + if not isinstance(value, int): + raise TypeError("axis_length must be an integer") + self._axis_length = value + + @property + def axis_width(self): + return self._axis_width + + @axis_width.setter + def axis_width(self, value): + if not isinstance(value, int): + raise TypeError("axis_width must be an integer") + self._axis_width = value + + @property + def signature_font_size(self): + return self._signature_font_size + + @signature_font_size.setter + def signature_font_size(self, value): + if not isinstance(value, int): + raise TypeError("signature_font_size must be an integer") + self._signature_font_size = value + + + @property + def axis_font_size(self): + return self._axis_font_size + + @axis_font_size.setter + def axis_font_size(self, value): + if not isinstance(value, int): + raise TypeError("axis_font_size must be an integer") + self._axis_font_size = value + + @property + def font_family(self): + return self._font_family + + @font_family.setter + def font_family(self, value): + if not isinstance(value, str): + raise TypeError("font_family must be a string") + + self._font_family = self._validate_font_family(value) + + # Assistant methods + def _load_images(self, folder) -> List[Image.Image]: + """ + Loads all image files from the specified folder with supported extensions. + + This method searches for files with `.png`, `.jpg`, and `.jpeg` extensions in the given folder, + opens them using PIL, and returns a list of loaded images. + + Args: + folder (Union[str, Path]): Path to the folder containing the images. + + Returns: + List[PIL.Image.Image]: A list of loaded images. + + Notes: + - Only files with extensions "*.png", "*.jpg", "*.jpeg" (case-sensitive) are loaded. + - If the folder is empty or contains no supported image formats, an empty list is returned. + - The input path is internally converted to `Path` using `pathlib`. + + Raises: + FileNotFoundError: If the specified folder does not exist. + PIL.UnidentifiedImageError: If an image file cannot be opened by PIL. + """ + + folder = Path(folder) + images = [] + for ext in ("*.png", "*.jpg", "*.jpeg"): + images.extend([Image.open(p) for p in folder.glob(ext)]) + + return images + + def _resize_proportional(self, + img: Image.Image, + width: int = None, + height: int = None) -> Image.Image: + """ + Resize an image proportionally based on the specified width or height. + + This method adjusts the image size while preserving its aspect ratio + if only `width` or `height` is provided. If both `width` and `height` are + given, the image is resized exactly to that size (aspect ratio may be distorted). + If neither is provided, the original image is returned unchanged. + + Args: + img (PIL.Image.Image): The input image to be resized. + width (int, optional): Target width. If specified alone, height will be adjusted proportionally. + height (int, optional): Target height. If specified alone, width will be adjusted proportionally. + + Returns: + PIL.Image.Image: A resized image according to the specified dimensions. + """ + w, h = img.size + + if width and not height: + ratio = width / w + new_size = (width, int(h * ratio)) + elif height and not width: + ratio = height / h + new_size = (int(w * ratio), height) + elif width and height: + new_size = (width, height) + else: + return img + + return img.resize(new_size, Image.LANCZOS) + + def append(self, image: Image.Image): + if not isinstance(image, Image.Image): + raise TypeError("The value must be an instance of PIL.Image.Image") + self._images.append(image) + + @abstractmethod + def _draw_border(self): pass + + @abstractmethod + def _add_numbering(self, label: Optional[str]): pass + + @abstractmethod + def _add_axes(self, label_x: str, label_y: str): pass + + @abstractmethod + def _layout_images(self, + layout: Union[str, LayoutMode], + spacing: int, + bg_color: str, + cols: Optional[int], + rows: Optional[int]): pass + + # The implementer method + @abstractmethod + def preprocessing_image(self, + index: int, + width: int = None, + height: int = None): pass + + @abstractmethod + def united_images(self, + layout: Union[str, LayoutMode] = "row", + spacing: int = 10, + bg_color: str = "white", + grid_cols: Optional[int] = None, + grid_rows: Optional[int] = None, + width: int = None, + height: int = None): pass + + + \ No newline at end of file diff --git a/image_processing/__init__.py b/image_processing/__init__.py index 798a1ab..3502538 100644 --- a/image_processing/__init__.py +++ b/image_processing/__init__.py @@ -1,9 +1,11 @@ from __future__ import annotations from image_processing.enumerates import * from image_processing.imageDesign import ImagesDesign +from image_processing.DrawioProcessing import DrawioImageDesign __all__ = ["LayoutMode", "LabelMode", "SignaturePosition", - "ImagesDesign",] \ No newline at end of file + "ImagesDesign", + "DrawioImageDesign"] \ No newline at end of file diff --git a/image_processing/imageDesign.py b/image_processing/imageDesign.py index ceee5c5..6e97a87 100644 --- a/image_processing/imageDesign.py +++ b/image_processing/imageDesign.py @@ -4,46 +4,10 @@ from PIL import Image, ImageOps, ImageDraw, ImageFont from image_processing.enumerates import * - - -def to_roman(n: int) -> str: - val = [ - 1000, 900, 500, 400, - 100, 90, 50, 40, - 10, 9, 5, 4, 1 - ] - syms = [ - 'M', 'CM', 'D', 'CD', - 'C', 'XC', 'L', 'XL', - 'X', 'IX', 'V', 'IV', 'I' - ] - roman = '' - for i in range(len(val)): - count = n // val[i] - roman += syms[i] * count - n -= val[i] * count - return roman - - -def get_label(index: int, mode: str | LabelMode = LabelMode.CYRILLIC_LOWER) -> str: - mode = mode.value if isinstance(mode, LabelMode) else mode - - match mode: - case "latin_lower": return chr(ord('a') + index) - case "latin_upper": return chr(ord('A') + index) - case "cyrillic_lower" | "cyrillic_upper": - base = ord('а') if mode == "cyrillic_lower" else ord('А') - if index >= 32: - raise ValueError(f"Индекс {index} выходит за пределы кириллического алфавита") - return chr(base + index) - case "arabic": return str(index + 1) - case "roman": return to_roman(index + 1) - case _: - available = ", ".join(m.value for m in LabelMode) - raise ValueError(f"Неверный режим: '{mode}'. Доступные режимы: {available}") +from image_processing.ImageProcessing import get_label, ImageProcessing -class ImagesDesign: +class ImagesDesign(ImageProcessing): def __init__(self, images_path: Union[str, Path], @@ -62,389 +26,43 @@ def __init__(self, axis_length: int = 60, axis_width: int = 3, axis_font_size: int = 24, - font_family: str = "./fonts/Arial.ttf", + font_family: str = "Arial", ): - """ - Initialize the ImagesDesign class for processing and composing images with optional - borders, labels, and axis annotations. - - Args: - images_path (Union[str, Path]): Path to the folder containing image files (PNG, JPG, JPEG). - border_size (Union[int, Tuple[int, int, int, int], None], optional): - Border size around each image. Can be: - - int: uniform border on all sides, - - tuple: (left, top, right, bottom), - - None: no border. - Defaults to 10. - border_fill (Union[str, Tuple[int, int, int]], optional): - Color of the border. Can be a string color name (e.g., "black") or an RGB tuple. Defaults to "black". - signature (bool, optional): Whether to add a label/numbering on each image. Defaults to True. - signature_label (Union[str, Tuple[str], LabelMode, None], optional): - Labeling mode. Can be: - - str: mode name ("latin_lower", "roman", etc.), - - tuple of strings: custom labels per image, - - LabelMode enum, - - None: no labels. - Defaults to "latin_lower". - signature_label_color (str, optional): Color of the label text. Defaults to "white". - signature_pos (SignaturePosition, optional): Position of the label on the image (top-left, bottom-right, etc.). Defaults to SignaturePosition.TOP_LEFT. - signature_size (Tuple[int, int], optional): Size of the label box (width, height). Defaults to (40, 40). - signature_color (str, optional): Background color of the label box. Defaults to "black". - signature_font_size (int, optional): Font size for labels annotations. Defaults to 24. - draw_axis (bool, optional): Whether to draw X and Y axes on each image. Defaults to False. - axis_labels (Union[Tuple[str, str], Tuple[Tuple[str], Tuple[str]]], optional): - Labels for X and Y axes. Can be: - - Tuple of two strings: global labels, - - Tuple of two tuples: per-image labels. - Defaults to ("X", "Y"). - axis_offset (int, optional): Distance in pixels from the image edge to the axis origin. Defaults to 20. - axis_length (int, optional): Length of the drawn axes in pixels. Defaults to 60. - axis_font_size (int, optional): Font size for axis annotations. Defaults to 24. - - Raises: - TypeError: If any of the arguments are of incorrect type. - ValueError: If label or axis settings are out of bounds or improperly defined. - """ - - self._signature_font_size = signature_font_size - self._axis_font_size = axis_font_size - self._font_family = font_family - - self.signature = signature - self.draw_axis = draw_axis - self.images_path = images_path - self.border_size = border_size - self.border_fill = border_fill - self.axis_labels = axis_labels - self.axis_offset = axis_offset - self.axis_length = axis_length - self.axis_width = axis_width - self.signature_pos = signature_pos - self.signature_size = signature_size - self.signature_color = signature_color - self.signature_label = signature_label - self.signature_label_color = signature_label_color + super().__init__(images_path, + border_size, + border_fill, + signature, + signature_label, + signature_label_color, + signature_pos, + signature_size, + signature_color, + signature_font_size, + draw_axis, + axis_labels, + axis_offset, + axis_length, + axis_width, + axis_font_size, + font_family) self._load_fonts() self._images = self._load_images(self.images_path) - # Magic methods - def __str__(self) -> str: - return ( - f"ImagesDesign(\n" - f" images_path={self.images_path},\n" - f" border_size={self.border_size}, border_fill={self.border_fill},\n" - f" signature={self.signature}, signature_label={self.signature_label},\n" - f" signature_pos={self.signature_pos}, signature_size={self.signature_size},\n" - f" signature_font_size={self._signature_font_size},\n" - f" draw_axis={self.draw_axis}, axis_labels={self.axis_labels},\n" - f" axis_offset={self.axis_offset}, axis_length={self.axis_length},\n" - f" axis_font_size={self._axis_font_size}\n" - f")" - ) - - def __repr__(self) -> str: - return ( - f"ImagesDesign(images_path={repr(self.images_path)}, " - f"border_size={repr(self.border_size)}, border_fill={repr(self.border_fill)}, " - f"signature={self.signature}, signature_label={repr(self.signature_label)}, " - f"signature_label_color={repr(self.signature_label_color)}, " - f"signature_pos={repr(self.signature_pos)}, signature_size={repr(self.signature_size)}, " - f"signature_font_size={self._signature_font_size}, signature_color={repr(self.signature_color)}, " - f"draw_axis={self.draw_axis}, axis_labels={repr(self.axis_labels)}, " - f"axis_offset={self.axis_offset}, axis_length={self.axis_length}, axis_font_size={self._axis_font_size})" - f"" - ) - - - def __len__(self) -> int: - return len(self._images) - - def __getitem__(self, index: int) -> Image.Image: - if not isinstance(index, int): - raise TypeError("The index must be an integer") - return self._images[index] - - def __setitem__(self, index: int, value: Image.Image) -> None: - if not isinstance(index, int): - raise TypeError("The index must be an integer") - if not isinstance(value, Image.Image): - raise TypeError("The value must be an instance of PIL.Image.Image") - if not (0 <= index < len(self._images)): - raise IndexError(f"Index {index} outside the range of the image list") - - self._images[index] = value - - def __delitem__(self, index: int) -> None: - if not isinstance(index, int): - raise TypeError("The index must be an integer") - if not (0 <= index < len(self._images)): - raise IndexError(f"Index {index} outside the range of the image list") - del self._images[index] - - def __contains__(self, item: Image.Image) -> bool: - if not isinstance(item, Image.Image): - raise TypeError("Verification is only possible for objects of the PIL.Image type.Image") - return item in self._images - - def __iter__(self): - return iter(self._images) - - def __call__(self, - layout: Union[str, LayoutMode] = "row", - spacing: int = 10, - bg_color: str = "white", - grid_cols: Optional[int] = None, - grid_rows: Optional[int] = None) -> Image.Image: - - return self.united_images(layout=layout, - spacing=spacing, - bg_color=bg_color, - grid_cols=grid_cols, - grid_rows=grid_rows) - - # Validation and conversion - @staticmethod - def _validate_path(path): - if not isinstance(path, (str, Path)): - raise TypeError("images_path must be a str or Path") - return Path(path) - - @staticmethod - def _validate_border_size(border_size): - if isinstance(border_size, int): - return (border_size,) * 4 - if isinstance(border_size, (tuple, list)) and len(border_size) == 4: - return tuple(border_size) - if border_size is None: - return (0,) * 4 - raise TypeError("border_size must be int, tuple of 4 elements, or None") - - @staticmethod - def _validate_color(color): - if isinstance(color, str): - return color - if isinstance(color, tuple) and all(isinstance(c, int) for c in color): - return color - raise TypeError("Color must be a string or tuple of ints") - - @staticmethod - def _validate_tuple_pair(value, name): - if isinstance(value, tuple) and len(value) == 2: - return value - raise TypeError(f"{name} must be a tuple of length 2") - - # Properties with setters and getters - @property - def images_path(self): - return self._images_path - - @images_path.setter - def images_path(self, value): - self._images_path = self._validate_path(value) - - @property - def border_size(self): - return self._border_size - - @border_size.setter - def border_size(self, value): - self._border_size = self._validate_border_size(value) - - @property - def border_fill(self): - return self._border_fill - - @border_fill.setter - def border_fill(self, value): - self._border_fill = self._validate_color(value) - - @property - def signature(self): - return self._signature - - @signature.setter - def signature(self, value): - if not isinstance(value, bool): - raise TypeError("signature must be a boolean") - self._signature = value - - @property - def signature_label(self): - return self._signature_label - - @signature_label.setter - def signature_label(self, value): - if not (isinstance(value, (str, tuple, LabelMode)) or value is None): - raise TypeError("signature_label must be str, tuple, LabelMode or None") - self._signature_label = value - - @property - def signature_label_color(self): - return self._signature_label_color - - @signature_label_color.setter - def signature_label_color(self, value): - self._signature_label_color = self._validate_color(value) - - @property - def signature_pos(self): - return self._signature_pos - - @signature_pos.setter - def signature_pos(self, value): - if not isinstance(value, (SignaturePosition, str)): - raise TypeError("signature_pos must be a SignaturePosition or string") - self._signature_pos = value - - @property - def signature_size(self): - return self._signature_size - - @signature_size.setter - def signature_size(self, value): - self._signature_size = self._validate_tuple_pair(value, "signature_size") - - @property - def signature_color(self): - return self._signature_color - - @signature_color.setter - def signature_color(self, value): - self._signature_color = self._validate_color(value) - - @property - def draw_axis(self): - return self._draw_axis - - @draw_axis.setter - def draw_axis(self, value): - if not isinstance(value, bool): - raise TypeError("draw_axis must be a boolean") - self._draw_axis = value - - @property - def axis_labels(self): - return self._axis_labels - - @axis_labels.setter - def axis_labels(self, value): - if isinstance(value, tuple) and len(value) == 2: - self._axis_labels = value - else: - raise TypeError("axis_labels must be a tuple of two strings or tuples") - - @property - def axis_offset(self): - return self._axis_offset - - @axis_offset.setter - def axis_offset(self, value): - if not isinstance(value, (int, tuple)): - raise TypeError("axis_offset must be an integer") - self._axis_offset = value - - @property - def axis_length(self): - return self._axis_length - - @axis_length.setter - def axis_length(self, value): - if not isinstance(value, int): - raise TypeError("axis_length must be an integer") - self._axis_length = value - - @property - def axis_width(self): - return self._axis_width - - @axis_width.setter - def axis_width(self, value): - if not isinstance(value, int): - raise TypeError("axis_width must be an integer") - self._axis_width = value - - @property - def signature_font_size(self): - return self._signature_font_size - - @signature_font_size.setter - def signature_font_size(self, value): - if not isinstance(value, int): - raise TypeError("signature_font_size must be an integer") - self._signature_font_size = value - self._load_fonts() - - @property - def axis_font_size(self): - return self._axis_font_size - - @axis_font_size.setter - def axis_font_size(self, value): - if not isinstance(value, int): - raise TypeError("axis_font_size must be an integer") - self._axis_font_size = value - self._load_fonts() - - @property - def font_family(self): - return self._font_family - - @font_family.setter - def font_family(self, value): - if not isinstance(value, str): - raise TypeError("font_family must be a string") - self._font_family = value - self._load_fonts() # Assistant methods def _load_fonts(self): try: - self._signature_font = ImageFont.truetype(self._font_family, self._signature_font_size) + self._signature_font = ImageFont.truetype(f"./fonts/{self._font_family}.ttf", self._signature_font_size) except IOError: + print("error") self._signature_font = ImageFont.load_default() try: - self._axis_font = ImageFont.truetype(self._font_family, self._axis_font_size) + self._axis_font = ImageFont.truetype(f"./fonts/{self._font_family}.ttf", self._axis_font_size) except IOError: self._axis_font = ImageFont.load_default() - - @classmethod - def from_images(cls, images: List[Image.Image], **kwargs): - obj = cls(images_path='.', **kwargs) - obj._images = images - return obj - - def _load_images(self, folder) -> List[Image.Image]: - """ - Loads all image files from the specified folder with supported extensions. - - This method searches for files with `.png`, `.jpg`, and `.jpeg` extensions in the given folder, - opens them using PIL, and returns a list of loaded images. - - Args: - folder (Union[str, Path]): Path to the folder containing the images. - - Returns: - List[PIL.Image.Image]: A list of loaded images. - - Notes: - - Only files with extensions "*.png", "*.jpg", "*.jpeg" (case-sensitive) are loaded. - - If the folder is empty or contains no supported image formats, an empty list is returned. - - The input path is internally converted to `Path` using `pathlib`. - - Raises: - FileNotFoundError: If the specified folder does not exist. - PIL.UnidentifiedImageError: If an image file cannot be opened by PIL. - """ - - folder = Path(folder) - images = [] - for ext in ("*.png", "*.jpg", "*.jpeg"): - images.extend([Image.open(p) for p in folder.glob(ext)]) - - return images def _draw_border(self, image: Image.Image) -> Image.Image: """ @@ -662,41 +280,6 @@ def _layout_images(self, raise ValueError("layout должен быть 'row', 'column' или 'grid'") - def _resize_proportional(self, - img: Image.Image, - width: int = None, - height: int = None) -> Image.Image: - """ - Resize an image proportionally based on the specified width or height. - - This method adjusts the image size while preserving its aspect ratio - if only `width` or `height` is provided. If both `width` and `height` are - given, the image is resized exactly to that size (aspect ratio may be distorted). - If neither is provided, the original image is returned unchanged. - - Args: - img (PIL.Image.Image): The input image to be resized. - width (int, optional): Target width. If specified alone, height will be adjusted proportionally. - height (int, optional): Target height. If specified alone, width will be adjusted proportionally. - - Returns: - PIL.Image.Image: A resized image according to the specified dimensions. - """ - w, h = img.size - - if width and not height: - ratio = width / w - new_size = (width, int(h * ratio)) - elif height and not width: - ratio = height / h - new_size = (int(w * ratio), height) - elif width and height: - new_size = (width, height) - else: - return img - - return img.resize(new_size, Image.LANCZOS) - # The implementer method def preprocessing_image(self, index: int, @@ -727,6 +310,7 @@ def preprocessing_image(self, if index >= len(self._images): raise IndexError(f"Index {index} outside the range of the image list") + self._load_fonts() valid_modes = {m.value for m in LabelMode} img = self._images[index] if width and height: @@ -810,8 +394,5 @@ def united_images(self, return self._layout_images(images, layout, spacing, bg_color, grid_cols, grid_rows) - def append(self, image: Image.Image): - if not isinstance(image, Image.Image): - raise TypeError("The value must be an instance of PIL.Image.Image") - self._images.append(image) + \ No newline at end of file diff --git a/pages/image_processing_page.py b/pages/image_processing_page.py index d8616a5..0f5c499 100644 --- a/pages/image_processing_page.py +++ b/pages/image_processing_page.py @@ -5,7 +5,7 @@ from pathlib import Path from typing import Tuple, Union from tempfile import TemporaryDirectory -from image_processing import ImagesDesign +from image_processing import ImagesDesign, SignaturePosition, LabelMode, LayoutMode # Хранилище временных параметров компоновки united_params = { @@ -22,7 +22,7 @@ united_controls = {} # Допустимые значения layout -valid_layouts = {'row', 'column', 'grid'} +valid_layouts = set([mode.value for mode in LayoutMode]) tmp_dir = TemporaryDirectory() design = ImagesDesign(images_path=tmp_dir.name) @@ -30,8 +30,8 @@ # Список шрифтов font_dir = Path('./fonts') font_files = sorted([f.stem for f in font_dir.glob('*.ttf') if f.is_file()]) -signature_label_options = ['cyrillic_lower', 'cyrillic_upper', 'latin_lower', 'latin_upper', 'roman'] -signature_pos_options = ['top-left', 'top-right', 'bottom-left', 'bottom-right'] +signature_label_options = [mode.value for mode in LabelMode] +signature_pos_options = [mode.value for mode in SignaturePosition] def image_processing_page(): with ui.column().classes('w-full items-center justify-center gap-4'): @@ -65,7 +65,7 @@ def safe_int(val, default=0): return default ui.input('Размер рамки', value=str(design.border_size), - on_change=lambda e: update_param('border_size', safe_int(e.value), image_slot)).props('type=number') + on_change=lambda e: update_param('border_size', safe_int(e.value), image_slot)).props('type=number min=0') ui.color_input(label='Цвет рамки', value='#000000', on_change=lambda e: update_param('border_fill', e.value, image_slot)) @@ -80,37 +80,33 @@ def safe_int(val, default=0): label='Позиция подписи', on_change=lambda e: update_param('signature_pos', e.value, image_slot)) ui.input('Размер подписи (ширина)', value=str(design.signature_size[0]), - on_change=lambda e: update_param('signature_size', (safe_int(e.value), design.signature_size[1]), image_slot)).props('type=number') + on_change=lambda e: update_param('signature_size', (safe_int(e.value), design.signature_size[1]), image_slot)).props('type=number min=0') ui.input('Размер подписи (высота)', value=str(design.signature_size[1]), - on_change=lambda e: update_param('signature_size', (design.signature_size[0], safe_int(e.value)), image_slot)).props('type=number') + on_change=lambda e: update_param('signature_size', (design.signature_size[0], safe_int(e.value)), image_slot)).props('type=number min=0') ui.color_input(label='Цвет подписи (фон)', value='#000', on_change=lambda e: update_param('signature_color', e.value, image_slot)) ui.input('Размер шрифта подписи', value=str(design.signature_font_size), - on_change=lambda e: update_param('signature_font_size', safe_int(e.value), image_slot)).props('type=number') + on_change=lambda e: update_param('signature_font_size', safe_int(e.value), image_slot)).props('type=number min=3') ui.checkbox('Показывать оси', value=design.draw_axis, on_change=lambda e: update_param('draw_axis', e.value, image_slot)) - ui.input('Подписи оси X', value=design.axis_labels[0] if isinstance(design.axis_labels[0], str) else ','.join(design.axis_labels[0]), on_change=lambda e: update_axis_labels('x', e.value, image_slot)) ui.input('Подписи оси Y', value=design.axis_labels[1] if isinstance(design.axis_labels[1], str) else ','.join(design.axis_labels[1]), on_change=lambda e: update_axis_labels('y', e.value, image_slot)) - ui.input('Смещение по X', value=str(design.axis_offset[0] if isinstance(design.axis_offset, tuple) else design.axis_offset), - on_change=lambda e: update_axis_offset('x', e.value, image_slot)).props('type=number') - + on_change=lambda e: update_axis_offset('x', e.value, image_slot)).props('type=number min=0') ui.input('Смещение по Y', value=str(design.axis_offset[1] if isinstance(design.axis_offset, tuple) else design.axis_offset), - on_change=lambda e: update_axis_offset('y', e.value, image_slot)).props('type=number') - + on_change=lambda e: update_axis_offset('y', e.value, image_slot)).props('type=number min=0') ui.input('Длина осей', value=str(design.axis_length), - on_change=lambda e: update_param('axis_length', safe_int(e.value), image_slot)).props('type=number') + on_change=lambda e: update_param('axis_length', safe_int(e.value), image_slot)).props('type=number min=1') ui.input('Толщина осей', value=str(design.axis_width), - on_change=lambda e: update_param('axis_width', safe_int(e.value), image_slot)).props('type=number') + on_change=lambda e: update_param('axis_width', safe_int(e.value), image_slot)).props('type=number min=1') ui.input('Размер шрифта осей', value=str(design.axis_font_size), - on_change=lambda e: update_param('axis_font_size', safe_int(e.value), image_slot)).props('type=number') - ui.select(font_files or ['Arial'], value=Path(design.font_family).stem, + on_change=lambda e: update_param('axis_font_size', safe_int(e.value), image_slot)).props('type=number min=3') + ui.select(font_files or ['Arial'], value=design.font_family, label='Шрифт', - on_change=lambda e: update_param('font_family', f'./fonts/{e.value}.ttf', image_slot)) + on_change=lambda e: update_param('font_family', e.value, image_slot)) with ui.expansion('Параметры компоновки', icon='grid_on'): with ui.grid(columns=4).classes('gap-4 w-full'): From 6237ea7a1590f8b359b274de58bc011220f3c635 Mon Sep 17 00:00:00 2001 From: Daniel Robotics Date: Thu, 15 May 2025 18:27:32 +1000 Subject: [PATCH 2/7] Write new `DrawioImageDesign` --- .$result.drawio.dtmp | 94 ---------- .gitignore | 1 + image_processing/DrawioProcessing.py | 266 +++++++++------------------ image_processing/ImageProcessing.py | 25 +-- 4 files changed, 98 insertions(+), 288 deletions(-) delete mode 100644 .$result.drawio.dtmp diff --git a/.$result.drawio.dtmp b/.$result.drawio.dtmp deleted file mode 100644 index 6a915ea..0000000 --- a/.$result.drawio.dtmp +++ /dev/null @@ -1,94 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/.gitignore b/.gitignore index 32099c1..aed64c1 100644 --- a/.gitignore +++ b/.gitignore @@ -11,4 +11,5 @@ images/ test_code.py .pytest_cache *.drawio +*.bkp diff --git a/image_processing/DrawioProcessing.py b/image_processing/DrawioProcessing.py index 99cd5d2..3654256 100644 --- a/image_processing/DrawioProcessing.py +++ b/image_processing/DrawioProcessing.py @@ -1,4 +1,8 @@ -import io, math, uuid, base64, xml.etree.ElementTree as ET +import io +import math +import uuid +import base64 +import xml.etree.ElementTree as ET from pathlib import Path from typing import Optional, Union @@ -8,182 +12,96 @@ class DrawioImageDesign(ImageProcessing): - # ───────────────────────────────────────────────────────── # - # INIT - # ───────────────────────────────────────────────────────── # + def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) - self._images = self._load_images(self.images_path) + self._images = self._load_images(self._images_path) + + self._images = self._load_images(self._images_path) + + self._root = None + self._xml_root = None + self._create_drawio_structure() + + # Методы родительского класса + def _draw_border(self): + return f"imageBorder={self._border_fill};strokeWidth={max(self._border_size)};" + + def _add_numbering(self): pass + + def _add_axes(self): pass + + def _layout_images(self): pass + + def preprocessing_image(self, + index: int, + width: int = None, + height: int = None, + position_x: int = 0, + position_y: int = 0): + + if index >= len(self._images): + raise IndexError(f"Index {index} outside the range of the image list") + + image = self._resize_proportional(self._images[index], width, height) + image_w, image_h = image.size + + image_base64 = self._image_to_base64(image) + cell_id = self._generate_id(suffix=f"-{index+1}") + + style = ("shape=image;", + "verticalLabelPosition=bottom;", + "labelBackgroundColor=default;", + "verticalAlign=top;", + "aspect=fixed;", + "imageAspect=0;", + f"image=data:image/png,{image_base64};", + self._draw_border() + ) + + mxCell = ET.SubElement(self._xml_root, "mxCell", + id=cell_id, value="", + style="".join(style), + vertex="1", parent="1" + ) + mxGeometry = ET.SubElement(mxCell, "mxGeometry", + x=str(position_x), + y=str(position_y), + width=str(image_w), + height=str(image_h)) + mxGeometry.set("as", "geometry") + + def united_images(self): pass + + # методы этого класса + def _create_drawio_structure(self): + self._root = ET.Element("mxfile", host="ScienceHelper") + + diagram_id = self._generate_id(prefix="", suffix="") + diagram = ET.SubElement(self._root, "diagram", + name="Обработчик изображений", + id=diagram_id) + + model = ET.SubElement(diagram, "mxGraphModel") + self._xml_root = ET.SubElement(model, "root") + + ET.SubElement(self._xml_root, "mxCell", id="0") + ET.SubElement(self._xml_root, "mxCell", id="1", parent="0") - # ───────────────────────────────────────────────────────── # - # HELPERS - # ───────────────────────────────────────────────────────── # @staticmethod - def _pil_to_b64(img: Image.Image) -> str: - buf = io.BytesIO() - img.save(buf, format="PNG") - return base64.b64encode(buf.getvalue()).decode("ascii") + def _generate_id(prefix: str = "E__", + suffix: str = "-1") -> str: + uid = uuid.uuid4().bytes[:9] + base64_id = base64.urlsafe_b64encode(uid).decode("ascii").rsplit("=") + return f"{prefix}{base64_id[0]}{suffix}" @staticmethod - def _add_geom(cell: ET.Element, **coords): - g = ET.SubElement(cell, "mxGeometry", **{k: str(v) for k, v in coords.items()}) - g.set("as", "geometry") - return g - - # толщина + цвет рамки - def _border_params(self) -> tuple[int, str]: - if isinstance(self.border_size, int): - bw = self.border_size - else: # (l,t,r,b) → max - bw = max(self.border_size) - color = (('#%02x%02x%02x' % self.border_fill) # RGB → #rrggbb - if isinstance(self.border_fill, tuple) - else self.border_fill) - return bw, color - - # ───────────────────────────────────────────────────────── # - # PRE-PROCESS (без прорисовки рамки!) - # ───────────────────────────────────────────────────────── # - def _draw_border(self, img: Image.Image) -> Image.Image: - return img # рамку добавит draw.io - - def preprocessing_image(self, idx: int, - width: int | None = None, - height: int | None = None) -> Image.Image: - img = self._images[idx] - return self._resize_proportional(img, width, height) - - # ───────────────────────────────────────────────────────── # - # LAYOUT - # ───────────────────────────────────────────────────────── # - def _layout_images(self, layout: Union[str, LayoutMode], - spacing: int, - cols: Optional[int], - rows: Optional[int]) -> list[tuple[int, int]]: - layout = layout.value if isinstance(layout, LayoutMode) else layout - pos: list[tuple[int, int]] = [] - - if layout == "row": - x = y = 0 - for im in self._images: - pos.append((x, y)) - x += im.width + spacing - - elif layout == "column": - x = y = 0 - for im in self._images: - pos.append((x, y)) - y += im.height + spacing - - elif layout == "grid": - cols = cols or math.ceil(math.sqrt(len(self._images))) - img_w = self._images[0].width + spacing - img_h = self._images[0].height + spacing - for i, _ in enumerate(self._images): - pos.append(((i % cols) * img_w, - (i // cols) * img_h)) - else: - raise ValueError(f"Unknown layout: {layout}") - - return pos - - # ───────────────────────────────────────────────────────── # - # LABEL & AXES (внутри группы) - # ───────────────────────────────────────────────────────── # - def _add_numbering(self, xml_root: ET.Element, parent_id: str, - text: str, dx: float, dy: float): - lbl = ET.SubElement(xml_root, "mxCell", - id=str(uuid.uuid4()), value=text, - style=f"shape=label;align=left;verticalAlign=top;" - f"fontSize={self.signature_font_size};" - f"fontColor={self.signature_label_color};" - f"fillColor=none;strokeColor=none;", - vertex="1", parent=parent_id) - self._add_geom(lbl, x=dx, y=dy, - width=self.signature_size[0], - height=self.signature_size[1]) - - def _add_axes(self, xml_root: ET.Element, parent_id: str, - w: int, h: int, - label_x: str, label_y: str): - off_x, off_y = (self.axis_offset if isinstance(self.axis_offset, tuple) - else (self.axis_offset, self.axis_offset)) - - # линии-стрелки - for _ in range(2): - line = ET.SubElement(xml_root, "mxCell", - id=str(uuid.uuid4()), - style=f"endArrow=block;strokeWidth={self.axis_width};", - edge="1", parent=parent_id) - self._add_geom(line, relative="1") - - # подписи - self._add_numbering(xml_root, parent_id, - label_x, off_x + self.axis_length, h - self.axis_font_size - 4) - self._add_numbering(xml_root, parent_id, - label_y, 4, off_y) - - # ───────────────────────────────────────────────────────── # - # BUILD XML - # ───────────────────────────────────────────────────────── # - def united_images(self, - layout: Union[str, LayoutMode] = "row", - spacing: int = 10, - grid_cols: Optional[int] = None, - grid_rows: Optional[int] = None, - width: int | None = None, - height: int | None = None) -> str: - - root = ET.Element("mxfile", host="app.diagrams.net") - diagram = ET.SubElement(root, "diagram", name="Page-1", id=str(uuid.uuid4())) - model = ET.SubElement(diagram, "mxGraphModel") - xml_root = ET.SubElement(model, "root") - ET.SubElement(xml_root, "mxCell", id="0") - ET.SubElement(xml_root, "mxCell", id="1", parent="0") - - bw, bc = self._border_params() # толщина и цвет рамки - positions = self._layout_images(layout, spacing, grid_cols, grid_rows) - - for idx, (px, py) in enumerate(positions): - img = self.preprocessing_image(idx, width, height) - - # Группа - g_id = str(uuid.uuid4()) - group = ET.SubElement(xml_root, "mxCell", - id=g_id, value="", vertex="1", parent="1") - self._add_geom(group, - x=px, y=py, - width=img.width + bw * 2, - height=img.height + bw * 2) - - # Изображение - style = (f"shape=image;strokeWidth={bw};strokeColor={bc};" - "aspect=fixed;imageAspect=0;" - f"image=data:image/png,{self._pil_to_b64(img)}") - img_cell = ET.SubElement(xml_root, "mxCell", - id=str(uuid.uuid4()), value="", - style=style, vertex="1", parent=g_id) - self._add_geom(img_cell, x=bw, y=bw, - width=img.width, height=img.height) - - # Подпись - if self.signature: - self._add_numbering(xml_root, g_id, - get_label(idx, self.signature_label), - dx=4, dy=4) - - # Оси - if self.draw_axis: - lx = (self.axis_labels[0] if isinstance(self.axis_labels[0], str) - else self.axis_labels[0][idx]) - ly = (self.axis_labels[1] if isinstance(self.axis_labels[1], str) - else self.axis_labels[1][idx]) - self._add_axes(xml_root, g_id, img.width, img.height, lx, ly) - - return ET.tostring(root, encoding="utf-8", xml_declaration=True).decode("utf-8") - - # ───────────────────────────────────────────────────────── # - # EXPORT - # ───────────────────────────────────────────────────────── # - def export_to_drawio(self, file: str | Path, **kwargs): - Path(file).write_text(self.united_images(**kwargs), encoding="utf-8") + def _image_to_base64(image: Image.Image) -> str: + buffer = io.BytesIO() + image.save(buffer, format="PNG") + return base64.b64encode(buffer.getvalue()).decode("ascii") + + + # def export_to_drawio(self, file: str | Path, **kwargs): + # ET.indent(tree, space=" ", level=0) + # Path(file).write_text(self.united_images(**kwargs), encoding="utf-8") diff --git a/image_processing/ImageProcessing.py b/image_processing/ImageProcessing.py index aafebcf..cacc7ac 100644 --- a/image_processing/ImageProcessing.py +++ b/image_processing/ImageProcessing.py @@ -484,35 +484,20 @@ def append(self, image: Image.Image): def _draw_border(self): pass @abstractmethod - def _add_numbering(self, label: Optional[str]): pass + def _add_numbering(self): pass @abstractmethod - def _add_axes(self, label_x: str, label_y: str): pass + def _add_axes(self): pass @abstractmethod - def _layout_images(self, - layout: Union[str, LayoutMode], - spacing: int, - bg_color: str, - cols: Optional[int], - rows: Optional[int]): pass + def _layout_images(self): pass # The implementer method @abstractmethod - def preprocessing_image(self, - index: int, - width: int = None, - height: int = None): pass + def preprocessing_image(self): pass @abstractmethod - def united_images(self, - layout: Union[str, LayoutMode] = "row", - spacing: int = 10, - bg_color: str = "white", - grid_cols: Optional[int] = None, - grid_rows: Optional[int] = None, - width: int = None, - height: int = None): pass + def united_images(self): pass \ No newline at end of file From f58e00f1819da1aa6cbe4bce9b5a64705c21f570 Mon Sep 17 00:00:00 2001 From: Daniel Robotics Date: Fri, 16 May 2025 11:07:13 +1000 Subject: [PATCH 3/7] Added caption of drawings --- image_processing/DrawioProcessing.py | 76 +++++++++++++++++++++++++--- image_processing/ImageProcessing.py | 22 ++++++++ image_processing/imageDesign.py | 21 ++------ 3 files changed, 96 insertions(+), 23 deletions(-) diff --git a/image_processing/DrawioProcessing.py b/image_processing/DrawioProcessing.py index 3654256..4b36ddf 100644 --- a/image_processing/DrawioProcessing.py +++ b/image_processing/DrawioProcessing.py @@ -7,6 +7,7 @@ from typing import Optional, Union from PIL import Image +from xml.sax.saxutils import escape from image_processing.enumerates import * from image_processing.ImageProcessing import ImageProcessing, get_label @@ -27,18 +28,71 @@ def __init__(self, *args, **kwargs): def _draw_border(self): return f"imageBorder={self._border_fill};strokeWidth={max(self._border_size)};" - def _add_numbering(self): pass + def _add_numbering(self, + image_w: int, + image_h: int, + label: str = "", + parent_id: str = "1"): + + x0, y0, x1, y1 = self._get_positions(image_w, image_h) + + + id = parent_id + "numbering" + html_text = ( + f'' + f'{label}' + ) + + style = ("rounded=0;", + "whiteSpace=wrap;", + "html=1;", + "strokeColor=none;", + f"fillColor={self._signature_color};", + f"fontSize={self._signature_font_size};") + + mxCell = ET.SubElement(self._xml_root, "mxCell", + id=id, value=html_text, + style="".join(style), + vertex="1", parent=parent_id + ) + + key = self._signature_pos.value if isinstance(self._signature_pos, SignaturePosition) else self._signature_pos + match key: + case SignaturePosition.TOP_LEFT.value: + x0 = x0 - max(self._border_size) + y0 = y0 - max(self._border_size) + case SignaturePosition.TOP_RIGHT.value: + x0 = x0 + max(self._border_size) + y0 = y0 - max(self._border_size) + case SignaturePosition.BOTTOM_LEFT.value: + x0 = x0 - max(self._border_size) + y0 = y0 + max(self._border_size) + case SignaturePosition.BOTTOM_RIGHT.value: + x0 = x0 + max(self._border_size) + y0 = y0 + max(self._border_size) + + + mxGeometry = ET.SubElement(mxCell, "mxGeometry", + x=str(x0), + y=str(y0), + width=str(self._signature_size[0]), + height=str(self._signature_size[1])) + mxGeometry.set("as", "geometry") + - def _add_axes(self): pass + def _add_axes(self, + parent_id: str = "1"): pass def _layout_images(self): pass def preprocessing_image(self, index: int, - width: int = None, - height: int = None, + width: int | None = None, + height: int | None = None, position_x: int = 0, - position_y: int = 0): + position_y: int = 0, + parent_id: str = "1"): if index >= len(self._images): raise IndexError(f"Index {index} outside the range of the image list") @@ -62,7 +116,7 @@ def preprocessing_image(self, mxCell = ET.SubElement(self._xml_root, "mxCell", id=cell_id, value="", style="".join(style), - vertex="1", parent="1" + vertex="1", parent=parent_id ) mxGeometry = ET.SubElement(mxCell, "mxGeometry", x=str(position_x), @@ -70,6 +124,16 @@ def preprocessing_image(self, width=str(image_w), height=str(image_h)) mxGeometry.set("as", "geometry") + + if self._signature and self._signature_label: + self._add_numbering(image_w=image_w, + image_h=image_h, + label="1", + parent_id=cell_id) + + if self._draw_axis: + self._add_axes() + def united_images(self): pass diff --git a/image_processing/ImageProcessing.py b/image_processing/ImageProcessing.py index cacc7ac..043f464 100644 --- a/image_processing/ImageProcessing.py +++ b/image_processing/ImageProcessing.py @@ -410,6 +410,28 @@ def font_family(self, value): self._font_family = self._validate_font_family(value) # Assistant methods + + def _get_positions(self, + image_w: int, + image_h: int) -> list | tuple: + + rect_w, rect_h = self._signature_size + left, top, right, bottom = self._border_size + positions = { + "top-left": (left, top, left + rect_w, top + rect_h), + "top-right": (image_w - right - rect_w, top, image_w - right, top + rect_h), + "bottom-left": (left, image_h - bottom - rect_h, left + rect_w, image_h - bottom), + "bottom-right": (image_w - right - rect_w, image_h - bottom - rect_h, image_w - right, image_h - bottom), + } + + key = self._signature_pos.value if isinstance(self._signature_pos, SignaturePosition) else self._signature_pos + rect_position = positions.get(key) + + if not rect_position: + raise ValueError("rect_corner должен быть одним из: top-left, top-right, bottom-left, bottom-right") + + return rect_position + def _load_images(self, folder) -> List[Image.Image]: """ Loads all image files from the specified folder with supported extensions. diff --git a/image_processing/imageDesign.py b/image_processing/imageDesign.py index 6e97a87..0e4c383 100644 --- a/image_processing/imageDesign.py +++ b/image_processing/imageDesign.py @@ -116,27 +116,14 @@ def _add_numbering(self, img: Image.Image, label: Optional[str]) -> Image.Image: return img draw = ImageDraw.Draw(img) - w, h = img.size - rect_w, rect_h = self._signature_size - left, top, right, bottom = self._border_size - - positions = { - "top-left": (left, top, left + rect_w, top + rect_h), - "top-right": (w - right - rect_w, top, w - right, top + rect_h), - "bottom-left": (left, h - bottom - rect_w, left + rect_w, h - bottom), - "bottom-right": (w - right - rect_w, h - bottom - rect_h, w - right, h - bottom), - } - - key = self._signature_pos.value if isinstance(self._signature_pos, SignaturePosition) else self._signature_pos - rect = positions.get(key) + image_w, image_h = img.size - if not rect: - raise ValueError("rect_corner должен быть одним из: top-left, top-right, bottom-left, bottom-right") + rect_position = self._get_positions(image_w, image_h) - draw.rectangle(rect, fill=self._signature_color) + draw.rectangle(rect_position, fill=self._signature_color) text_w, text_h = draw.textbbox((0, 0), label, font=self._signature_font)[2:] - x0, y0, x1, y1 = rect + x0, y0, x1, y1 = rect_position cx = x0 + (x1 - x0 - text_w) // 2 cy = y0 + (y1 - y0 - text_h) // 2 draw.text((cx, cy), label, font=self._signature_font, fill=self._signature_label_color) From 5c013869e731f2c5246c28d29e28ebb05f756b77 Mon Sep 17 00:00:00 2001 From: Daniel Robotics Date: Fri, 16 May 2025 11:12:35 +1000 Subject: [PATCH 4/7] Added new tests --- test/test_drawio_image_design.py | 72 ++++++++++++++++++++++++++++++++ 1 file changed, 72 insertions(+) create mode 100644 test/test_drawio_image_design.py diff --git a/test/test_drawio_image_design.py b/test/test_drawio_image_design.py new file mode 100644 index 0000000..77ff8e9 --- /dev/null +++ b/test/test_drawio_image_design.py @@ -0,0 +1,72 @@ +import pytest +import xml.etree.ElementTree as ET +from PIL import Image +from image_processing.enumerates import SignaturePosition +from image_processing import DrawioImageDesign + + +class MockDrawioImageDesign(DrawioImageDesign): + def __init__(self): + # Эмуляция родительского состояния без вызова super().__init__() + self._images_path = "" + self._images = [Image.new("RGB", (320, 270), "white")] + + # Настройки подписи и рамки + self._font_family = "Arial" + self._signature_label_color = "white" + self._signature_color = "black" + self._signature_font_size = 24 + self._signature_size = (40, 40) + self._border_size = (10, 10, 10, 10) + self._border_fill = "#000" + self._signature = True + self._signature_label = True + self._signature_pos = SignaturePosition.TOP_RIGHT + self._draw_axis = False + + # XML-структура + self._root = None + self._xml_root = None + self._create_drawio_structure() + + def _resize_proportional(self, image, width, height): + return image.resize((width, height)) + + def _load_images(self, path): + return self._images + + + +@pytest.fixture +def design(): + return MockDrawioImageDesign() + + +def test_add_numbering_creates_cell(design): + design._add_numbering(image_w=320, image_h=270, label="1", parent_id="testparent") + + cell = next((e for e in design._xml_root if e.attrib.get("id") == "testparentnumbering"), None) + assert cell is not None, "Номерная ячейка не создана" + assert cell.tag == "mxCell" + assert "style" in cell.attrib + assert "value" in cell.attrib + assert "html=1" in cell.attrib["style"] + assert cell.attrib["value"].startswith(' Date: Fri, 16 May 2025 14:45:16 +1000 Subject: [PATCH 5/7] The code for creating the Draw IO file has been added and tests have been added. --- .gitignore | 2 +- image_processing/DrawioProcessing.py | 323 +++++++++++++++++++++------ image_processing/ImageProcessing.py | 26 ++- image_processing/imageDesign.py | 20 +- test/test_drawio_image_design.py | 95 ++++++-- 5 files changed, 350 insertions(+), 116 deletions(-) diff --git a/.gitignore b/.gitignore index aed64c1..75f1cfd 100644 --- a/.gitignore +++ b/.gitignore @@ -12,4 +12,4 @@ test_code.py .pytest_cache *.drawio *.bkp - +*.dtmp diff --git a/image_processing/DrawioProcessing.py b/image_processing/DrawioProcessing.py index 4b36ddf..54b7b31 100644 --- a/image_processing/DrawioProcessing.py +++ b/image_processing/DrawioProcessing.py @@ -1,15 +1,13 @@ import io -import math import uuid import base64 import xml.etree.ElementTree as ET -from pathlib import Path from typing import Optional, Union from PIL import Image -from xml.sax.saxutils import escape +from pathlib import Path from image_processing.enumerates import * -from image_processing.ImageProcessing import ImageProcessing, get_label +from image_processing.ImageProcessing import ImageProcessing class DrawioImageDesign(ImageProcessing): @@ -18,8 +16,6 @@ def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self._images = self._load_images(self._images_path) - self._images = self._load_images(self._images_path) - self._root = None self._xml_root = None self._create_drawio_structure() @@ -34,57 +30,151 @@ def _add_numbering(self, label: str = "", parent_id: str = "1"): - x0, y0, x1, y1 = self._get_positions(image_w, image_h) + x0, y0, _, _ = self._get_positions(image_w, image_h) + offset = max(self._border_size) + key = self._signature_pos.value if isinstance(self._signature_pos, SignaturePosition) else self._signature_pos + + pos_map = { + SignaturePosition.TOP_LEFT.value: (-offset, -offset), + SignaturePosition.TOP_RIGHT.value: (offset, -offset), + SignaturePosition.BOTTOM_LEFT.value: (-offset, offset), + SignaturePosition.BOTTOM_RIGHT.value: (offset, offset) + } + + dx, dy = pos_map.get(key, (0, 0)) + x0 += dx + y0 += dy + + cell = self._create_mx_cell( + id=self._generate_id(suffix="-numbering"), + value=self._get_numbering_text(label), + style=self._get_numbering_style(), + vertex="1", + parent=parent_id + ) + + self._create_mx_geometry( + cell, + x=str(x0), + y=str(y0), + width=str(self._signature_size[0]), + height=str(self._signature_size[1]) + ) - id = parent_id + "numbering" - html_text = ( - f'' - f'{label}' + def _add_axes(self, + image_w: int, + image_h: int, + label_x: str, + label_y: str, + parent_id: str = "1"): + + offset_x, offset_y = (self._axis_offset, self._axis_offset) if isinstance(self._axis_offset, int) else self._axis_offset + + label_x_width = len(label_x) * self._axis_font_size * 0.6 + label_y_height = len(label_y) * self._axis_font_size * 0.6 + + width = self._axis_length + 5 + label_x_width + height = self._axis_length + 5 + label_y_height + + group_id = self._generate_id(suffix="-axisgroup") + style = self._get_text_style() + + group_cell = self._create_mx_cell( + id=group_id, + value="", + style="group", + vertex="1", + connectable="0", + parent=parent_id + ) + self._create_mx_geometry( + group_cell, + x=str(offset_x), + y=str(image_h - offset_y - height), + width=str(width), + height=str(height) ) - style = ("rounded=0;", - "whiteSpace=wrap;", - "html=1;", - "strokeColor=none;", - f"fillColor={self._signature_color};", - f"fontSize={self._signature_font_size};") - - mxCell = ET.SubElement(self._xml_root, "mxCell", - id=id, value=html_text, - style="".join(style), - vertex="1", parent=parent_id - ) + x0, y0 = 0, height + x_end = self._axis_length + y_end = height - self._axis_length + + xaxis_id = self._generate_id(suffix="-xaxis") + yaxis_id = self._generate_id(suffix="-yaxis") + xlabel_id = self._generate_id(suffix="-xlabel") + ylabel_id = self._generate_id(suffix="-ylabel") + + self._create_axis(xaxis_id, x0, y0, x_end, y0, group_id) + self._create_axis(yaxis_id, x0, y0, x0, y_end, group_id) + + self._add_label(xlabel_id, label_x, x_end + 5, y0 - 10, len(label_x), group_id, style) + self._add_label(ylabel_id, label_y, x0 + 5, y_end - self._axis_font_size, len(label_y), group_id, style) - key = self._signature_pos.value if isinstance(self._signature_pos, SignaturePosition) else self._signature_pos - match key: - case SignaturePosition.TOP_LEFT.value: - x0 = x0 - max(self._border_size) - y0 = y0 - max(self._border_size) - case SignaturePosition.TOP_RIGHT.value: - x0 = x0 + max(self._border_size) - y0 = y0 - max(self._border_size) - case SignaturePosition.BOTTOM_LEFT.value: - x0 = x0 - max(self._border_size) - y0 = y0 + max(self._border_size) - case SignaturePosition.BOTTOM_RIGHT.value: - x0 = x0 + max(self._border_size) - y0 = y0 + max(self._border_size) - - mxGeometry = ET.SubElement(mxCell, "mxGeometry", - x=str(x0), - y=str(y0), - width=str(self._signature_size[0]), - height=str(self._signature_size[1])) - mxGeometry.set("as", "geometry") + def _layout_images(self, + layout: str = "row", + spacing: int = 10, + grid_cols: Optional[int] = None, + grid_rows: Optional[int] = None): + image_w, image_h = self._images[0].size + positions = [] + + if layout == LayoutMode.ROW.value: + for i in range(len(self._images)): + x = i * (image_w + spacing) + y = 0 + positions.append((x, y)) + + elif layout == LayoutMode.COLUMN.value: + for i in range(len(self._images)): + x = 0 + y = i * (image_h + spacing) + positions.append((x, y)) + elif layout == LayoutMode.GRID.value: + n = len(self._images) + cols = grid_cols or int(n ** 0.5) + rows = grid_rows or ((n + cols - 1) // cols) + for idx in range(n): + col = idx % cols + row = idx // cols + x = col * (image_w + spacing) + y = row * (image_h + spacing) + positions.append((x, y)) + else: + raise ValueError(f"Unknown layout type: {layout}") - def _add_axes(self, - parent_id: str = "1"): pass + group_id = self._generate_id(suffix="-group") + + group_cell = self._create_mx_cell( + id=group_id, + value="", + style="group", + vertex="1", + connectable="0", + parent="1" + ) + + + all_right = [] + all_bottom = [] + for i, (x, y) in enumerate(positions): + self.preprocessing_image(index=i, + position_x=x, + position_y=y, + parent_id=group_id) + all_right.append(x + image_w) + all_bottom.append(y + image_h) + + self._create_mx_geometry( + group_cell, + x="30", + y="30", + width=str(max(all_right)), + height=str(max(all_bottom)) + ) - def _layout_images(self): pass def preprocessing_image(self, index: int, @@ -113,29 +203,50 @@ def preprocessing_image(self, self._draw_border() ) - mxCell = ET.SubElement(self._xml_root, "mxCell", - id=cell_id, value="", - style="".join(style), - vertex="1", parent=parent_id - ) - mxGeometry = ET.SubElement(mxCell, "mxGeometry", - x=str(position_x), - y=str(position_y), - width=str(image_w), - height=str(image_h)) - mxGeometry.set("as", "geometry") + cell = self._create_mx_cell( + id=cell_id, + value="", + style="".join(style), + vertex="1", + parent=parent_id + ) + + self._create_mx_geometry( + cell, + x=str(position_x), + y=str(position_y), + width=str(image_w), + height=str(image_h) + ) if self._signature and self._signature_label: - self._add_numbering(image_w=image_w, - image_h=image_h, - label="1", + self._add_numbering(image_w=image_w, image_h=image_h, + label=self._get_label(index=index), parent_id=cell_id) if self._draw_axis: - self._add_axes() + lx, ly = self._axis_labels + lx, ly = (lx[index], ly[index]) if isinstance(lx, tuple) else (lx, ly) + self._add_axes(image_w, image_h, label_x=lx, label_y=ly, parent_id=cell_id) - - def united_images(self): pass + + def united_images(self, + layout: Union[str, LayoutMode] = "row", + spacing: int = 10, + grid_cols: Optional[int] = None, + grid_rows: Optional[int] = None, + width: int = None, + height: int = None): + + layout = layout.value if isinstance(layout, LayoutMode) else layout + + if width or height: + self._images = [self._resize_proportional(img, width=width, height=height) for img in self._images] + + self._layout_images(layout=layout, + spacing=spacing, + grid_cols=grid_cols, + grid_rows=grid_rows) # методы этого класса def _create_drawio_structure(self): @@ -152,20 +263,88 @@ def _create_drawio_structure(self): ET.SubElement(self._xml_root, "mxCell", id="0") ET.SubElement(self._xml_root, "mxCell", id="1", parent="0") + def _create_mx_cell(self, **attrs) -> ET.Element: + return ET.SubElement(self._xml_root, "mxCell", **attrs) + + def _create_mx_geometry(self, parent: ET.Element, **attrs) -> ET.Element: + geom = ET.SubElement(parent, "mxGeometry", **attrs) + geom.set("as", "geometry") + return geom + + def _create_axis(self, + id: str, + x0: float, + y0: float, + x1: float, + y1: float, + parent_id: str): + cell = self._create_mx_cell( + id=id, + value="", + style=f"endArrow=blockThin;html=1;rounded=0;strokeWidth={self._axis_width}", + edge="1", + parent=parent_id + ) + + geom = self._create_mx_geometry(cell, width="50", height="50", relative="1") + + source = ET.SubElement(geom, "mxPoint", x=str(x0), y=str(y0)) + target = ET.SubElement(geom, "mxPoint", x=str(x1), y=str(y1)) + + source.set("as", "sourcePoint") + target.set("as", "targetPoint") + + def _add_label(self, + cell_id: str, + text: str, + x: float, + y: float, + width: int, + parent_id: str, + style: str): + + cell = self._create_mx_cell( + id=cell_id, value=text, + style=style, vertex="1", parent=parent_id + ) + self._create_mx_geometry(cell, + x=str(x), y=str(y), + width=str(width * self._axis_font_size * 0.6), height="20" + ) + + def _get_text_style(self) -> str: + return "".join(( + "text;", "html=1;", "align=left;", "verticalAlign=middle;", + "resizable=0;", "points=[];", "autosize=1;", + "strokeColor=none;", "fillColor=none;", + f"fontFamily={self._font_family};", + f"fontColor=#000;", f"fontSize={self._axis_font_size};" + )) + + def _get_numbering_style(self) -> str: + return "".join(( + "rounded=0;", "whiteSpace=wrap;", "html=1;", "strokeColor=none;", + f"fillColor={self._signature_color};", + f"fontSize={self._signature_font_size};" + )) + + def _get_numbering_text(self, label: str) -> str: + return f'{label}' + + def export_to_drawio(self, file: str | Path, **kwargs): + tree = ET.ElementTree(self._root) + ET.indent(tree, space=" ", level=0) + Path(file).write_text(self.united_images(**kwargs), encoding="utf-8") + @staticmethod def _generate_id(prefix: str = "E__", suffix: str = "-1") -> str: uid = uuid.uuid4().bytes[:9] - base64_id = base64.urlsafe_b64encode(uid).decode("ascii").rsplit("=") - return f"{prefix}{base64_id[0]}{suffix}" + base64_id = base64.urlsafe_b64encode(uid).decode("ascii").rstrip("=") + return f"{prefix}{base64_id}{suffix}" @staticmethod def _image_to_base64(image: Image.Image) -> str: buffer = io.BytesIO() image.save(buffer, format="PNG") return base64.b64encode(buffer.getvalue()).decode("ascii") - - - # def export_to_drawio(self, file: str | Path, **kwargs): - # ET.indent(tree, space=" ", level=0) - # Path(file).write_text(self.united_images(**kwargs), encoding="utf-8") diff --git a/image_processing/ImageProcessing.py b/image_processing/ImageProcessing.py index 043f464..a13334f 100644 --- a/image_processing/ImageProcessing.py +++ b/image_processing/ImageProcessing.py @@ -1,4 +1,3 @@ -from abc import ABC, abstractmethod from pathlib import Path from typing import List, Optional, Tuple, Union from PIL import Image, ImageOps, ImageDraw, ImageFont @@ -43,7 +42,7 @@ def get_label(index: int, mode: str | LabelMode = LabelMode.CYRILLIC_LOWER) -> s raise ValueError(f"Неверный режим: '{mode}'. Доступные режимы: {available}") -class ImageProcessing(ABC): +class ImageProcessing: def __init__(self, images_path: Union[str, Path], @@ -411,6 +410,23 @@ def font_family(self, value): # Assistant methods + def _get_label(self, index): + valid_modes = {m.value for m in LabelMode} + + label_mode = self._signature_label + if isinstance(label_mode, (str, LabelMode)) and (getattr(label_mode, 'value', label_mode) in valid_modes): + label = get_label(index, label_mode) + elif isinstance(label_mode, tuple): + if index >= len(label_mode): + raise IndexError(f"The signature for the index {index} was not found in the transmitted tuple") + label = label_mode[index] + elif isinstance(label_mode, str): + label = label_mode + else: + raise ValueError(f"Incorrect signature format: {label_mode}") + + return label + def _get_positions(self, image_w: int, image_h: int) -> list | tuple: @@ -502,23 +518,17 @@ def append(self, image: Image.Image): raise TypeError("The value must be an instance of PIL.Image.Image") self._images.append(image) - @abstractmethod def _draw_border(self): pass - @abstractmethod def _add_numbering(self): pass - @abstractmethod def _add_axes(self): pass - @abstractmethod def _layout_images(self): pass # The implementer method - @abstractmethod def preprocessing_image(self): pass - @abstractmethod def united_images(self): pass diff --git a/image_processing/imageDesign.py b/image_processing/imageDesign.py index 0e4c383..9ea9e32 100644 --- a/image_processing/imageDesign.py +++ b/image_processing/imageDesign.py @@ -157,8 +157,8 @@ def _add_axes(self, img: Image.Image, label_x: str, label_y: str) -> Image.Image w, h = img.size # Handle axis_offset as int or tuple - offset_x, offset_y = (self.axis_offset, self.axis_offset) \ - if isinstance(self.axis_offset, int) else self.axis_offset + offset_x, offset_y = (self._axis_offset, self._axis_offset) \ + if isinstance(self._axis_offset, int) else self._axis_offset arrow_length = self._axis_width * 2.5 arrow_half = self._axis_width * 1.2 @@ -166,7 +166,7 @@ def _add_axes(self, img: Image.Image, label_x: str, label_y: str) -> Image.Image x0, y0 = offset_x, h - offset_y # X-axis - end_x = x0 + self.axis_length - arrow_length + end_x = x0 + self._axis_length - arrow_length draw.line((x0, y0, end_x, y0), fill="black", width= self._axis_width) draw.polygon([ (end_x + arrow_length, y0), @@ -298,25 +298,13 @@ def preprocessing_image(self, raise IndexError(f"Index {index} outside the range of the image list") self._load_fonts() - valid_modes = {m.value for m in LabelMode} img = self._images[index] if width and height: img = self._resize_proportional(img=img, width=width, height=height) proc = self._draw_border(img) if self._signature and self._signature_label: - label_mode = self._signature_label - if isinstance(label_mode, (str, LabelMode)) and (getattr(label_mode, 'value', label_mode) in valid_modes): - label = get_label(index, label_mode) - elif isinstance(label_mode, tuple): - if index >= len(label_mode): - raise IndexError(f"The signature for the index {index} was not found in the transmitted tuple") - label = label_mode[index] - elif isinstance(label_mode, str): - label = label_mode - else: - raise ValueError(f"Incorrect signature format: {label_mode}") - proc = self._add_numbering(proc, label) + proc = self._add_numbering(proc, self._get_label(index)) if self._draw_axis: label_x, label_y = self._axis_labels diff --git a/test/test_drawio_image_design.py b/test/test_drawio_image_design.py index 77ff8e9..9802c5d 100644 --- a/test/test_drawio_image_design.py +++ b/test/test_drawio_image_design.py @@ -1,17 +1,15 @@ import pytest import xml.etree.ElementTree as ET from PIL import Image -from image_processing.enumerates import SignaturePosition +from image_processing.enumerates import SignaturePosition, LabelMode from image_processing import DrawioImageDesign class MockDrawioImageDesign(DrawioImageDesign): def __init__(self): - # Эмуляция родительского состояния без вызова super().__init__() self._images_path = "" self._images = [Image.new("RGB", (320, 270), "white")] - # Настройки подписи и рамки self._font_family = "Arial" self._signature_label_color = "white" self._signature_color = "black" @@ -24,49 +22,108 @@ def __init__(self): self._signature_pos = SignaturePosition.TOP_RIGHT self._draw_axis = False - # XML-структура + self._axis_font_size = 12 + self._axis_length = 60 + self._axis_width = 1 + self._axis_offset = (30, 30) + self._axis_labels = ("X", "Y") + self._root = None self._xml_root = None self._create_drawio_structure() def _resize_proportional(self, image, width, height): + if width is None or height is None: + return image return image.resize((width, height)) def _load_images(self, path): return self._images - @pytest.fixture def design(): - return MockDrawioImageDesign() + d = MockDrawioImageDesign() + d._signature_label = LabelMode.CYRILLIC_LOWER # Исправление ошибки сигнатуры + return d def test_add_numbering_creates_cell(design): design._add_numbering(image_w=320, image_h=270, label="1", parent_id="testparent") + cell = next((e for e in design._xml_root if e.attrib.get("parent") == "testparent" and e.attrib.get("id", "").endswith("-numbering")), None) - cell = next((e for e in design._xml_root if e.attrib.get("id") == "testparentnumbering"), None) - assert cell is not None, "Номерная ячейка не создана" - assert cell.tag == "mxCell" - assert "style" in cell.attrib - assert "value" in cell.attrib - assert "html=1" in cell.attrib["style"] - assert cell.attrib["value"].startswith(' Date: Fri, 16 May 2025 15:52:16 +1000 Subject: [PATCH 6/7] Update UI interface and add description methods --- image_processing/DrawioProcessing.py | 331 ++++++++++++++++++++++++++- pages/image_processing_page.py | 103 +++++++-- 2 files changed, 409 insertions(+), 25 deletions(-) diff --git a/image_processing/DrawioProcessing.py b/image_processing/DrawioProcessing.py index 54b7b31..c030cd0 100644 --- a/image_processing/DrawioProcessing.py +++ b/image_processing/DrawioProcessing.py @@ -2,10 +2,10 @@ import uuid import base64 import xml.etree.ElementTree as ET -from typing import Optional, Union from PIL import Image from pathlib import Path +from typing import Optional, Union from image_processing.enumerates import * from image_processing.ImageProcessing import ImageProcessing @@ -22,6 +22,17 @@ def __init__(self, *args, **kwargs): # Методы родительского класса def _draw_border(self): + """ + Generates a style string for the image border in draw.io format. + + Returns: + str: A style string that defines the border color and stroke width + based on the object's `_border_fill` and the maximum of `_border_size`. + Example: "imageBorder=#000;strokeWidth=10;" + + Used in: + - Setting the border style for mxCell elements representing images. + """ return f"imageBorder={self._border_fill};strokeWidth={max(self._border_size)};" def _add_numbering(self, @@ -30,6 +41,25 @@ def _add_numbering(self, label: str = "", parent_id: str = "1"): + """ + Adds a numbering label (e.g., index or identifier) to an image element + as an `mxCell` with geometry and styling for draw.io. + + Args: + image_w (int): Width of the image in pixels. + image_h (int): Height of the image in pixels. + label (str, optional): The text label to display (e.g., a number or letter). Defaults to "". + parent_id (str, optional): The ID of the parent `mxCell` group or image. Defaults to "1". + + Behavior: + - Computes a position for the label based on `_signature_pos` (e.g., top-right). + - Applies an offset using `_border_size`. + - Creates a styled `mxCell` for the label and positions it using `mxGeometry`. + + Used in: + - `preprocessing_image()` for attaching index/label annotations to image blocks. + """ + x0, y0, _, _ = self._get_positions(image_w, image_h) offset = max(self._border_size) key = self._signature_pos.value if isinstance(self._signature_pos, SignaturePosition) else self._signature_pos @@ -69,6 +99,28 @@ def _add_axes(self, label_y: str, parent_id: str = "1"): + """ + Adds X and Y axes with corresponding labels to an image element. + + Args: + image_w (int): Width of the image in pixels. + image_h (int): Height of the image in pixels. + label_x (str): Text label for the X axis. + label_y (str): Text label for the Y axis. + parent_id (str, optional): The ID of the parent `mxCell` group (usually the image). Defaults to "1". + + Behavior: + - Calculates the total size required to draw the axes including labels. + - Creates a group `mxCell` to contain the axes. + - Draws two axis lines using `_create_axis`: + - X axis: horizontal line from (0, height) to (axis_length, height) + - Y axis: vertical line from (0, height) to (0, height - axis_length) + - Adds text labels near the ends of each axis using `_add_label`. + + Used in: + - `preprocessing_image()` when axis display is enabled (`_draw_axis=True`). + """ + offset_x, offset_y = (self._axis_offset, self._axis_offset) if isinstance(self._axis_offset, int) else self._axis_offset label_x_width = len(label_x) * self._axis_font_size * 0.6 @@ -111,12 +163,35 @@ def _add_axes(self, self._add_label(xlabel_id, label_x, x_end + 5, y0 - 10, len(label_x), group_id, style) self._add_label(ylabel_id, label_y, x0 + 5, y_end - self._axis_font_size, len(label_y), group_id, style) - def _layout_images(self, layout: str = "row", spacing: int = 10, grid_cols: Optional[int] = None, grid_rows: Optional[int] = None): + """ + Arranges multiple images in a specified layout (row, column, or grid) + and groups them into a single parent mxCell in the draw.io structure. + + Args: + layout (str, optional): Layout mode: "row", "column", or "grid". Defaults to "row". + spacing (int, optional): Spacing in pixels between images. Defaults to 10. + grid_cols (Optional[int], optional): Number of columns in grid layout. Used only if layout is "grid". + grid_rows (Optional[int], optional): Number of rows in grid layout. Used only if layout is "grid". + + Behavior: + - Calculates (x, y) positions for each image depending on layout type: + - "row": horizontally aligned images. + - "column": vertically stacked images. + - "grid": images arranged in a 2D grid. + - Calls `preprocessing_image()` for each image with its computed position. + - Wraps all images in a group `mxCell` with geometry sized to fit all children. + + Raises: + ValueError: If an unsupported layout type is provided. + + Used in: + - `united_images()` to render the full composed diagram from the image list. + """ image_w, image_h = self._images[0].size positions = [] @@ -183,7 +258,32 @@ def preprocessing_image(self, position_x: int = 0, position_y: int = 0, parent_id: str = "1"): - + """ + Processes a single image from the internal image list by resizing, + encoding, and embedding it into the draw.io diagram as an `mxCell`. + + Args: + index (int): Index of the image in the `_images` list. + width (int | None, optional): Target width for resizing. If None, original width is preserved. + height (int | None, optional): Target height for resizing. If None, original height is preserved. + position_x (int, optional): X-coordinate of the image within the parent container. Defaults to 0. + position_y (int, optional): Y-coordinate of the image within the parent container. Defaults to 0. + parent_id (str, optional): ID of the parent `mxCell` group. Defaults to "1". + + Behavior: + - Resizes the image proportionally if dimensions are provided. + - Converts the image to base64 and embeds it into a styled `mxCell`. + - Adds geometry based on specified position and image size. + - Optionally adds: + - A numbering label (`_add_numbering`) if `_signature` and `_signature_label` are enabled. + - Coordinate axes (`_add_axes`) if `_draw_axis` is enabled. + + Raises: + IndexError: If the provided index is out of bounds. + + Used in: + - `_layout_images()` and other high-level composition methods. + """ if index >= len(self._images): raise IndexError(f"Index {index} outside the range of the image list") @@ -237,7 +337,26 @@ def united_images(self, grid_rows: Optional[int] = None, width: int = None, height: int = None): - + """ + Composes all loaded images into a single layout group and generates + the corresponding draw.io structure. + + Args: + layout (Union[str, LayoutMode], optional): Layout mode for arranging images. + Can be "row", "column", or "grid". Defaults to "row". + spacing (int, optional): Spacing between images in pixels. Defaults to 10. + grid_cols (Optional[int], optional): Number of columns in grid layout. Only used if layout is "grid". + grid_rows (Optional[int], optional): Number of rows in grid layout. Only used if layout is "grid". + width (int, optional): If set, resizes all images to this width before layout. + height (int, optional): If set, resizes all images to this height before layout. + + Behavior: + - Optionally resizes all images to the specified `width` and `height`. + - Passes control to `_layout_images()` to arrange the images based on the selected layout mode. + + Used in: + - `export_to_drawio()` to generate the final diagram for export. + """ layout = layout.value if isinstance(layout, LayoutMode) else layout if width or height: @@ -250,6 +369,21 @@ def united_images(self, # методы этого класса def _create_drawio_structure(self): + """ + Initializes the root XML structure for a draw.io diagram. + + Behavior: + - Creates the top-level element with the draw.io host attribute. + - Adds a element with a unique ID and a predefined name ("Обработчик изображений"). + - Constructs the and its container. + - Adds two base `mxCell` elements with IDs "0" and "1", where: + - ID "0" is the invisible root of all elements. + - ID "1" serves as the main container for the user-defined content. + + Used in: + - Constructor (`__init__`) to prepare an empty draw.io-compatible structure. + - Required before adding any cells, images, or layout groups. + """ self._root = ET.Element("mxfile", host="ScienceHelper") diagram_id = self._generate_id(prefix="", suffix="") @@ -264,9 +398,43 @@ def _create_drawio_structure(self): ET.SubElement(self._xml_root, "mxCell", id="1", parent="0") def _create_mx_cell(self, **attrs) -> ET.Element: + """ + Creates and appends an element to the draw.io XML structure. + + Args: + **attrs: Arbitrary keyword arguments representing XML attributes + for the element (e.g., id, value, style, parent, vertex, edge). + + Returns: + ET.Element: The newly created element. + + Behavior: + - Appends the element to the internal `_xml_root` container. + + Used in: + - Most rendering methods to define images, groups, arrows, and text labels. + """ return ET.SubElement(self._xml_root, "mxCell", **attrs) def _create_mx_geometry(self, parent: ET.Element, **attrs) -> ET.Element: + """ + Creates and appends an element to a given element. + + Args: + parent (ET.Element): The parent element to which the geometry is attached. + **attrs: Arbitrary keyword arguments representing attributes of the element + (e.g., x, y, width, height, relative). + + Returns: + ET.Element: The newly created element. + + Behavior: + - Sets the "as" attribute to "geometry", indicating its role in the draw.io structure. + - Used to define the position and size of an . + + Used in: + - Image blocks, labels, axes, and other visual elements requiring placement. + """ geom = ET.SubElement(parent, "mxGeometry", **attrs) geom.set("as", "geometry") return geom @@ -278,6 +446,25 @@ def _create_axis(self, x1: float, y1: float, parent_id: str): + """ + Creates a visual axis (as an edge with an arrow) and appends it to the draw.io XML structure. + + Args: + id (str): Unique ID for the axis mxCell. + x0 (float): X-coordinate of the axis starting point. + y0 (float): Y-coordinate of the axis starting point. + x1 (float): X-coordinate of the axis ending point. + y1 (float): Y-coordinate of the axis ending point. + parent_id (str): ID of the parent mxCell group. + + Behavior: + - Creates an edge-style `mxCell` with a thin arrowhead and custom stroke width. + - Adds an `mxGeometry` block with relative positioning. + - Defines `mxPoint` elements for the source and target coordinates of the axis line. + + Used in: + - `_add_axes()` to render X and Y directional lines next to images. + """ cell = self._create_mx_cell( id=id, value="", @@ -302,6 +489,27 @@ def _add_label(self, width: int, parent_id: str, style: str): + """ + Adds a text label as an `mxCell` element to the draw.io diagram. + + Args: + cell_id (str): Unique ID for the label mxCell. + text (str): The text content to display. + x (float): X-coordinate of the label position. + y (float): Y-coordinate of the label position. + width (int): Logical width of the text (multiplied by font size to determine pixel width). + parent_id (str): ID of the parent mxCell (e.g., an axis group). + style (str): The style string for the label (e.g., font, color, alignment). + + Behavior: + - Creates a vertex `mxCell` containing the label text. + - Applies style and attaches it to the given parent cell. + - Defines the geometry (position and size) based on coordinates and scaled text width. + + Used in: + - `_add_axes()` for axis labels. + - Any other diagram element that needs textual annotation. + """ cell = self._create_mx_cell( id=cell_id, value=text, @@ -313,6 +521,26 @@ def _add_label(self, ) def _get_text_style(self) -> str: + """ + Constructs a style string for text labels in draw.io format. + + Returns: + str: A concatenated style string that defines appearance and behavior of text elements. + Includes font settings, alignment, autosizing, and no stroke or fill colors. + + Example: + "text;html=1;align=left;verticalAlign=middle;resizable=0;...;fontFamily=Arial;fontSize=12;" + + Behavior: + - Enables HTML rendering for text. + - Sets text alignment to left and vertically centered. + - Disables resizing and connections. + - Ensures clean appearance with no border or background fill. + - Applies current font family and axis font size. + + Used in: + - `_add_label()` to style axis or annotation text elements. + """ return "".join(( "text;", "html=1;", "align=left;", "verticalAlign=middle;", "resizable=0;", "points=[];", "autosize=1;", @@ -322,6 +550,25 @@ def _get_text_style(self) -> str: )) def _get_numbering_style(self) -> str: + """ + Generates a style string for numbering labels in draw.io format. + + Returns: + str: A style string that defines the visual appearance of a numbering label. + Includes background color, font size, and HTML rendering. + + Example: + "rounded=0;whiteSpace=wrap;html=1;strokeColor=none;fillColor=black;fontSize=24;" + + Behavior: + - Disables rounded corners and stroke outlines. + - Enables HTML text rendering and word wrapping. + - Applies background fill color using `_signature_color`. + - Sets font size from `_signature_font_size`. + + Used in: + - `_add_numbering()` to style index or label annotations on images. + """ return "".join(( "rounded=0;", "whiteSpace=wrap;", "html=1;", "strokeColor=none;", f"fillColor={self._signature_color};", @@ -329,22 +576,96 @@ def _get_numbering_style(self) -> str: )) def _get_numbering_text(self, label: str) -> str: + """ + Generates an HTML-formatted string for a numbering label in draw.io. + + Args: + label (str): The label text to be displayed (e.g., a number or character). + + Returns: + str: An HTML string using a tag with the configured font family and text color. + Example: '1' + + Behavior: + - Uses the current `_font_family` and `_signature_label_color` to format the label. + - Intended for use with draw.io's HTML rendering in `mxCell.value`. + + Used in: + - `_add_numbering()` when embedding label text into image annotations. + """ return f'{label}' def export_to_drawio(self, file: str | Path, **kwargs): + """ + Exports the composed diagram structure to a .drawio-compatible XML file. + + Args: + file (str | Path): The output file path where the XML content will be saved. + **kwargs: Additional keyword arguments passed to `united_images()` + (e.g., layout, spacing, width, height). + + Behavior: + - Calls `united_images()` to arrange and prepare the diagram content. + - Serializes the internal `_root` XML tree into indented draw.io format. + - Writes the final XML string to the specified file with UTF-8 encoding. + + Notes: + - The output file can be opened directly in draw.io or diagrams.net. + - The layout and formatting of images are controlled via kwargs. + + Used in: + - External scripts or UI to generate and save a final visual diagram. + """ + self.united_images(**kwargs) + tree = ET.ElementTree(self._root) ET.indent(tree, space=" ", level=0) - Path(file).write_text(self.united_images(**kwargs), encoding="utf-8") + tree.write(file, encoding="utf-8", xml_declaration=True) @staticmethod def _generate_id(prefix: str = "E__", suffix: str = "-1") -> str: + """ + Generates a unique ID string for use in draw.io element attributes. + + Args: + prefix (str, optional): Prefix to prepend to the ID. Defaults to "E__". + suffix (str, optional): Suffix to append to the ID. Defaults to "-1". + + Returns: + str: A unique string composed of the prefix, a base64-encoded UUID segment, + and the suffix. Example: "E__abc123xyz-1" + + Behavior: + - Uses the first 9 bytes of a UUID4 as the base for the ID. + - Encodes it using URL-safe base64 and removes padding. + + Used in: + - Element creation functions to assign distinct and consistent IDs to mxCells. + """ uid = uuid.uuid4().bytes[:9] base64_id = base64.urlsafe_b64encode(uid).decode("ascii").rstrip("=") return f"{prefix}{base64_id}{suffix}" @staticmethod def _image_to_base64(image: Image.Image) -> str: + """ + Converts a PIL Image to a base64-encoded PNG string. + + Args: + image (Image.Image): The PIL Image object to encode. + + Returns: + str: A base64-encoded string representing the image in PNG format. + Suitable for embedding directly in draw.io XML as a data URI. + + Behavior: + - Saves the image to an in-memory bytes buffer in PNG format. + - Encodes the buffer to base64 and decodes it to an ASCII string. + + Used in: + - `preprocessing_image()` to embed images into the `mxCell` style attribute. + """ buffer = io.BytesIO() image.save(buffer, format="PNG") return base64.b64encode(buffer.getvalue()).decode("ascii") diff --git a/pages/image_processing_page.py b/pages/image_processing_page.py index 0f5c499..9bbeeb8 100644 --- a/pages/image_processing_page.py +++ b/pages/image_processing_page.py @@ -1,13 +1,14 @@ import io import base64 +import xml.etree.ElementTree as ET + from PIL import Image from nicegui import ui from pathlib import Path from typing import Tuple, Union from tempfile import TemporaryDirectory -from image_processing import ImagesDesign, SignaturePosition, LabelMode, LayoutMode +from image_processing import ImagesDesign, SignaturePosition, LabelMode, LayoutMode, DrawioImageDesign -# Хранилище временных параметров компоновки united_params = { 'layout': 'row', 'spacing': 10, @@ -18,21 +19,21 @@ 'height': None, } -# UI state variables для хранения ссылок на input элементы united_controls = {} -# Допустимые значения layout valid_layouts = set([mode.value for mode in LayoutMode]) tmp_dir = TemporaryDirectory() design = ImagesDesign(images_path=tmp_dir.name) -# Список шрифтов font_dir = Path('./fonts') font_files = sorted([f.stem for f in font_dir.glob('*.ttf') if f.is_file()]) signature_label_options = [mode.value for mode in LabelMode] signature_pos_options = [mode.value for mode in SignaturePosition] +download_link = ui.html('').classes('hidden') +download_drawio_link = ui.html('').classes('hidden') + def image_processing_page(): with ui.column().classes('w-full items-center justify-center gap-4'): image_slot = ui.image().classes('w-1/2 rounded-xl shadow-lg') @@ -48,14 +49,14 @@ def image_processing_page(): with ui.row().classes('gap-4'): ui.button('📤 Загрузить', on_click=upload_dialog.open).props('color=primary') ui.button('🗑 Очистить', on_click=lambda: clear_images(image_slot)).props('color=negative') - ui.button('📥 Скачать результат', - on_click=lambda: ui.run_javascript('document.getElementById("download_result").click();')) \ + ui.button('📥 Скачать .png', on_click=download_png).props('color=primary') \ .bind_visibility_from(image_slot, 'visible') + ui.button('📥 Скачать .drawio', on_click=download_drawio).props('color=accent') \ + .bind_visibility_from(image_slot, 'visible') - global download_link - download_link = ui.html('').classes('hidden') + download_link + download_drawio_link - # Параметры обработки with ui.expansion('Параметры обработки', icon='settings'): with ui.grid(columns=4).classes('gap-4 w-full'): def safe_int(val, default=0): @@ -64,11 +65,18 @@ def safe_int(val, default=0): except ValueError: return default + def safe_font(val: str, fallback: int = 12) -> int: + v = safe_int(val, fallback) + if v <= 0: + ui.notify("Размер шрифта должен быть положительным", type="warning") + return fallback + return v + ui.input('Размер рамки', value=str(design.border_size), on_change=lambda e: update_param('border_size', safe_int(e.value), image_slot)).props('type=number min=0') ui.color_input(label='Цвет рамки', value='#000000', on_change=lambda e: update_param('border_fill', e.value, image_slot)) - + ui.checkbox('Добавлять подпись', value=design.signature, on_change=lambda e: update_param('signature', e.value, image_slot)) ui.select(signature_label_options, value=design.signature_label, @@ -87,7 +95,7 @@ def safe_int(val, default=0): on_change=lambda e: update_param('signature_color', e.value, image_slot)) ui.input('Размер шрифта подписи', value=str(design.signature_font_size), on_change=lambda e: update_param('signature_font_size', safe_int(e.value), image_slot)).props('type=number min=3') - + ui.checkbox('Показывать оси', value=design.draw_axis, on_change=lambda e: update_param('draw_axis', e.value, image_slot)) ui.input('Подписи оси X', value=design.axis_labels[0] if isinstance(design.axis_labels[0], str) else ','.join(design.axis_labels[0]), @@ -95,9 +103,9 @@ def safe_int(val, default=0): ui.input('Подписи оси Y', value=design.axis_labels[1] if isinstance(design.axis_labels[1], str) else ','.join(design.axis_labels[1]), on_change=lambda e: update_axis_labels('y', e.value, image_slot)) ui.input('Смещение по X', value=str(design.axis_offset[0] if isinstance(design.axis_offset, tuple) else design.axis_offset), - on_change=lambda e: update_axis_offset('x', e.value, image_slot)).props('type=number min=0') + on_change=lambda e: update_axis_offset('x', e.value, image_slot)).props('type=number min=0') ui.input('Смещение по Y', value=str(design.axis_offset[1] if isinstance(design.axis_offset, tuple) else design.axis_offset), - on_change=lambda e: update_axis_offset('y', e.value, image_slot)).props('type=number min=0') + on_change=lambda e: update_axis_offset('y', e.value, image_slot)).props('type=number min=0') ui.input('Длина осей', value=str(design.axis_length), on_change=lambda e: update_param('axis_length', safe_int(e.value), image_slot)).props('type=number min=1') ui.input('Толщина осей', value=str(design.axis_width), @@ -148,7 +156,6 @@ def update_axis_offset(axis: str, value: str, image_slot): except ValueError: ui.notify(f"Смещение по оси {axis.upper()} должно быть числом", type='warning') - def update_axis_labels(axis: str, text: str, image_slot): try: values = [v.strip() for v in text.split(',') if v.strip()] @@ -156,12 +163,10 @@ def update_axis_labels(axis: str, text: str, image_slot): ui.notify(f"Поле оси {axis.upper()} пусто", type='warning') return - # Если одно значение — это глобальная подпись parsed_value: Union[str, Tuple[str, ...]] = ( values[0] if len(values) == 1 else tuple(values) ) - # Проверим длину, если список if isinstance(parsed_value, tuple) and len(parsed_value) != len(design): ui.notify(f"Количество подписей для оси {axis.upper()} должно быть {len(design)}", type='negative') return @@ -176,9 +181,6 @@ def update_axis_labels(axis: str, text: str, image_slot): except Exception as ex: ui.notify(f"Ошибка при установке подписей осей: {ex}", type='negative') - - - def handle_upload(e, dialog, image_slot, download_link): allowed_ext = ('.png', '.jpg', '.jpeg') if not e.name.lower().endswith(allowed_ext): @@ -231,3 +233,64 @@ def update_output(image_slot): download_link.set_content(f''' ''') + +def download_png(): + if not len(design): + ui.notify("Нет изображений для сохранения", type="warning") + return + + try: + result = design.united_images( + layout=united_params['layout'], + spacing=united_params['spacing'], + bg_color=united_params['bg_color'], + grid_cols=united_params['grid_cols'], + grid_rows=united_params['grid_rows'], + width=united_params['width'], + height=united_params['height'], + ) + output_path = Path(tmp_dir.name) / "result.png" + result.save(output_path, format="PNG") + ui.download(str(output_path), filename="result.png") + except Exception as e: + ui.notify(f"Ошибка при сохранении PNG: {e}", type="negative") + +def download_drawio(): + + if not len(design): + ui.notify("Нет изображений для сохранения", type="warning") + return + try: + drawio = DrawioImageDesign(images_path=tmp_dir.name) + drawio._images = design._images.copy() + + drawio.border_size = design.border_size + drawio.border_fill = design.border_fill + drawio.signature = design.signature + drawio.signature_label = design.signature_label + drawio.signature_label_color = design.signature_label_color + drawio.signature_color = design.signature_color + drawio.signature_font_size = design.signature_font_size + drawio.signature_size = design.signature_size + drawio.signature_pos = design.signature_pos + drawio.axis_labels = design.axis_labels + drawio.axis_length = design.axis_length + drawio.axis_width = design.axis_width + drawio.axis_font_size = design.axis_font_size + drawio.axis_offset = design.axis_offset + drawio.font_family = design.font_family + drawio.draw_axis = design.draw_axis + + output_path = Path(tmp_dir.name) / "result.drawio" + + drawio.export_to_drawio(file=output_path, + layout=united_params['layout'], + spacing=united_params['spacing'], + grid_cols=united_params['grid_cols'], + grid_rows=united_params['grid_rows'], + width=united_params['width'], + height=united_params['height']) + + ui.download(str(output_path), filename="result.drawio") + except Exception as e: + ui.notify(f"Ошибка при сохранении drawio: {e}", type="negative") From 1d510eab6150bfbec99ed879c987843bc26be5ef Mon Sep 17 00:00:00 2001 From: Daniel Robotics Date: Fri, 16 May 2025 15:55:21 +1000 Subject: [PATCH 7/7] Added saving files to temporary storage --- pages/analysis_page.py | 92 +++++++++++++----------------------------- 1 file changed, 27 insertions(+), 65 deletions(-) diff --git a/pages/analysis_page.py b/pages/analysis_page.py index 7b9395b..bfb877f 100644 --- a/pages/analysis_page.py +++ b/pages/analysis_page.py @@ -4,11 +4,12 @@ from nicegui import ui from pathlib import Path +from tempfile import TemporaryDirectory from utils import filter_rows_by_specialty, bool_to_YesNo, load_json +temp_dir = TemporaryDirectory() def science_articles_page() -> None: - # ─── проверяем, что все нужные файлы есть ───────────────── data_dir = Path(setting.MAIN_DIRECTORY) / setting.DATA_DIRECTORY req = [data_dir / setting.SPECIALIZATION_NAME, data_dir / 'vak_articles.json', @@ -22,8 +23,6 @@ def science_articles_page() -> None: return taxonomy = json.loads(req[0].read_text(encoding='utf-8')) - - # ─── helper-функции для справочника ──────────────────────── get_cat = lambda: ['Выбрать...'] + [c['category_name'] for c in taxonomy] get_sub = lambda c: ['Выбрать...'] + [s['subcategory_name'] for s in next((x for x in taxonomy if x['category_name'] == c['label']), {}) @@ -34,17 +33,12 @@ def get_specs(cat, sub): sub = next((s for s in c['sub_category'] if s['subcategory_name'] == sub['label']), None) if c else None return sub['values'] if sub else [] - # ─── технические утилиты ─────────────────────────────────── def codes(selected): return sorted({'.'.join(s['label'].split('.')[:3]) for s in selected}) def stringify_lists(rows): - out = [] - for r in rows: - out.append({k: ', '.join(v) if isinstance(v, list) else v for k, v in r.items()}) - return out + return [{k: ', '.join(v) if isinstance(v, list) else v for k, v in r.items()} for r in rows] - # ─── UI: выбор категорий / специальностей ─────────────────────── with ui.column().classes('w-full items-center gap-4'): ui.label('Анализ научных журналов').classes('text-xl') @@ -59,7 +53,7 @@ def stringify_lists(rows): tbl_wrap = ui.column().classes('w-full') dl_btn = ui.button('⬇ Скачать Excel').props('color=secondary').classes('mt-2 self-start hidden') - active = {'btn': None} # текущая подсвеченная + active = {'btn': None} def highlight(b): if active['btn']: @@ -67,21 +61,15 @@ def highlight(b): b.props('color=primary').update() active['btn'] = b - # ── отрисовка таблицы + назначение download ──────────── def show_table(rows, columns, xlsx_path): tbl_wrap.clear() with tbl_wrap: - ui.table(columns=columns, - rows=rows, - pagination=10, - row_key=columns[0]['field']) \ - .classes('w-full').style('table-layout:fixed;word-break:break-word;') + ui.table(columns=columns, rows=rows, pagination=10, row_key=columns[0]['field']) \ + .classes('w-full').style('table-layout:fixed;word-break:break-word;') - # делаем кнопку видимой и переназначаем download dl_btn.classes(remove='hidden') - dl_btn.on('click', lambda: ui.download(xlsx_path)) + dl_btn.on('click', lambda: ui.download(str(xlsx_path), filename=xlsx_path.name)) - # ── основной анализ ──────────────────────────────────── async def run(): if not specs_selected: ui.notify('Выберите хотя бы одну специализацию'); return @@ -108,68 +96,53 @@ async def run(): 'RSCI': bool_to_YesNo(hit['rsci']['value']), }) - # Excel-файлы - xlsx_data = data_dir / 'data.xlsx'; pd.DataFrame(data).to_excel(xlsx_data, index=False) - xlsx_filters = data_dir / 'filters.xlsx'; pd.DataFrame(vak_filters).to_excel(xlsx_filters, index=False) - xlsx_articles = data_dir / 'articles.xlsx'; pd.DataFrame(vak_articles).to_excel(xlsx_articles, index=False) + xlsx_data = Path(temp_dir.name) / 'data.xlsx'; pd.DataFrame(data).to_excel(xlsx_data, index=False) + xlsx_filters = Path(temp_dir.name) / 'filters.xlsx'; pd.DataFrame(vak_filters).to_excel(xlsx_filters, index=False) + xlsx_articles = Path(temp_dir.name) / 'articles.xlsx'; pd.DataFrame(vak_articles).to_excel(xlsx_articles, index=False) cols_data = [ - # центр и в заголовке, и в ячейках {'name': 'ВАК ID', 'label': 'ID', 'field': 'ВАК ID', 'align': 'center', 'headerClasses': 'text-center', 'style': 'width:70px; white-space:normal;'}, {'name': 'Наименование журнала', 'label': 'Журнал', - 'field': 'Наименование журнала', - 'headerClasses': 'text-center', # заголовок по центру - # ячейки по умолчанию left + 'field': 'Наименование журнала', 'headerClasses': 'text-center', 'style': 'max-width:280px; white-space:normal;'}, - # центр и в ячейках, и в заголовке {'name': 'issns', 'label': 'ISSN', 'field': 'issns', 'align': 'center', 'headerClasses': 'text-center', 'style': 'width:120px; white-space:normal;'}, - {'name': 'Специализации', 'label': 'Специализации', - 'field': 'Специализации', 'headerClasses': 'text-center', - 'style': 'max-width:320px; white-space:normal;'}, + {'name': 'Специализации', 'label': 'Специализации', 'field': 'Специализации', + 'headerClasses': 'text-center', 'style': 'max-width:320px; white-space:normal;'}, - {'name': 'Уровень журнала', 'label': 'Уровень', - 'field': 'Уровень журнала', 'headerClasses': 'text-center', - 'style': 'width:90px;'}, + {'name': 'Уровень журнала', 'label': 'Уровень', 'field': 'Уровень журнала', + 'headerClasses': 'text-center', 'style': 'width:90px;'}, {'name': 'WOS', 'label': 'WOS', 'field': 'WOS', - 'align': 'center', 'headerClasses': 'text-center', - 'style': 'width:70px;'}, + 'align': 'center', 'headerClasses': 'text-center', 'style': 'width:70px;'}, {'name': 'Scopus', 'label': 'Scopus', 'field': 'Scopus', - 'align': 'center', 'headerClasses': 'text-center', - 'style': 'width:70px;'}, + 'align': 'center', 'headerClasses': 'text-center', 'style': 'width:70px;'}, {'name': 'RSCI', 'label': 'RSCI', 'field': 'RSCI', - 'align': 'center', 'headerClasses': 'text-center', - 'style': 'width:70px;'}, + 'align': 'center', 'headerClasses': 'text-center', 'style': 'width:70px;'}, ] cols_simple = [ {'name': 'N', 'label': 'ID', 'field': 'N', - 'align': 'center', 'headerClasses': 'text-center', - 'style': 'width:60px;'}, + 'align': 'center', 'headerClasses': 'text-center', 'style': 'width:60px;'}, {'name': 'title', 'label': 'Название', 'field': 'title', - 'headerClasses': 'text-center', - 'style': 'max-width:320px; white-space:normal;'}, + 'headerClasses': 'text-center', 'style': 'max-width:320px; white-space:normal;'}, {'name': 'issn', 'label': 'ISSN', 'field': 'issn', - 'align': 'center', 'headerClasses': 'text-center', - 'style': 'width:120px;'}, + 'align': 'center', 'headerClasses': 'text-center', 'style': 'width:120px;'}, {'name': 'specialties', 'label': 'Специализации', 'field': 'specialties', - 'headerClasses': 'text-center', - 'style': 'max-width:340px; white-space:normal;'}, + 'headerClasses': 'text-center', 'style': 'max-width:340px; white-space:normal;'}, ] - # кнопки-переключатели with btn_row: b_art = ui.button('ВАК-статьи', on_click=lambda: [show_table(stringify_lists(vak_articles), cols_simple, xlsx_articles), highlight(b_art)]).props('color=secondary') b_flt = ui.button('Фильтр', on_click=lambda: [show_table(stringify_lists(vak_filters), cols_simple, xlsx_filters), highlight(b_flt)]).props('color=secondary') @@ -177,44 +150,33 @@ async def run(): highlight(b_res) show_table(data, cols_data, xlsx_data) - spin.classes(add='hidden'); run_btn.enable() - specs_selected: list[dict] = [] def show_spec(opts): spec_box.clear() if not opts: return - spec_box.classes(remove='hidden') run_btn.classes(remove='hidden') specs_selected.clear() - - ui.select( - opts, - label='Научные специальности', - multiple=True - ).classes('w-96').on( - 'update:model-value', - lambda e: (specs_selected.clear(), specs_selected.extend(e.args)) - ) + ui.select(opts, label='Научные специальности', multiple=True).classes('w-96') \ + .on('update:model-value', lambda e: (specs_selected.clear(), specs_selected.extend(e.args))) def sub_changed(cat): if cat['label'] == 'Выбрать...': sub_box.classes('hidden'); spec_box.clear(); return sub_box.classes(remove='hidden'); spec_box.classes(add='hidden'); run_btn.classes(add='hidden') sub_box.clear() - def on_sub(e): sub = e.args if sub['label'] == 'Выбрать...': spec_box.clear(); return show_spec(get_specs(cat, sub)) - with sub_box: - ui.select(get_sub(cat), label='Подкатегория').classes('w-96').on('update:model-value', on_sub) + ui.select(get_sub(cat), label='Подкатегория').classes('w-96') \ + .on('update:model-value', on_sub) cat_sel.on('update:model-value', lambda e: sub_changed(e.args)) - run_btn.on('click', run) + run_btn.on('click', run) \ No newline at end of file