fix(export): v4.7.3 tableaux + emojis en PDF/HTML
FlowDeck CI / test (push) Failing after 20s
FlowDeck CI / docker (push) Skipped

- 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:
2026-09-03 01:59:39 -04:00
parent fa97f07ec8
commit 6e30589133
9 changed files with 283 additions and 7 deletions
+14
View File
@@ -1,5 +1,19 @@
# Changelog — FlowDeck
## v4.7.3 (2026-09-03) — Fix export PDF/HTML : tableaux + émojis
### Fixed
- **Tableaux Markdown rendus comme du texte brut** dans les exports HTML/PDF (les lignes `| a | b |` ressortaient en paragraphes). Ajout d'un parseur GFM de tableaux (`_md_to_blocks` → bloc `table`) et d'un rendu en vrai `<table>` (en-tête `<th>`, corps `<td>`, alignement gauche/centre/droite, bordures fines via `.ftable`). Le format Markdown reconstruit un tableau pipe valide.
- **Émojis en « carrés noirs » dans le PDF.** Cause : le moteur `xhtml2pdf` (reportlab) n'embarque que des polices de base sans glyphes Unicode/émojis. Le moteur PDF passe à **WeasyPrint** (vraies tables CSS + émojis couleur via Pango + `fonts-noto-color-emoji`, installés dans l'image Docker). Repli automatique sur `xhtml2pdf` si WeasyPrint n'a pas ses libs natives (ex. poste de dev Windows) — le contenu s'exporte quand même.
### Infra
- `Dockerfile` : ajout des libs WeasyPrint (pango/harfbuzz/gdk-pixbuf/shared-mime-info) + polices `fonts-dejavu-core` et `fonts-noto-color-emoji`.
- `requirements.txt` : + `weasyprint==69.0` (xhtml2pdf conservé en repli).
### Tests
- **199 tests** (4 nouveaux) : parse table → `<table>` HTML réel + alignements ; standalone HTML embarque `.ftable` ; roundtrip table → markdown pipe ; PDF d'une page avec table = PDF valide.
- Vérifié en réel sur `README.md` : PDF avec tableau structuré + émojis en couleur (pixels colorés confirmés), HTML avec tableau bordé.
## v4.7.2 (2026-09-03) — Fix export : le contenu des documents était absent
### Fixed
+13 -1
View File
@@ -2,7 +2,19 @@ FROM python:3.12-slim
WORKDIR /app
RUN apt-get update && apt-get install -y --no-install-recommends curl && rm -rf /var/lib/apt/lists/*
RUN apt-get update && apt-get install -y --no-install-recommends \
curl \
# WeasyPrint PDF: text layout (pango/harfbuzz), image decoding, fonts,
# colour emoji support.
libpango-1.0-0 \
libpangoft2-1.0-0 \
libharfbuzz0b \
libffi-dev \
libgdk-pixbuf-2.0-0 \
shared-mime-info \
fonts-dejavu-core \
fonts-noto-color-emoji \
&& rm -rf /var/lib/apt/lists/*
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
+7 -1
View File
@@ -295,6 +295,12 @@ Détails livrés :
- [x] **Export MD/HTML/PDF des documents** — le service ne lisait que les pages `blocks` ; les pages `file` (upload `.md`/code, contenu sur disque) et `markdown` sortaient avec le seul titre. Résolution de la vraie source pour les 3 formats + rendu HTML/PDF correct des headings/listes.
- [x] 5 nouveaux tests → 195/195 ; vérifié sur les vraies données
### v4.7.3 — Fix export PDF/HTML : tableaux + émojis ✅ (2026-09-03)
- [x] **Tableaux** — parseur GFM (bloc `table`) + rendu `<table>` HTML (thead/tbody, alignements, bordures `.ftable`) dans les exports HTML/PDF ; roundtrip markdown pipe valide.
- [x] **Émojis PDF** — passage du moteur à **WeasyPrint** (émojis couleur via Pango + fonts-noto-color-emoji dans l'image) ; repli automatique xhtml2pdf si libs natives absentes (dev Windows).
- [x] Dockerfile : libs pango/harfbuzz/gdk-pixbuf + polices ; requirements : + weasyprint==69.0
- [x] 4 nouveaux tests → 199/199 ; PDF README.md vérifié : tableau structuré + émojis colorés
### v4.8.0 — Collaboration
- [ ] **Inline comments** — commentaires sur sélection de texte
- [ ] **@mentions** — notifier un utilisateur → page/commentaire
@@ -447,4 +453,4 @@ v4.0.2 ✅ v4.1.0 ✅ v4.2.0 ✅ v4.3.0 ✅ v4.4.0 ✅ v4.5.0 ✅
Quality Data Sources Templates + 10 Views Tasks & Sprints & Content Export Collab + Pro + Agent
& Tests & Linked DB Dashboards complets Dependencies My Tasks Blocks MD/PDF/HTML Agent IA (futur)
*Dernière mise à jour: 2026-09-03 — v4.7.2 Export fix contenu ✅*
*Dernière mise à jour: 2026-09-03 — v4.7.3 Export tableaux + émojis ✅*
+1 -1
View File
@@ -1 +1 @@
4.7.2
4.7.3
+2 -2
View File
@@ -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,
+1 -1
View File
@@ -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
View File
@@ -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"![{alt}]({src})")
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")
+1
View File
@@ -11,4 +11,5 @@ python-dotenv==1.0.*
packaging>=24.0
itsdangerous==2.2.*
slowapi==0.1.*
weasyprint==69.0
xhtml2pdf==0.2.*
+68
View File
@@ -3165,5 +3165,73 @@ def test_v472_binary_file_page_not_exported(client, monkeypatch, tmp_path):
md = page_to_markdown(_load(pid), include_children=False)
assert "# manual.pdf" in md
assert "fake binary" not in md # never dump binary into markdown
finally:
_cleanup_src(pid, uid)
_TABLE_MD = (
"# Titre\n\n"
"| ID | Nom | Score | Statut |\n"
"|----|:---:|------:|--------|\n"
"| 1 | Alice | 95.5 | ✅ Actif |\n"
"| 2 | Bob | 87.2 | 🟡 En attente |\n"
"| 3 | Charlie | 99.9 | ❌ Inactif |\n"
)
def test_v472_table_md_renders_real_html_table(client):
"""A GFM pipe table becomes a real <table> (header + cells + alignment)."""
from app.services.export import _page_blocks, blocks_to_html
pid, uid = _make_src_page(raw_md=_TABLE_MD, title="Table Page")
try:
blocks = _page_blocks(_load(pid))
tables = [b for b in blocks if b.get("type") == "table"]
assert tables, "expected a parsed table block"
t = tables[0]
assert t["align"] == ["left", "center", "right", "left"]
html = blocks_to_html(blocks)
assert "<table" in html
assert "<thead>" in html and "<th>" in html
assert "<tbody>" in html and "<td>" in html
assert 'text-align:center;' in html
assert "Alice" in html and "✅ Actif" in html
finally:
_cleanup_src(pid, uid)
def test_v472_table_md_standalone_html_has_table_css(client):
"""Standalone HTML export embeds the table and its stylesheet class."""
from app.services.export import page_to_standalone_html
pid, uid = _make_src_page(raw_md=_TABLE_MD, title="Table Page")
try:
html = page_to_standalone_html(_load(pid), include_children=False)
assert 'class="ftable"' in html
assert "Alice" in html
assert "Charlie" in html
finally:
_cleanup_src(pid, uid)
def test_v472_table_markdown_roundtrip(client):
"""A table block is re-emitted as a valid pipe table with a separator."""
from app.services.export import _page_blocks, blocks_to_markdown
pid, uid = _make_src_page(raw_md=_TABLE_MD, title="Table Page")
try:
md = blocks_to_markdown(_page_blocks(_load(pid)))
assert "| ID | Nom | Score | Statut |" in md
assert ":---:" in md and "---:" in md
assert "| 3 | Charlie" in md
finally:
_cleanup_src(pid, uid)
def test_v472_table_in_pdf(client, monkeypatch, tmp_path):
"""PDF export of a markdown table page returns a valid PDF (any engine)."""
from app.services.export import page_to_pdf_bytes
pid, uid = _make_src_page(raw_md=_TABLE_MD, title="Table Page")
try:
pdf = page_to_pdf_bytes(_load(pid))
assert pdf[:5] == b"%PDF-"
assert len(pdf) > 1000
finally:
_cleanup_src(pid, uid)