doc-control 0.0.1.dev0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (68) hide show
  1. doc_control/__init__.py +4 -0
  2. doc_control/__main__.py +3 -0
  3. doc_control/cli.py +80 -0
  4. doc_control/envelope.py +40 -0
  5. doc_control/errors.py +72 -0
  6. doc_control/model/__init__.py +1 -0
  7. doc_control/model/address.py +113 -0
  8. doc_control/model/align.py +529 -0
  9. doc_control/model/anchor.py +609 -0
  10. doc_control/model/annotations.py +280 -0
  11. doc_control/model/document.py +109 -0
  12. doc_control/model/format.py +109 -0
  13. doc_control/model/index.py +353 -0
  14. doc_control/model/layout.py +143 -0
  15. doc_control/model/normalized.py +160 -0
  16. doc_control/model/pages.py +403 -0
  17. doc_control/model/parts.py +50 -0
  18. doc_control/model/search.py +403 -0
  19. doc_control/model/segment.py +445 -0
  20. doc_control/model/structure.py +418 -0
  21. doc_control/model/styles.py +134 -0
  22. doc_control/ooxml/__init__.py +1 -0
  23. doc_control/ooxml/commentparts.py +679 -0
  24. doc_control/ooxml/formatting.py +181 -0
  25. doc_control/ooxml/resolve.py +269 -0
  26. doc_control/ooxml/revisions.py +1024 -0
  27. doc_control/ooxml/splice.py +957 -0
  28. doc_control/ooxml/styledef.py +504 -0
  29. doc_control/ooxml/zipio.py +454 -0
  30. doc_control/ops/__init__.py +1 -0
  31. doc_control/ops/anchor.py +135 -0
  32. doc_control/ops/batch.py +177 -0
  33. doc_control/ops/comments.py +433 -0
  34. doc_control/ops/compare.py +620 -0
  35. doc_control/ops/diff.py +177 -0
  36. doc_control/ops/dotx.py +206 -0
  37. doc_control/ops/find.py +190 -0
  38. doc_control/ops/history.py +479 -0
  39. doc_control/ops/layout.py +40 -0
  40. doc_control/ops/locate.py +364 -0
  41. doc_control/ops/meta.py +49 -0
  42. doc_control/ops/page.py +104 -0
  43. doc_control/ops/paging.py +124 -0
  44. doc_control/ops/read.py +517 -0
  45. doc_control/ops/recipe.py +108 -0
  46. doc_control/ops/rename.py +370 -0
  47. doc_control/ops/revision.py +616 -0
  48. doc_control/ops/segment.py +289 -0
  49. doc_control/ops/styleset.py +262 -0
  50. doc_control/ops/stylewrite.py +573 -0
  51. doc_control/ops/text.py +207 -0
  52. doc_control/ops/tracked.py +1441 -0
  53. doc_control/py.typed +0 -0
  54. doc_control/recipes/engine.py +209 -0
  55. doc_control/recipes/schema.py +140 -0
  56. doc_control/recipes/store.py +96 -0
  57. doc_control/registry.py +76 -0
  58. doc_control/safety/__init__.py +38 -0
  59. doc_control/safety/history.py +508 -0
  60. doc_control/safety/lock.py +69 -0
  61. doc_control/safety/store.py +415 -0
  62. doc_control/safety/verify.py +357 -0
  63. doc_control/schema.py +143 -0
  64. doc_control-0.0.1.dev0.dist-info/METADATA +62 -0
  65. doc_control-0.0.1.dev0.dist-info/RECORD +68 -0
  66. doc_control-0.0.1.dev0.dist-info/WHEEL +4 -0
  67. doc_control-0.0.1.dev0.dist-info/entry_points.txt +2 -0
  68. doc_control-0.0.1.dev0.dist-info/licenses/LICENSE +202 -0
