diff --git a/HISTORY.rst b/HISTORY.rst index 8039fdd4e..2682ecd90 100644 --- a/HISTORY.rst +++ b/HISTORY.rst @@ -5,9 +5,11 @@ Release History 1.0.0 (2024-05-DD) ++++++++++++++++++ -- change package name to python-docx-bb to avoid confusion with version + +- change package name to ``python-docx-bb`` to avoid confusion with version numbers and pypi -- DEV-3948: Merge in upstream through python-openxml/python-docx:1.1.2 +- DEV-3948: Merge in upstream through ``python-openxml/python-docx:1.1.2`` +- DEV-3861: add Custom Properties support from ``michael-koeller:feature/custom_properties`` +---------------------+------------------------------------------------------------------------------------------------+ | python-openxml | Changes | @@ -42,48 +44,63 @@ Release History | | - Add Section.iter_inner_content() | +---------------------+------------------------------------------------------------------------------------------------+ + 0.4.7 (2023-07-19) ++++++++++++++++++ -- DEV-2649: add shd tag to TcPr for colored table cells -- Fix "text" attr setter for CT_DR and CT_IR -- DEV-3177: get children anywhere for all_runs for CT_IR to support runs - nested inside of deletes +- DEV-2649: add ``w:shd`` tag to ``TcPr`` for colored table cells +- Fix "text" attr setter for ``CT_DR`` and ``CT_IR`` +- DEV-3177: get children anywhere for all_runs for ``CT_IR`` to support runs nested + inside of deletes + 0.4.6 (2023-05-26) ++++++++++++++++++ -- DEV-3195: wrap int() call in float() so we can read float vals of size attrs - in e.g. w:spacing tags +- DEV-3195: wrap ``int()`` call in ``float()`` so we can read float vals of size attrs + in e.g. ``w:spacing`` tags + 0.4.5 (2023-02-06) ++++++++++++++++++ -- DEV-2853: fix `all_runs` methods of Ins and Del objects + +- DEV-2853: fix ``all_runs`` methods of Ins and Del objects + 0.4.4 (2022-09-14) ++++++++++++++++++ -- DEV-1807: fix `text` property of CT_IR and CT_DR + +- DEV-1807: fix ``text`` property of ``CT_IR`` and ``CT_DR`` + 0.4.3 (2022-05-13) ++++++++++++++++++ -- DEV-1907: remove self-added `br` property from CT_R - + having it there seemed to remove methods added by ZeroOrMore() + +- DEV-1907: remove self-added ``br`` property from ``CT_R`` + + having it there seemed to remove methods added by ``ZeroOrMore()`` + 0.4.2 (2022-04-11) ++++++++++++++++++ + - Adds attributes to access more information about formatting + 0.4.1 (2022-01-31) ++++++++++++++++++ -DEV-1405: Comments in edit transfer -- Adds all_runs property to Ins and Del objects - + Allows us to get comments that affect runs inside Ins and Del -- changes `comments` property of Paragraph to use all_runs instead of runs - + allows us to get comments from Ins and Del runs + +- DEV-1405: Comments in edit transfer +- Adds all_runs property to ``Ins`` and ``Del`` objects + - Allows us to get comments that affect runs inside Ins and Del +- changes ``comments`` property of Paragraph to use ``all_runs`` instead of ``runs`` + - allows us to get comments from Ins and Del runs + 0.4.1-rc.1 (2022-01-21) -++++++++++++++++++ ++++++++++++++++++++++++ + - Adds ability to get all runs from Ins and Del objects + Allows us to get comments that affect runs inside Ins and Del + 0.4 (2021-12-07) ++++++++++++++++++ @@ -93,30 +110,31 @@ DEV-1405: Comments in edit transfer 0.3 (2021-09-01) ++++++++++++++++++ - - Upgrade BlackBoiler fork of bb-docx to python-openxml v0.8.11 -+---------------------+---------------------------------------------------------------------------------------------------------------------------------------+ -| python-openxml | Changes | -+=====================+=======================================================================================================================================+ -| 0.8.11 (2021-05-15) | - Small build changes and Python 3.8 version changes like collections.abc location | -+---------------------+---------------------------------------------------------------------------------------------------------------------------------------+ -| 0.8.10 (2019-01-08) | - Revert use of expanded package directory for default.docx to work around setup.py problem with filenames containing square brackets | -+---------------------+---------------------------------------------------------------------------------------------------------------------------------------+ -| 0.8.9 (2019-01-08) | - Fix gap in MANIFEST.in that excluded default document template directory | -+---------------------+---------------------------------------------------------------------------------------------------------------------------------------+ -| 0.8.8 (2019-01-07) | - Add support for headers and footers | -+---------------------+---------------------------------------------------------------------------------------------------------------------------------------+ -| 0.8.7 (2018-08-18) | - Add _Row.height_rule | -| | - Add _Row.height | -| | - Add _Cell.vertical_alignment | -| | - Fix #455: increment next_id, don't fill gaps | -| | - Add #375: import docx failure on --OO optimization | -| | - Add #254: remove default zoom percentage | -| | - Add #266: miscellaneous documentation fixes | -| | - Add #175: refine MANIFEST.ini | -| | - Add #168: Unicode error on core-props in Python 2" | -+---------------------+---------------------------------------------------------------------------------------------------------------------------------------+ ++---------------------+------------------------------------------------------------------------------------------------+ +| python-openxml | Changes | ++=====================+================================================================================================+ +| 0.8.11 (2021-05-15) | - Small build changes and Python 3.8 version changes like collections.abc location | ++---------------------+------------------------------------------------------------------------------------------------+ +| 0.8.10 (2019-01-08) | - Revert use of expanded package directory for default.docx to work around setup.py problem | +| | with filenames containing square brackets | ++---------------------+------------------------------------------------------------------------------------------------+ +| 0.8.9 (2019-01-08) | - Fix gap in MANIFEST.in that excluded default document template directory | ++---------------------+------------------------------------------------------------------------------------------------+ +| 0.8.8 (2019-01-07) | - Add support for headers and footers | ++---------------------+------------------------------------------------------------------------------------------------+ +| 0.8.7 (2018-08-18) | - Add _Row.height_rule | +| | - Add _Row.height | +| | - Add _Cell.vertical_alignment | +| | - Fix #455: increment next_id, don't fill gaps | +| | - Add #375: import docx failure on --OO optimization | +| | - Add #254: remove default zoom percentage | +| | - Add #266: miscellaneous documentation fixes | +| | - Add #175: refine MANIFEST.ini | +| | - Add #168: Unicode error on core-props in Python 2" | ++---------------------+------------------------------------------------------------------------------------------------+ + 0.2 (2019-04-19) ++++++++++++++++++ diff --git a/features/doc-customprops.feature b/features/doc-customprops.feature new file mode 100644 index 000000000..e2dbfaf10 --- /dev/null +++ b/features/doc-customprops.feature @@ -0,0 +1,40 @@ +Feature: Read and write custom document properties + In order to find documents and make them manageable by digital means + As a developer using python-docx + I need to access and modify the Dublin Core metadata for a document + + + Scenario: read the custom properties of a document + Given a document having known custom properties + Then I can access the custom properties object + And the expected custom properties are visible + And the custom property values match the known values + + + Scenario: change the custom properties of a document + Given a document having known custom properties + When I assign new values to the custom properties + Then the custom property values match the new values + + + Scenario: a default custom properties part is added if doc doesn't have one + Given a document having no custom properties part + When I access the custom properties object + Then a custom properties part with no values is added + + + Scenario: set custom properties on a document that doesn't have one + Given a document having no custom properties part + When I assign new values to the custom properties + Then the custom property values match the new values + + + Scenario: iterate the custom properties of a document + Given a document having known custom properties + Then I can iterate the custom properties object + + + Scenario: delete an existing custom property + Given a document having known custom properties + When I delete an existing custom property + Then the custom property is missing in the remaining list of custom properties diff --git a/features/steps/customprops.py b/features/steps/customprops.py new file mode 100644 index 000000000..6b9ff883f --- /dev/null +++ b/features/steps/customprops.py @@ -0,0 +1,125 @@ +# encoding: utf-8 + +""" +Gherkin step implementations for custom properties-related features. +""" + +from __future__ import ( + absolute_import, division, print_function, unicode_literals +) + +from datetime import datetime, timedelta + +from behave import given, then, when + +from docx import Document +from docx.opc.customprops import CustomProperties + +from helpers import test_docx + + +# given =================================================== + +@given('a document having known custom properties') +def given_a_document_having_known_custom_properties(context): + context.document = Document(test_docx('doc-customprops')) + context.exp_prop_names = [ + 'AppVersion', 'CustomPropBool', 'CustomPropInt', 'CustomPropString', + 'DocSecurity', 'HyperlinksChanged', 'LinksUpToDate', 'ScaleCrop', 'ShareDoc' + ] + + +@given('a document having no custom properties part') +def given_a_document_having_no_custom_properties_part(context): + context.document = Document(test_docx('doc-no-customprops')) + context.exp_prop_names = [] + + +# when ==================================================== + +@when('I access the custom properties object') +def when_I_access_the_custom_properties_object(context): + context.document.custom_properties + + +@when("I assign new values to the custom properties") +def when_I_assign_new_values_to_the_custom_properties(context): + context.propvals = ( + ('CustomPropBool', False), + ('CustomPropInt', 1), + ('CustomPropString', 'Lorem ipsum'), + ) + custom_properties = context.document.custom_properties + for name, value in context.propvals: + custom_properties[name] = value + + +@when("I delete an existing custom property") +def when_I_delete_an_existing_custom_property(context): + custom_properties = context.document.custom_properties + del custom_properties["CustomPropInt"] + context.prop_name = "CustomPropInt" + + +# then ==================================================== + +@then('a custom properties part with no values is added') +def then_a_custom_properties_part_with_no_values_is_added(context): + custom_properties = context.document.custom_properties + assert len(custom_properties) == 0 + + +@then('I can access the custom properties object') +def then_I_can_access_the_custom_properties_object(context): + custom_properties = context.document.custom_properties + assert isinstance(custom_properties, CustomProperties) + + +@then('the expected custom properties are visible') +def then_the_expected_custom_properties_are_visible(context): + custom_properties = context.document.custom_properties + exp_prop_names = context.exp_prop_names + for name in exp_prop_names: + assert custom_properties.lookup(name) is not None + + +@then('the custom property values match the known values') +def then_the_custom_property_values_match_the_known_values(context): + known_propvals = ( + ('CustomPropBool', True), + ('CustomPropInt', 13), + ('CustomPropString', 'Test String'), + ) + custom_properties = context.document.custom_properties + for name, expected_value in known_propvals: + value = custom_properties[name] + assert value == expected_value, ( + "got '%s' for custom property '%s'" % (value, name) + ) + + +@then('the custom property values match the new values') +def then_the_custom_property_values_match_the_new_values(context): + custom_properties = context.document.custom_properties + for name, expected_value in context.propvals: + value = custom_properties[name] + assert value == expected_value, ( + "got '%s' for custom property '%s'" % (value, name) + ) + + +@then('I can iterate the custom properties object') +def then_I_can_iterate_the_custom_properties_object(context): + custom_properties = context.document.custom_properties + exp_prop_names = context.exp_prop_names + act_prop_names = [name for name in custom_properties] + assert act_prop_names == exp_prop_names + + +@then('the custom property is missing in the remaining list of custom properties') +def then_the_custom_property_is_missing_in_the_remaining_list_of_custom_properties(context): + custom_properties = context.document.custom_properties + prop_name = context.prop_name + assert prop_name is not None + assert custom_properties.lookup(prop_name) is None + assert prop_name not in [name for name in custom_properties] diff --git a/features/steps/test_files/doc-customprops.docx b/features/steps/test_files/doc-customprops.docx new file mode 100644 index 000000000..a3dc7a027 Binary files /dev/null and b/features/steps/test_files/doc-customprops.docx differ diff --git a/features/steps/test_files/doc-no-customprops.docx b/features/steps/test_files/doc-no-customprops.docx new file mode 100644 index 000000000..588bf557f Binary files /dev/null and b/features/steps/test_files/doc-no-customprops.docx differ diff --git a/src/docx/__init__.py b/src/docx/__init__.py index 11aa8f79d..275e53454 100644 --- a/src/docx/__init__.py +++ b/src/docx/__init__.py @@ -13,7 +13,7 @@ if TYPE_CHECKING: from docx.opc.part import Part -__version__ = "1.0.0-dev" +__version__ = "1.0.0-rc1" __all__ = ["Document"] @@ -25,6 +25,7 @@ from docx.opc.constants import RELATIONSHIP_TYPE as RT from docx.opc.part import PartFactory from docx.opc.parts.coreprops import CorePropertiesPart +from docx.opc.parts.customprops import CustomPropertiesPart from docx.parts.document import DocumentPart from docx.parts.hdrftr import FooterPart, HeaderPart from docx.parts.image import ImagePart @@ -44,6 +45,7 @@ def part_class_selector(content_type: str, reltype: str) -> Type[Part] | None: PartFactory.part_class_selector = part_class_selector PartFactory.part_type_for[CT.WML_COMMENTS] = CommentsPart PartFactory.part_type_for[CT.OPC_CORE_PROPERTIES] = CorePropertiesPart +PartFactory.part_type_for[CT.OPC_CUSTOM_PROPERTIES] = CustomPropertiesPart PartFactory.part_type_for[CT.WML_DOCUMENT_MAIN] = DocumentPart PartFactory.part_type_for[CT.WML_FOOTER] = FooterPart PartFactory.part_type_for[CT.WML_HEADER] = HeaderPart diff --git a/src/docx/document.py b/src/docx/document.py index fefb0b51a..805bd5065 100644 --- a/src/docx/document.py +++ b/src/docx/document.py @@ -213,6 +213,14 @@ def core_properties(self): """A |CoreProperties| object providing Dublin Core properties of document.""" return self._part.core_properties + @property + def custom_properties(self): + """ + A |CustomProperties| object providing read/write access to the custom + properties of this document. + """ + return self._part.custom_properties + @property def inline_shapes(self): """The |InlineShapes| collection for this document. diff --git a/src/docx/opc/constants.py b/src/docx/opc/constants.py index 89d3c16cc..892bd9dac 100644 --- a/src/docx/opc/constants.py +++ b/src/docx/opc/constants.py @@ -45,6 +45,7 @@ class CONTENT_TYPE: ) OFC_VML_DRAWING = "application/vnd.openxmlformats-officedocument.vmlDrawing" OPC_CORE_PROPERTIES = "application/vnd.openxmlformats-package.core-properties+xml" + OPC_CUSTOM_PROPERTIES = "application/vnd.openxmlformats-officedocument.custom-properties+xml" OPC_DIGITAL_SIGNATURE_CERTIFICATE = ( "application/vnd.openxmlformats-package.digital-signature-certificate" ) diff --git a/src/docx/opc/customprops.py b/src/docx/opc/customprops.py new file mode 100644 index 000000000..83eea5844 --- /dev/null +++ b/src/docx/opc/customprops.py @@ -0,0 +1,81 @@ +# encoding: utf-8 + +""" +Support reading and writing custom properties to and from a .docx file. +""" + +from __future__ import ( + absolute_import, division, print_function, unicode_literals +) + +import numbers +from lxml import etree + +NS_VT = "http://schemas.openxmlformats.org/officeDocument/2006/docPropsVTypes" + + +class CustomProperties(object): + """ + Corresponds to part named ``/docProps/custom.xml``, containing the custom + document properties for this document package. + """ + def __init__(self, element): + self._element = element + + def __getitem__(self, item): + prop = self.lookup(item) + if prop is not None: + elm = prop[0] + if elm.tag == f"{{{NS_VT}}}i4": + try: + return int(elm.text) + except ValueError: + return elm.text + elif elm.tag == f"{{{NS_VT}}}bool": + return True if elm.text == '1' else False + return elm.text + + def __setitem__(self, key, value): + prop = self.lookup(key) + if prop is None: + elm_type = 'lpwstr' + if isinstance(value, bool): + elm_type = 'bool' + value = str(1 if value else 0) + elif isinstance(value, numbers.Number): + elm_type = 'i4' + value = str(int(value)) + prop = etree.SubElement(self._element, "property") + elm = etree.SubElement(prop, f"{{{NS_VT}}}{elm_type}", nsmap={'vt':NS_VT}) + elm.text = value + prop.set("name", key) + # magic number "FMTID_UserDefinedProperties" + # MS doc ref: https://learn.microsoft.com/de-de/windows/win32/stg/predefined-property-set-format-identifiers + prop.set("fmtid", "{D5CDD505-2E9C-101B-9397-08002B2CF9AE}") + prop.set("pid", str(len(self._element) + 1)) + else: + elm = prop[0] + if elm.tag == f"{{{NS_VT}}}i4": + elm.text = str(int(value)) + elif elm.tag == f"{{{NS_VT}}}bool": + elm.text = str(1 if value else 0) + else: + elm.text = str(value) + + def __delitem__(self, key): + prop = self.lookup(key) + if prop is not None: + self._element.remove(prop) + + def __len__(self): + return len(self._element) + + def __iter__(self): + for child in self._element: + yield child.get("name") + + def lookup(self, item): + for child in self._element: + if child.get("name") == item: + return child + return None diff --git a/src/docx/opc/package.py b/src/docx/opc/package.py index 28291cb77..c456658f9 100644 --- a/src/docx/opc/package.py +++ b/src/docx/opc/package.py @@ -8,6 +8,7 @@ from docx.opc.packuri import PACKAGE_URI, PackURI from docx.opc.part import PartFactory from docx.opc.parts.coreprops import CorePropertiesPart +from docx.opc.parts.customprops import CustomPropertiesPart from docx.opc.pkgreader import PackageReader from docx.opc.pkgwriter import PackageWriter from docx.parts.comments import CommentsPart @@ -46,6 +47,14 @@ def core_properties(self) -> CoreProperties: properties for this document.""" return self._core_properties_part.core_properties + @property + def custom_properties(self): + """ + |CustomProperties| object providing read/write access to the + custom properties for this document. + """ + return self._custom_properties_part.custom_properties + def iter_rels(self) -> Iterator[_Relationship]: """Generate exactly one reference to each relationship in the package by performing a depth-first traversal of the rels graph.""" @@ -180,7 +189,7 @@ def _core_properties_part(self) -> CorePropertiesPart: core_properties_part = CorePropertiesPart.default(self) self.relate_to(core_properties_part, RT.CORE_PROPERTIES) return core_properties_part - + @property def _comments_part(self): """ @@ -190,10 +199,23 @@ def _comments_part(self): try: return self.part_related_by(RT.COMMENTS) except KeyError: - comments_part = CommentsPart.default(self) + comments_part = CommentsPart.default(self) self.relate_to(comments_part, RT.COMMENTS) return comments_part + @property + def _custom_properties_part(self): + """ + |CustomPropertiesPart| object related to this package. Creates + a default custom properties part if one is not present (not common). + """ + try: + return self.part_related_by(RT.CUSTOM_PROPERTIES) + except KeyError: + custom_properties_part = CustomPropertiesPart.default(self) + self.relate_to(custom_properties_part, RT.CUSTOM_PROPERTIES) + return custom_properties_part + @property def _footnotes_part(self): """ diff --git a/src/docx/opc/parts/customprops.py b/src/docx/opc/parts/customprops.py new file mode 100644 index 000000000..f0ec31669 --- /dev/null +++ b/src/docx/opc/parts/customprops.py @@ -0,0 +1,70 @@ +# encoding: utf-8 + +""" +Custom properties part, corresponds to ``/docProps/custom.xml`` part in package. +""" + +from __future__ import ( + absolute_import, division, print_function, unicode_literals +) + +from lxml import etree + +from docx.opc.constants import CONTENT_TYPE as CT +from docx.opc.customprops import CustomProperties +from docx.oxml.customprops import CT_CustomProperties +from docx.opc.packuri import PackURI +from docx.opc.part import XmlPart + +# configure XML parser +parser_lookup = etree.ElementDefaultClassLookup(element=CT_CustomProperties) +ct_parser = etree.XMLParser(remove_blank_text=True) +ct_parser.set_element_class_lookup(parser_lookup) + + +def ct_parse_xml(xml): + """ + Return root lxml element obtained by parsing XML character string in + *xml*, which can be either a Python 2.x string or unicode. The custom + parser is used, so custom element classes are produced for elements in + *xml* that have them. + """ + root_element = etree.fromstring(xml, ct_parser) + return root_element + + +class CustomPropertiesPart(XmlPart): + """ + Corresponds to part named ``/docProps/custom.xml``, containing the custom + document properties for this document package. + """ + @classmethod + def default(cls, package): + """ + Return a new |CustomPropertiesPart| object initialized with default + values for its base properties. + """ + custom_properties_part = cls._new(package) + return custom_properties_part + + @property + def custom_properties(self): + """ + A |CustomProperties| object providing read/write access to the custom + properties contained in this custom properties part. + """ + return CustomProperties(self.element) + + @classmethod + def load(cls, partname, content_type, blob, package): + element = ct_parse_xml(blob) + return cls(partname, content_type, element, package) + + @classmethod + def _new(cls, package): + partname = PackURI('/docProps/custom.xml') + content_type = CT.OPC_CUSTOM_PROPERTIES + customProperties = CT_CustomProperties.new() + return CustomPropertiesPart( + partname, content_type, customProperties, package + ) diff --git a/src/docx/oxml/__init__.py b/src/docx/oxml/__init__.py index d746797d7..e385badd8 100644 --- a/src/docx/oxml/__init__.py +++ b/src/docx/oxml/__init__.py @@ -88,6 +88,10 @@ register_element_cls("cp:coreProperties", CT_CoreProperties) +from .customprops import CT_CustomProperties # noqa + +register_element_cls('cup:Properties', CT_CustomProperties) + from .document import CT_Body, CT_Document # noqa register_element_cls("w:body", CT_Body) diff --git a/src/docx/oxml/customprops.py b/src/docx/oxml/customprops.py new file mode 100644 index 000000000..506645289 --- /dev/null +++ b/src/docx/oxml/customprops.py @@ -0,0 +1,146 @@ +# encoding: utf-8 + +""" +lxml custom element classes for core properties-related XML elements. +""" + +from __future__ import ( + absolute_import, division, print_function, unicode_literals +) + +from datetime import datetime, timedelta +import re + +from docx.oxml.ns import nsdecls, qn +from docx.oxml.xmlchemy import BaseOxmlElement +from docx.oxml import parse_xml + + + +class CT_CustomProperties(BaseOxmlElement): + """ + ```` element, the root element of the Custom Properties + part stored as ``/docProps/custom.xml``. String elements are + limited in length to 255 unicode characters. + """ + + _customProperties_tmpl = "\n" % nsdecls("cup", "vt") + _offset_pattern = re.compile("([+-])(\\d\\d):(\\d\\d)") + + @classmethod + def new(cls): + """ + Return a new ```` element + """ + xml = cls._customProperties_tmpl + custom_properties = parse_xml(xml) + return custom_properties + + def _datetime_of_element(self, property_name): + element = getattr(self, property_name) + if element is None: + return None + datetime_str = element.text + try: + return self._parse_W3CDTF_to_datetime(datetime_str) + except ValueError: + # invalid datetime strings are ignored + return None + + def _get_or_add(self, prop_name): + """ + Return element returned by 'get_or_add_' method for *prop_name*. + """ + get_or_add_method_name = 'get_or_add_%s' % prop_name + get_or_add_method = getattr(self, get_or_add_method_name) + element = get_or_add_method() + return element + + @classmethod + def _offset_dt(cls, dt, offset_str): + """ + Return a |datetime| instance that is offset from datetime *dt* by + the timezone offset specified in *offset_str*, a string like + ``'-07:00'``. + """ + match = cls._offset_pattern.match(offset_str) + if match is None: + raise ValueError( + "'%s' is not a valid offset string" % offset_str + ) + sign, hours_str, minutes_str = match.groups() + sign_factor = -1 if sign == '+' else 1 + hours = int(hours_str) * sign_factor + minutes = int(minutes_str) * sign_factor + td = timedelta(hours=hours, minutes=minutes) + return dt + td + + @classmethod + def _parse_W3CDTF_to_datetime(cls, w3cdtf_str): + # valid W3CDTF date cases: + # yyyy e.g. '2003' + # yyyy-mm e.g. '2003-12' + # yyyy-mm-dd e.g. '2003-12-31' + # UTC timezone e.g. '2003-12-31T10:14:55Z' + # numeric timezone e.g. '2003-12-31T10:14:55-08:00' + templates = ( + '%Y-%m-%dT%H:%M:%S', + '%Y-%m-%d', + '%Y-%m', + '%Y', + ) + # strptime isn't smart enough to parse literal timezone offsets like + # '-07:30', so we have to do it ourselves + parseable_part = w3cdtf_str[:19] + offset_str = w3cdtf_str[19:] + dt = None + for tmpl in templates: + try: + dt = datetime.strptime(parseable_part, tmpl) + except ValueError: + continue + if dt is None: + raise ValueError("could not parse W3CDTF datetime string '%s'" % {w3cdtf_str}) + if len(offset_str) == 6: + return cls._offset_dt(dt, offset_str) + return dt + + def _set_element_datetime(self, prop_name, value): + """ + Set date/time value of child element having *prop_name* to *value*. + """ + if not isinstance(value, datetime): + raise ValueError("property requires object, got %s" % type(value)) + element = self._get_or_add(prop_name) + dt_str = value.strftime('%Y-%m-%dT%H:%M:%SZ') + element.text = dt_str + if prop_name in ('created', 'modified'): + # These two require an explicit 'xsi:type="dcterms:W3CDTF"' attribute. + # The first and last line are a hack required to add + # the xsi namespace to the root element rather than each child + # element in which it is referenced. + self.set(qn('xsi:foo'), 'bar') + element.set(qn('xsi:type'), 'dcterms:W3CDTF') + del self.attrib[qn('xsi:foo')] + + def _set_element_text(self, prop_name, value): + """ + Set string value of *name* property to *value*. + """ + value = str(value) + if len(value) > 255: + raise ValueError("exceeded 255 char limit for property, got:\n\n'%s'" % value) + element = self._get_or_add(prop_name) + element.text = value + + def _text_of_element(self, property_name): + """ + Return the text in the element matching *property_name*, or an empty + string if the element is not present or contains no text. + """ + element = getattr(self, property_name) + if element is None: + return '' + if element.text is None: + return '' + return element.text diff --git a/src/docx/oxml/ns.py b/src/docx/oxml/ns.py index 5bed1e6a0..f179f61fc 100644 --- a/src/docx/oxml/ns.py +++ b/src/docx/oxml/ns.py @@ -8,6 +8,7 @@ "a": "http://schemas.openxmlformats.org/drawingml/2006/main", "c": "http://schemas.openxmlformats.org/drawingml/2006/chart", "cp": "http://schemas.openxmlformats.org/package/2006/metadata/core-properties", + "cup": "http://schemas.openxmlformats.org/officeDocument/2006/custom-properties", "dc": "http://purl.org/dc/elements/1.1/", "dcmitype": "http://purl.org/dc/dcmitype/", "dcterms": "http://purl.org/dc/terms/", @@ -16,6 +17,7 @@ "pic": "http://schemas.openxmlformats.org/drawingml/2006/picture", "r": "http://schemas.openxmlformats.org/officeDocument/2006/relationships", "sl": "http://schemas.openxmlformats.org/schemaLibrary/2006/main", + "vt": "http://schemas.openxmlformats.org/officeDocument/2006/docPropsVTypes", "w": "http://schemas.openxmlformats.org/wordprocessingml/2006/main", "w14": "http://schemas.microsoft.com/office/word/2010/wordml", "wp": "http://schemas.openxmlformats.org/drawingml/2006/wordprocessingDrawing", diff --git a/src/docx/oxml/text/delrun.py b/src/docx/oxml/text/delrun.py index e9d6f98cf..1f18e6dc3 100644 --- a/src/docx/oxml/text/delrun.py +++ b/src/docx/oxml/text/delrun.py @@ -53,9 +53,8 @@ def text(self, text): def clear_content(self): """ - Remove all child elements except the ```` element if present. + Remove all child elements. """ - # content_child_elms = self[1:] if self.rPr is not None else self[:] for child in self[:]: self.remove(child) diff --git a/src/docx/parts/document.py b/src/docx/parts/document.py index 2f4ef6b9f..e96efa642 100644 --- a/src/docx/parts/document.py +++ b/src/docx/parts/document.py @@ -50,6 +50,14 @@ def core_properties(self) -> CoreProperties: of this document.""" return self.package.core_properties + @property + def custom_properties(self): + """ + A |CustomProperties| object providing read/write access to the custom + properties of this document. + """ + return self.package.custom_properties + @property def document(self): """A |Document| object providing access to the content of this document.""" diff --git a/tests/opc/parts/test_customprops.py b/tests/opc/parts/test_customprops.py new file mode 100644 index 000000000..5c37dbda1 --- /dev/null +++ b/tests/opc/parts/test_customprops.py @@ -0,0 +1,42 @@ +# encoding: utf-8 + +""" +Unit test suite for the docx.opc.parts.customprops module +""" + +from __future__ import ( + absolute_import, division, print_function, unicode_literals +) + +import pytest + +from docx.opc.customprops import CustomProperties +from docx.opc.parts.customprops import CustomPropertiesPart +from docx.oxml.customprops import CT_CustomProperties + +from tests.unitutil.mock import class_mock, instance_mock + + +class DescribeCustomPropertiesPart(object): + + def it_provides_access_to_its_custom_props_object(self, element_, mock_custom_properties_): + custom_properties_part = CustomPropertiesPart(None, None, element_, None) + custom_properties = custom_properties_part.custom_properties + mock_custom_properties_.assert_called_once_with(custom_properties_part.element) + assert isinstance(custom_properties, CustomProperties) + + def it_can_create_a_default_custom_properties_part(self): + custom_properties_part = CustomPropertiesPart.default(None) + assert isinstance(custom_properties_part, CustomPropertiesPart) + custom_properties = custom_properties_part.custom_properties + assert len(custom_properties) == 0 + + # fixtures --------------------------------------------- + + @pytest.fixture + def mock_custom_properties_(self, request): + return class_mock(request, 'docx.opc.parts.customprops.CustomProperties') + + @pytest.fixture + def element_(self, request): + return instance_mock(request, CT_CustomProperties) diff --git a/tests/opc/test_customprops.py b/tests/opc/test_customprops.py new file mode 100644 index 000000000..0fd33f240 --- /dev/null +++ b/tests/opc/test_customprops.py @@ -0,0 +1,106 @@ +# encoding: utf-8 + +""" +Unit test suite for the docx.opc.customprops module +""" + +from __future__ import ( + absolute_import, division, print_function, unicode_literals +) + +import pytest + +from docx.opc.customprops import CustomProperties +from docx.oxml import parse_xml + + +class DescribeCustomProperties(object): + + def it_can_read_existing_prop_values(self, prop_get_fixture): + custom_properties, prop_name, exp_value = prop_get_fixture + actual_value = custom_properties[prop_name] + assert actual_value == exp_value + + def it_can_change_existing_prop_values(self, custom_properties_default, prop_set_fixture): + _, prop_name, value, _ = prop_set_fixture + assert custom_properties_default[prop_name] != value + custom_properties_default[prop_name] = value + assert custom_properties_default[prop_name] == value + + def it_can_set_new_prop_values(self, prop_set_fixture): + custom_properties, prop_name, value, exp_xml = prop_set_fixture + custom_properties[prop_name] = value + assert custom_properties._element.xml == exp_xml + + def it_can_delete_existing_prop(self, prop_get_fixture): + custom_properties, prop_name, _ = prop_get_fixture + del custom_properties[prop_name] + assert custom_properties.lookup(prop_name) is None + + def it_can_iterate_existing_props(self, custom_properties_default): + exp_names = ['CustomPropBool', 'CustomPropInt', 'CustomPropString'] + + # check 1: as list + assert list(custom_properties_default) == ['CustomPropBool', 'CustomPropInt', 'CustomPropString'] + + # check 2: use iterator + exp_names_iter = iter(exp_names) + for prop_name in custom_properties_default: + assert prop_name == next(exp_names_iter) + + # fixtures ------------------------------------------------------- + + @pytest.fixture(params=[ + ('CustomPropString', 'Test String'), + ('CustomPropBool', True), + ('CustomPropInt', 13), + ('CustomPropFoo', None), + ]) + def prop_get_fixture(self, request, custom_properties_default): + prop_name, expected_value = request.param + return custom_properties_default, prop_name, expected_value + + @pytest.fixture(params=[ + ('CustomPropString', 'lpwstr', 'Hi there!', 'Hi there!'), + ('CustomPropBool', 'bool', '0', False), + ('CustomPropInt', 'i4', '5', 5), + ]) + def prop_set_fixture(self, request, custom_properties_blank): + prop_name, str_type, str_value, value = request.param + expected_xml = self.build_custom_properties_xml(prop_name, str_type, str_value) + return custom_properties_blank, prop_name, value, expected_xml + + # fixture components --------------------------------------------- + + def build_custom_properties_xml(self, prop_name, str_type, str_value): + tmpl = ( + '\n' + ' \n' + ' %s\n' + ' \n' + '' + ) + return tmpl % (prop_name, str_type, str_value, str_type) + + @pytest.fixture + def custom_properties_blank(self): + element = parse_xml( + '' + '\n' + ) + return CustomProperties(element) + + @pytest.fixture + def custom_properties_default(self): + element = parse_xml( + b'\n' + b'\n' + b' 1\n' + b' 13\n' + b' Test String\n' + b'\n' + ) + return CustomProperties(element)