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 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,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -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.