"""Render findings.md (text plus the generated plots) into a report: report.docx and report.pdf. Handles the markdown subset findings.md uses: headings, paragraphs with bold and links, bullet lists, pipe tables, and image lines followed by an italic "*Figure n. ...*" caption line. Wide figures (aspect ratio above 2) get their own landscape page so the panel labels stay legible; everything else flows inline on portrait pages. The PDF is produced by converting the .docx with LibreOffice, which must be on the PATH. """ import re import shutil import subprocess import sys from pathlib import Path from PIL import Image from docx import Document from docx.enum.section import WD_ORIENT, WD_SECTION from docx.enum.text import WD_ALIGN_PARAGRAPH from docx.shared import Emu, Inches, Pt, RGBColor SRC = Path(__file__).parent / 'findings.md' OUT_DOCX = Path(__file__).parent / 'report.docx' PORTRAIT = (Inches(8.5), Inches(11), Inches(1.0)) # width, height, side margin LANDSCAPE = (Inches(11), Inches(8.5), Inches(0.5)) MARGIN_TB = Inches(0.8) WIDE_ASPECT = 2.0 # aspect above this gets a dedicated landscape page MAX_INLINE_HEIGHT = 7.4 # inches, leaves room for the caption on a portrait page IMG_RE = re.compile(r'^!\[(?P[^\]]*)\]\((?P[^)]+)\)\s*$') CAPTION_RE = re.compile(r'^\*(?PFigure .*)\*\s*$') # bold, inline code, or link, in one pass so the pieces stay in document order INLINE_RE = re.compile(r'\*\*(?P[^*]+)\*\*|`(?P[^`]+)`|\[(?P[^\]]+)\]\([^)]+\)') def set_page(section, geometry): width, height, side = geometry section.orientation = WD_ORIENT.LANDSCAPE if width > height else WD_ORIENT.PORTRAIT section.page_width, section.page_height = width, height section.left_margin = section.right_margin = side section.top_margin = section.bottom_margin = MARGIN_TB def usable_width(geometry): width, _, side = geometry return Emu(width - 2*side).inches # Length arithmetic returns plain integer emu class Report: """Builds the document, tracking which page orientation is currently active.""" def __init__(self): self.doc = Document() self.geometry = PORTRAIT set_page(self.doc.sections[0], PORTRAIT) normal = self.doc.styles['Normal'] normal.font.name = 'Calibri' normal.font.size = Pt(10.5) normal.paragraph_format.space_after = Pt(8) def use(self, geometry): """Switch orientation lazily, at the moment content is added, so no empty page is left behind when two figures of the same orientation follow each other.""" if geometry is self.geometry: return set_page(self.doc.add_section(WD_SECTION.NEW_PAGE), geometry) self.geometry = geometry def runs(self, paragraph, text): """Emit `text` into `paragraph`, honouring bold, inline code and link markup.""" pos = 0 for m in INLINE_RE.finditer(text): if m.start() > pos: paragraph.add_run(text[pos:m.start()]) if m.group('bold') is not None: paragraph.add_run(m.group('bold')).bold = True elif m.group('code') is not None: run = paragraph.add_run(m.group('code')) run.font.name = 'Consolas' run.font.size = Pt(9.5) else: run = paragraph.add_run(m.group('text')) run.font.color.rgb = RGBColor(0x1F, 0x4E, 0x79) pos = m.end() paragraph.add_run(text[pos:]) def heading(self, text, level): self.use(PORTRAIT) p = self.doc.add_heading('', level=level) self.runs(p, text) return p def paragraph(self, text, style=None): self.use(PORTRAIT) p = self.doc.add_paragraph(style=style) self.runs(p, text) return p def figure(self, path, caption): img = Image.open(path) aspect = img.width / img.height wide = aspect >= WIDE_ASPECT self.use(LANDSCAPE if wide else PORTRAIT) width = usable_width(self.geometry) height = width/aspect if not wide and height > MAX_INLINE_HEIGHT: # tall figure: fit the page height instead height, width = MAX_INLINE_HEIGHT, MAX_INLINE_HEIGHT*aspect p = self.doc.add_paragraph() p.alignment = WD_ALIGN_PARAGRAPH.CENTER p.paragraph_format.space_after = Pt(4) p.add_run().add_picture(str(path), width=Inches(width)) cap = self.doc.add_paragraph() cap.alignment = WD_ALIGN_PARAGRAPH.CENTER for run in [cap.add_run(caption)]: run.italic = True run.font.size = Pt(9) def table(self, rows): self.use(PORTRAIT) t = self.doc.add_table(rows=len(rows), cols=len(rows[0])) t.style = 'Table Grid' t.autofit = True for r, row in enumerate(rows): for c, cell in enumerate(row): para = t.cell(r, c).paragraphs[0] self.runs(para, cell) para.paragraph_format.space_after = Pt(2) for run in para.runs: run.font.size = Pt(9) run.bold = run.bold or r == 0 self.doc.add_paragraph() def split_row(line): return [c.strip() for c in line.strip().strip('|').split('|')] def is_separator(line): return bool(re.fullmatch(r'\|[\s:|-]+\|', line.strip())) def render(md_lines, report, base): i, n = 0, len(md_lines) while i < n: line = md_lines[i].rstrip() if not line.strip(): i += 1 continue m = IMG_RE.match(line) if m: j = i + 1 while j < n and not md_lines[j].strip(): j += 1 caption_lines = [] while j < n and md_lines[j].strip(): # captions wrap over several lines caption_lines.append(md_lines[j].strip()) j += 1 caption = ' '.join(caption_lines) cm = CAPTION_RE.match(caption) report.figure(base/m.group('path'), cm.group('text') if cm else m.group('alt')) i = j if cm else i + 1 continue if line.startswith('#'): level = len(line) - len(line.lstrip('#')) report.heading(line[level:].strip(), level - 1 if level > 1 else 0) i += 1 continue if line.lstrip().startswith('- '): while i < n and md_lines[i].lstrip().startswith('- '): indent = len(md_lines[i]) - len(md_lines[i].lstrip()) item = [md_lines[i].lstrip()[2:].strip()] i += 1 while (i < n and md_lines[i].strip() # wrapped continuation and not md_lines[i].lstrip().startswith('- ') and len(md_lines[i]) - len(md_lines[i].lstrip()) > indent + 1): item.append(md_lines[i].strip()) i += 1 style = 'List Bullet' if indent < 2 else 'List Bullet 2' report.paragraph(' '.join(item), style=style) continue if line.startswith('|'): rows = [] while i < n and md_lines[i].strip().startswith('|'): if not is_separator(md_lines[i]): rows.append(split_row(md_lines[i])) i += 1 report.table(rows) continue block = [line.strip()] i += 1 while i < n and md_lines[i].strip() and not md_lines[i].lstrip().startswith(('#', '|', '- ', '![')): block.append(md_lines[i].strip()) i += 1 report.paragraph(' '.join(block)) def to_pdf(docx_path): soffice = shutil.which('soffice') or shutil.which('libreoffice') if soffice is None: print('LibreOffice not found; wrote the .docx only', file=sys.stderr) return None subprocess.run([soffice, '--headless', '--convert-to', 'pdf', '--outdir', str(docx_path.parent), str(docx_path)], check=True, stdout=subprocess.DEVNULL) return docx_path.with_suffix('.pdf') if __name__ == '__main__': report = Report() render(SRC.read_text().splitlines(), report, SRC.parent) report.doc.save(OUT_DOCX) print(f'wrote {OUT_DOCX}') pdf = to_pdf(OUT_DOCX) if pdf: print(f'wrote {pdf}')