@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.
Files changed (74) hide show
  1. package/README.md +34 -0
  2. package/bootstrap/Install RealtorTasksAI.command +37 -0
  3. package/bootstrap/Install RealtorTasksAI.ps1 +42 -0
  4. package/package.json +14 -3
  5. package/runtime/document_renderer.py +882 -0
  6. package/runtime/server.py +55 -581
  7. package/runtime/software_use.py +39 -0
  8. package/runtime/workflow-requirements.txt +5 -0
  9. package/runtime/workflows/__init__.py +0 -0
  10. package/runtime/workflows/account_snapshot.py +33 -0
  11. package/runtime/workflows/attachments.py +45 -0
  12. package/runtime/workflows/authority_guides.py +81 -0
  13. package/runtime/workflows/catalog.py +51 -0
  14. package/runtime/workflows/catalog_app.py +174 -0
  15. package/runtime/workflows/catalog_generation.py +186 -0
  16. package/runtime/workflows/catalog_output.py +312 -0
  17. package/runtime/workflows/catalog_suggestions.py +51 -0
  18. package/runtime/workflows/catalog_workspace.py +152 -0
  19. package/runtime/workflows/cli.py +66 -0
  20. package/runtime/workflows/document_answers.py +96 -0
  21. package/runtime/workflows/document_selection.py +50 -0
  22. package/runtime/workflows/documents.py +243 -0
  23. package/runtime/workflows/embedded/catalog.html +83 -0
  24. package/runtime/workflows/embedded/offer-review.html +516 -0
  25. package/runtime/workflows/embedded/preview.html +36 -0
  26. package/runtime/workflows/embedded_actions.py +96 -0
  27. package/runtime/workflows/embedded_demo.py +424 -0
  28. package/runtime/workflows/folders.py +120 -0
  29. package/runtime/workflows/gateway.py +157 -0
  30. package/runtime/workflows/generation_lock.py +26 -0
  31. package/runtime/workflows/launcher.py +61 -0
  32. package/runtime/workflows/licensed_delivery.py +46 -0
  33. package/runtime/workflows/mcp_dev.py +52 -0
  34. package/runtime/workflows/meeting_plan.py +92 -0
  35. package/runtime/workflows/model_client.py +26 -0
  36. package/runtime/workflows/numeric_consistency.py +72 -0
  37. package/runtime/workflows/public_source_fetch.py +66 -0
  38. package/runtime/workflows/realtor/__init__.py +0 -0
  39. package/runtime/workflows/realtor/adapter.py +179 -0
  40. package/runtime/workflows/realtor/seller_offer/ORIGIN.json +16 -0
  41. package/runtime/workflows/realtor/seller_offer/__init__.py +0 -0
  42. package/runtime/workflows/realtor/seller_offer/examples/demo-input.json +414 -0
  43. package/runtime/workflows/realtor/seller_offer/examples/make_demo.py +44 -0
  44. package/runtime/workflows/realtor/seller_offer/references/input-contract.md +65 -0
  45. package/runtime/workflows/realtor/seller_offer/references/source-boundaries.md +16 -0
  46. package/runtime/workflows/realtor/seller_offer/scripts/__init__.py +0 -0
  47. package/runtime/workflows/realtor/seller_offer/scripts/build_workbook.mjs +233 -0
  48. package/runtime/workflows/realtor/seller_offer/scripts/build_workbook.py +163 -0
  49. package/runtime/workflows/realtor/seller_offer/scripts/offer_engine.py +370 -0
  50. package/runtime/workflows/realtor/seller_offer/scripts/presentation.py +52 -0
  51. package/runtime/workflows/realtor/seller_offer/scripts/render_outputs.py +228 -0
  52. package/runtime/workflows/realtor/seller_offer/scripts/run_package.py +95 -0
  53. package/runtime/workflows/realtor/seller_offer/tests/test_engine.py +252 -0
  54. package/runtime/workflows/realtor/seller_offer/tests/test_workbook.mjs +28 -0
  55. package/runtime/workflows/realtor-release-registry.json +705 -0
  56. package/runtime/workflows/released_catalog.py +91 -0
  57. package/runtime/workflows/source_capture.py +82 -0
  58. package/runtime/workflows/store.py +492 -0
  59. package/runtime/workflows/table_calculations.py +132 -0
  60. package/runtime/workflows/template.py +65 -0
  61. package/runtime/workflows/workspace_launch.py +76 -0
  62. package/src/index.js +217 -118
  63. package/src/managed-python.js +63 -0
  64. package/src/operation-lock.js +22 -0
  65. package/src/prepared-update.js +86 -0
  66. package/src/private-workflow.js +116 -0
  67. package/src/python-runtime.js +50 -0
  68. package/src/recover-installation.js +30 -0
  69. package/src/recovery-lock.js +30 -0
  70. package/src/software-hash.js +24 -0
  71. package/src/software-use.js +32 -0
  72. package/src/update-journal.js +24 -0
  73. package/src/update-recovery.js +72 -0
  74. 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)