From 02fe2e4bd7b260cbbc7bcbb398c679deebab91ea Mon Sep 17 00:00:00 2001 From: Vaceslav Ustinov Date: Sat, 26 Sep 2026 15:30:03 +0200 Subject: [PATCH] feat: format-detecting TemplateProcessor facade for .docx and .odt/.ott (#138) --- .../Core/TemplateProcessorTests.cs | 689 ++++++++++++++++++ TriasDev.Templify/Core/TemplateFormat.cs | 28 + .../Core/TemplateFormatDetector.cs | 163 +++++ TriasDev.Templify/Core/TemplateProcessor.cs | 603 +++++++++++++++ TriasDev.Templify/PublicAPI.Unshipped.txt | 19 + 5 files changed, 1502 insertions(+) create mode 100644 TriasDev.Templify.Tests/Core/TemplateProcessorTests.cs create mode 100644 TriasDev.Templify/Core/TemplateFormat.cs create mode 100644 TriasDev.Templify/Core/TemplateFormatDetector.cs create mode 100644 TriasDev.Templify/Core/TemplateProcessor.cs diff --git a/TriasDev.Templify.Tests/Core/TemplateProcessorTests.cs b/TriasDev.Templify.Tests/Core/TemplateProcessorTests.cs new file mode 100644 index 0000000..45a0d3c --- /dev/null +++ b/TriasDev.Templify.Tests/Core/TemplateProcessorTests.cs @@ -0,0 +1,689 @@ +// Copyright (c) 2026 TriasDev GmbH & Co. KG +// Licensed under the MIT License. See LICENSE file in the project root for full license information. + +using System.Globalization; +using System.IO.Compression; +using System.Text; +using TriasDev.Templify.Core; +using TriasDev.Templify.Tests.Helpers; + +namespace TriasDev.Templify.Tests.Core; + +/// +/// The format-detecting facade (#138): detection from the package content and +/// delegation to or . +/// +public sealed class TemplateProcessorTests +{ + private const string WordMainContentType = "application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml"; + + private static readonly Dictionary _data = new Dictionary { ["Name"] = "Alice" }; + + // ---------- DetectFormat ---------- + + [Fact] + public void DetectFormat_Docx_ReturnsDocx() + { + using MemoryStream stream = new MemoryStream(CreateDocx()); + + Assert.Equal(TemplateFormat.Docx, TemplateProcessor.DetectFormat(stream)); + } + + [Theory] + [InlineData("application/vnd.ms-word.document.macroEnabled.main+xml")] + [InlineData("application/vnd.openxmlformats-officedocument.wordprocessingml.template.main+xml")] + [InlineData("application/vnd.ms-word.template.macroEnabledTemplate.main+xml")] + public void DetectFormat_OtherWordprocessingPackages_ReturnDocx(string mainContentType) + { + using MemoryStream stream = new MemoryStream(WithMainContentType(CreateDocx(), mainContentType)); + + Assert.Equal(TemplateFormat.Docx, TemplateProcessor.DetectFormat(stream)); + } + + [Fact] + public void DetectFormat_Odt_ReturnsOdt() + { + using MemoryStream stream = new OdtDocumentBuilder().AddParagraph("Hello").ToStream(); + + Assert.Equal(TemplateFormat.Odt, TemplateProcessor.DetectFormat(stream)); + } + + [Fact] + public void DetectFormat_Ott_ReturnsOdt() + { + using MemoryStream stream = new OdtDocumentBuilder().AddParagraph("Hello").AsTemplate().ToStream(); + + Assert.Equal(TemplateFormat.Odt, TemplateProcessor.DetectFormat(stream)); + } + + [Fact] + public void DetectFormat_OdtWithoutMimetypeEntry_UsesManifestMediaType() + { + using MemoryStream stream = new OdtDocumentBuilder().AddParagraph("Hello").WithoutMimetype().ToStream(); + + Assert.Equal(TemplateFormat.Odt, TemplateProcessor.DetectFormat(stream)); + } + + [Theory] + [InlineData("application/vnd.oasis.opendocument.spreadsheet")] + [InlineData("application/vnd.oasis.opendocument.presentation")] + [InlineData("application/vnd.oasis.opendocument.text-master")] + public void DetectFormat_OtherOpenDocumentTypes_ReturnUnknown(string mediaType) + { + using MemoryStream stream = new MemoryStream(CreateZip(("mimetype", mediaType), ("content.xml", ""))); + + Assert.Equal(TemplateFormat.Unknown, TemplateProcessor.DetectFormat(stream)); + } + + [Fact] + public void DetectFormat_FlatOpenDocument_ReturnsUnknown() + { + string fodt = ""; + using MemoryStream stream = new MemoryStream(Encoding.UTF8.GetBytes(fodt)); + + Assert.Equal(TemplateFormat.Unknown, TemplateProcessor.DetectFormat(stream)); + } + + [Fact] + public void DetectFormat_SpreadsheetOpenXmlPackage_ReturnsUnknown() + { + string contentTypes = "" + + "" + + ""; + using MemoryStream stream = new MemoryStream(CreateZip(("[Content_Types].xml", contentTypes))); + + Assert.Equal(TemplateFormat.Unknown, TemplateProcessor.DetectFormat(stream)); + } + + [Fact] + public void DetectFormat_RandomBytes_ReturnsUnknown() + { + byte[] bytes = new byte[4096]; + new Random(42).NextBytes(bytes); + using MemoryStream stream = new MemoryStream(bytes); + + Assert.Equal(TemplateFormat.Unknown, TemplateProcessor.DetectFormat(stream)); + } + + [Fact] + public void DetectFormat_EmptyStream_ReturnsUnknown() + { + using MemoryStream stream = new MemoryStream(); + + Assert.Equal(TemplateFormat.Unknown, TemplateProcessor.DetectFormat(stream)); + } + + [Fact] + public void DetectFormat_ZipWithoutMarkers_ReturnsUnknown() + { + using MemoryStream stream = new MemoryStream(CreateZip(("readme.txt", "hello"), ("content.xml", ""))); + + Assert.Equal(TemplateFormat.Unknown, TemplateProcessor.DetectFormat(stream)); + } + + [Fact] + public void DetectFormat_MalformedContentTypes_ReturnsUnknown() + { + using MemoryStream stream = new MemoryStream(CreateZip(("[Content_Types].xml", "(() => TemplateProcessor.DetectFormat(stream)); + } + + [Fact] + public void DetectFormat_Null_Throws() + { + Assert.Throws(() => TemplateProcessor.DetectFormat(null!)); + } + + // ---------- Delegation ---------- + + [Fact] + public void ProcessTemplate_Docx_MatchesDocumentTemplateProcessor() + { + byte[] template = CreateDocx(); + + ProcessingResult direct = new DocumentTemplateProcessor(Options()).ProcessTemplate(template, _data, out byte[] expected); + ProcessingResult facade = new TemplateProcessor(Options()).ProcessTemplate(template, _data, out byte[] actual); + + AssertSameResult(direct, facade); + Assert.Equal(TemplateFormat.Docx, DetectBytes(actual)); + Assert.Equal(DocxTexts(expected), DocxTexts(actual)); + Assert.Equal(new[] { "Hello Alice!" }, DocxTexts(actual)); + } + + [Fact] + public void ProcessTemplate_Odt_MatchesOdtTemplateProcessor() + { + byte[] template = new OdtDocumentBuilder().AddParagraph("Hello {{Name}}!").ToBytes(); + + ProcessingResult direct = new OdtTemplateProcessor(Options()).ProcessTemplate(template, _data, out byte[] expected); + ProcessingResult facade = new TemplateProcessor(Options()).ProcessTemplate(template, _data, out byte[] actual); + + AssertSameResult(direct, facade); + Assert.Equal(expected, actual); + Assert.Equal(new[] { "Hello Alice!" }, new OdtDocumentVerifier(actual).GetParagraphTexts()); + } + + [Fact] + public void ProcessTemplate_Ott_ProducesOdt() + { + byte[] template = new OdtDocumentBuilder().AddParagraph("Hello {{Name}}!").AsTemplate().ToBytes(); + + ProcessingResult result = new TemplateProcessor(Options()).ProcessTemplate(template, _data, out byte[] output); + + Assert.True(result.IsSuccess, result.ErrorMessage); + OdtDocumentVerifier verifier = new OdtDocumentVerifier(output); + verifier.AssertValidOdtPackage(); + Assert.Equal("application/vnd.oasis.opendocument.text", verifier.Mimetype); + } + + [Fact] + public void ProcessTemplate_Streams_DelegateByFormat() + { + TemplateProcessor processor = new TemplateProcessor(Options()); + + using MemoryStream docxOutput = new MemoryStream(); + using MemoryStream docxTemplate = new MemoryStream(CreateDocx()); + ProcessingResult docxResult = processor.ProcessTemplate(docxTemplate, docxOutput, _data); + + using MemoryStream odtOutput = new MemoryStream(); + using MemoryStream odtTemplate = new OdtDocumentBuilder().AddParagraph("Hello {{Name}}!").ToStream(); + ProcessingResult odtResult = processor.ProcessTemplate(odtTemplate, odtOutput, _data); + + Assert.True(docxResult.IsSuccess, docxResult.ErrorMessage); + Assert.True(odtResult.IsSuccess, odtResult.ErrorMessage); + Assert.Equal(new[] { "Hello Alice!" }, DocxTexts(docxOutput.ToArray())); + Assert.Equal(new[] { "Hello Alice!" }, new OdtDocumentVerifier(odtOutput.ToArray()).GetParagraphTexts()); + } + + [Fact] + public void ProcessTemplate_ReadOnlyDictionaryAndJson_Delegate() + { + TemplateProcessor processor = new TemplateProcessor(Options()); + IReadOnlyDictionary readOnly = new Dictionary { ["Name"] = "Alice" }; + byte[] odt = new OdtDocumentBuilder().AddParagraph("Hello {{Name}}!").ToBytes(); + byte[] docx = CreateDocx(); + + ProcessingResult odtReadOnly = processor.ProcessTemplate(odt, readOnly, out byte[] odtOut1); + ProcessingResult odtJson = processor.ProcessTemplate(odt, "{\"Name\":\"Alice\"}", out byte[] odtOut2); + ProcessingResult docxReadOnly = processor.ProcessTemplate(docx, readOnly, out byte[] docxOut1); + ProcessingResult docxJson = processor.ProcessTemplate(docx, "{\"Name\":\"Alice\"}", out byte[] docxOut2); + + Assert.All(new[] { odtReadOnly, odtJson, docxReadOnly, docxJson }, r => Assert.True(r.IsSuccess, r.ErrorMessage)); + Assert.Equal("Hello Alice!", new OdtDocumentVerifier(odtOut1).GetParagraphTexts()[0]); + Assert.Equal("Hello Alice!", new OdtDocumentVerifier(odtOut2).GetParagraphTexts()[0]); + Assert.Equal("Hello Alice!", DocxTexts(docxOut1)[0]); + Assert.Equal("Hello Alice!", DocxTexts(docxOut2)[0]); + + using MemoryStream jsonOutput = new MemoryStream(); + using MemoryStream jsonTemplate = new MemoryStream(odt); + Assert.True(processor.ProcessTemplate(jsonTemplate, jsonOutput, "{\"Name\":\"Alice\"}").IsSuccess); + using MemoryStream readOnlyOutput = new MemoryStream(); + using MemoryStream readOnlyTemplate = new MemoryStream(docx); + Assert.True(processor.ProcessTemplate(readOnlyTemplate, readOnlyOutput, readOnly).IsSuccess); + } + + [Fact] + public void ProcessTemplate_NonSeekableTemplate_IsBuffered() + { + TemplateProcessor processor = new TemplateProcessor(Options()); + + using NonSeekableStream odtTemplate = new NonSeekableStream(new OdtDocumentBuilder().AddParagraph("Hello {{Name}}!").ToStream()); + using MemoryStream odtOutput = new MemoryStream(); + ProcessingResult odtResult = processor.ProcessTemplate(odtTemplate, odtOutput, _data); + + using NonSeekableStream docxTemplate = new NonSeekableStream(new MemoryStream(CreateDocx())); + using MemoryStream docxOutput = new MemoryStream(); + ProcessingResult docxResult = processor.ProcessTemplate(docxTemplate, docxOutput, _data); + + Assert.True(odtResult.IsSuccess, odtResult.ErrorMessage); + Assert.True(docxResult.IsSuccess, docxResult.ErrorMessage); + Assert.Equal("Hello Alice!", new OdtDocumentVerifier(odtOutput.ToArray()).GetParagraphTexts()[0]); + Assert.Equal("Hello Alice!", DocxTexts(docxOutput.ToArray())[0]); + } + + [Fact] + public void ProcessTemplate_ResultsAndWarnings_AreThoseOfTheDelegate() + { + byte[] template = new OdtDocumentBuilder().AddParagraph("{{Name}} {{Missing}}").ToBytes(); + + ProcessingResult direct = new OdtTemplateProcessor(Options()).ProcessTemplate(template, _data, out _); + ProcessingResult facade = new TemplateProcessor(Options()).ProcessTemplate(template, _data, out _); + + AssertSameResult(direct, facade); + Assert.Equal(new[] { "Missing" }, facade.MissingVariables); + } + + [Fact] + public void ProcessTemplate_TemplateSyntaxError_IsTheDelegatesFailure() + { + byte[] template = new OdtDocumentBuilder().AddParagraph("{{#if Name}}").AddParagraph("x").ToBytes(); + + ProcessingResult direct = new OdtTemplateProcessor(Options()).ProcessTemplate(template, _data, out _); + ProcessingResult facade = new TemplateProcessor(Options()).ProcessTemplate(template, _data, out byte[] output); + + Assert.False(facade.IsSuccess); + Assert.Equal(direct.ErrorMessage, facade.ErrorMessage); + Assert.Empty(output); + } + + [Fact] + public void ProcessTemplate_MissingVariableWithThrow_Throws() + { + PlaceholderReplacementOptions options = new PlaceholderReplacementOptions + { + MissingVariableBehavior = MissingVariableBehavior.ThrowException, + }; + byte[] template = new OdtDocumentBuilder().AddParagraph("{{Missing}}").ToBytes(); + + Assert.Throws( + () => new TemplateProcessor(options).ProcessTemplate(template, _data, out _)); + } + + [Fact] + public void ProcessTemplate_OptionsAreUsedForBothFormats() + { + PlaceholderReplacementOptions options = new PlaceholderReplacementOptions + { + MissingVariableBehavior = MissingVariableBehavior.ReplaceWithEmpty, + }; + TemplateProcessor processor = new TemplateProcessor(options); + + processor.ProcessTemplate(new OdtDocumentBuilder().AddParagraph("[{{Missing}}]").ToBytes(), _data, out byte[] odt); + processor.ProcessTemplate(CreateDocx("[{{Missing}}]"), _data, out byte[] docx); + + Assert.Equal("[]", new OdtDocumentVerifier(odt).GetParagraphTexts()[0]); + Assert.Equal("[]", DocxTexts(docx)[0]); + } + + // ---------- Unsupported formats ---------- + + [Fact] + public void ProcessTemplate_UnknownFormat_FailsWithClearMessageAndWritesNothing() + { + using MemoryStream template = new MemoryStream(Encoding.UTF8.GetBytes("not a document")); + using MemoryStream output = new MemoryStream(); + + ProcessingResult result = new TemplateProcessor().ProcessTemplate(template, output, _data); + + Assert.False(result.IsSuccess); + Assert.StartsWith("Unsupported template format", result.ErrorMessage); + Assert.Contains(".docx", result.ErrorMessage); + Assert.Contains(".odt", result.ErrorMessage); + Assert.Equal(0, output.Length); + } + + [Fact] + public void ProcessTemplate_OtherOpenDocumentType_NamesMediaType() + { + byte[] template = CreateZip(("mimetype", "application/vnd.oasis.opendocument.spreadsheet"), ("content.xml", "")); + + ProcessingResult result = new TemplateProcessor().ProcessTemplate(template, _data, out byte[] output); + + Assert.False(result.IsSuccess); + Assert.Contains("application/vnd.oasis.opendocument.spreadsheet", result.ErrorMessage); + Assert.Empty(output); + } + + [Fact] + public void ProcessTemplate_EmptyTemplate_Fails() + { + ProcessingResult result = new TemplateProcessor().ProcessTemplate(Array.Empty(), _data, out byte[] output); + + Assert.False(result.IsSuccess); + Assert.StartsWith("Unsupported template format", result.ErrorMessage); + Assert.Empty(output); + } + + [Fact] + public void ProcessTemplate_UnreadableTemplate_Fails() + { + using MemoryStream inner = new MemoryStream(CreateDocx()); + using WriteOnlyStream template = new WriteOnlyStream(inner); + using MemoryStream output = new MemoryStream(); + + ProcessingResult result = new TemplateProcessor().ProcessTemplate(template, output, _data); + + Assert.False(result.IsSuccess); + Assert.Contains("must be readable", result.ErrorMessage); + } + + [Fact] + public void ProcessTemplate_NullArguments_Throw() + { + TemplateProcessor processor = new TemplateProcessor(); + using MemoryStream stream = new MemoryStream(); + + Assert.Throws(() => processor.ProcessTemplate(null!, stream, _data)); + Assert.Throws(() => processor.ProcessTemplate(stream, null!, _data)); + Assert.Throws(() => processor.ProcessTemplate(stream, stream, (Dictionary)null!)); + Assert.Throws(() => processor.ProcessTemplate((byte[])null!, _data, out _)); + Assert.Throws(() => processor.ProcessTemplate(Array.Empty(), (string)null!, out _)); + Assert.Throws(() => processor.ProcessTemplateFile(" ", "out.odt", _data)); + Assert.Throws(() => processor.ValidateTemplate(null!)); + } + + [Fact] + public void ProcessTemplate_InvalidJson_ThrowsBeforeDetection() + { + Assert.ThrowsAny( + () => new TemplateProcessor().ProcessTemplate(Array.Empty(), "{not json", out _)); + } + + // ---------- Files ---------- + + [Fact] + public void ProcessTemplateFile_DelegatesByContentNotExtension() + { + string directory = Directory.CreateTempSubdirectory("templify-facade-").FullName; + try + { + // An OpenDocument template saved with a misleading extension is still detected by content. + string odtTemplate = Path.Combine(directory, "template.docx"); + string docxTemplate = Path.Combine(directory, "template.odt"); + File.WriteAllBytes(odtTemplate, new OdtDocumentBuilder().AddParagraph("Hello {{Name}}!").ToBytes()); + File.WriteAllBytes(docxTemplate, CreateDocx()); + TemplateProcessor processor = new TemplateProcessor(Options()); + + string odtOutput = Path.Combine(directory, "out1"); + string docxOutput = Path.Combine(directory, "out2"); + string jsonOutput = Path.Combine(directory, "out3"); + string readOnlyOutput = Path.Combine(directory, "out4"); + Assert.True(processor.ProcessTemplateFile(odtTemplate, odtOutput, _data).IsSuccess); + Assert.True(processor.ProcessTemplateFile(docxTemplate, docxOutput, _data).IsSuccess); + Assert.True(processor.ProcessTemplateFile(odtTemplate, jsonOutput, "{\"Name\":\"Alice\"}").IsSuccess); + Assert.True(processor.ProcessTemplateFile( + docxTemplate, readOnlyOutput, (IReadOnlyDictionary)new Dictionary { ["Name"] = "Alice" }).IsSuccess); + + Assert.Equal("Hello Alice!", new OdtDocumentVerifier(File.ReadAllBytes(odtOutput)).GetParagraphTexts()[0]); + Assert.Equal("Hello Alice!", DocxTexts(File.ReadAllBytes(docxOutput))[0]); + Assert.Equal("Hello Alice!", new OdtDocumentVerifier(File.ReadAllBytes(jsonOutput)).GetParagraphTexts()[0]); + Assert.Equal("Hello Alice!", DocxTexts(File.ReadAllBytes(readOnlyOutput))[0]); + } + finally + { + Directory.Delete(directory, recursive: true); + } + } + + [Fact] + public void ProcessTemplateFile_UnknownFormat_DoesNotCreateOutput() + { + string directory = Directory.CreateTempSubdirectory("templify-facade-").FullName; + try + { + string template = Path.Combine(directory, "template.odt"); + string output = Path.Combine(directory, "out.odt"); + File.WriteAllText(template, "plain text"); + + ProcessingResult result = new TemplateProcessor().ProcessTemplateFile(template, output, _data); + + Assert.False(result.IsSuccess); + Assert.StartsWith("Unsupported template format", result.ErrorMessage); + Assert.False(File.Exists(output)); + } + finally + { + Directory.Delete(directory, recursive: true); + } + } + + [Fact] + public void ProcessTemplateFile_MissingTemplate_Throws() + { + string missing = Path.Combine(Path.GetTempPath(), $"templify-missing-{Guid.NewGuid():N}.odt"); + + Assert.Throws(() => new TemplateProcessor().ProcessTemplateFile(missing, missing + ".out", _data)); + } + + // ---------- Validation ---------- + + [Fact] + public void ValidateTemplate_DelegatesByFormat() + { + TemplateProcessor processor = new TemplateProcessor(Options()); + byte[] odt = new OdtDocumentBuilder().AddParagraph("{{#if Flag}}").AddParagraph("{{Name}}").ToBytes(); + byte[] docx = CreateDocx("{{Name}} {{Other}}"); + + using MemoryStream odtStream = new MemoryStream(odt); + ValidationResult odtFacade = processor.ValidateTemplate(odtStream); + using MemoryStream odtDirectStream = new MemoryStream(odt); + ValidationResult odtDirect = new OdtTemplateProcessor(Options()).ValidateTemplate(odtDirectStream); + + using MemoryStream docxStream = new MemoryStream(docx); + ValidationResult docxFacade = processor.ValidateTemplate(docxStream, _data); + using MemoryStream docxDirectStream = new MemoryStream(docx); + ValidationResult docxDirect = new DocumentTemplateProcessor(Options()).ValidateTemplate(docxDirectStream, _data); + + Assert.False(odtFacade.IsValid); + Assert.Equal(odtDirect.Errors.Select(e => e.Message), odtFacade.Errors.Select(e => e.Message)); + Assert.Equal(odtDirect.AllPlaceholders, odtFacade.AllPlaceholders); + Assert.Equal(docxDirect.MissingVariables, docxFacade.MissingVariables); + Assert.Equal(new[] { "Other" }, docxFacade.MissingVariables); + } + + [Fact] + public void ValidateTemplate_ReadOnlyDictionaryAndNonSeekableStream() + { + IReadOnlyDictionary data = new Dictionary { ["Name"] = "Alice" }; + using NonSeekableStream template = new NonSeekableStream(new OdtDocumentBuilder().AddParagraph("{{Name}} {{Other}}").ToStream()); + + ValidationResult result = new TemplateProcessor().ValidateTemplate(template, data); + + Assert.Equal(new[] { "Other" }, result.MissingVariables); + Assert.Equal(new[] { "Name", "Other" }, result.AllPlaceholders); + } + + [Fact] + public void ValidateTemplate_UnknownFormat_IsInvalid() + { + using MemoryStream template = new MemoryStream(CreateZip(("readme.txt", "hello"))); + + ValidationResult result = new TemplateProcessor().ValidateTemplate(template); + + Assert.False(result.IsValid); + ValidationError error = Assert.Single(result.Errors); + Assert.StartsWith("Unsupported template format", error.Message); + } + + [Fact] + public void ValidateTemplate_UnreadableStream_IsInvalid() + { + using MemoryStream inner = new MemoryStream(CreateDocx()); + using WriteOnlyStream template = new WriteOnlyStream(inner); + + ValidationResult result = new TemplateProcessor().ValidateTemplate(template, _data); + + Assert.False(result.IsValid); + Assert.Contains("must be readable", Assert.Single(result.Errors).Message); + } + + // ---------- Helpers ---------- + + private static PlaceholderReplacementOptions Options() => + new PlaceholderReplacementOptions { Culture = CultureInfo.InvariantCulture }; + + private static byte[] CreateDocx(string text = "Hello {{Name}}!") + { + DocumentBuilder builder = new DocumentBuilder(); + builder.AddParagraph(text); + return builder.ToStream().ToArray(); + } + + private static TemplateFormat DetectBytes(byte[] bytes) + { + using MemoryStream stream = new MemoryStream(bytes); + return TemplateProcessor.DetectFormat(stream); + } + + private static List DocxTexts(byte[] docx) + { + using DocumentVerifier verifier = new DocumentVerifier(new MemoryStream(docx)); + return verifier.GetAllParagraphTexts(); + } + + private static void AssertSameResult(ProcessingResult expected, ProcessingResult actual) + { + Assert.Equal(expected.IsSuccess, actual.IsSuccess); + Assert.Equal(expected.ErrorMessage, actual.ErrorMessage); + Assert.Equal(expected.ReplacementCount, actual.ReplacementCount); + Assert.Equal(expected.MissingVariables, actual.MissingVariables); + Assert.Equal(expected.Warnings.Select(w => w.Message), actual.Warnings.Select(w => w.Message)); + } + + private static byte[] WithMainContentType(byte[] docx, string contentType) + { + using MemoryStream stream = new MemoryStream(); + stream.Write(docx); + using (ZipArchive archive = new ZipArchive(stream, ZipArchiveMode.Update, leaveOpen: true)) + { + ZipArchiveEntry entry = archive.GetEntry("[Content_Types].xml")!; + string xml; + using (StreamReader reader = new StreamReader(entry.Open())) + { + xml = reader.ReadToEnd(); + } + + Assert.Contains(WordMainContentType, xml); + entry.Delete(); + ZipArchiveEntry replacement = archive.CreateEntry("[Content_Types].xml"); + using StreamWriter writer = new StreamWriter(replacement.Open()); + writer.Write(xml.Replace(WordMainContentType, contentType)); + } + + return stream.ToArray(); + } + + private static byte[] CreateZip(params (string Name, string Content)[] entries) + { + using MemoryStream stream = new MemoryStream(); + using (ZipArchive archive = new ZipArchive(stream, ZipArchiveMode.Create, leaveOpen: true)) + { + foreach ((string name, string content) in entries) + { + ZipArchiveEntry entry = archive.CreateEntry(name, CompressionLevel.NoCompression); + using Stream entryStream = entry.Open(); + entryStream.Write(Encoding.UTF8.GetBytes(content)); + } + } + + return stream.ToArray(); + } + + private sealed class NonSeekableStream : Stream + { + private readonly Stream _inner; + + public NonSeekableStream(Stream inner) + { + _inner = inner; + } + + public override bool CanRead => true; + + public override bool CanSeek => false; + + public override bool CanWrite => false; + + public override long Length => throw new NotSupportedException(); + + public override long Position + { + get => throw new NotSupportedException(); + set => throw new NotSupportedException(); + } + + public override void Flush() + { + } + + public override int Read(byte[] buffer, int offset, int count) => _inner.Read(buffer, offset, count); + + public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException(); + + public override void SetLength(long value) => throw new NotSupportedException(); + + public override void Write(byte[] buffer, int offset, int count) => throw new NotSupportedException(); + + protected override void Dispose(bool disposing) + { + if (disposing) + { + _inner.Dispose(); + } + + base.Dispose(disposing); + } + } + + private sealed class WriteOnlyStream : Stream + { + private readonly Stream _inner; + + public WriteOnlyStream(Stream inner) + { + _inner = inner; + } + + public override bool CanRead => false; + + public override bool CanSeek => true; + + public override bool CanWrite => true; + + public override long Length => _inner.Length; + + public override long Position + { + get => _inner.Position; + set => _inner.Position = value; + } + + public override void Flush() + { + } + + public override int Read(byte[] buffer, int offset, int count) => throw new NotSupportedException(); + + public override long Seek(long offset, SeekOrigin origin) => _inner.Seek(offset, origin); + + public override void SetLength(long value) => _inner.SetLength(value); + + public override void Write(byte[] buffer, int offset, int count) => _inner.Write(buffer, offset, count); + } +} diff --git a/TriasDev.Templify/Core/TemplateFormat.cs b/TriasDev.Templify/Core/TemplateFormat.cs new file mode 100644 index 0000000..b5b0aef --- /dev/null +++ b/TriasDev.Templify/Core/TemplateFormat.cs @@ -0,0 +1,28 @@ +// Copyright (c) 2026 TriasDev GmbH & Co. KG +// Licensed under the MIT License. See LICENSE file in the project root for full license information. + +namespace TriasDev.Templify.Core; + +/// +/// The document format of a template, as detected by . +/// +public enum TemplateFormat +{ + /// + /// Not a supported template format: not a ZIP package, or a package that is neither a Word document nor an + /// OpenDocument Text document (for example a spreadsheet, a legacy binary .doc, or flat OpenDocument .fodt). + /// + Unknown = 0, + + /// + /// A Word (Office Open XML WordprocessingML) document: .docx, and also .docm, .dotx and .dotm packages. + /// Processed by . + /// + Docx = 1, + + /// + /// An OpenDocument Text document (.odt) or template (.ott), as written by LibreOffice, Collabora Online or + /// OpenOffice. Processed by . + /// + Odt = 2, +} diff --git a/TriasDev.Templify/Core/TemplateFormatDetector.cs b/TriasDev.Templify/Core/TemplateFormatDetector.cs new file mode 100644 index 0000000..49e312c --- /dev/null +++ b/TriasDev.Templify/Core/TemplateFormatDetector.cs @@ -0,0 +1,163 @@ +// Copyright (c) 2026 TriasDev GmbH & Co. KG +// Licensed under the MIT License. See LICENSE file in the project root for full license information. + +using System.IO.Compression; +using System.Text; +using System.Xml; +using System.Xml.Linq; +using TriasDev.Templify.OpenDocument; + +namespace TriasDev.Templify.Core; + +/// +/// Detects the format of a template package from its content, not from a file name. +/// +/// +/// +/// OpenDocument: the mimetype entry (with a fallback to the root entry of +/// META-INF/manifest.xml, as does) names the text or text-template media type. +/// Word: [Content_Types].xml declares a WordprocessingML main document part +/// (document, template, macro-enabled document or macro-enabled template). +/// +/// Only the ZIP central directory and these small entries are read. +/// +internal static class TemplateFormatDetector +{ + private const string ContentTypesEntry = "[Content_Types].xml"; + + /// Upper bound for the entries read during detection; real ones are far smaller. + private const int MaxEntryBytes = 1024 * 1024; + + private static readonly XNamespace _contentTypes = "http://schemas.openxmlformats.org/package/2006/content-types"; + private static readonly XNamespace _manifest = "urn:oasis:names:tc:opendocument:xmlns:manifest:1.0"; + + private static readonly HashSet _wordMainContentTypes = new HashSet(StringComparer.OrdinalIgnoreCase) + { + "application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml", + "application/vnd.openxmlformats-officedocument.wordprocessingml.template.main+xml", + "application/vnd.ms-word.document.macroEnabled.main+xml", + "application/vnd.ms-word.template.macroEnabledTemplate.main+xml", + }; + + private static readonly XmlReaderSettings _readerSettings = new XmlReaderSettings + { + DtdProcessing = DtdProcessing.Prohibit, + XmlResolver = null, + CloseInput = false, + }; + + /// + /// Detects the format of the package in a readable, seekable stream. The stream is read from its start; + /// its position is not restored (callers do that). + /// + /// The package. + /// The OpenDocument media type found, if any (also when it is not a text type). + public static TemplateFormat Detect(Stream stream, out string? odfMediaType) + { + odfMediaType = null; + + try + { + stream.Position = 0; + using ZipArchive archive = new ZipArchive(stream, ZipArchiveMode.Read, leaveOpen: true); + + odfMediaType = ReadOdfMediaType(archive); + if (odfMediaType != null) + { + return odfMediaType is OdfNames.TextMediaType or OdfNames.TextTemplateMediaType + ? TemplateFormat.Odt + : TemplateFormat.Unknown; + } + + return IsWordprocessingPackage(archive) ? TemplateFormat.Docx : TemplateFormat.Unknown; + } + catch (InvalidDataException) + { + // Not a ZIP archive (empty, random bytes, legacy .doc, flat .fodt) or a corrupt entry. + return TemplateFormat.Unknown; + } + } + + /// + /// The message for a template whose format is not supported. + /// + public static string GetUnsupportedFormatMessage(string? odfMediaType) + { + string message = "Unsupported template format: the template is neither a Word document (.docx) nor an " + + "OpenDocument Text document (.odt/.ott)."; + + return odfMediaType != null + ? $"{message} Found the OpenDocument media type '{odfMediaType}'." + : $"{message} Legacy Word (.doc) and flat OpenDocument (.fodt) files are not supported."; + } + + private static string? ReadOdfMediaType(ZipArchive archive) + { + ZipArchiveEntry? mimetype = archive.GetEntry(OdtPackage.MimetypeEntry); + if (mimetype != null) + { + byte[]? data = ReadEntry(mimetype); + string mediaType = data != null ? Encoding.ASCII.GetString(data).Trim() : string.Empty; + if (mediaType.Length > 0) + { + return mediaType; + } + } + + ZipArchiveEntry? manifest = archive.GetEntry(OdtPackage.ManifestEntry); + XDocument? document = manifest != null ? LoadXml(manifest) : null; + return document?.Root? + .Elements(_manifest + "file-entry") + .FirstOrDefault(e => e.Attribute(_manifest + "full-path")?.Value == "/")? + .Attribute(_manifest + "media-type")?.Value; + } + + private static bool IsWordprocessingPackage(ZipArchive archive) + { + ZipArchiveEntry? entry = archive.GetEntry(ContentTypesEntry); + XDocument? contentTypes = entry != null ? LoadXml(entry) : null; + if (contentTypes?.Root == null) + { + return false; + } + + return contentTypes.Root.Elements() + .Where(e => e.Name == _contentTypes + "Override" || e.Name == _contentTypes + "Default") + .Select(e => e.Attribute("ContentType")?.Value) + .Any(type => type != null && _wordMainContentTypes.Contains(type)); + } + + private static XDocument? LoadXml(ZipArchiveEntry entry) + { + byte[]? data = ReadEntry(entry); + if (data == null) + { + return null; + } + + try + { + using MemoryStream input = new MemoryStream(data, writable: false); + using XmlReader reader = XmlReader.Create(input, _readerSettings); + return XDocument.Load(reader); + } + catch (XmlException) + { + return null; + } + } + + /// Reads an entry, or returns null when it is larger than detection needs. + private static byte[]? ReadEntry(ZipArchiveEntry entry) + { + if (entry.Length > MaxEntryBytes) + { + return null; + } + + using Stream entryStream = entry.Open(); + using MemoryStream buffer = new MemoryStream(); + entryStream.CopyTo(buffer); + return buffer.ToArray(); + } +} diff --git a/TriasDev.Templify/Core/TemplateProcessor.cs b/TriasDev.Templify/Core/TemplateProcessor.cs new file mode 100644 index 0000000..a571f6b --- /dev/null +++ b/TriasDev.Templify/Core/TemplateProcessor.cs @@ -0,0 +1,603 @@ +// Copyright (c) 2026 TriasDev GmbH & Co. KG +// Licensed under the MIT License. See LICENSE file in the project root for full license information. + +using System.Text.Json; +using TriasDev.Templify.Utilities; + +namespace TriasDev.Templify.Core; + +/// +/// Processes Word (.docx) and OpenDocument Text (.odt, .ott) templates through one entry point. The format is +/// detected from the package content (not from the file name), and processing is delegated to +/// or . +/// +/// +/// +/// Detection reads the ZIP package: an OpenDocument package names its media type in the mimetype entry +/// (application/vnd.oasis.opendocument.text, or …-text-template for .ott), a Word package declares +/// a WordprocessingML main document in [Content_Types].xml (.docx, and also .docm, .dotx and .dotm). +/// See . +/// +/// +/// Results, warnings, options and exceptions are exactly those of the processor the template is delegated to, +/// including the stream requirements for the output: a Word document is edited in place and needs a readable, +/// writable and seekable output stream; an OpenDocument document only needs a writable one. A +/// , or a opened with , +/// works for both. The output has the format of the template (an .ott template produces an .odt document). +/// +/// +/// A template stream that is not seekable is first copied into memory, because the format has to be detected +/// before the template is processed. A template in an unsupported format (not a ZIP package, another +/// OpenDocument type such as a spreadsheet, a legacy .doc, or a flat OpenDocument .fodt) is reported as a +/// failed , or as an invalid , and nothing is written. +/// +/// +/// Use or directly when the format is +/// known in advance. +/// +/// +public sealed class TemplateProcessor +{ + private const string UnreadableTemplateMessage = "Invalid template stream: the template stream must be readable."; + + private readonly DocumentTemplateProcessor _docxProcessor; + private readonly OdtTemplateProcessor _odtProcessor; + + /// + /// Initializes a new instance of the class. + /// + /// + /// Configuration options for placeholder replacement, used for both formats. If null, default options are used. + /// + public TemplateProcessor(PlaceholderReplacementOptions? options = null) + { + PlaceholderReplacementOptions effective = options ?? new PlaceholderReplacementOptions(); + _docxProcessor = new DocumentTemplateProcessor(effective); + _odtProcessor = new OdtTemplateProcessor(effective); + } + + /// + /// Detects the format of a template from its content. + /// + /// + /// Stream containing the template. Must be readable and seekable. The whole package is inspected, regardless + /// of the current position, and the position is restored before the method returns. + /// + /// + /// for a Word package, for an OpenDocument + /// Text document or template, and for anything else (including an empty + /// stream and other OpenDocument types). + /// + /// Thrown when templateStream is null. + /// + /// Thrown when templateStream is not readable or not seekable. Copy a non-seekable stream into a + /// first. + /// + public static TemplateFormat DetectFormat(Stream templateStream) + { + ArgumentNullException.ThrowIfNull(templateStream); + if (!templateStream.CanRead || !templateStream.CanSeek) + { + throw new ArgumentException( + "The template stream must be readable and seekable to detect its format.", nameof(templateStream)); + } + + long position = templateStream.Position; + try + { + return TemplateFormatDetector.Detect(templateStream, out _); + } + finally + { + templateStream.Position = position; + } + } + + /// + /// Processes a Word or OpenDocument Text template, replacing placeholders with values from the data dictionary. + /// + /// + /// Stream containing the template (.docx or .odt/.ott). Must be readable; a stream that is not seekable is + /// copied into memory first. + /// + /// + /// Stream to write the processed document to. Readable, writable and seekable for a Word template; writable + /// for an OpenDocument template. See the remarks on . + /// + /// Dictionary containing variable names and their replacement values. + /// + /// The of the processor for the detected format, or a failed result when the + /// template stream is not readable or its format is not supported. + /// + /// Thrown when any parameter is null. + /// + /// Thrown only when a variable is missing and is configured. + /// + public ProcessingResult ProcessTemplate( + Stream templateStream, + Stream outputStream, + Dictionary data) + { + ArgumentNullException.ThrowIfNull(templateStream); + ArgumentNullException.ThrowIfNull(outputStream); + ArgumentNullException.ThrowIfNull(data); + + return ProcessStream( + templateStream, + (format, template) => format == TemplateFormat.Odt + ? _odtProcessor.ProcessTemplate(template, outputStream, data) + : _docxProcessor.ProcessTemplate(template, outputStream, data)); + } + + /// + /// Processes a Word or OpenDocument Text template, replacing placeholders with values from read-only data. + /// + /// + /// Stream containing the template (.docx or .odt/.ott). Must be readable; a stream that is not seekable is + /// copied into memory first. + /// + /// + /// Stream to write the processed document to. Readable, writable and seekable for a Word template; writable + /// for an OpenDocument template. + /// + /// + /// Variable names and their replacement values. Not copied: lookups use the dictionary's own key comparer. + /// + /// + /// The of the processor for the detected format, or a failed result when the + /// template stream is not readable or its format is not supported. + /// + /// Thrown when any parameter is null. + /// + /// Thrown only when a variable is missing and is configured. + /// + public ProcessingResult ProcessTemplate( + Stream templateStream, + Stream outputStream, + IReadOnlyDictionary data) + { + ArgumentNullException.ThrowIfNull(templateStream); + ArgumentNullException.ThrowIfNull(outputStream); + ArgumentNullException.ThrowIfNull(data); + + return ProcessStream( + templateStream, + (format, template) => format == TemplateFormat.Odt + ? _odtProcessor.ProcessTemplate(template, outputStream, data) + : _docxProcessor.ProcessTemplate(template, outputStream, data)); + } + + /// + /// Processes a Word or OpenDocument Text template, replacing placeholders with values from a JSON string. + /// + /// + /// Stream containing the template (.docx or .odt/.ott). Must be readable; a stream that is not seekable is + /// copied into memory first. + /// + /// + /// Stream to write the processed document to. Readable, writable and seekable for a Word template; writable + /// for an OpenDocument template. + /// + /// JSON string containing variable names and their replacement values. Must be a valid JSON object (not an array). + /// A indicating success or failure and providing metrics. + /// Thrown when any parameter is null. + /// Thrown when jsonData is empty or whitespace. + /// Thrown when jsonData is invalid JSON or root is not an object. + public ProcessingResult ProcessTemplate( + Stream templateStream, + Stream outputStream, + string jsonData) + { + ArgumentNullException.ThrowIfNull(jsonData); + + // JSON parse errors (JsonException, ArgumentException) propagate to the caller, as in the other processors. + Dictionary data = JsonDataParser.ParseJsonToDataDictionary(jsonData); + return ProcessTemplate(templateStream, outputStream, data); + } + + /// + /// Processes a Word or OpenDocument Text template held in memory and returns the processed document as a + /// byte array. + /// + /// The template file content (.docx or .odt/.ott). Not modified. + /// Dictionary containing variable names and their replacement values. + /// + /// When this method returns, the processed file content (in the format of the template) if processing + /// succeeded; otherwise an empty array. + /// + /// + /// The of the processor for the detected format, or a failed result when the + /// format is not supported. + /// + /// Thrown when any parameter is null. + /// + /// Thrown only when a variable is missing and is configured. + /// + public ProcessingResult ProcessTemplate( + byte[] template, + Dictionary data, + out byte[] output) + { + ArgumentNullException.ThrowIfNull(template); + ArgumentNullException.ThrowIfNull(data); + + switch (Detect(template, out string? odfMediaType)) + { + case TemplateFormat.Docx: + return _docxProcessor.ProcessTemplate(template, data, out output); + case TemplateFormat.Odt: + return _odtProcessor.ProcessTemplate(template, data, out output); + default: + output = Array.Empty(); + return UnsupportedFormat(odfMediaType); + } + } + + /// + /// Processes a Word or OpenDocument Text template held in memory, using read-only data, and returns the + /// processed document as a byte array. + /// + /// The template file content (.docx or .odt/.ott). Not modified. + /// + /// Variable names and their replacement values. Not copied: lookups use the dictionary's own key comparer. + /// + /// + /// When this method returns, the processed file content (in the format of the template) if processing + /// succeeded; otherwise an empty array. + /// + /// + /// The of the processor for the detected format, or a failed result when the + /// format is not supported. + /// + /// Thrown when any parameter is null. + /// + /// Thrown only when a variable is missing and is configured. + /// + public ProcessingResult ProcessTemplate( + byte[] template, + IReadOnlyDictionary data, + out byte[] output) + { + ArgumentNullException.ThrowIfNull(template); + ArgumentNullException.ThrowIfNull(data); + + switch (Detect(template, out string? odfMediaType)) + { + case TemplateFormat.Docx: + return _docxProcessor.ProcessTemplate(template, data, out output); + case TemplateFormat.Odt: + return _odtProcessor.ProcessTemplate(template, data, out output); + default: + output = Array.Empty(); + return UnsupportedFormat(odfMediaType); + } + } + + /// + /// Processes a Word or OpenDocument Text template held in memory, using data from a JSON string, and returns + /// the processed document as a byte array. + /// + /// The template file content (.docx or .odt/.ott). Not modified. + /// JSON string containing variable names and their replacement values. Must be a valid JSON object (not an array). + /// + /// When this method returns, the processed file content (in the format of the template) if processing + /// succeeded; otherwise an empty array. + /// + /// A indicating success or failure and providing metrics. + /// Thrown when any parameter is null. + /// Thrown when jsonData is empty or whitespace. + /// Thrown when jsonData is invalid JSON or root is not an object. + public ProcessingResult ProcessTemplate( + byte[] template, + string jsonData, + out byte[] output) + { + ArgumentNullException.ThrowIfNull(template); + ArgumentNullException.ThrowIfNull(jsonData); + + Dictionary data = JsonDataParser.ParseJsonToDataDictionary(jsonData); + return ProcessTemplate(template, data, out output); + } + + /// + /// Processes a Word or OpenDocument Text template file and writes the processed document to a file. + /// + /// Path of the template file (.docx or .odt/.ott). Not modified. + /// + /// Path of the output file. Created or overwritten only when processing succeeds; may be the same path as + /// . The output has the format of the template (.odt for an .ott template), + /// whatever the extension of this path. + /// + /// Dictionary containing variable names and their replacement values. + /// + /// The of the processor for the detected format, or a failed result when the + /// format is not supported. + /// + /// Thrown when any parameter is null. + /// Thrown when a path is empty or whitespace. + /// + /// Thrown when the template file cannot be read or the output file cannot be written (for example + /// or ). + /// + /// Thrown when access to a file is denied. + /// + /// Thrown only when a variable is missing and is configured. + /// + public ProcessingResult ProcessTemplateFile( + string templatePath, + string outputPath, + Dictionary data) + { + ValidatePaths(templatePath, outputPath); + ArgumentNullException.ThrowIfNull(data); + + return ProcessFile( + templatePath, + format => format == TemplateFormat.Odt + ? _odtProcessor.ProcessTemplateFile(templatePath, outputPath, data) + : _docxProcessor.ProcessTemplateFile(templatePath, outputPath, data)); + } + + /// + /// Processes a Word or OpenDocument Text template file, using read-only data, and writes the processed + /// document to a file. + /// + /// Path of the template file (.docx or .odt/.ott). Not modified. + /// + /// Path of the output file. Created or overwritten only when processing succeeds; may be the same path as + /// . The output has the format of the template. + /// + /// + /// Variable names and their replacement values. Not copied: lookups use the dictionary's own key comparer. + /// + /// + /// The of the processor for the detected format, or a failed result when the + /// format is not supported. + /// + /// Thrown when any parameter is null. + /// Thrown when a path is empty or whitespace. + /// + /// Thrown when the template file cannot be read or the output file cannot be written (for example + /// or ). + /// + /// Thrown when access to a file is denied. + /// + /// Thrown only when a variable is missing and is configured. + /// + public ProcessingResult ProcessTemplateFile( + string templatePath, + string outputPath, + IReadOnlyDictionary data) + { + ValidatePaths(templatePath, outputPath); + ArgumentNullException.ThrowIfNull(data); + + return ProcessFile( + templatePath, + format => format == TemplateFormat.Odt + ? _odtProcessor.ProcessTemplateFile(templatePath, outputPath, data) + : _docxProcessor.ProcessTemplateFile(templatePath, outputPath, data)); + } + + /// + /// Processes a Word or OpenDocument Text template file, using data from a JSON string, and writes the + /// processed document to a file. + /// + /// Path of the template file (.docx or .odt/.ott). Not modified. + /// + /// Path of the output file. Created or overwritten only when processing succeeds; may be the same path as + /// . The output has the format of the template. + /// + /// JSON string containing variable names and their replacement values. Must be a valid JSON object (not an array). + /// A indicating success or failure and providing metrics. + /// Thrown when any parameter is null. + /// Thrown when a path or jsonData is empty or whitespace. + /// Thrown when jsonData is invalid JSON or root is not an object. + /// + /// Thrown when the template file cannot be read or the output file cannot be written (for example + /// or ). + /// + /// Thrown when access to a file is denied. + public ProcessingResult ProcessTemplateFile( + string templatePath, + string outputPath, + string jsonData) + { + ValidatePaths(templatePath, outputPath); + ArgumentNullException.ThrowIfNull(jsonData); + + Dictionary data = JsonDataParser.ParseJsonToDataDictionary(jsonData); + return ProcessTemplateFile(templatePath, outputPath, data); + } + + /// + /// Validates a Word or OpenDocument Text template for syntax errors (unmatched markers, invalid conditions, + /// invalid iteration variables). Does not check for missing variables since no data is provided. + /// + /// + /// Stream containing the template (.docx or .odt/.ott). Must be readable; a stream that is not seekable is + /// copied into memory first. + /// + /// + /// The of the processor for the detected format. A template stream that is not + /// readable, or a template in an unsupported format, is reported as an error. + /// + /// Thrown when templateStream is null. + public ValidationResult ValidateTemplate(Stream templateStream) + { + ArgumentNullException.ThrowIfNull(templateStream); + + return ValidateStream( + templateStream, + (format, template) => format == TemplateFormat.Odt + ? _odtProcessor.ValidateTemplate(template) + : _docxProcessor.ValidateTemplate(template)); + } + + /// + /// Validates a Word or OpenDocument Text template for syntax errors and missing variables. + /// + /// + /// Stream containing the template (.docx or .odt/.ott). Must be readable; a stream that is not seekable is + /// copied into memory first. + /// + /// Dictionary containing variable names and their values for validation. + /// + /// The of the processor for the detected format. A template stream that is not + /// readable, or a template in an unsupported format, is reported as an error. + /// + /// Thrown when any parameter is null. + public ValidationResult ValidateTemplate(Stream templateStream, Dictionary data) + { + ArgumentNullException.ThrowIfNull(templateStream); + ArgumentNullException.ThrowIfNull(data); + + return ValidateStream( + templateStream, + (format, template) => format == TemplateFormat.Odt + ? _odtProcessor.ValidateTemplate(template, data) + : _docxProcessor.ValidateTemplate(template, data)); + } + + /// + /// Validates a Word or OpenDocument Text template for syntax errors and missing variables, using read-only data. + /// + /// + /// Stream containing the template (.docx or .odt/.ott). Must be readable; a stream that is not seekable is + /// copied into memory first. + /// + /// + /// Variable names and their values for validation. Not copied: lookups use the dictionary's own key comparer. + /// + /// + /// The of the processor for the detected format. A template stream that is not + /// readable, or a template in an unsupported format, is reported as an error. + /// + /// Thrown when any parameter is null. + public ValidationResult ValidateTemplate(Stream templateStream, IReadOnlyDictionary data) + { + ArgumentNullException.ThrowIfNull(templateStream); + ArgumentNullException.ThrowIfNull(data); + + return ValidateStream( + templateStream, + (format, template) => format == TemplateFormat.Odt + ? _odtProcessor.ValidateTemplate(template, data) + : _docxProcessor.ValidateTemplate(template, data)); + } + + /// + /// Detects the format of a template stream (buffering a non-seekable one) and runs the matching processor. + /// + private static ProcessingResult ProcessStream( + Stream templateStream, + Func process) + { + if (!templateStream.CanRead) + { + return ProcessingResult.Failure(UnreadableTemplateMessage); + } + + MemoryStream? buffer = BufferIfNotSeekable(templateStream); + try + { + Stream template = buffer ?? templateStream; + TemplateFormat format = DetectAndRestore(template, out string? odfMediaType); + return format == TemplateFormat.Unknown + ? UnsupportedFormat(odfMediaType) + : process(format, template); + } + finally + { + buffer?.Dispose(); + } + } + + /// + /// Detects the format of a template stream (buffering a non-seekable one) and runs the matching validator. + /// + private static ValidationResult ValidateStream( + Stream templateStream, + Func validate) + { + if (!templateStream.CanRead) + { + return InvalidTemplate(UnreadableTemplateMessage); + } + + MemoryStream? buffer = BufferIfNotSeekable(templateStream); + try + { + Stream template = buffer ?? templateStream; + TemplateFormat format = DetectAndRestore(template, out string? odfMediaType); + return format == TemplateFormat.Unknown + ? InvalidTemplate(TemplateFormatDetector.GetUnsupportedFormatMessage(odfMediaType)) + : validate(format, template); + } + finally + { + buffer?.Dispose(); + } + } + + /// + /// Detects the format of a template file and runs the matching processor, which reads the file itself. + /// + private static ProcessingResult ProcessFile(string templatePath, Func process) + { + TemplateFormat format; + string? odfMediaType; + using (FileStream stream = new FileStream(templatePath, FileMode.Open, FileAccess.Read, FileShare.Read)) + { + format = TemplateFormatDetector.Detect(stream, out odfMediaType); + } + + return format == TemplateFormat.Unknown ? UnsupportedFormat(odfMediaType) : process(format); + } + + private static TemplateFormat Detect(byte[] template, out string? odfMediaType) + { + using MemoryStream stream = new MemoryStream(template, writable: false); + return TemplateFormatDetector.Detect(stream, out odfMediaType); + } + + private static TemplateFormat DetectAndRestore(Stream template, out string? odfMediaType) + { + long position = template.Position; + try + { + return TemplateFormatDetector.Detect(template, out odfMediaType); + } + finally + { + template.Position = position; + } + } + + private static MemoryStream? BufferIfNotSeekable(Stream templateStream) + { + if (templateStream.CanSeek) + { + return null; + } + + MemoryStream buffer = new MemoryStream(); + templateStream.CopyTo(buffer); + buffer.Position = 0; + return buffer; + } + + private static ProcessingResult UnsupportedFormat(string? odfMediaType) => + ProcessingResult.Failure(TemplateFormatDetector.GetUnsupportedFormatMessage(odfMediaType)); + + private static ValidationResult InvalidTemplate(string message) => + ValidationResult.Failure( + new[] { ValidationError.Create(ValidationErrorType.InvalidPlaceholderSyntax, message) }, + Array.Empty()); + + private static void ValidatePaths(string templatePath, string outputPath) + { + ArgumentException.ThrowIfNullOrWhiteSpace(templatePath); + ArgumentException.ThrowIfNullOrWhiteSpace(outputPath); + } +} diff --git a/TriasDev.Templify/PublicAPI.Unshipped.txt b/TriasDev.Templify/PublicAPI.Unshipped.txt index d19ae8d..d01b501 100644 --- a/TriasDev.Templify/PublicAPI.Unshipped.txt +++ b/TriasDev.Templify/PublicAPI.Unshipped.txt @@ -13,3 +13,22 @@ TriasDev.Templify.Core.OdtTemplateProcessor.ProcessTemplateFile(string! template TriasDev.Templify.Core.OdtTemplateProcessor.ValidateTemplate(System.IO.Stream! templateStream) -> TriasDev.Templify.Core.ValidationResult! TriasDev.Templify.Core.OdtTemplateProcessor.ValidateTemplate(System.IO.Stream! templateStream, System.Collections.Generic.Dictionary! data) -> TriasDev.Templify.Core.ValidationResult! TriasDev.Templify.Core.OdtTemplateProcessor.ValidateTemplate(System.IO.Stream! templateStream, System.Collections.Generic.IReadOnlyDictionary! data) -> TriasDev.Templify.Core.ValidationResult! +TriasDev.Templify.Core.TemplateFormat +TriasDev.Templify.Core.TemplateFormat.Docx = 1 -> TriasDev.Templify.Core.TemplateFormat +TriasDev.Templify.Core.TemplateFormat.Odt = 2 -> TriasDev.Templify.Core.TemplateFormat +TriasDev.Templify.Core.TemplateFormat.Unknown = 0 -> TriasDev.Templify.Core.TemplateFormat +TriasDev.Templify.Core.TemplateProcessor +TriasDev.Templify.Core.TemplateProcessor.ProcessTemplate(System.IO.Stream! templateStream, System.IO.Stream! outputStream, System.Collections.Generic.Dictionary! data) -> TriasDev.Templify.Core.ProcessingResult! +TriasDev.Templify.Core.TemplateProcessor.ProcessTemplate(System.IO.Stream! templateStream, System.IO.Stream! outputStream, System.Collections.Generic.IReadOnlyDictionary! data) -> TriasDev.Templify.Core.ProcessingResult! +TriasDev.Templify.Core.TemplateProcessor.ProcessTemplate(System.IO.Stream! templateStream, System.IO.Stream! outputStream, string! jsonData) -> TriasDev.Templify.Core.ProcessingResult! +TriasDev.Templify.Core.TemplateProcessor.ProcessTemplate(byte[]! template, System.Collections.Generic.Dictionary! data, out byte[]! output) -> TriasDev.Templify.Core.ProcessingResult! +TriasDev.Templify.Core.TemplateProcessor.ProcessTemplate(byte[]! template, System.Collections.Generic.IReadOnlyDictionary! data, out byte[]! output) -> TriasDev.Templify.Core.ProcessingResult! +TriasDev.Templify.Core.TemplateProcessor.ProcessTemplate(byte[]! template, string! jsonData, out byte[]! output) -> TriasDev.Templify.Core.ProcessingResult! +TriasDev.Templify.Core.TemplateProcessor.ProcessTemplateFile(string! templatePath, string! outputPath, System.Collections.Generic.Dictionary! data) -> TriasDev.Templify.Core.ProcessingResult! +TriasDev.Templify.Core.TemplateProcessor.ProcessTemplateFile(string! templatePath, string! outputPath, System.Collections.Generic.IReadOnlyDictionary! data) -> TriasDev.Templify.Core.ProcessingResult! +TriasDev.Templify.Core.TemplateProcessor.ProcessTemplateFile(string! templatePath, string! outputPath, string! jsonData) -> TriasDev.Templify.Core.ProcessingResult! +TriasDev.Templify.Core.TemplateProcessor.TemplateProcessor(TriasDev.Templify.Core.PlaceholderReplacementOptions? options = null) -> void +TriasDev.Templify.Core.TemplateProcessor.ValidateTemplate(System.IO.Stream! templateStream) -> TriasDev.Templify.Core.ValidationResult! +TriasDev.Templify.Core.TemplateProcessor.ValidateTemplate(System.IO.Stream! templateStream, System.Collections.Generic.Dictionary! data) -> TriasDev.Templify.Core.ValidationResult! +TriasDev.Templify.Core.TemplateProcessor.ValidateTemplate(System.IO.Stream! templateStream, System.Collections.Generic.IReadOnlyDictionary! data) -> TriasDev.Templify.Core.ValidationResult! +static TriasDev.Templify.Core.TemplateProcessor.DetectFormat(System.IO.Stream! templateStream) -> TriasDev.Templify.Core.TemplateFormat