"""FlowDeck — Export service (v4.7.2). Four types of export, all generated server-side: - Markdown (``page_to_markdown``): title + blocks + récursif sous-pages - HTML (``page_to_standalone_html``): document autonome (styles inline) - PDF (``page_to_pdf_bytes``): convertit un HTML print-friendly - Site (``build_static_site``): site statique multi-pages (zip) Supports the three ways a page's content can be stored: - content_format == "blocks" -> JSON list of blocks in ``content`` - content_format == "markdown" -> raw Markdown in ``content`` - content_format == "file" -> ``content`` is JSON metadata; the real text lives in an uploaded file on disk (uploads/workspace_*). We read it back so an exported document carries its actual content, not just its title. """ from __future__ import annotations import io import json import os import re import zipfile from pathlib import Path from urllib.parse import quote from app.db import get_conn # ═══════════════ Helpers ═══════════════ def _text(v: str, *, escape: bool = True) -> str: """Normalize a block's content string.""" s = (v or "").replace("\r\n", "\n").replace("\r", "\n") if escape: s = (s.replace("&", "&") .replace("<", "<") .replace(">", ">")) return s def _sanitize_id(block_id) -> str: if not block_id: return "" return "".join(ch for ch in str(block_id) if ch.isalnum()) def _page_title(page: dict) -> str: return (page.get("title") or "Untitled").strip() or "Untitled" def _blocks_of(page: dict) -> list: content = page.get("content") or "" fmt = page.get("content_format") or "blocks" if fmt != "blocks" or not content: return [] try: data = json.loads(content) except (json.JSONDecodeError, TypeError): return [] return data if isinstance(data, list) else [] def _block_text(b: dict) -> str: return _text(b.get("content"), escape=False) # ── Source resolution: read real textual content for ANY page type ── # Extensions whose content is plain text / code / markdown (textual exportable). _TEXTUAL_EXTS = { "md", "markdown", "txt", "log", "text", "py", "js", "ts", "jsx", "tsx", "html", "htm", "css", "json", "xml", "yaml", "yml", "toml", "ini", "cfg", "conf", "env", "sh", "bash", "zsh", "ps1", "bat", "cmd", "rb", "go", "rs", "java", "c", "cpp", "h", "hpp", "php", "swift", "kt", "scala", "sql", "r", "vue", "svelte", "astro", "properties", "gitignore", "dockerfile", "makefile", } _CODE_LANG = { "py": "python", "js": "javascript", "ts": "typescript", "jsx": "javascript", "tsx": "typescript", "html": "html", "htm": "html", "css": "css", "json": "json", "xml": "xml", "yaml": "yaml", "yml": "yaml", "toml": "toml", "ini": "ini", "cfg": "ini", "conf": "ini", "env": "ini", "sh": "bash", "bash": "bash", "zsh": "bash", "ps1": "powershell", "bat": "batch", "cmd": "batch", "rb": "ruby", "go": "go", "rs": "rust", "java": "java", "c": "c", "cpp": "cpp", "h": "c", "hpp": "cpp", "php": "php", "swift": "swift", "kt": "kotlin", "scala": "scala", "sql": "sql", "r": "r", "vue": "html", "svelte": "html", "astro": "html", "properties": "ini", "md": "markdown", "markdown": "markdown", "txt": "plaintext", "log": "plaintext", "text": "plaintext", } _MARKDOWN_MIMES = {"text/markdown", "text/x-markdown", "application/octet-stream"} def _data_root() -> Path: """Directory that contains ``uploads/`` (mirrors dashboard.py /data).""" return Path(os.environ.get("FLOWDECK_DATA_DIR", "/data")) def _file_meta(page: dict) -> dict: try: meta = json.loads(page.get("content") or "{}") return meta if isinstance(meta, dict) else {} except (json.JSONDecodeError, TypeError): return {} def _file_text(page: dict) -> str | None: """Return the textual content of an uploaded ``file`` page, or None. Only reads plain-text / code / markdown files. Binary (PDF, images…) returns None and is skipped by exporters (nothing meaningful to include). """ if (page.get("content_format") or "") != "file": return None meta = _file_meta(page) rel = (meta.get("file_path") or "").replace("\\", "/").strip() if not rel or ".." in rel.replace("\\", "/").split("/") or not rel.startswith("uploads/"): return None name = (rel.rsplit("/", 1)[-1] or "").lower() ext = name.rsplit(".", 1)[-1] if "." in name else "" mime = (meta.get("mime_type") or "").lower() if not (ext in _TEXTUAL_EXTS or mime.startswith("text/")): return None try: full = (_data_root() / rel).resolve() root = _data_root().resolve() if root not in full.parents: return None return full.read_text(encoding="utf-8", errors="replace") except (OSError, ValueError): return None def _page_source(page: dict): """Return (kind, payload) describing where the page's real content lives. kind ∈ {"blocks", "md", "code"}: - "blocks": payload is the block list (block editor pages) - "md" : payload is raw Markdown text - "code" : payload is (text, language) An empty/unsupported page yields ("blocks", []). """ fmt = (page.get("content_format") or "blocks") content = page.get("content") or "" if fmt == "blocks": return "blocks", _blocks_of(page) if fmt == "markdown": if content.strip(): return "md", content return "blocks", [] if fmt == "file": text = _file_text(page) if text is None: return "blocks", [] meta = _file_meta(page) name = (meta.get("file_path") or "").replace("\\", "/").rsplit("/", 1)[-1].lower() ext = name.rsplit(".", 1)[-1] if "." in name else "" mime = (meta.get("mime_type") or "").lower() if ext in ("md", "markdown") or mime in _MARKDOWN_MIMES or mime.startswith("text/markdown"): return "md", text lang = _CODE_LANG.get(ext, "plaintext") return "code", (text, lang) # Unknown format (e.g. legacy) -> try to dump as raw text if content.strip(): return "md", content 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) def pad(r): return 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] has_header = b.get("has_header", True) out: list[str] = [] def rowline(r): cells = list(r) + [""] * (width - len(r)) return "| " + " | ".join(cells) + " |" start = 0 if has_header: out.append(rowline(rows[0])) seps = [] for a in align: if a == "center": seps.append(":---:") elif a == "right": seps.append("---:") else: seps.append(":---") out.append("| " + " | ".join(seps) + " |") start = 1 for ri in range(start, len(rows)): out.append(rowline(rows[ri])) 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)}" has_header = b.get("has_header", True) first_col = b.get("first_col_header", False) header_rows = 1 if has_header else 0 head = "" if header_rows: head_rows = [] hr = rows[0] cells = list(hr) + [""] * (width - len(hr)) head_cells = [] for ci, c in enumerate(cells): tag = "th" if first_col and ci == 0 else "th" head_cells.append(cell_html(tag, c, align[ci])) head_rows.append("" + "".join(head_cells) + "") head = "" + "".join(head_rows) + "" tbody_rows = rows[header_rows:] body_rows = [] for r in tbody_rows: cells = list(r) + [""] * (width - len(r)) row_cells = [] for ci, c in enumerate(cells): tag = "th" if first_col and ci == 0 else "td" row_cells.append(cell_html(tag, c, align[ci])) body_rows.append("" + "".join(row_cells) + "") body = "" + "".join(body_rows) + "" return f'{head}{body}
' # ── Markdown renderer (raw markdown → exportable fragments) ── def _md_to_blocks(md: str) -> list: """Convert raw Markdown text into the same lightweight block list the editor produces (headings, lists, to-do, quote, code, divider, paragraph). Kept intentionally simple: inline formatting (bold/links) is preserved as literal text, matching how the block editor treats imported .md files. """ blocks: list = [] buf = md.replace("\r\n", "\n").replace("\r", "\n") lines = buf.split("\n") i = 0 n = len(lines) para: list[str] = [] def flush_para(): nonlocal para if para: blocks.append({"type": "paragraph", "content": "\n".join(para).strip()}) para = [] while i < n: line = lines[i].rstrip() stripped = line.strip() if not stripped: flush_para() i += 1 continue if stripped.startswith("```") or stripped.startswith("~~~"): flush_para() fence = stripped[0:3] lang = stripped[3:].strip() i += 1 code: list[str] = [] while i < n and not lines[i].strip().startswith(fence): code.append(lines[i]) i += 1 if i < n: 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)) flush_para() blocks.append({"type": f"heading_{min(level, 4)}", "content": m.group(2).strip()}) i += 1 continue if stripped == "---" or stripped == "***" or stripped == "___": flush_para() blocks.append({"type": "divider", "content": ""}) i += 1 continue if re.match(r"^\s*[-*+]\s+\[[ xX]\]\s+", line): flush_para() while i < n: s = lines[i].strip() m2 = re.match(r"^[-*+]\s+\[([ xX])\]\s+(.*)$", s) if not m2: break blocks.append({ "type": "to_do", "content": m2.group(2).strip(), "checked": m2.group(1).lower() == "x", }) i += 1 continue if re.match(r"^\s*[-*+]\s+", line): flush_para() while i < n: s = lines[i].strip() m2 = re.match(r"^[-*+]\s+(.*)$", s) if not m2: break blocks.append({"type": "bulleted_list", "content": m2.group(1).strip()}) i += 1 continue if re.match(r"^\s*\d+[.)]\s+", line): flush_para() while i < n: s = lines[i].strip() m2 = re.match(r"^\d+[.)]\s+(.*)$", s) if not m2: break blocks.append({"type": "numbered_list", "content": m2.group(1).strip()}) i += 1 continue mq = re.match(r"^>\s?(.*)$", stripped) if mq and line == stripped: flush_para() while i < n: s = lines[i].strip() m2 = re.match(r"^>\s?(.*)$", s) if not m2: break para.append(m2.group(1)) i += 1 blocks.append({"type": "quote", "content": "\n".join(para)}) para = [] continue para.append(stripped) i += 1 flush_para() return blocks def _page_blocks(page: dict) -> list: """Blocks used for HTML/PDF rendering regardless of storage format.""" kind, payload = _page_source(page) if kind == "blocks": return payload if kind == "code": text, lang = payload return [{"type": "code", "content": text, "language": lang}] if text else [] if kind == "md": return _md_to_blocks(payload) return [] def _page_markdown_source(page: dict) -> str: """Raw markdown when the page IS markdown-sourced, else empty string.""" kind, payload = _page_source(page) if kind == "md": return payload return "" def markdown_to_blocks(md: str) -> list: """Public wrapper around the GFM→blocks parser (used by page import).""" return _md_to_blocks(md) # ═══════════════ Markdown ═══════════════ def blocks_to_markdown(blocks: list) -> str: """Convert a block array to Markdown (server-side, all block types).""" out: list[str] = [] for b in blocks or []: t = b.get("type", "paragraph") c = _block_text(b) if t == "heading_1": out.append(f"# {c}") elif t == "heading_2": out.append(f"## {c}") elif t == "heading_3": out.append(f"### {c}") elif t == "heading_4": out.append(f"#### {c}") elif t == "bulleted_list": out.append(f"- {c}") elif t == "numbered_list": out.append(f"1. {c}") elif t == "to_do": out.append(f"{'- [x]' if b.get('checked') else '- [ ]'} {c}") elif t == "quote": out.append(f"> {c}") elif t == "divider": out.append("---") elif t == "code": lang = b.get("language") or "" out.append(f"```{lang}\n{c}\n```") elif t == "toggle": out.append(f"### {c}") if b.get("children"): out.append(blocks_to_markdown(b["children"])) elif t == "math": out.append(f"$$\n{c}\n$$") elif t == "table_of_contents": out.append("[TOC]") elif t == "columns": for child in b.get("children") or []: out.append(blocks_to_markdown([child])) elif t == "image": src = b.get("src") or "" alt = (b.get("alt") or "").strip() or "image" out.append(f"![{alt}]({src})") elif t == "video": out.append(f"[Video]({b.get('src') or ''})") elif t == "audio": out.append(f"[Audio]({b.get('src') or ''})") elif t == "bookmark": url = b.get("url") or b.get("src") or "" title = (b.get("title") or "").strip() out.append(f"[{title or url}]({url})" if title else url) elif t == "embed": url = b.get("src") or "" if b.get("embed_type") in ("pdf", "download", None, ""): out.append(f"[{url}]({url})" if url else "[embed]") else: out.append(f"[{url}]({url})" if url else "[embed]") elif t == "table": out.append(_table_to_markdown(b)) elif t == "synced": synced_id = b.get("synced_id") if synced_id: try: from app.services.synced_blocks import get_synced_block sb = get_synced_block(synced_id) if sb and sb.get("content"): resolved = json.loads(sb["content"]) if isinstance(resolved, list): out.append(blocks_to_markdown(resolved)) else: out.append(str(resolved)) except Exception: out.append(f"[Synced block {synced_id}]") else: out.append(c) return "\n\n".join(filter(None, out)) def _child_pages(page: dict) -> list: """Immediate non-deleted children of a page.""" with get_conn() as conn: rows = conn.execute( "SELECT * FROM pages WHERE parent_id=? AND deleted_at IS NULL " "ORDER BY COALESCE(sort_order, created_at) ASC, id ASC", (page["id"],), ).fetchall() return [dict(r) for r in rows] def page_to_markdown(page: dict, *, include_children: bool = True) -> str: """Markdown for a single page, with optional sub-pages appended.""" parts = [f"# {_page_title(page)}", ""] md_source = _page_markdown_source(page) if md_source: parts.append(md_source.strip()) else: md = blocks_to_markdown(_page_blocks(page)) if md: parts.append(md) md = "\n\n".join(filter(None, parts)).rstrip() if include_children: for sub in _child_pages(page): sub_md = page_to_markdown(sub, include_children=True) if sub_md: md += f"\n\n---\n\n{sub_md}" return md # ═══════════════ HTML ═══════════════ def blocks_to_html(blocks: list) -> str: """Convert a block array to a self-contained HTML fragment.""" parts: list[str] = [] for b in blocks or []: t = b.get("type", "paragraph") c = _text(b.get("content")) if t == "heading_1": parts.append(f'

