diff --git a/docs/HISTORY.rst b/docs/HISTORY.rst index 01c2cb1d..23d423a7 100644 --- a/docs/HISTORY.rst +++ b/docs/HISTORY.rst @@ -4,6 +4,8 @@ Changelog 1.0.0dev0 (unreleased) ---------------------- +- Add support for ScreenOverlay + 1.0 (2024/11/19) ----------------- diff --git a/fastkml/__init__.py b/fastkml/__init__.py index b607e08f..d17b4a43 100644 --- a/fastkml/__init__.py +++ b/fastkml/__init__.py @@ -46,6 +46,7 @@ from fastkml.links import Link from fastkml.overlays import GroundOverlay from fastkml.overlays import PhotoOverlay +from fastkml.overlays import ScreenOverlay from fastkml.styles import BalloonStyle from fastkml.styles import IconStyle from fastkml.styles import LabelStyle @@ -72,6 +73,7 @@ "PhotoOverlay", "Schema", "SchemaData", + "ScreenOverlay", "StyleUrl", "Style", "StyleMap", diff --git a/fastkml/containers.py b/fastkml/containers.py index 5041d219..8d0f6cfe 100644 --- a/fastkml/containers.py +++ b/fastkml/containers.py @@ -41,6 +41,7 @@ from fastkml.helpers import xml_subelement_list_kwarg from fastkml.overlays import GroundOverlay from fastkml.overlays import PhotoOverlay +from fastkml.overlays import ScreenOverlay from fastkml.registry import RegistryItem from fastkml.registry import registry from fastkml.styles import Style @@ -55,6 +56,8 @@ logger = logging.getLogger(__name__) +__all__ = ["Document", "Folder"] + KmlGeometry = Union[ Point, LineString, @@ -328,8 +331,19 @@ def get_style_by_url(self, style_url: str) -> Optional[Union[Style, StyleMap]]: RegistryItem( ns_ids=("kml",), attr_name="features", - node_name="Folder,Placemark,Document,GroundOverlay,PhotoOverlay,NetworkLink", - classes=(Document, Folder, Placemark, GroundOverlay, PhotoOverlay, NetworkLink), + node_name=( + "Folder,Placemark,Document,GroundOverlay,PhotoOverlay,ScreenOverlay," + "NetworkLink" + ), + classes=( + Document, + Folder, + Placemark, + GroundOverlay, + PhotoOverlay, + ScreenOverlay, + NetworkLink, + ), get_kwarg=xml_subelement_list_kwarg, set_element=xml_subelement_list, ), diff --git a/fastkml/overlays.py b/fastkml/overlays.py index ab6a9551..acc5be4a 100644 --- a/fastkml/overlays.py +++ b/fastkml/overlays.py @@ -30,6 +30,7 @@ from fastkml.enums import AltitudeMode from fastkml.enums import GridOrigin from fastkml.enums import Shape +from fastkml.enums import Units from fastkml.features import Snippet from fastkml.features import _Feature from fastkml.geometry import LinearRing @@ -37,8 +38,12 @@ from fastkml.geometry import MultiGeometry from fastkml.geometry import Point from fastkml.geometry import Polygon +from fastkml.helpers import attribute_enum_kwarg +from fastkml.helpers import attribute_float_kwarg from fastkml.helpers import clean_string +from fastkml.helpers import enum_attribute from fastkml.helpers import enum_subelement +from fastkml.helpers import float_attribute from fastkml.helpers import float_subelement from fastkml.helpers import int_subelement from fastkml.helpers import subelement_enum_kwarg @@ -65,7 +70,12 @@ "ImagePyramid", "KmlGeometry", "LatLonBox", + "OverlayXY", "PhotoOverlay", + "RotationXY", + "ScreenOverlay", + "ScreenXY", + "Size", "ViewVolume", ] @@ -1264,3 +1274,405 @@ def __repr__(self) -> str: set_element=xml_subelement, ), ) + + +class _XY(_XMLObject): + """Specifies a point relative to the screen origin in pixels.""" + + _default_nsid = config.KML + + x: Optional[float] + y: Optional[float] + x_units: Optional[Units] + + y_units: Optional[Units] + + def __init__( + self, + ns: Optional[str] = None, + name_spaces: Optional[Dict[str, str]] = None, + x: Optional[float] = None, + y: Optional[float] = None, + x_units: Optional[Units] = None, + y_units: Optional[Units] = None, + **kwargs: Any, + ) -> None: + """ + Initialize a new _XY object. + + Args: + ---- + ns : Optional[str] + The namespace for the element. + name_spaces : Optional[Dict[str, str]] + A dictionary of namespace prefixes and URIs. + x : Optional[float] + The horizontal position of the point relative to the left edge. + y : Optional[float] + The vertical position of the point relative to the bottom edge. + x_units : Optional[Units] + The horizontal units of the point. + y_units : Optional[Units] + The vertical units of the point + kwargs : Any + Additional keyword arguments. + + """ + super().__init__(ns=ns, name_spaces=name_spaces, **kwargs) + self.x = x + self.y = y + self.x_units = x_units + self.y_units = y_units + + def __repr__(self) -> str: + """Create a string (c)representation for _XY.""" + return ( + f"{self.__class__.__module__}.{self.__class__.__name__}(" + f"ns={self.ns!r}, " + f"name_spaces={self.name_spaces!r}, " + f"x={self.x!r}, " + f"y={self.y!r}, " + f"x_units={self.x_units}, " + f"y_units={self.y_units}, " + f"**{self._get_splat()!r}," + ")" + ) + + def __bool__(self) -> bool: + """ + Check if all the attributes necessary are not None. + + Returns + ------- + bool: True if all attributes (x, y) are not None. + + """ + return all([self.x is not None, self.y is not None]) + + +registry.register( + _XY, + RegistryItem( + ns_ids=("", "kml"), + attr_name="x", + node_name="x", + classes=(float,), + get_kwarg=attribute_float_kwarg, + set_element=float_attribute, + ), +) +registry.register( + _XY, + RegistryItem( + ns_ids=("", "kml"), + attr_name="y", + node_name="y", + classes=(float,), + get_kwarg=attribute_float_kwarg, + set_element=float_attribute, + ), +) +registry.register( + _XY, + RegistryItem( + ns_ids=("", "kml"), + attr_name="x_units", + node_name="xunits", + classes=(Units,), + get_kwarg=attribute_enum_kwarg, + set_element=enum_attribute, + default=Units.fraction, + ), +) +registry.register( + _XY, + RegistryItem( + ns_ids=("", "kml"), + attr_name="y_units", + node_name="yunits", + classes=(Units,), + get_kwarg=attribute_enum_kwarg, + set_element=enum_attribute, + default=Units.fraction, + ), +) + + +class OverlayXY(_XY): + """Specifies the placement of the overlay on the screen.""" + + @classmethod + def get_tag_name(cls) -> str: + """Return the tag name.""" + return "overlayXY" + + +class ScreenXY(_XY): + """Specifies the placement of the overlay on the screen.""" + + @classmethod + def get_tag_name(cls) -> str: + """Return the tag name.""" + return "screenXY" + + +class RotationXY(_XY): + """Specifies the rotation of the overlay on the screen.""" + + @classmethod + def get_tag_name(cls) -> str: + """Return the tag name.""" + return "rotationXY" + + +class Size(_XY): + """Specifies the size of the overlay on the screen.""" + + @classmethod + def get_tag_name(cls) -> str: + """Return the tag name.""" + return "size" + + +class ScreenOverlay(_Overlay): + """ + A ScreenOverlay draws an image overlay fixed to the screen. + + This element draws an image overlay fixed to the screen. Sample uses include + watermarking the map with an image, such as a company logo, or adding a + heads-up display (HUD) to show real-time information. + + The child of specifies the image to be used as the overlay. + This file can be either on a local file system or on a web server. + + https://developers.google.com/kml/documentation/kmlreference#screenoverlay + """ + + def __init__( + self, + ns: Optional[str] = None, + name_spaces: Optional[Dict[str, str]] = None, + id: Optional[str] = None, + target_id: Optional[str] = None, + name: Optional[str] = None, + visibility: Optional[bool] = None, + isopen: Optional[bool] = None, + atom_link: Optional[atom.Link] = None, + atom_author: Optional[atom.Author] = None, + address: Optional[str] = None, + phone_number: Optional[str] = None, + snippet: Optional[Snippet] = None, + description: Optional[str] = None, + view: Optional[Union[Camera, LookAt]] = None, + times: Optional[Union[TimeSpan, TimeStamp]] = None, + style_url: Optional[StyleUrl] = None, + styles: Optional[Iterable[Union[Style, StyleMap]]] = None, + region: Optional[Region] = None, + extended_data: Optional[ExtendedData] = None, + color: Optional[str] = None, + draw_order: Optional[int] = None, + icon: Optional[Icon] = None, + # Screen Overlay specific + overlay_xy: Optional[OverlayXY] = None, + screen_xy: Optional[ScreenXY] = None, + rotation_xy: Optional[RotationXY] = None, + size: Optional[Size] = None, + rotation: Optional[float] = None, + **kwargs: Any, + ) -> None: + """ + Initialize a new ScreenOverlay object. + + Args: + ---- + ns : Optional[str] + The namespace of the element. + name_spaces : Optional[Dict[str, str]] + The dictionary of namespace prefixes and URIs. + id : Optional[str] + The ID of the element. + target_id : Optional[str] + The target ID of the element. + name : Optional[str] + The name of the element. + visibility : Optional[bool] + The visibility of the element. + isopen : Optional[bool] + The open state of the element. + atom_link : Optional[atom.Link] + The Atom link associated with the element. + atom_author : Optional[atom.Author] + The Atom author associated with the element. + address : Optional[str] + The address of the element. + phone_number : Optional[str] + The phone number of the element. + snippet : Optional[Snippet] + The snippet associated with the element. + description : Optional[str] + The description of the element. + view : Optional[Union[Camera, LookAt]] + The view associated with the element. + times : Optional[Union[TimeSpan, TimeStamp]] + The times associated with the element. + style_url : Optional[StyleUrl] + The style URL of the element. + styles : Optional[Iterable[Union[Style, StyleMap]]] + The styles associated with the element. + region : Optional[Region] + The region associated with the element. + extended_data : Optional[ExtendedData] + The extended data associated with the element. + color : Optional[str] + The color of the element. + draw_order : Optional[int] + The draw order of the element. + icon : Optional[Icon] + The icon associated with the element. + altitude : Optional[float] + The altitude of the element. + altitude_mode : Optional[AltitudeMode] + The altitude mode of the element. + lat_lon_box : Optional[LatLonBox] + The latitude-longitude box associated with the element. + overlay_xy : Optional[OverlayXY] + The overlay XY associated with the element. + screen_xy : Optional[ScreenXY] + The screen XY associated with the element. + rotation_xy : Optional[RotationXY] + The rotation XY associated with the element. + size : Optional[Size] + The size associated with the element. + rotation : Optional[float] + The rotation of the element. + kwargs : Any + Additional keyword arguments. + + Returns: + ------- + None + + """ + super().__init__( + ns=ns, + name_spaces=name_spaces, + id=id, + target_id=target_id, + name=name, + visibility=visibility, + isopen=isopen, + atom_link=atom_link, + atom_author=atom_author, + address=address, + phone_number=phone_number, + snippet=snippet, + description=description, + view=view, + times=times, + style_url=style_url, + styles=styles, + region=region, + extended_data=extended_data, + color=color, + draw_order=draw_order, + icon=icon, + **kwargs, + ) + self.overlay_xy = overlay_xy + self.screen_xy = screen_xy + self.rotation_xy = rotation_xy + self.size = size + self.rotation = rotation + + def __repr__(self) -> str: + """Create a string (c)representation for ScreenOverlay.""" + return ( + f"{self.__class__.__module__}.{self.__class__.__name__}(" + f"ns={self.ns!r}, " + f"name_spaces={self.name_spaces!r}, " + f"id={self.id!r}, " + f"target_id={self.target_id!r}, " + f"name={self.name!r}, " + f"visibility={self.visibility!r}, " + f"isopen={self.isopen!r}, " + f"atom_link={self.atom_link!r}, " + f"atom_author={self.atom_author!r}, " + f"address={self.address!r}, " + f"phone_number={self.phone_number!r}, " + f"snippet={self.snippet!r}, " + f"description={self.description!r}, " + f"view={self.view!r}, " + f"times={self.times!r}, " + f"style_url={self.style_url!r}, " + f"styles={self.styles!r}, " + f"region={self.region!r}, " + f"extended_data={self.extended_data!r}, " + f"color={self.color!r}, " + f"draw_order={self.draw_order!r}, " + f"icon={self.icon!r}, " + f"overlay_xy={self.overlay_xy!r}, " + f"screen_xy={self.screen_xy!r}, " + f"rotation_xy={self.rotation_xy!r}, " + f"size={self.size!r}, " + f"rotation={self.rotation!r}, " + f"**{self._get_splat()!r}," + ")" + ) + + +registry.register( + ScreenOverlay, + RegistryItem( + ns_ids=("kml",), + attr_name="overlay_xy", + node_name="overlayXY", + classes=(OverlayXY,), + get_kwarg=xml_subelement_kwarg, + set_element=xml_subelement, + ), +) +registry.register( + ScreenOverlay, + RegistryItem( + ns_ids=("kml",), + attr_name="screen_xy", + node_name="screenXY", + classes=(ScreenXY,), + get_kwarg=xml_subelement_kwarg, + set_element=xml_subelement, + ), +) +registry.register( + ScreenOverlay, + RegistryItem( + ns_ids=("kml",), + attr_name="rotation_xy", + node_name="rotationXY", + classes=(RotationXY,), + get_kwarg=xml_subelement_kwarg, + set_element=xml_subelement, + ), +) +registry.register( + ScreenOverlay, + RegistryItem( + ns_ids=("kml",), + attr_name="size", + node_name="size", + classes=(Size,), + get_kwarg=xml_subelement_kwarg, + set_element=xml_subelement, + ), +) +registry.register( + ScreenOverlay, + RegistryItem( + ns_ids=("kml",), + attr_name="rotation", + node_name="rotation", + classes=(float,), + get_kwarg=subelement_float_kwarg, + set_element=float_subelement, + default=0.0, + ), +) diff --git a/tests/hypothesis/overlay_test.py b/tests/hypothesis/overlay_test.py index 5d7762ef..807e50bb 100644 --- a/tests/hypothesis/overlay_test.py +++ b/tests/hypothesis/overlay_test.py @@ -17,6 +17,7 @@ import typing +import pytest from hypothesis import given from hypothesis import strategies as st from pygeoif.hypothesis.strategies import epsg4326 @@ -31,6 +32,7 @@ from tests.hypothesis.common import assert_str_roundtrip from tests.hypothesis.common import assert_str_roundtrip_terse from tests.hypothesis.common import assert_str_roundtrip_verbose +from tests.hypothesis.strategies import xy class TestLxml(Lxml): @@ -242,3 +244,68 @@ def test_fuzz_ground_overlay( assert_str_roundtrip(ground_overlay) assert_str_roundtrip_terse(ground_overlay) assert_str_roundtrip_verbose(ground_overlay) + + @pytest.mark.parametrize( + "cls", + [ + fastkml.overlays.OverlayXY, + fastkml.overlays.RotationXY, + fastkml.overlays.ScreenXY, + fastkml.overlays.Size, + ], + ) + @given( + x=st.one_of(st.none(), st.floats(allow_nan=False, allow_infinity=False)), + y=st.one_of(st.none(), st.floats(allow_nan=False, allow_infinity=False)), + x_units=st.one_of(st.none(), st.sampled_from(fastkml.enums.Units)), + y_units=st.one_of(st.none(), st.sampled_from(fastkml.enums.Units)), + ) + def test_fuzz_xy( + self, + cls: typing.Union[ + typing.Type[fastkml.overlays.OverlayXY], + typing.Type[fastkml.overlays.RotationXY], + typing.Type[fastkml.overlays.ScreenXY], + typing.Type[fastkml.overlays.Size], + ], + x: typing.Optional[float], + y: typing.Optional[float], + x_units: typing.Optional[fastkml.enums.Units], + y_units: typing.Optional[fastkml.enums.Units], + ) -> None: + xy = cls(x=x, y=y, x_units=x_units, y_units=y_units) + + assert_repr_roundtrip(xy) + assert_str_roundtrip(xy) + assert_str_roundtrip_terse(xy) + assert_str_roundtrip_verbose(xy) + + @given( + overlay_xy=xy(fastkml.overlays.OverlayXY), + screen_xy=xy(fastkml.overlays.ScreenXY), + rotation_xy=xy(fastkml.overlays.RotationXY), + size=xy(fastkml.overlays.Size), + rotation=st.floats(min_value=-180, max_value=180).filter(lambda x: x != 0), + ) + def test_screen_overlay( + self, + overlay_xy: typing.Optional[fastkml.overlays.OverlayXY], + screen_xy: typing.Optional[fastkml.overlays.ScreenXY], + rotation_xy: typing.Optional[fastkml.overlays.RotationXY], + size: typing.Optional[fastkml.overlays.Size], + rotation: typing.Optional[float], + ) -> None: + screen_overlay = fastkml.overlays.ScreenOverlay( + id="screen_overlay1", + name="screen_overlay", + overlay_xy=overlay_xy, + screen_xy=screen_xy, + rotation_xy=rotation_xy, + size=size, + rotation=rotation, + ) + + assert_repr_roundtrip(screen_overlay) + assert_str_roundtrip(screen_overlay) + assert_str_roundtrip_terse(screen_overlay) + assert_str_roundtrip_verbose(screen_overlay) diff --git a/tests/hypothesis/strategies.py b/tests/hypothesis/strategies.py index 4390510f..e070c450 100644 --- a/tests/hypothesis/strategies.py +++ b/tests/hypothesis/strategies.py @@ -178,6 +178,14 @@ when=kml_datetimes(), ) +xy = partial( + st.builds, + x=st.floats(allow_nan=False, allow_infinity=False), + y=st.floats(allow_nan=False, allow_infinity=False), + x_units=st.sampled_from(fastkml.enums.Units), + y_units=st.sampled_from(fastkml.enums.Units), +) + @st.composite def query_strings(draw: st.DrawFn) -> str: diff --git a/tests/overlays_test.py b/tests/overlays_test.py index 3a7a71cf..9539acb0 100644 --- a/tests/overlays_test.py +++ b/tests/overlays_test.py @@ -16,6 +16,8 @@ """Test the kml classes.""" +import contextlib + from pygeoif import geometry as geo from fastkml import enums @@ -24,15 +26,74 @@ from fastkml import overlays from fastkml import views from fastkml.enums import AltitudeMode +from fastkml.enums import Units from tests.base import Lxml from tests.base import StdLibrary -class TestGroundOverlay(StdLibrary): - pass +class TestScreenOverlay(StdLibrary): + def test_screen_overlay_from_string(self) -> None: + """Create a ScreenOverlay object with all optional parameters.""" + doc = ( + '' + "Simple crosshairs" + "0" + "This screen overlay uses fractional positioning to put the " + "image in the exact center of the screen" + "" + "http://developers.google.com/kml/images/crosshairs.png" + "" + '' + '' + '' + '' + "-45" + "" + ) + + screen_overlay = overlays.ScreenOverlay.from_string(doc) + + assert screen_overlay == overlays.ScreenOverlay( + name="Simple crosshairs", + visibility=False, + description=( + "This screen overlay uses fractional positioning to put the image " + "in the exact center of the screen" + ), + icon=links.Icon( + href="http://developers.google.com/kml/images/crosshairs.png", + ), + overlay_xy=overlays.OverlayXY( + x=0.5, + y=0.5, + x_units=Units.fraction, + y_units=Units.fraction, + ), + screen_xy=overlays.ScreenXY( + x=0.5, + y=0.5, + x_units=Units.fraction, + y_units=Units.fraction, + ), + rotation_xy=overlays.RotationXY( + x=0.5, + y=0.5, + x_units=Units.fraction, + y_units=Units.fraction, + ), + size=overlays.Size( + x=0.0, + y=0.0, + x_units=Units.pixels, + y_units=Units.pixels, + ), + rotation=-45, + ) + with contextlib.suppress(TypeError): + screen_overlay.validate() -class TestGroundOverlayString(StdLibrary): +class TestGroundOverlay(StdLibrary): def test_default_to_string(self) -> None: g = overlays.GroundOverlay() @@ -362,11 +423,11 @@ def test_camera_initialization(self) -> None: assert po.view.roll == 60 -class TestGroundOverlayLxml(Lxml, TestGroundOverlay): +class TestScreenOverlayLxml(Lxml, TestScreenOverlay): """Test with lxml.""" -class TestGroundOverlayStringLxml(Lxml, TestGroundOverlay): +class TestGroundOverlayLxml(Lxml, TestGroundOverlay): """Test with lxml."""