3535
3636from __future__ import annotations
3737
38+ import json
3839from typing import Annotated , Any
3940
4041import pyarrow as pa
4445
4546from . import core
4647from .core import DocgenError , TemplateRef
48+ from .meta import object_tags
4749from .schema_utils import to_context
4850
51+ # Absolute path to the bundled sample DOCX template (shared with tables.py), so
52+ # examples render a real document regardless of the worker's working directory.
53+ from .tables import SAMPLE_TEMPLATE_PATH
54+
4955
5056def _render_one (ref : TemplateRef | None , ctx_value : Any , want_pdf : bool ) -> bytes | None :
5157 """Render a single row to DOCX (or PDF) bytes, or ``None`` on NULL/failure."""
@@ -88,6 +94,105 @@ def _render_array(
8894
8995_RENDER_CATEGORIES = ["docgen" , "template" , "blob" ]
9096
97+ # Shared per-object discovery/description tags for the docgen_render overloads.
98+ # All four overloads name themselves ``docgen_render`` and describe the SAME
99+ # logical function, so they carry identical title/description/keywords/source.
100+ _RENDER_TITLE = "Render Document from Template"
101+
102+ _RENDER_DESCRIPTION_LLM = (
103+ "Mail-merge **one DOCX (Microsoft Word) document per input row** from a "
104+ "template, returning each rendered file as a `BLOB`.\n \n "
105+ "## What it does\n \n "
106+ "The first argument is the template, accepted as **either** a `VARCHAR` "
107+ "path (resolved directly, then under `$VGI_DOCGEN_TEMPLATES`) **or** a "
108+ "`BLOB` of raw `.docx` bytes. The second argument is an arbitrary `STRUCT` "
109+ "of row data: every field becomes a Jinja2 variable in the template "
110+ "(`{{ field }}`), and nested lists/structs drive `{% for %}` loops and "
111+ "tables. An optional third boolean (`pdf`) converts the result to PDF via a "
112+ "headless LibreOffice when one is on PATH.\n \n "
113+ "## When to use it\n \n "
114+ "Use this scalar to produce a separate filled document for each row -- one "
115+ "invoice per customer, one letter per recipient, one statement per account. "
116+ "When you instead want a *single* document concatenating every row, use the "
117+ "`docgen_merge` table function.\n \n "
118+ "## Inputs and output\n \n "
119+ "- `template` -- `VARCHAR` path or `BLOB` `.docx` bytes.\n "
120+ "- `data` -- `STRUCT`; fields become template variables.\n "
121+ "- `pdf` (optional) -- `BOOLEAN`; `true` requests PDF output.\n "
122+ "- Returns a `BLOB`: the rendered `.docx` (or PDF).\n \n "
123+ "## Edge cases\n \n "
124+ "Hostile or missing input degrades to `NULL`, never a crash: a `NULL` "
125+ "template or `NULL` data, a non-DOCX blob, a missing template file, a "
126+ "malformed template, a Jinja render error, and a requested-but-unavailable "
127+ "PDF conversion all yield `NULL`."
128+ )
129+
130+ _RENDER_DESCRIPTION_MD = (
131+ "# Render Document from Template\n \n "
132+ "Mail-merge **one DOCX document per row** from a template, returning each "
133+ "rendered file as a `BLOB`.\n \n "
134+ "## Usage\n \n "
135+ "```sql\n "
136+ "-- One rendered document per row, from a template file\n "
137+ "SELECT docgen.docgen_render('invoice.docx', {customer: name, total: amt})\n "
138+ "FROM invoices;\n \n "
139+ "-- From inline template BLOB bytes\n "
140+ "SELECT docgen.docgen_render(tpl_bytes, {customer: name}) FROM letters;\n \n "
141+ "-- PDF output (requires headless LibreOffice on PATH)\n "
142+ "SELECT docgen.docgen_render('invoice.docx', {total: amt}, true) FROM invoices;\n "
143+ "```\n \n "
144+ "## Notes\n \n "
145+ "- The template is a `VARCHAR` path (resolved under "
146+ "`$VGI_DOCGEN_TEMPLATES`) or inline `.docx` `BLOB` bytes.\n "
147+ "- Every field of the `STRUCT` data argument becomes a Jinja2 variable "
148+ "(`{{ field }}`); nested lists drive `{% for %}` loops.\n "
149+ "- Missing/hostile input (NULL, non-DOCX blob, bad placeholder, missing "
150+ "template, unavailable LibreOffice) degrades to `NULL` rather than crashing."
151+ )
152+
153+ _RENDER_KEYWORDS = (
154+ "docgen, render, mail merge, document generation, docx, word, template, "
155+ "jinja2, docxtpl, fill template, invoice, letter, statement, contract, "
156+ "blob, pdf, libreoffice, placeholder"
157+ )
158+
159+ _RENDER_TAGS = object_tags (
160+ _RENDER_TITLE ,
161+ _RENDER_DESCRIPTION_LLM ,
162+ _RENDER_DESCRIPTION_MD ,
163+ _RENDER_KEYWORDS ,
164+ "vgi_docgen/scalars.py" ,
165+ )
166+
167+ # VGI509 guaranteed-runnable, catalog-qualified examples. Each is self-contained
168+ # and re-runnable against an attached ``docgen`` worker WITHOUT any external
169+ # table: the first renders a real document from the worker's BUNDLED sample
170+ # template (absolute path), and the rest exercise the documented NULL-vs-crash
171+ # discipline. ``expected_result`` is omitted deliberately.
172+ _RENDER_EXECUTABLE_EXAMPLES = json .dumps (
173+ [
174+ {
175+ "description" : (
176+ "Render a document from the bundled sample template filled with a "
177+ "STRUCT of fields and confirm a non-empty DOCX BLOB is produced."
178+ ),
179+ "sql" : (
180+ "SELECT octet_length(docgen.docgen_render("
181+ f"'{ SAMPLE_TEMPLATE_PATH } ', "
182+ "{customer: 'Ada Lovelace', total: '99.50'})) > 0 AS ok"
183+ ),
184+ },
185+ {
186+ "description" : "A NULL template passes straight through to a NULL document (NULL-in, NULL-out)." ,
187+ "sql" : "SELECT docgen.docgen_render(NULL::VARCHAR, {customer: 'Ada'}) AS doc" ,
188+ },
189+ {
190+ "description" : "Non-DOCX inline bytes degrade to a clean NULL rather than crashing the worker." ,
191+ "sql" : "SELECT docgen.docgen_render('not a docx'::BLOB, {x: 1}) AS doc" ,
192+ },
193+ ]
194+ )
195+
91196
92197class DocgenRenderPath (ScalarFunction ):
93198 """``docgen_render(path, data)`` -- render a template file to a DOCX BLOB."""
@@ -102,10 +207,15 @@ class Meta:
102207 "resolved under $VGI_DOCGEN_TEMPLATES) filled with a STRUCT of row "
103208 "data; returns the rendered .docx as a BLOB, or NULL on failure."
104209 )
210+ tags = {** _RENDER_TAGS , "vgi.executable_examples" : _RENDER_EXECUTABLE_EXAMPLES }
105211 examples = [
106212 FunctionExample (
107- sql = "SELECT docgen.docgen_render('invoice.docx', {customer: name, total: amt}) FROM inv" ,
108- description = "Render an invoice per row from a template file" ,
213+ sql = (
214+ "SELECT octet_length(docgen.docgen_render("
215+ f"'{ SAMPLE_TEMPLATE_PATH } ', "
216+ "{customer: 'Ada', total: '99.50'})) > 0 AS ok"
217+ ),
218+ description = "Render an invoice from the bundled sample template" ,
109219 ),
110220 ]
111221
@@ -136,10 +246,11 @@ class Meta:
136246 "with a STRUCT; with pdf=true convert to PDF via headless "
137247 "LibreOffice (NULL if LibreOffice is unavailable), else return DOCX."
138248 )
249+ tags = dict (_RENDER_TAGS )
139250 examples = [
140251 FunctionExample (
141- sql = "SELECT docgen.docgen_render('invoice.docx', {total: amt }, true) FROM inv " ,
142- description = "Render to PDF (requires LibreOffice on PATH)" ,
252+ sql = "SELECT docgen.docgen_render('invoice.docx', {total: '99.50' }, true) AS doc " ,
253+ description = "Render to PDF (requires LibreOffice on PATH; NULL otherwise )" ,
143254 ),
144255 ]
145256
@@ -171,10 +282,11 @@ class Meta:
171282 "filled with a STRUCT of row data; returns the rendered .docx as a "
172283 "BLOB, or NULL on failure."
173284 )
285+ tags = dict (_RENDER_TAGS )
174286 examples = [
175287 FunctionExample (
176- sql = "SELECT docgen.docgen_render(tpl , {customer: name }) FROM letters, templates " ,
177- description = "Render from a template held as bytes" ,
288+ sql = "SELECT docgen.docgen_render('not a docx'::BLOB , {customer: 'Ada' }) AS doc " ,
289+ description = "Render from inline template BLOB bytes (non-DOCX bytes yield a clean NULL) " ,
178290 ),
179291 ]
180292
@@ -205,10 +317,11 @@ class Meta:
205317 "filled with a STRUCT; with pdf=true convert to PDF via headless "
206318 "LibreOffice (NULL if unavailable), else return DOCX."
207319 )
320+ tags = dict (_RENDER_TAGS )
208321 examples = [
209322 FunctionExample (
210- sql = "SELECT docgen.docgen_render(tpl , {total: amt }, true) FROM inv, templates " ,
211- description = "Render template bytes to PDF (requires LibreOffice)" ,
323+ sql = "SELECT docgen.docgen_render('not a docx'::BLOB , {total: '99.50' }, true) AS doc " ,
324+ description = "Render template bytes to PDF (requires LibreOffice; NULL otherwise )" ,
212325 ),
213326 ]
214327
0 commit comments