diff --git a/apps/api/src/synthora/api/export.py b/apps/api/src/synthora/api/export.py index 32eefa4..eb28b07 100644 --- a/apps/api/src/synthora/api/export.py +++ b/apps/api/src/synthora/api/export.py @@ -14,8 +14,14 @@ body {{ font-family: Georgia, serif; max-width: 46rem; margin: 3rem auto; line-height: 1.6; color: #1c2a30; padding: 0 1.5rem; }} h1, h2, h3 {{ font-family: Georgia, serif; letter-spacing: -0.01em; }} code {{ font-family: ui-monospace, monospace; background: #f3f1ea; padding: 0.1em 0.3em; border-radius: 3px; }} + pre {{ background: #f3f1ea; padding: 0.75rem 1rem; overflow-x: auto; border-radius: 4px; }} + pre code {{ background: transparent; padding: 0; }} a {{ color: #0e6f6a; }} blockquote {{ border-left: 3px solid #0e6f6a; margin-left: 0; padding-left: 1rem; color: #3d5059; }} + table {{ border-collapse: collapse; width: 100%; margin: 1rem 0; }} + th, td {{ border: 1px solid #d8d2c4; padding: 0.35rem 0.6rem; text-align: left; }} + th {{ background: #f3f1ea; }} + hr {{ border: none; border-top: 1px solid #d8d2c4; margin: 1.5rem 0; }} @media print {{ body {{ margin: 0.5in; max-width: none; }} }} @@ -27,38 +33,114 @@ def markdown_to_html(markdown: str) -> str: - """Minimal dependency-free Markdown -> HTML for report export. + """Dependency-free Markdown -> HTML for report export. - Covers the subset our writers emit: headings, paragraphs, lists, - bold/italic, inline code, and links. + Covers headings, paragraphs, bullet/ordered lists, blockquotes, fenced + code blocks, tables, bold/italic, inline code, and links. """ lines = markdown.splitlines() out: list[str] = [] - in_list = False - for line in lines: + i = 0 + list_kind: str | None = None # "ul" | "ol" + + def close_list() -> None: + nonlocal list_kind + if list_kind: + out.append(f"{list_kind}>") + list_kind = None + + while i < len(lines): + line = lines[i] stripped = line.strip() + + if stripped.startswith("```"): + close_list() + fence = stripped[3:].strip() + i += 1 + code_lines: list[str] = [] + while i < len(lines) and not lines[i].strip().startswith("```"): + code_lines.append(lines[i]) + i += 1 + if i < len(lines): + i += 1 + code = html_lib.escape("\n".join(code_lines)) + lang = html_lib.escape(fence) if fence else "" + cls = f' class="language-{lang}"' if lang else "" + out.append(f"
{code}")
+ continue
+
+ if stripped.startswith("|") and "|" in stripped[1:]:
+ close_list()
+ table_rows: list[str] = []
+ while i < len(lines) and lines[i].strip().startswith("|"):
+ table_rows.append(lines[i].strip())
+ i += 1
+ if len(table_rows) >= 2 and re.match(r"^\|[-: |]+\|$", table_rows[1]):
+ table_rows.pop(1)
+ if table_rows:
+ out.append("" + ) + continue + + ordered = re.match(r"^(\d+)\.\s+(.*)$", stripped) + if ordered: + if list_kind != "ol": + close_list() + out.append("{_inline(' '.join(quote_lines))}
{_inline(stripped)}
") - if in_list: - out.append("") + i += 1 + + close_list() return "\n".join(out) @@ -79,26 +161,17 @@ def render_html_document(markdown: str, *, title: str) -> str: ) -def _plain_from_markdown(markdown: str) -> str: - """Strip common markdown markers for PDF core fonts.""" - text = re.sub(r"^#{1,6}\s+", "", markdown, flags=re.MULTILINE) - text = re.sub(r"\*\*(.+?)\*\*", r"\1", text) - text = re.sub(r"(? str: return text.encode("latin-1", "replace").decode("latin-1") def markdown_to_pdf_bytes(markdown: str, *, title: str = "Report") -> bytes: - """Render markdown to PDF bytes using fpdf2 (no network).""" + """Render markdown to PDF bytes using fpdf2 HTML layout (no network).""" from fpdf import FPDF from fpdf.enums import XPos, YPos pdf = FPDF() + pdf.set_compression(False) pdf.set_auto_page_break(auto=True, margin=15) pdf.set_margins(15, 15, 15) pdf.add_page() @@ -112,17 +185,8 @@ def markdown_to_pdf_bytes(markdown: str, *, title: str = "Report") -> bytes: ) pdf.ln(4) pdf.set_font("Helvetica", size=11) - body = _plain_from_markdown(markdown or "") - for line in body.splitlines() or [""]: - if not line.strip(): - pdf.ln(6) - continue - pdf.multi_cell( - 0, - 6, - _pdf_safe(line), - new_x=XPos.LMARGIN, - new_y=YPos.NEXT, - ) + body_html = markdown_to_html(markdown or "") + if body_html.strip(): + pdf.write_html(_pdf_safe(body_html)) out = pdf.output() return bytes(out) diff --git a/apps/web/src/api.ts b/apps/web/src/api.ts index 540a2f6..efe7298 100644 --- a/apps/web/src/api.ts +++ b/apps/web/src/api.ts @@ -456,6 +456,19 @@ export const api = { body: JSON.stringify({ query, max_results: maxResults }), }, ), + + health: () => request<{ status: string }>("/health"), + ready: () => request<{ status: string }>("/ready"), + mcpToolsList: () => + request<{ tools: Array+ Showing runs for session:{" "} + {sessionTitle(selectedSessionId)} +
+ )} {error &&{error}
} {runs.length === 0 && !error &&No research yet.
} {runs.length > 0 && ( @@ -143,7 +190,14 @@ export function History({ onOpen }: { onOpen: (runId: string) => void }) { {sessions.map((s) => ({s.id.slice(0, 8)}…
diff --git a/docs/feature-parity.md b/docs/feature-parity.md
index df825f2..63d6614 100644
--- a/docs/feature-parity.md
+++ b/docs/feature-parity.md
@@ -59,7 +59,7 @@ See also [parity-audit.md](parity-audit.md).
| Search strategy abstraction (5 strategies + aliases) | ✅ | `strategy_registry` |
| Search engine abstraction (full catalog) | ✅ | 29 registered engines |
| LLM provider abstraction + think-tag handling | ✅ | 11 providers |
-| Research history + export md/html/pdf | ✅ | API + web buttons |
+| Research history + export md/html/pdf | ✅ | API + web buttons; structured HTML/PDF export |
| Docker Compose self-host | ✅ | `docker-compose.yml` |
| Python SDK | ✅ | `packages/sdk` — full REST mirror incl. upload, export download, news GET |
| Document library + RAG (`collection` engine) | ✅ | documents API + `document_index` |
@@ -125,6 +125,11 @@ bytes), ``get_news_subscription``, ``search_documents(max_results=...)``,
``health``/``ready``; web client ``getNewsSubscription`` and search
``max_results``; streamable MCP workspace isolation test.
+Closed on ``feat/export-and-web-parity``: full markdown export subset (fenced
+code, blockquotes, ordered lists, tables, rules); PDF via fpdf2 ``write_html``
+(preserves structure); web ``health``/``ready``/MCP REST wrappers; History
+session drill-down via ``getSession``.
+
No known functional gaps remain beyond explicit non-goals below.
Chat remains session-scoped ``fast_research`` with prior-report memory —
diff --git a/tests/test_export.py b/tests/test_export.py
index aed86fd..c9e18e8 100644
--- a/tests/test_export.py
+++ b/tests/test_export.py
@@ -18,6 +18,27 @@
## Sources
"""
+RICH = """# Rich export
+
+> A quoted insight
+
+```python
+def hello():
+ return "world"
+```
+
+1. ordered one
+2. ordered two
+
+| Col A | Col B |
+| --- | --- |
+| a1 | b1 |
+
+---
+
+Final **note**.
+"""
+
def test_markdown_to_html_structure():
html = markdown_to_html(SAMPLE)
@@ -30,6 +51,15 @@ def test_markdown_to_html_structure():
assert 'a link' in html
+def test_markdown_to_html_rich_features():
+ html = markdown_to_html(RICH)
+ assert "" in html + assert "" in html and "- ordered one
" in html + assert "" in html and "
Col A " in html + assert "
alert" not in doc @@ -46,3 +76,12 @@ def test_markdown_to_pdf_bytes(): pdf = markdown_to_pdf_bytes(SAMPLE, title="Report Title") assert pdf[:4] == b"%PDF" assert len(pdf) > 100 + + +def test_markdown_to_pdf_preserves_structure(): + pdf = markdown_to_pdf_bytes(RICH, title="Rich export") + assert pdf[:4] == b"%PDF" + # fpdf2 embeds literal text from headings/list items in the PDF stream. + blob = pdf.decode("latin-1", "replace") + assert "Rich export" in blob + assert "ordered one" in blob