diff --git a/src/docx/oxml/__init__.py b/src/docx/oxml/__init__.py index 37f608cef..79ef17169 100644 --- a/src/docx/oxml/__init__.py +++ b/src/docx/oxml/__init__.py @@ -231,6 +231,7 @@ from .text.parfmt import ( CT_Ind, CT_Jc, + CT_PBdr, CT_PPr, CT_Spacing, CT_TabStop, @@ -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) diff --git a/src/docx/oxml/text/parfmt.py b/src/docx/oxml/text/parfmt.py index 2133686b2..9c88fa576 100644 --- a/src/docx/oxml/text/parfmt.py +++ b/src/docx/oxml/text/parfmt.py @@ -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 ( @@ -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): """```` 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] @@ -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] diff --git a/src/docx/text/paragraph.py b/src/docx/text/paragraph.py index 234ea66cb..ad203a672 100644 --- a/src/docx/text/paragraph.py +++ b/src/docx/text/paragraph.py @@ -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`. diff --git a/tests/text/test_horizontal_rule.py b/tests/text/test_horizontal_rule.py new file mode 100644 index 000000000..7121e3527 --- /dev/null +++ b/tests/text/test_horizontal_rule.py @@ -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