ergodic-sampling/make_report.py

223 lines
8.3 KiB
Python
Raw Normal View History

2026-09-12 20:27:28 -04:00
"""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<alt>[^\]]*)\]\((?P<path>[^)]+)\)\s*$')
CAPTION_RE = re.compile(r'^\*(?P<text>Figure .*)\*\s*$')
# bold, inline code, or link, in one pass so the pieces stay in document order
INLINE_RE = re.compile(r'\*\*(?P<bold>[^*]+)\*\*|`(?P<code>[^`]+)`|\[(?P<text>[^\]]+)\]\([^)]+\)')
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}')