"""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"\1", 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"{_esc(b[1])}" 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"{_esc(b[1].upper())}", 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"{_esc(b[1])}", 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"{_esc(b[1].title() if b[1].isupper() else b[1])}: {_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