From b800cf51063da8e4293e3784aacb4f33419a002c Mon Sep 17 00:00:00 2001 From: Ariel Ror Date: Fri, 12 Apr 2024 15:31:43 -0400 Subject: [PATCH] Add images to word export (#22) * Add images to word export Create image part for a word doc, create images, clone images between document fragments, and track relationship IDs of images. Export caption text. Formatting of image captions will be handled separately. * Cleanup word fragment writer dispose --- Src/xWorks/ConfiguredLcmGenerator.cs | 5 +- Src/xWorks/LcmWordGenerator.cs | 401 ++++++++++++++++++++++++--- Src/xWorks/WordStylesGenerator.cs | 4 +- Src/xWorks/xWorks.csproj | 1 + 4 files changed, 361 insertions(+), 50 deletions(-) diff --git a/Src/xWorks/ConfiguredLcmGenerator.cs b/Src/xWorks/ConfiguredLcmGenerator.cs index 891ceaea1e..704b646ad6 100644 --- a/Src/xWorks/ConfiguredLcmGenerator.cs +++ b/Src/xWorks/ConfiguredLcmGenerator.cs @@ -861,7 +861,7 @@ private static string GenerateSrcAttributeFromFilePath(ICmFile file, string subF { filePath = MakeSafeFilePath(file.AbsoluteInternalPath); } - return settings.UseRelativePaths ? filePath : new Uri(filePath).ToString(); + return filePath; } private static string GenerateSrcAttributeForMediaFromFilePath(string filename, string subFolder, GeneratorSettings settings) @@ -1940,9 +1940,8 @@ private static IFragment GeneratePictureContent(ConfigurableDictionaryNode confi contentGenerator.WriteProcessedContents(writer, settings.ContentGenerator.AddImageCaption(captionBldr.ToString())); } writer.Flush(); + return bldr; } - - return bldr; } private static IFragment GenerateCollectionItemContent(ConfigurableDictionaryNode config, DictionaryPublicationDecorator publicationDecorator, diff --git a/Src/xWorks/LcmWordGenerator.cs b/Src/xWorks/LcmWordGenerator.cs index cdb21ee63a..20b5dfe819 100644 --- a/Src/xWorks/LcmWordGenerator.cs +++ b/Src/xWorks/LcmWordGenerator.cs @@ -20,7 +20,11 @@ using System.Linq; using System.Text; using System.Web.UI.WebControls; +using System.Windows.Media.Imaging; using XCore; +using XmlDrawing = DocumentFormat.OpenXml.Drawing; +using DrawingWP = DocumentFormat.OpenXml.Drawing.Wordprocessing; +using Pictures = DocumentFormat.OpenXml.Drawing.Pictures; namespace SIL.FieldWorks.XWorks { @@ -34,6 +38,8 @@ public class LcmWordGenerator : ILcmContentGenerator, ILcmStylesGenerator private static Styles _styleSheet { get; set; } = new Styles(); private static Dictionary _styleDictionary = new Dictionary(); private ReadOnlyPropertyTable _propertyTable; + internal const int maxImageHeightInches = 1; + internal const int maxImageWidthInches = 1; public LcmWordGenerator(LcmCache cache) { @@ -55,7 +61,7 @@ public static void SavePublishedDocx(int[] entryHvos, DictionaryPublicationDecor var readOnlyPropertyTable = new ReadOnlyPropertyTable(propertyTable); generator.Init(readOnlyPropertyTable); - var settings = new ConfiguredLcmGenerator.GeneratorSettings(cache, readOnlyPropertyTable, true, true, System.IO.Path.GetDirectoryName(filePath), + var settings = new ConfiguredLcmGenerator.GeneratorSettings(cache, readOnlyPropertyTable, false, true, System.IO.Path.GetDirectoryName(filePath), ConfiguredLcmGenerator.IsEntryStyleRtl(readOnlyPropertyTable, configuration), System.IO.Path.GetFileName(cssPath) == "configured.css") { ContentGenerator = generator, StylesGenerator = generator}; settings.StylesGenerator.AddGlobalStyles(configuration, readOnlyPropertyTable); @@ -369,6 +375,14 @@ private string ToString(OpenXmlElement textBody) FragStr.AppendLine(); break; + case "r": + string docStr = ToString(docSection); + if (string.IsNullOrEmpty(docStr)) + if (docSection.Descendants().Any()) + docStr = "[image run]"; + FragStr.Append(docStr); + break; + default: FragStr.Append(ToString(docSection)); break; @@ -391,9 +405,17 @@ public void Append(IFragment frag) { foreach (OpenXmlElement elem in ((DocFragment)frag).DocBody.Elements().ToList()) { + if (elem.Descendants().Any()) + { + // then need to append image in such a way that the relID is maintained + this.DocBody.AppendChild(CloneImageRun(frag, elem)); + // wordWriter.WordFragment.AppendPhotoToParagraph(frag, elem, wordWriter.ForceNewParagraph); + } + // Append each element. It is necessary to deep clone the node to maintain its tree of document properties // and to ensure its styles will be maintained in the copy. - this.DocBody.AppendChild(elem.CloneNode(true)); + else + this.DocBody.AppendChild(elem.CloneNode(true)); } } @@ -407,7 +429,7 @@ public void Append(WP.Table table) } /// - /// Appends a new run inside the last paragraph of the doc fragment--creates a new paragraph if none exists. + /// Appends a new run inside the last paragraph of the doc fragment--creates a new paragraph if none exists or if forceNewParagraph is true. /// The run will be added to the end of the paragraph. /// /// The run to append. @@ -419,6 +441,65 @@ public void AppendToParagraph(WP.Run run, bool forceNewParagraph) lastPar.AppendChild(run.CloneNode(true)); } + public void AppendCaptionToParagraph(WP.SimpleField caption) + { + // Deep clone the run b/c of its tree of properties and to maintain styles. + WP.Paragraph lastPar = GetLastParagraph(); + lastPar.AppendChild(caption.CloneNode(true)); + } + + /// + /// Appends a new run inside the last paragraph of the doc fragment--creates a new paragraph if none exists or if forceNewParagraph is true. + /// The run will be added to the end of the paragraph. + /// + /// The run to append. + /// Even if a paragraph exists, force the creation of a new paragraph. + public void AppendImageToParagraph(IFragment fragToCopy, OpenXmlElement run, bool forceNewParagraph) + { + WP.Paragraph lastPar = forceNewParagraph ? GetNewParagraph() : GetLastParagraph(); + var clonedRun = CloneImageRun(fragToCopy, run); + lastPar.AppendChild(clonedRun); + } + + /// + /// Clones and returns a run containing an image. + /// + public OpenXmlElement CloneImageRun(IFragment fragToCopy, OpenXmlElement run) + { + var clonedRun = run.CloneNode(true); + clonedRun.Descendants().ToList().ForEach( + blip => + { + var newRelation = + CopyImage(DocFrag, blip.Embed, ((DocFragment)fragToCopy).DocFrag); + // Update the relationship ID in the cloned blip element. + blip.Embed = newRelation; + }); + clonedRun.Descendants().ToList().ForEach( + imageData => + { + var newRelation = CopyImage(DocFrag, imageData.RelationshipId, ((DocFragment)fragToCopy).DocFrag); + // Update the relationship ID in the cloned image data element. + imageData.RelationshipId = newRelation; + }); + return clonedRun; + } + + /// + /// Copies the image part of one document to another and returns the relationship ID of the copied image part. + /// + public static string CopyImage(WordprocessingDocument newDoc, string relId, WordprocessingDocument org) + { + if (org.MainDocumentPart == null || newDoc.MainDocumentPart == null) + { + throw new ArgumentNullException("MainDocumentPart is null."); + } + var p = org.MainDocumentPart.GetPartById(relId) as ImagePart; + var newPart = newDoc.MainDocumentPart.AddPart(p); + newPart.FeedData(p.GetStream()); + return newDoc.MainDocumentPart.GetIdOfPart(newPart); + } + /// /// Appends text to the last run inside the doc fragment. /// If no run exists, a new one will be created. @@ -515,21 +596,19 @@ public WordFragmentWriter(DocFragment frag) public void Dispose() { - foreach (var cachEntry in collatorCache.Values) - { - cachEntry?.Dispose(); - } - Dispose(true); - GC.SuppressFinalize(this); - } + // When writer is being disposed, dispose only the dictionary entries, + // not the word doc fragment. + // ConfiguredLcmGenerator consistently returns the fragment and disposes the writer, + // which would otherwise result in a disposed fragment being accessed. - protected virtual void Dispose(bool disposing) - { - Debug.WriteLineIf(!disposing, "****** Missing Dispose() call for " + GetType().Name + ". ****** "); if (!isDisposed) { - WordFragment.DocFrag.Dispose(); - WordFragment.MemStr.Dispose(); + foreach (var cachEntry in collatorCache.Values) + { + cachEntry?.Dispose(); + } + + GC.SuppressFinalize(this); isDisposed = true; } } @@ -597,12 +676,10 @@ public IFragment GenerateGroupingNode(object field, string className, Configurab Func childContentGenerator) { //TODO: handle grouping nodes - IFragment docfrag = new DocFragment("TODO: handle grouping nodes"); - + //IFragment docfrag = new DocFragment(...); //LinkStyleOrInheritParentStyle(docfrag, config); - - return docfrag; - //return null; + //return docfrag; + return null; } public IFragment AddSenseData(IFragment senseNumberSpan, bool isBlockProperty, Guid ownerGuid, IFragment senseContent, string className) { @@ -908,29 +985,48 @@ public void AddEntryData(IFragmentWriter writer, List().Any()) { - case WP.Run run: - // For spaces to show correctly, set preserve spaces on the text element - WP.Text txt = new WP.Text(" "); - txt.Space = SpaceProcessingModeValues.Preserve; - run.AppendChild(txt); - wordWriter.WordFragment.AppendToParagraph(run, wordWriter.ForceNewParagraph); - wordWriter.ForceNewParagraph = false; - - // Add the paragraph style. - wordWriter.WordFragment.LinkParaStyle(frag.ParagraphStyle); - - break; - case WP.Table table: - wordWriter.WordFragment.Append(table); - - // Start a new paragraph with the next run to maintain the correct position of the table. - wordWriter.ForceNewParagraph = true; - break; - default: - throw new Exception("Unexpected element type on DocBody: " + elem.GetType().ToString()); + // The image should begin its own paragraph, as should the next run, + // to maintain correct order of the image. + // If the image has a caption, it should remain in the same paragraph as the image. + wordWriter.ForceNewParagraph = true; + wordWriter.WordFragment.AppendImageToParagraph(frag, elem, wordWriter.ForceNewParagraph); + } + else{ + switch (elem) + { + //case WP.SimpleField caption: + // TODO: Captions should be created as runs inside simplefields--make sure to handle this case + // SimpleField stores an image caption. + // In this case, the caption is added to the last referenced paragraph, + // which should contain the associated image. + //wordWriter.WordFragment.AppendCaptionToParagraph(caption); + //break; + case WP.Run run: + // TODO: should no longer need to add spaces here after before/after text is handled. + // For spaces to show correctly, set preserve spaces on the text element + WP.Text txt = new WP.Text(" "); + txt.Space = SpaceProcessingModeValues.Preserve; + run.AppendChild(txt); + wordWriter.WordFragment.AppendToParagraph(run, wordWriter.ForceNewParagraph); + wordWriter.ForceNewParagraph = false; + + // Add the paragraph style. + wordWriter.WordFragment.LinkParaStyle(frag.ParagraphStyle); + + break; + case WP.Table table: + wordWriter.WordFragment.Append(table); + + // Start a new paragraph with the next run to maintain the correct position of the table. + wordWriter.ForceNewParagraph = true; + break; + default: + throw new Exception("Unexpected element type on DocBody: " + elem.GetType().ToString()); + + } } } } @@ -962,18 +1058,43 @@ public void EndObject(IFragmentWriter writer) } public void WriteProcessedContents(IFragmentWriter writer, IFragment contents) { - if (contents.IsNullOrEmpty()) + if (!contents.IsNullOrEmpty()) { ((WordFragmentWriter)writer).Insert(contents); } } public IFragment AddImage(string classAttribute, string srcAttribute, string pictureGuid) { - return new DocFragment("TODO: add image"); + DocFragment imageFrag = new DocFragment(); + WordprocessingDocument wordDoc = imageFrag.DocFrag; + string partId = AddImagePartToPackage(wordDoc, srcAttribute); + Drawing image = CreateImage(wordDoc, srcAttribute, partId); + + if (wordDoc.MainDocumentPart is null || wordDoc.MainDocumentPart.Document.Body is null) + { + throw new ArgumentNullException("MainDocumentPart and/or Body is null."); + } + + Run imgRun = new Run(); + imgRun.AppendChild(image); + RunProperties imgProperties = new RunProperties(); + + // Append the image to body, the image should be in a Run. + wordDoc.MainDocumentPart.Document.Body.AppendChild(imgRun); + return imageFrag; } public IFragment AddImageCaption(string captionContent) { - return new DocFragment("TODO: add image caption"); + // TODO: captions need to be added into simplefields and linked to the relevant image/table to show as captions + /*SimpleField simpleField = new SimpleField(new Run(new RunProperties(new NoProof()), new WP.Text() { Text = " ", Space = SpaceProcessingModeValues.Preserve })); + simpleField.Instruction = @"SEQ " + "Figure 1"; + Run runLabel = new Run(new WP.Text() { Text = " " + captionContent, Space = SpaceProcessingModeValues.Preserve }); + simpleField.Append(runLabel); + DocFragment frag = new DocFragment(); + frag.DocBody.Append(simpleField); + return frag;*/ + + return new DocFragment(captionContent); } public IFragment GenerateSenseNumber(string formattedSenseNumber, string senseNumberWs, ConfigurableDictionaryNode senseConfigNode) { @@ -1105,13 +1226,201 @@ public static StyleDefinitionsPart AddStylesPartToPackage(WordprocessingDocument return part; } + // Add an ImagePart to the document. Returns the part ID. + public static string AddImagePartToPackage(WordprocessingDocument doc, string imagePath, ImagePartType imageType = ImagePartType.Jpeg) + { + MainDocumentPart mainPart = doc.MainDocumentPart; + ImagePart imagePart = mainPart.AddImagePart(imageType); + using (FileStream stream = new FileStream(imagePath, FileMode.Open)) + { + imagePart.FeedData(stream); + } + + return mainPart.GetIdOfPart(imagePart); + } + + public static Drawing CreateImage(WordprocessingDocument doc, string filepath, string partId) + { + // Create a bitmap to store the image so we can track/preserve aspect ratio. + var img = new BitmapImage(); + + // Minimize the time that the image file is locked by opening with a filestream to initialize the bitmap image + using (var fs = new FileStream(filepath, FileMode.Open, FileAccess.Read, FileShare.Read)) + { + img.BeginInit(); + img.StreamSource = fs; + img.EndInit(); + } + + var actWidthPx = img.PixelWidth; + var actHeightPx = img.PixelHeight; + var horzRezDpi = img.DpiX; + var vertRezDpi = img.DpiY; + var actWidthInches = (float)(actWidthPx / horzRezDpi); + var actHeightInches = (float)(actHeightPx / vertRezDpi); + + var ratioActualInches = actHeightInches / actWidthInches; + var ratioMaxInches = (float)(maxImageHeightInches) / (float)(maxImageWidthInches); + + // height/widthInches will store the actual height and width + // to use for the image in the Word doc. + float heightInches = maxImageHeightInches; + float widthInches = maxImageWidthInches; + + // If the ratio of the actual image is greater than the max ratio, + // we leave height equal to the max height and scale width accordingly. + if (ratioActualInches >= ratioMaxInches) + { + widthInches = actWidthInches * (maxImageHeightInches / actHeightInches); + } + // Otherwise, if the ratio of the actual image is less than the max ratio, + // we leave width equal to the max width and scale height accordingly. + else if (ratioActualInches < ratioMaxInches) + { + heightInches = actHeightInches * (maxImageWidthInches / actWidthInches); + } + + // Calculate the actual height and width in emus to use for the image. + const int emusPerInch = 914400; + var widthEmus = (long)(widthInches * emusPerInch); + var heightEmus = (long)(heightInches * emusPerInch); + + // We want a 4pt right/left margin--4pt is equal to 0.0553 inches in MS word. + float rlMarginInches = 0.0553F; + + // Create and add a floating image with image wrap set to top/bottom + // Name for the image -- the name of the file after all containing folders and the file extension are removed. + string name = (filepath.Split('\\').Last()).Split('.').First(); + string haPosition = "right"; + // Define the reference of the image. + DrawingWP.Anchor anchor = new DrawingWP.Anchor(); + anchor.Append(new DrawingWP.SimplePosition() { X = 0L, Y = 0L }); + anchor.Append( + new DrawingWP.HorizontalPosition( + new DrawingWP.HorizontalAlignment(haPosition) + ) + { + RelativeFrom = + DrawingWP.HorizontalRelativePositionValues.Margin + } + ); + anchor.Append( + new DrawingWP.VerticalPosition( + new DrawingWP.PositionOffset("0") + ) + { + RelativeFrom = + DrawingWP.VerticalRelativePositionValues.Paragraph + } + ); + anchor.Append( + new DrawingWP.Extent() + { + Cx = widthEmus, + Cy = heightEmus + } + ); + anchor.Append( + new DrawingWP.EffectExtent() + { + LeftEdge = 0L, + TopEdge = 0L, + RightEdge = 0L, + BottomEdge = 0L + } + ); + anchor.Append(new DrawingWP.WrapTopBottom()); + anchor.Append( + new DrawingWP.DocProperties() + { + Id = (UInt32Value)1U, + Name = name + } + ); + anchor.Append( + new DrawingWP.NonVisualGraphicFrameDrawingProperties( + new XmlDrawing.GraphicFrameLocks() { NoChangeAspect = true }) + ); + anchor.Append( + new XmlDrawing.Graphic( + new XmlDrawing.GraphicData( + new Pictures.Picture( + + new Pictures.NonVisualPictureProperties( + new Pictures.NonVisualDrawingProperties() + { + Id = (UInt32Value)0U, + Name = name + }, + new Pictures.NonVisualPictureDrawingProperties()), + + new Pictures.BlipFill( + new XmlDrawing.Blip( + new XmlDrawing.BlipExtensionList( + new XmlDrawing.BlipExtension() + { + Uri = + "{28A0092B-C50C-407E-A947-70E740481C1C}" + }) + ) + { + Embed = partId, + CompressionState = + XmlDrawing.BlipCompressionValues.Print + }, + new XmlDrawing.Stretch( + new XmlDrawing.FillRectangle())), + + new Pictures.ShapeProperties( + + new XmlDrawing.Transform2D( + new XmlDrawing.Offset() { X = 0L, Y = 0L }, + + new XmlDrawing.Extents() + { + Cx = widthEmus, + Cy = heightEmus + }), + + new XmlDrawing.PresetGeometry( + new XmlDrawing.AdjustValueList() + ) + { Preset = XmlDrawing.ShapeTypeValues.Rectangle } + ) + ) + ) + { Uri = "http://schemas.openxmlformats.org/drawingml/2006/picture" }) + ); + + anchor.DistanceFromTop = (UInt32Value)0U; + anchor.DistanceFromBottom = (UInt32Value)0U; + + // Want 4pt padding on right & left; calculate what this is in emus. + anchor.DistanceFromLeft = (UInt32Value)(rlMarginInches * emusPerInch); + anchor.DistanceFromRight = (UInt32Value)(rlMarginInches * emusPerInch); + anchor.SimplePos = false; + + // RelativeHeight determines order of display when elements overlap + // (e.g. how far towards the front or back this element's priority should be). + // Since we choose not to allow overlap, we needn't worry about relative height. + anchor.RelativeHeight = (UInt32Value)0U; + anchor.BehindDoc = false; + anchor.Locked = false; + anchor.LayoutInCell = true; + anchor.AllowOverlap = false; + + Drawing element = new Drawing(); + element.Append(anchor); + + return element; + } + /// /// Finds an unused class name for the configuration node. This should be called when there are two nodes in the DictionaryConfigurationModel /// have the same class name, but different style content. We want this name to be usefully recognizable. /// /// - public static string GetBestUniqueNameForNode(Dictionary styles, - ConfigurableDictionaryNode node) + public static string GetBestUniqueNameForNode(Dictionary styles, ConfigurableDictionaryNode node) { Guard.AgainstNull(node.Parent, "There should not be duplicate class names at the top of tree."); // First try appending the parent node classname. diff --git a/Src/xWorks/WordStylesGenerator.cs b/Src/xWorks/WordStylesGenerator.cs index d48bba8f7c..d0dea0b589 100644 --- a/Src/xWorks/WordStylesGenerator.cs +++ b/Src/xWorks/WordStylesGenerator.cs @@ -1,4 +1,5 @@ using DocumentFormat.OpenXml.Wordprocessing; +using ExCSS; using SIL.FieldWorks.Common.Framework; using SIL.FieldWorks.Common.Widgets; using SIL.LCModel; @@ -325,7 +326,8 @@ public static Styles GenerateWordStylesFromConfigurationNode( // TODO: handle listAndPara and pictureOptions cases // case IParaOption listAndParaOpts: - // case DictionaryNodePictureOptions pictureOptions: + //case DictionaryNodePictureOptions pictureOptions: + //return GenerateWordStyleFromPictureOptions(configNode, pictureOptions, styleName, cache, propertyTable); default: { diff --git a/Src/xWorks/xWorks.csproj b/Src/xWorks/xWorks.csproj index 31a967e5a1..6f9dab766e 100644 --- a/Src/xWorks/xWorks.csproj +++ b/Src/xWorks/xWorks.csproj @@ -168,6 +168,7 @@ False ..\..\Output\Debug\Newtonsoft.Json.dll + False ..\..\Output\Debug\SIL.Core.Desktop.dll