fancy-template 0.2.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- fancy_template-0.2.0/.gitignore +13 -0
- fancy_template-0.2.0/LICENSE +21 -0
- fancy_template-0.2.0/PKG-INFO +67 -0
- fancy_template-0.2.0/README.md +43 -0
- fancy_template-0.2.0/SPEC.md +418 -0
- fancy_template-0.2.0/profiles/dark-slide.json +16 -0
- fancy_template-0.2.0/profiles/fine-print.json +18 -0
- fancy_template-0.2.0/profiles/holy-sheet.json +11 -0
- fancy_template-0.2.0/profiles/last-word.json +18 -0
- fancy_template-0.2.0/pyproject.toml +106 -0
- fancy_template-0.2.0/src/fancy_template/__init__.py +121 -0
- fancy_template-0.2.0/src/fancy_template/_dates.py +172 -0
- fancy_template-0.2.0/src/fancy_template/_declarations.py +188 -0
- fancy_template-0.2.0/src/fancy_template/_errors.py +26 -0
- fancy_template-0.2.0/src/fancy_template/_filters.py +118 -0
- fancy_template-0.2.0/src/fancy_template/_inspector.py +350 -0
- fancy_template-0.2.0/src/fancy_template/_json.py +207 -0
- fancy_template-0.2.0/src/fancy_template/_limits.py +54 -0
- fancy_template-0.2.0/src/fancy_template/_numbers.py +144 -0
- fancy_template-0.2.0/src/fancy_template/_profile.py +82 -0
- fancy_template-0.2.0/src/fancy_template/_renderer.py +277 -0
- fancy_template-0.2.0/src/fancy_template/_scanner.py +284 -0
- fancy_template-0.2.0/src/fancy_template/py.typed +0 -0
- fancy_template-0.2.0/tests/template_runner.py +41 -0
- fancy_template-0.2.0/tests/test_conformance.py +149 -0
- fancy_template-0.2.0/tests/test_security.py +160 -0
- fancy_template-0.2.0/tests/test_spec.py +161 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Particle Academy
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: fancy-template
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: Document templates for the Fancy document writers: a writer document with {{ tokens }} and JSON directives, plus a declaration of the data it needs. inspect() at save time, validate_data() before render, render() to plain writer JSON. No code execution, no includes, bounded everywhere. One spec, three runtimes, held to a shared conformance table.
|
|
5
|
+
Project-URL: Homepage, https://github.com/Particle-Academy/fancy-template
|
|
6
|
+
Project-URL: Issues, https://github.com/Particle-Academy/fancy-template/issues
|
|
7
|
+
Project-URL: Source, https://github.com/Particle-Academy/fancy-template
|
|
8
|
+
Author: Particle Academy
|
|
9
|
+
License-Expression: MIT
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Keywords: agentic,certificate,cross-language,document,docx,particle-academy,pdf,pptx,template,xlsx
|
|
12
|
+
Classifier: Development Status :: 4 - Beta
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
19
|
+
Classifier: Topic :: Text Processing :: Markup
|
|
20
|
+
Classifier: Typing :: Typed
|
|
21
|
+
Requires-Python: >=3.11
|
|
22
|
+
Requires-Dist: fancy-expr<2.0,>=0.2.0
|
|
23
|
+
Description-Content-Type: text/markdown
|
|
24
|
+
|
|
25
|
+
# fancy-template (Python)
|
|
26
|
+
|
|
27
|
+
Document templates for the Fancy document writers: a writer document
|
|
28
|
+
(fine-print, last-word, dark-slide, holy-sheet JSON) with `{{ tokens }}` in some
|
|
29
|
+
string values and JSON directives on some array items, plus a declaration of the
|
|
30
|
+
data it needs. **One specification, three implementations** (Python, TypeScript,
|
|
31
|
+
PHP), all asserted against the same `template/*` table in `fancy-conformance`.
|
|
32
|
+
|
|
33
|
+
```python
|
|
34
|
+
from fancy_template import inspect, validate_data, render
|
|
35
|
+
|
|
36
|
+
inspect(template)["issues"] # at SAVE time, no data
|
|
37
|
+
validate_data(template, data) # before rendering
|
|
38
|
+
doc = render(template, data) # plain writer JSON, or raises TemplateError
|
|
39
|
+
|
|
40
|
+
import holy_sheet
|
|
41
|
+
|
|
42
|
+
holy_sheet.to_bytes(doc) # the writer validates it again
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
## What it guarantees
|
|
46
|
+
|
|
47
|
+
- **Missing data is loud.** An absent value raises `TemplateError` with a stable
|
|
48
|
+
`code`, a JSON Pointer `path` into the template and the `variable` path --
|
|
49
|
+
never the data value itself.
|
|
50
|
+
- **No code execution, no includes.** Tokens are paths plus a fixed filter list;
|
|
51
|
+
`$if` is a sandboxed fancy-expr expression; data is reduced to plain JSON
|
|
52
|
+
before anything reads it, and no method of a host object is ever called.
|
|
53
|
+
- **Profiles are allowlists.** Only listed fields take tokens. holy-sheet cells
|
|
54
|
+
write every string result as `{"value": s}`, so user data can never become a
|
|
55
|
+
formula; dark-slide escapes values for the element's markdown or html.
|
|
56
|
+
- **Bounded everywhere**, with a stable error code per bound.
|
|
57
|
+
|
|
58
|
+
## Where Python differs from its own instincts
|
|
59
|
+
|
|
60
|
+
- `True == 1` is true in Python; here a boolean is never a number, and
|
|
61
|
+
`{"fancyTemplate": True}` is refused.
|
|
62
|
+
- `str.isalpha`, `str.isdigit` and `re`'s `\d` are Unicode-aware; names, dates
|
|
63
|
+
and digits here are ASCII only, as in the other legs.
|
|
64
|
+
- The recursion limit sits below the 4096 levels the other legs accept as JSON,
|
|
65
|
+
so every walk of unknown depth is iterative.
|
|
66
|
+
|
|
67
|
+
The specification is `SPEC.md` in the repository root.
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# fancy-template (Python)
|
|
2
|
+
|
|
3
|
+
Document templates for the Fancy document writers: a writer document
|
|
4
|
+
(fine-print, last-word, dark-slide, holy-sheet JSON) with `{{ tokens }}` in some
|
|
5
|
+
string values and JSON directives on some array items, plus a declaration of the
|
|
6
|
+
data it needs. **One specification, three implementations** (Python, TypeScript,
|
|
7
|
+
PHP), all asserted against the same `template/*` table in `fancy-conformance`.
|
|
8
|
+
|
|
9
|
+
```python
|
|
10
|
+
from fancy_template import inspect, validate_data, render
|
|
11
|
+
|
|
12
|
+
inspect(template)["issues"] # at SAVE time, no data
|
|
13
|
+
validate_data(template, data) # before rendering
|
|
14
|
+
doc = render(template, data) # plain writer JSON, or raises TemplateError
|
|
15
|
+
|
|
16
|
+
import holy_sheet
|
|
17
|
+
|
|
18
|
+
holy_sheet.to_bytes(doc) # the writer validates it again
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
## What it guarantees
|
|
22
|
+
|
|
23
|
+
- **Missing data is loud.** An absent value raises `TemplateError` with a stable
|
|
24
|
+
`code`, a JSON Pointer `path` into the template and the `variable` path --
|
|
25
|
+
never the data value itself.
|
|
26
|
+
- **No code execution, no includes.** Tokens are paths plus a fixed filter list;
|
|
27
|
+
`$if` is a sandboxed fancy-expr expression; data is reduced to plain JSON
|
|
28
|
+
before anything reads it, and no method of a host object is ever called.
|
|
29
|
+
- **Profiles are allowlists.** Only listed fields take tokens. holy-sheet cells
|
|
30
|
+
write every string result as `{"value": s}`, so user data can never become a
|
|
31
|
+
formula; dark-slide escapes values for the element's markdown or html.
|
|
32
|
+
- **Bounded everywhere**, with a stable error code per bound.
|
|
33
|
+
|
|
34
|
+
## Where Python differs from its own instincts
|
|
35
|
+
|
|
36
|
+
- `True == 1` is true in Python; here a boolean is never a number, and
|
|
37
|
+
`{"fancyTemplate": True}` is refused.
|
|
38
|
+
- `str.isalpha`, `str.isdigit` and `re`'s `\d` are Unicode-aware; names, dates
|
|
39
|
+
and digits here are ASCII only, as in the other legs.
|
|
40
|
+
- The recursion limit sits below the 4096 levels the other legs accept as JSON,
|
|
41
|
+
so every walk of unknown depth is iterative.
|
|
42
|
+
|
|
43
|
+
The specification is `SPEC.md` in the repository root.
|
|
@@ -0,0 +1,418 @@
|
|
|
1
|
+
# fancy-template -- specification
|
|
2
|
+
|
|
3
|
+
**Normative.** One specification, three implementations (PHP first, then
|
|
4
|
+
TypeScript and Python), held together by the `template/*` suites in
|
|
5
|
+
`fancy-conformance`. Where an implementation and this file disagree, the
|
|
6
|
+
implementation is wrong. Where this file and a conformance row disagree, one of
|
|
7
|
+
them is a bug and it gets fixed in both places in one change.
|
|
8
|
+
|
|
9
|
+
Design and the owner's decisions: envelope `.ai/plans/document-writer-templates.md`
|
|
10
|
+
(accepted 2026-10-27).
|
|
11
|
+
|
|
12
|
+
## 1. What this is
|
|
13
|
+
|
|
14
|
+
A **document template** is a writer document (fine-print, last-word, dark-slide,
|
|
15
|
+
holy-sheet JSON) with `{{ tokens }}` in some string values and JSON directives on
|
|
16
|
+
some array items, plus a declaration of the data it needs. `render(template,
|
|
17
|
+
data)` returns a plain writer document, which the host then passes through that
|
|
18
|
+
writer's own `validate` and `write`. The writers do not depend on this package.
|
|
19
|
+
|
|
20
|
+
It is not a programming language. No code execution, no host-object access, no
|
|
21
|
+
includes, no calls. Templates and data both arrive from people and agents over the
|
|
22
|
+
wire.
|
|
23
|
+
|
|
24
|
+
## 2. Envelope
|
|
25
|
+
|
|
26
|
+
```json
|
|
27
|
+
{ "fancyTemplate": 1,
|
|
28
|
+
"writer": "fine-print",
|
|
29
|
+
"description": "optional, free text",
|
|
30
|
+
"variables": { "recipientName": { "type": "string", "required": true, "maxLength": 200 } },
|
|
31
|
+
"document": { "...": "a writer document" } }
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
| key | rule | issue code |
|
|
35
|
+
|---|---|---|
|
|
36
|
+
| `fancyTemplate` | exactly the integer `1` (an integral value, as section 3 defines integer: `1.0` is the same number; `true` and `"1"` are not) | `TEMPLATE_VERSION` |
|
|
37
|
+
| `writer` | a profile this implementation knows (section 9) | `TEMPLATE_WRITER` |
|
|
38
|
+
| `variables` | optional object; absent means `{}` | `TEMPLATE_SHAPE` |
|
|
39
|
+
| `document` | required object | `TEMPLATE_SHAPE` |
|
|
40
|
+
| anything else except `description` (a string) | not allowed | `TEMPLATE_SHAPE` |
|
|
41
|
+
|
|
42
|
+
The envelope itself must be an object (`TEMPLATE_SHAPE` at path `""`).
|
|
43
|
+
|
|
44
|
+
## 3. Variable declarations
|
|
45
|
+
|
|
46
|
+
`variables` maps a **name** (`^[A-Za-z_][A-Za-z0-9_]*$`) to a **declaration**.
|
|
47
|
+
A declaration is an object with these keys and no others:
|
|
48
|
+
|
|
49
|
+
| key | applies to | meaning |
|
|
50
|
+
|---|---|---|
|
|
51
|
+
| `type` | all, required | `string` `number` `integer` `boolean` `date` `image` `array` `object` |
|
|
52
|
+
| `required` | all | boolean, default `false` |
|
|
53
|
+
| `default` | all | used when the value is absent; must itself be valid |
|
|
54
|
+
| `example` | all | used by `preview()`; must itself be valid |
|
|
55
|
+
| `description` | all | string |
|
|
56
|
+
| `enum` | string, number, integer | non-empty list of allowed values of that type |
|
|
57
|
+
| `maxLength` | string, date, image | non-negative integer, counted in **Unicode code points** (JSON Schema's unit) |
|
|
58
|
+
| `maxItems` | array | non-negative integer |
|
|
59
|
+
| `items` | array, required | the declaration of every element (its `required` is ignored) |
|
|
60
|
+
| `properties` | object, required | map of name to declaration, same rules recursively |
|
|
61
|
+
|
|
62
|
+
Any other shape is `DECL_INVALID` at the offending key's path (or at the
|
|
63
|
+
declaration when `type` is missing or unknown). A bad name is `DECL_INVALID` at
|
|
64
|
+
`/variables/<name>`.
|
|
65
|
+
|
|
66
|
+
Types:
|
|
67
|
+
|
|
68
|
+
- `string`: a JSON string.
|
|
69
|
+
- `number`: a finite JSON number. `integer`: a number with an integral value
|
|
70
|
+
(`3.0` is an integer, as in JSON Schema).
|
|
71
|
+
- `boolean`: `true` / `false`.
|
|
72
|
+
- `date`: a string, `YYYY-MM-DD` or `YYYY-MM-DDTHH:MM[:SS[.fraction]][Z|+HH:MM|-HH:MM]`,
|
|
73
|
+
with a real calendar date (leap years honoured) and a real time.
|
|
74
|
+
- `image`: a string `data:image/png;base64,<b64>` or `data:image/jpeg;base64,<b64>`
|
|
75
|
+
whose base64 decodes strictly and whose decoded bytes start with the format's
|
|
76
|
+
magic number (PNG `89 50 4E 47 0D 0A 1A 0A`, JPEG `FF D8 FF`).
|
|
77
|
+
- `array`, `object`: JSON containers.
|
|
78
|
+
|
|
79
|
+
`null` is not a value of any type.
|
|
80
|
+
|
|
81
|
+
**Not JSON Schema, on purpose.** `required: true` on the declaration and the
|
|
82
|
+
`date`/`image` types are the plan's approved vocabulary and read better to the
|
|
83
|
+
people writing templates. They map mechanically to JSON Schema (`required`
|
|
84
|
+
hoisted to an array; `date` and `image` become `string` with a `format`), so a
|
|
85
|
+
form can still be generated from them; that mapping is not part of phase 1.
|
|
86
|
+
|
|
87
|
+
## 4. Data and `validateData`
|
|
88
|
+
|
|
89
|
+
`validateData(template, data) -> errors` where each error is `{ path, code }`,
|
|
90
|
+
`path` a JSON Pointer into `data`. Errors are listed in declaration order,
|
|
91
|
+
depth-first. An empty list means valid. Codes:
|
|
92
|
+
|
|
93
|
+
| code | when |
|
|
94
|
+
|---|---|
|
|
95
|
+
| `DATA_TYPE` | wrong type, including `null`; also when `data` itself is not an object (path `""`) |
|
|
96
|
+
| `DATA_REQUIRED` | a required name is absent and has no `default` |
|
|
97
|
+
| `DATA_MAX_LENGTH` | longer than `maxLength` code points |
|
|
98
|
+
| `DATA_MAX_ITEMS` | more than `maxItems` elements |
|
|
99
|
+
| `DATA_ENUM` | not one of `enum` (compared by type and value) |
|
|
100
|
+
| `DATA_FORMAT` | a `date` or `image` string that is not one |
|
|
101
|
+
| `DATA_TOO_DEEP` | data nested deeper than `limits.depth` (section 8) |
|
|
102
|
+
|
|
103
|
+
**Undeclared keys are ignored and unreachable**: `inspect` refuses any token or
|
|
104
|
+
directive that names one, so extra data a host passes along can never appear in a
|
|
105
|
+
document. Absent optional names are fine at this stage; a token that reads one
|
|
106
|
+
fails at render unless it has a fallback (section 6).
|
|
107
|
+
|
|
108
|
+
Before validating, data is reduced to plain JSON values. A host object that is
|
|
109
|
+
not plain JSON data is converted the way the host language's JSON encoder would
|
|
110
|
+
convert it, or refused (`DATA_TYPE`). No host method is called during render.
|
|
111
|
+
|
|
112
|
+
Never plain JSON, in every language, so the three legs refuse the same input
|
|
113
|
+
(`DATA_TYPE` at `""` for data, `TEMPLATE_SHAPE` at `""` for a template): a
|
|
114
|
+
non-finite number, a string that is not well-formed Unicode (a lone surrogate),
|
|
115
|
+
a cycle, and nesting deeper than 4096 levels (PHP's JSON encoder limit). These
|
|
116
|
+
are refused WHOLE, before validation, rather than reported at a path, because
|
|
117
|
+
JavaScript's encoder would otherwise rewrite `Infinity` to `null` and move the
|
|
118
|
+
error.
|
|
119
|
+
|
|
120
|
+
**Defaults** apply at every level: an absent name with a `default` takes the
|
|
121
|
+
default (recursively for object properties) before anything reads it.
|
|
122
|
+
|
|
123
|
+
## 5. Tokens
|
|
124
|
+
|
|
125
|
+
Tokens appear only inside string VALUES of `document`, and only in fields the
|
|
126
|
+
writer's profile allows (section 9).
|
|
127
|
+
|
|
128
|
+
```
|
|
129
|
+
text := ( char | "{{" token "}}" )*
|
|
130
|
+
token := ws ( string | path ) ( ws "|" ws filter )* ws
|
|
131
|
+
path := head ( "." segment )*
|
|
132
|
+
head := name | "$index" | "$first" | "$last"
|
|
133
|
+
segment := name | digits
|
|
134
|
+
name := [A-Za-z_][A-Za-z0-9_]*
|
|
135
|
+
digits := [0-9]+
|
|
136
|
+
filter := fname ( ws ":" ws arg )?
|
|
137
|
+
fname := [a-z]+
|
|
138
|
+
arg := string | bare
|
|
139
|
+
bare := [A-Za-z0-9_.+-]+
|
|
140
|
+
string := '"' ( any char except '"' and '\' | '\"' | '\\' )* '"'
|
|
141
|
+
ws := ( " " | TAB )*
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
- `{{` always opens a token. There is no other escape: a literal `{{` is written
|
|
145
|
+
`{{ "{{" }}`. A `}}` outside a token, or a single `{` or `}`, is literal text.
|
|
146
|
+
- Inside a string literal `}}` does not close the token.
|
|
147
|
+
- Scanning is a single left-to-right pass with no backtracking.
|
|
148
|
+
- Failures, in scan order. The scan for the closing `}}` (skipping string
|
|
149
|
+
literals) fails with `TOKEN_TOO_LONG` as soon as it has passed
|
|
150
|
+
`limits.tokenLength` UTF-8 bytes after the `{{` without finding one, and with
|
|
151
|
+
`TOKEN_UNCLOSED` if the text ends first. A token is therefore never scanned
|
|
152
|
+
past its limit. Then the inner text is parsed: `TOKEN_EMPTY` if it is only
|
|
153
|
+
whitespace, `TOKEN_SYNTAX` for anything else that does not match, including a
|
|
154
|
+
newline or an unknown `$` head.
|
|
155
|
+
|
|
156
|
+
`tokenize(text)` returns the parts in order: `{ "text": s }` for literal text
|
|
157
|
+
(adjacent text merged, empty text omitted), `{ "path": [segments], "filters": [...] }`
|
|
158
|
+
or `{ "literal": s, "filters": [...] }` for a token. A filter is `{ "name": n }` or
|
|
159
|
+
`{ "name": n, "arg": s }`; an arg is always a string, whether written bare or quoted.
|
|
160
|
+
`""` tokenizes to `[]`.
|
|
161
|
+
|
|
162
|
+
A string that is **exactly one token** (nothing before `{{` or after `}}`, not even
|
|
163
|
+
whitespace) is a **whole token**. Whether that keeps the value's type depends on
|
|
164
|
+
the field's encoding (section 9); in a text field it is stringified like any
|
|
165
|
+
other token.
|
|
166
|
+
|
|
167
|
+
## 6. Resolution
|
|
168
|
+
|
|
169
|
+
The **scope** is: loop bindings of the enclosing `$each` items (innermost first:
|
|
170
|
+
its `$as` name, then `$index`, `$first`, `$last`), then the declared variables.
|
|
171
|
+
`inspect` refuses a token whose head is neither (`TOKEN_UNDECLARED`) and a
|
|
172
|
+
`$index`/`$first`/`$last` outside any `$each` (`TOKEN_UNDECLARED`).
|
|
173
|
+
|
|
174
|
+
`inspect` also walks the rest of the path through the DECLARATIONS: a name must be
|
|
175
|
+
a declared property of an `object`, digits must step into an `array`, and nothing
|
|
176
|
+
steps into a scalar; otherwise `TOKEN_UNDECLARED`. A loop binding's declaration is
|
|
177
|
+
its array's `items`.
|
|
178
|
+
|
|
179
|
+
At render the rest of the path walks the value: a name selects an object key, digits select
|
|
180
|
+
an array element. Anything that does not resolve -- an absent key, an index out of
|
|
181
|
+
range, a step into a scalar -- is **absent**. Absent is never an empty string.
|
|
182
|
+
|
|
183
|
+
| resolved value | text field result |
|
|
184
|
+
|---|---|
|
|
185
|
+
| string | itself |
|
|
186
|
+
| number | shortest round-trip decimal, as JavaScript's `Number.prototype.toString` writes it (`3`, `0.1`, `1e+21`) |
|
|
187
|
+
| boolean | `true` / `false` |
|
|
188
|
+
| array, object | `TEMPLATE_NOT_SCALAR` |
|
|
189
|
+
| absent | `TEMPLATE_MISSING`, unless a `default` filter supplies a value |
|
|
190
|
+
|
|
191
|
+
Every render error carries `code` and `path`, where `path` is a JSON Pointer into
|
|
192
|
+
the TEMPLATE (`/document/blocks/3/runs/0/text`), never an expanded copy and never
|
|
193
|
+
a data value. Errors name variables by path (`recipientName`, `item.name`), never
|
|
194
|
+
by value.
|
|
195
|
+
|
|
196
|
+
## 7. Filters
|
|
197
|
+
|
|
198
|
+
Applied left to right. A filter receives the current value, which may be absent.
|
|
199
|
+
Every filter except `default` given an absent value fails with `TEMPLATE_MISSING`.
|
|
200
|
+
Unknown filter names are `FILTER_UNKNOWN` and a bad or missing argument is
|
|
201
|
+
`FILTER_ARG`, both reported by `inspect`. A value of the wrong kind at render is
|
|
202
|
+
`FILTER_INPUT`.
|
|
203
|
+
|
|
204
|
+
| filter | argument | input | output |
|
|
205
|
+
|---|---|---|---|
|
|
206
|
+
| `default` | required | anything | the input unchanged if present, else the argument (a string) |
|
|
207
|
+
| `upper` / `lower` | none | scalar (stringified first) | ASCII letters mapped; every other character unchanged |
|
|
208
|
+
| `number` | required, integer 0-10 | number | fixed decimals, comma thousands groups |
|
|
209
|
+
| `money` | required, ISO 4217 code from the table | integer minor units | symbol or code, comma groups, the currency's minor digits |
|
|
210
|
+
| `date` | required, a pattern | a `date` string | formatted |
|
|
211
|
+
| `join` | optional, default `", "` | array of scalars | elements stringified and joined |
|
|
212
|
+
|
|
213
|
+
**`number`**: the input's shortest round-trip decimal representation is rounded to
|
|
214
|
+
the given number of decimals, half away from zero, in decimal arithmetic (never by
|
|
215
|
+
binary floating point: `2.675 | number:2` is `2.68`). Thousands are grouped with
|
|
216
|
+
`,`. Negative values take a leading `-`; a value that rounds to zero is written
|
|
217
|
+
without a sign.
|
|
218
|
+
|
|
219
|
+
**`money`**: the input must be an integer (minor units: `123450 | money:USD` is
|
|
220
|
+
`$1,234.50`). The exponent comes from the table; the amount is never converted
|
|
221
|
+
through a float. Prefix is the symbol (`USD $`, `EUR €`, `GBP £`, `JPY ¥`,
|
|
222
|
+
`INR ₹`, `KRW ₩`) or the code and a space (`CHF 1,234.50`); a negative amount is
|
|
223
|
+
`-$1,234.50`. Exponents: 0 for `JPY KRW CLP VND ISK`, 3 for `KWD BHD JOD OMR TND`,
|
|
224
|
+
2 for `USD EUR GBP CAD AUD NZD CHF CNY INR BRL MXN SEK NOK DKK PLN SGD HKD ZAR`.
|
|
225
|
+
Any other code is `FILTER_ARG`.
|
|
226
|
+
|
|
227
|
+
**`date`**: input is a `date` string (section 3); a time-of-day defaults to
|
|
228
|
+
`00:00:00`, an offset is ignored (the wall-clock time written is the one given).
|
|
229
|
+
Pattern letters, each run matched in full:
|
|
230
|
+
|
|
231
|
+
| run | output |
|
|
232
|
+
|---|---|
|
|
233
|
+
| `yyyy` / `yy` | year, 4 digits / last 2 |
|
|
234
|
+
| `MMMM` `MMM` `MM` `M` | January / Jan / 01 / 1 |
|
|
235
|
+
| `dd` `d` | day, 2 digits / no padding |
|
|
236
|
+
| `EEEE` `EEE` | Monday / Mon |
|
|
237
|
+
| `HH` `H` | hour 00-23 / 0-23 |
|
|
238
|
+
| `hh` `h` | hour 01-12 / 1-12 |
|
|
239
|
+
| `mm` `ss` | minute, second, 2 digits |
|
|
240
|
+
| `a` | `AM` / `PM` |
|
|
241
|
+
|
|
242
|
+
Text in single quotes is literal (`''` is one quote). Any other ASCII letter, or a
|
|
243
|
+
run length not listed, is `FILTER_ARG`. Every other character is literal. English
|
|
244
|
+
names only (owner decision 4).
|
|
245
|
+
|
|
246
|
+
## 8. Directives
|
|
247
|
+
|
|
248
|
+
On an object that is an ELEMENT OF AN ARRAY anywhere in `document`:
|
|
249
|
+
|
|
250
|
+
| key | value |
|
|
251
|
+
|---|---|
|
|
252
|
+
| `$each` | a path (section 5 grammar, no filters) to an array |
|
|
253
|
+
| `$as` | a name for the element; default `item`; only with `$each` |
|
|
254
|
+
| `$if` | a fancy-expr expression, kept or dropped by fancy-expr truthiness |
|
|
255
|
+
| `$value` | any JSON value; makes the element a **splice** (below); only with `$each` |
|
|
256
|
+
|
|
257
|
+
Directive keys are removed from the output. `$each` repeats the object once per
|
|
258
|
+
element, spliced into the parent array in order, with bindings `<$as>`, `$index`
|
|
259
|
+
(0-based), `$first`, `$last`. When both are present, `$each` runs first and `$if`
|
|
260
|
+
is evaluated once per element. `$if` sees every declared variable (after
|
|
261
|
+
defaults) and the loop bindings -- including the element's own `$each` binding,
|
|
262
|
+
**whatever order the two keys are written in** (JSON key order is not something
|
|
263
|
+
an author should have to get right); an absent path is `null` (fancy-expr's
|
|
264
|
+
rule), so a condition on an optional variable is not an error.
|
|
265
|
+
|
|
266
|
+
### The `$value` splice
|
|
267
|
+
|
|
268
|
+
A directive sits on an object, so an element that is itself an array (a
|
|
269
|
+
holy-sheet row), a string (a list of strings) or any other value cannot carry
|
|
270
|
+
one. The **splice** repeats any value:
|
|
271
|
+
|
|
272
|
+
```json
|
|
273
|
+
{ "$each": "lines", "$as": "line", "$value": ["{{ line.desc }}", "{{ line.qty }}"] }
|
|
274
|
+
```
|
|
275
|
+
|
|
276
|
+
An object element of an array that has a `$value` key is a splice. It is
|
|
277
|
+
replaced, in place, by one rendered copy of its `$value` per element of `$each`,
|
|
278
|
+
in order; an empty array produces nothing. Everything else is `$each`'s rules:
|
|
279
|
+
`$as`, the bindings, `$if` evaluated once per element (in any key order), an
|
|
280
|
+
absent array at render is `TEMPLATE_MISSING` at `$each`, and the same bounds
|
|
281
|
+
(below).
|
|
282
|
+
|
|
283
|
+
- **`$value` is the element.** Its content renders exactly as if it had been
|
|
284
|
+
written at the splice's position in the array: the profile matches it by the
|
|
285
|
+
ELEMENT's path (`sheets.*.rows.*` for a row, so its strings are `cell`s and a
|
|
286
|
+
string result is still `{ "value": s }`), and `$value` is not a path segment.
|
|
287
|
+
Error paths point into the template: `/document/sheets/0/rows/1/$value/0`.
|
|
288
|
+
- **Any JSON value**: an array, an object, a string, a number, a boolean or
|
|
289
|
+
`null`. `null`, `0` and `false` are values to copy, not "no value".
|
|
290
|
+
- **It may contain directives, under the same nesting bound.** Arrays inside
|
|
291
|
+
`$value` take directive elements and splices like any other array, and each
|
|
292
|
+
counts toward `limits.directiveDepth` on its path, the splice itself
|
|
293
|
+
included. The TOP of `$value` is not an array element, so a `$`-key on it is
|
|
294
|
+
`DIRECTIVE_MISPLACED` -- a condition belongs on the splice.
|
|
295
|
+
- **A splice holds only directives.** `$value` without `$each` is
|
|
296
|
+
`DIRECTIVE_INVALID` at `$value` (as `$as` without `$each` is), and its content
|
|
297
|
+
is not inspected. A key not starting with `$` beside `$value` is
|
|
298
|
+
`DIRECTIVE_SPLICE_KEY` at that key, and its value is not inspected: there is
|
|
299
|
+
no object left to put it on.
|
|
300
|
+
- **Bounds**: each element counts one toward `limits.iterations`, shared with
|
|
301
|
+
every `$each`; each rendered copy's values count toward `limits.outputNodes`
|
|
302
|
+
and `outputBytes`; the splice object itself is not output and counts nothing.
|
|
303
|
+
|
|
304
|
+
`inspect` order for a splice: its `$` keys in written order, then its other
|
|
305
|
+
keys in written order, then the content of `$value` (when `$each` is present).
|
|
306
|
+
|
|
307
|
+
Static issues (`inspect`):
|
|
308
|
+
|
|
309
|
+
| code | when |
|
|
310
|
+
|---|---|
|
|
311
|
+
| `DIRECTIVE_MISPLACED` | a `$`-key on an object that is not an array element, or on `document` itself, or on the top of a `$value` |
|
|
312
|
+
| `DIRECTIVE_UNKNOWN` | any other `$`-key |
|
|
313
|
+
| `DIRECTIVE_INVALID` | `$as` or `$value` without `$each`; a non-string value; a bad path; an `$as` that is not a name or shadows a variable or an enclosing `$as` |
|
|
314
|
+
| `DIRECTIVE_SPLICE_KEY` | a key not starting with `$` on a splice (an object with `$value`) |
|
|
315
|
+
| `DIRECTIVE_EXPR` | `$if` that fancy-expr refuses (any fancy-expr code) |
|
|
316
|
+
| `DIRECTIVE_UNDECLARED` | `$each` head or a `$if` reference that is not in scope |
|
|
317
|
+
| `DIRECTIVE_NOT_ARRAY` | `$each` path whose declaration is not `array` |
|
|
318
|
+
| `DIRECTIVE_TOO_DEEP` | more than `limits.directiveDepth` directive objects nested |
|
|
319
|
+
|
|
320
|
+
At render, a `$each` path that is absent is `TEMPLATE_MISSING` and one that is not
|
|
321
|
+
an array is `DIRECTIVE_NOT_ARRAY`.
|
|
322
|
+
|
|
323
|
+
## 9. Profiles
|
|
324
|
+
|
|
325
|
+
A profile is data (`profiles/<writer>.json`): which string fields may hold tokens
|
|
326
|
+
and how a value is ENCODED there. Field patterns are dot-separated over the path
|
|
327
|
+
of the string inside `document`: a literal key, `*` for exactly one key or
|
|
328
|
+
index, `**` for zero or more. **Anything not listed refuses tokens**
|
|
329
|
+
(`TOKEN_FORBIDDEN_FIELD`), and a token inside an object KEY is `TOKEN_IN_KEY`.
|
|
330
|
+
|
|
331
|
+
| encoding | result |
|
|
332
|
+
|---|---|
|
|
333
|
+
| `text` | the string, every token stringified |
|
|
334
|
+
| `band` | as `text`, but a VALUE containing `{page}` or `{pages}` is `VALUE_PAGE_FIELD` |
|
|
335
|
+
| `markup` | as `text`, each value escaped for the element's `format`: `markdown` backslash-escapes every ASCII punctuation character (CommonMark), `html` escapes `& < > " '` as entities, `plain` or absent leaves it |
|
|
336
|
+
| `markdown` | as `markup` with format fixed to markdown |
|
|
337
|
+
| `cell` | a whole token keeps a number, boolean or date as-is; every other result is written `{ "value": s }`, so user data can never become a formula. "Date" means: the token's path is DECLARED `date` and the result (after filters) is a valid `date` string (section 3) -- a date-shaped value of a `string` variable is wrapped, and `{{ d \| date:"yyyy" }}` is text. A valid date cannot begin with `=`. A literal (token-free) cell is the author's and is left as written |
|
|
338
|
+
| `image` | must be a whole token, no filters, naming an `image` variable; the value's type must be in the profile's `mime` list (`VALUE_IMAGE_TYPE`). A literal (token-free) value must be a valid image data URL of an allowed type (`TEMPLATE_IMAGE_INVALID`) |
|
|
339
|
+
|
|
340
|
+
Wrong use of an image field is `TOKEN_IMAGE_FIELD`.
|
|
341
|
+
|
|
342
|
+
**`{page}` is refused rather than escaped** because neither fine-print nor
|
|
343
|
+
last-word has an escape for it: both replace the literal text in a band. A refusal
|
|
344
|
+
is loud; any lookalike substitution would print a different character. When a
|
|
345
|
+
writer gains an escape, the band encoding uses it.
|
|
346
|
+
|
|
347
|
+
**The markdown escape is CommonMark's, and the profile does not force `plain`.**
|
|
348
|
+
dark-slide's PPTX writer (`MarkdownInline`, in all three of its languages) does
|
|
349
|
+
not honour backslash escapes and writes `html` as plain text. Measured in phase 6
|
|
350
|
+
by writing the hostile corpus deck through it: an escaped value prints its
|
|
351
|
+
backslashes, AND the escape fails to neutralise markup -- `\*bold\*` still opens
|
|
352
|
+
italics and `` \`c\` `` still opens a code span, because the tokenizer reads the
|
|
353
|
+
`*` and the backtick as markers regardless of the backslash before them.
|
|
354
|
+
|
|
355
|
+
Forcing `format: plain` on a substituted element is not the answer: `format` is
|
|
356
|
+
per ELEMENT, not per run, so it would print the template author's own `**`, `#`
|
|
357
|
+
and `-` literally on every slide that uses a token, and it contradicts
|
|
358
|
+
template/profiles 0101 and 0102. The escape is the correct encoding for every
|
|
359
|
+
CommonMark consumer, including fancy-slides; the fix belongs in dark-slide
|
|
360
|
+
(`MarkdownInline`: a backslash before ASCII punctuation yields that character,
|
|
361
|
+
consumed before marker detection). Until then a dark-slide template that must
|
|
362
|
+
show user text verbatim in a `.pptx` uses an element without `format`.
|
|
363
|
+
|
|
364
|
+
**dark-slide speaker notes are `text`, not `markdown`** (owner ruling,
|
|
365
|
+
2026-10-27). The PPTX writer writes notes as plain text in all three of its
|
|
366
|
+
languages, and fancy-slides edits them in a plain textarea, so a markdown
|
|
367
|
+
escape would print its backslashes in both. A value reaches the notes exactly
|
|
368
|
+
as given (template/profiles 0104).
|
|
369
|
+
|
|
370
|
+
Profiles: `fine-print`, `last-word`, `dark-slide` and `holy-sheet`, implemented
|
|
371
|
+
in all three legs (phase 6).
|
|
372
|
+
|
|
373
|
+
## 10. Bounds
|
|
374
|
+
|
|
375
|
+
Host-overridable through `options.limits`; each failure has a stable code.
|
|
376
|
+
|
|
377
|
+
| limit | default | measured as | code |
|
|
378
|
+
|---|---|---|---|
|
|
379
|
+
| `templateBytes` | 262144 | UTF-8 bytes of every string in the envelope, keys included | `TEMPLATE_TOO_LARGE` |
|
|
380
|
+
| `tokens` | 10000 | tokens in the template | `TEMPLATE_TOO_MANY_TOKENS` |
|
|
381
|
+
| `tokenLength` | 512 | UTF-8 bytes between `{{` and `}}`; also the length of a `$if` or `$each` value | `TOKEN_TOO_LONG` |
|
|
382
|
+
| `directiveDepth` | 8 | directive objects nested inside directive objects | `DIRECTIVE_TOO_DEEP` |
|
|
383
|
+
| `iterations` | 10000 | `$each` elements expanded in one render, all loops and splices together | `RENDER_TOO_MANY_ITERATIONS` |
|
|
384
|
+
| `outputNodes` | 50000 | values in the output (each object, array and scalar is one) | `RENDER_TOO_MANY_NODES` |
|
|
385
|
+
| `outputBytes` | 8388608 | UTF-8 bytes of every string value in the output | `RENDER_TOO_LARGE` |
|
|
386
|
+
| `depth` | 64 | nesting of the template and of the data | `TEMPLATE_TOO_DEEP` / `DATA_TOO_DEEP` |
|
|
387
|
+
| per value | declarations | `maxLength`, `maxItems` | `DATA_MAX_LENGTH` / `DATA_MAX_ITEMS` |
|
|
388
|
+
|
|
389
|
+
`depth` counts containers: the envelope (or the data object) is level 1 and each
|
|
390
|
+
nested object or array one more; a container at a level above the limit fails.
|
|
391
|
+
`directiveDepth` counts directive-bearing objects on one path from `document`
|
|
392
|
+
down, the object itself included. Iterations are counted as they happen, so a
|
|
393
|
+
nested `$each` counts every inner element of every outer one.
|
|
394
|
+
|
|
395
|
+
`depth`, then `templateBytes`, then `tokens` are checked first and stop
|
|
396
|
+
`inspect`: a template over one of them is reported with that one issue. fancy-expr's own limits
|
|
397
|
+
(8192 bytes, depth 64) apply inside `$if` as well.
|
|
398
|
+
|
|
399
|
+
## 11. API
|
|
400
|
+
|
|
401
|
+
Identical in every language (names adapted to the language's case convention:
|
|
402
|
+
PHP `Template::validateData`, TypeScript `validateData`, Python `validate_data`):
|
|
403
|
+
|
|
404
|
+
- `inspect(template, options?) -> { ok, issues: [{ code, path, variable? }] }` -- no
|
|
405
|
+
data. Issues in document order: envelope keys, then variables (in written
|
|
406
|
+
order, a declaration's own keys before its nested declarations), then
|
|
407
|
+
`document` depth-first. Within an object its `$` keys come first, in written
|
|
408
|
+
order, then its other entries in written order (a splice then walks its
|
|
409
|
+
`$value`, section 8). Within a string, tokens in
|
|
410
|
+
order; within a token, its path before its filters.
|
|
411
|
+
- `validateData(template, data, options?) -> errors` (section 4).
|
|
412
|
+
- `render(template, data, options?) -> document`, or throws `TemplateError { code, path, variable? }`.
|
|
413
|
+
Order: `inspect` (first issue), then `validateData` (first error), then render.
|
|
414
|
+
- `preview(template, options?) -> document` -- `render` with each variable's
|
|
415
|
+
`example` (else `default`).
|
|
416
|
+
- `tokenize(text, options?) -> parts` (section 5).
|
|
417
|
+
|
|
418
|
+
Output key order is the template's, minus directive keys.
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
{
|
|
2
|
+
"writer": "dark-slide",
|
|
3
|
+
"schema": "dark-slide deck",
|
|
4
|
+
"notes": "Implemented in all three legs (phase 6). src and background.image are image fields so that a LITERAL local path is refused: dark-slide's ImageResolver reads local paths.",
|
|
5
|
+
"fields": [
|
|
6
|
+
{ "path": "title", "encode": "text" },
|
|
7
|
+
{ "path": "slides.*.elements.*.content", "encode": "markup" },
|
|
8
|
+
{ "path": "slides.*.notes", "encode": "text" },
|
|
9
|
+
{ "path": "slides.*.elements.*.alt", "encode": "text" },
|
|
10
|
+
{ "path": "slides.*.elements.*.items.*", "encode": "text" },
|
|
11
|
+
{ "path": "slides.*.elements.*.columns.*.label", "encode": "text" },
|
|
12
|
+
{ "path": "slides.*.elements.*.rows.**", "encode": "text" },
|
|
13
|
+
{ "path": "slides.*.elements.*.src", "encode": "image", "mime": ["image/png", "image/jpeg"] },
|
|
14
|
+
{ "path": "slides.*.background.image", "encode": "image", "mime": ["image/png", "image/jpeg"] }
|
|
15
|
+
]
|
|
16
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
{
|
|
2
|
+
"writer": "fine-print",
|
|
3
|
+
"schema": "fine-print 0.3",
|
|
4
|
+
"notes": "An ALLOWLIST: a string field not matched here refuses tokens. link, color, font, fonts, page geometry and every style field are deliberately absent. Band slots refuse a value containing {page}/{pages}: fine-print replaces them with str_replace and has no escape.",
|
|
5
|
+
"fields": [
|
|
6
|
+
{ "path": "title", "encode": "text" },
|
|
7
|
+
{ "path": "**.runs.*.text", "encode": "text" },
|
|
8
|
+
{ "path": "**.blocks.*.text", "encode": "text" },
|
|
9
|
+
{ "path": "**.blocks.*.alt", "encode": "text" },
|
|
10
|
+
{ "path": "**.blocks.*.src", "encode": "image", "mime": ["image/png", "image/jpeg"] },
|
|
11
|
+
{ "path": "page.header.left", "encode": "band" },
|
|
12
|
+
{ "path": "page.header.center", "encode": "band" },
|
|
13
|
+
{ "path": "page.header.right", "encode": "band" },
|
|
14
|
+
{ "path": "page.footer.left", "encode": "band" },
|
|
15
|
+
{ "path": "page.footer.center", "encode": "band" },
|
|
16
|
+
{ "path": "page.footer.right", "encode": "band" }
|
|
17
|
+
]
|
|
18
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
{
|
|
2
|
+
"writer": "holy-sheet",
|
|
3
|
+
"schema": "holy-sheet workbook",
|
|
4
|
+
"notes": "Implemented in all three legs (phase 6). Every string result in a cell is written {value: s}, so user data can never become a formula. formula is deliberately absent.",
|
|
5
|
+
"fields": [
|
|
6
|
+
{ "path": "sheets.*.name", "encode": "text" },
|
|
7
|
+
{ "path": "sheets.*.rows.*.*", "encode": "cell" },
|
|
8
|
+
{ "path": "sheets.*.cells.*", "encode": "cell" },
|
|
9
|
+
{ "path": "sheets.*.cells.*.value", "encode": "text" }
|
|
10
|
+
]
|
|
11
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
{
|
|
2
|
+
"writer": "last-word",
|
|
3
|
+
"schema": "last-word 0.x (Doc model shared with fine-print)",
|
|
4
|
+
"notes": "An ALLOWLIST: a string field not matched here refuses tokens. link, chart option, color, font, fonts, page geometry and every style field are deliberately absent. Band slots refuse a value containing {page}/{pages}: last-word writes them as PAGE and NUMPAGES fields with str_replace and has no escape.",
|
|
5
|
+
"fields": [
|
|
6
|
+
{ "path": "title", "encode": "text" },
|
|
7
|
+
{ "path": "**.runs.*.text", "encode": "text" },
|
|
8
|
+
{ "path": "**.blocks.*.text", "encode": "text" },
|
|
9
|
+
{ "path": "**.blocks.*.alt", "encode": "text" },
|
|
10
|
+
{ "path": "**.blocks.*.src", "encode": "image", "mime": ["image/png", "image/jpeg"] },
|
|
11
|
+
{ "path": "page.header.left", "encode": "band" },
|
|
12
|
+
{ "path": "page.header.center", "encode": "band" },
|
|
13
|
+
{ "path": "page.header.right", "encode": "band" },
|
|
14
|
+
{ "path": "page.footer.left", "encode": "band" },
|
|
15
|
+
{ "path": "page.footer.center", "encode": "band" },
|
|
16
|
+
{ "path": "page.footer.right", "encode": "band" }
|
|
17
|
+
]
|
|
18
|
+
}
|