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..89c468038 --- /dev/null +++ b/features/steps/footnotes.py @@ -0,0 +1,171 @@ +"""Step implementations for footnote-related features.""" + +from behave import given, then, when +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 000000000..640bf06be Binary files /dev/null and b/features/steps/test_files/footnotes.docx differ 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..23b0105c8 100644 --- a/src/docx/document.py +++ b/src/docx/document.py @@ -16,6 +16,8 @@ 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 @@ -112,6 +114,11 @@ def core_properties(self): """A |CoreProperties| object providing Dublin Core properties of document.""" return self._part.core_properties + @property + def footnotes(self) -> CT_Footnotes: + """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 +181,10 @@ def tables(self) -> List[Table]: """ return self._body.tables + 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) + @property def _block_width(self) -> Length: """A |Length| object specifying the space between margins in last section.""" @@ -187,6 +198,44 @@ def _body(self) -> _Body: self.__body = _Body(self._element.body, self) 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 + # 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 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 + # 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 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: + 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..338ca1626 --- /dev/null +++ b/src/docx/footnotes.py @@ -0,0 +1,83 @@ +"""The |Footnotes| object and related proxy classes.""" + +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_Footnotes, CT_FtnEnd + + +class Footnotes(Parented): + """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|""" + footnote = self._element.get_by_id(reference_id) + if footnote is None: + raise IndexError + return Footnote(footnote, self) + + 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 + 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) 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` + # 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: CT_FtnEnd, parent: t.ProvidesStoryPart): + super(Footnote, self).__init__(f, parent) + self._f = self._element = f + + def __eq__(self, other) -> bool: + if isinstance(other, Footnote): + return self._f is other._f + return False + + def __ne__(self, other) -> bool: + if isinstance(other, Footnote): + return self._f is not other._f + return True + + @property + def id(self) -> int: + return self._f.id diff --git a/src/docx/oxml/__init__.py b/src/docx/oxml/__init__.py index bf32932f9..68c121d04 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) @@ -241,3 +250,13 @@ 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_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) diff --git a/src/docx/oxml/footnote.py b/src/docx/oxml/footnote.py new file mode 100644 index 000000000..f436bc4fb --- /dev/null +++ b/src/docx/oxml/footnote.py @@ -0,0 +1,58 @@ +"""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.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") + + 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") + new_footnote.id = footnote_reference_id + self.addprevious(new_footnote) + return new_footnote + + @property + 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 71072e2df..62eb56dce 100644 --- a/src/docx/oxml/section.py +++ b/src/docx/oxml/section.py @@ -4,14 +4,23 @@ 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 -from docx.oxml.simpletypes import ST_SignedTwipsMeasure, ST_TwipsMeasure, XsdString +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 ( @@ -26,6 +35,35 @@ 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""" + + 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: 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): """`w:hdr` and `w:ftr`, the root element for header and footer part respectively.""" @@ -57,6 +95,18 @@ 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.""" @@ -104,6 +154,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] @@ -145,6 +196,11 @@ 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:] + ) + ) del _tag_seq def add_footerReference(self, type_: WD_HEADER_FOOTER, rId: str) -> CT_HdrFtrRef: @@ -211,6 +267,84 @@ 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) -> 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 + attribute is not present.""" + fPr = self.footnotePr + if fPr is None or fPr.numFmt is None: + return None + return fPr.numFmt.val + + @footnote_number_format.setter + 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) -> 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 + attribute is not present.""" + fPr = self.footnotePr + if fPr is None or fPr.numRestart is None: + return None + return fPr.numRestart.val + + @footnote_numbering_restart_location.setter + 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 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) -> 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 + attribute is not present.""" + fPr = self.footnotePr + if fPr is None or fPr.numStart is None: + return None + return fPr.numStart.val + + @footnote_numbering_start_value.setter + 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 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) -> 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 + 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: ST_FtnPos | None): + 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..6316fb022 100644 --- a/src/docx/oxml/simpletypes.py +++ b/src/docx/oxml/simpletypes.py @@ -221,6 +221,15 @@ 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 +280,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 +318,15 @@ 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/oxml/text/footnote_reference.py b/src/docx/oxml/text/footnote_reference.py new file mode 100644 index 000000000..2a20a1ee7 --- /dev/null +++ b/src/docx/oxml/text/footnote_reference.py @@ -0,0 +1,11 @@ +"""Custom element classes related to footnote references (CT_FtnEdnRef).""" + +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) diff --git a/src/docx/oxml/text/paragraph.py b/src/docx/oxml/text/paragraph.py index 63e96f312..79d603a81 100644 --- a/src/docx/oxml/text/paragraph.py +++ b/src/docx/oxml/text/paragraph.py @@ -70,6 +70,17 @@ def lastRenderedPageBreaks(self) -> List[CT_LastRenderedPageBreak]: "./w:r/w:lastRenderedPageBreak | ./w:hyperlink/w:r/w:lastRenderedPageBreak" ) + @property + 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 and len(new_footnote_ids) > 0: + footnote_ids.extend(new_footnote_ids) + 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..306130a9b 100644 --- a/src/docx/oxml/text/run.py +++ b/src/docx/oxml/text/run.py @@ -13,6 +13,7 @@ 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 @@ -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") @@ -35,6 +39,16 @@ 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: int) -> CT_FtnEdnRef: + """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 +106,27 @@ def lastRenderedPageBreaks(self) -> List[CT_LastRenderedPageBreak]: """All `w:lastRenderedPageBreaks` descendants of this run.""" return self.xpath("./w:lastRenderedPageBreak") + @property + def footnote_reference_ids(self) -> List[int] | None: + """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) -> CT_FtnEdnRef | None: + """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..19fdbd847 --- /dev/null +++ b/src/docx/parts/footnotes.py @@ -0,0 +1,37 @@ +"""Provides FootnotesPart and related objects""" + +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.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/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. 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..968694b43 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 @@ -16,6 +17,7 @@ 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 @@ -27,6 +29,19 @@ def __init__(self, p: CT_P, parent: t.ProvidesStoryPart): super(Paragraph, self).__init__(parent) self._p = self._element = p + def add_footnote(self) -> CT_FtnEnd: + """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 +91,16 @@ 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) -> 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 + 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: @@ -171,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 0e2f5bc17..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, Iterator, cast +from typing import IO, TYPE_CHECKING, Iterator, List, cast from docx.drawing import Drawing from docx.enum.style import WD_STYLE_TYPE @@ -136,6 +136,11 @@ def font(self) -> Font: this run, such as font name and size.""" return Font(self._element) + @property + 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 def italic(self) -> bool | None: """Read/write tri-state value. diff --git a/tests/test_document.py b/tests/test_document.py index 6a2c5af88..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 @@ -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,26 @@ 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..07478b3d1 100644 --- a/tests/test_section.py +++ b/tests/test_section.py @@ -161,6 +161,138 @@ 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..628db92c1 100644 --- a/tests/text/test_paragraph.py +++ b/tests/text/test_paragraph.py @@ -5,8 +5,10 @@ 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 @@ -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,40 @@ 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"),