fix(export): v4.7.3 tableaux + emojis en PDF/HTML
- Tableaux GFM rendus comme texte brut dans les exports HTML/PDF. Ajout d'un parseur de tableaux pipe (bloc 'table') + rendu <table> (thead/tbody, alignement gauche/centre/droite, bordures .ftable) dans blocks_to_html; blocks_to_markdown reconstruit un tableau pipe valide. - Emojis 'carrés noirs' en PDF: xhtml2pdf n'embarque que des polices de base sans glyphes emoji. Moteur PDF -> WeasyPrint (tables CSS + emojis couleur via pango + fonts-noto-color-emoji installes dans l'image). Repli automatique sur xhtml2pdf quand weasyprint n'a pas ses libs natives (dev Windows). - Dockerfile: libs weasyprint (pango/harfbuzz/gdk-pixbuf/shared-mime-info) + fonts-dejavu-core + fonts-noto-color-emoji. requirements: + weasyprint==69.0. - Verifie en reel sur README.md: HTML = <table class=ftable> (thead/th, center); PDF 10 pages, texte de table present, Noto-Color-Emoji embarque + pixels colores confirmes. 199/199 tests (4 nouveaux).
This commit is contained in:
+2
-2
@@ -39,13 +39,13 @@ async def lifespan(_app: FastAPI):
|
||||
(admin_hash,)
|
||||
)
|
||||
conn.commit()
|
||||
logger.info("FlowDeck v4.7.2 started on port %d", settings.app_port)
|
||||
logger.info("FlowDeck v4.7.3 started on port %d", settings.app_port)
|
||||
yield
|
||||
|
||||
|
||||
app = FastAPI(
|
||||
title="FlowDeck",
|
||||
version="4.7.2",
|
||||
version="4.7.3",
|
||||
docs_url="/docs" if settings.log_level == "DEBUG" else None,
|
||||
redoc_url=None,
|
||||
lifespan=lifespan,
|
||||
|
||||
@@ -74,7 +74,7 @@ async def export_pdf(page_id: int, request: Request):
|
||||
try:
|
||||
pdf_bytes = page_to_pdf_bytes(page)
|
||||
except ImportError:
|
||||
raise HTTPException(status_code=501, detail="PDF export requires 'xhtml2pdf'")
|
||||
raise HTTPException(status_code=501, detail="PDF export requires 'weasyprint' or 'xhtml2pdf'")
|
||||
except Exception as exc: # noqa: BLE001
|
||||
logger.error("PDF export failed for page %s: %s", page_id, exc)
|
||||
raise HTTPException(status_code=500, detail="PDF generation failed")
|
||||
|
||||
+176
-1
@@ -171,6 +171,145 @@ def _page_source(page: dict):
|
||||
return "blocks", []
|
||||
|
||||
|
||||
# ── GFM pipe-table parsing (raw markdown → "table" block) ──
|
||||
|
||||
_SEP_CELL = re.compile(r"^:?-+:?$")
|
||||
|
||||
|
||||
def _split_pipe_cells(line: str) -> list[str]:
|
||||
"""Split a GFM pipe row into trimmed cell strings."""
|
||||
s = line.strip()
|
||||
if s.startswith("|"):
|
||||
s = s[1:]
|
||||
if s.endswith("|") and not s.endswith(r"\|"):
|
||||
s = s[:-1]
|
||||
# split on unescaped pipes
|
||||
cells: list[str] = []
|
||||
cur: list[str] = []
|
||||
i = 0
|
||||
while i < len(s):
|
||||
ch = s[i]
|
||||
if ch == "\\" and i + 1 < len(s) and s[i + 1] == "|":
|
||||
cur.append("|")
|
||||
i += 2
|
||||
continue
|
||||
if ch == "|":
|
||||
cells.append("".join(cur).strip())
|
||||
cur = []
|
||||
i += 1
|
||||
continue
|
||||
cur.append(ch)
|
||||
i += 1
|
||||
cells.append("".join(cur).strip())
|
||||
return cells
|
||||
|
||||
|
||||
def _is_table_delimiter(line: str) -> bool:
|
||||
s = line.strip()
|
||||
if not s:
|
||||
return False
|
||||
if s.startswith("|"):
|
||||
s = s[1:]
|
||||
if s.endswith("|"):
|
||||
s = s[:-1]
|
||||
cells = [c.strip() for c in s.split("|")]
|
||||
return bool(cells) and all(_SEP_CELL.match(c) for c in cells)
|
||||
|
||||
|
||||
def _parse_table_at(lines: list[str], i: int, n: int):
|
||||
"""If a GFM table starts at index i (header row + delimiter row), return
|
||||
(table_block, next_index). Otherwise return None."""
|
||||
header_cells = _split_pipe_cells(lines[i])
|
||||
if len(header_cells) <= 1:
|
||||
return None
|
||||
if i + 1 >= n or not _is_table_delimiter(lines[i + 1]):
|
||||
return None
|
||||
sep_cells = _split_pipe_cells(lines[i + 1])
|
||||
align = []
|
||||
for c in sep_cells[: len(header_cells)]:
|
||||
c = c.strip()
|
||||
if c.startswith(":") and c.endswith(":"):
|
||||
align.append("center")
|
||||
elif c.endswith(":"):
|
||||
align.append("right")
|
||||
else:
|
||||
align.append("left")
|
||||
rows = [header_cells]
|
||||
j = i + 2
|
||||
while j < n:
|
||||
s = lines[j].strip()
|
||||
if not s or not s.startswith("|"):
|
||||
break
|
||||
cells = _split_pipe_cells(lines[j])
|
||||
rows.append(cells)
|
||||
j += 1
|
||||
width = max(len(r) for r in rows)
|
||||
pad = lambda r: r + [""] * (width - len(r))
|
||||
align = (align + ["left"] * width)[:width]
|
||||
return (
|
||||
{
|
||||
"type": "table",
|
||||
"has_header": True,
|
||||
"align": align,
|
||||
"rows": [pad(r) for r in rows],
|
||||
},
|
||||
j,
|
||||
)
|
||||
|
||||
|
||||
def _table_to_markdown(b: dict) -> str:
|
||||
rows = b.get("rows") or []
|
||||
if not rows:
|
||||
return ""
|
||||
align = b.get("align") or []
|
||||
width = max(len(r) for r in rows)
|
||||
align = (align + ["left"] * width)[:width]
|
||||
out: list[str] = []
|
||||
|
||||
def rowline(r):
|
||||
cells = list(r) + [""] * (width - len(r))
|
||||
return "| " + " | ".join(cells) + " |"
|
||||
|
||||
for ri, r in enumerate(rows):
|
||||
out.append(rowline(r))
|
||||
if b.get("has_header") and ri == 0:
|
||||
seps = []
|
||||
for a in align:
|
||||
if a == "center":
|
||||
seps.append(":---:")
|
||||
elif a == "right":
|
||||
seps.append("---:")
|
||||
else:
|
||||
seps.append(":---")
|
||||
out.append("| " + " | ".join(seps) + " |")
|
||||
return "\n".join(out)
|
||||
|
||||
|
||||
def _table_to_html(b: dict) -> str:
|
||||
rows = b.get("rows") or []
|
||||
if not rows:
|
||||
return ""
|
||||
align = b.get("align") or []
|
||||
width = max(len(r) for r in rows)
|
||||
align = (align + ["left"] * width)[:width]
|
||||
|
||||
def cell_html(tag, text, a):
|
||||
style = f' style="text-align:{a};"' if a and a != "left" else ""
|
||||
return f"<{tag}{style}>{_text(text)}</{tag}>"
|
||||
|
||||
def row_html(r, tag_default):
|
||||
cells = list(r) + [""] * (width - len(r))
|
||||
return "<tr>" + "".join(cell_html(tag_default, c, align[ci]) for ci, c in enumerate(cells)) + "</tr>"
|
||||
|
||||
header_rows = 1 if b.get("has_header") else 0
|
||||
head = ""
|
||||
if header_rows:
|
||||
head = "<thead>" + "".join(row_html(r, "th") for r in rows[:header_rows]) + "</thead>"
|
||||
tbody_rows = rows[header_rows:]
|
||||
body = "<tbody>" + "".join(row_html(r, "td") for r in tbody_rows) + "</tbody>"
|
||||
return f'<table class="ftable">{head}{body}</table>'
|
||||
|
||||
|
||||
# ── Markdown renderer (raw markdown → exportable fragments) ──
|
||||
|
||||
def _md_to_blocks(md: str) -> list:
|
||||
@@ -213,6 +352,14 @@ def _md_to_blocks(md: str) -> list:
|
||||
i += 1 # closing fence
|
||||
blocks.append({"type": "code", "content": "\n".join(code), "language": lang})
|
||||
continue
|
||||
if stripped.startswith("|"):
|
||||
# GFM pipe table: header row immediately followed by a delimiter row
|
||||
parsed = _parse_table_at(lines, i, n)
|
||||
if parsed is not None:
|
||||
flush_para()
|
||||
tbl, i = parsed
|
||||
blocks.append(tbl)
|
||||
continue
|
||||
m = re.match(r"^(#{1,6})\s+(.*)$", stripped)
|
||||
if m and line == stripped: # ATX heading must be whole line
|
||||
level = len(m.group(1))
|
||||
@@ -344,6 +491,8 @@ def blocks_to_markdown(blocks: list) -> str:
|
||||
src = b.get("src") or ""
|
||||
alt = (b.get("alt") or "").strip() or "image"
|
||||
out.append(f"")
|
||||
elif t == "table":
|
||||
out.append(_table_to_markdown(b))
|
||||
else:
|
||||
out.append(c)
|
||||
return "\n\n".join(filter(None, out))
|
||||
@@ -444,6 +593,8 @@ def blocks_to_html(blocks: list) -> str:
|
||||
src = b.get("src") or ""
|
||||
alt = _text(b.get("alt"))
|
||||
parts.append(f'<figure><img src="{src}" alt="{alt}"><figcaption>{alt}</figcaption></figure>')
|
||||
elif t == "table":
|
||||
parts.append(_table_to_html(b))
|
||||
else:
|
||||
parts.append(f"<p>{c}</p>")
|
||||
return "\n".join(parts)
|
||||
@@ -485,6 +636,10 @@ details[open] summary{margin-bottom:8px;}
|
||||
figure{margin:16px 0;text-align:center;}
|
||||
figure img{max-width:100%;border-radius:8px;}
|
||||
figcaption{font-size:13px;color:#8b949e;margin-top:6px;}
|
||||
.ftable{width:100%;border-collapse:collapse;margin:16px 0;font-size:14.5px;line-height:1.45;}
|
||||
.ftable th,.ftable td{border:1px solid #d8dee4;padding:7px 12px;vertical-align:top;}
|
||||
.ftable th{background:#f6f8fa;font-weight:600;}
|
||||
.ftable tr:nth-child(even) td{background:#fcfcfd;}
|
||||
.footer{margin-top:56px;padding-top:16px;border-top:1px solid #eaeef2;color:#8b949e;font-size:12px;display:flex;justify-content:space-between;}
|
||||
a{color:#0969da;}
|
||||
@media print{body{background:#fff;}.wrap{padding:0;max-width:100%;}}
|
||||
@@ -548,6 +703,9 @@ pre{{background:#f4f4f4;padding:10px;font-size:10px;white-space:pre-wrap;}}
|
||||
code{{font-family:monospace;font-size:10px;}}
|
||||
blockquote{{border-left:3px solid #ccc;margin:8px 0;padding:2px 12px;font-style:italic;}}
|
||||
table{{border-collapse:collapse;width:100%;}}
|
||||
.ftable{{border-collapse:collapse;width:100%;margin:10px 0;}}
|
||||
.ftable th,.ftable td{{border:1px solid #999;padding:5px 8px;}}
|
||||
.ftable th{{background:#f0f0f0;font-weight:bold;}}
|
||||
hr{{border:none;border-top:1px solid #ddd;margin:16px 0;}}
|
||||
.todo{{margin:4px 0;}}
|
||||
.math{{font-style:italic;margin:10px 0;}}
|
||||
@@ -561,8 +719,25 @@ hr{{border:none;border-top:1px solid #ddd;margin:16px 0;}}
|
||||
|
||||
|
||||
def page_to_pdf_bytes(page: dict) -> bytes:
|
||||
"""Render a page to a PDF (via xhtml2pdf)."""
|
||||
"""Render a page to a PDF.
|
||||
|
||||
Primary engine: WeasyPrint — renders colour emoji and proper CSS tables
|
||||
(needs system libs: pango + fonts; available in the Docker image).
|
||||
Fallback: xhtml2pdf (pure Python) when WeasyPrint's native libraries are
|
||||
absent (e.g. a Windows dev host) — text/table content still exports,
|
||||
though emoji are limited to monochrome by the engine.
|
||||
"""
|
||||
# 1) WeasyPrint (best fidelity: colour emoji, CSS tables)
|
||||
try:
|
||||
from weasyprint import HTML
|
||||
|
||||
html = page_to_standalone_html(page, include_children=False)
|
||||
return HTML(string=html, base_url=_data_root().as_uri() + "/").write_pdf()
|
||||
except Exception: # ImportError or missing native libs (OSError) -> fallback
|
||||
pass
|
||||
# 2) xhtml2pdf fallback (pure Python)
|
||||
from xhtml2pdf import pisa
|
||||
|
||||
src = _pdf_html(page)
|
||||
buf = io.BytesIO()
|
||||
pdf = pisa.CreatePDF(src, dest=buf, encoding="utf-8")
|
||||
|
||||
Reference in New Issue
Block a user