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:
@@ -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
@@ -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
@@ -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 ✅*
|
||||
|
||||
+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")
|
||||
|
||||
@@ -11,4 +11,5 @@ python-dotenv==1.0.*
|
||||
packaging>=24.0
|
||||
itsdangerous==2.2.*
|
||||
slowapi==0.1.*
|
||||
weasyprint==69.0
|
||||
xhtml2pdf==0.2.*
|
||||
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user