Metadata-Version: 2.4
Name: rust-docxtpl
Version: 0.1.2
Classifier: Development Status :: 4 - Beta
Classifier: Intended Audience :: Developers
Classifier: License :: OSI Approved :: GNU Lesser General Public License v2 (LGPLv2)
Classifier: Operating System :: OS Independent
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3 :: Only
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Programming Language :: Python :: 3.14
Classifier: Programming Language :: Python :: Implementation :: CPython
Classifier: Programming Language :: Rust
Classifier: Topic :: Office/Business
Classifier: Topic :: Text Processing :: Markup :: XML
Requires-Dist: python-docx>=1.1.1
Requires-Dist: docxcompose
Requires-Dist: jinja2
Requires-Dist: lxml
Provides-Extra: subdoc
License-File: LICENSE
License-File: NOTICE
Summary: Rust-accelerated drop-in replacement for docxtpl (import name stays `docxtpl`)
Keywords: jinja2,docx,template
Author: Anthony Hoo
License: LGPL-2.1-only
Requires-Python: >=3.10
Description-Content-Type: text/markdown; charset=UTF-8; variant=GFM
Project-URL: Changelog, https://github.com/Anthony-Hoo/rust-docxtpl/blob/main/CHANGELOG.md
Project-URL: Homepage, https://github.com/Anthony-Hoo/rust-docxtpl
Project-URL: Repository, https://github.com/Anthony-Hoo/rust-docxtpl
Project-URL: Upstream, https://github.com/elapouya/python-docx-template

# rust-docxtpl