{c}

') elif t == "heading_2": parts.append(f'

{c}

') elif t == "heading_3": parts.append(f'

{c}

') elif t == "heading_4": parts.append(f'

{c}

') elif t == "bulleted_list": parts.append(f"
  • {c}
  • ") elif t == "numbered_list": parts.append(f"
  • {c}
  • ") elif t == "to_do": checked = "checked" if b.get("checked") else "" style = "text-decoration:line-through;opacity:.55;" if b.get("checked") else "" parts.append( f'
    ' f'{c}
    ' ) elif t == "toggle": children = blocks_to_html(b.get("children") or []) parts.append(f"
    {c}{children}
    ") elif t == "quote": parts.append(f"
    {c}
    ") elif t == "divider": parts.append("
    ") elif t == "code": lang = b.get("language") or "" label = f'
    {_text(lang)}
    ' if lang else "" parts.append(f"
    {label}{c}
    ") elif t == "math": parts.append(f'
    \\[{c}\\]
    ') elif t == "table_of_contents": toc = [x for x in (blocks or []) if x.get("type", "").startswith("heading_") and (x.get("content") or "").strip()] if toc: items = "".join( f'
    ' f'{_text(x.get("content"))}
    ' for x in toc ) parts.append(f'') elif t == "columns": cols = "".join( f'
    {blocks_to_html([child])}
    ' for child in (b.get("children") or []) ) parts.append(f'
    {cols}
    ') elif t == "callout": icon = b.get("icon") or "💡" bg = (b.get("style") or {}).get("bgColor", "#eef2ff") parts.append(f'
    {_text(icon, escape=False)}
    {c}
    ') elif t in ("mermaid", "equation_inline", "progress"): # v7.3.0 blocks — server-rendered so export embeds real content from app.services.wiki_blocks import render_block parts.append(render_block(b)) elif t == "image": src = b.get("src") or "" alt = _text(b.get("alt")) parts.append(f'
    {alt}
    {alt}
    ') elif t == "video": src = b.get("src") or "" if src: parts.append(f'') elif t == "audio": src = b.get("src") or "" if src: parts.append(f'') elif t == "bookmark": url = b.get("url") or b.get("src") or "" title = _text(b.get("title")) or url desc = _text(b.get("description")) img = b.get("image") or "" site = _text(b.get("site_name")) or "" img_html = f'' if img else "" desc_html = f'
    {desc}
    ' if desc else "" site_html = f'
    {site}
    ' if site else "" parts.append( f'' f'
    ' f'
    {title}
    ' f'{desc_html}{site_html}
    {img_html}
    ' ) elif t == "embed": url = b.get("src") or "" emb = (b.get("embed_type") or "") if emb in ("inline_dbs", "collection"): parts.append('
    [Embedded content]
    ') elif emb == "download": parts.append(f'⬇ {_text(b.get("file_name") or "Download")}') elif emb == "pdf" and url: parts.append(f'') elif url: from app.services.embeds import embed_src src = b.get("embed_src") or embed_src(url) or url height = 520 if b.get("height"): try: height = int(b["height"]) except (ValueError, TypeError): pass parts.append( f'
    ' ) elif t == "table": parts.append(_table_to_html(b)) elif t == "synced": synced_id = b.get("synced_id") if synced_id: try: from app.services.synced_blocks import get_synced_block sb = get_synced_block(synced_id) if sb and sb.get("content"): resolved = json.loads(sb["content"]) if isinstance(resolved, list): parts.append(blocks_to_html(resolved)) else: parts.append(f"

    {_text(resolved)}

    ") except Exception: parts.append(f"

    [Synced block {synced_id}]

    ") else: parts.append(f"

    {c}

    ") return "\n".join(parts) def _standalone_css() -> str: return """ :root{color-scheme:light;} *{box-sizing:border-box;} body{margin:0;font-family:system-ui,-apple-system,'Segoe UI',Roboto,sans-serif;color:#1f2328;background:#fff;line-height:1.65;} .wrap{max-width:780px;margin:0 auto;padding:48px 32px 96px;} h1{font-size:2.4rem;line-height:1.2;margin:0 0 8px;} h2{font-size:1.7rem;border-bottom:1px solid #ececec;padding-bottom:6px;margin:32px 0 12px;} h3{font-size:1.35rem;margin:24px 0 8px;} h4{font-size:1.1rem;margin:20px 0 6px;} p{margin:8px 0;} li{margin:4px 0;} ol{list-style:decimal;padding-left:24px;} ul{list-style:disc;padding-left:24px;} blockquote{border-left:4px solid #d0d7de;margin:12px 0;padding:4px 16px;color:#57606a;} hr{border:none;border-top:1px solid #eaeef2;margin:24px 0;} pre{background:#f6f8fa;border-radius:8px;padding:16px 20px;overflow-x:auto;font-size:14px;} code{font-family:'SFMono-Regular',Consolas,monospace;background:#f6f8fa;border-radius:4px;padding:2px 5px;font-size:.9em;} pre code{background:none;padding:0;font-size:13px;} .code-lang{font-size:11px;color:#8b949e;text-transform:uppercase;letter-spacing:.5px;margin-bottom:8px;} details{background:#f6f8fa;border:1px solid #eaeef2;border-radius:8px;padding:10px 14px;margin:10px 0;} details summary{cursor:pointer;font-weight:600;} details[open] summary{margin-bottom:8px;} .todo{display:flex;align-items:flex-start;gap:8px;margin:4px 0;} .todo input{margin-top:5px;} .toc{border:1px solid #eaeef2;border-radius:8px;padding:16px 20px;margin:12px 0;} .toc-title{font-size:12px;font-weight:700;text-transform:uppercase;letter-spacing:.5px;color:#57606a;margin-bottom:10px;} .toc a{color:#0969da;text-decoration:none;display:block;padding:4px 0;} .columns{display:flex;gap:14px;margin:12px 0;align-items:stretch;} .column{flex:1;min-width:0;background:#f9fafb;border:1px solid #eaeef2;border-radius:8px;padding:12px 14px;box-sizing:border-box;} .callout{display:flex;gap:10px;align-items:flex-start;border:1px solid #e0e7ff;border-radius:8px;padding:14px 18px;margin:12px 0;font-size:15px;} .callout>span{font-size:20px;flex-shrink:0;} .math{margin:14px 0;overflow-x:auto;} 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%;}} """ def page_to_standalone_html( page: dict, *, include_children: bool = True, base_url: str = "", ) -> str: """Return a standalone, self-contained HTML document for a page.""" title = _page_title(page) body = blocks_to_html(_page_blocks(page)) meta_updated = page.get("updated_at") or "" footer = f"" sub_html = "" if include_children: for sub in _child_pages(page): sub_html += '\n
    \n
    ' sub_html += page_to_standalone_html(sub, include_children=True, base_url=base_url) sub_html += "
    " return f""" {_text(title)}

    {_text(title)}

    {body} {sub_html} {footer}
    """ # ═══════════════ PDF ═══════════════ def _pdf_html(page: dict) -> str: """A print-friendly, minimal-CSS HTML for PDF conversion.""" title = _page_title(page) body = blocks_to_html(_page_blocks(page)) return f"""{_text(title)}

    {_text(title)}

    {body} """ def page_to_pdf_bytes(page: dict) -> bytes: """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") if pdf.err: raise RuntimeError(f"PDF generation failed: {pdf.err}") return buf.getvalue() # ═══════════════ Static site (zip) ═══════════════ def _site_index_html(pages: list[dict]) -> str: """Build the index.html of the static site (list of all pages).""" def link(p: dict) -> str: title = _page_title(p) return f'
  • {_text(title)}
  • ' items = "".join(link(p) for p in pages) return f""" FlowDeck Site

    FlowDeck Site

    """ def build_static_site_bytes(root_page: dict) -> bytes: """Build a full static site as a zip: index.html + one HTML file per page.""" pages = [root_page] + _child_pages(root_page) buf = io.BytesIO() with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as z: z.writestr("index.html", _site_index_html(pages)) for p in pages: title = _page_title(p) name = f"{quote(title, safe='')}.html" z.writestr(name, page_to_standalone_html(p, include_children=False)) return buf.getvalue()