222 lines
8.3 KiB
Python
222 lines
8.3 KiB
Python
"""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}')
|