Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add basic support for adding SVG pictures to docx files #1343 #1386

Open
wants to merge 3 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions src/docx/image/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
from docx.image.jpeg import Exif, Jfif
from docx.image.png import Png
from docx.image.tiff import Tiff
from docx.image.svg import Svg

SIGNATURES = (
# class, offset, signature_bytes
Expand All @@ -20,4 +21,6 @@
(Tiff, 0, b"MM\x00*"), # big-endian (Motorola) TIFF
(Tiff, 0, b"II*\x00"), # little-endian (Intel) TIFF
(Bmp, 0, b"BM"),
(Svg, 0, b"<svg "),
(Svg, 0, b"<?xml version="),
)
1 change: 1 addition & 0 deletions src/docx/image/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,7 @@ class MIME_TYPE:
JPEG = "image/jpeg"
PNG = "image/png"
TIFF = "image/tiff"
SVG = "image/svg+xml"


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

from .constants import MIME_TYPE
from .image import BaseImageHeader


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)
# FIXME: The width could be expressed as '4cm'
# See https://www.w3.org/TR/SVG11/struct.html#NewDocument
width = int(root.attrib["width"])
height = int(root.attrib["height"])
return width, height
2 changes: 2 additions & 0 deletions src/docx/oxml/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,8 @@

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
1 change: 1 addition & 0 deletions src/docx/oxml/ns.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
"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 = {value: key for key, value in nsmap.items()}
Expand Down
45 changes: 41 additions & 4 deletions src/docx/oxml/shape.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ class CT_Blip(BaseOxmlElement):
link: str | None = OptionalAttribute( # pyright: ignore[reportAssignmentType]
"r:link", ST_RelationshipId
)
extLst = ZeroOrOne("a:extLst")


class CT_BlipFillProperties(BaseOxmlElement):
Expand Down Expand Up @@ -115,7 +116,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 @@ -149,14 +150,48 @@ def new(cls, pic_id, filename, rId, cx, cy):
"""Return a new ``<pic:pic>`` element populated with the minimal 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 @@ -178,7 +213,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 @@ -210,6 +245,7 @@ class CT_PositiveSize2D(BaseOxmlElement):
cy: Length = RequiredAttribute( # pyright: ignore[reportAssignmentType]
"cy", ST_PositiveCoordinate
)
svgBlip = ZeroOrOne("asvg:svgBlip")


class CT_PresetGeometry2D(BaseOxmlElement):
Expand Down Expand Up @@ -276,6 +312,7 @@ 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):
Expand Down