Skip to content

Commit ca4da3f

Browse files
committed
fix(comments): use PowerPoint's real modern-comment contract — parent threads now render (issue #25)
Threaded comments were invisible in PowerPoint's Comments pane despite a green trinity and clean script UAT. Root-caused by diffing our OOXML against a threaded comment PowerPoint for Mac itself authored+saved (ground truth, captured this session) — three string axes and one element were inferred wrong in Waves 1-2: - comment-part content type: was application/vnd.ms-powerpoint.threadedComments+xml -> application/vnd.ms-powerpoint.comments+xml - slide -> comment-part reltype: was .../2018/10/relationships/threadedComment -> .../2018/10/relationships/comments - presentation -> authors reltype: was .../2018/10/relationships/threadedCommentAuthors -> .../2018/10/relationships/authors - every <p188:cm> now emits its required first child <pc:sldMkLst><pc:docMk/><pc:sldMk cId="0" sldId="{p:sldId}"/></pc:sldMkLst> (ns .../2013/main/command) — the slide binding PowerPoint needs; omitting it (or the fork-private extLst anchor) leaves the comment unbound. PowerPoint resolves the comment part by the slide relationship type; an unrecognized reltype meant the part was never loaded -> empty pane. Files: opc/constants.py (3 values; symbols unchanged so the PartFactory registry + producer move in lockstep — Cato-audited), oxml/ns.py (+pc), oxml/comments.py (sldMkLst ZeroOrOne + set_slide_marker), comments.py (Comments.add binds the slide). 6 regression assertions in DescribeThreadedCommentPowerPointContract lock all four axes (red->green proven by stashing src) plus set_slide_marker idempotency. Verification: pytest 3759 passed; ruff check + format clean; behave 1062 scenarios / 3215 steps 0 failed. Regenerated UAT deck opened in real PowerPoint via Interceptor — Comments pane renders 3 threads, correct author/text, resolved status persisted (uat/FIXED_comments_rendering.png vs uat/REPRO_no_comments_pane.png). Cato cross-vendor audit: pass. KNOWN-INCOMPLETE: PowerPoint still drops every <p188:reply> (replies do not render; only parent comments do). This is a logically independent reply-level contract defect, deliberately NOT inference-fixed — it needs its own PowerPoint-authored <p188:reply> ground-truth diff. Tracked as a scoped follow-up in the session ISA. UAT script runs clean — pending maintainer visual signoff in PowerPoint/Keynote. No push/PR per repo §6a. Refs #25.
1 parent a7c6cb9 commit ca4da3f

5 files changed

Lines changed: 145 additions & 18 deletions

File tree

src/pptx/comments.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -393,6 +393,11 @@ def add(self, text: str, author: str, anchor=None) -> Comment:
393393
part = self._slide.part.modern_comments_part
394394
author_guid = self._slide._get_or_add_author_guid(author)
395395
cm = part.add_comment(_new_guid(), author_guid, _now_iso(), text)
396+
# GROUND TRUTH (2026-05-17): PowerPoint binds a modern comment to its
397+
# slide via a `<pc:sldMkLst>` carrying the slide's `<p:sldId>/@id`.
398+
# Without it the comment never renders in the Comments pane (issue
399+
# #25 root cause). slide_id is the integer sldId from presentation.xml.
400+
cm.set_slide_marker(self._slide.slide_id)
396401
if anchor is not None and getattr(anchor, "shape_id", None) is not None:
397402
cm.anchorShapeId = anchor.shape_id
398403
return Comment(cm, self._slide)

src/pptx/opc/constants.py

