Skip to content

Commit afdb63a

Browse files
tiberlas=
authored andcommitted
footnotes: add footnoteReference <w:footnoteReference>, and footnotes <w:footnotes> support.
1 parent 0cf6d71 commit afdb63a

File tree

13 files changed

+401
-0
lines changed

13 files changed

+401
-0
lines changed

src/docx/__init__.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626
from docx.opc.part import PartFactory
2727
from docx.opc.parts.coreprops import CorePropertiesPart
2828
from docx.parts.document import DocumentPart
29+
from docx.parts.footnotes import FootnotesPart
2930
from docx.parts.hdrftr import FooterPart, HeaderPart
3031
from docx.parts.image import ImagePart
3132
from docx.parts.numbering import NumberingPart
@@ -43,6 +44,7 @@ def part_class_selector(content_type: str, reltype: str) -> Type[Part] | None:
4344
PartFactory.part_type_for[CT.OPC_CORE_PROPERTIES] = CorePropertiesPart
4445
PartFactory.part_type_for[CT.WML_DOCUMENT_MAIN] = DocumentPart
4546
PartFactory.part_type_for[CT.WML_FOOTER] = FooterPart
47+
PartFactory.part_type_for[CT.WML_FOOTNOTES] = FootnotesPart
4648
PartFactory.part_type_for[CT.WML_HEADER] = HeaderPart
4749
PartFactory.part_type_for[CT.WML_NUMBERING] = NumberingPart
4850
PartFactory.part_type_for[CT.WML_SETTINGS] = SettingsPart
@@ -53,6 +55,7 @@ def part_class_selector(content_type: str, reltype: str) -> Type[Part] | None:
5355
CorePropertiesPart,
5456
DocumentPart,
5557
FooterPart,
58+
FootnotesPart,
5659
HeaderPart,
5760
NumberingPart,
5861
PartFactory,

src/docx/document.py

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,11 @@ def core_properties(self):
112112
"""A |CoreProperties| object providing Dublin Core properties of document."""
113113
return self._part.core_properties
114114

115+
@property
116+
def footnotes(self):
117+
"""A |Footnotes| object providing access to footnote elements in this document."""
118+
return self._part.footnotes
119+
115120
@property
116121
def inline_shapes(self):
117122
"""The |InlineShapes| collection for this document.
@@ -174,6 +179,10 @@ def tables(self) -> List[Table]:
174179
"""
175180
return self._body.tables
176181

182+
def _add_footnote(self, footnote_reference_ids):
183+
"""Inserts a newly created footnote to |Footnotes|."""
184+
return self._part.footnotes.add_footnote(footnote_reference_ids)
185+
177186
@property
178187
def _block_width(self) -> Length:
179188
"""A |Length| object specifying the space between margins in last section."""
@@ -187,6 +196,46 @@ def _body(self) -> _Body:
187196
self.__body = _Body(self._element.body, self)
188197
return self.__body
189198

199+
def _calculate_next_footnote_reference_id(self, p):
200+
"""
201+
Return the appropriate footnote reference id number for
202+
a new footnote added at the end of paragraph `p`.
203+
"""
204+
# When adding a footnote it can be inserted
205+
# in front of some other footnotes, so
206+
# we need to sort footnotes by `footnote_reference_id`
207+
# in |Footnotes| and in |Paragraph|
208+
new_fr_id = 1
209+
# If paragraph already contains footnotes
210+
# append the new footnote and the end with the next reference id.
211+
if p.footnote_reference_ids is not None:
212+
new_fr_id = p.footnote_reference_ids[-1] + 1
213+
# Read the paragraphs containing footnotes and find where the
214+
# new footnote will be. Keeping in mind that the footnotes are
215+
# sorted by id.
216+
# The value of the new footnote id is the value of the first paragraph
217+
# containing the footnote id that is before the new footnote, incremented by one.
218+
# If a paragraph with footnotes is after the new footnote
219+
# then increment thous footnote ids.
220+
has_passed_containing_para = False
221+
for p_i in reversed(range(len(self.paragraphs))):
222+
# mark when we pass the paragraph containing the footnote
223+
if p is self.paragraphs[p_i]._p:
224+
has_passed_containing_para = True
225+
continue
226+
# Skip paragraphs without footnotes (they don't impact new id).
227+
if self.paragraphs[p_i]._p.footnote_reference_ids is None:
228+
continue
229+
# These footnotes are after the new footnote, so we increment them.
230+
if not has_passed_containing_para:
231+
self.paragraphs[p_i].increment_containing_footnote_reference_ids()
232+
else:
233+
# This is the last footnote before the new footnote, so we use its
234+
# value to determent the value of the new footnote.
235+
new_fr_id = max(self.paragraphs[p_i]._p.footnote_reference_ids)+1
236+
break
237+
return new_fr_id
238+
190239

