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:
zeekay
2026-07-25 08:14:55 -07:00
co-authored by Claude Opus 4.8
parent 657e2b5fc0
commit 267735d0bd
11 changed files with 2676 additions and 12 deletions
+7
View File
@@ -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
+51 -3
View File
@@ -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).
+2 -1
View File
@@ -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:
+875
View File
@@ -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
View File
@@ -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.
+6
View File
@@ -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
View File
@@ -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"
+9
View File
@@ -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
View File
@@ -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"
+416
View File
@@ -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