Lines changed: 21 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -88,10 +88,13 @@ class CONTENT_TYPE:
8888
PML_TABLE_STYLES = (
8989
"application/vnd.openxmlformats-officedocument.presentationml.tableStyles+xml"
9090
)
91-
# -- modern (2018) threaded-comments part. There is no ECMA-376 content
92-
# -- type for threaded comments; PowerPoint stamps the part with this
93-
# -- vendor MIME type (`[MS-PPTX]`, threaded-comments extension).
94-
PML_THREADED_COMMENTS = "application/vnd.ms-powerpoint.threadedComments+xml"
91+
# -- modern (2018) threaded-comments part. GROUND TRUTH (2026-05-17):
92+
# -- captured from a comment PowerPoint for Mac itself authored+saved —
93+
# -- the part is stamped `application/vnd.ms-powerpoint.comments+xml`
94+
# -- (NOT `...threadedComments+xml`, which was an inference and is the
95+
# -- string PowerPoint silently fails to recognize → empty Comments
96+
# -- pane, issue #25). Symbol name kept for blast-radius; value fixed.
97+
PML_THREADED_COMMENTS = "application/vnd.ms-powerpoint.comments+xml"
9598
# -- modern (2018) threaded-comment AUTHORS part. Distinct from the legacy
9699
# -- `commentAuthors.xml` (ECMA-376 §19.5, integer ids): the modern
97100
# -- `<p188:cm>/@authorId` is a GUID and must resolve to a
@@ -311,20 +314,20 @@ class RELATIONSHIP_TYPE:
311314
SLIDE_UPDATE_INFO = (
312315
"http://schemas.openxmlformats.org/officeDocument/2006/relationships/slideUpdateInfo"
313316
)
314-
# -- modern (2018) threaded-comments slide→part relationship. Vendor
315-
# -- reltype from the MS threaded-comments extension; the slide relates
316-
# -- to its modernComment_*.xml part with this type (Wave 2 wiring).
317-
THREADED_COMMENT = "http://schemas.microsoft.com/office/2018/10/relationships/threadedComment"
318-
# -- modern (2018) threaded-comment AUTHORS relationship. Sibling reltype
319-
# -- in the same `2018/10/relationships/` family as THREADED_COMMENT; the
320-
# -- PRESENTATION part (not the slide) relates to the single
321-
# -- `/ppt/authors.xml` modern-authors part with this type. This is the
322-
# -- `threadedCommentAuthors` member of the [MS-PPTX] threaded-comments
323-
# -- extension (the relationship PowerPoint stamps from presentation.xml
324-
# -- to authors.xml) — its name mirrors the THREADED_COMMENT sibling and
325-
# -- is what real PowerPoint emits/expects; the bare `.../authors` form is
326-
# -- not the one PowerPoint resolves `<p188:cm>/@authorId` GUIDs through.
327-
AUTHORS = "http://schemas.microsoft.com/office/2018/10/relationships/threadedCommentAuthors"
317+
# -- modern (2018) threaded-comments slide→part relationship. GROUND
318+
# -- TRUTH (2026-05-17, PowerPoint-authored capture): the slide relates
319+
# -- to its modern-comments part with `.../2018/10/relationships/comments`
320+
# -- (NOT `.../threadedComment`). PowerPoint locates the comment part by
321+
# -- THIS reltype off the slide; the prior `threadedComment` form is
322+
# -- unrecognized → the part is never loaded → empty Comments pane.
323+
THREADED_COMMENT = "http://schemas.microsoft.com/office/2018/10/relationships/comments"
324+
# -- modern (2018) threaded-comment AUTHORS relationship. GROUND TRUTH
325+
# -- (2026-05-17, PowerPoint-authored capture): the PRESENTATION part
326+
# -- relates to `/ppt/authors.xml` with `.../2018/10/relationships/authors`
327+
# -- (NOT `.../threadedCommentAuthors`). The bare `.../authors` form IS
328+
# -- the one PowerPoint emits and resolves `<p188:cm>/@authorId` GUIDs
329+
# -- through — the earlier `threadedCommentAuthors` was an inference.
330+
AUTHORS = "http://schemas.microsoft.com/office/2018/10/relationships/authors"
328331
STYLES = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles"
329332
TABLE = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/table"
330333
TABLE_SINGLE_CELLS = (

src/pptx/oxml/comments.py

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -322,8 +322,19 @@ class CT_ThreadedComment(BaseOxmlElement):
322322
``<p188:txBody>`` rich-text body, and an optional ``<p188:replyLst>``
323323
holding the reply thread. ``status`` (e.g. ``"resolved"``) marks a
324324
closed thread.
325+
326+
GROUND TRUTH (2026-05-17, captured from a comment PowerPoint for Mac
327+
authored+saved): the FIRST child of every top-level ``<p188:cm>`` is a
328+
``<pc:sldMkLst>`` slide-binding marker
329+
(``<pc:docMk/><pc:sldMk cId=".." sldId=".."/>``, ns
330+
``http://schemas.microsoft.com/office/powerpoint/2013/main/command``).
331+
This is how PowerPoint binds a comment to its slide — NOT the per-slide
332+
relationship and NOT the fork-private ``extLst`` anchor. Omitting it
333+
leaves the comment unbound and PowerPoint does not render it (issue #25
334+
empty-Comments-pane root cause). Replies do not carry their own marker.
325335
"""
326336

337+
sldMkLst = ZeroOrOne("pc:sldMkLst", successors=("p188:txBody", "p188:replyLst", "p188:extLst"))
327338
txBody = ZeroOrOne("p188:txBody", successors=("p188:replyLst", "p188:extLst"))
328339
replyLst: "CT_ThreadedCommentReplyList | None" = ZeroOrOne( # pyright: ignore
329340
"p188:replyLst", successors=("p188:extLst",)
@@ -386,6 +397,28 @@ def anchorShapeId(self, shape_id: int) -> None:
386397
ext.append(anchor)
387398
extLst.append(ext)
388399

400+
def set_slide_marker(self, slide_id: int, c_id: int = 0) -> None:
401+
"""Set the ``<pc:sldMkLst>`` binding this comment to slide `slide_id`.
402+
403+
Builds ``<pc:sldMkLst><pc:docMk/><pc:sldMk cId=c_id sldId=slide_id/>
404+
</pc:sldMkLst>`` and places it as the FIRST child of this
405+
``<p188:cm>`` (before ``<p188:txBody>``), matching the structure
406+
PowerPoint itself emits. ``slide_id`` is the slide's
407+
``<p:sldId>/@id`` (e.g. 256 for the first slide); ``c_id`` is the
408+
collaboration/change id PowerPoint stamps ``0`` for a locally
409+
authored comment. Replaces any pre-existing marker (idempotent).
410+
"""
411+
existing = self.sldMkLst
412+
if existing is not None:
413+
self.remove(existing)
414+
self.insert(
415+
0,
416+
parse_xml(
417+
"<pc:sldMkLst %s><pc:docMk/><pc:sldMk "
418+
'cId="%d" sldId="%d"/></pc:sldMkLst>' % (nsdecls("pc"), c_id, slide_id)
419+
),
420+
)
421+
389422
@classmethod
390423
def new(cls, comment_id: str, author_id: str, created: str) -> CT_ThreadedComment:
391424
"""Return a minimal-valid ``<p188:cm>`` with an empty text body."""

src/pptx/oxml/ns.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
"p": "http://schemas.openxmlformats.org/presentationml/2006/main",
2424
"p14": "http://schemas.microsoft.com/office/powerpoint/2010/main",
2525
"p188": "http://schemas.microsoft.com/office/powerpoint/2018/8/main",
26+
"pc": "http://schemas.microsoft.com/office/powerpoint/2013/main/command",
2627
"pd": "http://schemas.openxmlformats.org/drawingml/2006/presentationDrawing",
2728
"pic": "http://schemas.openxmlformats.org/drawingml/2006/picture",
2829
"pr": "http://schemas.openxmlformats.org/package/2006/relationships",

tests/test_issue25_comments.py

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -674,5 +674,90 @@ def it_places_bodyPr_before_the_paragraph(self):
674674
)
675675

676676

677+
class DescribeThreadedCommentPowerPointContract:
678+
"""Regression: the modern-comment OOXML must match PowerPoint ground truth.
679+
680+
Captured 2026-05-17 from a threaded comment PowerPoint for Mac itself
681+
authored+saved. Three string axes plus a slide-binding element were
682+
inferred wrong in Waves 1-2 (content type ``threadedComments+xml``,
683+
reltypes ``threadedComment``/``threadedCommentAuthors``, and a missing
684+
``<pc:sldMkLst>``). With any of them wrong PowerPoint silently fails to
685+
load/bind the part → empty Comments pane while the test trinity is
686+
green. This locks every axis to the PowerPoint-emitted contract.
687+
"""
688+
689+
def it_uses_the_powerpoint_content_type_and_reltypes(self):
690+
assert CT.PML_THREADED_COMMENTS == "application/vnd.ms-powerpoint.comments+xml"
691+
assert RT.THREADED_COMMENT == (
692+
"http://schemas.microsoft.com/office/2018/10/relationships/comments"
693+
)
694+
assert RT.AUTHORS == "http://schemas.microsoft.com/office/2018/10/relationships/authors"
695+
696+
def _round_trip_zip(self):
697+
prs = Presentation()
698+
s = prs.slides.add_slide(prs.slide_layouts[1])
699+
s.comments.add("Looks great! Ship it.", author="Alex Reviewer")
700+
buf = io.BytesIO()
701+
prs.save(buf)
702+
buf.seek(0)
703+
import zipfile
704+
705+
return zipfile.ZipFile(buf), s.slide_id
706+
707+
def it_stamps_the_powerpoint_content_type_in_content_types(self):
708+
z, _ = self._round_trip_zip()
709+
ctypes = z.read("[Content_Types].xml").decode()
710+
assert "application/vnd.ms-powerpoint.comments+xml" in ctypes
711+
assert "threadedComments+xml" not in ctypes
712+
713+
def it_relates_slide_to_part_and_presentation_to_authors_by_pp_reltype(self):
714+
z, _ = self._round_trip_zip()
715+
slide_rels = z.read("ppt/slides/_rels/slide1.xml.rels").decode()
716+
prs_rels = z.read("ppt/_rels/presentation.xml.rels").decode()
717+
assert "/2018/10/relationships/comments" in slide_rels
718+
assert "/2018/10/relationships/threadedComment" not in slide_rels
719+
assert "/2018/10/relationships/authors" in prs_rels
720+
assert "threadedCommentAuthors" not in prs_rels
721+
722+
def it_binds_each_comment_to_its_slide_via_sldMkLst(self):
723+
import re
724+
725+
z, slide_id = self._round_trip_zip()
726+
name = next(n for n in z.namelist() if "modernComment" in n)
727+
xml = z.read(name).decode()
728+
# <pc:sldMkLst> present with <pc:docMk/> and <pc:sldMk sldId=...>
729+
assert "sldMkLst" in xml, "every <p188:cm> needs a <pc:sldMkLst> slide binding"
730+
assert "docMk" in xml
731+
assert "sldMk" in xml
732+
m = re.search(r'<pc:sldMk[^>]*sldId="(\d+)"', xml)
733+
assert m is not None, "<pc:sldMk> must carry the slide's sldId"
734+
assert int(m.group(1)) == slide_id, (
735+
"sldMk/@sldId must equal the slide's <p:sldId>/@id (%d) so "
736+
"PowerPoint binds the comment to the right slide" % slide_id
737+
)
738+
739+
def it_places_sldMkLst_before_txBody_in_child_order(self):
740+
z, _ = self._round_trip_zip()
741+
name = next(n for n in z.namelist() if "modernComment" in n)
742+
xml = z.read(name).decode()
743+
assert xml.index("sldMkLst") < xml.index("txBody"), (
744+
"<pc:sldMkLst> must precede <p188:txBody> (PowerPoint child order)"
745+
)
746+
747+
def it_keeps_exactly_one_sldMkLst_when_set_twice(self):
748+
# Cato low-finding lock: set_slide_marker must REPLACE, not prepend a
749+
# second <pc:sldMkLst>, if invoked again on the same comment element.
750+
prs = Presentation()
751+
s = prs.slides.add_slide(prs.slide_layouts[1])
752+
c = s.comments.add("once", author="Rev")
753+
c._cm.set_slide_marker(s.slide_id)
754+
c._cm.set_slide_marker(s.slide_id)
755+
from pptx.oxml.ns import qn
756+
757+
assert len(c._cm.findall(qn("pc:sldMkLst"))) == 1, (
758+
"set_slide_marker must be idempotent (exactly one <pc:sldMkLst>)"
759+
)
760+
761+
677762
if __name__ == "__main__":
678763
raise SystemExit(pytest.main([__file__, "-q"]))

0 commit comments

Comments
 (0)