From 119def31eeec270b4b6bb48494ab362a952db559 Mon Sep 17 00:00:00 2001 From: Didier Marin Date: Sun, 15 Dec 2024 19:42:19 +0100 Subject: [PATCH 1/2] chore: git ignore .vscode --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 5aabfd8cc..d42b6a7aa 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,4 @@ _scratch/ Session.vim /.tox/ +/.vscode/ From a59569a2ea1e19afbe528591b4294431f8462a91 Mon Sep 17 00:00:00 2001 From: Didier Marin Date: Sun, 15 Dec 2024 21:05:15 +0100 Subject: [PATCH 2/2] feat: comments, footnotes and others from bayoo fork --- features/comments.feature | 30 ++++ features/footnotes.feature | 25 +++ features/steps/comment.py | 74 ++++++++ features/steps/document.py | 14 +- features/steps/footnote.py | 52 ++++++ .../steps/test_files/having-comments.docx | Bin 0 -> 6092 bytes .../steps/test_files/having-footnotes.docx | Bin 0 -> 6253 bytes src/docx/__init__.py | 6 + src/docx/api.py | 36 +++- src/docx/blkcntnr.py | 17 +- src/docx/document.py | 39 ++++- src/docx/opc/package.py | 28 +++ src/docx/oxml/__init__.py | 47 ++++- src/docx/oxml/comments.py | 124 ++++++++++++++ src/docx/oxml/footnotes.py | 94 ++++++++++ src/docx/oxml/numbering.py | 9 + src/docx/oxml/styles.py | 15 ++ src/docx/oxml/table.py | 82 ++++++++- src/docx/oxml/text/font.py | 44 +++++ src/docx/oxml/text/paragraph.py | 68 +++++++- src/docx/oxml/text/parfmt.py | 1 + src/docx/oxml/text/run.py | 131 +++++++++++++- src/docx/parts/comments.py | 36 ++++ src/docx/parts/document.py | 40 +++++ src/docx/parts/footnotes.py | 37 ++++ src/docx/table.py | 5 + src/docx/templates/default-comments.xml | 2 + src/docx/templates/default-footnotes.xml | 6 + src/docx/text/comment.py | 58 +++++++ src/docx/text/font.py | 25 ++- src/docx/text/footnote.py | 38 +++++ src/docx/text/paragraph.py | 161 +++++++++++++++++- src/docx/text/run.py | 119 ++++++++++++- tests/test_comment.py | 39 +++++ tests/test_footnote.py | 34 ++++ 35 files changed, 1510 insertions(+), 26 deletions(-) create mode 100644 features/comments.feature create mode 100644 features/footnotes.feature create mode 100644 features/steps/comment.py create mode 100644 features/steps/footnote.py create mode 100644 features/steps/test_files/having-comments.docx create mode 100644 features/steps/test_files/having-footnotes.docx create mode 100644 src/docx/oxml/comments.py create mode 100644 src/docx/oxml/footnotes.py create mode 100644 src/docx/parts/comments.py create mode 100644 src/docx/parts/footnotes.py create mode 100644 src/docx/templates/default-comments.xml create mode 100644 src/docx/templates/default-footnotes.xml create mode 100644 src/docx/text/comment.py create mode 100644 src/docx/text/footnote.py create mode 100644 tests/test_comment.py create mode 100644 tests/test_footnote.py diff --git a/features/comments.feature b/features/comments.feature new file mode 100644 index 000000000..017e74571 --- /dev/null +++ b/features/comments.feature @@ -0,0 +1,30 @@ +Feature: Comments + In order to add annotations to a document + As a developer using python-docx + I want to be able to add and read comments in a document + + Scenario: Add comment to paragraph's first run + Given a document having a paragraph + When I add a comment with text "New comment" to the paragraph's first run + Then the comment text matches "New comment" + + Scenario: Add comment to paragraph + Given a document having a paragraph + When I add a comment with text "New comment" + Then the paragraph has a comment + And the comment text matches "New comment" + + Scenario: Get comment text + Given a document having a comment + When I get the comment text + Then the text matches the comment content + + Scenario: Set comment text + Given a document having a comment + When I set the comment text to "Updated comment" + Then the comment text matches "Updated comment" + + Scenario: Access comment paragraph + Given a document having a comment + When I access the comment paragraph + Then I get a comment paragraph object \ No newline at end of file diff --git a/features/footnotes.feature b/features/footnotes.feature new file mode 100644 index 000000000..3b651b17d --- /dev/null +++ b/features/footnotes.feature @@ -0,0 +1,25 @@ +Feature: Footnotes + In order to add references to a document + As a developer using python-docx + I want to be able to add and read footnotes in a document + + Scenario: Get footnote text + Given a document having a footnote + When I get the footnote text + Then the footnote text matches the content + + Scenario: Set footnote text + Given a document having a footnote + When I set the footnote text to "Updated footnote" + Then the footnote text matches "Updated footnote" + + Scenario: Access footnote paragraph + Given a document having a footnote + When I access the footnote paragraph + Then I get a footnote paragraph object + + Scenario: Add footnote to paragraph + Given a document having a paragraph + When I add a footnote with text "New footnote" + Then the paragraph has a footnote + And the footnote text matches "New footnote" \ No newline at end of file diff --git a/features/steps/comment.py b/features/steps/comment.py new file mode 100644 index 000000000..787547001 --- /dev/null +++ b/features/steps/comment.py @@ -0,0 +1,74 @@ +from behave import given, then, when + +from docx import Document + +from helpers import test_docx + + +# ----- Document having comment ----- +@given("a document having a comment") +def given_document_having_comment(context): + context.document = Document(test_docx("having-comments")) + context.comment = context.document.paragraphs[2].comments[0] + + +# ----- Get comment text ----- +@when("I get the comment text") +def when_get_comment_text(context): + context.comment_text = context.comment.text + + +@then("the text matches the comment content") +def then_text_matches_content(context): + assert context.comment_text == "Comment text" + + +# ----- Set comment text ----- +@when('I set the comment text to "{text}"') +def when_set_comment_text(context, text): + context.comment.text = text + + +@then('the comment text matches "{text}"') +def then_comment_text_matches(context, text): + assert context.comment.text == text, f"Expected '{text}', got '{context.comment.text}'" + + +# ----- Access comment paragraph ----- +@when("I access the comment paragraph") +def when_access_comment_paragraph(context): + context.paragraph = context.comment.paragraph + + +@then("I get a comment paragraph object") +def then_get_comment_paragraph_object(context): + from docx.text.paragraph import Paragraph + + assert isinstance(context.paragraph, Paragraph) + + +# ----- Add comment to paragraph ----- +@when('I add a comment with text "{text}"') +def when_add_comment_with_text(context, text: str): + context.comment = context.paragraph.add_comment(text) + + +@then("the comment text matches {text}") +def then_comment_text_matches(context, text: str): + assert context.comment.text == text + + +@then("the paragraph has a comment") +def then_paragraph_has_comment(context): + assert len(context.paragraph.comments) > 0 + + +# ----- Add comment to run ----- +@when('I add a comment with text "{text}" to the paragraph\'s first run') +def when_add_comment_to_run(context, text: str): + context.comment = context.paragraph.runs[0].add_comment(text) + + +@then("the run has a comment") +def then_run_has_comment(context): + assert len(context.run.comments) > 0 diff --git a/features/steps/document.py b/features/steps/document.py index 49165efc3..2c25b460f 100644 --- a/features/steps/document.py +++ b/features/steps/document.py @@ -26,6 +26,12 @@ def given_a_document_having_builtin_styles(context): context.document = Document() +@given("a document having a paragraph") +def given_document_having_paragraph(context): + context.document = Document() + context.paragraph = context.document.add_paragraph("Paragraph text") + + @given("a document having inline shapes") def given_a_document_having_inline_shapes(context): context.document = Document(test_docx("shp-inline-shape-access")) @@ -126,17 +132,13 @@ def when_add_picture_specifying_width_and_height(context): @when("I add a picture specifying a height of 1.5 inches") def when_add_picture_specifying_height(context): document = context.document - context.picture = document.add_picture( - test_file("monty-truth.png"), height=Inches(1.5) - ) + context.picture = document.add_picture(test_file("monty-truth.png"), height=Inches(1.5)) @when("I add a picture specifying a width of 1.5 inches") def when_add_picture_specifying_width(context): document = context.document - context.picture = document.add_picture( - test_file("monty-truth.png"), width=Inches(1.5) - ) + context.picture = document.add_picture(test_file("monty-truth.png"), width=Inches(1.5)) @when("I add a picture specifying only the image file") diff --git a/features/steps/footnote.py b/features/steps/footnote.py new file mode 100644 index 000000000..0517cbf77 --- /dev/null +++ b/features/steps/footnote.py @@ -0,0 +1,52 @@ +from behave import given, when, then +from docx import Document +from docx.text.paragraph import Paragraph + +from helpers import test_docx + + +@given("a document having a footnote") +def given_document_having_footnote(context): + context.document = Document(test_docx("having-footnotes")) + context.footnote = context.document.paragraphs[0].footnotes[0] + + +@when("I get the footnote text") +def when_get_footnote_text(context): + context.footnote_text = context.footnote.text + + +@then("the footnote text matches the content") +def then_text_matches_content(context): + assert context.footnote_text == "Footnote text" + + +@when('I set the footnote text to "{text}"') +def when_set_footnote_text(context, text: str): + context.footnote.text = text + + +@then('the footnote text matches "{text}"') +def then_footnote_text_matches(context, text: str): + assert context.footnote.text == text, f"Footnote text: '{context.footnote.text}'" + + +@when("I access the footnote paragraph") +def when_access_footnote_paragraph(context): + context.paragraph = context.footnote.paragraph + + +@then("I get a footnote paragraph object") +def then_get_footnote_paragraph_object(context): + assert isinstance(context.paragraph, Paragraph) + + +# ----- Add footnote ----- +@when('I add a footnote with text "{text}"') +def when_add_footnote_with_text(context, text: str): + context.footnote = context.paragraph.add_footnote(text) + + +@then("the paragraph has a footnote") +def then_paragraph_has_footnote(context): + assert len(context.paragraph.footnotes) > 0 diff --git a/features/steps/test_files/having-comments.docx b/features/steps/test_files/having-comments.docx new file mode 100644 index 0000000000000000000000000000000000000000..37f730fa6e8e4c8b608c468ee05792a167f74001 GIT binary patch literal 6092 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 literal 0 HcmV?d00001 diff --git a/features/steps/test_files/having-footnotes.docx b/features/steps/test_files/having-footnotes.docx new file mode 100644 index 0000000000000000000000000000000000000000..c9b3f0f8996402a06e467853aeea714b0b3e4a1a GIT binary patch literal 6253 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 Type[Part] | None: @@ -40,6 +42,7 @@ def part_class_selector(content_type: str, reltype: str) -> Type[Part] | None: PartFactory.part_class_selector = part_class_selector +PartFactory.part_type_for[CT.WML_COMMENTS] = CommentsPart 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 @@ -47,6 +50,7 @@ def part_class_selector(content_type: str, reltype: str) -> Type[Part] | None: PartFactory.part_type_for[CT.WML_NUMBERING] = NumberingPart PartFactory.part_type_for[CT.WML_SETTINGS] = SettingsPart PartFactory.part_type_for[CT.WML_STYLES] = StylesPart +PartFactory.part_type_for[CT.WML_FOOTNOTES] = FootnotesPart del ( CT, @@ -54,6 +58,8 @@ def part_class_selector(content_type: str, reltype: str) -> Type[Part] | None: DocumentPart, FooterPart, HeaderPart, + FootnotesPart, + CommentsPart, NumberingPart, PartFactory, SettingsPart, diff --git a/src/docx/api.py b/src/docx/api.py index aea876458..c72f5c0c1 100644 --- a/src/docx/api.py +++ b/src/docx/api.py @@ -6,14 +6,18 @@ from __future__ import annotations import os -from typing import IO, TYPE_CHECKING, cast +from typing import IO, TYPE_CHECKING, Any, Optional, Union, cast from docx.opc.constants import CONTENT_TYPE as CT from docx.package import Package if TYPE_CHECKING: + import docx.types as t from docx.document import Document as DocumentObject from docx.parts.document import DocumentPart + from docx.section import Section + from docx.table import Table + from docx.text.paragraph import Paragraph def Document(docx: str | IO[bytes] | None = None) -> DocumentObject: @@ -35,3 +39,33 @@ def _default_docx_path(): """Return the path to the built-in default .docx package.""" _thisdir = os.path.split(__file__)[0] return os.path.join(_thisdir, "templates", "default.docx") + + +def element(element: Any, part: t.ProvidesStoryPart) -> Optional[Union[Paragraph, Table, Section]]: + if ( + isinstance(element, type) + and element.__module__ == "docx.oxml.text.paragraph" + and element.__name__ == "CT_P" + ): + from .text.paragraph import Paragraph + + return Paragraph(element, part) + elif ( + isinstance(element, type) + and element.__module__ == "docx.oxml.table" + and element.__name__ == "CT_Tbl" + ): + from .table import Table + + return Table(element, part) + elif ( + isinstance(element, type) + and element.__module__ == "docx.oxml.section" + and element.__name__ == "CT_SectPr" + ): + from .section import Section + + if not isinstance(part, DocumentPart): + raise TypeError("part must be a DocumentPart for Section elements") + + return Section(element, part) diff --git a/src/docx/blkcntnr.py b/src/docx/blkcntnr.py index a9969f6f6..c1f25dc85 100644 --- a/src/docx/blkcntnr.py +++ b/src/docx/blkcntnr.py @@ -8,10 +8,12 @@ from __future__ import annotations -from typing import TYPE_CHECKING, Iterator +from typing import TYPE_CHECKING, Iterator, List, Optional from typing_extensions import TypeAlias +from docx.api import element +from docx.oxml.ns import qn from docx.oxml.table import CT_Tbl from docx.oxml.text.paragraph import CT_P from docx.shared import StoryChild @@ -20,8 +22,10 @@ if TYPE_CHECKING: import docx.types as t from docx.oxml.document import CT_Body + from docx.oxml.numbering import CT_AbstractNum from docx.oxml.section import CT_HdrFtr from docx.oxml.table import CT_Tc + from docx.section import Section from docx.shared import Length from docx.styles.style import ParagraphStyle from docx.table import Table @@ -95,6 +99,17 @@ def tables(self): return [Table(tbl, self) for tbl in self._element.tbl_lst] + @property + def elements(self) -> Optional[List[Paragraph | Table | Section]]: + """ + A list containing the elements in this container (paragraph and tables), in document order. + """ + return [element(item, self.part) for item in self._element.getchildren()] + + @property + def abstractNumIds(self) -> List[CT_AbstractNum]: + return list(self.part.numbering_part.element.iterchildren(qn("w:abstractNum"))) + def _add_paragraph(self): """Return paragraph newly added to the end of the content in this container.""" return Paragraph(self._element.add_p(), self) diff --git a/src/docx/document.py b/src/docx/document.py index 8944a0e50..272535639 100644 --- a/src/docx/document.py +++ b/src/docx/document.py @@ -5,18 +5,22 @@ from __future__ import annotations -from typing import IO, TYPE_CHECKING, Iterator, List +from typing import IO, TYPE_CHECKING, Iterator, List, Optional from docx.blkcntnr import BlockItemContainer from docx.enum.section import WD_SECTION from docx.enum.text import WD_BREAK +from docx.oxml.ns import qn from docx.section import Section, Sections from docx.shared import ElementProxy, Emu if TYPE_CHECKING: import docx.types as t from docx.oxml.document import CT_Body, CT_Document + from docx.oxml.numbering import CT_AbstractNum + from docx.parts.comments import CommentsPart from docx.parts.document import DocumentPart + from docx.parts.footnotes import FootnotesPart from docx.settings import Settings from docx.shared import Length from docx.styles.style import ParagraphStyle, _TableStyle @@ -112,6 +116,22 @@ def core_properties(self): """A |CoreProperties| object providing Dublin Core properties of document.""" return self._part.core_properties + @property + def comments_part(self) -> CommentsPart: + """ + A |Comments| object providing read/write access to the core + properties of this document. + """ + return self.part.comments_part + + @property + def footnotes_part(self) -> FootnotesPart: + """ + A |Footnotes| object providing read/write access to the core + properties of this document. + """ + return self.part._footnotes_part + @property def inline_shapes(self): """The |InlineShapes| collection for this document. @@ -174,6 +194,23 @@ def tables(self) -> List[Table]: """ return self._body.tables + @property + def elements(self) -> Optional[List[Paragraph | Table | Section]]: + return self._body.elements + + @property + def abstractNumIds(self) -> List[CT_AbstractNum]: + """ + Returns list of all the 'w:abstractNumId' of this document + """ + return self._body.abstractNumIds + + @property + def last_abs_num(self): + last = self.abstractNumIds[-1] + val = last.attrib.get(qn("w:abstractNumId")) + return last, val + @property def _block_width(self) -> Length: """A |Length| object specifying the space between margins in last section.""" diff --git a/src/docx/opc/package.py b/src/docx/opc/package.py index 3b1eef256..67042f213 100644 --- a/src/docx/opc/package.py +++ b/src/docx/opc/package.py @@ -10,6 +10,8 @@ from docx.opc.parts.coreprops import CorePropertiesPart from docx.opc.pkgreader import PackageReader from docx.opc.pkgwriter import PackageWriter +from docx.parts.comments import CommentsPart +from docx.parts.footnotes import FootnotesPart from docx.opc.rel import Relationships from docx.shared import lazyproperty @@ -44,6 +46,32 @@ def core_properties(self) -> CoreProperties: properties for this document.""" return self._core_properties_part.core_properties + @property + def _comments_part(self) -> CommentsPart: + """ + |CommentsPart| object related to this package. Creates + a default Comments part if one is not present. + """ + try: + return self.part_related_by(RT.COMMENTS) + except KeyError: + comments_part = CommentsPart.default(self) + self.relate_to(comments_part, RT.COMMENTS) + return comments_part + + @property + def _footnotes_part(self) -> FootnotesPart: + """ + |FootnotesPart| object related to this package. Creates + a default Comments part if one is not present. + """ + try: + return self.part_related_by(RT.FOOTNOTES) + except KeyError: + footnotes_part = FootnotesPart.default(self) + self.relate_to(footnotes_part, RT.FOOTNOTES) + return footnotes_part + def iter_rels(self) -> Iterator[_Relationship]: """Generate exactly one reference to each relationship in the package by performing a depth-first traversal of the rels graph.""" diff --git a/src/docx/oxml/__init__.py b/src/docx/oxml/__init__.py index bf32932f9..229e2be53 100644 --- a/src/docx/oxml/__init__.py +++ b/src/docx/oxml/__init__.py @@ -67,6 +67,8 @@ # --------------------------------------------------------------------------- # text-related elements +from docx.oxml.text.run import CT_RPr + register_element_cls("w:br", CT_Br) register_element_cls("w:cr", CT_Cr) register_element_cls("w:lastRenderedPageBreak", CT_LastRenderedPageBreak) @@ -74,6 +76,7 @@ register_element_cls("w:ptab", CT_PTab) register_element_cls("w:r", CT_R) register_element_cls("w:t", CT_Text) +register_element_cls("w:rPr", CT_RPr) # --------------------------------------------------------------------------- # header/footer-related mappings @@ -93,12 +96,13 @@ register_element_cls("w:body", CT_Body) register_element_cls("w:document", CT_Document) -from .numbering import CT_Num, CT_Numbering, CT_NumLvl, CT_NumPr # noqa +from .numbering import CT_Num, CT_AbstractNum, CT_Numbering, CT_NumLvl, CT_NumPr # noqa register_element_cls("w:abstractNumId", CT_DecimalNumber) register_element_cls("w:ilvl", CT_DecimalNumber) register_element_cls("w:lvlOverride", CT_NumLvl) register_element_cls("w:num", CT_Num) +register_element_cls("w:abstractNum", CT_AbstractNum) register_element_cls("w:numId", CT_DecimalNumber) register_element_cls("w:numPr", CT_NumPr) register_element_cls("w:numbering", CT_Numbering) @@ -126,9 +130,20 @@ register_element_cls("w:settings", CT_Settings) -from .styles import CT_LatentStyles, CT_LsdException, CT_Style, CT_Styles # noqa +from .styles import ( + CT_DocDefaults, + CT_LatentStyles, + CT_LsdException, + CT_PPrDefault, + CT_RPrDefault, + CT_Style, + CT_Styles, +) # noqa register_element_cls("w:basedOn", CT_String) +register_element_cls("w:docDefaults", CT_DocDefaults) +register_element_cls("w:rPrDefault", CT_RPrDefault) +register_element_cls("w:pPrDefault", CT_PPrDefault) register_element_cls("w:latentStyles", CT_LatentStyles) register_element_cls("w:locked", CT_OnOff) register_element_cls("w:lsdException", CT_LsdException) @@ -155,7 +170,11 @@ CT_TcPr, CT_TrPr, CT_VMerge, + CT_TblMar, CT_VerticalJc, + CT_TblBoarders, + CT_Bottom, + CT_TcBorders, ) register_element_cls("w:bidiVisual", CT_OnOff) @@ -167,6 +186,8 @@ register_element_cls("w:tblGrid", CT_TblGrid) register_element_cls("w:tblLayout", CT_TblLayoutType) register_element_cls("w:tblPr", CT_TblPr) +register_element_cls("w:tblW", CT_TblWidth) +register_element_cls("w:tblCellMar", CT_TblMar) register_element_cls("w:tblPrEx", CT_TblPrEx) register_element_cls("w:tblStyle", CT_String) register_element_cls("w:tc", CT_Tc) @@ -177,6 +198,9 @@ register_element_cls("w:trPr", CT_TrPr) register_element_cls("w:vAlign", CT_VerticalJc) register_element_cls("w:vMerge", CT_VMerge) +register_element_cls("w:tblBorders", CT_TblBoarders) +register_element_cls("w:tcBorders", CT_TcBorders) +register_element_cls("w:bottom", CT_Bottom) from .text.font import ( # noqa CT_Color, @@ -241,3 +265,22 @@ register_element_cls("w:tab", CT_TabStop) register_element_cls("w:tabs", CT_TabStops) register_element_cls("w:widowControl", CT_OnOff) + +# --------------------------------------------------------------------------- +# comments and footnotes + +from .comments import CT_CRE, CT_CRS, CT_Com, CT_Comments, CT_CRef + +register_element_cls("w:comments", CT_Comments) +register_element_cls("w:comment", CT_Com) +register_element_cls("w:commentRangeStart", CT_CRS) +register_element_cls("w:commentRangeEnd", CT_CRE) +register_element_cls("w:commentReference", CT_CRef) + + +from .footnotes import CT_FNR, CT_Footnote, CT_FootnoteRef, CT_Footnotes + +register_element_cls("w:footnotes", CT_Footnotes) +register_element_cls("w:footnote", CT_Footnote) +register_element_cls("w:footnoteReference", CT_FNR) +register_element_cls("w:footnoteRef", CT_FootnoteRef) diff --git a/src/docx/oxml/comments.py b/src/docx/oxml/comments.py new file mode 100644 index 000000000..9a5748122 --- /dev/null +++ b/src/docx/oxml/comments.py @@ -0,0 +1,124 @@ +""" +Custom element classes related to the comments part +""" + +from typing import TYPE_CHECKING, List, Optional + +from ..opc.constants import NAMESPACE +from ..text.paragraph import Paragraph +from ..text.run import Run +from . import OxmlElement +from .simpletypes import ST_DecimalNumber, ST_String +from .xmlchemy import BaseOxmlElement, RequiredAttribute, ZeroOrMore, ZeroOrOne + +if TYPE_CHECKING: + from docx.oxml.text.paragraph import CT_P + + +class CT_Com(BaseOxmlElement): + """ + A ```` element, a container for Comment properties + """ + + initials: str = RequiredAttribute("w:initials", ST_String) # pyright: ignore[reportAssignmentType] + _id: int = RequiredAttribute("w:id", ST_DecimalNumber) # pyright: ignore[reportAssignmentType] + date: str = RequiredAttribute("w:date", ST_String) # pyright: ignore[reportAssignmentType] + author: str = RequiredAttribute("w:author", ST_String) # pyright: ignore[reportAssignmentType] + + p = ZeroOrOne("w:p", successors=("w:comment",)) + + @classmethod + def new(cls, initials: str, comm_id: int, date: str, author: str) -> "CT_Com": + """ + Return a new ```` element having _id of *comm_id* and having + the passed params as meta data + """ + comment = OxmlElement("w:comment") + comment.initials = initials + comment.date = date + comment._id = comm_id + comment.author = author + return comment + + def _add_p(self, text: str) -> "CT_P": + _p = OxmlElement("w:p") + _r = _p.add_r() + run = Run(_r, self) + run.text = text + self._insert_p(_p) + return _p + + @property + def meta(self) -> List[str]: + return [self.author, self.initials, self.date] + + @property + def paragraph(self) -> Paragraph: + return Paragraph(self.p, self) + + +class CT_Comments(BaseOxmlElement): + """ + A ```` element, a container for Comments properties + """ + + comment = ZeroOrMore("w:comment", successors=("w:comments",)) + + def add_comment(self, author: str, initials: str, date: str) -> CT_Com: + _next_id = self._next_commentId + comment: CT_Com = CT_Com.new(initials, _next_id, date, author) + self.append(comment) + print(self.xml) + return comment + + @property + def _next_commentId(self) -> int: + ids = self.xpath("./w:comment/@w:id") + _ids = [int(_str) for _str in ids] + _ids.sort() + if len(_ids) == 0: + return 0 + return _ids[-1] + 1 + + +class CT_CRS(BaseOxmlElement): + """ + A ```` element + """ + + _id = RequiredAttribute("w:id", ST_DecimalNumber) + + @classmethod + def new(cls, _id: int) -> "CT_CRS": + commentRangeStart = OxmlElement("w:commentRangeStart") + commentRangeStart._id = _id + + return commentRangeStart + + +class CT_CRE(BaseOxmlElement): + """ + A ``w:commentRangeEnd`` element + """ + + _id = RequiredAttribute("w:id", ST_DecimalNumber) + + @classmethod + def new(cls, _id: int) -> "CT_CRE": + commentRangeEnd = OxmlElement("w:commentRangeEnd") + commentRangeEnd._id = _id + return commentRangeEnd + + +class CT_CRef(BaseOxmlElement): + """ + w:commentReference + """ + + _id = RequiredAttribute("w:id", ST_DecimalNumber) + + @classmethod + def new(cls, _id: int) -> "CT_CRef": + commentReference = OxmlElement("w:commentReference") + commentReference._id = _id + return commentReference diff --git a/src/docx/oxml/footnotes.py b/src/docx/oxml/footnotes.py new file mode 100644 index 000000000..c98aae179 --- /dev/null +++ b/src/docx/oxml/footnotes.py @@ -0,0 +1,94 @@ +""" +Custom element classes related to the footnotes part +""" + +from typing import TYPE_CHECKING, Optional + +from ..opc.constants import NAMESPACE +from ..text.paragraph import Paragraph +from ..text.run import Run +from . import OxmlElement +from .simpletypes import ST_DecimalNumber +from .xmlchemy import BaseOxmlElement, RequiredAttribute, ZeroOrMore, ZeroOrOne + +if TYPE_CHECKING: + from docx.oxml.text.paragraph import CT_P + + +class CT_Footnotes(BaseOxmlElement): + """ + A ```` element, a container for Footnotes properties + """ + + footnote = ZeroOrMore("w:footnote", successors=("w:footnotes",)) + + @property + def _next_id(self) -> int: + ids = self.xpath("./w:footnote/@w:id") + + return int(ids[-1]) + 1 + + def add_footnote(self) -> "CT_Footnote": + _next_id = self._next_id + footnote = CT_Footnote.new(_next_id) + footnote = self._insert_footnote(footnote) + return footnote + + def get_footnote_by_id(self, _id: int) -> Optional["CT_Footnote"]: + namesapce = NAMESPACE().WML_MAIN + for fn in self.findall(".//w:footnote", {"w": namesapce}): + if fn._id == _id: + return fn + return None + + +class CT_Footnote(BaseOxmlElement): + """ + A ```` element, a container for Footnote properties + """ + + _id = RequiredAttribute("w:id", ST_DecimalNumber) + p = ZeroOrOne("w:p", successors=("w:footnote",)) + + @classmethod + def new(cls, _id: int) -> "CT_Footnote": + footnote = OxmlElement("w:footnote") + footnote._id = _id + + return footnote + + def _add_p(self, text: str) -> "CT_P": + _p = OxmlElement("w:p") + _p.footnote_style() + + _r = _p.add_r() + _r.footnote_style() + _r = _p.add_r() + _r.add_footnoteRef() + + run = Run(_r, self) + run.text = text + + self._insert_p(_p) + return _p + + @property + def paragraph(self): + return Paragraph(self.p, self) + + +class CT_FNR(BaseOxmlElement): + _id = RequiredAttribute("w:id", ST_DecimalNumber) + + @classmethod + def new(cls, _id): + footnoteReference = OxmlElement("w:footnoteReference") + footnoteReference._id = _id + return footnoteReference + + +class CT_FootnoteRef(BaseOxmlElement): + @classmethod + def new(cls): + ref = OxmlElement("w:footnoteRef") + return ref diff --git a/src/docx/oxml/numbering.py b/src/docx/oxml/numbering.py index 3512de655..aa15039b0 100644 --- a/src/docx/oxml/numbering.py +++ b/src/docx/oxml/numbering.py @@ -37,6 +37,14 @@ def new(cls, num_id, abstractNum_id): return num +class CT_AbstractNum(BaseOxmlElement): + """ + ```` element, which represents an abstract numbering definition that defines most of the formatting details. + """ + + abstractNumId = RequiredAttribute("w:abstractNumId", ST_DecimalNumber) + + class CT_NumLvl(BaseOxmlElement): """```` element, which identifies a level in a list definition to override with settings it contains.""" @@ -79,6 +87,7 @@ class CT_Numbering(BaseOxmlElement): """```` element, the root element of a numbering part, i.e. numbering.xml.""" + abstractNum = ZeroOrMore("w:abstractNum", successors=("w:num",)) num = ZeroOrMore("w:num", successors=("w:numIdMacAtCleanup",)) def add_num(self, abstractNum_id): diff --git a/src/docx/oxml/styles.py b/src/docx/oxml/styles.py index fb0e5d0dd..94adf161a 100644 --- a/src/docx/oxml/styles.py +++ b/src/docx/oxml/styles.py @@ -30,6 +30,20 @@ def styleId_from_name(name): }.get(name, name.replace(" ", "")) +class CT_DocDefaults(BaseOxmlElement): + _tag_seq = ("w:rPrDefault", "w:pPrDefault") + rPrDefault = ZeroOrOne("w:rPrDefault", successors=(_tag_seq[1:])) + pPrDefault = ZeroOrOne("w:pPrDefault", successors=()) + + +class CT_RPrDefault(BaseOxmlElement): + rPr = ZeroOrOne("w:rPr", successors=()) + + +class CT_PPrDefault(BaseOxmlElement): + pPr = ZeroOrOne("w:pPr", successors=()) + + class CT_LatentStyles(BaseOxmlElement): """`w:latentStyles` element, defining behavior defaults for latent styles and containing `w:lsdException` child elements that each override those defaults for a @@ -273,6 +287,7 @@ class CT_Styles(BaseOxmlElement): """```` element, the root element of a styles part, i.e. styles.xml.""" _tag_seq = ("w:docDefaults", "w:latentStyles", "w:style") + docDefaults = ZeroOrOne("w:docDefaults", successors=_tag_seq[1:]) latentStyles = ZeroOrOne("w:latentStyles", successors=_tag_seq[2:]) style = ZeroOrMore("w:style", successors=()) del _tag_seq diff --git a/src/docx/oxml/table.py b/src/docx/oxml/table.py index e38d58562..b5a838544 100644 --- a/src/docx/oxml/table.py +++ b/src/docx/oxml/table.py @@ -6,7 +6,8 @@ from docx.enum.table import WD_CELL_VERTICAL_ALIGNMENT, WD_ROW_HEIGHT_RULE, WD_TABLE_DIRECTION from docx.exceptions import InvalidSpanError -from docx.oxml.ns import nsdecls, qn +from docx.oxml import OxmlElement +from docx.oxml.ns import nsdecls, qn, nsmap from docx.oxml.parser import parse_xml from docx.oxml.shared import CT_DecimalNumber from docx.oxml.simpletypes import ( @@ -15,6 +16,7 @@ ST_TblWidth, ST_TwipsMeasure, XsdInt, + ST_String, ) from docx.oxml.text.paragraph import CT_P from docx.oxml.xmlchemy import ( @@ -255,6 +257,21 @@ def _tcs_xml(cls, col_count: int, col_width: Length) -> str: f" \n" ) * col_count + @property + def _section(self): + body = self.getparent() + sections = body.findall(".//w:sectPr", {"w": nsmap["w"]}) + if len(sections) == 1: + return sections[0] + else: + tbl_index = body.index(self) + for i, sect in enumerate(sections): + if i == len(sections) - 1: + return sect + else: + if body.index(sect.getparent().getparent()) > tbl_index: + return sect + class CT_TblGrid(BaseOxmlElement): """`w:tblGrid` element. @@ -294,6 +311,10 @@ class CT_TblLayoutType(BaseOxmlElement): ) +class CT_TblBoarders(BaseOxmlElement): + pass + + class CT_TblPr(BaseOxmlElement): """```` element, child of ````, holds child elements that define table properties such as style and borders.""" @@ -332,12 +353,15 @@ class CT_TblPr(BaseOxmlElement): bidiVisual: CT_OnOff | None = ZeroOrOne( # pyright: ignore[reportAssignmentType] "w:bidiVisual", successors=_tag_seq[4:] ) + tblW = ZeroOrOne("w:tblW", successors=("w:tblPr",)) + tblCellMar = ZeroOrOne("w:tblCellMar", successors=("w:tblPr",)) jc: CT_Jc | None = ZeroOrOne( # pyright: ignore[reportAssignmentType] "w:jc", successors=_tag_seq[8:] ) tblLayout: CT_TblLayoutType | None = ZeroOrOne( # pyright: ignore[reportAssignmentType] "w:tblLayout", successors=_tag_seq[13:] ) + tblBorders = ZeroOrOne("w:tblBorders", successors=("w:tblPr",)) del _tag_seq @property @@ -583,7 +607,9 @@ def vMerge_val(top_tc: CT_Tc): return ( ST_Merge.CONTINUE if top_tc is not self - else None if height == 1 else ST_Merge.RESTART + else None + if height == 1 + else ST_Merge.RESTART ) top_tc = self if top_tc is None else top_tc @@ -781,6 +807,27 @@ def _tr_idx(self) -> int: return self._tbl.tr_lst.index(self._tr) +class CT_TcBorders(BaseOxmlElement): + """ + element + """ + + top = ZeroOrOne("w:top") + start = ZeroOrOne("w:start") + bottom = ZeroOrOne("w:bottom", successors=("w:tblPr",)) + end = ZeroOrOne("w:end") + + def new(cls): + """ + Return a new ```` element + """ + return parse_xml("\n" "" % nsdecls("w")) + + def add_bottom_border(self, val, sz): + bottom = CT_Bottom.new(val, sz) + return self._insert_bottom(bottom) + + class CT_TcPr(BaseOxmlElement): """```` element, defining table cell properties.""" @@ -818,6 +865,7 @@ class CT_TcPr(BaseOxmlElement): gridSpan: CT_DecimalNumber | None = ZeroOrOne( # pyright: ignore[reportAssignmentType] "w:gridSpan", successors=_tag_seq[3:] ) + tcBorders = ZeroOrOne("w:tcBorders", successors=("w:tcPr",)) vMerge: CT_VMerge | None = ZeroOrOne( # pyright: ignore[reportAssignmentType] "w:vMerge", successors=_tag_seq[5:] ) @@ -975,3 +1023,33 @@ class CT_VMerge(BaseOxmlElement): val: str | None = OptionalAttribute( # pyright: ignore[reportAssignmentType] "w:val", ST_Merge, default=ST_Merge.CONTINUE ) + + +class CT_TblMar(BaseOxmlElement): + """ + ```` element + """ + + left = ZeroOrOne("w:left", successors=("w:tblCellMar",)) + right = ZeroOrOne("w:write", successors=("w:tblCellMar",)) + + +class CT_Bottom(BaseOxmlElement): + """ + element + """ + + val = OptionalAttribute("w:val", ST_String) + sz = OptionalAttribute("w:sz", ST_String) + space = OptionalAttribute("w:space", ST_String) + color = OptionalAttribute("w:color", ST_String) + + @classmethod + def new(cls, val, sz): + bottom = OxmlElement("w:bottom") + bottom.val = val + bottom.sz = sz + bottom.space = "0" + bottom.color = "auto" + + return bottom diff --git a/src/docx/oxml/text/font.py b/src/docx/oxml/text/font.py index 140086aab..d80e14b1b 100644 --- a/src/docx/oxml/text/font.py +++ b/src/docx/oxml/text/font.py @@ -45,6 +45,12 @@ class CT_Fonts(BaseOxmlElement): hAnsi: str | None = OptionalAttribute( # pyright: ignore[reportAssignmentType] "w:hAnsi", ST_String ) + asciiTheme: str | None = OptionalAttribute( # pyright: ignore[reportAssignmentType] + "w:asciiTheme", ST_String + ) + hAnsiTheme: str | None = OptionalAttribute( # pyright: ignore[reportAssignmentType] + "w:hAnsiTheme", ST_String + ) class CT_Highlight(BaseOxmlElement): @@ -222,6 +228,44 @@ def rFonts_hAnsi(self, value: str | None): rFonts = self.get_or_add_rFonts() rFonts.hAnsi = value + @property + def rFonts_asciiTheme(self): + """ + The value of `w:rFonts/@w:asciiTheme` or |None| if not present. Represents + the assigned typeface Theme. The rFonts element also specifies other + special-case typeface Theme; this method handles the case where just + the common Theme is required. + """ + rFonts = self.rFonts + if rFonts is None: + return None + return rFonts.asciiTheme + + @rFonts_asciiTheme.setter + def rFonts_asciiTheme(self, value: str | None): + if value is None: + self._remove_rFonts() + return + rFonts = self.get_or_add_rFonts() + rFonts.asciiTheme = value + + @property + def rFonts_hAnsiTheme(self): + """ + The value of `w:rFonts/@w:hAnsiTheme` or |None| if not present. + """ + rFonts = self.rFonts + if rFonts is None: + return None + return rFonts.hAnsiTheme + + @rFonts_hAnsiTheme.setter + def rFonts_hAnsiTheme(self, value: str | None): + if value is None and self.rFonts is None: + return + rFonts = self.get_or_add_rFonts() + rFonts.hAnsiTheme = value + @property def style(self) -> str | None: """String in `./w:rStyle/@val`, or None if `w:rStyle` is not present.""" diff --git a/src/docx/oxml/text/paragraph.py b/src/docx/oxml/text/paragraph.py index 63e96f312..d1b8c7ccc 100644 --- a/src/docx/oxml/text/paragraph.py +++ b/src/docx/oxml/text/paragraph.py @@ -11,6 +11,8 @@ if TYPE_CHECKING: from docx.enum.text import WD_PARAGRAPH_ALIGNMENT + from docx.oxml.comments import CT_Com, CT_Comments + from docx.oxml.footnotes import CT_Footnotes from docx.oxml.section import CT_SectPr from docx.oxml.text.hyperlink import CT_Hyperlink from docx.oxml.text.pagebreak import CT_LastRenderedPageBreak @@ -36,6 +38,54 @@ def add_p_before(self) -> CT_P: self.addprevious(new_p) return new_p + def link_comment(self, _id: int, rangeStart: int = 0, rangeEnd: int = 0): + rStart = OxmlElement("w:commentRangeStart") + rStart._id = _id + rEnd = OxmlElement("w:commentRangeEnd") + rEnd._id = _id + if rangeStart == 0 and rangeEnd == 0: + self.insert(0, rStart) + self.append(rEnd) + else: + self.insert(rangeStart, rStart) + if rangeEnd == len(self.getchildren()) - 1: + self.append(rEnd) + else: + self.insert(rangeEnd + 1, rEnd) + + def add_comm( + self, + author: str, + initials: str, + dtime: str, + comment_text: str, + rangeStart: int, + rangeEnd: int, + comment_part_comments: CT_Comments, + ) -> CT_Com: + comment: CT_Com = comment_part_comments.add_comment(author, initials, dtime) + comment._add_p(comment_text) + _r: CT_R = self.add_r() + _r.add_comment_reference(comment._id) + self.link_comment(comment._id, rangeStart=rangeStart, rangeEnd=rangeEnd) + + return comment + + def add_fn(self, text: str, footnotes: CT_Footnotes): + footnote = footnotes.add_footnote() + footnote._add_p(text) + _r = self.add_r() + _r.add_footnote_reference(footnote._id) + + return footnote + + def footnote_style(self): + pPr = self.get_or_add_pPr() + rstyle = pPr.get_or_add_pStyle() + rstyle.val = "FootnoteText" + + return self + @property def alignment(self) -> WD_PARAGRAPH_ALIGNMENT | None: """The value of the `` grandchild element or |None| if not present.""" @@ -87,13 +137,29 @@ def style(self) -> str | None: return None return pPr.style + @property + def comment_id(self) -> int | None: + _id = self.xpath("./w:commentRangeStart/@w:id") + if len(_id) > 1 or len(_id) == 0: + return None + else: + return int(_id[0]) + + @property + def footnote_ids(self) -> list[int] | None: + _id = self.xpath("./w:r/w:footnoteReference/@w:id") + if len(_id) == 0: + return None + else: + return [int(_id) for _id in _id] + @style.setter def style(self, style: str | None): pPr = self.get_or_add_pPr() pPr.style = style @property - def text(self): # pyright: ignore[reportIncompatibleMethodOverride] + def text(self): """The textual content of this paragraph. Inner-content child elements like `w:r` and `w:hyperlink` are translated to diff --git a/src/docx/oxml/text/parfmt.py b/src/docx/oxml/text/parfmt.py index de5609636..6566f05c5 100644 --- a/src/docx/oxml/text/parfmt.py +++ b/src/docx/oxml/text/parfmt.py @@ -111,6 +111,7 @@ class CT_PPr(BaseOxmlElement): "w:ind", successors=_tag_seq[23:] ) jc = ZeroOrOne("w:jc", successors=_tag_seq[27:]) + rPr = ZeroOrOne("w:rPr", successors=_tag_seq[34:]) sectPr = ZeroOrOne("w:sectPr", successors=_tag_seq[35:]) del _tag_seq diff --git a/src/docx/oxml/text/run.py b/src/docx/oxml/text/run.py index 88efae83c..a12ba6abd 100644 --- a/src/docx/oxml/text/run.py +++ b/src/docx/oxml/text/run.py @@ -4,17 +4,26 @@ from typing import TYPE_CHECKING, Callable, Iterator, List +from docx.oxml import OxmlElement from docx.oxml.drawing import CT_Drawing from docx.oxml.ns import qn -from docx.oxml.simpletypes import ST_BrClear, ST_BrType -from docx.oxml.text.font import CT_RPr -from docx.oxml.xmlchemy import BaseOxmlElement, OptionalAttribute, ZeroOrMore, ZeroOrOne +from docx.oxml.simpletypes import ST_BrClear, ST_BrType, ST_String +from docx.oxml.text.pagebreak import CT_LastRenderedPageBreak +from docx.oxml.xmlchemy import ( + BaseOxmlElement, + OptionalAttribute, + RequiredAttribute, + ZeroOrMore, + ZeroOrOne, +) from docx.shared import TextAccumulator if TYPE_CHECKING: + from docx.oxml.comments import CT_Com, CT_Comments + from docx.oxml.footnotes import CT_FNR, CT_FootnoteRef from docx.oxml.shape import CT_Anchor, CT_Inline - from docx.oxml.text.pagebreak import CT_LastRenderedPageBreak from docx.oxml.text.parfmt import CT_TabStop + from docx.oxml.text.run import CT_RPr # ------------------------------------------------------------------------------------ # Run-level elements @@ -30,6 +39,8 @@ class CT_R(BaseOxmlElement): _add_t: Callable[..., CT_Text] rPr: CT_RPr | None = ZeroOrOne("w:rPr") # pyright: ignore[reportAssignmentType] + # wrong + ref = ZeroOrOne("w:commentRangeStart", successors=("w:r",)) br = ZeroOrMore("w:br") cr = ZeroOrMore("w:cr") drawing = ZeroOrMore("w:drawing") @@ -52,6 +63,66 @@ def add_drawing(self, inline_or_anchor: CT_Inline | CT_Anchor) -> CT_Drawing: drawing.append(inline_or_anchor) return drawing + def add_comment( + self, + author: str, + initials: str, + dtime: str, + comment_text: str, + comment_part_comments: CT_Comments, + ) -> CT_Com: + comment: CT_Com = comment_part_comments.add_comment(author, initials, dtime) + _p = comment._add_p(comment_text) + self.add_comment_reference(comment._id) + self.link_comment(comment._id) + + return comment + + def link_comment(self, _id: int): + rStart = OxmlElement("w:commentRangeStart") + rStart._id = _id + rEnd = OxmlElement("w:commentRangeEnd") + rEnd._id = _id + self.addprevious(rStart) + self.addnext(rEnd) + + def add_comment_reference(self, _id: int) -> BaseOxmlElement: + reference = OxmlElement("w:commentReference") + reference._id = _id + self.append(reference) + return reference + + def add_footnote_reference(self, _id: int) -> "CT_FNR": + rPr = self.get_or_add_rPr() + rstyle = rPr.get_or_add_rStyle() + rstyle.val = "FootnoteReference" + reference = OxmlElement("w:footnoteReference") + reference._id = _id + self.append(reference) + return reference + + def add_footnoteRef(self) -> "CT_FootnoteRef": + ref = OxmlElement("w:footnoteRef") + self.append(ref) + + return ref + + def footnote_style(self) -> "CT_R": + rPr = self.get_or_add_rPr() + rstyle = rPr.get_or_add_rStyle() + rstyle.val = "FootnoteReference" + + self.add_footnoteRef() + return self + + @property + def footnote_id(self) -> int | None: + _id = self.xpath("./w:footnoteReference/@w:id") + if len(_id) > 1 or len(_id) == 0: + return None + else: + return int(_id[0]) + def clear_content(self) -> None: """Remove all child elements except a `w:rPr` element if present.""" # -- remove all run inner-content except a `w:rPr` when present. -- @@ -92,6 +163,12 @@ def lastRenderedPageBreaks(self) -> List[CT_LastRenderedPageBreak]: """All `w:lastRenderedPageBreaks` descendants of this run.""" return self.xpath("./w:lastRenderedPageBreak") + def add_comment_reference(self, _id: int) -> BaseOxmlElement: + reference = OxmlElement("w:commentReference") + reference._id = _id + self.append(reference) + return reference + @property def style(self) -> str | None: """String contained in `w:val` attribute of `w:rStyle` grandchild. @@ -119,12 +196,13 @@ def text(self) -> str: Inner-content child elements like `w:tab` are translated to their text equivalent. """ + # TODO: insert '-' for qn('w:noBreakHyphen')? return "".join( str(e) for e in self.xpath("w:br | w:cr | w:noBreakHyphen | w:ptab | w:t | w:tab") ) @text.setter - def text(self, text: str): # pyright: ignore[reportIncompatibleMethodOverride] + def text(self, text: str): self.clear_content() _RunContentAppender.append_to_run_from_text(self, text) @@ -132,6 +210,41 @@ def _insert_rPr(self, rPr: CT_RPr) -> CT_RPr: self.insert(0, rPr) return rPr + def add_fldChar( + self, fldCharType: str, fldLock: bool = False, dirty: bool = False + ) -> BaseOxmlElement | None: + if fldCharType not in ("begin", "end", "separate"): + return None + + fld_char = OxmlElement("w:fldChar") + fld_char.set(qn("w:fldCharType"), fldCharType) + if fldLock: + fld_char.set(qn("w:fldLock"), "true") + elif dirty: + fld_char.set(qn("w:fldLock"), "true") + self.append(fld_char) + return fld_char + + @property + def instr_text(self) -> BaseOxmlElement | None: + for child in list(self): + if child.tag.endswith("instrText"): + return child + return None + + @instr_text.setter + def instr_text(self, instr_text_val: str): + if self.instr_text is not None: + self._remove_instr_text() + + instr_text = OxmlElement("w:instrText") + instr_text.text = instr_text_val + self.append(instr_text) + + def _remove_instr_text(self): + for child in self.iterchildren("{*}instrText"): + self.remove(child) + # ------------------------------------------------------------------------------------ # Run inner-content elements @@ -224,6 +337,14 @@ def __str__(self) -> str: return self.text or "" +class CT_RPr(BaseOxmlElement): + rStyle = ZeroOrOne("w:rStyle") + + +class CT_RStyle(BaseOxmlElement): + val = RequiredAttribute("w:val", ST_String) + + # ------------------------------------------------------------------------------------ # Utility diff --git a/src/docx/parts/comments.py b/src/docx/parts/comments.py new file mode 100644 index 000000000..867a21014 --- /dev/null +++ b/src/docx/parts/comments.py @@ -0,0 +1,36 @@ +from __future__ import absolute_import, division, print_function, unicode_literals + +import os +from typing import TYPE_CHECKING + +from docx.opc.constants import CONTENT_TYPE as CT +from docx.oxml import parse_xml + +from ..opc.packuri import PackURI +from ..opc.part import XmlPart + +if TYPE_CHECKING: + from docx.package import Package + from docx.oxml.comments import CT_Comments + + +class CommentsPart(XmlPart): + """Definition of Comments Part""" + + @classmethod + def default(cls, package: "Package") -> "CommentsPart": + partname = PackURI("/word/comments.xml") + content_type = CT.WML_COMMENTS + element = parse_xml(cls._default_comments_xml()) + return cls(partname, content_type, element, package) + + @classmethod + def _default_comments_xml(cls) -> bytes: + path = os.path.join(os.path.split(__file__)[0], "..", "templates", "default-comments.xml") + with open(path, "rb") as f: + xml_bytes = f.read() + return xml_bytes + + @property + def comments(self) -> "CT_Comments": + return self.element # type: ignore diff --git a/src/docx/parts/document.py b/src/docx/parts/document.py index 416bb1a27..20b391a23 100644 --- a/src/docx/parts/document.py +++ b/src/docx/parts/document.py @@ -14,6 +14,8 @@ from docx.parts.styles import StylesPart from docx.shape import InlineShapes from docx.shared import lazyproperty +from docx.parts.comments import CommentsPart +from docx.parts.footnotes import FootnotesPart if TYPE_CHECKING: from docx.opc.coreprops import CoreProperties @@ -147,3 +149,41 @@ def _styles_part(self) -> StylesPart: styles_part = StylesPart.default(package) self.relate_to(styles_part, RT.STYLES) return styles_part + + @property + def _comments_part(self) -> CommentsPart: + try: + return self.part_related_by(RT.COMMENTS) + except KeyError: + comments_part = CommentsPart.default(self) + self.relate_to(comments_part, RT.COMMENTS) + return comments_part + + @lazyproperty + def comments_part(self) -> CommentsPart: + """ + A |Comments| object providing read/write access to the core + properties of this document. + """ + return self.package._comments_part + + @property + def _footnotes_part(self) -> FootnotesPart: + """ + |FootnotesPart| object related to this package. Creates + a default Comments part if one is not present. + """ + try: + return self.part_related_by(RT.FOOTNOTES) + except KeyError: + footnotes_part = FootnotesPart.default(self) + self.relate_to(footnotes_part, RT.FOOTNOTES) + return footnotes_part + + @lazyproperty + def footnotes_part(self) -> FootnotesPart: + """ + A |FootnotesPart| object providing read/write access to the + footnotes in this document. + """ + return self.package._footnotes_part diff --git a/src/docx/parts/footnotes.py b/src/docx/parts/footnotes.py new file mode 100644 index 000000000..eb7e5734d --- /dev/null +++ b/src/docx/parts/footnotes.py @@ -0,0 +1,37 @@ +from __future__ import absolute_import, division, print_function, unicode_literals + +import os +from typing import TYPE_CHECKING + +from ..opc.constants import CONTENT_TYPE as CT +from ..opc.packuri import PackURI +from ..opc.part import XmlPart +from ..oxml import parse_xml + +if TYPE_CHECKING: + from docx.oxml.footnotes import CT_Footnotes + from docx.package import Package + + +class FootnotesPart(XmlPart): + """ + Definition of Footnotes Part + """ + + @classmethod + def default(cls, package: "Package") -> "FootnotesPart": + partname = PackURI("/word/footnotes.xml") + content_type = CT.WML_FOOTNOTES + element = parse_xml(cls._default_footnotes_xml()) + return cls(partname, content_type, element, package) + + @classmethod + def _default_footnotes_xml(cls) -> bytes: + 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 + + @property + def footnotes(self) -> "CT_Footnotes": + return self.element # type: ignore diff --git a/src/docx/table.py b/src/docx/table.py index 545c46884..400da580b 100644 --- a/src/docx/table.py +++ b/src/docx/table.py @@ -17,6 +17,7 @@ import docx.types as t from docx.enum.table import WD_ROW_HEIGHT_RULE, WD_TABLE_ALIGNMENT, WD_TABLE_DIRECTION from docx.oxml.table import CT_Row, CT_Tbl, CT_TblPr, CT_Tc + from docx.section import Section from docx.shared import Length from docx.styles.style import ( ParagraphStyle, @@ -54,6 +55,10 @@ def add_row(self): tc.width = gridCol.w return _Row(tr, self) + @property + def section(self) -> Section: + return Section(self._element._section, self.part) + @property def alignment(self) -> WD_TABLE_ALIGNMENT | None: """Read/write. diff --git a/src/docx/templates/default-comments.xml b/src/docx/templates/default-comments.xml new file mode 100644 index 000000000..4ceb12ea4 --- /dev/null +++ b/src/docx/templates/default-comments.xml @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/src/docx/templates/default-footnotes.xml b/src/docx/templates/default-footnotes.xml new file mode 100644 index 000000000..5dc12e66f --- /dev/null +++ b/src/docx/templates/default-footnotes.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/src/docx/text/comment.py b/src/docx/text/comment.py new file mode 100644 index 000000000..a2bf49b67 --- /dev/null +++ b/src/docx/text/comment.py @@ -0,0 +1,58 @@ +from typing import TYPE_CHECKING + +from ..shared import Parented + +if TYPE_CHECKING: + from docx.oxml.comments import CT_Comments + from docx.text.paragraph import Paragraph + + +class Comment(Parented): + """[summary] + :param Parented: [description] + :type Parented: [type] + """ + + def __init__(self, com: "CT_Comments", parent: Parented): + super(Comment, self).__init__(parent) + self._com = self._element = self.element = com + + @property + def id(self) -> int: + return self._com._id + + @property + def paragraph(self) -> "Paragraph": + return self.element.paragraph + + @property + def text(self) -> str: + return self.element.paragraph.text + + @text.setter + def text(self, text: str): + self.element.paragraph.text = text + + @property + def author(self) -> str: + return self.element.author + + @author.setter + def author(self, author: str): + self.element.author = author + + @property + def initials(self) -> str: + return self.element.initials + + @initials.setter + def initials(self, initials: str): + self.element.initials = initials + + @property + def date(self) -> str: + return self.element.date + + @date.setter + def date(self, date: str): + self.element.date = date diff --git a/src/docx/text/font.py b/src/docx/text/font.py index acd60795b..f2ade5165 100644 --- a/src/docx/text/font.py +++ b/src/docx/text/font.py @@ -199,6 +199,25 @@ def name(self, value: str | None) -> None: rPr.rFonts_ascii = value rPr.rFonts_hAnsi = value + @property + def theme(self) -> str | None: + """ + Get or set the typeface theme for this |Font| instance, causing the + text it controls to appear in the themed font, if a matching font is + found. |None| indicates the typeface is inherited from the style + hierarchy. + """ + rPr = self._element.rPr + if rPr is None: + return None + return rPr.rFonts_asciiTheme + + @theme.setter + def theme(self, value: str | None): + rPr = self._element.get_or_add_rPr() + rPr.rFonts_asciiTheme = value + rPr.rFonts_hAnsiTheme = value + @property def no_proof(self) -> bool | None: """Read/write tri-state value. @@ -398,11 +417,7 @@ def underline(self, value: bool | WD_UNDERLINE | None) -> None: # -- False == 0, which happen to match the mapping for WD_UNDERLINE.SINGLE # -- and .NONE respectively. val = ( - WD_UNDERLINE.SINGLE - if value is True - else WD_UNDERLINE.NONE - if value is False - else value + WD_UNDERLINE.SINGLE if value is True else WD_UNDERLINE.NONE if value is False else value ) rPr.u_val = val diff --git a/src/docx/text/footnote.py b/src/docx/text/footnote.py new file mode 100644 index 000000000..a70b3ea7b --- /dev/null +++ b/src/docx/text/footnote.py @@ -0,0 +1,38 @@ +from typing import TYPE_CHECKING + +from ..shared import Parented + +if TYPE_CHECKING: + from docx.oxml.footnotes import CT_Footnote + from docx.text.paragraph import Paragraph + + +class Footnote(Parented): + """A footnote object representing a footnote in a document. + + :param Parented: Parent class providing document hierarchy functionality + """ + + def __init__(self, footnote: "CT_Footnote", parent: Parented): + super(Footnote, self).__init__(parent) + self._footnote = self._element = self.element = footnote + + @property + def id(self) -> int: + """The ID of the footnote.""" + return self._footnote._id + + @property + def paragraph(self) -> "Paragraph": + """The paragraph containing this footnote's content.""" + return self.element.paragraph + + @property + def text(self) -> str: + """The text content of this footnote.""" + return self.element.paragraph.text + + @text.setter + def text(self, text: str): + """Set the text content of this footnote.""" + self.element.paragraph.text = text diff --git a/src/docx/text/paragraph.py b/src/docx/text/paragraph.py index 234ea66cb..30146c24f 100644 --- a/src/docx/text/paragraph.py +++ b/src/docx/text/paragraph.py @@ -2,12 +2,17 @@ from __future__ import annotations +import re +from datetime import datetime from typing import TYPE_CHECKING, Iterator, List, cast from docx.enum.style import WD_STYLE_TYPE +from docx.oxml.ns import qn from docx.oxml.text.run import CT_R from docx.shared import StoryChild from docx.styles.style import ParagraphStyle +from docx.text.comment import Comment +from docx.text.footnote import Footnote from docx.text.hyperlink import Hyperlink from docx.text.pagebreak import RenderedPageBreak from docx.text.parfmt import ParagraphFormat @@ -16,6 +21,8 @@ if TYPE_CHECKING: import docx.types as t from docx.enum.text import WD_PARAGRAPH_ALIGNMENT + from docx.oxml.comments import CT_Com, CT_Comments + from docx.oxml.footnotes import CT_Footnotes from docx.oxml.text.paragraph import CT_P from docx.styles.style import CharacterStyle @@ -43,6 +50,50 @@ def add_run(self, text: str | None = None, style: str | CharacterStyle | None = run.style = style return run + def delete(self): + """ + delete the content of the paragraph + """ + self._p.getparent().remove(self._p) + self._p = self._element = None + + def add_comment( + self, + text: str, + author: str = "python-docx", + initials: str = "pd", + dtime: str | None = None, + rangeStart: int = 0, + rangeEnd: int = 0, + comment_part_comments: CT_Comments | None = None, + ) -> Comment: + _comment_part_comments: CT_Comments = ( + comment_part_comments if comment_part_comments else self.part._comments_part.element # pyright: ignore[reportPrivateUsage] + ) + + if dtime is None: + dtime = str(datetime.now()).replace(" ", "T") + + comment: CT_Com = self._p.add_comm( + author, initials, dtime, text, rangeStart, rangeEnd, _comment_part_comments + ) + + return Comment(comment, self.part) + + def add_footnote(self, text: str) -> Footnote: + footnotes_part_footnotes: CT_Footnotes = self.part._footnotes_part.element # pyright: ignore[reportPrivateUsage] + footnote = self._p.add_fn(text, footnotes_part_footnotes) + return Footnote(footnote, self.part) + + def merge_paragraph(self, otherParagraph: Paragraph): + r_lst = otherParagraph.runs + self.append_runs(r_lst) + + def append_runs(self, runs: List[Run]): + self.add_run(" ") + for run in runs: + self._p.append(run._r) # pyright: ignore[reportPrivateUsage] + @property def alignment(self) -> WD_PARAGRAPH_ALIGNMENT | None: """A member of the :ref:`WdParagraphAlignment` enumeration specifying the @@ -52,10 +103,16 @@ def alignment(self) -> WD_PARAGRAPH_ALIGNMENT | None: value and will inherit its alignment value from its style hierarchy. Assigning |None| to this property removes any directly-applied alignment value. """ + if self._p is None: + raise ValueError("Paragraph is not initialized") + return self._p.alignment @alignment.setter def alignment(self, value: WD_PARAGRAPH_ALIGNMENT): + if self._p is None: + raise ValueError("Paragraph is not initialized") + self._p.alignment = value def clear(self): @@ -63,17 +120,26 @@ def clear(self): Paragraph-level formatting, such as style, is preserved. """ + if self._p is None: + raise ValueError("Paragraph is not initialized") + self._p.clear_content() return self @property def contains_page_break(self) -> bool: """`True` when one or more rendered page-breaks occur in this paragraph.""" + if self._p is None: + raise ValueError("Paragraph is not initialized") + return bool(self._p.lastRenderedPageBreaks) @property def hyperlinks(self) -> List[Hyperlink]: """A |Hyperlink| instance for each hyperlink in this paragraph.""" + if self._p is None: + raise ValueError("Paragraph is not initialized") + return [Hyperlink(hyperlink, self) for hyperlink in self._p.hyperlink_lst] def insert_paragraph_before( @@ -99,6 +165,9 @@ def iter_inner_content(self) -> Iterator[Run | Hyperlink]: precise position of the hyperlink within the paragraph text is important. Note that a hyperlink itself contains runs. """ + if self._p is None: + raise ValueError("Paragraph is not initialized") + for r_or_hlink in self._p.inner_content_elements: yield ( Run(r_or_hlink, self) @@ -110,6 +179,9 @@ def iter_inner_content(self) -> Iterator[Run | Hyperlink]: def paragraph_format(self): """The |ParagraphFormat| object providing access to the formatting properties for this paragraph, such as line spacing and indentation.""" + if self._element is None: + raise ValueError("Paragraph is not initialized") + return ParagraphFormat(self._element) @property @@ -127,6 +199,11 @@ def runs(self) -> List[Run]: paragraph.""" return [Run(r, self) for r in self._p.r_lst] + @property + def all_runs(self) -> List[Run]: + """Get all runs in the paragraph, including those nested within other elements.""" + return [Run(r, self) for r in self._p.xpath(".//w:r[not(ancestor::w:r)]")] + @property def style(self) -> ParagraphStyle | None: """Read/Write. @@ -162,12 +239,94 @@ def text(self) -> str: """ return self._p.text + @property + def header_level(self) -> int | None: + """ + input Paragraph Object + output Paragraph level in case of header or returns None + """ + if self.style is None or self.style.name is None: + return None + + headerPattern = re.compile(r".*Heading (\d+)$") + level = 0 + if headerPattern.match(self.style.name): + level = int(self.style.name.lower().split("heading")[-1].strip()) + return level + + @property + def NumId(self) -> int | None: + """ + returns NumId val in case of paragraph has numbering + else: return None + """ + try: + return self._p.pPr.numPr.numId.val + except: + return None + + @property + def list_lvl(self) -> int | None: + """ + returns ilvl val in case of paragraph has a numbering level + else: return None + """ + try: + return self._p.pPr.numPr.ilvl.val + except: + return None + + @property + def list_info(self) -> tuple[bool, int, int] | None: + """ + returns tuple (has numbering info, numId value, ilvl value) + """ + if self.NumId and self.list_lvl: + return True, self.NumId, self.list_lvl + else: + return False, 0, 0 + + @property + def is_heading(self) -> bool: + return bool(self.header_level) + + @property + def full_text(self) -> str: + return "".join([r.text for r in self.all_runs]) + + @property + def footnotes(self) -> List[Footnote]: + footnote_ids = self._p.footnote_ids + footnotes_part_footnotes: CT_Footnotes = self.part._footnotes_part.footnotes # pyright: ignore[reportPrivateUsage] + footnotes = [] + for footnote_id in footnote_ids: + footnote = footnotes_part_footnotes.find( + f"{qn('w:footnote')}[@{qn('w:id')}='{footnote_id}']" + ) + if footnote is not None: + footnotes.append(Footnote(footnote, self.part)) + + return footnotes + + @property + def footnote_ids(self) -> List[int] | None: + return self._p.footnote_ids + + @property + def comments(self) -> List[Comment]: + runs_comments = [run.comments for run in self.runs] + return [comment for comments in runs_comments for comment in comments] + + @property + def comment_ids(self) -> List[int]: + return [comment.id for comment in self.comments] + @text.setter def text(self, text: str | None): self.clear() self.add_run(text) - def _insert_paragraph_before(self): + def _insert_paragraph_before(self) -> Paragraph: """Return a newly created paragraph, inserted directly before this paragraph.""" p = self._p.add_p_before() return Paragraph(p, self._parent) diff --git a/src/docx/text/run.py b/src/docx/text/run.py index 0e2f5bc17..0cdd18df2 100644 --- a/src/docx/text/run.py +++ b/src/docx/text/run.py @@ -2,23 +2,33 @@ from __future__ import annotations -from typing import IO, TYPE_CHECKING, Iterator, cast +from datetime import datetime +from typing import IO, TYPE_CHECKING, Iterator, List, cast from docx.drawing import Drawing from docx.enum.style import WD_STYLE_TYPE from docx.enum.text import WD_BREAK +from docx.opc.packuri import PackURI from docx.oxml.drawing import CT_Drawing +from docx.oxml.ns import qn from docx.oxml.text.pagebreak import CT_LastRenderedPageBreak from docx.shape import InlineShape from docx.shared import StoryChild from docx.styles.style import CharacterStyle +from docx.text.comment import Comment from docx.text.font import Font +from docx.text.footnote import Footnote from docx.text.pagebreak import RenderedPageBreak if TYPE_CHECKING: import docx.types as t from docx.enum.text import WD_UNDERLINE + from docx.opc.part import Part + from docx.oxml.comments import CT_Com, CT_Comments, CT_CRef + from docx.oxml.footnotes import CT_Footnotes from docx.oxml.text.run import CT_R, CT_Text + from docx.parts.comments import CommentsPart + from docx.parts.document import DocumentPart from docx.shared import Length @@ -95,6 +105,25 @@ def add_text(self, text: str): t = self._r.add_t(text) return _Text(t) + def add_comment( + self, + text: str, + author: str = "python-docx", + initials: str = "pd", + dtime: datetime | None = None, + ) -> Comment: + document_part: DocumentPart = self.part + comments_part: CommentsPart = document_part._comments_part # pyright: ignore[reportPrivateUsage] + comments_part_comments: CT_Comments = comments_part.comments + + if dtime is None: + dtime = datetime.now() + date = str(dtime).replace(" ", "T") + + comment: CT_Com = self._r.add_comment(author, initials, date, text, comments_part_comments) + + return Comment(comment, comments_part) + @property def bold(self) -> bool | None: """Read/write tri-state value. @@ -236,6 +265,94 @@ def underline(self) -> bool | WD_UNDERLINE | None: def underline(self, value: bool): self.font.underline = value + @property + def footnote(self) -> Footnote | None: + _id = self._r.footnote_id + + if _id is not None: + footnotes_part_footnotes: CT_Footnotes = ( + self._parent._parent.part._footnotes_part.element + ) + footnote = footnotes_part_footnotes.get_footnote_by_id(_id) + return Footnote(footnote, footnotes_part_footnotes) + else: + return None + + @property + def is_hyperlink(self) -> bool: + """ + checks if the run is nested inside a hyperlink element + """ + return self.element.getparent().tag.split("}")[1] == "hyperlink" + + def get_hyperlink(self) -> tuple[str, bool]: + """ + returns the text of the hyperlink of the run in case of the run has a hyperlink + """ + document = self._parent._parent.document + parent = self.element.getparent() + link_text = "" + if self.is_hyperlink: + if parent.attrib.__contains__(qn("r:id")): + rId = parent.get(qn("r:id")) + link_text = document._part._rels[rId].target_ref + return link_text, True + elif parent.attrib.__contains__(qn("w:anchor")): + link_text = parent.get(qn("w:anchor")) + return link_text, False + else: + print("No Link in Hyperlink!") + print(self.text) + return "", False + else: + return "None" + + @property + def comments(self) -> List[Comment]: + """Return a list of Comment objects for comments referenced by this run.""" + comment_part_comments: CT_Comments = self._parent._parent.part._comments_part.element + comment_refs: list[CT_CRef] = self._element.findall(qn("w:commentReference")) + ids = [int(ref.get(qn("w:id"))) for ref in comment_refs] + coms: list[CT_Com] = [com for com in comment_part_comments if com._id in ids] + return [Comment(com, comment_part_comments) for com in coms] + + def add_ole_object_to_run(self, ole_object_path: str) -> str: + """ + Add saved OLE Object in the disk to an run and retun the newly created relationship ID + Note: OLE Objects must be stored in the disc as `.bin` file + """ + reltype: str = ( + "http://schemas.openxmlformats.org/officeDocument/2006/relationships/oleObject" + ) + pack_path: str = "/word/embeddings/" + ole_object_path.split("\\")[-1] + partname: PackURI = PackURI(pack_path) + content_type: str = "application/vnd.openxmlformats-officedocument.oleObject" + + with open(ole_object_path, "rb") as f: + blob = f.read() + target_part: Part = Part(partname=partname, content_type=content_type, blob=blob) + rel_id: str = self.part.rels._next_rId # pyright: ignore[reportPrivateUsage] + self.part.rels.add_relationship(reltype=reltype, target=target_part, rId=rel_id) + return rel_id + + def add_fldChar(self, fldCharType: str, fldLock: bool = False, dirty: bool = False) -> str: + fldChar = self._r.add_fldChar(fldCharType, fldLock, dirty) + return fldChar + + @property + def instr_text(self) -> str | None: + return self._r.instr_text + + @instr_text.setter + def instr_text(self, instr_text_val: str): + self._r.instr_text = instr_text_val + + def remove_instr_text(self): + if self.instr_text is None: + return None + else: + self._r._remove_instr_text() # pyright: ignore[reportPrivateUsage] + class _Text: """Proxy object wrapping `` element.""" diff --git a/tests/test_comment.py b/tests/test_comment.py new file mode 100644 index 000000000..7b2828ea4 --- /dev/null +++ b/tests/test_comment.py @@ -0,0 +1,39 @@ +"""Unit tests for the Comment class.""" + +from docx import Document + + +class TestComment: + def it_works(self): + doc = Document() + p = doc.add_paragraph("Hello world!") + p.add_run(" Second run!") + + # Add a comment to the paragraph's first run + r1 = p.runs[0] + c = r1.add_comment("New comment", author="Alice") + assert c.text == "New comment" + assert c.author == "Alice" + assert len(r1.comments) == 1 + assert r1.comments[0].id == 0 + assert p.comment_ids == [0] + + # Change the author, initials, and date of the comment + c.author = "Bob" + c.initials = "BB" + c.date = "2024-01-01T00:00:00Z" + assert c.author == "Bob" + assert c.initials == "BB" + assert c.date == "2024-01-01T00:00:00Z" + + # Added comment is also available in the paragraph's comments + assert len(p.comments) == 1 + assert p.comments[0].text == "New comment" + assert p.comments[0].id == 0 + + # Add another comment, but to the paragraph + c2 = p.add_comment("New comment 2", author="Charlie") + assert c2.text == "New comment 2" + assert len(p.comments) == 2 + assert p.comments[1].text == "New comment 2" + assert p.comments[1].id == 1 diff --git a/tests/test_footnote.py b/tests/test_footnote.py new file mode 100644 index 000000000..3fcbf4508 --- /dev/null +++ b/tests/test_footnote.py @@ -0,0 +1,34 @@ +"""Unit tests for the Footnote class.""" + +from docx import Document + + +class TestFootnote: + def it_works(self): + doc = Document() + p = doc.add_paragraph("Hello world!") + p.add_run(" Second run!") + + # Add a footnote to the paragraph + f = p.add_footnote("New footnote") + assert f.text == "New footnote" + assert len(p.footnotes) == 1 + assert p.footnotes[0].id == 1 + assert p.footnote_ids == [1] + + # Check that the paragraph now has 3 runs: the first run, the second run, and the footnote + assert len(p.runs) == 3 + assert p.runs[0].text == "Hello world!" + assert p.runs[1].text == " Second run!" + assert p.runs[2].footnote.id == 1 + + # Change the text of the footnote + f.text = "New footnote 2" + assert f.text == "New footnote 2" + + # Add another footnote + f2 = p.add_footnote("New footnote 3") + assert f2.text == "New footnote 3" + assert len(p.footnotes) == 2 + assert p.footnotes[1].id == 2 + assert p.footnote_ids == [1, 2]