diff --git a/docx/image/__init__.py b/docx/image/__init__.py index 4368394..3b0f097 100644 --- a/docx/image/__init__.py +++ b/docx/image/__init__.py @@ -8,11 +8,12 @@ from __future__ import absolute_import, division, print_function, unicode_literals from docx.image.bmp import Bmp +from docx.image.emf import Emf from docx.image.gif import Gif from docx.image.jpeg import Exif, Jfif from docx.image.png import Png +from docx.image.svg import Svg from docx.image.tiff import Tiff -from docx.image.emf import Emf SIGNATURES = ( @@ -26,4 +27,6 @@ (Tiff, 0, b"II*\x00"), # little-endian (Intel) TIFF (Bmp, 0, b"BM"), (Emf, 0, b"\x01\x00\x00\x00"), + (Svg, 0, b" tuple[int, int]: + _, _, logical_width, logical_height = map(int, viewbox.split()) + + aspect_ratio = logical_width / logical_height + + if aspect_ratio >= 1: + final_width = base_px + final_height = base_px / aspect_ratio + else: + final_height = base_px + final_width = base_px * aspect_ratio + + return int(final_width), int(final_height) diff --git a/docx/oxml/__init__.py b/docx/oxml/__init__.py index 62043fa..31de218 100644 --- a/docx/oxml/__init__.py +++ b/docx/oxml/__init__.py @@ -133,6 +133,8 @@ def OxmlElement(nsptag_str, attrs=None, nsdecls=None): register_element_cls("a:blip", CT_Blip) register_element_cls("a:ext", CT_PositiveSize2D) +register_element_cls("a:extLst", CT_Transform2D) +register_element_cls("asvg:svgBlip", CT_Transform2D) register_element_cls("a:graphic", CT_GraphicalObject) register_element_cls("a:graphicData", CT_GraphicalObjectData) register_element_cls("a:off", CT_Point2D) diff --git a/docx/oxml/ns.py b/docx/oxml/ns.py index 77cc77e..4436ccb 100644 --- a/docx/oxml/ns.py +++ b/docx/oxml/ns.py @@ -1,5 +1,3 @@ -# encoding: utf-8 - """ Namespace-related objects. """ @@ -25,9 +23,10 @@ "wp": "http://schemas.openxmlformats.org/drawingml/2006/wordprocessingDrawing", "xml": "http://www.w3.org/XML/1998/namespace", "xsi": "http://www.w3.org/2001/XMLSchema-instance", + "asvg": "http://schemas.microsoft.com/office/drawing/2016/SVG/main", } -pfxmap = dict((value, key) for key, value in nsmap.items()) +pfxmap = {value: key for key, value in nsmap.items()} class NamespacePrefixedTag(str): @@ -50,7 +49,7 @@ def clark_name(self): @classmethod def from_clark_name(cls, clark_name): nsuri, local_name = clark_name[1:].split("}") - nstag = "%s:%s" % (pfxmap[nsuri], local_name) + nstag = f"{pfxmap[nsuri]}:{local_name}" return cls(nstag) @property @@ -93,7 +92,7 @@ def nsdecls(*prefixes): Return a string containing a namespace declaration for each of the namespace prefix strings, e.g. 'p', 'ct', passed as *prefixes*. """ - return " ".join(['xmlns:%s="%s"' % (pfx, nsmap[pfx]) for pfx in prefixes]) + return " ".join([f'xmlns:{pfx}="{nsmap[pfx]}"' for pfx in prefixes]) def nspfxmap(*nspfxs): @@ -102,7 +101,7 @@ def nspfxmap(*nspfxs): *nspfxs*. Any number of namespace prefixes can be supplied, e.g. namespaces('a', 'r', 'p'). """ - return dict((pfx, nsmap[pfx]) for pfx in nspfxs) + return {pfx: nsmap[pfx] for pfx in nspfxs} def qn(tag): diff --git a/docx/oxml/shape.py b/docx/oxml/shape.py index b195210..556ee78 100644 --- a/docx/oxml/shape.py +++ b/docx/oxml/shape.py @@ -1,6 +1,7 @@ """ Custom element classes for shape-related elements like ```` """ + from docx.oxml import parse_xml from docx.oxml.ns import nsdecls from docx.oxml.xmlchemy import BaseOxmlElement, OneAndOnlyOne @@ -31,6 +32,7 @@ class CT_Blip(BaseOxmlElement): embed = OptionalAttribute("r:embed", ST_RelationshipId) link = OptionalAttribute("r:link", ST_RelationshipId) + extLst = ZeroOrOne("a:extLst") class CT_BlipFillProperties(BaseOxmlElement): @@ -108,7 +110,7 @@ def _inline_xml(cls): " \n" ' \n' " \n" - "" % nsdecls("wp", "a", "pic", "r") + "" % nsdecls("wp", "a", "pic", "r", "asvg") ) @@ -145,14 +147,48 @@ def new(cls, pic_id, filename, rId, cx, cy): contents required to define a viable picture element, based on the values passed as parameters. """ - pic = parse_xml(cls._pic_xml()) + if filename.endswith(".svg"): + pic = parse_xml(cls._pic_xml_svg()) + pic.blipFill.blip.extLst.ext.svgBlip.embed = rId + else: + pic = parse_xml(cls._pic_xml()) + pic.blipFill.blip.embed = rId pic.nvPicPr.cNvPr.id = pic_id pic.nvPicPr.cNvPr.name = filename - pic.blipFill.blip.embed = rId pic.spPr.cx = cx pic.spPr.cy = cy return pic + @classmethod + def _pic_xml_svg(cls): + return ( + "\n" + " \n" + ' \n' + " \n" + " \n" + " \n" + " \n" + " \n" + ' \n' + " \n" + " \n" + " \n" + " \n" + " \n" + " \n" + " \n" + " \n" + " \n" + " \n" + ' \n' + ' \n' + " \n" + ' \n' + " \n" + "" % nsdecls("pic", "a", "r", "asvg") + ) + @classmethod def _pic_xml(cls): return ( @@ -174,7 +210,7 @@ def _pic_xml(cls): " \n" ' \n' " \n" - "" % nsdecls("pic", "a", "r") + "" % nsdecls("pic", "a", "r", "asvg") ) @@ -204,6 +240,7 @@ class CT_PositiveSize2D(BaseOxmlElement): cx = RequiredAttribute("cx", ST_PositiveCoordinate) cy = RequiredAttribute("cy", ST_PositiveCoordinate) + svgBlip = ZeroOrOne("asvg:svgBlip") class CT_PresetGeometry2D(BaseOxmlElement): @@ -260,9 +297,7 @@ def cy(self): Shape height as an instance of Emu, or None if not present. """ xfrm = self.xfrm - if xfrm is None: - return None - return xfrm.cy + return None if xfrm is None else xfrm.cy @cy.setter def cy(self, value): @@ -284,13 +319,12 @@ class CT_Transform2D(BaseOxmlElement): off = ZeroOrOne("a:off", successors=("a:ext",)) ext = ZeroOrOne("a:ext", successors=()) + embed = OptionalAttribute("r:embed", ST_RelationshipId) @property def cx(self): ext = self.ext - if ext is None: - return None - return ext.cx + return None if ext is None else ext.cx @cx.setter def cx(self, value): @@ -300,9 +334,7 @@ def cx(self, value): @property def cy(self): ext = self.ext - if ext is None: - return None - return ext.cy + return None if ext is None else ext.cy @cy.setter def cy(self, value): diff --git a/tests/image/test_svg.py b/tests/image/test_svg.py new file mode 100644 index 0000000..bc18b6e --- /dev/null +++ b/tests/image/test_svg.py @@ -0,0 +1,57 @@ +import io +from xml.etree.ElementTree import Element, tostring + +import pytest + +from docx.image.svg import BASE_PX, Svg + + +@pytest.fixture +def svg_with_dimensions(): + """Fixture for SVG stream with width and height.""" + root = Element("svg", width="200", height="100") + return io.BytesIO(tostring(root)) + + +@pytest.fixture +def svg_with_viewbox(): + """Fixture for SVG stream with viewBox but no width and height.""" + root = Element("svg", viewBox="0 0 400 200") + return io.BytesIO(tostring(root)) + + +@pytest.fixture( + params=[ + ("0 0 400 200", 72, 36, 72), # Landscape + ("0 0 200 400", 100, 200, 200), # Portrait + ("0 0 100 100", 50, 50, 50), # Square + ] +) +def viewbox_data(request): + """Fixture for different viewBox test cases as tuples.""" + return request.param + + +@pytest.fixture( + params=[ + (b'', 200, 100), + (b'', BASE_PX, BASE_PX // 2), + ] +) +def svg_stream_data(request): + return request.param + + +def test_dimensions_from_stream(svg_stream_data): + stream_data, expected_width, expected_height = svg_stream_data + stream = io.BytesIO(stream_data) + width, height = Svg._dimensions_from_stream(stream) + assert width == expected_width + assert height == expected_height + + +def test_calculate_scaled_dimensions(viewbox_data): + viewbox, expected_width, expected_height, base_px = viewbox_data + width, height = Svg._calculate_scaled_dimensions(viewbox, base_px) + assert width == expected_width + assert height == expected_height diff --git a/tests/test_files/snippets/inline.txt b/tests/test_files/snippets/inline.txt index 3b0d581..baf0a7d 100644 --- a/tests/test_files/snippets/inline.txt +++ b/tests/test_files/snippets/inline.txt @@ -1,4 +1,4 @@ - +