From c14854da66495bfb7eedcf9c5c8f8f695ecb95cc Mon Sep 17 00:00:00 2001 From: robertbrodie Date: Sat, 25 Apr 2026 08:12:43 +1000 Subject: [PATCH] fix: add notesMasterIdLst element when creating notes master When python-pptx creates a notes master on first access to slide.notes_slide, it correctly creates the NotesMasterPart and adds the relationship in the .rels file, but omits the corresponding reference from presentation.xml. Without this element, OOXML consumers cannot discover the notes master from the presentation element, even though the relationship and part exist. This causes Apple Keynote (and potentially other consumers) to fail to recognize the notes master, breaking speaker notes import. This fix: - Adds CT_NotesMasterIdList and CT_NotesMasterIdListEntry element classes with proper ZeroOrOne/RequiredAttribute declarations - Registers both element classes in the oxml element class lookup - Adds a ZeroOrOne declaration for notesMasterIdLst on CT_Presentation with correct successor sequence - Updates PresentationPart.notes_master_part to populate the notesMasterIdLst element with the relationship ID after creating the notes master relationship Closes #1051 --- src/pptx/oxml/__init__.py | 4 ++++ src/pptx/oxml/presentation.py | 36 ++++++++++++++++++++++++++++++++ src/pptx/parts/presentation.py | 3 ++- tests/parts/test_presentation.py | 10 ++++++++- 4 files changed, 51 insertions(+), 2 deletions(-) diff --git a/src/pptx/oxml/__init__.py b/src/pptx/oxml/__init__.py index 21afaa921..c70fe7b21 100644 --- a/src/pptx/oxml/__init__.py +++ b/src/pptx/oxml/__init__.py @@ -273,6 +273,8 @@ def register_element_cls(nsptagname: str, cls: Type[BaseOxmlElement]): from pptx.oxml.presentation import ( # noqa: E402 + CT_NotesMasterIdList, + CT_NotesMasterIdListEntry, CT_Presentation, CT_SlideId, CT_SlideIdList, @@ -281,6 +283,8 @@ def register_element_cls(nsptagname: str, cls: Type[BaseOxmlElement]): CT_SlideSize, ) +register_element_cls("p:notesMasterId", CT_NotesMasterIdListEntry) +register_element_cls("p:notesMasterIdLst", CT_NotesMasterIdList) register_element_cls("p:presentation", CT_Presentation) register_element_cls("p:sldId", CT_SlideId) register_element_cls("p:sldIdLst", CT_SlideIdList) diff --git a/src/pptx/oxml/presentation.py b/src/pptx/oxml/presentation.py index 17997c2b1..ddd111dd4 100644 --- a/src/pptx/oxml/presentation.py +++ b/src/pptx/oxml/presentation.py @@ -17,6 +17,7 @@ class CT_Presentation(BaseOxmlElement): get_or_add_sldSz: Callable[[], CT_SlideSize] get_or_add_sldIdLst: Callable[[], CT_SlideIdList] get_or_add_sldMasterIdLst: Callable[[], CT_SlideMasterIdList] + get_or_add_notesMasterIdLst: Callable[[], CT_NotesMasterIdList] sldMasterIdLst: CT_SlideMasterIdList | None = ( ZeroOrOne( # pyright: ignore[reportAssignmentType] @@ -30,6 +31,17 @@ class CT_Presentation(BaseOxmlElement): ), ) ) + notesMasterIdLst: CT_NotesMasterIdList | None = ( + ZeroOrOne( # pyright: ignore[reportAssignmentType] + "p:notesMasterIdLst", + successors=( + "p:handoutMasterIdLst", + "p:sldIdLst", + "p:sldSz", + "p:notesSz", + ), + ) + ) sldIdLst: CT_SlideIdList | None = ZeroOrOne( # pyright: ignore[reportAssignmentType] "p:sldIdLst", successors=("p:sldSz", "p:notesSz") ) @@ -115,6 +127,30 @@ class CT_SlideMasterIdListEntry(BaseOxmlElement): rId: str = RequiredAttribute("r:id", XsdString) # pyright: ignore[reportAssignmentType] +class CT_NotesMasterIdList(BaseOxmlElement): + """`p:notesMasterIdLst` element. + + Child of `p:presentation` containing a reference to the notes master that belongs to the + presentation. + """ + + _add_notesMasterId: Callable[..., CT_NotesMasterIdListEntry] + notesMasterId = ZeroOrOne("p:notesMasterId") + + def add_notesMasterId(self, rId: str) -> CT_NotesMasterIdListEntry: + """Create and return a new `p:notesMasterId` child element with r:id set to `rId`.""" + return self._add_notesMasterId(rId=rId) + + +class CT_NotesMasterIdListEntry(BaseOxmlElement): + """`p:notesMasterId` element. + + Child of `p:notesMasterIdLst` containing an `rId` reference to the notes master part. + """ + + rId: str = RequiredAttribute("r:id", XsdString) # pyright: ignore[reportAssignmentType] + + class CT_SlideSize(BaseOxmlElement): """`p:sldSz` element. diff --git a/src/pptx/parts/presentation.py b/src/pptx/parts/presentation.py index 1413de457..59e8ba848 100644 --- a/src/pptx/parts/presentation.py +++ b/src/pptx/parts/presentation.py @@ -72,7 +72,8 @@ def notes_master_part(self) -> NotesMasterPart: return self.part_related_by(RT.NOTES_MASTER) except KeyError: notes_master_part = NotesMasterPart.create_default(self.package) - self.relate_to(notes_master_part, RT.NOTES_MASTER) + rId = self.relate_to(notes_master_part, RT.NOTES_MASTER) + self._element.get_or_add_notesMasterIdLst().add_notesMasterId(rId) return notes_master_part @lazyproperty diff --git a/tests/parts/test_presentation.py b/tests/parts/test_presentation.py index edde4c44c..a28b373a7 100644 --- a/tests/parts/test_presentation.py +++ b/tests/parts/test_presentation.py @@ -65,13 +65,21 @@ def but_it_adds_a_notes_master_part_when_needed( NotesMasterPart_ = class_mock(request, "pptx.parts.presentation.NotesMasterPart") NotesMasterPart_.create_default.return_value = notes_master_part_ part_related_by_.side_effect = KeyError - prs_part = PresentationPart(None, None, package_, None) + relate_to_.return_value = "rId42" + prs_elm = element("p:presentation/p:sldMasterIdLst") + prs_part = PresentationPart(None, None, package_, prs_elm) notes_master_part = prs_part.notes_master_part NotesMasterPart_.create_default.assert_called_once_with(package_) relate_to_.assert_called_once_with(prs_part, notes_master_part_, RT.NOTES_MASTER) assert notes_master_part is notes_master_part_ + # --- notesMasterIdLst element was added to presentation.xml --- + notesMasterIdLst = prs_elm.notesMasterIdLst + assert notesMasterIdLst is not None + notesMasterId = notesMasterIdLst.notesMasterId + assert notesMasterId is not None + assert notesMasterId.rId == "rId42" def it_provides_access_to_its_notes_master(self, request, notes_master_part_): notes_master_ = instance_mock(request, NotesMaster)