Skip to content

Commit c7b6603

Browse files
authored
feat: report section index in ParagraphLocation (ISSUES.md #28) (#45)
Add a 1-based section field to ParagraphLocation, computed by walking paragraph-level w:pPr/w:sectPr boundaries in document order. A sectPr-carrying paragraph belongs to the section it closes; the next paragraph starts the following one, and the body-level sectPr defines the final section. Direct-child walks keep w:pPrChange revision records from spoofing section boundaries. Both accessors report it: get_paragraph_location() via a prefix scan (same pattern as heading_path) and list_paragraph_locations() via a single precomputed pass. Documented in docs/api.md and SKILL.md.
1 parent 3bd6a26 commit c7b6603

5 files changed

Lines changed: 204 additions & 26 deletions

File tree

‎docs/api.md‎

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -129,15 +129,15 @@ for start in range(1, count + 1, page_size):
129129

130130
#### `get_paragraph_location(ref)`
131131

132-
Report whether a paragraph lives in the document body or inside a table cell, whether it is a list item, and its heading context (style, outline level, and the chain of headings above it).
132+
Report whether a paragraph lives in the document body or inside a table cell, whether it is a list item, its heading context (style, outline level, and the chain of headings above it), and its section index.
133133

134134
**Parameters:**
135135

136136
- `ref` (str): Paragraph reference from `list_paragraphs()`, such as `P2#f3c1`
137137

138138
**Returns:** `ParagraphLocation`. `location.in_table` is `False` for body paragraphs; `True` when the paragraph is inside a `<w:tc>` cell, in which case `location.table` carries the 1-based table index, row, `w:gridSpan`-aware logical column, and nesting depth. `location.list` is a `ListItem(num_id, ilvl)` for paragraphs carrying a direct `w:pPr/w:numPr`, `None` otherwise — raw values only: numbering inherited via a paragraph style is not resolved, and rendered display numbers (e.g. "7.2(a)") are not computed.
139139

140-
`location.style` is the raw `w:pStyle` style id (e.g. `"Heading1"`), `None` when the paragraph carries no explicit style — no name resolution against `word/styles.xml`. `location.outline_level` is the 0-based outline level (`0` == Heading 1, so a document heading level is `outline_level + 1`): a direct `w:outlineLvl` on the paragraph wins, and the spec's `w:val="9"` marker means body text (`None`); otherwise the level defined by the paragraph's style applies, with `w:basedOn` inheritance chains resolved. `location.heading_path` is the chain of nearest preceding headings that contains the paragraph, outermost first (e.g. `("Chapter one", "Termination")`), built from each heading's current visible text; a heading's own path lists only its ancestors, never itself. Headings inside table cells participate in document order.
140+
`location.style` is the raw `w:pStyle` style id (e.g. `"Heading1"`), `None` when the paragraph carries no explicit style — no name resolution against `word/styles.xml`. `location.outline_level` is the 0-based outline level (`0` == Heading 1, so a document heading level is `outline_level + 1`): a direct `w:outlineLvl` on the paragraph wins, and the spec's `w:val="9"` marker means body text (`None`); otherwise the level defined by the paragraph's style applies, with `w:basedOn` inheritance chains resolved. `location.heading_path` is the chain of nearest preceding headings that contains the paragraph, outermost first (e.g. `("Chapter one", "Termination")`), built from each heading's current visible text; a heading's own path lists only its ancestors, never itself. Headings inside table cells participate in document order. `location.section` is the paragraph's 1-based section index: a paragraph carrying a direct `w:pPr/w:sectPr` closes a section and belongs to the section it closes, the next paragraph starts the following one, and the body-level `w:sectPr` defines the final section — single-section documents report `1` everywhere.
141141

142142
**Example:**
143143

@@ -151,13 +151,14 @@ if loc.list:
151151
if loc.outline_level is not None:
152152
print(f"heading level {loc.outline_level + 1}: style={loc.style}")
153153
print(" > ".join(loc.heading_path)) # e.g. "Chapter one > Termination"
154+
print(f"section {loc.section}")
154155
```
155156

156157
#### `list_paragraph_locations()`
157158

158-
Batch counterpart to `get_paragraph_location()`: pair every paragraph with its structural location in one pass, precomputing table indices, style outline levels, and heading paths once instead of rescanning the document per ref.
159+
Batch counterpart to `get_paragraph_location()`: pair every paragraph with its structural location in one pass, precomputing table indexes, style outline levels, heading paths, and section indexes once instead of rescanning the document per ref.
159160

160-
**Returns:** List of `(ref, ParagraphLocation)` tuples in document order, where `ref` is the same `P{index}#{hash}` token emitted by `list_paragraphs()`. Each location carries the same table, list, style, outline-level, and heading-path info as `get_paragraph_location()`.
161+
**Returns:** List of `(ref, ParagraphLocation)` tuples in document order, where `ref` is the same `P{index}#{hash}` token emitted by `list_paragraphs()`. Each location carries the same table, list, style, outline-level, heading-path, and section info as `get_paragraph_location()`.
161162

162163
**Example:**
163164

‎docx_editor/document.py‎

Lines changed: 30 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525
_build_table_index,
2626
_compute_heading_paths,
2727
_compute_paragraph_location,
28+
_compute_section_indexes,
2829
build_text_map,
2930
compute_paragraph_hash,
3031
)
@@ -436,8 +437,14 @@ def get_paragraph_location(self, ref: str) -> ParagraphLocation:
436437
excludes itself. Headings inside table cells participate in
437438
document order.
438439
439-
Heading context is derived from whole-document scans on every
440-
call; to locate many paragraphs, prefer
440+
``location.section`` is the paragraph's 1-based section index. A
441+
paragraph carrying a direct ``w:pPr/w:sectPr`` closes a section
442+
and belongs to the section it closes; the next paragraph starts
443+
the following one. The body-level ``w:sectPr`` defines the final
444+
section. Single-section documents report ``1`` everywhere.
445+
446+
Heading and section context is derived from whole-document scans
447+
on every call; to locate many paragraphs, prefer
441448
:meth:`list_paragraph_locations`, which precomputes it once.
442449
443450
Args:
@@ -451,7 +458,8 @@ def get_paragraph_location(self, ref: str) -> ParagraphLocation:
451458
``location.list`` is a :class:`ListItem` for list paragraphs,
452459
``None`` otherwise. ``location.style``,
453460
``location.outline_level`` and ``location.heading_path`` carry
454-
the paragraph's heading context as described above.
461+
the paragraph's heading context, and ``location.section`` its
462+
1-based section index, as described above.
455463
456464
Raises:
457465
ValueError: If ``ref`` has an invalid format.
@@ -470,6 +478,7 @@ def get_paragraph_location(self, ref: str) -> ParagraphLocation:
470478
if loc.outline_level is not None:
471479
print(f"heading level {loc.outline_level + 1}")
472480
print(f"under {' > '.join(loc.heading_path) or '(no heading)'}")
481+
print(f"section {loc.section}")
473482
"""
474483
self._ensure_open()
475484
parsed = ParagraphRef.parse(ref)
@@ -486,17 +495,19 @@ def get_paragraph_location(self, ref: str) -> ParagraphLocation:
486495
raise HashMismatchError(parsed.index, parsed.hash, actual_hash, preview)
487496
style_outlines = self._style_outline_map()
488497
heading_path = _compute_heading_paths(paragraphs[: parsed.index], style_outlines)[-1]
489-
return _compute_paragraph_location(p, style_outlines=style_outlines, heading_path=heading_path)
498+
section = _compute_section_indexes(paragraphs[: parsed.index])[-1]
499+
return _compute_paragraph_location(p, style_outlines=style_outlines, heading_path=heading_path, section=section)
490500

491501
def list_paragraph_locations(self) -> list[tuple[str, ParagraphLocation]]:
492502
"""List every paragraph paired with its structural location.
493503
494504
Batch counterpart to :meth:`get_paragraph_location`: precomputes
495-
table indices, style outline levels, and heading paths once instead
496-
of re-scanning the document per ref. Each entry is ``(ref,
497-
location)`` where ``ref`` is the same ``"P{i}#{hash}"`` token
498-
emitted by :meth:`list_paragraphs` (the part before ``|``) and
499-
accepted by :meth:`get_paragraph_location` and the editing methods.
505+
table indexes, style outline levels, heading paths, and section
506+
indexes once instead of re-scanning the document per ref. Each
507+
entry is ``(ref, location)`` where ``ref`` is the same
508+
``"P{i}#{hash}"`` token emitted by :meth:`list_paragraphs` (the
509+
part before ``|``) and accepted by :meth:`get_paragraph_location`
510+
and the editing methods.
500511
501512
Returns:
502513
List of ``(ref, ParagraphLocation)`` tuples in document order.
@@ -505,9 +516,10 @@ def list_paragraph_locations(self) -> list[tuple[str, ParagraphLocation]]:
505516
``location.list`` is a :class:`ListItem` for paragraphs with a
506517
direct ``w:pPr/w:numPr``, ``None`` otherwise (raw values; no
507518
style-inherited numbering, no rendered display numbers).
508-
``location.style``, ``location.outline_level`` and
509-
``location.heading_path`` carry the paragraph's heading context
510-
with the same semantics as :meth:`get_paragraph_location`.
519+
``location.style``, ``location.outline_level``,
520+
``location.heading_path`` and ``location.section`` carry the
521+
paragraph's heading and section context with the same
522+
semantics as :meth:`get_paragraph_location`.
511523
512524
Example:
513525
for ref, loc in doc.list_paragraph_locations():
@@ -517,19 +529,23 @@ def list_paragraph_locations(self) -> list[tuple[str, ParagraphLocation]]:
517529
if loc.list:
518530
print(f"{ref}: list numId={loc.list.num_id} level={loc.list.ilvl}")
519531
print(f"{ref}: under {' > '.join(loc.heading_path) or '(no heading)'}")
532+
print(f"{ref}: section {loc.section}")
520533
"""
521534
self._ensure_open()
522535
dom = self._document_editor.dom
523536
table_index = _build_table_index(dom)
524537
style_outlines = self._style_outline_map()
525538
paragraphs = dom.getElementsByTagName("w:p")
526539
heading_paths = _compute_heading_paths(paragraphs, style_outlines)
540+
section_indexes = _compute_section_indexes(paragraphs)
527541
result = []
528-
for i, (p, path) in enumerate(zip(paragraphs, heading_paths, strict=True), start=1):
542+
for i, (p, path, section) in enumerate(zip(paragraphs, heading_paths, section_indexes, strict=True), start=1):
529543
ref = f"P{i}#{compute_paragraph_hash(p)}"
530544
result.append((
531545
ref,
532-
_compute_paragraph_location(p, table_index, style_outlines=style_outlines, heading_path=path),
546+
_compute_paragraph_location(
547+
p, table_index, style_outlines=style_outlines, heading_path=path, section=section
548+
),
533549
))
534550
return result
535551

‎docx_editor/xml_editor.py‎

Lines changed: 47 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -179,9 +179,9 @@ class ListItem:
179179
class ParagraphLocation:
180180
"""Structural location of a paragraph within the document body.
181181
182-
Reports table membership, list membership, and heading context. The
183-
shape is intentionally extensible: future releases may add other
184-
container kinds (header, footer, footnote), section index, etc., as
182+
Reports table membership, list membership, heading context, and
183+
section index. The shape is intentionally extensible: future releases
184+
may add other container kinds (header, footer, footnote), etc., as
185185
plain optional field additions.
186186
187187
``list`` is the paragraph's raw numbering reference (see
@@ -201,13 +201,21 @@ class ParagraphLocation:
201201
contains this paragraph, outermost first (e.g. ``("Chapter one",
202202
"Termination")``), using each heading's current visible text. A
203203
heading's own path lists only its ancestors, never itself.
204+
205+
``section`` is the paragraph's 1-based section index. A paragraph
206+
whose ``w:pPr`` carries a direct ``w:sectPr`` closes a section — the
207+
carrying paragraph belongs to the section it closes, and the next
208+
paragraph starts the following one. The body-level ``w:sectPr``
209+
defines the final section. Every paragraph belongs to exactly one
210+
section; single-section documents report ``1`` everywhere.
204211
"""
205212

206213
table: TableCell | None
207214
list: ListItem | None = None
208215
style: str | None = None
209216
outline_level: int | None = None
210217
heading_path: tuple[str, ...] = ()
218+
section: int = 1
211219

212220
@property
213221
def in_table(self) -> bool:
@@ -491,11 +499,31 @@ def _compute_heading_paths(paragraphs, style_outlines: dict[str, int]) -> list[t
491499
return paths
492500

493501

502+
def _compute_section_indexes(paragraphs) -> list[int]:
503+
"""1-based section index for each of ``paragraphs`` (document order).
504+
505+
A direct ``<w:pPr>/<w:sectPr>`` closes the current section — the
506+
carrying paragraph still belongs to it; the next paragraph starts the
507+
following section. The body-level ``<w:sectPr>`` (final section) needs
508+
no handling: the running counter already names it. Direct-child walk
509+
only, so ``<w:pPrChange>`` decoys are ignored.
510+
"""
511+
indexes = []
512+
section = 1
513+
for p in paragraphs:
514+
indexes.append(section)
515+
ppr = _direct_child(p, "w:pPr")
516+
if ppr is not None and _direct_child(ppr, "w:sectPr") is not None:
517+
section += 1
518+
return indexes
519+
520+
494521
def _compute_paragraph_location(
495522
paragraph,
496523
table_index: dict | None = None,
497524
style_outlines: dict[str, int] | None = None,
498525
heading_path: tuple[str, ...] = (),
526+
section: int = 1,
499527
) -> ParagraphLocation:
500528
"""Compute the structural location of a ``<w:p>`` element.
501529
@@ -512,23 +540,34 @@ def _compute_paragraph_location(
512540
``style_outlines`` is an optional precomputed ``{style_id:
513541
outline_level}`` map (see :func:`_build_style_outline_map`) used to
514542
resolve style-defined outline levels; ``None`` behaves like ``{}``.
515-
``heading_path`` is threaded through verbatim — computing it needs
516-
whole-document context (see :func:`_compute_heading_paths`).
543+
``heading_path`` and ``section`` are threaded through verbatim —
544+
computing them needs whole-document context (see
545+
:func:`_compute_heading_paths` and :func:`_compute_section_indexes`).
517546
"""
518547
list_item = _extract_list_item(paragraph)
519548
style = _extract_style(paragraph)
520549
outline_level = _extract_outline_level(paragraph, style, style_outlines or {})
521550
tc = _innermost_ancestor(paragraph, "w:tc")
522551
if tc is None:
523552
return ParagraphLocation(
524-
table=None, list=list_item, style=style, outline_level=outline_level, heading_path=heading_path
553+
table=None,
554+
list=list_item,
555+
style=style,
556+
outline_level=outline_level,
557+
heading_path=heading_path,
558+
section=section,
525559
)
526560
tr = _innermost_ancestor(tc, "w:tr")
527561
tbl = _innermost_ancestor(tr, "w:tbl") if tr is not None else None
528562
if tr is None or tbl is None:
529563
# Malformed table structure — tolerate by treating as body content.
530564
return ParagraphLocation(
531-
table=None, list=list_item, style=style, outline_level=outline_level, heading_path=heading_path
565+
table=None,
566+
list=list_item,
567+
style=style,
568+
outline_level=outline_level,
569+
heading_path=heading_path,
570+
section=section,
532571
)
533572
if table_index is not None and tbl in table_index:
534573
index = table_index[tbl]
@@ -545,6 +584,7 @@ def _compute_paragraph_location(
545584
style=style,
546585
outline_level=outline_level,
547586
heading_path=heading_path,
587+
section=section,
548588
)
549589

550590

‎skills/docx/SKILL.md‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -226,7 +226,8 @@ match = doc.find_text("30 days")
226226
# Get all visible text (inserted text included, deleted text excluded)
227227
visible = doc.get_visible_text()
228228

229-
# Structural location: table cell, list item (raw numId/ilvl), and heading context
229+
# Structural location: table cell, list item (raw numId/ilvl), heading context,
230+
# and section index
230231
loc = doc.get_paragraph_location("P3#b2c4")
231232
if loc.table:
232233
print(f"table {loc.table.index} r{loc.table.row} c{loc.table.col}")
@@ -235,6 +236,7 @@ if loc.list:
235236
if loc.outline_level is not None: # 0-based; 0 == Heading 1
236237
print(f"heading level {loc.outline_level + 1}: style={loc.style}")
237238
print(" > ".join(loc.heading_path)) # e.g. "Chapter one > Termination"
239+
print(loc.section) # 1-based section index; sectPr-carrying paragraph closes its section
238240

239241
# All edit methods return the new paragraph ref as a plain string.
240242
# Use it for follow-up edits on the same paragraph:

0 commit comments

Comments
 (0)