1use 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
22pub const TEMPLATE_SYNTAX_VERSION: &str = "1";
24
25#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
27#[serde(rename_all = "snake_case")]
28pub enum TemplateDiagnosticSeverity {
29 Warning,
31 Error,
33}
34
35#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
37pub struct TemplateDiagnostic {
38 pub severity: TemplateDiagnosticSeverity,
40 pub part: String,
42 pub location: String,
44 pub placeholder: String,
46 pub message: String,
48 pub suggestion: String,
50}
51
52#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
54pub struct TemplatePlaceholder {
55 pub part: String,
57 pub location: String,
59 pub expression: String,
61 pub kind: String,
63}
64
65#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
67pub struct TemplateInspection {
68 pub syntax_version: String,
70 pub placeholders: Vec<TemplatePlaceholder>,
72 pub diagnostics: Vec<TemplateDiagnostic>,
74}
75
76impl TemplateInspection {
77 pub fn has_errors(&self) -> bool {
79 self.diagnostics
80 .iter()
81 .any(|diagnostic| diagnostic.severity == TemplateDiagnosticSeverity::Error)
82 }
83}
84
85#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
87pub struct TemplateRenderReport {
88 pub syntax_version: String,
90 pub output: String,
92 pub written: bool,
94 pub strict: bool,
96 pub replacements: usize,
98 pub expanded_blocks: usize,
100 pub diagnostics: Vec<TemplateDiagnostic>,
102}
103
104impl TemplateRenderReport {
105 pub fn has_errors(&self) -> bool {
107 self.diagnostics
108 .iter()
109 .any(|diagnostic| diagnostic.severity == TemplateDiagnosticSeverity::Error)
110 }
111}
112
113#[derive(Debug, Clone)]
115pub struct DocxTemplate {
116 parts: BTreeMap<String, Vec<u8>>,
117 limits: InputLimits,
118}
119
120impl DocxTemplate {
121 pub fn open(path: impl AsRef<Path>) -> Result<Self> {
123 Self::open_with_limits(path, InputLimits::default())
124 }
125
126 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 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 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}