Spaces:
Sleeping
Sleeping
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 Download report_export.py from Basementup/Seamstressesstitching: direct link, hf CLI and curl.
- Browser
- Download file 23.4 kB
-
https://huggingface.co/spaces/Basementup/Seamstressesstitching/resolve/main/report_export.py
- Command line
-
hf download hf://spaces/Basementup/Seamstressesstitching/report_export.py
-
curl -L -o report_export.py https://huggingface.co/spaces/Basementup/Seamstressesstitching/resolve/main/report_export.py
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 | |
| 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> {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> {"“" 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> {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 | |