"""Word and PDF layout for the case summary: numbered sections, tables, page numbers, header and footer carrying the generation date and the SHA-256 seal.""" import os from brief import section_list, refs, ref BLUE = (0x1F, 0x4E, 0x99) def _src(rs, n=4): return refs(rs, n) # ------------------------------------------------------------------ DOCX def export_docx(R, path): from docx import Document from docx.enum.section import WD_ORIENT # noqa from docx.enum.table import WD_TABLE_ALIGNMENT from docx.oxml import OxmlElement from docx.oxml.ns import qn from docx.shared import Pt, RGBColor, Cm d = Document() sec = d.sections[0] sec.page_width, sec.page_height = Cm(21.0), Cm(29.7) for side in ('left_margin', 'right_margin'): setattr(sec, side, Cm(2.0)) sec.top_margin = sec.bottom_margin = Cm(2.0) st = d.styles['Normal']; st.font.name = 'Calibri'; st.font.size = Pt(10.5) st.paragraph_format.space_after = Pt(4) for lvl, size in ((0, 20), (1, 14), (2, 12)): hs = d.styles['Title' if lvl == 0 else f'Heading {lvl}'] hs.font.color.rgb = RGBColor(*BLUE); hs.font.name = 'Calibri'; hs.font.size = Pt(size) def field(par, code): r = par.add_run() for tag, txt in (('begin', None), (None, code), ('separate', None), (None, '1'), ('end', None)): if tag: e = OxmlElement('w:fldChar'); e.set(qn('w:fldCharType'), tag); r._r.append(e) elif txt == code: e = OxmlElement('w:instrText'); e.set(qn('xml:space'), 'preserve'); e.text = f' {code} '; r._r.append(e) else: e = OxmlElement('w:t'); e.text = txt; r._r.append(e) return r hp = sec.header.paragraphs[0] hr = hp.add_run(f"The Seamstresses · Case summary · {R['headline'][:80]}"); hr.font.size = Pt(8); hr.font.color.rgb = RGBColor(0x55, 0x55, 0x55) fp = sec.footer.paragraphs[0] fr = fp.add_run(f"Generated {R['generated']} · SHA-256 {R['seal']} · Page "); fr.font.size = Pt(7) field(fp, 'PAGE').font.size = Pt(7) fp.add_run(' of ').font.size = Pt(7) field(fp, 'NUMPAGES').font.size = Pt(7) def para(text, size=None, bold=False, italic=False, indent=None, color=None, after=None): p = d.add_paragraph() r = p.add_run(text); r.bold = bold; r.italic = italic if size: r.font.size = Pt(size) if color: r.font.color.rgb = RGBColor(*color) if indent is not None: p.paragraph_format.left_indent = Cm(indent) if after is not None: p.paragraph_format.space_after = Pt(after) return p def num_item(no, text, rs, quote=True, indent=0.0): p = d.add_paragraph() p.paragraph_format.left_indent = Cm(indent + 1.1) p.paragraph_format.first_line_indent = Cm(-1.1) p.add_run(f'{no}\t').bold = True p.add_run(f'“{text}”' if quote else text) s = p.add_run(' ' + _src(rs)); s.font.size = Pt(8); s.font.color.rgb = RGBColor(*BLUE) p.paragraph_format.tab_stops.add_tab_stop(Cm(indent + 1.1)) return p def table(headers, rows, widths): t = d.add_table(rows=1, cols=len(headers)) t.style = 'Table Grid'; t.alignment = WD_TABLE_ALIGNMENT.CENTER for i, h in enumerate(headers): c = t.rows[0].cells[i]; c.text = '' r = c.paragraphs[0].add_run(h); r.bold = True; r.font.size = Pt(9); r.font.color.rgb = RGBColor(0xFF, 0xFF, 0xFF) sh = OxmlElement('w:shd'); sh.set(qn('w:val'), 'clear'); sh.set(qn('w:fill'), '1F4E99'); c._tc.get_or_add_tcPr().append(sh) for row in rows: cells = t.add_row().cells for i, v in enumerate(row): cells[i].text = '' color = None if isinstance(v, tuple): v, color = v r = cells[i].paragraphs[0].add_run(str(v)); r.font.size = Pt(8.5) if color: r.font.color.rgb = RGBColor(*color); r.bold = True t.autofit = False tot = sum(widths) widths = [w * 17.0 / tot for w in widths] # fill the A4 text width (17 cm) tblPr = t._tbl.tblPr lay = OxmlElement('w:tblLayout'); lay.set(qn('w:type'), 'fixed'); tblPr.append(lay) tw = OxmlElement('w:tblW'); tw.set(qn('w:w'), str(int(17.0 * 567))); tw.set(qn('w:type'), 'dxa') for old in tblPr.findall(qn('w:tblW')): tblPr.remove(old) tblPr.append(tw) for gi, gc in enumerate(t._tbl.tblGrid.findall(qn('w:gridCol'))): if gi < len(widths): gc.set(qn('w:w'), str(int(widths[gi] * 567))) for row in t.rows: for i, w in enumerate(widths): row.cells[i].width = Cm(w) d.add_paragraph() return t d.add_paragraph(R['headline'], style='Title') para(f"Case summary built from {len(R['sources'])} upload(s) on {R['generated']}. Every point is quoted from the uploads with its source " f"[document page/time ¶paragraph]; nothing has been added. Sources are listed in the last section.", 9, italic=True, color=(0x44, 0x44, 0x44)) d.add_heading('Summary', 1) for i, e in enumerate(R['exec'], 1): p = d.add_paragraph(); p.paragraph_format.left_indent = Cm(0.8); p.paragraph_format.first_line_indent = Cm(-0.8) p.add_run(f'{i}.\t').bold = True; p.add_run(e) for num, title, kind in section_list(R): d.add_heading(f'{num}. {title}', 1) if kind == 'parties': d.add_heading('Parties and people', 2) if R['entities']: table(['#', 'Name (as written)', 'Role in the uploads', 'Type', 'Where'], [(f'{num}.{i}', e['name'] + (f" (also: {', '.join(e['aliases'])})" if e['aliases'] else ''), e['role'] or '-', e['type'], _src(e['refs'], 3)) for i, e in enumerate(R['entities'], 1)], [1.1, 5.6, 3.2, 2.2, 4.9]) else: para('No named parties detected.', italic=True) d.add_heading('References and numbers', 2) if R['references']: off = len(R['entities']) table(['#', 'Type', 'Reference', 'Times', 'Source'], [(f'{num}.{off + i}', r['label'].title(), r['value'], r['count'], _src(r['refs'], 2)) for i, r in enumerate(R['references'], 1)], [1.1, 3.2, 5.6, 1.4, 5.7]) else: para('No reference numbers detected.', italic=True) elif kind == 'chron': if R['chronology']: para('Sorted by date. Entries describing the same event on the same date were combined; ⚠ marks a date or figure that conflicts with another passage (see section 5).', 8.5, italic=True) table(['#', 'Date / time', 'What happened (verbatim)', 'Source'], [(f'{num}.{i}', (r['date_text'] + (' ' + r['time'] if r['time'] else '') + (f"\n⚠ C{', C'.join(map(str, r['conflicts']))}" if r['conflicts'] else ''), (0xC0, 0x00, 0x00)) if r['conflicts'] else r['date_text'] + (' ' + r['time'] if r['time'] else ''), r['plain'], _src(r['refs'], 3)) for i, r in enumerate(R['chronology'], 1)], [1.1, 3.0, 9.4, 3.5]) else: para('No dated events found.', italic=True) elif kind == 'subjects': for si, s in enumerate(R['sections'], 1): d.add_heading(f'{num}.{si} {s["title"]}', 2) for fi, f in enumerate(s['items'], 1): num_item(f'{num}.{si}.{fi}', f['plain'], f['refs'], indent=0.3) elif kind in ('harm', 'requests'): for i, f in enumerate(R[kind], 1): num_item(f'{num}.{i}', f['plain'], f['refs']) elif kind == 'conflicts': d.add_heading('Contradictions (both passages quoted)', 2) if R['conflicts']: rows = [] for ci, c in enumerate(R['conflicts'], 1): rows.append((f'C{ci}', (c['type'], (0xC0, 0, 0)), f"A: “{c['a']['text']}” {_src(c['a']['refs'], 2)}\n\nB: “{c['b']['text']}” {_src(c['b']['refs'], 2)}")) table(['#', 'Type', 'The two passages'], rows, [1.1, 3.4, 12.5]) para('Flagged automatically because the wording is very similar but a date, amount or yes/no differs. Check whether they describe the same event.', 8.5, italic=True) else: para('None found (dates, amounts and yes/no statements were compared across all uploads).', italic=True) d.add_heading('Risk language', 2) if R['risk']: table(['#', 'Kind', 'Passage (verbatim)', 'Source'], [(f'{num}.{i}', f['label'], f['plain'], _src(f['refs'], 2)) for i, f in enumerate(R['risk'], 1)], [1.1, 3.4, 9.0, 3.5]) else: para('None found.', italic=True) elif kind == 'laws': para('Only laws and rules that the uploads themselves cite, exactly as cited. Nothing has been added.', 8.5, italic=True) rows = [] for i, e in enumerate(R['laws'], 1): rows.append((f'{num}.{i}', e['instrument'], '\n'.join(f"“{c['text']}” ({c['count']}x) {_src(c['refs'], 2)}" for c in e['cites'][:8]))) table(['#', 'Law or rule', 'As cited in the uploads'], rows, [1.1, 5.0, 10.9]) elif kind == 'dedup': dd = R['dedup'] para(f"Paragraphs in: {dd['paras_in']} → out: {dd['paras_out']}. Words in: {dd['words_in']} → out: {dd['words_out']}. Order kept from {dd['backbone']}.") para(f"Duplicate groups: {dd['groups']} ({dd['identical_groups']} identical copies, {dd['choice_groups']} with different wording). " f"Duplicate paragraphs removed: {dd['copies_removed']}. Repeated sentences removed: {dd['sentences_removed']}.") rows = [] for g in dd['groups_detail']: if len(g['versions']) < 2: continue rows.append((f"G{g['id']}", f"v{g['picked']}" + (' (best)' if g['picked'] == g['best'] else f" (your pick; best v{g['best']})") + ('\n⚠ figures differ' if g['figures_differ'] else '') + ''.join(f"\n+ 1 new sentence from v{x['version']}" for x in g.get('added', [])), '\n'.join(f"v{v['n']}: {', '.join(ref(r) for r in v['refs'][:4])} · {v['words']} words" for v in g['versions']))) if rows: table(['Group', 'Kept', 'Versions and where they appear'], rows, [1.6, 4.0, 11.4]) if dd['left_out']: d.add_heading('Sentences only in versions not kept', 2) for i, x in enumerate(dd['left_out'], 1): num_item(f'{num}.{i}', f"(group {x['group']} v{x['version']}) {x['plain']}", x['refs']) elif kind == 'sources': table(['ID', 'File', 'How it was read', 'SHA-256'], [(s['id'], s['name'], s['extractor'] + (('\n' + '; '.join(s['warnings'])) if s['warnings'] else ''), s['sha256']) for s in R['sources']], [1.2, 4.2, 5.0, 6.6]) para(f"Report seal (SHA-256 of the .txt report): {R['seal']}", 8) d.add_page_break() d.add_paragraph('Part 2 - Master document', style='Title') para('All uploads merged into one, duplicates removed, wording exactly as in the uploads. Paragraph numbers are added; the source of each paragraph is shown after it.', 9, italic=True) for m in R['master']: if m['kind'] == 'heading': d.add_heading(m['plain'], 2) continue p = d.add_paragraph() p.paragraph_format.left_indent = Cm(1.1); p.paragraph_format.first_line_indent = Cm(-1.1) p.paragraph_format.tab_stops.add_tab_stop(Cm(1.1)) p.add_run(f"{m['n']}.\t").bold = True import stitch for txt, bold in stitch._runs(m['text']): p.add_run(txt).bold = bold s = p.add_run(' ' + _src(m['refs'], 3)); s.font.size = Pt(7.5); s.font.color.rgb = RGBColor(*BLUE) d.save(path) return path # ------------------------------------------------------------------ PDF def export_pdf(R, path): from xml.sax.saxutils import escape as E from reportlab.lib import colors from reportlab.lib.pagesizes import A4 from reportlab.lib.styles import ParagraphStyle from reportlab.lib.units import cm from reportlab.pdfbase import pdfmetrics from reportlab.pdfbase.ttfonts import TTFont from reportlab.pdfgen import canvas as _cv from reportlab.platypus import SimpleDocTemplate, Paragraph, Spacer, Table, TableStyle, PageBreak font, bold = 'Helvetica', 'Helvetica-Bold' for reg, b in (('/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf', '/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf'),): if os.path.exists(reg) and os.path.exists(b): try: pdfmetrics.registerFont(TTFont('DV', reg)); pdfmetrics.registerFont(TTFont('DVB', b)) from reportlab.lib.fonts import addMapping addMapping('DV', 0, 0, 'DV'); addMapping('DV', 1, 0, 'DVB'); addMapping('DV', 0, 1, 'DV'); addMapping('DV', 1, 1, 'DVB') font, bold = 'DV', 'DVB' except Exception: pass blue = colors.HexColor('#1F4E99') S = lambda n, **k: ParagraphStyle(n, fontName=k.pop('fn', font), **k) sT = S('t', fontSize=18, leading=22, textColor=blue, fn=bold, spaceAfter=6) sH1 = S('h1', fontSize=13, leading=16, textColor=blue, fn=bold, spaceBefore=10, spaceAfter=4) sH2 = S('h2', fontSize=11, leading=14, textColor=blue, fn=bold, spaceBefore=6, spaceAfter=3) sB = S('b', fontSize=9.5, leading=12.5, spaceAfter=3) sI = S('i', fontSize=8.5, leading=11, textColor=colors.HexColor('#444444'), spaceAfter=4) sN = S('n', fontSize=9.5, leading=12.5, leftIndent=1.1 * cm, firstLineIndent=-1.1 * cm, spaceAfter=3) sC = S('c', fontSize=8, leading=10) sCH = S('ch', fontSize=8, leading=10, textColor=colors.white, fn=bold) src = lambda rs, n=4: f'{E(_src(rs, n))}' class NC(_cv.Canvas): def __init__(self, *a, **k): super().__init__(*a, **k); self._pages = [] def showPage(self): self._pages.append(dict(self.__dict__)); self._startPage() def save(self): n = len(self._pages) for st in self._pages: self.__dict__.update(st) self.setFont(font, 7); self.setFillColor(colors.HexColor('#555555')) self.drawString(2 * cm, A4[1] - 1.2 * cm, f"The Seamstresses · Case summary · {R['headline'][:90]}") self.drawString(2 * cm, 1.0 * cm, f"Generated {R['generated']} · SHA-256 {R['seal']}") self.drawRightString(A4[0] - 2 * cm, 1.0 * cm, f'Page {self._pageNumber} of {n}') super().showPage() super().save() story = [Paragraph(E(R['headline']), sT), Paragraph(E(f"Case summary built from {len(R['sources'])} upload(s) on {R['generated']}. Every point is quoted from the uploads with its source " f"[document page/time ¶paragraph]; nothing has been added."), sI), Paragraph('Summary', sH1)] for i, e in enumerate(R['exec'], 1): story.append(Paragraph(f'{i}.  {E(e)}', sN)) def table(headers, rows, widths, red_col=None): data = [[Paragraph(E(h), sCH) for h in headers]] for row in rows: data.append([Paragraph(v if isinstance(v, str) and v.startswith('<') else E(str(v)).replace('\n', '
'), sC) for v in row]) t = Table(data, colWidths=[w * cm for w in widths], repeatRows=1) t.setStyle(TableStyle([('BACKGROUND', (0, 0), (-1, 0), blue), ('GRID', (0, 0), (-1, -1), 0.4, colors.HexColor('#9DB3D9')), ('VALIGN', (0, 0), (-1, -1), 'TOP'), ('ROWBACKGROUNDS', (0, 1), (-1, -1), [colors.white, colors.HexColor('#EEF3FB')]), ('LEFTPADDING', (0, 0), (-1, -1), 3), ('RIGHTPADDING', (0, 0), (-1, -1), 3)])) story.append(t); story.append(Spacer(1, 6)) def item(no, text, rs, quote=True): story.append(Paragraph(f'{E(no)}  {"“" if quote else ""}{E(text)}{"”" if quote else ""} {src(rs)}', sN)) for num, title, kind in section_list(R): story.append(Paragraph(E(f'{num}. {title}'), sH1)) if kind == 'parties': story.append(Paragraph('Parties and people', sH2)) if R['entities']: table(['#', 'Name (as written)', 'Role in the uploads', 'Type', 'Where'], [(f'{num}.{i}', e['name'] + (f" (also: {', '.join(e['aliases'])})" if e['aliases'] else ''), e['role'] or '-', e['type'], _src(e['refs'], 3)) for i, e in enumerate(R['entities'], 1)], [1.0, 5.4, 3.2, 2.2, 5.2]) else: story.append(Paragraph('No named parties detected.', sI)) story.append(Paragraph('References and numbers', sH2)) if R['references']: off = len(R['entities']) table(['#', 'Type', 'Reference', 'Times', 'Source'], [(f'{num}.{off + i}', r['label'].title(), r['value'], r['count'], _src(r['refs'], 2)) for i, r in enumerate(R['references'], 1)], [1.0, 3.2, 5.6, 1.4, 5.8]) else: story.append(Paragraph('No reference numbers detected.', sI)) elif kind == 'chron': if R['chronology']: story.append(Paragraph('Sorted by date. Entries describing the same event on the same date were combined; ⚠ marks a conflict with another passage (see section 5).', sI)) rows = [] for i, r in enumerate(R['chronology'], 1): when = E(r['date_text'] + (' ' + r['time'] if r['time'] else '')) if r['conflicts']: when = f'{when}
⚠ C{", C".join(map(str, r["conflicts"]))}
' rows.append((f'{num}.{i}', when if when.startswith('<') else when, r['plain'], _src(r['refs'], 3))) table(['#', 'Date / time', 'What happened (verbatim)', 'Source'], rows, [1.0, 2.9, 9.6, 3.5]) else: story.append(Paragraph('No dated events found.', sI)) elif kind == 'subjects': for si, s in enumerate(R['sections'], 1): story.append(Paragraph(E(f'{num}.{si} {s["title"]}'), sH2)) for fi, f in enumerate(s['items'], 1): item(f'{num}.{si}.{fi}', f['plain'], f['refs']) elif kind in ('harm', 'requests'): for i, f in enumerate(R[kind], 1): item(f'{num}.{i}', f['plain'], f['refs']) elif kind == 'conflicts': story.append(Paragraph('Contradictions (both passages quoted)', sH2)) if R['conflicts']: rows = [(f'C{ci}', f'{E(c["type"])}', f'A: “{E(c["a"]["text"])}” {src(c["a"]["refs"], 2)}

