Compare commits
148 Commits
alanoe-ae/
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f96b0b6413 | ||
|
|
560b4b3311 | ||
|
|
e77cbf8c7b | ||
|
|
7a6ddbcc54 | ||
|
|
d9bb19cdd4 | ||
|
|
416729dddc | ||
|
|
cbbeae57bd | ||
|
|
606d189787 | ||
|
|
399761f9c9 | ||
|
|
4477729a57 | ||
|
|
b391b213eb | ||
|
|
e766040c5b | ||
|
|
fb0e3583da | ||
|
|
40861400b3 | ||
|
|
9392b06da4 | ||
|
|
a48a6a96b2 | ||
|
|
685425095b | ||
|
|
08a5d748f6 | ||
|
|
ebc770a291 | ||
|
|
97df786298 | ||
|
|
994da90f66 | ||
|
|
aa7b7a77e3 | ||
|
|
b97fa32f10 | ||
|
|
def1205179 | ||
|
|
eed645c0c5 | ||
|
|
60caff83fa | ||
|
|
65e00b189e | ||
|
|
0f42e5a4c8 | ||
|
|
bc92389ee1 | ||
|
|
0ef74a740e | ||
|
|
0d616add84 | ||
|
|
9124386b4c | ||
|
|
1cca257016 | ||
|
|
a449f01f36 | ||
|
|
2812487300 | ||
|
|
2886a851e0 | ||
|
|
0061b556ec | ||
|
|
0607e7175d | ||
|
|
d99607003c | ||
|
|
b9be3a5b50 | ||
|
|
83ecb60bdd | ||
|
|
a10c3c16e4 | ||
|
|
d472308ae5 | ||
|
|
ce25ec8228 | ||
|
|
ac38610947 | ||
|
|
a50b52b317 | ||
|
|
f3ba468927 | ||
|
|
b7872d7723 | ||
|
|
0642badebc | ||
|
|
664bb845fa | ||
|
|
e0d5eb0e0a | ||
|
|
d322120d2a | ||
|
|
58de890f92 | ||
|
|
bee0706645 | ||
|
|
24de22bfc6 | ||
|
|
d43c6debbf | ||
|
|
d539119fed | ||
|
|
d042d4592c | ||
|
|
af2e09209b | ||
|
|
d9801fd6e4 | ||
|
|
6e51079b2a | ||
|
|
9af9d9e69b | ||
|
|
708581fa6e | ||
|
|
e3b40ec959 | ||
|
|
724623671c | ||
|
|
ef09ce6277 | ||
|
|
ce23f81cbe | ||
|
|
5d9bba118a | ||
|
|
006d5bc506 | ||
|
|
4047845c3a | ||
|
|
1a4a5f5723 | ||
|
|
650b28d852 | ||
|
|
890549d705 | ||
|
|
52d9b28a31 | ||
|
|
4857a6ed72 | ||
|
|
bd4b2be81d | ||
|
|
ea987f9e38 | ||
|
|
a9122c34d4 | ||
|
|
b75867397a | ||
|
|
badce481c6 | ||
|
|
85a7b92bf3 | ||
|
|
0660a0f37e | ||
|
|
2fd2140caf | ||
|
|
d4415c34a4 | ||
|
|
a712baff03 | ||
|
|
7816ea6270 | ||
|
|
5f21feb72d | ||
|
|
7290528b3d | ||
|
|
a0f6589d34 | ||
|
|
0ab1144a8d | ||
|
|
6da5022b41 | ||
|
|
bda05f2101 | ||
|
|
25a6e42850 | ||
|
|
624bc15a10 | ||
|
|
43ceb38465 | ||
|
|
2681c4801d | ||
|
|
c1f994e632 | ||
|
|
9b4e623c3c | ||
|
|
6bfd2eb85a | ||
|
|
d283f3f88a | ||
|
|
b4e533ae66 | ||
|
|
4e073ea451 | ||
|
|
8a3c2051ad | ||
|
|
9a7ac8f4e9 | ||
|
|
00b9a97222 | ||
|
|
605844aa17 | ||
|
|
b95a23395f | ||
|
|
23038b98de | ||
|
|
19733a6b5b | ||
|
|
d2037d2a54 | ||
|
|
e574b6c650 | ||
|
|
589262664f | ||
|
|
d22aeb41db | ||
|
|
4a9acdcf79 | ||
|
|
234ec65e9d | ||
|
|
e3e1ab58d1 | ||
|
|
727d07fa3e | ||
|
|
0238472033 | ||
|
|
5e8aeae272 | ||
|
|
5dfe448a9a | ||
|
|
541d5ce7cb | ||
|
|
b0a5de05aa | ||
|
|
d900f9296a | ||
|
|
1f3ef9b236 | ||
|
|
e4737ddfbb | ||
|
|
7b2d7e1946 | ||
|
|
2dee2ddd1c | ||
|
|
5f3fe03108 | ||
|
|
b6f9c992f5 | ||
|
|
a78462ee0f | ||
|
|
8004fcf6bc | ||
|
|
a45cc806d8 | ||
|
|
21a0a46a43 | ||
|
|
9708802400 | ||
|
|
61c8924663 | ||
|
|
b7d782ab20 | ||
|
|
6722d63537 | ||
|
|
bf2240f4b2 | ||
|
|
ab61d39847 | ||
|
|
f780714a52 | ||
|
|
30712bbc67 | ||
|
|
0a1cb24b12 | ||
|
|
7a65b2a310 | ||
|
|
a435b1c46a | ||
|
|
ef622f28e5 | ||
|
|
44fb098904 | ||
|
|
ba9a4629e9 | ||
|
|
60151d6677 |
23
.github/ISSUE_TEMPLATE/bug_report.md
vendored
Normal file
23
.github/ISSUE_TEMPLATE/bug_report.md
vendored
Normal file
@ -0,0 +1,23 @@
|
||||
---
|
||||
name: Bug report
|
||||
about: Create a bug report to help us fix the code
|
||||
title: ''
|
||||
labels: bug
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
## Describe the bug
|
||||
A clear and concise description of what the bug is.
|
||||
|
||||
## To Reproduce
|
||||
Please, provide a fully standalone runnable test case : minimal python code + docx template + other files if needed (images etc...)
|
||||
|
||||
## Expected behavior
|
||||
A clear and concise description of what you expected to happen.
|
||||
|
||||
## Screenshots
|
||||
If applicable, add screenshots to help explain your problem.
|
||||
|
||||
## Additional context
|
||||
Add any other context about the problem here.
|
||||
20
.github/ISSUE_TEMPLATE/help-request.md
vendored
Normal file
20
.github/ISSUE_TEMPLATE/help-request.md
vendored
Normal file
@ -0,0 +1,20 @@
|
||||
---
|
||||
name: Help request
|
||||
about: Ask help to the community
|
||||
title: ''
|
||||
labels: help wanted
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
## Describe your problem
|
||||
A clear and concise description of what you want to do
|
||||
|
||||
## More details about your problem
|
||||
Steps to reproduce the behavior, expected behavior etc...
|
||||
|
||||
## Provide a test case
|
||||
If applicable, provide minimal python code + docx template + other files (images etc...) to reproduce the behavior
|
||||
|
||||
## Screenshots
|
||||
If applicable, add screenshots to help explain your problem.
|
||||
20
.github/ISSUE_TEMPLATE/request-for-enhancement.md
vendored
Normal file
20
.github/ISSUE_TEMPLATE/request-for-enhancement.md
vendored
Normal file
@ -0,0 +1,20 @@
|
||||
---
|
||||
name: Request for enhancement
|
||||
about: Suggest an idea for this project
|
||||
title: ''
|
||||
labels: Request for enhancement
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
## Is your feature request related to a problem? Please describe.
|
||||
A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
|
||||
|
||||
## Describe the solution you'd like
|
||||
A clear and concise description of what you want to happen.
|
||||
|
||||
## Describe alternatives you've considered
|
||||
A clear and concise description of any alternative solutions or features you've considered.
|
||||
|
||||
## Additional context
|
||||
Add any other context or screenshots about the feature request here.
|
||||
4
.github/workflows/codestyle.yml
vendored
4
.github/workflows/codestyle.yml
vendored
@ -7,7 +7,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
python-version: [2.7, 3.5, 3.6, 3.7, 3.8]
|
||||
python-version: ['3.9', '3.10', '3.11', '3.12', '3.13']
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- name: Set up Python ${{ matrix.python-version }}
|
||||
@ -22,4 +22,4 @@ jobs:
|
||||
run: |
|
||||
pip install flake8
|
||||
# stop the build if there are code styling problems. The GitHub editor is 127 chars wide.
|
||||
flake8 . --count --max-line-length=127 --show-source --statistics
|
||||
flake8 . --count --max-line-length=127 --show-source --statistics
|
||||
|
||||
35
.readthedocs.yaml
Normal file
35
.readthedocs.yaml
Normal file
@ -0,0 +1,35 @@
|
||||
# Read the Docs configuration file for Sphinx projects
|
||||
# See https://docs.readthedocs.io/en/stable/config-file/v2.html for details
|
||||
|
||||
# Required
|
||||
version: 2
|
||||
|
||||
# Set the OS, Python version and other tools you might need
|
||||
build:
|
||||
os: ubuntu-22.04
|
||||
tools:
|
||||
python: "3.12"
|
||||
# You can also specify other tool versions:
|
||||
# nodejs: "20"
|
||||
# rust: "1.70"
|
||||
# golang: "1.20"
|
||||
|
||||
# Build documentation in the "docs/" directory with Sphinx
|
||||
sphinx:
|
||||
configuration: docs/conf.py
|
||||
# You can configure Sphinx to use a different builder, for instance use the dirhtml builder for simpler URLs
|
||||
# builder: "dirhtml"
|
||||
# Fail on all warnings to avoid broken references
|
||||
# fail_on_warning: true
|
||||
|
||||
# Optionally build your docs in additional formats such as PDF and ePub
|
||||
# formats:
|
||||
# - pdf
|
||||
# - epub
|
||||
|
||||
# Optional but recommended, declare the Python requirements required
|
||||
# to build your documentation
|
||||
# See https://docs.readthedocs.io/en/stable/guides/reproducible-builds.html
|
||||
python:
|
||||
install:
|
||||
- requirements: requirements.txt
|
||||
115
CHANGES.rst
115
CHANGES.rst
@ -1,17 +1,112 @@
|
||||
0.11.4 (2021-04-06)
|
||||
0.20.2 *(Unreleased)*
|
||||
-------------------
|
||||
- Move docxcompose to optional dependency (Thanks to Waket Zheng)
|
||||
|
||||
0.20.1 (2025-07-15)
|
||||
-------------------
|
||||
- Fix and improve get_undeclared_template_variables() method (Thanks to Pablo Esteban)
|
||||
|
||||
0.20.0 (2024-12-29)
|
||||
-------------------
|
||||
- Add RichTextParagraph (Thanks to ST-Imrie)
|
||||
- Add RTL support for bold/italic (Thanks to bm-rana)
|
||||
- Update documentation
|
||||
|
||||
0.19.1 (2024-12-29)
|
||||
-------------------
|
||||
- PR #575 : fix unicode in footnotes (Thanks to Jonathan Pyle)
|
||||
|
||||
0.19.0 (2024-11-12)
|
||||
-------------------
|
||||
- Support rendering variables in footnotes (Thanks to Bart Broere)
|
||||
|
||||
0.18.0 (2024-07-21)
|
||||
-------------------
|
||||
- IMPORTANT : Remove Python 2.x support
|
||||
- Add hyperlink option in InlineImage (Thanks to Jean Marcos da Rosa)
|
||||
- Update index.rst (Thanks to jkpet)
|
||||
- Add poetry env
|
||||
- Black all files
|
||||
|
||||
0.17.0 (2024-05-01)
|
||||
-------------------
|
||||
- Add support to python-docx 1.1.1
|
||||
|
||||
0.16.8 (2024-02-23)
|
||||
-------------------
|
||||
- PR #527 : upgrade Jinja2 in Pipfile.lock
|
||||
|
||||
0.16.7 (2023-05-08)
|
||||
-------------------
|
||||
- PR #493 - thanks to AdrianVorobel
|
||||
|
||||
0.16.6 (2023-03-12)
|
||||
-------------------
|
||||
- PR #482 - thanks to dreizehnutters
|
||||
|
||||
0.16.5 (2023-01-07)
|
||||
-------------------
|
||||
- PR #467 - thanks to Slarag
|
||||
- fix #465
|
||||
- fix #464
|
||||
|
||||
0.16.4 (2022-08-04)
|
||||
-------------------
|
||||
- Regional fonts for RichText
|
||||
- Reorganize documentation
|
||||
|
||||
0.16.3 (2022-07-14)
|
||||
-------------------
|
||||
- fix #448
|
||||
|
||||
0.16.2 (2022-07-14)
|
||||
-------------------
|
||||
- fix #444
|
||||
- fix #443
|
||||
|
||||
0.16.1 (2022-06-12)
|
||||
-------------------
|
||||
- PR #442
|
||||
|
||||
0.16.0 (2022-04-16)
|
||||
-------------------
|
||||
- add jinja2 comment support - Thanks to staffanm
|
||||
|
||||
0.15.2 (2022-01-12)
|
||||
-------------------
|
||||
- fix #408
|
||||
- Multi-rendering with same DocxTemplate object is now possible
|
||||
see tests/multi_rendering.py
|
||||
- fix #392
|
||||
- fix #398
|
||||
|
||||
0.14.1 (2021-10-01)
|
||||
-------------------
|
||||
- One can now use python -m docxtpl on command line
|
||||
to generate a docx from a template and a json file as a context
|
||||
Thanks to Lcrs123@github
|
||||
|
||||
0.12.0 (2021-08-15)
|
||||
-------------------
|
||||
- Code has be split into many files for better readability
|
||||
- Use docxcomposer to attach parts when a docx file is given to create a subdoc
|
||||
Images, styles etc... must now be taken in account in subdocs
|
||||
- Some internal XML IDs are now renumbered to avoid collision, thus images are not randomly disapearing anymore.
|
||||
- fix #372
|
||||
- fix #374
|
||||
- fix #375
|
||||
- fix #369
|
||||
- fix #368
|
||||
- fix #347
|
||||
- fix #181
|
||||
- fix #61
|
||||
|
||||
0.11.5 (2021-05-09)
|
||||
-------------------
|
||||
- PR #351
|
||||
- It is now possible to put InlineImage in header/footer
|
||||
|
||||
0.11.2 (2020-11-09)
|
||||
-------------------
|
||||
- fix #323
|
||||
|
||||
0.11.1 (2020-10-27)
|
||||
-------------------
|
||||
- fix #320
|
||||
|
||||
0.11.0 (2020-10-19)
|
||||
-------------------
|
||||
- \\n, \\a, \\t and \\f are now accepted in simple context string. Thanks to chabErch@github
|
||||
|
||||
0.10.5 (2020-10-15)
|
||||
|
||||
10
Pipfile
10
Pipfile
@ -1,13 +1,13 @@
|
||||
[[source]]
|
||||
url = "https://pypi.python.org/simple"
|
||||
url = "https://pypi.org/simple"
|
||||
verify_ssl = true
|
||||
name = "pypi"
|
||||
|
||||
[dev-packages]
|
||||
"e1839a8" = {path = ".", editable = true}
|
||||
|
||||
[packages]
|
||||
|
||||
[dev-packages]
|
||||
docxtpl = {editable = true, path = "."}
|
||||
flake8 = "*"
|
||||
|
||||
[requires]
|
||||
python_version = "3.6"
|
||||
python_version = "3"
|
||||
|
||||
514
Pipfile.lock
generated
514
Pipfile.lock
generated
@ -1,181 +1,419 @@
|
||||
{
|
||||
"_meta": {
|
||||
"hash": {
|
||||
"sha256": "23d67e77e436d7d9001f9d16dcfd0a12cc15bc444b55aace59c1d999efbb2680"
|
||||
"sha256": "0368a5e08ceb2b4910a110742515b5ff1d04a3a3af2b91b49d922ef9aaab6915"
|
||||
},
|
||||
"pipfile-spec": 6,
|
||||
"requires": {
|
||||
"python_version": "3.6"
|
||||
"python_version": "3"
|
||||
},
|
||||
"sources": [
|
||||
{
|
||||
"name": "pypi",
|
||||
"url": "https://pypi.python.org/simple",
|
||||
"url": "https://pypi.org/simple",
|
||||
"verify_ssl": true
|
||||
}
|
||||
]
|
||||
},
|
||||
"default": {
|
||||
"flake8": {
|
||||
"hashes": [
|
||||
"sha256:749dbbd6bfd0cf1318af27bf97a14e28e5ff548ef8e5b1566ccfb25a11e7c839",
|
||||
"sha256:aadae8761ec651813c24be05c6f7b4680857ef6afaae4651a4eccaef97ce6c3b"
|
||||
],
|
||||
"index": "pypi",
|
||||
"version": "==3.8.4"
|
||||
},
|
||||
"importlib-metadata": {
|
||||
"hashes": [
|
||||
"sha256:77a540690e24b0305878c37ffd421785a6f7e53c8b5720d211b211de8d0e95da",
|
||||
"sha256:cefa1a2f919b866c5beb7c9f7b0ebb4061f30a8a9bf16d609b000e2dfaceb9c3"
|
||||
],
|
||||
"markers": "python_version < '3.8'",
|
||||
"version": "==2.0.0"
|
||||
},
|
||||
"mccabe": {
|
||||
"hashes": [
|
||||
"sha256:ab8a6258860da4b6677da4bd2fe5dc2c659cff31b3ee4f7f5d64e79735b80d42",
|
||||
"sha256:dd8d182285a0fe56bace7f45b5e7d1a6ebcbf524e8f3bd87eb0f125271b8831f"
|
||||
],
|
||||
"version": "==0.6.1"
|
||||
},
|
||||
"pycodestyle": {
|
||||
"hashes": [
|
||||
"sha256:2295e7b2f6b5bd100585ebcb1f616591b652db8a741695b3d8f5d28bdc934367",
|
||||
"sha256:c58a7d2815e0e8d7972bf1803331fb0152f867bd89adf8a01dfd55085434192e"
|
||||
],
|
||||
"markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'",
|
||||
"version": "==2.6.0"
|
||||
},
|
||||
"pyflakes": {
|
||||
"hashes": [
|
||||
"sha256:0d94e0e05a19e57a99444b6ddcf9a6eb2e5c68d3ca1e98e90707af8152c90a92",
|
||||
"sha256:35b2d75ee967ea93b55750aa9edbbf72813e06a66ba54438df2cfac9e3c27fc8"
|
||||
],
|
||||
"markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'",
|
||||
"version": "==2.2.0"
|
||||
},
|
||||
"zipp": {
|
||||
"hashes": [
|
||||
"sha256:102c24ef8f171fd729d46599845e95c7ab894a4cf45f5de11a44cc7444fb1108",
|
||||
"sha256:ed5eee1974372595f9e416cc7bbeeb12335201d8081ca8a0743c954d4446e5cb"
|
||||
],
|
||||
"markers": "python_version >= '3.6'",
|
||||
"version": "==3.4.0"
|
||||
}
|
||||
},
|
||||
"default": {},
|
||||
"develop": {
|
||||
"babel": {
|
||||
"hashes": [
|
||||
"sha256:0c54cffb19f690cdcc52a3b50bcbf71e07a808d1c80d549f2459b9d2cf0afb9d",
|
||||
"sha256:4d0b53093fdfb4b21c92b5213dba5a1b23885afa8383709427046b21c366e5f2"
|
||||
],
|
||||
"markers": "python_version >= '3.8'",
|
||||
"version": "==2.17.0"
|
||||
},
|
||||
"black": {
|
||||
"hashes": [
|
||||
"sha256:14b3502784f09ce2443830e3133dacf2c0110d45191ed470ecb04d0f5f6fcb0f",
|
||||
"sha256:17374989640fbca88b6a448129cd1745c5eb8d9547b464f281b251dd00155ccd",
|
||||
"sha256:1c536fcf674217e87b8cc3657b81809d3c085d7bf3ef262ead700da345bfa6ea",
|
||||
"sha256:1cbacacb19e922a1d75ef2b6ccaefcd6e93a2c05ede32f06a21386a04cedb981",
|
||||
"sha256:1f93102e0c5bb3907451063e08b9876dbeac810e7da5a8bfb7aeb5a9ef89066b",
|
||||
"sha256:2cd9c95431d94adc56600710f8813ee27eea544dd118d45896bb734e9d7a0dc7",
|
||||
"sha256:30d2c30dc5139211dda799758559d1b049f7f14c580c409d6ad925b74a4208a8",
|
||||
"sha256:394d4ddc64782e51153eadcaaca95144ac4c35e27ef9b0a42e121ae7e57a9175",
|
||||
"sha256:3bb2b7a1f7b685f85b11fed1ef10f8a9148bceb49853e47a294a3dd963c1dd7d",
|
||||
"sha256:4007b1393d902b48b36958a216c20c4482f601569d19ed1df294a496eb366392",
|
||||
"sha256:5a2221696a8224e335c28816a9d331a6c2ae15a2ee34ec857dcf3e45dbfa99ad",
|
||||
"sha256:63f626344343083322233f175aaf372d326de8436f5928c042639a4afbbf1d3f",
|
||||
"sha256:649fff99a20bd06c6f727d2a27f401331dc0cc861fb69cde910fe95b01b5928f",
|
||||
"sha256:680359d932801c76d2e9c9068d05c6b107f2584b2a5b88831c83962eb9984c1b",
|
||||
"sha256:846ea64c97afe3bc677b761787993be4991810ecc7a4a937816dd6bddedc4875",
|
||||
"sha256:b5e39e0fae001df40f95bd8cc36b9165c5e2ea88900167bddf258bacef9bbdc3",
|
||||
"sha256:ccfa1d0cb6200857f1923b602f978386a3a2758a65b52e0950299ea014be6800",
|
||||
"sha256:d37d422772111794b26757c5b55a3eade028aa3fde43121ab7b673d050949d65",
|
||||
"sha256:ddacb691cdcdf77b96f549cf9591701d8db36b2f19519373d60d31746068dbf2",
|
||||
"sha256:e6668650ea4b685440857138e5fe40cde4d652633b1bdffc62933d0db4ed9812",
|
||||
"sha256:f9da3333530dbcecc1be13e69c250ed8dfa67f43c4005fb537bb426e19200d50",
|
||||
"sha256:fe4d6476887de70546212c99ac9bd803d90b42fc4767f058a0baa895013fbb3e"
|
||||
],
|
||||
"markers": "python_version >= '3.9'",
|
||||
"version": "==24.10.0"
|
||||
},
|
||||
"click": {
|
||||
"hashes": [
|
||||
"sha256:63c132bbbed01578a06712a2d1f497bb62d9c1c0d329b7903a866228027263b2",
|
||||
"sha256:ed53c9d8990d83c2a27deae68e4ee337473f6330c040a31d4225c9574d16096a"
|
||||
],
|
||||
"markers": "python_version >= '3.7'",
|
||||
"version": "==8.1.8"
|
||||
},
|
||||
"docxcompose": {
|
||||
"hashes": [
|
||||
"sha256:bcf2799a0b63c29eb77a3d799a2f28443ae0f69f8691ff3d753f706be515c3e9"
|
||||
],
|
||||
"version": "==1.4.0"
|
||||
},
|
||||
"docxtpl": {
|
||||
"editable": true,
|
||||
"path": "."
|
||||
},
|
||||
"e1839a8": {
|
||||
"editable": true,
|
||||
"path": "."
|
||||
"flake8": {
|
||||
"hashes": [
|
||||
"sha256:93b92ba5bdb60754a6da14fa3b93a9361fd00a59632ada61fd7b130436c40343",
|
||||
"sha256:fa558ae3f6f7dbf2b4f22663e5343b6b6023620461f8d4ff2019ef4b5ee70426"
|
||||
],
|
||||
"index": "pypi",
|
||||
"version": "==7.2.0"
|
||||
},
|
||||
"jinja2": {
|
||||
"hashes": [
|
||||
"sha256:89aab215427ef59c34ad58735269eb58b1a5808103067f7bb9d5836c651b3bb0",
|
||||
"sha256:f0a4641d3cf955324a89c04f3d94663aa4d638abe8f733ecd3582848e1c37035"
|
||||
"sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d",
|
||||
"sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67"
|
||||
],
|
||||
"markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'",
|
||||
"version": "==2.11.2"
|
||||
"markers": "python_version >= '3.7'",
|
||||
"version": "==3.1.6"
|
||||
},
|
||||
"lxml": {
|
||||
"hashes": [
|
||||
"sha256:098fb713b31050463751dcc694878e1d39f316b86366fb9fe3fbbe5396ac9fab",
|
||||
"sha256:0e89f5d422988c65e6936e4ec0fe54d6f73f3128c80eb7ecc3b87f595523607b",
|
||||
"sha256:189ad47203e846a7a4951c17694d845b6ade7917c47c64b29b86526eefc3adf5",
|
||||
"sha256:1d87936cb5801c557f3e981c9c193861264c01209cb3ad0964a16310ca1b3301",
|
||||
"sha256:211b3bcf5da70c2d4b84d09232534ad1d78320762e2c59dedc73bf01cb1fc45b",
|
||||
"sha256:2358809cc64394617f2719147a58ae26dac9e21bae772b45cfb80baa26bfca5d",
|
||||
"sha256:23c83112b4dada0b75789d73f949dbb4e8f29a0a3511647024a398ebd023347b",
|
||||
"sha256:24e811118aab6abe3ce23ff0d7d38932329c513f9cef849d3ee88b0f848f2aa9",
|
||||
"sha256:2d5896ddf5389560257bbe89317ca7bcb4e54a02b53a3e572e1ce4226512b51b",
|
||||
"sha256:2d6571c48328be4304aee031d2d5046cbc8aed5740c654575613c5a4f5a11311",
|
||||
"sha256:2e311a10f3e85250910a615fe194839a04a0f6bc4e8e5bb5cac221344e3a7891",
|
||||
"sha256:302160eb6e9764168e01d8c9ec6becddeb87776e81d3fcb0d97954dd51d48e0a",
|
||||
"sha256:3a7a380bfecc551cfd67d6e8ad9faa91289173bdf12e9cfafbd2bdec0d7b1ec1",
|
||||
"sha256:3d9b2b72eb0dbbdb0e276403873ecfae870599c83ba22cadff2db58541e72856",
|
||||
"sha256:475325e037fdf068e0c2140b818518cf6bc4aa72435c407a798b2db9f8e90810",
|
||||
"sha256:4b7572145054330c8e324a72d808c8c8fbe12be33368db28c39a255ad5f7fb51",
|
||||
"sha256:4fff34721b628cce9eb4538cf9a73d02e0f3da4f35a515773cce6f5fe413b360",
|
||||
"sha256:56eff8c6fb7bc4bcca395fdff494c52712b7a57486e4fbde34c31bb9da4c6cc4",
|
||||
"sha256:573b2f5496c7e9f4985de70b9bbb4719ffd293d5565513e04ac20e42e6e5583f",
|
||||
"sha256:7ecaef52fd9b9535ae5f01a1dd2651f6608e4ec9dc136fc4dfe7ebe3c3ddb230",
|
||||
"sha256:803a80d72d1f693aa448566be46ffd70882d1ad8fc689a2e22afe63035eb998a",
|
||||
"sha256:8862d1c2c020cb7a03b421a9a7b4fe046a208db30994fc8ff68c627a7915987f",
|
||||
"sha256:9b06690224258db5cd39a84e993882a6874676f5de582da57f3df3a82ead9174",
|
||||
"sha256:a71400b90b3599eb7bf241f947932e18a066907bf84617d80817998cee81e4bf",
|
||||
"sha256:bb252f802f91f59767dcc559744e91efa9df532240a502befd874b54571417bd",
|
||||
"sha256:be1ebf9cc25ab5399501c9046a7dcdaa9e911802ed0e12b7d620cd4bbf0518b3",
|
||||
"sha256:be7c65e34d1b50ab7093b90427cbc488260e4b3a38ef2435d65b62e9fa3d798a",
|
||||
"sha256:c0dac835c1a22621ffa5e5f999d57359c790c52bbd1c687fe514ae6924f65ef5",
|
||||
"sha256:c152b2e93b639d1f36ec5a8ca24cde4a8eefb2b6b83668fcd8e83a67badcb367",
|
||||
"sha256:d182eada8ea0de61a45a526aa0ae4bcd222f9673424e65315c35820291ff299c",
|
||||
"sha256:d18331ea905a41ae71596502bd4c9a2998902328bbabd29e3d0f5f8569fabad1",
|
||||
"sha256:d20d32cbb31d731def4b1502294ca2ee99f9249b63bc80e03e67e8f8e126dea8",
|
||||
"sha256:d4ad7fd3269281cb471ad6c7bafca372e69789540d16e3755dd717e9e5c9d82f",
|
||||
"sha256:d6f8c23f65a4bfe4300b85f1f40f6c32569822d08901db3b6454ab785d9117cc",
|
||||
"sha256:d84d741c6e35c9f3e7406cb7c4c2e08474c2a6441d59322a00dcae65aac6315d",
|
||||
"sha256:e65c221b2115a91035b55a593b6eb94aa1206fa3ab374f47c6dc10d364583ff9",
|
||||
"sha256:f98b6f256be6cec8dd308a8563976ddaff0bdc18b730720f6f4bee927ffe926f"
|
||||
"sha256:00b8686694423ddae324cf614e1b9659c2edb754de617703c3d29ff568448df5",
|
||||
"sha256:073eb6dcdf1f587d9b88c8c93528b57eccda40209cf9be549d469b942b41d70b",
|
||||
"sha256:09846782b1ef650b321484ad429217f5154da4d6e786636c38e434fa32e94e49",
|
||||
"sha256:0a01ce7d8479dce84fc03324e3b0c9c90b1ece9a9bb6a1b6c9025e7e4520e78c",
|
||||
"sha256:0be91891bdb06ebe65122aa6bf3fc94489960cf7e03033c6f83a90863b23c58b",
|
||||
"sha256:0cef4feae82709eed352cd7e97ae062ef6ae9c7b5dbe3663f104cd2c0e8d94ba",
|
||||
"sha256:0e108352e203c7afd0eb91d782582f00a0b16a948d204d4dec8565024fafeea5",
|
||||
"sha256:0ea0252b51d296a75f6118ed0d8696888e7403408ad42345d7dfd0d1e93309a7",
|
||||
"sha256:0fce1294a0497edb034cb416ad3e77ecc89b313cff7adbee5334e4dc0d11f422",
|
||||
"sha256:1320091caa89805df7dcb9e908add28166113dcd062590668514dbd510798c88",
|
||||
"sha256:142accb3e4d1edae4b392bd165a9abdee8a3c432a2cca193df995bc3886249c8",
|
||||
"sha256:14479c2ad1cb08b62bb941ba8e0e05938524ee3c3114644df905d2331c76cd57",
|
||||
"sha256:151d6c40bc9db11e960619d2bf2ec5829f0aaffb10b41dcf6ad2ce0f3c0b2325",
|
||||
"sha256:15a665ad90054a3d4f397bc40f73948d48e36e4c09f9bcffc7d90c87410e478a",
|
||||
"sha256:1a42b3a19346e5601d1b8296ff6ef3d76038058f311902edd574461e9c036982",
|
||||
"sha256:1af80c6316ae68aded77e91cd9d80648f7dd40406cef73df841aa3c36f6907c8",
|
||||
"sha256:1b717b00a71b901b4667226bba282dd462c42ccf618ade12f9ba3674e1fabc55",
|
||||
"sha256:1dc4ca99e89c335a7ed47d38964abcb36c5910790f9bd106f2a8fa2ee0b909d2",
|
||||
"sha256:20e16c08254b9b6466526bc1828d9370ee6c0d60a4b64836bc3ac2917d1e16df",
|
||||
"sha256:226046e386556a45ebc787871d6d2467b32c37ce76c2680f5c608e25823ffc84",
|
||||
"sha256:24974f774f3a78ac12b95e3a20ef0931795ff04dbb16db81a90c37f589819551",
|
||||
"sha256:24f6df5f24fc3385f622c0c9d63fe34604893bc1a5bdbb2dbf5870f85f9a404a",
|
||||
"sha256:27a9ded0f0b52098ff89dd4c418325b987feed2ea5cc86e8860b0f844285d740",
|
||||
"sha256:29f451a4b614a7b5b6c2e043d7b64a15bd8304d7e767055e8ab68387a8cacf4e",
|
||||
"sha256:2b31a3a77501d86d8ade128abb01082724c0dfd9524f542f2f07d693c9f1175f",
|
||||
"sha256:2c62891b1ea3094bb12097822b3d44b93fc6c325f2043c4d2736a8ff09e65f60",
|
||||
"sha256:2dc191e60425ad70e75a68c9fd90ab284df64d9cd410ba8d2b641c0c45bc006e",
|
||||
"sha256:31e63621e073e04697c1b2d23fcb89991790eef370ec37ce4d5d469f40924ed6",
|
||||
"sha256:32697d2ea994e0db19c1df9e40275ffe84973e4232b5c274f47e7c1ec9763cdd",
|
||||
"sha256:3a3178b4873df8ef9457a4875703488eb1622632a9cee6d76464b60e90adbfcd",
|
||||
"sha256:3b9c2754cef6963f3408ab381ea55f47dabc6f78f4b8ebb0f0b25cf1ac1f7609",
|
||||
"sha256:3d3c30ba1c9b48c68489dc1829a6eede9873f52edca1dda900066542528d6b20",
|
||||
"sha256:3e6d5557989cdc3ebb5302bbdc42b439733a841891762ded9514e74f60319ad6",
|
||||
"sha256:4025bf2884ac4370a3243c5aa8d66d3cb9e15d3ddd0af2d796eccc5f0244390e",
|
||||
"sha256:4291d3c409a17febf817259cb37bc62cb7eb398bcc95c1356947e2871911ae61",
|
||||
"sha256:4329422de653cdb2b72afa39b0aa04252fca9071550044904b2e7036d9d97fe4",
|
||||
"sha256:43d549b876ce64aa18b2328faff70f5877f8c6dede415f80a2f799d31644d776",
|
||||
"sha256:460508a4b07364d6abf53acaa0a90b6d370fafde5693ef37602566613a9b0779",
|
||||
"sha256:47fb24cc0f052f0576ea382872b3fc7e1f7e3028e53299ea751839418ade92a6",
|
||||
"sha256:48b4afaf38bf79109bb060d9016fad014a9a48fb244e11b94f74ae366a64d252",
|
||||
"sha256:497cab4d8254c2a90bf988f162ace2ddbfdd806fce3bda3f581b9d24c852e03c",
|
||||
"sha256:4aa412a82e460571fad592d0f93ce9935a20090029ba08eca05c614f99b0cc92",
|
||||
"sha256:4b7ce10634113651d6f383aa712a194179dcd496bd8c41e191cec2099fa09de5",
|
||||
"sha256:4cd915c0fb1bed47b5e6d6edd424ac25856252f09120e3e8ba5154b6b921860e",
|
||||
"sha256:4d885698f5019abe0de3d352caf9466d5de2baded00a06ef3f1216c1a58ae78f",
|
||||
"sha256:4f5322cf38fe0e21c2d73901abf68e6329dc02a4994e483adbcf92b568a09a54",
|
||||
"sha256:50441c9de951a153c698b9b99992e806b71c1f36d14b154592580ff4a9d0d877",
|
||||
"sha256:529024ab3a505fed78fe3cc5ddc079464e709f6c892733e3f5842007cec8ac6e",
|
||||
"sha256:53370c26500d22b45182f98847243efb518d268374a9570409d2e2276232fd37",
|
||||
"sha256:53d9469ab5460402c19553b56c3648746774ecd0681b1b27ea74d5d8a3ef5590",
|
||||
"sha256:56dbdbab0551532bb26c19c914848d7251d73edb507c3079d6805fa8bba5b706",
|
||||
"sha256:5a99d86351f9c15e4a901fc56404b485b1462039db59288b203f8c629260a142",
|
||||
"sha256:5cca36a194a4eb4e2ed6be36923d3cffd03dcdf477515dea687185506583d4c9",
|
||||
"sha256:5f11a1526ebd0dee85e7b1e39e39a0cc0d9d03fb527f56d8457f6df48a10dc0c",
|
||||
"sha256:61c7bbf432f09ee44b1ccaa24896d21075e533cd01477966a5ff5a71d88b2f56",
|
||||
"sha256:639978bccb04c42677db43c79bdaa23785dc7f9b83bfd87570da8207872f1ce5",
|
||||
"sha256:63e7968ff83da2eb6fdda967483a7a023aa497d85ad8f05c3ad9b1f2e8c84987",
|
||||
"sha256:664cdc733bc87449fe781dbb1f309090966c11cc0c0cd7b84af956a02a8a4729",
|
||||
"sha256:67ed8a40665b84d161bae3181aa2763beea3747f748bca5874b4af4d75998f87",
|
||||
"sha256:67f779374c6b9753ae0a0195a892a1c234ce8416e4448fe1e9f34746482070a7",
|
||||
"sha256:6854f8bd8a1536f8a1d9a3655e6354faa6406621cf857dc27b681b69860645c7",
|
||||
"sha256:696ea9e87442467819ac22394ca36cb3d01848dad1be6fac3fb612d3bd5a12cf",
|
||||
"sha256:6ef80aeac414f33c24b3815ecd560cee272786c3adfa5f31316d8b349bfade28",
|
||||
"sha256:72ac9762a9f8ce74c9eed4a4e74306f2f18613a6b71fa065495a67ac227b3056",
|
||||
"sha256:75133890e40d229d6c5837b0312abbe5bac1c342452cf0e12523477cd3aa21e7",
|
||||
"sha256:7605c1c32c3d6e8c990dd28a0970a3cbbf1429d5b92279e37fda05fb0c92190e",
|
||||
"sha256:773e27b62920199c6197130632c18fb7ead3257fce1ffb7d286912e56ddb79e0",
|
||||
"sha256:795f61bcaf8770e1b37eec24edf9771b307df3af74d1d6f27d812e15a9ff3872",
|
||||
"sha256:79d5bfa9c1b455336f52343130b2067164040604e41f6dc4d8313867ed540079",
|
||||
"sha256:7a62cc23d754bb449d63ff35334acc9f5c02e6dae830d78dab4dd12b78a524f4",
|
||||
"sha256:7be701c24e7f843e6788353c055d806e8bd8466b52907bafe5d13ec6a6dbaecd",
|
||||
"sha256:7ca56ebc2c474e8f3d5761debfd9283b8b18c76c4fc0967b74aeafba1f5647f9",
|
||||
"sha256:7ce1a171ec325192c6a636b64c94418e71a1964f56d002cc28122fceff0b6121",
|
||||
"sha256:8f82125bc7203c5ae8633a7d5d20bcfdff0ba33e436e4ab0abc026a53a8960b7",
|
||||
"sha256:91505d3ddebf268bb1588eb0f63821f738d20e1e7f05d3c647a5ca900288760b",
|
||||
"sha256:942a5d73f739ad7c452bf739a62a0f83e2578afd6b8e5406308731f4ce78b16d",
|
||||
"sha256:9454b8d8200ec99a224df8854786262b1bd6461f4280064c807303c642c05e76",
|
||||
"sha256:9459e6892f59ecea2e2584ee1058f5d8f629446eab52ba2305ae13a32a059530",
|
||||
"sha256:9776af1aad5a4b4a1317242ee2bea51da54b2a7b7b48674be736d463c999f37d",
|
||||
"sha256:97dac543661e84a284502e0cf8a67b5c711b0ad5fb661d1bd505c02f8cf716d7",
|
||||
"sha256:98a3912194c079ef37e716ed228ae0dcb960992100461b704aea4e93af6b0bb9",
|
||||
"sha256:9b4a3bd174cc9cdaa1afbc4620c049038b441d6ba07629d89a83b408e54c35cd",
|
||||
"sha256:9c886b481aefdf818ad44846145f6eaf373a20d200b5ce1a5c8e1bc2d8745410",
|
||||
"sha256:9ceaf423b50ecfc23ca00b7f50b64baba85fb3fb91c53e2c9d00bc86150c7e40",
|
||||
"sha256:a11a96c3b3f7551c8a8109aa65e8594e551d5a84c76bf950da33d0fb6dfafab7",
|
||||
"sha256:a3bcdde35d82ff385f4ede021df801b5c4a5bcdfb61ea87caabcebfc4945dc1b",
|
||||
"sha256:a7fb111eef4d05909b82152721a59c1b14d0f365e2be4c742a473c5d7372f4f5",
|
||||
"sha256:a81e1196f0a5b4167a8dafe3a66aa67c4addac1b22dc47947abd5d5c7a3f24b5",
|
||||
"sha256:a8c9b7f16b63e65bbba889acb436a1034a82d34fa09752d754f88d708eca80e1",
|
||||
"sha256:a8ef956fce64c8551221f395ba21d0724fed6b9b6242ca4f2f7beb4ce2f41997",
|
||||
"sha256:ab339536aa798b1e17750733663d272038bf28069761d5be57cb4a9b0137b4f8",
|
||||
"sha256:aea53d51859b6c64e7c51d522c03cc2c48b9b5d6172126854cc7f01aa11f52bc",
|
||||
"sha256:aea7c06667b987787c7d1f5e1dfcd70419b711cdb47d6b4bb4ad4b76777a0563",
|
||||
"sha256:aefe1a7cb852fa61150fcb21a8c8fcea7b58c4cb11fbe59c97a0a4b31cae3c8c",
|
||||
"sha256:b0989737a3ba6cf2a16efb857fb0dfa20bc5c542737fddb6d893fde48be45433",
|
||||
"sha256:b108134b9667bcd71236c5a02aad5ddd073e372fb5d48ea74853e009fe38acb6",
|
||||
"sha256:b12cb6527599808ada9eb2cd6e0e7d3d8f13fe7bbb01c6311255a15ded4c7ab4",
|
||||
"sha256:b5aff6f3e818e6bdbbb38e5967520f174b18f539c2b9de867b1e7fde6f8d95a4",
|
||||
"sha256:b67319b4aef1a6c56576ff544b67a2a6fbd7eaee485b241cabf53115e8908b8f",
|
||||
"sha256:b7c86884ad23d61b025989d99bfdd92a7351de956e01c61307cb87035960bcb1",
|
||||
"sha256:b92b69441d1bd39f4940f9eadfa417a25862242ca2c396b406f9272ef09cdcaa",
|
||||
"sha256:bcb7a1096b4b6b24ce1ac24d4942ad98f983cd3810f9711bcd0293f43a9d8b9f",
|
||||
"sha256:bda3ea44c39eb74e2488297bb39d47186ed01342f0022c8ff407c250ac3f498e",
|
||||
"sha256:be2ba4c3c5b7900246a8f866580700ef0d538f2ca32535e991027bdaba944063",
|
||||
"sha256:c5681160758d3f6ac5b4fea370495c48aac0989d6a0f01bb9a72ad8ef5ab75c4",
|
||||
"sha256:c5d32f5284012deaccd37da1e2cd42f081feaa76981f0eaa474351b68df813c5",
|
||||
"sha256:c6364038c519dffdbe07e3cf42e6a7f8b90c275d4d1617a69bb59734c1a2d571",
|
||||
"sha256:c70e93fba207106cb16bf852e421c37bbded92acd5964390aad07cb50d60f5cf",
|
||||
"sha256:ca755eebf0d9e62d6cb013f1261e510317a41bf4650f22963474a663fdfe02aa",
|
||||
"sha256:cccd007d5c95279e529c146d095f1d39ac05139de26c098166c4beb9374b0f4d",
|
||||
"sha256:ce9c671845de9699904b1e9df95acfe8dfc183f2310f163cdaa91a3535af95de",
|
||||
"sha256:d12832e1dbea4be280b22fd0ea7c9b87f0d8fc51ba06e92dc62d52f804f78ebd",
|
||||
"sha256:d2ed1b3cb9ff1c10e6e8b00941bb2e5bb568b307bfc6b17dffbbe8be5eecba86",
|
||||
"sha256:d5663bc1b471c79f5c833cffbc9b87d7bf13f87e055a5c86c363ccd2348d7e82",
|
||||
"sha256:d90b729fd2732df28130c064aac9bb8aff14ba20baa4aee7bd0795ff1187545f",
|
||||
"sha256:dc0af80267edc68adf85f2a5d9be1cdf062f973db6790c1d065e45025fa26140",
|
||||
"sha256:de5b4e1088523e2b6f730d0509a9a813355b7f5659d70eb4f319c76beea2e250",
|
||||
"sha256:de6f6bb8a7840c7bf216fb83eec4e2f79f7325eca8858167b68708b929ab2172",
|
||||
"sha256:df53330a3bff250f10472ce96a9af28628ff1f4efc51ccba351a8820bca2a8ba",
|
||||
"sha256:e094ec83694b59d263802ed03a8384594fcce477ce484b0cbcd0008a211ca751",
|
||||
"sha256:e794f698ae4c5084414efea0f5cc9f4ac562ec02d66e1484ff822ef97c2cadff",
|
||||
"sha256:e7bc6df34d42322c5289e37e9971d6ed114e3776b45fa879f734bded9d1fea9c",
|
||||
"sha256:eaf24066ad0b30917186420d51e2e3edf4b0e2ea68d8cd885b14dc8afdcf6556",
|
||||
"sha256:ecf4c4b83f1ab3d5a7ace10bafcb6f11df6156857a3c418244cef41ca9fa3e44",
|
||||
"sha256:ef5a7178fcc73b7d8c07229e89f8eb45b2908a9238eb90dcfc46571ccf0383b8",
|
||||
"sha256:f5cb182f6396706dc6cc1896dd02b1c889d644c081b0cdec38747573db88a7d7",
|
||||
"sha256:fa0e294046de09acd6146be0ed6727d1f42ded4ce3ea1e9a19c11b6774eea27c",
|
||||
"sha256:fb54f7c6bafaa808f27166569b1511fc42701a7713858dddc08afdde9746849e",
|
||||
"sha256:fd3be6481ef54b8cfd0e1e953323b7aa9d9789b94842d0e5b142ef4bb7999539"
|
||||
],
|
||||
"markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'",
|
||||
"version": "==4.6.1"
|
||||
"markers": "python_version >= '3.6'",
|
||||
"version": "==5.4.0"
|
||||
},
|
||||
"markupsafe": {
|
||||
"hashes": [
|
||||
"sha256:00bc623926325b26bb9605ae9eae8a215691f33cae5df11ca5424f06f2d1f473",
|
||||
"sha256:09027a7803a62ca78792ad89403b1b7a73a01c8cb65909cd876f7fcebd79b161",
|
||||
"sha256:09c4b7f37d6c648cb13f9230d847adf22f8171b1ccc4d5682398e77f40309235",
|
||||
"sha256:1027c282dad077d0bae18be6794e6b6b8c91d58ed8a8d89a89d59693b9131db5",
|
||||
"sha256:13d3144e1e340870b25e7b10b98d779608c02016d5184cfb9927a9f10c689f42",
|
||||
"sha256:24982cc2533820871eba85ba648cd53d8623687ff11cbb805be4ff7b4c971aff",
|
||||
"sha256:29872e92839765e546828bb7754a68c418d927cd064fd4708fab9fe9c8bb116b",
|
||||
"sha256:43a55c2930bbc139570ac2452adf3d70cdbb3cfe5912c71cdce1c2c6bbd9c5d1",
|
||||
"sha256:46c99d2de99945ec5cb54f23c8cd5689f6d7177305ebff350a58ce5f8de1669e",
|
||||
"sha256:500d4957e52ddc3351cabf489e79c91c17f6e0899158447047588650b5e69183",
|
||||
"sha256:535f6fc4d397c1563d08b88e485c3496cf5784e927af890fb3c3aac7f933ec66",
|
||||
"sha256:596510de112c685489095da617b5bcbbac7dd6384aeebeda4df6025d0256a81b",
|
||||
"sha256:62fe6c95e3ec8a7fad637b7f3d372c15ec1caa01ab47926cfdf7a75b40e0eac1",
|
||||
"sha256:6788b695d50a51edb699cb55e35487e430fa21f1ed838122d722e0ff0ac5ba15",
|
||||
"sha256:6dd73240d2af64df90aa7c4e7481e23825ea70af4b4922f8ede5b9e35f78a3b1",
|
||||
"sha256:717ba8fe3ae9cc0006d7c451f0bb265ee07739daf76355d06366154ee68d221e",
|
||||
"sha256:79855e1c5b8da654cf486b830bd42c06e8780cea587384cf6545b7d9ac013a0b",
|
||||
"sha256:7c1699dfe0cf8ff607dbdcc1e9b9af1755371f92a68f706051cc8c37d447c905",
|
||||
"sha256:88e5fcfb52ee7b911e8bb6d6aa2fd21fbecc674eadd44118a9cc3863f938e735",
|
||||
"sha256:8defac2f2ccd6805ebf65f5eeb132adcf2ab57aa11fdf4c0dd5169a004710e7d",
|
||||
"sha256:98c7086708b163d425c67c7a91bad6e466bb99d797aa64f965e9d25c12111a5e",
|
||||
"sha256:9add70b36c5666a2ed02b43b335fe19002ee5235efd4b8a89bfcf9005bebac0d",
|
||||
"sha256:9bf40443012702a1d2070043cb6291650a0841ece432556f784f004937f0f32c",
|
||||
"sha256:ade5e387d2ad0d7ebf59146cc00c8044acbd863725f887353a10df825fc8ae21",
|
||||
"sha256:b00c1de48212e4cc9603895652c5c410df699856a2853135b3967591e4beebc2",
|
||||
"sha256:b1282f8c00509d99fef04d8ba936b156d419be841854fe901d8ae224c59f0be5",
|
||||
"sha256:b2051432115498d3562c084a49bba65d97cf251f5a331c64a12ee7e04dacc51b",
|
||||
"sha256:ba59edeaa2fc6114428f1637ffff42da1e311e29382d81b339c1817d37ec93c6",
|
||||
"sha256:c8716a48d94b06bb3b2524c2b77e055fb313aeb4ea620c8dd03a105574ba704f",
|
||||
"sha256:cd5df75523866410809ca100dc9681e301e3c27567cf498077e8551b6d20e42f",
|
||||
"sha256:cdb132fc825c38e1aeec2c8aa9338310d29d337bebbd7baa06889d09a60a1fa2",
|
||||
"sha256:e249096428b3ae81b08327a63a485ad0878de3fb939049038579ac0ef61e17e7",
|
||||
"sha256:e8313f01ba26fbbe36c7be1966a7b7424942f670f38e666995b88d012765b9be"
|
||||
"sha256:0bff5e0ae4ef2e1ae4fdf2dfd5b76c75e5c2fa4132d05fc1b0dabcd20c7e28c4",
|
||||
"sha256:0f4ca02bea9a23221c0182836703cbf8930c5e9454bacce27e767509fa286a30",
|
||||
"sha256:1225beacc926f536dc82e45f8a4d68502949dc67eea90eab715dea3a21c1b5f0",
|
||||
"sha256:131a3c7689c85f5ad20f9f6fb1b866f402c445b220c19fe4308c0b147ccd2ad9",
|
||||
"sha256:15ab75ef81add55874e7ab7055e9c397312385bd9ced94920f2802310c930396",
|
||||
"sha256:1a9d3f5f0901fdec14d8d2f66ef7d035f2157240a433441719ac9a3fba440b13",
|
||||
"sha256:1c99d261bd2d5f6b59325c92c73df481e05e57f19837bdca8413b9eac4bd8028",
|
||||
"sha256:1e084f686b92e5b83186b07e8a17fc09e38fff551f3602b249881fec658d3eca",
|
||||
"sha256:2181e67807fc2fa785d0592dc2d6206c019b9502410671cc905d132a92866557",
|
||||
"sha256:2cb8438c3cbb25e220c2ab33bb226559e7afb3baec11c4f218ffa7308603c832",
|
||||
"sha256:3169b1eefae027567d1ce6ee7cae382c57fe26e82775f460f0b2778beaad66c0",
|
||||
"sha256:3809ede931876f5b2ec92eef964286840ed3540dadf803dd570c3b7e13141a3b",
|
||||
"sha256:38a9ef736c01fccdd6600705b09dc574584b89bea478200c5fbf112a6b0d5579",
|
||||
"sha256:3d79d162e7be8f996986c064d1c7c817f6df3a77fe3d6859f6f9e7be4b8c213a",
|
||||
"sha256:444dcda765c8a838eaae23112db52f1efaf750daddb2d9ca300bcae1039adc5c",
|
||||
"sha256:48032821bbdf20f5799ff537c7ac3d1fba0ba032cfc06194faffa8cda8b560ff",
|
||||
"sha256:4aa4e5faecf353ed117801a068ebab7b7e09ffb6e1d5e412dc852e0da018126c",
|
||||
"sha256:52305740fe773d09cffb16f8ed0427942901f00adedac82ec8b67752f58a1b22",
|
||||
"sha256:569511d3b58c8791ab4c2e1285575265991e6d8f8700c7be0e88f86cb0672094",
|
||||
"sha256:57cb5a3cf367aeb1d316576250f65edec5bb3be939e9247ae594b4bcbc317dfb",
|
||||
"sha256:5b02fb34468b6aaa40dfc198d813a641e3a63b98c2b05a16b9f80b7ec314185e",
|
||||
"sha256:6381026f158fdb7c72a168278597a5e3a5222e83ea18f543112b2662a9b699c5",
|
||||
"sha256:6af100e168aa82a50e186c82875a5893c5597a0c1ccdb0d8b40240b1f28b969a",
|
||||
"sha256:6c89876f41da747c8d3677a2b540fb32ef5715f97b66eeb0c6b66f5e3ef6f59d",
|
||||
"sha256:6e296a513ca3d94054c2c881cc913116e90fd030ad1c656b3869762b754f5f8a",
|
||||
"sha256:70a87b411535ccad5ef2f1df5136506a10775d267e197e4cf531ced10537bd6b",
|
||||
"sha256:7e94c425039cde14257288fd61dcfb01963e658efbc0ff54f5306b06054700f8",
|
||||
"sha256:846ade7b71e3536c4e56b386c2a47adf5741d2d8b94ec9dc3e92e5e1ee1e2225",
|
||||
"sha256:88416bd1e65dcea10bc7569faacb2c20ce071dd1f87539ca2ab364bf6231393c",
|
||||
"sha256:88b49a3b9ff31e19998750c38e030fc7bb937398b1f78cfa599aaef92d693144",
|
||||
"sha256:8c4e8c3ce11e1f92f6536ff07154f9d49677ebaaafc32db9db4620bc11ed480f",
|
||||
"sha256:8e06879fc22a25ca47312fbe7c8264eb0b662f6db27cb2d3bbbc74b1df4b9b87",
|
||||
"sha256:9025b4018f3a1314059769c7bf15441064b2207cb3f065e6ea1e7359cb46db9d",
|
||||
"sha256:93335ca3812df2f366e80509ae119189886b0f3c2b81325d39efdb84a1e2ae93",
|
||||
"sha256:9778bd8ab0a994ebf6f84c2b949e65736d5575320a17ae8984a77fab08db94cf",
|
||||
"sha256:9e2d922824181480953426608b81967de705c3cef4d1af983af849d7bd619158",
|
||||
"sha256:a123e330ef0853c6e822384873bef7507557d8e4a082961e1defa947aa59ba84",
|
||||
"sha256:a904af0a6162c73e3edcb969eeeb53a63ceeb5d8cf642fade7d39e7963a22ddb",
|
||||
"sha256:ad10d3ded218f1039f11a75f8091880239651b52e9bb592ca27de44eed242a48",
|
||||
"sha256:b424c77b206d63d500bcb69fa55ed8d0e6a3774056bdc4839fc9298a7edca171",
|
||||
"sha256:b5a6b3ada725cea8a5e634536b1b01c30bcdcd7f9c6fff4151548d5bf6b3a36c",
|
||||
"sha256:ba8062ed2cf21c07a9e295d5b8a2a5ce678b913b45fdf68c32d95d6c1291e0b6",
|
||||
"sha256:ba9527cdd4c926ed0760bc301f6728ef34d841f405abf9d4f959c478421e4efd",
|
||||
"sha256:bbcb445fa71794da8f178f0f6d66789a28d7319071af7a496d4d507ed566270d",
|
||||
"sha256:bcf3e58998965654fdaff38e58584d8937aa3096ab5354d493c77d1fdd66d7a1",
|
||||
"sha256:c0ef13eaeee5b615fb07c9a7dadb38eac06a0608b41570d8ade51c56539e509d",
|
||||
"sha256:cabc348d87e913db6ab4aa100f01b08f481097838bdddf7c7a84b7575b7309ca",
|
||||
"sha256:cdb82a876c47801bb54a690c5ae105a46b392ac6099881cdfb9f6e95e4014c6a",
|
||||
"sha256:cfad01eed2c2e0c01fd0ecd2ef42c492f7f93902e39a42fc9ee1692961443a29",
|
||||
"sha256:d16a81a06776313e817c951135cf7340a3e91e8c1ff2fac444cfd75fffa04afe",
|
||||
"sha256:d8213e09c917a951de9d09ecee036d5c7d36cb6cb7dbaece4c71a60d79fb9798",
|
||||
"sha256:e07c3764494e3776c602c1e78e298937c3315ccc9043ead7e685b7f2b8d47b3c",
|
||||
"sha256:e17c96c14e19278594aa4841ec148115f9c7615a47382ecb6b82bd8fea3ab0c8",
|
||||
"sha256:e444a31f8db13eb18ada366ab3cf45fd4b31e4db1236a4448f68778c1d1a5a2f",
|
||||
"sha256:e6a2a455bd412959b57a172ce6328d2dd1f01cb2135efda2e4576e8a23fa3b0f",
|
||||
"sha256:eaa0a10b7f72326f1372a713e73c3f739b524b3af41feb43e4921cb529f5929a",
|
||||
"sha256:eb7972a85c54febfb25b5c4b4f3af4dcc731994c7da0d8a0b4a6eb0640e1d178",
|
||||
"sha256:ee55d3edf80167e48ea11a923c7386f4669df67d7994554387f84e7d8b0a2bf0",
|
||||
"sha256:f3818cb119498c0678015754eba762e0d61e5b52d34c8b13d770f0719f7b1d79",
|
||||
"sha256:f8b3d067f2e40fe93e1ccdd6b2e1d16c43140e76f02fb1319a05cf2b79d99430",
|
||||
"sha256:fcabf5ff6eea076f859677f5f0b6b5c1a51e70a376b0579e0eadef8db48c6b50"
|
||||
],
|
||||
"markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'",
|
||||
"version": "==1.1.1"
|
||||
"markers": "python_version >= '3.9'",
|
||||
"version": "==3.0.2"
|
||||
},
|
||||
"mccabe": {
|
||||
"hashes": [
|
||||
"sha256:348e0240c33b60bbdf4e523192ef919f28cb2c3d7d5c7794f74009290f236325",
|
||||
"sha256:6c2d30ab6be0e4a46919781807b4f0d834ebdd6c6e3dca0bda5a15f863427b6e"
|
||||
],
|
||||
"markers": "python_version >= '3.6'",
|
||||
"version": "==0.7.0"
|
||||
},
|
||||
"mypy-extensions": {
|
||||
"hashes": [
|
||||
"sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505",
|
||||
"sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558"
|
||||
],
|
||||
"markers": "python_version >= '3.8'",
|
||||
"version": "==1.1.0"
|
||||
},
|
||||
"packaging": {
|
||||
"hashes": [
|
||||
"sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484",
|
||||
"sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f"
|
||||
],
|
||||
"markers": "python_version >= '3.8'",
|
||||
"version": "==25.0"
|
||||
},
|
||||
"pathspec": {
|
||||
"hashes": [
|
||||
"sha256:a0d503e138a4c123b27490a4f7beda6a01c6f288df0e4a8b79c7eb0dc7b4cc08",
|
||||
"sha256:a482d51503a1ab33b1c67a6c3813a26953dbdc71c31dacaef9a838c4e29f5712"
|
||||
],
|
||||
"markers": "python_version >= '3.8'",
|
||||
"version": "==0.12.1"
|
||||
},
|
||||
"platformdirs": {
|
||||
"hashes": [
|
||||
"sha256:a03875334331946f13c549dbd8f4bac7a13a50a895a0eb1e8c6a8ace80d40a94",
|
||||
"sha256:eb437d586b6a0986388f0d6f74aa0cde27b48d0e3d66843640bfb6bdcdb6e351"
|
||||
],
|
||||
"markers": "python_version >= '3.9'",
|
||||
"version": "==4.3.7"
|
||||
},
|
||||
"pycodestyle": {
|
||||
"hashes": [
|
||||
"sha256:35863c5974a271c7a726ed228a14a4f6daf49df369d8c50cd9a6f58a5e143ba9",
|
||||
"sha256:c8415bf09abe81d9c7f872502a6eee881fbe85d8763dd5b9924bb0a01d67efae"
|
||||
],
|
||||
"markers": "python_version >= '3.9'",
|
||||
"version": "==2.13.0"
|
||||
},
|
||||
"pyflakes": {
|
||||
"hashes": [
|
||||
"sha256:5039c8339cbb1944045f4ee5466908906180f13cc99cc9949348d10f82a5c32a",
|
||||
"sha256:6dfd61d87b97fba5dcfaaf781171ac16be16453be6d816147989e7f6e6a9576b"
|
||||
],
|
||||
"markers": "python_version >= '3.9'",
|
||||
"version": "==3.3.2"
|
||||
},
|
||||
"python-docx": {
|
||||
"hashes": [
|
||||
"sha256:bc76ecac6b2d00ce6442a69d03a6f35c71cd72293cd8405a7472dfe317920024"
|
||||
"sha256:08c20d6058916fb19853fcf080f7f42b6270d89eac9fa5f8c15f691c0017fabe",
|
||||
"sha256:0cf1f22e95b9002addca7948e16f2cd7acdfd498047f1941ca5d293db7762efd"
|
||||
],
|
||||
"version": "==0.8.10"
|
||||
"markers": "python_version >= '3.7'",
|
||||
"version": "==1.1.2"
|
||||
},
|
||||
"setuptools": {
|
||||
"hashes": [
|
||||
"sha256:2e308396e1d83de287ada2c2fd6e64286008fe6aca5008e0b6a8cb0e2c86eedd",
|
||||
"sha256:ea0e7655c05b74819f82e76e11a85b31779fee7c4969e82f72bab0664e8317e4"
|
||||
],
|
||||
"markers": "python_version >= '3.9'",
|
||||
"version": "==80.1.0"
|
||||
},
|
||||
"six": {
|
||||
"hashes": [
|
||||
"sha256:30639c035cdb23534cd4aa2dd52c3bf48f06e5f4a941509c8bafd8ce11080259",
|
||||
"sha256:8b74bedcbbbaca38ff6d7491d76f2b06b3592611af620f8426e82dddb04a5ced"
|
||||
"sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274",
|
||||
"sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81"
|
||||
],
|
||||
"markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'",
|
||||
"version": "==1.15.0"
|
||||
"version": "==1.17.0"
|
||||
},
|
||||
"tomli": {
|
||||
"hashes": [
|
||||
"sha256:023aa114dd824ade0100497eb2318602af309e5a55595f76b626d6d9f3b7b0a6",
|
||||
"sha256:02abe224de6ae62c19f090f68da4e27b10af2b93213d36cf44e6e1c5abd19fdd",
|
||||
"sha256:286f0ca2ffeeb5b9bd4fcc8d6c330534323ec51b2f52da063b11c502da16f30c",
|
||||
"sha256:2d0f2fdd22b02c6d81637a3c95f8cd77f995846af7414c5c4b8d0545afa1bc4b",
|
||||
"sha256:33580bccab0338d00994d7f16f4c4ec25b776af3ffaac1ed74e0b3fc95e885a8",
|
||||
"sha256:400e720fe168c0f8521520190686ef8ef033fb19fc493da09779e592861b78c6",
|
||||
"sha256:40741994320b232529c802f8bc86da4e1aa9f413db394617b9a256ae0f9a7f77",
|
||||
"sha256:465af0e0875402f1d226519c9904f37254b3045fc5084697cefb9bdde1ff99ff",
|
||||
"sha256:4a8f6e44de52d5e6c657c9fe83b562f5f4256d8ebbfe4ff922c495620a7f6cea",
|
||||
"sha256:4e340144ad7ae1533cb897d406382b4b6fede8890a03738ff1683af800d54192",
|
||||
"sha256:678e4fa69e4575eb77d103de3df8a895e1591b48e740211bd1067378c69e8249",
|
||||
"sha256:6972ca9c9cc9f0acaa56a8ca1ff51e7af152a9f87fb64623e31d5c83700080ee",
|
||||
"sha256:7fc04e92e1d624a4a63c76474610238576942d6b8950a2d7f908a340494e67e4",
|
||||
"sha256:889f80ef92701b9dbb224e49ec87c645ce5df3fa2cc548664eb8a25e03127a98",
|
||||
"sha256:8d57ca8095a641b8237d5b079147646153d22552f1c637fd3ba7f4b0b29167a8",
|
||||
"sha256:8dd28b3e155b80f4d54beb40a441d366adcfe740969820caf156c019fb5c7ec4",
|
||||
"sha256:9316dc65bed1684c9a98ee68759ceaed29d229e985297003e494aa825ebb0281",
|
||||
"sha256:a198f10c4d1b1375d7687bc25294306e551bf1abfa4eace6650070a5c1ae2744",
|
||||
"sha256:a38aa0308e754b0e3c67e344754dff64999ff9b513e691d0e786265c93583c69",
|
||||
"sha256:a92ef1a44547e894e2a17d24e7557a5e85a9e1d0048b0b5e7541f76c5032cb13",
|
||||
"sha256:ac065718db92ca818f8d6141b5f66369833d4a80a9d74435a268c52bdfa73140",
|
||||
"sha256:b82ebccc8c8a36f2094e969560a1b836758481f3dc360ce9a3277c65f374285e",
|
||||
"sha256:c954d2250168d28797dd4e3ac5cf812a406cd5a92674ee4c8f123c889786aa8e",
|
||||
"sha256:cb55c73c5f4408779d0cf3eef9f762b9c9f147a77de7b258bef0a5628adc85cc",
|
||||
"sha256:cd45e1dc79c835ce60f7404ec8119f2eb06d38b1deba146f07ced3bbc44505ff",
|
||||
"sha256:d3f5614314d758649ab2ab3a62d4f2004c825922f9e370b29416484086b264ec",
|
||||
"sha256:d920f33822747519673ee656a4b6ac33e382eca9d331c87770faa3eef562aeb2",
|
||||
"sha256:db2b95f9de79181805df90bedc5a5ab4c165e6ec3fe99f970d0e302f384ad222",
|
||||
"sha256:e59e304978767a54663af13c07b3d1af22ddee3bb2fb0618ca1593e4f593a106",
|
||||
"sha256:e85e99945e688e32d5a35c1ff38ed0b3f41f43fad8df0bdf79f72b2ba7bc5272",
|
||||
"sha256:ece47d672db52ac607a3d9599a9d48dcb2f2f735c6c2d1f34130085bb12b112a",
|
||||
"sha256:f4039b9cbc3048b2416cc57ab3bda989a6fcf9b36cf8937f01a6e731b64f80d7"
|
||||
],
|
||||
"markers": "python_version < '3.11'",
|
||||
"version": "==2.2.1"
|
||||
},
|
||||
"typing-extensions": {
|
||||
"hashes": [
|
||||
"sha256:a439e7c04b49fec3e5d3e2beaa21755cadbbdc391694e28ccdd36ca4a1408f8c",
|
||||
"sha256:e6c81219bd689f51865d9e372991c540bda33a0379d5573cddb9a3a23f7caaef"
|
||||
],
|
||||
"markers": "python_version < '3.11'",
|
||||
"version": "==4.13.2"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -21,10 +21,6 @@ You save the document as a .docx file (xml format) : it will be your .docx templ
|
||||
|
||||
Now you can use python-docx-template to generate as many word documents you want from this .docx template and context variables you will associate.
|
||||
|
||||
Share
|
||||
-----
|
||||
|
||||
If you like this project, please rate and share it here : http://rate.re/github/elapouya/python-docx-template
|
||||
|
||||
Documentation
|
||||
-------------
|
||||
@ -34,8 +30,9 @@ Please, `read the doc <http://docxtpl.readthedocs.org>`_
|
||||
Other projects
|
||||
--------------
|
||||
|
||||
Have a look at some of my other projects :
|
||||
If you like python-docx-template, please have a look at some of my other projects :
|
||||
|
||||
- `django-listing <https://github.com/elapouya/django-listing>`_ : A listing/table library on steroid for Djano
|
||||
- `python-textops3 <https://github.com/elapouya/python-textops3>`_ : Chainable text operations
|
||||
- `django-robohash-svg <https://github.com/elapouya/django-robohash-svg>`_ : Create svg robots avatars
|
||||
|
||||
|
||||
56
docs/conf.py
56
docs/conf.py
@ -26,33 +26,33 @@
|
||||
# extensions coming with Sphinx (named 'sphinx.ext.*') or your custom
|
||||
# ones.
|
||||
extensions = [
|
||||
'sphinx.ext.autodoc',
|
||||
"sphinx.ext.autodoc",
|
||||
]
|
||||
|
||||
# Add any paths that contain templates here, relative to this directory.
|
||||
templates_path = ['_templates']
|
||||
templates_path = ["_templates"]
|
||||
|
||||
# The suffix of source filenames.
|
||||
source_suffix = '.rst'
|
||||
source_suffix = ".rst"
|
||||
|
||||
# The encoding of source files.
|
||||
# source_encoding = 'utf-8-sig'
|
||||
|
||||
# The master toctree document.
|
||||
master_doc = 'index'
|
||||
master_doc = "index"
|
||||
|
||||
# General information about the project.
|
||||
project = u'python-docx-template'
|
||||
copyright = u'2015, Eric Lapouyade'
|
||||
project = "python-docx-template"
|
||||
copyright = "2015, Eric Lapouyade"
|
||||
|
||||
# The version info for the project you're documenting, acts as replacement for
|
||||
# |version| and |release|, also used in various other places throughout the
|
||||
# built documents.
|
||||
#
|
||||
# The short X.Y version.
|
||||
version = '0.9'
|
||||
version = "0.20"
|
||||
# The full version, including alpha/beta/rc tags.
|
||||
release = '0.9.x'
|
||||
release = "0.20.x"
|
||||
|
||||
# The language for content autogenerated by Sphinx. Refer to documentation
|
||||
# for a list of supported languages.
|
||||
@ -66,7 +66,7 @@ release = '0.9.x'
|
||||
|
||||
# List of patterns, relative to source directory, that match files and
|
||||
# directories to ignore when looking for source files.
|
||||
exclude_patterns = ['_build']
|
||||
exclude_patterns = ["_build"]
|
||||
|
||||
# The reST default role (used for this markup: `text`) to use for all
|
||||
# documents.
|
||||
@ -84,7 +84,7 @@ exclude_patterns = ['_build']
|
||||
# show_authors = False
|
||||
|
||||
# The name of the Pygments (syntax highlighting) style to use.
|
||||
pygments_style = 'sphinx'
|
||||
pygments_style = "sphinx"
|
||||
|
||||
# A list of ignored prefixes for module index sorting.
|
||||
# modindex_common_prefix = []
|
||||
@ -97,7 +97,7 @@ pygments_style = 'sphinx'
|
||||
|
||||
# The theme to use for HTML and HTML Help pages. See the documentation for
|
||||
# a list of builtin themes.
|
||||
html_theme = 'default'
|
||||
html_theme = "sphinx_book_theme"
|
||||
|
||||
# Theme options are theme-specific and customize the look and feel of a theme
|
||||
# further. For a list of options available for each theme, see the
|
||||
@ -126,7 +126,7 @@ html_theme = 'default'
|
||||
# Add any paths that contain custom static files (such as style sheets) here,
|
||||
# relative to this directory. They are copied after the builtin static files,
|
||||
# so a file named "default.css" will overwrite the builtin "default.css".
|
||||
html_static_path = ['_static']
|
||||
html_static_path = ["_static"]
|
||||
|
||||
# Add any extra paths that contain custom files (such as robots.txt or
|
||||
# .htaccess) here, relative to this directory. These files are copied
|
||||
@ -175,7 +175,7 @@ html_static_path = ['_static']
|
||||
# html_file_suffix = None
|
||||
|
||||
# Output file base name for HTML help builder.
|
||||
htmlhelp_basename = 'python-docx-templatedoc'
|
||||
htmlhelp_basename = "python-docx-templatedoc"
|
||||
|
||||
|
||||
# -- Options for LaTeX output ---------------------------------------------
|
||||
@ -193,8 +193,13 @@ latex_elements = {
|
||||
# (source start file, target name, title,
|
||||
# author, documentclass [howto, manual, or own class]).
|
||||
latex_documents = [
|
||||
('index', 'python-docx-template.tex', u'python-docx-template Documentation',
|
||||
u'Eric Lapouyade', 'manual'),
|
||||
(
|
||||
"index",
|
||||
"python-docx-template.tex",
|
||||
"python-docx-template Documentation",
|
||||
"Eric Lapouyade",
|
||||
"manual",
|
||||
),
|
||||
]
|
||||
|
||||
# The name of an image file (relative to this directory) to place at the top of
|
||||
@ -223,8 +228,13 @@ latex_documents = [
|
||||
# One entry per manual page. List of tuples
|
||||
# (source start file, name, description, authors, manual section).
|
||||
man_pages = [
|
||||
('index', 'python-docx-template', u'python-docx-template Documentation',
|
||||
[u'Eric Lapouyade'], 1)
|
||||
(
|
||||
"index",
|
||||
"python-docx-template",
|
||||
"python-docx-template Documentation",
|
||||
["Eric Lapouyade"],
|
||||
1,
|
||||
)
|
||||
]
|
||||
|
||||
# If true, show URL addresses after external links.
|
||||
@ -237,9 +247,15 @@ man_pages = [
|
||||
# (source start file, target name, title, author,
|
||||
# dir menu entry, description, category)
|
||||
texinfo_documents = [
|
||||
('index', 'python-docx-template', u'python-docx-template Documentation',
|
||||
u'Eric Lapouyade', 'python-docx-template', 'One line description of project.',
|
||||
'Miscellaneous'),
|
||||
(
|
||||
"index",
|
||||
"python-docx-template",
|
||||
"python-docx-template Documentation",
|
||||
"Eric Lapouyade",
|
||||
"python-docx-template",
|
||||
"One line description of project.",
|
||||
"Miscellaneous",
|
||||
),
|
||||
]
|
||||
|
||||
# Documents to append as an appendix to all manuals.
|
||||
|
||||
281
docs/index.rst
281
docs/index.rst
@ -42,8 +42,6 @@ You save the document as a .docx file (xml format) : it will be your .docx templ
|
||||
|
||||
Now you can use python-docx-template to generate as many word documents you want from this .docx template and context variables you will associate.
|
||||
|
||||
Note : python-docx-template as been tested with MS Word 97, it may not work with other version.
|
||||
|
||||
Jinja2-like syntax
|
||||
------------------
|
||||
|
||||
@ -64,51 +62,102 @@ MS Word will create internally only one 'run' in the paragraph. Now,
|
||||
if you put in bold a text in the middle of this paragraph,
|
||||
word will transform the previous 'run' into 3 different 'runs' (normal - bold - normal).
|
||||
|
||||
**Important:**
|
||||
|
||||
Always put space after a jinja2 starting var/tag delimiter and a space before the ending one :
|
||||
|
||||
Avoid::
|
||||
|
||||
{{myvariable}}
|
||||
{%if something%}
|
||||
|
||||
Use instead::
|
||||
|
||||
{{ myvariable }}
|
||||
{% if something %}
|
||||
|
||||
Extensions
|
||||
++++++++++
|
||||
|
||||
Tags
|
||||
....
|
||||
|
||||
In order to manage paragraphs, table rows, table columns, runs, special syntax has to be used ::
|
||||
In order to manage paragraphs, table rows, table columns, runs, special syntax has to be used::
|
||||
|
||||
{%p jinja2_tag %} for paragraphs
|
||||
{%tr jinja2_tag %} for table rows
|
||||
{%tc jinja2_tag %} for table columns
|
||||
{%r jinja2_tag %} for runs
|
||||
|
||||
By using these tags, python-docx-template will take care to put the real jinja2 tags at the right place into the document's xml source code.
|
||||
In addition, these tags also tell python-docx-template to **remove** the paragraph, table row, table column or run where the begin and ending tags are located and only takes care about what is in between.
|
||||
By using these tags, python-docx-template will take care to put the real jinja2 tags (without the `p`, `tr`, `tc` or `r`) at the right place into the document's xml source code.
|
||||
In addition, these tags also tell python-docx-template to **remove** the paragraph, table row, table column or run where the tags are located.
|
||||
|
||||
For example, if you have this kind of template::
|
||||
|
||||
{%p if display_paragraph %}
|
||||
One or many paragraphs
|
||||
{%p endif %}
|
||||
|
||||
The first and last paragraphs (those containing ``{%p ... %}`` tags) will never appear in generated docx, regardless of the ``display_paragraph`` value.
|
||||
|
||||
Here only::
|
||||
|
||||
One or many paragraphs
|
||||
|
||||
will appear in generated docx if ``display_paragraph`` is True, otherwise, no paragraph at all are displayed.
|
||||
|
||||
**IMPORTANT :** Always put space after a starting tag delimiter and a space before the ending one :
|
||||
|
||||
Avoid::
|
||||
|
||||
{%if something%}
|
||||
{%pif display_paragraph%}
|
||||
|
||||
Use instead::
|
||||
|
||||
{% if something %}
|
||||
{%p if display_paragraph %}
|
||||
|
||||
**IMPORTANT** : Do not use ``{%p``, ``{%tr``, ``{%tc`` or ``{%r`` twice in the same
|
||||
paragraph, row, column or run. Example :
|
||||
|
||||
Do not use this ::
|
||||
Do not use this::
|
||||
|
||||
{%p if display_paragraph %}Here is my paragraph {%p endif %}
|
||||
|
||||
But use this instead in your docx template ::
|
||||
But use this instead in your docx template::
|
||||
|
||||
{%p if display_paragraph %}
|
||||
Here is my paragraph
|
||||
{%p endif %}
|
||||
|
||||
This syntax is possible because MS Word considers each line as a new paragraph and
|
||||
``{%p`` tags are not in the same paragraph in the second case.
|
||||
This syntax is possible because MS Word considers each line as a new paragraph (if you do not use SHIFT-RETURN).
|
||||
|
||||
Display variables
|
||||
.................
|
||||
|
||||
As part of jinja2, one can used double braces::
|
||||
|
||||
{{ <var> }}
|
||||
|
||||
if ``<var>`` is a string, ``\n``, ``\a``, ``\t`` and ``\f`` will be translated respectively into newlines, new paragraphs, tabs and page breaks
|
||||
|
||||
But if ``<var>`` is a RichText_ object, you must specify that you are changing the actual 'run'::
|
||||
|
||||
{{r <var> }}
|
||||
|
||||
Note the ``r`` right after the opening braces.
|
||||
|
||||
**VERY IMPORTANT :** Variables must not contains characters like ``<``, ``>`` and ``&`` unless using Escaping_
|
||||
|
||||
**IMPORTANT :** Always put space after a starting var delimiter and a space before the ending one :
|
||||
|
||||
Avoid::
|
||||
|
||||
{{myvariable}}
|
||||
{{rmyrichtext}}
|
||||
|
||||
Use instead::
|
||||
|
||||
{{ myvariable }}
|
||||
{{r myrichtext }}
|
||||
|
||||
Comments
|
||||
........
|
||||
|
||||
You can add jinja-like comments in your template::
|
||||
|
||||
{#p this is a comment as a paragraph #}
|
||||
{#tr this is a comment as a table row #}
|
||||
{#tc this is a comment as a table cell #}
|
||||
|
||||
See tests/templates/comments_tpl.docx for an example.
|
||||
|
||||
Split and merge text
|
||||
....................
|
||||
@ -134,33 +183,35 @@ One can use *ENTER* or *SHIFT+ENTER* to split a text like below, then use ``{%-`
|
||||
|
||||
**IMPORTANT 2 :** ``{%- xxx -%}`` tags must be alone in a line : do not add some text before or after on the same line.
|
||||
|
||||
Escaping delimiters
|
||||
...................
|
||||
|
||||
Display variables
|
||||
.................
|
||||
In order to display ``{%``, ``%}``, ``{{`` or ``}}``, one can use::
|
||||
|
||||
As part of jinja2, one can used double braces::
|
||||
{_%, %_}, {_{ or }_}
|
||||
|
||||
{{ <var> }}
|
||||
Tables
|
||||
......
|
||||
|
||||
if ``<var>`` is a string, ``\n``, ``\a``, ``\t`` and ``\f`` will be translated respectively into newlines, new paragraphs, tabs and page breaks
|
||||
Spanning
|
||||
~~~~~~~~
|
||||
|
||||
But if ``<var>`` is a RichText_ object, you must specify that you are changing the actual 'run'::
|
||||
You can span table cells horizontally in two ways, by using ``colspan`` tag (see tests/dynamic_table.py)::
|
||||
|
||||
{{r <var> }}
|
||||
{% colspan <var> %}
|
||||
|
||||
Note the ``r`` right after the openning braces.
|
||||
`<var>` must contain an integer for the number of columns to span. See tests/test_files/dynamic_table.py for an example.
|
||||
|
||||
**IMPORTANT** : Do not use the ``r`` variable in your template because ``{{r}}`` could be interpreted as a ``{{r``
|
||||
without variable specified. Nevertheless, you can use a bigger variable name starting
|
||||
with 'r'. For example ``{{render_color}}`` will be interpreted as ``{{ render_color }}`` not as ``{{r ender_color}}``.
|
||||
You can also span horizontally within a for loop (see tests/horizontal_merge.py)::
|
||||
|
||||
**IMPORTANT** : Do not use 2 times ``{{r`` in the same run. Use RichText.add()
|
||||
method to concatenate several strings and styles at python side and only one
|
||||
``{{r`` at template side.
|
||||
{% hm %}
|
||||
|
||||
You can also merge cells vertically within a for loop (see tests/vertical_merge.py)::
|
||||
|
||||
{% vm %}
|
||||
|
||||
Cell color
|
||||
..........
|
||||
~~~~~~~~~~
|
||||
|
||||
There is a special case when you want to change the background color of a table cell, you must put the following tag at the very beginning of the cell::
|
||||
|
||||
@ -168,24 +219,6 @@ There is a special case when you want to change the background color of a table
|
||||
|
||||
`<var>` must contain the color's hexadecimal code *without* the hash sign
|
||||
|
||||
Column spanning
|
||||
...............
|
||||
|
||||
If you want to dynamically span a table cell over many column (this is useful when you have a table with a dynamic column count),
|
||||
you must put the following tag at the very beginning of the cell to span::
|
||||
|
||||
{% colspan <var> %}
|
||||
|
||||
`<var>` must contain an integer for the number of columns to span. See tests/test_files/dynamic_table.py for an example.
|
||||
|
||||
Escaping
|
||||
........
|
||||
|
||||
In order to display ``{%``, ``%}``, ``{{`` or ``}}``, one can use::
|
||||
|
||||
{_%, %_}, {_{ or }_}
|
||||
|
||||
|
||||
.. _RichText:
|
||||
|
||||
RichText
|
||||
@ -194,14 +227,31 @@ RichText
|
||||
When you use ``{{ <var> }}`` tag in your template, it will be replaced by the string contained within `var` variable.
|
||||
BUT it will keep the current style.
|
||||
If you want to add dynamically changeable style, you have to use both : the ``{{r <var> }}`` tag AND a ``RichText`` object within `var` variable.
|
||||
You can change color, bold, italic, size and so on, but the best way is to use Microsoft Word to define your own *character* style
|
||||
You can change color, bold, italic, size, font and so on, but the best way is to use Microsoft Word to define your own *character* style
|
||||
( Home tab -> modify style -> manage style button -> New style, select ‘Character style’ in the form ), see example in `tests/richtext.py`
|
||||
Instead of using ``RichText()``, one can use its shortcut : ``R()``
|
||||
|
||||
The ``RichText()`` or ``R()`` offers newline, new paragraph, and page break features : just use ``\n``, ``\a``, ``\t`` or ``\f`` in the
|
||||
text, they will be converted accordingly.
|
||||
|
||||
There is a specific case for font: if your font is not displayed correctly, it may be because it is defined
|
||||
only for a region. To know your region, it requires a little work by analyzing the document.xml inside the docx template (this is a zip file).
|
||||
To specify a region, you have to prefix your font name this that region and a column::
|
||||
|
||||
ch = RichText('测试TEST', font='eastAsia:微软雅黑')
|
||||
|
||||
**Important** : When you use ``{{r }}`` it removes the current character styling from your docx template, this means that if
|
||||
you do not specify a style in ``RichText()``, the style will go back to a microsoft word default style.
|
||||
This will affect only character styles, not the paragraph styles (MSWord manages this 2 kind of styles).
|
||||
|
||||
**IMPORTANT** : Do not use 2 times ``{{r`` in the same run. Use RichText.add()
|
||||
method to concatenate several strings and styles at python side and only one
|
||||
``{{r`` at template side.
|
||||
|
||||
**Important** : ``RichText`` objects are rendered into xml *before* any filter is applied
|
||||
thus ``RichText`` are not compatible with Jinja2 filters. You cannot write in your template something like ``{{r <var>|lower }}``.
|
||||
Only solution is instead to do any filtering into your python code when creating the ``RichText`` object.
|
||||
|
||||
Hyperlink with RichText
|
||||
+++++++++++++++++++++++
|
||||
|
||||
@ -213,6 +263,14 @@ You can add an hyperlink to a text by using a Richtext with this syntax::
|
||||
|
||||
Put ``rt`` in your context, then use ``{{r rt}}`` in your template
|
||||
|
||||
RichTextParagraph
|
||||
-----------------
|
||||
|
||||
If you want to change paragraph properties, you can use ``RichTextParagraph()`` or ``RP()`` object.
|
||||
It must be added to the template by using ``{{p <var> }}``.
|
||||
Have a look to the example here ``tests/richtextparagraph.py``.
|
||||
|
||||
|
||||
Inline image
|
||||
------------
|
||||
|
||||
@ -221,20 +279,45 @@ just add ``{{ <var> }}`` tag in your template where ``<var>`` is an instance of
|
||||
|
||||
myimage = InlineImage(tpl, image_descriptor='test_files/python_logo.png', width=Mm(20), height=Mm(10))
|
||||
|
||||
You just have to specify the template object, the image file path and optionnally width and/or height.
|
||||
You just have to specify the template object, the image file path and optionally width and/or height.
|
||||
For height and width you have to use millimeters (Mm), inches (Inches) or points(Pt) class.
|
||||
Please see tests/inline_image.py for an example.
|
||||
|
||||
Sub-documents
|
||||
-------------
|
||||
|
||||
A template variable can contain a complex and built from scratch with python-docx word document.
|
||||
To do so, get first a sub-document object from template object and use it as a python-docx document object, see example in `tests/subdoc.py`.
|
||||
> Need to install with the subdoc extra: `pip install "docxtpl[subdoc]"`
|
||||
|
||||
Escaping, newline, new paragraph, Listing
|
||||
-----------------------------------------
|
||||
A template variable can contain a complex subdoc object and be built from scratch using python-docx document methods.
|
||||
To do so, first, get the sub-document object from your template object, then use it by treating it as a python-docx document object.
|
||||
See example in `tests/subdoc.py`.
|
||||
|
||||
When you use a ``{{ <var> }}``, you are modifying an **XML** word document, this means you cannot use all chars,
|
||||
Since docxtpl V0.12.0, it is now possible to merge an existing .docx as a subdoc, just specify its path when
|
||||
calling method `new_subdoc()` ::
|
||||
|
||||
tpl = DocxTemplate('templates/merge_docx_master_tpl.docx')
|
||||
sd = tpl.new_subdoc('templates/merge_docx_subdoc.docx')
|
||||
|
||||
context = {
|
||||
'mysubdoc': sd,
|
||||
}
|
||||
|
||||
tpl.render(context)
|
||||
tpl.save('output/merge_docx.docx')
|
||||
|
||||
In the above example, the content of 'templates/merge_docx_subdoc.docx' will be inserted into the parent document in place of the declared
|
||||
variable `{{p mysubdoc }}`.
|
||||
|
||||
See `tests/merge_docx.py` for full code.
|
||||
|
||||
.. _Escaping:
|
||||
|
||||
Escaping
|
||||
--------
|
||||
|
||||
By default, no escaping is done : read carefully this chapter if you want to avoid crashes during docx generation.
|
||||
|
||||
When you use a ``{{ <var> }}``, under the hood, you are modifying an **XML** word document, this means you cannot use all chars,
|
||||
especially ``<``, ``>`` and ``&``. In order to use them, you must escape them. There are 4 ways :
|
||||
|
||||
* ``context = { 'var':R('my text') }`` and ``{{r <var> }}`` in the template (note the ``r``),
|
||||
@ -242,12 +325,9 @@ especially ``<``, ``>`` and ``&``. In order to use them, you must escape them. T
|
||||
* ``context = { 'var':escape('my text')}`` and ``{{ <var> }}`` in the template.
|
||||
* enable autoescaping when calling render method: ``tpl.render(context, autoescape=True)`` (default is autoescape=False)
|
||||
|
||||
The ``RichText()`` or ``R()`` offers newline, new paragraph, and page break features : just use ``\n``, ``\a``, ``\t`` or ``\f`` in the
|
||||
text, they will be converted accordingly.
|
||||
|
||||
See tests/escape.py example for more informations.
|
||||
|
||||
Another solution, if you want to include a listing into your document, that is to escape the text and manage \n, \a, and \f
|
||||
Another solution, if you want to include a listing into your document, that is to escape the text and manage ``\n``, ``\a``, and ``\f``
|
||||
you can use the ``Listing`` class :
|
||||
|
||||
in your python code::
|
||||
@ -255,6 +335,7 @@ in your python code::
|
||||
context = { 'mylisting':Listing('the listing\nwith\nsome\nlines \a and some paragraph \a and special chars : <>&') }
|
||||
|
||||
in your docx template just use ``{{ mylisting }}``
|
||||
|
||||
With ``Listing()``, you will keep the current character styling (except after a ``\a`` as you start a new paragraph).
|
||||
|
||||
Replace docx pictures
|
||||
@ -300,10 +381,10 @@ It works like medias replacement, except it is for embedded objects like embedde
|
||||
|
||||
Syntax to replace embedded_dummy.docx::
|
||||
|
||||
tpl.replace_embedded('embdded_dummy.docx','embdded_docx_i_want.docx')
|
||||
tpl.replace_embedded('embedded_dummy.docx','embedded_docx_i_want.docx')
|
||||
|
||||
|
||||
WARNING : unlike replace_pic() method, embdded_dummy.docx MUST exist in the template directory when rendering and saving the generated docx. It must be the same
|
||||
WARNING : unlike replace_pic() method, embedded_dummy.docx MUST exist in the template directory when rendering and saving the generated docx. It must be the same
|
||||
file as the one inserted manually in the docx template.
|
||||
The replacement occurs in headers, footers and the whole document's body.
|
||||
|
||||
@ -320,6 +401,26 @@ The zipname starts with "word/embeddings/". Note that the file to be replaced is
|
||||
This works for embedded MSWord file like Excel or PowerPoint file, but won't work for others like PDF, Python or even Text files :
|
||||
For these ones, MSWord generate an oleObjectNNN.bin file which is no use to be replaced as it is encoded.
|
||||
|
||||
Get Defined Variables
|
||||
---------------------
|
||||
|
||||
In order to get the missing variables after rendering use ::
|
||||
|
||||
tpl=DocxTemplate('your_template.docx')
|
||||
tpl.render(context_dict)
|
||||
set_of_variables = tpl.get_undeclared_template_variables(context=context_dict)
|
||||
|
||||
**IMPORTANT** : If `context` is not passed, you will get a set with all keys you need, e.g. to be prompted to a user or written in a file for manual processing.
|
||||
|
||||
Multiple rendering
|
||||
------------------
|
||||
|
||||
Since v0.15.0, it is possible to create ``DocxTemplate`` object once and call
|
||||
``render(context)`` several times. Note that if you want to use replacement
|
||||
methods like ``replace_media()``, ``replace_embedded()`` and/or ``replace_zipname()``
|
||||
during multiple rendering, you will have to call ``reset_replacements()``
|
||||
at rendering loop start.
|
||||
|
||||
|
||||
|
||||
Microsoft Word 2016 special cases
|
||||
@ -327,7 +428,7 @@ Microsoft Word 2016 special cases
|
||||
|
||||
MS Word 2016 will ignore ``\t`` tabulations. This is special to that version.
|
||||
Libreoffice or Wordpad do not have this problem. The same thing occurs for line
|
||||
beginning with a jinja2 tag provinding spaces : They will be ignored.
|
||||
beginning with a jinja2 tag providing spaces : They will be ignored.
|
||||
To solve these problem, the solution is to use Richtext::
|
||||
|
||||
tpl.render({
|
||||
@ -340,25 +441,11 @@ And in your template, use the {{r notation::
|
||||
{{r test_space_r}} Spaces will be preserved
|
||||
{{r test_tabs_r}} Tabs will be displayed
|
||||
|
||||
Tables
|
||||
------
|
||||
|
||||
You can span table cells horizontally in two ways, by using ``colspan`` tag (see tests/dynamic_table.py)::
|
||||
|
||||
{% colspan <number of column to span> %}
|
||||
|
||||
or within a for loop (see tests/horizontal_merge.py)::
|
||||
|
||||
{% hm %}
|
||||
|
||||
You can also merge cells vertically within a for loop (see tests/vertical_merge.py)::
|
||||
|
||||
{% vm %}
|
||||
|
||||
Jinja custom filters
|
||||
--------------------
|
||||
|
||||
``render()`` accepts ``jinja_env`` optionnal argument : you may pass a jinja environment object.
|
||||
``render()`` accepts ``jinja_env`` optional argument : you may pass a jinja environment object.
|
||||
By this way you will be able to add some custom jinja filters::
|
||||
|
||||
from docxtpl import DocxTemplate
|
||||
@ -378,6 +465,30 @@ Then in your template, you will be able to use::
|
||||
|
||||
Euros price : {{ price_dollars|multiply_by(0.88) }}
|
||||
|
||||
|
||||
Command-line execution
|
||||
----------------------
|
||||
|
||||
One can use `docxtpl` module directly on command line to generate a docx from a template and a json file as a context::
|
||||
|
||||
usage: python -m docxtpl [-h] [-o] [-q] template_path json_path output_filename
|
||||
|
||||
Make docx file from existing template docx and json data.
|
||||
|
||||
positional arguments:
|
||||
template_path The path to the template docx file.
|
||||
json_path The path to the json file with the data.
|
||||
output_filename The filename to save the generated docx.
|
||||
|
||||
optional arguments:
|
||||
-h, --help show this help message and exit
|
||||
-o, --overwrite If output file already exists, overwrites without asking
|
||||
for confirmation
|
||||
-q, --quiet Do not display unnecessary messages
|
||||
|
||||
|
||||
See tests/module_execute.py for an example.
|
||||
|
||||
Examples
|
||||
--------
|
||||
|
||||
|
||||
@ -4,849 +4,15 @@ Created : 2015-03-12
|
||||
|
||||
@author: Eric Lapouyade
|
||||
"""
|
||||
__version__ = '0.11.4'
|
||||
|
||||
import functools
|
||||
import io
|
||||
from lxml import etree
|
||||
from docx import Document
|
||||
from docx.opc.oxml import parse_xml
|
||||
from docx.opc.part import XmlPart
|
||||
import docx.oxml.ns
|
||||
from docx.opc.constants import RELATIONSHIP_TYPE as REL_TYPE
|
||||
from jinja2 import Environment, Template, meta
|
||||
from jinja2.exceptions import TemplateError
|
||||
__version__ = "0.20.1"
|
||||
|
||||
# flake8: noqa
|
||||
from .inline_image import InlineImage
|
||||
from .listing import Listing
|
||||
from .richtext import RichText, R, RichTextParagraph, RP
|
||||
from .template import DocxTemplate
|
||||
try:
|
||||
from html import escape
|
||||
from .subdoc import Subdoc
|
||||
except ImportError:
|
||||
# cgi.escape is deprecated in python 3.7
|
||||
from cgi import escape
|
||||
import re
|
||||
import six
|
||||
import binascii
|
||||
import os
|
||||
import zipfile
|
||||
|
||||
|
||||
class DocxTemplate(object):
|
||||
""" Class for managing docx files as they were jinja2 templates """
|
||||
|
||||
HEADER_URI = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/header"
|
||||
FOOTER_URI = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/footer"
|
||||
|
||||
def __init__(self, docx):
|
||||
self.docx = Document(docx)
|
||||
self.crc_to_new_media = {}
|
||||
self.crc_to_new_embedded = {}
|
||||
self.zipname_to_replace = {}
|
||||
self.pics_to_replace = {}
|
||||
self.pic_map = {}
|
||||
self.current_rendering_part = None
|
||||
|
||||
def __getattr__(self, name):
|
||||
return getattr(self.docx, name)
|
||||
|
||||
def xml_to_string(self, xml, encoding='unicode'):
|
||||
# Be careful : pretty_print MUST be set to False, otherwise patch_xml()
|
||||
# won't work properly
|
||||
return etree.tostring(xml, encoding='unicode', pretty_print=False)
|
||||
|
||||
def get_docx(self):
|
||||
return self.docx
|
||||
|
||||
def get_xml(self):
|
||||
return self.xml_to_string(self.docx._element.body)
|
||||
|
||||
def write_xml(self, filename):
|
||||
with open(filename, 'w') as fh:
|
||||
fh.write(self.get_xml())
|
||||
|
||||
def patch_xml(self, src_xml):
|
||||
""" Make a lots of cleanning to have a raw xml understandable by jinja2 :
|
||||
strip all unnecessary xml tags, manage table cell background color and colspan,
|
||||
unescape html entities, etc... """
|
||||
|
||||
# replace {<something>{ by {{ ( works with {{ }} {% and %} )
|
||||
src_xml = re.sub(r'(?<={)(<[^>]*>)+(?=[\{%])|(?<=[%\}])(<[^>]*>)+(?=\})', '',
|
||||
src_xml, flags=re.DOTALL)
|
||||
|
||||
# replace {{<some tags>jinja2 stuff<some other tags>}} by {{jinja2 stuff}}
|
||||
# same thing with {% ... %}
|
||||
# "jinja2 stuff" could a variable, a 'if' etc... anything jinja2 will understand
|
||||
def striptags(m):
|
||||
return re.sub('</w:t>.*?(<w:t>|<w:t [^>]*>)', '',
|
||||
m.group(0), flags=re.DOTALL)
|
||||
src_xml = re.sub(r'{%(?:(?!%}).)*|{{(?:(?!}}).)*', striptags,
|
||||
src_xml, flags=re.DOTALL)
|
||||
|
||||
# manage table cell colspan
|
||||
def colspan(m):
|
||||
cell_xml = m.group(1) + m.group(3)
|
||||
cell_xml = re.sub(r'<w:r[ >](?:(?!<w:r[ >]).)*<w:t></w:t>.*?</w:r>',
|
||||
'', cell_xml, flags=re.DOTALL)
|
||||
cell_xml = re.sub(r'<w:gridSpan[^/]*/>', '', cell_xml, count=1)
|
||||
return re.sub(r'(<w:tcPr[^>]*>)', r'\1<w:gridSpan w:val="{{%s}}"/>'
|
||||
% m.group(2), cell_xml)
|
||||
src_xml = re.sub(r'(<w:tc[ >](?:(?!<w:tc[ >]).)*){%\s*colspan\s+([^%]*)\s*%}(.*?</w:tc>)',
|
||||
colspan, src_xml, flags=re.DOTALL)
|
||||
|
||||
# manage table cell background color
|
||||
def cellbg(m):
|
||||
cell_xml = m.group(1) + m.group(3)
|
||||
cell_xml = re.sub(r'<w:r[ >](?:(?!<w:r[ >]).)*<w:t></w:t>.*?</w:r>',
|
||||
'', cell_xml, flags=re.DOTALL)
|
||||
cell_xml = re.sub(r'<w:shd[^/]*/>', '', cell_xml, count=1)
|
||||
return re.sub(r'(<w:tcPr[^>]*>)',
|
||||
r'\1<w:shd w:val="clear" w:color="auto" w:fill="{{%s}}"/>'
|
||||
% m.group(2), cell_xml)
|
||||
src_xml = re.sub(r'(<w:tc[ >](?:(?!<w:tc[ >]).)*){%\s*cellbg\s+([^%]*)\s*%}(.*?</w:tc>)',
|
||||
cellbg, src_xml, flags=re.DOTALL)
|
||||
|
||||
# ensure space preservation
|
||||
src_xml = re.sub(r'<w:t>((?:(?!<w:t>).)*)({{.*?}}|{%.*?%})',
|
||||
r'<w:t xml:space="preserve">\1\2',
|
||||
src_xml, flags=re.DOTALL)
|
||||
src_xml = re.sub(r'({{r\s.*?}}|{%r\s.*?%})',
|
||||
r'</w:t></w:r><w:r><w:t xml:space="preserve">\1</w:t></w:r><w:r><w:t xml:space="preserve">',
|
||||
src_xml, flags=re.DOTALL)
|
||||
|
||||
# {%- will merge with previous paragraph text
|
||||
src_xml = re.sub(r'</w:t>(?:(?!</w:t>).)*?{%-', '{%', src_xml, flags=re.DOTALL)
|
||||
# -%} will merge with next paragraph text
|
||||
src_xml = re.sub(r'-%}(?:(?!<w:t[ >]).)*?<w:t[^>]*?>', '%}', src_xml, flags=re.DOTALL)
|
||||
|
||||
for y in ['tr', 'tc', 'p', 'r']:
|
||||
# replace into xml code the row/paragraph/run containing
|
||||
# {%y xxx %} or {{y xxx}} template tag
|
||||
# by {% xxx %} or {{ xx }} without any surronding <w:y> tags :
|
||||
# This is mandatory to have jinja2 generating correct xml code
|
||||
pat = r'<w:%(y)s[ >](?:(?!<w:%(y)s[ >]).)*({%%|{{)%(y)s ([^}%%]*(?:%%}|}})).*?</w:%(y)s>' % {'y': y}
|
||||
src_xml = re.sub(pat, r'\1 \2', src_xml, flags=re.DOTALL)
|
||||
|
||||
# add vMerge
|
||||
# use {% vm %} to make this table cell and its copies be vertically merged within a {% for %}
|
||||
def v_merge_tc(m):
|
||||
def v_merge(m1):
|
||||
return (
|
||||
'<w:vMerge w:val="{% if loop.first %}restart{% else %}continue{% endif %}"/>' +
|
||||
m1.group(1) + # Everything between ``</w:tcPr>`` and ``<w:t>``.
|
||||
"{% if loop.first %}" +
|
||||
m1.group(2) + # Everything before ``{% vm %}``.
|
||||
m1.group(3) + # Everything after ``{% vm %}``.
|
||||
"{% endif %}" +
|
||||
m1.group(4) # ``</w:t>``.
|
||||
)
|
||||
return re.sub(
|
||||
r'(</w:tcPr[ >].*?<w:t(?:.*?)>)(.*?)(?:{%\s*vm\s*%})(.*?)(</w:t>)',
|
||||
v_merge,
|
||||
m.group(), # Everything between ``</w:tc>`` and ``</w:tc>`` with ``{% vm %}`` inside.
|
||||
flags=re.DOTALL,
|
||||
)
|
||||
src_xml = re.sub(r'<w:tc[ >](?:(?!<w:tc[ >]).)*?{%\s*vm\s*%}.*?</w:tc[ >]',
|
||||
v_merge_tc, src_xml, flags=re.DOTALL)
|
||||
|
||||
# Use ``{% hm %}`` to make table cell become horizontally merged within
|
||||
# a ``{% for %}``.
|
||||
def h_merge_tc(m):
|
||||
xml_to_patch = m.group() # Everything between ``</w:tc>`` and ``</w:tc>`` with ``{% hm %}`` inside.
|
||||
|
||||
def with_gridspan(m1):
|
||||
return (
|
||||
m1.group(1) + # ``w:gridSpan w:val="``.
|
||||
'{{ ' + m1.group(2) + ' * loop.length }}' + # Content of ``w:val``, multiplied by loop length.
|
||||
m1.group(3) # Closing quotation mark.
|
||||
)
|
||||
|
||||
def without_gridspan(m2):
|
||||
return (
|
||||
'<w:gridSpan w:val="{{ loop.length }}"/>' +
|
||||
m2.group(1) + # Everything between ``</w:tcPr>`` and ``<w:t>``.
|
||||
m2.group(2) + # Everything before ``{% hm %}``.
|
||||
m2.group(3) + # Everything after ``{% hm %}``.
|
||||
m2.group(4) # ``</w:t>``.
|
||||
)
|
||||
|
||||
if re.search(r'w:gridSpan', xml_to_patch):
|
||||
# Simple case, there's already ``gridSpan``, multiply its value.
|
||||
|
||||
xml = re.sub(
|
||||
r'(w:gridSpan w:val=")(\d+)(")',
|
||||
with_gridspan,
|
||||
xml_to_patch,
|
||||
flags=re.DOTALL,
|
||||
)
|
||||
xml = re.sub(
|
||||
r'{%\s*hm\s*%}',
|
||||
'',
|
||||
xml, # Patched xml.
|
||||
flags=re.DOTALL,
|
||||
)
|
||||
else:
|
||||
# There're no ``gridSpan``, add one.
|
||||
xml = re.sub(
|
||||
r'(</w:tcPr[ >].*?<w:t(?:.*?)>)(.*?)(?:{%\s*hm\s*%})(.*?)(</w:t>)',
|
||||
without_gridspan,
|
||||
xml_to_patch,
|
||||
flags=re.DOTALL,
|
||||
)
|
||||
|
||||
# Discard every other cell generated in loop.
|
||||
return "{% if loop.first %}" + xml + "{% endif %}"
|
||||
|
||||
src_xml = re.sub(r'<w:tc[ >](?:(?!<w:tc[ >]).)*?{%\s*hm\s*%}.*?</w:tc[ >]',
|
||||
h_merge_tc, src_xml, flags=re.DOTALL)
|
||||
|
||||
def clean_tags(m):
|
||||
return (m.group(0)
|
||||
.replace(r"‘", "'")
|
||||
.replace('<', '<')
|
||||
.replace('>', '>')
|
||||
.replace(u'“', u'"')
|
||||
.replace(u'”', u'"')
|
||||
.replace(u"‘", u"'")
|
||||
.replace(u"’", u"'"))
|
||||
src_xml = re.sub(r'(?<=\{[\{%])(.*?)(?=[\}%]})', clean_tags, src_xml)
|
||||
|
||||
return src_xml
|
||||
|
||||
def render_xml_part(self, src_xml, part, context, jinja_env=None):
|
||||
src_xml = src_xml.replace(r'<w:p>', '\n<w:p>')
|
||||
try:
|
||||
self.current_rendering_part = part
|
||||
if jinja_env:
|
||||
template = jinja_env.from_string(src_xml)
|
||||
else:
|
||||
template = Template(src_xml)
|
||||
dst_xml = template.render(context)
|
||||
except TemplateError as exc:
|
||||
if hasattr(exc, 'lineno') and exc.lineno is not None:
|
||||
line_number = max(exc.lineno - 4, 0)
|
||||
exc.docx_context = map(lambda x: re.sub(r'<[^>]+>', '', x),
|
||||
src_xml.splitlines()[line_number:(line_number + 7)])
|
||||
raise exc
|
||||
dst_xml = dst_xml.replace('\n<w:p>', '<w:p>')
|
||||
dst_xml = (dst_xml
|
||||
.replace('{_{', '{{')
|
||||
.replace('}_}', '}}')
|
||||
.replace('{_%', '{%')
|
||||
.replace('%_}', '%}'))
|
||||
dst_xml = self.resolve_listing(dst_xml)
|
||||
return dst_xml
|
||||
|
||||
def resolve_listing(self, xml):
|
||||
|
||||
def resolve_text(run_properties, paragraph_properties, m):
|
||||
xml = m.group(0).replace('\t', '</w:t></w:r>'
|
||||
'<w:r>%s<w:tab/></w:r>'
|
||||
'<w:r>%s<w:t xml:space="preserve">' % (run_properties, run_properties))
|
||||
xml = xml.replace('\a', '</w:t></w:r></w:p>'
|
||||
'<w:p>%s<w:r>%s<w:t xml:space="preserve">' % (paragraph_properties, run_properties))
|
||||
xml = xml.replace('\n', '</w:t><w:br/><w:t xml:space="preserve">')
|
||||
xml = xml.replace('\f', '</w:t></w:r></w:p>'
|
||||
'<w:p><w:r><w:br w:type="page"/></w:r></w:p>'
|
||||
'<w:p>%s<w:r>%s<w:t xml:space="preserve">' % (paragraph_properties, run_properties))
|
||||
return xml
|
||||
|
||||
def resolve_run(paragraph_properties, m):
|
||||
run_properties = re.search(r'<w:rPr>.*?</w:rPr>', m.group(0))
|
||||
run_properties = run_properties.group(0) if run_properties else ''
|
||||
return re.sub(r'<w:t(?:[^>]*)?>.*?</w:t>',
|
||||
lambda x: resolve_text(run_properties, paragraph_properties, x), m.group(0),
|
||||
flags=re.DOTALL)
|
||||
|
||||
def resolve_paragraph(m):
|
||||
paragraph_properties = re.search(r'<w:pPr>.*?</w:pPr>', m.group(0))
|
||||
paragraph_properties = paragraph_properties.group(0) if paragraph_properties else ''
|
||||
return re.sub(r'<w:r(?:[^>]*)?>.*?</w:r>',
|
||||
lambda x: resolve_run(paragraph_properties, x),
|
||||
m.group(0), flags=re.DOTALL)
|
||||
|
||||
xml = re.sub(r'<w:p(?:[^>]*)?>.*?</w:p>', resolve_paragraph, xml, flags=re.DOTALL)
|
||||
|
||||
return xml
|
||||
|
||||
def build_xml(self, context, jinja_env=None):
|
||||
xml = self.get_xml()
|
||||
xml = self.patch_xml(xml)
|
||||
xml = self.render_xml_part(xml, self.docx._part, context, jinja_env)
|
||||
return xml
|
||||
|
||||
def map_tree(self, tree):
|
||||
root = self.docx._element
|
||||
body = root.body
|
||||
root.replace(body, tree)
|
||||
|
||||
def get_headers_footers(self, uri):
|
||||
for relKey, val in self.docx._part._rels.items():
|
||||
if (val.reltype == uri) and (val.target_part.blob):
|
||||
yield relKey, val.target_part
|
||||
|
||||
def get_part_xml(self, part):
|
||||
return self.xml_to_string(parse_xml(part.blob))
|
||||
|
||||
def get_headers_footers_encoding(self, xml):
|
||||
m = re.match(r'<\?xml[^\?]+\bencoding="([^"]+)"', xml, re.I)
|
||||
if m:
|
||||
return m.group(1)
|
||||
return 'utf-8'
|
||||
|
||||
def build_headers_footers_xml(self, context, uri, jinja_env=None):
|
||||
for relKey, part in self.get_headers_footers(uri):
|
||||
xml = self.get_part_xml(part)
|
||||
encoding = self.get_headers_footers_encoding(xml)
|
||||
xml = self.patch_xml(xml)
|
||||
xml = self.render_xml_part(xml, part, context, jinja_env)
|
||||
yield relKey, xml.encode(encoding)
|
||||
|
||||
def map_headers_footers_xml(self, relKey, xml):
|
||||
part = self.docx._part._rels[relKey].target_part
|
||||
new_part = XmlPart.load(part.partname, part.content_type, xml, part.package)
|
||||
for rId, rel in part.rels.items():
|
||||
new_part.load_rel(rel.reltype, rel._target, rel.rId, rel.is_external)
|
||||
self.docx._part._rels[relKey]._target = new_part
|
||||
|
||||
def render(self, context, jinja_env=None, autoescape=False):
|
||||
if autoescape:
|
||||
if not jinja_env:
|
||||
jinja_env = Environment(autoescape=autoescape)
|
||||
else:
|
||||
jinja_env.autoescape = autoescape
|
||||
|
||||
# Body
|
||||
xml_src = self.build_xml(context, jinja_env)
|
||||
|
||||
# fix tables if needed
|
||||
tree = self.fix_tables(xml_src)
|
||||
|
||||
self.map_tree(tree)
|
||||
|
||||
# Headers
|
||||
headers = self.build_headers_footers_xml(context, self.HEADER_URI,
|
||||
jinja_env)
|
||||
for relKey, xml in headers:
|
||||
self.map_headers_footers_xml(relKey, xml)
|
||||
|
||||
# Footers
|
||||
footers = self.build_headers_footers_xml(context, self.FOOTER_URI,
|
||||
jinja_env)
|
||||
for relKey, xml in footers:
|
||||
self.map_headers_footers_xml(relKey, xml)
|
||||
|
||||
# using of TC tag in for cycle can cause that count of columns does not
|
||||
# correspond to real count of columns in row. This function is able to fix it.
|
||||
def fix_tables(self, xml):
|
||||
parser = etree.XMLParser(recover=True)
|
||||
tree = etree.fromstring(xml, parser=parser)
|
||||
# get namespace
|
||||
ns = '{' + tree.nsmap['w'] + '}'
|
||||
# walk trough xml and find table
|
||||
for t in tree.iter(ns+'tbl'):
|
||||
tblGrid = t.find(ns+'tblGrid')
|
||||
columns = tblGrid.findall(ns+'gridCol')
|
||||
to_add = 0
|
||||
# walk trough all rows and try to find if there is higher cell count
|
||||
for r in t.iter(ns+'tr'):
|
||||
cells = r.findall(ns+'tc')
|
||||
if (len(columns) + to_add) < len(cells):
|
||||
to_add = len(cells) - len(columns)
|
||||
# is neccessary to add columns?
|
||||
if to_add > 0:
|
||||
# at first, calculate width of table according to columns
|
||||
# (we want to preserve it)
|
||||
width = 0.0
|
||||
new_average = None
|
||||
for c in columns:
|
||||
if not c.get(ns+'w') is None:
|
||||
width += float(c.get(ns+'w'))
|
||||
# try to keep proportion of table
|
||||
if width > 0:
|
||||
old_average = width / len(columns)
|
||||
new_average = width / (len(columns) + to_add)
|
||||
# scale the old columns
|
||||
for c in columns:
|
||||
c.set(ns+'w', str(int(float(c.get(ns+'w')) *
|
||||
new_average/old_average)))
|
||||
# add new columns
|
||||
for i in range(to_add):
|
||||
etree.SubElement(tblGrid, ns+'gridCol',
|
||||
{ns+'w': str(int(new_average))})
|
||||
|
||||
# Refetch columns after columns addition.
|
||||
columns = tblGrid.findall(ns + 'gridCol')
|
||||
columns_len = len(columns)
|
||||
|
||||
cells_len_max = 0
|
||||
|
||||
def get_cell_len(total, cell):
|
||||
tc_pr = cell.find(ns + 'tcPr')
|
||||
grid_span = None if tc_pr is None else tc_pr.find(ns + 'gridSpan')
|
||||
|
||||
if grid_span is not None:
|
||||
return total + int(grid_span.get(ns + 'val'))
|
||||
|
||||
return total + 1
|
||||
|
||||
# Calculate max of table cells to compare with `gridCol`.
|
||||
for r in t.iter(ns + 'tr'):
|
||||
cells = r.findall(ns + 'tc')
|
||||
cells_len = functools.reduce(get_cell_len, cells, 0)
|
||||
cells_len_max = max(cells_len_max, cells_len)
|
||||
|
||||
to_remove = columns_len - cells_len_max
|
||||
|
||||
# If after the loop, there're less columns, than
|
||||
# originally was, remove extra `gridCol` declarations.
|
||||
if to_remove > 0:
|
||||
# Have to keep track of the removed width to scale the
|
||||
# table back to its original width.
|
||||
removed_width = 0.0
|
||||
|
||||
for c in columns[-to_remove:]:
|
||||
removed_width += float(c.get(ns + 'w'))
|
||||
|
||||
tblGrid.remove(c)
|
||||
|
||||
columns_left = tblGrid.findall(ns + 'gridCol')
|
||||
|
||||
# Distribute `removed_width` across all columns that has
|
||||
# left after extras removal.
|
||||
extra_space = 0
|
||||
if len(columns_left) > 0:
|
||||
extra_space = removed_width / len(columns_left)
|
||||
extra_space = int(extra_space)
|
||||
|
||||
for c in columns_left:
|
||||
c.set(ns+'w', str(int(float(c.get(ns+'w')) + extra_space)))
|
||||
|
||||
return tree
|
||||
|
||||
def new_subdoc(self, docpath=None):
|
||||
return Subdoc(self, docpath)
|
||||
|
||||
@staticmethod
|
||||
def get_file_crc(file_obj):
|
||||
if hasattr(file_obj, 'read'):
|
||||
buf = file_obj.read()
|
||||
else:
|
||||
with open(file_obj, 'rb') as fh:
|
||||
buf = fh.read()
|
||||
|
||||
crc = (binascii.crc32(buf) & 0xFFFFFFFF)
|
||||
return crc
|
||||
|
||||
def replace_media(self, src_file, dst_file):
|
||||
"""Replace one media by another one into a docx
|
||||
|
||||
This has been done mainly because it is not possible to add images in
|
||||
docx header/footer.
|
||||
With this function, put a dummy picture in your header/footer,
|
||||
then specify it with its replacement in this function using the file path
|
||||
or file-like objects.
|
||||
|
||||
Syntax: tpl.replace_media('dummy_media_to_replace.png','media_to_paste.jpg')
|
||||
-- or --
|
||||
tpl.replace_media(io.BytesIO(image_stream), io.BytesIO(new_image_stream))
|
||||
|
||||
Note: for images, the aspect ratio will be the same as the replaced image
|
||||
|
||||
Note2: it is important to have the source media file as it is required
|
||||
to calculate its CRC to find them in the docx
|
||||
"""
|
||||
|
||||
crc = self.get_file_crc(src_file)
|
||||
if hasattr(dst_file, 'read'):
|
||||
self.crc_to_new_media[crc] = dst_file.read()
|
||||
else:
|
||||
with open(dst_file, 'rb') as fh:
|
||||
self.crc_to_new_media[crc] = fh.read()
|
||||
|
||||
def replace_pic(self, embedded_file, dst_file):
|
||||
"""Replace embedded picture with original-name given by embedded_file.
|
||||
(give only the file basename, not the full path)
|
||||
The new picture is given by dst_file (either a filename or a file-like
|
||||
object)
|
||||
|
||||
Notes:
|
||||
1) embedded_file and dst_file must have the same extension/format
|
||||
in case dst_file is a file-like object, no check is done on
|
||||
format compatibility
|
||||
2) the aspect ratio will be the same as the replaced image
|
||||
3) There is no need to keep the original file (this is not the case
|
||||
for replace_embedded and replace_media)
|
||||
"""
|
||||
|
||||
if hasattr(dst_file, 'read'):
|
||||
# NOTE: file extension not checked
|
||||
self.pics_to_replace[embedded_file] = dst_file.read()
|
||||
else:
|
||||
with open(dst_file, 'rb') as fh:
|
||||
self.pics_to_replace[embedded_file] = fh.read()
|
||||
|
||||
def replace_embedded(self, src_file, dst_file):
|
||||
"""Replace one embedded object by another one into a docx
|
||||
|
||||
This has been done mainly because it is not possible to add images
|
||||
in docx header/footer.
|
||||
With this function, put a dummy picture in your header/footer,
|
||||
then specify it with its replacement in this function
|
||||
|
||||
Syntax: tpl.replace_embedded('dummy_doc.docx','doc_to_paste.docx')
|
||||
|
||||
Note2 : it is important to have the source file as it is required to
|
||||
calculate its CRC to find them in the docx
|
||||
"""
|
||||
with open(dst_file, 'rb') as fh:
|
||||
crc = self.get_file_crc(src_file)
|
||||
self.crc_to_new_embedded[crc] = fh.read()
|
||||
|
||||
def replace_zipname(self, zipname, dst_file):
|
||||
"""Replace one file in the docx file
|
||||
|
||||
First note that a MSWord .docx file is in fact a zip file.
|
||||
|
||||
This method can be used to replace document embedded in the docx template.
|
||||
|
||||
Some embedded document may have been modified by MSWord while saving
|
||||
the template : thus replace_embedded() cannot be used as CRC is not the
|
||||
same as the original file.
|
||||
|
||||
This method works for embedded MSWord file like Excel or PowerPoint file,
|
||||
but won't work for others like PDF, Python or even Text files :
|
||||
For these ones, MSWord generate an oleObjectNNN.bin file which is no
|
||||
use to be replaced as it is encoded.
|
||||
|
||||
Syntax:
|
||||
|
||||
tpl.replace_zipname(
|
||||
'word/embeddings/Feuille_Microsoft_Office_Excel1.xlsx',
|
||||
'my_excel_file.xlsx')
|
||||
|
||||
The zipname is the one you can find when you open docx with WinZip,
|
||||
7zip (Windows) or unzip -l (Linux). The zipname starts with
|
||||
"word/embeddings/". Note that the file is renamed by MSWord,
|
||||
so you have to guess a little bit...
|
||||
"""
|
||||
with open(dst_file, 'rb') as fh:
|
||||
self.zipname_to_replace[zipname] = fh.read()
|
||||
|
||||
def post_processing(self, docx_file):
|
||||
if (self.crc_to_new_media or
|
||||
self.crc_to_new_embedded or
|
||||
self.zipname_to_replace):
|
||||
|
||||
if hasattr(docx_file, 'read'):
|
||||
tmp_file = io.BytesIO()
|
||||
DocxTemplate(docx_file).save(tmp_file)
|
||||
tmp_file.seek(0)
|
||||
docx_file.seek(0)
|
||||
docx_file.truncate()
|
||||
docx_file.seek(0)
|
||||
|
||||
else:
|
||||
tmp_file = '%s_docxtpl_before_replace_medias' % docx_file
|
||||
os.rename(docx_file, tmp_file)
|
||||
|
||||
with zipfile.ZipFile(tmp_file) as zin:
|
||||
with zipfile.ZipFile(docx_file, 'w') as zout:
|
||||
for item in zin.infolist():
|
||||
buf = zin.read(item.filename)
|
||||
if item.filename in self.zipname_to_replace:
|
||||
zout.writestr(item, self.zipname_to_replace[item.filename])
|
||||
elif (item.filename.startswith('word/media/') and
|
||||
item.CRC in self.crc_to_new_media):
|
||||
zout.writestr(item, self.crc_to_new_media[item.CRC])
|
||||
elif (item.filename.startswith('word/embeddings/') and
|
||||
item.CRC in self.crc_to_new_embedded):
|
||||
zout.writestr(item, self.crc_to_new_embedded[item.CRC])
|
||||
else:
|
||||
zout.writestr(item, buf)
|
||||
|
||||
if not hasattr(tmp_file, 'read'):
|
||||
os.remove(tmp_file)
|
||||
if hasattr(docx_file, 'read'):
|
||||
docx_file.seek(0)
|
||||
|
||||
def pre_processing(self):
|
||||
|
||||
if self.pics_to_replace:
|
||||
self._replace_pics()
|
||||
|
||||
def _replace_pics(self):
|
||||
"""Replaces pictures xml tags in the docx template with pictures provided by the user"""
|
||||
|
||||
replaced_pics = {key: False for key in self.pics_to_replace}
|
||||
|
||||
# Main document
|
||||
part = self.docx.part
|
||||
self._replace_docx_part_pics(part, replaced_pics)
|
||||
|
||||
# Header/Footer
|
||||
for relid, rel in six.iteritems(part.rels):
|
||||
if rel.reltype in (REL_TYPE.HEADER, REL_TYPE.FOOTER):
|
||||
self._replace_docx_part_pics(rel.target_part, replaced_pics)
|
||||
|
||||
# make sure all template images defined by user were replaced
|
||||
for img_id, replaced in replaced_pics.items():
|
||||
if not replaced:
|
||||
raise ValueError(
|
||||
"Picture %s not found in the docx template" % img_id
|
||||
)
|
||||
|
||||
def get_pic_map(self):
|
||||
return self.pic_map
|
||||
|
||||
def _replace_docx_part_pics(self, doc_part, replaced_pics):
|
||||
|
||||
et = etree.fromstring(doc_part.blob)
|
||||
|
||||
part_map = {}
|
||||
|
||||
gds = et.xpath('//a:graphic/a:graphicData', namespaces=docx.oxml.ns.nsmap)
|
||||
for gd in gds:
|
||||
rel = None
|
||||
# Either IMAGE, CHART, SMART_ART, ...
|
||||
try:
|
||||
if gd.attrib['uri'] == docx.oxml.ns.nsmap['pic']:
|
||||
# Either PICTURE or LINKED_PICTURE image
|
||||
blip = gd.xpath('pic:pic/pic:blipFill/a:blip',
|
||||
namespaces=docx.oxml.ns.nsmap)[0]
|
||||
dest = blip.xpath('@r:embed', namespaces=docx.oxml.ns.nsmap)
|
||||
if len(dest) > 0:
|
||||
rel = dest[0]
|
||||
else:
|
||||
continue
|
||||
else:
|
||||
continue
|
||||
|
||||
non_visual_properties = 'pic:pic/pic:nvPicPr/pic:cNvPr/'
|
||||
filename = gd.xpath('%s@name' % non_visual_properties,
|
||||
namespaces=docx.oxml.ns.nsmap)[0]
|
||||
titles = gd.xpath('%s@title' % non_visual_properties,
|
||||
namespaces=docx.oxml.ns.nsmap)
|
||||
if titles:
|
||||
title = titles[0]
|
||||
else:
|
||||
title = ""
|
||||
descriptions = gd.xpath('%s@descr' % non_visual_properties,
|
||||
namespaces=docx.oxml.ns.nsmap)
|
||||
if descriptions:
|
||||
description = descriptions[0]
|
||||
else:
|
||||
description = ""
|
||||
|
||||
part_map[filename] = (doc_part.rels[rel].target_ref,
|
||||
doc_part.rels[rel].target_part)
|
||||
|
||||
# replace data
|
||||
for img_id, img_data in six.iteritems(self.pics_to_replace):
|
||||
if img_id == filename or img_id == title or img_id == description:
|
||||
part_map[filename][1]._blob = img_data
|
||||
replaced_pics[img_id] = True
|
||||
break
|
||||
|
||||
# FIXME: figure out what exceptions are thrown here and catch more specific exceptions
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
self.pic_map.update(part_map)
|
||||
|
||||
def build_url_id(self, url):
|
||||
return self.docx._part.relate_to(url, REL_TYPE.HYPERLINK,
|
||||
is_external=True)
|
||||
|
||||
def save(self, filename, *args, **kwargs):
|
||||
self.pre_processing()
|
||||
self.docx.save(filename, *args, **kwargs)
|
||||
self.post_processing(filename)
|
||||
|
||||
def get_undeclared_template_variables(self, jinja_env=None):
|
||||
xml = self.get_xml()
|
||||
xml = self.patch_xml(xml)
|
||||
for uri in [self.HEADER_URI, self.FOOTER_URI]:
|
||||
for relKey, part in self.get_headers_footers(uri):
|
||||
_xml = self.get_part_xml(part)
|
||||
xml += self.patch_xml(_xml)
|
||||
if jinja_env:
|
||||
env = jinja_env
|
||||
else:
|
||||
env = Environment()
|
||||
parse_content = env.parse(xml)
|
||||
return meta.find_undeclared_variables(parse_content)
|
||||
|
||||
undeclared_template_variables = property(get_undeclared_template_variables)
|
||||
|
||||
|
||||
class Subdoc(object):
|
||||
""" Class for subdocument to insert into master document """
|
||||
def __init__(self, tpl, docpath=None):
|
||||
self.tpl = tpl
|
||||
self.docx = tpl.get_docx()
|
||||
self.subdocx = Document(docpath)
|
||||
self.subdocx._part = self.docx._part
|
||||
|
||||
def __getattr__(self, name):
|
||||
return getattr(self.subdocx, name)
|
||||
|
||||
def _get_xml(self):
|
||||
if self.subdocx._element.body.sectPr is not None:
|
||||
self.subdocx._element.body.remove(self.subdocx._element.body.sectPr)
|
||||
xml = re.sub(r'</?w:body[^>]*>', '', etree.tostring(
|
||||
self.subdocx._element.body, encoding='unicode', pretty_print=False))
|
||||
return xml
|
||||
|
||||
def __unicode__(self):
|
||||
return self._get_xml()
|
||||
|
||||
def __str__(self):
|
||||
return self._get_xml()
|
||||
|
||||
def __html__(self):
|
||||
return self._get_xml()
|
||||
|
||||
|
||||
class RichText(object):
|
||||
""" class to generate Rich Text when using templates variables
|
||||
|
||||
This is much faster than using Subdoc class,
|
||||
but this only for texts INSIDE an existing paragraph.
|
||||
"""
|
||||
def __init__(self, text=None, **text_prop):
|
||||
self.xml = ''
|
||||
if text:
|
||||
self.add(text, **text_prop)
|
||||
|
||||
def add(self, text,
|
||||
style=None,
|
||||
color=None,
|
||||
highlight=None,
|
||||
size=None,
|
||||
subscript=None,
|
||||
superscript=None,
|
||||
bold=False,
|
||||
italic=False,
|
||||
underline=False,
|
||||
strike=False,
|
||||
font=None,
|
||||
url_id=None):
|
||||
|
||||
# If a RichText is added
|
||||
if isinstance(text, RichText):
|
||||
self.xml += text.xml
|
||||
return
|
||||
|
||||
# If not a string : cast to string (ex: int, dict etc...)
|
||||
if not isinstance(text, (six.text_type, six.binary_type)):
|
||||
text = six.text_type(text)
|
||||
if not isinstance(text, six.text_type):
|
||||
text = text.decode('utf-8', errors='ignore')
|
||||
text = escape(text)
|
||||
|
||||
prop = u''
|
||||
|
||||
if style:
|
||||
prop += u'<w:rStyle w:val="%s"/>' % style
|
||||
if color:
|
||||
if color[0] == '#':
|
||||
color = color[1:]
|
||||
prop += u'<w:color w:val="%s"/>' % color
|
||||
if highlight:
|
||||
if highlight[0] == '#':
|
||||
highlight = highlight[1:]
|
||||
prop += u'<w:highlight w:val="%s"/>' % highlight
|
||||
if size:
|
||||
prop += u'<w:sz w:val="%s"/>' % size
|
||||
prop += u'<w:szCs w:val="%s"/>' % size
|
||||
if subscript:
|
||||
prop += u'<w:vertAlign w:val="subscript"/>'
|
||||
if superscript:
|
||||
prop += u'<w:vertAlign w:val="superscript"/>'
|
||||
if bold:
|
||||
prop += u'<w:b/>'
|
||||
if italic:
|
||||
prop += u'<w:i/>'
|
||||
if underline:
|
||||
if underline not in ['single', 'double', 'thick', 'dotted', 'dash', 'dotDash', 'dotDotDash', 'wave']:
|
||||
underline = 'single'
|
||||
prop += u'<w:u w:val="%s"/>' % underline
|
||||
if strike:
|
||||
prop += u'<w:strike/>'
|
||||
if font:
|
||||
prop += (u'<w:rFonts w:ascii="{font}" w:hAnsi="{font}" w:cs="{font}"/>'
|
||||
.format(font=font))
|
||||
|
||||
xml = u'<w:r>'
|
||||
if prop:
|
||||
xml += u'<w:rPr>%s</w:rPr>' % prop
|
||||
xml += u'<w:t xml:space="preserve">%s</w:t></w:r>' % text
|
||||
if url_id:
|
||||
xml = (u'<w:hyperlink r:id="%s" w:tgtFrame="_blank">%s</w:hyperlink>'
|
||||
% (url_id, xml))
|
||||
self.xml += xml
|
||||
|
||||
def __unicode__(self):
|
||||
return self.xml
|
||||
|
||||
def __str__(self):
|
||||
return self.xml
|
||||
|
||||
def __html__(self):
|
||||
return self.xml
|
||||
|
||||
|
||||
R = RichText
|
||||
|
||||
|
||||
class Listing(object):
|
||||
r"""class to manage \n and \a without to use RichText,
|
||||
by this way you keep the current template styling
|
||||
|
||||
use {{ mylisting }} in your template and
|
||||
context={ mylisting:Listing(the_listing_with_newlines) }
|
||||
"""
|
||||
def __init__(self, text):
|
||||
# If not a string : cast to string (ex: int, dict etc...)
|
||||
if not isinstance(text, (six.text_type, six.binary_type)):
|
||||
text = six.text_type(text)
|
||||
self.xml = escape(text)
|
||||
|
||||
def __unicode__(self):
|
||||
return self.xml
|
||||
|
||||
def __str__(self):
|
||||
return self.xml
|
||||
|
||||
def __html__(self):
|
||||
return self.xml
|
||||
|
||||
|
||||
class InlineImage(object):
|
||||
"""Class to generate an inline image
|
||||
|
||||
This is much faster than using Subdoc class.
|
||||
"""
|
||||
tpl = None
|
||||
image_descriptor = None
|
||||
width = None
|
||||
height = None
|
||||
|
||||
def __init__(self, tpl, image_descriptor, width=None, height=None):
|
||||
self.tpl, self.image_descriptor = tpl, image_descriptor
|
||||
self.width, self.height = width, height
|
||||
|
||||
def _insert_image(self):
|
||||
pic = self.tpl.current_rendering_part.new_pic_inline(
|
||||
self.image_descriptor,
|
||||
self.width,
|
||||
self.height
|
||||
).xml
|
||||
return '</w:t></w:r><w:r><w:drawing>%s</w:drawing></w:r><w:r>' \
|
||||
'<w:t xml:space="preserve">' % pic
|
||||
|
||||
def __unicode__(self):
|
||||
return self._insert_image()
|
||||
|
||||
def __str__(self):
|
||||
return self._insert_image()
|
||||
|
||||
def __html__(self):
|
||||
return self._insert_image()
|
||||
pass
|
||||
|
||||
175
docxtpl/__main__.py
Normal file
175
docxtpl/__main__.py
Normal file
@ -0,0 +1,175 @@
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
|
||||
from .template import DocxTemplate, TemplateError
|
||||
|
||||
TEMPLATE_ARG = "template_path"
|
||||
JSON_ARG = "json_path"
|
||||
OUTPUT_ARG = "output_filename"
|
||||
OVERWRITE_ARG = "overwrite"
|
||||
QUIET_ARG = "quiet"
|
||||
|
||||
|
||||
def make_arg_parser():
|
||||
parser = argparse.ArgumentParser(
|
||||
usage="python -m docxtpl [-h] [-o] [-q] {} {} {}".format(
|
||||
TEMPLATE_ARG, JSON_ARG, OUTPUT_ARG
|
||||
),
|
||||
description="Make docx file from existing template docx and json data.",
|
||||
)
|
||||
parser.add_argument(
|
||||
TEMPLATE_ARG, type=str, help="The path to the template docx file."
|
||||
)
|
||||
parser.add_argument(
|
||||
JSON_ARG, type=str, help="The path to the json file with the data."
|
||||
)
|
||||
parser.add_argument(
|
||||
OUTPUT_ARG, type=str, help="The filename to save the generated docx."
|
||||
)
|
||||
parser.add_argument(
|
||||
"-" + OVERWRITE_ARG[0],
|
||||
"--" + OVERWRITE_ARG,
|
||||
action="store_true",
|
||||
help="If output file already exists, overwrites without asking for confirmation",
|
||||
)
|
||||
parser.add_argument(
|
||||
"-" + QUIET_ARG[0],
|
||||
"--" + QUIET_ARG,
|
||||
action="store_true",
|
||||
help="Do not display unnecessary messages",
|
||||
)
|
||||
return parser
|
||||
|
||||
|
||||
def get_args(parser):
|
||||
try:
|
||||
parsed_args = vars(parser.parse_args())
|
||||
return parsed_args
|
||||
# Argument errors raise a SystemExit with code 2. Normal usage of the
|
||||
# --help or -h flag raises a SystemExit with code 0.
|
||||
except SystemExit as e:
|
||||
if e.code == 0:
|
||||
raise SystemExit
|
||||
else:
|
||||
raise RuntimeError(
|
||||
"Correct usage is:\n{parser.usage}".format(parser=parser)
|
||||
)
|
||||
|
||||
|
||||
def is_argument_valid(arg_name, arg_value, overwrite):
|
||||
# Basic checks for the arguments
|
||||
if arg_name == TEMPLATE_ARG:
|
||||
return os.path.isfile(arg_value) and arg_value.endswith(".docx")
|
||||
elif arg_name == JSON_ARG:
|
||||
return os.path.isfile(arg_value) and arg_value.endswith(".json")
|
||||
elif arg_name == OUTPUT_ARG:
|
||||
return arg_value.endswith(".docx") and check_exists_ask_overwrite(
|
||||
arg_value, overwrite
|
||||
)
|
||||
elif arg_name in [OVERWRITE_ARG, QUIET_ARG]:
|
||||
return arg_value in [True, False]
|
||||
|
||||
|
||||
def check_exists_ask_overwrite(arg_value, overwrite):
|
||||
# If output file does not exist or command was run with overwrite option,
|
||||
# returns True, else asks for overwrite confirmation. If overwrite is
|
||||
# confirmed returns True, else raises OSError.
|
||||
if os.path.exists(arg_value) and not overwrite:
|
||||
try:
|
||||
msg = (
|
||||
"File %s already exists, would you like to overwrite the existing file? "
|
||||
"(y/n)" % arg_value
|
||||
)
|
||||
if input(msg).lower() == "y":
|
||||
return True
|
||||
else:
|
||||
raise OSError
|
||||
except OSError:
|
||||
raise RuntimeError(
|
||||
"File %s already exists, please choose a different name." % arg_value
|
||||
)
|
||||
else:
|
||||
return True
|
||||
|
||||
|
||||
def validate_all_args(parsed_args):
|
||||
overwrite = parsed_args[OVERWRITE_ARG]
|
||||
# Raises AssertionError if any of the arguments is not validated
|
||||
try:
|
||||
for arg_name, arg_value in parsed_args.items():
|
||||
if not is_argument_valid(arg_name, arg_value, overwrite):
|
||||
raise AssertionError
|
||||
except AssertionError:
|
||||
raise RuntimeError(
|
||||
'The specified {arg_name} "{arg_value}" is not valid.'.format(
|
||||
arg_name=arg_name, arg_value=arg_value
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def get_json_data(json_path):
|
||||
with open(json_path) as file:
|
||||
try:
|
||||
json_data = json.load(file)
|
||||
return json_data
|
||||
except json.JSONDecodeError as e:
|
||||
print(
|
||||
"There was an error on line {e.lineno}, column {e.colno} while trying "
|
||||
"to parse file {json_path}".format(e=e, json_path=json_path)
|
||||
)
|
||||
raise RuntimeError("Failed to get json data.")
|
||||
|
||||
|
||||
def make_docxtemplate(template_path):
|
||||
try:
|
||||
return DocxTemplate(template_path)
|
||||
except TemplateError:
|
||||
raise RuntimeError("Could not create docx template.")
|
||||
|
||||
|
||||
def render_docx(doc, json_data):
|
||||
try:
|
||||
doc.render(json_data)
|
||||
return doc
|
||||
except TemplateError:
|
||||
raise RuntimeError("An error ocurred while trying to render the docx")
|
||||
|
||||
|
||||
def save_file(doc, parsed_args):
|
||||
try:
|
||||
output_path = parsed_args[OUTPUT_ARG]
|
||||
doc.save(output_path)
|
||||
if not parsed_args[QUIET_ARG]:
|
||||
print(
|
||||
"Document successfully generated and saved at {output_path}".format(
|
||||
output_path=output_path
|
||||
)
|
||||
)
|
||||
except OSError as e:
|
||||
print("{e.strerror}. Could not save file {e.filename}.".format(e=e))
|
||||
raise RuntimeError("Failed to save file.")
|
||||
|
||||
|
||||
def main():
|
||||
parser = make_arg_parser()
|
||||
# Everything is in a try-except block that catches a RuntimeError that is
|
||||
# raised if any of the individual functions called cause an error
|
||||
# themselves, terminating the main function.
|
||||
parsed_args = get_args(parser)
|
||||
try:
|
||||
validate_all_args(parsed_args)
|
||||
json_data = get_json_data(os.path.abspath(parsed_args[JSON_ARG]))
|
||||
doc = make_docxtemplate(os.path.abspath(parsed_args[TEMPLATE_ARG]))
|
||||
doc = render_docx(doc, json_data)
|
||||
save_file(doc, parsed_args)
|
||||
except RuntimeError as e:
|
||||
print("Error: " + e.__str__())
|
||||
return
|
||||
finally:
|
||||
if not parsed_args[QUIET_ARG]:
|
||||
print("Exiting program!")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
78
docxtpl/inline_image.py
Normal file
78
docxtpl/inline_image.py
Normal file
@ -0,0 +1,78 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
Created : 2021-07-30
|
||||
|
||||
@author: Eric Lapouyade
|
||||
"""
|
||||
from docx.oxml import OxmlElement, parse_xml
|
||||
from docx.oxml.ns import qn
|
||||
|
||||
|
||||
class InlineImage(object):
|
||||
"""Class to generate an inline image
|
||||
|
||||
This is much faster than using Subdoc class.
|
||||
"""
|
||||
|
||||
tpl = None
|
||||
image_descriptor = None
|
||||
width = None
|
||||
height = None
|
||||
anchor = None
|
||||
|
||||
def __init__(self, tpl, image_descriptor, width=None, height=None, anchor=None):
|
||||
self.tpl, self.image_descriptor = tpl, image_descriptor
|
||||
self.width, self.height = width, height
|
||||
self.anchor = anchor
|
||||
|
||||
def _add_hyperlink(self, run, url, part):
|
||||
# Create a relationship for the hyperlink
|
||||
r_id = part.relate_to(
|
||||
url,
|
||||
"http://schemas.openxmlformats.org/officeDocument/2006/relationships/hyperlink",
|
||||
is_external=True,
|
||||
)
|
||||
|
||||
# Find the <wp:docPr> and <pic:cNvPr> element
|
||||
docPr = run.xpath(".//wp:docPr")[0]
|
||||
cNvPr = run.xpath(".//pic:cNvPr")[0]
|
||||
|
||||
# Create the <a:hlinkClick> element
|
||||
hlinkClick1 = OxmlElement("a:hlinkClick")
|
||||
hlinkClick1.set(qn("r:id"), r_id)
|
||||
hlinkClick2 = OxmlElement("a:hlinkClick")
|
||||
hlinkClick2.set(qn("r:id"), r_id)
|
||||
|
||||
# Insert the <a:hlinkClick> element right after the <wp:docPr> element
|
||||
docPr.append(hlinkClick1)
|
||||
cNvPr.append(hlinkClick2)
|
||||
|
||||
return run
|
||||
|
||||
def _insert_image(self):
|
||||
pic = self.tpl.current_rendering_part.new_pic_inline(
|
||||
self.image_descriptor,
|
||||
self.width,
|
||||
self.height,
|
||||
).xml
|
||||
if self.anchor:
|
||||
run = parse_xml(pic)
|
||||
if run.xpath(".//a:blip"):
|
||||
hyperlink = self._add_hyperlink(
|
||||
run, self.anchor, self.tpl.current_rendering_part
|
||||
)
|
||||
pic = hyperlink.xml
|
||||
|
||||
return (
|
||||
"</w:t></w:r><w:r><w:drawing>%s</w:drawing></w:r><w:r>"
|
||||
'<w:t xml:space="preserve">' % pic
|
||||
)
|
||||
|
||||
def __unicode__(self):
|
||||
return self._insert_image()
|
||||
|
||||
def __str__(self):
|
||||
return self._insert_image()
|
||||
|
||||
def __html__(self):
|
||||
return self._insert_image()
|
||||
35
docxtpl/listing.py
Normal file
35
docxtpl/listing.py
Normal file
@ -0,0 +1,35 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
Created : 2021-07-30
|
||||
|
||||
@author: Eric Lapouyade
|
||||
"""
|
||||
try:
|
||||
from html import escape
|
||||
except ImportError:
|
||||
# cgi.escape is deprecated in python 3.7
|
||||
from cgi import escape
|
||||
|
||||
|
||||
class Listing(object):
|
||||
r"""class to manage \n and \a without to use RichText,
|
||||
by this way you keep the current template styling
|
||||
|
||||
use {{ mylisting }} in your template and
|
||||
context={ mylisting:Listing(the_listing_with_newlines) }
|
||||
"""
|
||||
|
||||
def __init__(self, text):
|
||||
# If not a string : cast to string (ex: int, dict etc...)
|
||||
if not isinstance(text, (str, bytes)):
|
||||
text = str(text)
|
||||
self.xml = escape(text)
|
||||
|
||||
def __unicode__(self):
|
||||
return self.xml
|
||||
|
||||
def __str__(self):
|
||||
return self.xml
|
||||
|
||||
def __html__(self):
|
||||
return self.xml
|
||||
180
docxtpl/richtext.py
Normal file
180
docxtpl/richtext.py
Normal file
@ -0,0 +1,180 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
Created : 2021-07-30
|
||||
|
||||
@author: Eric Lapouyade
|
||||
"""
|
||||
try:
|
||||
from html import escape
|
||||
except ImportError:
|
||||
# cgi.escape is deprecated in python 3.7
|
||||
from cgi import escape
|
||||
|
||||
|
||||
class RichText(object):
|
||||
"""class to generate Rich Text when using templates variables
|
||||
|
||||
This is much faster than using Subdoc class,
|
||||
but this only for texts INSIDE an existing paragraph.
|
||||
"""
|
||||
|
||||
def __init__(self, text=None, **text_prop):
|
||||
self.xml = ""
|
||||
if text:
|
||||
self.add(text, **text_prop)
|
||||
|
||||
def add(
|
||||
self,
|
||||
text,
|
||||
style=None,
|
||||
color=None,
|
||||
highlight=None,
|
||||
size=None,
|
||||
subscript=None,
|
||||
superscript=None,
|
||||
bold=False,
|
||||
italic=False,
|
||||
underline=False,
|
||||
strike=False,
|
||||
font=None,
|
||||
url_id=None,
|
||||
rtl=False,
|
||||
lang=None,
|
||||
):
|
||||
|
||||
# If a RichText is added
|
||||
if isinstance(text, RichText):
|
||||
self.xml += text.xml
|
||||
return
|
||||
|
||||
# # If nothing to add : just return
|
||||
# if text is None or text == "":
|
||||
# return
|
||||
|
||||
# If not a string : cast to string (ex: int, dict etc...)
|
||||
if not isinstance(text, (str, bytes)):
|
||||
text = str(text)
|
||||
if not isinstance(text, str):
|
||||
text = text.decode("utf-8", errors="ignore")
|
||||
text = escape(text)
|
||||
|
||||
prop = ""
|
||||
|
||||
if style:
|
||||
prop += '<w:rStyle w:val="%s"/>' % style
|
||||
if color:
|
||||
if color[0] == "#":
|
||||
color = color[1:]
|
||||
prop += '<w:color w:val="%s"/>' % color
|
||||
if highlight:
|
||||
if highlight[0] == "#":
|
||||
highlight = highlight[1:]
|
||||
prop += '<w:shd w:fill="%s"/>' % highlight
|
||||
if size:
|
||||
prop += '<w:sz w:val="%s"/>' % size
|
||||
prop += '<w:szCs w:val="%s"/>' % size
|
||||
if subscript:
|
||||
prop += '<w:vertAlign w:val="subscript"/>'
|
||||
if superscript:
|
||||
prop += '<w:vertAlign w:val="superscript"/>'
|
||||
if bold:
|
||||
prop += "<w:b/>"
|
||||
if rtl:
|
||||
prop += "<w:bCs/>"
|
||||
if italic:
|
||||
prop += "<w:i/>"
|
||||
if rtl:
|
||||
prop += "<w:iCs/>"
|
||||
if underline:
|
||||
if underline not in [
|
||||
"single",
|
||||
"double",
|
||||
"thick",
|
||||
"dotted",
|
||||
"dash",
|
||||
"dotDash",
|
||||
"dotDotDash",
|
||||
"wave",
|
||||
]:
|
||||
underline = "single"
|
||||
prop += '<w:u w:val="%s"/>' % underline
|
||||
if strike:
|
||||
prop += "<w:strike/>"
|
||||
if font:
|
||||
regional_font = ""
|
||||
if ":" in font:
|
||||
region, font = font.split(":", 1)
|
||||
regional_font = ' w:{region}="{font}"'.format(font=font, region=region)
|
||||
prop += '<w:rFonts w:ascii="{font}" w:hAnsi="{font}" w:cs="{font}"{regional_font}/>'.format(
|
||||
font=font, regional_font=regional_font
|
||||
)
|
||||
if rtl:
|
||||
prop += '<w:rtl w:val="true"/>'
|
||||
if lang:
|
||||
prop += '<w:lang w:val="%s"/>' % lang
|
||||
xml = "<w:r>"
|
||||
if prop:
|
||||
xml += "<w:rPr>%s</w:rPr>" % prop
|
||||
xml += '<w:t xml:space="preserve">%s</w:t></w:r>' % text
|
||||
if url_id:
|
||||
xml = '<w:hyperlink r:id="%s" w:tgtFrame="_blank">%s</w:hyperlink>' % (
|
||||
url_id,
|
||||
xml,
|
||||
)
|
||||
self.xml += xml
|
||||
|
||||
def __unicode__(self):
|
||||
return self.xml
|
||||
|
||||
def __str__(self):
|
||||
return self.xml
|
||||
|
||||
def __html__(self):
|
||||
return self.xml
|
||||
|
||||
|
||||
class RichTextParagraph(object):
|
||||
"""class to generate Rich Text Paragraphs when using templates variables
|
||||
|
||||
This is much faster than using Subdoc class,
|
||||
but this only for texts OUTSIDE an existing paragraph.
|
||||
"""
|
||||
|
||||
def __init__(self, text=None, **text_prop):
|
||||
self.xml = ""
|
||||
if text:
|
||||
self.add(text, **text_prop)
|
||||
|
||||
def add(
|
||||
self,
|
||||
text,
|
||||
parastyle=None,
|
||||
):
|
||||
|
||||
# If a RichText is added
|
||||
if not isinstance(text, RichText):
|
||||
text = RichText(text)
|
||||
|
||||
prop = ""
|
||||
if parastyle:
|
||||
prop += '<w:pStyle w:val="%s"/>' % parastyle
|
||||
|
||||
xml = "<w:p>"
|
||||
if prop:
|
||||
xml += "<w:pPr>%s</w:pPr>" % prop
|
||||
xml += text.xml
|
||||
xml += "</w:p>"
|
||||
self.xml += xml
|
||||
|
||||
def __unicode__(self):
|
||||
return self.xml
|
||||
|
||||
def __str__(self):
|
||||
return self.xml
|
||||
|
||||
def __html__(self):
|
||||
return self.xml
|
||||
|
||||
|
||||
R = RichText
|
||||
RP = RichTextParagraph
|
||||
103
docxtpl/subdoc.py
Normal file
103
docxtpl/subdoc.py
Normal file
@ -0,0 +1,103 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
Created : 2021-07-30
|
||||
|
||||
@author: Eric Lapouyade
|
||||
"""
|
||||
|
||||
from docx import Document
|
||||
from docx.oxml import CT_SectPr
|
||||
from docx.opc.constants import RELATIONSHIP_TYPE as RT
|
||||
from docxcompose.properties import CustomProperties
|
||||
from docxcompose.utils import xpath
|
||||
from docxcompose.composer import Composer
|
||||
from docxcompose.utils import NS
|
||||
from lxml import etree
|
||||
import re
|
||||
|
||||
|
||||
class SubdocComposer(Composer):
|
||||
def attach_parts(self, doc, remove_property_fields=True):
|
||||
"""Attach docx parts instead of appending the whole document
|
||||
thus subdoc insertion can be delegated to jinja2"""
|
||||
self.reset_reference_mapping()
|
||||
|
||||
# Remove custom property fields but keep the values
|
||||
if remove_property_fields:
|
||||
cprops = CustomProperties(doc)
|
||||
for name in cprops.keys():
|
||||
cprops.dissolve_fields(name)
|
||||
|
||||
self._create_style_id_mapping(doc)
|
||||
|
||||
for element in doc.element.body:
|
||||
if isinstance(element, CT_SectPr):
|
||||
continue
|
||||
self.add_referenced_parts(doc.part, self.doc.part, element)
|
||||
self.add_styles(doc, element)
|
||||
self.add_numberings(doc, element)
|
||||
self.restart_first_numbering(doc, element)
|
||||
self.add_images(doc, element)
|
||||
self.add_diagrams(doc, element)
|
||||
self.add_shapes(doc, element)
|
||||
self.add_footnotes(doc, element)
|
||||
self.remove_header_and_footer_references(doc, element)
|
||||
|
||||
self.add_styles_from_other_parts(doc)
|
||||
self.renumber_bookmarks()
|
||||
self.renumber_docpr_ids()
|
||||
self.renumber_nvpicpr_ids()
|
||||
self.fix_section_types(doc)
|
||||
|
||||
def add_diagrams(self, doc, element):
|
||||
# While waiting docxcompose 1.3.3
|
||||
dgm_rels = xpath(element, ".//dgm:relIds[@r:dm]")
|
||||
for dgm_rel in dgm_rels:
|
||||
for item, rt_type in (
|
||||
("dm", RT.DIAGRAM_DATA),
|
||||
("lo", RT.DIAGRAM_LAYOUT),
|
||||
("qs", RT.DIAGRAM_QUICK_STYLE),
|
||||
("cs", RT.DIAGRAM_COLORS),
|
||||
):
|
||||
dm_rid = dgm_rel.get("{%s}%s" % (NS["r"], item))
|
||||
dm_part = doc.part.rels[dm_rid].target_part
|
||||
new_rid = self.doc.part.relate_to(dm_part, rt_type)
|
||||
dgm_rel.set("{%s}%s" % (NS["r"], item), new_rid)
|
||||
|
||||
|
||||
class Subdoc(object):
|
||||
"""Class for subdocument to insert into master document"""
|
||||
|
||||
def __init__(self, tpl, docpath=None):
|
||||
self.tpl = tpl
|
||||
self.docx = tpl.get_docx()
|
||||
self.subdocx = Document(docpath)
|
||||
if docpath:
|
||||
compose = SubdocComposer(self.docx)
|
||||
compose.attach_parts(self.subdocx)
|
||||
else:
|
||||
self.subdocx._part = self.docx._part
|
||||
|
||||
def __getattr__(self, name):
|
||||
return getattr(self.subdocx, name)
|
||||
|
||||
def _get_xml(self):
|
||||
if self.subdocx.element.body.sectPr is not None:
|
||||
self.subdocx.element.body.remove(self.subdocx.element.body.sectPr)
|
||||
xml = re.sub(
|
||||
r"</?w:body[^>]*>",
|
||||
"",
|
||||
etree.tostring(
|
||||
self.subdocx.element.body, encoding="unicode", pretty_print=False
|
||||
),
|
||||
)
|
||||
return xml
|
||||
|
||||
def __unicode__(self):
|
||||
return self._get_xml()
|
||||
|
||||
def __str__(self):
|
||||
return self._get_xml()
|
||||
|
||||
def __html__(self):
|
||||
return self._get_xml()
|
||||
926
docxtpl/template.py
Normal file
926
docxtpl/template.py
Normal file
@ -0,0 +1,926 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
Created : 2015-03-12
|
||||
|
||||
@author: Eric Lapouyade
|
||||
"""
|
||||
|
||||
from os import PathLike
|
||||
from typing import TYPE_CHECKING, Any, Optional, IO, Union, Dict, Set
|
||||
import functools
|
||||
import io
|
||||
from lxml import etree
|
||||
from docx import Document
|
||||
from docx.opc.oxml import parse_xml
|
||||
from docx.opc.part import XmlPart
|
||||
import docx.oxml.ns
|
||||
from docx.opc.constants import RELATIONSHIP_TYPE as REL_TYPE
|
||||
from jinja2 import Environment, Template, meta
|
||||
from jinja2.exceptions import TemplateError
|
||||
|
||||
try:
|
||||
from html import escape # noqa: F401
|
||||
except ImportError:
|
||||
# cgi.escape is deprecated in python 3.7
|
||||
from cgi import escape # noqa: F401
|
||||
import re
|
||||
import binascii
|
||||
import os
|
||||
import zipfile
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .subdoc import Subdoc
|
||||
|
||||
|
||||
class DocxTemplate(object):
|
||||
"""Class for managing docx files as they were jinja2 templates"""
|
||||
|
||||
HEADER_URI = (
|
||||
"http://schemas.openxmlformats.org/officeDocument/2006/relationships/header"
|
||||
)
|
||||
FOOTER_URI = (
|
||||
"http://schemas.openxmlformats.org/officeDocument/2006/relationships/footer"
|
||||
)
|
||||
|
||||
def __init__(self, template_file: Union[IO[bytes], str, PathLike]) -> None:
|
||||
self.template_file = template_file
|
||||
self.reset_replacements()
|
||||
self.docx = None
|
||||
self.is_rendered = False
|
||||
self.is_saved = False
|
||||
self.allow_missing_pics = False
|
||||
|
||||
def init_docx(self, reload: bool = True):
|
||||
if not self.docx or (self.is_rendered and reload):
|
||||
self.docx = Document(self.template_file)
|
||||
self.is_rendered = False
|
||||
|
||||
def render_init(self):
|
||||
self.init_docx()
|
||||
self.pic_map = {}
|
||||
self.current_rendering_part = None
|
||||
self.docx_ids_index = 1000
|
||||
self.is_saved = False
|
||||
|
||||
def __getattr__(self, name):
|
||||
return getattr(self.docx, name)
|
||||
|
||||
def xml_to_string(self, xml, encoding="unicode"):
|
||||
# Be careful : pretty_print MUST be set to False, otherwise patch_xml()
|
||||
# won't work properly
|
||||
return etree.tostring(xml, encoding="unicode", pretty_print=False)
|
||||
|
||||
def get_docx(self):
|
||||
self.init_docx()
|
||||
return self.docx
|
||||
|
||||
def get_xml(self):
|
||||
return self.xml_to_string(self.docx._element.body)
|
||||
|
||||
def write_xml(self, filename):
|
||||
with open(filename, "w") as fh:
|
||||
fh.write(self.get_xml())
|
||||
|
||||
def patch_xml(self, src_xml):
|
||||
"""Make a lots of cleaning to have a raw xml understandable by jinja2 :
|
||||
strip all unnecessary xml tags, manage table cell background color and colspan,
|
||||
unescape html entities, etc..."""
|
||||
|
||||
# replace {<something>{ by {{ ( works with {{ }} {% and %} {# and #})
|
||||
src_xml = re.sub(
|
||||
r"(?<={)(<[^>]*>)+(?=[\{%\#])|(?<=[%\}\#])(<[^>]*>)+(?=\})",
|
||||
"",
|
||||
src_xml,
|
||||
flags=re.DOTALL,
|
||||
)
|
||||
|
||||
# replace {{<some tags>jinja2 stuff<some other tags>}} by {{jinja2 stuff}}
|
||||
# same thing with {% ... %} and {# #}
|
||||
# "jinja2 stuff" could a variable, a 'if' etc... anything jinja2 will understand
|
||||
def striptags(m):
|
||||
return re.sub(
|
||||
"</w:t>.*?(<w:t>|<w:t [^>]*>)", "", m.group(0), flags=re.DOTALL
|
||||
)
|
||||
|
||||
src_xml = re.sub(
|
||||
r"{%(?:(?!%}).)*|{#(?:(?!#}).)*|{{(?:(?!}}).)*",
|
||||
striptags,
|
||||
src_xml,
|
||||
flags=re.DOTALL,
|
||||
)
|
||||
|
||||
# manage table cell colspan
|
||||
def colspan(m):
|
||||
cell_xml = m.group(1) + m.group(3)
|
||||
cell_xml = re.sub(
|
||||
r"<w:r[ >](?:(?!<w:r[ >]).)*<w:t></w:t>.*?</w:r>",
|
||||
"",
|
||||
cell_xml,
|
||||
flags=re.DOTALL,
|
||||
)
|
||||
cell_xml = re.sub(r"<w:gridSpan[^/]*/>", "", cell_xml, count=1)
|
||||
return re.sub(
|
||||
r"(<w:tcPr[^>]*>)",
|
||||
r'\1<w:gridSpan w:val="{{%s}}"/>' % m.group(2),
|
||||
cell_xml,
|
||||
)
|
||||
|
||||
src_xml = re.sub(
|
||||
r"(<w:tc[ >](?:(?!<w:tc[ >]).)*){%\s*colspan\s+([^%]*)\s*%}(.*?</w:tc>)",
|
||||
colspan,
|
||||
src_xml,
|
||||
flags=re.DOTALL,
|
||||
)
|
||||
|
||||
# manage table cell background color
|
||||
def cellbg(m):
|
||||
cell_xml = m.group(1) + m.group(3)
|
||||
cell_xml = re.sub(
|
||||
r"<w:r[ >](?:(?!<w:r[ >]).)*<w:t></w:t>.*?</w:r>",
|
||||
"",
|
||||
cell_xml,
|
||||
flags=re.DOTALL,
|
||||
)
|
||||
cell_xml = re.sub(r"<w:shd[^/]*/>", "", cell_xml, count=1)
|
||||
return re.sub(
|
||||
r"(<w:tcPr[^>]*>)",
|
||||
r'\1<w:shd w:val="clear" w:color="auto" w:fill="{{%s}}"/>' % m.group(2),
|
||||
cell_xml,
|
||||
)
|
||||
|
||||
src_xml = re.sub(
|
||||
r"(<w:tc[ >](?:(?!<w:tc[ >]).)*){%\s*cellbg\s+([^%]*)\s*%}(.*?</w:tc>)",
|
||||
cellbg,
|
||||
src_xml,
|
||||
flags=re.DOTALL,
|
||||
)
|
||||
|
||||
# ensure space preservation
|
||||
src_xml = re.sub(
|
||||
r"<w:t>((?:(?!<w:t>).)*)({{.*?}}|{%.*?%})",
|
||||
r'<w:t xml:space="preserve">\1\2',
|
||||
src_xml,
|
||||
flags=re.DOTALL,
|
||||
)
|
||||
src_xml = re.sub(
|
||||
r"({{r\s.*?}}|{%r\s.*?%})",
|
||||
r'</w:t></w:r><w:r><w:t xml:space="preserve">\1</w:t></w:r><w:r><w:t xml:space="preserve">',
|
||||
src_xml,
|
||||
flags=re.DOTALL,
|
||||
)
|
||||
|
||||
# {%- will merge with previous paragraph text
|
||||
src_xml = re.sub(r"</w:t>(?:(?!</w:t>).)*?{%-", "{%", src_xml, flags=re.DOTALL)
|
||||
# -%} will merge with next paragraph text
|
||||
src_xml = re.sub(
|
||||
r"-%}(?:(?!<w:t[ >]|{%|{{).)*?<w:t[^>]*?>", "%}", src_xml, flags=re.DOTALL
|
||||
)
|
||||
|
||||
for y in ["tr", "tc", "p", "r"]:
|
||||
# replace into xml code the row/paragraph/run containing
|
||||
# {%y xxx %} or {{y xxx}} template tag
|
||||
# by {% xxx %} or {{ xx }} without any surrounding <w:y> tags :
|
||||
# This is mandatory to have jinja2 generating correct xml code
|
||||
pat = (
|
||||
r"<w:%(y)s[ >](?:(?!<w:%(y)s[ >]).)*({%%|{{)%(y)s ([^}%%]*(?:%%}|}})).*?</w:%(y)s>"
|
||||
% {"y": y}
|
||||
)
|
||||
src_xml = re.sub(pat, r"\1 \2", src_xml, flags=re.DOTALL)
|
||||
|
||||
for y in ["tr", "tc", "p"]:
|
||||
# same thing, but for {#y xxx #} (but not where y == 'r', since that
|
||||
# makes less sense to use comments in that context
|
||||
pat = (
|
||||
r"<w:%(y)s[ >](?:(?!<w:%(y)s[ >]).)*({#)%(y)s ([^}#]*(?:#})).*?</w:%(y)s>"
|
||||
% {"y": y}
|
||||
)
|
||||
src_xml = re.sub(pat, r"\1 \2", src_xml, flags=re.DOTALL)
|
||||
|
||||
# add vMerge
|
||||
# use {% vm %} to make this table cell and its copies
|
||||
# be vertically merged within a {% for %}
|
||||
def v_merge_tc(m):
|
||||
def v_merge(m1):
|
||||
return (
|
||||
'<w:vMerge w:val="{% if loop.first %}restart{% else %}continue{% endif %}"/>'
|
||||
+ m1.group(1) # Everything between ``</w:tcPr>`` and ``<w:t>``.
|
||||
+ "{% if loop.first %}"
|
||||
+ m1.group(2) # Everything before ``{% vm %}``.
|
||||
+ m1.group(3) # Everything after ``{% vm %}``.
|
||||
+ "{% endif %}"
|
||||
+ m1.group(4) # ``</w:t>``.
|
||||
)
|
||||
|
||||
return re.sub(
|
||||
r"(</w:tcPr[ >].*?<w:t(?:.*?)>)(.*?)(?:{%\s*vm\s*%})(.*?)(</w:t>)",
|
||||
v_merge,
|
||||
m.group(),
|
||||
# Everything between ``</w:tc>`` and ``</w:tc>`` with ``{% vm %}`` inside.
|
||||
flags=re.DOTALL,
|
||||
)
|
||||
|
||||
src_xml = re.sub(
|
||||
r"<w:tc[ >](?:(?!<w:tc[ >]).)*?{%\s*vm\s*%}.*?</w:tc[ >]",
|
||||
v_merge_tc,
|
||||
src_xml,
|
||||
flags=re.DOTALL,
|
||||
)
|
||||
|
||||
# Use ``{% hm %}`` to make table cell become horizontally merged within
|
||||
# a ``{% for %}``.
|
||||
def h_merge_tc(m):
|
||||
xml_to_patch = (
|
||||
m.group()
|
||||
) # Everything between ``</w:tc>`` and ``</w:tc>`` with ``{% hm %}`` inside.
|
||||
|
||||
def with_gridspan(m1):
|
||||
return (
|
||||
m1.group(1) # ``w:gridSpan w:val="``.
|
||||
+ "{{ "
|
||||
+ m1.group(2)
|
||||
+ " * loop.length }}" # Content of ``w:val``, multiplied by loop length.
|
||||
+ m1.group(3) # Closing quotation mark.
|
||||
)
|
||||
|
||||
def without_gridspan(m2):
|
||||
return (
|
||||
'<w:gridSpan w:val="{{ loop.length }}"/>'
|
||||
+ m2.group(1) # Everything between ``</w:tcPr>`` and ``<w:t>``.
|
||||
+ m2.group(2) # Everything before ``{% hm %}``.
|
||||
+ m2.group(3) # Everything after ``{% hm %}``.
|
||||
+ m2.group(4) # ``</w:t>``.
|
||||
)
|
||||
|
||||
if re.search(r"w:gridSpan", xml_to_patch):
|
||||
# Simple case, there's already ``gridSpan``, multiply its value.
|
||||
|
||||
xml = re.sub(
|
||||
r'(w:gridSpan w:val=")(\d+)(")',
|
||||
with_gridspan,
|
||||
xml_to_patch,
|
||||
flags=re.DOTALL,
|
||||
)
|
||||
xml = re.sub(
|
||||
r"{%\s*hm\s*%}",
|
||||
"",
|
||||
xml, # Patched xml.
|
||||
flags=re.DOTALL,
|
||||
)
|
||||
else:
|
||||
# There're no ``gridSpan``, add one.
|
||||
xml = re.sub(
|
||||
r"(</w:tcPr[ >].*?<w:t(?:.*?)>)(.*?)(?:{%\s*hm\s*%})(.*?)(</w:t>)",
|
||||
without_gridspan,
|
||||
xml_to_patch,
|
||||
flags=re.DOTALL,
|
||||
)
|
||||
|
||||
# Discard every other cell generated in loop.
|
||||
return "{% if loop.first %}" + xml + "{% endif %}"
|
||||
|
||||
src_xml = re.sub(
|
||||
r"<w:tc[ >](?:(?!<w:tc[ >]).)*?{%\s*hm\s*%}.*?</w:tc[ >]",
|
||||
h_merge_tc,
|
||||
src_xml,
|
||||
flags=re.DOTALL,
|
||||
)
|
||||
|
||||
def clean_tags(m):
|
||||
return (
|
||||
m.group(0)
|
||||
.replace(r"‘", "'")
|
||||
.replace("<", "<")
|
||||
.replace(">", ">")
|
||||
.replace("“", '"')
|
||||
.replace("”", '"')
|
||||
.replace("‘", "'")
|
||||
.replace("’", "'")
|
||||
)
|
||||
|
||||
src_xml = re.sub(r"(?<=\{[\{%])(.*?)(?=[\}%]})", clean_tags, src_xml)
|
||||
|
||||
return src_xml
|
||||
|
||||
def render_xml_part(self, src_xml, part, context, jinja_env=None):
|
||||
src_xml = re.sub(r"<w:p([ >])", r"\n<w:p\1", src_xml)
|
||||
try:
|
||||
self.current_rendering_part = part
|
||||
if jinja_env:
|
||||
template = jinja_env.from_string(src_xml)
|
||||
else:
|
||||
template = Template(src_xml)
|
||||
dst_xml = template.render(context)
|
||||
except TemplateError as exc:
|
||||
if hasattr(exc, "lineno") and exc.lineno is not None:
|
||||
line_number = max(exc.lineno - 4, 0)
|
||||
exc.docx_context = map(
|
||||
lambda x: re.sub(r"<[^>]+>", "", x),
|
||||
src_xml.splitlines()[line_number: (line_number + 7)], # fmt: skip
|
||||
)
|
||||
|
||||
raise exc
|
||||
dst_xml = re.sub(r"\n<w:p([ >])", r"<w:p\1", dst_xml)
|
||||
dst_xml = (
|
||||
dst_xml.replace("{_{", "{{")
|
||||
.replace("}_}", "}}")
|
||||
.replace("{_%", "{%")
|
||||
.replace("%_}", "%}")
|
||||
)
|
||||
dst_xml = self.resolve_listing(dst_xml)
|
||||
return dst_xml
|
||||
|
||||
def render_properties(
|
||||
self, context: Dict[str, Any], jinja_env: Optional[Environment] = None
|
||||
) -> None:
|
||||
# List of string attributes of docx.opc.coreprops.CoreProperties which are strings.
|
||||
# It seems that some attributes cannot be written as strings. Those are commented out.
|
||||
properties = [
|
||||
"author",
|
||||
# 'category',
|
||||
"comments",
|
||||
# 'content_status',
|
||||
"identifier",
|
||||
# 'keywords',
|
||||
"language",
|
||||
# 'last_modified_by',
|
||||
"subject",
|
||||
"title",
|
||||
# 'version',
|
||||
]
|
||||
if jinja_env is None:
|
||||
jinja_env = Environment()
|
||||
|
||||
for prop in properties:
|
||||
initial = getattr(self.docx.core_properties, prop)
|
||||
template = jinja_env.from_string(initial)
|
||||
rendered = template.render(context)
|
||||
setattr(self.docx.core_properties, prop, rendered)
|
||||
|
||||
def render_footnotes(
|
||||
self, context: Dict[str, Any], jinja_env: Optional[Environment] = None
|
||||
) -> None:
|
||||
if jinja_env is None:
|
||||
jinja_env = Environment()
|
||||
|
||||
for section in self.docx.sections:
|
||||
for part in section.part.package.parts:
|
||||
if part.content_type == (
|
||||
"application/vnd.openxmlformats-officedocument"
|
||||
".wordprocessingml.footnotes+xml"
|
||||
):
|
||||
xml = self.patch_xml(
|
||||
part.blob.decode("utf-8")
|
||||
if isinstance(part.blob, bytes)
|
||||
else part.blob
|
||||
)
|
||||
xml = self.render_xml_part(xml, part, context, jinja_env)
|
||||
part._blob = xml.encode("utf-8")
|
||||
|
||||
def resolve_listing(self, xml):
|
||||
|
||||
def resolve_text(run_properties, paragraph_properties, m):
|
||||
xml = m.group(0).replace(
|
||||
"\t",
|
||||
"</w:t></w:r>"
|
||||
"<w:r>%s<w:tab/></w:r>"
|
||||
'<w:r>%s<w:t xml:space="preserve">' % (run_properties, run_properties),
|
||||
)
|
||||
xml = xml.replace(
|
||||
"\a",
|
||||
"</w:t></w:r></w:p>"
|
||||
'<w:p>%s<w:r>%s<w:t xml:space="preserve">'
|
||||
% (paragraph_properties, run_properties),
|
||||
)
|
||||
xml = xml.replace("\n", '</w:t><w:br/><w:t xml:space="preserve">')
|
||||
xml = xml.replace(
|
||||
"\f",
|
||||
"</w:t></w:r></w:p>"
|
||||
'<w:p><w:r><w:br w:type="page"/></w:r></w:p>'
|
||||
'<w:p>%s<w:r>%s<w:t xml:space="preserve">'
|
||||
% (paragraph_properties, run_properties),
|
||||
)
|
||||
return xml
|
||||
|
||||
def resolve_run(paragraph_properties, m):
|
||||
run_properties = re.search(r"<w:rPr>.*?</w:rPr>", m.group(0))
|
||||
run_properties = run_properties.group(0) if run_properties else ""
|
||||
return re.sub(
|
||||
r"<w:t(?: [^>]*)?>.*?</w:t>",
|
||||
lambda x: resolve_text(run_properties, paragraph_properties, x),
|
||||
m.group(0),
|
||||
flags=re.DOTALL,
|
||||
)
|
||||
|
||||
def resolve_paragraph(m):
|
||||
paragraph_properties = re.search(r"<w:pPr>.*?</w:pPr>", m.group(0))
|
||||
paragraph_properties = (
|
||||
paragraph_properties.group(0) if paragraph_properties else ""
|
||||
)
|
||||
return re.sub(
|
||||
r"<w:r(?: [^>]*)?>.*?</w:r>",
|
||||
lambda x: resolve_run(paragraph_properties, x),
|
||||
m.group(0),
|
||||
flags=re.DOTALL,
|
||||
)
|
||||
|
||||
xml = re.sub(
|
||||
r"<w:p(?: [^>]*)?>.*?</w:p>", resolve_paragraph, xml, flags=re.DOTALL
|
||||
)
|
||||
|
||||
return xml
|
||||
|
||||
def build_xml(self, context, jinja_env=None):
|
||||
xml = self.get_xml()
|
||||
xml = self.patch_xml(xml)
|
||||
xml = self.render_xml_part(xml, self.docx._part, context, jinja_env)
|
||||
return xml
|
||||
|
||||
def map_tree(self, tree):
|
||||
root = self.docx._element
|
||||
body = root.body
|
||||
root.replace(body, tree)
|
||||
|
||||
def get_headers_footers(self, uri):
|
||||
for relKey, val in self.docx._part.rels.items():
|
||||
if (val.reltype == uri) and (val.target_part.blob):
|
||||
yield relKey, val.target_part
|
||||
|
||||
def get_part_xml(self, part):
|
||||
return self.xml_to_string(parse_xml(part.blob))
|
||||
|
||||
def get_headers_footers_encoding(self, xml):
|
||||
m = re.match(r'<\?xml[^\?]+\bencoding="([^"]+)"', xml, re.I)
|
||||
if m:
|
||||
return m.group(1)
|
||||
return "utf-8"
|
||||
|
||||
def build_headers_footers_xml(self, context, uri, jinja_env=None):
|
||||
for relKey, part in self.get_headers_footers(uri):
|
||||
xml = self.get_part_xml(part)
|
||||
encoding = self.get_headers_footers_encoding(xml)
|
||||
xml = self.patch_xml(xml)
|
||||
xml = self.render_xml_part(xml, part, context, jinja_env)
|
||||
yield relKey, xml.encode(encoding)
|
||||
|
||||
def map_headers_footers_xml(self, relKey, xml):
|
||||
part = self.docx._part.rels[relKey].target_part
|
||||
new_part = XmlPart.load(part.partname, part.content_type, xml, part.package)
|
||||
for rId, rel in part.rels.items():
|
||||
new_part.load_rel(rel.reltype, rel._target, rel.rId, rel.is_external)
|
||||
self.docx._part.rels[relKey]._target = new_part
|
||||
|
||||
def render(
|
||||
self,
|
||||
context: Dict[str, Any],
|
||||
jinja_env: Optional[Environment] = None,
|
||||
autoescape: bool = False,
|
||||
) -> None:
|
||||
# init template working attributes
|
||||
self.render_init()
|
||||
|
||||
if autoescape:
|
||||
if not jinja_env:
|
||||
jinja_env = Environment(autoescape=autoescape)
|
||||
else:
|
||||
jinja_env.autoescape = autoescape
|
||||
|
||||
# Body
|
||||
xml_src = self.build_xml(context, jinja_env)
|
||||
|
||||
# fix tables if needed
|
||||
tree = self.fix_tables(xml_src)
|
||||
|
||||
# fix docPr ID's
|
||||
self.fix_docpr_ids(tree)
|
||||
|
||||
# Replace body xml tree
|
||||
self.map_tree(tree)
|
||||
|
||||
# Headers
|
||||
headers = self.build_headers_footers_xml(context, self.HEADER_URI, jinja_env)
|
||||
for relKey, xml in headers:
|
||||
self.map_headers_footers_xml(relKey, xml)
|
||||
|
||||
# Footers
|
||||
footers = self.build_headers_footers_xml(context, self.FOOTER_URI, jinja_env)
|
||||
for relKey, xml in footers:
|
||||
self.map_headers_footers_xml(relKey, xml)
|
||||
|
||||
self.render_properties(context, jinja_env)
|
||||
|
||||
self.render_footnotes(context, jinja_env)
|
||||
|
||||
# set rendered flag
|
||||
self.is_rendered = True
|
||||
|
||||
# using of TC tag in for cycle can cause that count of columns does not
|
||||
# correspond to real count of columns in row. This function is able to fix it.
|
||||
def fix_tables(self, xml):
|
||||
parser = etree.XMLParser(recover=True)
|
||||
tree = etree.fromstring(xml, parser=parser)
|
||||
# get namespace
|
||||
ns = "{" + tree.nsmap["w"] + "}"
|
||||
# walk trough xml and find table
|
||||
for t in tree.iter(ns + "tbl"):
|
||||
tblGrid = t.find(ns + "tblGrid")
|
||||
columns = tblGrid.findall(ns + "gridCol")
|
||||
to_add = 0
|
||||
# walk trough all rows and try to find if there is higher cell count
|
||||
for r in t.iter(ns + "tr"):
|
||||
cells = r.findall(ns + "tc")
|
||||
if (len(columns) + to_add) < len(cells):
|
||||
to_add = len(cells) - len(columns)
|
||||
# is necessary to add columns?
|
||||
if to_add > 0:
|
||||
# at first, calculate width of table according to columns
|
||||
# (we want to preserve it)
|
||||
width = 0.0
|
||||
new_average = None
|
||||
for c in columns:
|
||||
if not c.get(ns + "w") is None:
|
||||
width += float(c.get(ns + "w"))
|
||||
# try to keep proportion of table
|
||||
if width > 0:
|
||||
old_average = width / len(columns)
|
||||
new_average = width / (len(columns) + to_add)
|
||||
# scale the old columns
|
||||
for c in columns:
|
||||
c.set(
|
||||
ns + "w",
|
||||
str(
|
||||
int(float(c.get(ns + "w")) * new_average / old_average)
|
||||
),
|
||||
)
|
||||
# add new columns
|
||||
for i in range(to_add):
|
||||
etree.SubElement(
|
||||
tblGrid, ns + "gridCol", {ns + "w": str(int(new_average))}
|
||||
)
|
||||
|
||||
# Refetch columns after columns addition.
|
||||
columns = tblGrid.findall(ns + "gridCol")
|
||||
columns_len = len(columns)
|
||||
|
||||
cells_len_max = 0
|
||||
|
||||
def get_cell_len(total, cell):
|
||||
tc_pr = cell.find(ns + "tcPr")
|
||||
grid_span = None if tc_pr is None else tc_pr.find(ns + "gridSpan")
|
||||
|
||||
if grid_span is not None:
|
||||
return total + int(grid_span.get(ns + "val"))
|
||||
|
||||
return total + 1
|
||||
|
||||
# Calculate max of table cells to compare with `gridCol`.
|
||||
for r in t.iter(ns + "tr"):
|
||||
cells = r.findall(ns + "tc")
|
||||
cells_len = functools.reduce(get_cell_len, cells, 0)
|
||||
cells_len_max = max(cells_len_max, cells_len)
|
||||
|
||||
to_remove = columns_len - cells_len_max
|
||||
|
||||
# If after the loop, there're less columns, than
|
||||
# originally was, remove extra `gridCol` declarations.
|
||||
if to_remove > 0:
|
||||
# Have to keep track of the removed width to scale the
|
||||
# table back to its original width.
|
||||
removed_width = 0.0
|
||||
|
||||
for c in columns[-to_remove:]:
|
||||
removed_width += float(c.get(ns + "w"))
|
||||
|
||||
tblGrid.remove(c)
|
||||
|
||||
columns_left = tblGrid.findall(ns + "gridCol")
|
||||
|
||||
# Distribute `removed_width` across all columns that has
|
||||
# left after extras removal.
|
||||
extra_space = 0
|
||||
if len(columns_left) > 0:
|
||||
extra_space = removed_width / len(columns_left)
|
||||
extra_space = int(extra_space)
|
||||
|
||||
for c in columns_left:
|
||||
c.set(ns + "w", str(int(float(c.get(ns + "w")) + extra_space)))
|
||||
|
||||
return tree
|
||||
|
||||
def fix_docpr_ids(self, tree):
|
||||
# some Ids may have some collisions : so renumbering all of them :
|
||||
for elt in tree.xpath("//wp:docPr", namespaces=docx.oxml.ns.nsmap):
|
||||
self.docx_ids_index += 1
|
||||
elt.attrib["id"] = str(self.docx_ids_index)
|
||||
|
||||
def new_subdoc(self, docpath=None) -> Subdoc:
|
||||
from .subdoc import Subdoc
|
||||
|
||||
self.init_docx()
|
||||
return Subdoc(self, docpath)
|
||||
|
||||
@staticmethod
|
||||
def get_file_crc(file_obj):
|
||||
if hasattr(file_obj, "read"):
|
||||
buf = file_obj.read()
|
||||
else:
|
||||
with open(file_obj, "rb") as fh:
|
||||
buf = fh.read()
|
||||
|
||||
crc = binascii.crc32(buf) & 0xFFFFFFFF
|
||||
return crc
|
||||
|
||||
def replace_media(self, src_file, dst_file):
|
||||
"""Replace one media by another one into a docx
|
||||
|
||||
This has been done mainly because it is not possible to add images in
|
||||
docx header/footer.
|
||||
With this function, put a dummy picture in your header/footer,
|
||||
then specify it with its replacement in this function using the file path
|
||||
or file-like objects.
|
||||
|
||||
Syntax: tpl.replace_media('dummy_media_to_replace.png','media_to_paste.jpg')
|
||||
-- or --
|
||||
tpl.replace_media(io.BytesIO(image_stream), io.BytesIO(new_image_stream))
|
||||
|
||||
Note: for images, the aspect ratio will be the same as the replaced image
|
||||
|
||||
Note2: it is important to have the source media file as it is required
|
||||
to calculate its CRC to find them in the docx
|
||||
"""
|
||||
|
||||
crc = self.get_file_crc(src_file)
|
||||
if hasattr(dst_file, "read"):
|
||||
self.crc_to_new_media[crc] = dst_file.read()
|
||||
else:
|
||||
with open(dst_file, "rb") as fh:
|
||||
self.crc_to_new_media[crc] = fh.read()
|
||||
|
||||
def replace_pic(self, embedded_file, dst_file):
|
||||
"""Replace embedded picture with original-name given by embedded_file.
|
||||
(give only the file basename, not the full path)
|
||||
The new picture is given by dst_file (either a filename or a file-like
|
||||
object)
|
||||
|
||||
Notes:
|
||||
1) embedded_file and dst_file must have the same extension/format
|
||||
in case dst_file is a file-like object, no check is done on
|
||||
format compatibility
|
||||
2) the aspect ratio will be the same as the replaced image
|
||||
3) There is no need to keep the original file (this is not the case
|
||||
for replace_embedded and replace_media)
|
||||
"""
|
||||
|
||||
if hasattr(dst_file, "read"):
|
||||
# NOTE: file extension not checked
|
||||
self.pics_to_replace[embedded_file] = dst_file.read()
|
||||
else:
|
||||
with open(dst_file, "rb") as fh:
|
||||
self.pics_to_replace[embedded_file] = fh.read()
|
||||
|
||||
def replace_embedded(self, src_file, dst_file):
|
||||
"""Replace one embedded object by another one into a docx
|
||||
|
||||
This has been done mainly because it is not possible to add images
|
||||
in docx header/footer.
|
||||
With this function, put a dummy picture in your header/footer,
|
||||
then specify it with its replacement in this function
|
||||
|
||||
Syntax: tpl.replace_embedded('dummy_doc.docx','doc_to_paste.docx')
|
||||
|
||||
Note2 : it is important to have the source file as it is required to
|
||||
calculate its CRC to find them in the docx
|
||||
"""
|
||||
with open(dst_file, "rb") as fh:
|
||||
crc = self.get_file_crc(src_file)
|
||||
self.crc_to_new_embedded[crc] = fh.read()
|
||||
|
||||
def replace_zipname(self, zipname, dst_file):
|
||||
"""Replace one file in the docx file
|
||||
|
||||
First note that a MSWord .docx file is in fact a zip file.
|
||||
|
||||
This method can be used to replace document embedded in the docx template.
|
||||
|
||||
Some embedded document may have been modified by MSWord while saving
|
||||
the template : thus replace_embedded() cannot be used as CRC is not the
|
||||
same as the original file.
|
||||
|
||||
This method works for embedded MSWord file like Excel or PowerPoint file,
|
||||
but won't work for others like PDF, Python or even Text files :
|
||||
For these ones, MSWord generate an oleObjectNNN.bin file which is no
|
||||
use to be replaced as it is encoded.
|
||||
|
||||
Syntax:
|
||||
|
||||
tpl.replace_zipname(
|
||||
'word/embeddings/Feuille_Microsoft_Office_Excel1.xlsx',
|
||||
'my_excel_file.xlsx')
|
||||
|
||||
The zipname is the one you can find when you open docx with WinZip,
|
||||
7zip (Windows) or unzip -l (Linux). The zipname starts with
|
||||
"word/embeddings/". Note that the file is renamed by MSWord,
|
||||
so you have to guess a little bit...
|
||||
"""
|
||||
with open(dst_file, "rb") as fh:
|
||||
self.zipname_to_replace[zipname] = fh.read()
|
||||
|
||||
def reset_replacements(self):
|
||||
"""Reset replacement dictionaries
|
||||
|
||||
This will reset data for image/embedded/zipname replacement
|
||||
|
||||
This is useful when calling several times render() with different
|
||||
image/embedded/zipname replacements without re-instantiating
|
||||
DocxTemplate object.
|
||||
In this case, the right sequence for each rendering will be :
|
||||
- reset_replacements(...)
|
||||
- replace_zipname(...), replace_media(...) and/or replace_embedded(...),
|
||||
- render(...)
|
||||
|
||||
If you instantiate DocxTemplate object before each render(),
|
||||
this method is useless.
|
||||
"""
|
||||
self.crc_to_new_media = {}
|
||||
self.crc_to_new_embedded = {}
|
||||
self.zipname_to_replace = {}
|
||||
self.pics_to_replace = {}
|
||||
|
||||
def post_processing(self, docx_file):
|
||||
if self.crc_to_new_media or self.crc_to_new_embedded or self.zipname_to_replace:
|
||||
|
||||
if hasattr(docx_file, "read"):
|
||||
tmp_file = io.BytesIO()
|
||||
DocxTemplate(docx_file).save(tmp_file)
|
||||
tmp_file.seek(0)
|
||||
docx_file.seek(0)
|
||||
docx_file.truncate()
|
||||
docx_file.seek(0)
|
||||
|
||||
else:
|
||||
tmp_file = "%s_docxtpl_before_replace_medias" % docx_file
|
||||
os.rename(docx_file, tmp_file)
|
||||
|
||||
with zipfile.ZipFile(tmp_file) as zin:
|
||||
with zipfile.ZipFile(docx_file, "w") as zout:
|
||||
for item in zin.infolist():
|
||||
buf = zin.read(item.filename)
|
||||
if item.filename in self.zipname_to_replace:
|
||||
zout.writestr(item, self.zipname_to_replace[item.filename])
|
||||
elif (
|
||||
item.filename.startswith("word/media/")
|
||||
and item.CRC in self.crc_to_new_media
|
||||
):
|
||||
zout.writestr(item, self.crc_to_new_media[item.CRC])
|
||||
elif (
|
||||
item.filename.startswith("word/embeddings/")
|
||||
and item.CRC in self.crc_to_new_embedded
|
||||
):
|
||||
zout.writestr(item, self.crc_to_new_embedded[item.CRC])
|
||||
else:
|
||||
zout.writestr(item, buf)
|
||||
|
||||
if not hasattr(tmp_file, "read"):
|
||||
os.remove(tmp_file)
|
||||
if hasattr(docx_file, "read"):
|
||||
docx_file.seek(0)
|
||||
|
||||
def pre_processing(self):
|
||||
|
||||
if self.pics_to_replace:
|
||||
self._replace_pics()
|
||||
|
||||
def _replace_pics(self):
|
||||
"""Replaces pictures xml tags in the docx template with pictures provided by the user"""
|
||||
|
||||
replaced_pics = {key: False for key in self.pics_to_replace}
|
||||
|
||||
# Main document
|
||||
part = self.docx.part
|
||||
self._replace_docx_part_pics(part, replaced_pics)
|
||||
|
||||
# Header/Footer
|
||||
for relid, rel in part.rels.items():
|
||||
if rel.reltype in (REL_TYPE.HEADER, REL_TYPE.FOOTER):
|
||||
self._replace_docx_part_pics(rel.target_part, replaced_pics)
|
||||
|
||||
if not self.allow_missing_pics:
|
||||
# make sure all template images defined by user were replaced
|
||||
for img_id, replaced in replaced_pics.items():
|
||||
if not replaced:
|
||||
raise ValueError(
|
||||
"Picture %s not found in the docx template" % img_id
|
||||
)
|
||||
|
||||
def get_pic_map(self):
|
||||
return self.pic_map
|
||||
|
||||
def _replace_docx_part_pics(self, doc_part, replaced_pics):
|
||||
|
||||
et = etree.fromstring(doc_part.blob)
|
||||
|
||||
part_map = {}
|
||||
|
||||
gds = et.xpath("//a:graphic/a:graphicData", namespaces=docx.oxml.ns.nsmap)
|
||||
for gd in gds:
|
||||
rel = None
|
||||
# Either IMAGE, CHART, SMART_ART, ...
|
||||
try:
|
||||
if gd.attrib["uri"] == docx.oxml.ns.nsmap["pic"]:
|
||||
# Either PICTURE or LINKED_PICTURE image
|
||||
blip = gd.xpath(
|
||||
"pic:pic/pic:blipFill/a:blip", namespaces=docx.oxml.ns.nsmap
|
||||
)[0]
|
||||
dest = blip.xpath("@r:embed", namespaces=docx.oxml.ns.nsmap)
|
||||
if len(dest) > 0:
|
||||
rel = dest[0]
|
||||
else:
|
||||
continue
|
||||
else:
|
||||
continue
|
||||
|
||||
non_visual_properties = "pic:pic/pic:nvPicPr/pic:cNvPr/"
|
||||
filename = gd.xpath(
|
||||
"%s@name" % non_visual_properties, namespaces=docx.oxml.ns.nsmap
|
||||
)[0]
|
||||
titles = gd.xpath(
|
||||
"%s@title" % non_visual_properties, namespaces=docx.oxml.ns.nsmap
|
||||
)
|
||||
if titles:
|
||||
title = titles[0]
|
||||
else:
|
||||
title = ""
|
||||
descriptions = gd.xpath(
|
||||
"%s@descr" % non_visual_properties, namespaces=docx.oxml.ns.nsmap
|
||||
)
|
||||
if descriptions:
|
||||
description = descriptions[0]
|
||||
else:
|
||||
description = ""
|
||||
|
||||
part_map[filename] = (
|
||||
doc_part.rels[rel].target_ref,
|
||||
doc_part.rels[rel].target_part,
|
||||
)
|
||||
|
||||
# replace data
|
||||
for img_id, img_data in self.pics_to_replace.items():
|
||||
if img_id == filename or img_id == title or img_id == description:
|
||||
part_map[filename][1]._blob = img_data
|
||||
replaced_pics[img_id] = True
|
||||
break
|
||||
|
||||
# FIXME: figure out what exceptions are thrown here
|
||||
# and catch more specific exceptions
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
self.pic_map.update(part_map)
|
||||
|
||||
def build_url_id(self, url):
|
||||
self.init_docx()
|
||||
return self.docx._part.relate_to(url, REL_TYPE.HYPERLINK, is_external=True)
|
||||
|
||||
def save(self, filename: Union[IO[bytes], str, PathLike], *args, **kwargs) -> None:
|
||||
# case where save() is called without doing rendering
|
||||
# ( user wants only to replace image/embedded/zipname )
|
||||
if not self.is_saved and not self.is_rendered:
|
||||
self.docx = Document(self.template_file)
|
||||
self.pre_processing()
|
||||
self.docx.save(filename, *args, **kwargs)
|
||||
self.post_processing(filename)
|
||||
self.is_saved = True
|
||||
|
||||
def get_undeclared_template_variables(
|
||||
self,
|
||||
jinja_env: Optional[Environment] = None,
|
||||
context: Optional[Dict[str, Any]] = None,
|
||||
) -> Set[str]:
|
||||
# Create a temporary document to analyze the template without affecting the current state
|
||||
temp_doc = Document(self.template_file)
|
||||
|
||||
# Get XML from the temporary document
|
||||
xml = self.xml_to_string(temp_doc._element.body)
|
||||
xml = self.patch_xml(xml)
|
||||
|
||||
# Add headers and footers
|
||||
for uri in [self.HEADER_URI, self.FOOTER_URI]:
|
||||
for relKey, val in temp_doc._part.rels.items():
|
||||
if (val.reltype == uri) and (val.target_part.blob):
|
||||
_xml = self.xml_to_string(parse_xml(val.target_part.blob))
|
||||
xml += self.patch_xml(_xml)
|
||||
|
||||
if jinja_env:
|
||||
env = jinja_env
|
||||
else:
|
||||
env = Environment()
|
||||
|
||||
parse_content = env.parse(xml)
|
||||
all_variables = meta.find_undeclared_variables(parse_content)
|
||||
|
||||
# If context is provided, return only variables that are not in the context
|
||||
if context is not None:
|
||||
provided_variables = set(context.keys())
|
||||
return all_variables - provided_variables
|
||||
|
||||
# If no context provided, return all variables (original behavior)
|
||||
return all_variables
|
||||
1032
poetry.lock
generated
Normal file
1032
poetry.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
70
pyproject.toml
Normal file
70
pyproject.toml
Normal file
@ -0,0 +1,70 @@
|
||||
[project]
|
||||
name = "docxtpl"
|
||||
dynamic = ["version"]
|
||||
description = "Python docx template engine"
|
||||
authors = [{name="Eric Lapouyade", email="elapouya@proton.me"}]
|
||||
readme = "README.rst"
|
||||
requires-python = ">=3.7"
|
||||
license = {text="LGPL-2.1-only"}
|
||||
classifiers=[
|
||||
"Intended Audience :: Developers",
|
||||
"Development Status :: 4 - Beta",
|
||||
"Programming Language :: Python :: 3",
|
||||
"Programming Language :: Python :: 3.7",
|
||||
"Programming Language :: Python :: 3.8",
|
||||
"Programming Language :: Python :: 3.9",
|
||||
"Programming Language :: Python :: 3.10",
|
||||
"Programming Language :: Python :: 3.11",
|
||||
"Programming Language :: Python :: 3.12",
|
||||
"Programming Language :: Python :: 3.13",
|
||||
]
|
||||
keywords = ["jinja2"]
|
||||
dependencies = [
|
||||
"python-docx",
|
||||
"jinja2",
|
||||
"lxml",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
subdoc = ["docxcompose"]
|
||||
docs = ["Sphinx", "sphinxcontrib-napoleon"]
|
||||
|
||||
[dependency-groups]
|
||||
dev = [
|
||||
"mypy >=1.18.2; python_version >= '3.9'",
|
||||
"lxml-stubs >=0.5.1; python_version >= '3.9'",
|
||||
"flake8 >=7.3.0; python_version >= '3.9'"
|
||||
]
|
||||
|
||||
[project.urls]
|
||||
homepage = "https://github.com/elapouya/python-docx-template"
|
||||
repository = "https://github.com/elapouya/python-docx-template.git"
|
||||
document = "https://docxtpl.readthedocs.org"
|
||||
|
||||
[tool.poetry]
|
||||
version = "0.0.0"
|
||||
|
||||
[tool.poetry.requires-plugins]
|
||||
poetry-dynamic-versioning = { version = ">=1.0.0,<2.0.0", extras = ["plugin"] }
|
||||
|
||||
[tool.poetry-dynamic-versioning]
|
||||
enable = true
|
||||
|
||||
[tool.poetry-dynamic-versioning.from-file]
|
||||
source = "docxtpl/__init__.py"
|
||||
pattern = '__version__ = "(.+)"'
|
||||
|
||||
[tool.mypy]
|
||||
pretty = true
|
||||
python_version = "3.9"
|
||||
check_untyped_defs = true
|
||||
warn_unused_ignores = true
|
||||
exclude = ["docs", "build", "setup.py"]
|
||||
|
||||
[[tool.mypy.overrides]]
|
||||
module = ["docxcompose.*"]
|
||||
ignore_missing_imports = true
|
||||
|
||||
[build-system]
|
||||
requires = ["poetry-core", "poetry-dynamic-versioning >=1.0.0,<2.0.0"]
|
||||
build-backend = "poetry_dynamic_versioning.backend"
|
||||
@ -1,4 +1,5 @@
|
||||
six
|
||||
python-docx
|
||||
docxcompose
|
||||
jinja2
|
||||
lxml
|
||||
sphinx-book-theme
|
||||
|
||||
68
setup.py
68
setup.py
@ -1,8 +1,9 @@
|
||||
from setuptools import setup
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
|
||||
from setuptools import setup
|
||||
|
||||
# To register onto Pypi :
|
||||
# python setup.py sdist bdist_wheel upload
|
||||
|
||||
@ -10,13 +11,13 @@ import sys
|
||||
def read(*names):
|
||||
values = dict()
|
||||
for name in names:
|
||||
filename = name + '.rst'
|
||||
filename = name + ".rst"
|
||||
if os.path.isfile(filename):
|
||||
fd = open(filename)
|
||||
value = fd.read()
|
||||
fd.close()
|
||||
else:
|
||||
value = ''
|
||||
value = ""
|
||||
values[name] = value
|
||||
return values
|
||||
|
||||
@ -27,13 +28,15 @@ long_description = """
|
||||
News
|
||||
====
|
||||
%(CHANGES)s
|
||||
""" % read('README', 'CHANGES')
|
||||
""" % read(
|
||||
"README", "CHANGES"
|
||||
)
|
||||
|
||||
|
||||
def get_version(pkg):
|
||||
path = os.path.join(os.path.dirname(__file__), pkg, '__init__.py')
|
||||
path = os.path.join(os.path.dirname(__file__), pkg, "__init__.py")
|
||||
if sys.version_info >= (3, 0):
|
||||
fh = open(path, encoding='utf-8') # required to read utf-8 file on windows
|
||||
fh = open(path, encoding="utf-8") # required to read utf-8 file on windows
|
||||
else:
|
||||
fh = open(path) # encoding parameter does not exist in python 2
|
||||
with fh:
|
||||
@ -43,27 +46,32 @@ def get_version(pkg):
|
||||
raise RuntimeError("Unable to find __version__ string in %s." % path)
|
||||
|
||||
|
||||
setup(name='docxtpl',
|
||||
version=get_version('docxtpl'),
|
||||
description='Python docx template engine',
|
||||
long_description=long_description,
|
||||
classifiers=[
|
||||
"Intended Audience :: Developers",
|
||||
"Development Status :: 4 - Beta",
|
||||
"Programming Language :: Python :: 2",
|
||||
"Programming Language :: Python :: 2.7",
|
||||
"Programming Language :: Python :: 3",
|
||||
"Programming Language :: Python :: 3.4",
|
||||
],
|
||||
keywords='jinja2',
|
||||
url='https://github.com/elapouya/python-docx-template',
|
||||
author='Eric Lapouyade',
|
||||
license='LGPL 2.1',
|
||||
packages=['docxtpl'],
|
||||
install_requires=['six',
|
||||
'python-docx',
|
||||
'jinja2',
|
||||
'lxml'],
|
||||
extras_require={'docs': ['Sphinx', 'sphinxcontrib-napoleon']},
|
||||
eager_resources=['docs'],
|
||||
zip_safe=False)
|
||||
setup(
|
||||
name="docxtpl",
|
||||
version=get_version("docxtpl"),
|
||||
description="Python docx template engine",
|
||||
long_description=long_description,
|
||||
long_description_content_type="text/x-rst",
|
||||
classifiers=[
|
||||
"Intended Audience :: Developers",
|
||||
"Development Status :: 4 - Beta",
|
||||
"Programming Language :: Python :: 3",
|
||||
"Programming Language :: Python :: 3.7",
|
||||
"Programming Language :: Python :: 3.8",
|
||||
"Programming Language :: Python :: 3.9",
|
||||
"Programming Language :: Python :: 3.10",
|
||||
"Programming Language :: Python :: 3.11",
|
||||
"Programming Language :: Python :: 3.12",
|
||||
"Programming Language :: Python :: 3.13",
|
||||
],
|
||||
keywords="jinja2",
|
||||
url="https://github.com/elapouya/python-docx-template",
|
||||
author="Eric Lapouyade",
|
||||
license="LGPL-2.1-only",
|
||||
license_files=[],
|
||||
packages=["docxtpl"],
|
||||
install_requires=["python-docx>=1.1.1", "jinja2", "lxml"],
|
||||
extras_require={"docs": ["Sphinx", "sphinxcontrib-napoleon"], "subdoc": ["docxcompose"]},
|
||||
eager_resources=["docs"],
|
||||
zip_safe=False,
|
||||
)
|
||||
|
||||
@ -1,42 +1,42 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
'''
|
||||
"""
|
||||
Created : 2015-03-12
|
||||
|
||||
@author: Eric Lapouyade
|
||||
'''
|
||||
"""
|
||||
|
||||
from docxtpl import DocxTemplate, RichText
|
||||
|
||||
tpl = DocxTemplate('templates/cellbg_tpl.docx')
|
||||
tpl = DocxTemplate("templates/cellbg_tpl.docx")
|
||||
|
||||
context = {
|
||||
'alerts': [
|
||||
"alerts": [
|
||||
{
|
||||
'date': '2015-03-10',
|
||||
'desc': RichText('Very critical alert', color='FF0000', bold=True),
|
||||
'type': 'CRITICAL',
|
||||
'bg': 'FF0000',
|
||||
"date": "2015-03-10",
|
||||
"desc": RichText("Very critical alert", color="FF0000", bold=True),
|
||||
"type": "CRITICAL",
|
||||
"bg": "FF0000",
|
||||
},
|
||||
{
|
||||
'date': '2015-03-11',
|
||||
'desc': RichText('Just a warning'),
|
||||
'type': 'WARNING',
|
||||
'bg': 'FFDD00',
|
||||
"date": "2015-03-11",
|
||||
"desc": RichText("Just a warning"),
|
||||
"type": "WARNING",
|
||||
"bg": "FFDD00",
|
||||
},
|
||||
{
|
||||
'date': '2015-03-12',
|
||||
'desc': RichText('Information'),
|
||||
'type': 'INFO',
|
||||
'bg': '8888FF',
|
||||
"date": "2015-03-12",
|
||||
"desc": RichText("Information"),
|
||||
"type": "INFO",
|
||||
"bg": "8888FF",
|
||||
},
|
||||
{
|
||||
'date': '2015-03-13',
|
||||
'desc': RichText('Debug trace'),
|
||||
'type': 'DEBUG',
|
||||
'bg': 'FF00FF',
|
||||
"date": "2015-03-13",
|
||||
"desc": RichText("Debug trace"),
|
||||
"type": "DEBUG",
|
||||
"bg": "FF00FF",
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
tpl.render(context)
|
||||
tpl.save('output/cellbg.docx')
|
||||
tpl.save("output/cellbg.docx")
|
||||
|
||||
6
tests/comments.py
Normal file
6
tests/comments.py
Normal file
@ -0,0 +1,6 @@
|
||||
from docxtpl import DocxTemplate
|
||||
|
||||
tpl = DocxTemplate("templates/comments_tpl.docx")
|
||||
|
||||
tpl.render({})
|
||||
tpl.save("output/comments.docx")
|
||||
@ -1,9 +1,9 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
'''
|
||||
"""
|
||||
Created : 2015-03-12
|
||||
|
||||
@author: sandeeprah, Eric Lapouyade
|
||||
'''
|
||||
"""
|
||||
|
||||
from docxtpl import DocxTemplate
|
||||
import jinja2
|
||||
@ -14,7 +14,7 @@ jinja_env = jinja2.Environment()
|
||||
# to create new filters, first create functions that accept the value to filter
|
||||
# as first argument, and filter parameters as next arguments
|
||||
def my_filterA(value, my_string_arg):
|
||||
return_value = value + ' ' + my_string_arg
|
||||
return_value = value + " " + my_string_arg
|
||||
return return_value
|
||||
|
||||
|
||||
@ -24,12 +24,12 @@ def my_filterB(value, my_float_arg):
|
||||
|
||||
|
||||
# Then, declare them to jinja like this :
|
||||
jinja_env.filters['my_filterA'] = my_filterA
|
||||
jinja_env.filters['my_filterB'] = my_filterB
|
||||
jinja_env.filters["my_filterA"] = my_filterA
|
||||
jinja_env.filters["my_filterB"] = my_filterB
|
||||
|
||||
|
||||
context = {'base_value_string': ' Hello', 'base_value_float': 1.5}
|
||||
context = {"base_value_string": " Hello", "base_value_float": 1.5}
|
||||
|
||||
tpl = DocxTemplate('templates/custom_jinja_filters_tpl.docx')
|
||||
tpl = DocxTemplate("templates/custom_jinja_filters_tpl.docx")
|
||||
tpl.render(context, jinja_env)
|
||||
tpl.save('output/custom_jinja_filters.docx')
|
||||
tpl.save("output/custom_jinja_filters.docx")
|
||||
|
||||
10
tests/doc_properties.py
Normal file
10
tests/doc_properties.py
Normal file
@ -0,0 +1,10 @@
|
||||
from docxtpl import DocxTemplate
|
||||
|
||||
doctemplate = r"templates/doc_properties_tpl.docx"
|
||||
|
||||
tpl = DocxTemplate(doctemplate)
|
||||
|
||||
context = {"test": "HelloWorld"}
|
||||
|
||||
tpl.render(context)
|
||||
tpl.save("output/doc_properties.docx")
|
||||
@ -1,15 +1,15 @@
|
||||
from docxtpl import DocxTemplate
|
||||
|
||||
tpl = DocxTemplate('templates/dynamic_table_tpl.docx')
|
||||
tpl = DocxTemplate("templates/dynamic_table_tpl.docx")
|
||||
|
||||
context = {
|
||||
'col_labels': ['fruit', 'vegetable', 'stone', 'thing'],
|
||||
'tbl_contents': [
|
||||
{'label': 'yellow', 'cols': ['banana', 'capsicum', 'pyrite', 'taxi']},
|
||||
{'label': 'red', 'cols': ['apple', 'tomato', 'cinnabar', 'doubledecker']},
|
||||
{'label': 'green', 'cols': ['guava', 'cucumber', 'aventurine', 'card']},
|
||||
"col_labels": ["fruit", "vegetable", "stone", "thing"],
|
||||
"tbl_contents": [
|
||||
{"label": "yellow", "cols": ["banana", "capsicum", "pyrite", "taxi"]},
|
||||
{"label": "red", "cols": ["apple", "tomato", "cinnabar", "doubledecker"]},
|
||||
{"label": "green", "cols": ["guava", "cucumber", "aventurine", "card"]},
|
||||
],
|
||||
}
|
||||
|
||||
tpl.render(context)
|
||||
tpl.save('output/dynamic_table.docx')
|
||||
tpl.save("output/dynamic_table.docx")
|
||||
|
||||
@ -1,45 +1,45 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
'''
|
||||
"""
|
||||
Created : 2017-09-09
|
||||
|
||||
@author: Eric Lapouyade
|
||||
'''
|
||||
"""
|
||||
|
||||
from docxtpl import DocxTemplate
|
||||
|
||||
# rendering the "dynamic embedded docx":
|
||||
embedded_docx_tpl = DocxTemplate('templates/embedded_embedded_docx_tpl.docx')
|
||||
embedded_docx_tpl = DocxTemplate("templates/embedded_embedded_docx_tpl.docx")
|
||||
context = {
|
||||
'name': 'John Doe',
|
||||
"name": "John Doe",
|
||||
}
|
||||
embedded_docx_tpl.render(context)
|
||||
embedded_docx_tpl.save('output/embedded_embedded_docx.docx')
|
||||
embedded_docx_tpl.save("output/embedded_embedded_docx.docx")
|
||||
|
||||
|
||||
# rendring the main document :
|
||||
tpl = DocxTemplate('templates/embedded_main_tpl.docx')
|
||||
# rendering the main document :
|
||||
tpl = DocxTemplate("templates/embedded_main_tpl.docx")
|
||||
|
||||
context = {
|
||||
'name': 'John Doe',
|
||||
"name": "John Doe",
|
||||
}
|
||||
|
||||
tpl.replace_embedded(
|
||||
'templates/embedded_dummy.docx', 'templates/embedded_static_docx.docx'
|
||||
"templates/embedded_dummy.docx", "templates/embedded_static_docx.docx"
|
||||
)
|
||||
tpl.replace_embedded(
|
||||
'templates/embedded_dummy2.docx', 'output/embedded_embedded_docx.docx'
|
||||
"templates/embedded_dummy2.docx", "output/embedded_embedded_docx.docx"
|
||||
)
|
||||
|
||||
# The zipname is the one you can find when you open docx with WinZip, 7zip (Windows)
|
||||
# or unzip -l (Linux). The zipname starts with "word/embeddings/".
|
||||
# Note that the file is renamed by MSWord, so you have to guess a little bit...
|
||||
tpl.replace_zipname(
|
||||
'word/embeddings/Feuille_Microsoft_Office_Excel3.xlsx', 'templates/real_Excel.xlsx'
|
||||
"word/embeddings/Feuille_Microsoft_Office_Excel3.xlsx", "templates/real_Excel.xlsx"
|
||||
)
|
||||
tpl.replace_zipname(
|
||||
'word/embeddings/Pr_sentation_Microsoft_Office_PowerPoint4.pptx',
|
||||
'templates/real_PowerPoint.pptx',
|
||||
"word/embeddings/Pr_sentation_Microsoft_Office_PowerPoint4.pptx",
|
||||
"templates/real_PowerPoint.pptx",
|
||||
)
|
||||
|
||||
tpl.render(context)
|
||||
tpl.save('output/embedded.docx')
|
||||
tpl.save("output/embedded.docx")
|
||||
|
||||
@ -1,19 +1,19 @@
|
||||
from docxtpl import DocxTemplate, R, Listing
|
||||
|
||||
tpl = DocxTemplate('templates/escape_tpl.docx')
|
||||
tpl = DocxTemplate("templates/escape_tpl.docx")
|
||||
|
||||
context = {
|
||||
'myvar': R(
|
||||
"myvar": R(
|
||||
'"less than" must be escaped : <, this can be done with RichText() or R()'
|
||||
),
|
||||
'myescvar': 'It can be escaped with a "|e" jinja filter in the template too : < ',
|
||||
'nlnp': R('Here is a multiple\nlines\nstring\aand some\aother\aparagraphs',
|
||||
color='#ff00ff'),
|
||||
'mylisting': Listing(
|
||||
'the listing\nwith\nsome\nlines\nand special chars : <>& ...'
|
||||
"myescvar": 'It can be escaped with a "|e" jinja filter in the template too : < ',
|
||||
"nlnp": R(
|
||||
"Here is a multiple\nlines\nstring\aand some\aother\aparagraphs",
|
||||
color="#ff00ff",
|
||||
),
|
||||
'page_break': R('\f'),
|
||||
'new_listing': """
|
||||
"mylisting": Listing("the listing\nwith\nsome\nlines\nand special chars : <>& ..."),
|
||||
"page_break": R("\f"),
|
||||
"new_listing": """
|
||||
This is a new listing
|
||||
Now, does not require Listing() Object
|
||||
Here is a \t tab\a
|
||||
@ -21,34 +21,34 @@ Here is a new paragraph\a
|
||||
Here is a page break : \f
|
||||
That's it
|
||||
""",
|
||||
'some_html': (
|
||||
'HTTP/1.1 200 OK\n'
|
||||
'Server: Apache-Coyote/1.1\n'
|
||||
'Cache-Control: no-store\n'
|
||||
'Expires: Thu, 01 Jan 1970 00:00:00 GMT\n'
|
||||
'Pragma: no-cache\n'
|
||||
'Content-Type: text/html;charset=UTF-8\n'
|
||||
'Content-Language: zh-CN\n'
|
||||
'Date: Thu, 22 Oct 2020 10:59:40 GMT\n'
|
||||
'Content-Length: 9866\n'
|
||||
'\n'
|
||||
'<html>\n'
|
||||
'<head>\n'
|
||||
' <title>Struts Problem Report</title>\n'
|
||||
' <style>\n'
|
||||
' \tpre {\n'
|
||||
'\t \tmargin: 0;\n'
|
||||
'\t padding: 0;\n'
|
||||
'\t } '
|
||||
'\n'
|
||||
' </style>\n'
|
||||
'</head>\n'
|
||||
'<body>\n'
|
||||
'...\n'
|
||||
'</body>\n'
|
||||
'</html>'
|
||||
"some_html": (
|
||||
"HTTP/1.1 200 OK\n"
|
||||
"Server: Apache-Coyote/1.1\n"
|
||||
"Cache-Control: no-store\n"
|
||||
"Expires: Thu, 01 Jan 1970 00:00:00 GMT\n"
|
||||
"Pragma: no-cache\n"
|
||||
"Content-Type: text/html;charset=UTF-8\n"
|
||||
"Content-Language: zh-CN\n"
|
||||
"Date: Thu, 22 Oct 2020 10:59:40 GMT\n"
|
||||
"Content-Length: 9866\n"
|
||||
"\n"
|
||||
"<html>\n"
|
||||
"<head>\n"
|
||||
" <title>Struts Problem Report</title>\n"
|
||||
" <style>\n"
|
||||
" \tpre {\n"
|
||||
"\t \tmargin: 0;\n"
|
||||
"\t padding: 0;\n"
|
||||
"\t } "
|
||||
"\n"
|
||||
" </style>\n"
|
||||
"</head>\n"
|
||||
"<body>\n"
|
||||
"...\n"
|
||||
"</body>\n"
|
||||
"</html>"
|
||||
),
|
||||
}
|
||||
|
||||
tpl.render(context)
|
||||
tpl.save('output/escape.docx')
|
||||
tpl.save("output/escape.docx")
|
||||
|
||||
@ -5,25 +5,23 @@
|
||||
import os
|
||||
from unicodedata import name
|
||||
|
||||
from six import iteritems, text_type
|
||||
|
||||
from docxtpl import DocxTemplate
|
||||
|
||||
|
||||
XML_RESERVED = """<"&'>"""
|
||||
|
||||
tpl = DocxTemplate('templates/escape_tpl_auto.docx')
|
||||
tpl = DocxTemplate("templates/escape_tpl_auto.docx")
|
||||
|
||||
context = {
|
||||
'nested_dict': {name(text_type(c)): c for c in XML_RESERVED},
|
||||
'autoescape': 'Escaped "str & ing"!',
|
||||
'autoescape_unicode': u'This is an escaped <unicode> example \u4f60 & \u6211',
|
||||
'iteritems': iteritems,
|
||||
"nested_dict": {name(str(c)): c for c in XML_RESERVED},
|
||||
"autoescape": 'Escaped "str & ing"!',
|
||||
"autoescape_unicode": "This is an escaped <unicode> example \u4f60 & \u6211",
|
||||
"iteritems": lambda x: x.items(),
|
||||
}
|
||||
|
||||
tpl.render(context, autoescape=True)
|
||||
|
||||
OUTPUT = 'output'
|
||||
OUTPUT = "output"
|
||||
if not os.path.exists(OUTPUT):
|
||||
os.makedirs(OUTPUT)
|
||||
tpl.save(OUTPUT + '/escape_auto.docx')
|
||||
tpl.save(OUTPUT + "/escape_auto.docx")
|
||||
|
||||
17
tests/footnotes.py
Normal file
17
tests/footnotes.py
Normal file
@ -0,0 +1,17 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
Created : 2024-09-23
|
||||
|
||||
@author: Bart Broere
|
||||
"""
|
||||
|
||||
from docxtpl import DocxTemplate
|
||||
|
||||
DEST_FILE = "output/footnotes.docx"
|
||||
|
||||
tpl = DocxTemplate("templates/footnotes_tpl.docx")
|
||||
|
||||
context = {"a_jinja_variable": "A Jinja variable!"}
|
||||
|
||||
tpl.render(context)
|
||||
tpl.save(DEST_FILE)
|
||||
180
tests/get_undeclared_variables.py
Normal file
180
tests/get_undeclared_variables.py
Normal file
@ -0,0 +1,180 @@
|
||||
#!/usr/bin/env python
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
Test for get_undeclared_template_variables method
|
||||
|
||||
This test demonstrates the correct behavior of get_undeclared_template_variables:
|
||||
1. Before rendering - finds all template variables
|
||||
2. After rendering with incomplete context - finds missing variables
|
||||
3. After rendering with complete context - returns empty set
|
||||
"""
|
||||
|
||||
from docxtpl import DocxTemplate
|
||||
|
||||
|
||||
def test_before_render():
|
||||
"""Test that get_undeclared_template_variables finds all variables before rendering"""
|
||||
print("=== Test 1: Before render ===")
|
||||
tpl = DocxTemplate("templates/get_undeclared_variables.docx")
|
||||
undeclared = tpl.get_undeclared_template_variables()
|
||||
print(f"Variables found: {undeclared}")
|
||||
|
||||
# Should find all variables
|
||||
expected_vars = {
|
||||
"name",
|
||||
"age",
|
||||
"email",
|
||||
"is_student",
|
||||
"has_degree",
|
||||
"degree_field",
|
||||
"skills",
|
||||
"projects",
|
||||
"company_name",
|
||||
"page_number",
|
||||
"generation_date",
|
||||
"author",
|
||||
}
|
||||
|
||||
if undeclared == expected_vars:
|
||||
print("PASS: Found all expected variables before render")
|
||||
else:
|
||||
print(f"FAIL: Expected {expected_vars}, got {undeclared}")
|
||||
|
||||
return undeclared == expected_vars
|
||||
|
||||
|
||||
def test_after_incomplete_render():
|
||||
"""Test that get_undeclared_template_variables finds missing variables after incomplete render"""
|
||||
print("\n=== Test 2: After incomplete render ===")
|
||||
tpl = DocxTemplate("templates/get_undeclared_variables.docx")
|
||||
|
||||
# Provide only some variables (missing several)
|
||||
context = {
|
||||
"name": "John Doe",
|
||||
"age": 25,
|
||||
"email": "john@example.com",
|
||||
"is_student": True,
|
||||
"skills": ["Python", "Django"],
|
||||
"company_name": "Test Corp",
|
||||
"author": "Test Author",
|
||||
}
|
||||
|
||||
tpl.render(context)
|
||||
undeclared = tpl.get_undeclared_template_variables(context=context)
|
||||
print(f"Missing variables: {undeclared}")
|
||||
|
||||
# Should find missing variables
|
||||
expected_missing = {
|
||||
"has_degree",
|
||||
"degree_field",
|
||||
"projects",
|
||||
"page_number",
|
||||
"generation_date",
|
||||
}
|
||||
|
||||
if undeclared == expected_missing:
|
||||
print("PASS: Found missing variables after incomplete render")
|
||||
else:
|
||||
print(f"FAIL: Expected missing {expected_missing}, got {undeclared}")
|
||||
|
||||
return undeclared == expected_missing
|
||||
|
||||
|
||||
def test_after_complete_render():
|
||||
"""Test that get_undeclared_template_variables returns empty set after complete render"""
|
||||
print("\n=== Test 3: After complete render ===")
|
||||
tpl = DocxTemplate("templates/get_undeclared_variables.docx")
|
||||
|
||||
# Provide all variables
|
||||
context = {
|
||||
"name": "John Doe",
|
||||
"age": 25,
|
||||
"email": "john@example.com",
|
||||
"is_student": True,
|
||||
"has_degree": True,
|
||||
"degree_field": "Computer Science",
|
||||
"skills": ["Python", "Django", "JavaScript"],
|
||||
"projects": [
|
||||
{"name": "Project A", "year": 2023, "description": "A great project"},
|
||||
{"name": "Project B", "year": 2024, "description": "Another great project"},
|
||||
],
|
||||
"company_name": "Test Corp",
|
||||
"page_number": 1,
|
||||
"generation_date": "2024-01-15",
|
||||
"author": "Test Author",
|
||||
}
|
||||
|
||||
tpl.render(context)
|
||||
undeclared = tpl.get_undeclared_template_variables(context=context)
|
||||
print(f"Undeclared variables: {undeclared}")
|
||||
|
||||
# Should return empty set
|
||||
if undeclared == set():
|
||||
print("PASS: No undeclared variables after complete render")
|
||||
else:
|
||||
print(f"FAIL: Expected empty set, got {undeclared}")
|
||||
|
||||
return undeclared == set()
|
||||
|
||||
|
||||
def test_with_custom_jinja_env():
|
||||
"""Test that get_undeclared_template_variables works with custom Jinja environment"""
|
||||
print("\n=== Test 4: With custom Jinja environment ===")
|
||||
from jinja2 import Environment
|
||||
|
||||
tpl = DocxTemplate("templates/get_undeclared_variables.docx")
|
||||
custom_env = Environment()
|
||||
|
||||
undeclared = tpl.get_undeclared_template_variables(jinja_env=custom_env)
|
||||
print(f"Variables found with custom env: {undeclared}")
|
||||
|
||||
# Should find all variables
|
||||
expected_vars = {
|
||||
"name",
|
||||
"age",
|
||||
"email",
|
||||
"is_student",
|
||||
"has_degree",
|
||||
"degree_field",
|
||||
"skills",
|
||||
"projects",
|
||||
"company_name",
|
||||
"page_number",
|
||||
"generation_date",
|
||||
"author",
|
||||
}
|
||||
|
||||
if undeclared == expected_vars:
|
||||
print("PASS: Custom Jinja environment works correctly")
|
||||
else:
|
||||
print(f"FAIL: Expected {expected_vars}, got {undeclared}")
|
||||
|
||||
return undeclared == expected_vars
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
print("Testing get_undeclared_template_variables method...")
|
||||
print("=" * 50)
|
||||
|
||||
# Run all tests
|
||||
test1_passed = test_before_render()
|
||||
test2_passed = test_after_incomplete_render()
|
||||
test3_passed = test_after_complete_render()
|
||||
test4_passed = test_with_custom_jinja_env()
|
||||
|
||||
print("\n" + "=" * 50)
|
||||
print("SUMMARY:")
|
||||
print(f"Test 1 (Before render): {'PASS' if test1_passed else 'FAIL'}")
|
||||
print(f"Test 2 (After incomplete render): {'PASS' if test2_passed else 'FAIL'}")
|
||||
print(f"Test 3 (After complete render): {'PASS' if test3_passed else 'FAIL'}")
|
||||
print(f"Test 4 (Custom Jinja env): {'PASS' if test4_passed else 'FAIL'}")
|
||||
|
||||
all_passed = test1_passed and test2_passed and test3_passed and test4_passed
|
||||
|
||||
if all_passed:
|
||||
print("ALL TESTS PASSED!")
|
||||
else:
|
||||
print("SOME TESTS FAILED!")
|
||||
|
||||
print("\nNote: This test demonstrates that get_undeclared_template_variables")
|
||||
print("now correctly analyzes the original template, not the rendered document.")
|
||||
@ -1,25 +1,25 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
'''
|
||||
"""
|
||||
Created : 2015-03-12
|
||||
|
||||
@author: Eric Lapouyade
|
||||
'''
|
||||
"""
|
||||
|
||||
from docxtpl import DocxTemplate
|
||||
|
||||
tpl = DocxTemplate('templates/header_footer_tpl.docx')
|
||||
tpl = DocxTemplate("templates/header_footer_tpl.docx")
|
||||
|
||||
sd = tpl.new_subdoc()
|
||||
p = sd.add_paragraph(
|
||||
'This is a sub-document to check it does not break header and footer'
|
||||
"This is a sub-document to check it does not break header and footer"
|
||||
)
|
||||
|
||||
context = {
|
||||
'title': 'Header and footer test',
|
||||
'company_name': 'The World Wide company',
|
||||
'date': '2016-03-17',
|
||||
'mysubdoc': sd,
|
||||
"title": "Header and footer test",
|
||||
"company_name": "The World Wide company",
|
||||
"date": "2016-03-17",
|
||||
"mysubdoc": sd,
|
||||
}
|
||||
|
||||
tpl.render(context)
|
||||
tpl.save('output/header_footer.docx')
|
||||
tpl.save("output/header_footer.docx")
|
||||
|
||||
@ -1,17 +1,17 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
'''
|
||||
"""
|
||||
Created : 2015-03-12
|
||||
|
||||
@author: Eric Lapouyade
|
||||
'''
|
||||
"""
|
||||
|
||||
from docxtpl import DocxTemplate
|
||||
|
||||
tpl = DocxTemplate('templates/header_footer_entities_tpl.docx')
|
||||
tpl = DocxTemplate("templates/header_footer_entities_tpl.docx")
|
||||
|
||||
context = {
|
||||
'title': 'Header and footer test',
|
||||
"title": "Header and footer test",
|
||||
}
|
||||
|
||||
tpl.render(context)
|
||||
tpl.save('output/header_footer_entities.docx')
|
||||
tpl.save("output/header_footer_entities.docx")
|
||||
|
||||
@ -1,19 +1,19 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
'''
|
||||
"""
|
||||
Created : 2017-09-03
|
||||
|
||||
@author: Eric Lapouyade
|
||||
'''
|
||||
"""
|
||||
|
||||
from docxtpl import DocxTemplate
|
||||
|
||||
DEST_FILE = 'output/header_footer_image.docx'
|
||||
DEST_FILE = "output/header_footer_image.docx"
|
||||
|
||||
tpl = DocxTemplate('templates/header_footer_image_tpl.docx')
|
||||
tpl = DocxTemplate("templates/header_footer_image_tpl.docx")
|
||||
|
||||
context = {
|
||||
'mycompany': 'The World Wide company',
|
||||
"mycompany": "The World Wide company",
|
||||
}
|
||||
tpl.replace_media('templates/dummy_pic_for_header.png', 'templates/python.png')
|
||||
tpl.replace_media("templates/dummy_pic_for_header.png", "templates/python.png")
|
||||
tpl.render(context)
|
||||
tpl.save(DEST_FILE)
|
||||
|
||||
@ -1,29 +1,29 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
'''
|
||||
"""
|
||||
Created : 2019-05-22
|
||||
|
||||
@author: Eric Dufresne
|
||||
'''
|
||||
"""
|
||||
|
||||
from docxtpl import DocxTemplate
|
||||
import io
|
||||
|
||||
DEST_FILE = 'output/header_footer_image_file_obj.docx'
|
||||
DEST_FILE2 = 'output/header_footer_image_file_obj2.docx'
|
||||
DEST_FILE = "output/header_footer_image_file_obj.docx"
|
||||
DEST_FILE2 = "output/header_footer_image_file_obj2.docx"
|
||||
|
||||
tpl = DocxTemplate('templates/header_footer_image_tpl.docx')
|
||||
tpl = DocxTemplate("templates/header_footer_image_tpl.docx")
|
||||
|
||||
context = {
|
||||
'mycompany': 'The World Wide company',
|
||||
"mycompany": "The World Wide company",
|
||||
}
|
||||
|
||||
dummy_pic = io.BytesIO(open('templates/dummy_pic_for_header.png', 'rb').read())
|
||||
new_image = io.BytesIO(open('templates/python.png', 'rb').read())
|
||||
dummy_pic = io.BytesIO(open("templates/dummy_pic_for_header.png", "rb").read())
|
||||
new_image = io.BytesIO(open("templates/python.png", "rb").read())
|
||||
tpl.replace_media(dummy_pic, new_image)
|
||||
tpl.render(context)
|
||||
tpl.save(DEST_FILE)
|
||||
|
||||
tpl = DocxTemplate('templates/header_footer_image_tpl.docx')
|
||||
tpl = DocxTemplate("templates/header_footer_image_tpl.docx")
|
||||
dummy_pic.seek(0)
|
||||
new_image.seek(0)
|
||||
tpl.replace_media(dummy_pic, new_image)
|
||||
@ -32,5 +32,5 @@ tpl.render(context)
|
||||
file_obj = io.BytesIO()
|
||||
tpl.save(file_obj)
|
||||
file_obj.seek(0)
|
||||
with open(DEST_FILE2, 'wb') as f:
|
||||
with open(DEST_FILE2, "wb") as f:
|
||||
f.write(file_obj.read())
|
||||
|
||||
@ -1,19 +1,24 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
'''
|
||||
"""
|
||||
Created : 2021-04-06
|
||||
|
||||
@author: Eric Lapouyade
|
||||
'''
|
||||
"""
|
||||
|
||||
from docxtpl import DocxTemplate, InlineImage
|
||||
|
||||
# for height and width you have to use millimeters (Mm), inches or points(Pt) class :
|
||||
from docx.shared import Mm
|
||||
|
||||
tpl = DocxTemplate('templates/header_footer_inline_image_tpl.docx')
|
||||
tpl = DocxTemplate("templates/header_footer_inline_image_tpl.docx")
|
||||
|
||||
context = {
|
||||
'inline_image': InlineImage(tpl, 'templates/django.png', height=Mm(10))
|
||||
"inline_image": InlineImage(tpl, "templates/django.png", height=Mm(10)),
|
||||
"images": [
|
||||
InlineImage(tpl, "templates/python.png", height=Mm(10)),
|
||||
InlineImage(tpl, "templates/python.png", height=Mm(10)),
|
||||
InlineImage(tpl, "templates/python.png", height=Mm(10)),
|
||||
],
|
||||
}
|
||||
tpl.render(context)
|
||||
tpl.save('output/header_footer_inline_image.docx')
|
||||
tpl.save("output/header_footer_inline_image.docx")
|
||||
|
||||
@ -1,27 +1,28 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
'''
|
||||
"""
|
||||
Created : 2016-07-19
|
||||
|
||||
@author: AhnSeongHyun
|
||||
|
||||
Edited : 2016-07-19 by Eric Lapouyade
|
||||
'''
|
||||
"""
|
||||
|
||||
from docxtpl import DocxTemplate
|
||||
|
||||
tpl = DocxTemplate('templates/header_footer_tpl_utf8.docx')
|
||||
tpl = DocxTemplate("templates/header_footer_tpl_utf8.docx")
|
||||
|
||||
sd = tpl.new_subdoc()
|
||||
p = sd.add_paragraph(
|
||||
u'This is a sub-document to check it does not break header and footer with utf-8 characters inside the template .docx'
|
||||
"This is a sub-document to check it does not break header and footer with utf-8 "
|
||||
"characters inside the template .docx"
|
||||
)
|
||||
|
||||
context = {
|
||||
'title': u'헤더와 푸터',
|
||||
'company_name': u'세계적 회사',
|
||||
'date': u'2016-03-17',
|
||||
'mysubdoc': sd,
|
||||
"title": "헤더와 푸터",
|
||||
"company_name": "세계적 회사",
|
||||
"date": "2016-03-17",
|
||||
"mysubdoc": sd,
|
||||
}
|
||||
|
||||
tpl.render(context)
|
||||
tpl.save('output/header_footer_utf8.docx')
|
||||
tpl.save("output/header_footer_utf8.docx")
|
||||
|
||||
@ -2,6 +2,6 @@
|
||||
|
||||
from docxtpl import DocxTemplate
|
||||
|
||||
tpl = DocxTemplate('templates/horizontal_merge_tpl.docx')
|
||||
tpl = DocxTemplate("templates/horizontal_merge_tpl.docx")
|
||||
tpl.render({})
|
||||
tpl.save('output/horizontal_merge.docx')
|
||||
tpl.save("output/horizontal_merge.docx")
|
||||
|
||||
@ -1,9 +1,9 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
'''
|
||||
"""
|
||||
Created : 2017-01-14
|
||||
|
||||
@author: Eric Lapouyade
|
||||
'''
|
||||
"""
|
||||
|
||||
from docxtpl import DocxTemplate, InlineImage
|
||||
|
||||
@ -11,37 +11,41 @@ from docxtpl import DocxTemplate, InlineImage
|
||||
from docx.shared import Mm
|
||||
import jinja2
|
||||
|
||||
tpl = DocxTemplate('templates/inline_image_tpl.docx')
|
||||
tpl = DocxTemplate("templates/inline_image_tpl.docx")
|
||||
|
||||
context = {
|
||||
'myimage': InlineImage(tpl, 'templates/python_logo.png', width=Mm(20)),
|
||||
'myimageratio': InlineImage(
|
||||
tpl, 'templates/python_jpeg.jpg', width=Mm(30), height=Mm(60)
|
||||
"myimage": InlineImage(tpl, "templates/python_logo.png", width=Mm(20)),
|
||||
"myimageratio": InlineImage(
|
||||
tpl, "templates/python_jpeg.jpg", width=Mm(30), height=Mm(60)
|
||||
),
|
||||
'frameworks': [
|
||||
"frameworks": [
|
||||
{
|
||||
'image': InlineImage(tpl, 'templates/django.png', height=Mm(10)),
|
||||
'desc': 'The web framework for perfectionists with deadlines',
|
||||
"image": InlineImage(tpl, "templates/django.png", height=Mm(10)),
|
||||
"desc": "The web framework for perfectionists with deadlines",
|
||||
},
|
||||
{
|
||||
'image': InlineImage(tpl, 'templates/zope.png', height=Mm(10)),
|
||||
'desc': 'Zope is a leading Open Source Application Server and Content Management Framework',
|
||||
"image": InlineImage(tpl, "templates/zope.png", height=Mm(10)),
|
||||
"desc": "Zope is a leading Open Source Application Server "
|
||||
"and Content Management Framework",
|
||||
},
|
||||
{
|
||||
'image': InlineImage(tpl, 'templates/pyramid.png', height=Mm(10)),
|
||||
'desc': 'Pyramid is a lightweight Python web framework aimed at taking small web apps into big web apps.',
|
||||
"image": InlineImage(tpl, "templates/pyramid.png", height=Mm(10)),
|
||||
"desc": "Pyramid is a lightweight Python web framework aimed at taking "
|
||||
"small web apps into big web apps.",
|
||||
},
|
||||
{
|
||||
'image': InlineImage(tpl, 'templates/bottle.png', height=Mm(10)),
|
||||
'desc': 'Bottle is a fast, simple and lightweight WSGI micro web-framework for Python',
|
||||
"image": InlineImage(tpl, "templates/bottle.png", height=Mm(10)),
|
||||
"desc": "Bottle is a fast, simple and lightweight WSGI micro web-framework "
|
||||
"for Python",
|
||||
},
|
||||
{
|
||||
'image': InlineImage(tpl, 'templates/tornado.png', height=Mm(10)),
|
||||
'desc': 'Tornado is a Python web framework and asynchronous networking library.',
|
||||
"image": InlineImage(tpl, "templates/tornado.png", height=Mm(10)),
|
||||
"desc": "Tornado is a Python web framework and asynchronous networking "
|
||||
"library.",
|
||||
},
|
||||
],
|
||||
}
|
||||
# testing that it works also when autoescape has been forced to True
|
||||
jinja_env = jinja2.Environment(autoescape=True)
|
||||
tpl.render(context, jinja_env)
|
||||
tpl.save('output/inline_image.docx')
|
||||
tpl.save("output/inline_image.docx")
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
from docxtpl import DocxTemplate
|
||||
|
||||
tpl = DocxTemplate('templates/less_cells_after_loop_tpl.docx')
|
||||
tpl = DocxTemplate("templates/less_cells_after_loop_tpl.docx")
|
||||
tpl.render({})
|
||||
tpl.save('output/less_cells_after_loop.docx')
|
||||
tpl.save("output/less_cells_after_loop.docx")
|
||||
|
||||
19
tests/merge_docx.py
Normal file
19
tests/merge_docx.py
Normal file
@ -0,0 +1,19 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
Created : 2021-07-30
|
||||
|
||||
@author: Eric Lapouyade
|
||||
"""
|
||||
|
||||
from docxtpl import DocxTemplate
|
||||
|
||||
|
||||
tpl = DocxTemplate("templates/merge_docx_master_tpl.docx")
|
||||
sd = tpl.new_subdoc("templates/merge_docx_subdoc.docx")
|
||||
|
||||
context = {
|
||||
"mysubdoc": sd,
|
||||
}
|
||||
|
||||
tpl.render(context)
|
||||
tpl.save("output/merge_docx.docx")
|
||||
@ -1,17 +1,17 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
'''
|
||||
"""
|
||||
Created : 2015-03-12
|
||||
|
||||
@author: Eric Lapouyade
|
||||
'''
|
||||
"""
|
||||
|
||||
from docxtpl import DocxTemplate
|
||||
|
||||
tpl = DocxTemplate('templates/merge_paragraph_tpl.docx')
|
||||
tpl = DocxTemplate("templates/merge_paragraph_tpl.docx")
|
||||
|
||||
context = {
|
||||
'living_in_town': True,
|
||||
"living_in_town": True,
|
||||
}
|
||||
|
||||
tpl.render(context)
|
||||
tpl.save('output/merge_paragraph.docx')
|
||||
tpl.save("output/merge_paragraph.docx")
|
||||
|
||||
25
tests/module_execute.py
Normal file
25
tests/module_execute.py
Normal file
@ -0,0 +1,25 @@
|
||||
import os
|
||||
|
||||
TEMPLATE_PATH = "templates/module_execute_tpl.docx"
|
||||
JSON_PATH = "templates/module_execute.json"
|
||||
OUTPUT_FILENAME = "output/module_execute.docx"
|
||||
OVERWRITE = "-o"
|
||||
QUIET = "-q"
|
||||
|
||||
|
||||
if os.path.exists(OUTPUT_FILENAME):
|
||||
os.unlink(OUTPUT_FILENAME)
|
||||
|
||||
os.chdir(os.path.dirname(__file__))
|
||||
cmd = "python -m docxtpl %s %s %s %s %s" % (
|
||||
TEMPLATE_PATH,
|
||||
JSON_PATH,
|
||||
OUTPUT_FILENAME,
|
||||
OVERWRITE,
|
||||
QUIET,
|
||||
)
|
||||
print('Executing "%s" ...' % cmd)
|
||||
os.system(cmd)
|
||||
|
||||
if os.path.exists(OUTPUT_FILENAME):
|
||||
print(" --> File %s has been generated." % OUTPUT_FILENAME)
|
||||
40
tests/multi_rendering.py
Normal file
40
tests/multi_rendering.py
Normal file
@ -0,0 +1,40 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
Created : 2021-12-20
|
||||
|
||||
@author: Eric Lapouyade
|
||||
"""
|
||||
|
||||
from docxtpl import DocxTemplate
|
||||
|
||||
tpl = DocxTemplate("templates/multi_rendering_tpl.docx")
|
||||
|
||||
documents_data = [
|
||||
{
|
||||
"dest_file": "multi_render1.docx",
|
||||
"context": {
|
||||
"title": "Title ONE",
|
||||
"body": "This is the body for first document",
|
||||
},
|
||||
},
|
||||
{
|
||||
"dest_file": "multi_render2.docx",
|
||||
"context": {
|
||||
"title": "Title TWO",
|
||||
"body": "This is the body for second document",
|
||||
},
|
||||
},
|
||||
{
|
||||
"dest_file": "multi_render3.docx",
|
||||
"context": {
|
||||
"title": "Title THREE",
|
||||
"body": "This is the body for third document",
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
for document_data in documents_data:
|
||||
dest_file = document_data["dest_file"]
|
||||
context = document_data["context"]
|
||||
tpl.render(context)
|
||||
tpl.save("output/%s" % dest_file)
|
||||
@ -1,45 +1,45 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
'''
|
||||
"""
|
||||
Created : 2016-03-26
|
||||
|
||||
@author: Eric Lapouyade
|
||||
'''
|
||||
"""
|
||||
|
||||
from docxtpl import DocxTemplate
|
||||
|
||||
tpl = DocxTemplate('templates/nested_for_tpl.docx')
|
||||
tpl = DocxTemplate("templates/nested_for_tpl.docx")
|
||||
|
||||
context = {
|
||||
'dishes': [
|
||||
{'name': 'Pizza', 'ingredients': ['bread', 'tomato', 'ham', 'cheese']},
|
||||
"dishes": [
|
||||
{"name": "Pizza", "ingredients": ["bread", "tomato", "ham", "cheese"]},
|
||||
{
|
||||
'name': 'Hamburger',
|
||||
'ingredients': ['bread', 'chopped steak', 'cheese', 'sauce'],
|
||||
"name": "Hamburger",
|
||||
"ingredients": ["bread", "chopped steak", "cheese", "sauce"],
|
||||
},
|
||||
{
|
||||
'name': 'Apple pie',
|
||||
'ingredients': ['flour', 'apples', 'suggar', 'quince jelly'],
|
||||
"name": "Apple pie",
|
||||
"ingredients": ["flour", "apples", "suggar", "quince jelly"],
|
||||
},
|
||||
],
|
||||
'authors': [
|
||||
"authors": [
|
||||
{
|
||||
'name': 'Saint-Exupery',
|
||||
'books': [
|
||||
{'title': 'Le petit prince'},
|
||||
{'title': "L'aviateur"},
|
||||
{'title': 'Vol de nuit'},
|
||||
"name": "Saint-Exupery",
|
||||
"books": [
|
||||
{"title": "Le petit prince"},
|
||||
{"title": "L'aviateur"},
|
||||
{"title": "Vol de nuit"},
|
||||
],
|
||||
},
|
||||
{
|
||||
'name': 'Barjavel',
|
||||
'books': [
|
||||
{'title': 'Ravage'},
|
||||
{'title': "La nuit des temps"},
|
||||
{'title': 'Le grand secret'},
|
||||
"name": "Barjavel",
|
||||
"books": [
|
||||
{"title": "Ravage"},
|
||||
{"title": "La nuit des temps"},
|
||||
{"title": "Le grand secret"},
|
||||
],
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
tpl.render(context)
|
||||
tpl.save('output/nested_for.docx')
|
||||
tpl.save("output/nested_for.docx")
|
||||
|
||||
@ -1,26 +1,26 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
'''
|
||||
"""
|
||||
Created : 2015-03-12
|
||||
|
||||
@author: Eric Lapouyade
|
||||
'''
|
||||
"""
|
||||
|
||||
from docxtpl import DocxTemplate
|
||||
|
||||
tpl = DocxTemplate('templates/order_tpl.docx')
|
||||
tpl = DocxTemplate("templates/order_tpl.docx")
|
||||
|
||||
context = {
|
||||
'customer_name': 'Eric',
|
||||
'items': [
|
||||
{'desc': 'Python interpreters', 'qty': 2, 'price': 'FREE'},
|
||||
{'desc': 'Django projects', 'qty': 5403, 'price': 'FREE'},
|
||||
{'desc': 'Guido', 'qty': 1, 'price': '100,000,000.00'},
|
||||
"customer_name": "Eric",
|
||||
"items": [
|
||||
{"desc": "Python interpreters", "qty": 2, "price": "FREE"},
|
||||
{"desc": "Django projects", "qty": 5403, "price": "FREE"},
|
||||
{"desc": "Guido", "qty": 1, "price": "100,000,000.00"},
|
||||
],
|
||||
'in_europe': True,
|
||||
'is_paid': False,
|
||||
'company_name': 'The World Wide company',
|
||||
'total_price': '100,000,000.00',
|
||||
"in_europe": True,
|
||||
"is_paid": False,
|
||||
"company_name": "The World Wide company",
|
||||
"total_price": "100,000,000.00",
|
||||
}
|
||||
|
||||
tpl.render(context)
|
||||
tpl.save('output/order.docx')
|
||||
tpl.save("output/order.docx")
|
||||
|
||||
@ -3,12 +3,12 @@ from docxtpl import DocxTemplate
|
||||
# With old docxtpl version, "... for spicy ..." was replaced by "... forspicy..."
|
||||
# This test is for checking that is some cases the spaces are not lost anymore
|
||||
|
||||
tpl = DocxTemplate('templates/preserve_spaces_tpl.docx')
|
||||
tpl = DocxTemplate("templates/preserve_spaces_tpl.docx")
|
||||
|
||||
tags = ['tag_1', 'tag_2']
|
||||
replacement = ['looking', 'too']
|
||||
tags = ["tag_1", "tag_2"]
|
||||
replacement = ["looking", "too"]
|
||||
|
||||
context = dict(zip(tags, replacement))
|
||||
|
||||
tpl.render(context)
|
||||
tpl.save('output/preserve_spaces.docx')
|
||||
tpl.save("output/preserve_spaces.docx")
|
||||
|
||||
@ -1,18 +1,18 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
'''
|
||||
"""
|
||||
Created : 2017-09-03
|
||||
|
||||
@author: Eric Lapouyade
|
||||
'''
|
||||
"""
|
||||
|
||||
from docxtpl import DocxTemplate
|
||||
|
||||
DEST_FILE = 'output/replace_picture.docx'
|
||||
DEST_FILE = "output/replace_picture.docx"
|
||||
|
||||
tpl = DocxTemplate('templates/replace_picture_tpl.docx')
|
||||
tpl = DocxTemplate("templates/replace_picture_tpl.docx")
|
||||
|
||||
context = {}
|
||||
|
||||
tpl.replace_pic('python_logo.png', 'templates/python.png')
|
||||
tpl.replace_pic("python_logo.png", "templates/python.png")
|
||||
tpl.render(context)
|
||||
tpl.save(DEST_FILE)
|
||||
|
||||
@ -1,52 +1,64 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
'''
|
||||
"""
|
||||
Created : 2015-03-26
|
||||
|
||||
@author: Eric Lapouyade
|
||||
'''
|
||||
"""
|
||||
|
||||
from docxtpl import DocxTemplate, RichText
|
||||
|
||||
tpl = DocxTemplate('templates/richtext_tpl.docx')
|
||||
tpl = DocxTemplate("templates/richtext_tpl.docx")
|
||||
|
||||
rt = RichText()
|
||||
rt.add('a rich text', style='myrichtextstyle')
|
||||
rt.add(' with ')
|
||||
rt.add('some italic', italic=True)
|
||||
rt.add(' and ')
|
||||
rt.add('some violet', color='#ff00ff')
|
||||
rt.add(' and ')
|
||||
rt.add('some striked', strike=True)
|
||||
rt.add(' and ')
|
||||
rt.add('some small', size=14)
|
||||
rt.add(' or ')
|
||||
rt.add('big', size=60)
|
||||
rt.add(' text.')
|
||||
rt.add('\nYou can add an hyperlink, here to ')
|
||||
rt.add('google', url_id=tpl.build_url_id('http://google.com'))
|
||||
rt.add('\nEt voilà ! ')
|
||||
rt.add('\n1st line')
|
||||
rt.add('\n2nd line')
|
||||
rt.add('\n3rd line')
|
||||
rt.add('\n\n<cool>')
|
||||
rt.add("a rich text", style="myrichtextstyle")
|
||||
rt.add(" with ")
|
||||
rt.add("some italic", italic=True)
|
||||
rt.add(" and ")
|
||||
rt.add("some violet", color="#ff00ff")
|
||||
rt.add(" and ")
|
||||
rt.add("some striked", strike=True)
|
||||
rt.add(" and ")
|
||||
rt.add("some Highlighted", highlight="#ffff00")
|
||||
rt.add(" and ")
|
||||
rt.add("some small", size=14)
|
||||
rt.add(" or ")
|
||||
rt.add("big", size=60)
|
||||
rt.add(" text.")
|
||||
rt.add("\nYou can add an hyperlink, here to ")
|
||||
rt.add("google", url_id=tpl.build_url_id("http://google.com"))
|
||||
rt.add("\nEt voilà ! ")
|
||||
rt.add("\n1st line")
|
||||
rt.add("\n2nd line")
|
||||
rt.add("\n3rd line")
|
||||
rt.add("\aA new paragraph : <cool>\a")
|
||||
rt.add("--- A page break here (see next page) ---\f")
|
||||
|
||||
for ul in ['single', 'double', 'thick', 'dotted', 'dash', 'dotDash', 'dotDotDash', 'wave']:
|
||||
rt.add('\nUnderline : ' + ul + ' \n', underline=ul)
|
||||
rt.add('\nFonts :\n', underline=True)
|
||||
rt.add('Arial\n', font='Arial')
|
||||
rt.add('Courier New\n', font='Courier New')
|
||||
rt.add('Times New Roman\n', font='Times New Roman')
|
||||
rt.add('\n\nHere some')
|
||||
rt.add('superscript', superscript=True)
|
||||
rt.add(' and some')
|
||||
rt.add('subscript', subscript=True)
|
||||
for ul in [
|
||||
"single",
|
||||
"double",
|
||||
"thick",
|
||||
"dotted",
|
||||
"dash",
|
||||
"dotDash",
|
||||
"dotDotDash",
|
||||
"wave",
|
||||
]:
|
||||
rt.add("\nUnderline : " + ul + " \n", underline=ul)
|
||||
rt.add("\nFonts :\n", underline=True)
|
||||
rt.add("Arial\n", font="Arial")
|
||||
rt.add("Courier New\n", font="Courier New")
|
||||
rt.add("Times New Roman\n", font="Times New Roman")
|
||||
rt.add("\n\nHere some")
|
||||
rt.add("superscript", superscript=True)
|
||||
rt.add(" and some")
|
||||
rt.add("subscript", subscript=True)
|
||||
|
||||
rt_embedded = RichText('an example of ')
|
||||
rt_embedded = RichText("an example of ")
|
||||
rt_embedded.add(rt)
|
||||
|
||||
context = {
|
||||
'example': rt_embedded,
|
||||
"example": rt_embedded,
|
||||
}
|
||||
|
||||
tpl.render(context)
|
||||
tpl.save('output/richtext.docx')
|
||||
tpl.save("output/richtext.docx")
|
||||
|
||||
@ -1,16 +1,16 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
'''
|
||||
"""
|
||||
Created : 2015-03-26
|
||||
|
||||
@author: Eric Lapouyade
|
||||
'''
|
||||
"""
|
||||
|
||||
from docxtpl import DocxTemplate, RichText
|
||||
|
||||
tpl = DocxTemplate('templates/richtext_and_if_tpl.docx')
|
||||
tpl = DocxTemplate("templates/richtext_and_if_tpl.docx")
|
||||
|
||||
|
||||
context = {'foobar': RichText('Foobar!', color='ff0000')}
|
||||
context = {"foobar": RichText("Foobar!", color="ff0000")}
|
||||
|
||||
tpl.render(context)
|
||||
tpl.save('output/richtext_and_if.docx')
|
||||
tpl.save("output/richtext_and_if.docx")
|
||||
|
||||
21
tests/richtext_eastAsia.py
Normal file
21
tests/richtext_eastAsia.py
Normal file
@ -0,0 +1,21 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
Created : 2022-08-03
|
||||
@author: Dongfang Song
|
||||
"""
|
||||
|
||||
|
||||
from docxtpl import DocxTemplate, RichText
|
||||
|
||||
tpl = DocxTemplate("templates/richtext_eastAsia_tpl.docx")
|
||||
rt = RichText("测试TEST", font="eastAsia:Microsoft YaHei")
|
||||
ch = RichText("测试TEST", font="eastAsia:微软雅黑")
|
||||
sun = RichText("测试TEST", font="eastAsia:SimSun")
|
||||
context = {
|
||||
"example": rt,
|
||||
"Chinese": ch,
|
||||
"simsun": sun,
|
||||
}
|
||||
|
||||
tpl.render(context)
|
||||
tpl.save("output/richtext_eastAsia.docx")
|
||||
63
tests/richtextparagraph.py
Normal file
63
tests/richtextparagraph.py
Normal file
@ -0,0 +1,63 @@
|
||||
"""
|
||||
Created : 2025-02-28
|
||||
|
||||
@author: Hannah Imrie
|
||||
"""
|
||||
|
||||
from docxtpl import DocxTemplate, RichText, RichTextParagraph
|
||||
|
||||
tpl = DocxTemplate("templates/richtext_paragraph_tpl.docx")
|
||||
|
||||
rtp = RichTextParagraph()
|
||||
rt = RichText()
|
||||
|
||||
rtp.add(
|
||||
"The rich text paragraph function allows paragraph styles to be added to text",
|
||||
parastyle="myrichparastyle",
|
||||
)
|
||||
rtp.add("Any built in paragraph style can be used", parastyle="IntenseQuote")
|
||||
rtp.add(
|
||||
"or you can add your own, unlocking all style options", parastyle="createdStyle"
|
||||
)
|
||||
rtp.add(
|
||||
"To use, just create a style in your template word doc with the formatting you want "
|
||||
"and call it in the code.",
|
||||
parastyle="normal",
|
||||
)
|
||||
|
||||
rtp.add("This allows for the use of")
|
||||
rtp.add("custom bullet\apoints", parastyle="SquareBullet")
|
||||
rtp.add("Numbered Bullet Points", parastyle="BasicNumbered")
|
||||
rtp.add("and Alpha Bullet Points.", parastyle="alphaBracketNumbering")
|
||||
rtp.add("You can", parastyle="normal")
|
||||
rtp.add("set the", parastyle="centerAlign")
|
||||
rtp.add("text alignment", parastyle="rightAlign")
|
||||
rtp.add(
|
||||
"as well as the spacing between lines of text. Like this for example, "
|
||||
"this text has very tight spacing between the lines.\aIt also has no space between "
|
||||
"paragraphs of the same style.",
|
||||
parastyle="TightLineSpacing",
|
||||
)
|
||||
rtp.add(
|
||||
"Unlike this one, which has extra large spacing between lines for when you want to "
|
||||
"space things out a bit or just write a little less.",
|
||||
parastyle="WideLineSpacing",
|
||||
)
|
||||
rtp.add(
|
||||
"You can also set the background colour of a line.", parastyle="LineShadingGreen"
|
||||
)
|
||||
|
||||
rt.add("This works with ")
|
||||
rt.add("Rich ", bold=True)
|
||||
rt.add("Text ", italic=True)
|
||||
rt.add("Strings", underline="single")
|
||||
rt.add(" too.")
|
||||
|
||||
rtp.add(rt, parastyle="SquareBullet")
|
||||
|
||||
context = {
|
||||
"example": rtp,
|
||||
}
|
||||
|
||||
tpl.render(context)
|
||||
tpl.save("output/richtext_paragraph.docx")
|
||||
@ -1,18 +1,17 @@
|
||||
import subprocess
|
||||
import glob
|
||||
import six
|
||||
import os
|
||||
|
||||
tests = sorted(glob.glob('[A-Za-z]*.py'))
|
||||
excludes = ['runtests.py']
|
||||
tests = sorted(glob.glob("[A-Za-z]*.py"))
|
||||
excludes = ["runtests.py"]
|
||||
|
||||
output_dir = os.path.join(os.path.dirname(__file__), 'output')
|
||||
output_dir = os.path.join(os.path.dirname(__file__), "output")
|
||||
if not os.path.exists(output_dir):
|
||||
os.mkdir(output_dir)
|
||||
|
||||
for test in tests:
|
||||
if test not in excludes:
|
||||
six.print_('%s ...' % test)
|
||||
subprocess.call(['python', './%s' % test])
|
||||
print("%s ..." % test)
|
||||
subprocess.call(["python", "./%s" % test])
|
||||
|
||||
six.print_('Done.')
|
||||
print("Done.")
|
||||
|
||||
@ -1,36 +1,36 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
'''
|
||||
"""
|
||||
Created : 2015-03-12
|
||||
|
||||
@author: Eric Lapouyade
|
||||
'''
|
||||
"""
|
||||
|
||||
from docxtpl import DocxTemplate
|
||||
from docx.shared import Inches
|
||||
|
||||
tpl = DocxTemplate('templates/subdoc_tpl.docx')
|
||||
tpl = DocxTemplate("templates/subdoc_tpl.docx")
|
||||
|
||||
sd = tpl.new_subdoc()
|
||||
p = sd.add_paragraph('This is a sub-document inserted into a bigger one')
|
||||
p = sd.add_paragraph('It has been ')
|
||||
p.add_run('dynamically').style = 'dynamic'
|
||||
p.add_run(' generated with python by using ')
|
||||
p.add_run('python-docx').italic = True
|
||||
p.add_run(' library')
|
||||
p = sd.add_paragraph("This is a sub-document inserted into a bigger one")
|
||||
p = sd.add_paragraph("It has been ")
|
||||
p.add_run("dynamically").style = "dynamic"
|
||||
p.add_run(" generated with python by using ")
|
||||
p.add_run("python-docx").italic = True
|
||||
p.add_run(" library")
|
||||
|
||||
sd.add_heading('Heading, level 1', level=1)
|
||||
sd.add_paragraph('This is an Intense quote', style='IntenseQuote')
|
||||
sd.add_heading("Heading, level 1", level=1)
|
||||
sd.add_paragraph("This is an Intense quote", style="IntenseQuote")
|
||||
|
||||
sd.add_paragraph('A picture :')
|
||||
sd.add_picture('templates/python_logo.png', width=Inches(1.25))
|
||||
sd.add_paragraph("A picture :")
|
||||
sd.add_picture("templates/python_logo.png", width=Inches(1.25))
|
||||
|
||||
sd.add_paragraph('A Table :')
|
||||
sd.add_paragraph("A Table :")
|
||||
table = sd.add_table(rows=1, cols=3)
|
||||
hdr_cells = table.rows[0].cells
|
||||
hdr_cells[0].text = 'Qty'
|
||||
hdr_cells[1].text = 'Id'
|
||||
hdr_cells[2].text = 'Desc'
|
||||
recordset = ((1, 101, 'Spam'), (2, 42, 'Eggs'), (3, 631, 'Spam,spam, eggs, and ham'))
|
||||
hdr_cells[0].text = "Qty"
|
||||
hdr_cells[1].text = "Id"
|
||||
hdr_cells[2].text = "Desc"
|
||||
recordset = ((1, 101, "Spam"), (2, 42, "Eggs"), (3, 631, "Spam,spam, eggs, and ham"))
|
||||
for item in recordset:
|
||||
row_cells = table.add_row().cells
|
||||
row_cells[0].text = str(item[0])
|
||||
@ -38,8 +38,8 @@ for item in recordset:
|
||||
row_cells[2].text = item[2]
|
||||
|
||||
context = {
|
||||
'mysubdoc': sd,
|
||||
"mysubdoc": sd,
|
||||
}
|
||||
|
||||
tpl.render(context)
|
||||
tpl.save('output/subdoc.docx')
|
||||
tpl.save("output/subdoc.docx")
|
||||
|
||||
@ -1,20 +1,19 @@
|
||||
from docxtpl import DocxTemplate
|
||||
from jinja2.exceptions import TemplateError
|
||||
import six
|
||||
|
||||
six.print_('=' * 80)
|
||||
six.print_("Generating template error for testing (so it is safe to ignore) :")
|
||||
six.print_('.' * 80)
|
||||
print("=" * 80)
|
||||
print("Generating template error for testing (so it is safe to ignore) :")
|
||||
print("." * 80)
|
||||
try:
|
||||
tpl = DocxTemplate('templates/template_error_tpl.docx')
|
||||
tpl.render({'test_variable': 'test variable value'})
|
||||
tpl = DocxTemplate("templates/template_error_tpl.docx")
|
||||
tpl.render({"test_variable": "test variable value"})
|
||||
except TemplateError as the_error:
|
||||
six.print_(six.text_type(the_error))
|
||||
if hasattr(the_error, 'docx_context'):
|
||||
six.print_("Context:")
|
||||
print(str(the_error))
|
||||
if hasattr(the_error, "docx_context"):
|
||||
print("Context:")
|
||||
for line in the_error.docx_context:
|
||||
six.print_(line)
|
||||
tpl.save('output/template_error.docx')
|
||||
six.print_('.' * 80)
|
||||
six.print_(" End of TemplateError Test ")
|
||||
six.print_('=' * 80)
|
||||
print(line)
|
||||
tpl.save("output/template_error.docx")
|
||||
print("." * 80)
|
||||
print(" End of TemplateError Test ")
|
||||
print("=" * 80)
|
||||
|
||||
BIN
tests/templates/comments_tpl.docx
Normal file
BIN
tests/templates/comments_tpl.docx
Normal file
Binary file not shown.
BIN
tests/templates/doc_properties_tpl.docx
Normal file
BIN
tests/templates/doc_properties_tpl.docx
Normal file
Binary file not shown.
Binary file not shown.
BIN
tests/templates/footnotes_tpl.docx
Normal file
BIN
tests/templates/footnotes_tpl.docx
Normal file
Binary file not shown.
BIN
tests/templates/get_undeclared_variables.docx
Normal file
BIN
tests/templates/get_undeclared_variables.docx
Normal file
Binary file not shown.
Binary file not shown.
BIN
tests/templates/merge_docx_master_tpl.docx
Normal file
BIN
tests/templates/merge_docx_master_tpl.docx
Normal file
Binary file not shown.
BIN
tests/templates/merge_docx_subdoc.docx
Normal file
BIN
tests/templates/merge_docx_subdoc.docx
Normal file
Binary file not shown.
Binary file not shown.
8
tests/templates/module_execute.json
Normal file
8
tests/templates/module_execute.json
Normal file
@ -0,0 +1,8 @@
|
||||
{"json_dict_var" : {"json_dict_var":"successfully inserted"},
|
||||
"json_array_var": ["json","array","var","successfully", "inserted"],
|
||||
"json_string_var":"json_string_var successfully inserted",
|
||||
"json_int_var":123,
|
||||
"json_float_var":1.234,
|
||||
"json_true_var":true,
|
||||
"json_false_var":false,
|
||||
"json_none_var":null}
|
||||
BIN
tests/templates/module_execute_tpl.docx
Normal file
BIN
tests/templates/module_execute_tpl.docx
Normal file
Binary file not shown.
BIN
tests/templates/multi_rendering_tpl.docx
Normal file
BIN
tests/templates/multi_rendering_tpl.docx
Normal file
Binary file not shown.
Binary file not shown.
BIN
tests/templates/richtext_eastAsia_tpl.docx
Normal file
BIN
tests/templates/richtext_eastAsia_tpl.docx
Normal file
Binary file not shown.
BIN
tests/templates/richtext_paragraph_tpl.docx
Normal file
BIN
tests/templates/richtext_paragraph_tpl.docx
Normal file
Binary file not shown.
@ -1,23 +1,23 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
'''
|
||||
"""
|
||||
Created : 2017-10-15
|
||||
|
||||
@author: Arthaslixin
|
||||
'''
|
||||
"""
|
||||
|
||||
from docxtpl import DocxTemplate
|
||||
|
||||
tpl = DocxTemplate('templates/vertical_merge_tpl.docx')
|
||||
tpl = DocxTemplate("templates/vertical_merge_tpl.docx")
|
||||
|
||||
context = {
|
||||
'items': [
|
||||
{'desc': 'Python interpreters', 'qty': 2, 'price': 'FREE'},
|
||||
{'desc': 'Django projects', 'qty': 5403, 'price': 'FREE'},
|
||||
{'desc': 'Guido', 'qty': 1, 'price': '100,000,000.00'},
|
||||
"items": [
|
||||
{"desc": "Python interpreters", "qty": 2, "price": "FREE"},
|
||||
{"desc": "Django projects", "qty": 5403, "price": "FREE"},
|
||||
{"desc": "Guido", "qty": 1, "price": "100,000,000.00"},
|
||||
],
|
||||
'total_price': '100,000,000.00',
|
||||
'category': 'Book',
|
||||
"total_price": "100,000,000.00",
|
||||
"category": "Book",
|
||||
}
|
||||
|
||||
tpl.render(context)
|
||||
tpl.save('output/vertical_merge.docx')
|
||||
tpl.save("output/vertical_merge.docx")
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
from docxtpl import DocxTemplate
|
||||
|
||||
tpl = DocxTemplate('templates/vertical_merge_nested_tpl.docx')
|
||||
tpl = DocxTemplate("templates/vertical_merge_nested_tpl.docx")
|
||||
tpl.render({})
|
||||
tpl.save('output/vertical_merge_nested.docx')
|
||||
tpl.save("output/vertical_merge_nested.docx")
|
||||
|
||||
@ -1,12 +1,12 @@
|
||||
from docxtpl import DocxTemplate, RichText
|
||||
|
||||
tpl = DocxTemplate('templates/word2016_tpl.docx')
|
||||
tpl = DocxTemplate("templates/word2016_tpl.docx")
|
||||
tpl.render(
|
||||
{
|
||||
'test_space': ' ',
|
||||
'test_tabs': 5 * '\t',
|
||||
'test_space_r': RichText(' '),
|
||||
'test_tabs_r': RichText(5 * '\t'),
|
||||
"test_space": " ",
|
||||
"test_tabs": 5 * "\t",
|
||||
"test_space_r": RichText(" "),
|
||||
"test_tabs_r": RichText(5 * "\t"),
|
||||
}
|
||||
)
|
||||
tpl.save('output/word2016.docx')
|
||||
tpl.save("output/word2016.docx")
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user