From 9e38d472b65d2c7f0e9fc988f2b08390ac44bd36 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 10 Nov 2025 18:09:55 +0000 Subject: [PATCH 1/4] Initial plan From 0ceb27fc44239dc8a51c70d6b3f26255df94a042 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 10 Nov 2025 18:25:15 +0000 Subject: [PATCH 2/4] Extract image functionality into ImageHelper class - Created new ImageHelper.cs class with all image-related methods - Moved AddImagePart, CreateImageElement, GetNextDocPrId, TryGetJustification, TryParseLengthToEmu, ConvertJustificationToString, TryCalculateImageDimensions, TryGetPixelSize, TryReadPngDimensions, TryReadJpegDimensions, TryReadGifDimensions to ImageHelper - Updated DocumentAssembler.cs to call methods from ImageHelper - Removed duplicate image code from DocumentAssembler.cs - All 789 tests passing Co-authored-by: stesee <168659+stesee@users.noreply.github.com> --- .gitignore | 3 +- DOCUMENT_ASSEMBLER.md | 78 +++ .../DocumentAssemblerTests.cs | 288 ++++++++++- OpenXmlPowerTools.sln | 9 + .../DocumentAssembler/DocumentAssembler.cs | 100 +++- .../DocumentAssembler/ImageHelper.cs | 484 ++++++++++++++++++ OpenXmlPowerTools/DocumentAssembler/PA.cs | 8 +- OpenXmlPowerTools/HtmlToWmlConverterCore.cs | 16 +- .../DocumentAssembler04.cs | 123 +++++ .../DocumentAssembler04.csproj | 21 + .../DocumentAssembler04/SampleData.xml | 6 + .../DocumentAssembler04/TemplateDocument.docx | Bin 0 -> 2535 bytes 12 files changed, 1121 insertions(+), 15 deletions(-) create mode 100644 DOCUMENT_ASSEMBLER.md create mode 100644 OpenXmlPowerTools/DocumentAssembler/ImageHelper.cs create mode 100644 OpenXmlPowerToolsExamples/DocumentAssembler04/DocumentAssembler04.cs create mode 100644 OpenXmlPowerToolsExamples/DocumentAssembler04/DocumentAssembler04.csproj create mode 100644 OpenXmlPowerToolsExamples/DocumentAssembler04/SampleData.xml create mode 100644 OpenXmlPowerToolsExamples/DocumentAssembler04/TemplateDocument.docx diff --git a/.gitignore b/.gitignore index 1f6ad508..2d302bc6 100644 --- a/.gitignore +++ b/.gitignore @@ -41,4 +41,5 @@ TestResults/ .idea/ _ReSharper.Caches/ -TestResult/ \ No newline at end of file +TestResult/ +ExampleOutput* diff --git a/DOCUMENT_ASSEMBLER.md b/DOCUMENT_ASSEMBLER.md new file mode 100644 index 00000000..14a96dc1 --- /dev/null +++ b/DOCUMENT_ASSEMBLER.md @@ -0,0 +1,78 @@ +# OpenXmlPowerTools + +OpenXmlPowerTools provides guidance and example code for programming with Open XML Documents (DOCX, XLSX, and PPTX). It is based on, and extends the functionality of the Open XML SDK. + +## Document Assembler + +The `DocumentAssembler` module is a powerful tool for generating documents from templates and data. It allows you to create `.docx` files with dynamic content, such as tables, conditional sections, and repeating elements, based on data from an XML file. + +### Key Features + +* **Template-Based Document Generation**: Create documents from Word templates (`.docx`) and populate them with data from XML files. +* **Content Replacement**: Use simple placeholders in your template to insert data from your XML file. +* **Dynamic Tables**: Automatically generate tables in your document based on data from your XML file. +* **Conditional Content**: Include or exclude parts of your document based on conditions in your data. +* **Repeating Content**: Repeat sections of your document for each item in a collection in your data. +* **Error Handling**: The `DocumentAssembler` will report errors in the generated document if it encounters any issues with your template or data. + +### How it Works + +The `DocumentAssembler` works by processing a Word document that contains special markup in content controls or in paragraphs. This markup defines how the document should be assembled based on the provided XML data. + +The process is as follows: + +1. **Create a Template**: Start with a regular Word document (`.docx`). +2. **Add Placeholders**: Use content controls or special syntax in paragraphs to define placeholders for your data. +3. **Provide Data**: Create an XML file that contains the data you want to insert into the document. +4. **Assemble the Document**: Use the `DocumentAssembler.AssembleDocument` method to merge the template and data, producing a new Word document. + +### Template Syntax + +The template syntax uses XML elements within content controls or as text in the format `<#ElementName ... #>`. + +#### Content Replacement + +To replace a placeholder with a value from your XML data, you can use the `Content` element. The `Select` attribute contains an XPath expression to select the data from the XML file. + +**Example:** + +`<#Content Select="Customer/Name" #>` + +#### Tables + +To generate a table, you use the `Table` element. The `Select` attribute specifies the collection of data to iterate over. The table in the template must have a prototype row, which will be repeated for each item in the data. + +**Example:** + +`<#Table Select="Customers/Customer" #>` + +#### Conditional Content + +You can conditionally include content using the `Conditional` element. The `Select` attribute specifies the data to test, and the `Match` or `NotMatch` attribute specifies the value to compare against. + +**Example:** + +`<#Conditional Select="Customer/Country" Match="USA" #> +... content to include if the customer is from the USA ... +<#EndConditional #>` + +#### Repeating Content + +To repeat a section of the document, you can use the `Repeat` element. The `Select` attribute specifies the collection of data to iterate over. + +**Example:** + +`<#Repeat Select="Customers/Customer" #> +... content to repeat for each customer ... +<#EndRepeat #>` + +### Getting Started + +To use the `DocumentAssembler`, you will need to: + +1. Add a reference to the `OpenXmlPowerTools` library in your project. +2. Create a Word template with the appropriate placeholders. +3. Create an XML data file. +4. Call the `DocumentAssembler.AssembleDocument` method to generate your document. + +For more detailed examples and documentation, please refer to the `DocumentAssembler`, `DocumentAssembler01`, `DocumentAssembler02`, and `DocumentAssembler03` projects in the `OpenXmlPowerToolsExamples` directory. \ No newline at end of file diff --git a/OpenXmlPowerTools.Tests/DocumentAssemblerTests.cs b/OpenXmlPowerTools.Tests/DocumentAssemblerTests.cs index e69616a0..5432cd35 100644 --- a/OpenXmlPowerTools.Tests/DocumentAssemblerTests.cs +++ b/OpenXmlPowerTools.Tests/DocumentAssemblerTests.cs @@ -1,5 +1,6 @@ using Codeuctivity.OpenXmlPowerTools; using DocumentFormat.OpenXml.Packaging; +using DocumentFormat.OpenXml.Wordprocessing; using DocumentFormat.OpenXml.Validation; using System; using System.Collections.Generic; @@ -9,6 +10,7 @@ using System.Xml; using System.Xml.Linq; using Xunit; +using System.Globalization; namespace Codeuctivity.Tests { @@ -200,6 +202,290 @@ public void DA103_UseXmlDocument(string name, string data, bool err) Assert.Equal(err, returnedTemplateError); } + [Fact] + public void AssembleDocument_ImageMetadataCreatesImageParts() + { + var template = CreateTemplateDocument("DA-ImageTemplate.docx", + "<# #>", + "<# #>"); + var data = new XElement("Images", + new XElement("Image", TinyPngBase64), + new XElement("Image", TinyPngBase64)); + + var assembled = DocumentAssembler.AssembleDocument(template, data, out var templateError); + Assert.False(templateError); + + using var ms = new MemoryStream(assembled.DocumentByteArray); + using var wDoc = WordprocessingDocument.Open(ms, false); + var mainPart = wDoc.MainDocumentPart; + + Assert.Equal(2, mainPart.ImageParts.Count()); + + var docPrIds = mainPart + .GetXDocument() + .Descendants(WP.docPr) + .Select(d => (int)d.Attribute("id")) + .ToList(); + Assert.Equal(new[] { 1, 2 }, docPrIds); + + var blipIds = mainPart + .GetXDocument() + .Descendants(A.blip) + .Select(b => (string)b.Attribute(R.embed)) + .ToList(); + Assert.Equal(2, blipIds.Count); + Assert.All(blipIds, id => Assert.False(string.IsNullOrEmpty(id))); + } + + [Fact] + public void AssembleDocument_InvalidBase64RaisesTemplateError() + { + var template = CreateTemplateDocument("DA-ImageInvalidTemplate.docx", "<# #>"); + var data = new XElement("Images", new XElement("Image", "not-base64")); + + var assembled = DocumentAssembler.AssembleDocument(template, data, out var templateError); + Assert.True(templateError); + + using var ms = new MemoryStream(assembled.DocumentByteArray); + using var wDoc = WordprocessingDocument.Open(ms, false); + var text = string.Concat(wDoc.MainDocumentPart.GetXDocument().Descendants(W.t).Select(t => (string)t)); + Assert.Contains("Image:", text); + } + + [Fact] + public void AssembleDocument_OptionalImageIsSkippedWhenMissing() + { + var template = CreateTemplateDocument("DA-OptionalImageTemplate.docx", "<# #>"); + var data = new XElement("Images"); + + var assembled = DocumentAssembler.AssembleDocument(template, data, out var templateError); + Assert.False(templateError); + + using var ms = new MemoryStream(assembled.DocumentByteArray); + using var wDoc = WordprocessingDocument.Open(ms, false); + var drawings = wDoc.MainDocumentPart.GetXDocument().Descendants(W.drawing); + Assert.Empty(drawings); + Assert.Empty(wDoc.MainDocumentPart.ImageParts); + } + + [Fact] + public void AssembleDocument_ImageWidthScalesHeight() + { + var template = CreateTemplateDocument("DA-ImageWidth.docx", "<# #>"); + var data = new XElement("Images", new XElement("Image", LargeSamplePngBase64)); + + var assembled = DocumentAssembler.AssembleDocument(template, data, out var templateError); + Assert.False(templateError); + + var (paragraph, extent) = ExtractImageParagraph(assembled); + Assert.Null(paragraph.Element(W.pPr)); + Assert.Equal(EmusPerInch.ToString("0", CultureInfo.InvariantCulture), (string)extent.Attribute("cx")); + Assert.Equal((EmusPerInch / 2).ToString("0", CultureInfo.InvariantCulture), (string)extent.Attribute("cy")); + } + + [Fact] + public void AssembleDocument_ImageHeightScalesWidth() + { + var template = CreateTemplateDocument("DA-ImageHeight.docx", "<# #>"); + var data = new XElement("Images", new XElement("Image", LargeSamplePngBase64)); + + var assembled = DocumentAssembler.AssembleDocument(template, data, out var templateError); + Assert.False(templateError); + + var (paragraph, extent) = ExtractImageParagraph(assembled); + Assert.Null(paragraph.Element(W.pPr)); + Assert.Equal((EmusPerInch * 2).ToString("0", CultureInfo.InvariantCulture), (string)extent.Attribute("cx")); + Assert.Equal(EmusPerInch.ToString("0", CultureInfo.InvariantCulture), (string)extent.Attribute("cy")); + } + + [Fact] + public void AssembleDocument_ImageMaxWidthCentersParagraph() + { + var template = CreateTemplateDocument("DA-ImageMaxWidth.docx", "<# #>"); + var data = new XElement("Images", new XElement("Image", LargeSamplePngBase64)); + + var assembled = DocumentAssembler.AssembleDocument(template, data, out var templateError); + Assert.False(templateError); + + var (paragraph, extent) = ExtractImageParagraph(assembled); + Assert.Equal("center", (string)paragraph.Element(W.pPr)?.Element(W.jc)?.Attribute(W.val)); + var expectedWidth = 3 * EmusPerInch; + var expectedHeight = expectedWidth / 2; + Assert.Equal(expectedWidth.ToString("0", CultureInfo.InvariantCulture), (string)extent.Attribute("cx")); + Assert.Equal(expectedHeight.ToString("0", CultureInfo.InvariantCulture), (string)extent.Attribute("cy")); + } + + [Fact] + public void AssembleDocument_ImageMaxHeightJustifiesParagraph() + { + var template = CreateTemplateDocument("DA-ImageMaxHeight.docx", "<# #>"); + var data = new XElement("Images", new XElement("Image", TallPngBase64)); + + var assembled = DocumentAssembler.AssembleDocument(template, data, out var templateError); + Assert.False(templateError); + + var (paragraph, extent) = ExtractImageParagraph(assembled); + Assert.Equal("both", (string)paragraph.Element(W.pPr)?.Element(W.jc)?.Attribute(W.val)); + var maxHeight = 150 * EmusPerPixel; + var expectedWidth = (200 * EmusPerPixel) * (maxHeight / (400 * EmusPerPixel)); + Assert.Equal(expectedWidth.ToString("0", CultureInfo.InvariantCulture), (string)extent.Attribute("cx")); + Assert.Equal(maxHeight.ToString("0", CultureInfo.InvariantCulture), (string)extent.Attribute("cy")); + } + + [Fact] + public void AssembleDocument_ImageMaxHeightAndWidthAppliesLargestConstraint() + { + var template = CreateTemplateDocument("DA-ImageMaxBoth.docx", "<# #>"); + var data = new XElement("Images", new XElement("Image", LargeSamplePngBase64)); + + var assembled = DocumentAssembler.AssembleDocument(template, data, out var templateError); + Assert.False(templateError); + + var (paragraph, extent) = ExtractImageParagraph(assembled); + Assert.Equal("right", (string)paragraph.Element(W.pPr)?.Element(W.jc)?.Attribute(W.val)); + Assert.Equal((2 * EmusPerInch).ToString("0", CultureInfo.InvariantCulture), (string)extent.Attribute("cx")); + Assert.Equal(EmusPerInch.ToString("0", CultureInfo.InvariantCulture), (string)extent.Attribute("cy")); + } + + [Fact] + public void AssembleDocument_ImageInvalidAlignProducesTemplateError() + { + var template = CreateTemplateDocument("DA-ImageInvalidAlign.docx", "<# #>"); + var data = new XElement("Images", new XElement("Image", LargeSamplePngBase64)); + + var assembled = DocumentAssembler.AssembleDocument(template, data, out var templateError); + Assert.True(templateError); + + var text = GetDocumentText(assembled); + Assert.Contains("Align attribute must be one of Left, Center, Right, or Justify.", text); + } + + [Fact] + public void AssembleDocument_ImageInvalidWidthReportsError() + { + var template = CreateTemplateDocument("DA-ImageInvalidWidth.docx", "<# #>"); + var data = new XElement("Images", new XElement("Image", LargeSamplePngBase64)); + + var assembled = DocumentAssembler.AssembleDocument(template, data, out var templateError); + Assert.True(templateError); + var text = GetDocumentText(assembled); + Assert.Contains("Unable to parse length 'abc'.", text); + } + + [Fact] + public void AssembleDocument_ImageZeroHeightReportsError() + { + var template = CreateTemplateDocument("DA-ImageZeroHeight.docx", "<# #>"); + var data = new XElement("Images", new XElement("Image", LargeSamplePngBase64)); + + var assembled = DocumentAssembler.AssembleDocument(template, data, out var templateError); + Assert.True(templateError); + var text = GetDocumentText(assembled); + Assert.Contains("Length value '0in' must be greater than zero.", text); + } + + [Fact] + public void AssembleDocument_ImageGifDimensionsFallback() + { + var template = CreateTemplateDocument("DA-ImageGifFallback.docx", "<# #>"); + var data = new XElement("Images", new XElement("Image", TruncatedGifBase64)); + + var assembled = DocumentAssembler.AssembleDocument(template, data, out var templateError); + Assert.False(templateError); + + var (paragraph, extent) = ExtractImageParagraph(assembled); + Assert.Null(paragraph.Element(W.pPr)); + Assert.Equal((200 * EmusPerPixel).ToString("0", CultureInfo.InvariantCulture), (string)extent.Attribute("cx")); + Assert.Equal((80 * EmusPerPixel).ToString("0", CultureInfo.InvariantCulture), (string)extent.Attribute("cy")); + } + + [Fact] + public void AssembleDocument_ImageRespectsMaxDimensionsAndAlign() + { + var template = CreateTemplateDocument("DA-ImageConstraintTemplate.docx", "<# #>"); + var data = new XElement("ImageData", new XElement("Image", LargeSamplePngBase64)); + + var assembled = DocumentAssembler.AssembleDocument(template, data, out var templateError); + Assert.False(templateError); + + using var ms = new MemoryStream(assembled.DocumentByteArray); + using var wDoc = WordprocessingDocument.Open(ms, false); + var paragraph = wDoc.MainDocumentPart.GetXDocument().Descendants(W.p).FirstOrDefault(p => p.Descendants(W.drawing).Any()); + Assert.NotNull(paragraph); + var jc = paragraph!.Element(W.pPr)?.Element(W.jc)?.Attribute(W.val)?.Value; + Assert.Equal("center", jc); + + var extent = paragraph.Descendants(WP.extent).First(); + Assert.Equal(2857500d.ToString("0", CultureInfo.InvariantCulture), (string)extent.Attribute("cx")); + Assert.Equal(1428750d.ToString("0", CultureInfo.InvariantCulture), (string)extent.Attribute("cy")); + } + + [Fact] + public void AssembleDocument_ImageUsesExplicitDimensions() + { + var template = CreateTemplateDocument("DA-ImageExplicitSizeTemplate.docx", "<# #>"); + var data = new XElement("ImageData", new XElement("Image", LargeSamplePngBase64)); + + var assembled = DocumentAssembler.AssembleDocument(template, data, out var templateError); + Assert.False(templateError); + + using var ms = new MemoryStream(assembled.DocumentByteArray); + using var wDoc = WordprocessingDocument.Open(ms, false); + var paragraph = wDoc.MainDocumentPart.GetXDocument().Descendants(W.p).FirstOrDefault(p => p.Descendants(W.drawing).Any()); + Assert.NotNull(paragraph); + var jc = paragraph!.Element(W.pPr)?.Element(W.jc)?.Attribute(W.val)?.Value; + Assert.Equal("right", jc); + + var extent = paragraph.Descendants(WP.extent).First(); + Assert.Equal(1828800d.ToString("0", CultureInfo.InvariantCulture), (string)extent.Attribute("cx")); + Assert.Equal(914400d.ToString("0", CultureInfo.InvariantCulture), (string)extent.Attribute("cy")); + } + + private static WmlDocument CreateTemplateDocument(string fileName, params string[] paragraphTexts) + { + using var ms = new MemoryStream(); + using (var wDoc = WordprocessingDocument.Create(ms, DocumentFormat.OpenXml.WordprocessingDocumentType.Document)) + { + var mainPart = wDoc.AddMainDocumentPart(); + var body = new Body(paragraphTexts.Select(text => + new Paragraph( + new Run( + new Text(text) { Space = DocumentFormat.OpenXml.SpaceProcessingModeValues.Preserve })))); + mainPart.Document = new Document(body); + mainPart.Document.Save(); + } + + return new WmlDocument(fileName, ms.ToArray()); + } + + private static (XElement Paragraph, XElement Extent) ExtractImageParagraph(WmlDocument document) + { + using var ms = new MemoryStream(); + ms.Write(document.DocumentByteArray, 0, document.DocumentByteArray.Length); + using var wDoc = WordprocessingDocument.Open(ms, false); + var main = wDoc.MainDocumentPart.GetXDocument(); + var paragraph = main.Descendants(W.p).First(p => p.Descendants(W.drawing).Any()); + var extent = paragraph.Descendants(WP.extent).First(); + return (paragraph, extent); + } + + private static string GetDocumentText(WmlDocument document) + { + using var ms = new MemoryStream(); + ms.Write(document.DocumentByteArray, 0, document.DocumentByteArray.Length); + using var wDoc = WordprocessingDocument.Open(ms, false); + var main = wDoc.MainDocumentPart.GetXDocument(); + return string.Concat(main.Descendants(W.t).Select(t => (string)t)); + } + + private const double EmusPerInch = 914400d; + private const double EmusPerPixel = EmusPerInch / 96d; + private const string TinyPngBase64 = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR4nGMAAQAABQABDQottAAAAABJRU5ErkJggg=="; + private const string LargeSamplePngBase64 = "iVBORw0KGgoAAAANSUhEUgAAAZAAAADICAIAAABJdyC1AAACvElEQVR4nO3WsQ3DQBAEsXvB/bf8akHZYQyygo0Ge2buABQ82wMAvhIsIEOwgAzBAjIEC8gQLCBDsIAMwQIyBAvIECwgQ7CADMECMgQLyBAsIEOwgAzBAjIEC8gQLCBDsIAMwQIyBAvIECwgQ7CADMECMgQLyBAsIEOwgAzBAjIEC8gQLCBDsIAMwQIyBAvIECwgQ7CADMECMgQLyPhtDyi5c7Yn8J/O3O0JDR4WkCFYQIZgARmCBWQIFpAhWECGYAEZggVkCBaQIVhAhmABGYIFZAgWkCFYQIZgARmCBWQIFpAhWECGYAEZggVkCBaQIVhAhmABGYIFZAgWkCFYQIZgARmCBWQIFpAhWECGYAEZggVkCBaQIVhAhmABGYIFZAgWkCFYQIZgARmCBWQIFpAhWECGYAEZggVkCBaQIVhAhmABGYIFZAgWkCFYQIZgARmCBWQIFpAhWECGYAEZggVkCBaQIVhAhmABGYIFZAgWkCFYQIZgARmCBWQIFpAhWECGYAEZggVkCBaQIVhAhmABGYIFZAgWkCFYQIZgARmCBWScmbu9AeATDwvIECwgQ7CADMECMgQLyBAsIEOwgAzBAjIEC8gQLCBDsIAMwQIyBAvIECwgQ7CADMECMgQLyBAsIEOwgAzBAjIEC8gQLCBDsIAMwQIyBAvIECwgQ7CADMECMgQLyBAsIEOwgAzBAjIEC8gQLCBDsIAMwQIyBAvIECwgQ7CADMECMgQLyBAsIEOwgAzBAjIEC8gQLCBDsIAMwQKm4gVVxAWP2c47WwAAAABJRU5ErkJggg=="; + + private const string WidePngBase64 = "iVBORw0KGgoAAAANSUhEUgAAAZAAAADICAIAAABJdyC1AAACuUlEQVR4nO3UMQ7CQBAEwT3EvxEvXz/BZKalqniCifrs7gAUvGfmnO/TNwBu7H5edxuAfyFYQIZgARmCBWQIFpAhWECGYAEZggVkCBaQIVhAhmABGYIFZAgWkCFYQIZgARmCBWQIFpAhWECGYAEZggVkCBaQIVhAhmABGYIFZAgWkCFYQIZgARmCBWQIFpAhWECGYAEZggVkCBaQIVhAhmABGYIFZAgWkCFYQIZgARmCBWQIFpAhWECGYAEZggVkCBaQIVhAhmABGYIFZAgWkCFYQIZgARmCBWQIFpAhWECGYAEZggVkCBaQIVhAhmABGYIFFzMzu2y8A5u4PQZkIj89BEMEAAAAASUVORK5CYII="; + private const string TallPngBase64 = "iVBORw0KGgoAAAANSUhEUgAAAMgAAAGQCAIAAABkkLjnAAAEF0lEQVR4nO3S0QkCURAEwX1i3mLke0lcI3hVAQzz0Wd3B+72npnzPbfv8mT72devP/CfhEVCWCSERUJYJIRFQlgkhEVCWCSERUJYJIRFQlgkhEVCWCSERUJYJIRFQlgkhEVCWCSERUJYJIRFQlgkhEVCWCSERUJYJIRFQlgkhEVCWCSERUJYJIRFQlgkhEVCWCSERUJYJIRFQlgkhEVCWCSERUJYJIRFQlgkhEVCWCSERUJYJIRFQlgkhEVCWCSERUJYJIRFQlgkhEVCWCSERUJYJIRFQlgkhEVCWCSERUJYJIRFQlgkhEVCWCSERUJYJIRFQlgkhEVCWCSERUJYJIRFQlgkhEVCWCSERUJYJIRFQlgkhEVCWCSERUJYJIRFQlgkhEVCWCSERUJYJIRFQlgkhEVCWCSERUJYJIRFQlgkhEVCWCSERUJYJIRFQlgkhEVCWCSERUJYJIRFQlgkhEVCWCSERUJYJIRFQlgkhEVCWCSERUJYJIRFQlgkhEVCWCSERUJYJIRFQlgkhEVCWCSERUJYJIRFQlgkhEVCWCSERUJYJIRFQlgkhEVCWCSERUJYJIRFQlgkhEVCWCSERUJYJIRFQlgkhEVCWCSERUJYJIRFQlgkhEVCWCSERUJYJIRFQlgkhEVCWCSERUJYJIRFQlgkhEVCWCSERUJYJIRFQlgkhEVCWCSERUJYJIRFQlgkhEVCWCSERUJYJIRFQlgkzu42y8yTXVnDDBu1Y983AAAAAElFTkSuQmCC"; + private const string TruncatedGifBase64 = "R0lGODlhyABQAA=="; private static readonly List s_ExpectedErrors = new List() { "The 'http://schemas.openxmlformats.org/wordprocessingml/2006/main:evenHBand' attribute is not declared.", @@ -218,4 +504,4 @@ public void DA103_UseXmlDocument(string name, string data, bool err) "The 'http://schemas.openxmlformats.org/wordprocessingml/2006/main:oddVBand' attribute is not declared.", }; } -} \ No newline at end of file +} diff --git a/OpenXmlPowerTools.sln b/OpenXmlPowerTools.sln index 57fdb2bd..77971c1b 100644 --- a/OpenXmlPowerTools.sln +++ b/OpenXmlPowerTools.sln @@ -78,6 +78,10 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "MarkupSimplifierApp", "OpenXmlPowerToolsExamples\MarkupSimplifierApp\MarkupSimplifierApp.csproj", "{6731E031-9C81-48FB-97A7-0E945993BCE2}" EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "OpenXmlPowerToolsExamples", "OpenXmlPowerToolsExamples", "{AA7E2DBC-70B3-4F8A-AC47-4416CDA9F3DA}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DocumentAssembler04", "OpenXmlPowerToolsExamples\DocumentAssembler04\DocumentAssembler04.csproj", "{94E64B7D-BB4A-4478-B3BF-69F83C2FD379}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -208,6 +212,10 @@ Global {6731E031-9C81-48FB-97A7-0E945993BCE2}.Debug|Any CPU.Build.0 = Debug|Any CPU {6731E031-9C81-48FB-97A7-0E945993BCE2}.Release|Any CPU.ActiveCfg = Release|Any CPU {6731E031-9C81-48FB-97A7-0E945993BCE2}.Release|Any CPU.Build.0 = Release|Any CPU + {94E64B7D-BB4A-4478-B3BF-69F83C2FD379}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {94E64B7D-BB4A-4478-B3BF-69F83C2FD379}.Debug|Any CPU.Build.0 = Debug|Any CPU + {94E64B7D-BB4A-4478-B3BF-69F83C2FD379}.Release|Any CPU.ActiveCfg = Release|Any CPU + {94E64B7D-BB4A-4478-B3BF-69F83C2FD379}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -242,6 +250,7 @@ Global {D4078011-2611-46A7-8A30-55E4AB8FA786} = {A83D6B58-6D38-46AF-8C20-5CFC170A1063} {DCE8EC51-1E58-49A0-82CF-5BE269FA0A9D} = {A83D6B58-6D38-46AF-8C20-5CFC170A1063} {6731E031-9C81-48FB-97A7-0E945993BCE2} = {A83D6B58-6D38-46AF-8C20-5CFC170A1063} + {94E64B7D-BB4A-4478-B3BF-69F83C2FD379} = {AA7E2DBC-70B3-4F8A-AC47-4416CDA9F3DA} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {E623EFF5-2CA4-4FA0-B3AB-53F921DA212E} diff --git a/OpenXmlPowerTools/DocumentAssembler/DocumentAssembler.cs b/OpenXmlPowerTools/DocumentAssembler/DocumentAssembler.cs index c838e7bf..94d4a7a0 100644 --- a/OpenXmlPowerTools/DocumentAssembler/DocumentAssembler.cs +++ b/OpenXmlPowerTools/DocumentAssembler/DocumentAssembler.cs @@ -1,4 +1,5 @@ using DocumentFormat.OpenXml.Packaging; +using DocumentFormat.OpenXml.Wordprocessing; using System; using System.Collections; using System.Collections.Generic; @@ -66,7 +67,7 @@ private static void ProcessTemplatePart(XElement data, TemplateError te, OpenXml ProcessOrphanEndRepeatEndConditional(xDocRoot, te); // do the actual content replacement - xDocRoot = ContentReplacementTransform(xDocRoot, data, te) as XElement; + xDocRoot = ContentReplacementTransform(xDocRoot, data, te, part) as XElement; xDoc.Elements().First().ReplaceWith(xDocRoot); part.PutXDocument(); @@ -508,6 +509,25 @@ private class RunReplacementInfo ", } }, + { + PA.Image, + new PASchemaSet() { + XsdMarkup = + @" + + + + + + + + + + + + ", + } + }, { PA.Table, new PASchemaSet() { @@ -601,7 +621,7 @@ private class RunReplacementInfo private static Dictionary s_PASchemaSets; - private static object? ContentReplacementTransform(XNode node, XElement data, TemplateError templateError) + private static object? ContentReplacementTransform(XNode node, XElement data, TemplateError templateError, OpenXmlPart owningPart) { if (node is XElement element) { @@ -612,7 +632,7 @@ private class RunReplacementInfo var xPath = (string)element.Attribute(PA.Select); var optionalString = (string)element.Attribute(PA.Optional); - var optional = (optionalString != null && optionalString.ToLower() == "true"); + var optional = bool.TryParse(optionalString, out var optionalValue) && optionalValue; string newValue; try @@ -649,11 +669,73 @@ private class RunReplacementInfo return list; } } + if (element.Name == PA.Image) + { + var xPath = (string)element.Attribute(PA.Select); + var optionalString = (string)element.Attribute(PA.Optional); + var optional = bool.TryParse(optionalString, out var optionalValue) && optionalValue; + var alignString = (string)element.Attribute(PA.Align); + var widthAttr = (string)element.Attribute(PA.Width); + var heightAttr = (string)element.Attribute(PA.Height); + var maxWidthAttr = (string)element.Attribute(PA.MaxWidth); + var maxHeightAttr = (string)element.Attribute(PA.MaxHeight); + + string base64Content; + try + { + base64Content = EvaluateXPathToString(data, xPath, optional); + } + catch (XPathException e) + { + return CreateContextErrorMessage(element, "XPathException: " + e.Message, templateError); + } + + if (string.IsNullOrEmpty(base64Content)) + { + return null; + } + + byte[] imageBytes; + try + { + imageBytes = Convert.FromBase64String(base64Content); + } + catch (FormatException e) + { + return CreateContextErrorMessage(element, "Image: " + e.Message, templateError); + } + + if (!ImageHelper.TryGetJustification(alignString, out var justification, out var justificationError)) + { + return CreateContextErrorMessage(element, justificationError, templateError); + } + + if (owningPart == null) + { + throw new OpenXmlPowerToolsException("Image: owning part is not available."); + } + + if (!ImageHelper.TryCalculateImageDimensions(imageBytes, widthAttr, heightAttr, maxWidthAttr, maxHeightAttr, out var widthEmu, out var heightEmu, out var sizeError)) + { + return CreateContextErrorMessage(element, sizeError, templateError); + } + + var imagePart = ImageHelper.AddImagePart(owningPart); + using (var stream = new MemoryStream(imageBytes)) + { + imagePart.FeedData(stream); + } + + var relationshipId = owningPart.GetIdOfPart(imagePart); + var docPrId = ImageHelper.GetNextDocPrId(owningPart); + var imageElement = ImageHelper.CreateImageElement(relationshipId, docPrId, widthEmu, heightEmu, justification); + return imageElement; + } if (element.Name == PA.Repeat) { var selector = (string)element.Attribute(PA.Select); var optionalString = (string)element.Attribute(PA.Optional); - var optional = (optionalString != null && optionalString.ToLower() == "true"); + var optional = bool.TryParse(optionalString, out var optionalValue) && optionalValue; IEnumerable repeatingData; try @@ -676,7 +758,7 @@ private class RunReplacementInfo { var content = element .Elements() - .Select(e => ContentReplacementTransform(e, d, templateError)) + .Select(e => ContentReplacementTransform(e, d, templateError, owningPart)) .ToList(); return content; }) @@ -706,7 +788,7 @@ private class RunReplacementInfo .Skip(2) .ToList(); var footerRows = footerRowsBeforeTransform - .Select(x => ContentReplacementTransform(x, data, templateError)) + .Select(x => ContentReplacementTransform(x, data, templateError, owningPart)) .ToList(); if (protoRow == null) { @@ -784,14 +866,14 @@ private class RunReplacementInfo if ((match != null && testValue == match) || (notMatch != null && testValue != notMatch)) { - var content = element.Elements().Select(e => ContentReplacementTransform(e, data, templateError)); + var content = element.Elements().Select(e => ContentReplacementTransform(e, data, templateError, owningPart)); return content; } return null; } return new XElement(element.Name, element.Attributes(), - element.Nodes().Select(n => ContentReplacementTransform(n, data, templateError))); + element.Nodes().Select(n => ContentReplacementTransform(n, data, templateError, owningPart))); } return node; } @@ -884,4 +966,4 @@ private static string EvaluateXPathToString(XElement element, string xPath, bool return xPathSelectResult.ToString(); } } -} \ No newline at end of file +} diff --git a/OpenXmlPowerTools/DocumentAssembler/ImageHelper.cs b/OpenXmlPowerTools/DocumentAssembler/ImageHelper.cs new file mode 100644 index 00000000..35637243 --- /dev/null +++ b/OpenXmlPowerTools/DocumentAssembler/ImageHelper.cs @@ -0,0 +1,484 @@ +using DocumentFormat.OpenXml.Packaging; +using DocumentFormat.OpenXml.Wordprocessing; +using SkiaSharp; +using System.Globalization; +using System.Linq; +using System.Xml.Linq; + +namespace Codeuctivity.OpenXmlPowerTools +{ + /// + /// Helper class for processing images in DocumentAssembler. + /// Handles image insertion, dimension calculation, and format detection. + /// + internal static class ImageHelper + { + private const double EmusPerInch = 914400d; + private const double EmusPerPoint = 12700d; + private const double EmusPerPixel = 914400d / 96d; + private const double EmusPerMillimeter = EmusPerInch / 25.4d; + private const double EmusPerCentimeter = EmusPerMillimeter * 10d; + + /// + /// Adds an image part to the appropriate OpenXml part type. + /// + internal static ImagePart AddImagePart(OpenXmlPart part) + { + return part switch + { + MainDocumentPart mainDocumentPart => mainDocumentPart.AddImagePart(ImagePartType.Png), + HeaderPart headerPart => headerPart.AddImagePart(ImagePartType.Png), + FooterPart footerPart => footerPart.AddImagePart(ImagePartType.Png), + FootnotesPart footnotesPart => footnotesPart.AddImagePart(ImagePartType.Png), + EndnotesPart endnotesPart => endnotesPart.AddImagePart(ImagePartType.Png), + _ => throw new OpenXmlPowerToolsException($"Image: unsupported part type {part.GetType().Name}."), + }; + } + + /// + /// Creates an XML element representing an image in a Word document. + /// + internal static XElement CreateImageElement(string relationshipId, int docPrId, double widthEmu, double heightEmu, JustificationValues? justification) + { + var widthAttribute = widthEmu.ToString("0", CultureInfo.InvariantCulture); + var heightAttribute = heightEmu.ToString("0", CultureInfo.InvariantCulture); + XElement? paragraphProperties = null; + if (justification.HasValue && justification.Value != JustificationValues.Left) + { + paragraphProperties = new XElement(W.pPr, + new XElement(W.jc, new XAttribute(W.val, ConvertJustificationToString(justification.Value)))); + } + + var pictureName = $"Picture {docPrId}"; + var element = + new XElement(W.p, + paragraphProperties, + new XElement(W.r, + new XElement(W.drawing, + new XElement(WP.inline, + new XElement(WP.extent, new XAttribute("cx", widthAttribute), new XAttribute("cy", heightAttribute)), + new XElement(WP.effectExtent, new XAttribute("l", "0"), new XAttribute("t", "0"), new XAttribute("r", "0"), new XAttribute("b", "0")), + new XElement(WP.docPr, new XAttribute("id", docPrId), new XAttribute("name", pictureName)), + new XElement(WP.cNvGraphicFramePr, + new XElement(A.graphicFrameLocks, new XAttribute("noChangeAspect", "1"))), + new XElement(A.graphic, + new XElement(A.graphicData, new XAttribute("uri", "http://schemas.openxmlformats.org/drawingml/2006/picture"), + new XElement(Pic._pic, + new XElement(Pic.nvPicPr, + new XElement(Pic.cNvPr, new XAttribute("id", "0"), new XAttribute("name", pictureName)), + new XElement(Pic.cNvPicPr)), + new XElement(Pic.blipFill, + new XElement(A.blip, new XAttribute(R.embed, relationshipId)), + new XElement(A.stretch, + new XElement(A.fillRect))), + new XElement(Pic.spPr, + new XElement(A.xfrm, + new XElement(A.off, new XAttribute("x", "0"), new XAttribute("y", "0")), + new XElement(A.ext, new XAttribute("cx", widthAttribute), new XAttribute("cy", heightAttribute))), + new XElement(A.prstGeom, new XAttribute("prst", "rect"), + new XElement(A.avLst)))))))))); + return element; + } + + /// + /// Gets the next unique document property ID for an image. + /// + internal static int GetNextDocPrId(OpenXmlPart part) + { + var tracker = part.Annotation(); + if (tracker == null) + { + var existingIds = part + .GetXDocument() + .Descendants(WP.docPr) + .Select(dp => (int?)dp.Attribute("id") ?? 0); + var maxId = existingIds.Any() ? existingIds.Max() : 0; + tracker = new ImageIdTracker { NextId = maxId + 1 }; + part.AddAnnotation(tracker); + } + + return tracker.NextId++; + } + + /// + /// Tracks the next available image ID for a document part. + /// + private sealed class ImageIdTracker + { + public int NextId { get; set; } + } + + /// + /// Tries to parse an alignment string into a JustificationValues enum. + /// + internal static bool TryGetJustification(string? align, out JustificationValues? justification, out string errorMessage) + { + justification = null; + errorMessage = string.Empty; + if (string.IsNullOrWhiteSpace(align)) + { + return true; + } + + switch (align.Trim().ToLowerInvariant()) + { + case "left": + justification = JustificationValues.Left; + return true; + case "center": + case "centre": + justification = JustificationValues.Center; + return true; + case "right": + justification = JustificationValues.Right; + return true; + case "justify": + case "both": + justification = JustificationValues.Both; + return true; + default: + errorMessage = "Image: Align attribute must be one of Left, Center, Right, or Justify."; + return false; + } + } + + /// + /// Tries to parse a length string with units (px, pt, cm, mm, in, emu) into EMUs. + /// + internal static bool TryParseLengthToEmu(string? rawValue, out double? emuValue, out string errorMessage) + { + emuValue = null; + errorMessage = string.Empty; + if (string.IsNullOrWhiteSpace(rawValue)) + { + return true; + } + + var value = rawValue.Trim().ToLowerInvariant(); + double multiplier; + if (value.EndsWith("px")) + { + multiplier = EmusPerPixel; + value = value[..^2]; + } + else if (value.EndsWith("pt")) + { + multiplier = EmusPerPoint; + value = value[..^2]; + } + else if (value.EndsWith("cm")) + { + multiplier = EmusPerCentimeter; + value = value[..^2]; + } + else if (value.EndsWith("mm")) + { + multiplier = EmusPerMillimeter; + value = value[..^2]; + } + else if (value.EndsWith("in")) + { + multiplier = EmusPerInch; + value = value[..^2]; + } + else if (value.EndsWith("emu")) + { + multiplier = 1d; + value = value[..^3]; + } + else + { + multiplier = EmusPerPixel; + } + + if (!double.TryParse(value, NumberStyles.Float, CultureInfo.InvariantCulture, out var parsed)) + { + errorMessage = $"Image: Unable to parse length '{rawValue}'."; + return false; + } + + if (parsed <= 0) + { + errorMessage = $"Image: Length value '{rawValue}' must be greater than zero."; + return false; + } + + emuValue = parsed * multiplier; + return true; + } + + /// + /// Converts a JustificationValues enum to its string representation. + /// + internal static string ConvertJustificationToString(JustificationValues value) + { + if (value == JustificationValues.Left) + { + return "left"; + } + + if (value == JustificationValues.Center) + { + return "center"; + } + + if (value == JustificationValues.Right) + { + return "right"; + } + + if (value == JustificationValues.Both) + { + return "both"; + } + + if (value == JustificationValues.Distribute) + { + return "distribute"; + } + + if (value == JustificationValues.Start) + { + return "start"; + } + + if (value == JustificationValues.End) + { + return "end"; + } + + return value.ToString().ToLowerInvariant(); + } + + /// + /// Calculates image dimensions in EMUs, applying width, height, and max constraints while preserving aspect ratio. + /// + internal static bool TryCalculateImageDimensions( + byte[] imageBytes, + string? widthAttr, + string? heightAttr, + string? maxWidthAttr, + string? maxHeightAttr, + out double widthEmu, + out double heightEmu, + out string errorMessage) + { + widthEmu = 0; + heightEmu = 0; + errorMessage = string.Empty; + + if (!TryGetPixelSize(imageBytes, out var pixelWidth, out var pixelHeight, out errorMessage)) + { + return false; + } + + var actualWidthEmu = pixelWidth * EmusPerPixel; + var actualHeightEmu = pixelHeight * EmusPerPixel; + + if (!TryParseLengthToEmu(widthAttr, out var widthOverrideEmu, out errorMessage)) + { + return false; + } + + if (!TryParseLengthToEmu(heightAttr, out var heightOverrideEmu, out errorMessage)) + { + return false; + } + + if (!TryParseLengthToEmu(maxWidthAttr, out var maxWidthEmu, out errorMessage)) + { + return false; + } + + if (!TryParseLengthToEmu(maxHeightAttr, out var maxHeightEmu, out errorMessage)) + { + return false; + } + + widthEmu = actualWidthEmu; + heightEmu = actualHeightEmu; + + if (widthOverrideEmu.HasValue && heightOverrideEmu.HasValue) + { + widthEmu = widthOverrideEmu.Value; + heightEmu = heightOverrideEmu.Value; + } + else if (widthOverrideEmu.HasValue) + { + widthEmu = widthOverrideEmu.Value; + heightEmu = widthOverrideEmu.Value * actualHeightEmu / actualWidthEmu; + } + else if (heightOverrideEmu.HasValue) + { + heightEmu = heightOverrideEmu.Value; + widthEmu = heightOverrideEmu.Value * actualWidthEmu / actualHeightEmu; + } + + if (maxWidthEmu.HasValue && widthEmu > maxWidthEmu.Value) + { + var scale = maxWidthEmu.Value / widthEmu; + widthEmu = maxWidthEmu.Value; + heightEmu *= scale; + } + + if (maxHeightEmu.HasValue && heightEmu > maxHeightEmu.Value) + { + var scale = maxHeightEmu.Value / heightEmu; + heightEmu = maxHeightEmu.Value; + widthEmu *= scale; + } + + if (widthEmu <= 0 || heightEmu <= 0) + { + errorMessage = "Image: Calculated dimensions are invalid."; + return false; + } + + return true; + } + + /// + /// Gets pixel dimensions of an image, trying SkiaSharp first and falling back to header inspection. + /// + internal static bool TryGetPixelSize(byte[] imageBytes, out int width, out int height, out string errorMessage) + { + width = 0; + height = 0; + errorMessage = string.Empty; + + try + { + using var bitmap = SKBitmap.Decode(imageBytes); + if (bitmap != null && bitmap.Width > 0 && bitmap.Height > 0) + { + width = bitmap.Width; + height = bitmap.Height; + return true; + } + } + catch + { + // ignore and fall back to header-based detection + } + + if (TryReadPngDimensions(imageBytes, out width, out height)) + { + return true; + } + + if (TryReadJpegDimensions(imageBytes, out width, out height)) + { + return true; + } + + if (TryReadGifDimensions(imageBytes, out width, out height)) + { + return true; + } + + errorMessage = "Image: Unable to determine image dimensions."; + return false; + } + + /// + /// Reads PNG image dimensions from the file header. + /// + private static bool TryReadPngDimensions(byte[] bytes, out int width, out int height) + { + width = 0; + height = 0; + if (bytes.Length < 24) + { + return false; + } + + var signature = new byte[] { 137, 80, 78, 71, 13, 10, 26, 10 }; + for (var i = 0; i < signature.Length; i++) + { + if (bytes[i] != signature[i]) + { + return false; + } + } + + if (bytes[12] != 0x49 || bytes[13] != 0x48 || bytes[14] != 0x44 || bytes[15] != 0x52) + { + return false; + } + + width = (bytes[16] << 24) | (bytes[17] << 16) | (bytes[18] << 8) | bytes[19]; + height = (bytes[20] << 24) | (bytes[21] << 16) | (bytes[22] << 8) | bytes[23]; + return width > 0 && height > 0; + } + + /// + /// Reads JPEG image dimensions from the file header. + /// + private static bool TryReadJpegDimensions(byte[] bytes, out int width, out int height) + { + width = 0; + height = 0; + if (bytes.Length < 4 || bytes[0] != 0xFF || bytes[1] != 0xD8) + { + return false; + } + + var index = 2; + while (index + 9 < bytes.Length) + { + if (bytes[index] != 0xFF) + { + index++; + continue; + } + + var marker = bytes[index + 1]; + index += 2; + + if (marker == 0xD8 || marker == 0xD9) + { + continue; + } + + if (index + 2 > bytes.Length) + { + break; + } + + var length = (bytes[index] << 8) + bytes[index + 1]; + if (length < 2 || index + length > bytes.Length) + { + break; + } + + if (marker >= 0xC0 && marker <= 0xC3) + { + height = (bytes[index + 3] << 8) + bytes[index + 4]; + width = (bytes[index + 5] << 8) + bytes[index + 6]; + return width > 0 && height > 0; + } + + index += length; + } + + return false; + } + + /// + /// Reads GIF image dimensions from the file header. + /// + private static bool TryReadGifDimensions(byte[] bytes, out int width, out int height) + { + width = 0; + height = 0; + if (bytes.Length < 10 || bytes[0] != 'G' || bytes[1] != 'I' || bytes[2] != 'F') + { + return false; + } + + width = bytes[6] | (bytes[7] << 8); + height = bytes[8] | (bytes[9] << 8); + return width > 0 && height > 0; + } + } +} diff --git a/OpenXmlPowerTools/DocumentAssembler/PA.cs b/OpenXmlPowerTools/DocumentAssembler/PA.cs index a4482288..4271a402 100644 --- a/OpenXmlPowerTools/DocumentAssembler/PA.cs +++ b/OpenXmlPowerTools/DocumentAssembler/PA.cs @@ -7,6 +7,7 @@ public partial class DocumentAssembler private static class PA { public static readonly XName Content = "Content"; + public static readonly XName Image = "Image"; public static readonly XName Table = "Table"; public static readonly XName Repeat = "Repeat"; public static readonly XName EndRepeat = "EndRepeat"; @@ -14,9 +15,14 @@ private static class PA public static readonly XName EndConditional = "EndConditional"; public static readonly XName Select = "Select"; public static readonly XName Optional = "Optional"; + public static readonly XName Align = "Align"; + public static readonly XName Width = "Width"; + public static readonly XName Height = "Height"; + public static readonly XName MaxWidth = "MaxWidth"; + public static readonly XName MaxHeight = "MaxHeight"; public static readonly XName Match = "Match"; public static readonly XName NotMatch = "NotMatch"; public static readonly XName Depth = "Depth"; } } -} \ No newline at end of file +} diff --git a/OpenXmlPowerTools/HtmlToWmlConverterCore.cs b/OpenXmlPowerTools/HtmlToWmlConverterCore.cs index 02542519..f2dc92a6 100644 --- a/OpenXmlPowerTools/HtmlToWmlConverterCore.cs +++ b/OpenXmlPowerTools/HtmlToWmlConverterCore.cs @@ -3021,8 +3021,18 @@ private static XElement GetRunProperties(XText textNode, HtmlToWmlConverterSetti XElement? szCs = null; if (fontSizeTPoint != null) { - sz = new XElement(W.sz, new XAttribute(W.val, (int)((double)fontSizeTPoint * 2))); - szCs = new XElement(W.szCs, new XAttribute(W.val, (int)((double)fontSizeTPoint * 2))); + var sizeInPoints = (double)fontSizeTPoint; + if (!double.IsNaN(sizeInPoints) && !double.IsInfinity(sizeInPoints)) + { + var halfPoints = sizeInPoints * 2.0; + if (halfPoints >= 1 && halfPoints <= 1638) + { + var roundedHalfPoints = (int)Math.Round(halfPoints, MidpointRounding.AwayFromZero); + roundedHalfPoints = Math.Max(1, Math.Min(roundedHalfPoints, 1638)); + sz = new XElement(W.sz, new XAttribute(W.val, roundedHalfPoints)); + szCs = new XElement(W.szCs, new XAttribute(W.val, roundedHalfPoints)); + } + } } XElement? strike = null; @@ -5517,4 +5527,4 @@ public static void UpdateThemePart(WordprocessingDocument wDoc, XElement html) wDoc.MainDocumentPart.ThemePart.PutXDocument(); } } -} \ No newline at end of file +} diff --git a/OpenXmlPowerToolsExamples/DocumentAssembler04/DocumentAssembler04.cs b/OpenXmlPowerToolsExamples/DocumentAssembler04/DocumentAssembler04.cs new file mode 100644 index 00000000..431d637b --- /dev/null +++ b/OpenXmlPowerToolsExamples/DocumentAssembler04/DocumentAssembler04.cs @@ -0,0 +1,123 @@ +using Codeuctivity.OpenXmlPowerTools; +using SkiaSharp; +using System; +using System.IO; +using System.Xml.Linq; + +namespace DocumentAssembler04 +{ + internal class Program + { + private static void Main() + { + var outputDir = CreateOutputDirectory(); + + var templatePath = LocateTemplate(); + if (!File.Exists(templatePath)) + { + Console.WriteLine($"TemplateDocument.docx not found at {templatePath}"); + return; + } + + var data = LoadOrCreateSampleData(); + + var templateDocument = new WmlDocument(templatePath); + var assembled = DocumentAssembler.AssembleDocument(templateDocument, data, out var templateError); + if (templateError) + { + Console.WriteLine("The template produced validation errors. Inspect the generated document for highlighted issues."); + } + + var outputDocx = Path.Combine(outputDir.FullName, "DocumentWithImage.docx"); + assembled.SaveAs(outputDocx); + Console.WriteLine($"Generated document: {outputDocx}"); + } + + private static DirectoryInfo CreateOutputDirectory() + { + var now = DateTime.Now; + var dirName = $"ExampleOutput-{now.Year - 2000:00}-{now.Month:00}-{now.Day:00}-{now.Hour:00}{now.Minute:00}{now.Second:00}"; + var directory = new DirectoryInfo(dirName); + if (!directory.Exists) + { + directory.Create(); + } + return directory; + } + + private static string LocateTemplate() + { + var baseDir = AppContext.BaseDirectory; + var templateInOutput = Path.Combine(baseDir, "TemplateDocument.docx"); + if (File.Exists(templateInOutput)) + { + return templateInOutput; + } + + // fallback to project directory (useful when running from source) + return Path.Combine(AppContext.BaseDirectory, "..", "..", "..", "TemplateDocument.docx"); + } + + private static XElement LoadOrCreateSampleData() + { + var samplePath = Path.Combine(AppContext.BaseDirectory, "SampleData.xml"); + if (File.Exists(samplePath)) + { + return XElement.Load(samplePath); + } + + var samples = new XElement("Images", + new XElement("Image", GenerateImageBase64(400, 200, SKColors.SteelBlue, "IMG1")), + new XElement("Image", GenerateImageBase64(800, 400, SKColors.Teal, "IMG2")), + new XElement("Image", GenerateImageBase64(320, 640, SKColors.Purple, "IMG3")), + new XElement("Image", GenerateImageBase64(600, 160, SKColors.IndianRed, "IMG4")) + ); + return samples; + } + + private static string GenerateImageBase64(int width, int height, SKColor background, string label) + { + using var surface = SKSurface.Create(new SKImageInfo(width, height)); + var canvas = surface.Canvas; + canvas.Clear(background); + + using (var paint = new SKPaint()) + { + paint.IsAntialias = true; + paint.Shader = SKShader.CreateLinearGradient( + new SKPoint(0, 0), + new SKPoint(width, height), + new[] { background, background.WithAlpha(200), SKColors.White }, + null, + SKShaderTileMode.Clamp); + canvas.DrawRect(new SKRect(0, 0, width, height), paint); + } + + using (var borderPaint = new SKPaint { Color = SKColors.White, StrokeWidth = Math.Max(width, height) / 60f, IsStroke = true, IsAntialias = true }) + { + canvas.DrawRect(new SKRect(borderPaint.StrokeWidth, borderPaint.StrokeWidth, width - borderPaint.StrokeWidth, height - borderPaint.StrokeWidth), borderPaint); + } + + using (var circlePaint = new SKPaint { Color = SKColors.OrangeRed, IsAntialias = true }) + { + canvas.DrawCircle(width / 2f, height / 2f, Math.Min(width, height) / 4f, circlePaint); + } + + using var font = new SKFont { Size = Math.Min(width, height) / 5f, Edging = SKFontEdging.Antialias }; + using (var textPaint = new SKPaint + { + Color = SKColors.White, + IsAntialias = true + }) + { + canvas.DrawText(label, width / 2f, (height / 2f) + (font.Size / 3f), SKTextAlign.Center, font, textPaint); + } + + canvas.Flush(); + + using var image = surface.Snapshot(); + using var data = image.Encode(SKEncodedImageFormat.Png, 100); + return Convert.ToBase64String(data.ToArray()); + } + } +} diff --git a/OpenXmlPowerToolsExamples/DocumentAssembler04/DocumentAssembler04.csproj b/OpenXmlPowerToolsExamples/DocumentAssembler04/DocumentAssembler04.csproj new file mode 100644 index 00000000..6ed993fb --- /dev/null +++ b/OpenXmlPowerToolsExamples/DocumentAssembler04/DocumentAssembler04.csproj @@ -0,0 +1,21 @@ + + + Exe + net8.0;net10.0 + 8.0 + true + + + + + + + + + PreserveNewest + + + PreserveNewest + + + diff --git a/OpenXmlPowerToolsExamples/DocumentAssembler04/SampleData.xml b/OpenXmlPowerToolsExamples/DocumentAssembler04/SampleData.xml new file mode 100644 index 00000000..2c5a482b --- /dev/null +++ b/OpenXmlPowerToolsExamples/DocumentAssembler04/SampleData.xml @@ -0,0 +1,6 @@ + +iVBORw0KGgoAAAANSUhEUgAAAZAAAADICAIAAABJdyC1AAAFVklEQVR4nO3aX2iVdRzH8d/Z5jaYZZMC0ctGCBUU6BCSGGL+gXlhUBlZrYmgXRhdaBtdzF2ZdedAwZuspAuFgnURqOChNkHrxj91E0ERXSgUCs4/c/NELPpjpa7W1kder6vnnPOcH99z8+b3PM+plLK9ACSom+kBAG6XYAExBAuIIVhADMECYggWEEOwgBiCBcQQLCBGw80/rtX6pmsSgJ9VKv3lb9hhATEEC4ghWEAMwQJiCBZwpzwlnNQNfIDJmtRfEeywgBiCBcQQLCCGYAExBAuIIVhADMECYggWEEOwgBiCBcQQLCCGYAExBAuIIVhADMECYggWEEOwgBiCBcQQLCCGYAExBAuIIVhADMECYggWEEOwgBiCBcQQLCCGYAExBAuIIVhADMECYggWEEOwgBiCBcQQLCCGYAExBAuIIVhADMECYggWEEOwgBiCBcQQLCCGYAExBAuIIVhADMECYggWEEOwgBiCBcQQLCCGYAExBAuIIVhADMECYggWEEOwgBiCBcQQLCCGYAExBAuIIVhADMECYggWEEOwgBiCBcQQLCCGYAExBAuIIVhADMECYggWEEOwgBiCBcQQrDvWwoX3vvbaY3PmNM30IDBlBCvPpUuvV6tdR4++ODzc3dX1yN+d9uGHz1y+PDY2dv121jx/vmfioKdn6dRNClOsYaoX5D83Ojre0bGvlNLSMmtw8NmRkdGDB7/882nz5s3etev4ZBfv6Vn6xhtDUzQpTDE7rGAjI9e2bTv8yitLWlub9+9/8siRFz755KX29gWllM2bF911V1O12rV48fyhoe4zZ15+9dUlN2ymbjgupfT3d8ye3Xjo0PPT/lPgtghWtlOnzra1zX3rrRUDA8eXL393/foP9u5dU0rZs+fzixdHOzr2dXc/2tt75PHH39669bFbrtbXV714cXTFivemZXaYNJeE2Roa6q5dG1+58v62trkT77S0zKqvr4yP1yZebtt2eN26hzo7H7j77r+4+15XV5neeeFfscPK1t6+4PTpcw0NdatW7e/o2Lds2TsbNgz+WqtSysGDT5dSBgZOXL9euyFS99zT3NhYP0ODwz8hWMFaW5t37nzizTeHh4e/W7t2YSll9eq23t4/POZbtGj+gQNfNDc3NDX90qYLF648+OB9pZTnnnu4VvstbRPq6iq2XfxvuSTM09hYX6121Wq1WbPqd+4cqla/+frrH/fuXbNp06KxsesbN370+5N37/7s2LENJ0+ePX/+SlNT/dWr41u2fHzgwFPnzo2cOPH91avjE6d99dUPvb1Ld+wY+vTTbwcHn+3sfH+GfhzcTKWU7Tf5uFbru/ELlf6bLggwCZOKjEtCIIZgATEEC4ghWEAMwQJiCBYQQ7CAGIIFxBAsIIZgATEEC4ghWEAMwQJiCBYQQ7CAGIIFxBAsIIZgATEEC4ghWEAMwQJiCBYQQ7CAGIIFxBAsIIZgATEEC4ghWEAMwQJiCBYQQ7CAGIIFxBAsIIZgATEEC4ghWEAMwQJiCBYQQ7CAGIIFxBAsIIZgATEEC4ghWEAMwQJiCBYQQ7CAGIIFxBAsIIZgATEEC4ghWEAMwQJiCBYQQ7CAGIIFxBAsIIZgATEEC4ghWEAMwQJiCBYQQ7CAGIIFxBAsIIZgATEEC4ghWEAMwQJiCBYQQ7CAGIIFxBAsIIZgATEEC4ghWEAMwQJiCBYQQ7CAGIIFxBAsIIZgATEEC4ghWECMhsl+oVbr+28mAbgFOywghmABMQQLiCFYQAzBAmJUStk+0zMA3BY7LCCGYAExBAuIIVhADMECYggWEEOwgBiCBZQUPwF8CtMHqcxOEQAAAABJRU5ErkJggg== +iVBORw0KGgoAAAANSUhEUgAAAyAAAAGQCAIAAADZR5NjAAAJr0lEQVR4nO3cX4hldQHA8XPnzs4ILWwqyD7Nw7qBkoKIgtiAIrSVoKxPNQ+SL6bSQwQ+bL3MDPQQ9JYVEkkD+lD4sKKL2YYwkD3MSOiD4hK6IMuiVKJoa+syf8KVInLHHPjuH4bP5+mee36/c895+97fOfeOhoWFAQCAzkR4LAAABBYAQE9gAQDEBBYAQExgAQDEBBYAQExgAQDEBBYAQExgAQDEJj979+b8fP2JAAA7wWhxcatdVrAAAGICCwAgJrAAAGICCwAgJrAAAC7srwi39cA8AMBOta2/VrCCBQAQE1gAADGBBQAQE1gAADGBBQAQE1gAADGBBQAQE1gAADGBBQAQE1gAADGBBQAQE1gAADGBBQAQE1gAADGBBQAQE1gAADGBBQAQE1gAADGBBQAQE1gAADGBBQAQE1gAADGBBQAQE1gAADGBBQAQE1gAADGBBQAQE1gAADGBBQAQE1gAADGBBQAQE1gAADGBBQAQE1gAADGBBQAQE1gAADGBBQAQE1gAADGBBQAQE1gAADGBBQAQE1gAADGBBQAQE1gAADGBBQAQE1gAADGBBQAQE1gAADGBBQAQE1gAADGBBQAQE1gAADGBBQAQE1gAADGBBQAQE1gAADGBBQAQE1gAADGBBQAQE1gAADGBBQAQE1gAADGBBQAQE1gAADGBBQAQE1gAADGBBQAQE1gAADGBBQAQE1gAADGBBQAQE1gAADGBBQAQE1gAADGBBQAQE1gAADGBBQAQE1gAADGBBQAQE1gAADGBBQAQE1gAADGBBQAQE1gAADGBBQAQE1gAADGBBQAQE1gAADGBBQAQE1gAADGBBQAQE1gAADGBBQAQE1gAADGBBQAQE1gAADGBBQAQE1gAADGBBQAQE1gAADGBBQAQE1gAADGBBQAQE1gAADGBBQAQE1gAADGBBQAQE1gAADGBBQAQE1gAADGBBQAQE1gAADGBBQAQE1gAADGBBQAQE1gAADGBBQAQE1gAADGBBQAQE1gAADGBBQAQE1gAADGBBQAQE1gAADGBBQAQE1gAADGBBQAQE1gAADGBBQAQE1gAADGBBQAQE1gAADGBBQAQE1gAADGBBQAQE1gAADGBBQAQE1gAADGBBQAQE1gAADGBBQAQE1gAADGBBQAQE1gAADGBBQAQE1gAADGBBQAQE1gAADGBBQAQE1gAADGBBQAQE1gAADGBBQAQE1gAADGBBQAQE1gAADGBBQAQE1gAADGBBQAQE1gAADGBBQAQE1gAADGBBQAQE1gAADGBBQAQE1gAADGBBQAQE1gAADGBBQAQE1gAADGBBQAQE1gAADGBBQAQE1gAADGBBQAQm6wPCOwo999440M33/zBRx/948yZB48cOfH++59/7qHZ2R+/8ML5PDuAS5QVLGBLX923b+7667/y2GO3LS39bHV16eDBbU0/NDt73k4N4JJmBQvY0sO33vrD55//59raMAy/e/31e669dtfExO6pqUfuvHPv7t1T4/HDR4+unjw5DMN7hw79fHV1dmbmi5ddtrC8fPjYscXbb989NXX03nu/+eST5xx/+LXXXnr77Z+urFzsqwToCSxgS1++6qqX3nrrP5vfeeaZYRh+cuDAIysrKydPzuzZ8/Tc3A2PPjoMw9R4/PcPP7xtaWnf5Zcv33ff4WPH5peXv3fLLQcef/xXd9/96fHT4/FvXnnl92+8cVGvD+B8EVjAlsaj0aff/NrVV++/4opPXn9h167xaLS+uTkxGv365ZeHYTj+7rt7pqf/7/j1zc0/HD9+QS4C4CIQWMCW/vLOOzfs3bty9qbeaBiWDh789lNPTU5MfP2JJ06vrU2MRrMzM+ubm8MwnFlff+/06U9mfbz9X845fm1jY+PsC4AdyUPuwJZ+8eKLP7rjjunxeBiGb1133fTkx1/J/nTixD3XXDMMwzf27//Bvx9jP2ctTYxGE6PROccD7GxWsIAt/fbVV7905ZV/fuCBv5069ddTp7777LPDMHz/ued+edddD95009rGxv1nn8rayh/ffPPpubmHjhz5nOMBdozRsLDwGbs35+f/d8Li4nk+JQCAS862osgtQgCAmMACAIgJLACAmMACAIgJLACAmMACAIgJLACAmMACAIgJLACAmMACAIgJLACAmMACAIgJLACAmMACAIgJLACAmMACAIgJLACAmMACAIgJLACAmMACAIgJLACAmMACAIgJLACAmMACAIgJLACAmMACAIgJLACAmMACAIgJLACAmMACAIgJLACAmMACAIgJLACAmMACAIgJLACAmMACAIgJLACAmMACAIgJLACAmMACAIgJLACAmMACAIgJLACAmMACAIgJLACAmMACAIgJLACAmMACAIgJLACAmMACAIgJLACAmMACAIgJLACAmMACAIgJLACAmMACAIgJLACAmMACAIgJLACAmMACAIgJLACAmMACAIgJLACAmMACAIgJLACAmMACAIgJLACAmMACAIgJLACAmMACAIgJLACAmMACAIgJLACAmMACAIgJLACAmMACAIgJLACAmMACAIgJLACAmMACAIgJLACAmMACAIgJLACAmMACAIgJLACAmMACAIgJLACAmMACAIgJLACAmMACAIgJLACAmMACAIgJLACAmMACAIgJLACAmMACAIgJLACAmMACAIgJLACAmMACAIgJLACAmMACAIgJLACAmMACAIgJLACAmMACAIgJLACAmMACAIgJLACAmMACAIgJLACAmMACAIgJLACAmMACAIgJLACAmMACAIgJLACAmMACAIgJLACAmMACAIgJLACAmMACAIgJLACAmMACAIgJLACAmMACAIgJLACAmMACAIgJLACAmMACAIgJLACAmMACAIgJLACAmMACAIgJLACAmMACAIgJLACAmMACAIgJLACAmMACAIgJLACAmMACAIgJLACAmMACAIgJLACAmMACAIgJLACAmMACAIgJLACAmMACAIgJLACAmMACAIgJLACAmMACAIgJLACAmMACAIgJLACAmMACAIgJLACAmMACAIgJLACAmMACAIgJLACAmMACAIgJLACAmMACAIgJLACAmMACAIgJLACAmMACAIgJLACAmMACAIgJLACAmMACAIgJLACAmMACAIgJLACAmMACAIgJLACAmMACAIgJLACAmMACAIgJLACAmMACAIhNbnfC5vx8fQ4AADuKFSwAgJjAAgCICSwAgJjAAgCICSwAgAv7K8LR4mL9iQAAO5wVLACAmMACAIgJLACAmMACAIgJLACAmMACAIgJLACAmMACABha/wIkpugFaVq0kwAAAABJRU5ErkJggg== +iVBORw0KGgoAAAANSUhEUgAAAUAAAAKACAIAAABi8jbUAAAJlUlEQVR4nO3ZTailcxzA8efcO+4sEAtMTKJYTIkkpmRws5jCQtOsZqHOlNLIymKasrjuQs3Cxkss79REUTYTCqXxkrIgMZIpSRbyMmXB0NxmRtNwkre6NPfcL5/P6jn/8zyn3+bb/3meM3poeGgAmmamPQDwzwkYwgQMYQKGMAFDmIAhTMAQJmAIEzCErfv7rxdOLqzWJMCfWxwt/sU3dmAoEzCECRjCBAxhAob/7lvoFb0QA/69Ff31YweGMAFDmIAhTMAQJmAIEzCECRjCBAxhAoYwAUOYgCFMwBAmYAgTMIQJGMIEDGEChjABQ5iAIUzAECZgCBMwhAkYwgQMYQKGMAFDmIAhTMAQJmAIEzCECRjCBAxhAoYwAUOYgCFMwBAmYAgTMIQJGMIEDGEChjABQ5iAIUzAECZgCBMwhAkYwgQMYQKGMAFDmIAhTMAQJmAIEzCECRjCBAxhAoYwAUOYgCFMwBAmYAgTMIQJGMIEDGEChjABQ5iAIUzAECZgCBMwhAkYwgQMYQKGMAFDmIAhTMAQJmAIEzCECRjCBAxhAoYwAUOYgCFMwBAmYAgTMIQJGMIEDGEChjABQ5iAIUzAECZgCBMwhAkYwgQMYQKGMAFDmIAhTMAQJmAIEzCECRjCBAxhAoYwAUOYgCFMwBAmYAgTMIQJGMIEDGEChjABQ5iAIUzAECZgCBMwhAkYwgQMYQKGMAFDmIAhTMAQJmAIEzCECRjCBAxhAoYwAUOYgCFMwBAmYAgTMIQJGMIEDGEChjABQ5iAIUzAECZgCBMwhAkYwgQMYQKGMAFDmIAhTMAQJmAIEzCECRjCBAxhAoYwAUOYgCFMwBAmYAgTMIQJGMIEDGEChjABQ5iAIUzAECZgCBMwhAkYwgQMYQKGMAFDmIAhTMAQJmAIEzCECRjCBAxhAoYwAUOYgCFMwBAmYAgTMIQJGMIEDGEChjABQ5iAIUzAECZgCBMwhAkYwgQMYQKGMAFDmIAhTMAQJmAIEzCECRjCBAxhAoYwAUOYgCFMwBAmYAgTMIQJGMIEDGEChjABQ5iAIUzAECZgCBMwhAkYwgQMYQKGMAFDmIAhTMAQJmAIEzCECRjCBAxhAoYwAUOYgCFMwBAmYAgTMIQJGMIEDGEChjABQ5iAIUz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 + \ No newline at end of file diff --git a/OpenXmlPowerToolsExamples/DocumentAssembler04/TemplateDocument.docx b/OpenXmlPowerToolsExamples/DocumentAssembler04/TemplateDocument.docx new file mode 100644 index 0000000000000000000000000000000000000000..e099f010eb8b2213e1aa11699800e36c72fd4c5d GIT binary patch literal 2535 zcmcIm&2G~`5Kak2qMQ&ta6%(js)L|$5?ZLzI91hx2&E9UMI5TC+S+Spi~qE{Nz-!a z8}J&u0*}C*3p@aCz=0dHYv-qF+M-Ia<)0bv&NuVT&a9i8`P;KOb$tFp_n`bX{5qA( zsp|pi_SWM_(nucco+MQ47rS9lTg4l6C>Jb_Y_nV}nLr~ac3I@x=7-%^3(MweeWs>o z0FI4>ZMLLLs+J|377d9g#tDt^ix=~dNEEqmCB*qed}>unrRSEDqAo~U(5_GnYb>aQTje=!|t7bJucXryU}Nin}W;jqx`2Jm%2;d}S@+u|tJGs>7hz{}K`w z^`fX*n)v!m^IT&8{`qzE2$QO=dzjb*MV_UI5dWLd?P1bxnAmf~r->al3k}ycdBZK6 zkmmFHb(4~`k&&D(pK~bI`4MQDaGy%syqFza1T|~eA2CW}<45E7_vu_tUH6g33pTDM zvn{=>IwDc3_VSl62$jTA4vz0jr{|3amtG z8wsa^@(wlXz49&)N47}-3h*fe)RT{^Fcxnln(=1m1GzE>>fX?y?a+Wa(l)fTSKc?k zn)l@j%&GfWm{fVwu-38gkyq?zLUK$Ib)m3Dx*wT~)IMX;1cR$!aBC`gw&7?m4Co=- z20~9d^lj$1B(5dZkeSp8g+hfz(1mhwX`-P&8-&i47Y1m;Z3E#AcukegHnbO$X5md+ zNaiujdq}#_($G*SFO`z+1arSQAmB(laH5mnk6gjuByxep)6D9<*IWFZQ&8>T7#Xcp z7E6YfS|}|oqog<1K_~LJ2-gE-oZyhf-gup-VZ~uAWgHG`R5KWsTa>tz+s2DZhUvvI z7Tb`*Z%eJ{dyy9NAc|B@yJh;`>&f-z=G3j({N;Z}*!=Zzh8ybG|D;g^qdRaqJ(oM1 k`iUln_T$lsqkGO^BIL*HK+VnRJ8DQC&u{pAcvngP0Fv+ua{vGU literal 0 HcmV?d00001 From 831bc507443c41ed1e2782b2cd8328ebcd014873 Mon Sep 17 00:00:00 2001 From: Stefan Seeland <168659+stesee@users.noreply.github.com> Date: Tue, 11 Nov 2025 00:58:47 +0100 Subject: [PATCH 3/4] Update OpenXmlPowerTools/DocumentAssembler/ImageHelper.cs Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- OpenXmlPowerTools/DocumentAssembler/ImageHelper.cs | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/OpenXmlPowerTools/DocumentAssembler/ImageHelper.cs b/OpenXmlPowerTools/DocumentAssembler/ImageHelper.cs index 35637243..9a430444 100644 --- a/OpenXmlPowerTools/DocumentAssembler/ImageHelper.cs +++ b/OpenXmlPowerTools/DocumentAssembler/ImageHelper.cs @@ -91,7 +91,13 @@ internal static int GetNextDocPrId(OpenXmlPart part) var existingIds = part .GetXDocument() .Descendants(WP.docPr) - .Select(dp => (int?)dp.Attribute("id") ?? 0); + .Select(dp => + { + var idAttr = dp.Attribute("id"); + if (idAttr != null && int.TryParse(idAttr.Value, out int id)) + return id; + return 0; + }); var maxId = existingIds.Any() ? existingIds.Max() : 0; tracker = new ImageIdTracker { NextId = maxId + 1 }; part.AddAnnotation(tracker); From d4acce4c0776a5fa33d51932cc9ae0bde09169e8 Mon Sep 17 00:00:00 2001 From: Stefan Seeland <168659+stesee@users.noreply.github.com> Date: Sat, 15 Nov 2025 21:02:52 +0100 Subject: [PATCH 4/4] Remove header-based image dimension detection fallback Replaced custom PNG, JPEG, and GIF header-based dimension detection with exception handling. Now, if image dimensions cannot be determined via SkiaSharp, the error message is set from the exception, simplifying the code and error reporting. --- .../DocumentAssembler/ImageHelper.cs | 122 +----------------- 1 file changed, 4 insertions(+), 118 deletions(-) diff --git a/OpenXmlPowerTools/DocumentAssembler/ImageHelper.cs b/OpenXmlPowerTools/DocumentAssembler/ImageHelper.cs index 9a430444..dfd5f751 100644 --- a/OpenXmlPowerTools/DocumentAssembler/ImageHelper.cs +++ b/OpenXmlPowerTools/DocumentAssembler/ImageHelper.cs @@ -1,6 +1,7 @@ using DocumentFormat.OpenXml.Packaging; using DocumentFormat.OpenXml.Wordprocessing; using SkiaSharp; +using System; using System.Globalization; using System.Linq; using System.Xml.Linq; @@ -362,129 +363,14 @@ internal static bool TryGetPixelSize(byte[] imageBytes, out int width, out int h return true; } } - catch - { - // ignore and fall back to header-based detection - } - - if (TryReadPngDimensions(imageBytes, out width, out height)) - { - return true; - } - - if (TryReadJpegDimensions(imageBytes, out width, out height)) - { - return true; - } - - if (TryReadGifDimensions(imageBytes, out width, out height)) - { - return true; - } - - errorMessage = "Image: Unable to determine image dimensions."; - return false; - } - - /// - /// Reads PNG image dimensions from the file header. - /// - private static bool TryReadPngDimensions(byte[] bytes, out int width, out int height) - { - width = 0; - height = 0; - if (bytes.Length < 24) - { - return false; - } - - var signature = new byte[] { 137, 80, 78, 71, 13, 10, 26, 10 }; - for (var i = 0; i < signature.Length; i++) - { - if (bytes[i] != signature[i]) - { - return false; - } - } - - if (bytes[12] != 0x49 || bytes[13] != 0x48 || bytes[14] != 0x44 || bytes[15] != 0x52) - { - return false; - } - - width = (bytes[16] << 24) | (bytes[17] << 16) | (bytes[18] << 8) | bytes[19]; - height = (bytes[20] << 24) | (bytes[21] << 16) | (bytes[22] << 8) | bytes[23]; - return width > 0 && height > 0; - } - - /// - /// Reads JPEG image dimensions from the file header. - /// - private static bool TryReadJpegDimensions(byte[] bytes, out int width, out int height) - { - width = 0; - height = 0; - if (bytes.Length < 4 || bytes[0] != 0xFF || bytes[1] != 0xD8) + catch (Exception excepiton) { + errorMessage = excepiton.Message; return false; } - var index = 2; - while (index + 9 < bytes.Length) - { - if (bytes[index] != 0xFF) - { - index++; - continue; - } - - var marker = bytes[index + 1]; - index += 2; - - if (marker == 0xD8 || marker == 0xD9) - { - continue; - } - - if (index + 2 > bytes.Length) - { - break; - } - - var length = (bytes[index] << 8) + bytes[index + 1]; - if (length < 2 || index + length > bytes.Length) - { - break; - } - - if (marker >= 0xC0 && marker <= 0xC3) - { - height = (bytes[index + 3] << 8) + bytes[index + 4]; - width = (bytes[index + 5] << 8) + bytes[index + 6]; - return width > 0 && height > 0; - } - - index += length; - } - + errorMessage = "Image: Unable to determine image dimensions."; return false; } - - /// - /// Reads GIF image dimensions from the file header. - /// - private static bool TryReadGifDimensions(byte[] bytes, out int width, out int height) - { - width = 0; - height = 0; - if (bytes.Length < 10 || bytes[0] != 'G' || bytes[1] != 'I' || bytes[2] != 'F') - { - return false; - } - - width = bytes[6] | (bytes[7] << 8); - height = bytes[8] | (bytes[9] << 8); - return width > 0 && height > 0; - } } }