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
|
# 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
|
## v4.7.2 (2026-09-03) — Fix export : le contenu des documents était absent
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|||||||
+13
-1
@@ -2,7 +2,19 @@ FROM python:3.12-slim
|
|||||||
|
|
||||||
WORKDIR /app
|
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 .
|
COPY requirements.txt .
|
||||||
RUN pip install --no-cache-dir -r 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] **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
|
- [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
|
### v4.8.0 — Collaboration
|
||||||
- [ ] **Inline comments** — commentaires sur sélection de texte
|
- [ ] **Inline comments** — commentaires sur sélection de texte
|
||||||
- [ ] **@mentions** — notifier un utilisateur → page/commentaire
|
- [ ] **@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
|
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)
|
& 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,)
|
(admin_hash,)
|
||||||
)
|
)
|
||||||
conn.commit()
|
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
|
yield
|
||||||
|
|
||||||
|
|
||||||
app = FastAPI(
|
app = FastAPI(
|
||||||
title="FlowDeck",
|
title="FlowDeck",
|
||||||
version="4.7.2",
|
version="4.7.3",
|
||||||
docs_url="/docs" if settings.log_level == "DEBUG" else None,
|
docs_url="/docs" if settings.log_level == "DEBUG" else None,
|
||||||
redoc_url=None,
|
redoc_url=None,
|
||||||
lifespan=lifespan,
|
lifespan=lifespan,
|
||||||
|
|||||||
@@ -74,7 +74,7 @@ async def export_pdf(page_id: int, request: Request):
|
|||||||
try:
|
try:
|
||||||
pdf_bytes = page_to_pdf_bytes(page)
|
pdf_bytes = page_to_pdf_bytes(page)
|
||||||
except ImportError:
|
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
|
except Exception as exc: # noqa: BLE001
|
||||||
logger.error("PDF export failed for page %s: %s", page_id, exc)
|
logger.error("PDF export failed for page %s: %s", page_id, exc)
|
||||||
raise HTTPException(status_code=500, detail="PDF generation failed")
|
raise HTTPException(status_code=500, detail="PDF generation failed")
|
||||||
|
|||||||
+176
-1
@@ -171,6 +171,145 @@ def _page_source(page: dict):
|
|||||||
return "blocks", []
|
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) ──
|
# ── Markdown renderer (raw markdown → exportable fragments) ──
|
||||||
|
|
||||||
def _md_to_blocks(md: str) -> list:
|
def _md_to_blocks(md: str) -> list:
|
||||||
@@ -213,6 +352,14 @@ def _md_to_blocks(md: str) -> list:
|
|||||||
i += 1 # closing fence
|
i += 1 # closing fence
|
||||||
blocks.append({"type": "code", "content": "\n".join(code), "language": lang})
|
blocks.append({"type": "code", "content": "\n".join(code), "language": lang})
|
||||||
continue
|
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)
|
m = re.match(r"^(#{1,6})\s+(.*)$", stripped)
|
||||||
if m and line == stripped: # ATX heading must be whole line
|
if m and line == stripped: # ATX heading must be whole line
|
||||||
level = len(m.group(1))
|
level = len(m.group(1))
|
||||||
@@ -344,6 +491,8 @@ def blocks_to_markdown(blocks: list) -> str:
|
|||||||
src = b.get("src") or ""
|
src = b.get("src") or ""
|
||||||
alt = (b.get("alt") or "").strip() or "image"
|
alt = (b.get("alt") or "").strip() or "image"
|
||||||
out.append(f"")
|
out.append(f"")
|
||||||
|
elif t == "table":
|
||||||
|
out.append(_table_to_markdown(b))
|
||||||
else:
|
else:
|
||||||
out.append(c)
|
out.append(c)
|
||||||
return "\n\n".join(filter(None, out))
|
return "\n\n".join(filter(None, out))
|
||||||
@@ -444,6 +593,8 @@ def blocks_to_html(blocks: list) -> str:
|
|||||||
src = b.get("src") or ""
|
src = b.get("src") or ""
|
||||||
alt = _text(b.get("alt"))
|
alt = _text(b.get("alt"))
|
||||||
parts.append(f'<figure><img src="{src}" alt="{alt}"><figcaption>{alt}</figcaption></figure>')
|
parts.append(f'<figure><img src="{src}" alt="{alt}"><figcaption>{alt}</figcaption></figure>')
|
||||||
|
elif t == "table":
|
||||||
|
parts.append(_table_to_html(b))
|
||||||
else:
|
else:
|
||||||
parts.append(f"<p>{c}</p>")
|
parts.append(f"<p>{c}</p>")
|
||||||
return "\n".join(parts)
|
return "\n".join(parts)
|
||||||
@@ -485,6 +636,10 @@ details[open] summary{margin-bottom:8px;}
|
|||||||
figure{margin:16px 0;text-align:center;}
|
figure{margin:16px 0;text-align:center;}
|
||||||
figure img{max-width:100%;border-radius:8px;}
|
figure img{max-width:100%;border-radius:8px;}
|
||||||
figcaption{font-size:13px;color:#8b949e;margin-top:6px;}
|
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;}
|
.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;}
|
a{color:#0969da;}
|
||||||
@media print{body{background:#fff;}.wrap{padding:0;max-width:100%;}}
|
@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;}}
|
code{{font-family:monospace;font-size:10px;}}
|
||||||
blockquote{{border-left:3px solid #ccc;margin:8px 0;padding:2px 12px;font-style:italic;}}
|
blockquote{{border-left:3px solid #ccc;margin:8px 0;padding:2px 12px;font-style:italic;}}
|
||||||
table{{border-collapse:collapse;width:100%;}}
|
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;}}
|
hr{{border:none;border-top:1px solid #ddd;margin:16px 0;}}
|
||||||
.todo{{margin:4px 0;}}
|
.todo{{margin:4px 0;}}
|
||||||
.math{{font-style:italic;margin:10px 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:
|
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
|
from xhtml2pdf import pisa
|
||||||
|
|
||||||
src = _pdf_html(page)
|
src = _pdf_html(page)
|
||||||
buf = io.BytesIO()
|
buf = io.BytesIO()
|
||||||
pdf = pisa.CreatePDF(src, dest=buf, encoding="utf-8")
|
pdf = pisa.CreatePDF(src, dest=buf, encoding="utf-8")
|
||||||
|
|||||||
@@ -11,4 +11,5 @@ python-dotenv==1.0.*
|
|||||||
packaging>=24.0
|
packaging>=24.0
|
||||||
itsdangerous==2.2.*
|
itsdangerous==2.2.*
|
||||||
slowapi==0.1.*
|
slowapi==0.1.*
|
||||||
|
weasyprint==69.0
|
||||||
xhtml2pdf==0.2.*
|
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)
|
md = page_to_markdown(_load(pid), include_children=False)
|
||||||
assert "# manual.pdf" in md
|
assert "# manual.pdf" in md
|
||||||
assert "fake binary" not in md # never dump binary into markdown
|
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:
|
finally:
|
||||||
_cleanup_src(pid, uid)
|
_cleanup_src(pid, uid)
|
||||||
Reference in New Issue
Block a user