#!/usr/bin/env python3 """Convert DokuWiki pages to nicely-formatted PDFs (reportlab).""" import re import sys from reportlab.lib import colors from reportlab.lib.colors import HexColor from reportlab.lib.pagesizes import A4 from reportlab.lib.styles import getSampleStyleSheet, ParagraphStyle from reportlab.lib.units import cm, mm from reportlab.platypus import ( SimpleDocTemplate, Paragraph, Spacer, Table, TableStyle, PageBreak, Preformatted, KeepTogether, HRFlowable ) from reportlab.lib.enums import TA_LEFT, TA_CENTER, TA_JUSTIFY ACCENT = HexColor("#00a3c4") ACCENT_LIGHT = HexColor("#e0f4f8") DARK = HexColor("#1a1a2e") GRAY = HexColor("#666666") CODE_BG = HexColor("#f4f4f4") INFO_BG = HexColor("#e8f4fd") IMPORTANT_BG = HexColor("#fef3cd") TIP_BG = HexColor("#d4edda") def build_styles(): styles = getSampleStyleSheet() styles.add(ParagraphStyle("H1c", parent=styles["Heading1"], fontSize=20, textColor=ACCENT, spaceAfter=14, spaceBefore=10, fontName="Helvetica-Bold")) styles.add(ParagraphStyle("H2c", parent=styles["Heading2"], fontSize=15, textColor=ACCENT, spaceAfter=10, spaceBefore=14, fontName="Helvetica-Bold", borderPadding=(0, 0, 4, 0), borderColor=ACCENT, borderWidth=0)) styles.add(ParagraphStyle("H3c", parent=styles["Heading3"], fontSize=12.5, textColor=DARK, spaceAfter=8, spaceBefore=10, fontName="Helvetica-Bold")) styles.add(ParagraphStyle("H4c", parent=styles["Heading4"], fontSize=11, textColor=DARK, spaceAfter=6, spaceBefore=8, fontName="Helvetica-Bold")) styles.add(ParagraphStyle("Bodyc", parent=styles["BodyText"], fontSize=9.5, leading=13, spaceAfter=6, alignment=TA_JUSTIFY)) styles.add(ParagraphStyle("Bulletc", parent=styles["BodyText"], fontSize=9.5, leading=13, leftIndent=16, bulletIndent=4, spaceAfter=3)) styles.add(ParagraphStyle("ItalicFooter", parent=styles["BodyText"], fontSize=9, textColor=GRAY, alignment=TA_CENTER, spaceAfter=4)) return styles def fmt_inline(text): """Convert DokuWiki inline markup to reportlab mini-HTML.""" # escape &, <, > text = text.replace("&", "&").replace("<", "<").replace(">", ">") # bold **x** text = re.sub(r"\*\*(.+?)\*\*", r"\1", text) # inline code ''x'' text = re.sub(r"''(.+?)''", r'\1', text) # italic //x// (avoid URLs) text = re.sub(r"(?\1", text) # doku links [[target|label]] -> label text = re.sub(r"\[\[[^\]|]+\|([^\]]+)\]\]", r"\1", text) text = re.sub(r"\[\[([^\]]+)\]\]", r"\1", text) # line break \\ text = text.replace("\\\\", "
") return text def parse_wiki(raw): """Return list of (kind, payload) blocks.""" lines = raw.splitlines() blocks = [] i = 0 while i < len(lines): ln = lines[i] stripped = ln.strip() # Separator if stripped == "----": blocks.append(("hr", None)) i += 1 continue # Headings m = re.match(r"^(=+)\s*(.+?)\s*=+\s*$", stripped) if m: level = 7 - len(m.group(1)) # ======(6) -> h1 level = max(1, min(4, level)) blocks.append((f"h{level}", m.group(2))) i += 1 continue # Code block if stripped.startswith("" not in lines[i]: code_lines.append(lines[i]) i += 1 i += 1 # skip blocks.append(("code", "\n".join(code_lines))) continue # WRAP box m = re.match(r"^]*)>\s*$", stripped) if m: kind = "info" attrs = m.group(1).lower() if "important" in attrs: kind = "important" elif "tip" in attrs: kind = "tip" elif "info" in attrs: kind = "info" i += 1 body_lines = [] while i < len(lines) and "" not in lines[i]: body_lines.append(lines[i]) i += 1 i += 1 blocks.append(("wrap", (kind, "\n".join(body_lines)))) continue # Table (starts with ^ or |) if stripped.startswith("^") or stripped.startswith("|"): tbl_lines = [] while i < len(lines) and (lines[i].strip().startswith("^") or lines[i].strip().startswith("|")): tbl_lines.append(lines[i].strip()) i += 1 blocks.append(("table", tbl_lines)) continue # Bullet list (item or sub-item) if re.match(r"^\s*\*\s+", ln): items = [] while i < len(lines) and re.match(r"^\s*\*\s+", lines[i]): m2 = re.match(r"^(\s*)\*\s+(.+)$", lines[i]) indent = len(m2.group(1)) items.append((indent, m2.group(2))) i += 1 blocks.append(("bullets", items)) continue # Ordered list (DokuWiki uses " - item") if re.match(r"^\s+-\s+", ln): items = [] while i < len(lines) and re.match(r"^\s+-\s+", lines[i]): m2 = re.match(r"^(\s*)-\s+(.+)$", lines[i]) indent = len(m2.group(1)) items.append((indent, m2.group(2))) i += 1 blocks.append(("ordered", items)) continue # Italic footer //...// if stripped.startswith("//") and stripped.endswith("//") and len(stripped) > 4: blocks.append(("italic", stripped.strip("/").strip())) i += 1 continue # Blank line -> spacer if not stripped: blocks.append(("space", None)) i += 1 continue # Regular paragraph (gather until blank or special) para_lines = [ln] i += 1 while i < len(lines): nxt = lines[i] s = nxt.strip() if not s or s.startswith("=") or s.startswith("^") or s.startswith("|") \ or s.startswith("<") or s == "----" or re.match(r"^\s*\*\s+", nxt) \ or re.match(r"^\s+-\s+", nxt): break para_lines.append(nxt) i += 1 blocks.append(("para", " ".join(l.strip() for l in para_lines))) return blocks def parse_table_row(line): """Parse a DokuWiki table row. Returns (is_header_cells_list, cells).""" # Row like: ^ h1 ^ h2 ^ or | c1 | c2 | # A row may mix ^ and | (cell-level header) sep_chars = "^|" cells = [] is_header = [] # find separators indices = [idx for idx, c in enumerate(line) if c in sep_chars] for a, b in zip(indices[:-1], indices[1:]): cell = line[a+1:b].strip() header = line[a] == "^" cells.append(cell) is_header.append(header) return is_header, cells def build_table(tbl_lines, styles): data = [] header_flags = [] for ln in tbl_lines: is_h, cells = parse_table_row(ln) if not cells: continue data.append(cells) header_flags.append(is_h) if not data: return None # Wrap each cell in a Paragraph for wrapping wrapped = [] cell_style = ParagraphStyle("cell", fontSize=8.5, leading=11) head_style = ParagraphStyle("head", fontSize=9, leading=11, fontName="Helvetica-Bold", textColor=colors.white) for row_idx, row in enumerate(data): new_row = [] for col_idx, cell in enumerate(row): is_h = header_flags[row_idx][col_idx] if col_idx < len(header_flags[row_idx]) else False style = head_style if is_h else cell_style new_row.append(Paragraph(fmt_inline(cell), style)) wrapped.append(new_row) ncols = max(len(r) for r in wrapped) # pad for r in wrapped: while len(r) < ncols: r.append(Paragraph("", cell_style)) # column widths: spread evenly on 17cm available total_w = 17 * cm col_w = total_w / ncols t = Table(wrapped, colWidths=[col_w] * ncols, repeatRows=1) ts = TableStyle([ ("GRID", (0, 0), (-1, -1), 0.3, colors.lightgrey), ("VALIGN", (0, 0), (-1, -1), "TOP"), ("LEFTPADDING", (0, 0), (-1, -1), 4), ("RIGHTPADDING", (0, 0), (-1, -1), 4), ("TOPPADDING", (0, 0), (-1, -1), 3), ("BOTTOMPADDING", (0, 0), (-1, -1), 3), ("ROWBACKGROUNDS", (0, 1), (-1, -1), [colors.white, HexColor("#f9f9f9")]), ]) # header row style (if row 0 is all headers) if all(header_flags[0]): ts.add("BACKGROUND", (0, 0), (-1, 0), ACCENT) # color header cells individually otherwise for ri, flags in enumerate(header_flags): for ci, is_h in enumerate(flags): if is_h and ri > 0: ts.add("BACKGROUND", (ci, ri), (ci, ri), ACCENT_LIGHT) t.setStyle(ts) return t def build_wrap(kind, text, styles): bg = {"info": INFO_BG, "important": IMPORTANT_BG, "tip": TIP_BG}.get(kind, INFO_BG) border = {"info": ACCENT, "important": HexColor("#e0a020"), "tip": HexColor("#28a745")}.get(kind, ACCENT) label = {"info": "Info", "important": "Important", "tip": "Astuce"}.get(kind, "Note") # Body can contain inline markup — render lines joined text_fmt = fmt_inline(text.strip()).replace("\n", "
") inner = f'{label}
{text_fmt}' p = Paragraph(inner, ParagraphStyle("wrap", fontSize=9.5, leading=13, textColor=DARK, spaceAfter=4)) tbl = Table([[p]], colWidths=[17 * cm]) tbl.setStyle(TableStyle([ ("BACKGROUND", (0, 0), (-1, -1), bg), ("BOX", (0, 0), (-1, -1), 1, border), ("LEFTPADDING", (0, 0), (-1, -1), 10), ("RIGHTPADDING", (0, 0), (-1, -1), 10), ("TOPPADDING", (0, 0), (-1, -1), 8), ("BOTTOMPADDING", (0, 0), (-1, -1), 8), ("LINEBEFORE", (0, 0), (0, -1), 4, border), ])) return tbl def render_blocks(blocks, styles, title): story = [ Paragraph(title, styles["H1c"]), HRFlowable(width="100%", thickness=2, color=ACCENT, spaceBefore=2, spaceAfter=10), ] for kind, payload in blocks: if kind == "h1": story.append(Paragraph(fmt_inline(payload), styles["H1c"])) elif kind == "h2": story.append(Paragraph(fmt_inline(payload), styles["H2c"])) story.append(HRFlowable(width="40%", thickness=1, color=ACCENT, spaceBefore=0, spaceAfter=6)) elif kind == "h3": story.append(Paragraph(fmt_inline(payload), styles["H3c"])) elif kind == "h4": story.append(Paragraph(fmt_inline(payload), styles["H4c"])) elif kind == "hr": story.append(HRFlowable(width="100%", thickness=0.5, color=colors.lightgrey, spaceBefore=6, spaceAfter=6)) elif kind == "para": story.append(Paragraph(fmt_inline(payload), styles["Bodyc"])) elif kind == "italic": story.append(Paragraph(f"{fmt_inline(payload)}", styles["ItalicFooter"])) elif kind == "code": story.append(Preformatted(payload, ParagraphStyle( "code", fontName="Courier", fontSize=8, leading=10, backColor=CODE_BG, borderColor=colors.lightgrey, borderWidth=0.5, borderPadding=6, spaceAfter=8, spaceBefore=4, leftIndent=4))) elif kind == "wrap": w_kind, w_body = payload story.append(build_wrap(w_kind, w_body, styles)) story.append(Spacer(1, 6)) elif kind == "table": t = build_table(payload, styles) if t: story.append(t) story.append(Spacer(1, 8)) elif kind == "bullets": for indent, text in payload: lvl = indent // 2 story.append(Paragraph(fmt_inline(text), ParagraphStyle( "bl", fontSize=9.5, leading=13, leftIndent=16 + lvl * 12, bulletIndent=4 + lvl * 12, spaceAfter=2), bulletText="•")) elif kind == "ordered": for n, (indent, text) in enumerate(payload, 1): lvl = max(0, (indent - 2) // 2) story.append(Paragraph(fmt_inline(text), ParagraphStyle( "ol", fontSize=9.5, leading=13, leftIndent=18 + lvl * 12, bulletIndent=4 + lvl * 12, spaceAfter=2), bulletText=f"{n}.")) elif kind == "space": story.append(Spacer(1, 4)) return story def add_page_footer(canvas, doc): canvas.saveState() canvas.setFont("Helvetica", 8) canvas.setFillColor(GRAY) canvas.drawString(1.5 * cm, 1 * cm, "SANEF DSI / Sécurité Opérationnelle — Plan d'action Qualys V3") canvas.drawRightString(A4[0] - 1.5 * cm, 1 * cm, f"Page {canvas.getPageNumber()}") canvas.restoreState() def generate(src_path, dst_path, title): with open(src_path, "r", encoding="utf-8") as f: raw = f.read() blocks = parse_wiki(raw) styles = build_styles() story = render_blocks(blocks, styles, title) doc = SimpleDocTemplate(dst_path, pagesize=A4, leftMargin=1.5 * cm, rightMargin=1.5 * cm, topMargin=1.8 * cm, bottomMargin=1.8 * cm, title=title) doc.build(story, onFirstPage=add_page_footer, onLaterPages=add_page_footer) print(f"Generated: {dst_path}") if __name__ == "__main__": # args: src title dst src, title, dst = sys.argv[1], sys.argv[2], sys.argv[3] generate(src, dst, title)