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
2 changes: 2 additions & 0 deletions src/docx/oxml/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -231,6 +231,7 @@
from .text.parfmt import (
CT_Ind,
CT_Jc,
CT_PBdr,
CT_PPr,
CT_Spacing,
CT_TabStop,
Expand All @@ -243,6 +244,7 @@
register_element_cls("w:keepNext", CT_OnOff)
register_element_cls("w:outlineLvl", CT_DecimalNumber)
register_element_cls("w:pageBreakBefore", CT_OnOff)
register_element_cls("w:pBdr", CT_PBdr)
register_element_cls("w:pPr", CT_PPr)
register_element_cls("w:pStyle", CT_String)
register_element_cls("w:spacing", CT_Spacing)
Expand Down
16 changes: 16 additions & 0 deletions src/docx/oxml/text/parfmt.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
WD_TAB_ALIGNMENT,
WD_TAB_LEADER,
)
from docx.oxml.ns import qn
from docx.oxml.shared import CT_DecimalNumber
from docx.oxml.simpletypes import ST_SignedTwipsMeasure, ST_TwipsMeasure
from docx.oxml.xmlchemy import (
Expand Down Expand Up @@ -51,9 +52,23 @@ class CT_Jc(BaseOxmlElement):
)


class CT_PBdr(BaseOxmlElement):
"""Paragraph borders, including Word's horizontal-rule bottom border."""

get_or_add_bottom: Callable[[], BaseOxmlElement]
bottom = ZeroOrOne("w:bottom", successors=("w:right", "w:between", "w:bar"))

def set_horizontal_rule(self) -> None:
"""Apply the simple bottom border Word uses for a horizontal rule."""
bottom = self.get_or_add_bottom()
for name, value in (("val", "single"), ("sz", "6"), ("space", "1"), ("color", "auto")):
bottom.set(qn(f"w:{name}"), value)


class CT_PPr(BaseOxmlElement):
"""``<w:pPr>`` element, containing the properties for a paragraph."""

get_or_add_pBdr: Callable[[], CT_PBdr]
get_or_add_ind: Callable[[], CT_Ind]
get_or_add_pStyle: Callable[[], CT_String]
get_or_add_sectPr: Callable[[], CT_SectPr]
Expand Down Expand Up @@ -107,6 +122,7 @@ class CT_PPr(BaseOxmlElement):
pageBreakBefore = ZeroOrOne("w:pageBreakBefore", successors=_tag_seq[4:])
widowControl = ZeroOrOne("w:widowControl", successors=_tag_seq[6:])
numPr = ZeroOrOne("w:numPr", successors=_tag_seq[7:])
pBdr = ZeroOrOne("w:pBdr", successors=_tag_seq[9:])
tabs = ZeroOrOne("w:tabs", successors=_tag_seq[11:])
spacing = ZeroOrOne("w:spacing", successors=_tag_seq[22:])
ind: CT_Ind | None = ZeroOrOne( # pyright: ignore[reportAssignmentType]
Expand Down
8 changes: 8 additions & 0 deletions src/docx/text/paragraph.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,14 @@ def __init__(self, p: CT_P, parent: t.ProvidesStoryPart):
super(Paragraph, self).__init__(parent)
self._p = self._element = p

def add_horizontal_rule(self) -> None:
"""Apply Word's horizontal-rule bottom border to this paragraph.

Call this on an empty paragraph for the same structure Word creates
when its horizontal-line shortcut is used.
"""
self._p.get_or_add_pPr().get_or_add_pBdr().set_horizontal_rule()

def add_run(self, text: str | None = None, style: str | CharacterStyle | None = None) -> Run:
"""Append run containing `text` and having character-style `style`.

Expand Down
41 changes: 41 additions & 0 deletions tests/text/test_horizontal_rule.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
# pyright: reportPrivateUsage=false

"""Saved-document tests for Word horizontal rules."""

from __future__ import annotations

from io import BytesIO

from docx import Document
from docx.oxml.ns import qn


def it_applies_a_bottom_border_to_an_empty_paragraph() -> None:
document = Document()
paragraph = document.add_paragraph()
paragraph.add_horizontal_rule()
stream = BytesIO()
document.save(stream)
stream.seek(0)

reopened = Document(stream)
rule = reopened.paragraphs[0]
assert rule.text == ""
border = rule._p.xpath("./w:pPr/w:pBdr/w:bottom")[0]
assert {name: border.get(qn(f"w:{name}")) for name in ("val", "sz", "space", "color")} == {
"val": "single",
"sz": "6",
"space": "1",
"color": "auto",
}


def it_preserves_existing_paragraph_properties_and_is_idempotent() -> None:
paragraph = Document().add_paragraph(style="Quote")
paragraph.paragraph_format.left_indent = 914400
paragraph.add_horizontal_rule()
paragraph.add_horizontal_rule()
assert len(paragraph._p.xpath("./w:pPr/w:pBdr/w:bottom")) == 1
assert paragraph.style is not None
assert paragraph.style.name == "Quote"
assert paragraph.paragraph_format.left_indent == 914400