- 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>
363 lines
14 KiB
Python
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("&", "&").replace("<", "<").replace(">", ">")
|
|
# 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)
|