191240
class _Body(BlockItemContainer):
192241
"""Proxy for `<w:body>` element in this document.

src/docx/footnotes.py

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
"""The |Footnotes| object and related proxy classes."""
2+
3+
from __future__ import annotations
4+
5+
from docx.blkcntnr import BlockItemContainer
6+
from docx.shared import Parented
7+
8+
9+
class Footnotes(Parented):
10+
"""
11+
Proxy object wrapping ``<w:footnotes>`` element.
12+
"""
13+
def __init__(self, footnotes, parent):
14+
super(Footnotes, self).__init__(parent)
15+
self._element = self._footnotes = footnotes
16+
17+
def __getitem__(self, reference_id):
18+
"""
19+
A |Footnote| for a specific footnote of reference id, defined with ``w:id`` argument of ``<w:footnoteReference>``.
20+
If reference id is invalid raises an |IndexError|
21+
"""
22+
footnote = self._element.get_by_id(reference_id)
23+
if footnote is None:
24+
raise IndexError
25+
return Footnote(footnote, self)
26+
27+
def __len__(self):
28+
return len(self._element)
29+
30+
def add_footnote(self, footnote_reference_id):
31+
"""
32+
Return a newly created |Footnote|, the new footnote will
33+
be inserted in the correct spot by `footnote_reference_id`.
34+
The footnotes are kept in order by `footnote_reference_id`.
35+
"""
36+
elements = self._element # for easy access
37+
new_footnote = None
38+
if elements.get_by_id(footnote_reference_id):
39+
# When adding a footnote it can be inserted
40+
# in front of some other footnotes, so
41+
# we need to sort footnotes by `footnote_reference_id`
42+
# in |Footnotes| and in |Paragraph|
43+
#
44+
# resolve reference ids in |Footnotes|
45+
# iterate in reverse and compare the current
46+
# id with the inserted id. If there are the same
47+
# insert the new footnote in that place, if not
48+
# increment the current footnote id.
49+
for index in reversed(range(len(elements))):
50+
if elements[index].id == footnote_reference_id:
51+
elements[index].id += 1
52+
new_footnote = elements[index].add_footnote_before(footnote_reference_id)
53+
break
54+
else:
55+
elements[index].id += 1
56+
else:
57+
# append the newly created |Footnote| to |Footnotes|
58+
new_footnote = elements.add_footnote(footnote_reference_id)
59+
return Footnote(new_footnote, self)
60+
61+
62+
class Footnote(BlockItemContainer):
63+
"""
64+
Proxy object wrapping ``<w:footnote>`` element.
65+
"""
66+
def __init__(self, f, parent):
67+
super(Footnote, self).__init__(f, parent)
68+
self._f = self._element = f
69+
70+
def __eq__(self, other):
71+
if isinstance(other, Footnote):
72+
return self._f is other._f
73+
return False
74+
75+
def __ne__(self, other):
76+
if isinstance(other, Footnote):
77+
return self._f is not other._f
78+
return True
79+
80+
@property
81+
def id(self):
82+
return self._f.id

src/docx/oxml/__init__.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -241,3 +241,15 @@
241241
register_element_cls("w:tab", CT_TabStop)
242242
register_element_cls("w:tabs", CT_TabStops)
243243
register_element_cls("w:widowControl", CT_OnOff)
244+
245+
# ---------------------------------------------------------------------------
246+
# footnote-related mappings
247+
248+
from .footnote import (
249+
CT_FtnEnd,
250+
CT_Footnotes
251+
)
252+
from .text.footnote_reference import CT_FtnEdnRef
253+
register_element_cls('w:footnoteReference', CT_FtnEdnRef)
254+
register_element_cls('w:footnote', CT_FtnEnd)
255+
register_element_cls('w:footnotes', CT_Footnotes)

src/docx/oxml/footnote.py

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
"""Custom element classes related to footnote (CT_FtnEnd, CT_Footnotes)."""
2+
3+
from docx.oxml.ns import qn
4+
from docx.oxml.parser import OxmlElement
5+
from docx.oxml.xmlchemy import (
6+
BaseOxmlElement, RequiredAttribute, ZeroOrMore, OneOrMore
7+
)
8+
from docx.oxml.simpletypes import (
9+
ST_DecimalNumber
10+
)
11+
12+
class CT_Footnotes(BaseOxmlElement):
13+
"""
14+
``<w:footnotes>`` element, containing a sequence of footnote (w:footnote) elements
15+
"""
16+
footnote_sequence = OneOrMore('w:footnote')
17+
18+
def add_footnote(self, footnote_reference_id):
19+
"""
20+
Create a ``<w:footnote>`` element with `footnote_reference_id`.
21+
"""
22+
new_f = self.add_footnote_sequence()
23+
new_f.id = footnote_reference_id
24+
return new_f
25+
26+
def get_by_id(self, id):
27+
found = self.xpath('w:footnote[@w:id="%s"]' % id)
28+
if not found:
29+
return None
30+
return found[0]
31+
32+
33+
class CT_FtnEnd(BaseOxmlElement):
34+
"""
35+
``<w:footnote>`` element, containing the properties for a specific footnote
36+
"""
37+
id = RequiredAttribute('w:id', ST_DecimalNumber)
38+
p = ZeroOrMore('w:p')
39+
40+
def add_footnote_before(self, footnote_reference_id):
41+
"""
42+
Create a ``<w:footnote>`` element with `footnote_reference_id`
43+
and insert it before the current element.
44+
"""
45+
new_footnote = OxmlElement('w:footnote')
46+
new_footnote.id = footnote_reference_id
47+
self.addprevious(new_footnote)
48+
return new_footnote
49+
50+
@property
51+
def paragraphs(self):
52+
"""
53+
Returns a list of paragraphs |CT_P|, or |None| if none paragraph is present.
54+
"""
55+
paragraphs = []
56+
for child in self:
57+
if child.tag == qn('w:p'):
58+
paragraphs.append(child)
59+
if paragraphs == []:
60+
paragraphs = None
61+
return paragraphs
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
"""Custom element classes related to footnote references (CT_FtnEdnRef)."""
2+
3+
from docx.oxml.xmlchemy import (
4+
BaseOxmlElement, RequiredAttribute, OptionalAttribute
5+
)
6+
from docx.oxml.simpletypes import (
7+
ST_DecimalNumber, ST_OnOff
8+
)
9+
10+
class CT_FtnEdnRef(BaseOxmlElement):
11+
"""
12+
``<w:footnoteReference>`` element, containing the properties for a footnote reference
13+
"""
14+
id = RequiredAttribute('w:id', ST_DecimalNumber)
15+
customMarkFollows = OptionalAttribute('w:customMarkFollows', ST_OnOff)

src/docx/oxml/text/paragraph.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,21 @@ def lastRenderedPageBreaks(self) -> List[CT_LastRenderedPageBreak]:
7070
"./w:r/w:lastRenderedPageBreak | ./w:hyperlink/w:r/w:lastRenderedPageBreak"
7171
)
7272

73+
@property
74+
def footnote_reference_ids(self):
75+
"""
76+
Return all footnote reference ids (``<w:footnoteReference>``) form the paragraph,
77+
or |None| if not present.
78+
"""
79+
footnote_ids = []
80+
for run in self.r_lst:
81+
new_footnote_ids = run.footnote_reference_ids
82+
if new_footnote_ids:
83+
footnote_ids.extend(new_footnote_ids)
84+
if footnote_ids == []:
85+
footnote_ids = None
86+
return footnote_ids
87+
7388
def set_sectPr(self, sectPr: CT_SectPr):
7489
"""Unconditionally replace or add `sectPr` as grandchild in correct sequence."""
7590
pPr = self.get_or_add_pPr()

src/docx/oxml/text/run.py

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,18 @@ class CT_R(BaseOxmlElement):
3535
drawing = ZeroOrMore("w:drawing")
3636
t = ZeroOrMore("w:t")
3737
tab = ZeroOrMore("w:tab")
38+
footnoteReference = ZeroOrMore('w:footnoteReference')
39+
40+
def add_footnoteReference(self, id):
41+
"""
42+
Return a newly added ``<w:footnoteReference>`` element containing
43+
the footnote reference id.
44+
"""
45+
rPr = self._add_rPr()
46+
rPr.style = 'FootnoteReference'
47+
new_fr = self._add_footnoteReference()
48+
new_fr.id = id
49+
return new_fr
3850

3951
def add_t(self, text: str) -> CT_Text:
4052
"""Return a newly added `<w:t>` element containing `text`."""
@@ -92,6 +104,30 @@ def lastRenderedPageBreaks(self) -> List[CT_LastRenderedPageBreak]:
92104
"""All `w:lastRenderedPageBreaks` descendants of this run."""
93105
return self.xpath("./w:lastRenderedPageBreak")
94106

107+
@property
108+
def footnote_reference_ids(self):
109+
"""
110+
Return all footnote reference ids (``<w:footnoteReference>``), or |None| if not present.
111+
"""
112+
references = []
113+
for child in self:
114+
if child.tag == qn('w:footnoteReference'):
115+
references.append(child.id)
116+
if references == []:
117+
references = None
118+
return references
119+
120+
def increment_containing_footnote_reference_ids(self):
121+
"""
122+
Increment all footnote reference ids by one if they exist.
123+
Return all footnote reference ids (``<w:footnoteReference>``), or |None| if not present.
124+
"""
125+
if self.footnoteReference_lst is not None:
126+
for i in range(len(self.footnoteReference_lst)):
127+
self.footnoteReference_lst[i].id += 1
128+
return self.footnoteReference_lst
129+
return None
130+
95131
@property
96132
def style(self) -> str | None:
97133
"""String contained in `w:val` attribute of `w:rStyle` grandchild.

