Source code for losalamos.figures

# SPDX-License-Identifier: GPL-3.0-or-later
#
# Copyright (C) 2025 The Project Authors
# See pyproject.toml for authors/maintainers.
# See LICENSE for license details.
"""
{Short module description (1-3 sentences)}
todo docstring

"""
# IMPORTS
# ***********************************************************************
# import modules from other libs

# Native imports
# =======================================================================
import os
import pprint
import shutil
from pathlib import Path

# ... {develop}

# External imports
# =======================================================================
from PIL import Image, ImageOps
from lxml import etree

# ... {develop}

# Project-level imports
# =======================================================================
from losalamos.root import DataSet


# ... {develop}


# CONSTANTS
# ***********************************************************************
# define constants in uppercase

# CONSTANTS -- Project-level
# =======================================================================
# ... {develop}

# CONSTANTS -- Module-level
# =======================================================================
# ... {develop}


# FUNCTIONS
# ***********************************************************************

# FUNCTIONS -- Project-level
# =======================================================================
# ... {develop}

# FUNCTIONS -- Module-level
# =======================================================================
# ... {develop}


# CLASSES
# ***********************************************************************

# CLASSES -- Project-level
# =======================================================================


[docs] class Figure(DataSet): # todo docstring def __init__(self, name="MyFig", alias="Fig"): super().__init__(name=name, alias=alias)
[docs] def load_data(self, file_data): self.file_data = Path(file_data).absolute()
[docs] @staticmethod def scale_image(file_input, file_output, scale_factor, dpi=300): """ Scale an image by a numerical factor while maintaining its original aspect ratio. :param file_input: Path to the source image file. :type file_input: str :param file_output: Path where the scaled image will be saved. :type file_output: str :param scale_factor: Multiplier for the image dimensions (e.g., 0.5 for half size). :type scale_factor: float :param dpi: Resolution in dots per inch for the output file metadata. Default value = ``300`` :type dpi: int :return: No value is returned. :rtype: None .. note:: The resizing process utilizes the ``Image.Resampling.LANCZOS`` filter to ensure high-quality downsampling or upsampling. The output is saved with a fixed quality compression of ``95``. """ img = Image.open(file_input) # Compute new dimensions while maintaining aspect ratio new_width = int(img.width * scale_factor) new_height = int(img.height * scale_factor) # Resize image using high-quality resampling img = img.resize((new_width, new_height), Image.Resampling.LANCZOS) # Save the image with 300 DPI img.save(file_output, dpi=(dpi, dpi), quality=95) return None
[docs] @staticmethod def make_thumbnail( file_input, file_output, size=(512, 512), figure_ratio=None, mode="crop", dpi=300, quality=85, background=(255, 255, 255), ): """ Generate a lightweight JPEG thumbnail from an input image with resizing options. :param file_input: Path to the source image file. :type file_input: str :param file_output: Path where the generated thumbnail will be saved. :type file_output: str :param size: Target dimensions for the output image. Default value = ``(512, 512)`` :type size: tuple :param figure_ratio: [optional] Aspect ratio used to calculate target height from width. :type figure_ratio: tuple :param mode: Resizing strategy, either ``crop`` to fill dimensions or ``fit`` to pad. Default value = ``crop`` :type mode: str :param dpi: Resolution in dots per inch for the output file. Default value = ``300`` :type dpi: int :param quality: JPEG compression quality from 1 to 100. Default value = ``85`` :type quality: int :param background: RGB color used for padding when mode is ``fit``. Default value = ``(255, 255, 255)`` :type background: tuple :return: No value is returned. :rtype: None .. note:: The function automatically converts non-compatible image modes to ``RGB`` to ensure JPEG compatibility. If ``figure_ratio`` is provided as ``(width, height)``, it overrides the height specified in the ``size`` parameter. """ img = Image.open(file_input) # Ensure compatibility with JPEG if img.mode not in ("RGB", "L"): img = img.convert("RGB") target_w, target_h = size # Override size using figure ratio if requested if figure_ratio is not None: rw, rh = figure_ratio target_h = int(target_w * rh / rw) if mode == "crop": img = ImageOps.fit( img, (target_w, target_h), method=Image.Resampling.LANCZOS, centering=(0.5, 0.5), ) elif mode == "fit": img.thumbnail( (target_w, target_h), resample=Image.Resampling.LANCZOS, ) # Optional padding to exact size canvas = Image.new("RGB", (target_w, target_h), background) offset = ( (target_w - img.width) // 2, (target_h - img.height) // 2, ) canvas.paste(img, offset) img = canvas else: raise ValueError("mode must be 'crop' or 'fit'") img.save( file_output, format="JPEG", quality=quality, optimize=True, dpi=(dpi, dpi), ) return None
[docs] @staticmethod def image_to_jpeg(file_input, file_output, quality=95, dpi=300): """ Convert an image file to JPEG format with specified quality and resolution. :param file_input: Path to the input image file. :type file_input: str :param file_output: Path where the output JPEG will be saved. :type file_output: str :param quality: Compression quality from 1 to 100. Default value = ``95`` :type quality: int :param dpi: Resolution in dots per inch. Default value = ``300`` :type dpi: int :return: None :rtype: NoneType """ with Image.open(file_input) as img: # Convert to RGB if the image has an alpha channel if img.mode != "RGB": img = img.convert("RGB") # Save with specified quality and DPI save_params = {"format": "JPEG", "quality": quality} if dpi: save_params["dpi"] = (dpi, dpi) img.save(file_output, **save_params) return None
[docs] class FigureSVG(Figure): def __init__(self, name="MySVG", alias="SVG"): super().__init__(name=name, alias=alias) self.tree = None # set defaults # -------------------------------------------------- self.inkscape_src = "C:/Program Files/Inkscape/bin" # None # inkscape.exe folder in Windows (consider add to PATH). self.name_spaces = { "svg": "http://www.w3.org/2000/svg", "inkscape": "http://www.inkscape.org/namespaces/inkscape", "sodipodi": "http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd", } def _get_namedview(self): namedview = self.data.find("sodipodi:namedview", namespaces=self.name_spaces) if namedview is None: namedview = etree.SubElement( self.data, f"{{{self.name_spaces['sodipodi']}}}namedview", id="namedview1", ) return namedview
[docs] def load_data(self, file_data): """ Load and parse SVG XML data from a file path into the object instance. :param file_data: The file system path pointing to the source SVG data. :type file_data: str :return: No value is returned. :rtype: None .. important:: This method converts the input path to an absolute path and utilizes an ``etree.XMLParser`` with ``huge_tree`` enabled to handle large datasets. It populates the ``tree`` and ``data`` attributes before triggering an internal ``update`` call. """ # overwrite relative path input # -------------------------------------------------- self.file_data = Path(file_data).absolute() # load tree # -------------------------------------------------- parser = etree.XMLParser(huge_tree=True) self.tree = etree.parse(self.file_data, parser) # get the root # -------------------------------------------------- self.data = self.tree.getroot() # update # -------------------------------------------------- self.update() # ... continues in downstream objects ... # return None
[docs] def save(self): """ Serializes the current XML tree and writes it to the local file system. :return: Always returns ``None`` :rtype: None """ xml_str = etree.tostring( self.tree, encoding="utf-8", xml_declaration=True, pretty_print=False ) with open(self.file_data, "wb") as f: f.write(xml_str) return None
''' # keeping this code block in case bugs in save() arise again def save_old(self): """ Serializes the current XML tree and writes it to the local file system. :return: Always returns ``None`` :rtype: None """ root = self.tree.getroot() xml_str = etree.tostring( root, encoding="utf-8", xml_declaration=True, pretty_print=True ) with open(self.file_data, "wb") as f: f.write(xml_str) return None '''
[docs] def export(self, folder, filename, data_suffix=None): """ Saves the current SVG data to a specific directory with an optional filename suffix. :param folder: The destination directory path :type folder: str :param filename: The base name of the output file :type filename: str :param data_suffix: [optional] An additional string appended to the filename :type data_suffix: str :return: Always returns ``None`` :rtype: None .. note:: This method temporarily overrides the internal ``file_data`` path to execute the save operation before restoring the original path. """ if data_suffix is None: data_suffix = "" output_file = str(Path(folder) / f"{filename}{data_suffix}.svg") original_file = str(self.file_data)[:] self.file_data = output_file self.save() self.file_data = Path(original_file[:]) return None
[docs] def hide_layer(self, label="frames"): """ Modifies the style attribute of a specific layer to make it invisible. :param label: The Inkscape label of the layer to hide. Default value = ``frames`` :type label: str :return: Always returns ``None`` :rtype: None """ layer = self.data.find( f".//svg:g[@inkscape:label='{label}']", namespaces=self.name_spaces ) layer.set("style", "display:none") # Hide the layer return None
[docs] def hide_layers(self, labels): """ Hide multiple layers based on a list of provided labels. :param labels: A list of layer labels to be hidden. :type labels: list :return: None :rtype: NoneType """ for lbl in labels: self.hide_layer(label=lbl) return None
[docs] def show_layer(self, label="frames"): """ Modifies the style attribute of a specific layer to make it visible. :param label: The Inkscape label of the layer to display. Default value = ``frames`` :type label: str :return: Always returns ``None`` :rtype: None """ layer = self.data.find( f".//svg:g[@inkscape:label='{label}']", namespaces=self.name_spaces ) layer.set("style", "display:inline") # Show the layer return None
[docs] def show_layers(self, labels, inclusive=True): """ Show specified layers and optionally hide all others. :param labels: A list of layer labels to be made visible. :type labels: list :param inclusive: Determines if other layers stay visible or are hidden. Default value = ``True`` :type inclusive: bool :return: None :rtype: NoneType .. note:: If ``inclusive`` is set to ``True``, the specified layers are made visible without affecting others. If ``False``, the method performs a "show only" operation by hiding any layer not present in the ``labels`` list. """ if inclusive: for lbl in labels: self.show_layer(label=lbl) else: all_layers = self.get_layers_labels() for lbl in all_layers and lbl not in labels: self.hide_layer(label=lbl) return None
[docs] def get_layers(self): """ Return a dictionary of top-level Inkscape layers indexed by their label. :return: Dictionary mapping layer labels to lxml Elements :rtype: dict[str, etree.Element] .. note:: Only <g> elements that are direct children of the root <svg> element and have inkscape:groupmode="layer" are considered. """ if self.data is None: return {} layers = self.data.findall( "svg:g[@inkscape:groupmode='layer']", namespaces=self.name_spaces, ) label_attr = f"{{{self.name_spaces['inkscape']}}}label" layer_dc = {} for layer in layers: label = layer.get(label_attr) # Skip layers without labels (defensive) if label is None: continue # If duplicate labels exist, last one wins layer_dc[label] = layer return layer_dc
[docs] def get_layers_labels(self): """ Return the list of layers labels """ dc = self.get_layers() return list(dc.keys())
[docs] def set_page_opacity(self, opacity=1.0): """ Set the Inkscape page background opacity. :param opacity: Opacity value in range [0.0, 1.0] (1=opaque) :type opacity: float """ opacity = max(0.0, min(1.0, float(opacity))) namedview = self._get_namedview() namedview.set( f"{{{self.name_spaces['inkscape']}}}pageopacity", f"{opacity:.6f}", ) return None
[docs] def set_page_color(self, color="#ffffff"): """ Set the Inkscape page background color. :param color: Hex color string (e.g. '#ffffff') :type color: str """ namedview = self._get_namedview() namedview.set("pagecolor", color) return None
[docs] def set_element_color(self, element, fill=None, stroke=None): """ Set fill and/or stroke color of an SVG element. :param element: lxml SVG element :param fill: Fill color (e.g. '#00ff00') or None :param stroke: Stroke color or None """ style = self._parse_style(element.get("style")) if fill is not None: style["fill"] = fill if stroke is not None: style["stroke"] = stroke element.set("style", self._style_to_string(style)) return None
[docs] def get_layer_elements(self, label, drawable_only=True): """ Return drawable SVG elements from a layer, indexed by element ID. :param label: Inkscape layer label :param drawable_only: Exclude non-drawable tags (image, defs, etc.) :return: dict[str, dict] """ layer = self.get_layers().get(label) if layer is None: raise ValueError(f"Layer '{label}' not found") drawable_tags = { "rect", "path", "circle", "ellipse", "line", "polyline", "polygon", "text", } out = {} for el in layer.findall(".//*", namespaces=self.name_spaces): tag = etree.QName(el).localname if drawable_only and tag not in drawable_tags: continue el_id = el.get("id") if el_id is None: # Skip elements without IDs (Inkscape usually assigns one) continue # Defensive: last one wins (should not happen in Inkscape) out[el_id] = { "element": el, "tag": tag, } return out
[docs] def to_image( self, file_output=None, dpi=300, crop_id=None, hide_layers=None, show_layers=None, show_inclusive=True, to_jpeg=False, remove_png=True, ): """ Export the current drawing as an image file, optionally adjusting layers and format. :param file_output: [optional] The destination path for the exported image. If None, it defaults to the source file name. :type file_output: str or :class:`pathlib.Path` :param dpi: Dots per inch for the export resolution. Default value = ``300`` :type dpi: int :param crop_id: [optional] The specific object ID to crop instead of the whole page. :type crop_id: str :param hide_layers: [optional] List of layer labels to set to hidden before export. :type hide_layers: list :param show_layers: [optional] List of layer labels to set to visible before export. :type show_layers: list :param show_inclusive: Whether to show layers inclusively when using ``show_layers``. Default value = ``True`` :type show_inclusive: bool :param to_jpeg: Convert the final output from PNG to JPEG format. Default value = ``False`` :type to_jpeg: bool :param remove_png: Delete the intermediate PNG file if ``to_jpeg`` is True. Default value = ``True`` :type remove_png: bool :return: The path to the generated image file. :rtype: :class:`pathlib.Path` .. warning:: This method requires ``inkscape`` to be installed and available in the system PATH. .. note:: The method modifies layer visibility before export based on the provided labels. It saves the current state of the drawing to disk before calling Inkscape via a subprocess to perform the rendering. """ import subprocess import tempfile # handle output file # ----------------------------------------------------------------------- if file_output is None: file_output = self.file_data.parent / str(self.file_data.stem + ".png") # handle svg file copy # ----------------------------------------------------------------------- temp_folder = tempfile.mkdtemp(prefix="losalamos_svg_") dst_file = Path(temp_folder) / "losalamos.svg" src_file = self.file_data shutil.copy(src=self.file_data, dst=dst_file) self.file_data = dst_file # Get abspath file_output = Path(file_output).resolve() # handle visibility of layers # ----------------------------------------------------------------------- b_save = False if hide_layers is not None: self.hide_layers(labels=hide_layers) b_save = True if show_layers is not None: self.show_layers(labels=hide_layers, inclusive=show_inclusive) b_save = True # save to temporary file if b_save: self.save() # set return file return_file = file_output # build command # ----------------------------------------------------------------------- cmd = [ "inkscape", self.file_data, "--export-type=png", "--export-dpi={}".format(dpi), "--export-filename={}".format(file_output), ] if crop_id is not None: cmd = cmd + ["--export-id={}".format(crop_id)] # call inkscape process # ----------------------------------------------------------------------- subprocess.run(cmd) # handle jpg conversion # ----------------------------------------------------------------------- if to_jpeg: new_name = file_output.stem + ".jpeg" new_file = Path(file_output.parent / new_name) self.image_to_jpeg(file_input=file_output, file_output=new_file, dpi=dpi) if remove_png: os.remove(file_output) # reset return file return_file = new_file # restore file data and cleanup # ----------------------------------------------------------------------- self.file_data = src_file # print(temp_folder) shutil.rmtree(temp_folder) return return_file
@staticmethod def _parse_style(style_str): """ Parse an SVG style string into a dict. """ if not style_str: return {} return dict(item.split(":", 1) for item in style_str.split(";") if ":" in item) @staticmethod def _style_to_string(style_dict): """ Serialize a style dict back to a SVG style string. """ return ";".join(f"{k}:{v}" for k, v in style_dict.items())
# ... {develop} # SCRIPT # *********************************************************************** # standalone behaviour as a script if __name__ == "__main__": # Script section # =================================================================== print("Hello world!") # ... {develop} # Script subsection # ------------------------------------------------------------------- # ... {develop}