Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions docs/api/footnotes.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
Footnote objects
================

.. autoclass:: docx.footnotes.Footnotes
:members: get

.. autoclass:: docx.footnotes.Footnote
:members: footnote_id, text, add_paragraph, paragraphs, tables, add_table, iter_inner_content
2 changes: 2 additions & 0 deletions docs/index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,7 @@ User Guide
user/api-concepts
user/styles-understanding
user/styles-using
user/footnotes
user/comments
user/shapes

Expand All @@ -97,6 +98,7 @@ API Documentation
api/text
api/table
api/section
api/footnotes
api/comments
api/shape
api/dml
Expand Down
43 changes: 43 additions & 0 deletions docs/user/footnotes.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
Native footnotes
================

Create a note immediately after a run in a body paragraph or table cell::

document = Document()
paragraph = document.add_paragraph()
before = paragraph.add_run("A statement")
paragraph.add_run(" with following text.")
note = document.add_footnote(before, "An explanation.")
note.add_paragraph("A second paragraph.")
note.paragraphs[0].add_run(" Important.").bold = True
document.save("notes.docx")

The new reference occupies its own run. Inserting it does not change the text or
formatting of adjacent runs. References must be direct children of attached body
or table-cell paragraphs in the same document. Headers, footers, other notes,
comments, hyperlink runs, detached content, and text boxes are unsupported.

Each call creates a new native note. Reusing an existing note through an additional
reference is outside this API. Word supplies the visible number, placement, and
pagination according to document and section settings. This API does not calculate
page positions or change numbering settings.

``Document.footnotes`` supports iteration, ``len()``, and ``get(footnote_id)``.
Separator entries are excluded. Iteration follows part order, which can differ
from reference order. ``Run.footnote_ids`` exposes reference IDs in content order,
including dangling references. Reading the collection does not create a part.

Notes are block containers with normal paragraph, run, table, and image APIs.
Their own part owns relationships for note content. Newlines passed to
``add_footnote`` create separate paragraphs. An empty note starts with one paragraph
containing its native number marker and a space. ``Footnote.text`` includes that
space and joins paragraphs with newlines. Do not replace the first paragraph's
text or clear its marker run when editing content, since doing so removes the
native note marker. Append runs or edit the existing text runs instead.

New notes use Footnote Text and Footnote Reference styles. Existing styles are
preserved. Missing styles are created, with note paragraphs inheriting the default
paragraph style and reference numbers superscripted. Existing note content and
unrelated package content are preserved when adding notes.

Endnotes, field-based cross-references, and note deletion are outside this feature.
8 changes: 8 additions & 0 deletions features/doc-footnotes.feature
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
Feature: Create editable native footnotes
As a document author
I need references placed deliberately and editable note content

Scenario: Save a formatted note between existing runs
Given a document with a native footnote between existing runs
When I save and reopen the footnote document
Then the reference position and formatted note content are preserved
35 changes: 35 additions & 0 deletions features/steps/footnotes.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
"""Acceptance steps for native footnote creation."""

from io import BytesIO

from behave import given, then, when

from docx import Document


@given("a document with a native footnote between existing runs")
def given_footnote(context):
context.document = Document()
paragraph = context.document.add_paragraph("Statement")
before = paragraph.runs[0]
paragraph.add_run(" continues.")
note = context.document.add_footnote(before, "Explanation")
note.paragraphs[0].add_run(" with emphasis").italic = True
note.add_paragraph("More detail")


@when("I save and reopen the footnote document")
def when_footnote_roundtrip(context):
stream = BytesIO()
context.document.save(stream)
context.document = Document(stream)


