Skip to content

Commit

Permalink
Add basic support for adding SVG pictures to docx files (#27)
Browse files Browse the repository at this point in the history
* Add basic support for adding SVG pictures to docx files
  • Loading branch information
lsaint authored Nov 26, 2024
1 parent c539969 commit e2727e9
Show file tree
Hide file tree
Showing 8 changed files with 180 additions and 21 deletions.
5 changes: 4 additions & 1 deletion docx/image/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 = (
Expand All @@ -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"<svg "),
(Svg, 0, b"<?xml version="),
)
1 change: 1 addition & 0 deletions docx/image/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,7 @@ class MIME_TYPE(object):
PNG = "image/png"
TIFF = "image/tiff"
EMF = "image/emf"
SVG = "image/svg+xml"


class PNG_CHUNK_TYPE(object):
Expand Down
65 changes: 65 additions & 0 deletions docx/image/svg.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import xml.etree.ElementTree as ET

from .constants import MIME_TYPE
from .image import BaseImageHeader

BASE_PX = 72


class Svg(BaseImageHeader):
"""
Image header parser for SVG images.
"""

@classmethod
def from_stream(cls, stream):
"""
Return |Svg| instance having header properties parsed from SVG image
in *stream*.
"""
px_width, px_height = cls._dimensions_from_stream(stream)
return cls(px_width, px_height, 72, 72)

@property
def content_type(self):
"""
MIME content type for this image, unconditionally `image/svg+xml` for
SVG images.
"""
return MIME_TYPE.SVG

@property
def default_ext(self):
"""
Default filename extension, always 'svg' for SVG images.
"""
return "svg"

@classmethod
def _dimensions_from_stream(cls, stream):
stream.seek(0)
data = stream.read()
root = ET.fromstring(data)
if root.attrib.get("width") is None:
return cls._calculate_scaled_dimensions(root.attrib["viewBox"])

width = int(root.attrib["width"])
height = int(root.attrib["height"])
return width, height

@classmethod
def _calculate_scaled_dimensions(
cls, viewbox: str, base_px: int = BASE_PX
) -> 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)
2 changes: 2 additions & 0 deletions docx/oxml/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
11 changes: 5 additions & 6 deletions docx/oxml/ns.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,3 @@
# encoding: utf-8

"""
Namespace-related objects.
"""
Expand All @@ -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):
Expand All @@ -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
Expand Down Expand Up @@ -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):
Expand All @@ -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):
Expand Down
58 changes: 45 additions & 13 deletions docx/oxml/shape.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
"""
Custom element classes for shape-related elements like ``<w:inline>``
"""

from docx.oxml import parse_xml
from docx.oxml.ns import nsdecls
from docx.oxml.xmlchemy import BaseOxmlElement, OneAndOnlyOne
Expand Down Expand Up @@ -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):
Expand Down Expand Up @@ -108,7 +110,7 @@ def _inline_xml(cls):
" <a:graphic>\n"
' <a:graphicData uri="URI not set"/>\n'
" </a:graphic>\n"
"</wp:inline>" % nsdecls("wp", "a", "pic", "r")
"</wp:inline>" % nsdecls("wp", "a", "pic", "r", "asvg")
)


Expand Down Expand Up @@ -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 (
"<pic:pic %s>\n"
" <pic:nvPicPr>\n"
' <pic:cNvPr id="666" name="unnamed"/>\n'
" <pic:cNvPicPr/>\n"
" </pic:nvPicPr>\n"
" <pic:blipFill>\n"
" <a:blip>\n"
" <a:extLst>\n"
' <a:ext uri="{96DAC541-7B7A-43D3-8B79-37D633B846F1}">\n'
" <asvg:svgBlip/>\n"
" </a:ext>\n"
" </a:extLst>\n"
" </a:blip>\n"
" <a:stretch>\n"
" <a:fillRect/>\n"
" </a:stretch>\n"
" </pic:blipFill>\n"
" <pic:spPr>\n"
" <a:xfrm>\n"
' <a:off x="0" y="0"/>\n'
' <a:ext cx="914400" cy="914400"/>\n'
" </a:xfrm>\n"
' <a:prstGeom prst="rect"/>\n'
" </pic:spPr>\n"
"</pic:pic>" % nsdecls("pic", "a", "r", "asvg")
)

@classmethod
def _pic_xml(cls):
return (
Expand All @@ -174,7 +210,7 @@ def _pic_xml(cls):
" </a:xfrm>\n"
' <a:prstGeom prst="rect"/>\n'
" </pic:spPr>\n"
"</pic:pic>" % nsdecls("pic", "a", "r")
"</pic:pic>" % nsdecls("pic", "a", "r", "asvg")
)


Expand Down Expand Up @@ -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):
Expand Down Expand Up @@ -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):
Expand All @@ -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):
Expand All @@ -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):
Expand Down
57 changes: 57 additions & 0 deletions tests/image/test_svg.py
Original file line number Diff line number Diff line change
@@ -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'<svg width="200" height="100"/>', 200, 100),
(b'<svg viewBox="0 0 400 200"/>', 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
2 changes: 1 addition & 1 deletion tests/test_files/snippets/inline.txt
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
<wp:inline xmlns:wp="http://schemas.openxmlformats.org/drawingml/2006/wordprocessingDrawing" xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" xmlns:pic="http://schemas.openxmlformats.org/drawingml/2006/picture" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships">
<wp:inline xmlns:wp="http://schemas.openxmlformats.org/drawingml/2006/wordprocessingDrawing" xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" xmlns:pic="http://schemas.openxmlformats.org/drawingml/2006/picture" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" xmlns:asvg="http://schemas.microsoft.com/office/drawing/2016/SVG/main">
<wp:extent cx="444" cy="888"/>
<wp:docPr id="24" name="Picture 24"/>
<wp:cNvGraphicFramePr>
Expand Down

0 comments on commit e2727e9

Please sign in to comment.