docxcast 0.1.0__py3-none-any.whl
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.
- docxcast/__init__.py +41 -0
- docxcast/_ooxml.py +191 -0
- docxcast/derive.py +203 -0
- docxcast/render.py +306 -0
- docxcast/schema.py +272 -0
- docxcast-0.1.0.dist-info/METADATA +118 -0
- docxcast-0.1.0.dist-info/RECORD +9 -0
- docxcast-0.1.0.dist-info/WHEEL +4 -0
- docxcast-0.1.0.dist-info/licenses/LICENSE +21 -0
docxcast/__init__.py
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"""DocXCast: Turn Word content controls into a typed schema, then cast data back into documents."""
|
|
2
|
+
|
|
3
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
4
|
+
|
|
5
|
+
from docxcast.derive import DeriveError, derive_schema
|
|
6
|
+
from docxcast.render import RenderError, RenderResult, render
|
|
7
|
+
from docxcast.schema import (
|
|
8
|
+
FORMAT_DATE,
|
|
9
|
+
FORMAT_DATE_MONTH,
|
|
10
|
+
FORMAT_DATE_TIME,
|
|
11
|
+
FORMAT_DATE_YEAR,
|
|
12
|
+
Field,
|
|
13
|
+
Issue,
|
|
14
|
+
Repeat,
|
|
15
|
+
Schema,
|
|
16
|
+
Section,
|
|
17
|
+
ValidationResult,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
try:
|
|
21
|
+
__version__ = version('docxcast')
|
|
22
|
+
except PackageNotFoundError:
|
|
23
|
+
__version__ = '0.1.0'
|
|
24
|
+
|
|
25
|
+
__all__ = [
|
|
26
|
+
'derive_schema',
|
|
27
|
+
'DeriveError',
|
|
28
|
+
'render',
|
|
29
|
+
'RenderError',
|
|
30
|
+
'RenderResult',
|
|
31
|
+
'Schema',
|
|
32
|
+
'Section',
|
|
33
|
+
'Repeat',
|
|
34
|
+
'Field',
|
|
35
|
+
'Issue',
|
|
36
|
+
'ValidationResult',
|
|
37
|
+
'FORMAT_DATE',
|
|
38
|
+
'FORMAT_DATE_MONTH',
|
|
39
|
+
'FORMAT_DATE_YEAR',
|
|
40
|
+
'FORMAT_DATE_TIME',
|
|
41
|
+
]
|
docxcast/_ooxml.py
ADDED
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
"""OOXML namespace constants and shared sdt helpers used by both derive and render."""
|
|
2
|
+
|
|
3
|
+
from functools import lru_cache
|
|
4
|
+
from typing import Optional
|
|
5
|
+
|
|
6
|
+
NS = {
|
|
7
|
+
'w': 'http://schemas.openxmlformats.org/wordprocessingml/2006/main',
|
|
8
|
+
'w14': 'http://schemas.microsoft.com/office/word/2010/wordml',
|
|
9
|
+
'w15': 'http://schemas.microsoft.com/office/word/2012/wordml',
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
XML_SPACE = '{http://www.w3.org/XML/1998/namespace}space'
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@lru_cache(maxsize=None)
|
|
16
|
+
def qn(tag: str) -> str:
|
|
17
|
+
prefix, local = tag.split(':')
|
|
18
|
+
return f'{{{NS[prefix]}}}{local}'
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
# tags the sdt walker descends into (paragraphs and table cells)
|
|
22
|
+
CONTAINER_TAGS = frozenset(qn(t) for t in ('w:p', 'w:tbl', 'w:tr', 'w:tc'))
|
|
23
|
+
|
|
24
|
+
KIND_REPEAT = 'repeat'
|
|
25
|
+
KIND_ITEM = 'item'
|
|
26
|
+
KIND_SECTION = 'section'
|
|
27
|
+
KIND_GROUP = 'group'
|
|
28
|
+
KIND_PICTURE = 'picture'
|
|
29
|
+
KIND_DROPDOWN = 'dropdown'
|
|
30
|
+
KIND_DATE = 'date'
|
|
31
|
+
KIND_CHECKBOX = 'checkbox'
|
|
32
|
+
KIND_TEXT = 'text'
|
|
33
|
+
|
|
34
|
+
_TAG_KINDS = {
|
|
35
|
+
qn('w15:repeatingSection'): KIND_REPEAT,
|
|
36
|
+
qn('w15:repeatingSectionItem'): KIND_ITEM,
|
|
37
|
+
qn('w:docPartList'): KIND_SECTION,
|
|
38
|
+
qn('w:group'): KIND_GROUP,
|
|
39
|
+
qn('w:picture'): KIND_PICTURE,
|
|
40
|
+
qn('w:dropDownList'): KIND_DROPDOWN,
|
|
41
|
+
qn('w:date'): KIND_DATE,
|
|
42
|
+
qn('w14:checkbox'): KIND_CHECKBOX,
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def kind_of(pr) -> str:
|
|
47
|
+
for child in pr:
|
|
48
|
+
kind = _TAG_KINDS.get(child.tag)
|
|
49
|
+
if kind is not None:
|
|
50
|
+
return kind
|
|
51
|
+
return KIND_TEXT
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def repeating_item(content):
|
|
55
|
+
for child in content:
|
|
56
|
+
if child.tag != qn('w:sdt'):
|
|
57
|
+
continue
|
|
58
|
+
pr = child.find(qn('w:sdtPr'))
|
|
59
|
+
if pr is not None and pr.find(qn('w15:repeatingSectionItem')) is not None:
|
|
60
|
+
return child
|
|
61
|
+
return None
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def dropdown_items(pr) -> list:
|
|
65
|
+
"""(display, value) pairs of a dropdown, skipping placeholder items without display text."""
|
|
66
|
+
dropdown = pr.find(qn('w:dropDownList'))
|
|
67
|
+
if dropdown is None:
|
|
68
|
+
return []
|
|
69
|
+
items = []
|
|
70
|
+
for item in dropdown.findall(qn('w:listItem')):
|
|
71
|
+
display = item.get(qn('w:displayText'))
|
|
72
|
+
if display is not None:
|
|
73
|
+
items.append((display, item.get(qn('w:value'))))
|
|
74
|
+
return items
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def text_attr(pr, tag: str) -> Optional[str]:
|
|
78
|
+
el = pr.find(qn(tag))
|
|
79
|
+
return el.get(qn('w:val')) if el is not None else None
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def date_mask_runs(mask: str) -> list:
|
|
83
|
+
"""Tokenize a Word dateFormat mask into (letter, text) runs: quoted
|
|
84
|
+
spans and non-ASCII-letter text are literals (letter None), an
|
|
85
|
+
unquoted ASCII letter run is a token carrying its letter. Shared by
|
|
86
|
+
derive (granularity) and render (formatting) so both read one
|
|
87
|
+
grammar and can't drift apart."""
|
|
88
|
+
runs = []
|
|
89
|
+
i = 0
|
|
90
|
+
while i < len(mask):
|
|
91
|
+
c = mask[i]
|
|
92
|
+
if c == "'": # a quoted literal
|
|
93
|
+
end = mask.find("'", i + 1)
|
|
94
|
+
if end == -1:
|
|
95
|
+
runs.append((None, mask[i + 1:]))
|
|
96
|
+
break
|
|
97
|
+
runs.append((None, mask[i + 1:end]))
|
|
98
|
+
i = end + 1
|
|
99
|
+
continue
|
|
100
|
+
j = i
|
|
101
|
+
while j < len(mask) and mask[j] == c:
|
|
102
|
+
j += 1
|
|
103
|
+
runs.append((c if c.isascii() and c.isalpha() else None, mask[i:j]))
|
|
104
|
+
i = j
|
|
105
|
+
return runs
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
# guard names injected into every repeating-section item's scope; a real
|
|
109
|
+
# field may not take them
|
|
110
|
+
RESERVED = frozenset(('has_prev', 'has_next'))
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def split_conditional(alias: Optional[str]) -> tuple[Optional[str], bool]:
|
|
114
|
+
"""A trailing '?' on an address (TS-style ``name?``) marks its block
|
|
115
|
+
conditionally displayed: a falsy value removes the block."""
|
|
116
|
+
if alias and alias.endswith('?'):
|
|
117
|
+
return alias[:-1], True
|
|
118
|
+
return alias, False
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def scope_guards(i: int, n: int) -> dict:
|
|
122
|
+
"""Positional guard values for item ``i`` of ``n`` in a repeat."""
|
|
123
|
+
return {'has_prev': i > 0, 'has_next': i < n - 1}
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def is_block(content) -> bool:
|
|
127
|
+
"""Group content is block-level when the outside-sdt walk meets a
|
|
128
|
+
paragraph — the same walk group_literals chunks, so block and
|
|
129
|
+
inline cannot be classified two ways."""
|
|
130
|
+
return any(el.tag == qn('w:p') for el in outside_sdts(content))
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def outside_sdts(container):
|
|
134
|
+
"""Iterate elements of ``container`` without descending into nested
|
|
135
|
+
content controls — their content belongs to the control, not the
|
|
136
|
+
group around it."""
|
|
137
|
+
for child in container:
|
|
138
|
+
if child.tag == qn('w:sdt'):
|
|
139
|
+
continue
|
|
140
|
+
yield child
|
|
141
|
+
yield from outside_sdts(child)
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def group_members(content) -> list:
|
|
145
|
+
"""(sdt, pr, alias, tag) of a group's control members, in document
|
|
146
|
+
order."""
|
|
147
|
+
members = []
|
|
148
|
+
for child in content:
|
|
149
|
+
if child.tag == qn('w:sdt'):
|
|
150
|
+
if (pr := child.find(qn('w:sdtPr'))) is not None:
|
|
151
|
+
members.append((child, pr, text_attr(pr, 'w:alias'), text_attr(pr, 'w:tag')))
|
|
152
|
+
elif child.tag in CONTAINER_TAGS:
|
|
153
|
+
members.extend(group_members(child))
|
|
154
|
+
return members
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def group_address(pr, members) -> tuple:
|
|
158
|
+
"""A group's (alias, tag, member index, conditional): the group's own
|
|
159
|
+
address if it carries one, else the first member that does (-1 if
|
|
160
|
+
none). The alias comes back unmarked — split_conditional's trailing
|
|
161
|
+
'?' marks the group a conditional block. Shared by derive and
|
|
162
|
+
render so the two can't drift apart."""
|
|
163
|
+
if text_attr(pr, 'w:alias') or text_attr(pr, 'w:tag'):
|
|
164
|
+
name, conditional = split_conditional(text_attr(pr, 'w:alias'))
|
|
165
|
+
return name, text_attr(pr, 'w:tag'), -1, conditional
|
|
166
|
+
for i, (_, mpr, alias, tag) in enumerate(members):
|
|
167
|
+
if alias or tag:
|
|
168
|
+
name, conditional = split_conditional(alias)
|
|
169
|
+
return name, tag, i, conditional
|
|
170
|
+
return None, None, -1, False
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def outside_text(el) -> str:
|
|
174
|
+
return ''.join(t.text or '' for t in outside_sdts(el) if t.tag == qn('w:t')).strip()
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def group_chunks(content) -> list:
|
|
178
|
+
"""A group's literal text as (holder, text) pairs: one per
|
|
179
|
+
paragraph outside nested controls, or the joined run text of an
|
|
180
|
+
inline group. Derive advertises the texts, render deletes from the
|
|
181
|
+
holders — one chunking rule for both."""
|
|
182
|
+
els = list(outside_sdts(content))
|
|
183
|
+
if paras := [p for p in els if p.tag == qn('w:p')]:
|
|
184
|
+
return [(p, outside_text(p)) for p in paras]
|
|
185
|
+
return [(content, outside_text(content))]
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def group_literals(content) -> list:
|
|
189
|
+
"""A group's literal value alternatives: the text it shows when no
|
|
190
|
+
control applies."""
|
|
191
|
+
return [text for _, text in group_chunks(content) if text]
|
docxcast/derive.py
ADDED
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
"""Derive a Schema from the content controls of a docx template."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from docx import Document
|
|
6
|
+
|
|
7
|
+
from docxcast._ooxml import (
|
|
8
|
+
CONTAINER_TAGS,
|
|
9
|
+
KIND_CHECKBOX,
|
|
10
|
+
KIND_DATE,
|
|
11
|
+
KIND_DROPDOWN,
|
|
12
|
+
KIND_GROUP,
|
|
13
|
+
KIND_ITEM,
|
|
14
|
+
KIND_REPEAT,
|
|
15
|
+
KIND_SECTION,
|
|
16
|
+
RESERVED,
|
|
17
|
+
date_mask_runs,
|
|
18
|
+
dropdown_items,
|
|
19
|
+
group_address,
|
|
20
|
+
group_literals,
|
|
21
|
+
group_members,
|
|
22
|
+
is_block,
|
|
23
|
+
kind_of,
|
|
24
|
+
qn,
|
|
25
|
+
repeating_item,
|
|
26
|
+
split_conditional,
|
|
27
|
+
text_attr,
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
from docxcast.schema import (
|
|
31
|
+
FORMAT_DATE,
|
|
32
|
+
FORMAT_DATE_MONTH,
|
|
33
|
+
FORMAT_DATE_TIME,
|
|
34
|
+
FORMAT_DATE_YEAR,
|
|
35
|
+
TYPE_BOOLEAN,
|
|
36
|
+
Field,
|
|
37
|
+
Repeat,
|
|
38
|
+
Schema,
|
|
39
|
+
Section,
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def derive_schema(template) -> Schema:
|
|
44
|
+
"""Read the content controls of a docx template and return its Schema.
|
|
45
|
+
|
|
46
|
+
template: a path-like or file-like object holding a .docx file.
|
|
47
|
+
"""
|
|
48
|
+
return _derive(Document(template))
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class DeriveError(ValueError):
|
|
52
|
+
"""Raised when a template addresses one field twice — e.g. two named
|
|
53
|
+
members inside a single group. A ValueError so host apps map it to
|
|
54
|
+
their bad-input response, not a crash."""
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _derive(doc: Document) -> Schema:
|
|
58
|
+
children = []
|
|
59
|
+
_parse_children(doc.element.body, children)
|
|
60
|
+
return Schema(children=children)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _parse_children(container, out: list, repeat: bool = False) -> None:
|
|
64
|
+
for child in container:
|
|
65
|
+
if child.tag == qn('w:sdt'):
|
|
66
|
+
_parse_sdt(child, out, repeat)
|
|
67
|
+
elif child.tag in CONTAINER_TAGS:
|
|
68
|
+
_parse_children(child, out, repeat)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _parse_sdt(sdt, out: list, repeat: bool = False) -> None:
|
|
72
|
+
pr = sdt.find(qn('w:sdtPr'))
|
|
73
|
+
if pr is None:
|
|
74
|
+
return
|
|
75
|
+
name = text_attr(pr, 'w:alias')
|
|
76
|
+
remark = text_attr(pr, 'w:tag')
|
|
77
|
+
kind = kind_of(pr)
|
|
78
|
+
if kind in (KIND_REPEAT, KIND_SECTION) and name is None:
|
|
79
|
+
return # unaddressable, skip entirely
|
|
80
|
+
if kind != KIND_GROUP:
|
|
81
|
+
if split_conditional(name)[1]:
|
|
82
|
+
raise DeriveError(f'{name!r}: a trailing "?" marks a conditional block — '
|
|
83
|
+
'wrap the block in a group control')
|
|
84
|
+
if name in RESERVED:
|
|
85
|
+
raise DeriveError(f'{name!r} is reserved for the positional guards '
|
|
86
|
+
f'({"/".join(sorted(RESERVED))}) inside repeats')
|
|
87
|
+
content = sdt.find(qn('w:sdtContent'))
|
|
88
|
+
if kind == KIND_REPEAT:
|
|
89
|
+
out.append(_parse_repeat(content, name, remark, _is_locked(pr)))
|
|
90
|
+
elif kind == KIND_ITEM:
|
|
91
|
+
_parse_children(content, out, repeat)
|
|
92
|
+
elif kind == KIND_SECTION:
|
|
93
|
+
out.append(_parse_section(content, name, remark, _is_locked(pr)))
|
|
94
|
+
elif kind == KIND_GROUP:
|
|
95
|
+
if (group := _parse_group(pr, content, repeat)) is not None:
|
|
96
|
+
out.append(group)
|
|
97
|
+
elif name is not None:
|
|
98
|
+
out.append(_parse_field(pr, name, remark, _is_locked(pr), kind))
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _parse_repeat(content, name, remark, required) -> Repeat:
|
|
102
|
+
fields = []
|
|
103
|
+
if (item := repeating_item(content)) is not None:
|
|
104
|
+
_parse_children(item.find(qn('w:sdtContent')), fields, repeat=True)
|
|
105
|
+
return Repeat(name=name, remark=remark, fields=fields, required=required)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _parse_section(content, name, remark, required) -> Section:
|
|
109
|
+
# a section fills from its own dict at render, so a repeat item's
|
|
110
|
+
# positional-guard scope ends here
|
|
111
|
+
children = []
|
|
112
|
+
_parse_children(content, children)
|
|
113
|
+
return Section(name=name, remark=remark, children=children, required=required)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _parse_field(pr, name: str, remark, required: bool, kind: str) -> Field:
|
|
117
|
+
kwargs = {}
|
|
118
|
+
if kind == KIND_DROPDOWN:
|
|
119
|
+
pairs = dropdown_items(pr)
|
|
120
|
+
kwargs.update(enum=[v for _, v in pairs], enum_labels=[d for d, _ in pairs])
|
|
121
|
+
elif kind == KIND_DATE:
|
|
122
|
+
date = pr.find(qn('w:date'))
|
|
123
|
+
kwargs['format'] = (
|
|
124
|
+
FORMAT_DATE_TIME if text_attr(date, 'w:storeMappedDataAs') == 'dateTime'
|
|
125
|
+
else _granularity(text_attr(date, 'w:dateFormat'))
|
|
126
|
+
)
|
|
127
|
+
elif kind == KIND_CHECKBOX:
|
|
128
|
+
kwargs['type'] = TYPE_BOOLEAN
|
|
129
|
+
return Field(name=name, remark=remark, required=required, **kwargs)
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _parse_group(pr, content, repeat: bool = False):
|
|
133
|
+
"""One field assembled from a group's members: the group's own
|
|
134
|
+
address if it carries one, else the first member that does; every
|
|
135
|
+
other member must stay anonymous and no second control is allowed.
|
|
136
|
+
The control member gives type/format; the group's literal text
|
|
137
|
+
becomes the value alternatives. A trailing '?' on the address turns
|
|
138
|
+
the group into a conditional block instead: the literal text stays
|
|
139
|
+
decoration (never alternatives), render drops the whole block when
|
|
140
|
+
the value is falsy, and the guarded member may be a repeat or
|
|
141
|
+
section, not just a control."""
|
|
142
|
+
members = group_members(content)
|
|
143
|
+
name, remark, source, conditional = group_address(pr, members)
|
|
144
|
+
for i, (_, _, alias, tag) in enumerate(members):
|
|
145
|
+
if (alias or tag) and i != source:
|
|
146
|
+
raise DeriveError(
|
|
147
|
+
f'group {name!r}: member {alias or tag!r} also carries an address '
|
|
148
|
+
f'(w:alias/w:tag); a group is one field, address it once')
|
|
149
|
+
if len(members) > 1:
|
|
150
|
+
raise DeriveError(
|
|
151
|
+
f'group {name!r}: {len(members)} control members; a group is one '
|
|
152
|
+
f'field — one control, the rest literal text')
|
|
153
|
+
if name is None:
|
|
154
|
+
return None # unaddressable, skipped like unaddressable repeats
|
|
155
|
+
if name in RESERVED:
|
|
156
|
+
if not conditional:
|
|
157
|
+
raise DeriveError(f'{name!r} is reserved for the positional guards '
|
|
158
|
+
f'({"/".join(sorted(RESERVED))}) inside repeats')
|
|
159
|
+
if not repeat:
|
|
160
|
+
raise DeriveError(f'{name}? is a positional guard; it only exists '
|
|
161
|
+
'inside a repeating section item')
|
|
162
|
+
return None # a render directive, never a schema node
|
|
163
|
+
if not conditional and is_block(content):
|
|
164
|
+
raise DeriveError(
|
|
165
|
+
f'group {name!r}: a block-level group is a conditional block — mark '
|
|
166
|
+
f'the guarded control\'s Title with a trailing \'?\'')
|
|
167
|
+
required = _is_locked(pr) or (bool(members) and _is_locked(members[0][1]))
|
|
168
|
+
if conditional:
|
|
169
|
+
if not members: # the group itself is the guard of static content
|
|
170
|
+
return Field(name=name, remark=remark, required=required)
|
|
171
|
+
msdt, mpr, _, _ = members[0]
|
|
172
|
+
mcontent = msdt.find(qn('w:sdtContent'))
|
|
173
|
+
if (mkind := kind_of(mpr)) == KIND_REPEAT:
|
|
174
|
+
return _parse_repeat(mcontent, name, remark, required)
|
|
175
|
+
if mkind == KIND_SECTION:
|
|
176
|
+
return _parse_section(mcontent, name, remark, required)
|
|
177
|
+
return _parse_field(mpr, name, remark, required, mkind)
|
|
178
|
+
if members:
|
|
179
|
+
field = _parse_field(members[0][1], name, remark, required, kind_of(members[0][1]))
|
|
180
|
+
else:
|
|
181
|
+
field = Field(name=name, remark=remark, required=required)
|
|
182
|
+
if literals := group_literals(content):
|
|
183
|
+
field.alternatives = literals
|
|
184
|
+
return field
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def _granularity(date_format) -> str:
|
|
188
|
+
"""The picker's own display format names the finest unit the value
|
|
189
|
+
carries; quoted literals in the mask are decoration, not tokens."""
|
|
190
|
+
if not date_format:
|
|
191
|
+
return FORMAT_DATE # a bare picker collects a full date
|
|
192
|
+
letters = {letter for letter, _ in date_mask_runs(date_format) if letter}
|
|
193
|
+
if 'd' in letters:
|
|
194
|
+
return FORMAT_DATE
|
|
195
|
+
return FORMAT_DATE_MONTH if 'M' in letters else FORMAT_DATE_YEAR
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def _is_locked(pr) -> bool:
|
|
199
|
+
lock = pr.find(qn('w:lock'))
|
|
200
|
+
if lock is None:
|
|
201
|
+
return False
|
|
202
|
+
vals = {lock.get(qn('w:val')), lock.get(qn('w:lockType'))}
|
|
203
|
+
return 'sdtLocked' in vals
|
docxcast/render.py
ADDED
|
@@ -0,0 +1,306 @@
|
|
|
1
|
+
"""Render data back into a docx template: fill controls, clone repeats, unwrap."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from calendar import month_abbr, month_name
|
|
7
|
+
from copy import deepcopy
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
from io import BytesIO
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Optional
|
|
12
|
+
|
|
13
|
+
from docx import Document
|
|
14
|
+
from lxml import etree
|
|
15
|
+
|
|
16
|
+
from docxcast._ooxml import (
|
|
17
|
+
CONTAINER_TAGS,
|
|
18
|
+
KIND_CHECKBOX,
|
|
19
|
+
KIND_DATE,
|
|
20
|
+
KIND_DROPDOWN,
|
|
21
|
+
KIND_GROUP,
|
|
22
|
+
KIND_ITEM,
|
|
23
|
+
KIND_PICTURE,
|
|
24
|
+
KIND_REPEAT,
|
|
25
|
+
KIND_SECTION,
|
|
26
|
+
XML_SPACE,
|
|
27
|
+
dropdown_items,
|
|
28
|
+
date_mask_runs,
|
|
29
|
+
group_address,
|
|
30
|
+
group_chunks,
|
|
31
|
+
group_literals,
|
|
32
|
+
group_members,
|
|
33
|
+
kind_of,
|
|
34
|
+
qn,
|
|
35
|
+
repeating_item,
|
|
36
|
+
scope_guards,
|
|
37
|
+
split_conditional,
|
|
38
|
+
text_attr,
|
|
39
|
+
)
|
|
40
|
+
from docxcast.derive import _derive
|
|
41
|
+
from docxcast.schema import Issue, ValidationResult
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class RenderError(Exception):
|
|
45
|
+
"""Raised by render(strict=True) on the first error-level validation issue."""
|
|
46
|
+
|
|
47
|
+
def __init__(self, issue: Issue):
|
|
48
|
+
self.issue = issue
|
|
49
|
+
super().__init__(f'{issue.path}: {issue.message}')
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@dataclass
|
|
53
|
+
class RenderResult:
|
|
54
|
+
document: bytes
|
|
55
|
+
report: ValidationResult
|
|
56
|
+
|
|
57
|
+
def save(self, path) -> None:
|
|
58
|
+
Path(path).write_bytes(self.document)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def render(template, data, *, strict: bool = False, keep_controls: bool = False) -> RenderResult:
|
|
62
|
+
"""Fill data into the template's content controls and return the rendered docx.
|
|
63
|
+
|
|
64
|
+
Controls are unwrapped by default so the output is a clean document; pass
|
|
65
|
+
keep_controls=True to keep the control structure for round-trip editing.
|
|
66
|
+
The template is re-parsed on every call and data is validated against it.
|
|
67
|
+
"""
|
|
68
|
+
doc = Document(template)
|
|
69
|
+
report = _derive(doc).validate(data)
|
|
70
|
+
if strict and report.errors:
|
|
71
|
+
raise RenderError(report.errors[0])
|
|
72
|
+
_fill_children(doc.element.body, _as_dict(data), keep_controls)
|
|
73
|
+
buf = BytesIO()
|
|
74
|
+
doc.save(buf)
|
|
75
|
+
return RenderResult(document=buf.getvalue(), report=report)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _fill_children(container, data: dict, keep_controls: bool) -> None:
|
|
79
|
+
for child in list(container):
|
|
80
|
+
if child.tag == qn('w:sdt'):
|
|
81
|
+
_handle_sdt(child, data, keep_controls)
|
|
82
|
+
elif child.tag in CONTAINER_TAGS:
|
|
83
|
+
_fill_children(child, data, keep_controls)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _handle_sdt(sdt, data: dict, keep_controls: bool) -> None:
|
|
87
|
+
pr = sdt.find(qn('w:sdtPr'))
|
|
88
|
+
content = sdt.find(qn('w:sdtContent'))
|
|
89
|
+
if pr is None or content is None:
|
|
90
|
+
return
|
|
91
|
+
# derive rejects '?' outside conditional groups, so by fill time a
|
|
92
|
+
# marked address is always a conditional group's member
|
|
93
|
+
alias, _ = split_conditional(text_attr(pr, 'w:alias'))
|
|
94
|
+
kind = kind_of(pr)
|
|
95
|
+
value = data.get(alias) if alias else None
|
|
96
|
+
|
|
97
|
+
if kind == KIND_REPEAT:
|
|
98
|
+
if value is None:
|
|
99
|
+
_unwrap(sdt, [], keep_controls)
|
|
100
|
+
return
|
|
101
|
+
item = repeating_item(content)
|
|
102
|
+
if item is None:
|
|
103
|
+
return
|
|
104
|
+
blocks = []
|
|
105
|
+
for i, entry in enumerate(value):
|
|
106
|
+
clone = deepcopy(item)
|
|
107
|
+
clone_content = clone.find(qn('w:sdtContent'))
|
|
108
|
+
_fill_children(clone_content, _scoped(entry, i, len(value)), keep_controls)
|
|
109
|
+
if keep_controls:
|
|
110
|
+
blocks.append(clone)
|
|
111
|
+
else:
|
|
112
|
+
blocks.extend(clone_content)
|
|
113
|
+
if keep_controls:
|
|
114
|
+
content[:] = blocks
|
|
115
|
+
else:
|
|
116
|
+
_splice(sdt, blocks)
|
|
117
|
+
return
|
|
118
|
+
|
|
119
|
+
if kind in (KIND_SECTION, KIND_ITEM):
|
|
120
|
+
if kind == KIND_SECTION and value is None:
|
|
121
|
+
_unwrap(sdt, [], keep_controls)
|
|
122
|
+
return
|
|
123
|
+
_fill_children(content, _as_dict(value) if kind == KIND_SECTION else data, keep_controls)
|
|
124
|
+
_unwrap(sdt, content, keep_controls)
|
|
125
|
+
return
|
|
126
|
+
|
|
127
|
+
if kind == KIND_PICTURE:
|
|
128
|
+
_unwrap(sdt, [], False) # picture controls are always removed
|
|
129
|
+
return
|
|
130
|
+
|
|
131
|
+
if kind == KIND_GROUP:
|
|
132
|
+
_fill_group(sdt, pr, content, data, keep_controls)
|
|
133
|
+
return
|
|
134
|
+
|
|
135
|
+
if value is not None:
|
|
136
|
+
_fill_leaf(content, kind, value, pr)
|
|
137
|
+
_unwrap(sdt, content, keep_controls)
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _fill_group(sdt, pr, content, data: dict, keep_controls: bool) -> None:
|
|
141
|
+
"""A group renders exactly one of its branches: the value either
|
|
142
|
+
fills the control (literal text dropped) or is one of the literals
|
|
143
|
+
(control dropped). keep_controls=True keeps both sides editable
|
|
144
|
+
instead of erasing the unchosen branch. A conditional group (its
|
|
145
|
+
address ends in '?') instead gates its whole block on the value:
|
|
146
|
+
falsy drops the block — label and all — truthy fills transparently
|
|
147
|
+
with the literal text kept as decoration."""
|
|
148
|
+
members = group_members(content)
|
|
149
|
+
name, _, _, conditional = group_address(pr, members)
|
|
150
|
+
value = data.get(name) if name else None
|
|
151
|
+
if conditional:
|
|
152
|
+
if not value:
|
|
153
|
+
_unwrap(sdt, [], keep_controls)
|
|
154
|
+
return
|
|
155
|
+
_fill_children(content, data, keep_controls)
|
|
156
|
+
_unwrap(sdt, content, keep_controls)
|
|
157
|
+
return
|
|
158
|
+
literals = group_literals(content)
|
|
159
|
+
if value in literals:
|
|
160
|
+
for msdt, *_ in members:
|
|
161
|
+
_unwrap(msdt, [], keep_controls)
|
|
162
|
+
elif value is not None and members:
|
|
163
|
+
if not keep_controls:
|
|
164
|
+
_drop_literal_runs(content, set(literals)) # before the fill unwraps the leaf
|
|
165
|
+
msdt, mpr, _, _ = members[0]
|
|
166
|
+
mcontent = msdt.find(qn('w:sdtContent'))
|
|
167
|
+
if mcontent is not None:
|
|
168
|
+
_fill_leaf(mcontent, kind_of(mpr), value, mpr)
|
|
169
|
+
_unwrap(msdt, mcontent, keep_controls)
|
|
170
|
+
_unwrap(sdt, content, keep_controls)
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def _drop_literal_runs(content, texts: set) -> None:
|
|
174
|
+
for holder, text in group_chunks(content):
|
|
175
|
+
if text in texts:
|
|
176
|
+
for r in [c for c in holder if c.tag == qn('w:r')]:
|
|
177
|
+
holder.remove(r)
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def _fill_leaf(content, kind: str, value, pr) -> None:
|
|
181
|
+
if kind == KIND_CHECKBOX:
|
|
182
|
+
_set_checkbox(content, pr, bool(value))
|
|
183
|
+
elif kind == KIND_DROPDOWN:
|
|
184
|
+
_set_text(content, _display_text(pr, value) or str(value))
|
|
185
|
+
elif kind == KIND_DATE:
|
|
186
|
+
_set_text(content, _date_text(pr, value))
|
|
187
|
+
else:
|
|
188
|
+
_set_text(content, str(value))
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def _set_checkbox(content, pr, checked: bool) -> None:
|
|
192
|
+
checkbox = pr.find(qn('w14:checkbox'))
|
|
193
|
+
state = None
|
|
194
|
+
if checkbox is not None:
|
|
195
|
+
checked_el = checkbox.find(qn('w14:checked'))
|
|
196
|
+
if checked_el is None:
|
|
197
|
+
checked_el = etree.SubElement(checkbox, qn('w14:checked'))
|
|
198
|
+
checked_el.set(qn('w14:val'), '1' if checked else '0')
|
|
199
|
+
state = checkbox.find(qn('w14:checkedState') if checked else qn('w14:uncheckedState'))
|
|
200
|
+
glyph = '☒' if checked else '☐'
|
|
201
|
+
if state is not None and (val := state.get(qn('w14:val'))):
|
|
202
|
+
glyph = chr(int(val, 16))
|
|
203
|
+
_set_text(content, glyph)
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def _display_text(pr, value) -> Optional[str]:
|
|
207
|
+
target = str(value)
|
|
208
|
+
for display, item_value in dropdown_items(pr):
|
|
209
|
+
if item_value == target:
|
|
210
|
+
return display
|
|
211
|
+
return None
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def _date_text(pr, value) -> str:
|
|
215
|
+
"""A date control fills with its own display mask — the same text Word
|
|
216
|
+
would write had the user picked the date in the picker. Values that
|
|
217
|
+
don't parse fall back to the raw string. The w:date element is
|
|
218
|
+
guaranteed: _fill_leaf only routes KIND_DATE controls here."""
|
|
219
|
+
s = str(value)
|
|
220
|
+
mask = text_attr(pr.find(qn('w:date')), 'w:dateFormat')
|
|
221
|
+
if not mask or (parts := _date_parts(s)) is None:
|
|
222
|
+
return s
|
|
223
|
+
return _apply_mask(mask, *parts)
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
# the shapes schema's four format regexes accept, capture-grouped: month
|
|
227
|
+
# only opens day, day only opens the clock — a time without a date never
|
|
228
|
+
# parses
|
|
229
|
+
_ISO_RE = re.compile(r'^(\d{4})(?:-(\d{2})(?:-(\d{2})'
|
|
230
|
+
r'(?:[T ](\d{2}):(\d{2})(?::(\d{2}))?)?)?)?')
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def _date_parts(value: str) -> Optional[tuple]:
|
|
234
|
+
m = _ISO_RE.match(value)
|
|
235
|
+
if m is None:
|
|
236
|
+
return None
|
|
237
|
+
return tuple(int(p) if p else None for p in m.groups())
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def _apply_mask(mask: str, year: int, month: Optional[int], day: Optional[int],
|
|
241
|
+
hour: Optional[int], minute: Optional[int],
|
|
242
|
+
second: Optional[int]) -> str:
|
|
243
|
+
"""Render a date-time through a Word dateFormat mask, run by run. A
|
|
244
|
+
run's letter dispatches the field-code units (y/M/d, H/h 24-12-hour,
|
|
245
|
+
m minute, s second — the units a mask's own granularity guarantees
|
|
246
|
+
are present); unrecognized letters and literals pass through. Month
|
|
247
|
+
names are the locale-independent English tables."""
|
|
248
|
+
out = []
|
|
249
|
+
for letter, text in date_mask_runs(mask):
|
|
250
|
+
width = len(text)
|
|
251
|
+
if letter == 'y':
|
|
252
|
+
out.append(f'{year:04d}' if width > 2 else f'{year % 100:02d}')
|
|
253
|
+
elif letter == 'M' and month is not None:
|
|
254
|
+
out.append(str(month) if width == 1 else month_abbr[month] if width == 3
|
|
255
|
+
else month_name[month] if width == 4 else f'{month:02d}')
|
|
256
|
+
elif letter == 'd' and day is not None:
|
|
257
|
+
out.append(str(day) if width == 1 else f'{day:02d}')
|
|
258
|
+
elif letter in ('h', 'H') and hour is not None:
|
|
259
|
+
h = hour if letter == 'H' else (hour % 12 or 12)
|
|
260
|
+
out.append(str(h) if width == 1 else f'{h:02d}')
|
|
261
|
+
elif letter == 'm' and minute is not None:
|
|
262
|
+
out.append(str(minute) if width == 1 else f'{minute:02d}')
|
|
263
|
+
elif letter == 's' and second is not None:
|
|
264
|
+
out.append(str(second) if width == 1 else f'{second:02d}')
|
|
265
|
+
else:
|
|
266
|
+
out.append(text)
|
|
267
|
+
return ''.join(out)
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def _set_text(content, text: str) -> None:
|
|
271
|
+
runs = content.findall(qn('w:r'))
|
|
272
|
+
r = runs[0] if runs else etree.SubElement(content, qn('w:r'))
|
|
273
|
+
r_pr = r.find(qn('w:rPr'))
|
|
274
|
+
for extra in runs[1:]:
|
|
275
|
+
content.remove(extra)
|
|
276
|
+
r.clear()
|
|
277
|
+
if r_pr is not None:
|
|
278
|
+
r.append(r_pr)
|
|
279
|
+
t = etree.SubElement(r, qn('w:t'))
|
|
280
|
+
t.set(XML_SPACE, 'preserve')
|
|
281
|
+
t.text = text
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
def _unwrap(sdt, nodes, keep_controls: bool) -> None:
|
|
285
|
+
if not keep_controls:
|
|
286
|
+
_splice(sdt, list(nodes))
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
def _splice(old, nodes) -> None:
|
|
290
|
+
parent = old.getparent()
|
|
291
|
+
if parent is None:
|
|
292
|
+
return
|
|
293
|
+
for node in nodes:
|
|
294
|
+
old.addprevious(node)
|
|
295
|
+
parent.remove(old)
|
|
296
|
+
if parent.tag == qn('w:tc') and not parent.findall(qn('w:p')):
|
|
297
|
+
etree.SubElement(parent, qn('w:p')) # a cell must keep a paragraph
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
def _as_dict(value) -> dict:
|
|
301
|
+
return value if isinstance(value, dict) else {}
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
def _scoped(entry, i: int, n: int) -> dict:
|
|
305
|
+
"""Item-local guard scope: positional booleans for conditional blocks."""
|
|
306
|
+
return {**_as_dict(entry), **scope_guards(i, n)}
|
docxcast/schema.py
ADDED
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
"""Schema model: the contract derived from a docx template and validated against data."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from dataclasses import asdict, dataclass, field
|
|
7
|
+
from typing import Optional, Union
|
|
8
|
+
|
|
9
|
+
class _Missing:
|
|
10
|
+
"""Sentinel that survives deepcopy so asdict-based filtering works."""
|
|
11
|
+
|
|
12
|
+
def __deepcopy__(self, memo):
|
|
13
|
+
return self
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
MISSING = _Missing()
|
|
17
|
+
|
|
18
|
+
MISSING_REQUIRED = 'missing_required'
|
|
19
|
+
TYPE_MISMATCH = 'type_mismatch'
|
|
20
|
+
ENUM_INVALID = 'enum_invalid'
|
|
21
|
+
FORMAT_INVALID = 'format_invalid'
|
|
22
|
+
UNKNOWN_KEY = 'unknown_key'
|
|
23
|
+
|
|
24
|
+
FORMAT_DATE = 'date'
|
|
25
|
+
FORMAT_DATE_MONTH = 'date-month'
|
|
26
|
+
FORMAT_DATE_YEAR = 'date-year'
|
|
27
|
+
FORMAT_DATE_TIME = 'date-time'
|
|
28
|
+
TYPE_BOOLEAN = 'boolean'
|
|
29
|
+
|
|
30
|
+
_MESSAGES = {
|
|
31
|
+
MISSING_REQUIRED: 'Required value is missing.',
|
|
32
|
+
TYPE_MISMATCH: 'Value has the wrong type.',
|
|
33
|
+
ENUM_INVALID: 'Value is not among the allowed options.',
|
|
34
|
+
FORMAT_INVALID: 'Value does not match the expected format.',
|
|
35
|
+
UNKNOWN_KEY: 'Key is not defined in the schema.',
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
# everything except the warning-level code is an error
|
|
39
|
+
ERROR_CODES = frozenset(_MESSAGES) - {UNKNOWN_KEY}
|
|
40
|
+
|
|
41
|
+
_DATE_RE = re.compile(r'^\d{4}-\d{2}-\d{2}$')
|
|
42
|
+
_DATE_MONTH_RE = re.compile(r'^\d{4}-\d{2}$')
|
|
43
|
+
_DATE_YEAR_RE = re.compile(r'^\d{4}$')
|
|
44
|
+
_DATETIME_RE = re.compile(
|
|
45
|
+
r'^\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}(:\d{2})?(\.\d+)?(Z|[+-]\d{2}:?\d{2})?$'
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
_FORMATS = (
|
|
49
|
+
(FORMAT_DATE, _DATE_RE, 'ISO 8601 date (YYYY-MM-DD)'),
|
|
50
|
+
(FORMAT_DATE_MONTH, _DATE_MONTH_RE, 'ISO 8601 month (YYYY-MM)'),
|
|
51
|
+
(FORMAT_DATE_YEAR, _DATE_YEAR_RE, 'year (YYYY)'),
|
|
52
|
+
(FORMAT_DATE_TIME, _DATETIME_RE, 'ISO 8601 date-time'),
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
# the regex source of truth, emitted as JSON Schema `pattern` so the
|
|
56
|
+
# format branch is enforced by any validator, not just this one
|
|
57
|
+
_PATTERNS = {fmt: rx.pattern for fmt, rx, _ in _FORMATS}
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
@dataclass
|
|
61
|
+
class Issue:
|
|
62
|
+
"""A single validation issue. `message` is English and self-contained."""
|
|
63
|
+
|
|
64
|
+
path: str
|
|
65
|
+
code: str
|
|
66
|
+
message: str
|
|
67
|
+
expected: object = MISSING
|
|
68
|
+
got: object = MISSING
|
|
69
|
+
|
|
70
|
+
def to_dict(self) -> dict:
|
|
71
|
+
return {k: v for k, v in asdict(self).items() if v is not MISSING}
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
@dataclass
|
|
75
|
+
class Field:
|
|
76
|
+
name: str
|
|
77
|
+
remark: Optional[str] = None
|
|
78
|
+
type: str = 'string' # 'string' | 'boolean'
|
|
79
|
+
format: Optional[str] = None # 'date' | 'date-month' | 'date-year' | 'date-time'
|
|
80
|
+
enum: Optional[list] = None
|
|
81
|
+
alternatives: Optional[list] = None # literal values a group shows instead of the format
|
|
82
|
+
enum_labels: Optional[list] = None # display labels parallel to enum
|
|
83
|
+
required: bool = False
|
|
84
|
+
|
|
85
|
+
def to_json_schema(self) -> dict:
|
|
86
|
+
node = self._value_branch()
|
|
87
|
+
if self.alternatives:
|
|
88
|
+
# a union carries no type of its own — each branch does
|
|
89
|
+
node = {'anyOf': [node, {'enum': self.alternatives}]}
|
|
90
|
+
if self.enum is not None and not self.required:
|
|
91
|
+
# an optional field may stay unfilled — '' rides the emitted
|
|
92
|
+
# contract so strict validators (jsonschema etc.) agree with
|
|
93
|
+
# _validate_field, not just this one
|
|
94
|
+
node = {'anyOf': [node, {'enum': ['']}]}
|
|
95
|
+
if (desc := self._description()) is not None:
|
|
96
|
+
node['description'] = desc
|
|
97
|
+
return node
|
|
98
|
+
|
|
99
|
+
def _value_branch(self) -> dict:
|
|
100
|
+
"""The field's value node — a union wraps it in anyOf."""
|
|
101
|
+
branch = {'type': self.type}
|
|
102
|
+
for key, val in (
|
|
103
|
+
('format', self.format),
|
|
104
|
+
('enum', self.enum),
|
|
105
|
+
('pattern', _PATTERNS.get(self.format)),
|
|
106
|
+
):
|
|
107
|
+
if val is not None:
|
|
108
|
+
branch[key] = val
|
|
109
|
+
return branch
|
|
110
|
+
|
|
111
|
+
def _description(self) -> Optional[str]:
|
|
112
|
+
if self.enum_labels and self.enum:
|
|
113
|
+
mapping = '; '.join(f'{d}={v}' for d, v in zip(self.enum_labels, self.enum))
|
|
114
|
+
return f'{self.remark} ({mapping})' if self.remark else mapping
|
|
115
|
+
return self.remark
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
@dataclass
|
|
119
|
+
class Repeat:
|
|
120
|
+
name: str
|
|
121
|
+
remark: Optional[str] = None
|
|
122
|
+
fields: list = field(default_factory=list)
|
|
123
|
+
required: bool = False
|
|
124
|
+
|
|
125
|
+
def to_json_schema(self) -> dict:
|
|
126
|
+
node = {'type': 'array', 'items': _object_schema(self.fields)}
|
|
127
|
+
if self.remark is not None:
|
|
128
|
+
node['description'] = self.remark
|
|
129
|
+
return node
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
@dataclass
|
|
133
|
+
class Section:
|
|
134
|
+
name: str
|
|
135
|
+
remark: Optional[str] = None
|
|
136
|
+
children: list = field(default_factory=list)
|
|
137
|
+
required: bool = False
|
|
138
|
+
|
|
139
|
+
def to_json_schema(self) -> dict:
|
|
140
|
+
return _object_schema(self.children, self.remark)
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
Node = Union[Field, Repeat, Section]
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
@dataclass
|
|
147
|
+
class Schema:
|
|
148
|
+
children: list = field(default_factory=list)
|
|
149
|
+
|
|
150
|
+
def to_json_schema(self) -> dict:
|
|
151
|
+
return _object_schema(self.children)
|
|
152
|
+
|
|
153
|
+
def to_dict(self) -> dict:
|
|
154
|
+
return asdict(self)
|
|
155
|
+
|
|
156
|
+
def validate(self, data: object) -> ValidationResult:
|
|
157
|
+
issues: list = []
|
|
158
|
+
if data is not None:
|
|
159
|
+
_validate_object(self.children, data, '', issues)
|
|
160
|
+
return ValidationResult(issues=issues)
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
@dataclass
|
|
164
|
+
class ValidationResult:
|
|
165
|
+
issues: list = field(default_factory=list)
|
|
166
|
+
|
|
167
|
+
@property
|
|
168
|
+
def errors(self) -> list:
|
|
169
|
+
return [i for i in self.issues if i.code in ERROR_CODES]
|
|
170
|
+
|
|
171
|
+
@property
|
|
172
|
+
def warnings(self) -> list:
|
|
173
|
+
return [i for i in self.issues if i.code not in ERROR_CODES]
|
|
174
|
+
|
|
175
|
+
@property
|
|
176
|
+
def ok(self) -> bool:
|
|
177
|
+
return not self.errors
|
|
178
|
+
|
|
179
|
+
def to_dict(self) -> dict:
|
|
180
|
+
return {'ok': self.ok, 'issues': [i.to_dict() for i in self.issues]}
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def _nullable(schema: dict) -> dict:
|
|
184
|
+
"""An optional object may also be null — the shape the model reads:
|
|
185
|
+
an omitted structure field is absent, not filled with an empty
|
|
186
|
+
object. Arrays keep a single type: empty already reads falsy to
|
|
187
|
+
the renderer, and the dashscope schema validator rejects type
|
|
188
|
+
unions on arrays."""
|
|
189
|
+
if schema.get('type') == 'object':
|
|
190
|
+
schema['type'] = ['object', 'null']
|
|
191
|
+
return schema
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def _object_schema(children: list, remark: Optional[str] = None) -> dict:
|
|
195
|
+
node = {'type': 'object'}
|
|
196
|
+
if children:
|
|
197
|
+
node['properties'] = {
|
|
198
|
+
c.name: c.to_json_schema() if c.required
|
|
199
|
+
else _nullable(c.to_json_schema()) for c in children}
|
|
200
|
+
required = [c.name for c in children if c.required]
|
|
201
|
+
if required:
|
|
202
|
+
node['required'] = required
|
|
203
|
+
if remark is not None:
|
|
204
|
+
node['description'] = remark
|
|
205
|
+
return node
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def _validate_children(nodes: list, data: dict, prefix: str, issues: list) -> None:
|
|
209
|
+
known = set()
|
|
210
|
+
for node in nodes:
|
|
211
|
+
known.add(node.name)
|
|
212
|
+
value = data.get(node.name, MISSING)
|
|
213
|
+
if value is MISSING:
|
|
214
|
+
if node.required:
|
|
215
|
+
issues.append(_issue(_path(prefix, node.name), MISSING_REQUIRED))
|
|
216
|
+
continue
|
|
217
|
+
_validate_node(node, value, _path(prefix, node.name), issues)
|
|
218
|
+
for key in data:
|
|
219
|
+
if key not in known:
|
|
220
|
+
issues.append(_issue(_path(prefix, key), UNKNOWN_KEY))
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def _validate_node(node: Node, value: object, path: str, issues: list) -> None:
|
|
224
|
+
if value is None:
|
|
225
|
+
if node.required:
|
|
226
|
+
issues.append(_issue(path, MISSING_REQUIRED))
|
|
227
|
+
return # null is an absent value, allowed on every optional node
|
|
228
|
+
if isinstance(node, Section):
|
|
229
|
+
_validate_object(node.children, value, path, issues)
|
|
230
|
+
elif isinstance(node, Repeat):
|
|
231
|
+
if not isinstance(value, list):
|
|
232
|
+
issues.append(_issue(path, TYPE_MISMATCH, expected='array', got=value))
|
|
233
|
+
return
|
|
234
|
+
for i, item in enumerate(value):
|
|
235
|
+
_validate_object(node.fields, item, f'{path}[{i}]', issues)
|
|
236
|
+
else:
|
|
237
|
+
_validate_field(node, value, path, issues)
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def _validate_object(nodes: list, value: object, path: str, issues: list) -> None:
|
|
241
|
+
if not isinstance(value, dict):
|
|
242
|
+
issues.append(_issue(path, TYPE_MISMATCH, expected='object', got=value))
|
|
243
|
+
return
|
|
244
|
+
_validate_children(nodes, value, path, issues)
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def _validate_field(field: Field, value: object, path: str, issues: list) -> None:
|
|
248
|
+
if field.alternatives and value in field.alternatives:
|
|
249
|
+
return # a literal alternative satisfies the union on its own
|
|
250
|
+
if field.type == TYPE_BOOLEAN:
|
|
251
|
+
if not isinstance(value, bool):
|
|
252
|
+
issues.append(_issue(path, TYPE_MISMATCH, expected=TYPE_BOOLEAN, got=value))
|
|
253
|
+
return
|
|
254
|
+
if not isinstance(value, str):
|
|
255
|
+
issues.append(_issue(path, TYPE_MISMATCH, expected=field.type, got=value))
|
|
256
|
+
return
|
|
257
|
+
if value == '' and not field.required:
|
|
258
|
+
return # an optional field may stay unfilled: emptiness is governed
|
|
259
|
+
# by required (the control's lock), never by enum membership
|
|
260
|
+
if field.enum is not None and value not in field.enum:
|
|
261
|
+
issues.append(_issue(path, ENUM_INVALID, expected=field.enum, got=value))
|
|
262
|
+
for fmt, rx, label in _FORMATS:
|
|
263
|
+
if field.format == fmt and not rx.match(value):
|
|
264
|
+
issues.append(_issue(path, FORMAT_INVALID, expected=label, got=value))
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def _issue(path: str, code: str, expected: object = MISSING, got: object = MISSING) -> Issue:
|
|
268
|
+
return Issue(path=path, code=code, message=_MESSAGES[code], expected=expected, got=got)
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def _path(prefix: str, name: str) -> str:
|
|
272
|
+
return f'{prefix}.{name}' if prefix else name
|
|
@@ -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,9 @@
|
|
|
1
|
+
docxcast/__init__.py,sha256=I7d8XVZXj08TU6HBgX122rk6mKWKjt6SD0lv5FwQ-R0,869
|
|
2
|
+
docxcast/_ooxml.py,sha256=MGHIJJEoGswiGdz5V1XnO53dG9eoQf9F0q0dtXVmvPc,6419
|
|
3
|
+
docxcast/derive.py,sha256=ksZYA-gzlf0PpD_lEKnjCmah-77q7XvJkm7AiSMpB5Y,7660
|
|
4
|
+
docxcast/render.py,sha256=-4a60DsgSLzP_oh-LvTbUrDz_acTDiXnSBuJW3DM8VE,10584
|
|
5
|
+
docxcast/schema.py,sha256=HN0LHMC04oVxAWuTC3hrS2NeeVHL7MVyvvcxTOQCZpw,9203
|
|
6
|
+
docxcast-0.1.0.dist-info/METADATA,sha256=-a_7UTrbVI_Aq3Mpxgzb4VI0ZaQKgzxp8xYmssGjBjQ,5449
|
|
7
|
+
docxcast-0.1.0.dist-info/WHEEL,sha256=zOwg4jB6zX2kU910N-cMawjivD6tO8NEWvE12je1bVk,87
|
|
8
|
+
docxcast-0.1.0.dist-info/licenses/LICENSE,sha256=h8-551rXql0Z6aYn_7eita2vDlYtGqP12OGxrmlaQ74,1067
|
|
9
|
+
docxcast-0.1.0.dist-info/RECORD,,
|
|
@@ -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.
|