@then("the reference position and formatted note content are preserved")
def then_footnote_preserved(context):
document = context.document
runs = document.paragraphs[0].runs
assert [run.text for run in runs] == ["Statement", "", " continues."]
note = document.footnotes.get(runs[1].footnote_ids[0])
assert note.text == " Explanation with emphasis\nMore detail"
assert note.paragraphs[0].runs[-1].italic
3 changes: 3 additions & 0 deletions src/docx/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
from docx.opc.parts.coreprops import CorePropertiesPart
from docx.parts.comments import CommentsPart
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
Expand All @@ -44,6 +45,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_COMMENTS] = CommentsPart
PartFactory.part_type_for[CT.WML_DOCUMENT_MAIN] = DocumentPart
PartFactory.part_type_for[CT.WML_FOOTNOTES] = FootnotesPart
PartFactory.part_type_for[CT.WML_FOOTER] = FooterPart
PartFactory.part_type_for[CT.WML_HEADER] = HeaderPart
PartFactory.part_type_for[CT.WML_NUMBERING] = NumberingPart
Expand All @@ -56,6 +58,7 @@ def part_class_selector(content_type: str, reltype: str) -> Type[Part] | None:
CommentsPart,
DocumentPart,
FooterPart,
FootnotesPart,
HeaderPart,
NumberingPart,
PartFactory,
Expand Down
3 changes: 2 additions & 1 deletion src/docx/blkcntnr.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,13 +21,14 @@
import docx.types as t
from docx.oxml.comments import CT_Comment
from docx.oxml.document import CT_Body
from docx.oxml.footnotes import CT_Footnote
from docx.oxml.section import CT_HdrFtr
from docx.oxml.table import CT_Tc
from docx.shared import Length
from docx.styles.style import ParagraphStyle
from docx.table import Table

BlockItemElement: TypeAlias = "CT_Body | CT_Comment | CT_HdrFtr | CT_Tc"
BlockItemElement: TypeAlias = "CT_Body | CT_Comment | CT_Footnote | CT_HdrFtr | CT_Tc"


class BlockItemContainer(StoryChild):
Expand Down
15 changes: 15 additions & 0 deletions src/docx/document.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
from docx.blkcntnr import BlockItemContainer
from docx.enum.section import WD_SECTION
from docx.enum.text import WD_BREAK
from docx.footnotes import Footnote, Footnotes
from docx.section import Section, Sections
from docx.shared import ElementProxy, Emu, Inches, Length
from docx.text.run import Run
Expand Down Expand Up @@ -38,6 +39,20 @@ def __init__(self, element: CT_Document, part: DocumentPart):
self._part = part
self.__body = None

def add_footnote(self, after: Run, text: str = "") -> Footnote:
"""Insert a native footnote reference immediately after `after`.

`after` must be a direct run in an attached body or table-cell paragraph
in this document. Each call creates a new note. The original run is
unchanged. Newlines in `text` separate paragraphs.
"""
return self.footnotes._add(after, text)

@property
def footnotes(self) -> Footnotes:
"""Ordinary footnotes. Reading this collection does not create a part."""
return Footnotes(self._part)