[![CI](https://github.com/Anthony-Hoo/rust-docxtpl/actions/workflows/ci.yml/badge.svg)](https://github.com/Anthony-Hoo/rust-docxtpl/actions/workflows/ci.yml)
[![PyPI](https://img.shields.io/pypi/v/rust-docxtpl.svg)](https://pypi.org/project/rust-docxtpl/)

English | [简体中文](https://github.com/Anthony-Hoo/rust-docxtpl/blob/main/README.zh-CN.md)（中文全文见下方）

Rust-accelerated, drop-in distribution of the `docxtpl` import package.
Compatibility baseline: **docxtpl 0.20.1** (python-docx 1.2.0, Jinja2 3.1.x).

```diff
- docxtpl==0.20.1
+ rust-docxtpl==0.1.0
```

```bash
pip uninstall docxtpl && pip install rust-docxtpl
```

Wheels: CPython 3.10–3.14 on Linux (x86_64, aarch64; manylinux2014),
Windows (x86_64) and macOS (arm64, x86_64). Other platforms build from the
sdist with a Rust toolchain (`pip install rust-docxtpl` does that automatically
when `cargo` is on `PATH`).

Application code, templates and context data stay exactly as they are:

```python
from docxtpl import DocxTemplate, InlineImage, RichText

tpl = DocxTemplate(path)
tpl.get_undeclared_template_variables()
tpl.render(context, jinja_env)
tpl.save(output)
```

Jinja2 evaluation is still done by the real Jinja2 with your `Environment`
(filters, tests, globals, `Undefined`, autoescape, Python objects in the
context), and documents are still real python-docx / lxml objects that you can
modify before and after `render()`. Do **not** install this next to the
original `docxtpl` distribution: both own the `docxtpl` package, and importing
fails with an explanatory `ImportError` if they are mixed.

## What is faster, and why

Measured on the largest real-world template available to the authors (3.7 MB
`document.xml`, 141k elements); see [Benchmarks](#benchmarks).

| Upstream cost | Cause | What this package does |
|---|---|---|
| ~88 % of `render()` in `map_tree()` | `root.replace(body, tree)` makes lxml re-home every node of the *outgoing* body. lxml's namespace cache never hits for namespaces declared on the root (it stores `(new, new)` instead of `(old, new)`, `proxy.pxi:_fixCNs`), so the work is quadratic in the body size | The rendered body is attached to a twin of the root element and `Document._element` / `DocumentPart._element` are pointed at it. Nothing is unlinked; the old tree stays complete, as upstream leaves it |
| `patch_xml()`: ~20 backtracking regex passes over the whole XML, per part, per scan, per render | Python `re` with lookaround | Hand-written Rust scanners (`crates/core`), byte-exact, GIL released |
| Every `get_undeclared_template_variables()` reloads, patches and parses the template | no reuse | Result cached by SHA-256 of the template bytes + environment fingerprint |
| Jinja2 lexes, parses and compiles the multi-MB source on every render | `from_string` has no cache | Code object shared between *equivalent* environments |

## Compatibility contract

Output is compared with upstream using a strict OOXML comparator
(all parts, element order, attributes, namespace bindings, text, tails,
relationship ids, media bytes).

| API | Status |
|---|---|
| `DocxTemplate(path / PathLike / stream)`, `render`, `save`, `init_docx`, `get_docx`, `.docx`, attribute proxying | same code path as upstream |
| `patch_xml(str) -> str` | native; byte-identical (differential fuzzing + 35 real templates) |
| `get_undeclared_template_variables(jinja_env=None, context=None)` | cached; always analyses the template *file*, returns a fresh `set`, restores stream positions |
| `InlineImage` (subclassable, `_insert_image`, `_add_hyperlink`), `RichText`/`R`, `RichTextParagraph`/`RP`, `Listing`, `Subdoc`, `new_subdoc(path)` | upstream code, verbatim |
| `replace_pic/media/embedded/zipname`, `reset_replacements`, `build_url_id`, `python -m docxtpl` | upstream code, verbatim |
| Subclass overrides of `patch_xml`, `xml_to_string`, `resolve_listing`, `map_tree`, … | honoured; the affected cache / native shortcut is bypassed |

Deliberate differences:

1. After `render()`, `tpl.docx._element` is a new root element object unless
   some other object referenced the old one (then upstream's slow path runs
   and identity is kept). `Document`, `DocumentPart`, relationships and all
   other parts keep their identity. Objects obtained *before* `render()`
   (including python-docx's cached `document._body`) stay complete and stale,
   exactly as with upstream; their `getparent()` chain ends at the old root
   instead of at the old body.
2. Re-entering `render()` on one instance raises `RuntimeError`.
3. A custom filter that is *impure* and applied to *constants only* is still
   evaluated on every render (such templates are never served from the code
   cache); nothing to do for users.

### Caches

Process-local LRU, byte-budgeted (`DOCXTPL_CACHE_BYTES`, default 32 MiB,
`0` disables; or `docxtpl.set_cache_budget()`), keyed by content digests,
never by path, mtime or `id()`. It stores variable-name sets, patched
template XML and compiled code objects: never a context, image, rendered
document or callback result. Environments are fingerprinted on every call
(they are mutable); subclasses of `Environment`, extensions, a `finalize`
hook, callable `autoescape`, or filters/tests that are closures, bound
methods or callable objects disable the corresponding layer.

Compiled code can additionally be shared *between processes* through an
on-disk cache: set `DOCXTPL_CODE_CACHE_DIR=/path` (or call
`docxtpl.configure_code_cache(path, max_entries=512)`). A pre-fork server
whose workers are recycled otherwise lexes, parses and compiles the multi-MB
source on nearly every request. Entries are `marshal`ed code objects keyed by
the source digest plus a stable description of the environment (names and
code of custom filters/tests, `undefined`, policies, lexer settings) and the
Python/Jinja2/marshal versions; corrupt entries are dropped, the directory is
bounded by `DOCXTPL_CODE_CACHE_MAX_ENTRIES`. As with
`jinja2.FileSystemBytecodeCache`, code from that directory is executed, so
only the application may write there. Off by default.

### Observability

```python
import docxtpl
docxtpl.enable_timings()          # or DOCXTPL_TIMINGS=1
...
docxtpl.stats()
# {'counters': {'patch_xml_native': 19, 'patch_xml_reference': 0, 'map_tree_swap': 1,
#               'map_tree_replace': 0, 'jinja_compile_reused': 19, 'cache_code_hit': 19, ...},
#  'timings': {'render': {'calls': 1, 'seconds': 0.17}, ...}}
docxtpl.cache_info()
```

`*_reference` / `map_tree_replace` / `jinja_compile_plain` count executions of
the upstream-equivalent slow paths. No template text or context data is ever
recorded.

### Optional: python-docx XPath cache (`docxtpl.accel`)

Off by default. `docxtpl.accel.enable()` (or `DOCXTPL_ACCEL=1`) replaces one
method, `BaseOxmlElement.xpath`, with an equivalent that reuses compiled
expressions instead of building an lxml evaluator per call. Measured on the
largest report: reading all cell paragraph text 0.24 s -> 0.085 s; table
normalisation and docxcompose merges are unchanged (they are not XPath-bound).
Expected gain in the full export is a few percent, which is why it is opt-in
and why python-docx is **not** re-implemented: its objects *are* lxml
elements that applications and docxcompose manipulate directly, parsing /
XPath / serialisation already run in C, and a cached `qn()` measured no gain.

## Layout

```
crates/core   pure Rust kernels (no Python): patch.rs, render.rs, scan.rs
crates/py     PyO3 bindings -> docxtpl._native
python/docxtpl
  template.py      upstream DocxTemplate with the hot paths rerouted
  _reference.py    upstream regex code, verbatim: fallback + test oracle
  _jinja.py        environment fingerprints, compiled-code reuse
  _cache.py        byte-budgeted LRU       _stats.py  counters / timings
  accel.py         opt-in python-docx XPath cache
tests/        pytest: differential (fuzz + optional private corpus), facade behaviour
.github/      ci.yml (clippy, cargo test, pytest on 3 OSes), release.yml (wheels -> PyPI)
```

Each Rust pass quotes the regex it replaces and states the matching rule it
implements. When a rule is in doubt, `_reference.py` is the specification and
`tests/fuzz.py` is the judge:

```bash
python -m venv .venv && . .venv/bin/activate && pip install maturin pytest
maturin develop --release                      # builds docxtpl._native into .venv
cargo test && cargo clippy --all-targets -- -D warnings
pytest tests                                   # differential tests on a private
                                               # DOCX corpus are skipped when absent
python tests/fuzz.py 500000 7                  # grammar fuzzing against _reference.py
python tests/make_golden.py                    # after changing fuzz grammar / reference
```

## Benchmarks

The (private) benchmark harness alternates fresh processes of both
environments (AB/BA); iteration 1 of a process is *cold* (empty caches),
later ones *warm*.
Numbers below: 13th-gen Core i9 laptop, WSL2, CPython 3.12.14, lxml 5.3.1;
p50 of the library layer = two variable scans + `render()`. The templates are
private production documents (a 141k-element test report, six test-record
templates, a cover page) and are not part of this repository; the numbers
are indicative only.

A = `.venv-baseline`, B = `.venv-candidate`; 4 rounds x 3 iterations per process.

| case | library A p50 | B cold p50 | B warm p50 | cold B/A | warm B/A | warm p95 B/A | CPU warm B/A | peak RSS cold B/A |
|---|---:|---:|---:|---:|---:|---:|---:|---:|
| remote-report-tpl194 | 13.047 s | 0.829 s | 0.166 s | 0.063 | 0.013 | 0.013 | 0.020 | 0.77 |
| remote-records-tpl177 | 1.213 s | 0.190 s | 0.027 s | 0.156 | 0.022 | 0.023 | 0.050 | 0.74 |
| remote-records-tpl178 | 0.280 s | 0.075 s | 0.009 s | 0.265 | 0.032 | 0.034 | 0.066 | 0.95 |
| remote-records-tpl180 | 1.312 s | 0.188 s | 0.029 s | 0.143 | 0.022 | 0.027 | 0.047 | 0.82 |
| remote-records-tpl181 | 0.383 s | 0.076 s | 0.007 s | 0.192 | 0.019 | 0.019 | 0.037 | 0.81 |
| remote-records-tpl183 | 0.758 s | 0.128 s | 0.019 s | 0.168 | 0.026 | 0.029 | 0.046 | 0.88 |
| remote-records-tpl185 | 0.028 s | 0.013 s | 0.002 s | 0.419 | 0.078 | 0.082 | 0.456 | 0.89 |
| local-cover-tpl185 | 0.029 s | 0.013 s | 0.002 s | 0.411 | 0.073 | 0.077 | 0.446 | 0.89 |
| synthetic-object-protocol | 0.017 s | 0.014 s | 0.003 s | 0.665 | 0.149 | 0.126 | 0.469 | 1.00 |

Control group (PRD 8.2-9), report template, same facade and algorithms with the
Rust kernels switched off (`DOCXTPL_NATIVE=0`): library layer 2.63 s cold /
1.10 s warm, versus 0.83 s / 0.17 s with them: the Rust kernels remove 68 % /
85 % of what the Python-only optimisation leaves.


## Building wheels

`maturin build --release` produces a wheel for the local platform.
`.github/workflows/release.yml` builds the full wheel matrix plus the sdist
on every `v*` tag and publishes them to PyPI through
[trusted publishing](https://docs.pypi.org/trusted-publishers/) (no API
token stored in the repository); `ci.yml` runs clippy, `cargo test` and the
pytest suite on Linux, Windows and macOS for every push and pull request.

## License

LGPL-2.1-only, as a derivative of docxtpl. See `LICENSE` and `NOTICE`.


---

# rust-docxtpl

[![CI](https://github.com/Anthony-Hoo/rust-docxtpl/actions/workflows/ci.yml/badge.svg)](https://github.com/Anthony-Hoo/rust-docxtpl/actions/workflows/ci.yml)
[![PyPI](https://img.shields.io/pypi/v/rust-docxtpl.svg)](https://pypi.org/project/rust-docxtpl/)

[English](https://github.com/Anthony-Hoo/rust-docxtpl/blob/main/README.md) (full English text above) | 简体中文

`docxtpl`（python-docx-template）导入包的 Rust 加速发行版，可直接替换。
兼容基线：**docxtpl 0.20.1**（python-docx 1.2.0、Jinja2 3.1.x）。

```diff
- docxtpl==0.20.1
+ rust-docxtpl==0.1.0
```

```bash
pip uninstall docxtpl && pip install rust-docxtpl
```

预编译 wheel 覆盖 CPython 3.10–3.14 的 Linux（x86_64、aarch64，manylinux2014）、
Windows（x86_64）和 macOS（arm64、x86_64）。其他平台从 sdist 构建，需要 Rust
工具链（`cargo` 在 `PATH` 上时 `pip install rust-docxtpl` 会自动编译）。

应用代码、模板和上下文数据一行不改：

```python
from docxtpl import DocxTemplate, InlineImage, RichText

tpl = DocxTemplate(path)
tpl.get_undeclared_template_variables()
tpl.render(context, jinja_env)
tpl.save(output)
```

Jinja2 求值仍由真实的 Jinja2 用你的 `Environment` 完成（过滤器、测试、全局变量、
`Undefined`、autoescape、上下文里的 Python 对象都照旧），文档仍是真实的
python-docx / lxml 对象，`render()` 前后都可以修改。**不要**和原版 `docxtpl`
发行包同时安装：两者都拥有 `docxtpl` 这个包目录，混装时 `import docxtpl` 会抛出
带说明的 `ImportError`。

## 快在哪里，为什么

在作者能拿到的最大真实模板上测量（`document.xml` 3.7 MB、14.1 万个元素），
见[基准测试](#基准测试)。

| 上游开销 | 原因 | 本包的做法 |
|---|---|---|
| `render()` 约 88% 的时间在 `map_tree()` | `root.replace(body, tree)` 让 lxml 逐个重挂*旧*正文的每个节点。对根元素上声明的命名空间，lxml 的命名空间缓存永远不命中（它存的是 `(new, new)` 而不是 `(old, new)`，见 `proxy.pxi:_fixCNs`），工作量随正文大小平方增长 | 把渲染后的正文挂到根元素的一个孪生副本上，再把 `Document._element` / `DocumentPart._element` 指过去。什么都不拆，旧树保持完整，和上游留下的状态一样 |
| `patch_xml()`：约 20 趟回溯正则扫全文，每个部件、每次扫描、每次 render 都跑 | Python `re` 加环视 | 手写 Rust 扫描器（`crates/core`），逐字节等价，执行时释放 GIL |
| 每次 `get_undeclared_template_variables()` 都重新加载、patch、解析模板 | 没有复用 | 以模板字节的 SHA-256 加环境指纹为键缓存结果 |
| Jinja2 每次 render 都重新 lex、parse、compile 数 MB 的源码 | `from_string` 没有缓存 | *等价*环境之间共享编译后的代码对象 |

## 兼容性契约

输出用严格的 OOXML 比较器与上游对比（全部部件、元素顺序、属性、命名空间绑定、
文本、尾文本、关系 id、媒体字节）。

| API | 状态 |
|---|---|
| `DocxTemplate(path / PathLike / stream)`、`render`、`save`、`init_docx`、`get_docx`、`.docx`、属性代理 | 与上游同一条代码路径 |
| `patch_xml(str) -> str` | 原生实现；逐字节相同（差异模糊测试 + 35 份真实模板） |
| `get_undeclared_template_variables(jinja_env=None, context=None)` | 有缓存；始终分析模板*文件*，返回新的 `set`，恢复流的读取位置 |
| `InlineImage`（可继承，`_insert_image`、`_add_hyperlink`）、`RichText`/`R`、`RichTextParagraph`/`RP`、`Listing`、`Subdoc`、`new_subdoc(path)` | 上游代码，原样 |
| `replace_pic/media/embedded/zipname`、`reset_replacements`、`build_url_id`、`python -m docxtpl` | 上游代码，原样 |
| 子类覆盖 `patch_xml`、`xml_to_string`、`resolve_listing`、`map_tree` 等 | 生效；相应的缓存 / 原生快路径自动绕过 |

有意为之的差异：

1. `render()` 之后 `tpl.docx._element` 是一个新的根元素对象，除非有其他对象引用着
   旧根（那时走上游的慢路径，保持身份不变）。`Document`、`DocumentPart`、关系和
   其他所有部件的身份都不变。`render()` *之前*取到的对象（包括 python-docx 缓存的
   `document._body`）保持完整但陈旧，和上游完全一样；只是它们的 `getparent()` 链
   终止在旧根而不是旧正文。
2. 在同一个实例上重入 `render()` 抛 `RuntimeError`。
3. *不纯*且*只作用于常量*的自定义过滤器仍会在每次 render 时求值（这类模板永远不
   走代码缓存）；用户无需处理。

### 缓存

进程内 LRU，按字节预算（`DOCXTPL_CACHE_BYTES`，默认 32 MiB，`0` 关闭；或
`docxtpl.set_cache_budget()`），以内容摘要为键，从不用路径、mtime 或 `id()`。
只存变量名集合、patch 后的模板 XML 和编译后的代码对象：从不存上下文、图片、
渲染结果或回调结果。每次调用都重新计算环境指纹（环境是可变的）；`Environment`
的子类、扩展、`finalize` 钩子、可调用的 `autoescape`，以及是闭包、绑定方法或
可调用对象的过滤器 / 测试，都会关闭对应的缓存层。

编译后的代码还可以通过磁盘缓存在*进程之间*共享：设置
`DOCXTPL_CODE_CACHE_DIR=/path`（或调用
`docxtpl.configure_code_cache(path, max_entries=512)`）。pre-fork 服务器的
worker 会被轮换，否则几乎每个请求都要重新 lex、parse、compile 数 MB 的源码。
条目是 `marshal` 序列化的代码对象，键由源码摘要、环境的稳定描述（自定义过滤器 /
测试的名字和代码、`undefined`、policies、词法设置）以及 Python / Jinja2 / marshal
版本组成；损坏的条目会被丢弃，目录大小由 `DOCXTPL_CODE_CACHE_MAX_ENTRIES` 限制。
与 `jinja2.FileSystemBytecodeCache` 一样，该目录里的代码会被执行，因此只能由
应用自己写入。默认关闭。

### 可观测性

```python
import docxtpl
docxtpl.enable_timings()          # 或 DOCXTPL_TIMINGS=1
...
docxtpl.stats()
# {'counters': {'patch_xml_native': 19, 'patch_xml_reference': 0, 'map_tree_swap': 1,
#               'map_tree_replace': 0, 'jinja_compile_reused': 19, 'cache_code_hit': 19, ...},
#  'timings': {'render': {'calls': 1, 'seconds': 0.17}, ...}}
docxtpl.cache_info()
```

`*_reference` / `map_tree_replace` / `jinja_compile_plain` 统计的是与上游等价的
慢路径的执行次数。不会记录任何模板文本或上下文数据。

### 可选：python-docx XPath 缓存（`docxtpl.accel`）

默认关闭。`docxtpl.accel.enable()`（或 `DOCXTPL_ACCEL=1`）只替换一个方法
`BaseOxmlElement.xpath`，用复用编译后表达式的等价实现代替每次调用都新建 lxml
求值器。在最大的报告上测得：读取全部单元格段落文本 0.24 s → 0.085 s；表格归一化
和 docxcompose 合并不变（它们不受 XPath 制约）。对完整导出的预期收益只有几个
百分点，所以做成可选项；这也是**不**重写 python-docx 的原因：它的对象*就是*
lxml 元素，应用和 docxcompose 直接操作它们，解析 / XPath / 序列化本来就在 C 里跑，
给 `qn()` 加缓存实测没有收益。

## 目录结构

```
crates/core   纯 Rust 内核（不依赖 Python）：patch.rs、render.rs、scan.rs
crates/py     PyO3 绑定 -> docxtpl._native
python/docxtpl
  template.py      上游 DocxTemplate，热点路径改道
  _reference.py    上游正则代码，原样：回退路径 + 测试基准
  _jinja.py        环境指纹、编译代码复用
  _cache.py        字节预算 LRU          _stats.py  计数器 / 计时
  accel.py         可选的 python-docx XPath 缓存
tests/        pytest：差异测试（模糊测试 + 可选的私有语料）、门面行为
.github/      ci.yml（clippy、cargo test、三个 OS 上的 pytest）、release.yml（wheel -> PyPI）
```

每一个 Rust pass 都引用它替换的那条正则，并写明实现的匹配规则。规则有疑问时，
`_reference.py` 是规格，`tests/fuzz.py` 是裁判：

```bash
python -m venv .venv && . .venv/bin/activate && pip install maturin pytest
maturin develop --release                      # 把 docxtpl._native 构建进 .venv
cargo test && cargo clippy --all-targets -- -D warnings
pytest tests                                   # 依赖私有 DOCX 语料的差异测试
                                               # 在语料缺失时自动跳过
python tests/fuzz.py 500000 7                  # 对 _reference.py 做语法模糊测试
python tests/make_golden.py                    # 修改模糊语法 / 参考实现后重新生成
```

## 基准测试

（私有的）基准框架交替启动两个环境的新进程（AB/BA）；每个进程的第 1 次迭代是
*冷*（缓存为空），之后是*热*。
下面的数字来自 13 代 Core i9 笔记本、WSL2、CPython 3.12.14、lxml 5.3.1；
库层 p50 = 两次变量扫描 + `render()`。模板是私有的生产文档（一份 14.1 万元素的
测试报告、六份原始记录模板、一份封面），不在本仓库中；数字仅供参考。

A = `.venv-baseline`，B = `.venv-candidate`；4 轮 × 每进程 3 次迭代。

| 用例 | 库层 A p50 | B 冷 p50 | B 热 p50 | 冷 B/A | 热 B/A | 热 p95 B/A | 热 CPU B/A | 冷峰值 RSS B/A |
|---|---:|---:|---:|---:|---:|---:|---:|---:|
| remote-report-tpl194 | 13.047 s | 0.829 s | 0.166 s | 0.063 | 0.013 | 0.013 | 0.020 | 0.77 |
| remote-records-tpl177 | 1.213 s | 0.190 s | 0.027 s | 0.156 | 0.022 | 0.023 | 0.050 | 0.74 |
| remote-records-tpl178 | 0.280 s | 0.075 s | 0.009 s | 0.265 | 0.032 | 0.034 | 0.066 | 0.95 |
| remote-records-tpl180 | 1.312 s | 0.188 s | 0.029 s | 0.143 | 0.022 | 0.027 | 0.047 | 0.82 |
| remote-records-tpl181 | 0.383 s | 0.076 s | 0.007 s | 0.192 | 0.019 | 0.019 | 0.037 | 0.81 |
| remote-records-tpl183 | 0.758 s | 0.128 s | 0.019 s | 0.168 | 0.026 | 0.029 | 0.046 | 0.88 |
| remote-records-tpl185 | 0.028 s | 0.013 s | 0.002 s | 0.419 | 0.078 | 0.082 | 0.456 | 0.89 |
| local-cover-tpl185 | 0.029 s | 0.013 s | 0.002 s | 0.411 | 0.073 | 0.077 | 0.446 | 0.89 |
| synthetic-object-protocol | 0.017 s | 0.014 s | 0.003 s | 0.665 | 0.149 | 0.126 | 0.469 | 1.00 |

对照组：报告模板，同样的门面和算法，但关闭 Rust 内核（`DOCXTPL_NATIVE=0`）：
库层冷 2.63 s / 热 1.10 s，开启时 0.83 s / 0.17 s。也就是说，纯 Python 优化之后
剩下的时间里，Rust 内核又去掉了 68% / 85%。

## 构建 wheel

`maturin build --release` 生成本机平台的 wheel。
`.github/workflows/release.yml` 在每个 `v*` tag 上构建完整的 wheel 矩阵和 sdist，
并通过 [trusted publishing](https://docs.pypi.org/trusted-publishers/) 发布到
PyPI（仓库里不存任何 API token）；`ci.yml` 在每次 push 和 pull request 时于
Linux、Windows、macOS 上运行 clippy、`cargo test` 和 pytest。

## 许可证

LGPL-2.1-only，作为 docxtpl 的衍生作品。见 `LICENSE` 与 `NOTICE`。

