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.
- doc_control/__init__.py +4 -0
- doc_control/__main__.py +3 -0
- doc_control/cli.py +80 -0
- doc_control/envelope.py +40 -0
- doc_control/errors.py +72 -0
- doc_control/model/__init__.py +1 -0
- doc_control/model/address.py +113 -0
- doc_control/model/align.py +529 -0
- doc_control/model/anchor.py +609 -0
- doc_control/model/annotations.py +280 -0
- doc_control/model/document.py +109 -0
- doc_control/model/format.py +109 -0
- doc_control/model/index.py +353 -0
- doc_control/model/layout.py +143 -0
- doc_control/model/normalized.py +160 -0
- doc_control/model/pages.py +403 -0
- doc_control/model/parts.py +50 -0
- doc_control/model/search.py +403 -0
- doc_control/model/segment.py +445 -0
- doc_control/model/structure.py +418 -0
- doc_control/model/styles.py +134 -0
- doc_control/ooxml/__init__.py +1 -0
- doc_control/ooxml/commentparts.py +679 -0
- doc_control/ooxml/formatting.py +181 -0
- doc_control/ooxml/resolve.py +269 -0
- doc_control/ooxml/revisions.py +1024 -0
- doc_control/ooxml/splice.py +957 -0
- doc_control/ooxml/styledef.py +504 -0
- doc_control/ooxml/zipio.py +454 -0
- doc_control/ops/__init__.py +1 -0
- doc_control/ops/anchor.py +135 -0
- doc_control/ops/batch.py +177 -0
- doc_control/ops/comments.py +433 -0
- doc_control/ops/compare.py +620 -0
- doc_control/ops/diff.py +177 -0
- doc_control/ops/dotx.py +206 -0
- doc_control/ops/find.py +190 -0
- doc_control/ops/history.py +479 -0
- doc_control/ops/layout.py +40 -0
- doc_control/ops/locate.py +364 -0
- doc_control/ops/meta.py +49 -0
- doc_control/ops/page.py +104 -0
- doc_control/ops/paging.py +124 -0
- doc_control/ops/read.py +517 -0
- doc_control/ops/recipe.py +108 -0
- doc_control/ops/rename.py +370 -0
- doc_control/ops/revision.py +616 -0
- doc_control/ops/segment.py +289 -0
- doc_control/ops/styleset.py +262 -0
- doc_control/ops/stylewrite.py +573 -0
- doc_control/ops/text.py +207 -0
- doc_control/ops/tracked.py +1441 -0
- doc_control/py.typed +0 -0
- doc_control/recipes/engine.py +209 -0
- doc_control/recipes/schema.py +140 -0
- doc_control/recipes/store.py +96 -0
- doc_control/registry.py +76 -0
- doc_control/safety/__init__.py +38 -0
- doc_control/safety/history.py +508 -0
- doc_control/safety/lock.py +69 -0
- doc_control/safety/store.py +415 -0
- doc_control/safety/verify.py +357 -0
- doc_control/schema.py +143 -0
- doc_control-0.0.1.dev0.dist-info/METADATA +62 -0
- doc_control-0.0.1.dev0.dist-info/RECORD +68 -0
- doc_control-0.0.1.dev0.dist-info/WHEEL +4 -0
- doc_control-0.0.1.dev0.dist-info/entry_points.txt +2 -0
- 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
|