studio: office documents — a non-GPU document pipeline (PDF/DOCX/XLSX/PPTX/MD)
Add a first-class office-document capability ALONGSIDE the ComfyUI/GPU creative
pipeline: structured content (typed template fields, filled by a person or the
AI) → a pure-Python renderer → a real .pdf/.docx/.xlsx/.pptx/.md. No graph, no
GPU. Orthogonal subsystem — not bolted onto graphs.
- middleware/doc_render.py: one neutral IR per archetype (doc/sheet/deck) and
exactly one renderer per format (DRY). PDF via WeasyPrint (full-CSS monochrome
print style) with a self-contained pure-Python PDF fallback (valid %PDF, zero
native deps — so CI/model-less pods still render). DOCX/XLSX/PPTX via
python-docx/openpyxl/python-pptx.
- middleware/documents.py: one declarative CATALOG of 22 common templates
(Documents 15, Spreadsheets 4, Presentations 3). Each template's typed fields
drive the form, the AI-fill JSON, and the renderer inputs (one schema). Per-org
store (orgs/<org>/documents, atomic index.json + <id>/doc.json), rendered on
demand. Endpoints (/v1 house style): GET /v1/documents, POST
/v1/documents/generate, GET /v1/documents/library, GET|PATCH|DELETE
/v1/documents/{id}, GET /v1/documents/{id}/download?format=. Registered via the
same injected tenancy resolvers as stacks_store.
- AI-fill is a layered enhancement — the form path renders with NO LLM, so a
broken/unauthorized gateway never blocks creation (422 needFields → prefilled
form). Reuses the shipped Chat's working token: the browser sends window.HZ.pk
(publishable pk- key) as the bearer; the backend forwards it fail-closed —
server key, else a gateway-verifiable pk-/RS-JWT; an HS256 session token or
sk-/hk- secret is NEVER forwarded. Model default enso (window.HZ.model).
- studio_home.html: a "Documents" Home tab (searchable catalog + your documents
with Download PDF/Word/Excel/PowerPoint) + describe/fill dialog; replaced the
dead doc/sheet/paper composer placeholders with one "Office documents" card.
- deps: weasyprint/python-docx/openpyxl/python-pptx (requirements.txt) + pango
native libs (Dockerfile). CI builds the image; not built locally.
- tests-unit/documents_test: catalog invariants, full template×format render
matrix asserted by magic numbers, AI-fill parse + fail-closed bearer allowlist,
and the REST surface incl. per-org isolation (44 tests, green).
- version 0.17.24 → 0.18.0.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
657e2b5fc0
commit
267735d0bd
@@ -5,6 +5,13 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
build-essential \
|
||||
libgl1 \
|
||||
libglib2.0-0 \
|
||||
# WeasyPrint native stack (office-document PDF renderer, middleware/doc_render.py):
|
||||
# pango/cairo/gdk-pixbuf + a base font. Absent → the built-in PDF fallback is used.
|
||||
libpango-1.0-0 \
|
||||
libpangocairo-1.0-0 \
|
||||
libgdk-pixbuf-2.0-0 \
|
||||
shared-mime-info \
|
||||
fonts-liberation \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
@@ -219,10 +219,58 @@ shared Hanzo IAM (standard OIDC code flow). No embedding.
|
||||
behind disabled API-nodes) are intentionally left. patch_frontend.py keeps its
|
||||
orthogonal concern (URLs/domains + `Comfy-Org` → `Hanzo AI`).
|
||||
|
||||
## Office documents (the non-GPU pipeline)
|
||||
Studio's creative side is ComfyUI graphs → GPU. Office documents are the **orthogonal**
|
||||
subsystem: structured content (a template's typed fields, filled by a person OR by the AI)
|
||||
→ a pure-Python renderer → a real `.pdf/.docx/.xlsx/.pptx/.md`. No graph, no model, no GPU.
|
||||
NOT bolted onto graphs; its own module, store, endpoints, and Home tab.
|
||||
|
||||
- **Renderers** (`middleware/doc_render.py`, pure — no HTTP/storage/org): one neutral IR
|
||||
per archetype (`doc` prose blocks · `sheet` sheets · `deck` slides) and EXACTLY ONE
|
||||
renderer per format. Adding a template = one `build(fields)->(kind,ir)`; adding a format =
|
||||
one renderer. Deps imported lazily so the module loads stdlib-only. **PDF** prefers
|
||||
WeasyPrint (full-CSS, monochrome print stylesheet); when its native pango/cairo stack is
|
||||
absent (the light CI unit venv, model-less pods) a **built-in pure-Python PDF writer**
|
||||
(base-14 Helvetica, WinAnsi/cp1252, auto-pagination, `/Info` title) produces a valid
|
||||
`%PDF` — so every format renders with just the wheels. **DOCX/XLSX/PPTX** = python-docx /
|
||||
openpyxl / python-pptx (real, always). Native libs in the `Dockerfile` (libpango/…).
|
||||
- **Catalog + AI-fill + store + REST** (`middleware/documents.py`): ONE declarative `CATALOG`
|
||||
of 22 common templates (Documents 15 → pdf/docx/md · Spreadsheets 4 → xlsx/pdf/docx/md ·
|
||||
Presentations 3 → pptx/pdf/md). Each template's typed `fields` drive the form, the AI-fill
|
||||
JSON, AND the renderer inputs (one schema, three uses). Registered from within
|
||||
`add_studio_home_routes` with the SAME injected tenancy resolvers as `stacks_store`
|
||||
(`org_of`/`owner_of`). Per-org store `orgs/<org>/documents/` (atomic `index.json` +
|
||||
`<id>/doc.json`), doc.json is the source of truth, files rendered on demand at download.
|
||||
Endpoints (`/v1` house style): `GET /v1/documents` (grouped catalog),
|
||||
`POST /v1/documents/generate` `{templateId, description?|fields?}`,
|
||||
`GET /v1/documents/library`, `GET|PATCH|DELETE /v1/documents/{id}`,
|
||||
`GET /v1/documents/{id}/download?format=`.
|
||||
- **AI-fill is a LAYERED ENHANCEMENT** — the structured-form path renders a valid document
|
||||
with NO LLM, so a broken/unauthorized gateway can NEVER block creation (`422 needFields`
|
||||
→ the UI reveals the prefilled form). It reuses the SAME working token the shipped Chat
|
||||
uses: the browser sends `window.HZ.pk` (the publishable `pk-` key `_publishable`/
|
||||
`_hz_config_script` emit) as the bearer to `/v1/documents/generate`; the backend forwards
|
||||
it, fail-closed, in order: (1) a **server-side gateway key** (`STUDIO_DOC_TOKEN`→
|
||||
`STUDIO_COPILOT_TOKEN`→`STUDIO_COMMERCE_TOKEN`); (2) else the caller's own token, forwarded
|
||||
ONLY when gateway-verifiable — a publishable `pk-` key or an asymmetric/JWKS JWT
|
||||
(RS/ES/PS/EdDSA). A symmetric **HS256 hanzo.id session token is NEVER forwarded** (the
|
||||
gateway rejects it: "unsupported signing method: HS256"), nor any secret `sk-`/`hk-` key.
|
||||
Model = `STUDIO_DOC_MODEL`→`STUDIO_CHAT_MODEL`→`enso` (matches window.HZ.model).
|
||||
- **UI** (`middleware/studio_home.html`, vanilla JS, monochrome/Geist): a **📄 Documents**
|
||||
Home tab — searchable server-driven catalog + "Your documents" grid with Download
|
||||
PDF/Word/Excel/PowerPoint. The `docdlg` dialog: describe in one line → Generate with AI
|
||||
(sends window.HZ.pk), or fill the short typed form → Generate; edit fields before/after;
|
||||
re-open a saved doc to edit (PATCH). The dead `doc/sheet/paper` composer KIND placeholders
|
||||
were replaced with one "Office documents" card that opens the tab.
|
||||
|
||||
## Tests
|
||||
`tests-unit/{folder_paths_test,middleware_test,app_test,prompt_server_test}` —
|
||||
`tests-unit/{folder_paths_test,middleware_test,app_test,prompt_server_test,studio_home_test,documents_test}` —
|
||||
run `uv run pytest tests-unit/folder_paths_test tests-unit/middleware_test
|
||||
tests-unit/app_test tests-unit/prompt_server_test -q`. The auth/tenancy work is
|
||||
tests-unit/app_test tests-unit/prompt_server_test tests-unit/studio_home_test
|
||||
tests-unit/documents_test -q`. The auth/tenancy work is
|
||||
covered by `middleware_test/iam_auth_test.py`,
|
||||
`middleware_test/session_test.py` and
|
||||
`folder_paths_test/org_scoping_test.py`.
|
||||
`folder_paths_test/org_scoping_test.py`. Office documents:
|
||||
`documents_test/test_documents.py` (catalog invariants, the full template×format render
|
||||
matrix asserted by magic numbers, AI-fill parse + fail-closed bearer allowlist, and the
|
||||
REST surface incl. per-org isolation on a real aiohttp app).
|
||||
|
||||
@@ -24,7 +24,8 @@ test:
|
||||
tests-unit/middleware_test \
|
||||
tests-unit/app_test \
|
||||
tests-unit/prompt_server_test \
|
||||
tests-unit/studio_home_test
|
||||
tests-unit/studio_home_test \
|
||||
tests-unit/documents_test
|
||||
|
||||
# Deploy: roll the freshly-built image onto the studio Service CR.
|
||||
deploy:
|
||||
|
||||
@@ -0,0 +1,875 @@
|
||||
"""Office-document renderers — the NON-GPU pipeline.
|
||||
|
||||
Studio's creative side is ComfyUI graphs → GPU. Office documents are the opposite:
|
||||
structured content (a template's typed fields, filled by a person or by the AI) →
|
||||
a pure-Python renderer → a real .pdf/.docx/.xlsx/.pptx/.md file. No graph, no model,
|
||||
no GPU.
|
||||
|
||||
One and only one way, kept DRY by a neutral intermediate representation (IR):
|
||||
|
||||
* a template maps its fields to ONE of three archetype IRs —
|
||||
- ``doc`` : a list of prose blocks (title/heading/paragraph/table/…) → PDF · DOCX · MD
|
||||
- ``sheet`` : a list of sheets (columns + rows + totals) → XLSX · PDF · DOCX · MD
|
||||
- ``deck`` : a list of slides (title + bullets + notes) → PPTX · PDF · MD
|
||||
* each output format has EXACTLY ONE renderer that consumes the IR.
|
||||
|
||||
So adding a template = write one ``build(fields) -> (kind, ir)``; adding a format =
|
||||
write one renderer. The renderers never know about templates, HTTP, storage, or org
|
||||
scoping — they are pure ``(ir) -> bytes``.
|
||||
|
||||
Dependencies are imported LAZILY inside each renderer so this module imports with only
|
||||
the standard library present (unit tests, light pods). PDF prefers WeasyPrint (full CSS
|
||||
fidelity); when its native stack (pango/cairo) is absent it falls back to a self-contained
|
||||
pure-Python PDF writer that still emits a valid, readable, monochrome document — the
|
||||
``weasyprint``-absent fallback. DOCX/XLSX/PPTX are pure-Python (python-docx / openpyxl /
|
||||
python-pptx) and always render for real.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import html as _html
|
||||
import io
|
||||
import zlib
|
||||
from typing import Any
|
||||
|
||||
# ── Public surface ────────────────────────────────────────────────────────────────
|
||||
|
||||
FORMATS = ("pdf", "docx", "xlsx", "pptx", "md")
|
||||
|
||||
CONTENT_TYPE = {
|
||||
"pdf": "application/pdf",
|
||||
"docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||
"xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||
"pptx": "application/vnd.openxmlformats-officedocument.presentationml.presentation",
|
||||
"md": "text/markdown; charset=utf-8",
|
||||
}
|
||||
|
||||
# Which formats each archetype can produce. The catalog's per-template ``formats`` is
|
||||
# always a subset of its archetype's capability — the render dispatch enforces both.
|
||||
KIND_FORMATS = {
|
||||
"doc": ("pdf", "docx", "md"),
|
||||
"sheet": ("xlsx", "pdf", "docx", "md"),
|
||||
"deck": ("pptx", "pdf", "md"),
|
||||
}
|
||||
|
||||
|
||||
def content_type(fmt: str) -> str:
|
||||
return CONTENT_TYPE[fmt]
|
||||
|
||||
|
||||
def render(kind: str, ir: dict, fmt: str) -> bytes:
|
||||
"""Render an archetype IR to ``fmt``, returning the file bytes.
|
||||
|
||||
Raises ``ValueError`` for an unsupported (kind, fmt) pair — the one place the
|
||||
dispatch table lives, so a caller can never silently get the wrong renderer.
|
||||
"""
|
||||
if fmt not in KIND_FORMATS.get(kind, ()): # pragma: no cover - guarded by callers
|
||||
raise ValueError(f"{kind!r} cannot render {fmt!r}")
|
||||
if kind == "doc":
|
||||
return _render_doc(ir, fmt)
|
||||
if kind == "sheet":
|
||||
return _render_sheet(ir, fmt)
|
||||
if kind == "deck":
|
||||
return _render_deck(ir, fmt)
|
||||
raise ValueError(f"unknown archetype {kind!r}") # pragma: no cover
|
||||
|
||||
|
||||
def weasyprint_available() -> bool:
|
||||
"""True when the high-fidelity PDF path (WeasyPrint + native pango/cairo) is usable.
|
||||
Diagnostic only — the renderer falls back automatically."""
|
||||
return _weasy_pdf("<html><body>probe</body></html>") is not None
|
||||
|
||||
|
||||
# ── IR block constructors — the shared vocabulary of the ``doc`` archetype ─────────
|
||||
# Blocks are plain dicts (JSON-friendly, no classes to import). A template's build()
|
||||
# assembles a list of them; four renderers consume the same list.
|
||||
|
||||
def title(text: str, sub: str | None = None) -> dict:
|
||||
return {"t": "title", "text": text or "", "sub": sub or ""}
|
||||
|
||||
|
||||
def heading(text: str, level: int = 2) -> dict:
|
||||
return {"t": "h", "text": text or "", "level": max(1, min(3, int(level)))}
|
||||
|
||||
|
||||
def para(text: str) -> dict:
|
||||
return {"t": "p", "text": text or ""}
|
||||
|
||||
|
||||
def keyvals(pairs: list, cols: int = 1) -> dict:
|
||||
return {"t": "kv", "pairs": [[str(k), str(v)] for k, v in pairs if v not in (None, "")], "cols": cols}
|
||||
|
||||
|
||||
def party(left: dict, right: dict | None = None) -> dict:
|
||||
"""Two facing address/identity blocks (invoice from/to, letter sender/recipient)."""
|
||||
def _side(s):
|
||||
s = s or {}
|
||||
return {"title": str(s.get("title", "")), "lines": [str(x) for x in (s.get("lines") or []) if str(x).strip()]}
|
||||
return {"t": "party", "left": _side(left), "right": _side(right)}
|
||||
|
||||
|
||||
def table(headers: list, rows: list, align: list | None = None, foot: list | None = None) -> dict:
|
||||
return {
|
||||
"t": "table",
|
||||
"headers": [str(h) for h in headers],
|
||||
"rows": [[("" if c is None else str(c)) for c in r] for r in rows],
|
||||
"align": align or [],
|
||||
"foot": [[("" if c is None else str(c)) for c in r] for r in (foot or [])],
|
||||
}
|
||||
|
||||
|
||||
def bullets(items: list) -> dict:
|
||||
return {"t": "ul", "items": [str(i) for i in items if str(i).strip()]}
|
||||
|
||||
|
||||
def rule() -> dict:
|
||||
return {"t": "hr"}
|
||||
|
||||
|
||||
def spacer(h: int = 12) -> dict:
|
||||
return {"t": "sp", "h": int(h)}
|
||||
|
||||
|
||||
def signature(label: str) -> dict:
|
||||
return {"t": "sign", "label": label or ""}
|
||||
|
||||
|
||||
def doc_ir(title_text: str, blocks: list, *, meta: dict | None = None) -> dict:
|
||||
return {"title": title_text or "Document", "blocks": [b for b in blocks if b], "meta": meta or {}}
|
||||
|
||||
|
||||
# ── doc archetype: PDF · DOCX · MD ────────────────────────────────────────────────
|
||||
|
||||
def _render_doc(ir: dict, fmt: str) -> bytes:
|
||||
if fmt == "pdf":
|
||||
html = _doc_html(ir)
|
||||
pdf = _weasy_pdf(html)
|
||||
return pdf if pdf is not None else _builtin_pdf(ir)
|
||||
if fmt == "docx":
|
||||
return _doc_docx(ir)
|
||||
if fmt == "md":
|
||||
return _doc_md(ir).encode("utf-8")
|
||||
raise ValueError(fmt) # pragma: no cover
|
||||
|
||||
|
||||
# Monochrome print stylesheet — Hanzo's design-system look on paper: near-black ink on
|
||||
# white, a clean sans stack, thin hairline rules, generous margins. Deliberately light
|
||||
# (this is the paper, previewed as paper even under a dark UI).
|
||||
_PRINT_CSS = """
|
||||
@page { size: Letter; margin: 22mm 20mm; }
|
||||
* { box-sizing: border-box; }
|
||||
body { font-family: -apple-system, "Helvetica Neue", Helvetica, Arial, "Segoe UI", sans-serif;
|
||||
color: #16171a; font-size: 10.5pt; line-height: 1.5; margin: 0; }
|
||||
h1.doctitle { font-size: 22pt; letter-spacing: -.01em; margin: 0 0 2pt; font-weight: 700; }
|
||||
.docsub { color: #6b6f76; font-size: 10.5pt; margin: 0 0 14pt; }
|
||||
h2 { font-size: 13pt; font-weight: 700; margin: 20pt 0 6pt; letter-spacing:-.005em; }
|
||||
h3 { font-size: 11pt; font-weight: 700; margin: 14pt 0 4pt; }
|
||||
h4 { font-size: 10.5pt; font-weight: 700; margin: 12pt 0 3pt; text-transform: uppercase; letter-spacing: .06em; color:#4a4d53; }
|
||||
p { margin: 0 0 8pt; }
|
||||
.hr { border: 0; border-top: 1px solid #d9dbdf; margin: 14pt 0; }
|
||||
.kv { width: 100%; border-collapse: collapse; margin: 0 0 8pt; }
|
||||
.kv td { padding: 2pt 0; vertical-align: top; }
|
||||
.kv td.k { color: #6b6f76; width: 34%; padding-right: 10pt; }
|
||||
.kv.two { }
|
||||
.parties { display: flex; gap: 26pt; margin: 6pt 0 16pt; }
|
||||
.parties .side { flex: 1; }
|
||||
.parties .side .ttl { text-transform: uppercase; letter-spacing: .06em; font-size: 8.5pt; color: #6b6f76; margin: 0 0 3pt; }
|
||||
.parties .side .ln { margin: 0; }
|
||||
table.grid { width: 100%; border-collapse: collapse; margin: 8pt 0 12pt; font-size: 10pt; }
|
||||
table.grid th { text-align: left; font-weight: 700; border-bottom: 1.4px solid #16171a; padding: 6pt 8pt 6pt 0; }
|
||||
table.grid td { border-bottom: 1px solid #e6e8eb; padding: 6pt 8pt 6pt 0; vertical-align: top; }
|
||||
table.grid th.num, table.grid td.num { text-align: right; padding-right: 0; }
|
||||
table.grid tfoot td { border-bottom: 0; border-top: 1.4px solid #16171a; font-weight: 700; padding-top: 7pt; }
|
||||
ul { margin: 0 0 8pt; padding-left: 16pt; }
|
||||
li { margin: 0 0 3pt; }
|
||||
.sign { margin-top: 34pt; }
|
||||
.sign .line { border-top: 1px solid #16171a; width: 58%; padding-top: 4pt; color: #6b6f76; font-size: 9pt; }
|
||||
"""
|
||||
|
||||
|
||||
def _doc_html(ir: dict) -> str:
|
||||
"""The IR as one self-contained HTML document for WeasyPrint. Built with stdlib
|
||||
``html.escape`` at every value boundary — no template engine, so there is no
|
||||
template-injection surface and no extra dependency; the structural HTML is ours,
|
||||
every field value is escaped."""
|
||||
body = _doc_html_body(ir) # already-escaped, structural HTML
|
||||
doc_title = _html.escape(ir.get("title") or "Document")
|
||||
return (
|
||||
"<!doctype html><html><head><meta charset='utf-8'><title>" + doc_title
|
||||
+ "</title><style>" + _PRINT_CSS + "</style></head><body>" + body + "</body></html>"
|
||||
)
|
||||
|
||||
|
||||
def _e(s: Any) -> str:
|
||||
return _html.escape("" if s is None else str(s))
|
||||
|
||||
|
||||
def _doc_html_body(ir: dict) -> str:
|
||||
out: list[str] = []
|
||||
for b in ir.get("blocks", []):
|
||||
t = b.get("t")
|
||||
if t == "title":
|
||||
out.append(f"<h1 class='doctitle'>{_e(b['text'])}</h1>")
|
||||
if b.get("sub"):
|
||||
out.append(f"<div class='docsub'>{_e(b['sub'])}</div>")
|
||||
elif t == "h":
|
||||
tag = {1: "h2", 2: "h3", 3: "h4"}[b.get("level", 2)]
|
||||
out.append(f"<{tag}>{_e(b['text'])}</{tag}>")
|
||||
elif t == "p":
|
||||
out.append(f"<p>{_e(b['text']).replace(chr(10), '<br>')}</p>")
|
||||
elif t == "kv":
|
||||
cls = "kv two" if b.get("cols") == 2 else "kv"
|
||||
rows = "".join(f"<tr><td class='k'>{_e(k)}</td><td>{_e(v)}</td></tr>" for k, v in b["pairs"])
|
||||
out.append(f"<table class='{cls}'>{rows}</table>")
|
||||
elif t == "party":
|
||||
def side(s):
|
||||
lns = "".join(f"<p class='ln'>{_e(x)}</p>" for x in s["lines"])
|
||||
ttl = f"<p class='ttl'>{_e(s['title'])}</p>" if s["title"] else ""
|
||||
return f"<div class='side'>{ttl}{lns}</div>"
|
||||
out.append(f"<div class='parties'>{side(b['left'])}{side(b['right'])}</div>")
|
||||
elif t == "table":
|
||||
out.append(_html_table(b))
|
||||
elif t == "ul":
|
||||
out.append("<ul>" + "".join(f"<li>{_e(i)}</li>" for i in b["items"]) + "</ul>")
|
||||
elif t == "hr":
|
||||
out.append("<hr class='hr'>")
|
||||
elif t == "sp":
|
||||
out.append(f"<div style='height:{int(b.get('h', 12))}pt'></div>")
|
||||
elif t == "sign":
|
||||
out.append(f"<div class='sign'><div class='line'>{_e(b['label'])}</div></div>")
|
||||
return "".join(out)
|
||||
|
||||
|
||||
def _num_cols(b: dict) -> set:
|
||||
return {i for i, a in enumerate(b.get("align", [])) if a == "num"}
|
||||
|
||||
|
||||
def _html_table(b: dict) -> str:
|
||||
nums = _num_cols(b)
|
||||
th = "".join(f"<th class='{'num' if i in nums else ''}'>{_e(h)}</th>" for i, h in enumerate(b["headers"]))
|
||||
body = "".join(
|
||||
"<tr>" + "".join(f"<td class='{'num' if i in nums else ''}'>{_e(c)}</td>" for i, c in enumerate(r)) + "</tr>"
|
||||
for r in b["rows"]
|
||||
)
|
||||
foot = ""
|
||||
if b.get("foot"):
|
||||
foot = "<tfoot>" + "".join(
|
||||
"<tr>" + "".join(f"<td class='{'num' if i in nums else ''}'>{_e(c)}</td>" for i, c in enumerate(r)) + "</tr>"
|
||||
for r in b["foot"]
|
||||
) + "</tfoot>"
|
||||
return f"<table class='grid'><thead><tr>{th}</tr></thead><tbody>{body}</tbody>{foot}</table>"
|
||||
|
||||
|
||||
_WEASY: Any = ... # unset sentinel; set to the module or False on first probe
|
||||
|
||||
|
||||
def _weasyprint():
|
||||
"""The weasyprint module if importable with its native stack, else False. Probed
|
||||
once and cached — the import is expensive and, when pango/cairo are absent, prints
|
||||
its own diagnostics, so we do it exactly once and swallow that chatter."""
|
||||
global _WEASY
|
||||
if _WEASY is ...:
|
||||
import contextlib
|
||||
import os as _os
|
||||
try:
|
||||
with open(_os.devnull, "w") as dn, contextlib.redirect_stderr(dn), contextlib.redirect_stdout(dn):
|
||||
import weasyprint # type: ignore
|
||||
_WEASY = weasyprint
|
||||
except Exception:
|
||||
_WEASY = False
|
||||
return _WEASY
|
||||
|
||||
|
||||
def _weasy_pdf(html: str) -> bytes | None:
|
||||
"""WeasyPrint render, or None when the library or its native stack is unavailable —
|
||||
the caller then uses the built-in fallback. Fully guarded so a pod without
|
||||
pango/cairo simply falls back."""
|
||||
wp = _weasyprint()
|
||||
if not wp:
|
||||
return None
|
||||
try:
|
||||
return wp.HTML(string=html).write_pdf()
|
||||
except Exception: # pragma: no cover - native failure at render time
|
||||
return None
|
||||
|
||||
|
||||
# ── DOCX (python-docx) ────────────────────────────────────────────────────────────
|
||||
|
||||
def _doc_docx(ir: dict) -> bytes:
|
||||
from docx import Document
|
||||
from docx.shared import Pt, RGBColor
|
||||
|
||||
INK = RGBColor(0x16, 0x17, 0x1A)
|
||||
DIM = RGBColor(0x6B, 0x6F, 0x76)
|
||||
d = Document()
|
||||
for s in d.styles:
|
||||
try:
|
||||
if s.font is not None and s.font.name is None:
|
||||
s.font.name = "Helvetica"
|
||||
except Exception:
|
||||
pass
|
||||
normal = d.styles["Normal"]
|
||||
normal.font.name = "Helvetica"
|
||||
normal.font.size = Pt(10.5)
|
||||
normal.font.color.rgb = INK
|
||||
|
||||
def _runcolor(p, color=INK):
|
||||
for r in p.runs:
|
||||
r.font.color.rgb = color
|
||||
|
||||
for b in ir.get("blocks", []):
|
||||
t = b.get("t")
|
||||
if t == "title":
|
||||
p = d.add_paragraph()
|
||||
run = p.add_run(b["text"])
|
||||
run.bold = True
|
||||
run.font.size = Pt(22)
|
||||
run.font.color.rgb = INK
|
||||
if b.get("sub"):
|
||||
sp = d.add_paragraph()
|
||||
sr = sp.add_run(b["sub"])
|
||||
sr.font.size = Pt(10.5)
|
||||
sr.font.color.rgb = DIM
|
||||
elif t == "h":
|
||||
p = d.add_paragraph()
|
||||
run = p.add_run(b["text"])
|
||||
run.bold = True
|
||||
run.font.size = Pt({1: 13, 2: 11, 3: 10.5}[b.get("level", 2)])
|
||||
run.font.color.rgb = INK if b.get("level", 2) < 3 else DIM
|
||||
elif t == "p":
|
||||
p = d.add_paragraph(b["text"])
|
||||
_runcolor(p)
|
||||
elif t == "kv":
|
||||
tbl = d.add_table(rows=0, cols=2)
|
||||
for k, v in b["pairs"]:
|
||||
cells = tbl.add_row().cells
|
||||
kr = cells[0].paragraphs[0].add_run(k)
|
||||
kr.font.color.rgb = DIM
|
||||
kr.font.size = Pt(10)
|
||||
vr = cells[1].paragraphs[0].add_run(v)
|
||||
vr.font.size = Pt(10)
|
||||
vr.font.color.rgb = INK
|
||||
elif t == "party":
|
||||
tbl = d.add_table(rows=1, cols=2)
|
||||
for cell, side in ((tbl.rows[0].cells[0], b["left"]), (tbl.rows[0].cells[1], b["right"])):
|
||||
if side["title"]:
|
||||
tr = cell.paragraphs[0].add_run(side["title"].upper())
|
||||
tr.bold = True
|
||||
tr.font.size = Pt(8.5)
|
||||
tr.font.color.rgb = DIM
|
||||
for ln in side["lines"]:
|
||||
lp = cell.add_paragraph()
|
||||
lr = lp.add_run(ln)
|
||||
lr.font.size = Pt(10)
|
||||
lr.font.color.rgb = INK
|
||||
elif t == "table":
|
||||
_docx_table(d, b, INK, DIM, Pt)
|
||||
elif t == "ul":
|
||||
for it in b["items"]:
|
||||
p = d.add_paragraph(it, style="List Bullet")
|
||||
_runcolor(p)
|
||||
elif t == "hr":
|
||||
d.add_paragraph("_" * 48).runs[0].font.color.rgb = DIM
|
||||
elif t == "sp":
|
||||
d.add_paragraph()
|
||||
elif t == "sign":
|
||||
d.add_paragraph()
|
||||
p = d.add_paragraph("__________________________")
|
||||
p.runs[0].font.color.rgb = INK
|
||||
lp = d.add_paragraph()
|
||||
lr = lp.add_run(b["label"])
|
||||
lr.font.size = Pt(9)
|
||||
lr.font.color.rgb = DIM
|
||||
buf = io.BytesIO()
|
||||
d.save(buf)
|
||||
return buf.getvalue()
|
||||
|
||||
|
||||
def _docx_table(d, b, INK, DIM, Pt) -> None:
|
||||
headers, rows, foot = b["headers"], b["rows"], b.get("foot") or []
|
||||
nums = _num_cols(b)
|
||||
from docx.enum.text import WD_ALIGN_PARAGRAPH
|
||||
tbl = d.add_table(rows=1, cols=len(headers))
|
||||
tbl.style = "Light Grid Accent 1" if "Light Grid Accent 1" in [s.name for s in d.styles] else "Table Grid"
|
||||
for i, h in enumerate(headers):
|
||||
cell = tbl.rows[0].cells[i]
|
||||
run = cell.paragraphs[0].add_run(h)
|
||||
run.bold = True
|
||||
run.font.size = Pt(9.5)
|
||||
run.font.color.rgb = INK
|
||||
if i in nums:
|
||||
cell.paragraphs[0].alignment = WD_ALIGN_PARAGRAPH.RIGHT
|
||||
for r in rows:
|
||||
cells = tbl.add_row().cells
|
||||
for i, c in enumerate(r):
|
||||
run = cells[i].paragraphs[0].add_run(c)
|
||||
run.font.size = Pt(9.5)
|
||||
run.font.color.rgb = INK
|
||||
if i in nums:
|
||||
cells[i].paragraphs[0].alignment = WD_ALIGN_PARAGRAPH.RIGHT
|
||||
for r in foot:
|
||||
cells = tbl.add_row().cells
|
||||
for i, c in enumerate(r):
|
||||
run = cells[i].paragraphs[0].add_run(c)
|
||||
run.bold = True
|
||||
run.font.size = Pt(9.5)
|
||||
run.font.color.rgb = INK
|
||||
if i in nums:
|
||||
cells[i].paragraphs[0].alignment = WD_ALIGN_PARAGRAPH.RIGHT
|
||||
|
||||
|
||||
# ── Markdown ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
def _doc_md(ir: dict) -> str:
|
||||
out: list[str] = []
|
||||
for b in ir.get("blocks", []):
|
||||
t = b.get("t")
|
||||
if t == "title":
|
||||
out.append(f"# {b['text']}")
|
||||
if b.get("sub"):
|
||||
out.append(f"*{b['sub']}*")
|
||||
elif t == "h":
|
||||
out.append(("#" * (b.get("level", 2) + 1)) + " " + b["text"])
|
||||
elif t == "p":
|
||||
out.append(b["text"])
|
||||
elif t == "kv":
|
||||
out.extend(f"- **{k}:** {v}" for k, v in b["pairs"])
|
||||
elif t == "party":
|
||||
for side in (b["left"], b["right"]):
|
||||
if side["title"]:
|
||||
out.append(f"**{side['title']}**")
|
||||
out.extend(side["lines"])
|
||||
out.append("")
|
||||
elif t == "table":
|
||||
out.append(_md_table(b["headers"], b["rows"] + (b.get("foot") or [])))
|
||||
elif t == "ul":
|
||||
out.extend(f"- {i}" for i in b["items"])
|
||||
elif t == "hr":
|
||||
out.append("---")
|
||||
elif t == "sign":
|
||||
out.append("")
|
||||
out.append("\\_\\_\\_\\_\\_\\_\\_\\_\\_\\_\\_\\_\\_\\_\\_\\_\\_\\_\\_\\_\\_")
|
||||
out.append(b["label"])
|
||||
out.append("")
|
||||
return "\n".join(out).strip() + "\n"
|
||||
|
||||
|
||||
def _md_table(headers: list, rows: list) -> str:
|
||||
def row(cells):
|
||||
return "| " + " | ".join(str(c).replace("|", "\\|") for c in cells) + " |"
|
||||
lines = [row(headers), "| " + " | ".join("---" for _ in headers) + " |"]
|
||||
lines += [row(r + [""] * (len(headers) - len(r))) for r in rows]
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
# ── Built-in PDF fallback — a valid, monochrome, paginated PDF with zero native deps ─
|
||||
# Base-14 Helvetica (no embedding needed), WinAnsi text, absolute-positioned lines,
|
||||
# automatic pagination and word wrap, simple heading/table/bullet layout. Not as rich
|
||||
# as WeasyPrint, but a genuine, readable document that always passes %PDF validation.
|
||||
|
||||
_PAGE_W, _PAGE_H = 612.0, 792.0
|
||||
_ML, _MR, _MT, _MB = 54.0, 54.0, 60.0, 56.0
|
||||
_CONTENT_W = _PAGE_W - _ML - _MR
|
||||
|
||||
# Coarse Helvetica advance widths (per 1pt), enough to wrap without overflow.
|
||||
_AVG = 0.50
|
||||
|
||||
|
||||
def _text_w(s: str, size: float, bold: bool = False) -> float:
|
||||
return len(s) * size * (_AVG + (0.02 if bold else 0.0))
|
||||
|
||||
|
||||
def _wrap(text: str, size: float, width: float, bold: bool = False) -> list[str]:
|
||||
lines: list[str] = []
|
||||
for raw in str(text).split("\n"):
|
||||
words, cur = raw.split(), ""
|
||||
for w in words:
|
||||
trial = w if not cur else cur + " " + w
|
||||
if _text_w(trial, size, bold) <= width or not cur:
|
||||
cur = trial
|
||||
else:
|
||||
lines.append(cur)
|
||||
cur = w
|
||||
lines.append(cur)
|
||||
return lines or [""]
|
||||
|
||||
|
||||
def _pdf_escape(s: str) -> str:
|
||||
return s.replace("\\", "\\\\").replace("(", "\\(").replace(")", "\\)")
|
||||
|
||||
|
||||
def _pdf_enc(s: str) -> str:
|
||||
# WinAnsi/cp1252 so currency/quotes/dashes survive; unmappable → '?'.
|
||||
return _pdf_escape(s.encode("cp1252", "replace").decode("cp1252"))
|
||||
|
||||
|
||||
class _PDFPager:
|
||||
"""Accumulates absolute-positioned text ops across auto-paginated pages."""
|
||||
|
||||
def __init__(self):
|
||||
self.pages: list[list[str]] = [[]]
|
||||
self.y = _PAGE_H - _MT
|
||||
|
||||
def _page(self) -> list[str]:
|
||||
return self.pages[-1]
|
||||
|
||||
def _newpage(self):
|
||||
self.pages.append([])
|
||||
self.y = _PAGE_H - _MT
|
||||
|
||||
def space(self, h: float):
|
||||
self.y -= h
|
||||
if self.y < _MB:
|
||||
self._newpage()
|
||||
|
||||
def line(self, text: str, size: float, *, bold=False, gray=False, indent=0.0, leading=1.42, gap=0.0):
|
||||
if gap:
|
||||
self.y -= gap
|
||||
wrapped = _wrap(text, size, _CONTENT_W - indent, bold)
|
||||
for ln in wrapped:
|
||||
if self.y - size < _MB:
|
||||
self._newpage()
|
||||
font = "F2" if bold else "F1"
|
||||
col = "0.42 0.44 0.47 rg\n" if gray else "0.086 0.09 0.102 rg\n"
|
||||
self._page().append(
|
||||
f"BT {col}/{font} {size:.2f} Tf 1 0 0 1 {_ML + indent:.2f} {self.y - size:.2f} Tm ({_pdf_enc(ln)}) Tj ET\n"
|
||||
)
|
||||
self.y -= size * leading
|
||||
|
||||
def cols(self, cells: list[tuple[str, float, bool]], xs: list[float], size: float, *, bold=False, gray=False, gap=0.0):
|
||||
"""One row of already-fitted cell strings at column x-offsets."""
|
||||
if gap:
|
||||
self.y -= gap
|
||||
if self.y - size < _MB:
|
||||
self._newpage()
|
||||
font = "F2" if bold else "F1"
|
||||
col = "0.42 0.44 0.47 rg\n" if gray else "0.086 0.09 0.102 rg\n"
|
||||
y = self.y - size
|
||||
for (text, colw, right), x in zip(cells, xs):
|
||||
s = _fit(text, size, colw, bold)
|
||||
tx = x + (colw - _text_w(s, size, bold)) if right else x
|
||||
self._page().append(f"BT {col}/{font} {size:.2f} Tf 1 0 0 1 {_ML + tx:.2f} {y:.2f} Tm ({_pdf_enc(s)}) Tj ET\n")
|
||||
self.y -= size * 1.55
|
||||
|
||||
def hline(self, weight: float = 0.6, gray=True):
|
||||
self.y -= 4
|
||||
if self.y < _MB:
|
||||
self._newpage()
|
||||
col = "0.85 0.86 0.88 RG\n" if gray else "0.086 0.09 0.102 RG\n"
|
||||
self._page().append(f"{col}{weight:.2f} w {_ML:.2f} {self.y:.2f} m {_PAGE_W - _MR:.2f} {self.y:.2f} l S\n")
|
||||
self.y -= 6
|
||||
|
||||
|
||||
def _fit(text: str, size: float, width: float, bold: bool) -> str:
|
||||
s = str(text)
|
||||
if _text_w(s, size, bold) <= width:
|
||||
return s
|
||||
while s and _text_w(s + "…", size, bold) > width:
|
||||
s = s[:-1]
|
||||
return (s + "…") if s else ""
|
||||
|
||||
|
||||
def _builtin_pdf(ir: dict) -> bytes:
|
||||
p = _PDFPager()
|
||||
for b in ir.get("blocks", []):
|
||||
t = b.get("t")
|
||||
if t == "title":
|
||||
p.line(b["text"], 21, bold=True, gap=2)
|
||||
if b.get("sub"):
|
||||
p.line(b["sub"], 10.5, gray=True, gap=1)
|
||||
p.space(8)
|
||||
elif t == "h":
|
||||
size = {1: 13.5, 2: 11.5, 3: 10.5}[b.get("level", 2)]
|
||||
p.line(b["text"], size, bold=True, gray=b.get("level", 2) == 3, gap=10)
|
||||
p.space(2)
|
||||
elif t == "p":
|
||||
p.line(b["text"], 10.5, gap=2)
|
||||
p.space(4)
|
||||
elif t == "kv":
|
||||
for k, v in b["pairs"]:
|
||||
kw = _CONTENT_W * 0.32
|
||||
p.cols([(k, kw, False), (v, _CONTENT_W - kw, False)], [0, kw], 10, gray=False)
|
||||
elif t == "party":
|
||||
half = _CONTENT_W / 2 - 8
|
||||
left, right = b["left"], b["right"]
|
||||
p.space(4)
|
||||
if left["title"] or right["title"]:
|
||||
p.cols([(left["title"].upper(), half, False), (right["title"].upper(), half, False)],
|
||||
[0, _CONTENT_W / 2 + 8], 8.5, bold=True, gray=True)
|
||||
for i in range(max(len(left["lines"]), len(right["lines"]))):
|
||||
lt = left["lines"][i] if i < len(left["lines"]) else ""
|
||||
rt = right["lines"][i] if i < len(right["lines"]) else ""
|
||||
p.cols([(lt, half, False), (rt, half, False)], [0, _CONTENT_W / 2 + 8], 10)
|
||||
p.space(8)
|
||||
elif t == "table":
|
||||
_builtin_table(p, b)
|
||||
elif t == "ul":
|
||||
for it in b["items"]:
|
||||
for j, ln in enumerate(_wrap(it, 10.5, _CONTENT_W - 16)):
|
||||
p.line(("• " if j == 0 else " ") + ln, 10.5, indent=8, leading=1.35)
|
||||
p.space(4)
|
||||
elif t == "hr":
|
||||
p.hline()
|
||||
elif t == "sp":
|
||||
p.space(int(b.get("h", 12)))
|
||||
elif t == "sign":
|
||||
p.space(28)
|
||||
p.hline(weight=0.9, gray=False)
|
||||
p.line(b["label"], 9, gray=True)
|
||||
return _assemble_pdf(p.pages, ir.get("title") or "Document")
|
||||
|
||||
|
||||
def _builtin_table(p: _PDFPager, b: dict) -> None:
|
||||
headers, rows, foot = b["headers"], b["rows"], b.get("foot") or []
|
||||
nums = _num_cols(b)
|
||||
n = len(headers)
|
||||
# First column takes remaining width; numeric/short columns get a fixed slice.
|
||||
fixed = min(_CONTENT_W * 0.22, 120)
|
||||
other = fixed if n > 1 else _CONTENT_W
|
||||
first = _CONTENT_W - other * (n - 1)
|
||||
widths = [first] + [other] * (n - 1)
|
||||
xs, acc = [], 0.0
|
||||
for w in widths:
|
||||
xs.append(acc)
|
||||
acc += w
|
||||
cells = [(h, widths[i] - 8, i in nums) for i, h in enumerate(headers)]
|
||||
p.space(6)
|
||||
p.cols(cells, xs, 9.5, bold=True, gap=2)
|
||||
p.hline(weight=1.2, gray=False)
|
||||
for r in rows:
|
||||
cells = [((r[i] if i < len(r) else ""), widths[i] - 8, i in nums) for i in range(n)]
|
||||
p.cols(cells, xs, 9.5)
|
||||
if foot:
|
||||
p.hline(weight=1.2, gray=False)
|
||||
for fr in foot:
|
||||
cells = [((fr[i] if i < len(fr) else ""), widths[i] - 8, i in nums) for i in range(n)]
|
||||
p.cols(cells, xs, 9.5, bold=True)
|
||||
p.space(6)
|
||||
|
||||
|
||||
def _assemble_pdf(pages: list[list[str]], title_text: str) -> bytes:
|
||||
"""Serialize positioned-text pages into a valid PDF 1.7 with an xref table.
|
||||
|
||||
Object numbers are planned up front so every cross-reference is known before a
|
||||
single byte is written — no insert/renumber games:
|
||||
1 = Catalog · 2 = Pages · 3 = F1 · 4 = F2 ·
|
||||
then per page i: content = 5+2i, page = 6+2i.
|
||||
"""
|
||||
n_pages = max(1, len(pages))
|
||||
page_nos = [6 + 2 * i for i in range(n_pages)]
|
||||
content_nos = [5 + 2 * i for i in range(n_pages)]
|
||||
objs: dict[int, bytes] = {}
|
||||
objs[1] = b"<< /Type /Catalog /Pages 2 0 R >>"
|
||||
kids = " ".join(f"{no} 0 R" for no in page_nos).encode()
|
||||
objs[2] = b"<< /Type /Pages /Kids [" + kids + b"] /Count %d >>" % n_pages
|
||||
objs[3] = b"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>"
|
||||
objs[4] = b"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica-Bold /Encoding /WinAnsiEncoding >>"
|
||||
for i in range(n_pages):
|
||||
ops = pages[i] if i < len(pages) else []
|
||||
# cp1252 so WinAnsi glyphs (€, curly quotes, en/em dash) survive as their byte
|
||||
# values; the fonts declare /WinAnsiEncoding so those bytes map to the right glyph.
|
||||
stream = ("".join(ops)).encode("cp1252", "replace")
|
||||
comp = zlib.compress(stream)
|
||||
objs[content_nos[i]] = (
|
||||
b"<< /Length %d /Filter /FlateDecode >>\nstream\n" % len(comp) + comp + b"\nendstream")
|
||||
objs[page_nos[i]] = (
|
||||
b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 %d %d] "
|
||||
b"/Resources << /Font << /F1 3 0 R /F2 4 0 R >> >> /Contents %d 0 R >>"
|
||||
% (int(_PAGE_W), int(_PAGE_H), content_nos[i]))
|
||||
|
||||
info_no = max(objs) + 1
|
||||
objs[info_no] = (b"<< /Title (" + _pdf_enc(title_text).encode("cp1252", "replace")
|
||||
+ b") /Producer (Hanzo Studio) >>")
|
||||
|
||||
count = max(objs)
|
||||
out = bytearray(b"%PDF-1.7\n%\xe2\xe3\xcf\xd3\n")
|
||||
offsets = [0] * (count + 1)
|
||||
for i in range(1, count + 1):
|
||||
offsets[i] = len(out)
|
||||
out += b"%d 0 obj\n" % i + objs[i] + b"\nendobj\n"
|
||||
xref_pos = len(out)
|
||||
out += b"xref\n0 %d\n" % (count + 1)
|
||||
out += b"0000000000 65535 f \n"
|
||||
for i in range(1, count + 1):
|
||||
out += b"%010d 00000 n \n" % offsets[i]
|
||||
out += b"trailer\n<< /Size %d /Root 1 0 R /Info %d 0 R >>\nstartxref\n%d\n%%%%EOF\n" % (
|
||||
count + 1, info_no, xref_pos)
|
||||
return bytes(out)
|
||||
|
||||
|
||||
# ── sheet archetype: XLSX · PDF · DOCX · MD ───────────────────────────────────────
|
||||
# SheetIR = {"title", "sheets": [{"name", "columns": [{"key","label","width","fmt"}],
|
||||
# "rows": [dict], "totals": [dict]}]}. fmt ∈ {"money","int","pct","date",None}.
|
||||
|
||||
def sheet_ir(title_text: str, sheets: list) -> dict:
|
||||
return {"title": title_text or "Spreadsheet", "sheets": sheets}
|
||||
|
||||
|
||||
def _render_sheet(ir: dict, fmt: str) -> bytes:
|
||||
if fmt == "xlsx":
|
||||
return _sheet_xlsx(ir)
|
||||
# Secondary formats reuse the doc archetype by turning each sheet into a table block.
|
||||
d = _sheet_as_doc(ir)
|
||||
return _render_doc(d, fmt)
|
||||
|
||||
|
||||
_XLSX_FMT = {"money": "#,##0.00", "int": "#,##0", "pct": "0.0%", "date": "yyyy-mm-dd"}
|
||||
|
||||
|
||||
def _sheet_xlsx(ir: dict) -> bytes:
|
||||
from openpyxl import Workbook
|
||||
from openpyxl.styles import Font, PatternFill, Alignment, Border, Side
|
||||
|
||||
wb = Workbook()
|
||||
wb.remove(wb.active)
|
||||
ink = "16171A"
|
||||
head_fill = PatternFill("solid", fgColor="F0F1F3")
|
||||
head_font = Font(name="Helvetica", bold=True, color=ink, size=10)
|
||||
base_font = Font(name="Helvetica", color=ink, size=10)
|
||||
bot = Border(bottom=Side(style="thin", color="16171A"))
|
||||
thin = Border(bottom=Side(style="hair", color="D9DBDF"))
|
||||
for sh in ir.get("sheets", []):
|
||||
ws = wb.create_sheet(title=(sh.get("name") or "Sheet")[:31])
|
||||
cols = sh.get("columns", [])
|
||||
for ci, col in enumerate(cols, start=1):
|
||||
c = ws.cell(row=1, column=ci, value=col.get("label", col.get("key", "")))
|
||||
c.font = head_font
|
||||
c.fill = head_fill
|
||||
c.border = bot
|
||||
c.alignment = Alignment(horizontal="right" if col.get("fmt") in ("money", "int", "pct") else "left")
|
||||
ws.column_dimensions[c.column_letter].width = col.get("width", 18)
|
||||
r = 2
|
||||
for row in sh.get("rows", []):
|
||||
for ci, col in enumerate(cols, start=1):
|
||||
v = row.get(col["key"])
|
||||
c = ws.cell(row=r, column=ci, value=v)
|
||||
c.font = base_font
|
||||
c.border = thin
|
||||
nf = _XLSX_FMT.get(col.get("fmt"))
|
||||
if nf:
|
||||
c.number_format = nf
|
||||
r += 1
|
||||
for total in sh.get("totals", []):
|
||||
for ci, col in enumerate(cols, start=1):
|
||||
v = total.get(col["key"])
|
||||
c = ws.cell(row=r, column=ci, value=v)
|
||||
c.font = Font(name="Helvetica", bold=True, color=ink, size=10)
|
||||
c.border = bot
|
||||
nf = _XLSX_FMT.get(col.get("fmt"))
|
||||
if nf and isinstance(v, (int, float)):
|
||||
c.number_format = nf
|
||||
r += 1
|
||||
ws.freeze_panes = "A2"
|
||||
buf = io.BytesIO()
|
||||
wb.save(buf)
|
||||
return buf.getvalue()
|
||||
|
||||
|
||||
def _fmt_cell(v: Any, fmt: str | None) -> str:
|
||||
if v is None or v == "":
|
||||
return ""
|
||||
if fmt == "money" and isinstance(v, (int, float)):
|
||||
return f"{v:,.2f}"
|
||||
if fmt == "int" and isinstance(v, (int, float)):
|
||||
return f"{int(v):,}"
|
||||
if fmt == "pct" and isinstance(v, (int, float)):
|
||||
return f"{v * 100:.1f}%"
|
||||
return str(v)
|
||||
|
||||
|
||||
def _sheet_as_doc(ir: dict) -> dict:
|
||||
blocks = [title(ir.get("title") or "Spreadsheet")]
|
||||
for sh in ir.get("sheets", []):
|
||||
cols = sh.get("columns", [])
|
||||
if len(ir.get("sheets", [])) > 1:
|
||||
blocks.append(heading(sh.get("name") or "Sheet", 2))
|
||||
headers = [c.get("label", c.get("key", "")) for c in cols]
|
||||
align = ["num" if c.get("fmt") in ("money", "int", "pct") else "" for c in cols]
|
||||
rows = [[_fmt_cell(row.get(c["key"]), c.get("fmt")) for c in cols] for row in sh.get("rows", [])]
|
||||
foot = [[_fmt_cell(t.get(c["key"]), c.get("fmt")) for c in cols] for t in sh.get("totals", [])]
|
||||
blocks.append(table(headers, rows, align=align, foot=foot))
|
||||
return doc_ir(ir.get("title") or "Spreadsheet", blocks)
|
||||
|
||||
|
||||
# ── deck archetype: PPTX · PDF · MD ───────────────────────────────────────────────
|
||||
# DeckIR = {"title", "slides": [{"layout": "title"|"bullets"|"section",
|
||||
# "title", "subtitle", "bullets": [str], "notes"}]}.
|
||||
|
||||
def deck_ir(title_text: str, slides: list) -> dict:
|
||||
return {"title": title_text or "Presentation", "slides": slides}
|
||||
|
||||
|
||||
def _render_deck(ir: dict, fmt: str) -> bytes:
|
||||
if fmt == "pptx":
|
||||
return _deck_pptx(ir)
|
||||
return _render_doc(_deck_as_doc(ir), fmt)
|
||||
|
||||
|
||||
def _deck_pptx(ir: dict) -> bytes:
|
||||
from pptx import Presentation
|
||||
from pptx.util import Inches, Pt
|
||||
from pptx.dml.color import RGBColor
|
||||
|
||||
INK = RGBColor(0x16, 0x17, 0x1A)
|
||||
DIM = RGBColor(0x6B, 0x6F, 0x76)
|
||||
prs = Presentation()
|
||||
prs.slide_width = Inches(13.333)
|
||||
prs.slide_height = Inches(7.5)
|
||||
blank = prs.slide_layouts[6]
|
||||
|
||||
def _tb(slide, left, top, width, height):
|
||||
box = slide.shapes.add_textbox(Inches(left), Inches(top), Inches(width), Inches(height))
|
||||
box.text_frame.word_wrap = True
|
||||
return box.text_frame
|
||||
|
||||
for sd in ir.get("slides", []):
|
||||
slide = prs.slides.add_slide(blank)
|
||||
layout = sd.get("layout", "bullets")
|
||||
if layout in ("title", "section"):
|
||||
tf = _tb(slide, 0.9, 2.6 if layout == "title" else 2.9, 11.5, 2.0)
|
||||
para0 = tf.paragraphs[0]
|
||||
run = para0.add_run()
|
||||
run.text = sd.get("title", "")
|
||||
run.font.size = Pt(40 if layout == "title" else 30)
|
||||
run.font.bold = True
|
||||
run.font.color.rgb = INK
|
||||
if sd.get("subtitle"):
|
||||
sp = tf.add_paragraph()
|
||||
sr = sp.add_run()
|
||||
sr.text = sd["subtitle"]
|
||||
sr.font.size = Pt(18)
|
||||
sr.font.color.rgb = DIM
|
||||
else:
|
||||
tf = _tb(slide, 0.9, 0.7, 11.5, 1.1)
|
||||
run = tf.paragraphs[0].add_run()
|
||||
run.text = sd.get("title", "")
|
||||
run.font.size = Pt(28)
|
||||
run.font.bold = True
|
||||
run.font.color.rgb = INK
|
||||
body = _tb(slide, 0.9, 2.0, 11.5, 4.8)
|
||||
first = True
|
||||
for b in sd.get("bullets", []):
|
||||
p = body.paragraphs[0] if first else body.add_paragraph()
|
||||
first = False
|
||||
r = p.add_run()
|
||||
r.text = str(b)
|
||||
r.font.size = Pt(18)
|
||||
r.font.color.rgb = INK
|
||||
p.space_after = Pt(8)
|
||||
if sd.get("notes"):
|
||||
slide.notes_slide.notes_text_frame.text = sd["notes"]
|
||||
buf = io.BytesIO()
|
||||
prs.save(buf)
|
||||
return buf.getvalue()
|
||||
|
||||
|
||||
def _deck_as_doc(ir: dict) -> dict:
|
||||
blocks = [title(ir.get("title") or "Presentation")]
|
||||
for i, sd in enumerate(ir.get("slides", []), start=1):
|
||||
blocks.append(heading(f"{i}. {sd.get('title', '')}".strip(". "), 2))
|
||||
if sd.get("subtitle"):
|
||||
blocks.append(para(sd["subtitle"]))
|
||||
if sd.get("bullets"):
|
||||
blocks.append(bullets(sd["bullets"]))
|
||||
if sd.get("notes"):
|
||||
blocks.append(para(sd["notes"]))
|
||||
return doc_ir(ir.get("title") or "Presentation", blocks)
|
||||
File diff suppressed because it is too large
Load Diff
+165
-6
@@ -468,6 +468,13 @@ body.immersive #stage{display:block}
|
||||
.atable .upc{display:none}
|
||||
.tpick-grid{grid-template-columns:repeat(auto-fill,minmax(44vw,1fr))}
|
||||
}
|
||||
/* Office documents — describe/fill dialog + generated-doc cards (house monochrome) */
|
||||
#docdlg{max-width:560px}
|
||||
#docdlg .docl{display:block;font-size:.7rem;color:var(--dim);margin:10px 0 4px;text-transform:uppercase;letter-spacing:.04em}
|
||||
#docform{max-height:44vh;overflow:auto;padding-right:4px}
|
||||
#docform .docfield input,#docform .docfield textarea{width:100%;background:#0f0f12;color:var(--txt);border:1px solid var(--line);border-radius:8px;padding:8px 10px;font:inherit;font-size:.86rem;min-height:0;margin:0;resize:vertical}
|
||||
#docform .docfield textarea{line-height:1.45}
|
||||
#doclib .doc{flex-direction:column;gap:8px}
|
||||
</style>
|
||||
<header>
|
||||
<svg class="logo" viewBox="0 0 520 520" xmlns="http://www.w3.org/2000/svg"><rect width="520" height="520" rx="120" fill="#111"/><g transform="translate(100,100) scale(4.78)" fill="#fff"><path d="M22.21 67V44.6369H0V67H22.21Z"/><path d="M66.7038 22.3184H22.2534L0.0878906 44.6367H44.4634L66.7038 22.3184Z"/><path d="M22.21 0H0V22.3184H22.21V0Z"/><path d="M66.7198 0H44.5098V22.3184H66.7198V0Z"/><path d="M66.7198 67V44.6369H44.5098V67H66.7198Z"/></g></svg>
|
||||
@@ -502,6 +509,7 @@ body.immersive #stage{display:block}
|
||||
|
||||
<div class="tabs">
|
||||
<button class="on" data-tab="assets" onclick="tab('assets')">Assets</button>
|
||||
<button data-tab="documents" onclick="tab('documents')" title="Create office documents — PDF, Word, Excel, PowerPoint"><span class="ic-slot" data-ic="doc"></span>Documents</button>
|
||||
<button data-tab="templates" onclick="tab('templates')">Templates</button>
|
||||
<button data-tab="designsys" onclick="tab('designsys')">Design systems</button>
|
||||
<button data-tab="pipelines" onclick="tab('pipelines')">Workflows</button>
|
||||
@@ -579,6 +587,18 @@ body.immersive #stage{display:block}
|
||||
<div class="sec-l">Start from a kind</div>
|
||||
<div class="grid" id="tgall"></div>
|
||||
</section>
|
||||
<section id="documents" hidden>
|
||||
<div class="row" style="justify-content:space-between;align-items:flex-end;flex-wrap:wrap;gap:10px">
|
||||
<div>
|
||||
<div class="sec-l" style="margin:0">Create an office document</div>
|
||||
<div style="color:var(--dim);font-size:.78rem;margin-top:3px">Pick a template, describe it in one line or fill a short form — download a PDF, Word, Excel or PowerPoint. No GPU needed.</div>
|
||||
</div>
|
||||
<input class="search" id="docq" placeholder="Search templates…" oninput="renderDocCatalog()">
|
||||
</div>
|
||||
<div id="docgall" class="grid" style="margin-top:16px"></div>
|
||||
<div class="sec-l">Your documents</div>
|
||||
<div id="doclib" class="grid"></div>
|
||||
</section>
|
||||
<section id="designsys" hidden></section>
|
||||
<section id="pipelines" hidden><div class="grid" id="wfgrid"></div></section>
|
||||
<section id="runs" hidden>
|
||||
@@ -768,6 +788,25 @@ body.immersive #stage{display:block}
|
||||
<div class="row" style="justify-content:flex-end;gap:6px;margin-top:8px"><button type="button" class="mini" onclick="fbdlg.close()">Close</button><button type="submit" class="mini hot">Send</button></div>
|
||||
</form>
|
||||
</dialog>
|
||||
<dialog id="docdlg" aria-label="Create an office document">
|
||||
<div class="row" style="justify-content:space-between;align-items:flex-start;gap:10px">
|
||||
<h3 style="font-size:1rem" id="docdtitle">Document</h3>
|
||||
<button class="mini" onclick="docdlg.close()">Close</button>
|
||||
</div>
|
||||
<p id="docddesc" style="color:var(--dim);font-size:.82rem;margin:4px 0 0"></p>
|
||||
<label class="docl" id="docdesclabel">Describe it in one line</label>
|
||||
<textarea id="docdesc" rows="2" placeholder="e.g. Invoice for Acme Corp, 3 line items totaling $5,000, net-30"></textarea>
|
||||
<div class="row" style="justify-content:space-between;gap:8px">
|
||||
<button class="mini" id="docformtoggle" onclick="docToggleForm()">Fill the form instead ▾</button>
|
||||
<button class="mini hot" id="docgen" onclick="docGenerate('describe')">Generate with AI</button>
|
||||
</div>
|
||||
<div id="docform" hidden style="margin-top:12px;border-top:1px solid var(--line);padding-top:12px"></div>
|
||||
<div id="docformactions" hidden class="row" style="justify-content:flex-end;margin-top:10px">
|
||||
<button class="mini hot" id="docformgo" onclick="docGenerate('form')">Generate document</button>
|
||||
</div>
|
||||
<div id="docderr" class="dlgerr"></div>
|
||||
<div id="docresult" hidden style="margin-top:14px;border-top:1px solid var(--line);padding-top:12px"></div>
|
||||
</dialog>
|
||||
<div id="live" class="sr-only" aria-live="polite" role="status"></div>
|
||||
<script defer src="https://analytics.hanzo.ai/script.js" data-website-id="2a8e455c-2b24-481b-860b-2499911ce2c2"></script>
|
||||
<script type="module" src="https://unpkg.com/@google/model-viewer@3.5.0/dist/model-viewer.min.js"></script>
|
||||
@@ -789,10 +828,10 @@ const TEMPLATES=[
|
||||
{k:'social', t:'Social / Ad', d:'Posts · ads · campaigns', ic:'megaphone', wf:'social', grp:'Marketing'},
|
||||
{k:'email', t:'Marketing email', d:'HTML / MJML', ic:'mail', wf:'email', grp:'Marketing'},
|
||||
{k:'deck', t:'Deck', d:'Sales · investor', ic:'presentation', wf:'deck', grp:'Marketing'},
|
||||
// ── Documents ──
|
||||
{k:'doc', t:'Document', d:'Word · guide', ic:'doc', wf:'doc', grp:'Documents'},
|
||||
{k:'sheet', t:'Spreadsheet', d:'xlsx · data', ic:'table', wf:'sheet', grp:'Documents'},
|
||||
{k:'paper', t:'Paper (LaTeX)', d:'Academic · PDF', ic:'grad', wf:'paper', grp:'Documents'},
|
||||
// ── Office documents (PDF · Word · Excel · PowerPoint) — the non-GPU document subsystem.
|
||||
// One discovery card that opens the Documents tab; the real, searchable catalog of
|
||||
// 22 templates is server-driven (GET /v1/documents). ──
|
||||
{k:'office', t:'Office documents', d:'PDF · Word · Excel · deck', ic:'doc', grp:'Documents', office:true},
|
||||
// ── Web ──
|
||||
{k:'site', t:'Website / App', d:'From hanzo.app templates', ic:'monitor', wf:'site', grp:'Web'},
|
||||
];
|
||||
@@ -868,14 +907,15 @@ const isVideo=p=>/\.(mp4|webm)$/i.test(p||'');
|
||||
const is3d=p=>/\.glb$/i.test(p||'');
|
||||
// The output-node in a graph, any medium — used to title runs by their real prefix.
|
||||
const saver=g=>Object.values(g||{}).find(n=>n&&['SaveImage','SaveVideo','SaveWEBM'].includes(n.class_type));
|
||||
function tcard(x){return `<button type="button" class="tcard" role="listitem" aria-label="${esc(x.t+' — '+x.d)}" onclick="pickTemplate('${x.k}','${x.t.replace(/'/g,"")}')"><span class="ic">${icon(x.ic)}</span><span class="tl">${esc(x.t)}</span><span class="td">${esc(x.d)}</span></button>`;}
|
||||
function tcard(x){const oc=x.office?"tab('documents')":`pickTemplate('${x.k}','${x.t.replace(/'/g,"")}')`;return `<button type="button" class="tcard" role="listitem" aria-label="${esc(x.t+' — '+x.d)}" onclick="${oc}"><span class="ic">${icon(x.ic)}</span><span class="tl">${esc(x.t)}</span><span class="td">${esc(x.d)}</span></button>`;}
|
||||
function tab(name){document.querySelectorAll('.tabs button').forEach(b=>b.classList.toggle('on',b.dataset.tab===name));
|
||||
// Leaving for another tab closes an open Stack detail (it lives over Assets).
|
||||
if(CURSTACK){CURSTACK=null;if(SELMODE){SELMODE=false;SEL.clear();updateBulk();}}
|
||||
$('stackview').hidden=true;
|
||||
const sec=name==='deleted'?'assets':name; // Archive reuses the assets grid, filtered to archived (status==='deleted')
|
||||
for(const s of ['assets','templates','pipelines','runs','designsys'])$(s).hidden=s!==sec;
|
||||
for(const s of ['assets','documents','templates','pipelines','runs','designsys'])$(s).hidden=s!==sec;
|
||||
if(name==='designsys')renderDesignSystems();
|
||||
if(name==='documents')loadDocuments();
|
||||
if(name==='deleted'){FILTER='deleted';}
|
||||
else if(name==='assets'&&(FILTER==='deleted'||FILTER==='flagged')){FILTER='all';}
|
||||
if(sec==='assets'){paintChips();render();updateBulk();}
|
||||
@@ -905,6 +945,125 @@ async function renderTemplate(slug){
|
||||
toast(r.ok?'Render queued → see Queue & History':'Failed: '+(errMsg(j)||('error '+r.status)));
|
||||
}
|
||||
function editorCtx(){ if(CTX&&CTX.id)location.href='/?advanced=1&load='+encodeURIComponent(CTX.id); }
|
||||
|
||||
// ── Office documents — the non-GPU document pipeline (server catalog + AI-fill + render) ──
|
||||
// Catalog is server-driven (/v1/documents); generated docs live per-org (/v1/documents/library).
|
||||
// The describe path is a layered enhancement — the form alone renders a real file, so a
|
||||
// broken/unauthorized LLM never blocks creating a document (422 needFields → reveal the form).
|
||||
// AI-fill reuses the SAME token the shipped Chat uses: window.HZ.pk (publishable pk- key),
|
||||
// sent as the bearer; the backend forwards it (pk- allowlist) or a server key, else falls back.
|
||||
let DOCCAT=null, DOCLIB=[], DOCTMPL=null, DOCID=null;
|
||||
const DOCFMT={pdf:'PDF',docx:'Word',xlsx:'Excel',pptx:'PowerPoint',md:'Markdown'};
|
||||
function docHeaders(){ const h=genHeaders(); try{const pk=window.HZ&&window.HZ.pk; if(pk)h['Authorization']='Bearer '+pk;}catch(_){} return h; }
|
||||
async function loadDocuments(){
|
||||
if(!DOCCAT){ try{DOCCAT=await (await fetch('/v1/documents')).json();}catch(_){DOCCAT={categories:[]};} }
|
||||
renderDocCatalog(); loadDocLib();
|
||||
}
|
||||
function docTmpl(id){ for(const c of (DOCCAT&&DOCCAT.categories)||[]) for(const t of c.templates) if(t.id===id) return t; return null; }
|
||||
function renderDocCatalog(){
|
||||
const q=(($('docq')&&$('docq').value)||'').toLowerCase();
|
||||
let html='';
|
||||
for(const c of (DOCCAT&&DOCCAT.categories)||[]){
|
||||
const ts=c.templates.filter(t=>!q||(t.name+' '+t.description).toLowerCase().includes(q));
|
||||
if(!ts.length)continue;
|
||||
html+=`<div style="grid-column:1/-1;color:var(--dim);font-size:.8rem;margin:8px 0 2px">${esc(c.name)}</div>`;
|
||||
html+=ts.map(t=>`<button type="button" class="tcard" onclick="openDoc('${qq(t.id)}')"><span class="ic">${esc(t.icon)}</span><span class="tl">${esc(t.name)}</span><span class="td">${esc(t.description)}</span></button>`).join('');
|
||||
}
|
||||
const g=$('docgall'); if(g)g.innerHTML=html||'<div class="empty" style="grid-column:1/-1">No matching templates.</div>';
|
||||
}
|
||||
// Build the structured form from the template's typed fields. Complex fields (line items,
|
||||
// rows, sections, slides) use a friendly "cell | cell" line editor, not raw JSON.
|
||||
function docCellsToText(f,v){ return (Array.isArray(v)?v:[]).map(r=>f.of.map(s=>(r&&r[s.key]!=null?r[s.key]:'')).join(' | ')).join('\n'); }
|
||||
function docField(f,val){
|
||||
const id='df_'+f.key, req=f.required?' *':'';
|
||||
const lab=`<label class="docl">${esc(f.label)}${req}</label>`;
|
||||
if(f.of){ const ph=f.of.map(s=>s.label).join(' | ');
|
||||
return lab+`<textarea id="${id}" rows="4" placeholder="${esc(ph)} (one per line)">${esc(docCellsToText(f,val))}</textarea>`; }
|
||||
if(f.type==='textarea') return lab+`<textarea id="${id}" rows="3">${esc(val||'')}</textarea>`;
|
||||
if(f.type==='list') return lab+`<textarea id="${id}" rows="3" placeholder="One per line">${esc(Array.isArray(val)?val.join('\n'):(val||''))}</textarea>`;
|
||||
const typ=(f.type==='number'||f.type==='money')?'number':(f.type==='email'?'email':'text');
|
||||
return lab+`<input id="${id}" type="${typ}" value="${esc(val==null?'':val)}">`;
|
||||
}
|
||||
function docBuildForm(t,values){ values=values||{}; $('docform').innerHTML=t.fields.map(f=>`<div class="docfield">${docField(f,values[f.key])}</div>`).join(''); }
|
||||
function docReadForm(){
|
||||
const out={};
|
||||
for(const f of DOCTMPL.fields){ const el=$('df_'+f.key); if(!el)continue; const raw=el.value;
|
||||
if(f.of){ out[f.key]=raw.split('\n').map(l=>l.trim()).filter(Boolean).map(l=>{const c=l.split('|').map(x=>x.trim());const o={};f.of.forEach((s,i)=>o[s.key]=c[i]||'');return o;}); }
|
||||
else if(f.type==='list'){ out[f.key]=raw.split('\n').map(x=>x.trim()).filter(Boolean); }
|
||||
else { out[f.key]=raw; } }
|
||||
return out;
|
||||
}
|
||||
function docResetDialog(){ $('docderr').style.display='none'; $('docresult').hidden=true; $('docresult').innerHTML='';
|
||||
$('docform').hidden=true; $('docformactions').hidden=true; $('docformtoggle').style.display=''; $('docformtoggle').textContent='Fill the form instead ▾'; }
|
||||
function openDoc(id){
|
||||
const t=docTmpl(id); if(!t){toast('Template unavailable');return;}
|
||||
DOCTMPL=t; DOCID=null;
|
||||
$('docdtitle').textContent=(t.icon?t.icon+' ':'')+t.name;
|
||||
$('docddesc').textContent=t.description;
|
||||
$('docdesc').value=''; $('docdesclabel').style.display=''; $('docdesc').style.display=''; $('docgen').style.display='';
|
||||
docResetDialog(); docBuildForm(t,t.sample||{});
|
||||
$('docdlg').showModal(); setTimeout(()=>$('docdesc').focus(),40);
|
||||
}
|
||||
async function editDoc(id){
|
||||
let doc; try{doc=await (await fetch('/v1/documents/'+encodeURIComponent(id))).json();}catch(_){doc=null;}
|
||||
if(!doc||!doc.template){toast('Could not open document');return;}
|
||||
DOCTMPL=doc.template; DOCID=id;
|
||||
$('docdtitle').textContent=(doc.icon?doc.icon+' ':'')+(doc.title||doc.template.name);
|
||||
$('docddesc').textContent='Edit the details, then re-generate.';
|
||||
$('docdesclabel').style.display='none'; $('docdesc').style.display='none';
|
||||
docResetDialog(); docBuildForm(DOCTMPL,doc.fields||{});
|
||||
$('docform').hidden=false; $('docformactions').hidden=false; $('docformtoggle').style.display='none';
|
||||
docShowResult({download:doc.download,formats:doc.formats},true);
|
||||
$('docdlg').showModal();
|
||||
}
|
||||
function docToggleForm(){ const show=$('docform').hidden; $('docform').hidden=!show; $('docformactions').hidden=!show;
|
||||
$('docformtoggle').textContent=show?'Hide the form ▴':'Fill the form instead ▾'; }
|
||||
function docShowResult(j,quiet){
|
||||
const dl=j.download||{}, formats=j.formats||(DOCTMPL&&DOCTMPL.formats)||[];
|
||||
const btns=formats.map(f=>`<a class="mini hot" href="${dl[f]||('/v1/documents/'+DOCID+'/download?format='+f)}" style="text-decoration:none">⬇ ${DOCFMT[f]||f.toUpperCase()}</a>`).join(' ');
|
||||
$('docresult').innerHTML=(quiet?'<div style="color:var(--dim);font-size:.8rem;margin-bottom:8px">Download:</div>'
|
||||
:'<div style="color:var(--ok);font-size:.82rem;margin-bottom:8px">✓ Saved to your Documents — download:</div>')
|
||||
+`<div class="row" style="gap:6px;flex-wrap:wrap">${btns}</div>`;
|
||||
$('docresult').hidden=false;
|
||||
}
|
||||
async function docGenerate(mode){
|
||||
if(!DOCTMPL)return;
|
||||
const btn=mode==='describe'?$('docgen'):$('docformgo');
|
||||
const body={templateId:DOCTMPL.id};
|
||||
if(mode==='describe'){ const d=$('docdesc').value.trim(); if(d.length<3){toast('Describe the document first');return;} body.description=d; }
|
||||
else { body.fields=docReadForm(); }
|
||||
const err=$('docderr'); err.style.display='none';
|
||||
const old=btn.textContent; btn.disabled=true; btn.textContent='Generating…';
|
||||
try{
|
||||
let r;
|
||||
if(mode==='form' && DOCID){ r=await fetch('/v1/documents/'+encodeURIComponent(DOCID),{method:'PATCH',headers:docHeaders(),body:JSON.stringify(body)}); }
|
||||
else { r=await fetch('/v1/documents/generate',{method:'POST',headers:docHeaders(),body:JSON.stringify(body)}); }
|
||||
const j=await r.json().catch(()=>({}));
|
||||
if(r.status===422 && j.needFields){ // AI unavailable — reveal the form so the user finishes it (never blocks)
|
||||
docBuildForm(DOCTMPL,(j.template&&j.template.sample)||DOCTMPL.sample||{});
|
||||
$('docform').hidden=false; $('docformactions').hidden=false; $('docformtoggle').textContent='Hide the form ▴';
|
||||
err.textContent=(j.error||'AI fill unavailable')+' — fill the fields below and click Generate document.'; err.style.display='block'; return;
|
||||
}
|
||||
if(!r.ok){ err.textContent=errMsg(j)||('Error '+r.status); err.style.display='block'; return; }
|
||||
if(j.id)DOCID=j.id;
|
||||
if(j.note){ err.textContent=j.note; err.style.display='block'; }
|
||||
if(j.fields){ docBuildForm(DOCTMPL,j.fields); }
|
||||
$('docform').hidden=false; $('docformactions').hidden=false;
|
||||
if($('docformtoggle').style.display!=='none')$('docformtoggle').textContent='Hide the form ▴';
|
||||
docShowResult(j); loadDocLib(); track('document_generate',{template:DOCTMPL.id,mode});
|
||||
}catch(e){ err.textContent='Network error — try again'; err.style.display='block'; }
|
||||
finally{ btn.disabled=false; btn.textContent=old; }
|
||||
}
|
||||
async function loadDocLib(){ let d={documents:[]}; try{d=await (await fetch('/v1/documents/library')).json();}catch(_){}
|
||||
DOCLIB=d.documents||[]; renderDocLib(); }
|
||||
function docLibCard(d){
|
||||
const dl=(d.formats||[]).map(f=>`<a class="mini" href="/v1/documents/${encodeURIComponent(d.id)}/download?format=${f}" style="text-decoration:none" title="Download ${DOCFMT[f]||f}">⬇ ${DOCFMT[f]||f.toUpperCase()}</a>`).join(' ');
|
||||
const dt=d.createdAt?new Date(d.createdAt*1000).toLocaleDateString():'';
|
||||
return `<div class="card"><div class="doc" onclick="editDoc('${qq(d.id)}')" style="cursor:pointer" title="Edit"><div style="font-size:1.7rem">${esc(d.icon||'📄')}</div><div style="color:var(--txt);font-size:.82rem;padding:0 8px">${esc(d.title)}</div></div>`
|
||||
+`<div style="padding:8px"><div class="sub">${esc(d.category||'')}${dt?' · '+dt:''}</div><div class="row" style="gap:5px;margin-top:6px;flex-wrap:wrap">${dl}<button class="mini" onclick="delDoc('${qq(d.id)}')" title="Delete">🗑</button></div></div></div>`;
|
||||
}
|
||||
function renderDocLib(){ const el=$('doclib'); if(el)el.innerHTML=DOCLIB.length?DOCLIB.map(docLibCard).join(''):'<div class="empty" style="grid-column:1/-1">No documents yet — create one above.</div>'; }
|
||||
async function delDoc(id){ if(!await ask('Delete this document? This cannot be undone.'))return; try{await fetch('/v1/documents/'+encodeURIComponent(id),{method:'DELETE',headers:genHeaders()});}catch(_){} loadDocLib(); }
|
||||
// ── Product usage → insights.hanzo.ai. sendBeacon to the capture endpoint; the
|
||||
// /v1 prefix is load-bearing (root /e/ is IAM-gated). The key is the project's
|
||||
// public write token. Fails silent — telemetry must never break the product.
|
||||
|
||||
@@ -1853,6 +1853,12 @@ def add_studio_home_routes(routes: web.RouteTableDef, server) -> None:
|
||||
routes, server, org_of=_org_of, owner_of=_owner_of,
|
||||
workspace_of=_workspace_of, soft_delete_assets=_soft_delete_assets)
|
||||
|
||||
# Office documents — the non-GPU pipeline (catalog + AI-fill + renderers) — are
|
||||
# their own orthogonal subsystem, registered here through the same injected tenancy
|
||||
# resolvers so the module stays decoupled (identical seam to stacks_store).
|
||||
from middleware.documents import add_documents_routes
|
||||
add_documents_routes(routes, server, org_of=_org_of, owner_of=_owner_of)
|
||||
|
||||
@routes.get("/studio")
|
||||
async def studio_home(request: web.Request):
|
||||
# no-store: the home HTML changes every release; without this, browsers
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "hanzo-studio"
|
||||
version = "0.17.24"
|
||||
version = "0.18.0"
|
||||
readme = "README.md"
|
||||
license = { file = "LICENSE" }
|
||||
requires-python = ">=3.10"
|
||||
|
||||
@@ -26,6 +26,15 @@ comfy-aimdo>=0.2.0
|
||||
requests
|
||||
PyJWT[crypto]>=2.7.0
|
||||
|
||||
# Office-document renderers (middleware/doc_render.py) — pure-Python, no GPU. WeasyPrint
|
||||
# gives full-CSS PDFs when its native pango/cairo stack is present (see Dockerfile);
|
||||
# where it is not (the light CI unit venv), the built-in PDF fallback takes over, so
|
||||
# every format renders with just these wheels installed.
|
||||
weasyprint>=63
|
||||
python-docx>=1.1.0
|
||||
openpyxl>=3.1.0
|
||||
python-pptx>=1.0.0
|
||||
|
||||
#non essential dependencies:
|
||||
kornia>=0.7.1
|
||||
spandrel
|
||||
|
||||
+1
-1
@@ -1,3 +1,3 @@
|
||||
# This file is automatically generated by the build process when version is
|
||||
# updated in pyproject.toml.
|
||||
__version__ = "0.17.3"
|
||||
__version__ = "0.18.0"
|
||||
|
||||
@@ -0,0 +1,416 @@
|
||||
"""Office documents — the non-GPU document pipeline.
|
||||
|
||||
Covers the four concerns end to end:
|
||||
* catalog — every template well-formed, formats within archetype capability,
|
||||
wire shape JSON-serializable (no build fn leaks).
|
||||
* render — schema -> IR -> real bytes for EVERY template in EVERY declared
|
||||
format, asserted by magic numbers (%PDF, OOXML zip, markdown).
|
||||
* AI-fill parse — a mocked gateway completion -> coerced, typed fields; the
|
||||
forwardable-bearer allowlist is fail-closed (JWT/pk- only).
|
||||
* REST + tenancy — generate/library/get/patch/delete/download over a real aiohttp app,
|
||||
per-org isolation, honest 4xx, correct Content-Type/-Disposition.
|
||||
|
||||
Runs without a GPU, without torch, and without the WeasyPrint native stack (the built-in
|
||||
PDF fallback produces a valid %PDF), so it is safe for the dependency-light CI unit gate.
|
||||
"""
|
||||
import io
|
||||
import json
|
||||
import sys
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
|
||||
|
||||
from aiohttp import web
|
||||
|
||||
import folder_paths
|
||||
from middleware import documents as D
|
||||
from middleware import doc_render as R
|
||||
|
||||
|
||||
# ── helpers ────────────────────────────────────────────────────────────────────────
|
||||
|
||||
def _valid(fmt: str, b: bytes) -> bool:
|
||||
if fmt == "pdf":
|
||||
return b[:5] == b"%PDF-" and b"%%EOF" in b[-64:]
|
||||
if fmt == "md":
|
||||
try:
|
||||
return len(b) > 10 and bool(b.decode("utf-8").strip()) # real, non-empty UTF-8 text
|
||||
except UnicodeDecodeError:
|
||||
return False
|
||||
try:
|
||||
return zipfile.ZipFile(io.BytesIO(b)).testzip() is None
|
||||
except zipfile.BadZipFile:
|
||||
return False
|
||||
|
||||
|
||||
def _jwt(alg: str) -> str:
|
||||
"""A JWT with the given header alg (payload/sig are inert — only the header matters
|
||||
to the forwardable-bearer allowlist)."""
|
||||
import base64
|
||||
h = base64.urlsafe_b64encode(json.dumps({"alg": alg, "typ": "JWT"}).encode()).rstrip(b"=").decode()
|
||||
return f"{h}.eyJzdWIiOiIxIn0.sig"
|
||||
|
||||
|
||||
_OOXML_PART = {"docx": "word/document.xml", "xlsx": "xl/workbook.xml", "pptx": "ppt/presentation.xml"}
|
||||
|
||||
|
||||
# ── catalog ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
def test_catalog_has_all_common_templates():
|
||||
ids = {t["id"] for t in D.CATALOG}
|
||||
expected = {
|
||||
# Documents
|
||||
"resume", "cover_letter", "business_letter", "memo", "report", "proposal", "sow",
|
||||
"meeting_notes", "invoice", "receipt", "contract", "press_release", "newsletter",
|
||||
"one_pager", "certificate",
|
||||
# Spreadsheets
|
||||
"budget", "expense_tracker", "timesheet", "project_plan",
|
||||
# Presentations
|
||||
"pitch_deck", "business_review", "project_update",
|
||||
}
|
||||
assert expected <= ids, f"missing templates: {expected - ids}"
|
||||
assert len(D.CATALOG) == len(ids), "duplicate template ids"
|
||||
|
||||
|
||||
def test_catalog_invariants():
|
||||
for t in D.CATALOG:
|
||||
assert t["category"] in D.CATEGORY_ORDER
|
||||
assert t["formats"], f"{t['id']} has no formats"
|
||||
# every template's declared formats are within its archetype's capability
|
||||
kind, _ir = D.build_ir(t, t["sample"])
|
||||
assert set(t["formats"]) <= set(R.KIND_FORMATS[kind]), \
|
||||
f"{t['id']} ({kind}) declares formats outside {R.KIND_FORMATS[kind]}"
|
||||
# the primary (first) format is the archetype's native format
|
||||
assert t["formats"][0] == R.KIND_FORMATS[kind][0]
|
||||
# fields typed and keyed
|
||||
for f in t["fields"]:
|
||||
assert f["key"] and f["label"] and f["type"]
|
||||
|
||||
|
||||
def test_public_catalog_is_json_serializable_without_build_fn():
|
||||
grouped = D.catalog_grouped()
|
||||
blob = json.dumps({"categories": grouped}) # must not raise (build fn stripped)
|
||||
assert '"build"' not in blob
|
||||
names = [g["name"] for g in grouped]
|
||||
assert names == [c for c in D.CATEGORY_ORDER] # grouped in the canonical order
|
||||
total = sum(len(g["templates"]) for g in grouped)
|
||||
assert total == len(D.CATALOG)
|
||||
|
||||
|
||||
# ── render matrix: every template × every declared format ───────────────────────────
|
||||
|
||||
@pytest.mark.parametrize("tmpl", D.CATALOG, ids=[t["id"] for t in D.CATALOG])
|
||||
def test_every_template_renders_every_format(tmpl):
|
||||
for fmt in tmpl["formats"]:
|
||||
data, title = D.render_document(tmpl, tmpl["sample"], fmt)
|
||||
assert isinstance(data, (bytes, bytearray)) and data, f"{tmpl['id']}/{fmt} produced no bytes"
|
||||
assert _valid(fmt, data), f"{tmpl['id']}/{fmt} invalid magic bytes"
|
||||
if fmt in _OOXML_PART:
|
||||
names = zipfile.ZipFile(io.BytesIO(data)).namelist()
|
||||
assert _OOXML_PART[fmt] in names, f"{tmpl['id']}/{fmt} missing {_OOXML_PART[fmt]}"
|
||||
assert title
|
||||
|
||||
|
||||
def test_pdf_fallback_is_a_valid_pdf_without_weasyprint():
|
||||
# The built-in writer must stand alone (CI/pods without pango). Assert directly.
|
||||
ir = R.doc_ir("T", [R.title("Hello"), R.para("World " * 40),
|
||||
R.table(["A", "B"], [["1", "2"], ["3", "4"]], foot=[["", "sum"]]),
|
||||
R.bullets(["one", "two"]), R.signature("sig")])
|
||||
pdf = R._builtin_pdf(ir)
|
||||
assert pdf[:5] == b"%PDF-" and b"%%EOF" in pdf[-64:]
|
||||
assert pdf.count(b" obj") == pdf.count(b"endobj") # balanced objects
|
||||
assert b"/Root 1 0 R" in pdf and b"startxref" in pdf
|
||||
|
||||
|
||||
def test_invoice_math_flows_into_the_document():
|
||||
inv = D.get_template("invoice")
|
||||
fields = dict(inv["sample"], tax_rate=10, items=[
|
||||
{"description": "A", "qty": 2, "unit_price": 100}, # 200
|
||||
{"description": "B", "qty": 1, "unit_price": 50}, # 50 -> subtotal 250, tax 25, total 275
|
||||
])
|
||||
md = D.render_document(inv, fields, "md")[0].decode()
|
||||
assert "Subtotal" in md and "250.00" in md
|
||||
assert "275.00" in md # total = subtotal + 10% tax
|
||||
|
||||
|
||||
# ── AI-fill: parse + coercion + fail-closed bearer allowlist ────────────────────────
|
||||
|
||||
def test_parse_fill_from_tool_call_coerces_types():
|
||||
inv = D.get_template("invoice")
|
||||
completion = {"choices": [{"message": {"tool_calls": [{"function": {
|
||||
"name": "fill_document",
|
||||
"arguments": json.dumps({"fields": {
|
||||
"number": "INV-9", "to_name": "Acme Corp", "tax_rate": "8.5",
|
||||
"items": [{"description": "Consulting", "qty": "3", "unit_price": "$1,000"}],
|
||||
"bogus_key": "dropped",
|
||||
}}),
|
||||
}}]}}]}
|
||||
fields = D.parse_fill(completion, inv)
|
||||
assert fields["number"] == "INV-9"
|
||||
assert fields["tax_rate"] == 8.5 and isinstance(fields["tax_rate"], float)
|
||||
assert fields["items"][0] == {"description": "Consulting", "qty": 3.0, "unit_price": 1000.0}
|
||||
assert "bogus_key" not in fields # unknown keys dropped
|
||||
assert "from_name" in fields # every declared field present
|
||||
|
||||
|
||||
def test_parse_fill_from_embedded_json_content():
|
||||
memo = D.get_template("memo")
|
||||
completion = {"choices": [{"message": {"content":
|
||||
'Here you go: {"fields": {"subject": "Policy", "body": "Text"}}'}}]}
|
||||
fields = D.parse_fill(completion, memo)
|
||||
assert fields["subject"] == "Policy" and fields["body"] == "Text"
|
||||
|
||||
|
||||
def test_parse_fill_tolerates_garbage():
|
||||
memo = D.get_template("memo")
|
||||
assert D.parse_fill({}, memo) == {}
|
||||
assert D.parse_fill({"choices": [{"message": {"content": "no json here"}}]}, memo) == {}
|
||||
|
||||
|
||||
class _Req:
|
||||
def __init__(self, auth=None, cookies=None):
|
||||
self.headers = {"Authorization": auth} if auth else {}
|
||||
self.cookies = cookies or {}
|
||||
|
||||
|
||||
def test_forwardable_bearer_is_fail_closed(monkeypatch):
|
||||
for env in D._SERVER_TOKEN_ENV:
|
||||
monkeypatch.delenv(env, raising=False)
|
||||
rs, hs = _jwt("RS256"), _jwt("HS256")
|
||||
assert D._forwardable_bearer(_Req("Bearer " + rs)) == "Bearer " + rs # JWKS-verifiable JWT
|
||||
assert D._forwardable_bearer(_Req("Bearer " + hs)) == "" # HS256 session token REFUSED
|
||||
assert D._forwardable_bearer(_Req("Bearer pk-publishable")) == "Bearer pk-publishable"
|
||||
assert D._forwardable_bearer(_Req("Bearer sk-secret")) == "" # secret keys refused
|
||||
assert D._forwardable_bearer(_Req("Bearer hk-secret")) == ""
|
||||
assert D._forwardable_bearer(_Req("Bearer rk-secret")) == ""
|
||||
assert D._forwardable_bearer(_Req(None)) == "" # no token
|
||||
assert D._forwardable_bearer(_Req("Bearer notoken")) == "" # opaque, not JWT/pk-
|
||||
# session-cookie path (what the browser sends) is honored for a verifiable JWT
|
||||
assert D._forwardable_bearer(_Req(None, {"access_token": rs})) == "Bearer " + rs
|
||||
|
||||
|
||||
def test_ai_token_prefers_server_gateway_key(monkeypatch):
|
||||
for env in D._SERVER_TOKEN_ENV:
|
||||
monkeypatch.delenv(env, raising=False)
|
||||
hs = _jwt("HS256")
|
||||
# No server key + only an HS256 session token → no usable AI token (fail-closed → form).
|
||||
assert D._ai_token(_Req("Bearer " + hs)) == ""
|
||||
# A configured server gateway key (server-side only, never page source) is used and wins.
|
||||
monkeypatch.setenv("STUDIO_DOC_TOKEN", "hk-server-key")
|
||||
assert D._ai_token(_Req("Bearer " + hs)) == "Bearer hk-server-key"
|
||||
|
||||
|
||||
|
||||
# ── REST surface over a real aiohttp app ────────────────────────────────────────────
|
||||
# Driven on a self-managed event loop so these are plain SYNC tests — no dependency on
|
||||
# the pytest-asyncio / pytest-aiohttp async-mode configuration (which varies across
|
||||
# environments). One loop per test; the app + client live and die with the fixture.
|
||||
|
||||
import asyncio
|
||||
|
||||
from aiohttp.test_utils import TestClient, TestServer
|
||||
|
||||
|
||||
class _Resp:
|
||||
"""A fully-materialized response (body read eagerly) with sync accessors."""
|
||||
|
||||
def __init__(self, status, headers, body):
|
||||
self.status = status
|
||||
self.headers = headers
|
||||
self._body = body
|
||||
|
||||
def json(self):
|
||||
return json.loads(self._body)
|
||||
|
||||
def read(self):
|
||||
return self._body
|
||||
|
||||
def text(self):
|
||||
return self._body.decode("utf-8", "replace")
|
||||
|
||||
|
||||
class _SyncClient:
|
||||
def __init__(self, tc, loop):
|
||||
self._tc = tc
|
||||
self._loop = loop
|
||||
|
||||
def _request(self, method, url, **kw):
|
||||
async def go():
|
||||
r = await self._tc.request(method, url, **kw)
|
||||
body = await r.read()
|
||||
return _Resp(r.status, dict(r.headers), body)
|
||||
return self._loop.run_until_complete(go())
|
||||
|
||||
def get(self, url, **kw):
|
||||
return self._request("GET", url, **kw)
|
||||
|
||||
def post(self, url, **kw):
|
||||
return self._request("POST", url, **kw)
|
||||
|
||||
def patch(self, url, **kw):
|
||||
return self._request("PATCH", url, **kw)
|
||||
|
||||
def delete(self, url, **kw):
|
||||
return self._request("DELETE", url, **kw)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client(tmp_path, monkeypatch):
|
||||
"""A live app with the documents routes and a tmp per-org tree. ``X-Test-Org`` header
|
||||
selects the tenant (the injected org_of), so one client exercises multi-tenancy."""
|
||||
def outd(org=None):
|
||||
d = tmp_path / "orgs" / (org or "default") / "output"
|
||||
d.mkdir(parents=True, exist_ok=True)
|
||||
return str(d)
|
||||
|
||||
monkeypatch.setattr(folder_paths, "get_org_output_directory", outd)
|
||||
|
||||
def org_of(request):
|
||||
return request.headers.get("X-Test-Org", "default")
|
||||
|
||||
def owner_of(request):
|
||||
return request.headers.get("X-Test-User", "")
|
||||
|
||||
app = web.Application()
|
||||
routes = web.RouteTableDef()
|
||||
D.add_documents_routes(routes, object(), org_of=org_of, owner_of=owner_of)
|
||||
app.add_routes(routes)
|
||||
|
||||
loop = asyncio.new_event_loop()
|
||||
tc = TestClient(TestServer(app), loop=loop)
|
||||
loop.run_until_complete(tc.start_server())
|
||||
try:
|
||||
yield _SyncClient(tc, loop)
|
||||
finally:
|
||||
loop.run_until_complete(tc.close())
|
||||
loop.close()
|
||||
|
||||
|
||||
def _gen(client, template_id, **body):
|
||||
body["templateId"] = template_id
|
||||
return client.post("/v1/documents/generate", json=body)
|
||||
|
||||
|
||||
def test_catalog_endpoint(client):
|
||||
r = client.get("/v1/documents")
|
||||
assert r.status == 200
|
||||
cats = {g["name"] for g in r.json()["categories"]}
|
||||
assert {"Documents", "Spreadsheets", "Presentations"} <= cats
|
||||
|
||||
|
||||
def test_generate_from_fields_then_download_pdf_and_docx(client):
|
||||
"""The guarantee: a document renders end-to-end with NO LLM — form fields only."""
|
||||
inv = D.get_template("invoice")
|
||||
r = _gen(client, "invoice", fields=inv["sample"])
|
||||
assert r.status == 200, r.text()
|
||||
doc = r.json()
|
||||
assert doc["id"] and doc["title"].startswith("Invoice")
|
||||
assert set(doc["download"]) == set(inv["formats"])
|
||||
|
||||
for fmt, want_ct in (("pdf", "application/pdf"),
|
||||
("docx", "application/vnd.openxmlformats-officedocument.wordprocessingml.document")):
|
||||
dl = client.get(doc["download"][fmt])
|
||||
assert dl.status == 200
|
||||
assert dl.headers["Content-Type"] == want_ct
|
||||
assert "attachment;" in dl.headers["Content-Disposition"]
|
||||
assert dl.headers["Content-Disposition"].endswith(f'.{fmt}"')
|
||||
assert _valid(fmt, dl.read())
|
||||
|
||||
|
||||
def test_generate_each_archetype_native_format(client):
|
||||
for tid, fmt in (("resume", "pdf"), ("budget", "xlsx"), ("pitch_deck", "pptx")):
|
||||
t = D.get_template(tid)
|
||||
doc = _gen(client, tid, fields=t["sample"]).json()
|
||||
dl = client.get(doc["download"][fmt])
|
||||
assert dl.status == 200
|
||||
assert dl.headers["Content-Type"] == R.content_type(fmt)
|
||||
assert _valid(fmt, dl.read())
|
||||
|
||||
|
||||
def test_generate_uses_ai_fill_when_description_given(client, monkeypatch):
|
||||
# Mock the gateway call: description -> canned fields (no network).
|
||||
async def fake_ai_fill(request, tmpl, description):
|
||||
assert "Acme" in description
|
||||
return D.coerce_fields(tmpl, {"to_name": "Acme Corp", "number": "INV-42",
|
||||
"items": [{"description": "Work", "qty": 1, "unit_price": 5000}]})
|
||||
monkeypatch.setattr(D, "ai_fill", fake_ai_fill)
|
||||
|
||||
doc = _gen(client, "invoice", description="Invoice for Acme Corp, $5,000, net-30").json()
|
||||
assert doc["fields"]["to_name"] == "Acme Corp"
|
||||
assert doc["title"] == "Invoice INV-42"
|
||||
md = client.get(doc["download"]["md"]).read()
|
||||
assert b"5,000.00" in md and b"Acme Corp" in md
|
||||
|
||||
|
||||
def test_explicit_fields_override_ai(client, monkeypatch):
|
||||
async def fake_ai_fill(request, tmpl, description):
|
||||
return D.coerce_fields(tmpl, {"subject": "AI subject", "body": "AI body"})
|
||||
monkeypatch.setattr(D, "ai_fill", fake_ai_fill)
|
||||
doc = _gen(client, "memo", description="anything", fields={"subject": "Human subject"}).json()
|
||||
assert doc["fields"]["subject"] == "Human subject" # explicit wins
|
||||
assert doc["fields"]["body"] == "AI body" # AI fills the gap
|
||||
|
||||
|
||||
def test_generate_fail_closed_without_token_or_fields(client, monkeypatch):
|
||||
# description given, but no server key and no forwardable token -> AI unavailable, and
|
||||
# no form fields to fall back to: honest 422 asking for fields, never a blank doc.
|
||||
for env in D._SERVER_TOKEN_ENV:
|
||||
monkeypatch.delenv(env, raising=False)
|
||||
r = _gen(client, "invoice", description="Invoice for Acme")
|
||||
assert r.status == 422
|
||||
j = r.json()
|
||||
assert j["needFields"] is True and j["template"]["id"] == "invoice"
|
||||
|
||||
|
||||
def test_generate_rejects_unknown_template_and_empty_body(client):
|
||||
assert _gen(client, "nope", fields={}).status == 404
|
||||
assert client.post("/v1/documents/generate", json={"templateId": "memo"}).status == 400
|
||||
|
||||
|
||||
def test_library_lists_generated_docs_newest_first(client):
|
||||
_gen(client, "memo", fields={"subject": "One", "body": "b"})
|
||||
_gen(client, "resume", fields=D.get_template("resume")["sample"])
|
||||
docs = client.get("/v1/documents/library").json()["documents"]
|
||||
assert len(docs) == 2
|
||||
assert docs[0]["templateId"] == "resume" # newest first
|
||||
assert {"id", "title", "templateId", "formats", "createdAt"} <= set(docs[0])
|
||||
|
||||
|
||||
def test_get_patch_reflected_in_download(client):
|
||||
doc_id = _gen(client, "memo", fields={"subject": "Before", "body": "b"}).json()["id"]
|
||||
got = client.get(f"/v1/documents/{doc_id}").json()
|
||||
assert got["fields"]["subject"] == "Before"
|
||||
assert got["template"]["id"] == "memo"
|
||||
|
||||
assert client.patch(f"/v1/documents/{doc_id}", json={"fields": {"subject": "After"}}).status == 200
|
||||
md = client.get(f"/v1/documents/{doc_id}/download?format=md").read()
|
||||
assert b"After" in md and b"Before" not in md
|
||||
|
||||
|
||||
def test_delete_removes_document(client):
|
||||
doc_id = _gen(client, "memo", fields={"subject": "x", "body": "b"}).json()["id"]
|
||||
assert client.delete(f"/v1/documents/{doc_id}").status == 200
|
||||
assert client.get(f"/v1/documents/{doc_id}").status == 404
|
||||
assert client.get("/v1/documents/library").json()["documents"] == []
|
||||
|
||||
|
||||
def test_tenancy_isolation_between_orgs(client):
|
||||
doc_id = client.post("/v1/documents/generate",
|
||||
json={"templateId": "memo", "fields": {"subject": "secret"}},
|
||||
headers={"X-Test-Org": "org-a"}).json()["id"]
|
||||
# org-b cannot see or download org-a's document
|
||||
assert client.get(f"/v1/documents/{doc_id}", headers={"X-Test-Org": "org-b"}).status == 404
|
||||
assert client.get(f"/v1/documents/{doc_id}/download?format=pdf",
|
||||
headers={"X-Test-Org": "org-b"}).status == 404
|
||||
assert client.get("/v1/documents/library", headers={"X-Test-Org": "org-b"}).json()["documents"] == []
|
||||
# org-a still has it
|
||||
assert client.get(f"/v1/documents/{doc_id}", headers={"X-Test-Org": "org-a"}).status == 200
|
||||
|
||||
|
||||
def test_download_rejects_bad_id_and_format(client):
|
||||
assert client.get("/v1/documents/not-a-real-id/download").status == 404
|
||||
doc_id = _gen(client, "memo", fields={"subject": "x", "body": "b"}).json()["id"]
|
||||
assert client.get(f"/v1/documents/{doc_id}/download?format=xlsx").status == 400 # not in memo formats
|
||||
Reference in New Issue
Block a user