def add_comment(
self,
runs: Run | Sequence[Run],
Expand Down
159 changes: 159 additions & 0 deletions src/docx/footnotes.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
# pyright: reportPrivateUsage=false

"""Native footnote creation and access to existing editable notes."""

from __future__ import annotations

from typing import TYPE_CHECKING, Iterator, cast

from docx.blkcntnr import BlockItemContainer
from docx.enum.style import WD_STYLE_TYPE
from docx.opc.constants import RELATIONSHIP_TYPE as RT
from docx.oxml.footnotes import CT_Footnote
from docx.oxml.ns import qn
from docx.oxml.parser import OxmlElement
from docx.oxml.text.paragraph import CT_P
from docx.oxml.text.run import CT_R
from docx.parts.footnotes import FootnotesPart
from docx.text.paragraph import Paragraph
from docx.text.run import Run

if TYPE_CHECKING:
from docx.oxml.settings import CT_Settings
from docx.parts.document import DocumentPart
from docx.styles.style import CharacterStyle, ParagraphStyle


class Footnotes:
"""Ordinary footnotes, excluding separator and continuation entries.

Iteration follows part order, which need not match reference order. Reading this
collection does not create a part. Use ``Document.add_footnote()`` to create notes.
"""

def __init__(self, document_part: DocumentPart):
self._document_part = document_part

@property
def _part(self) -> FootnotesPart | None:
try:
return cast(FootnotesPart, self._document_part.part_related_by(RT.FOOTNOTES))
except KeyError:
return None

def __iter__(self) -> Iterator[Footnote]:
part = self._part
if part is not None:
for element in part._element.footnote_lst:
if element.type in (None, "normal"):
yield Footnote(element, part)

def __len__(self) -> int:
return sum(1 for _ in self)

def get(self, footnote_id: int) -> Footnote | None:
"""Return the ordinary note with this ID, or |None| if absent."""
return next((note for note in self if note.footnote_id == footnote_id), None)

def _add(self, after: object, text: object) -> Footnote:
"""Validate and prepare the note before attaching its part or reference."""
if not isinstance(after, Run):
raise TypeError("after must be a Run")
paragraph = after._r.getparent()
container = paragraph.getparent() if paragraph is not None else None
if (
after.part is not self._document_part
or not isinstance(paragraph, CT_P)
or self._document_part.element not in paragraph.iterancestors()
or container is None
or container.tag not in (qn("w:body"), qn("w:tc"))
):
raise ValueError("footnote references require an attached body or table-cell run")
if not isinstance(text, str):
raise TypeError("text must be a string")
styles = self._document_part.styles
for name, kind in (
("Footnote Text", WD_STYLE_TYPE.PARAGRAPH),
("Footnote Reference", WD_STYLE_TYPE.CHARACTER),
):
if name in styles and styles[name].type != kind:
raise ValueError(f"{name} has an incompatible style type")

part = self._part
used = {
int(value)
for value in self._document_part.element.xpath(".//w:footnoteReference/@w:id")
}
if part is not None:
used.update(note.id for note in part._element.footnote_lst)
note_id = max(used | {0}) + 1
if note_id > 2**31 - 1:
note_id = next(i for i in range(1, len(used) + 2) if i not in used)
element = CT_Footnote.new(note_id)
# Text is added while detached so invalid XML characters cannot leave a note.
first, *remaining = text.split("\n")
element.p_lst[0].add_r().text = first
for content in remaining:
p = element.add_p()
p.style = "FootnoteText"
p.add_r().text = content
reference = cast(CT_R, OxmlElement("w:r"))
reference.get_or_add_rPr().style = "FootnoteReference"
marker = OxmlElement("w:footnoteReference")
marker.set(qn("w:id"), str(note_id))
reference.append(marker)

if "Footnote Text" not in styles:
style = cast(
"ParagraphStyle",
styles.add_style( # pyright: ignore[reportUnknownMemberType]
"Footnote Text", WD_STYLE_TYPE.PARAGRAPH, builtin=True
),
)
style.base_style = styles.default(WD_STYLE_TYPE.PARAGRAPH)
if "Footnote Reference" not in styles:
reference_style = cast(
"CharacterStyle",
styles.add_style( # pyright: ignore[reportUnknownMemberType]
"Footnote Reference", WD_STYLE_TYPE.CHARACTER, builtin=True
),
)
reference_style.font.superscript = True
if part is None:
package = self._document_part.package
assert package is not None
part = FootnotesPart.default(package)
self._document_part.relate_to(part, RT.FOOTNOTES)
cast("CT_Settings", self._document_part.settings.element).ensure_footnote_separators()
part._element.append(element)
after._r.addnext(reference)
return Footnote(element, part)


class Footnote(BlockItemContainer):
"""An editable ordinary note with native Word numbering.

The first paragraph contains a native number marker and a space. Preserve that
marker when editing the note. Replacing the paragraph's text removes it.
"""

def __init__(self, element: CT_Footnote, part: FootnotesPart):
super().__init__(element, part)
self._footnote = element

@property
def footnote_id(self) -> int:
"""Read-only identifier used by reference runs."""
return self._footnote.id

def add_paragraph(self, text: str = "", style: str | ParagraphStyle | None = None) -> Paragraph:
"""Append a paragraph using Footnote Text unless a style is specified."""
paragraph = super().add_paragraph(text, style)
if style is None:
paragraph._p.style = "FootnoteText"
return paragraph

@property
def text(self) -> str:
"""Paragraph text joined by newlines, including the initial marker space."""
return "\n".join(paragraph.text for paragraph in self.paragraphs)
4 changes: 4 additions & 0 deletions src/docx/oxml/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,9 @@

from __future__ import annotations

from docx.oxml.footnotes import CT_Footnote, CT_Footnotes
from docx.oxml.drawing import CT_Drawing

from docx.oxml.parser import OxmlElement, parse_xml, register_element_cls
from docx.oxml.shape import (
CT_Anchor,
Expand Down Expand Up @@ -44,6 +46,8 @@
# ---------------------------------------------------------------------------
# DrawingML-related elements

register_element_cls("w:footnote", CT_Footnote)
register_element_cls("w:footnotes", CT_Footnotes)
register_element_cls("a:blip", CT_Blip)
register_element_cls("a:ext", CT_PositiveSize2D)
register_element_cls("a:graphic", CT_GraphicalObject)
Expand Down
Loading