From 6e30589133b75c1e089182dc62cd47a2e3f61a18 Mon Sep 17 00:00:00 2001 From: Bruno Charest Date: Thu, 3 Sep 2026 01:59:39 -0400 Subject: [PATCH] fix(export): v4.7.3 tableaux + emojis en PDF/HTML MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Tableaux GFM rendus comme texte brut dans les exports HTML/PDF. Ajout d'un parseur de tableaux pipe (bloc 'table') + rendu (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 =
(thead/th, center); PDF 10 pages, texte de table present, Noto-Color-Emoji embarque + pixels colores confirmes. 199/199 tests (4 nouveaux). --- CHANGELOG.md | 14 ++++ Dockerfile | 14 +++- ROADMAP.md | 8 +- VERSION | 2 +- app/main.py | 4 +- app/routers/export.py | 2 +- app/services/export.py | 177 ++++++++++++++++++++++++++++++++++++++++- requirements.txt | 1 + tests/test_app.py | 68 ++++++++++++++++ 9 files changed, 283 insertions(+), 7 deletions(-) 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)}" + + 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"![{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'
{alt}
{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 and "
" in html + assert "
" 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