src/docx/parts/document.py

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
from docx.document import Document
88
from docx.enum.style import WD_STYLE_TYPE
99
from docx.opc.constants import RELATIONSHIP_TYPE as RT
10+
from docx.parts.footnotes import FootnotesPart
1011
from docx.parts.hdrftr import FooterPart, HeaderPart
1112
from docx.parts.numbering import NumberingPart
1213
from docx.parts.settings import SettingsPart
@@ -61,6 +62,14 @@ def footer_part(self, rId: str):
6162
"""Return |FooterPart| related by `rId`."""
6263
return self.related_parts[rId]
6364

65+
@property
66+
def footnotes(self):
67+
"""
68+
A |Footnotes| object providing access to the footnotes in the footnotes part
69+
of this document.
70+
"""
71+
return self._footnotes_part.footnotes
72+
6473
def get_style(self, style_id: str | None, style_type: WD_STYLE_TYPE) -> BaseStyle:
6574
"""Return the style in this document matching `style_id`.
6675
@@ -119,6 +128,19 @@ def styles(self):
119128
document."""
120129
return self._styles_part.styles
121130

131+
@property
132+
def _footnotes_part(self):
133+
"""
134+
Instance of |FootnotesPart| for this document. Creates an empty footnotes
135+
part if one is not present.
136+
"""
137+
try:
138+
return self.part_related_by(RT.FOOTNOTES)
139+
except KeyError:
140+
footnotes_part = FootnotesPart.default(self.package)
141+
self.relate_to(footnotes_part, RT.FOOTNOTES)
142+
return footnotes_part
143+
122144
@property
123145
def _settings_part(self) -> SettingsPart:
124146
"""A |SettingsPart| object providing access to the document-level settings for

0 commit comments

Comments
 (0)