from __future__ import annotations
from typing import Any, cast, Dict, List, TYPE_CHECKING, Tuple
import contextlib
import unohelper
from com.sun.star.container import XContainerListener
from com.sun.star.drawing import XControlShape
from com.sun.star.form import XForm
from com.sun.star.lang import XComponent
from ooo.dyn.drawing.text_vertical_adjust import TextVerticalAdjust
from ooo.dyn.beans.property_attribute import PropertyAttributeEnum
from ooo.dyn.awt.size import Size
from ooodev.calc.calc_cell import CalcCell
from ooodev.calc.cell.custom_prop_base import CustomPropBase
from ooodev.form.controls.form_ctl_hidden import FormCtlHidden
from ooodev.utils import gen_util as gUtil
from ooodev.utils import props as mProps
from ooodev.utils.gen_util import NULL_OBJ
from ooodev.utils.helper.dot_dict import DotDict
if TYPE_CHECKING:
from com.sun.star.container import ContainerEvent
from com.sun.star.container import XContainer
from com.sun.star.drawing import ControlShape
from com.sun.star.lang import EventObject
from ooodev.loader.inst.lo_inst import LoInst
# Because the shape that is added to the cell is relative to the cell then when the cell moves the shape moves with it.
# The shapes anchor is a reference to the cell. When the cell is moved the shape Anchor is updated.
# The cell shape contains a reference to the hidden control that holds the custom properties.
# Known Issues:
# 1. When a cell is copied and pasted the custom properties are not copied. This is by design.
# However, when cell is copied it will copy the shape that is in the cell. The duplicated shape will be automatically removed then the next call to original cell or the copied cell for custom properties.
#
# 2. When a cell is deleted the hidden control for the custom properties are not deleted.
# This is because the hidden control is not part of the cell but part of the form.
# This has no ill effect on the document. To remove all the hidden controls at one the CellCustomProperties Form could be deleted. Although not recommended.
# Removing the form would not remove the shapes. It would be possible for a developer to monitor the sheet and manually remove the hidden controls.
# This is not really an issue because the hidden controls are not visible and do not effect the document.
# See Also: https://ask.libreoffice.org/t/how-to-detect-cell-delete-event/106250
# Also There is a ooodev.calc.cell.custom_prop_clean.CustomPropClean that can be used to clean up the hidden controls if needed.
# this could also be done on a document saving event or other event if needed
[docs]class CustomProp(CustomPropBase):
"""A partial class for Calc Cell custom properties."""
[docs] class ContainerListener(unohelper.Base, XContainerListener):
[docs] def __init__(
self, form_name: str, cp: CustomProp, lo_inst: LoInst, subscriber: XContainer | None = None
) -> None:
super().__init__()
self._form_name = form_name
self._cp = cp
self._lo_inst = lo_inst
if subscriber:
subscriber.addContainerListener(self)
[docs] def reset(self) -> None:
self._cp._reset()
# region XContainerListener
[docs] def elementInserted(self, event: ContainerEvent) -> None:
"""
Event is invoked when a container has inserted an element.
"""
# replaced element should be a form
if self.is_element_monitored_form(event.Element):
self.reset()
[docs] def elementRemoved(self, event: ContainerEvent) -> None:
"""
Event is invoked when a container has removed an element.
"""
if self.is_element_monitored_form(event.Element):
self.reset()
[docs] def elementReplaced(self, event: ContainerEvent) -> None:
"""
Event is invoked when a container has replaced an element.
"""
if self.is_element_monitored_form(event.ReplacedElement):
self.reset()
[docs] def disposing(self, event: EventObject) -> None:
"""
Gets called when the broadcaster is about to be disposed.
All listeners and all other objects, which reference the broadcaster
should release the reference to the source. No method should be invoked
anymore on this object ( including ``XComponent.removeEventListener()`` ).
This method is called for every listener registration of derived listener
interfaced, not only for registrations at ``XComponent``.
"""
self.reset()
@property
def lo_inst(self) -> LoInst:
return self._lo_inst
[docs] def __init__(self, cell: CalcCell) -> None:
CustomPropBase.__init__(self, cell.calc_sheet)
self._cell = cell
self._forbidden_keys = set(("HiddenValue", "Name", "ClassId", "Tag"))
self._attribute_name = "CustomPropertiesId"
self._ctl_name = None
self._row = self._cell.cell_obj.row - 1
self._col = self._cell.cell_obj.col_obj.index
self._container_listener: CustomProp.ContainerListener
self._container_listener = CustomProp.ContainerListener(
form_name=self._form_name, cp=self, lo_inst=self._cell.lo_inst, subscriber=self._draw_page.forms.component
)
# region Manage Cell Shape
def _get_control_id(self) -> Tuple[XControlShape, str]:
key = "control_id"
if key in self.cache:
return self.cache[key]
shape = self._find_shape_by_cell_row_col(self._row, self._col)
if shape is None:
shape, s = self._add_shape_to_cell()
return shape, s
s = self._get_hidden_control_name_from_shape(shape)
self.cache[key] = shape, s
return shape, s
def _get_shapes_dict(self) -> Dict[str, List[XControlShape]]:
comp = self.draw_page.component
shapes = {}
# find all shapes on the draw page that start with prefix and end with suffix
for shape in comp: # type: ignore
if not hasattr(shape, "supportsService"):
continue
if not shape.supportsService("com.sun.star.drawing.ControlShape"):
continue
try:
name = cast(str, shape.Name)
except AttributeError:
continue
if name.startswith(self.shape_prefix) and name.endswith(self.shape_suffix):
if name in shapes:
shapes[name].append(shape)
else:
shapes[name] = [shape]
return shapes
def _find_shape_by_cell_row_col(self, row: int, col: int) -> XControlShape | None:
# When a cell has been copied and pasted there will be a duplicate shape in the dest cell.
# For this reason a small cleanup is done if needed to remove any duplicate shapes.
# The duplicate shapes will have a higher z-order then the original shape. The higher z-order shapes are removed.
# Depending on environment there may not be duplicates with the exact shape name. It may be an artifact such as '_cprop_idhdkuy07hizr3eh_id 1'
# In this case the artifact is removed when the cell custom properties that contains the artifact is accessed.
key = f"shape_{row}_{col}"
if key in self.cache:
return self.cache[key]
comp = self.draw_page.component
found_shape = None
cleanup = []
for shape in comp: # type: ignore
if not hasattr(shape, "supportsService"):
# Added in Version 0.47.16
# Some shapes do not have a supportsService attribute. Just ignore them here.
continue
if not shape.supportsService("com.sun.star.drawing.ControlShape"):
continue
if not hasattr(shape, "Name"):
# Added in Version 0.47.15
# Some shapes do not have a Name attribute. Just ignore them here.
continue
anchor = shape.Anchor
if anchor is None:
continue
if anchor.getImplementationName() != "ScCellObj":
continue
if not shape.Name.startswith(self.shape_prefix):
continue
cell_address = anchor.CellAddress
if cell_address.Row == row and cell_address.Column == col:
if shape.Name.endswith(self.shape_suffix):
if found_shape is None:
found_shape = shape
else:
cleanup.append(shape)
if found_shape is None:
if cleanup:
for shape in cleanup:
with contextlib.suppress(Exception):
self._draw_page.remove(shape)
cleanup.clear()
return None
def get_result(shp: Any) -> Any:
nonlocal row, col
anchor = shp.Anchor
if anchor is None:
return None
if anchor.getImplementationName() != "ScCellObj":
return None
cell_address = anchor.CellAddress
if cell_address.Row == row and cell_address.Column == col:
# the current shape may be the original shape in another cell.
# That means this cell is a copy.
return shp
return None
result = None
shapes_dict = self._get_shapes_dict()
shapes = shapes_dict[found_shape.Name]
if len(shapes) == 1:
result = found_shape
else:
# sort shapes by zorder
found_shape = None
result = None
shapes.sort(key=lambda x: x.ZOrder) # type: ignore
# if there is more then one shape then the first shape is the original
# if cleanup is not empty at this point then it will contain artifacts such as '_cprop_idhdkuy07hizr3eh_id 1' that need to be removed.
cleanup.extend(shapes[1:])
result = get_result(shapes[0])
if cleanup:
for shape in cleanup:
with contextlib.suppress(Exception):
self.draw_page.remove(shape)
if result:
self.cache[key] = result
return result
def _create_shape(self) -> XControlShape:
shape = cast(
"ControlShape",
self.sheet.lo_inst.create_instance_msf(XControlShape, "com.sun.star.drawing.ControlShape", raise_err=True),
)
# Setting shape.Visible does not work here. It does work after shape has been added to the draw page.
# shape.setPropertyValue("Visible", False)
# shape.Visible = False
return shape
def _add_shape_to_cell(self) -> Tuple[XControlShape, str]:
# There is a strange issue, maybe a bug, when the shape is added the the cell the sheet must be activated.
# If the sheet is not active when the shape is added then everything seems to work fine until you try to save the document.
# The document will hang and not save.
# Testing is showing that by activate the sheet before adding the shape the issue is resolved.
# This also works in headless mode.
# Once a cell has a shape for the custom properties it will not be added again and this is no longer an issue.
active_sheet = self.sheet.calc_doc.get_active_sheet()
activated = False
if active_sheet.name != self.sheet.name:
activated = True
self.sheet.calc_sheet.set_active()
shape = cast("ControlShape", self._create_shape())
str_id = "id" + gUtil.Util.generate_random_alpha_numeric(14).lower()
shape.Name = f"{self.shape_prefix}{str_id}{self.shape_suffix}" # type: ignore
self.draw_page.add(shape)
# note setting visible to true here cause the document to hang when be saved. This is a critical failure.
shape.Anchor = self._cell.component # type: ignore
shape_key_val = {
"Decorative": False,
"TextVerticalAdjust": TextVerticalAdjust.CENTER,
"HoriOrient": 0,
"MoveProtect": False,
"Printable": False,
"ResizeWithCell": True,
"SizeProtect": False,
"Visible": True,
}
for key, val in shape_key_val.items():
if hasattr(shape, key):
setattr(shape, key, val)
shape.setSize(Size(1, 1))
if activated:
active_sheet.set_active()
return shape, str_id
# endregion Manage Cell Shape
# region Manage Hidden Control
def _get_hidden_control(self) -> FormCtlHidden:
_, ctl_name = self._get_control_id()
key = f"hidden_ctl_{ctl_name}"
if key in self.cache:
return cast(FormCtlHidden, self.cache[key])
frm = self._get_form()
if not frm.hasByName(ctl_name):
comp = self._cell.lo_inst.create_instance_mcf(
XComponent, "com.sun.star.form.component.HiddenControl", raise_err=True
)
comp.HiddenValue = "" # type: ignore
frm.insertByName(ctl_name, comp)
ctl = FormCtlHidden(frm.getByName(ctl_name), self.sheet.lo_inst)
self.cache[key] = ctl
return ctl
# endregion Manage Hidden Control
# region Property Access
[docs] def get_custom_property(self, name: str, default: Any = NULL_OBJ) -> Any:
"""
Gets a custom property.
Args:
name (str): The name of the property.
default (Any, optional): The default value to return if the property does not exist.
Raises:
AttributeError: If the property is not found.
Returns:
Any: The value of the property.
"""
ctl = self._get_hidden_control()
info = ctl.get_property_set_info()
if info.hasPropertyByName(name):
return ctl.get_property(name)
if default is not NULL_OBJ:
return default
raise AttributeError(f"Property '{name}' not found.")
[docs] def get_custom_properties(self) -> DotDict:
"""
Gets custom properties.
Returns:
DotDict: custom properties.
Hint:
DotDict is a class that allows you to access dictionary keys as attributes or keys.
DotDict can be imported from ``ooodev.utils.helper.dot_dict.DotDict``.
"""
ctl = self._get_hidden_control()
props = ctl.get_property_values()
lst = []
for prop in props:
if prop.Name not in self._forbidden_keys:
lst.append(prop)
return mProps.Props.props_to_dot_dict(lst)
[docs] def has_custom_property(self, name: str) -> bool:
"""
Gets if a custom property exists.
Args:
name (str): The name of the property to check.
Returns:
bool: ``True`` if the property exists, otherwise ``False``.
"""
key = self.form_name
if key not in self.cache:
# form has not been loaded yet, It may not exist
if not self.draw_page.forms.has_by_name(self._form_name):
# if there is no form there is no properties for any cell yet.
return False
key = "control_id"
if key not in self.cache:
# shape has not been loaded
shape = self._find_shape_by_cell_row_col(self._row, self._col)
# if the cell has no shape it has no properties
if shape is None:
return False
ctl = self._get_hidden_control()
info = ctl.get_property_set_info()
return info.hasPropertyByName(name)
[docs] def has_custom_properties(self) -> bool:
"""
Gets if a custom property exists.
Args:
name (str): The name of the property to check.
Returns:
bool: ``True`` if the property exists, otherwise ``False``.
"""
key = self.form_name
if key not in self.cache:
# form has not been loaded yet, It may not exist
if not self.draw_page.forms.has_by_name(self.form_name):
# if there is no form there is no properties for any cell yet.
return False
key = "control_id"
shape = None
if key not in self.cache:
# shape has not been loaded
shape = self._find_shape_by_cell_row_col(self._row, self._col)
# if the cell has no shape it has no properties
if shape is None:
return False
hidden = self._get_hidden_control_name_from_shape(shape)
if hidden is None:
return False
props = self.get_custom_properties()
return len(props) > 0
[docs] def set_custom_property(self, name: str, value: Any):
"""
Sets a custom property.
Args:
name (str): The name of the property.
value (Any): The value of the property.
Raises:
AttributeError: If the property is a forbidden key.
"""
if name in self._forbidden_keys:
raise AttributeError(f"Property '{name}' is forbidden. Forbidden keys: {self._forbidden_keys}")
ctl = self._get_hidden_control()
info = ctl.get_property_set_info()
if info.hasPropertyByName(name):
ctl.remove_property(name)
ctl.add_property(name, PropertyAttributeEnum.REMOVABLE, value)
[docs] def set_custom_properties(self, properties: DotDict) -> None:
"""
Sets custom properties.
Args:
properties (DotDict): custom properties to set.
Hint:
DotDict is a class that allows you to access dictionary keys as attributes or keys.
DotDict can be imported from ``ooodev.utils.helper.dot_dict.DotDict``.
"""
for name, value in properties.items():
self.set_custom_property(name, value)
[docs] def remove_custom_property(self, name: str) -> None:
"""
Removes a custom property.
Args:
name (str): The name of the property to remove.
Raises:
AttributeError: If the property is a forbidden key.
Returns:
None:
"""
if name in self._forbidden_keys:
raise AttributeError(f"Property '{name}' is forbidden. Forbidden keys: {self._forbidden_keys}")
ctl = self._get_hidden_control()
info = ctl.get_property_set_info()
if info.hasPropertyByName(name):
ctl.remove_property(name)
[docs] def remove_custom_properties(self) -> None:
"""
Removes all custom properties.
Returns:
None:
"""
# remove hidden control
# remove form if it is empty
# remove shape
forms = self.draw_page.forms
if forms.has_by_name(self.form_name):
_, ctl_id = self._get_control_id()
form = forms[self.form_name]
form.remove_by_name(ctl_id)
if not form.has_elements():
forms.remove_by_name(self.form_name)
form = None
shape = self._find_shape_by_cell_row_col(self._row, self._col)
if shape:
self._draw_page.remove(shape)
shape = None
# endregion Property Access
def __del__(self) -> None:
with contextlib.suppress(Exception):
comp = self._draw_page.forms.component
if self._container_listener and comp:
comp.removeContainerListener(self._container_listener)
self._container_listener = None # type: ignore
super().__del__()