@tasksai/install 0.1.38 → 0.1.40
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.
- package/README.md +34 -0
- package/bootstrap/Install RealtorTasksAI.command +37 -0
- package/bootstrap/Install RealtorTasksAI.ps1 +42 -0
- package/package.json +14 -3
- package/runtime/document_renderer.py +882 -0
- package/runtime/server.py +55 -581
- package/runtime/software_use.py +39 -0
- package/runtime/workflow-requirements.txt +5 -0
- package/runtime/workflows/__init__.py +0 -0
- package/runtime/workflows/account_snapshot.py +33 -0
- package/runtime/workflows/attachments.py +45 -0
- package/runtime/workflows/authority_guides.py +81 -0
- package/runtime/workflows/catalog.py +51 -0
- package/runtime/workflows/catalog_app.py +174 -0
- package/runtime/workflows/catalog_generation.py +186 -0
- package/runtime/workflows/catalog_output.py +312 -0
- package/runtime/workflows/catalog_suggestions.py +51 -0
- package/runtime/workflows/catalog_workspace.py +152 -0
- package/runtime/workflows/cli.py +66 -0
- package/runtime/workflows/document_answers.py +96 -0
- package/runtime/workflows/document_selection.py +50 -0
- package/runtime/workflows/documents.py +243 -0
- package/runtime/workflows/embedded/catalog.html +83 -0
- package/runtime/workflows/embedded/offer-review.html +516 -0
- package/runtime/workflows/embedded/preview.html +36 -0
- package/runtime/workflows/embedded_actions.py +96 -0
- package/runtime/workflows/embedded_demo.py +424 -0
- package/runtime/workflows/folders.py +120 -0
- package/runtime/workflows/gateway.py +157 -0
- package/runtime/workflows/generation_lock.py +26 -0
- package/runtime/workflows/launcher.py +61 -0
- package/runtime/workflows/licensed_delivery.py +46 -0
- package/runtime/workflows/mcp_dev.py +52 -0
- package/runtime/workflows/meeting_plan.py +92 -0
- package/runtime/workflows/model_client.py +26 -0
- package/runtime/workflows/numeric_consistency.py +72 -0
- package/runtime/workflows/public_source_fetch.py +66 -0
- package/runtime/workflows/realtor/__init__.py +0 -0
- package/runtime/workflows/realtor/adapter.py +179 -0
- package/runtime/workflows/realtor/seller_offer/ORIGIN.json +16 -0
- package/runtime/workflows/realtor/seller_offer/__init__.py +0 -0
- package/runtime/workflows/realtor/seller_offer/examples/demo-input.json +414 -0
- package/runtime/workflows/realtor/seller_offer/examples/make_demo.py +44 -0
- package/runtime/workflows/realtor/seller_offer/references/input-contract.md +65 -0
- package/runtime/workflows/realtor/seller_offer/references/source-boundaries.md +16 -0
- package/runtime/workflows/realtor/seller_offer/scripts/__init__.py +0 -0
- package/runtime/workflows/realtor/seller_offer/scripts/build_workbook.mjs +233 -0
- package/runtime/workflows/realtor/seller_offer/scripts/build_workbook.py +163 -0
- package/runtime/workflows/realtor/seller_offer/scripts/offer_engine.py +370 -0
- package/runtime/workflows/realtor/seller_offer/scripts/presentation.py +52 -0
- package/runtime/workflows/realtor/seller_offer/scripts/render_outputs.py +228 -0
- package/runtime/workflows/realtor/seller_offer/scripts/run_package.py +95 -0
- package/runtime/workflows/realtor/seller_offer/tests/test_engine.py +252 -0
- package/runtime/workflows/realtor/seller_offer/tests/test_workbook.mjs +28 -0
- package/runtime/workflows/realtor-release-registry.json +705 -0
- package/runtime/workflows/released_catalog.py +91 -0
- package/runtime/workflows/source_capture.py +82 -0
- package/runtime/workflows/store.py +492 -0
- package/runtime/workflows/table_calculations.py +132 -0
- package/runtime/workflows/template.py +65 -0
- package/runtime/workflows/workspace_launch.py +76 -0
- package/src/index.js +217 -118
- package/src/managed-python.js +63 -0
- package/src/operation-lock.js +22 -0
- package/src/prepared-update.js +86 -0
- package/src/private-workflow.js +116 -0
- package/src/python-runtime.js +50 -0
- package/src/recover-installation.js +30 -0
- package/src/recovery-lock.js +30 -0
- package/src/software-hash.js +24 -0
- package/src/software-use.js +32 -0
- package/src/update-journal.js +24 -0
- package/src/update-recovery.js +72 -0
- package/src/workspace-runtime.js +45 -0
|
@@ -0,0 +1,882 @@
|
|
|
1
|
+
"""Shared local Word renderer used by the loader and saved workflows."""
|
|
2
|
+
import re
|
|
3
|
+
|
|
4
|
+
__all__ = ['markdown_table_row', 'markdown_table_alignments', 'is_markdown_table_start', 'inferred_plain_heading_level', 'labeled_fact', 'add_labeled_text', 'add_labeled_fact', 'iter_inline_markdown', 'add_hyperlink', 'add_markdown_text', 'add_markdown_heading', 'add_horizontal_rule', 'set_cell_shading', 'set_cell_margins', 'repeat_table_header', 'prevent_table_row_split', 'add_page_number', 'add_markdown_table', 'write_docx']
|
|
5
|
+
|
|
6
|
+
def markdown_table_row(line):
|
|
7
|
+
"""Return parsed table cells when a line looks like a Markdown table row."""
|
|
8
|
+
stripped = line.strip()
|
|
9
|
+
if "|" not in stripped:
|
|
10
|
+
return None
|
|
11
|
+
if stripped.startswith("|"):
|
|
12
|
+
stripped = stripped[1:]
|
|
13
|
+
if stripped.endswith("|"):
|
|
14
|
+
stripped = stripped[:-1]
|
|
15
|
+
cells = []
|
|
16
|
+
current = []
|
|
17
|
+
escaped = False
|
|
18
|
+
for char in stripped:
|
|
19
|
+
if escaped:
|
|
20
|
+
current.append(char)
|
|
21
|
+
escaped = False
|
|
22
|
+
elif char == "\\":
|
|
23
|
+
escaped = True
|
|
24
|
+
elif char == "|":
|
|
25
|
+
cells.append("".join(current).strip())
|
|
26
|
+
current = []
|
|
27
|
+
else:
|
|
28
|
+
current.append(char)
|
|
29
|
+
cells.append("".join(current).strip())
|
|
30
|
+
return cells if cells else None
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def markdown_table_alignments(separator_cells):
|
|
34
|
+
"""Return column alignments if cells are a valid Markdown table separator."""
|
|
35
|
+
alignments = []
|
|
36
|
+
for cell in separator_cells or []:
|
|
37
|
+
compact = cell.replace(" ", "")
|
|
38
|
+
if not re.match(r"^:?-{3,}:?$", compact):
|
|
39
|
+
return None
|
|
40
|
+
if compact.startswith(":") and compact.endswith(":"):
|
|
41
|
+
alignments.append("center")
|
|
42
|
+
elif compact.endswith(":"):
|
|
43
|
+
alignments.append("right")
|
|
44
|
+
else:
|
|
45
|
+
alignments.append("left")
|
|
46
|
+
return alignments
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def is_markdown_table_start(lines, index):
|
|
50
|
+
"""True when lines[index:index+2] form a Markdown table header."""
|
|
51
|
+
if index + 1 >= len(lines):
|
|
52
|
+
return False
|
|
53
|
+
header = markdown_table_row(lines[index])
|
|
54
|
+
separator = markdown_table_row(lines[index + 1])
|
|
55
|
+
return bool(header and markdown_table_alignments(separator))
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def inferred_plain_heading_level(lines, index):
|
|
59
|
+
"""Infer conservative headings when an AI omits Markdown # markers."""
|
|
60
|
+
# Explicit Markdown line breaks belong to one prose block (for example,
|
|
61
|
+
# a sender name and brokerage). Title case does not make them headings.
|
|
62
|
+
if lines[index].endswith(" ") or (index > 0 and lines[index - 1].endswith(" ")):
|
|
63
|
+
return None
|
|
64
|
+
text = lines[index].strip()
|
|
65
|
+
if not text or len(text) > 90 or "|" in text or ":" in text:
|
|
66
|
+
return None
|
|
67
|
+
if re.match(r"^(?:[-*+]\s+|\d+\.\s+|>|<!--)", text):
|
|
68
|
+
return None
|
|
69
|
+
if text.endswith((".", "?", "!", ";")):
|
|
70
|
+
return None
|
|
71
|
+
|
|
72
|
+
letters = [char for char in text if char.isalpha()]
|
|
73
|
+
if len(letters) >= 4 and all(char.isupper() for char in letters):
|
|
74
|
+
return 2
|
|
75
|
+
|
|
76
|
+
next_index = index + 1
|
|
77
|
+
while next_index < len(lines) and not lines[next_index].strip():
|
|
78
|
+
next_index += 1
|
|
79
|
+
next_line = lines[next_index] if next_index < len(lines) else ""
|
|
80
|
+
introduces_list = bool(
|
|
81
|
+
re.match(r"^\s*[-*+]\s+\S", next_line)
|
|
82
|
+
or re.match(r"^\s*\d+\.\s+\S", next_line)
|
|
83
|
+
)
|
|
84
|
+
introduces_facts = bool(re.match(r"^\s*[^:\n]{1,55}:\s+\S", next_line))
|
|
85
|
+
if introduces_list or introduces_facts or text.istitle():
|
|
86
|
+
return 3
|
|
87
|
+
return None
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def labeled_fact(line):
|
|
91
|
+
"""Return a label/value pair for a short professional fact line."""
|
|
92
|
+
bold_match = re.match(r"^\*\*([^*\n:]{1,55}):\*\*\s+(.+)$", line.strip())
|
|
93
|
+
if bold_match:
|
|
94
|
+
return bold_match.group(1).strip(), bold_match.group(2).strip()
|
|
95
|
+
match = re.match(r"^([^:\n]{1,55}):\s+(.+)$", line.strip())
|
|
96
|
+
if not match:
|
|
97
|
+
return None
|
|
98
|
+
if re.search(r"[`*_\[\]]", match.group(1)):
|
|
99
|
+
# A colon after inline Markdown is sentence punctuation, not a
|
|
100
|
+
# short label/value field. Let the normal inline parser handle it.
|
|
101
|
+
return None
|
|
102
|
+
return match.group(1).strip(), match.group(2).strip()
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def add_labeled_text(paragraph, label, value):
|
|
106
|
+
"""Render a label/value pair in an existing Word paragraph."""
|
|
107
|
+
label_run = paragraph.add_run(f"{label}: ")
|
|
108
|
+
label_run.bold = True
|
|
109
|
+
add_markdown_text(paragraph, value)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def add_labeled_fact(doc, label, value):
|
|
113
|
+
"""Render a fact line with a bold label and distinct Word paragraph."""
|
|
114
|
+
paragraph = doc.add_paragraph()
|
|
115
|
+
add_labeled_text(paragraph, label, value)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def iter_inline_markdown(text):
|
|
119
|
+
"""Yield (text, marks) spans for common inline Markdown."""
|
|
120
|
+
text = re.sub(r"\\([\\`*_{}\[\]()#+.!-])", r"\1", text)
|
|
121
|
+
token_re = re.compile(
|
|
122
|
+
r"(`[^`]+`|\*\*[^*]+\*\*|(?<!_)__(?![_\s])[^_]+?(?<!\s)__(?!_)|\*[^*\s][^*]*\*|(?<!\w)_[^_\s][^_]*_(?!\w)|\[[^\]]+\]\([^)]+\))"
|
|
123
|
+
)
|
|
124
|
+
pos = 0
|
|
125
|
+
for match in token_re.finditer(text):
|
|
126
|
+
if match.start() > pos:
|
|
127
|
+
yield text[pos:match.start()], {}
|
|
128
|
+
token = match.group(0)
|
|
129
|
+
marks = {}
|
|
130
|
+
value = token
|
|
131
|
+
if token.startswith("**") and token.endswith("**"):
|
|
132
|
+
value = token[2:-2]
|
|
133
|
+
marks["bold"] = True
|
|
134
|
+
elif token.startswith("__") and token.endswith("__"):
|
|
135
|
+
value = token[2:-2]
|
|
136
|
+
marks["bold"] = True
|
|
137
|
+
elif token.startswith("`") and token.endswith("`"):
|
|
138
|
+
value = token[1:-1]
|
|
139
|
+
marks["code"] = True
|
|
140
|
+
elif token.startswith("["):
|
|
141
|
+
link = re.match(r"^\[([^\]]+)\]\(([^)]+)\)$", token)
|
|
142
|
+
if link:
|
|
143
|
+
value = link.group(1)
|
|
144
|
+
marks["link"] = link.group(2)
|
|
145
|
+
elif token[0] in {"*", "_"} and token.endswith(token[0]):
|
|
146
|
+
value = token[1:-1]
|
|
147
|
+
marks["italic"] = True
|
|
148
|
+
yield value, marks
|
|
149
|
+
pos = match.end()
|
|
150
|
+
if pos < len(text):
|
|
151
|
+
yield text[pos:], {}
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def add_hyperlink(paragraph, text, url):
|
|
155
|
+
"""Add a clickable hyperlink run when python-docx low-level APIs are available."""
|
|
156
|
+
from docx.oxml import OxmlElement
|
|
157
|
+
from docx.oxml.ns import qn
|
|
158
|
+
|
|
159
|
+
part = paragraph.part
|
|
160
|
+
relationship_id = part.relate_to(
|
|
161
|
+
url,
|
|
162
|
+
"http://schemas.openxmlformats.org/officeDocument/2006/relationships/hyperlink",
|
|
163
|
+
is_external=True,
|
|
164
|
+
)
|
|
165
|
+
|
|
166
|
+
hyperlink = OxmlElement("w:hyperlink")
|
|
167
|
+
hyperlink.set(qn("r:id"), relationship_id)
|
|
168
|
+
run_element = OxmlElement("w:r")
|
|
169
|
+
properties = OxmlElement("w:rPr")
|
|
170
|
+
|
|
171
|
+
color = OxmlElement("w:color")
|
|
172
|
+
color.set(qn("w:val"), "0563C1")
|
|
173
|
+
properties.append(color)
|
|
174
|
+
|
|
175
|
+
underline = OxmlElement("w:u")
|
|
176
|
+
underline.set(qn("w:val"), "single")
|
|
177
|
+
properties.append(underline)
|
|
178
|
+
|
|
179
|
+
run_element.append(properties)
|
|
180
|
+
text_element = OxmlElement("w:t")
|
|
181
|
+
text_element.text = text
|
|
182
|
+
run_element.append(text_element)
|
|
183
|
+
hyperlink.append(run_element)
|
|
184
|
+
paragraph._p.append(hyperlink)
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def add_markdown_text(paragraph, text):
|
|
188
|
+
"""Add common inline Markdown formatting to a python-docx paragraph."""
|
|
189
|
+
for value, marks in iter_inline_markdown(text):
|
|
190
|
+
if not value:
|
|
191
|
+
continue
|
|
192
|
+
if marks.get("link"):
|
|
193
|
+
try:
|
|
194
|
+
add_hyperlink(paragraph, value, marks["link"])
|
|
195
|
+
except Exception:
|
|
196
|
+
paragraph.add_run(f"{value} ({marks['link']})")
|
|
197
|
+
continue
|
|
198
|
+
run = paragraph.add_run(value)
|
|
199
|
+
if marks.get("bold"):
|
|
200
|
+
run.bold = True
|
|
201
|
+
if re.match(r"^\+\d", value):
|
|
202
|
+
# Aptos substitution in some headless Word renderers maps a
|
|
203
|
+
# bold leading plus sign to a diamond. Arial preserves the
|
|
204
|
+
# intended glyph and remains portable in Office.
|
|
205
|
+
run.font.name = "Arial"
|
|
206
|
+
if marks.get("italic"):
|
|
207
|
+
run.italic = True
|
|
208
|
+
if marks.get("code"):
|
|
209
|
+
run.font.name = "Courier New"
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def add_markdown_heading(doc, text, level):
|
|
213
|
+
"""Create a Word heading while parsing any inline Markdown in its text."""
|
|
214
|
+
|
|
215
|
+
paragraph = doc.add_heading(level=level)
|
|
216
|
+
add_markdown_text(paragraph, text)
|
|
217
|
+
return paragraph
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def add_horizontal_rule(paragraph):
|
|
221
|
+
"""Render a horizontal Markdown rule as a Word paragraph border."""
|
|
222
|
+
from docx.oxml import OxmlElement
|
|
223
|
+
from docx.oxml.ns import qn
|
|
224
|
+
|
|
225
|
+
p_pr = paragraph._p.get_or_add_pPr()
|
|
226
|
+
borders = p_pr.first_child_found_in("w:pBdr")
|
|
227
|
+
if borders is None:
|
|
228
|
+
borders = OxmlElement("w:pBdr")
|
|
229
|
+
p_pr.append(borders)
|
|
230
|
+
bottom = OxmlElement("w:bottom")
|
|
231
|
+
bottom.set(qn("w:val"), "single")
|
|
232
|
+
bottom.set(qn("w:sz"), "6")
|
|
233
|
+
bottom.set(qn("w:space"), "1")
|
|
234
|
+
bottom.set(qn("w:color"), "BFBFBF")
|
|
235
|
+
borders.append(bottom)
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def set_cell_shading(cell, fill):
|
|
239
|
+
"""Apply a solid background color to a Word table cell."""
|
|
240
|
+
from docx.oxml import OxmlElement
|
|
241
|
+
from docx.oxml.ns import qn
|
|
242
|
+
|
|
243
|
+
cell_properties = cell._tc.get_or_add_tcPr()
|
|
244
|
+
shading = cell_properties.find(qn("w:shd"))
|
|
245
|
+
if shading is None:
|
|
246
|
+
shading = OxmlElement("w:shd")
|
|
247
|
+
cell_properties.append(shading)
|
|
248
|
+
shading.set(qn("w:fill"), fill)
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def set_cell_margins(cell, top=70, start=90, bottom=70, end=90):
|
|
252
|
+
"""Set compact, readable table cell margins in twentieths of a point."""
|
|
253
|
+
from docx.oxml import OxmlElement
|
|
254
|
+
from docx.oxml.ns import qn
|
|
255
|
+
|
|
256
|
+
cell_properties = cell._tc.get_or_add_tcPr()
|
|
257
|
+
margins = cell_properties.first_child_found_in("w:tcMar")
|
|
258
|
+
if margins is None:
|
|
259
|
+
margins = OxmlElement("w:tcMar")
|
|
260
|
+
cell_properties.append(margins)
|
|
261
|
+
for name, value in (("top", top), ("start", start), ("bottom", bottom), ("end", end)):
|
|
262
|
+
node = margins.find(qn(f"w:{name}"))
|
|
263
|
+
if node is None:
|
|
264
|
+
node = OxmlElement(f"w:{name}")
|
|
265
|
+
margins.append(node)
|
|
266
|
+
node.set(qn("w:w"), str(value))
|
|
267
|
+
node.set(qn("w:type"), "dxa")
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def repeat_table_header(row):
|
|
271
|
+
"""Repeat the first row of a long table on subsequent pages."""
|
|
272
|
+
from docx.oxml import OxmlElement
|
|
273
|
+
from docx.oxml.ns import qn
|
|
274
|
+
|
|
275
|
+
row_properties = row._tr.get_or_add_trPr()
|
|
276
|
+
repeat = row_properties.find(qn("w:tblHeader"))
|
|
277
|
+
if repeat is None:
|
|
278
|
+
repeat = OxmlElement("w:tblHeader")
|
|
279
|
+
row_properties.append(repeat)
|
|
280
|
+
repeat.set(qn("w:val"), "true")
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
def prevent_table_row_split(row):
|
|
284
|
+
"""Keep a table row together when Word paginates the document."""
|
|
285
|
+
from docx.oxml import OxmlElement
|
|
286
|
+
from docx.oxml.ns import qn
|
|
287
|
+
|
|
288
|
+
row_properties = row._tr.get_or_add_trPr()
|
|
289
|
+
if row_properties.find(qn("w:cantSplit")) is None:
|
|
290
|
+
row_properties.append(OxmlElement("w:cantSplit"))
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
def add_page_number(paragraph):
|
|
294
|
+
"""Add a dynamic PAGE field to a footer paragraph."""
|
|
295
|
+
from docx.oxml import OxmlElement
|
|
296
|
+
from docx.oxml.ns import qn
|
|
297
|
+
|
|
298
|
+
run = paragraph.add_run()
|
|
299
|
+
begin = OxmlElement("w:fldChar")
|
|
300
|
+
begin.set(qn("w:fldCharType"), "begin")
|
|
301
|
+
instruction = OxmlElement("w:instrText")
|
|
302
|
+
instruction.set(qn("xml:space"), "preserve")
|
|
303
|
+
instruction.text = " PAGE "
|
|
304
|
+
end = OxmlElement("w:fldChar")
|
|
305
|
+
end.set(qn("w:fldCharType"), "end")
|
|
306
|
+
run._r.extend((begin, instruction, end))
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
def add_markdown_table(doc, rows, alignments):
|
|
310
|
+
"""Render a Markdown pipe table as a native Word table."""
|
|
311
|
+
from docx.enum.text import WD_ALIGN_PARAGRAPH
|
|
312
|
+
from docx.enum.section import WD_SECTION_START, WD_ORIENT
|
|
313
|
+
from docx.shared import Pt, RGBColor
|
|
314
|
+
|
|
315
|
+
if not rows:
|
|
316
|
+
return
|
|
317
|
+
|
|
318
|
+
width = max(len(row) for row in rows)
|
|
319
|
+
# A few records with many fields are easier to compare down the page.
|
|
320
|
+
# Transpose only rectangular tables that would require landscape; preserve every cell and
|
|
321
|
+
# leave the caller's data (including the Excel representation) untouched.
|
|
322
|
+
if width > 6 and 2 <= len(rows) <= 6 and all(len(row) == width for row in rows):
|
|
323
|
+
rows = [list(column) for column in zip(*rows)]
|
|
324
|
+
width = len(rows[0])
|
|
325
|
+
alignments = ["left"] * width
|
|
326
|
+
original=doc.sections[-1]
|
|
327
|
+
page_size=(original.page_width,original.page_height,original.orientation)
|
|
328
|
+
landscape=width>6 and original.page_width<original.page_height
|
|
329
|
+
# Carry a nearby heading and short introduction onto the table page.
|
|
330
|
+
# Only inspect adjacent paragraphs; never cross another table.
|
|
331
|
+
lead=None
|
|
332
|
+
introductions=[]
|
|
333
|
+
lead_count=0
|
|
334
|
+
introduction_length=0
|
|
335
|
+
heading_count=0
|
|
336
|
+
following_node=doc._element.body.sectPr
|
|
337
|
+
for paragraph in reversed(doc.paragraphs[-6:]):
|
|
338
|
+
if paragraph._p.xpath('./w:pPr/w:sectPr'):break
|
|
339
|
+
if paragraph._p.getnext() is not following_node or len(paragraph.text)>600:break
|
|
340
|
+
introduction_length+=len(paragraph.text)
|
|
341
|
+
if introduction_length>600:break
|
|
342
|
+
following_node=paragraph._p
|
|
343
|
+
introductions.append(paragraph)
|
|
344
|
+
visible_runs=[run for run in paragraph.runs if run.text.strip()]
|
|
345
|
+
if paragraph.style.name.startswith('Heading') or (visible_runs and all(run.bold for run in visible_runs)):
|
|
346
|
+
lead=paragraph._p
|
|
347
|
+
lead_count=len(introductions)
|
|
348
|
+
heading_count+=1
|
|
349
|
+
# A portrait table needs only its own major section heading.
|
|
350
|
+
# Landscape transitions also carry nearby introductory sections
|
|
351
|
+
# because the inserted page boundary overrides their keep rules.
|
|
352
|
+
if not landscape and paragraph.style.name in ('Heading 1', 'Heading 2'):
|
|
353
|
+
break
|
|
354
|
+
# A table caption can follow a short introduction and its section
|
|
355
|
+
# heading. Continue across those adjacent paragraphs so they travel
|
|
356
|
+
# together instead of leaving an introduction on its own page.
|
|
357
|
+
if heading_count==2:break
|
|
358
|
+
# A longer section introduction may exceed the bounded scan before its
|
|
359
|
+
# section heading is reached. Keep the adjacent short tail with the wide
|
|
360
|
+
# table caption rather than stranding it on a portrait page by itself.
|
|
361
|
+
if landscape and heading_count==1 and len(introductions)>lead_count:
|
|
362
|
+
lead=introductions[-1]._p
|
|
363
|
+
lead_count=len(introductions)
|
|
364
|
+
# A section title can sit directly above a second title in the scanned
|
|
365
|
+
# introduction. Carry that adjacent heading too; otherwise an inserted
|
|
366
|
+
# landscape boundary overrides Word's keep-with-next and strands it.
|
|
367
|
+
from docx.text.paragraph import Paragraph
|
|
368
|
+
from docx.oxml.ns import qn
|
|
369
|
+
if landscape and lead is not None:
|
|
370
|
+
for _ in range(2):
|
|
371
|
+
previous = lead.getprevious()
|
|
372
|
+
if previous is None or previous.tag != qn('w:p') or previous.xpath('./w:pPr/w:sectPr'):
|
|
373
|
+
break
|
|
374
|
+
paragraph = Paragraph(previous, doc._body)
|
|
375
|
+
if not paragraph.style.name.startswith('Heading') or introduction_length + len(paragraph.text) > 600:
|
|
376
|
+
break
|
|
377
|
+
introduction_length += len(paragraph.text)
|
|
378
|
+
paragraph.paragraph_format.keep_with_next = True
|
|
379
|
+
lead = previous
|
|
380
|
+
if lead is not None:
|
|
381
|
+
for introduction in introductions[:lead_count]:
|
|
382
|
+
introduction.paragraph_format.keep_with_next = True
|
|
383
|
+
boundary=lead.getprevious() if lead is not None else None
|
|
384
|
+
# A short table footnote or total can sit between two table sections.
|
|
385
|
+
# Keep that bridge in the existing landscape group instead of creating a
|
|
386
|
+
# nearly empty portrait page. Longer narrative and intervening headings
|
|
387
|
+
# remain separate; never cross another table or a non-paragraph element.
|
|
388
|
+
cursor=boundary
|
|
389
|
+
bridge_characters=0
|
|
390
|
+
for _ in range(5):
|
|
391
|
+
if cursor is None or cursor.tag != qn('w:p'):break
|
|
392
|
+
if cursor.find(qn('w:pPr')+'/'+qn('w:sectPr')) is not None:
|
|
393
|
+
boundary=cursor
|
|
394
|
+
break
|
|
395
|
+
bridge=Paragraph(cursor,doc)
|
|
396
|
+
bridge_characters+=len(bridge.text)
|
|
397
|
+
if bridge.style.name.startswith('Heading') or bridge_characters>600:break
|
|
398
|
+
cursor=cursor.getprevious()
|
|
399
|
+
prior_section=boundary.find(qn('w:pPr')+'/'+qn('w:sectPr')) if boundary is not None else None
|
|
400
|
+
prior_size=prior_section.find(qn('w:pgSz')) if prior_section is not None else None
|
|
401
|
+
continuing_landscape=bool(prior_size is not None and prior_size.get(qn('w:orient'))=='landscape' and not ''.join(boundary.itertext()).strip())
|
|
402
|
+
# Adjacent table groups can contain narrower tables too. Keep the group
|
|
403
|
+
# together instead of inserting portrait pages between landscape tables.
|
|
404
|
+
landscape=landscape or (continuing_landscape and original.page_width<original.page_height)
|
|
405
|
+
# A first table after a brief introduction has ample first-page space.
|
|
406
|
+
# Avoid turning that introduction into an accidental cover page.
|
|
407
|
+
brief_opening = not doc.tables and sum(len(p.text) for p in doc.paragraphs) <= 700 and len(doc.paragraphs) <= 8
|
|
408
|
+
if len(rows) > 20 and not landscape and not brief_opening:
|
|
409
|
+
# Start long comparisons with their heading on a fresh page. Some
|
|
410
|
+
# Office renderers can omit continuation rows when a nearly page-sized
|
|
411
|
+
# table begins in the last available line of the preceding page.
|
|
412
|
+
start=next((p for p in doc.paragraphs if p._p is lead),None)
|
|
413
|
+
if start is not None:
|
|
414
|
+
paragraphs=doc.paragraphs
|
|
415
|
+
index=next(i for i,p in enumerate(paragraphs) if p._p is lead)
|
|
416
|
+
if index and paragraphs[index-1].text.strip()==start.text.strip():
|
|
417
|
+
start=paragraphs[index-1]
|
|
418
|
+
start.paragraph_format.keep_with_next=True
|
|
419
|
+
else:
|
|
420
|
+
start=doc.add_paragraph()
|
|
421
|
+
start.paragraph_format.keep_with_next=True
|
|
422
|
+
start.paragraph_format.page_break_before=True
|
|
423
|
+
if landscape:
|
|
424
|
+
if continuing_landscape:
|
|
425
|
+
# Extend the landscape section through this table, its short
|
|
426
|
+
# introduction and any bounded notes after the previous table.
|
|
427
|
+
boundary.getparent().remove(boundary)
|
|
428
|
+
wide=doc.sections[-1]
|
|
429
|
+
elif brief_opening:
|
|
430
|
+
# Use the opening page for its first wide table instead of leaving
|
|
431
|
+
# a short title/introduction on an otherwise empty portrait page.
|
|
432
|
+
wide=original
|
|
433
|
+
else:
|
|
434
|
+
wide=doc.add_section(WD_SECTION_START.NEW_PAGE)
|
|
435
|
+
if lead is not None:lead.addprevious(doc.paragraphs[-1]._p)
|
|
436
|
+
wide.orientation=WD_ORIENT.LANDSCAPE
|
|
437
|
+
wide.page_width,wide.page_height=page_size[1],page_size[0]
|
|
438
|
+
normalized = [row + [""] * (width - len(row)) for row in rows]
|
|
439
|
+
table = doc.add_table(rows=len(normalized), cols=width)
|
|
440
|
+
table.style = "Table Grid"
|
|
441
|
+
table.autofit = True
|
|
442
|
+
# Email addresses are read as a unit. In a small contact directory, reserve
|
|
443
|
+
# enough width for a raw-address column instead of stranding its last letter.
|
|
444
|
+
if 3 <= width <= 4 and len(normalized) > 2:
|
|
445
|
+
email_columns = [i for i in range(width) if any(row[i].strip() for row in normalized[1:])
|
|
446
|
+
and all(not row[i].strip() or re.fullmatch(r'[^\s@]+@[^\s@]+\.[^\s@]+', row[i].strip()) for row in normalized[1:])]
|
|
447
|
+
if len(email_columns) == 1:
|
|
448
|
+
email_column = email_columns[0]
|
|
449
|
+
section = doc.sections[-1]
|
|
450
|
+
available = section.page_width - section.left_margin - section.right_margin
|
|
451
|
+
longest = max(len(row[email_column]) for row in normalized[1:])
|
|
452
|
+
fraction = min(0.45, max(1 / width, (longest * 4.5 + 12) / (available / 12700)))
|
|
453
|
+
table.autofit = False
|
|
454
|
+
for i, column in enumerate(table.columns):
|
|
455
|
+
size = int(available * (fraction if i == email_column else (1 - fraction) / (width - 1)))
|
|
456
|
+
column.width = size
|
|
457
|
+
for cell in column.cells:
|
|
458
|
+
cell.width = size
|
|
459
|
+
# Give a dominant narrative column room beside short owner/date/status
|
|
460
|
+
# fields. Restrict this to narrow tables; balanced comparisons keep their
|
|
461
|
+
# existing layout. No headings or cell contents are rewritten.
|
|
462
|
+
if 3 <= width <= 4 and len(normalized) > 2:
|
|
463
|
+
averages = [sum(len(row[i]) for row in normalized[1:]) / (len(normalized)-1) for i in range(width)]
|
|
464
|
+
dominant = max(range(width), key=averages.__getitem__)
|
|
465
|
+
others = [value for i, value in enumerate(averages) if i != dominant]
|
|
466
|
+
if averages[dominant] >= 100 and averages[dominant] >= 3 * max(others):
|
|
467
|
+
section = doc.sections[-1]
|
|
468
|
+
available = section.page_width - section.left_margin - section.right_margin
|
|
469
|
+
table.autofit = False
|
|
470
|
+
for i, column in enumerate(table.columns):
|
|
471
|
+
size = int(available * (0.5 if i == dominant else 0.5 / (width-1)))
|
|
472
|
+
column.width = size
|
|
473
|
+
for cell in column.cells:
|
|
474
|
+
cell.width = size
|
|
475
|
+
repeat_table_header(table.rows[0])
|
|
476
|
+
# Short summaries should travel as a unit instead of leaving their final
|
|
477
|
+
# one or two rows on an otherwise empty page. Bound both row count and
|
|
478
|
+
# estimated wrapping so long narrative tables can still paginate normally.
|
|
479
|
+
import math
|
|
480
|
+
section = doc.sections[-1]
|
|
481
|
+
cell_characters = max(12, int((section.page_width.pt-section.left_margin.pt-section.right_margin.pt)/width/4.5)-4)
|
|
482
|
+
row_lines = [max((sum(max(1, math.ceil(len(line)/cell_characters)) for line in value.split('\n')) for value in row), default=1) for row in normalized]
|
|
483
|
+
compact_table = len(normalized) <= 12 and sum(row_lines) <= 22
|
|
484
|
+
# Let long tables flow, but avoid a lone opening or closing data row when
|
|
485
|
+
# its neighboring rows form a small block. Do not chain very tall rows.
|
|
486
|
+
opening_block = len(normalized) >= 3 and sum(row_lines[:3]) <= 12
|
|
487
|
+
closing_block = len(normalized) >= 3 and sum(row_lines[-2:]) <= 12
|
|
488
|
+
for row_index, row in enumerate(normalized):
|
|
489
|
+
row_characters = sum(len(value) for value in row)
|
|
490
|
+
longest_cell = max((len(value) for value in row), default=0)
|
|
491
|
+
if row_index == 0 or (row_characters <= 600 and longest_cell <= 350):
|
|
492
|
+
# Keep ordinary rows intact so a cell does not strand a fragment
|
|
493
|
+
# on the next page. Exceptionally large rows may still split to
|
|
494
|
+
# avoid creating a mostly blank page.
|
|
495
|
+
prevent_table_row_split(table.rows[row_index])
|
|
496
|
+
for column_index, value in enumerate(row):
|
|
497
|
+
cell = table.cell(row_index, column_index)
|
|
498
|
+
set_cell_margins(cell)
|
|
499
|
+
if row_index == 0:
|
|
500
|
+
set_cell_shading(cell, "17365D")
|
|
501
|
+
elif row_index % 2 == 0:
|
|
502
|
+
set_cell_shading(cell, "EEF3F8")
|
|
503
|
+
paragraph = cell.paragraphs[0]
|
|
504
|
+
if (compact_table and row_index < len(normalized)-1) or (opening_block and row_index < 2) or (closing_block and row_index == len(normalized)-2):
|
|
505
|
+
paragraph.paragraph_format.keep_with_next = True
|
|
506
|
+
paragraph.paragraph_format.space_after = Pt(0)
|
|
507
|
+
add_markdown_text(paragraph, value)
|
|
508
|
+
if row_index == 0:
|
|
509
|
+
for run in paragraph.runs:
|
|
510
|
+
run.bold = True
|
|
511
|
+
run.font.color.rgb = RGBColor(255, 255, 255)
|
|
512
|
+
alignment = alignments[column_index] if column_index < len(alignments) else "left"
|
|
513
|
+
if alignment == "right":
|
|
514
|
+
paragraph.alignment = WD_ALIGN_PARAGRAPH.RIGHT
|
|
515
|
+
elif alignment == "center":
|
|
516
|
+
paragraph.alignment = WD_ALIGN_PARAGRAPH.CENTER
|
|
517
|
+
for run in paragraph.runs:
|
|
518
|
+
run.font.size = Pt(9)
|
|
519
|
+
|
|
520
|
+
doc.add_paragraph()
|
|
521
|
+
if landscape:
|
|
522
|
+
following=doc.add_section(WD_SECTION_START.NEW_PAGE)
|
|
523
|
+
following.page_width,following.page_height,following.orientation=page_size
|
|
524
|
+
|
|
525
|
+
|
|
526
|
+
def write_docx(path, title, content_markdown, product_name, large_print=False, monochrome=False, page_header=''):
|
|
527
|
+
"""Write a professionally styled DOCX file locally."""
|
|
528
|
+
try:
|
|
529
|
+
from docx import Document
|
|
530
|
+
from docx.enum.text import WD_ALIGN_PARAGRAPH
|
|
531
|
+
from docx.shared import Inches, Pt, RGBColor
|
|
532
|
+
except Exception as exc:
|
|
533
|
+
raise RuntimeError(
|
|
534
|
+
"python-docx is not installed. Re-run the official installer or install python-docx in the MCP runtime environment."
|
|
535
|
+
) from exc
|
|
536
|
+
|
|
537
|
+
doc = Document()
|
|
538
|
+
doc.core_properties.title = title or f"{product_name} Work Product"
|
|
539
|
+
doc.core_properties.author = product_name
|
|
540
|
+
|
|
541
|
+
section = doc.sections[0]
|
|
542
|
+
section.top_margin = Inches(1.1 if page_header and large_print else 0.8 if page_header else 0.55)
|
|
543
|
+
section.bottom_margin = Inches(0.55)
|
|
544
|
+
section.left_margin = Inches(0.7)
|
|
545
|
+
section.right_margin = Inches(0.7)
|
|
546
|
+
section.header_distance = Inches(0.3)
|
|
547
|
+
section.footer_distance = Inches(0.3)
|
|
548
|
+
|
|
549
|
+
styles = doc.styles
|
|
550
|
+
normal = styles["Normal"]
|
|
551
|
+
normal.font.name = "Aptos"
|
|
552
|
+
normal.font.size = Pt(9.3)
|
|
553
|
+
normal.font.color.rgb = RGBColor(31, 41, 55)
|
|
554
|
+
normal.paragraph_format.space_after = Pt(4)
|
|
555
|
+
normal.paragraph_format.line_spacing = 1.05
|
|
556
|
+
|
|
557
|
+
title_style = styles["Title"]
|
|
558
|
+
title_style.font.name = "Aptos Display"
|
|
559
|
+
title_style.font.size = Pt(19)
|
|
560
|
+
title_style.font.bold = True
|
|
561
|
+
title_style.font.color.rgb = RGBColor(23, 54, 93)
|
|
562
|
+
title_style.paragraph_format.space_after = Pt(5)
|
|
563
|
+
|
|
564
|
+
heading_sizes = {1: 14, 2: 11.5, 3: 10.5, 4: 9.5}
|
|
565
|
+
for level, size in heading_sizes.items():
|
|
566
|
+
style = styles[f"Heading {level}"]
|
|
567
|
+
style.font.name = "Aptos Display"
|
|
568
|
+
style.font.size = Pt(size)
|
|
569
|
+
style.font.bold = True
|
|
570
|
+
style.font.color.rgb = RGBColor(23, 54, 93)
|
|
571
|
+
style.paragraph_format.space_before = Pt(8 if level <= 2 else 5)
|
|
572
|
+
style.paragraph_format.space_after = Pt(3)
|
|
573
|
+
style.paragraph_format.keep_with_next = True
|
|
574
|
+
|
|
575
|
+
header = section.header.paragraphs[0]
|
|
576
|
+
header.text = page_header or f"{product_name} | Professional Work Product"
|
|
577
|
+
header.alignment = WD_ALIGN_PARAGRAPH.RIGHT
|
|
578
|
+
for run in header.runs:
|
|
579
|
+
run.font.name = "Aptos"
|
|
580
|
+
run.font.size = Pt(7.5)
|
|
581
|
+
run.font.color.rgb = RGBColor(100, 116, 139)
|
|
582
|
+
|
|
583
|
+
footer = section.footer.paragraphs[0]
|
|
584
|
+
footer.alignment = WD_ALIGN_PARAGRAPH.CENTER
|
|
585
|
+
footer_run = footer.add_run(f"Generated locally by {product_name} | Private local output | Page ")
|
|
586
|
+
footer_run.font.name = "Aptos"
|
|
587
|
+
footer_run.font.size = Pt(7.5)
|
|
588
|
+
footer_run.font.color.rgb = RGBColor(100, 116, 139)
|
|
589
|
+
add_page_number(footer)
|
|
590
|
+
|
|
591
|
+
if title:
|
|
592
|
+
doc.add_heading(title, level=0)
|
|
593
|
+
|
|
594
|
+
lines = content_markdown.splitlines()
|
|
595
|
+
paragraph_buffer = []
|
|
596
|
+
|
|
597
|
+
def flush_paragraph():
|
|
598
|
+
if not paragraph_buffer:
|
|
599
|
+
return
|
|
600
|
+
text_parts = []
|
|
601
|
+
for part in paragraph_buffer:
|
|
602
|
+
clean = part.strip()
|
|
603
|
+
if not clean:
|
|
604
|
+
continue
|
|
605
|
+
text_parts.append(clean)
|
|
606
|
+
text_parts.append("\n" if part.endswith(" ") else " ")
|
|
607
|
+
text = "".join(text_parts).rstrip()
|
|
608
|
+
paragraph_buffer.clear()
|
|
609
|
+
if text:
|
|
610
|
+
add_markdown_text(doc.add_paragraph(), text)
|
|
611
|
+
|
|
612
|
+
in_code_block = False
|
|
613
|
+
keep_checklist_together = False
|
|
614
|
+
list_number_id = None
|
|
615
|
+
email_start = None
|
|
616
|
+
|
|
617
|
+
def finish_email():
|
|
618
|
+
nonlocal email_start
|
|
619
|
+
if email_start is None:
|
|
620
|
+
return
|
|
621
|
+
# Keep a short sender/sign-off tail with the final body paragraph.
|
|
622
|
+
# Restrict this to a recognized greeting and a structural boundary.
|
|
623
|
+
paragraphs = doc.paragraphs[email_start:]
|
|
624
|
+
for offset in range(1, min(3, len(paragraphs))):
|
|
625
|
+
tail = paragraphs[-offset]
|
|
626
|
+
if not tail.text.strip() or len(tail.text) > 160:
|
|
627
|
+
break
|
|
628
|
+
paragraphs[-offset - 1].paragraph_format.keep_with_next = True
|
|
629
|
+
email_start = None
|
|
630
|
+
|
|
631
|
+
index = 0
|
|
632
|
+
while index < len(lines):
|
|
633
|
+
raw_line = lines[index]
|
|
634
|
+
line = raw_line.rstrip("\r\n")
|
|
635
|
+
stripped = line.strip()
|
|
636
|
+
if stripped.startswith("```"):
|
|
637
|
+
flush_paragraph()
|
|
638
|
+
in_code_block = not in_code_block
|
|
639
|
+
index += 1
|
|
640
|
+
continue
|
|
641
|
+
if in_code_block:
|
|
642
|
+
para = doc.add_paragraph()
|
|
643
|
+
run = para.add_run(line)
|
|
644
|
+
run.font.name = "Courier New"
|
|
645
|
+
run.font.size = Pt(9)
|
|
646
|
+
index += 1
|
|
647
|
+
continue
|
|
648
|
+
if not stripped:
|
|
649
|
+
flush_paragraph()
|
|
650
|
+
index += 1
|
|
651
|
+
continue
|
|
652
|
+
|
|
653
|
+
heading = re.match(r"^(#{1,4})\s+(.+)$", stripped)
|
|
654
|
+
standalone_bold_heading = re.fullmatch(r"\*\*([^*\n]{1,120})\*\*", stripped)
|
|
655
|
+
inferred_heading = inferred_plain_heading_level(lines, index)
|
|
656
|
+
if email_start is not None:
|
|
657
|
+
# Plain title-case sender names and bold closing notes inside an
|
|
658
|
+
# email are prose. Explicit Markdown headings remain boundaries.
|
|
659
|
+
inferred_heading = None
|
|
660
|
+
standalone_bold_heading = None
|
|
661
|
+
bullet = re.match(r"^(\s*)[-*+]\s+(.+)$", line)
|
|
662
|
+
numbered = re.match(r"^(\s*)\d+\.\s+(.+)$", line)
|
|
663
|
+
if not numbered:
|
|
664
|
+
list_number_id = None
|
|
665
|
+
quote = re.match(r"^>\s?(.+)$", stripped)
|
|
666
|
+
fact = (
|
|
667
|
+
labeled_fact(stripped)
|
|
668
|
+
if not paragraph_buffer
|
|
669
|
+
and not line.endswith(" ")
|
|
670
|
+
and not bullet
|
|
671
|
+
and not numbered
|
|
672
|
+
and not quote
|
|
673
|
+
else None
|
|
674
|
+
)
|
|
675
|
+
|
|
676
|
+
if re.fullmatch(r"<!--\s*[^\n]*?\s*-->", stripped):
|
|
677
|
+
# Public-copy delimiters and other machine-readable Markdown
|
|
678
|
+
# comments are useful in the source but must not appear in the
|
|
679
|
+
# downloadable customer document.
|
|
680
|
+
flush_paragraph()
|
|
681
|
+
index += 1
|
|
682
|
+
continue
|
|
683
|
+
if stripped == ">":
|
|
684
|
+
# A blank line inside a Markdown blockquote is structural spacing,
|
|
685
|
+
# not a literal greater-than character in the Word document.
|
|
686
|
+
flush_paragraph()
|
|
687
|
+
index += 1
|
|
688
|
+
continue
|
|
689
|
+
if re.fullmatch(r"(?:(?:Hi|Hello)(?: [^\n,#*]{1,80})?|Dear [^\n,#*]{1,80}),", stripped):
|
|
690
|
+
flush_paragraph()
|
|
691
|
+
finish_email()
|
|
692
|
+
header = []
|
|
693
|
+
preceding_lines = []
|
|
694
|
+
for preceding in reversed(lines[max(0, index - 20):index]):
|
|
695
|
+
if re.match(r'^\s*#', preceding) or preceding.strip() in {'---', '***', '___'}:
|
|
696
|
+
break
|
|
697
|
+
preceding_lines.append(preceding)
|
|
698
|
+
has_subject = any(re.match(r'^\s*(?:\*\*)?Subject:', item) for item in preceding_lines)
|
|
699
|
+
for previous in reversed(doc.paragraphs[-8:] if has_subject else []):
|
|
700
|
+
if not previous.text.strip() or len(previous.text) > 200:
|
|
701
|
+
break
|
|
702
|
+
header.append(previous)
|
|
703
|
+
if previous.style.name in ('Heading 1', 'Heading 2', 'Heading 3'):
|
|
704
|
+
# Inferred branding headings can follow the subject line;
|
|
705
|
+
# stop only once an actual mail header has been found.
|
|
706
|
+
if any(re.search(r'(^|\n)Subject:', p.text) for p in header):
|
|
707
|
+
break
|
|
708
|
+
if any(re.search(r'(^|\n)Subject:', p.text) for p in header):
|
|
709
|
+
for previous in header:
|
|
710
|
+
previous.paragraph_format.keep_with_next = True
|
|
711
|
+
email_start = len(doc.paragraphs)
|
|
712
|
+
paragraph = doc.add_paragraph()
|
|
713
|
+
paragraph.paragraph_format.keep_with_next = True
|
|
714
|
+
add_markdown_text(paragraph, stripped)
|
|
715
|
+
index += 1
|
|
716
|
+
continue
|
|
717
|
+
if re.fullmatch(r"(?:Warmly|Sincerely|Regards|Best|Best regards|Kind regards|Best wishes|Respectfully|Yours sincerely|Yours truly),", stripped, re.I):
|
|
718
|
+
flush_paragraph()
|
|
719
|
+
# A conventional email closing and adjacent sender lines are prose,
|
|
720
|
+
# not inferred headings. Keep the preceding closing sentence with it.
|
|
721
|
+
if doc.paragraphs:
|
|
722
|
+
previous = doc.paragraphs[-1]
|
|
723
|
+
if previous.text.strip() and len(previous.text) <= 500:
|
|
724
|
+
previous.paragraph_format.keep_with_next = True
|
|
725
|
+
paragraph = doc.add_paragraph()
|
|
726
|
+
paragraph.paragraph_format.keep_together = True
|
|
727
|
+
add_markdown_text(paragraph, stripped)
|
|
728
|
+
index += 1
|
|
729
|
+
for _ in range(4):
|
|
730
|
+
if index >= len(lines):
|
|
731
|
+
break
|
|
732
|
+
sender = lines[index].strip()
|
|
733
|
+
if not sender or sender in {"---", "***", "___"} or len(sender) > 120 or re.match(r"^(?:#|>|[-*+]\s|\d+\.\s|\|)", sender):
|
|
734
|
+
break
|
|
735
|
+
paragraph.add_run().add_break()
|
|
736
|
+
add_markdown_text(paragraph, sender)
|
|
737
|
+
index += 1
|
|
738
|
+
continue
|
|
739
|
+
if is_markdown_table_start(lines, index):
|
|
740
|
+
flush_paragraph()
|
|
741
|
+
finish_email()
|
|
742
|
+
header = markdown_table_row(lines[index])
|
|
743
|
+
alignments = markdown_table_alignments(markdown_table_row(lines[index + 1]))
|
|
744
|
+
table_rows = [header]
|
|
745
|
+
index += 2
|
|
746
|
+
while index < len(lines):
|
|
747
|
+
row = markdown_table_row(lines[index])
|
|
748
|
+
if not row:
|
|
749
|
+
break
|
|
750
|
+
table_rows.append(row)
|
|
751
|
+
index += 1
|
|
752
|
+
add_markdown_table(doc, table_rows, alignments)
|
|
753
|
+
continue
|
|
754
|
+
elif heading:
|
|
755
|
+
flush_paragraph()
|
|
756
|
+
finish_email()
|
|
757
|
+
heading_text = heading.group(2).strip()
|
|
758
|
+
if not (
|
|
759
|
+
title
|
|
760
|
+
and index == 0
|
|
761
|
+
and re.sub(r"\W+", "", heading_text).casefold()
|
|
762
|
+
== re.sub(r"\W+", "", title).casefold()
|
|
763
|
+
):
|
|
764
|
+
add_markdown_heading(doc, heading_text, min(len(heading.group(1)), 4))
|
|
765
|
+
keep_checklist_together = "checklist" in heading_text.casefold()
|
|
766
|
+
index += 1
|
|
767
|
+
elif standalone_bold_heading:
|
|
768
|
+
flush_paragraph()
|
|
769
|
+
add_markdown_heading(doc, standalone_bold_heading.group(1).strip(), 3)
|
|
770
|
+
index += 1
|
|
771
|
+
elif inferred_heading:
|
|
772
|
+
flush_paragraph()
|
|
773
|
+
add_markdown_heading(doc, stripped, inferred_heading)
|
|
774
|
+
keep_checklist_together = "checklist" in stripped.casefold()
|
|
775
|
+
index += 1
|
|
776
|
+
elif fact:
|
|
777
|
+
flush_paragraph()
|
|
778
|
+
add_labeled_fact(doc, fact[0], fact[1])
|
|
779
|
+
index += 1
|
|
780
|
+
elif bullet:
|
|
781
|
+
flush_paragraph()
|
|
782
|
+
para = doc.add_paragraph(style="List Bullet")
|
|
783
|
+
indent_level = min(len(bullet.group(1).replace("\t", " ")) // 2, 4)
|
|
784
|
+
para.paragraph_format.left_indent = Inches(0.25 * indent_level)
|
|
785
|
+
text = bullet.group(2).strip()
|
|
786
|
+
checkbox = re.match(r"^\[([ xX])\]\s+(.+)$", text)
|
|
787
|
+
if checkbox:
|
|
788
|
+
text = f"[{checkbox.group(1).lower()}] {checkbox.group(2).strip()}"
|
|
789
|
+
bullet_fact = labeled_fact(text)
|
|
790
|
+
if bullet_fact:
|
|
791
|
+
add_labeled_text(para, bullet_fact[0], bullet_fact[1])
|
|
792
|
+
else:
|
|
793
|
+
add_markdown_text(para, text)
|
|
794
|
+
next_is_bullet = bool(
|
|
795
|
+
index + 1 < len(lines)
|
|
796
|
+
and re.match(r"^\s*[-*+]\s+\S", lines[index + 1])
|
|
797
|
+
)
|
|
798
|
+
if keep_checklist_together and next_is_bullet:
|
|
799
|
+
para.paragraph_format.keep_with_next = True
|
|
800
|
+
elif keep_checklist_together:
|
|
801
|
+
keep_checklist_together = False
|
|
802
|
+
index += 1
|
|
803
|
+
elif numbered:
|
|
804
|
+
flush_paragraph()
|
|
805
|
+
para = doc.add_paragraph(style="List Number")
|
|
806
|
+
if list_number_id is None:
|
|
807
|
+
numbering = doc.part.numbering_part.element
|
|
808
|
+
style_num = doc.styles['List Number'].element.pPr.numPr.numId.val
|
|
809
|
+
abstract_id = numbering.num_having_numId(style_num).abstractNumId.val
|
|
810
|
+
instance = numbering.add_num(abstract_id)
|
|
811
|
+
start = int(re.match(r"^\s*(\d+)\.", line).group(1))
|
|
812
|
+
instance.add_lvlOverride(ilvl=0).add_startOverride(start)
|
|
813
|
+
list_number_id = instance.numId
|
|
814
|
+
num_pr = para._p.get_or_add_pPr().get_or_add_numPr()
|
|
815
|
+
num_pr.get_or_add_ilvl().val = 0
|
|
816
|
+
num_pr.get_or_add_numId().val = list_number_id
|
|
817
|
+
indent_level = min(len(numbered.group(1).replace("\t", " ")) // 2, 4)
|
|
818
|
+
para.paragraph_format.left_indent = Inches(0.25 * indent_level)
|
|
819
|
+
add_markdown_text(para, numbered.group(2).strip())
|
|
820
|
+
index += 1
|
|
821
|
+
elif quote:
|
|
822
|
+
flush_paragraph()
|
|
823
|
+
try:
|
|
824
|
+
para = doc.add_paragraph(style="Quote")
|
|
825
|
+
except Exception:
|
|
826
|
+
para = doc.add_paragraph()
|
|
827
|
+
para.paragraph_format.left_indent = Inches(0.25)
|
|
828
|
+
add_markdown_text(para, quote.group(1).strip())
|
|
829
|
+
index += 1
|
|
830
|
+
elif stripped in {"---", "***", "___"}:
|
|
831
|
+
flush_paragraph()
|
|
832
|
+
finish_email()
|
|
833
|
+
add_horizontal_rule(doc.add_paragraph())
|
|
834
|
+
index += 1
|
|
835
|
+
else:
|
|
836
|
+
paragraph_buffer.append(line)
|
|
837
|
+
index += 1
|
|
838
|
+
|
|
839
|
+
flush_paragraph()
|
|
840
|
+
finish_email()
|
|
841
|
+
|
|
842
|
+
if large_print:
|
|
843
|
+
# Apply the requested reading size to actual text, including tables and
|
|
844
|
+
# page furniture. Standard-size pagination estimates no longer apply.
|
|
845
|
+
paragraphs = list(doc.paragraphs)
|
|
846
|
+
for table in doc.tables:
|
|
847
|
+
for row in table.rows:
|
|
848
|
+
for cell in row.cells:
|
|
849
|
+
for paragraph in cell.paragraphs:
|
|
850
|
+
paragraph.paragraph_format.keep_with_next = False
|
|
851
|
+
paragraphs.append(paragraph)
|
|
852
|
+
for section in doc.sections:
|
|
853
|
+
paragraphs.extend(section.header.paragraphs)
|
|
854
|
+
paragraphs.extend(section.footer.paragraphs)
|
|
855
|
+
for paragraph in paragraphs:
|
|
856
|
+
for run in paragraph.runs:
|
|
857
|
+
inherited = paragraph.style.font.size
|
|
858
|
+
size = run.font.size or inherited
|
|
859
|
+
run.font.size = Pt(max(16, size.pt if size is not None else 16))
|
|
860
|
+
run.font.name = 'Arial'
|
|
861
|
+
doc.styles['Normal'].font.size = Pt(16)
|
|
862
|
+
doc.styles['Normal'].font.name = 'Arial'
|
|
863
|
+
|
|
864
|
+
if monochrome:
|
|
865
|
+
from docx.oxml.ns import qn
|
|
866
|
+
# Explicit colors override Office themes in body text, links, styles,
|
|
867
|
+
# table borders and page furniture. White fills avoid heavy ink use.
|
|
868
|
+
trees = [doc._element, doc.styles.element]
|
|
869
|
+
for section in doc.sections:
|
|
870
|
+
trees.extend([section.header._element, section.footer._element])
|
|
871
|
+
for tree in trees:
|
|
872
|
+
for element in tree.iter():
|
|
873
|
+
if element.tag == qn('w:color'):
|
|
874
|
+
element.set(qn('w:val'), '000000')
|
|
875
|
+
if qn('w:color') in element.attrib:
|
|
876
|
+
element.set(qn('w:color'), '000000')
|
|
877
|
+
if element.tag == qn('w:shd'):
|
|
878
|
+
element.set(qn('w:val'), 'clear')
|
|
879
|
+
element.set(qn('w:fill'), 'FFFFFF')
|
|
880
|
+
for attribute in ('themeColor', 'themeTint', 'themeShade', 'themeFill', 'themeFillTint', 'themeFillShade'):
|
|
881
|
+
element.attrib.pop(qn('w:' + attribute), None)
|
|
882
|
+
doc.save(path)
|