Skip to content

Commit f0fc1f8

Browse files
committed
feat(text): _Paragraph.fields field-discovery accessor (Phase 4)
Public Python API for the headers/footers/slide-numbers/dates epic (#20). Phase 4 ships the small read-side companion to Phase 3's authoring API: `_Paragraph.fields` returns the paragraph's `<a:fld>` children wrapped as `_Field` instances in document order. Combined with Phase 3, a user can now `add_field()` to write, save, re-open, and `paragraph.fields[0]` to read or mutate. Why this is small. The heavy lift landed in Phase 3 — `_Field` itself, the OOXML primitives (Phase 1), and the field-aware `content_children` + `_Paragraph.text` (also Phase 3). Phase 4 just exposes the parallel- to-`runs` discovery accessor that the existing `fld_lst` on `CT_TextParagraph` made trivial. Changes: - pptx.text.text._Paragraph.fields — new `@property`, returns `tuple[_Field, ...]` built from `self._element.fld_lst`. Mirrors the shape of `_Paragraph.runs` exactly so the idiom is instantly familiar. Out of scope for Phase 4 (deliberate): - Interleaved ordered iterator combining `_Run` / `_Field` / `_LineBreak` in a single sequence. `content_children` already exposes this at the oxml layer; surfacing as public API can land later if real users ask. - `Slide.has_auto_slide_number` / `has_auto_date` convenience flags — derive from `.fields` if useful; deferred. - HandoutMaster class and watermark helper — Phase 5. Anti-criteria upheld: - `_Paragraph.runs` continues to yield only `_Run` instances. The new test `it_keeps_runs_field_free_on_mixed_paragraphs` regression-pins that on a mixed `(a:r, a:fld, a:r)` paragraph. - `_Paragraph.text` semantics unchanged (still field-inclusive, as Phase 3 made it). - `_Field` class itself is read-only here — no modifications. Verification (local, CPython 3.14.4): - python3 -m pytest tests/ -q → 3632 passed in 5.24s (+6 vs Phase 3 baseline) - python3 -m ruff check src tests → All checks passed! - python3 -m ruff format --check src tests → 216 files already formatted - python3 -m behave features/ --no-color → 1048 scenarios, 0 failed - python3 uat/uat_headers_footers_phase4.py → PASS (opened uat/out_headers_footers_phase3.pptx, discovered fields[0] with type='slidenum' and id={2ED44585-...}, mutated text to "X" via the discovered handle, re-saved + re-opened, round-tripped clean) Refs #20.
1 parent 3bd4216 commit f0fc1f8

2 files changed

Lines changed: 58 additions & 0 deletions

File tree

src/pptx/text/text.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -778,6 +778,17 @@ def runs(self) -> tuple[_Run, ...]:
778778
"""Sequence of runs in this paragraph."""
779779
return tuple(_Run(r, self) for r in self._element.r_lst)
780780

781+
@property
782+
def fields(self) -> tuple[_Field, ...]:
783+
"""Sequence of fields in this paragraph in document order.
784+
785+
Mirrors :attr:`runs` but yields :class:`_Field` instances wrapping each
786+
``<a:fld>`` child element. Useful for discovering existing slide-number,
787+
date, and other PowerPoint-resolved fields in a deck — `.runs` deliberately
788+
excludes fields so that pre-existing iteration semantics stay intact.
789+
"""
790+
return tuple(_Field(f, self) for f in self._element.fld_lst)
791+
781792
@property
782793
def space_after(self) -> Length | None:
783794
"""The spacing to appear between this paragraph and the subsequent paragraph.

tests/text/test_text.py

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -902,6 +902,53 @@ def it_provides_access_to_its_runs(self, runs_fixture):
902902
assert isinstance(r, _Run)
903903
assert r._parent == paragraph
904904

905+
def it_returns_an_empty_tuple_of_fields_when_paragraph_has_none(self):
906+
paragraph = _Paragraph(element("a:p"), None)
907+
assert paragraph.fields == ()
908+
909+
def it_provides_access_to_a_single_field(self):
910+
paragraph = _Paragraph(element('a:p/a:fld{id=fld-1,type=slidenum}/a:t"sn"'), None)
911+
fields = paragraph.fields
912+
assert len(fields) == 1
913+
assert isinstance(fields[0], _Field)
914+
assert fields[0].type == "slidenum"
915+
assert fields[0].text == "sn"
916+
917+
def it_yields_multiple_fields_in_document_order(self):
918+
paragraph = _Paragraph(
919+
element(
920+
'a:p/(a:fld{id=fld-1,type=slidenum}/a:t"sn",a:fld{id=fld-2,type=datetime1}/a:t"date")'
921+
),
922+
None,
923+
)
924+
fields = paragraph.fields
925+
assert tuple(f.type for f in fields) == ("slidenum", "datetime1")
926+
assert tuple(f.text for f in fields) == ("sn", "date")
927+
928+
def it_chains_each_field_parent_back_to_the_paragraph(self):
929+
paragraph = _Paragraph(element('a:p/(a:fld{id=fld-1}/a:t"a",a:fld{id=fld-2}/a:t"b")'), None)
930+
for f in paragraph.fields:
931+
assert f._parent is paragraph
932+
933+
def it_returns_a_tuple_not_a_list(self):
934+
paragraph = _Paragraph(element("a:p/a:fld{id=fld-1}"), None)
935+
assert isinstance(paragraph.fields, tuple)
936+
937+
def it_keeps_runs_field_free_on_mixed_paragraphs(self):
938+
# ---a:r and a:fld interleaved: .runs yields only _Run, .fields yields only _Field
939+
paragraph = _Paragraph(
940+
element('a:p/(a:r/a:t"head",a:fld{id=fld-1,type=slidenum}/a:t"sn",a:r/a:t"tail")'),
941+
None,
942+
)
943+
runs = paragraph.runs
944+
fields = paragraph.fields
945+
assert len(runs) == 2
946+
assert tuple(r.text for r in runs) == ("head", "tail")
947+
assert all(isinstance(r, _Run) for r in runs)
948+
assert len(fields) == 1
949+
assert fields[0].type == "slidenum"
950+
assert isinstance(fields[0], _Field)
951+
905952
def it_knows_its_space_after(self, after_get_fixture):
906953
paragraph, expected_value = after_get_fixture
907954
assert paragraph.space_after == expected_value

0 commit comments

Comments
 (0)