Skip to main content

rusdox/
template.rs

1//! Word-native DOCX template inspection and rendering.
2//!
3//! Template expansion edits only textual OOXML parts. Unchanged package parts
4//! retain their exact bytes so designer-authored styles, media, relationships,
5//! and unsupported package extensions survive the render.
6
7use std::borrow::Cow;
8use std::collections::BTreeMap;
9use std::fs;
10use std::io::{BufReader, Write};
11use std::path::Path;
12
13use quick_xml::escape::{escape, unescape};
14use serde::{Deserialize, Serialize};
15use serde_json::Value;
16use zip::write::SimpleFileOptions;
17use zip::{CompressionMethod, ZipWriter};
18
19use crate::package_validate::read_docx_parts_with_limits;
20use crate::{DocxError, InputLimits, Result};
21
22/// Current version of the Word-native placeholder contract.
23pub const TEMPLATE_SYNTAX_VERSION: &str = "1";
24
25/// Severity of a template inspection or render diagnostic.
26#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
27#[serde(rename_all = "snake_case")]
28pub enum TemplateDiagnosticSeverity {
29    /// Selects the warning form.
30    Warning,
31    /// Selects the error form.
32    Error,
33}
34
35/// Actionable template diagnostic with an OOXML part and document location.
36#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
37pub struct TemplateDiagnostic {
38    /// Severity.
39    pub severity: TemplateDiagnosticSeverity,
40    /// Part.
41    pub part: String,
42    /// Location.
43    pub location: String,
44    /// Placeholder.
45    pub placeholder: String,
46    /// Message.
47    pub message: String,
48    /// Suggestion.
49    pub suggestion: String,
50}
51
52/// One placeholder discovered in a Word-native template.
53#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
54pub struct TemplatePlaceholder {
55    /// Part.
56    pub part: String,
57    /// Location.
58    pub location: String,
59    /// Expression.
60    pub expression: String,
61    /// Kind.
62    pub kind: String,
63}
64
65/// Machine-readable result of `template inspect`.
66#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
67pub struct TemplateInspection {
68    /// Syntax version.
69    pub syntax_version: String,
70    /// Ordered placeholders.
71    pub placeholders: Vec<TemplatePlaceholder>,
72    /// Ordered diagnostics.
73    pub diagnostics: Vec<TemplateDiagnostic>,
74}
75
76impl TemplateInspection {
77    /// Returns whether the value has errors.
78    pub fn has_errors(&self) -> bool {
79        self.diagnostics
80            .iter()
81            .any(|diagnostic| diagnostic.severity == TemplateDiagnosticSeverity::Error)
82    }
83}
84
85/// Machine-readable result of a Word-native template render.
86#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
87pub struct TemplateRenderReport {
88    /// Syntax version.
89    pub syntax_version: String,
90    /// Output.
91    pub output: String,
92    /// Whether an output package was written.
93    pub written: bool,
94    /// Whether strict missing-value validation was enabled.
95    pub strict: bool,
96    /// Replacements.
97    pub replacements: usize,
98    /// Expanded blocks.
99    pub expanded_blocks: usize,
100    /// Ordered diagnostics.
101    pub diagnostics: Vec<TemplateDiagnostic>,
102}
103
104impl TemplateRenderReport {
105    /// Returns whether the value has errors.
106    pub fn has_errors(&self) -> bool {
107        self.diagnostics
108            .iter()
109            .any(|diagnostic| diagnostic.severity == TemplateDiagnosticSeverity::Error)
110    }
111}
112
113/// A DOCX package whose textual OOXML parts can be expanded from JSON data.
114#[derive(Debug, Clone)]
115pub struct DocxTemplate {
116    parts: BTreeMap<String, Vec<u8>>,
117    limits: InputLimits,
118}
119
120impl DocxTemplate {
121    /// Opens a DOCX template with the default untrusted-input ceilings.
122    pub fn open(path: impl AsRef<Path>) -> Result<Self> {
123        Self::open_with_limits(path, InputLimits::default())
124    }
125
126    /// Opens a DOCX template with explicit untrusted-input ceilings.
127    pub fn open_with_limits(path: impl AsRef<Path>, limits: InputLimits) -> Result<Self> {
128        let file = fs::File::open(path)?;
129        let parts = read_docx_parts_with_limits(BufReader::new(file), limits)?;
130        if !parts.contains_key("word/document.xml") {
131            return Err(DocxError::parse(
132                "DOCX template is missing word/document.xml",
133            ));
134        }
135        Ok(Self { parts, limits })
136    }
137
138    /// Inspects every supported textual part without modifying the template.
139    pub fn inspect(&self) -> TemplateInspection {
140        let mut placeholders = Vec::new();
141        let mut diagnostics = Vec::new();
142        for (part, bytes) in self.template_parts() {
143            let Ok(xml) = std::str::from_utf8(bytes) else {
144                diagnostics.push(diagnostic(
145                    TemplateDiagnosticSeverity::Error,
146                    part,
147                    "part",
148                    "",
149                    "template part is not UTF-8 XML",
150                    "save the DOCX with UTF-8 OOXML parts",
151                ));
152                continue;
153            };
154            let segments = split_segments(xml);
155            inspect_segments(part, &segments, &mut placeholders, &mut diagnostics);
156        }
157        TemplateInspection {
158            syntax_version: TEMPLATE_SYNTAX_VERSION.to_string(),
159            placeholders,
160            diagnostics,
161        }
162    }
163
164    /// Renders JSON data into a new DOCX while preserving untouched package parts.
165    ///
166    /// Missing values become empty strings with warnings by default. With
167    /// `strict = true`, they are errors and no output is written.
168    pub fn render_to_path(
169        &self,
170        data: &Value,
171        output: impl AsRef<Path>,
172        strict: bool,
173    ) -> Result<TemplateRenderReport> {
174        let output = output.as_ref();
175        let mut parts = self.parts.clone();
176        let mut state = RenderState {
177            strict,
178            replacements: 0,
179            expanded_blocks: 0,
180            partial_stack: Vec::new(),
181            diagnostics: Vec::new(),
182            limits: self.limits,
183        };
184        let context = RenderContext {
185            root: data,
186            current: data,
187            index: None,
188        };
189
190        for (part, bytes) in self.template_parts() {
191            let xml = match std::str::from_utf8(bytes) {
192                Ok(xml) => xml,
193                Err(_) => {
194                    state.diagnostics.push(diagnostic(
195                        TemplateDiagnosticSeverity::Error,
196                        part,
197                        "part",
198                        "",
199                        "template part is not UTF-8 XML",
200                        "save the DOCX with UTF-8 OOXML parts",
201                    ));
202                    continue;
203                }
204            };
205            let segments = split_segments(xml);
206            let rendered = join_segments(&expand_segments(part, &segments, context, &mut state));
207            if rendered.len() as u64 > state.limits.max_template_output_xml_bytes {
208                state.diagnostics.push(diagnostic(
209                    TemplateDiagnosticSeverity::Error,
210                    part,
211                    "part",
212                    "",
213                    &format!(
214                        "rendered XML is {} bytes; limit is {} bytes",
215                        rendered.len(),
216                        state.limits.max_template_output_xml_bytes
217                    ),
218                    "reduce repeated data or use a larger trusted limit profile",
219                ));
220                continue;
221            }
222            parts.insert(part.to_string(), rendered.into_bytes());
223        }
224
225        let written = !state
226            .diagnostics
227            .iter()
228            .any(|diagnostic| diagnostic.severity == TemplateDiagnosticSeverity::Error);
229        if written {
230            crate::io_utils::atomic_write_with(output, |file| write_package(file, &parts))?;
231        }
232
233        Ok(TemplateRenderReport {
234            syntax_version: TEMPLATE_SYNTAX_VERSION.to_string(),
235            output: output.display().to_string(),
236            written,
237            strict,
238            replacements: state.replacements,
239            expanded_blocks: state.expanded_blocks,
240            diagnostics: state.diagnostics,
241        })
242    }
243
244    fn template_parts(&self) -> impl Iterator<Item = (&str, &[u8])> {
245        self.parts.iter().filter_map(|(name, bytes)| {
246            is_template_part(name).then_some((name.as_str(), bytes.as_slice()))
247        })
248    }
249}
250
251fn write_package(writer: &mut fs::File, parts: &BTreeMap<String, Vec<u8>>) -> Result<()> {
252    let mut archive = ZipWriter::new(writer);
253    let options = SimpleFileOptions::default().compression_method(CompressionMethod::Deflated);
254    for (name, bytes) in parts {
255        archive.start_file(name, options)?;
256        archive.write_all(bytes)?;
257    }
258    archive.finish()?;
259    Ok(())
260}
261
262fn is_template_part(name: &str) -> bool {
263    name == "word/document.xml"
264        || (name.starts_with("word/header") && name.ends_with(".xml"))
265        || (name.starts_with("word/footer") && name.ends_with(".xml"))
266        || matches!(
267            name,
268            "word/footnotes.xml"
269                | "word/endnotes.xml"
270                | "word/comments.xml"
271                | "word/glossary/document.xml"
272        )
273}
274
275#[derive(Debug, Clone, Copy, PartialEq, Eq)]
276enum SegmentKind {
277    Raw,
278    Paragraph,
279    TableRow,
280}
281
282#[derive(Debug, Clone)]
283struct Segment {
284    kind: SegmentKind,
285    xml: String,
286    location: String,
287}
288
289fn split_segments(xml: &str) -> Vec<Segment> {
290    let mut segments = Vec::new();
291    let mut cursor = 0;
292    let mut paragraph = 0usize;
293    let mut row = 0usize;
294
295    while cursor < xml.len() {
296        let next_row = find_element_start(xml, cursor, "w:tr");
297        let next_paragraph = find_element_start(xml, cursor, "w:p");
298        let next = match (next_row, next_paragraph) {
299            (Some(row), Some(paragraph)) if row <= paragraph => Some((row, SegmentKind::TableRow)),
300            (Some(_), Some(paragraph)) => Some((paragraph, SegmentKind::Paragraph)),
301            (Some(row), None) => Some((row, SegmentKind::TableRow)),
302            (None, Some(paragraph)) => Some((paragraph, SegmentKind::Paragraph)),
303            (None, None) => None,
304        };
305        let Some((start, kind)) = next else {
306            push_raw(&mut segments, &xml[cursor..]);
307            break;
308        };
309        if start > cursor {
310            push_raw(&mut segments, &xml[cursor..start]);
311        }
312        let tag = if kind == SegmentKind::TableRow {
313            "w:tr"
314        } else {
315            "w:p"
316        };
317        let Some(end) = find_element_end(xml, start, tag) else {
318            push_raw(&mut segments, &xml[start..]);
319            break;
320        };
321        let location = match kind {
322            SegmentKind::Paragraph => {
323                paragraph += 1;
324                format!("paragraph {paragraph}")
325            }
326            SegmentKind::TableRow => {
327                row += 1;
328                format!("table row {row}")
329            }
330            SegmentKind::Raw => unreachable!(),
331        };
332        segments.push(Segment {
333            kind,
334            xml: xml[start..end].to_string(),
335            location,
336        });
337        cursor = end;
338    }
339    segments
340}
341
342fn push_raw(segments: &mut Vec<Segment>, xml: &str) {
343    if !xml.is_empty() {
344        segments.push(Segment {
345            kind: SegmentKind::Raw,
346            xml: xml.to_string(),
347            location: "part".to_string(),
348        });
349    }
350}
351
352fn find_element_start(xml: &str, mut cursor: usize, tag: &str) -> Option<usize> {
353    let needle = format!("<{tag}");
354    while let Some(relative) = xml[cursor..].find(&needle) {
355        let start = cursor + relative;
356        let boundary = xml.as_bytes().get(start + needle.len()).copied();
357        if boundary.is_some_and(|value| value == b'>' || value.is_ascii_whitespace()) {
358            return Some(start);
359        }
360        cursor = start + needle.len();
361    }
362    None
363}
364
365fn find_element_end(xml: &str, start: usize, tag: &str) -> Option<usize> {
366    let open_end = xml[start..].find('>')? + start + 1;
367    if xml.as_bytes().get(open_end.saturating_sub(2)) == Some(&b'/') {
368        return Some(open_end);
369    }
370    let close = format!("</{tag}>");
371    let mut depth = 1usize;
372    let mut cursor = open_end;
373    while cursor < xml.len() {
374        let next_open = find_element_start(xml, cursor, tag);
375        let next_close = xml[cursor..].find(&close).map(|value| cursor + value);
376        match (next_open, next_close) {
377            (_, Some(close_start)) if next_open.is_none_or(|open| close_start < open) => {
378                depth -= 1;
379                let end = close_start + close.len();
380                if depth == 0 {
381                    return Some(end);
382                }
383                cursor = end;
384            }
385            (None, Some(close_start)) => {
386                depth -= 1;
387                let end = close_start + close.len();
388                if depth == 0 {
389                    return Some(end);
390                }
391                cursor = end;
392            }
393            (Some(open), _) => {
394                depth += 1;
395                cursor = xml[open..].find('>')? + open + 1;
396            }
397            (None, None) => return None,
398        }
399    }
400    None
401}
402
403fn inspect_segments(
404    part: &str,
405    segments: &[Segment],
406    placeholders: &mut Vec<TemplatePlaceholder>,
407    diagnostics: &mut Vec<TemplateDiagnostic>,
408) {
409    let mut markers = Vec::new();
410    for segment in segments
411        .iter()
412        .filter(|segment| segment.kind != SegmentKind::Raw)
413    {
414        let text = visible_text(&segment.xml).unwrap_or_default();
415        let spans = placeholder_spans(&text);
416        if text.contains("{{") && spans.is_empty() {
417            diagnostics.push(diagnostic(
418                TemplateDiagnosticSeverity::Error,
419                part,
420                &segment.location,
421                text.trim(),
422                "placeholder is not closed with `}}`",
423                "keep the complete placeholder inside one paragraph or table row",
424            ));
425        }
426        for span in spans {
427            let expression = text[span.inner_start..span.inner_end].trim().to_string();
428            let marker = BlockMarker::parse(&expression);
429            let kind = marker
430                .as_ref()
431                .map(BlockMarker::kind)
432                .unwrap_or("value")
433                .to_string();
434            placeholders.push(TemplatePlaceholder {
435                part: part.to_string(),
436                location: segment.location.clone(),
437                expression: expression.clone(),
438                kind,
439            });
440            if let Some(marker) = marker {
441                if text.trim() != format!("{{{{{expression}}}}}") {
442                    diagnostics.push(diagnostic(
443                        TemplateDiagnosticSeverity::Error,
444                        part,
445                        &segment.location,
446                        &expression,
447                        "block marker must occupy a complete paragraph or table row",
448                        "move the marker into its own paragraph or row",
449                    ));
450                }
451                markers.push((marker, segment.location.clone(), expression));
452            }
453        }
454    }
455    validate_marker_stack(part, &markers, diagnostics);
456}
457
458fn validate_marker_stack(
459    part: &str,
460    markers: &[(BlockMarker, String, String)],
461    diagnostics: &mut Vec<TemplateDiagnostic>,
462) {
463    let mut stack = Vec::new();
464    for (marker, location, expression) in markers {
465        match marker {
466            BlockMarker::Each(_) => stack.push(MarkerType::Each),
467            BlockMarker::If(_) => stack.push(MarkerType::If),
468            BlockMarker::Else if stack.last() != Some(&MarkerType::If) => {
469                diagnostics.push(diagnostic(
470                    TemplateDiagnosticSeverity::Error,
471                    part,
472                    location,
473                    expression,
474                    "`else` must be inside an `if` block",
475                    "add a matching `{{#if path}}` before this marker",
476                ))
477            }
478            BlockMarker::Else => {}
479            BlockMarker::CloseEach => close_marker(
480                part,
481                location,
482                expression,
483                MarkerType::Each,
484                &mut stack,
485                diagnostics,
486            ),
487            BlockMarker::CloseIf => close_marker(
488                part,
489                location,
490                expression,
491                MarkerType::If,
492                &mut stack,
493                diagnostics,
494            ),
495        }
496    }
497    for marker in stack.into_iter().rev() {
498        diagnostics.push(diagnostic(
499            TemplateDiagnosticSeverity::Error,
500            part,
501            "part",
502            marker.open_expression(),
503            "block marker is not closed",
504            marker.close_suggestion(),
505        ));
506    }
507}
508
509fn close_marker(
510    part: &str,
511    location: &str,
512    expression: &str,
513    expected: MarkerType,
514    stack: &mut Vec<MarkerType>,
515    diagnostics: &mut Vec<TemplateDiagnostic>,
516) {
517    if stack.pop() != Some(expected) {
518        diagnostics.push(diagnostic(
519            TemplateDiagnosticSeverity::Error,
520            part,
521            location,
522            expression,
523            "closing marker does not match the active block",
524            expected.open_suggestion(),
525        ));
526    }
527}
528
529#[derive(Debug, Clone, PartialEq, Eq)]
530enum BlockMarker {
531    Each(String),
532    If(String),
533    Else,
534    CloseEach,
535    CloseIf,
536}
537
538impl BlockMarker {
539    fn parse(expression: &str) -> Option<Self> {
540        if let Some(path) = expression.strip_prefix("#each ") {
541            Some(Self::Each(path.trim().to_string()))
542        } else if let Some(path) = expression.strip_prefix("#if ") {
543            Some(Self::If(path.trim().to_string()))
544        } else {
545            match expression {
546                "else" => Some(Self::Else),
547                "/each" => Some(Self::CloseEach),
548                "/if" => Some(Self::CloseIf),
549                _ => None,
550            }
551        }
552    }
553
554    fn kind(&self) -> &'static str {
555        match self {
556            Self::Each(_) => "each_start",
557            Self::If(_) => "if_start",
558            Self::Else => "else",
559            Self::CloseEach => "each_end",
560            Self::CloseIf => "if_end",
561        }
562    }
563}
564
565#[derive(Debug, Clone, Copy, PartialEq, Eq)]
566enum MarkerType {
567    Each,
568    If,
569}
570
571impl MarkerType {
572    fn open_expression(self) -> &'static str {
573        match self {
574            Self::Each => "#each",
575            Self::If => "#if",
576        }
577    }
578
579    fn close_suggestion(self) -> &'static str {
580        match self {
581            Self::Each => "add `{{/each}}` in a matching paragraph or row",
582            Self::If => "add `{{/if}}` in a matching paragraph or row",
583        }
584    }
585
586    fn open_suggestion(self) -> &'static str {
587        match self {
588            Self::Each => "add a matching `{{#each path}}` before this marker",
589            Self::If => "add a matching `{{#if path}}` before this marker",
590        }
591    }
592}
593
594#[derive(Clone, Copy)]
595struct RenderContext<'a> {
596    root: &'a Value,
597    current: &'a Value,
598    index: Option<usize>,
599}
600
601struct RenderState {
602    strict: bool,
603    replacements: usize,
604    expanded_blocks: usize,
605    partial_stack: Vec<String>,
606    diagnostics: Vec<TemplateDiagnostic>,
607    limits: InputLimits,
608}
609
610impl RenderState {
611    fn record_expansion(&mut self, part: &str, segment: &Segment, expression: &str) -> bool {
612        if self.replacements.saturating_add(self.expanded_blocks)
613            >= self.limits.max_template_expansions
614        {
615            self.diagnostics.push(diagnostic(
616                TemplateDiagnosticSeverity::Error,
617                part,
618                &segment.location,
619                expression,
620                &format!(
621                    "template expansion limit {} exceeded",
622                    self.limits.max_template_expansions
623                ),
624                "reduce loop data/placeholders or use a larger trusted limit profile",
625            ));
626            false
627        } else {
628            true
629        }
630    }
631}
632
633fn expand_segments(
634    part: &str,
635    segments: &[Segment],
636    context: RenderContext<'_>,
637    state: &mut RenderState,
638) -> Vec<Segment> {
639    let mut output = Vec::new();
640    let mut index = 0usize;
641    while index < segments.len() {
642        let segment = &segments[index];
643        let marker = segment_marker(segment);
644        match marker {
645            Some(BlockMarker::Each(path)) => {
646                let Some(block) = matching_block(segments, index, MarkerType::Each) else {
647                    state.diagnostics.push(diagnostic(
648                        TemplateDiagnosticSeverity::Error,
649                        part,
650                        &segment.location,
651                        &format!("#each {path}"),
652                        "loop block is not closed",
653                        "add `{{/each}}` in a matching paragraph or row",
654                    ));
655                    index += 1;
656                    continue;
657                };
658                match resolve_path(context, &path) {
659                    Some(Value::Array(items)) => {
660                        for (item_index, item) in items.iter().enumerate() {
661                            if !state.record_expansion(part, segment, &format!("#each {path}")) {
662                                break;
663                            }
664                            let child = RenderContext {
665                                root: context.root,
666                                current: item,
667                                index: Some(item_index),
668                            };
669                            output.extend(expand_segments(
670                                part,
671                                &segments[index + 1..block.end],
672                                child,
673                                state,
674                            ));
675                            state.expanded_blocks += 1;
676                        }
677                    }
678                    Some(_) => state.diagnostics.push(diagnostic(
679                        TemplateDiagnosticSeverity::Error,
680                        part,
681                        &segment.location,
682                        &path,
683                        "loop value is not an array",
684                        "provide a JSON array or remove the `#each` block",
685                    )),
686                    None => missing_value(part, segment, &path, state),
687                }
688                index = block.end + 1;
689            }
690            Some(BlockMarker::If(path)) => {
691                let Some(block) = matching_block(segments, index, MarkerType::If) else {
692                    state.diagnostics.push(diagnostic(
693                        TemplateDiagnosticSeverity::Error,
694                        part,
695                        &segment.location,
696                        &format!("#if {path}"),
697                        "condition block is not closed",
698                        "add `{{/if}}` in a matching paragraph or row",
699                    ));
700                    index += 1;
701                    continue;
702                };
703                let condition = match resolve_path(context, &path) {
704                    Some(value) => truthy(value),
705                    None => {
706                        missing_value(part, segment, &path, state);
707                        false
708                    }
709                };
710                let (start, end) = if condition {
711                    (index + 1, block.alternative.unwrap_or(block.end))
712                } else {
713                    (
714                        block.alternative.map_or(block.end, |value| value + 1),
715                        block.end,
716                    )
717                };
718                if state.record_expansion(part, segment, &format!("#if {path}")) {
719                    output.extend(expand_segments(part, &segments[start..end], context, state));
720                    state.expanded_blocks += 1;
721                }
722                index = block.end + 1;
723            }
724            Some(BlockMarker::Else | BlockMarker::CloseEach | BlockMarker::CloseIf) => {
725                state.diagnostics.push(diagnostic(
726                    TemplateDiagnosticSeverity::Error,
727                    part,
728                    &segment.location,
729                    &visible_text(&segment.xml).unwrap_or_default(),
730                    "unexpected block marker",
731                    "check the nesting and matching opening marker",
732                ));
733                index += 1;
734            }
735            None if segment.kind == SegmentKind::Raw => {
736                output.push(segment.clone());
737                index += 1;
738            }
739            None => {
740                let mut rendered = segment.clone();
741                rendered.xml = render_segment(part, segment, context, state);
742                output.push(rendered);
743                index += 1;
744            }
745        }
746    }
747    output
748}
749
750struct MatchingBlock {
751    end: usize,
752    alternative: Option<usize>,
753}
754
755fn matching_block(
756    segments: &[Segment],
757    opening: usize,
758    expected: MarkerType,
759) -> Option<MatchingBlock> {
760    let mut stack = vec![expected];
761    let mut alternative = None;
762    let opening_kind = segments.get(opening)?.kind;
763    for (index, segment) in segments.iter().enumerate().skip(opening + 1) {
764        match segment_marker(segment) {
765            Some(BlockMarker::Each(_)) => stack.push(MarkerType::Each),
766            Some(BlockMarker::If(_)) => stack.push(MarkerType::If),
767            Some(BlockMarker::Else) if stack == [MarkerType::If] => alternative = Some(index),
768            Some(BlockMarker::CloseEach) if stack.last() == Some(&MarkerType::Each) => {
769                stack.pop();
770            }
771            Some(BlockMarker::CloseIf) if stack.last() == Some(&MarkerType::If) => {
772                stack.pop();
773            }
774            _ => {}
775        }
776        if stack.is_empty() {
777            if segment.kind != opening_kind {
778                return None;
779            }
780            return Some(MatchingBlock {
781                end: index,
782                alternative,
783            });
784        }
785    }
786    None
787}
788
789fn segment_marker(segment: &Segment) -> Option<BlockMarker> {
790    if segment.kind == SegmentKind::Raw {
791        return None;
792    }
793    let text = visible_text(&segment.xml).ok()?;
794    let trimmed = text.trim();
795    let expression = trimmed.strip_prefix("{{")?.strip_suffix("}}")?.trim();
796    BlockMarker::parse(expression)
797}
798
799fn render_segment(
800    part: &str,
801    segment: &Segment,
802    context: RenderContext<'_>,
803    state: &mut RenderState,
804) -> String {
805    let Ok(nodes) = text_nodes(&segment.xml) else {
806        state.diagnostics.push(diagnostic(
807            TemplateDiagnosticSeverity::Error,
808            part,
809            &segment.location,
810            "",
811            "text node contains invalid XML escaping",
812            "repair the Word text in this paragraph or row",
813        ));
814        return segment.xml.clone();
815    };
816    let plain = nodes
817        .iter()
818        .map(|node| node.text.as_str())
819        .collect::<String>();
820    let spans = placeholder_spans(&plain);
821    if spans.is_empty() {
822        if plain.contains("{{") {
823            state.diagnostics.push(diagnostic(
824                TemplateDiagnosticSeverity::Error,
825                part,
826                &segment.location,
827                plain.trim(),
828                "placeholder is not closed with `}}`",
829                "close the placeholder or remove the opening braces",
830            ));
831        }
832        return segment.xml.clone();
833    }
834
835    let mut output_nodes = vec![String::new(); nodes.len()];
836    let mut cursor = 0usize;
837    for span in spans {
838        distribute_original(&plain, &nodes, cursor, span.start, &mut output_nodes);
839        let expression = plain[span.inner_start..span.inner_end].trim();
840        if BlockMarker::parse(expression).is_some() {
841            state.diagnostics.push(diagnostic(
842                TemplateDiagnosticSeverity::Error,
843                part,
844                &segment.location,
845                expression,
846                "block marker must occupy a complete paragraph or table row",
847                "move the marker into its own paragraph or row",
848            ));
849        } else {
850            if state.record_expansion(part, segment, expression) {
851                let replacement = evaluate_expression(part, segment, expression, context, state);
852                if let Some(node_index) = node_for_offset(&nodes, span.start) {
853                    output_nodes[node_index].push_str(&replacement);
854                    state.replacements += 1;
855                }
856            }
857        }
858        cursor = span.end;
859    }
860    distribute_original(&plain, &nodes, cursor, plain.len(), &mut output_nodes);
861
862    let mut rendered = segment.xml.clone();
863    for (node, value) in nodes.iter().zip(output_nodes).rev() {
864        let escaped: Cow<'_, str> = escape(&value);
865        rendered.replace_range(node.source_start..node.source_end, &escaped);
866    }
867    rendered
868}
869
870#[derive(Debug)]
871struct TextNode {
872    source_start: usize,
873    source_end: usize,
874    plain_start: usize,
875    plain_end: usize,
876    text: String,
877}
878
879fn text_nodes(xml: &str) -> Result<Vec<TextNode>> {
880    let mut nodes = Vec::new();
881    let mut cursor = 0usize;
882    let mut plain_cursor = 0usize;
883    while let Some(start) = find_element_start(xml, cursor, "w:t") {
884        let content_start = xml[start..]
885            .find('>')
886            .map(|value| start + value + 1)
887            .ok_or_else(|| DocxError::parse("invalid w:t element"))?;
888        if xml.as_bytes().get(content_start.saturating_sub(2)) == Some(&b'/') {
889            cursor = content_start;
890            continue;
891        }
892        let close = "</w:t>";
893        let content_end = xml[content_start..]
894            .find(close)
895            .map(|value| content_start + value)
896            .ok_or_else(|| DocxError::parse("unclosed w:t element"))?;
897        let text = unescape(&xml[content_start..content_end])?.into_owned();
898        let plain_end = plain_cursor + text.len();
899        nodes.push(TextNode {
900            source_start: content_start,
901            source_end: content_end,
902            plain_start: plain_cursor,
903            plain_end,
904            text,
905        });
906        plain_cursor = plain_end;
907        cursor = content_end + close.len();
908    }
909    Ok(nodes)
910}
911
912fn visible_text(xml: &str) -> Result<String> {
913    Ok(text_nodes(xml)?.into_iter().map(|node| node.text).collect())
914}
915
916fn distribute_original(
917    plain: &str,
918    nodes: &[TextNode],
919    start: usize,
920    end: usize,
921    outputs: &mut [String],
922) {
923    if start >= end {
924        return;
925    }
926    for (index, node) in nodes.iter().enumerate() {
927        let overlap_start = start.max(node.plain_start);
928        let overlap_end = end.min(node.plain_end);
929        if overlap_start < overlap_end {
930            outputs[index].push_str(&plain[overlap_start..overlap_end]);
931        }
932    }
933}
934
935fn node_for_offset(nodes: &[TextNode], offset: usize) -> Option<usize> {
936    nodes
937        .iter()
938        .position(|node| node.plain_start <= offset && offset < node.plain_end)
939        .or_else(|| {
940            (!nodes.is_empty() && offset == nodes.last()?.plain_end).then_some(nodes.len() - 1)
941        })
942}
943
944#[derive(Debug)]
945struct PlaceholderSpan {
946    start: usize,
947    end: usize,
948    inner_start: usize,
949    inner_end: usize,
950}
951
952fn placeholder_spans(text: &str) -> Vec<PlaceholderSpan> {
953    let mut spans = Vec::new();
954    let mut cursor = 0usize;
955    while let Some(open) = text[cursor..].find("{{").map(|value| cursor + value) {
956        let Some(close) = text[open + 2..].find("}}").map(|value| open + 2 + value) else {
957            break;
958        };
959        spans.push(PlaceholderSpan {
960            start: open,
961            end: close + 2,
962            inner_start: open + 2,
963            inner_end: close,
964        });
965        cursor = close + 2;
966    }
967    spans
968}
969
970fn evaluate_expression(
971    part: &str,
972    segment: &Segment,
973    expression: &str,
974    context: RenderContext<'_>,
975    state: &mut RenderState,
976) -> String {
977    if let Some(name) = expression.strip_prefix('>') {
978        let name = name.trim();
979        let partial = context
980            .root
981            .get("$partials")
982            .and_then(|partials| partials.get(name))
983            .and_then(Value::as_str)
984            .map(ToString::to_string);
985        let Some(partial) = partial else {
986            missing_value(part, segment, &format!("> {name}"), state);
987            return String::new();
988        };
989        if state.partial_stack.iter().any(|active| active == name) {
990            state.diagnostics.push(diagnostic(
991                TemplateDiagnosticSeverity::Error,
992                part,
993                &segment.location,
994                expression,
995                "recursive partial reference detected",
996                "remove the partial cycle; partial expansion must be finite",
997            ));
998            return String::new();
999        }
1000        if state.partial_stack.len() >= state.limits.max_template_partial_depth {
1001            state.diagnostics.push(diagnostic(
1002                TemplateDiagnosticSeverity::Error,
1003                part,
1004                &segment.location,
1005                expression,
1006                "template partial depth limit exceeded",
1007                "flatten the partial chain or use a larger trusted limit profile",
1008            ));
1009            return String::new();
1010        }
1011        state.partial_stack.push(name.to_string());
1012        let rendered = render_inline_text(part, segment, &partial, context, state);
1013        state.partial_stack.pop();
1014        return rendered;
1015    }
1016
1017    let mut pieces = expression.split('|').map(str::trim);
1018    let path = pieces.next().unwrap_or_default();
1019    let mut value = if path == "@index" {
1020        context.index.map(|index| Value::from(index + 1))
1021    } else {
1022        resolve_path(context, path).cloned()
1023    };
1024    for filter in pieces {
1025        if !is_known_filter(filter) {
1026            state.diagnostics.push(diagnostic(
1027                TemplateDiagnosticSeverity::Error,
1028                part,
1029                &segment.location,
1030                expression,
1031                &format!("unknown template filter `{filter}`"),
1032                "use `upper`, `lower`, `title`, `trim`, or `default(\"text\")`",
1033            ));
1034            return String::new();
1035        }
1036        value = apply_filter(value, filter);
1037    }
1038    match value {
1039        Some(Value::String(value)) => value,
1040        Some(Value::Number(value)) => value.to_string(),
1041        Some(Value::Bool(value)) => value.to_string(),
1042        Some(Value::Null) | None => {
1043            missing_value(part, segment, path, state);
1044            String::new()
1045        }
1046        Some(Value::Array(_) | Value::Object(_)) => {
1047            state.diagnostics.push(diagnostic(
1048                TemplateDiagnosticSeverity::Error,
1049                part,
1050                &segment.location,
1051                expression,
1052                "value is structured and cannot be inserted as text",
1053                "select a nested scalar path or use an `#each` block",
1054            ));
1055            String::new()
1056        }
1057    }
1058}
1059
1060fn render_inline_text(
1061    part: &str,
1062    segment: &Segment,
1063    template: &str,
1064    context: RenderContext<'_>,
1065    state: &mut RenderState,
1066) -> String {
1067    let spans = placeholder_spans(template);
1068    if spans.is_empty() {
1069        return template.to_string();
1070    }
1071    let mut rendered = String::new();
1072    let mut cursor = 0usize;
1073    for span in spans {
1074        rendered.push_str(&template[cursor..span.start]);
1075        let expression = template[span.inner_start..span.inner_end].trim();
1076        if BlockMarker::parse(expression).is_some() {
1077            state.diagnostics.push(diagnostic(
1078                TemplateDiagnosticSeverity::Error,
1079                part,
1080                &segment.location,
1081                expression,
1082                "partials support inline values, not block markers",
1083                "move loops and conditions into complete Word paragraphs or rows",
1084            ));
1085        } else {
1086            if state.record_expansion(part, segment, expression) {
1087                rendered.push_str(&evaluate_expression(
1088                    part, segment, expression, context, state,
1089                ));
1090                state.replacements += 1;
1091            }
1092        }
1093        cursor = span.end;
1094    }
1095    rendered.push_str(&template[cursor..]);
1096    rendered
1097}
1098
1099fn is_known_filter(filter: &str) -> bool {
1100    matches!(filter, "upper" | "lower" | "trim" | "title")
1101        || (filter.starts_with("default(") && filter.ends_with(')'))
1102}
1103
1104fn apply_filter(value: Option<Value>, filter: &str) -> Option<Value> {
1105    if let Some(argument) = filter
1106        .strip_prefix("default(")
1107        .and_then(|value| value.strip_suffix(')'))
1108    {
1109        if value.as_ref().is_none_or(|value| value.is_null()) {
1110            return Some(Value::String(
1111                argument.trim().trim_matches(['\'', '"']).to_string(),
1112            ));
1113        }
1114        return value;
1115    }
1116    let text = match value {
1117        Some(Value::String(value)) => value,
1118        Some(Value::Number(value)) => value.to_string(),
1119        Some(Value::Bool(value)) => value.to_string(),
1120        other => return other,
1121    };
1122    Some(Value::String(match filter {
1123        "upper" => text.to_uppercase(),
1124        "lower" => text.to_lowercase(),
1125        "trim" => text.trim().to_string(),
1126        "title" => text
1127            .split_whitespace()
1128            .map(|word| {
1129                let mut chars = word.chars();
1130                chars
1131                    .next()
1132                    .map(|first| first.to_uppercase().collect::<String>() + chars.as_str())
1133                    .unwrap_or_default()
1134            })
1135            .collect::<Vec<_>>()
1136            .join(" "),
1137        _ => text,
1138    }))
1139}
1140
1141fn resolve_path<'a>(context: RenderContext<'a>, path: &str) -> Option<&'a Value> {
1142    if path == "this" || path == "." {
1143        return Some(context.current);
1144    }
1145    resolve_from(context.current, path).or_else(|| resolve_from(context.root, path))
1146}
1147
1148fn resolve_from<'a>(value: &'a Value, path: &str) -> Option<&'a Value> {
1149    path.split('.')
1150        .filter(|segment| !segment.is_empty())
1151        .try_fold(value, |current, segment| match current {
1152            Value::Object(map) => map.get(segment),
1153            Value::Array(items) => segment
1154                .parse::<usize>()
1155                .ok()
1156                .and_then(|index| items.get(index)),
1157            _ => None,
1158        })
1159}
1160
1161fn truthy(value: &Value) -> bool {
1162    match value {
1163        Value::Null => false,
1164        Value::Bool(value) => *value,
1165        Value::Number(value) => value.as_f64().is_some_and(|value| value != 0.0),
1166        Value::String(value) => !value.is_empty(),
1167        Value::Array(value) => !value.is_empty(),
1168        Value::Object(value) => !value.is_empty(),
1169    }
1170}
1171
1172fn missing_value(part: &str, segment: &Segment, path: &str, state: &mut RenderState) {
1173    state.diagnostics.push(diagnostic(
1174        if state.strict {
1175            TemplateDiagnosticSeverity::Error
1176        } else {
1177            TemplateDiagnosticSeverity::Warning
1178        },
1179        part,
1180        &segment.location,
1181        path,
1182        "value is missing or null",
1183        "provide the JSON value, use `default(\"text\")`, or disable strict mode",
1184    ));
1185}
1186
1187fn join_segments(segments: &[Segment]) -> String {
1188    segments
1189        .iter()
1190        .map(|segment| segment.xml.as_str())
1191        .collect()
1192}
1193
1194fn diagnostic(
1195    severity: TemplateDiagnosticSeverity,
1196    part: &str,
1197    location: &str,
1198    placeholder: &str,
1199    message: &str,
1200    suggestion: &str,
1201) -> TemplateDiagnostic {
1202    TemplateDiagnostic {
1203        severity,
1204        part: part.to_string(),
1205        location: location.to_string(),
1206        placeholder: placeholder.to_string(),
1207        message: message.to_string(),
1208        suggestion: suggestion.to_string(),
1209    }
1210}
1211
1212#[cfg(test)]
1213mod tests {
1214    use super::{
1215        apply_filter, placeholder_spans, render_segment, split_segments, visible_text,
1216        RenderContext, RenderState,
1217    };
1218    use crate::InputLimits;
1219    use serde_json::json;
1220
1221    #[test]
1222    fn placeholders_can_span_word_text_runs() {
1223        let xml =
1224            r#"<w:p><w:r><w:t>Hello {{ custo</w:t></w:r><w:r><w:t>mer.name }}</w:t></w:r></w:p>"#;
1225        let segments = split_segments(xml);
1226        assert_eq!(segments.len(), 1);
1227        let text = visible_text(&segments[0].xml).expect("visible text");
1228        let spans = placeholder_spans(&text);
1229        assert_eq!(spans.len(), 1);
1230        assert_eq!(
1231            &text[spans[0].inner_start..spans[0].inner_end],
1232            " customer.name "
1233        );
1234    }
1235
1236    #[test]
1237    fn default_and_case_filters_are_deterministic() {
1238        assert_eq!(
1239            apply_filter(None, "default(\"unknown\")"),
1240            Some(json!("unknown"))
1241        );
1242        assert_eq!(
1243            apply_filter(Some(json!("hello")), "upper"),
1244            Some(json!("HELLO"))
1245        );
1246    }
1247
1248    #[test]
1249    fn rendering_across_runs_preserves_surrounding_run_nodes() {
1250        let xml = r#"<w:p><w:r><w:rPr><w:b/></w:rPr><w:t>Hello {{ custo</w:t></w:r><w:r><w:rPr><w:i/></w:rPr><w:t>mer.name }}!</w:t></w:r></w:p>"#;
1251        let segment = split_segments(xml).remove(0);
1252        let data = json!({"customer": {"name": "Ada"}});
1253        let mut state = RenderState {
1254            strict: true,
1255            replacements: 0,
1256            expanded_blocks: 0,
1257            partial_stack: Vec::new(),
1258            diagnostics: Vec::new(),
1259            limits: InputLimits::default(),
1260        };
1261        let rendered = render_segment(
1262            "word/document.xml",
1263            &segment,
1264            RenderContext {
1265                root: &data,
1266                current: &data,
1267                index: None,
1268            },
1269            &mut state,
1270        );
1271        assert_eq!(visible_text(&rendered).expect("visible text"), "Hello Ada!");
1272        assert!(rendered.contains("<w:b/>"));
1273        assert!(rendered.contains("<w:i/>"));
1274        assert_eq!(state.replacements, 1);
1275        assert!(state.diagnostics.is_empty());
1276    }
1277
1278    #[test]
1279    fn template_expansion_budget_fails_closed() {
1280        let segment =
1281            split_segments(r#"<w:p><w:r><w:t>{{ first }} {{ second }}</w:t></w:r></w:p>"#)
1282                .remove(0);
1283        let data = json!({"first": "one", "second": "two"});
1284        let mut state = RenderState {
1285            strict: true,
1286            replacements: 0,
1287            expanded_blocks: 0,
1288            partial_stack: Vec::new(),
1289            diagnostics: Vec::new(),
1290            limits: InputLimits {
1291                max_template_expansions: 1,
1292                ..InputLimits::default()
1293            },
1294        };
1295        let _ = render_segment(
1296            "word/document.xml",
1297            &segment,
1298            RenderContext {
1299                root: &data,
1300                current: &data,
1301                index: None,
1302            },
1303            &mut state,
1304        );
1305        assert_eq!(state.replacements, 1);
1306        assert!(state
1307            .diagnostics
1308            .iter()
1309            .any(|diagnostic| diagnostic.message.contains("expansion limit")));
1310    }
1311}