Skip to content

About

No description, website, or topics provided.

Resources

Stars

35 stars

Watchers

0 watching

Forks

Latest commit

 

History

23 Commits

Folders and files

Repository files navigation

docx-templater

A Rust library for generating Microsoft Word documents (.docx) from templates with variable placeholders, conditional sections, and loops.

Status: Under Active Development

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.

Implementation Progress

  • ✅ 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)

Features

  • 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 \n in a value is rendered — InsertWordBreak (default, emits <w:br/>) or SplitParagraph (produces separate <w:p> elements inheriting original formatting)
  • Whitespace preservation: preserve_whitespace option (default true) controls whether xml:space="preserve" is added to replacement <w:t> elements with leading/trailing spaces; set to false to 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: RenderError variants 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::Serialize type or serde_json::Value

Planned Features

  • Multi-part processing: Headers, footers, footnotes

Usage

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")?;

Variables

Hello {{user.name}}!
Invoice #{{invoice.number}}
First tag: {{tags.0}}

Conditionals

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.

Loops

Paragraph loops

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.

Table-row loops

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 %}          |

Missing Value Policy

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
    );

Line Break Policy

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);

Whitespace Preservation

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)
    );

What's Built

Phase 1 — Package Foundation

  • Load .docx files from path, reader, or bytes via ZipPackageSource
  • Write .docx files to path, writer, or bytes via ZipPackageWriter
  • 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

Phase 2 — XML Text Normalization

  • TextPlane: reconstructs logical text from fragmented <w:t> nodes
  • Handles split placeholders — {{user. and name}} across separate runs stitched into {{user.name}}
  • XmlNodeId mapping: character offsets back to source XML nodes
  • apply_replacements(): event-replay rewriter that substitutes text in <w:t> nodes while preserving all surrounding XML and formatting
  • xml:space="preserve" applied automatically for leading/trailing whitespace
  • Full XML escaping on all user-supplied text

Phase 3 — Template Syntax Parser

  • AST types: Node (Text, Variable, If, For, Comment), Expression (Path, Not)
  • winnow-based single-pass parser with LocatingSlice for correct source spans
  • Variable paths: {{name}}, {{user.name}}, {{items.0}}, {{.}}
  • Conditional blocks with optional {% else %}; not expression support
  • Loop blocks: {% for item in items %}...{% endfor %}
  • Comment nodes: {# comment #}
  • Nested blocks, syntax errors with spans, insta snapshot tests

Phase 4 — Context and Data Lookup

  • Context wraps serde_json::Value; accepts from_json() or from_serialize()
  • Path resolution through dot-notation and array-index segments
  • Truthiness per spec: falsy values are null, false, 0, "", [], {}
  • TemplateOptions with configurable MissingValuePolicy

Phase 5 — Inline Variable Replacement (MVP)

  • Template<S: PackageSource> with from_path(), from_reader(), from_bytes()
  • Parse-once at load time; AST validated before first render
  • template.render(context) → RenderedDocument
  • RenderedDocument::write_to_file(), as_bytes(), into_bytes()
  • Offset-based RenderPlan keeps the engine layer independent of XML details

Phase 6 — Conditionals and Multi-Paragraph Conditionals

  • {% if %}...{% endif %} evaluation with truthiness checking
  • {% else %} branches; not expressions; nested conditionals
  • Variables inside conditionals evaluated correctly
  • Multi-paragraph: tag paragraphs on their own <w:p> elements are deleted entirely — no blank lines
  • ParagraphBoundary metadata on TextPlane; paragraphs_in_range() query
  • RenderOperation::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

Phase 7 — Paragraph Loops

  • {% 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::Current resolves directly to the loop value
  • shift_ast_spans(): normalises body AST spans to fragment-relative offsets
  • RenderOperation::LoopExpansion: expands before the existing conditional/substitution pipeline
  • extract_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)

Phase 8 — Table-Row Loops and Nested Blocks

  • {% 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 emits RowRemoval; row deleted when false
  • Nested blocks: {% if %} inside loop body cells, paragraph loops inside table cells
  • RowBoundary on TextPlane; rows_in_range() query mirrors paragraphs_in_range() at row level
  • RenderOperation::TableRowLoopExpansion and RowRemoval — sibling variants to the paragraph-level operations
  • extract_rows_xml() / remove_rows() / insert_rows(): streaming XML primitives for row manipulation
  • apply_table_row_loop_expansions() in loop_renderer.rs: per-iteration row fragment rendering
  • render_part Step 0 runs paragraph expansion then row expansion; Step 1 handles row removals before paragraph removals

Phase 10 Hardening — LineBreakPolicy, preserve_whitespace, Decompression Limit, and Panic-Free Code

  • LineBreakPolicy — controls how \n / \r\n in a resolved variable value is rendered:
    • InsertWordBreak (default): emits <w:br w:type="textWrapping"/> between segments within the same paragraph
    • SplitParagraph: replaces the containing <w:p> with N separate paragraphs, each inheriting <w:pPr> and <w:rPr> from the original
  • preserve_whitespace: bool (default true) — when true, xml:space="preserve" is added automatically to replacement <w:t> elements with leading or trailing whitespace; set to false to suppress the attribute and let Word normalise edge whitespace
  • TemplateOptions::line_break_policy() and TemplateOptions::preserve_whitespace() builder methods; both options publicly accessible via TemplateOptions
  • paragraph_splitter.rs — new module in xml/; split_paragraph_at_newlines() performs a single-pass scan to locate the target paragraph, capture its formatting bytes, and reconstruct N <w:p> elements
  • rewriter.rs extended: emit_word_break_sequence() handles the InsertWordBreak multi-segment path in-line during the existing event-replay rewriter pass
  • Bug fixed: apply_one_expansion previously used a fixed iterations × body_paras multiplier to compute the insert offset for template-paragraph removal — SplitParagraph can 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 MiB constant in zip_source.rs; read_part rejects any entry whose declared uncompressed size exceeds the limit before calling read_to_end; PackageError::EntrySizeExceeded { name, size, limit } carries all three values for actionable error messages
  • Panic-free library code: sole non-test .unwrap() in render/inline.rs replaced with XmlError::NodeNotFound { node_id } propagated via ?; XmlError::NodeNotFound variant added to src/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_uint from winnow::ascii is the idiomatic combinator for this
    • nth(1).unwrap() → nth(1).map_or(false, |c| c.is_alphanumeric() || c == '_') — also fixes a latent bug where ._field was misidentified as the current-item . accessor
  • Enriched error context: RenderError::MissingValue, ExpectedIterable, TypeMismatch, and UnsupportedBlockPlacement each gain a part: Option<String> field threaded through from evaluate_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 returns None; ZipPackageSource overrides to return Some(&self.data); ZipPackageWriter::copy_part attempts ZipWriter::raw_copy_file when raw bytes are available, silently falling back to decompress + recompress — no API changes, no breakage for InMemoryPackageSource
  • 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)

Architecture

Four-layer separation of concerns:

  1. Public API — Template, Context, RenderedDocument
  2. Templating Layer — Syntax parsing, AST, expression evaluation
  3. DOCX/XML Layer — XML normalization, placeholder stitching, rewriting
  4. Package Layer — ZIP/OPC reading, validation, writing

Module Structure

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

Development

Prerequisites

Rust 2021 edition or later.

Building

cargo build

Running Tests

cargo test --all-features

Quality Checks

cargo fmt --check
cargo clippy --all-targets --all-features -- -D warnings
cargo doc --no-deps

Documentation

License

Licensed under either of:

at your option.

About

No description, website, or topics provided.

Resources

Stars

35 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages