Spaces:
Running
Running
Whole-document engine: every PDF page (pdfium + parallel OCR), page refs in chronology/matrix, SHA-256 cache, concurrent uploads, progress bar, ~5x faster analysis, case summary panel, professional Word+PDF exports (UK letter/pleading layouts), fix narrative/DOC-1 mix-up, remote AI off by default
1f56acc verified Download bpc_export.py from Basementup/Blackpolishedchrome: direct link, hf CLI and curl.
- Browser
- Download file 25.1 kB
-
https://huggingface.co/spaces/Basementup/Blackpolishedchrome/resolve/main/bpc_export.py
- Command line
-
hf download hf://spaces/Basementup/Blackpolishedchrome/bpc_export.py
-
curl -L -o bpc_export.py https://huggingface.co/spaces/Basementup/Blackpolishedchrome/resolve/main/bpc_export.py
25.1 kB
| """Professional DOCX and PDF exports for Black Polished Chrome (python-docx + reportlab, all local). | |
| Letters (Letter Before Claim, SAR) use a UK business-letter layout; Particulars of Claim and | |
| Skeleton Arguments use a court pleading layout; reports and briefs use a titled report layout. | |
| Every export carries a "not legal advice" notice and page numbers. | |
| """ | |
| from __future__ import annotations | |
| import html | |
| import os | |
| import re | |
| import tempfile | |
| import uuid | |
| from datetime import datetime | |
| from typing import Any, Iterable, List, Optional | |
| DISCLAIMER = ( | |
| "Not legal advice. This document was prepared with the Black Polished Chrome organisational tool. " | |
| "Check every fact, date, figure and legal reference against the original evidence and current law " | |
| "before sending or filing it; take independent legal advice where you can." | |
| ) | |
| FOOTER_TEXT = "Black Polished Chrome — not legal advice" | |
| NAVY = "1F2A44" | |
| GOLD = "B08A1E" | |
| LETTER_KINDS = {"lbc", "sar"} | |
| PLEADING_KINDS = {"particulars", "skeleton"} | |
| def kind_from_filename(filename: str) -> str: | |
| low = (filename or "").lower() | |
| if "letter_before" in low or "lbc" in low: | |
| return "lbc" | |
| if "subject_access" in low or "sar" in low: | |
| return "sar" | |
| if "particulars" in low: | |
| return "particulars" | |
| if "skeleton" in low: | |
| return "skeleton" | |
| return "report" | |
| def _out_dir() -> str: | |
| path = os.path.join(tempfile.gettempdir(), "bpc_exports", uuid.uuid4().hex[:10]) | |
| os.makedirs(path, exist_ok=True) | |
| return path | |
| # --------------------------------------------------------------------------- text parsing | |
| CAPS_HEADING_RE = re.compile(r"^(?:\d+\.\s+)?[A-Z0-9][A-Z0-9 ,'’&/()\-–—:.§]{2,90}$") | |
| NUM_PARA_RE = re.compile(r"^(\d+(?:\.\d+)+|\d+\.|\[\d+\]|§\d+)\s+(.*)$") | |
| LABEL_RE = re.compile(r"^(FACT|DEFENCE/LIE|LIE/DEFENSE|LAW|CONSEQUENCE|EVIDENCE|Provenance|Method|Reliability|Significance|RE|Subject)\s*:\s*(.*)$", re.I) | |
| def classify_lines(text: str) -> List[tuple]: | |
| """Turn generator text into (kind, value[, extra]) blocks.""" | |
| blocks: List[tuple] = [] | |
| for raw in (text or "").replace("\r", "").split("\n"): | |
| line = raw.rstrip() | |
| stripped = line.strip() | |
| if not stripped: | |
| blocks.append(("blank", "")) | |
| continue | |
| indent = len(line) - len(line.lstrip(" ")) | |
| md = re.match(r"^(#{1,4})\s+(.*)$", stripped) | |
| if md: | |
| blocks.append(("heading", md.group(2).strip("* "), len(md.group(1)))) | |
| continue | |
| if stripped.startswith(("- ", "* ", "• ")): | |
| blocks.append(("bullet", stripped[2:].strip())) | |
| continue | |
| if stripped.upper().startswith("RE:"): | |
| blocks.append(("subject", stripped)) | |
| continue | |
| if CAPS_HEADING_RE.match(stripped) and stripped.upper() == stripped and len(stripped) <= 90 and any(c.isalpha() for c in stripped): | |
| blocks.append(("heading", stripped, 1)) | |
| continue | |
| lab = LABEL_RE.match(stripped) | |
| if lab and indent: | |
| blocks.append(("label", lab.group(1), lab.group(2))) | |
| continue | |
| num = NUM_PARA_RE.match(stripped) | |
| if num: | |
| blocks.append(("numbered", num.group(1), num.group(2))) | |
| continue | |
| blocks.append(("indent" if indent >= 4 else "para", stripped)) | |
| return blocks | |
| def _strip_md(text: str) -> str: | |
| return re.sub(r"\*\*(.+?)\*\*", r"\1", text or "") | |
| # --------------------------------------------------------------------------- DOCX helpers | |
| def _docx_field(paragraph, instruction: str, placeholder: str = "1") -> None: | |
| from docx.oxml import OxmlElement | |
| from docx.oxml.ns import qn | |
| run = paragraph.add_run() | |
| for tag, attr in (("w:fldChar", "begin"), ("w:instrText", None), ("w:fldChar", "separate"), ("w:t", None), ("w:fldChar", "end")): | |
| el = OxmlElement(tag) | |
| if tag == "w:fldChar": | |
| el.set(qn("w:fldCharType"), attr) | |
| elif tag == "w:instrText": | |
| el.set(qn("xml:space"), "preserve") | |
| el.text = instruction | |
| else: | |
| el.text = placeholder | |
| run._r.append(el) | |
| def _shade(cell, colour: str) -> None: | |
| from docx.oxml import OxmlElement | |
| from docx.oxml.ns import qn | |
| shd = OxmlElement("w:shd") | |
| shd.set(qn("w:val"), "clear") | |
| shd.set(qn("w:color"), "auto") | |
| shd.set(qn("w:fill"), colour) | |
| cell._tc.get_or_add_tcPr().append(shd) | |
| def _repeat_header(row) -> None: | |
| from docx.oxml import OxmlElement | |
| from docx.oxml.ns import qn | |
| el = OxmlElement("w:tblHeader") | |
| el.set(qn("w:val"), "true") | |
| row._tr.get_or_add_trPr().append(el) | |
| def _new_docx(landscape: bool = False, font: str = "Arial", size: float = 11): | |
| from docx import Document | |
| from docx.enum.section import WD_ORIENT | |
| from docx.enum.text import WD_ALIGN_PARAGRAPH | |
| from docx.shared import Cm, Pt, RGBColor | |
| doc = Document() | |
| normal = doc.styles["Normal"] | |
| normal.font.name = font | |
| normal.font.size = Pt(size) | |
| normal.paragraph_format.space_after = Pt(6) | |
| for name, sz in (("Heading 1", 14), ("Heading 2", 12), ("Heading 3", 11)): | |
| st = doc.styles[name] | |
| st.font.name = font | |
| st.font.size = Pt(sz) | |
| st.font.color.rgb = RGBColor.from_string(NAVY) | |
| sec = doc.sections[0] | |
| sec.page_width, sec.page_height = Cm(21.0), Cm(29.7) | |
| if landscape: | |
| sec.orientation = WD_ORIENT.LANDSCAPE | |
| sec.page_width, sec.page_height = Cm(29.7), Cm(21.0) | |
| sec.left_margin = sec.right_margin = Cm(2.2 if not landscape else 1.6) | |
| sec.top_margin = sec.bottom_margin = Cm(2.0 if not landscape else 1.5) | |
| footer = sec.footer.paragraphs[0] | |
| footer.alignment = WD_ALIGN_PARAGRAPH.CENTER | |
| r = footer.add_run(f"{FOOTER_TEXT} · Page ") | |
| r.font.size = Pt(8) | |
| _docx_field(footer, "PAGE") | |
| footer.add_run(" of ").font.size = Pt(8) | |
| _docx_field(footer, "NUMPAGES") | |
| return doc | |
| def _docx_table(doc, headers: List[str], rows: List[List[str]], widths_cm: Optional[List[float]] = None, size: float = 8.5): | |
| from docx.shared import Cm, Pt, RGBColor | |
| table = doc.add_table(rows=1, cols=len(headers)) | |
| table.style = "Table Grid" | |
| table.autofit = False | |
| _repeat_header(table.rows[0]) | |
| for cell, h in zip(table.rows[0].cells, headers): | |
| cell.text = "" | |
| run = cell.paragraphs[0].add_run(h) | |
| run.bold = True | |
| run.font.size = Pt(size) | |
| run.font.color.rgb = RGBColor(0xFF, 0xFF, 0xFF) | |
| _shade(cell, NAVY) | |
| for i, row in enumerate(rows): | |
| cells = table.add_row().cells | |
| for cell, value in zip(cells, row): | |
| cell.text = "" | |
| run = cell.paragraphs[0].add_run(str(value or "—")) | |
| run.font.size = Pt(size) | |
| if i % 2: | |
| _shade(cell, "EEF1F6") | |
| if widths_cm: | |
| for col, w in zip(table.columns, widths_cm): | |
| col.width = Cm(w) | |
| for row in table.rows: | |
| for cell, w in zip(row.cells, widths_cm): | |
| cell.width = Cm(w) | |
| doc.add_paragraph() | |
| return table | |
| def _add_rich(paragraph, text: str, size: Optional[float] = None, bold: bool = False): | |
| from docx.shared import Pt | |
| for i, part in enumerate(re.split(r"\*\*(.+?)\*\*", text or "")): | |
| if not part: | |
| continue | |
| run = paragraph.add_run(part) | |
| run.bold = bold or bool(i % 2) | |
| if size: | |
| run.font.size = Pt(size) | |
| def _disclaimer_docx(doc) -> None: | |
| from docx.shared import Pt, RGBColor | |
| p = doc.add_paragraph() | |
| p.paragraph_format.space_before = Pt(18) | |
| r = p.add_run(DISCLAIMER) | |
| r.italic = True | |
| r.font.size = Pt(8) | |
| r.font.color.rgb = RGBColor(0x55, 0x55, 0x55) | |
| # --------------------------------------------------------------------------- letters & pleadings DOCX | |
| def text_to_docx(text: str, path: str, kind: str) -> str: | |
| from docx.enum.text import WD_ALIGN_PARAGRAPH | |
| from docx.shared import Cm, Pt | |
| pleading = kind in PLEADING_KINDS | |
| doc = _new_docx(font="Times New Roman" if pleading else "Arial", size=12 if pleading else 11) | |
| blocks = classify_lines(text) | |
| # Title block: leading headings before first blank-separated content | |
| title_lines = [] | |
| while blocks and blocks[0][0] in ("heading", "blank") and len(title_lines) < 3: | |
| b = blocks.pop(0) | |
| if b[0] == "heading": | |
| title_lines.append(b[1]) | |
| elif title_lines: | |
| break | |
| for i, t in enumerate(title_lines): | |
| p = doc.add_paragraph() | |
| p.alignment = WD_ALIGN_PARAGRAPH.CENTER if (pleading or kind == "report") else WD_ALIGN_PARAGRAPH.LEFT | |
| _add_rich(p, t, size=16 if i == 0 else 10, bold=i == 0) | |
| if kind in LETTER_KINDS: | |
| # sender address block (until first blank) right-aligned, then date right-aligned | |
| address: List[str] = [] | |
| while blocks and blocks[0][0] == "blank": | |
| blocks.pop(0) | |
| while blocks and blocks[0][0] != "blank": | |
| address.append(blocks.pop(0)[-1] if blocks[0][0] != "label" else blocks.pop(0)[2]) | |
| for line in address: | |
| p = doc.add_paragraph() | |
| p.alignment = WD_ALIGN_PARAGRAPH.RIGHT | |
| p.paragraph_format.space_after = Pt(0) | |
| p.add_run(str(line)) | |
| in_parties = pleading | |
| for b in blocks: | |
| kind_b = b[0] | |
| if kind_b == "blank": | |
| continue | |
| if pleading and in_parties and kind_b in ("para", "heading") and re.match(r"^(BETWEEN:?|-and-|claimant|defendants?|IN THE .*|claim no.*)$", b[1], re.I): | |
| p = doc.add_paragraph() | |
| low = b[1].lower() | |
| p.alignment = WD_ALIGN_PARAGRAPH.RIGHT if low in ("claimant", "defendant", "defendants") else ( | |
| WD_ALIGN_PARAGRAPH.LEFT if low.startswith("between") else WD_ALIGN_PARAGRAPH.CENTER) | |
| _add_rich(p, b[1], bold=low.startswith("in the") or low.startswith("between")) | |
| continue | |
| if pleading and in_parties and kind_b == "para" and len(b[1]) < 80: | |
| p = doc.add_paragraph() | |
| p.alignment = WD_ALIGN_PARAGRAPH.CENTER | |
| _add_rich(p, b[1].upper(), bold=True) | |
| continue | |
| if kind_b in ("numbered", "heading"): | |
| in_parties = False | |
| if kind_b == "heading": | |
| doc.add_heading(_strip_md(b[1]).title() if b[1].isupper() and not pleading else _strip_md(b[1]), level=min(3, b[2] if len(b) > 2 else 1)) | |
| elif kind_b == "subject": | |
| p = doc.add_paragraph() | |
| _add_rich(p, b[1], bold=True) | |
| elif kind_b == "bullet": | |
| p = doc.add_paragraph(style="List Bullet") | |
| _add_rich(p, b[1]) | |
| elif kind_b == "numbered": | |
| p = doc.add_paragraph() | |
| p.paragraph_format.left_indent = Cm(1.25) | |
| p.paragraph_format.first_line_indent = Cm(-1.25) | |
| p.alignment = WD_ALIGN_PARAGRAPH.JUSTIFY if pleading else WD_ALIGN_PARAGRAPH.LEFT | |
| p.add_run(f"{b[1]}\t") | |
| _add_rich(p, b[2]) | |
| elif kind_b == "label": | |
| p = doc.add_paragraph() | |
| p.paragraph_format.left_indent = Cm(1.25) | |
| p.paragraph_format.space_after = Pt(2) | |
| r = p.add_run(f"{b[1].title() if b[1].isupper() else b[1]}: ") | |
| r.bold = True | |
| r.font.size = Pt(10) | |
| _add_rich(p, b[2], size=10) | |
| elif kind_b == "indent": | |
| p = doc.add_paragraph() | |
| p.paragraph_format.left_indent = Cm(1.25) | |
| _add_rich(p, b[1], size=10) | |
| else: | |
| p = doc.add_paragraph() | |
| p.alignment = WD_ALIGN_PARAGRAPH.JUSTIFY if pleading else WD_ALIGN_PARAGRAPH.LEFT | |
| _add_rich(p, b[1]) | |
| _disclaimer_docx(doc) | |
| doc.core_properties.title = title_lines[0] if title_lines else "Black Polished Chrome document" | |
| doc.core_properties.comments = DISCLAIMER[:255] | |
| doc.save(path) | |
| return path | |
| # --------------------------------------------------------------------------- reportlab helpers | |
| def _rl(): | |
| from reportlab.lib import colors | |
| from reportlab.lib.enums import TA_CENTER, TA_JUSTIFY, TA_LEFT, TA_RIGHT | |
| from reportlab.lib.styles import ParagraphStyle, getSampleStyleSheet | |
| ss = getSampleStyleSheet() | |
| navy = colors.HexColor("#" + NAVY) | |
| st = { | |
| "title": ParagraphStyle("BTitle", parent=ss["Title"], fontSize=18, textColor=navy, spaceAfter=6), | |
| "subtitle": ParagraphStyle("BSub", parent=ss["Normal"], fontSize=9, alignment=TA_CENTER, textColor=colors.grey, spaceAfter=12), | |
| "h1": ParagraphStyle("BH1", parent=ss["Heading2"], fontSize=13, textColor=navy, spaceBefore=10, spaceAfter=5), | |
| "h2": ParagraphStyle("BH2", parent=ss["Heading3"], fontSize=11, textColor=navy, spaceBefore=6), | |
| "body": ParagraphStyle("BBody", parent=ss["Normal"], fontSize=10.5, leading=14.5, spaceAfter=6, alignment=TA_LEFT), | |
| "just": ParagraphStyle("BJust", parent=ss["Normal"], fontName="Times-Roman", fontSize=11.5, leading=16, spaceAfter=7, alignment=TA_JUSTIFY), | |
| "num": ParagraphStyle("BNum", parent=ss["Normal"], fontSize=10.5, leading=14.5, leftIndent=28, firstLineIndent=-28, spaceAfter=6), | |
| "label": ParagraphStyle("BLab", parent=ss["Normal"], fontSize=9.5, leading=12.5, leftIndent=28, spaceAfter=2), | |
| "right": ParagraphStyle("BRight", parent=ss["Normal"], fontSize=10.5, leading=13, alignment=TA_RIGHT), | |
| "centre": ParagraphStyle("BCentre", parent=ss["Normal"], fontSize=11, leading=15, alignment=TA_CENTER, spaceAfter=4), | |
| "disc": ParagraphStyle("BDisc", parent=ss["Normal"], fontSize=7.5, leading=10, textColor=colors.HexColor("#555555"), spaceBefore=16, fontName="Helvetica-Oblique"), | |
| "cell": ParagraphStyle("BCell", parent=ss["Normal"], fontSize=7.5, leading=9.2), | |
| "head": ParagraphStyle("BHead", parent=ss["Normal"], fontSize=7.5, leading=9.2, textColor=colors.white, fontName="Helvetica-Bold"), | |
| } | |
| return colors, st | |
| def _esc(text: str) -> str: | |
| out = html.escape(str(text or "")) | |
| return re.sub(r"\*\*(.+?)\*\*", r"<b>\1</b>", out) | |
| def _footer(canvas, doc): | |
| from reportlab.lib import colors | |
| canvas.saveState() | |
| canvas.setFont("Helvetica", 7.5) | |
| canvas.setFillColor(colors.grey) | |
| canvas.drawCentredString(doc.pagesize[0] / 2, 18, f"{FOOTER_TEXT} · Page {doc.page}") | |
| canvas.restoreState() | |
| def text_to_pdf(text: str, path: str, kind: str) -> str: | |
| from reportlab.lib.pagesizes import A4 | |
| from reportlab.lib.units import cm | |
| from reportlab.platypus import Paragraph, SimpleDocTemplate, Spacer | |
| _, st = _rl() | |
| pleading = kind in PLEADING_KINDS | |
| body_style = st["just"] if pleading else st["body"] | |
| story: List[Any] = [] | |
| blocks = classify_lines(text) | |
| title_lines = [] | |
| while blocks and blocks[0][0] in ("heading", "blank") and len(title_lines) < 3: | |
| b = blocks.pop(0) | |
| if b[0] == "heading": | |
| title_lines.append(b[1]) | |
| elif title_lines: | |
| break | |
| for i, t in enumerate(title_lines): | |
| story.append(Paragraph(_esc(t), st["title"] if i == 0 else st["subtitle"])) | |
| if kind in LETTER_KINDS: | |
| while blocks and blocks[0][0] == "blank": | |
| blocks.pop(0) | |
| while blocks and blocks[0][0] != "blank": | |
| b = blocks.pop(0) | |
| story.append(Paragraph(_esc(b[-1]), st["right"])) | |
| story.append(Spacer(1, 10)) | |
| in_parties = pleading | |
| for b in blocks: | |
| k = b[0] | |
| if k == "blank": | |
| continue | |
| if pleading and in_parties and k in ("para", "heading") and re.match(r"^(BETWEEN:?|-and-|claimant|defendants?|IN THE .*|claim no.*)$", b[1], re.I): | |
| low = b[1].lower() | |
| style = st["right"] if low in ("claimant", "defendant", "defendants") else st["centre"] | |
| txt = f"<b>{_esc(b[1])}</b>" if low.startswith(("in the", "between")) else _esc(b[1]) | |
| story.append(Paragraph(txt, style)) | |
| continue | |
| if pleading and in_parties and k == "para" and len(b[1]) < 80: | |
| story.append(Paragraph(f"<b>{_esc(b[1].upper())}</b>", st["centre"])) | |
| continue | |
| if k in ("numbered", "heading"): | |
| in_parties = False | |
| if k == "heading": | |
| story.append(Paragraph(_esc(_strip_md(b[1])), st["h1"] if (len(b) < 3 or b[2] <= 2) else st["h2"])) | |
| elif k == "subject": | |
| story.append(Paragraph(f"<b>{_esc(b[1])}</b>", st["body"])) | |
| elif k == "bullet": | |
| story.append(Paragraph(_esc(b[1]), st["body"], bulletText="•")) | |
| elif k == "numbered": | |
| story.append(Paragraph(f"{_esc(b[1])} {_esc(b[2])}", st["num"])) | |
| elif k == "label": | |
| story.append(Paragraph(f"<b>{_esc(b[1].title() if b[1].isupper() else b[1])}:</b> {_esc(b[2])}", st["label"])) | |
| elif k == "indent": | |
| story.append(Paragraph(_esc(b[1]), st["label"])) | |
| else: | |
| story.append(Paragraph(_esc(b[1]), body_style)) | |
| story.append(Paragraph(_esc(DISCLAIMER), st["disc"])) | |
| doc = SimpleDocTemplate(path, pagesize=A4, leftMargin=2.2 * cm, rightMargin=2.2 * cm, topMargin=2 * cm, bottomMargin=2 * cm, | |
| title=title_lines[0] if title_lines else "Black Polished Chrome", author="Black Polished Chrome") | |
| doc.build(story, onFirstPage=_footer, onLaterPages=_footer) | |
| return path | |
| def export_text(text: str, filename: str) -> List[str]: | |
| """Write DOCX + PDF for a generated document; returns the paths that succeeded.""" | |
| if not text or not str(text).strip(): | |
| return [] | |
| kind = kind_from_filename(filename) | |
| out = _out_dir() | |
| stem = re.sub(r"[^A-Za-z0-9_.-]+", "_", os.path.splitext(filename)[0]) or "BPC_Document" | |
| paths: List[str] = [] | |
| try: | |
| paths.append(text_to_docx(str(text), os.path.join(out, f"{stem}.docx"), kind)) | |
| except Exception: | |
| pass | |
| try: | |
| paths.append(text_to_pdf(str(text), os.path.join(out, f"{stem}.pdf"), kind)) | |
| except Exception: | |
| pass | |
| if not paths: | |
| txt = os.path.join(out, f"{stem}.txt") | |
| with open(txt, "w", encoding="utf-8") as fh: | |
| fh.write(f"{text}\n\n{DISCLAIMER}\n") | |
| paths.append(txt) | |
| return paths | |
| # --------------------------------------------------------------------------- matrix + chronology report | |
| def export_report( | |
| matrix_rows: List[dict], | |
| matrix_cols: List[str], | |
| chron_rows: List[dict], | |
| chron_cols: List[str], | |
| summary_rows: List[List[str]], | |
| owner: str = "", | |
| ) -> List[str]: | |
| out = _out_dir() | |
| generated = datetime.now().strftime("%d %B %Y, %H:%M") | |
| paths: List[str] = [] | |
| chron_cols = [c for c in chron_cols if c != "IsOrigin"] | |
| chron_data = [[str(i)] + [str(r.get(c, "") or "") for c in chron_cols] for i, r in enumerate(chron_rows, 1)] | |
| matrix_data = [[str(i)] + [str(r.get(c, "") or "") for c in matrix_cols] for i, r in enumerate(matrix_rows, 1)] | |
| nice = {"LIE/DEFENSE": "Lie / defence", "FACT": "Fact", "LAW": "Law", "CONSEQUENCE": "Consequence", | |
| "EVIDENCE METHOD": "Evidence method", "RELIABILITY": "Reliability", "PROVENANCE": "Source (document, page)", | |
| "Source": "Source (document, page)"} | |
| chron_head = ["#"] + [nice.get(c, c) for c in chron_cols] | |
| matrix_head = ["#"] + [nice.get(c, c) for c in matrix_cols] | |
| # DOCX | |
| try: | |
| from docx.enum.text import WD_ALIGN_PARAGRAPH, WD_BREAK | |
| from docx.shared import Pt, RGBColor | |
| doc = _new_docx(landscape=True, size=10) | |
| for _ in range(5): | |
| doc.add_paragraph() | |
| p = doc.add_paragraph() | |
| p.alignment = WD_ALIGN_PARAGRAPH.CENTER | |
| r = p.add_run("Litigation Evidence Report") | |
| r.bold = True | |
| r.font.size = Pt(28) | |
| r.font.color.rgb = RGBColor.from_string(NAVY) | |
| p = doc.add_paragraph() | |
| p.alignment = WD_ALIGN_PARAGRAPH.CENTER | |
| p.add_run("Master chronology and Fact–Lie–Law–Consequence matrix").font.size = Pt(14) | |
| p = doc.add_paragraph() | |
| p.alignment = WD_ALIGN_PARAGRAPH.CENTER | |
| p.add_run(f"{owner + ' · ' if owner else ''}Generated {generated}").font.size = Pt(11) | |
| _disclaimer_docx(doc) | |
| doc.paragraphs[-1].alignment = WD_ALIGN_PARAGRAPH.CENTER | |
| doc.add_paragraph().add_run().add_break(WD_BREAK.PAGE) | |
| doc.add_heading("Contents", level=1) | |
| _docx_field(doc.add_paragraph(), 'TOC \\o "1-2" \\h \\z \\u', "Right-click and choose Update Field (or press F9) to build the contents list.") | |
| doc.add_paragraph().add_run().add_break(WD_BREAK.PAGE) | |
| doc.add_heading("1. Case summary", level=1) | |
| _docx_table(doc, ["Item", "Detail"], summary_rows, [6, 20.5], size=10) | |
| doc.add_heading("2. Master chronology", level=1) | |
| cw = {"Date": 2.6, "Event": 11.5, "Source": 4.6, "Evidence method": 2.6, "Reliability": 2.4, "Significance": 2.3} | |
| _docx_table(doc, chron_head, chron_data, [0.8] + [cw.get(c, 2.5) for c in chron_cols], size=8) | |
| doc.add_heading("3. Fact–Lie–Law–Consequence matrix", level=1) | |
| mw = {"FACT": 6.5, "LIE/DEFENSE": 3.6, "LAW": 3.6, "CONSEQUENCE": 3.6, "EVIDENCE METHOD": 2.3, "RELIABILITY": 2.3, "PROVENANCE": 3.6} | |
| _docx_table(doc, matrix_head, matrix_data, [0.8] + [mw.get(c, 3) for c in matrix_cols], size=8) | |
| doc.core_properties.title = "Litigation Evidence Report" | |
| doc.core_properties.comments = DISCLAIMER[:255] | |
| path = os.path.join(out, "BPC_Litigation_Report.docx") | |
| doc.save(path) | |
| paths.append(path) | |
| except Exception: | |
| pass | |
| try: | |
| from reportlab.lib.pagesizes import A4, landscape | |
| from reportlab.lib.styles import ParagraphStyle | |
| from reportlab.lib.units import cm | |
| from reportlab.platypus import LongTable, PageBreak, Paragraph, SimpleDocTemplate, Spacer, TableStyle | |
| from reportlab.platypus.tableofcontents import TableOfContents | |
| colors, st = _rl() | |
| class Doc(SimpleDocTemplate): | |
| def afterFlowable(self, flowable): | |
| if isinstance(flowable, Paragraph) and flowable.style.name == "BH1": | |
| key = f"h{id(flowable)}" | |
| self.canv.bookmarkPage(key) | |
| self.notify("TOCEntry", (0, flowable.getPlainText(), self.page, key)) | |
| width = landscape(A4)[0] - 3.2 * cm | |
| def table(head, rows, fr): | |
| data = [[Paragraph(_esc(h), st["head"]) for h in head]] + [[Paragraph(_esc(c) or "—", st["cell"]) for c in row] for row in rows] | |
| t = LongTable(data, colWidths=[width * f / sum(fr) for f in fr], repeatRows=1) | |
| style = [("BACKGROUND", (0, 0), (-1, 0), colors.HexColor("#" + NAVY)), | |
| ("GRID", (0, 0), (-1, -1), 0.3, colors.HexColor("#9AA3B5")), | |
| ("VALIGN", (0, 0), (-1, -1), "TOP")] | |
| style += [("BACKGROUND", (0, i), (-1, i), colors.HexColor("#EEF1F6")) for i in range(2, len(data), 2)] | |
| t.setStyle(TableStyle(style)) | |
| return t | |
| cfr = {"Date": 2.6, "Event": 11.5, "Source": 4.6, "Evidence method": 2.6, "Reliability": 2.4, "Significance": 2.3} | |
| mfr = {"FACT": 6.5, "LIE/DEFENSE": 3.6, "LAW": 3.6, "CONSEQUENCE": 3.6, "EVIDENCE METHOD": 2.3, "RELIABILITY": 2.3, "PROVENANCE": 3.6} | |
| toc = TableOfContents() | |
| toc.levelStyles = [ParagraphStyle("toc0", fontSize=11, leading=16, leftIndent=8)] | |
| big = ParagraphStyle("Big", parent=st["title"], fontSize=30, spaceAfter=16) | |
| story = [Spacer(1, 4 * cm), Paragraph("Litigation Evidence Report", big), | |
| Paragraph("Master chronology and Fact–Lie–Law–Consequence matrix", st["centre"]), | |
| Paragraph(_esc(f"{owner + ' · ' if owner else ''}Generated {generated}"), st["centre"]), | |
| Spacer(1, 1.2 * cm), Paragraph(_esc(DISCLAIMER), ParagraphStyle("dc", parent=st["disc"], alignment=1, fontSize=9, leading=12)), | |
| PageBreak(), Paragraph("Contents", ParagraphStyle("ct", parent=st["h1"], fontSize=15)), toc, PageBreak(), | |
| Paragraph("1. Case summary", st["h1"]), table(["Item", "Detail"], summary_rows, [1, 4]), | |
| Paragraph("2. Master chronology", st["h1"]), | |
| table(chron_head, chron_data, [0.8] + [cfr.get(c, 2.5) for c in chron_cols]) if chron_data else Paragraph("No chronology rows.", st["body"]), | |
| Paragraph("3. Fact–Lie–Law–Consequence matrix", st["h1"]), | |
| table(matrix_head, matrix_data, [0.8] + [mfr.get(c, 3) for c in matrix_cols]) if matrix_data else Paragraph("No matrix rows.", st["body"])] | |
| path = os.path.join(out, "BPC_Litigation_Report.pdf") | |
| doc = Doc(path, pagesize=landscape(A4), leftMargin=1.6 * cm, rightMargin=1.6 * cm, topMargin=1.4 * cm, bottomMargin=1.4 * cm, | |
| title="Litigation Evidence Report", author="Black Polished Chrome") | |
| doc.multiBuild(story, onFirstPage=_footer, onLaterPages=_footer) | |
| paths.append(path) | |
| except Exception: | |
| pass | |
| return paths | |