@@ -0,0 +1,403 @@
1
+ """Estimated page of every paragraph. Always an estimate: only Word's layout engine knows.
2
+
3
+ The word-count model always runs; the marks and breaks correct it:
4
+
5
+ * ``w:lastRenderedPageBreak`` marks, written by Word each time it laid the document out,
6
+ are resync points: a mark starts a new page and resets the filled height, so the model
7
+ restarts from Word's own page start. Text added since the last layout carries no marks and
8
+ is paged by the model alone, like everything after the last mark. A mark that lands on a
9
+ page the model (or an explicit break) just started is not counted twice.
10
+ * Explicit page starts: ``w:br w:type="page"``, ``pageBreakBefore`` (direct or from the style
11
+ chain) and section breaks that start a new page (``nextPage``, ``oddPage``, ``evenPage``;
12
+ ``continuous`` does not).
13
+ * The model: a paragraph of *w* words takes ``ceil(w / words_per_line)`` lines (at least
14
+ one), where a line holds ``width / (font_pt * CHAR_EM) / WORD_CHARS`` words, and each line
15
+ is ``font_pt * LINE_FACTOR`` points high (scaled or fixed by ``w:spacing``), plus space
16
+ before and after. A page holds its height minus the top and bottom margins; the width is
17
+ the page width minus the left (with gutter) and right margins and the paragraph's indents.
18
+ Font size, spacing and indents come from the paragraph, its style chain and
19
+ ``w:docDefaults``; the page from the paragraph's section (``w:sectPr``). A table row is as
20
+ tall as its tallest cell, a cell being the text width divided by the row's cell count (and
21
+ again for a nested table). Not modelled: run-level font sizes, cell margins, merged cells,
22
+ rows split across pages, floating objects, widow control, keep-with-next, headers, footers.
23
+
24
+ Each paragraph's ``source`` says what fixed its start page: ``break`` when it is the first on
25
+ a page an explicit break started, else ``rendered`` when a rendered mark falls in it (at its
26
+ start or inside it), else ``model``.
27
+
28
+ ``page`` is the physical page counted from 1. ``page_label`` is the number Word would print:
29
+ the physical page shifted by ``w:pgNumType w:start`` of the section (applied at the section's
30
+ first page; ignored for a continuous section; the number format is not modelled).
31
+
32
+ A paragraph is placed on the page where it starts; ``end_page`` differs when it runs over.
33
+ """
34
+
35
+ from __future__ import annotations
36
+
37
+ import math
38
+ import re
39
+ import xml.etree.ElementTree as ET
40
+ from dataclasses import dataclass
41
+ from typing import Any
42
+
43
+ from doc_control.model.document import DOCUMENT_PART, Document
44
+ from doc_control.model.format import para_format, run_format
45
+ from doc_control.model.parts import OFFICE_REL, W, parse_part, related_part
46
+
47
+ DEFAULT_PAGE_TWIPS = (12240, 15840) # US Letter, Word's default
48
+ DEFAULT_MARGIN_TWIPS = 1440
49
+ DEFAULT_FONT_PT = 10.0 # the OOXML default when nothing sets a size
50
+ CHAR_EM = 0.5 # average character width as a fraction of the font size
51
+ WORD_CHARS = 6 # characters per word, space included
52
+ LINE_FACTOR = 1.15 # single line height as a multiple of the font size
53
+
54
+ NOTES = (
55
+ "Run-level font sizes are ignored: only paragraph, style and document defaults count.",
56
+ "A table row is as tall as its tallest cell; cell width is the text width divided by the "
57
+ "row's cells (cell margins, merged cells and rows split across pages are ignored).",
58
+ "page is the physical page from 1; page_label follows w:pgNumType w:start per section "
59
+ "(numeric only).",
60
+ )
61
+
62
+ CONSTANTS: dict[str, Any] = {
63
+ "char_em": CHAR_EM,
64
+ "word_chars": WORD_CHARS,
65
+ "line_factor": LINE_FACTOR,
66
+ "default_font_pt": DEFAULT_FONT_PT,
67
+ }
68
+
69
+ _SKIP = frozenset(W + n for n in ("pPr", "del", "moveFrom", "txbxContent", "drawing", "pict"))
70
+
71
+
72
+ @dataclass(frozen=True)
73
+ class ParaPage:
74
+ number: int # 1-based paragraph number
75
+ page: int # physical page the paragraph starts on
76
+ end_page: int
77
+ source: str # rendered | break | model
78
+ label: int # printed number of ``page``
79
+ end_label: int
80
+
81
+
82
+ @dataclass(frozen=True)
83
+ class PageEstimate:
84
+ pages: int
85
+ basis: str # "rendered+model" when the file holds rendered marks, else "model"
86
+ marks: int # rendered page marks in the document
87
+ words_per_page: int # the model's figure for the first section, in the default style
88
+ rows: tuple[ParaPage, ...]
89
+
90
+
91
+ @dataclass(frozen=True)
92
+ class _Event:
93
+ kind: str # "br" (explicit page break) | "lrpb" (lastRenderedPageBreak)
94
+ before: int # visible characters before it in the paragraph
95
+
96
+
97
+ @dataclass(frozen=True)
98
+ class _Facts:
99
+ words: int
100
+ chars: int
101
+ events: tuple[_Event, ...]
102
+ ppr: dict[str, Any]
103
+ font_pt: float
104
+ sect: ET.Element | None
105
+ start: int # byte offset of the paragraph in word/document.xml
106
+
107
+
108
+ @dataclass(frozen=True)
109
+ class _Geometry:
110
+ width_pt: float
111
+ height_pt: float
112
+
113
+ @classmethod
114
+ def of(cls, sect: ET.Element | None) -> _Geometry:
115
+ size = sect.find(W + "pgSz") if sect is not None else None
116
+ mar = sect.find(W + "pgMar") if sect is not None else None
117
+ side = _twips(mar, "left", DEFAULT_MARGIN_TWIPS) + _twips(mar, "gutter", 0)
118
+ side += _twips(mar, "right", DEFAULT_MARGIN_TWIPS)
119
+ tall = _twips(mar, "top", DEFAULT_MARGIN_TWIPS)
120
+ tall += _twips(mar, "bottom", DEFAULT_MARGIN_TWIPS)
121
+ width = _twips(size, "w", DEFAULT_PAGE_TWIPS[0]) - side
122
+ height = _twips(size, "h", DEFAULT_PAGE_TWIPS[1]) - tall
123
+ return cls(max(width, 400) / 20, max(height, 400) / 20)
124
+
125
+
126
+ def _twips(elem: ET.Element | None, name: str, default: int) -> int:
127
+ raw = elem.get(W + name) if elem is not None else None
128
+ try:
129
+ return int(raw) if raw is not None else default
130
+ except ValueError:
131
+ return default
132
+
133
+
134
+ def _walk(elem: ET.Element, events: list[_Event], count: list[int]) -> None:
135
+ for child in elem:
136
+ tag = child.tag
137
+ if not isinstance(tag, str) or tag in _SKIP:
138
+ continue
139
+ if tag == W + "t":
140
+ count[0] += len(child.text or "")
141
+ elif tag == W + "lastRenderedPageBreak":
142
+ events.append(_Event("lrpb", count[0]))
143
+ elif tag == W + "br":
144
+ if child.get(W + "type") == "page":
145
+ events.append(_Event("br", count[0]))
146
+ else:
147
+ _walk(child, events, count)
148
+
149
+
150
+ def _doc_defaults(doc: Document) -> tuple[dict[str, Any], dict[str, Any]]:
151
+ name = related_part(doc.pkg, OFFICE_REL + "styles", "word/styles.xml")
152
+ if name is None:
153
+ return {}, {}
154
+ defaults = parse_part(doc.pkg, name).find(W + "docDefaults")
155
+ if defaults is None:
156
+ return {}, {}
157
+ ppr = defaults.find(f"{W}pPrDefault/{W}pPr")
158
+ rpr = defaults.find(f"{W}rPrDefault/{W}rPr")
159
+ return para_format(ppr), run_format(rpr)
160
+
161
+
162
+ def _facts(doc: Document) -> tuple[list[_Facts], float]:
163
+ """Per-paragraph facts, and the font size of the default paragraph style."""
164
+ base_ppr, base_rpr = _doc_defaults(doc)
165
+ normal = doc.styles.default_paragraph
166
+ normal_rpr = {**base_rpr, **(doc.styles.effective(normal)[1] if normal else {})}
167
+ out: list[_Facts] = []
168
+ for para in doc.index.paragraphs:
169
+ elem = doc.index.spans.parse_paragraph(para.span_index)
170
+ ppr_elem = elem.find(W + "pPr")
171
+ style = doc.style_id(para)
172
+ s_ppr, s_rpr = doc.styles.effective(style) if style is not None else ({}, {})
173
+ rpr = {**base_rpr, **s_rpr}
174
+ events: list[_Event] = []
175
+ count = [0]
176
+ _walk(elem, events, count)
177
+ out.append(
178
+ _Facts(
179
+ words=len(para.text.split()),
180
+ chars=count[0],
181
+ events=tuple(events),
182
+ ppr={**base_ppr, **s_ppr, **para_format(ppr_elem)},
183
+ font_pt=float(rpr.get("size_pt", DEFAULT_FONT_PT)),
184
+ sect=ppr_elem.find(W + "sectPr") if ppr_elem is not None else None,
185
+ start=doc.index.spans.paragraphs[para.span_index].start,
186
+ )
187
+ )
188
+ return out, float(normal_rpr.get("size_pt", DEFAULT_FONT_PT))
189
+
190
+
191
+ def _section_type(sect: ET.Element | None) -> str:
192
+ kind = sect.find(W + "type") if sect is not None else None
193
+ return (kind.get(W + "val") if kind is not None else None) or "nextPage"
194
+
195
+
196
+ def _line_height(font_pt: float, ppr: dict[str, Any]) -> float:
197
+ natural = font_pt * LINE_FACTOR
198
+ spacing: dict[str, Any] = ppr.get("spacing", {})
199
+ line: int | None = spacing.get("line")
200
+ if line is None:
201
+ return natural
202
+ rule = spacing.get("line_rule", "auto")
203
+ if rule == "auto":
204
+ return natural * line / 240
205
+ fixed = line / 20
206
+ return fixed if rule == "exact" else max(fixed, natural)
207
+
208
+
209
+ def _words_per_line(width_pt: float, font_pt: float) -> float:
210
+ return max(width_pt / (font_pt * CHAR_EM) / WORD_CHARS, 1.0)
211
+
212
+
213
+ def _height(f: _Facts, width_pt: float) -> tuple[float, float]:
214
+ """(height of the paragraph, height of one of its lines) in a column of ``width_pt``."""
215
+ indent = f.ppr.get("indent", {})
216
+ width = max(width_pt - (indent.get("left", 0) + indent.get("right", 0)) / 20, 20.0)
217
+ lines = max(1, math.ceil(f.words / _words_per_line(width, f.font_pt)))
218
+ line_h = _line_height(f.font_pt, f.ppr)
219
+ spacing = f.ppr.get("spacing", {})
220
+ gap = (spacing.get("before", 0) + spacing.get("after", 0)) / 20
221
+ return lines * line_h + gap, line_h
222
+
223
+
224
+ _TABLE_TAG = re.compile(rb"<(/?)(?:[\w.-]+:)?(tbl|tr|tc)(?=[\s/>])")
225
+
226
+
227
+ def _table_cells(doc: Document, facts: list[_Facts]) -> list[tuple[int, int, float] | None]:
228
+ """Per paragraph, ``(row id, cell id, share of the text width)`` of its table cell, else None.
229
+
230
+ Rows and cells are found from the markup around each paragraph (nested tables give
231
+ the cell of the inner table); a cell's share is 1 / cells in its row, times the share
232
+ of the cell that holds its table.
233
+ """
234
+ data = doc.index.spans.data
235
+ tokens = [(m.start(), m.group(1) == b"/", m.group(2)) for m in _TABLE_TAG.finditer(data)]
236
+ kinds: list[bytes] = []
237
+ outer: list[int | None] = [] # cell: the cell around its table
238
+ row_of: list[int | None] = [] # cell: its row
239
+ cells: dict[int, int] = {}
240
+ stack: list[int] = []
241
+ at = 0
242
+ found: list[tuple[int, int] | None] = []
243
+ for f in facts:
244
+ while at < len(tokens) and tokens[at][0] < f.start:
245
+ _, closing, kind = tokens[at]
246
+ at += 1
247
+ if closing:
248
+ if stack:
249
+ stack.pop()
250
+ continue
251
+ node = len(kinds)
252
+ kinds.append(kind)
253
+ outer.append(None)
254
+ row_of.append(None)
255
+ if kind == b"tc" and stack:
256
+ row_of[node] = stack[-1]
257
+ cells[stack[-1]] = cells.get(stack[-1], 0) + 1
258
+ outer[node] = next((n for n in reversed(stack) if kinds[n] == b"tc"), None)
259
+ stack.append(node)
260
+ cell = next((n for n in reversed(stack) if kinds[n] == b"tc"), None)
261
+ row = row_of[cell] if cell is not None else None
262
+ found.append((row, cell) if row is not None and cell is not None else None)
263
+ share: dict[int, float] = {}
264
+ for node, kind in enumerate(kinds): # outer cells come before the cells inside them
265
+ row = row_of[node]
266
+ if kind == b"tc" and row is not None:
267
+ parent = outer[node]
268
+ share[node] = (share[parent] if parent is not None else 1.0) / max(cells[row], 1)
269
+ return [(pos[0], pos[1], share[pos[1]]) if pos is not None else None for pos in found]
270
+
271
+
272
+ class _Sim:
273
+ def __init__(self) -> None:
274
+ self.page = 1
275
+ self.used = 0.0
276
+ self.empty = True # nothing placed on the current page yet
277
+ self.pending = False # a break was just counted; Word's own mark for it is a duplicate
278
+ self.broke = False # the current page was started by an explicit break
279
+
280
+ def new_page(self, *, explicit: bool = False) -> None:
281
+ self.page += 1
282
+ self.used = 0.0
283
+ self.empty = True
284
+ self.pending = explicit
285
+ self.broke = explicit
286
+
287
+ def parity(self, kind: str) -> None:
288
+ if (kind == "oddPage" and self.page % 2 == 0) or (kind == "evenPage" and self.page % 2):
289
+ self.page += 1
290
+
291
+ def event(self, ev: _Event) -> None:
292
+ if ev.kind == "lrpb" and (self.pending or self.empty):
293
+ self.pending = False # this page already started here
294
+ else:
295
+ self.new_page(explicit=ev.kind == "br")
296
+
297
+ def place(self, height: float, line_h: float, geo: _Geometry) -> int:
298
+ """Fill pages with a block of ``height``; returns the page it starts on."""
299
+ room = geo.height_pt - self.used
300
+ if not self.empty and room < line_h:
301
+ self.new_page()
302
+ room = geo.height_pt
303
+ start = self.page
304
+ if height <= room:
305
+ self.used += height
306
+ else:
307
+ over = height - room
308
+ extra = math.ceil(over / geo.height_pt)
309
+ self.page += extra
310
+ self.used = over - (extra - 1) * geo.height_pt
311
+ return start
312
+
313
+
314
+ def _page_start(sect: ET.Element | None) -> int | None:
315
+ num = sect.find(W + "pgNumType") if sect is not None else None
316
+ raw = num.get(W + "start") if num is not None else None
317
+ return int(raw) if raw is not None and raw.lstrip("-").isdigit() else None
318
+
319
+
320
+ def _row_height(
321
+ group: list[int], facts: list[_Facts], shares: dict[int, tuple[int, float]], width: float
322
+ ) -> float:
323
+ """Tallest cell of a row: the heights of the paragraphs in each cell added up."""
324
+ per_cell: dict[int, float] = {}
325
+ for i in group:
326
+ cell, share = shares[i]
327
+ per_cell[cell] = per_cell.get(cell, 0.0) + _height(facts[i], width * share)[0]
328
+ return max(per_cell.values())
329
+
330
+
331
+ def estimate_pages(doc: Document) -> PageEstimate:
332
+ facts, normal_pt = _facts(doc)
333
+ marks = sum(1 for f in facts for e in f.events if e.kind == "lrpb")
334
+ body = parse_part(doc.pkg, DOCUMENT_PART).find(W + "body")
335
+ final = body.find(W + "sectPr") if body is not None else None
336
+ # Section k is closed by the k-th paragraph-level sectPr; the last one by the body's.
337
+ closing = [f.sect for f in facts if f.sect is not None] + [final]
338
+ where = _table_cells(doc, facts)
339
+ members: dict[int, list[int]] = {}
340
+ shares: dict[int, tuple[int, float]] = {} # paragraph -> (cell, share)
341
+ for i, pos in enumerate(where):
342
+ if pos is not None:
343
+ members.setdefault(pos[0], []).append(i)
344
+ shares[i] = (pos[1], pos[2])
345
+ placed: dict[int, tuple[int, int]] = {} # row id -> (start page, end page)
346
+
347
+ sim = _Sim()
348
+ rows: list[ParaPage] = []
349
+ section = 0
350
+ opens_section = False
351
+ offset = 0 # printed page number minus physical page
352
+ for number, f in enumerate(facts, start=1):
353
+ sect = closing[section]
354
+ geo = _Geometry.of(sect)
355
+ starts_section = number == 1 or opens_section
356
+ if opens_section and _section_type(sect) != "continuous":
357
+ sim.new_page(explicit=True)
358
+ sim.parity(_section_type(sect))
359
+ if f.ppr.get("page_break_before") and not sim.empty:
360
+ sim.new_page(explicit=True)
361
+ # A mark or a break before any text moves this paragraph; one after only the next.
362
+ early = [e.before == 0 and (e.kind == "lrpb" or f.chars > 0) for e in f.events]
363
+ for ev, is_early in zip(f.events, early, strict=True):
364
+ if is_early:
365
+ sim.event(ev)
366
+ first_label = _page_start(sect)
367
+ new_numbering = number == 1 or (starts_section and _section_type(sect) != "continuous")
368
+ if first_label is not None and new_numbering:
369
+ offset = first_label - sim.page
370
+ broke = sim.empty and sim.broke # first on a page that an explicit break started
371
+ pos = where[number - 1]
372
+ if pos is not None and pos[0] in placed:
373
+ start = placed[pos[0]][0]
374
+ elif pos is not None:
375
+ height = _row_height(members[pos[0]], facts, shares, geo.width_pt)
376
+ start = sim.place(height, _line_height(f.font_pt, f.ppr), geo)
377
+ else:
378
+ height, line_h = _height(f, geo.width_pt)
379
+ start = sim.place(height, line_h, geo)
380
+ sim.empty = False
381
+ for ev, is_early in zip(f.events, early, strict=True):
382
+ if is_early:
383
+ continue
384
+ sim.event(ev)
385
+ left = (f.chars - ev.before) / f.chars if f.chars else 0.0
386
+ sim.used = _height(f, geo.width_pt)[0] * left
387
+ sim.empty = left == 0.0
388
+ if f.chars:
389
+ sim.pending = False
390
+ if pos is not None and pos[0] not in placed:
391
+ placed[pos[0]] = (start, sim.page)
392
+ end = placed[pos[0]][1] if pos is not None else sim.page
393
+ has_mark = any(e.kind == "lrpb" for e in f.events)
394
+ source = "break" if broke else "rendered" if has_mark else "model"
395
+ rows.append(ParaPage(number, start, end, source, start + offset, end + offset))
396
+ opens_section = f.sect is not None
397
+ if opens_section:
398
+ section += 1
399
+ first = _Geometry.of(closing[0])
400
+ line_h = normal_pt * LINE_FACTOR
401
+ words_per_page = int(first.height_pt / line_h * _words_per_line(first.width_pt, normal_pt))
402
+ basis = "rendered+model" if marks else "model"
403
+ return PageEstimate(sim.page, basis, marks, words_per_page, tuple(rows))
@@ -0,0 +1,50 @@
1
+ """Finding and parsing the supporting parts of a package (read-only)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import posixpath
6
+ import xml.etree.ElementTree as ET
7
+
8
+ from doc_control.errors import DocControlError, ErrorCode
9
+ from doc_control.ooxml.zipio import Package
10
+
11
+ W_NS = "http://schemas.openxmlformats.org/wordprocessingml/2006/main"
12
+ W14_NS = "http://schemas.microsoft.com/office/word/2010/wordml"
13
+ W15_NS = "http://schemas.microsoft.com/office/word/2012/wordml"
14
+ OFFICE_REL = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/"
15
+ MS_REL_2011 = "http://schemas.microsoft.com/office/2011/relationships/"
16
+ DOCUMENT_RELS = "word/_rels/document.xml.rels"
17
+
18
+ W = f"{{{W_NS}}}"
19
+ W14 = f"{{{W14_NS}}}"
20
+ W15 = f"{{{W15_NS}}}"
21
+
22
+
23
+ def parse_part(pkg: Package, name: str) -> ET.Element:
24
+ """Parse an XML part; malformed XML is ``NOT_A_DOCX``."""
25
+ try:
26
+ return ET.fromstring(pkg.read(name))
27
+ except ET.ParseError as exc:
28
+ raise DocControlError(
29
+ ErrorCode.NOT_A_DOCX, f"{name} is not well-formed XML: {exc}", part=name
30
+ ) from exc
31
+
32
+
33
+ def related_part(pkg: Package, rel_type: str, default: str) -> str | None:
34
+ """The part ``word/document.xml`` relates to as ``rel_type`` (a full type URI).
35
+
36
+ Falls back to ``default`` when the relationship is missing but the part exists.
37
+ """
38
+ if DOCUMENT_RELS in pkg:
39
+ for rel in parse_part(pkg, DOCUMENT_RELS):
40
+ if rel.get("Type") != rel_type or rel.get("TargetMode") == "External":
41
+ continue
42
+ target = rel.get("Target", "")
43
+ name = (
44
+ target.lstrip("/")
45
+ if target.startswith("/")
46
+ else posixpath.normpath(posixpath.join("word", target))
47
+ )
48
+ if name in pkg:
49
+ return name
50
+ return default if default in pkg else None