diff --git a/CHANGELOG.md b/CHANGELOG.md
index 4935bbf..74f2e61 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -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 `
` (en-tête `
`, corps `
`, 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 → `
` 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
diff --git a/Dockerfile b/Dockerfile
index e4b0b1a..7606e8c 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -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
diff --git a/ROADMAP.md b/ROADMAP.md
index c8be4f0..7ad313b 100644
--- a/ROADMAP.md
+++ b/ROADMAP.md
@@ -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 `
` 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 ✅*
diff --git a/VERSION b/VERSION
index 0b87099..4178d09 100644
--- a/VERSION
+++ b/VERSION
@@ -1 +1 @@
-4.7.2
\ No newline at end of file
+4.7.3
\ No newline at end of file
diff --git a/app/main.py b/app/main.py
index fcd3ef9..159d7c0 100644
--- a/app/main.py
+++ b/app/main.py
@@ -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,
diff --git a/app/routers/export.py b/app/routers/export.py
index 00694f2..c8ed005 100644
--- a/app/routers/export.py
+++ b/app/routers/export.py
@@ -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")
diff --git a/app/services/export.py b/app/services/export.py
index 9ca471e..ad5f4b2 100644
--- a/app/services/export.py
+++ b/app/services/export.py
@@ -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 "
" + "".join(cell_html(tag_default, c, align[ci]) for ci, c in enumerate(cells)) + "
"
+
+ header_rows = 1 if b.get("has_header") else 0
+ head = ""
+ if header_rows:
+ head = "" + "".join(row_html(r, "th") for r in rows[:header_rows]) + ""
+ tbody_rows = rows[header_rows:]
+ body = "" + "".join(row_html(r, "td") for r in tbody_rows) + ""
+ return f'
{head}{body}
'
+
+
# ── 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'{alt}')
+ elif t == "table":
+ parts.append(_table_to_html(b))
else:
parts.append(f"
{c}
")
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")
diff --git a/requirements.txt b/requirements.txt
index 19679f3..f2497c2 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -11,4 +11,5 @@ python-dotenv==1.0.*
packaging>=24.0
itsdangerous==2.2.*
slowapi==0.1.*
+weasyprint==69.0
xhtml2pdf==0.2.*
diff --git a/tests/test_app.py b/tests/test_app.py
index e2eccc4..6ac1e29 100644
--- a/tests/test_app.py
+++ b/tests/test_app.py
@@ -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
(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 "
" in html and "
" in html
+ assert "
" in html and "
" 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)
\ No newline at end of file