B: “{E(c["b"]["text"])}” {src(c["b"]["refs"], 2)}') for ci, c in enumerate(R['conflicts'], 1)] table(['#', 'Type', 'The two passages'], rows, [1.0, 3.4, 12.6]) story.append(Paragraph('Flagged because the wording is very similar but a date, amount or yes/no differs. Check whether they describe the same event.', sI)) else: story.append(Paragraph('None found (dates, amounts and yes/no statements were compared across all uploads).', sI)) story.append(Paragraph('Risk language', sH2)) if R['risk']: table(['#', 'Kind', 'Passage (verbatim)', 'Source'], [(f'{num}.{i}', f['label'], f['plain'], _src(f['refs'], 2)) for i, f in enumerate(R['risk'], 1)], [1.0, 3.4, 9.1, 3.5]) else: story.append(Paragraph('None found.', sI)) elif kind == 'laws': story.append(Paragraph('Only laws and rules that the uploads themselves cite, exactly as cited. Nothing has been added.', sI)) table(['#', 'Law or rule', 'As cited in the uploads'], [(f'{num}.{i}', e['instrument'], '\n'.join(f"“{c['text']}” ({c['count']}x) {_src(c['refs'], 2)}" for c in e['cites'][:8])) for i, e in enumerate(R['laws'], 1)], [1.0, 5.0, 11.0]) elif kind == 'dedup': dd = R['dedup'] story.append(Paragraph(E(f"Paragraphs in: {dd['paras_in']} → out: {dd['paras_out']}. Words in: {dd['words_in']} → out: {dd['words_out']}. Order kept from {dd['backbone']}."), sB)) story.append(Paragraph(E(f"Duplicate groups: {dd['groups']} ({dd['identical_groups']} identical copies, {dd['choice_groups']} with different wording). " f"Duplicate paragraphs removed: {dd['copies_removed']}. Repeated sentences removed: {dd['sentences_removed']}."), sB)) rows = [(f"G{g['id']}", f"v{g['picked']}" + (' (best)' if g['picked'] == g['best'] else f" (your pick; best v{g['best']})") + ('\n⚠ figures differ' if g['figures_differ'] else '') + ''.join(f"\n+ 1 new sentence from v{x['version']}" for x in g.get('added', [])), '\n'.join(f"v{v['n']}: {', '.join(ref(r) for r in v['refs'][:4])} · {v['words']} words" for v in g['versions'])) for g in dd['groups_detail'] if len(g['versions']) > 1] if rows: table(['Group', 'Kept', 'Versions and where they appear'], rows, [1.6, 4.0, 11.4]) if dd['left_out']: story.append(Paragraph('Sentences only in versions not kept', sH2)) for i, x in enumerate(dd['left_out'], 1): item(f'{num}.{i}', f"(group {x['group']} v{x['version']}) {x['plain']}", x['refs']) elif kind == 'sources': table(['ID', 'File', 'How it was read', 'SHA-256'], [(s['id'], s['name'], s['extractor'] + (('\n' + '; '.join(s['warnings'])) if s['warnings'] else ''), s['sha256']) for s in R['sources']], [1.1, 4.2, 5.2, 6.5]) story.append(Paragraph(E(f"Report seal (SHA-256 of the .txt report): {R['seal']}"), sI)) story += [PageBreak(), Paragraph('Part 2 - Master document', sT), Paragraph('All uploads merged into one, duplicates removed, wording exactly as in the uploads. Paragraph numbers are added; the source of each paragraph is shown after it.', sI)] import re as _re for m in R['master']: if m['kind'] == 'heading': story.append(Paragraph(E(m['plain']), sH2)); continue body = _re.sub(r'\*\*(.+?)\*\*', r'\1', E(m['text'])).replace('**', '').replace('\n', '
') story.append(Paragraph(f"{m['n']}.  {body} {src(m['refs'], 3)}", sN)) doc = SimpleDocTemplate(path, pagesize=A4, leftMargin=2 * cm, rightMargin=2 * cm, topMargin=1.8 * cm, bottomMargin=1.6 * cm, title=R['headline'], author='The Seamstresses', subject=f"SHA-256 {R['seal']}") doc.build(story, canvasmaker=NC) return path