diff --git a/DocxTemplater.Test/SubTemplateRawInsertTest.cs b/DocxTemplater.Test/SubTemplateRawInsertTest.cs new file mode 100644 index 0000000..d56834b --- /dev/null +++ b/DocxTemplater.Test/SubTemplateRawInsertTest.cs @@ -0,0 +1,105 @@ +using DocumentFormat.OpenXml; +using DocumentFormat.OpenXml.Packaging; +using DocumentFormat.OpenXml.Wordprocessing; +using A = DocumentFormat.OpenXml.Drawing; +using DW = DocumentFormat.OpenXml.Drawing.Wordprocessing; +using PIC = DocumentFormat.OpenXml.Drawing.Pictures; + +namespace DocxTemplater.Test +{ + [TestFixture] + public class SubTemplateRawInsertTest + { + // The 'raw' argument inserts a sub-document verbatim, WITHOUT running the template engine over it. + // This is for embedding user-authored / untrusted documents whose text may legitimately contain the + // template syntax ({{ }}, {{#..}} loops) - e.g. code samples or template-injection findings in a + // security report - which must survive as literal text rather than be interpreted (and removed). + [Test] + public void RawInsert_PreservesTemplateSyntaxVerbatim_AndImage() + { + var imageBytes = File.ReadAllBytes("Resources/testImage.jpg"); + + using var subDocStream = new MemoryStream(); + using (var subDocument = WordprocessingDocument.Create(subDocStream, WordprocessingDocumentType.Document)) + { + var subMainPart = subDocument.AddMainDocumentPart(); + subMainPart.Document = new Document(new Body()); + var imagePart = subMainPart.AddImagePart(ImagePartType.Jpeg); + using (var imageStream = new MemoryStream(imageBytes)) + { + imagePart.FeedData(imageStream); + } + var relationshipId = subMainPart.GetIdOfPart(imagePart); + subMainPart.Document.Body.Append( + new Paragraph(new Run(new Text("Literal scalar {{ds.Name}} and loop {{#each users}}{{.name}}{{/each}} must survive.") { Space = SpaceProcessingModeValues.Preserve })), + new Paragraph(new Run(CreateInlineImage(relationshipId, 990000L, 792000L)))); + subMainPart.Document.Save(); + } + + using var memStream = new MemoryStream(); + using (var wpDocument = WordprocessingDocument.Create(memStream, WordprocessingDocumentType.Document)) + { + var mainPart = wpDocument.AddMainDocumentPart(); + mainPart.Document = new Document(new Body( + new Paragraph(new Run(new Text("{{ds}:template('ds.SubDocument','raw')}"))))); + } + memStream.Position = 0; + + var docTemplate = new DocxTemplate(memStream, new ProcessSettings + { + // Even under the most aggressive error handling, raw content must not be touched. + BindingErrorHandling = BindingErrorHandling.SkipBindingAndRemoveContent + }); + // Note: "Name" and "users" are deliberately NOT bound - if the sub-document were processed they + // would be treated as unbound and removed. Under 'raw' they must survive verbatim. + docTemplate.BindModel("ds", new { SubDocument = subDocStream.ToArray() }); + var result = docTemplate.Process(); + docTemplate.Validate(); + Assert.That(result, Is.Not.Null); + result.Position = 0; + + using var document = WordprocessingDocument.Open(result, false); + var mainDocumentPart = document.MainDocumentPart; + var body = mainDocumentPart.Document.Body; + + // The template syntax inside the inserted document survived as literal text. + Assert.That(body.InnerText, Does.Contain("{{ds.Name}}")); + Assert.That(body.InnerText, Does.Contain("{{#each users}}")); + Assert.That(body.InnerText, Does.Contain("{{/each}}")); + + // The embedded image is still preserved and resolvable in the raw insert. + var blip = body.Descendants().SingleOrDefault(); + Assert.That(blip, Is.Not.Null, "the inserted image should be present in the raw-merged document"); + Assert.That(blip.Embed?.Value, Is.Not.Null.And.Not.Empty); + Assert.That(mainDocumentPart.GetPartById(blip.Embed.Value), Is.InstanceOf()); + } + + private static Drawing CreateInlineImage(string relationshipId, long cx, long cy) + { + var picture = new PIC.Picture( + new PIC.NonVisualPictureProperties( + new PIC.NonVisualDrawingProperties { Id = 0U, Name = "image.jpg" }, + new PIC.NonVisualPictureDrawingProperties()), + new PIC.BlipFill( + new A.Blip { Embed = relationshipId }, + new A.Stretch(new A.FillRectangle())), + new PIC.ShapeProperties( + new A.Transform2D( + new A.Offset { X = 0L, Y = 0L }, + new A.Extents { Cx = cx, Cy = cy }), + new A.PresetGeometry(new A.AdjustValueList()) { Preset = A.ShapeTypeValues.Rectangle })); + + var inline = new DW.Inline( + new DW.Extent { Cx = cx, Cy = cy }, + new DW.DocProperties { Id = 1U, Name = "Picture 1" }, + new A.Graphic(new A.GraphicData(picture) { Uri = "http://schemas.openxmlformats.org/drawingml/2006/picture" })) + { + DistanceFromTop = 0U, + DistanceFromBottom = 0U, + DistanceFromLeft = 0U, + DistanceFromRight = 0U + }; + return new Drawing(inline); + } + } +} diff --git a/DocxTemplater/Formatter/SubTemplateFormatter.cs b/DocxTemplater/Formatter/SubTemplateFormatter.cs index f2fa98e..5701fba 100644 --- a/DocxTemplater/Formatter/SubTemplateFormatter.cs +++ b/DocxTemplater/Formatter/SubTemplateFormatter.cs @@ -45,9 +45,31 @@ public void ApplyFormat(ITemplateProcessingContext templateContext, FormatterCon // after which target.GetRoot() can no longer reach the owning part. var targetPart = (target.GetRoot() as OpenXmlPartRootElement)?.OpenXmlPart; - if (formatterContext.Args.Length > 1) + // Optional args after the template name: a selector (p/run/tr/tc) and/or "raw". + // "raw" inserts the sub-document verbatim, WITHOUT running the template engine over it - for + // embedding user-authored / untrusted documents whose text may legitimately contain {{ }} that + // must survive literally (e.g. code samples or template-injection findings in a security report). + string selector = null; + var raw = false; + foreach (var arg in formatterContext.Args.Skip(1)) + { + var value = arg?.Trim(); + if (string.Equals(value, "raw", StringComparison.OrdinalIgnoreCase)) + { + raw = true; + } + else if (value is "p" or "run" or "tr" or "tc") + { + selector = value; + } + else + { + throw new OpenXmlTemplateException($"Invalid template formatter argument '{value}'"); + } + } + + if (selector != null) { - var selector = formatterContext.Args[1]; templateElement = selector switch { "p" => templateElement.Descendants().First(), @@ -58,17 +80,22 @@ public void ApplyFormat(ITemplateProcessingContext templateContext, FormatterCon }; } - // create a new Template context with replaced ModelLookup - var templateModelLookup = new ModelLookup(); - templateModelLookup.Add("ds", formatterContext.Value); - foreach (var models in templateContext.ModelLookup.Models.Skip(1)) + // Process the sub-document unless "raw" was requested. A verbatim insert skips templating, so the + // inserted content's {{ }} tokens, loops and formatters are preserved as literal text. + if (!raw) { - templateModelLookup.Add(models.Key, models.Value); + // create a new Template context with replaced ModelLookup + var templateModelLookup = new ModelLookup(); + templateModelLookup.Add("ds", formatterContext.Value); + foreach (var models in templateContext.ModelLookup.Models.Skip(1)) + { + templateModelLookup.Add(models.Key, models.Value); + } + var variableReplacer = new VariableReplacer(templateModelLookup, templateContext.ProcessSettings); + var scriptCompiler = new ScriptCompiler(templateModelLookup, templateContext.ProcessSettings); + var processor = new XmlNodeTemplate(templateElement, new TemplateProcessingContext(templateContext.ProcessSettings, templateModelLookup, variableReplacer, scriptCompiler)); + processor.Process(); } - var variableReplacer = new VariableReplacer(templateModelLookup, templateContext.ProcessSettings); - var scriptCompiler = new ScriptCompiler(templateModelLookup, templateContext.ProcessSettings); - var processor = new XmlNodeTemplate(templateElement, new TemplateProcessingContext(templateContext.ProcessSettings, templateModelLookup, variableReplacer, scriptCompiler)); - processor.Process(); // Elements cloned into the target document. Their relationship references (images, external links) // still point at the source document's parts and must be re-imported / re-mapped below.