Seamstressesstitching / report_export.py
Basementup's picture
Defined case-summary output; any file type (OCR, email, spreadsheets, links, audio); Choose for me / Let me choose flow; blue theme; /stitch unchanged
cbfdc12 verified
Raw History Blame Contribute Delete
23.4 kB
"""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'<font size="7.5" color="#1F4E99">{E(_src(rs, n))}</font>'
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'<b>{i}.</b>&nbsp;&nbsp;{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', '<br/>'), 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'<b>{E(no)}</b>&nbsp;&nbsp;{"“" 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'<font color="#C00000"><b>{when}<br/>⚠ C{", C".join(map(str, r["conflicts"]))}</b></font>'
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'<font color="#C00000"><b>{E(c["type"])}</b></font>',
f'<b>A:</b> “{E(c["a"]["text"])}” {src(c["a"]["refs"], 2)}<br/><br/><b>B:</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'<b>\1</b>', E(m['text'])).replace('**', '').replace('\n', '<br/>')
story.append(Paragraph(f"<b>{m['n']}.</b>&nbsp;&nbsp;{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