docxcast 0.1.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.
@@ -0,0 +1,15 @@
1
+ # Python-generated files
2
+ __pycache__/
3
+ *.py[oc]
4
+ build/
5
+ dist/
6
+ wheels/
7
+ *.egg-info
8
+
9
+ # Virtual environments
10
+ .venv
11
+
12
+ # Workspace Local
13
+ .env
14
+ .claude
15
+ .DS_Store
@@ -0,0 +1 @@
1
+ 3.13
docxcast-0.1.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Flow Jiang
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,118 @@
1
+ Metadata-Version: 2.5
2
+ Name: docxcast
3
+ Version: 0.1.0
4
+ Summary: DocXCast: Turn Word content controls into a typed schema, then cast your data back into documents
5
+ Project-URL: Homepage, https://github.com/flowjzh/docxcast
6
+ Project-URL: Repository, https://github.com/flowjzh/docxcast.git
7
+ Project-URL: Issues, https://github.com/flowjzh/docxcast/issues
8
+ Author-email: Flow Jiang <flowjzh@gmail.com>
9
+ License-Expression: MIT
10
+ License-File: LICENSE
11
+ Keywords: content-controls,docx,llm,schema,structured-data,template,word
12
+ Classifier: Development Status :: 4 - Beta
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: Operating System :: OS Independent
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Programming Language :: Python :: 3 :: Only
17
+ Classifier: Programming Language :: Python :: 3.9
18
+ Classifier: Programming Language :: Python :: 3.10
19
+ Classifier: Programming Language :: Python :: 3.11
20
+ Classifier: Programming Language :: Python :: 3.12
21
+ Classifier: Programming Language :: Python :: 3.13
22
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
23
+ Classifier: Typing :: Typed
24
+ Requires-Python: >=3.9
25
+ Requires-Dist: python-docx>=1.2.0
26
+ Description-Content-Type: text/markdown
27
+
28
+ # DocXCast
29
+
30
+ <img width="600" alt="DocXCast" src="https://github.com/user-attachments/assets/dc3287be-341a-4d88-a18d-71acc48c0169" />
31
+
32
+ > **"Your template is the mold. Data is poured in, documents take shape."**
33
+
34
+ ### 🪄 About
35
+
36
+ **DocXCast** turns a Word document's **content controls** into a typed schema, and casts your data back into a formatted document.
37
+
38
+ **The Idea:**
39
+
40
+ A `.docx` template is a natural schema editor. In Word's Developer tab, anyone can insert content controls and describe them:
41
+
42
+ | Word control property | Role in DocXCast |
43
+ |---|---|
44
+ | **Title** | field name (e.g. `name`) |
45
+ | **Tag** | extraction requirement for the LLM (e.g. `the candidate's full name`) |
46
+ | **Repeating section** | array of records |
47
+ | **Building block gallery** | `Section` with its own extraction rule |
48
+ | **Group control** with a `name?` Title | conditional block (see below) |
49
+ | Locked control (`sdtLocked`) | required field |
50
+
51
+ The full control inventory — every construct's Word-visible pseudo-structure and the OOXML it is made of — lives in **[SYNTAX.md](SYNTAX.md)**.
52
+
53
+ **Two independent capabilities:**
54
+
55
+ 1. `derive_schema(template)` — read the controls and produce a `Schema`, serializable to JSON Schema for LLM structured extraction.
56
+ 2. `render(template, data)` — pour data back into the template: clone repeating sections, fill values, unwrap controls, keeping every run's formatting.
57
+
58
+ ### ⚡ Key Features
59
+
60
+ * **Your template IS the schema** — field names, extraction requirements and repeats all live in the docx, editable by non-programmers.
61
+ * **Typed control mapping** — text → `string`, dropdown → `enum`, date / date-time → `format`, checkbox → `boolean`, picture → derived and removed.
62
+ * **LLM-friendly contract validation** — `schema.validate(data)` returns structured, machine-readable issues (`path` / `code` / `message` / `expected` / `got`) that can be fed back into an LLM correction loop.
63
+ * **Lenient by default, strict on demand** — missing fields keep their template values; `strict=True` raises on the first error.
64
+ * **Clean output** — controls are unwrapped by default; `keep_controls=True` keeps them for round-trip editing.
65
+ * **Zero magic** — a single runtime dependency (`python-docx`).
66
+
67
+ ### 🚀 Quick Start
68
+
69
+ ```python
70
+ from docxcast import derive_schema, render
71
+
72
+ schema = derive_schema('resume-template.docx')
73
+
74
+ # hand this to your LLM as the extraction contract
75
+ json_schema = schema.to_json_schema()
76
+
77
+ # validate what the LLM extracted
78
+ report = schema.validate(data)
79
+ if not report.ok:
80
+ # feed report.issues back into the LLM for correction
81
+ ...
82
+
83
+ # cast data into the template; controls are unwrapped
84
+ result = render('resume-template.docx', data)
85
+ result.save('resume-output.docx')
86
+ ```
87
+
88
+ ### 📏 Validation
89
+
90
+ `Schema.validate()` never raises; it returns a `ValidationResult`:
91
+
92
+ ```python
93
+ result.ok # False if any error-level issue exists
94
+ result.errors # missing_required / type_mismatch / enum_invalid / format_invalid
95
+ result.warnings # unknown_key
96
+ ```
97
+
98
+ `render(..., strict=True)` raises `RenderError` on the first error-level issue.
99
+
100
+ ### 🔀 Conditional Blocks
101
+
102
+ Wrap a block in a **group control** and mark the guarded control's Title with a trailing `?` (TS-style): a falsy value removes the whole group — label text included — while a truthy value fills normally.
103
+
104
+ ```
105
+ Nationality: [nationality?] truthy → "Nationality: Chinese" falsy → the line is gone
106
+
107
+ Work Experiences both the heading and the repeat
108
+ [repeat: work_experiences?] disappear when the array is empty
109
+ ```
110
+
111
+ The `?` is a render directive only — the derived schema (and the LLM contract) see a plain `nationality` field. Marker placement, inline vs block-level drops, positional `has_prev?` / `has_next?` separators and every misuse error live in **[SYNTAX.md](SYNTAX.md)**.
112
+
113
+ ### ⚠️ Limitations
114
+
115
+ * `picture` controls are recognized in the schema but removed on render (no image insertion yet).
116
+ * Date values are written verbatim; the template's display mask is not enforced.
117
+ * Unnamed controls are skipped in the schema — every field needs a Title to be addressable.
118
+ * No whitespace control: text outside a dropped inline conditional span survives — put separators inside the span.
@@ -0,0 +1,91 @@
1
+ # DocXCast
2
+
3
+ <img width="600" alt="DocXCast" src="https://github.com/user-attachments/assets/dc3287be-341a-4d88-a18d-71acc48c0169" />
4
+
5
+ > **"Your template is the mold. Data is poured in, documents take shape."**
6
+
7
+ ### 🪄 About
8
+
9
+ **DocXCast** turns a Word document's **content controls** into a typed schema, and casts your data back into a formatted document.
10
+
11
+ **The Idea:**
12
+
13
+ A `.docx` template is a natural schema editor. In Word's Developer tab, anyone can insert content controls and describe them:
14
+
15
+ | Word control property | Role in DocXCast |
16
+ |---|---|
17
+ | **Title** | field name (e.g. `name`) |
18
+ | **Tag** | extraction requirement for the LLM (e.g. `the candidate's full name`) |
19
+ | **Repeating section** | array of records |
20
+ | **Building block gallery** | `Section` with its own extraction rule |
21
+ | **Group control** with a `name?` Title | conditional block (see below) |
22
+ | Locked control (`sdtLocked`) | required field |
23
+
24
+ The full control inventory — every construct's Word-visible pseudo-structure and the OOXML it is made of — lives in **[SYNTAX.md](SYNTAX.md)**.
25
+
26
+ **Two independent capabilities:**
27
+
28
+ 1. `derive_schema(template)` — read the controls and produce a `Schema`, serializable to JSON Schema for LLM structured extraction.
29
+ 2. `render(template, data)` — pour data back into the template: clone repeating sections, fill values, unwrap controls, keeping every run's formatting.
30
+
31
+ ### ⚡ Key Features
32
+
33
+ * **Your template IS the schema** — field names, extraction requirements and repeats all live in the docx, editable by non-programmers.
34
+ * **Typed control mapping** — text → `string`, dropdown → `enum`, date / date-time → `format`, checkbox → `boolean`, picture → derived and removed.
35
+ * **LLM-friendly contract validation** — `schema.validate(data)` returns structured, machine-readable issues (`path` / `code` / `message` / `expected` / `got`) that can be fed back into an LLM correction loop.
36
+ * **Lenient by default, strict on demand** — missing fields keep their template values; `strict=True` raises on the first error.
37
+ * **Clean output** — controls are unwrapped by default; `keep_controls=True` keeps them for round-trip editing.
38
+ * **Zero magic** — a single runtime dependency (`python-docx`).
39
+
40
+ ### 🚀 Quick Start
41
+
42
+ ```python
43
+ from docxcast import derive_schema, render
44
+
45
+ schema = derive_schema('resume-template.docx')
46
+
47
+ # hand this to your LLM as the extraction contract
48
+ json_schema = schema.to_json_schema()
49
+
50
+ # validate what the LLM extracted
51
+ report = schema.validate(data)
52
+ if not report.ok:
53
+ # feed report.issues back into the LLM for correction
54
+ ...
55
+
56
+ # cast data into the template; controls are unwrapped
57
+ result = render('resume-template.docx', data)
58
+ result.save('resume-output.docx')
59
+ ```
60
+
61
+ ### 📏 Validation
62
+
63
+ `Schema.validate()` never raises; it returns a `ValidationResult`:
64
+
65
+ ```python
66
+ result.ok # False if any error-level issue exists
67
+ result.errors # missing_required / type_mismatch / enum_invalid / format_invalid
68
+ result.warnings # unknown_key
69
+ ```
70
+
71
+ `render(..., strict=True)` raises `RenderError` on the first error-level issue.
72
+
73
+ ### 🔀 Conditional Blocks
74
+
75
+ Wrap a block in a **group control** and mark the guarded control's Title with a trailing `?` (TS-style): a falsy value removes the whole group — label text included — while a truthy value fills normally.
76
+
77
+ ```
78
+ Nationality: [nationality?] truthy → "Nationality: Chinese" falsy → the line is gone
79
+
80
+ Work Experiences both the heading and the repeat
81
+ [repeat: work_experiences?] disappear when the array is empty
82
+ ```
83
+
84
+ The `?` is a render directive only — the derived schema (and the LLM contract) see a plain `nationality` field. Marker placement, inline vs block-level drops, positional `has_prev?` / `has_next?` separators and every misuse error live in **[SYNTAX.md](SYNTAX.md)**.
85
+
86
+ ### ⚠️ Limitations
87
+
88
+ * `picture` controls are recognized in the schema but removed on render (no image insertion yet).
89
+ * Date values are written verbatim; the template's display mask is not enforced.
90
+ * Unnamed controls are skipped in the schema — every field needs a Title to be addressable.
91
+ * No whitespace control: text outside a dropped inline conditional span survives — put separators inside the span.
@@ -0,0 +1,416 @@
1
+ # DocXCast Template Syntax
2
+
3
+ Every construct docxcast recognizes, in two views: the pseudo-structure a
4
+ template author sees in Word, and the OOXML it is made of. Word writes more
5
+ into `w:sdtPr` than docxcast reads (`w:rPr`, `w:id`, `w:placeholder`,
6
+ `w:showingPlcHdr`, `w:appearance` — all ignored); what follows lists only the
7
+ nodes that carry meaning.
8
+
9
+ **Pseudo-structure notation**
10
+
11
+ ```
12
+ ▐ title ▌ one content control; the text inside is its Title
13
+ plain text literal document text (labels, separators, decoration)
14
+ ┌─ kind ─┐ a block-level control wrapping the enclosed lines
15
+ ```
16
+
17
+ **Namespaces**: `w` = WordprocessingML, `w14`/`w15` = Microsoft Word 2010/2012
18
+ extensions (checkbox, repeating section).
19
+
20
+ ---
21
+
22
+ ## Addressing: Title, Tag, Lock
23
+
24
+ Shared by every control kind — set them in Word's *Control Properties*
25
+ dialog:
26
+
27
+ | Word property | XML | Role |
28
+ |---|---|---|
29
+ | **Title** | `<w:alias w:val="name"/>` | the field name — the only address |
30
+ | **Tag** | `<w:tag w:val="the candidate's full name"/>` | free-text remark, handed to the LLM as the extraction requirement |
31
+ | **Content control cannot be deleted** | `<w:lock w:val="sdtLocked"/>` | required field (`w:lockType` works too; `sdtContentLocked` alone does not) |
32
+
33
+ A control without a Title is unaddressable (see [Unnamed controls](#unnamed-controls)).
34
+ The Tag never names a field.
35
+
36
+ ---
37
+
38
+ ## Leaf controls
39
+
40
+ ### Text control → `string` field
41
+
42
+ ```
43
+ In Word (Developer tab → Plain Text / Rich Text):
44
+
45
+ Name: ▐ name ▌
46
+ ```
47
+
48
+ ```xml
49
+ <w:p>
50
+ <w:r><w:t>Name: </w:t></w:r>
51
+ <w:sdt>
52
+ <w:sdtPr>
53
+ <w:alias w:val="name"/>
54
+ <w:tag w:val="the candidate's full name"/>
55
+ </w:sdtPr>
56
+ <w:sdtContent>
57
+ <w:r><w:t>Click or tap here to enter text.</w:t></w:r>
58
+ </w:sdtContent>
59
+ </w:sdt>
60
+ </w:p>
61
+ ```
62
+
63
+ Derives `Field(name, remark)`. Renders the value into the first content run
64
+ (formatting kept, extra runs dropped); no value → the template text stays.
65
+
66
+ ### Dropdown → `enum` field
67
+
68
+ ```
69
+ Status: ▐ employment_status ▾ ▁ Employed ▁ Unemployed ▁
70
+ ```
71
+
72
+ ```xml
73
+ <w:sdt>
74
+ <w:sdtPr>
75
+ <w:alias w:val="employment_status"/>
76
+ <w:tag w:val="the candidate's current employment status"/>
77
+ <w:dropDownList>
78
+ <w:listItem w:displayText="Employed" w:value="employed"/>
79
+ <w:listItem w:displayText="Unemployed" w:value="unemployed"/>
80
+ </w:dropDownList>
81
+ </w:sdtPr>
82
+ <w:sdtContent>
83
+ <w:r><w:t>Choose an item.</w:t></w:r>
84
+ </w:sdtContent>
85
+ </w:sdt>
86
+ ```
87
+
88
+ Derives `Field(enum=[employed, unemployed], enum_labels=[Employed, Unemployed])`;
89
+ the JSON Schema description carries the `Employed=employed` mapping so the
90
+ LLM answers with the value. Renders the **display text** (`Employed`), not
91
+ the value. List items without `w:displayText` are skipped — that is how Word
92
+ stores the placeholder entry.
93
+
94
+ ### Date picker → `format` field
95
+
96
+ ```
97
+ First working year: ▐ start_work_date [yyyy] ▌
98
+ ```
99
+
100
+ ```xml
101
+ <w:sdt>
102
+ <w:sdtPr>
103
+ <w:alias w:val="start_work_date"/>
104
+ <w:tag w:val="year the candidate started working"/>
105
+ <w:date>
106
+ <w:dateFormat w:val="yyyy"/>
107
+ <w:storeMappedDataAs w:val="date"/>
108
+ </w:date>
109
+ </w:sdtPr>
110
+ <w:sdtContent/>
111
+ </w:sdt>
112
+ ```
113
+
114
+ The picker's own display mask names the finest unit the value carries
115
+ (quoted literals in the mask are decoration, not tokens):
116
+
117
+ | `w:dateFormat` tokens | format | value shape |
118
+ |---|---|---|
119
+ | contains `d` | `date` | `2025-01-31` |
120
+ | contains `M` | `date-month` | `2025-01` |
121
+ | otherwise (e.g. `yyyy`) | `date-year` | `2025` |
122
+ | — (bare picker) | `date` | full ISO date |
123
+ | `w:storeMappedDataAs="dateTime"` | `date-time` | `2026-08-13 10:30:00` |
124
+
125
+ Values are validated against these shapes and written verbatim on render —
126
+ the template's display mask is not re-applied.
127
+
128
+ ### Checkbox → `boolean` field
129
+
130
+ ```
131
+ Married with children: ▐ is_married_with_children ☐ ▌
132
+ ```
133
+
134
+ ```xml
135
+ <w:sdt>
136
+ <w:sdtPr>
137
+ <w:alias w:val="is_married_with_children"/>
138
+ <w:tag w:val="whether the candidate is married with children"/>
139
+ <w14:checkbox>
140
+ <w14:checked w14:val="0"/>
141
+ <w14:checkedState w14:val="2612"/>
142
+ <w14:uncheckedState w14:val="2610"/>
143
+ </w14:checkbox>
144
+ </w:sdtPr>
145
+ <w:sdtContent>
146
+ <w:r><w:t>☐</w:t></w:r>
147
+ </w:sdtContent>
148
+ </w:sdt>
149
+ ```
150
+
151
+ Derives `Field(type=boolean)`. Renders `w14:checked` 1/0 and the glyph from
152
+ `checkedState`/`uncheckedState` (hex char codes — `2612` ☒ / `2610` ☐ here;
153
+ fallback glyphs when the states are absent).
154
+
155
+ ### Picture control
156
+
157
+ ```xml
158
+ <w:sdtPr>
159
+ <w:alias w:val="photo"/>
160
+ <w:picture/>
161
+ </w:sdtPr>
162
+ ```
163
+
164
+ Derives a plain `string` field (a path/URL contract for the caller) and is
165
+ **always removed** on render — no image insertion yet.
166
+
167
+ ---
168
+
169
+ ## Repeating section → array
170
+
171
+ ```
172
+ ┌─ repeating: work_experiences ────────────────────────────┐
173
+ │ ┌─ item ───────────────────────────────────────────────┐ │
174
+ │ │ ▐ start_date ▌ — ▐ end_date ▌ ▐ company ▌ ▐ title ▌ │ │
175
+ │ └──────────────────────────────────────────────────────┘ │
176
+ └──────────────────────────────────────────────────────────┘
177
+ ```
178
+
179
+ ```xml
180
+ <w:sdt>
181
+ <w:sdtPr>
182
+ <w:alias w:val="work_experiences"/>
183
+ <w:tag w:val="jobs held, one item per job"/>
184
+ <w15:repeatingSection/>
185
+ </w:sdtPr>
186
+ <w:sdtContent>
187
+ <w:sdt>
188
+ <w:sdtPr>
189
+ <w15:repeatingSectionItem/>
190
+ </w:sdtPr>
191
+ <w:sdtContent>
192
+ <!-- one item's paragraphs: the controls above -->
193
+ </w:sdtContent>
194
+ </w:sdt>
195
+ </w:sdtContent>
196
+ </w:sdt>
197
+ ```
198
+
199
+ Derives `Repeat(fields=…)` → `{"type": "array", "items": {…}}`. The item
200
+ `w15:repeatingSectionItem` is the clone prototype: each array entry renders a
201
+ deep copy with its own data; any template content in the repeat outside the
202
+ item is discarded. `null`/`[]` → zero items, the whole control disappears.
203
+ A repeat without a Title is unaddressable — skipped and omitted.
204
+
205
+ ---
206
+
207
+ ## Building block gallery → nested section
208
+
209
+ ```
210
+ ┌─ section: basic_info ────────────────────────────────────┐
211
+ │ ▐ name ▌ ▐ employment_status ▾ ▌ ▐ start_work_date ▌ │
212
+ └──────────────────────────────────────────────────────────┘
213
+ ```
214
+
215
+ ```xml
216
+ <w:sdt>
217
+ <w:sdtPr>
218
+ <w:alias w:val="basic_info"/>
219
+ <w:tag w:val="the candidate's basic information"/>
220
+ <w:docPartList/>
221
+ </w:sdtPr>
222
+ <w:sdtContent>
223
+ <!-- paragraphs and controls, parsed as children -->
224
+ </w:sdtContent>
225
+ </w:sdt>
226
+ ```
227
+
228
+ Derives `Section(children=…)` → a nested object: data arrives as
229
+ `{"basic_info": {"name": …, …}}`. `null` → the whole section is removed.
230
+ Insert via Developer tab → **Building Block Gallery** (Word writes
231
+ `w:docPartList`); the chosen gallery itself is irrelevant.
232
+
233
+ ---
234
+
235
+ ## Group control → union field
236
+
237
+ One field with two renderings: the value, **or** one of the group's literal
238
+ texts. The workhorse example — an end date that may also be "Present":
239
+
240
+ ```
241
+ ╭──────────────────────╮
242
+ 2024-02 — │ ▐ end_date ▌ Present │ ← one inline group
243
+ ╰──────────────────────╯
244
+ ```
245
+
246
+ ```xml
247
+ <w:r><w:t>2024-02 — </w:t></w:r>
248
+ <w:sdt>
249
+ <w:sdtPr>
250
+ <w:group/>
251
+ </w:sdtPr>
252
+ <w:sdtContent>
253
+ <w:sdt>
254
+ <w:sdtPr>
255
+ <w:alias w:val="end_date"/>
256
+ <w:tag w:val="when the job ended"/>
257
+ <w:date><w:dateFormat w:val="yyyy-MM"/><w:storeMappedDataAs w:val="date"/></w:date>
258
+ </w:sdtPr>
259
+ <w:sdtContent/>
260
+ </w:sdt>
261
+ <w:r><w:t>Present</w:t></w:r>
262
+ </w:sdtContent>
263
+ </w:sdt>
264
+ ```
265
+
266
+ Rules (enforced by `DeriveError`):
267
+
268
+ - exactly **one** control member; the rest is literal text
269
+ - addressed **once** — the group's own Title, or the member's; never both
270
+ - literal text outside the control becomes the field's `alternatives`
271
+ (`end_date` derives `anyOf[date-month, enum["Present"]]` and either branch
272
+ validates)
273
+ - the member's kind gives type/format; `sdtLocked` on group or member marks
274
+ required
275
+ - a union group is **inline** — its content lives inside one paragraph
276
+
277
+ Render picks one branch: a value that equals an alternative keeps the
278
+ literal and drops the control; any other value fills the control and deletes
279
+ the literal runs. `keep_controls=True` keeps both sides editable instead.
280
+
281
+ ---
282
+
283
+ ## Group control → conditional block
284
+
285
+ The same `w:group` with a **trailing `?`** on the address (TS-style
286
+ `nationality?`) turns it into a display gate: falsy value removes the whole
287
+ group — label and all — truthy fills transparently with the literal text
288
+ kept as decoration.
289
+
290
+ ### A labeled line
291
+
292
+ ```
293
+ ┌─ group (anonymous) ─────────────┐
294
+ │ Nationality: ▐ nationality? ▌ │
295
+ └─────────────────────────────────┘
296
+ ```
297
+
298
+ ```xml
299
+ <w:sdt>
300
+ <w:sdtPr>
301
+ <w:group/>
302
+ </w:sdtPr>
303
+ <w:sdtContent>
304
+ <w:p>
305
+ <w:r><w:t>Nationality: </w:t></w:r>
306
+ <w:sdt>
307
+ <w:sdtPr>
308
+ <w:alias w:val="nationality?"/>
309
+ <w:tag w:val="the candidate's nationality"/>
310
+ </w:sdtPr>
311
+ <w:sdtContent/>
312
+ </w:sdt>
313
+ </w:p>
314
+ </w:sdtContent>
315
+ </w:sdt>
316
+ ```
317
+
318
+ ### A heading plus its repeat
319
+
320
+ ```
321
+ ┌─ group (anonymous) ──────────────────────────────────────┐
322
+ │ Work Experiences │
323
+ │ ┌─ repeating: work_experiences? ───────────────────────┐ │
324
+ │ │ ┌─ item ───────────────────────────────────────────┐ │ │
325
+ │ │ │ ▐ company ▌ ▐ title ▌ │ │ │
326
+ │ │ └──────────────────────────────────────────────────┘ │ │
327
+ │ └──────────────────────────────────────────────────────┘ │
328
+ └──────────────────────────────────────────────────────────┘
329
+ ```
330
+
331
+ The same XML shapes as above — the repeat simply carries
332
+ `<w:alias w:val="work_experiences?"/>` and lives inside the group's
333
+ `w:sdtContent` next to the heading paragraph.
334
+
335
+ Semantics:
336
+
337
+ - **falsy** = `None`, `""`, `False`, `0`, `[]`, `{}` (Python truthiness) —
338
+ the whole group disappears from the output
339
+ - **truthy** — the guarded member fills by its own kind (control, repeat, or
340
+ section); literal text stays as decoration, never becomes `alternatives`
341
+ - the marker may sit on the group's own Title instead, with no control
342
+ inside — that guards **static text** on a field
343
+ (`┌─ group: nationality? ─ "Foreign candidate" ┐`)
344
+ - **inline** group → only the wrapped span drops, the paragraph survives;
345
+ **block-level** group → whole paragraphs drop. Wrap the paragraph mark to
346
+ make a line vanish completely.
347
+ - the `?` is a render directive only — the derived schema (and the LLM)
348
+ see the plain `nationality` / `work_experiences` field
349
+
350
+ ### Positional guards — separators between repeat items
351
+
352
+ Inside a repeating item, a group addressed `has_next?` (or `has_prev?`)
353
+ shows only **between** items — separators and divider lines that appear once
354
+ per gap, never before the first or after the last item:
355
+
356
+ ```
357
+ ┌─ repeating: skills ──────────────────────────────────────┐
358
+ │ ┌─ item ───────────────────────────────────────────────┐ │
359
+ │ │ ▐ skill ▌ │ │
360
+ │ │ ┌─ group: has_next? ───────────────────────────────┐ │ │
361
+ │ │ │ , │ │ │
362
+ │ │ └──────────────────────────────────────────────────┘ │ │
363
+ │ └──────────────────────────────────────────────────────┘ │
364
+ └──────────────────────────────────────────────────────────┘
365
+
366
+ 1 item → Python
367
+ 3 items → Python, Go, Rust (separator only between)
368
+ ```
369
+
370
+ ```xml
371
+ <!-- inside the repeatingSectionItem's sdtContent -->
372
+ <w:sdt>
373
+ <w:sdtPr>
374
+ <w:group/>
375
+ <w:alias w:val="has_next?"/>
376
+ </w:sdtPr>
377
+ <w:sdtContent>
378
+ <w:p><w:r><w:t>,</w:t></w:r></w:p>
379
+ </w:sdtContent>
380
+ </w:sdt>
381
+ ```
382
+
383
+ `has_prev`/`has_next` are reserved names — a data field may not take them.
384
+ The guards exist only in repeat-item scope; they are not schema nodes.
385
+
386
+ ---
387
+
388
+ ## Unnamed controls
389
+
390
+ | Shape | Schema | Render |
391
+ |---|---|---|
392
+ | unnamed text control | skipped | unwrapped, template text kept |
393
+ | unnamed section / repeat | skipped | omitted |
394
+ | unnamed group with no addressed member | skipped | unwrapped in place |
395
+
396
+ ## What fails `derive_schema`
397
+
398
+ | Template shape | Error |
399
+ |---|---|
400
+ | `name?` on a control/repeat/section not inside a group | a trailing `?` marks a conditional block — wrap it |
401
+ | block-level group whose address has no `?` | block-level groups are conditional — marker missing |
402
+ | any field/group named `has_prev` / `has_next` | reserved for positional guards |
403
+ | `has_prev?` / `has_next?` outside a repeating item (incl. under a section — section fill uses its own dict) | positional guards only exist inside repeat items |
404
+ | two addressed members in one group | a group is one field, address it once |
405
+ | two control members in one group | one control, the rest literal text |
406
+
407
+ ## Render invariants
408
+
409
+ - Controls are **unwrapped** by default (`keep_controls=True` keeps the
410
+ structure for round-trip editing; conditional drops keep the group too).
411
+ - Any drop path tops an emptied table cell back up with one empty `w:p` —
412
+ a `w:tc` must retain a paragraph.
413
+ - Missing leaf values keep their template text (lenient); `strict=True`
414
+ raises `RenderError` on the first error-level issue instead.
415
+ - No whitespace control: text outside a dropped inline span (an orphaned
416
+ separator) survives — put the separator inside the span.