Blackpolishedchrome / bpc_export.py
Basementup's picture
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
Raw History Blame Contribute Delete
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])}&nbsp;&nbsp;{_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
# PDF
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