patchcenter/tools/wiki_to_pdf.py
Admin MPCZ 677f621c81 Admin applications + correspondance cleanup + tools presentation DSI
- Admin applications: CRUD module (list/add/edit/delete/assign/multi-app)
  avec push iTop bidirectionnel (applications.py + 3 templates)
- Correspondance prod<->hors-prod: migration vers server_correspondance
  globale, suppression ancien code quickwin, ajout filtre environnement
  et solution applicative, colonne environnement dans builder
- Servers page: colonne application_name + equivalent(s) via get_links_bulk,
  filtre application_id, push iTop sur changement application
- Patching: bulk_update_application, bulk_update_excludes, validations
- Fix paramiko sftp.put (remote_path -> positional arg)
- Tools: wiki_to_pdf.py (DokuWiki -> PDF) + generate_ppt.py (PPTX 19 slides
  DSI patching) + contenu source (processus_patching.txt, script_presentation.txt)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 21:11:58 +02:00

363 lines
14 KiB
Python

#!/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("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
# bold **x**
text = re.sub(r"\*\*(.+?)\*\*", r"<b>\1</b>", text)
# inline code ''x''
text = re.sub(r"''(.+?)''", r'<font face="Courier" color="#b22222">\1</font>', text)
# italic //x// (avoid URLs)
text = re.sub(r"(?<!:)//(.+?)//", r"<i>\1</i>", 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("\\\\", "<br/>")
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("<code"):
i += 1
code_lines = []
while i < len(lines) and "</code>" not in lines[i]:
code_lines.append(lines[i])
i += 1
i += 1 # skip </code>
blocks.append(("code", "\n".join(code_lines)))
continue
# WRAP box
m = re.match(r"^<WRAP\s+([^>]*)>\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 "</WRAP>" 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", "<br/>")
inner = f'<b><font color="{border.hexval()}">{label}</font></b><br/>{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"<i>{fmt_inline(payload)}</i>", 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)