A Rust library for generating Microsoft Word documents (.docx) from templates with variable placeholders, conditional sections, and loops.
Current Version: 0.8.0 (Phase 10 complete — Hardening and Performance)
This library is in early development. The API is not yet stable and features are being implemented according to the specification.
- ✅ Phase 0: Engineering Baseline
- ✅ Phase 1: Package Foundation (ZIP/OPC handling)
- ✅ Phase 2: XML Text Normalization
- ✅ Phase 3: Template Syntax Parser
- ✅ Phase 4: Context and Data Lookup
- ✅ Phase 5: Inline Variable Replacement
- ✅ Phase 6: Conditionals
- ✅ Phase 7: Paragraph Loops
- ✅ Phase 8: Table Loops and Nested Blocks
- ✅ Phase 10 (hardening): Decompression-bomb guard, panic-free library code, parser hardening, enriched error context, raw-copy optimization,
LineBreakPolicy,preserve_whitespace, proptest coverage - ⬜ Phase 9: Multi-Part Support (headers, footers, footnotes)
- Variable substitution:
{{user.name}},{{items.0}},{{.}}(current item in a loop) - Conditionals:
{% if condition %} ... {% else %} ... {% endif %} - Loops:
{% for item in items %} ... {% endfor %}— paragraphs and table rows - Table-row loops: loop markers inside a
<w:tr>duplicate the entire row per element - Multi-paragraph/row blocks: tag lines on their own paragraphs or rows are deleted cleanly with no empty lines or rows left behind
- Conditional rows:
{% if %}aligned with a table row removes the row when false - Nested blocks: conditionals inside loop body cells, loops inside conditionals
- Line break policy: control how
\nin a value is rendered —InsertWordBreak(default, emits<w:br/>) orSplitParagraph(produces separate<w:p>elements inheriting original formatting) - Whitespace preservation:
preserve_whitespaceoption (defaulttrue) controls whetherxml:space="preserve"is added to replacement<w:t>elements with leading/trailing spaces; set tofalseto let Word normalise edge whitespace itself - Safe XML escaping: All user text automatically escaped
- Decompression-bomb protection: ZIP entries with declared uncompressed size > 100 MiB are rejected before decompression begins (
PackageError::EntrySizeExceeded) - Actionable error context:
RenderErrorvariants include the DOCX part name (e.g.word/document.xml) so errors pinpoint exactly where template evaluation failed - Raw-copy passthrough: unchanged ZIP parts are copied verbatim without decompress/recompress when the source exposes its raw archive bytes — transparent to all callers
- Split placeholder handling: Works even when a placeholder spans multiple XML runs
- Flexible context: Accepts any
serde::Serializetype orserde_json::Value
- Multi-part processing: Headers, footers, footnotes
use docx_templater::Template;
use serde_json::json;
let output = Template::from_path("template.docx")?
.render(json!({
"user": { "name": "Ada Lovelace" },
"items": [
{ "sku": "KB-01", "qty": 1 },
{ "sku": "MS-02", "qty": 2 }
]
}))?;
output.write_to_file("output.docx")?;Hello {{user.name}}!
Invoice #{{invoice.number}}
First tag: {{tags.0}}
Block tags are removed from the output; only the chosen branch content remains.
Inline (tag and content in the same paragraph):
{% if user.active %}Welcome back, {{user.name}}!{% endif %}
{% if user.vip %}VIP benefits apply.{% else %}Standard access.{% endif %}
{% if not user.active %}Your account is inactive.{% endif %}
Multi-paragraph (each tag on its own line, as Word naturally produces):
{% if user.active %}
Welcome back, {{user.name}}!
{% endif %}
When the condition is true, the tag paragraphs are removed and the body paragraph is kept with variables substituted. When false, all paragraphs in the block are removed — no empty lines are left behind.
Iterate over an array. Tag paragraphs are removed; the body paragraph is duplicated once per element.
Object array:
{% for item in items %}
{{item.name}} — qty {{item.qty}}
{% endfor %}
Primitive array (use {{.}} for the current element):
{% for tag in tags %}
{{.}}
{% endfor %}
An empty or missing array removes all loop paragraphs without error. A non-array value returns RenderError::ExpectedIterable.
Place {% for %} and {% endfor %} inside a table to duplicate rows instead of paragraphs.
Multi-row range — each marker in its own row, body rows between them:
| {% for item in items %} | |
| {{item.sku}} | {{item.qty}} |
| {% endfor %} | |
Renders to one data row per element; tag rows are removed.
Same-row — all markers inside a single row:
| {% for item in items %} {{item.sku}} {% endfor %} |
The whole row is cloned once per element; tag text is stripped from each copy.
Conditional rows — {% if %} whose block aligns with a complete row removes or keeps that row:
| {% if item.active %} |
| {{item.name}} |
| {% endif %} |
Control what happens when a variable path is not found in the context:
use docx_templater::{Template, TemplateOptions, MissingValuePolicy};
let template = Template::from_path("template.docx")?
.with_options(
TemplateOptions::default()
.missing_value_policy(MissingValuePolicy::Empty)
// also: MissingValuePolicy::Error (default), MissingValuePolicy::KeepPlaceholder
);Control how \n (or \r\n) characters in a resolved variable value are rendered. DOCX ignores literal newlines inside <w:t> elements, so the library converts them:
use docx_templater::{Template, TemplateOptions, LineBreakPolicy};
// InsertWordBreak (default): emits <w:br w:type="textWrapping"/> between segments,
// keeping everything in the same paragraph.
let template = Template::from_path("template.docx")?
.with_options(
TemplateOptions::default()
.line_break_policy(LineBreakPolicy::InsertWordBreak)
);
// SplitParagraph: splits the containing <w:p> into N separate paragraphs,
// each inheriting the original paragraph and run formatting.
let template = Template::from_path("template.docx")?
.with_options(
TemplateOptions::default()
.line_break_policy(LineBreakPolicy::SplitParagraph)
);Both policies can be combined with MissingValuePolicy:
use docx_templater::{TemplateOptions, MissingValuePolicy, LineBreakPolicy};
let options = TemplateOptions::default()
.missing_value_policy(MissingValuePolicy::Empty)
.line_break_policy(LineBreakPolicy::SplitParagraph);By default the library emits xml:space="preserve" on any <w:t> element whose replacement value has leading or trailing spaces, preventing Word from silently stripping them. Set preserve_whitespace(false) to suppress this attribute and let Word apply its own normalisation:
use docx_templater::{Template, TemplateOptions};
// Default: preserve edge whitespace in replacement values
let template = Template::from_path("template.docx")?
.with_options(TemplateOptions::default()); // preserve_whitespace defaults to true
// Opt out: let Word normalise whitespace
let template = Template::from_path("template.docx")?
.with_options(
TemplateOptions::default()
.preserve_whitespace(false)
);- Load
.docxfiles from path, reader, or bytes viaZipPackageSource - Write
.docxfiles to path, writer, or bytes viaZipPackageWriter - In-memory package source (
InMemoryPackageSource) for testing - DOCX structural validation (required parts:
[Content_Types].xml,_rels/.rels,word/document.xml) - Path traversal protection on all ZIP entry names
TextPlane: reconstructs logical text from fragmented<w:t>nodes- Handles split placeholders —
{{user.andname}}across separate runs stitched into{{user.name}} XmlNodeIdmapping: character offsets back to source XML nodesapply_replacements(): event-replay rewriter that substitutes text in<w:t>nodes while preserving all surrounding XML and formattingxml:space="preserve"applied automatically for leading/trailing whitespace- Full XML escaping on all user-supplied text
- AST types:
Node(Text, Variable, If, For, Comment),Expression(Path, Not) - winnow-based single-pass parser with
LocatingSlicefor correct source spans - Variable paths:
{{name}},{{user.name}},{{items.0}},{{.}} - Conditional blocks with optional
{% else %};notexpression support - Loop blocks:
{% for item in items %}...{% endfor %} - Comment nodes:
{# comment #} - Nested blocks, syntax errors with spans, insta snapshot tests
Contextwrapsserde_json::Value; acceptsfrom_json()orfrom_serialize()- Path resolution through dot-notation and array-index segments
- Truthiness per spec: falsy values are
null,false,0,"",[],{} TemplateOptionswith configurableMissingValuePolicy
Template<S: PackageSource>withfrom_path(),from_reader(),from_bytes()- Parse-once at load time; AST validated before first render
template.render(context)→RenderedDocumentRenderedDocument::write_to_file(),as_bytes(),into_bytes()- Offset-based
RenderPlankeeps the engine layer independent of XML details
{% if %}...{% endif %}evaluation with truthiness checking{% else %}branches;notexpressions; nested conditionals- Variables inside conditionals evaluated correctly
- Multi-paragraph: tag paragraphs on their own
<w:p>elements are deleted entirely — no blank lines ParagraphBoundarymetadata onTextPlane;paragraphs_in_range()queryRenderOperation::ParagraphRemoval: streaming XML pass deletes<w:p>by index with nested-depth tracking- Unified offset adjustment: paragraph and block removals combined before variable offset recalculation
{% for item in items %}...{% endfor %}iteration over paragraph-level body paragraphs- Object arrays:
{{item.field}}resolves in per-iteration scope - Primitive arrays:
{{.}}resolves to the current element - Empty / missing / null array: all loop paragraphs removed, no error
- Non-array value: returns
RenderError::ExpectedIterable - Multiple variables in the same body paragraph work correctly
- Surrounding paragraphs (header / footer) preserved in document order
EvalScope: per-iteration scope that checks the loop item name before the outer context;PathSegment::Currentresolves directly to the loop valueshift_ast_spans(): normalises body AST spans to fragment-relative offsetsRenderOperation::LoopExpansion: expands before the existing conditional/substitution pipelineextract_paragraphs_xml()/insert_paragraphs(): streaming XML primitives for body cloning- Bug fixed in
inline.rs: multiple replacements targeting the same<w:t>node now applied correctly (reverse-offset order against original text)
{% for %}...{% endfor %}inside a<w:tr>duplicates the row once per array element- Multi-row range: tag rows + body rows + endfor row; body rows cloned, tag rows removed
- Same-row case: all markers inside one row; whole row cloned, tag text stripped per copy
- Conditional rows:
{% if %}aligned with complete rows emitsRowRemoval; row deleted when false - Nested blocks:
{% if %}inside loop body cells, paragraph loops inside table cells RowBoundaryonTextPlane;rows_in_range()query mirrorsparagraphs_in_range()at row levelRenderOperation::TableRowLoopExpansionandRowRemoval— sibling variants to the paragraph-level operationsextract_rows_xml()/remove_rows()/insert_rows(): streaming XML primitives for row manipulationapply_table_row_loop_expansions()inloop_renderer.rs: per-iteration row fragment renderingrender_partStep 0 runs paragraph expansion then row expansion; Step 1 handles row removals before paragraph removals
LineBreakPolicy— controls how\n/\r\nin a resolved variable value is rendered:InsertWordBreak(default): emits<w:br w:type="textWrapping"/>between segments within the same paragraphSplitParagraph: replaces the containing<w:p>with N separate paragraphs, each inheriting<w:pPr>and<w:rPr>from the original
preserve_whitespace: bool(defaulttrue) — whentrue,xml:space="preserve"is added automatically to replacement<w:t>elements with leading or trailing whitespace; set tofalseto suppress the attribute and let Word normalise edge whitespaceTemplateOptions::line_break_policy()andTemplateOptions::preserve_whitespace()builder methods; both options publicly accessible viaTemplateOptionsparagraph_splitter.rs— new module inxml/;split_paragraph_at_newlines()performs a single-pass scan to locate the target paragraph, capture its formatting bytes, and reconstruct N<w:p>elementsrewriter.rsextended:emit_word_break_sequence()handles theInsertWordBreakmulti-segment path in-line during the existing event-replay rewriter pass- Bug fixed:
apply_one_expansionpreviously used a fixediterations × body_parasmultiplier to compute the insert offset for template-paragraph removal —SplitParagraphcan produce more paragraphs per iteration, so the multiplier was replaced with a real paragraph count from the rendered output - Decompression-bomb protection:
MAX_ENTRY_BYTES = 100 MiBconstant inzip_source.rs;read_partrejects any entry whose declared uncompressed size exceeds the limit before callingread_to_end;PackageError::EntrySizeExceeded { name, size, limit }carries all three values for actionable error messages - Panic-free library code: sole non-test
.unwrap()inrender/inline.rsreplaced withXmlError::NodeNotFound { node_id }propagated via?;XmlError::NodeNotFoundvariant added tosrc/xml/mod.rs - Parser hardening in
src/syntax/parser.rs:digit1.map(|s| s.parse().unwrap())→dec_uint.map(PathSegment::Index)— integer overflow now fails the parse cleanly;dec_uintfromwinnow::asciiis the idiomatic combinator for thisnth(1).unwrap()→nth(1).map_or(false, |c| c.is_alphanumeric() || c == '_')— also fixes a latent bug where._fieldwas misidentified as the current-item.accessor
- Enriched error context:
RenderError::MissingValue,ExpectedIterable,TypeMismatch, andUnsupportedBlockPlacementeach gain apart: Option<String>field threaded through fromevaluate_to_plan; error messages now include the DOCX part name when available (e.g.missing value for placeholder 'name' in word/document.xml) - Raw-copy optimization:
PackageSource::raw_archive_bytes()default trait method returnsNone;ZipPackageSourceoverrides to returnSome(&self.data);ZipPackageWriter::copy_partattemptsZipWriter::raw_copy_filewhen raw bytes are available, silently falling back to decompress + recompress — no API changes, no breakage forInMemoryPackageSource - Property-based fuzz coverage (
tests/property_tests.rs, 6 proptest cases): parser, XML text-plane, ZIP loader, and path resolver all verified to never panic on arbitrary inputs (100+ cases each)
Four-layer separation of concerns:
- Public API —
Template,Context,RenderedDocument - Templating Layer — Syntax parsing, AST, expression evaluation
- DOCX/XML Layer — XML normalization, placeholder stitching, rewriting
- Package Layer — ZIP/OPC reading, validation, writing
src/
├── lib.rs # Public re-exports, crate docs
├── error.rs # Top-level error types
├── options.rs # TemplateOptions, MissingValuePolicy, LineBreakPolicy,
│ # preserve_whitespace
├── rendered.rs # RenderedDocument output wrapper
├── template.rs # Template<S> orchestration and render()
├── context.rs # Context and path resolution
├── package/ # ZIP/OPC package handling
│ ├── mod.rs # PackageError enum (MissingRequiredPart, UnsupportedEntry,
│ │ # EntrySizeExceeded, MalformedArchive)
│ ├── source.rs # PackageSource trait
│ ├── writer.rs # PackageWriter trait
│ ├── zip_source.rs # ZipPackageSource; MAX_ENTRY_BYTES = 100 MiB limit
│ ├── zip_writer.rs # ZipPackageWriter
│ ├── memory_source.rs # InMemoryPackageSource
│ └── validation.rs # Path traversal guards
├── docx/ # DOCX-specific knowledge
│ ├── parts.rs # Part name constants and matchers
│ └── validate.rs # Structural validation
├── xml/ # XML processing
│ ├── names.rs # Word XML namespace constants
│ ├── escape.rs # XML escaping utilities
│ ├── text_plane.rs # TextPlane, TextFragment, ParagraphBoundary,
│ │ # RowBoundary, paragraphs_in_range, rows_in_range
│ ├── rewriter.rs # apply_replacements() event-replay rewriter;
│ │ # InsertWordBreak newline handling
│ ├── paragraph_splitter.rs # split_paragraph_at_newlines() for SplitParagraph
│ └── normalize.rs # Whitespace normalization stub
├── syntax/ # Template syntax parser
│ ├── ast.rs # AST node definitions
│ ├── span.rs # Source location tracking
│ ├── parser.rs # winnow parser
│ └── snapshots/ # insta snapshot tests
├── engine/ # Template evaluation
│ ├── path.rs # Path resolution through JSON
│ ├── truthiness.rs # Boolean coercion for conditionals
│ ├── conversion.rs # Value-to-string formatting
│ ├── evaluator.rs # AST → RenderPlan (EvalScope, shift_ast_spans,
│ │ # resolve_loop_row_indices)
│ └── plan.rs # RenderPlan, RenderOperation (TextReplacement,
│ # BlockRemoval, ParagraphRemoval, RowRemoval,
│ # LoopExpansion, TableRowLoopExpansion)
└── render/ # XML mutation
├── inline.rs # apply_text_replacements() variable substitution
├── block.rs # remove_paragraph_blocks(), remove_paragraphs(),
│ # extract_paragraphs_xml(), insert_paragraphs(),
│ # remove_rows(), extract_rows_xml(), insert_rows()
├── loop_renderer.rs # apply_loop_expansions(),
│ # apply_table_row_loop_expansions()
└── part_renderer.rs # render_part() four-step pipeline
Rust 2021 edition or later.
cargo buildcargo test --all-featurescargo fmt --check
cargo clippy --all-targets --all-features -- -D warnings
cargo doc --no-deps- SPECIFICATION.md — Complete product specification with user stories and business rules
- CHANGELOG.md — Version history
Licensed under either of:
- Apache License, Version 2.0 (LICENSE-APACHE or http://www.apache.org/licenses/LICENSE-2.0)
- MIT license (LICENSE-MIT or http://opensource.org/licenses/MIT)
at your option.