From afdb63a9e56363fac3ddd815ac5c9ffa1002b337 Mon Sep 17 00:00:00 2001 From: "t.tiberius" Date: Fri, 29 Dec 2023 16:23:59 +0100 Subject: [PATCH 1/7] footnotes: add footnoteReference ``, and footnotes `` support. --- src/docx/__init__.py | 3 + src/docx/document.py | 49 ++++++++++++++ src/docx/footnotes.py | 82 ++++++++++++++++++++++++ src/docx/oxml/__init__.py | 12 ++++ src/docx/oxml/footnote.py | 61 ++++++++++++++++++ src/docx/oxml/text/footnote_reference.py | 15 +++++ src/docx/oxml/text/paragraph.py | 15 +++++ src/docx/oxml/text/run.py | 36 +++++++++++ src/docx/parts/document.py | 22 +++++++ src/docx/parts/footnotes.py | 45 +++++++++++++ src/docx/templates/default-footnotes.xml | 22 +++++++ src/docx/text/paragraph.py | 32 +++++++++ src/docx/text/run.py | 7 ++ 13 files changed, 401 insertions(+) create mode 100644 src/docx/footnotes.py create mode 100644 src/docx/oxml/footnote.py create mode 100644 src/docx/oxml/text/footnote_reference.py create mode 100644 src/docx/parts/footnotes.py create mode 100644 src/docx/templates/default-footnotes.xml diff --git a/src/docx/__init__.py b/src/docx/__init__.py index 205221027..59527f3ab 100644 --- a/src/docx/__init__.py +++ b/src/docx/__init__.py @@ -26,6 +26,7 @@ from docx.opc.part import PartFactory from docx.opc.parts.coreprops import CorePropertiesPart from docx.parts.document import DocumentPart +from docx.parts.footnotes import FootnotesPart from docx.parts.hdrftr import FooterPart, HeaderPart from docx.parts.image import ImagePart from docx.parts.numbering import NumberingPart @@ -43,6 +44,7 @@ def part_class_selector(content_type: str, reltype: str) -> Type[Part] | None: PartFactory.part_type_for[CT.OPC_CORE_PROPERTIES] = CorePropertiesPart PartFactory.part_type_for[CT.WML_DOCUMENT_MAIN] = DocumentPart PartFactory.part_type_for[CT.WML_FOOTER] = FooterPart +PartFactory.part_type_for[CT.WML_FOOTNOTES] = FootnotesPart PartFactory.part_type_for[CT.WML_HEADER] = HeaderPart PartFactory.part_type_for[CT.WML_NUMBERING] = NumberingPart PartFactory.part_type_for[CT.WML_SETTINGS] = SettingsPart @@ -53,6 +55,7 @@ def part_class_selector(content_type: str, reltype: str) -> Type[Part] | None: CorePropertiesPart, DocumentPart, FooterPart, + FootnotesPart, HeaderPart, NumberingPart, PartFactory, diff --git a/src/docx/document.py b/src/docx/document.py index 8944a0e50..8ec747b1e 100644 --- a/src/docx/document.py +++ b/src/docx/document.py @@ -112,6 +112,11 @@ def core_properties(self): """A |CoreProperties| object providing Dublin Core properties of document.""" return self._part.core_properties + @property + def footnotes(self): + """A |Footnotes| object providing access to footnote elements in this document.""" + return self._part.footnotes + @property def inline_shapes(self): """The |InlineShapes| collection for this document. @@ -174,6 +179,10 @@ def tables(self) -> List[Table]: """ return self._body.tables + def _add_footnote(self, footnote_reference_ids): + """Inserts a newly created footnote to |Footnotes|.""" + return self._part.footnotes.add_footnote(footnote_reference_ids) + @property def _block_width(self) -> Length: """A |Length| object specifying the space between margins in last section.""" @@ -187,6 +196,46 @@ def _body(self) -> _Body: self.__body = _Body(self._element.body, self) return self.__body + def _calculate_next_footnote_reference_id(self, p): + """ + Return the appropriate footnote reference id number for + a new footnote added at the end of paragraph `p`. + """ + # When adding a footnote it can be inserted + # in front of some other footnotes, so + # we need to sort footnotes by `footnote_reference_id` + # in |Footnotes| and in |Paragraph| + new_fr_id = 1 + # If paragraph already contains footnotes + # append the new footnote and the end with the next reference id. + if p.footnote_reference_ids is not None: + new_fr_id = p.footnote_reference_ids[-1] + 1 + # 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: + continue + # 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 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 + class _Body(BlockItemContainer): """Proxy for `` element in this document. diff --git a/src/docx/footnotes.py b/src/docx/footnotes.py new file mode 100644 index 000000000..85a4c523b --- /dev/null +++ b/src/docx/footnotes.py @@ -0,0 +1,82 @@ +"""The |Footnotes| object and related proxy classes.""" + +from __future__ import annotations + +from docx.blkcntnr import BlockItemContainer +from docx.shared import Parented + + +class Footnotes(Parented): + """ + Proxy object wrapping ```` element. + """ + def __init__(self, footnotes, parent): + super(Footnotes, self).__init__(parent) + self._element = self._footnotes = footnotes + + def __getitem__(self, reference_id): + """ + A |Footnote| for a specific footnote of reference id, defined with ``w:id`` argument of ````. + If reference id is invalid raises an |IndexError| + """ + footnote = self._element.get_by_id(reference_id) + if footnote is None: + raise IndexError + return Footnote(footnote, self) + + def __len__(self): + return len(self._element) + + def add_footnote(self, footnote_reference_id): + """ + Return a newly created |Footnote|, the new footnote will + be inserted in the correct spot by `footnote_reference_id`. + The footnotes are kept in order by `footnote_reference_id`. + """ + elements = self._element # for easy access + new_footnote = None + if elements.get_by_id(footnote_reference_id): + # When adding a footnote it can be inserted + # in front of some other footnotes, so + # we need to sort footnotes by `footnote_reference_id` + # in |Footnotes| and in |Paragraph| + # + # resolve reference ids in |Footnotes| + # iterate in reverse and compare the current + # id with the inserted id. If there are the same + # insert the new footnote in that place, if not + # increment the current footnote id. + for index in reversed(range(len(elements))): + if elements[index].id == footnote_reference_id: + elements[index].id += 1 + new_footnote = elements[index].add_footnote_before(footnote_reference_id) + break + else: + elements[index].id += 1 + else: + # append the newly created |Footnote| to |Footnotes| + new_footnote = elements.add_footnote(footnote_reference_id) + return Footnote(new_footnote, self) + + +class Footnote(BlockItemContainer): + """ + Proxy object wrapping ```` element. + """ + def __init__(self, f, parent): + super(Footnote, self).__init__(f, parent) + self._f = self._element = f + + def __eq__(self, other): + if isinstance(other, Footnote): + return self._f is other._f + return False + + def __ne__(self, other): + if isinstance(other, Footnote): + return self._f is not other._f + return True + + @property + def id(self): + return self._f.id diff --git a/src/docx/oxml/__init__.py b/src/docx/oxml/__init__.py index bf32932f9..cf6e13f7f 100644 --- a/src/docx/oxml/__init__.py +++ b/src/docx/oxml/__init__.py @@ -241,3 +241,15 @@ register_element_cls("w:tab", CT_TabStop) register_element_cls("w:tabs", CT_TabStops) register_element_cls("w:widowControl", CT_OnOff) + +# --------------------------------------------------------------------------- +# footnote-related mappings + +from .footnote import ( + CT_FtnEnd, + CT_Footnotes +) +from .text.footnote_reference import CT_FtnEdnRef +register_element_cls('w:footnoteReference', CT_FtnEdnRef) +register_element_cls('w:footnote', CT_FtnEnd) +register_element_cls('w:footnotes', CT_Footnotes) diff --git a/src/docx/oxml/footnote.py b/src/docx/oxml/footnote.py new file mode 100644 index 000000000..a7d0f5555 --- /dev/null +++ b/src/docx/oxml/footnote.py @@ -0,0 +1,61 @@ +"""Custom element classes related to footnote (CT_FtnEnd, CT_Footnotes).""" + +from docx.oxml.ns import qn +from docx.oxml.parser import OxmlElement +from docx.oxml.xmlchemy import ( + BaseOxmlElement, RequiredAttribute, ZeroOrMore, OneOrMore +) +from docx.oxml.simpletypes import ( + ST_DecimalNumber +) + +class CT_Footnotes(BaseOxmlElement): + """ + ```` element, containing a sequence of footnote (w:footnote) elements + """ + footnote_sequence = OneOrMore('w:footnote') + + def add_footnote(self, footnote_reference_id): + """ + Create a ```` element with `footnote_reference_id`. + """ + new_f = self.add_footnote_sequence() + new_f.id = footnote_reference_id + return new_f + + def get_by_id(self, id): + found = self.xpath('w:footnote[@w:id="%s"]' % id) + if not found: + return None + return found[0] + + +class CT_FtnEnd(BaseOxmlElement): + """ + ```` element, containing the properties for a specific footnote + """ + id = RequiredAttribute('w:id', ST_DecimalNumber) + p = ZeroOrMore('w:p') + + def add_footnote_before(self, footnote_reference_id): + """ + Create a ```` element with `footnote_reference_id` + and insert it before the current element. + """ + new_footnote = OxmlElement('w:footnote') + 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/src/docx/oxml/text/footnote_reference.py b/src/docx/oxml/text/footnote_reference.py new file mode 100644 index 000000000..be06450b9 --- /dev/null +++ b/src/docx/oxml/text/footnote_reference.py @@ -0,0 +1,15 @@ +"""Custom element classes related to footnote references (CT_FtnEdnRef).""" + +from docx.oxml.xmlchemy import ( + BaseOxmlElement, RequiredAttribute, OptionalAttribute +) +from docx.oxml.simpletypes import ( + ST_DecimalNumber, ST_OnOff +) + +class CT_FtnEdnRef(BaseOxmlElement): + """ + ```` element, containing the properties for a footnote reference + """ + id = RequiredAttribute('w:id', ST_DecimalNumber) + customMarkFollows = OptionalAttribute('w:customMarkFollows', ST_OnOff) diff --git a/src/docx/oxml/text/paragraph.py b/src/docx/oxml/text/paragraph.py index 63e96f312..2e3e71b25 100644 --- a/src/docx/oxml/text/paragraph.py +++ b/src/docx/oxml/text/paragraph.py @@ -70,6 +70,21 @@ def lastRenderedPageBreaks(self) -> List[CT_LastRenderedPageBreak]: "./w:r/w:lastRenderedPageBreak | ./w:hyperlink/w:r/w:lastRenderedPageBreak" ) + @property + def footnote_reference_ids(self): + """ + Return all footnote reference ids (````) form the paragraph, + or |None| if not present. + """ + 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 + return footnote_ids + def set_sectPr(self, sectPr: CT_SectPr): """Unconditionally replace or add `sectPr` as grandchild in correct sequence.""" pPr = self.get_or_add_pPr() diff --git a/src/docx/oxml/text/run.py b/src/docx/oxml/text/run.py index 88efae83c..6544fa5af 100644 --- a/src/docx/oxml/text/run.py +++ b/src/docx/oxml/text/run.py @@ -35,6 +35,18 @@ class CT_R(BaseOxmlElement): drawing = ZeroOrMore("w:drawing") t = ZeroOrMore("w:t") tab = ZeroOrMore("w:tab") + footnoteReference = ZeroOrMore('w:footnoteReference') + + def add_footnoteReference(self, id): + """ + Return a newly added ```` element containing + the footnote reference id. + """ + rPr = self._add_rPr() + rPr.style = 'FootnoteReference' + new_fr = self._add_footnoteReference() + new_fr.id = id + return new_fr def add_t(self, text: str) -> CT_Text: """Return a newly added `` element containing `text`.""" @@ -92,6 +104,30 @@ def lastRenderedPageBreaks(self) -> List[CT_LastRenderedPageBreak]: """All `w:lastRenderedPageBreaks` descendants of this run.""" return self.xpath("./w:lastRenderedPageBreak") + @property + def footnote_reference_ids(self): + """ + Return all footnote reference ids (````), or |None| if not present. + """ + references = [] + for child in self: + if child.tag == qn('w:footnoteReference'): + references.append(child.id) + if references == []: + references = None + return references + + def increment_containing_footnote_reference_ids(self): + """ + Increment all footnote reference ids by one if they exist. + Return all footnote reference ids (````), or |None| if not present. + """ + if self.footnoteReference_lst is not None: + for i in range(len(self.footnoteReference_lst)): + self.footnoteReference_lst[i].id += 1 + return self.footnoteReference_lst + return None + @property def style(self) -> str | None: """String contained in `w:val` attribute of `w:rStyle` grandchild. diff --git a/src/docx/parts/document.py b/src/docx/parts/document.py index 416bb1a27..b71d351fe 100644 --- a/src/docx/parts/document.py +++ b/src/docx/parts/document.py @@ -7,6 +7,7 @@ from docx.document import Document from docx.enum.style import WD_STYLE_TYPE from docx.opc.constants import RELATIONSHIP_TYPE as RT +from docx.parts.footnotes import FootnotesPart from docx.parts.hdrftr import FooterPart, HeaderPart from docx.parts.numbering import NumberingPart from docx.parts.settings import SettingsPart @@ -61,6 +62,14 @@ def footer_part(self, rId: str): """Return |FooterPart| related by `rId`.""" return self.related_parts[rId] + @property + def footnotes(self): + """ + A |Footnotes| object providing access to the footnotes in the footnotes part + of this document. + """ + return self._footnotes_part.footnotes + def get_style(self, style_id: str | None, style_type: WD_STYLE_TYPE) -> BaseStyle: """Return the style in this document matching `style_id`. @@ -119,6 +128,19 @@ def styles(self): document.""" return self._styles_part.styles + @property + def _footnotes_part(self): + """ + Instance of |FootnotesPart| for this document. Creates an empty footnotes + part if one is not present. + """ + try: + return self.part_related_by(RT.FOOTNOTES) + except KeyError: + footnotes_part = FootnotesPart.default(self.package) + self.relate_to(footnotes_part, RT.FOOTNOTES) + return footnotes_part + @property def _settings_part(self) -> SettingsPart: """A |SettingsPart| object providing access to the document-level settings for diff --git a/src/docx/parts/footnotes.py b/src/docx/parts/footnotes.py new file mode 100644 index 000000000..53dd5c80b --- /dev/null +++ b/src/docx/parts/footnotes.py @@ -0,0 +1,45 @@ +"""Provides FootnotesPart and related objects""" + +import os + +from docx.opc.constants import CONTENT_TYPE as CT +from docx.opc.packuri import PackURI +from docx.oxml import parse_xml +from docx.footnotes import Footnotes +from docx.parts.story import StoryPart + + +class FootnotesPart(StoryPart): + """ + Proxy for the footnotes.xml part containing footnotes definitions for a document. + """ + @classmethod + def default(cls, package): + """ + Return a newly created footnote part, containing a default set of elements. + """ + partname = PackURI('/word/footnotes.xml') + content_type = CT.WML_FOOTNOTES + element = parse_xml(cls._default_footnote_xml()) + return cls(partname, content_type, element, package) + + @property + def footnotes(self): + """ + The |Footnotes| instance containing the footnotes ( element + proxies) for this footnotes part. + """ + return Footnotes(self.element, self) + + @classmethod + def _default_footnote_xml(cls): + """ + Return a bytestream containing XML for a default styles part. + """ + path = os.path.join( + os.path.split(__file__)[0], '..', 'templates', + 'default-footnotes.xml' + ) + with open(path, 'rb') as f: + xml_bytes = f.read() + return xml_bytes diff --git a/src/docx/templates/default-footnotes.xml b/src/docx/templates/default-footnotes.xml new file mode 100644 index 000000000..c78624208 --- /dev/null +++ b/src/docx/templates/default-footnotes.xml @@ -0,0 +1,22 @@ + + + + + + + + + + + + + + + + + diff --git a/src/docx/text/paragraph.py b/src/docx/text/paragraph.py index 234ea66cb..3ff3fcf92 100644 --- a/src/docx/text/paragraph.py +++ b/src/docx/text/paragraph.py @@ -27,6 +27,19 @@ def __init__(self, p: CT_P, parent: t.ProvidesStoryPart): super(Paragraph, self).__init__(parent) self._p = self._element = p + def add_footnote(self): + """ + Append a run that contains a ```` element. + 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 + new_fr_id = document._calculate_next_footnote_reference_id(self._p) + r = self._p.add_r() + r.add_footnoteReference(new_fr_id) + footnote = document._add_footnote(new_fr_id) + return footnote + def add_run(self, text: str | None = None, style: str | CharacterStyle | None = None) -> Run: """Append run containing `text` and having character-style `style`. @@ -76,6 +89,21 @@ def hyperlinks(self) -> List[Hyperlink]: """A |Hyperlink| instance for each hyperlink in this paragraph.""" return [Hyperlink(hyperlink, self) for hyperlink in self._p.hyperlink_lst] + @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. + """ + reference_ids = self._p.footnote_reference_ids + if reference_ids == None: + return None + footnotes = self._parent._parent.footnotes + footnote_list = [] + for ref_id in reference_ids: + footnote_list.append(footnotes[ref_id]) + return footnote_list + def insert_paragraph_before( self, text: str | None = None, style: str | ParagraphStyle | None = None ) -> Paragraph: @@ -106,6 +134,10 @@ def iter_inner_content(self) -> Iterator[Run | Hyperlink]: else Hyperlink(r_or_hlink, self) ) + def increment_containing_footnote_reference_ids(self): + for r in self.runs: + r._r.increment_containing_footnote_reference_ids() + @property def paragraph_format(self): """The |ParagraphFormat| object providing access to the formatting properties diff --git a/src/docx/text/run.py b/src/docx/text/run.py index 0e2f5bc17..5be9d802c 100644 --- a/src/docx/text/run.py +++ b/src/docx/text/run.py @@ -136,6 +136,13 @@ def font(self) -> Font: this run, such as font name and size.""" return Font(self._element) + @property + def footnote_reference_ids(self): + """ + Returns all footnote reference ids from the run, or |None| if none found. + """ + return self._r.footnote_reference_ids + @property def italic(self) -> bool | None: """Read/write tri-state value. From b960fbcc673e3f1f63816861e039ccd7b5d28ece Mon Sep 17 00:00:00 2001 From: "t.tiberius" Date: Fri, 29 Dec 2023 16:35:23 +0100 Subject: [PATCH 2/7] footnotes: add footnote properties --- src/docx/oxml/__init__.py | 9 +++ src/docx/oxml/section.py | 117 ++++++++++++++++++++++++++++++++++- src/docx/oxml/simpletypes.py | 27 ++++++++ src/docx/section.py | 50 +++++++++++++++ 4 files changed, 202 insertions(+), 1 deletion(-) diff --git a/src/docx/oxml/__init__.py b/src/docx/oxml/__init__.py index cf6e13f7f..cd204daa4 100644 --- a/src/docx/oxml/__init__.py +++ b/src/docx/oxml/__init__.py @@ -105,20 +105,29 @@ 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:footnotePr', CT_FtnProps) register_element_cls("w:footerReference", CT_HdrFtrRef) 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/src/docx/oxml/section.py b/src/docx/oxml/section.py index 71072e2df..63528eaca 100644 --- a/src/docx/oxml/section.py +++ b/src/docx/oxml/section.py @@ -2,6 +2,8 @@ from __future__ import annotations +from warnings import warn + from copy import deepcopy from typing import Callable, Iterator, List, Sequence, cast @@ -11,7 +13,7 @@ from docx.enum.section import WD_HEADER_FOOTER, WD_ORIENTATION, WD_SECTION_START from docx.oxml.ns import nsmap from docx.oxml.shared import CT_OnOff -from docx.oxml.simpletypes import ST_SignedTwipsMeasure, ST_TwipsMeasure, XsdString +from docx.oxml.simpletypes import ST_SignedTwipsMeasure, ST_TwipsMeasure, XsdString, ST_FtnPos, ST_NumberFormat, ST_RestartNumber from docx.oxml.table import CT_Tbl from docx.oxml.text.paragraph import CT_P from docx.oxml.xmlchemy import ( @@ -26,6 +28,22 @@ BlockElement: TypeAlias = "CT_P | CT_Tbl" +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.""" @@ -57,6 +75,16 @@ class CT_HdrFtrRef(BaseOxmlElement): rId: str = RequiredAttribute("r:id", XsdString) # pyright: ignore[reportAssignmentType] +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.""" @@ -145,6 +173,7 @@ class CT_SectPr(BaseOxmlElement): titlePg: CT_OnOff | None = ZeroOrOne( # pyright: ignore[reportAssignmentType] "w:titlePg", successors=_tag_seq[14:] ) + footnotePr = ZeroOrOne("w:footnotePr", successors=_tag_seq[1:]) del _tag_seq def add_footerReference(self, type_: WD_HEADER_FOOTER, rId: str) -> CT_HdrFtrRef: @@ -211,6 +240,92 @@ def footer(self, value: int | Length | None): pgMar = self.get_or_add_pgMar() pgMar.footer = value if value is None or isinstance(value, Length) else Length(value) + @property + def footnote_number_format(self): + """ + The value of the ``w:val`` attribute in the ```` child + element of ```` element, as a |String|, or |None| if either the element or the + attribute is not present. + """ + fPr = self.footnotePr + if fPr is None or fPr.numFmt: + return None + return fPr.numFmt.val + + @footnote_number_format.setter + def footnote_number_format(self, value): + 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 |None| if either the element or the + attribute is not present. + """ + fPr = self.footnotePr + if fPr is None or fPr.numRestart: + return None + return fPr.numRestart.val + + @footnote_numbering_restart_location.setter + def footnote_numbering_restart_location(self, value): + fPr = self.get_or_add_footnotePr() + numStart = fPr.get_or_add_numStart() + numRestart = fPr.get_or_add_numRestart() + numRestart.val = value + if numStart is None or len(numStart.values()) == 0: + numStart.val = 1 + elif value != 'continuous': + numStart.val = 1 + msg = "When `` is not 'continuous', then ```` must be 1." + warn(msg, UserWarning, stacklevel=2) + + @property + def footnote_numbering_start_value(self): + """ + The value of the ``w:val`` attribute in the ```` child + element of ```` element, as a |Number|, or |None| if either the element or the + attribute is not present. + """ + fPr = self.footnotePr + if fPr is None or fPr.numStart: + return None + return fPr.numStart.val + + @footnote_numbering_start_value.setter + def footnote_numbering_start_value(self, value): + fPr = self.get_or_add_footnotePr() + numStart = fPr.get_or_add_numStart() + numRestart = fPr.get_or_add_numRestart() + numStart.val = value + if numRestart is None or len(numRestart.values()) == 0: + numRestart.val = 'continuous' + elif value != 1: + numRestart.val = 'continuous' + msg = "When `` is not 1, then ```` must be 'continuous'." + warn(msg, UserWarning, stacklevel=2) + + @property + def footnote_position(self): + """ + The value of the ``w:val`` attribute in the ```` child + element of ```` element, as a |String|, or |None| if either the element or the + attribute is not present. + """ + fPr = self.footnotePr + if fPr is None or fPr.pos is None: + return None + return fPr.pos.val + + @footnote_position.setter + def footnote_position(self, value): + fPr = self.get_or_add_footnotePr() + pos = fPr.get_or_add_pos() + pos.val = value + def get_footerReference(self, type_: WD_HEADER_FOOTER) -> CT_HdrFtrRef | None: """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/src/docx/oxml/simpletypes.py b/src/docx/oxml/simpletypes.py index dd10ab910..cf74b90a3 100644 --- a/src/docx/oxml/simpletypes.py +++ b/src/docx/oxml/simpletypes.py @@ -221,6 +221,18 @@ class ST_DrawingElementId(XsdUnsignedInt): pass +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 def convert_from_xml( # pyright: ignore[reportIncompatibleMethodOverride] @@ -271,6 +283,10 @@ def convert_to_xml(cls, value: int | Length) -> str: return str(half_points) +class ST_NumberFormat(XsdString): + pass + + class ST_Merge(XsdStringEnumeration): """Valid values for attribute.""" @@ -305,6 +321,17 @@ 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 def convert_from_xml(cls, str_value: str) -> Length: diff --git a/src/docx/section.py b/src/docx/section.py index 982a14370..ed983922d 100644 --- a/src/docx/section.py +++ b/src/docx/section.py @@ -115,6 +115,56 @@ def footer_distance(self) -> Length | None: def footer_distance(self, value: int | Length | None): self._sectPr.footer = value + @property + def footnote_number_format(self): + """The number format property for the |Footnotes|. + + Read/write. |None| 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. |None| 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. |None| 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. |None| 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) -> Length | None: """|Length| object representing page gutter size in English Metric Units. From d608320f4fcbf3b8dafb1006a7ef17f6aa8d7618 Mon Sep 17 00:00:00 2001 From: "t.tiberius" Date: Fri, 29 Dec 2023 17:03:50 +0100 Subject: [PATCH 3/7] footnotes: clean up logick --- src/docx/document.py | 4 ++-- src/docx/footnotes.py | 2 +- src/docx/oxml/footnote.py | 4 +--- src/docx/oxml/section.py | 6 +++--- src/docx/oxml/text/paragraph.py | 2 -- src/docx/text/paragraph.py | 4 +--- 6 files changed, 8 insertions(+), 14 deletions(-) diff --git a/src/docx/document.py b/src/docx/document.py index 8ec747b1e..735b08402 100644 --- a/src/docx/document.py +++ b/src/docx/document.py @@ -208,7 +208,7 @@ def _calculate_next_footnote_reference_id(self, p): new_fr_id = 1 # If paragraph already contains footnotes # 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 # Read the paragraphs containing footnotes and find where the # new footnote will be. Keeping in mind that the footnotes are @@ -224,7 +224,7 @@ def _calculate_next_footnote_reference_id(self, 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: + if len(self.paragraphs[p_i]._p.footnote_reference_ids) == 0: continue # These footnotes are after the new footnote, so we increment them. if not has_passed_containing_para: diff --git a/src/docx/footnotes.py b/src/docx/footnotes.py index 85a4c523b..031d0ad5c 100644 --- a/src/docx/footnotes.py +++ b/src/docx/footnotes.py @@ -35,7 +35,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/src/docx/oxml/footnote.py b/src/docx/oxml/footnote.py index a7d0f5555..893332419 100644 --- a/src/docx/oxml/footnote.py +++ b/src/docx/oxml/footnote.py @@ -24,7 +24,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] @@ -56,6 +56,4 @@ def paragraphs(self): for child in self: if child.tag == qn('w:p'): paragraphs.append(child) - if paragraphs == []: - paragraphs = None return paragraphs diff --git a/src/docx/oxml/section.py b/src/docx/oxml/section.py index 63528eaca..20874daa8 100644 --- a/src/docx/oxml/section.py +++ b/src/docx/oxml/section.py @@ -248,7 +248,7 @@ def footnote_number_format(self): attribute is not present. """ fPr = self.footnotePr - if fPr is None or fPr.numFmt: + if fPr is None or fPr.numFmt is None: return None return fPr.numFmt.val @@ -266,7 +266,7 @@ def footnote_numbering_restart_location(self): attribute is not present. """ fPr = self.footnotePr - if fPr is None or fPr.numRestart: + if fPr is None or fPr.numRestart is None: return None return fPr.numRestart.val @@ -291,7 +291,7 @@ def footnote_numbering_start_value(self): attribute is not present. """ fPr = self.footnotePr - if fPr is None or fPr.numStart: + if fPr is None or fPr.numStart is None: return None return fPr.numStart.val diff --git a/src/docx/oxml/text/paragraph.py b/src/docx/oxml/text/paragraph.py index 2e3e71b25..2a5eef358 100644 --- a/src/docx/oxml/text/paragraph.py +++ b/src/docx/oxml/text/paragraph.py @@ -81,8 +81,6 @@ def footnote_reference_ids(self): new_footnote_ids = run.footnote_reference_ids if new_footnote_ids: footnote_ids.extend(new_footnote_ids) - if footnote_ids == []: - footnote_ids = None return footnote_ids def set_sectPr(self, sectPr: CT_SectPr): diff --git a/src/docx/text/paragraph.py b/src/docx/text/paragraph.py index 3ff3fcf92..c5ce91402 100644 --- a/src/docx/text/paragraph.py +++ b/src/docx/text/paragraph.py @@ -95,11 +95,9 @@ def footnotes(self): Returns a list of |Footnote| instances that refers to the footnotes in this paragraph, or |None| if none footnote is defined. """ + footnote_list = [] reference_ids = self._p.footnote_reference_ids - if reference_ids == None: - return None footnotes = self._parent._parent.footnotes - footnote_list = [] for ref_id in reference_ids: footnote_list.append(footnotes[ref_id]) return footnote_list From 532f7bfbc21874f387d5d4e5c4a854dae958dbf9 Mon Sep 17 00:00:00 2001 From: "t.tiberius" Date: Mon, 8 Jan 2024 15:39:35 +0100 Subject: [PATCH 4/7] acpt: add scenario for footnotes --- features/doc-access-footnotes.feature | 40 +++++++ features/doc-set-footnote-props.feature | 21 ++++ features/par-access-footnotes.feature | 15 +++ features/par-insert-footnote.feature | 23 ++++ features/steps/footnotes.py | 141 +++++++++++++++++++++++ features/steps/test_files/footnotes.docx | Bin 0 -> 15979 bytes 6 files changed, 240 insertions(+) create mode 100644 features/doc-access-footnotes.feature create mode 100644 features/doc-set-footnote-props.feature create mode 100644 features/par-access-footnotes.feature create mode 100644 features/par-insert-footnote.feature create mode 100644 features/steps/footnotes.py create mode 100755 features/steps/test_files/footnotes.docx diff --git a/features/doc-access-footnotes.feature b/features/doc-access-footnotes.feature new file mode 100644 index 000000000..7f57bcbf5 --- /dev/null +++ b/features/doc-access-footnotes.feature @@ -0,0 +1,40 @@ +Feature: Access document footnotes + In order to operate on an individual footnote + As a developer using python-docx + I need access to each footnote in the footnote collection of a document + I need access to footnote properties + + Scenario: Access footnote from a document containing footnotes + Given a document with 3 footnotes and 2 default footnotes + Then len(footnotes) is 5 + And I can access a footnote by footnote reference id + And I can access a paragraph in a specific footnote + + Scenario: Access a footnote from document with an invalid footnote reference id + Given a document with footnotes + When I try to access a footnote with invalid reference id + Then it trows an IndexError + + Scenario Outline: Access footnote properties + Given a document with footnotes and with all footnotes properties + Then I can access footnote property with value + + Examples: footnote property names and values + | propName | value | + | footnote_position | str('pageBottom') | + | footnote_number_format | str('lowerRoman') | + | footnote_numbering_start_value | int(1) | + | footnote_numbering_restart_location | str('continuous') | + + Scenario Outline: Access footnotes and footnote properties in a document without footnotes + Given a document without footnotes + # there are always 2 default footnotes with footnote reference id of -1 and 0 + Then len(footnotes) is 2 + And I can access footnote property with value + + Examples: footnote property names and values + | propName | value | + | footnote_position | None | + | footnote_number_format | None | + | footnote_numbering_start_value | None | + | footnote_numbering_restart_location | None | diff --git a/features/doc-set-footnote-props.feature b/features/doc-set-footnote-props.feature new file mode 100644 index 000000000..39e8b6b7b --- /dev/null +++ b/features/doc-set-footnote-props.feature @@ -0,0 +1,21 @@ +Feature: Set footnote properties + In order to change footnote properties of a document + As a developer using python-docx + I need a setter for footnote properties + + Scenario Outline: Change footnote properties + Given a document with footnotes and with all footnotes properties + When I change footnote property to + Then I can access footnote property with value + + Examples: footnote property names and values + | propName | value | + | footnote_position | str('beneathText') | + | footnote_position | str('pageBottom') | + | footnote_number_format | str('upperRoman') | + | footnote_number_format | str('decimal') | + | footnote_number_format | str('hex') | + | footnote_numbering_start_value | int(10) | + | footnote_numbering_restart_location | str('eachPage') | + | footnote_numbering_restart_location | str('eachSect') | + | footnote_numbering_restart_location | str('continuous') | diff --git a/features/par-access-footnotes.feature b/features/par-access-footnotes.feature new file mode 100644 index 000000000..ab31ee4b8 --- /dev/null +++ b/features/par-access-footnotes.feature @@ -0,0 +1,15 @@ +Feature: Access paragraph footnotes + In order to operate on an individual footnote + As a developer using python-docx + I need access to every footnote if present in s specific paragraph + + + Scenario Outline: Access all footnote text from a paragraph that might contain a footnote + Given a document with paragraphs[0] containing one, paragraphs[1] containing none, and paragraphs[2] containing two footnotes + Then paragraphs[] has footnote reference ids of , with footnote text + + Examples: footnote values per paragraph + | parId | refIds | fText | + | 0 | int(1) | str(' This is footnote text for the first footnote.') | + | 1 | None | None | + | 2 | [2,3] | [' This is footnote text for the second footnote.', ' This is footnote text for the third footnote.'] | diff --git a/features/par-insert-footnote.feature b/features/par-insert-footnote.feature new file mode 100644 index 000000000..eace61525 --- /dev/null +++ b/features/par-insert-footnote.feature @@ -0,0 +1,23 @@ +Feature: Insert a footnote at the end of a paragraph + In order to add new footnote at the end of a text (paragraph) + As a developer using python-docx + I need a way to add a footnote to the end of a specific paragraph + + + Scenario: Add a new footnote to a paragraph in a document without footnotes + Given a paragraph in a document without footnotes + When I add a footnote to the paragraphs[1] with text ' NEW FOOTNOTE' + Then the document contains a footnote with footnote reference id of 1 with text ' NEW FOOTNOTE' + And len(footnotes) is 3 + + Scenario Outline: Add a new footnote to a paragraph in a document containing one footnote before the paragraph and two footnote after + Given a document with paragraphs[0] containing one, paragraphs[1] containing none, and paragraphs[2] containing two footnotes + When I add a footnote to the paragraphs[1] with text ' NEW FOOTNOTE' + Then paragraphs[] has footnote reference ids of , with footnote text + And len(footnotes) is 6 + + Examples: footnote values per paragraph + | parId | refIds | fText | + | 0 | int(1) | str(' This is footnote text for the first footnote.') | + | 1 | int(2) | str(' NEW FOOTNOTE') | + | 2 | [3,4] | [' This is footnote text for the second footnote.',' This is footnote text for the third footnote.'] | diff --git a/features/steps/footnotes.py b/features/steps/footnotes.py new file mode 100644 index 000000000..7cc713a31 --- /dev/null +++ b/features/steps/footnotes.py @@ -0,0 +1,141 @@ +"""Step implementations for footnote-related features.""" + +from behave import given, when, then +from behave.runner import Context + +from docx import Document +from docx.footnotes import Footnote +from docx.text.paragraph import Paragraph + +from helpers import test_docx + +# given ==================================================== + + +@given("a document with 3 footnotes and 2 default footnotes") +def given_a_document_with_3_footnotes_and_2_default_footnotes(context: Context): + document = Document(test_docx("footnotes")) + context.footnotes = document.footnotes + + +@given("a document with footnotes and with all footnotes properties") +def given_a_document_with_footnotes_and_with_all_footnotes_properties(context: Context): + document = Document(test_docx("footnotes")) + context.section = document.sections[0] + + +@given("a document with footnotes") +def given_a_document_with_footnotes(context: Context): + document = Document(test_docx("footnotes")) + context.footnotes = document.footnotes + + +@given("a document without footnotes") +def given_a_document_without_footnotes(context: Context): + document = Document(test_docx("doc-default")) + context.footnotes = document.footnotes + context.section = document.sections[0] + + +@given("a paragraph in a document without footnotes") +def given_a_paragraph_in_a_document_without_footnotes(context: Context): + document = Document(test_docx("par-known-paragraphs")) + context.paragraphs = document.paragraphs + context.footnotes = document.footnotes + + +@given("a document with paragraphs[0] containing one, paragraphs[1] containing none, and paragraphs[2] containing two footnotes") +def given_a_document_with_3_footnotes(context: Context): + document = Document(test_docx("footnotes")) + context.paragraphs = document.paragraphs + context.footnotes = document.footnotes + + +# when ==================================================== + + +@when("I try to access a footnote with invalid reference id") +def when_I_try_to_access_a_footnote_with_invalid_reference_id(context: Context): + context.exc = None + try: + context.footnotes[10] + except IndexError as e: + context.exc = e + + +@when("I add a footnote to the paragraphs[{parId}] with text '{footnoteText}'") +def when_I_add_a_footnote_to_the_paragraph_with_text_text(context: Context, parId: str, footnoteText: str): + par = context.paragraphs[int(parId)] + new_footnote = par.add_footnote() + new_footnote.add_paragraph(footnoteText) + + +@when("I change footnote property {propName} to {value}") +def when_I_change_footnote_property_propName_to_value(context: Context, propName: str, value: str): + context.section.__setattr__(propName, eval(value)) + + +# then ===================================================== + + +@then("len(footnotes) is {expectedLen}") +def then_len_footnotes_is_len(context: Context, expectedLen: str): + footnotes = context.footnotes + assert len(footnotes) == int(expectedLen), f"expected len(footnotes) of {expectedLen}, got {len(footnotes)}" + + +@then("I can access a footnote by footnote reference id") +def then_I_can_access_a_footnote_by_footnote_reference_id(context: Context): + footnotes = context.footnotes + for refId in range(-1, 3): + footnote = footnotes[refId] + assert isinstance(footnote, Footnote) + + +@then("I can access a paragraph in a specific footnote") +def then_I_can_access_a_paragraph_in_a_specific_footnote(context: Context): + footnotes = context.footnotes + for refId in range(1, 3): + footnote = footnotes[refId] + assert isinstance(footnote.paragraphs[0], Paragraph) + + +@then("it trows an {exceptionType}") +def then_it_trows_an_IndexError(context: Context, exceptionType: str): + exc = context.exc + assert isinstance(exc, eval(exceptionType)), f"expected IndexError, got {type(exc)}" + + +@then("I can access footnote property {propName} with value {value}") +def then_I_can_access_footnote_propery_name_with_value_value(context: Context, propName: str, value: str): + actual_value = context.section.__getattribute__(propName) + expected = eval(value) + assert actual_value == expected, f"expected section.{propName} {value}, got {expected}" + + +@then("the document contains a footnote with footnote reference id of {refId} with text '{footnoteText}'") +def then_the_document_contains_a_footnote_with_footnote_reference_id_of_refId_with_text_text(context: Context, refId: str, footnoteText: str): + par = context.paragraphs[1] + f = par.footnotes[0] + assert f.id == int(refId), f"expected {refId}, got {f.id}" + assert f.paragraphs[0].text == footnoteText, f"expected {footnoteText}, got {f.paragraphs[0].text}" + + +@then("paragraphs[{parId}] has footnote reference ids of {refIds}, with footnote text {fText}") +def then_paragraph_has_footnote_reference_ids_of_refIds_with_footnote_text_text(context: Context, parId: str, refIds: str, fText: str): + par = context.paragraphs[int(parId)] + refIds = eval(refIds) + fText = eval(fText) + if refIds is not None: + if type(refIds) is list: + for i in range(len(refIds)): + f = par.footnotes[i] + assert isinstance(f, Footnote), f"expected to be instance of Footnote, got {type(f)}" + assert f.id == refIds[i], f"expected {refIds[i]}, got {f.id}" + assert f.paragraphs[0].text == fText[i], f"expected '{fText[i]}', got '{f.paragraphs[0].text}'" + else: + f = par.footnotes[0] + assert f.id == int(refIds), f"expected {refIds}, got {f.id}" + assert f.paragraphs[0].text == fText, f"expected '{fText}', got '{f.paragraphs[0].text}'" + else: + assert len(par.footnotes) == 0, f"expected an empty list, got {len(par.footnotes)} elements" diff --git a/features/steps/test_files/footnotes.docx b/features/steps/test_files/footnotes.docx new file mode 100755 index 0000000000000000000000000000000000000000..640bf06bed2ccc279c2afd268c54e3e9288e73ff GIT binary patch literal 15979 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 UVwr#XNN4~r5Ko=q`uXpF0bAH@0RR91 literal 0 HcmV?d00001 From 27b09fde3244e7561011fc72d22331fcc6714282 Mon Sep 17 00:00:00 2001 From: "t.tiberius" Date: Tue, 9 Jan 2024 16:50:21 +0100 Subject: [PATCH 5/7] test: add tests for footnote support --- tests/test_document.py | 17 +++++++++++ tests/test_section.py | 57 ++++++++++++++++++++++++++++++++++++ tests/text/test_paragraph.py | 22 ++++++++++++++ 3 files changed, 96 insertions(+) diff --git a/tests/test_document.py b/tests/test_document.py index 6a2c5af88..94002488e 100644 --- a/tests/test_document.py +++ b/tests/test_document.py @@ -23,6 +23,7 @@ from docx.table import Table from docx.text.paragraph import Paragraph from docx.text.run import Run +from docx.footnotes import Footnotes from .unitutil.cxml import element, xml from .unitutil.mock import Mock, class_mock, instance_mock, method_mock, property_mock @@ -103,6 +104,10 @@ def it_provides_access_to_its_core_properties(self, core_props_fixture): core_properties = document.core_properties assert core_properties is core_properties_ + def it_provides_access_to_its_footnotes(self, footnotes_fixture): + document, footnotes_ = footnotes_fixture + assert document.footnotes is footnotes_ + def it_provides_access_to_its_inline_shapes(self, inline_shapes_fixture): document, inline_shapes_ = inline_shapes_fixture assert document.inline_shapes is inline_shapes_ @@ -246,6 +251,18 @@ def core_props_fixture(self, document_part_, core_properties_): document_part_.core_properties = core_properties_ return document, core_properties_ + @pytest.fixture(params=[ + ('w:footnotes/(w:footnote{w:id=-1}/w:p/w:r/w:t"minus one note", w:footnote{w:id=0}/w:p/w:r/w:t"zero note")'), + ('w:footnotes/(w:footnote{w:id=1}/w:p/w:r/w:t"first note", w:footnote{w:id=2}/w:p/w:r/w:t"second note")'), + ('w:footnotes/(w:footnote{w:id=1}/w:p/w:r/w:t"first note", w:footnote{w:id=2}/w:p/w:r/w:t"second note", w:footnote{w:id=3}/w:p/w:r/w:t"third note")'), + ]) + def footnotes_fixture(self, request, document_part_): + footnotes_cxml = request.param + document = Document(None, document_part_) + footnotes = Footnotes(element(footnotes_cxml), None) + document_part_.footnotes = footnotes + return document, footnotes + @pytest.fixture def inline_shapes_fixture(self, document_part_, inline_shapes_): document = Document(None, document_part_) diff --git a/tests/test_section.py b/tests/test_section.py index 333e755b7..73e2412f6 100644 --- a/tests/test_section.py +++ b/tests/test_section.py @@ -161,6 +161,63 @@ def it_can_change_whether_the_document_has_distinct_odd_and_even_headers( assert sectPr.xml == expected_xml + @pytest.mark.parametrize( + ("sectPr_cxml", "footnote_prop_name", "expected_value"), + [ + ("w:sectPr/w:footnotePr/w:numFmt{w:val=decimal}", "footnote_number_format", "decimal"), + ("w:sectPr/w:footnotePr/w:numFmt{w:val=upperRoman}", "footnote_number_format", "upperRoman"), + ("w:sectPr/w:footnotePr/w:numFmt{w:val=lowerLetter}", "footnote_number_format", "lowerLetter"), + ("w:sectPr/w:footnotePr/w:numFmt{w:val=bullet}", "footnote_number_format", "bullet"), + ("w:sectPr/w:footnotePr/w:pos{w:val=pageBottom}", "footnote_number_format", None), + ("w:sectPr/w:footnotePr/w:pos{w:val=pageBottom}", "footnote_position", "pageBottom"), + ("w:sectPr/w:footnotePr/w:numStart{w:val=5}", "footnote_numbering_start_value", 5), + ("w:sectPr/w:footnotePr/w:numStart{w:val=13}", "footnote_numbering_start_value", 13), + ("w:sectPr/w:footnotePr/w:numRestart{w:val=eachSect}", "footnote_numbering_restart_location", "eachSect"), + ("w:sectPr/w:footnotePr/w:numRestart{w:val=eachPage}", "footnote_numbering_restart_location", "eachPage"), + ], + ) + def it_knows_its_footnote_properties( + self, + sectPr_cxml: str, + footnote_prop_name: str, + expected_value: str | int | None, + document_part_: Mock, + ): + sectPr = cast(CT_SectPr, element(sectPr_cxml)) + section = Section(sectPr, document_part_) + + value = getattr(section, footnote_prop_name) + + assert value == expected_value + + @pytest.mark.parametrize( + ("sectPr_cxml", "footnote_prop_name", "value", "expected_cxml"), + [ + ("w:sectPr", "footnote_number_format", "upperRoman", "w:sectPr/w:footnotePr/w:numFmt{w:val=upperRoman}"), + ("w:sectPr/w:footnotePr/w:numFmt{w:val=decimal}", "footnote_number_format", "upperRoman", "w:sectPr/w:footnotePr/w:numFmt{w:val=upperRoman}"), + ("w:sectPr", "footnote_position", "pageBottom", "w:sectPr/w:footnotePr/w:pos{w:val=pageBottom}"), + ("w:sectPr", "footnote_numbering_start_value", 1, "w:sectPr/w:footnotePr/(w:numStart{w:val=1},w:numRestart{w:val=continuous})"), + ("w:sectPr", "footnote_numbering_start_value", 5, "w:sectPr/w:footnotePr/(w:numStart{w:val=5},w:numRestart{w:val=continuous})"), + ("w:sectPr", "footnote_numbering_restart_location", "eachSect", "w:sectPr/w:footnotePr/(w:numStart{w:val=1},w:numRestart{w:val=eachSect})"), + ("w:sectPr", "footnote_numbering_restart_location", "continuous", "w:sectPr/w:footnotePr/(w:numStart{w:val=1},w:numRestart{w:val=continuous})"), + ], + ) + def it_can_change_its_footnote_properties( + self, + sectPr_cxml: str, + footnote_prop_name: str, + value: str | int | None, + expected_cxml: str, + document_part_: Mock, + ): + sectPr = cast(CT_SectPr, element(sectPr_cxml)) + expected_xml = xml(expected_cxml) + section = Section(sectPr, document_part_) + + setattr(section, footnote_prop_name, value) + + assert section._sectPr.xml == expected_xml + def it_provides_access_to_its_even_page_footer( self, document_part_: Mock, _Footer_: Mock, footer_: Mock ): diff --git a/tests/text/test_paragraph.py b/tests/text/test_paragraph.py index c1451c3c1..fe1a82b01 100644 --- a/tests/text/test_paragraph.py +++ b/tests/text/test_paragraph.py @@ -13,6 +13,8 @@ from docx.text.paragraph import Paragraph from docx.text.parfmt import ParagraphFormat from docx.text.run import Run +from docx.footnotes import Footnotes +from docx.document import Document from ..unitutil.cxml import element, xml from ..unitutil.mock import call, class_mock, instance_mock, method_mock, property_mock @@ -38,6 +40,10 @@ def it_knows_whether_it_contains_a_page_break( assert paragraph.contains_page_break == expected_value + def it_provides_access_to_its_footnotes(self, footnotes_fixture): + paragraph, footnotes_ = footnotes_fixture + assert paragraph.footnotes == footnotes_ + @pytest.mark.parametrize( ("p_cxml", "count"), [ @@ -215,6 +221,22 @@ def it_inserts_a_paragraph_before_to_help(self, _insert_before_fixture): # fixtures ------------------------------------------------------- + @pytest.fixture(params=[ + ('w:p/w:r/w:footnoteReference{w:id=2}', 'w:footnotes/(w:footnote{w:id=1}/w:p/w:r/w:t"first note", w:footnote{w:id=2}/w:p/w:r/w:t"second note")', [2]), + ('w:p/w:r/w:footnoteReference{w:id=1}', 'w:footnotes/(w:footnote{w:id=1}/w:p/w:r/w:t"first note", w:footnote{w:id=2}/w:p/w:r/w:t"second note")', [1]), + ('w:p/w:r/(w:footnoteReference{w:id=1}, w:footnoteReference{w:id=2})', 'w:footnotes/(w:footnote{w:id=1}/w:p/w:r/w:t"first note", w:footnote{w:id=2}/w:p/w:r/w:t"second note")', [1,2]), + ('w:p/w:r/(w:footnoteReference{w:id=3}, w:footnoteReference{w:id=2})', 'w:footnotes/(w:footnote{w:id=1}/w:p/w:r/w:t"first note", w:footnote{w:id=2}/w:p/w:r/w:t"second note", w:footnote{w:id=3}/w:p/w:r/w:t"third note")', [3,2]), + ]) + def footnotes_fixture(self, request, document_part_): + paragraph_cxml, footnotes_cxml, footnote_ids_in_p = request.param + document_elm = element('w:document/w:body') + document = Document(document_elm, document_part_) + paragraph = Paragraph(element(paragraph_cxml), document._body) + footnotes = Footnotes(element(footnotes_cxml), None) + footnotes_in_p = [footnotes[id] for id in footnote_ids_in_p] + document_part_.footnotes = footnotes + return paragraph, footnotes_in_p + @pytest.fixture( params=[ ("w:p", None, None, "w:p/w:r"), From f46762c911f2d888aeccc05316d3fae6ef5f0402 Mon Sep 17 00:00:00 2001 From: "t.tiberius" Date: Fri, 12 Jan 2024 17:03:17 +0100 Subject: [PATCH 6/7] rfctr: improve typing for Footnotes --- src/docx/document.py | 10 +++--- src/docx/footnotes.py | 21 +++++++----- src/docx/oxml/footnote.py | 57 +++++++++++++++++++-------------- src/docx/oxml/section.py | 43 +++++++++++++++---------- src/docx/oxml/text/paragraph.py | 10 +++--- src/docx/oxml/text/run.py | 10 ++++-- src/docx/text/paragraph.py | 19 ++++++----- src/docx/text/run.py | 8 ++--- 8 files changed, 101 insertions(+), 77 deletions(-) diff --git a/src/docx/document.py b/src/docx/document.py index 735b08402..f85acce91 100644 --- a/src/docx/document.py +++ b/src/docx/document.py @@ -22,6 +22,8 @@ from docx.styles.style import ParagraphStyle, _TableStyle from docx.table import Table from docx.text.paragraph import Paragraph + from docx.oxml.footnote import CT_Footnotes, CT_FtnEnd + from docx.oxml.text.paragraph import CT_P class Document(ElementProxy): @@ -113,7 +115,7 @@ def core_properties(self): return self._part.core_properties @property - def footnotes(self): + def footnotes(self) -> CT_Footnotes: """A |Footnotes| object providing access to footnote elements in this document.""" return self._part.footnotes @@ -179,7 +181,7 @@ def tables(self) -> List[Table]: """ return self._body.tables - def _add_footnote(self, footnote_reference_ids): + def _add_footnote(self, footnote_reference_ids: int) -> CT_FtnEnd: """Inserts a newly created footnote to |Footnotes|.""" return self._part.footnotes.add_footnote(footnote_reference_ids) @@ -196,7 +198,7 @@ def _body(self) -> _Body: self.__body = _Body(self._element.body, self) return self.__body - def _calculate_next_footnote_reference_id(self, p): + def _calculate_next_footnote_reference_id(self, p: CT_P) -> int: """ Return the appropriate footnote reference id number for a new footnote added at the end of paragraph `p`. @@ -228,7 +230,7 @@ def _calculate_next_footnote_reference_id(self, p): continue # 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() + self.paragraphs[p_i]._increment_containing_footnote_reference_ids() else: # This is the last footnote before the new footnote, so we use its # value to determent the value of the new footnote. diff --git a/src/docx/footnotes.py b/src/docx/footnotes.py index 031d0ad5c..5be3a692e 100644 --- a/src/docx/footnotes.py +++ b/src/docx/footnotes.py @@ -2,19 +2,24 @@ from __future__ import annotations +from typing import TYPE_CHECKING + from docx.blkcntnr import BlockItemContainer from docx.shared import Parented +if TYPE_CHECKING: + from docx import types as t + from docx.oxml.footnote import CT_FtnEnd, CT_Footnotes class Footnotes(Parented): """ Proxy object wrapping ```` element. """ - def __init__(self, footnotes, parent): + def __init__(self, footnotes: CT_Footnotes, parent: t.ProvidesStoryPart): super(Footnotes, self).__init__(parent) self._element = self._footnotes = footnotes - def __getitem__(self, reference_id): + def __getitem__(self, reference_id: int) -> Footnote: """ A |Footnote| for a specific footnote of reference id, defined with ``w:id`` argument of ````. If reference id is invalid raises an |IndexError| @@ -24,10 +29,10 @@ def __getitem__(self, reference_id): raise IndexError return Footnote(footnote, self) - def __len__(self): + def __len__(self) -> int: return len(self._element) - def add_footnote(self, footnote_reference_id): + def add_footnote(self, footnote_reference_id: int) -> Footnote: """ Return a newly created |Footnote|, the new footnote will be inserted in the correct spot by `footnote_reference_id`. @@ -63,20 +68,20 @@ class Footnote(BlockItemContainer): """ Proxy object wrapping ```` element. """ - def __init__(self, f, parent): + def __init__(self, f: CT_FtnEnd, parent: t.ProvidesStoryPart): super(Footnote, self).__init__(f, parent) self._f = self._element = f - def __eq__(self, other): + def __eq__(self, other) -> bool: if isinstance(other, Footnote): return self._f is other._f return False - def __ne__(self, other): + def __ne__(self, other) -> bool: if isinstance(other, Footnote): return self._f is not other._f return True @property - def id(self): + def id(self) -> int: return self._f.id diff --git a/src/docx/oxml/footnote.py b/src/docx/oxml/footnote.py index 893332419..240c0c4a1 100644 --- a/src/docx/oxml/footnote.py +++ b/src/docx/oxml/footnote.py @@ -1,5 +1,9 @@ """Custom element classes related to footnote (CT_FtnEnd, CT_Footnotes).""" +from __future__ import annotations + +from typing import TYPE_CHECKING, Callable, List + from docx.oxml.ns import qn from docx.oxml.parser import OxmlElement from docx.oxml.xmlchemy import ( @@ -9,25 +13,8 @@ ST_DecimalNumber ) -class CT_Footnotes(BaseOxmlElement): - """ - ```` element, containing a sequence of footnote (w:footnote) elements - """ - footnote_sequence = OneOrMore('w:footnote') - - def add_footnote(self, footnote_reference_id): - """ - Create a ```` element with `footnote_reference_id`. - """ - new_f = self.add_footnote_sequence() - new_f.id = footnote_reference_id - return new_f - - def get_by_id(self, id): - found = self.xpath(f'w:footnote[@w:id="{id}"]') - if not found: - return None - return found[0] +if TYPE_CHECKING: + from docx.oxml.text.paragraph import CT_P class CT_FtnEnd(BaseOxmlElement): @@ -37,7 +24,7 @@ class CT_FtnEnd(BaseOxmlElement): id = RequiredAttribute('w:id', ST_DecimalNumber) p = ZeroOrMore('w:p') - def add_footnote_before(self, footnote_reference_id): + def add_footnote_before(self, footnote_reference_id: int) -> CT_FtnEnd: """ Create a ```` element with `footnote_reference_id` and insert it before the current element. @@ -48,12 +35,34 @@ def add_footnote_before(self, footnote_reference_id): return new_footnote @property - def paragraphs(self): - """ - Returns a list of paragraphs |CT_P|, or |None| if none paragraph is present. - """ + def paragraphs(self) -> List[CT_P]: + """Returns a list of paragraphs |CT_P|.""" + paragraphs = [] for child in self: if child.tag == qn('w:p'): paragraphs.append(child) return paragraphs + + +class CT_Footnotes(BaseOxmlElement): + """ + ```` element, containing a sequence of footnote (w:footnote) elements + """ + add_footnote_sequence: Callable[[], CT_FtnEnd] + + footnote_sequence = OneOrMore('w:footnote') + + def add_footnote(self, footnote_reference_id: int) -> CT_FtnEnd: + """ + Create a ```` element with `footnote_reference_id`. + """ + new_f = self.add_footnote_sequence() + new_f.id = footnote_reference_id + return new_f + + def get_by_id(self, id: int) -> CT_FtnEnd | None: + found = self.xpath(f'w:footnote[@w:id="{id}"]') + if not found: + return None + return found[0] diff --git a/src/docx/oxml/section.py b/src/docx/oxml/section.py index 20874daa8..b6079972a 100644 --- a/src/docx/oxml/section.py +++ b/src/docx/oxml/section.py @@ -12,8 +12,8 @@ from docx.enum.section import WD_HEADER_FOOTER, WD_ORIENTATION, WD_SECTION_START from docx.oxml.ns import nsmap -from docx.oxml.shared import CT_OnOff -from docx.oxml.simpletypes import ST_SignedTwipsMeasure, ST_TwipsMeasure, XsdString, ST_FtnPos, ST_NumberFormat, ST_RestartNumber +from docx.oxml.shared import CT_OnOff, CT_DecimalNumber +from docx.oxml.simpletypes import ST_SignedTwipsMeasure, ST_TwipsMeasure, XsdString, ST_FtnPos, ST_NumberFormat, ST_RestartNumber, ST_DecimalNumber from docx.oxml.table import CT_Tbl from docx.oxml.text.paragraph import CT_P from docx.oxml.xmlchemy import ( @@ -35,13 +35,19 @@ class CT_FtnPos(BaseOxmlElement): class CT_FtnProps(BaseOxmlElement): """```` element, section wide footnote properties""" + + get_or_add_pos: Callable[[], CT_FtnPos] + get_or_add_numFmt: Callable[[], CT_NumFmt] + get_or_add_numStart: Callable[[], CT_DecimalNumber] + get_or_add_numRestart: Callable[[], CT_NumRestart] + _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:]) + pos: CT_FtnPos | None = ZeroOrOne('w:pos', successors=_tag_seq) # pyright: ignore[reportGeneralTypeIssues] + numFmt: CT_NumFmt | None = ZeroOrOne('w:numFmt', successors=_tag_seq[1:]) # pyright: ignore[reportGeneralTypeIssues] + numStart: CT_DecimalNumber | None = ZeroOrOne('w:numStart', successors=_tag_seq[2:]) # pyright: ignore[reportGeneralTypeIssues] + numRestart: CT_NumRestart | None = ZeroOrOne('w:numRestart', successors=_tag_seq[3:]) # pyright: ignore[reportGeneralTypeIssues] class CT_HdrFtr(BaseOxmlElement): @@ -132,6 +138,7 @@ class CT_SectPr(BaseOxmlElement): get_or_add_pgSz: Callable[[], CT_PageSz] get_or_add_titlePg: Callable[[], CT_OnOff] get_or_add_type: Callable[[], CT_SectType] + get_or_add_footnotePr: Callable[[], CT_FtnProps] _add_footerReference: Callable[[], CT_HdrFtrRef] _add_headerReference: Callable[[], CT_HdrFtrRef] _remove_titlePg: Callable[[], None] @@ -173,7 +180,9 @@ class CT_SectPr(BaseOxmlElement): titlePg: CT_OnOff | None = ZeroOrOne( # pyright: ignore[reportAssignmentType] "w:titlePg", successors=_tag_seq[14:] ) - footnotePr = ZeroOrOne("w:footnotePr", successors=_tag_seq[1:]) + footnotePr: CT_FtnProps | None = ZeroOrOne( # pyright: ignore[reportGeneralTypeIssues] + "w:footnotePr", successors=_tag_seq[1:] + ) del _tag_seq def add_footerReference(self, type_: WD_HEADER_FOOTER, rId: str) -> CT_HdrFtrRef: @@ -241,7 +250,7 @@ def footer(self, value: int | Length | None): pgMar.footer = value if value is None or isinstance(value, Length) else Length(value) @property - def footnote_number_format(self): + def footnote_number_format(self) -> ST_NumberFormat | None: """ The value of the ``w:val`` attribute in the ```` child element of ```` element, as a |String|, or |None| if either the element or the @@ -253,13 +262,13 @@ def footnote_number_format(self): return fPr.numFmt.val @footnote_number_format.setter - def footnote_number_format(self, value): + def footnote_number_format(self, value: ST_NumberFormat | None): fPr = self.get_or_add_footnotePr() numFmt = fPr.get_or_add_numFmt() numFmt.val = value @property - def footnote_numbering_restart_location(self): + def footnote_numbering_restart_location(self) -> ST_RestartNumber | None: """ The value of the ``w:val`` attribute in the ```` child element of ```` element, as a |String|, or |None| if either the element or the @@ -271,12 +280,12 @@ def footnote_numbering_restart_location(self): return fPr.numRestart.val @footnote_numbering_restart_location.setter - def footnote_numbering_restart_location(self, value): + def footnote_numbering_restart_location(self, value: ST_RestartNumber | None): fPr = self.get_or_add_footnotePr() numStart = fPr.get_or_add_numStart() numRestart = fPr.get_or_add_numRestart() numRestart.val = value - if numStart is None or len(numStart.values()) == 0: + if len(numStart.values()) == 0: numStart.val = 1 elif value != 'continuous': numStart.val = 1 @@ -284,7 +293,7 @@ def footnote_numbering_restart_location(self, value): warn(msg, UserWarning, stacklevel=2) @property - def footnote_numbering_start_value(self): + def footnote_numbering_start_value(self) -> ST_DecimalNumber | None: """ The value of the ``w:val`` attribute in the ```` child element of ```` element, as a |Number|, or |None| if either the element or the @@ -296,12 +305,12 @@ def footnote_numbering_start_value(self): return fPr.numStart.val @footnote_numbering_start_value.setter - def footnote_numbering_start_value(self, value): + def footnote_numbering_start_value(self, value: ST_DecimalNumber | None): fPr = self.get_or_add_footnotePr() numStart = fPr.get_or_add_numStart() numRestart = fPr.get_or_add_numRestart() numStart.val = value - if numRestart is None or len(numRestart.values()) == 0: + if len(numRestart.values()) == 0: numRestart.val = 'continuous' elif value != 1: numRestart.val = 'continuous' @@ -309,7 +318,7 @@ def footnote_numbering_start_value(self, value): warn(msg, UserWarning, stacklevel=2) @property - def footnote_position(self): + def footnote_position(self) -> ST_FtnPos | None: """ The value of the ``w:val`` attribute in the ```` child element of ```` element, as a |String|, or |None| if either the element or the @@ -321,7 +330,7 @@ def footnote_position(self): return fPr.pos.val @footnote_position.setter - def footnote_position(self, value): + def footnote_position(self, value: ST_FtnPos | None): fPr = self.get_or_add_footnotePr() pos = fPr.get_or_add_pos() pos.val = value diff --git a/src/docx/oxml/text/paragraph.py b/src/docx/oxml/text/paragraph.py index 2a5eef358..79d603a81 100644 --- a/src/docx/oxml/text/paragraph.py +++ b/src/docx/oxml/text/paragraph.py @@ -71,15 +71,13 @@ def lastRenderedPageBreaks(self) -> List[CT_LastRenderedPageBreak]: ) @property - def footnote_reference_ids(self): - """ - Return all footnote reference ids (````) form the paragraph, - or |None| if not present. - """ + def footnote_reference_ids(self) -> List[int]: + """Return all footnote reference ids (````) form the paragraph.""" + footnote_ids = [] for run in self.r_lst: new_footnote_ids = run.footnote_reference_ids - if new_footnote_ids: + if new_footnote_ids and len(new_footnote_ids) > 0: footnote_ids.extend(new_footnote_ids) return footnote_ids diff --git a/src/docx/oxml/text/run.py b/src/docx/oxml/text/run.py index 6544fa5af..6f5dece27 100644 --- a/src/docx/oxml/text/run.py +++ b/src/docx/oxml/text/run.py @@ -15,6 +15,7 @@ from docx.oxml.shape import CT_Anchor, CT_Inline from docx.oxml.text.pagebreak import CT_LastRenderedPageBreak from docx.oxml.text.parfmt import CT_TabStop + from docx.oxml.text.footnote_reference import CT_FtnEdnRef # ------------------------------------------------------------------------------------ # Run-level elements @@ -28,6 +29,9 @@ class CT_R(BaseOxmlElement): get_or_add_rPr: Callable[[], CT_RPr] _add_drawing: Callable[[], CT_Drawing] _add_t: Callable[..., CT_Text] + _add_rPr: Callable[[], CT_RPr] + _add_footnoteReference: Callable[[], CT_FtnEdnRef] + footnoteReference_lst: List[CT_FtnEdnRef] | None rPr: CT_RPr | None = ZeroOrOne("w:rPr") # pyright: ignore[reportAssignmentType] br = ZeroOrMore("w:br") @@ -37,7 +41,7 @@ class CT_R(BaseOxmlElement): tab = ZeroOrMore("w:tab") footnoteReference = ZeroOrMore('w:footnoteReference') - def add_footnoteReference(self, id): + def add_footnoteReference(self, id: int) -> CT_FtnEdnRef: """ Return a newly added ```` element containing the footnote reference id. @@ -105,7 +109,7 @@ def lastRenderedPageBreaks(self) -> List[CT_LastRenderedPageBreak]: return self.xpath("./w:lastRenderedPageBreak") @property - def footnote_reference_ids(self): + def footnote_reference_ids(self) -> List[int] | None: """ Return all footnote reference ids (````), or |None| if not present. """ @@ -117,7 +121,7 @@ def footnote_reference_ids(self): references = None return references - def increment_containing_footnote_reference_ids(self): + def increment_containing_footnote_reference_ids(self) -> CT_FtnEdnRef | None: """ Increment all footnote reference ids by one if they exist. Return all footnote reference ids (````), or |None| if not present. diff --git a/src/docx/text/paragraph.py b/src/docx/text/paragraph.py index c5ce91402..564d852fa 100644 --- a/src/docx/text/paragraph.py +++ b/src/docx/text/paragraph.py @@ -5,6 +5,7 @@ from typing import TYPE_CHECKING, Iterator, List, cast from docx.enum.style import WD_STYLE_TYPE +from docx.oxml.text.footnote_reference import CT_FtnEdnRef from docx.oxml.text.run import CT_R from docx.shared import StoryChild from docx.styles.style import ParagraphStyle @@ -18,6 +19,7 @@ from docx.enum.text import WD_PARAGRAPH_ALIGNMENT from docx.oxml.text.paragraph import CT_P from docx.styles.style import CharacterStyle + from docx.oxml.footnote import CT_FtnEnd class Paragraph(StoryChild): @@ -27,7 +29,7 @@ def __init__(self, p: CT_P, parent: t.ProvidesStoryPart): super(Paragraph, self).__init__(parent) self._p = self._element = p - def add_footnote(self): + def add_footnote(self) -> CT_FtnEnd: """ Append a run that contains a ```` element. The footnotes are kept in order by `footnote_reference_id`, so @@ -90,11 +92,8 @@ def hyperlinks(self) -> List[Hyperlink]: return [Hyperlink(hyperlink, self) for hyperlink in self._p.hyperlink_lst] @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. - """ + def footnotes(self) -> List[CT_FtnEnd]: + """Returns a list of |Footnote| instances that refers to the footnotes in this paragraph.""" footnote_list = [] reference_ids = self._p.footnote_reference_ids footnotes = self._parent._parent.footnotes @@ -132,10 +131,6 @@ def iter_inner_content(self) -> Iterator[Run | Hyperlink]: else Hyperlink(r_or_hlink, self) ) - def increment_containing_footnote_reference_ids(self): - for r in self.runs: - r._r.increment_containing_footnote_reference_ids() - @property def paragraph_format(self): """The |ParagraphFormat| object providing access to the formatting properties @@ -201,3 +196,7 @@ def _insert_paragraph_before(self): """Return a newly created paragraph, inserted directly before this paragraph.""" p = self._p.add_p_before() return Paragraph(p, self._parent) + + def _increment_containing_footnote_reference_ids(self) -> CT_FtnEdnRef | None: + for r in self.runs: + r._r.increment_containing_footnote_reference_ids() diff --git a/src/docx/text/run.py b/src/docx/text/run.py index 5be9d802c..e9417a10f 100644 --- a/src/docx/text/run.py +++ b/src/docx/text/run.py @@ -2,7 +2,7 @@ from __future__ import annotations -from typing import IO, TYPE_CHECKING, Iterator, cast +from typing import IO, TYPE_CHECKING, List, Iterator, cast from docx.drawing import Drawing from docx.enum.style import WD_STYLE_TYPE @@ -137,10 +137,8 @@ def font(self) -> Font: return Font(self._element) @property - def footnote_reference_ids(self): - """ - Returns all footnote reference ids from the run, or |None| if none found. - """ + def footnote_reference_ids(self) -> List[int] | None: + """Returns all footnote reference ids from the run, or |None| if none found.""" return self._r.footnote_reference_ids @property From 1e1e814f0ff9922482ca24074c1cd949cd9c83f4 Mon Sep 17 00:00:00 2001 From: "t.tiberius" Date: Mon, 15 Jan 2024 09:29:10 +0100 Subject: [PATCH 7/7] rfctr: Blacken footnote support --- features/steps/footnotes.py | 62 +++++++++---- src/docx/document.py | 14 ++- src/docx/footnotes.py | 34 ++++--- src/docx/oxml/__init__.py | 22 +++-- src/docx/oxml/footnote.py | 38 +++----- src/docx/oxml/section.py | 80 +++++++++-------- src/docx/oxml/simpletypes.py | 15 ++-- src/docx/oxml/text/footnote_reference.py | 18 ++-- src/docx/oxml/text/run.py | 23 ++--- src/docx/parts/footnotes.py | 28 +++--- src/docx/text/paragraph.py | 6 +- src/docx/text/run.py | 2 +- tests/test_document.py | 20 +++-- tests/test_section.py | 109 +++++++++++++++++++---- tests/text/test_paragraph.py | 36 ++++++-- 15 files changed, 304 insertions(+), 203 deletions(-) diff --git a/features/steps/footnotes.py b/features/steps/footnotes.py index 7cc713a31..89c468038 100644 --- a/features/steps/footnotes.py +++ b/features/steps/footnotes.py @@ -1,6 +1,6 @@ """Step implementations for footnote-related features.""" -from behave import given, when, then +from behave import given, then, when from behave.runner import Context from docx import Document @@ -44,7 +44,9 @@ def given_a_paragraph_in_a_document_without_footnotes(context: Context): context.footnotes = document.footnotes -@given("a document with paragraphs[0] containing one, paragraphs[1] containing none, and paragraphs[2] containing two footnotes") +@given( + "a document with paragraphs[0] containing one, paragraphs[1] containing none, and paragraphs[2] containing two footnotes" +) def given_a_document_with_3_footnotes(context: Context): document = Document(test_docx("footnotes")) context.paragraphs = document.paragraphs @@ -64,14 +66,18 @@ def when_I_try_to_access_a_footnote_with_invalid_reference_id(context: Context): @when("I add a footnote to the paragraphs[{parId}] with text '{footnoteText}'") -def when_I_add_a_footnote_to_the_paragraph_with_text_text(context: Context, parId: str, footnoteText: str): +def when_I_add_a_footnote_to_the_paragraph_with_text_text( + context: Context, parId: str, footnoteText: str +): par = context.paragraphs[int(parId)] new_footnote = par.add_footnote() new_footnote.add_paragraph(footnoteText) @when("I change footnote property {propName} to {value}") -def when_I_change_footnote_property_propName_to_value(context: Context, propName: str, value: str): +def when_I_change_footnote_property_propName_to_value( + context: Context, propName: str, value: str +): context.section.__setattr__(propName, eval(value)) @@ -81,7 +87,9 @@ def when_I_change_footnote_property_propName_to_value(context: Context, propName @then("len(footnotes) is {expectedLen}") def then_len_footnotes_is_len(context: Context, expectedLen: str): footnotes = context.footnotes - assert len(footnotes) == int(expectedLen), f"expected len(footnotes) of {expectedLen}, got {len(footnotes)}" + assert len(footnotes) == int( + expectedLen + ), f"expected len(footnotes) of {expectedLen}, got {len(footnotes)}" @then("I can access a footnote by footnote reference id") @@ -107,22 +115,36 @@ def then_it_trows_an_IndexError(context: Context, exceptionType: str): @then("I can access footnote property {propName} with value {value}") -def then_I_can_access_footnote_propery_name_with_value_value(context: Context, propName: str, value: str): +def then_I_can_access_footnote_propery_name_with_value_value( + context: Context, propName: str, value: str +): actual_value = context.section.__getattribute__(propName) expected = eval(value) - assert actual_value == expected, f"expected section.{propName} {value}, got {expected}" + assert ( + actual_value == expected + ), f"expected section.{propName} {value}, got {expected}" -@then("the document contains a footnote with footnote reference id of {refId} with text '{footnoteText}'") -def then_the_document_contains_a_footnote_with_footnote_reference_id_of_refId_with_text_text(context: Context, refId: str, footnoteText: str): +@then( + "the document contains a footnote with footnote reference id of {refId} with text '{footnoteText}'" +) +def then_the_document_contains_a_footnote_with_footnote_reference_id_of_refId_with_text_text( + context: Context, refId: str, footnoteText: str +): par = context.paragraphs[1] f = par.footnotes[0] assert f.id == int(refId), f"expected {refId}, got {f.id}" - assert f.paragraphs[0].text == footnoteText, f"expected {footnoteText}, got {f.paragraphs[0].text}" + assert ( + f.paragraphs[0].text == footnoteText + ), f"expected {footnoteText}, got {f.paragraphs[0].text}" -@then("paragraphs[{parId}] has footnote reference ids of {refIds}, with footnote text {fText}") -def then_paragraph_has_footnote_reference_ids_of_refIds_with_footnote_text_text(context: Context, parId: str, refIds: str, fText: str): +@then( + "paragraphs[{parId}] has footnote reference ids of {refIds}, with footnote text {fText}" +) +def then_paragraph_has_footnote_reference_ids_of_refIds_with_footnote_text_text( + context: Context, parId: str, refIds: str, fText: str +): par = context.paragraphs[int(parId)] refIds = eval(refIds) fText = eval(fText) @@ -130,12 +152,20 @@ def then_paragraph_has_footnote_reference_ids_of_refIds_with_footnote_text_text( if type(refIds) is list: for i in range(len(refIds)): f = par.footnotes[i] - assert isinstance(f, Footnote), f"expected to be instance of Footnote, got {type(f)}" + assert isinstance( + f, Footnote + ), f"expected to be instance of Footnote, got {type(f)}" assert f.id == refIds[i], f"expected {refIds[i]}, got {f.id}" - assert f.paragraphs[0].text == fText[i], f"expected '{fText[i]}', got '{f.paragraphs[0].text}'" + assert ( + f.paragraphs[0].text == fText[i] + ), f"expected '{fText[i]}', got '{f.paragraphs[0].text}'" else: f = par.footnotes[0] assert f.id == int(refIds), f"expected {refIds}, got {f.id}" - assert f.paragraphs[0].text == fText, f"expected '{fText}', got '{f.paragraphs[0].text}'" + assert ( + f.paragraphs[0].text == fText + ), f"expected '{fText}', got '{f.paragraphs[0].text}'" else: - assert len(par.footnotes) == 0, f"expected an empty list, got {len(par.footnotes)} elements" + assert ( + len(par.footnotes) == 0 + ), f"expected an empty list, got {len(par.footnotes)} elements" diff --git a/src/docx/document.py b/src/docx/document.py index f85acce91..23b0105c8 100644 --- a/src/docx/document.py +++ b/src/docx/document.py @@ -16,14 +16,14 @@ if TYPE_CHECKING: import docx.types as t from docx.oxml.document import CT_Body, CT_Document + from docx.oxml.footnote import CT_Footnotes, CT_FtnEnd + from docx.oxml.text.paragraph import CT_P from docx.parts.document import DocumentPart from docx.settings import Settings from docx.shared import Length from docx.styles.style import ParagraphStyle, _TableStyle from docx.table import Table from docx.text.paragraph import Paragraph - from docx.oxml.footnote import CT_Footnotes, CT_FtnEnd - from docx.oxml.text.paragraph import CT_P class Document(ElementProxy): @@ -199,11 +199,9 @@ def _body(self) -> _Body: return self.__body def _calculate_next_footnote_reference_id(self, p: CT_P) -> int: - """ - Return the appropriate footnote reference id number for - a new footnote added at the end of paragraph `p`. - """ - # When adding a footnote it can be inserted + """Return the appropriate footnote reference id number for + a new footnote added at the end of paragraph `p`.""" + # When adding a footnote it can be inserted # in front of some other footnotes, so # we need to sort footnotes by `footnote_reference_id` # in |Footnotes| and in |Paragraph| @@ -234,7 +232,7 @@ def _calculate_next_footnote_reference_id(self, p: CT_P) -> int: else: # 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 + new_fr_id = max(self.paragraphs[p_i]._p.footnote_reference_ids) + 1 break return new_fr_id diff --git a/src/docx/footnotes.py b/src/docx/footnotes.py index 5be3a692e..338ca1626 100644 --- a/src/docx/footnotes.py +++ b/src/docx/footnotes.py @@ -9,21 +9,18 @@ if TYPE_CHECKING: from docx import types as t - from docx.oxml.footnote import CT_FtnEnd, CT_Footnotes + from docx.oxml.footnote import CT_Footnotes, CT_FtnEnd + class Footnotes(Parented): - """ - Proxy object wrapping ```` element. - """ + """Proxy object wrapping ```` element.""" + def __init__(self, footnotes: CT_Footnotes, parent: t.ProvidesStoryPart): super(Footnotes, self).__init__(parent) self._element = self._footnotes = footnotes def __getitem__(self, reference_id: int) -> Footnote: - """ - A |Footnote| for a specific footnote of reference id, defined with ``w:id`` argument of ````. - If reference id is invalid raises an |IndexError| - """ + """A |Footnote| for a specific footnote of reference id, defined with ``w:id`` argument of ````. If reference id is invalid raises an |IndexError|""" footnote = self._element.get_by_id(reference_id) if footnote is None: raise IndexError @@ -33,15 +30,13 @@ def __len__(self) -> int: return len(self._element) def add_footnote(self, footnote_reference_id: int) -> Footnote: - """ - Return a newly created |Footnote|, the new footnote will + """Return a newly created |Footnote|, the new footnote will be inserted in the correct spot by `footnote_reference_id`. - The footnotes are kept in order by `footnote_reference_id`. - """ - elements = self._element # for easy access + The footnotes are kept in order by `footnote_reference_id`.""" + elements = self._element # for easy access new_footnote = None if elements.get_by_id(footnote_reference_id) is not None: - # When adding a footnote it can be inserted + # When adding a footnote it can be inserted # in front of some other footnotes, so # we need to sort footnotes by `footnote_reference_id` # in |Footnotes| and in |Paragraph| @@ -54,7 +49,9 @@ def add_footnote(self, footnote_reference_id: int) -> Footnote: for index in reversed(range(len(elements))): if elements[index].id == footnote_reference_id: elements[index].id += 1 - new_footnote = elements[index].add_footnote_before(footnote_reference_id) + new_footnote = elements[index].add_footnote_before( + footnote_reference_id + ) break else: elements[index].id += 1 @@ -65,10 +62,9 @@ def add_footnote(self, footnote_reference_id: int) -> Footnote: class Footnote(BlockItemContainer): - """ - Proxy object wrapping ```` element. - """ - def __init__(self, f: CT_FtnEnd, parent: t.ProvidesStoryPart): + """Proxy object wrapping ```` element.""" + + def __init__(self, f: CT_FtnEnd, parent: t.ProvidesStoryPart): super(Footnote, self).__init__(f, parent) self._f = self._element = f diff --git a/src/docx/oxml/__init__.py b/src/docx/oxml/__init__.py index cd204daa4..68c121d04 100644 --- a/src/docx/oxml/__init__.py +++ b/src/docx/oxml/__init__.py @@ -117,17 +117,17 @@ CT_SectType, ) -register_element_cls('w:footnotePr', CT_FtnProps) +register_element_cls("w:footnotePr", CT_FtnProps) register_element_cls("w:footerReference", CT_HdrFtrRef) 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: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:pos", CT_FtnPos) register_element_cls("w:sectPr", CT_SectPr) register_element_cls("w:type", CT_SectType) @@ -254,11 +254,9 @@ # --------------------------------------------------------------------------- # footnote-related mappings -from .footnote import ( - CT_FtnEnd, - CT_Footnotes -) +from .footnote import CT_Footnotes, CT_FtnEnd from .text.footnote_reference import CT_FtnEdnRef -register_element_cls('w:footnoteReference', CT_FtnEdnRef) -register_element_cls('w:footnote', CT_FtnEnd) -register_element_cls('w:footnotes', CT_Footnotes) + +register_element_cls("w:footnoteReference", CT_FtnEdnRef) +register_element_cls("w:footnote", CT_FtnEnd) +register_element_cls("w:footnotes", CT_Footnotes) diff --git a/src/docx/oxml/footnote.py b/src/docx/oxml/footnote.py index 240c0c4a1..f436bc4fb 100644 --- a/src/docx/oxml/footnote.py +++ b/src/docx/oxml/footnote.py @@ -6,30 +6,23 @@ from docx.oxml.ns import qn from docx.oxml.parser import OxmlElement -from docx.oxml.xmlchemy import ( - BaseOxmlElement, RequiredAttribute, ZeroOrMore, OneOrMore -) -from docx.oxml.simpletypes import ( - ST_DecimalNumber -) +from docx.oxml.simpletypes import ST_DecimalNumber +from docx.oxml.xmlchemy import BaseOxmlElement, OneOrMore, RequiredAttribute, ZeroOrMore if TYPE_CHECKING: from docx.oxml.text.paragraph import CT_P class CT_FtnEnd(BaseOxmlElement): - """ - ```` element, containing the properties for a specific footnote - """ - id = RequiredAttribute('w:id', ST_DecimalNumber) - p = ZeroOrMore('w:p') + """```` element, containing the properties for a specific footnote""" + + id = RequiredAttribute("w:id", ST_DecimalNumber) + p = ZeroOrMore("w:p") def add_footnote_before(self, footnote_reference_id: int) -> CT_FtnEnd: - """ - Create a ```` element with `footnote_reference_id` - and insert it before the current element. - """ - new_footnote = OxmlElement('w:footnote') + """Create a ```` element with `footnote_reference_id` + and insert it before the current element.""" + new_footnote = OxmlElement("w:footnote") new_footnote.id = footnote_reference_id self.addprevious(new_footnote) return new_footnote @@ -40,23 +33,20 @@ def paragraphs(self) -> List[CT_P]: paragraphs = [] for child in self: - if child.tag == qn('w:p'): + if child.tag == qn("w:p"): paragraphs.append(child) return paragraphs class CT_Footnotes(BaseOxmlElement): - """ - ```` element, containing a sequence of footnote (w:footnote) elements - """ + """```` element, containing a sequence of footnote (w:footnote) elements""" + add_footnote_sequence: Callable[[], CT_FtnEnd] - footnote_sequence = OneOrMore('w:footnote') + footnote_sequence = OneOrMore("w:footnote") def add_footnote(self, footnote_reference_id: int) -> CT_FtnEnd: - """ - Create a ```` element with `footnote_reference_id`. - """ + """Create a ```` element with `footnote_reference_id`.""" new_f = self.add_footnote_sequence() new_f.id = footnote_reference_id return new_f diff --git a/src/docx/oxml/section.py b/src/docx/oxml/section.py index b6079972a..62eb56dce 100644 --- a/src/docx/oxml/section.py +++ b/src/docx/oxml/section.py @@ -2,18 +2,25 @@ from __future__ import annotations -from warnings import warn - from copy import deepcopy from typing import Callable, Iterator, List, Sequence, cast +from warnings import warn from lxml import etree from typing_extensions import TypeAlias from docx.enum.section import WD_HEADER_FOOTER, WD_ORIENTATION, WD_SECTION_START from docx.oxml.ns import nsmap -from docx.oxml.shared import CT_OnOff, CT_DecimalNumber -from docx.oxml.simpletypes import ST_SignedTwipsMeasure, ST_TwipsMeasure, XsdString, ST_FtnPos, ST_NumberFormat, ST_RestartNumber, ST_DecimalNumber +from docx.oxml.shared import CT_DecimalNumber, CT_OnOff +from docx.oxml.simpletypes import ( + ST_DecimalNumber, + ST_FtnPos, + ST_NumberFormat, + ST_RestartNumber, + ST_SignedTwipsMeasure, + ST_TwipsMeasure, + XsdString, +) from docx.oxml.table import CT_Tbl from docx.oxml.text.paragraph import CT_P from docx.oxml.xmlchemy import ( @@ -30,7 +37,8 @@ class CT_FtnPos(BaseOxmlElement): """```` element, footnote placement""" - val = RequiredAttribute('w:val', ST_FtnPos) + + val = RequiredAttribute("w:val", ST_FtnPos) class CT_FtnProps(BaseOxmlElement): @@ -41,13 +49,19 @@ class CT_FtnProps(BaseOxmlElement): get_or_add_numStart: Callable[[], CT_DecimalNumber] get_or_add_numRestart: Callable[[], CT_NumRestart] - _tag_seq = ( - 'w:pos', 'w:numFmt', 'w:numStart', 'w:numRestart' - ) - pos: CT_FtnPos | None = ZeroOrOne('w:pos', successors=_tag_seq) # pyright: ignore[reportGeneralTypeIssues] - numFmt: CT_NumFmt | None = ZeroOrOne('w:numFmt', successors=_tag_seq[1:]) # pyright: ignore[reportGeneralTypeIssues] - numStart: CT_DecimalNumber | None = ZeroOrOne('w:numStart', successors=_tag_seq[2:]) # pyright: ignore[reportGeneralTypeIssues] - numRestart: CT_NumRestart | None = ZeroOrOne('w:numRestart', successors=_tag_seq[3:]) # pyright: ignore[reportGeneralTypeIssues] + _tag_seq = ("w:pos", "w:numFmt", "w:numStart", "w:numRestart") + pos: CT_FtnPos | None = ZeroOrOne( + "w:pos", successors=_tag_seq + ) # pyright: ignore[reportGeneralTypeIssues] + numFmt: CT_NumFmt | None = ZeroOrOne( + "w:numFmt", successors=_tag_seq[1:] + ) # pyright: ignore[reportGeneralTypeIssues] + numStart: CT_DecimalNumber | None = ZeroOrOne( + "w:numStart", successors=_tag_seq[2:] + ) # pyright: ignore[reportGeneralTypeIssues] + numRestart: CT_NumRestart | None = ZeroOrOne( + "w:numRestart", successors=_tag_seq[3:] + ) # pyright: ignore[reportGeneralTypeIssues] class CT_HdrFtr(BaseOxmlElement): @@ -83,12 +97,14 @@ class CT_HdrFtrRef(BaseOxmlElement): class CT_NumFmt(BaseOxmlElement): """```` element, footnote numbering format""" - val = RequiredAttribute('w:val', ST_NumberFormat) + + val = RequiredAttribute("w:val", ST_NumberFormat) class CT_NumRestart(BaseOxmlElement): """```` element, footnote numbering restart location""" - val = RequiredAttribute('w:val', ST_RestartNumber) + + val = RequiredAttribute("w:val", ST_RestartNumber) class CT_PageMar(BaseOxmlElement): @@ -180,8 +196,10 @@ class CT_SectPr(BaseOxmlElement): titlePg: CT_OnOff | None = ZeroOrOne( # pyright: ignore[reportAssignmentType] "w:titlePg", successors=_tag_seq[14:] ) - footnotePr: CT_FtnProps | None = ZeroOrOne( # pyright: ignore[reportGeneralTypeIssues] - "w:footnotePr", successors=_tag_seq[1:] + footnotePr: CT_FtnProps | None = ( + ZeroOrOne( # pyright: ignore[reportGeneralTypeIssues] + "w:footnotePr", successors=_tag_seq[1:] + ) ) del _tag_seq @@ -251,11 +269,9 @@ def footer(self, value: int | Length | None): @property def footnote_number_format(self) -> ST_NumberFormat | None: - """ - The value of the ``w:val`` attribute in the ```` child + """The value of the ``w:val`` attribute in the ```` child element of ```` element, as a |String|, or |None| if either the element or the - attribute is not present. - """ + attribute is not present.""" fPr = self.footnotePr if fPr is None or fPr.numFmt is None: return None @@ -269,11 +285,9 @@ def footnote_number_format(self, value: ST_NumberFormat | None): @property def footnote_numbering_restart_location(self) -> ST_RestartNumber | None: - """ - The value of the ``w:val`` attribute in the ```` child + """The value of the ``w:val`` attribute in the ```` child element of ```` element, as a |String|, or |None| if either the element or the - attribute is not present. - """ + attribute is not present.""" fPr = self.footnotePr if fPr is None or fPr.numRestart is None: return None @@ -287,18 +301,16 @@ def footnote_numbering_restart_location(self, value: ST_RestartNumber | None): numRestart.val = value if len(numStart.values()) == 0: numStart.val = 1 - elif value != 'continuous': + elif value != "continuous": numStart.val = 1 msg = "When `` is not 'continuous', then ```` must be 1." warn(msg, UserWarning, stacklevel=2) @property def footnote_numbering_start_value(self) -> ST_DecimalNumber | None: - """ - The value of the ``w:val`` attribute in the ```` child + """The value of the ``w:val`` attribute in the ```` child element of ```` element, as a |Number|, or |None| if either the element or the - attribute is not present. - """ + attribute is not present.""" fPr = self.footnotePr if fPr is None or fPr.numStart is None: return None @@ -311,19 +323,17 @@ def footnote_numbering_start_value(self, value: ST_DecimalNumber | None): numRestart = fPr.get_or_add_numRestart() numStart.val = value if len(numRestart.values()) == 0: - numRestart.val = 'continuous' + numRestart.val = "continuous" elif value != 1: - numRestart.val = 'continuous' + numRestart.val = "continuous" msg = "When `` is not 1, then ```` must be 'continuous'." warn(msg, UserWarning, stacklevel=2) @property def footnote_position(self) -> ST_FtnPos | None: - """ - The value of the ``w:val`` attribute in the ```` child + """The value of the ``w:val`` attribute in the ```` child element of ```` element, as a |String|, or |None| if either the element or the - attribute is not present. - """ + attribute is not present.""" fPr = self.footnotePr if fPr is None or fPr.pos is None: return None diff --git a/src/docx/oxml/simpletypes.py b/src/docx/oxml/simpletypes.py index cf74b90a3..6316fb022 100644 --- a/src/docx/oxml/simpletypes.py +++ b/src/docx/oxml/simpletypes.py @@ -222,15 +222,12 @@ class ST_DrawingElementId(XsdUnsignedInt): class ST_FtnPos(XsdString): - @classmethod def validate(cls, value): cls.validate_string(value) - valid_values = ('pageBottom', 'beneathText', 'sectEnd', 'docEnd') + valid_values = ("pageBottom", "beneathText", "sectEnd", "docEnd") if value not in valid_values: - raise ValueError( - "must be one of %s, got '%s'" % (valid_values, value) - ) + raise ValueError("must be one of %s, got '%s'" % (valid_values, value)) class ST_HexColor(BaseStringType): @@ -322,15 +319,13 @@ class ST_RelationshipId(XsdString): class ST_RestartNumber(XsdString): - @classmethod def validate(cls, value): cls.validate_string(value) - valid_values = ('continuous', 'eachSect', 'eachPage') + valid_values = ("continuous", "eachSect", "eachPage") if value not in valid_values: - raise ValueError( - "must be one of %s, got '%s'" % (valid_values, value) - ) + raise ValueError("must be one of %s, got '%s'" % (valid_values, value)) + class ST_SignedTwipsMeasure(XsdInt): @classmethod diff --git a/src/docx/oxml/text/footnote_reference.py b/src/docx/oxml/text/footnote_reference.py index be06450b9..2a20a1ee7 100644 --- a/src/docx/oxml/text/footnote_reference.py +++ b/src/docx/oxml/text/footnote_reference.py @@ -1,15 +1,11 @@ """Custom element classes related to footnote references (CT_FtnEdnRef).""" -from docx.oxml.xmlchemy import ( - BaseOxmlElement, RequiredAttribute, OptionalAttribute -) -from docx.oxml.simpletypes import ( - ST_DecimalNumber, ST_OnOff -) +from docx.oxml.simpletypes import ST_DecimalNumber, ST_OnOff +from docx.oxml.xmlchemy import BaseOxmlElement, OptionalAttribute, RequiredAttribute + class CT_FtnEdnRef(BaseOxmlElement): - """ - ```` element, containing the properties for a footnote reference - """ - id = RequiredAttribute('w:id', ST_DecimalNumber) - customMarkFollows = OptionalAttribute('w:customMarkFollows', ST_OnOff) + """```` element, containing the properties for a footnote reference""" + + id = RequiredAttribute("w:id", ST_DecimalNumber) + customMarkFollows = OptionalAttribute("w:customMarkFollows", ST_OnOff) diff --git a/src/docx/oxml/text/run.py b/src/docx/oxml/text/run.py index 6f5dece27..306130a9b 100644 --- a/src/docx/oxml/text/run.py +++ b/src/docx/oxml/text/run.py @@ -13,9 +13,9 @@ if TYPE_CHECKING: from docx.oxml.shape import CT_Anchor, CT_Inline + from docx.oxml.text.footnote_reference import CT_FtnEdnRef from docx.oxml.text.pagebreak import CT_LastRenderedPageBreak from docx.oxml.text.parfmt import CT_TabStop - from docx.oxml.text.footnote_reference import CT_FtnEdnRef # ------------------------------------------------------------------------------------ # Run-level elements @@ -39,15 +39,13 @@ class CT_R(BaseOxmlElement): drawing = ZeroOrMore("w:drawing") t = ZeroOrMore("w:t") tab = ZeroOrMore("w:tab") - footnoteReference = ZeroOrMore('w:footnoteReference') + footnoteReference = ZeroOrMore("w:footnoteReference") def add_footnoteReference(self, id: int) -> CT_FtnEdnRef: - """ - Return a newly added ```` element containing - the footnote reference id. - """ + """Return a newly added ```` element containing + the footnote reference id.""" rPr = self._add_rPr() - rPr.style = 'FootnoteReference' + rPr.style = "FootnoteReference" new_fr = self._add_footnoteReference() new_fr.id = id return new_fr @@ -110,20 +108,17 @@ def lastRenderedPageBreaks(self) -> List[CT_LastRenderedPageBreak]: @property def footnote_reference_ids(self) -> List[int] | None: - """ - Return all footnote reference ids (````), or |None| if not present. - """ + """Return all footnote reference ids (````), or |None| if not present.""" references = [] for child in self: - if child.tag == qn('w:footnoteReference'): - references.append(child.id) + if child.tag == qn("w:footnoteReference"): + references.append(child.id) if references == []: references = None return references def increment_containing_footnote_reference_ids(self) -> CT_FtnEdnRef | None: - """ - Increment all footnote reference ids by one if they exist. + """Increment all footnote reference ids by one if they exist. Return all footnote reference ids (````), or |None| if not present. """ if self.footnoteReference_lst is not None: diff --git a/src/docx/parts/footnotes.py b/src/docx/parts/footnotes.py index 53dd5c80b..19fdbd847 100644 --- a/src/docx/parts/footnotes.py +++ b/src/docx/parts/footnotes.py @@ -2,44 +2,36 @@ import os +from docx.footnotes import Footnotes from docx.opc.constants import CONTENT_TYPE as CT from docx.opc.packuri import PackURI from docx.oxml import parse_xml -from docx.footnotes import Footnotes from docx.parts.story import StoryPart class FootnotesPart(StoryPart): - """ - Proxy for the footnotes.xml part containing footnotes definitions for a document. - """ + """Proxy for the footnotes.xml part containing footnotes definitions for a document.""" + @classmethod def default(cls, package): - """ - Return a newly created footnote part, containing a default set of elements. - """ - partname = PackURI('/word/footnotes.xml') + """Return a newly created footnote part, containing a default set of elements.""" + partname = PackURI("/word/footnotes.xml") content_type = CT.WML_FOOTNOTES element = parse_xml(cls._default_footnote_xml()) return cls(partname, content_type, element, package) @property def footnotes(self): - """ - The |Footnotes| instance containing the footnotes ( element - proxies) for this footnotes part. - """ + """The |Footnotes| instance containing the footnotes ( element + proxies) for this footnotes part.""" return Footnotes(self.element, self) @classmethod def _default_footnote_xml(cls): - """ - Return a bytestream containing XML for a default styles part. - """ + """Return a bytestream containing XML for a default styles part.""" path = os.path.join( - os.path.split(__file__)[0], '..', 'templates', - 'default-footnotes.xml' + os.path.split(__file__)[0], "..", "templates", "default-footnotes.xml" ) - with open(path, 'rb') as f: + with open(path, "rb") as f: xml_bytes = f.read() return xml_bytes diff --git a/src/docx/text/paragraph.py b/src/docx/text/paragraph.py index 564d852fa..968694b43 100644 --- a/src/docx/text/paragraph.py +++ b/src/docx/text/paragraph.py @@ -17,9 +17,9 @@ if TYPE_CHECKING: import docx.types as t from docx.enum.text import WD_PARAGRAPH_ALIGNMENT + from docx.oxml.footnote import CT_FtnEnd from docx.oxml.text.paragraph import CT_P from docx.styles.style import CharacterStyle - from docx.oxml.footnote import CT_FtnEnd class Paragraph(StoryChild): @@ -30,8 +30,8 @@ def __init__(self, p: CT_P, parent: t.ProvidesStoryPart): self._p = self._element = p def add_footnote(self) -> CT_FtnEnd: - """ - Append a run that contains a ```` element. + """Append a run that contains a ```` element. + The footnotes are kept in order by `footnote_reference_id`, so the appropriate id is calculated based on the current state. """ diff --git a/src/docx/text/run.py b/src/docx/text/run.py index e9417a10f..ae8c3253e 100644 --- a/src/docx/text/run.py +++ b/src/docx/text/run.py @@ -2,7 +2,7 @@ from __future__ import annotations -from typing import IO, TYPE_CHECKING, List, Iterator, cast +from typing import IO, TYPE_CHECKING, Iterator, List, cast from docx.drawing import Drawing from docx.enum.style import WD_STYLE_TYPE diff --git a/tests/test_document.py b/tests/test_document.py index 94002488e..18da3ed79 100644 --- a/tests/test_document.py +++ b/tests/test_document.py @@ -12,6 +12,7 @@ from docx.document import Document, _Body from docx.enum.section import WD_SECTION from docx.enum.text import WD_BREAK +from docx.footnotes import Footnotes from docx.opc.coreprops import CoreProperties from docx.oxml.document import CT_Document from docx.parts.document import DocumentPart @@ -23,7 +24,6 @@ from docx.table import Table from docx.text.paragraph import Paragraph from docx.text.run import Run -from docx.footnotes import Footnotes from .unitutil.cxml import element, xml from .unitutil.mock import Mock, class_mock, instance_mock, method_mock, property_mock @@ -251,11 +251,19 @@ def core_props_fixture(self, document_part_, core_properties_): document_part_.core_properties = core_properties_ return document, core_properties_ - @pytest.fixture(params=[ - ('w:footnotes/(w:footnote{w:id=-1}/w:p/w:r/w:t"minus one note", w:footnote{w:id=0}/w:p/w:r/w:t"zero note")'), - ('w:footnotes/(w:footnote{w:id=1}/w:p/w:r/w:t"first note", w:footnote{w:id=2}/w:p/w:r/w:t"second note")'), - ('w:footnotes/(w:footnote{w:id=1}/w:p/w:r/w:t"first note", w:footnote{w:id=2}/w:p/w:r/w:t"second note", w:footnote{w:id=3}/w:p/w:r/w:t"third note")'), - ]) + @pytest.fixture( + params=[ + ( + 'w:footnotes/(w:footnote{w:id=-1}/w:p/w:r/w:t"minus one note", w:footnote{w:id=0}/w:p/w:r/w:t"zero note")' + ), + ( + 'w:footnotes/(w:footnote{w:id=1}/w:p/w:r/w:t"first note", w:footnote{w:id=2}/w:p/w:r/w:t"second note")' + ), + ( + 'w:footnotes/(w:footnote{w:id=1}/w:p/w:r/w:t"first note", w:footnote{w:id=2}/w:p/w:r/w:t"second note", w:footnote{w:id=3}/w:p/w:r/w:t"third note")' + ), + ] + ) def footnotes_fixture(self, request, document_part_): footnotes_cxml = request.param document = Document(None, document_part_) diff --git a/tests/test_section.py b/tests/test_section.py index 73e2412f6..07478b3d1 100644 --- a/tests/test_section.py +++ b/tests/test_section.py @@ -164,16 +164,56 @@ def it_can_change_whether_the_document_has_distinct_odd_and_even_headers( @pytest.mark.parametrize( ("sectPr_cxml", "footnote_prop_name", "expected_value"), [ - ("w:sectPr/w:footnotePr/w:numFmt{w:val=decimal}", "footnote_number_format", "decimal"), - ("w:sectPr/w:footnotePr/w:numFmt{w:val=upperRoman}", "footnote_number_format", "upperRoman"), - ("w:sectPr/w:footnotePr/w:numFmt{w:val=lowerLetter}", "footnote_number_format", "lowerLetter"), - ("w:sectPr/w:footnotePr/w:numFmt{w:val=bullet}", "footnote_number_format", "bullet"), - ("w:sectPr/w:footnotePr/w:pos{w:val=pageBottom}", "footnote_number_format", None), - ("w:sectPr/w:footnotePr/w:pos{w:val=pageBottom}", "footnote_position", "pageBottom"), - ("w:sectPr/w:footnotePr/w:numStart{w:val=5}", "footnote_numbering_start_value", 5), - ("w:sectPr/w:footnotePr/w:numStart{w:val=13}", "footnote_numbering_start_value", 13), - ("w:sectPr/w:footnotePr/w:numRestart{w:val=eachSect}", "footnote_numbering_restart_location", "eachSect"), - ("w:sectPr/w:footnotePr/w:numRestart{w:val=eachPage}", "footnote_numbering_restart_location", "eachPage"), + ( + "w:sectPr/w:footnotePr/w:numFmt{w:val=decimal}", + "footnote_number_format", + "decimal", + ), + ( + "w:sectPr/w:footnotePr/w:numFmt{w:val=upperRoman}", + "footnote_number_format", + "upperRoman", + ), + ( + "w:sectPr/w:footnotePr/w:numFmt{w:val=lowerLetter}", + "footnote_number_format", + "lowerLetter", + ), + ( + "w:sectPr/w:footnotePr/w:numFmt{w:val=bullet}", + "footnote_number_format", + "bullet", + ), + ( + "w:sectPr/w:footnotePr/w:pos{w:val=pageBottom}", + "footnote_number_format", + None, + ), + ( + "w:sectPr/w:footnotePr/w:pos{w:val=pageBottom}", + "footnote_position", + "pageBottom", + ), + ( + "w:sectPr/w:footnotePr/w:numStart{w:val=5}", + "footnote_numbering_start_value", + 5, + ), + ( + "w:sectPr/w:footnotePr/w:numStart{w:val=13}", + "footnote_numbering_start_value", + 13, + ), + ( + "w:sectPr/w:footnotePr/w:numRestart{w:val=eachSect}", + "footnote_numbering_restart_location", + "eachSect", + ), + ( + "w:sectPr/w:footnotePr/w:numRestart{w:val=eachPage}", + "footnote_numbering_restart_location", + "eachPage", + ), ], ) def it_knows_its_footnote_properties( @@ -193,13 +233,48 @@ def it_knows_its_footnote_properties( @pytest.mark.parametrize( ("sectPr_cxml", "footnote_prop_name", "value", "expected_cxml"), [ - ("w:sectPr", "footnote_number_format", "upperRoman", "w:sectPr/w:footnotePr/w:numFmt{w:val=upperRoman}"), - ("w:sectPr/w:footnotePr/w:numFmt{w:val=decimal}", "footnote_number_format", "upperRoman", "w:sectPr/w:footnotePr/w:numFmt{w:val=upperRoman}"), - ("w:sectPr", "footnote_position", "pageBottom", "w:sectPr/w:footnotePr/w:pos{w:val=pageBottom}"), - ("w:sectPr", "footnote_numbering_start_value", 1, "w:sectPr/w:footnotePr/(w:numStart{w:val=1},w:numRestart{w:val=continuous})"), - ("w:sectPr", "footnote_numbering_start_value", 5, "w:sectPr/w:footnotePr/(w:numStart{w:val=5},w:numRestart{w:val=continuous})"), - ("w:sectPr", "footnote_numbering_restart_location", "eachSect", "w:sectPr/w:footnotePr/(w:numStart{w:val=1},w:numRestart{w:val=eachSect})"), - ("w:sectPr", "footnote_numbering_restart_location", "continuous", "w:sectPr/w:footnotePr/(w:numStart{w:val=1},w:numRestart{w:val=continuous})"), + ( + "w:sectPr", + "footnote_number_format", + "upperRoman", + "w:sectPr/w:footnotePr/w:numFmt{w:val=upperRoman}", + ), + ( + "w:sectPr/w:footnotePr/w:numFmt{w:val=decimal}", + "footnote_number_format", + "upperRoman", + "w:sectPr/w:footnotePr/w:numFmt{w:val=upperRoman}", + ), + ( + "w:sectPr", + "footnote_position", + "pageBottom", + "w:sectPr/w:footnotePr/w:pos{w:val=pageBottom}", + ), + ( + "w:sectPr", + "footnote_numbering_start_value", + 1, + "w:sectPr/w:footnotePr/(w:numStart{w:val=1},w:numRestart{w:val=continuous})", + ), + ( + "w:sectPr", + "footnote_numbering_start_value", + 5, + "w:sectPr/w:footnotePr/(w:numStart{w:val=5},w:numRestart{w:val=continuous})", + ), + ( + "w:sectPr", + "footnote_numbering_restart_location", + "eachSect", + "w:sectPr/w:footnotePr/(w:numStart{w:val=1},w:numRestart{w:val=eachSect})", + ), + ( + "w:sectPr", + "footnote_numbering_restart_location", + "continuous", + "w:sectPr/w:footnotePr/(w:numStart{w:val=1},w:numRestart{w:val=continuous})", + ), ], ) def it_can_change_its_footnote_properties( diff --git a/tests/text/test_paragraph.py b/tests/text/test_paragraph.py index fe1a82b01..628db92c1 100644 --- a/tests/text/test_paragraph.py +++ b/tests/text/test_paragraph.py @@ -5,16 +5,16 @@ import pytest from docx import types as t +from docx.document import Document from docx.enum.style import WD_STYLE_TYPE from docx.enum.text import WD_ALIGN_PARAGRAPH +from docx.footnotes import Footnotes from docx.oxml.text.paragraph import CT_P from docx.oxml.text.run import CT_R from docx.parts.document import DocumentPart from docx.text.paragraph import Paragraph from docx.text.parfmt import ParagraphFormat from docx.text.run import Run -from docx.footnotes import Footnotes -from docx.document import Document from ..unitutil.cxml import element, xml from ..unitutil.mock import call, class_mock, instance_mock, method_mock, property_mock @@ -221,15 +221,33 @@ def it_inserts_a_paragraph_before_to_help(self, _insert_before_fixture): # fixtures ------------------------------------------------------- - @pytest.fixture(params=[ - ('w:p/w:r/w:footnoteReference{w:id=2}', 'w:footnotes/(w:footnote{w:id=1}/w:p/w:r/w:t"first note", w:footnote{w:id=2}/w:p/w:r/w:t"second note")', [2]), - ('w:p/w:r/w:footnoteReference{w:id=1}', 'w:footnotes/(w:footnote{w:id=1}/w:p/w:r/w:t"first note", w:footnote{w:id=2}/w:p/w:r/w:t"second note")', [1]), - ('w:p/w:r/(w:footnoteReference{w:id=1}, w:footnoteReference{w:id=2})', 'w:footnotes/(w:footnote{w:id=1}/w:p/w:r/w:t"first note", w:footnote{w:id=2}/w:p/w:r/w:t"second note")', [1,2]), - ('w:p/w:r/(w:footnoteReference{w:id=3}, w:footnoteReference{w:id=2})', 'w:footnotes/(w:footnote{w:id=1}/w:p/w:r/w:t"first note", w:footnote{w:id=2}/w:p/w:r/w:t"second note", w:footnote{w:id=3}/w:p/w:r/w:t"third note")', [3,2]), - ]) + @pytest.fixture( + params=[ + ( + "w:p/w:r/w:footnoteReference{w:id=2}", + 'w:footnotes/(w:footnote{w:id=1}/w:p/w:r/w:t"first note", w:footnote{w:id=2}/w:p/w:r/w:t"second note")', + [2], + ), + ( + "w:p/w:r/w:footnoteReference{w:id=1}", + 'w:footnotes/(w:footnote{w:id=1}/w:p/w:r/w:t"first note", w:footnote{w:id=2}/w:p/w:r/w:t"second note")', + [1], + ), + ( + "w:p/w:r/(w:footnoteReference{w:id=1}, w:footnoteReference{w:id=2})", + 'w:footnotes/(w:footnote{w:id=1}/w:p/w:r/w:t"first note", w:footnote{w:id=2}/w:p/w:r/w:t"second note")', + [1, 2], + ), + ( + "w:p/w:r/(w:footnoteReference{w:id=3}, w:footnoteReference{w:id=2})", + 'w:footnotes/(w:footnote{w:id=1}/w:p/w:r/w:t"first note", w:footnote{w:id=2}/w:p/w:r/w:t"second note", w:footnote{w:id=3}/w:p/w:r/w:t"third note")', + [3, 2], + ), + ] + ) def footnotes_fixture(self, request, document_part_): paragraph_cxml, footnotes_cxml, footnote_ids_in_p = request.param - document_elm = element('w:document/w:body') + document_elm = element("w:document/w:body") document = Document(document_elm, document_part_) paragraph = Paragraph(element(paragraph_cxml), document._body) footnotes = Footnotes(element(footnotes_cxml), None)