diff --git a/docx/__init__.py b/docx/__init__.py index 08fabcda8..b5ade25ca 100644 --- a/docx/__init__.py +++ b/docx/__init__.py @@ -2,7 +2,7 @@ from docx.api import Document # noqa -__version__ = '0.8.10.30' +__version__ = '0.8.10.31' # register custom Part classes with opc package reader diff --git a/docx/document.py b/docx/document.py index 6918649a9..1ab77bbe1 100644 --- a/docx/document.py +++ b/docx/document.py @@ -261,31 +261,31 @@ def _calculate_next_footnote_reference_id(self, p): # in |Footnotes| and in |Paragraph| new_fr_id = 1 # If paragraph already contains footnotes - # and it's the last paragraph with footnotes, then # append the new footnote and the end with the next reference id. - if p.footnote_reference_ids is not None: + if len(p.footnote_reference_ids) > 0: new_fr_id = p.footnote_reference_ids[-1] + 1 - # If the document has footnotes after this paragraph, - # the increment all footnotes pass this paragraph, - # and insert a new footnote at the proper position. - # break the loop when we get to the footnote before the one we are inserting. + # Read the paragraphs containing footnotes and find where the + # new footnote will be. Keeping in mind that the footnotes are + # sorted by id. + # The value of the new footnote id is the value of the first paragraph + # containing the footnote id that is before the new footnote, incremented by one. + # If a paragraph with footnotes is after the new footnote + # then increment thous footnote ids. has_passed_containing_para = False for p_i in reversed(range(len(self.paragraphs))): # mark when we pass the paragraph containing the footnote if p is self.paragraphs[p_i]._p: has_passed_containing_para = True continue - # skip paragraphs without footnotes (they don't impact new id) - if self.paragraphs[p_i]._p.footnote_reference_ids is None: + # Skip paragraphs without footnotes (they don't impact new id). + if len(self.paragraphs[p_i]._p.footnote_reference_ids) == 0: continue - # update footnote id of paragraph that is after the - # paragraph that is inserting new footnote. + # These footnotes are after the new footnote, so we increment them. if not has_passed_containing_para: self.paragraphs[p_i].increment_containing_footnote_reference_ids() else: - # this is the first paragraph containing footnotes before the - # paragraph that is inserting new footnote, so we get the largest - # reference id and add one + # This is the last footnote before the new footnote, so we use its + # value to determent the value of the new footnote. new_fr_id = max(self.paragraphs[p_i]._p.footnote_reference_ids)+1 break return new_fr_id diff --git a/docx/footnotes.py b/docx/footnotes.py index 29361fdae..9d246608e 100644 --- a/docx/footnotes.py +++ b/docx/footnotes.py @@ -37,7 +37,7 @@ def add_footnote(self, footnote_reference_id): """ elements = self._element # for easy access new_footnote = None - if elements.get_by_id(footnote_reference_id): + if elements.get_by_id(footnote_reference_id) is not None: # When adding a footnote it can be inserted # in front of some other footnotes, so # we need to sort footnotes by `footnote_reference_id` diff --git a/docx/oxml/__init__.py b/docx/oxml/__init__.py index 459ab45d9..f92fe792a 100644 --- a/docx/oxml/__init__.py +++ b/docx/oxml/__init__.py @@ -100,19 +100,28 @@ def OxmlElement(nsptag_str, attrs=None, nsdecls=None): register_element_cls('w:startOverride', CT_DecimalNumber) from .section import ( # noqa + CT_FtnProps, + CT_FtnPos, CT_HdrFtr, CT_HdrFtrRef, CT_PageMar, CT_PageSz, + CT_NumFmt, + CT_NumRestart, CT_SectPr, CT_SectType, ) register_element_cls("w:footerReference", CT_HdrFtrRef) +register_element_cls('w:footnotePr', CT_FtnProps) register_element_cls("w:ftr", CT_HdrFtr) register_element_cls("w:hdr", CT_HdrFtr) register_element_cls("w:headerReference", CT_HdrFtrRef) +register_element_cls('w:numFmt', CT_NumFmt) +register_element_cls('w:numStart', CT_DecimalNumber) +register_element_cls('w:numRestart', CT_NumRestart) register_element_cls("w:pgMar", CT_PageMar) register_element_cls("w:pgSz", CT_PageSz) +register_element_cls('w:pos', CT_FtnPos) register_element_cls("w:sectPr", CT_SectPr) register_element_cls("w:type", CT_SectType) diff --git a/docx/oxml/footnote.py b/docx/oxml/footnote.py index 67779f7bd..69c4a876f 100644 --- a/docx/oxml/footnote.py +++ b/docx/oxml/footnote.py @@ -27,7 +27,7 @@ def add_footnote(self, footnote_reference_id): return new_f def get_by_id(self, id): - found = self.xpath('w:footnote[@w:id="%s"]' % id) + found = self.xpath(f'w:footnote[@w:id="{id}"]') if not found: return None return found[0] @@ -49,16 +49,3 @@ def add_footnote_before(self, footnote_reference_id): new_footnote.id = footnote_reference_id self.addprevious(new_footnote) return new_footnote - - @property - def paragraphs(self): - """ - Returns a list of paragraphs |CT_P|, or |None| if none paragraph is present. - """ - paragraphs = [] - for child in self: - if child.tag == qn('w:p'): - paragraphs.append(child) - if paragraphs == []: - paragraphs = None - return paragraphs diff --git a/docx/oxml/section.py b/docx/oxml/section.py index ca7011182..8f09cac05 100644 --- a/docx/oxml/section.py +++ b/docx/oxml/section.py @@ -6,8 +6,11 @@ from copy import deepcopy +from warnings import warn + from docx.enum.section import WD_HEADER_FOOTER, WD_ORIENTATION, WD_SECTION_START -from docx.oxml.simpletypes import ST_SignedTwipsMeasure, ST_TwipsMeasure, XsdString +from docx.oxml.exceptions import XmlchemyError +from docx.oxml.simpletypes import ST_SignedTwipsMeasure, ST_TwipsMeasure, XsdString, ST_FtnPos, ST_NumberFormat, ST_RestartNumber from docx.oxml.xmlchemy import ( BaseOxmlElement, OptionalAttribute, @@ -17,6 +20,22 @@ ) +class CT_FtnPos(BaseOxmlElement): + """```` element, footnote placement""" + val = RequiredAttribute('w:val', ST_FtnPos) + + +class CT_FtnProps(BaseOxmlElement): + """```` element, section wide footnote properties""" + _tag_seq = ( + 'w:pos', 'w:numFmt', 'w:numStart', 'w:numRestart' + ) + pos = ZeroOrOne('w:pos', successors=_tag_seq) + numFmt = ZeroOrOne('w:numFmt', successors=_tag_seq[1:]) + numStart = ZeroOrOne('w:numStart', successors=_tag_seq[2:]) + numRestart = ZeroOrOne('w:numRestart', successors=_tag_seq[3:]) + + class CT_HdrFtr(BaseOxmlElement): """`w:hdr` and `w:ftr`, the root element for header and footer part respectively""" @@ -32,6 +51,16 @@ class CT_HdrFtrRef(BaseOxmlElement): rId = RequiredAttribute('r:id', XsdString) +class CT_NumFmt(BaseOxmlElement): + """```` element, footnote numbering format""" + val = RequiredAttribute('w:val', ST_NumberFormat) + + +class CT_NumRestart(BaseOxmlElement): + """```` element, footnote numbering restart location""" + val = RequiredAttribute('w:val', ST_RestartNumber) + + class CT_PageMar(BaseOxmlElement): """ ```` element, defining page margins. @@ -71,6 +100,7 @@ class CT_SectPr(BaseOxmlElement): pgSz = ZeroOrOne("w:pgSz", successors=_tag_seq[4:]) pgMar = ZeroOrOne("w:pgMar", successors=_tag_seq[5:]) titlePg = ZeroOrOne("w:titlePg", successors=_tag_seq[14:]) + footnotePr = ZeroOrOne("w:footnotePr", successors=_tag_seq[1:]) del _tag_seq def add_footerReference(self, type_, rId): @@ -137,6 +167,100 @@ def footer(self, value): pgMar = self.get_or_add_pgMar() pgMar.footer = value + @property + def footnote_number_format(self): + """ + The value of the ``w:val`` attribute in the ```` child + element of ```` element, as a |String|, or |'decimal'| if either the element or the + attribute is not present. + """ + fPr = self.footnotePr + if fPr is None or fPr.numFmt is None: + return 'decimal' + return fPr.numFmt.val + + @footnote_number_format.setter + def footnote_number_format(self, value): + if value is None: + value = 'decimal' + fPr = self.get_or_add_footnotePr() + numFmt = fPr.get_or_add_numFmt() + numFmt.val = value + + @property + def footnote_numbering_restart_location(self): + """ + The value of the ``w:val`` attribute in the ```` child + element of ```` element, as a |String|, or |'continuous'| if either the element or the + attribute is not present. + This property is tied with ````. + """ + fPr = self.footnotePr + if fPr is None or fPr.numRestart is None: + return 'continuous' + return fPr.numRestart.val + + @footnote_numbering_restart_location.setter + def footnote_numbering_restart_location(self, value): + # this property must have an appropriate ```` property. + if value is None: + value = 'continuous' + numStartValue = self.footnote_numbering_start_value + if value != 'continuous' and numStartValue != 1: + raise XmlchemyError( "When `` is not 'continuous', then ```` must be 1.") + fPr = self.get_or_add_footnotePr() + numStart = fPr.get_or_add_numStart() + numRestart = fPr.get_or_add_numRestart() + numStart.val = numStartValue + numRestart.val = value + + @property + def footnote_numbering_start_value(self): + """ + The value of the ``w:val`` attribute in the ```` child + element of ```` element, as a |Number|, or |1| if either the element or the + attribute is not present. + This property is tied with ````. + """ + fPr = self.footnotePr + if fPr is None or fPr.numStart is None: + return 1 + return fPr.numStart.val + + @footnote_numbering_start_value.setter + def footnote_numbering_start_value(self, value): + # this property must have an appropriate ```` property. + if value is None: + value = 1 + numRestartValue = self.footnote_numbering_restart_location + if value != 1 and numRestartValue != 'continuous': + raise XmlchemyError( "When `` is not 1, then ```` must be 'continuous'.") + fPr = self.get_or_add_footnotePr() + numStart = fPr.get_or_add_numStart() + numRestart = fPr.get_or_add_numRestart() + numStart.val = value + numRestart.val = numRestartValue + + @property + def footnote_position(self): + """ + The value of the ``w:val`` attribute in the ```` child + element of ```` element, as a |String|, or |'pageBottom'| if either the element or the + attribute is not present. + """ + fPr = self.footnotePr + if fPr is None or fPr.pos is None: + return 'pageBottom' + return fPr.pos.val + + @footnote_position.setter + def footnote_position(self, value): + if value is None: + value = 'pageBottom' + fPr = self.get_or_add_footnotePr() + pos = fPr.get_or_add_pos() + pos.val = value + def get_footerReference(self, type_): """Return footerReference element of *type_* or None if not present.""" path = "./w:footerReference[@w:type='%s']" % WD_HEADER_FOOTER.to_xml(type_) diff --git a/docx/oxml/simpletypes.py b/docx/oxml/simpletypes.py index 44b17f3a8..36b5330a5 100644 --- a/docx/oxml/simpletypes.py +++ b/docx/oxml/simpletypes.py @@ -249,6 +249,18 @@ def validate(cls, value): ) +class ST_FtnPos(XsdString): + + @classmethod + def validate(cls, value): + cls.validate_string(value) + valid_values = ('pageBottom', 'beneathText', 'sectEnd', 'docEnd') + if value not in valid_values: + raise ValueError( + "must be one of %s, got '%s'" % (valid_values, value) + ) + + class ST_HexColor(BaseStringType): @classmethod @@ -301,6 +313,10 @@ def convert_to_xml(cls, value): return str(half_points) +class ST_NumberFormat(XsdString): + pass + + class ST_Merge(XsdStringEnumeration): """ Valid values for attribute @@ -338,6 +354,18 @@ class ST_RelationshipId(XsdString): pass +class ST_RestartNumber(XsdString): + + @classmethod + def validate(cls, value): + cls.validate_string(value) + valid_values = ('continuous', 'eachSect', 'eachPage') + if value not in valid_values: + raise ValueError( + "must be one of %s, got '%s'" % (valid_values, value) + ) + + class ST_SignedTwipsMeasure(XsdInt): @classmethod diff --git a/docx/oxml/text/paragraph.py b/docx/oxml/text/paragraph.py index b462f8b9c..8cc9cbc43 100644 --- a/docx/oxml/text/paragraph.py +++ b/docx/oxml/text/paragraph.py @@ -88,11 +88,7 @@ def footnote_reference_ids(self): """ footnote_ids = [] for run in self.r_lst: - new_footnote_ids = run.footnote_reference_ids - if new_footnote_ids: - footnote_ids.extend(new_footnote_ids) - if footnote_ids == []: - footnote_ids = None + footnote_ids.extend([ref_id for ref_id in run.footnote_reference_ids]) return footnote_ids def lvl_from_para_props(self, numbering_el): diff --git a/docx/oxml/text/run.py b/docx/oxml/text/run.py index 1fd3d44a6..2db3b3ea7 100644 --- a/docx/oxml/text/run.py +++ b/docx/oxml/text/run.py @@ -79,24 +79,24 @@ def add_drawing(self, inline_or_anchor): def clear_content(self): """ - Remove all child elements except the ```` element if present. + Remove all child elements except the ```` and ```` element if present. """ content_child_elms = self[1:] if self.rPr is not None else self[:] for child in content_child_elms: + # We keep ``w:footnoteReference`` because of the + # platform `replace_special_chars_preprocessor` preprocessor. + if child.tag == qn('w:footnoteReference'): + continue self.remove(child) @property - def footnote_reference_ids(self) -> (list[int]|None): + def footnote_reference_ids(self): """ - Return all footnote reference ids (````), or |None| if not present. + Return all footnote reference ids (````). """ - references = [] for child in self: if child.tag == qn('w:footnoteReference'): - references.append(child.id) - if references == []: - references = None - return references + yield child.id def increment_containing_footnote_reference_ids(self): """ diff --git a/docx/section.py b/docx/section.py index 32ceec7da..1d0b09928 100644 --- a/docx/section.py +++ b/docx/section.py @@ -131,6 +131,56 @@ def footer_distance(self): def footer_distance(self, value): self._sectPr.footer = value + @property + def footnote_number_format(self): + """The number format property for the |Footnotes|. + + Read/write. |'decimal'| if no setting is present in the XML. + """ + return self._sectPr.footnote_number_format + + @footnote_number_format.setter + def footnote_number_format(self, value): + self._sectPr.footnote_number_format = value + + @property + def footnote_numbering_restart_location(self): + """The number restart location property for the |Footnotes|. + + If the value is not |'continuous'| then the footnote number start value property is set to |1|. + Read/write. |'continuous'| if no setting is present in the XML. + """ + return self._sectPr.footnote_numbering_restart_location + + @footnote_numbering_restart_location.setter + def footnote_numbering_restart_location(self, value): + self._sectPr.footnote_numbering_restart_location = value + + @property + def footnote_numbering_start_value(self): + """The number start value property for the |Footnotes|. + + If the value is not |1| then footnote number restart position property is set to |continuous|. + Read/write. |1| if no setting is present in the XML. + """ + return self._sectPr.footnote_numbering_start_value + + @footnote_numbering_start_value.setter + def footnote_numbering_start_value(self, value): + self._sectPr.footnote_numbering_start_value = value + + @property + def footnote_position(self): + """The position property for the |Footnotes|. + + Read/write. |'pageBottom'| if no setting is present in the XML. + """ + return self._sectPr.footnote_position + + @footnote_position.setter + def footnote_position(self, value): + self._sectPr.footnote_position = value + @property def gutter(self): """ diff --git a/docx/shared.py b/docx/shared.py index d11ed2184..e2f8fa913 100644 --- a/docx/shared.py +++ b/docx/shared.py @@ -286,12 +286,20 @@ def find_containing_document(element): """ from .document import Document while True: - if not hasattr(element, '_parent'): - raise PythonDocxError(f'{type(element)} has no `_parent` property.') - if isinstance(element._parent, Document): - return element._parent + if hasattr(element, '_document_part'): + return element._document_part.document + elif hasattr(element, '_parent'): + if isinstance(element._parent, Document): + return element._parent + else: + element = element._parent + elif hasattr(element, 'parent'): + if isinstance(element.parent, Document): + return element.parent + else: + element = element.parent else: - element = element._parent + raise PythonDocxError(f'{type(element)} couldn\'t find root Document.') def is_valid_url(url): diff --git a/docx/text/paragraph.py b/docx/text/paragraph.py index 7630d48ca..669f9eea4 100644 --- a/docx/text/paragraph.py +++ b/docx/text/paragraph.py @@ -47,7 +47,7 @@ def add_footnote(self): The footnotes are kept in order by `footnote_reference_id`, so the appropriate id is calculated based on the current state. """ - document = self._parent._parent + document = find_containing_document(self) new_fr_id = document._calculate_next_footnote_reference_id(self._p) r = self._p.add_r() r.add_footnoteReference(new_fr_id) @@ -190,14 +190,14 @@ def runs_and_hyperlinks(self): @property def footnotes(self): """ - Returns a list of |Footnote| instances that refers to the footnotes in this paragraph, - or |None| if none footnote is defined. + Returns a list of |Footnote| instances that refers to the footnotes in this paragraph. """ - reference_ids = self._p.footnote_reference_ids - if reference_ids == None: - return None - footnotes = self._parent._parent.footnotes footnote_list = [] + reference_ids = self._p.footnote_reference_ids + document = find_containing_document(self) + if document is None: + return footnote_list + footnotes = document.footnotes for ref_id in reference_ids: footnote_list.append(footnotes[ref_id]) return footnote_list @@ -571,7 +571,7 @@ def lstrip(self, chars=None): while self.runs: run = self.runs[0] run.text = run.text.lstrip(chars) - if not run.text: + if not run.text and len(run.footnote_reference_ids) == 0: run._r.getparent().remove(run._r) else: break @@ -585,7 +585,7 @@ def rstrip(self, chars=None): while self.runs: run = self.runs[len(self.runs) - 1] run.text = run.text.rstrip(chars) - if not run.text: + if not run.text and len(run.footnote_reference_ids) == 0: run._r.getparent().remove(run._r) else: break @@ -780,7 +780,7 @@ def image_parts(self): """ Return all image parts related to this paragraph. """ - doc = self.part.document + doc = find_containing_document(self) drawings = [] parts = [] diff --git a/docx/text/run.py b/docx/text/run.py index 243f28448..b3156f958 100644 --- a/docx/text/run.py +++ b/docx/text/run.py @@ -142,11 +142,11 @@ def font(self): return Font(self._element) @property - def footnote_reference_ids(self) -> (list[int]|None): + def footnote_reference_ids(self): """ - Returns all footnote reference ids from the run, or |None| if none found. + Returns all footnote reference ids from the run as a list. """ - return self._r.footnote_reference_ids + return [ref_id for ref_id in self._r.footnote_reference_ids] @property def italic(self):