commit 0a9f9349734545792ea348d620249caf0aec3b96
Author: Vojtěch Bartička <aitakaitov@gmail.com>
Date:   Tue Apr 19 15:24:10 2022 +0200

    Backend document rendering

diff --git a/Backend/Backend/Program.cs b/Backend/Backend/Program.cs
index 4380358..b186906 100644
--- a/Backend/Backend/Program.cs
+++ b/Backend/Backend/Program.cs
@@ -9,7 +9,6 @@ using Microsoft.OpenApi.Models;
 using RestAPI.Middleware;
 using RestAPI.Utils;
 using Serilog;
-using AutoMapper;
 using Core.MapperProfiles;
 
 var builder = WebApplication.CreateBuilder(args);
diff --git a/Backend/Core/Services/AnnotationService/AnnotationServiceEF.cs b/Backend/Core/Services/AnnotationService/AnnotationServiceEF.cs
index e3d929a..85011b6 100644
--- a/Backend/Core/Services/AnnotationService/AnnotationServiceEF.cs
+++ b/Backend/Core/Services/AnnotationService/AnnotationServiceEF.cs
@@ -23,6 +23,9 @@ namespace Core.Services.AnnotationService
         private readonly ILogger logger;
         private readonly IMapper mapper;
 
+        private const string TAG_ID_ATTRIBUTE_NAME = "aswi-tag-id";
+        private const string TAG_INSTANCE_ATTRIBUTE_NAME = "aswi-iag-instance";
+
         public AnnotationServiceEF(DatabaseContext context, ILogger logger, IMapper mapper)
         {
             this.context = context;
@@ -111,79 +114,115 @@ namespace Core.Services.AnnotationService
             }
 
             var documentContent = context.Documents.Where(d => d.Id == annotation.Document.Id).Select(d => d.Content).First();
-            var preprocessingResult = PreprocessHTML(documentContent.Content);
-
-            // We probably cannot use AutoMapper since we are dealing with too many different entities
-            AnnotationInfo annotationInfo = new()
-            {
-                SourceDocumentContent = documentContent.Content,
-                DocumentToRender = preprocessingResult.docToRender,
-                TagStartPositions = preprocessingResult.openingTagPositions,
-                TagLengths = preprocessingResult.openingTagLengths,
-                Note = annotation.Note,
-                State = annotation.State,
-                Type = IsHtml(documentContent.Content) ? EDocumentType.HTML : EDocumentType.TEXT
-            };
 
             var tags = context.AnnotationTags.Where(at => at.Annotation.Id == annotationId)
                 .Include(at => at.Tag).ThenInclude(t => t.Category)
                 .Include(at => at.SubTag)
                 .ToList();
 
+            List<TagInstanceInfo> tagInstanceInfos = new();
             foreach (var tag in tags)
             {
                 var tagInstance = mapper.Map<TagInstanceInfo>(tag);
-                annotationInfo.TagInstances.Add(tagInstance);
+                tagInstanceInfos.Add(tagInstance);
             }
 
+            var docToRender = PreprocessHTML(documentContent.Content, tags);
+
+            // We probably cannot use AutoMapper since we are dealing with too many different entities
+            AnnotationInfo annotationInfo = new()
+            {
+                SourceDocumentContent = documentContent.Content,
+                DocumentToRender = docToRender,
+                TagStartPositions = TagStartPositions.ToArray(),
+                TagLengths = TagStartLengths.ToArray(),
+                Note = annotation.Note,
+                State = annotation.State,
+                Type = IsHtml(documentContent.Content) ? EDocumentType.HTML : EDocumentType.TEXT,
+                TagInstances = tagInstanceInfos
+            };
+
             return annotationInfo;
         }
 
-        private (string docToRender, int[] openingTagPositions, int[] openingTagLengths, int[] closingTagPositions, int[] closingTagLengths) PreprocessHTML(string htmlSource)
+        private List<int> TagStartPositions = new();
+        private List<int> TagStartLengths = new();
+        private List<int> TagClosingPositions = new();
+        private List<int> TagClosingLengths = new();
+        private Dictionary<HtmlNode, HtmlNode> NodeDict = new();
+
+        private string PreprocessHTML(string htmlSource, List<AnnotationTag> tags)
         {
             var docOriginal = new HtmlDocument();
             docOriginal.LoadHtml(htmlSource);
             var docToEdit = new HtmlDocument();
             docToEdit.LoadHtml(htmlSource);
 
-            string idAttributeName = "aswi-tag-id";
-
             var descendantsOriginal = docOriginal.DocumentNode.DescendantsAndSelf();
             var descendantsToEdit = docToEdit.DocumentNode.DescendantsAndSelf();
 
-            List<int> tagStartPositions = new();
-            List<int> tagStartLengths = new();
+            int currentId = 0;
 
+            FillNodeDict(descendantsOriginal, descendantsToEdit);
+            AssignIdsToOriginalDocument(descendantsOriginal, ref currentId);
 
-            List<int> tagClosingPositions = new();
-            List<int> tagClosingLengths = new();
+            var cssNode = GenerateCSS(docToEdit, tags, new());
+            docToEdit.DocumentNode.ChildNodes.Add(cssNode);
 
-            int currentId = 0;
+            WrapTextInSpan(descendantsOriginal, docToEdit);
 
-            var zipped = descendantsOriginal.Zip(descendantsToEdit, (orig, toEdit) => new { Original = orig, ToEdit = toEdit });
-            foreach (var node in zipped)
+            int descendantsCount = descendantsToEdit.Count();
+            foreach (var tag in tags)
             {
-                var originalNode = node.Original;
-                var toEditNode = node.ToEdit;
-
-                if (originalNode.Name.Contains("#"))
+                int i = 0;
+                List<HtmlNode> addedForSelection = new();
+                while (i < descendantsCount)
                 {
-                    continue;
+                    for (; i < descendantsCount; i++)
+                    {
+                        var node = descendantsToEdit.ElementAt(i);
+                        if (!node.Name.Contains("#text") || addedForSelection.Contains(node) || addedForSelection.Contains(node.ParentNode) ||
+                            node.ParentNode.Name == "style")
+                        {
+                            continue;
+                        }
+
+                        var start = TagStartPositions[node.ParentNode.GetAttributeValue(TAG_ID_ATTRIBUTE_NAME, -1)];
+                        var end = TagClosingPositions[node.ParentNode.GetAttributeValue(TAG_ID_ATTRIBUTE_NAME, -1)];
+
+                        int selectionStart = tag.Position;
+                        int selectionEnd = tag.Position + tag.Length;
+
+                        if (selectionStart < end && selectionEnd > start)
+                        {
+                            if (selectionStart <= start && selectionEnd >= end)
+                            {
+                                addedForSelection.Add(SolveFullFill(node, selectionStart, selectionEnd, start, end, docToEdit, tag));
+                            }
+                            else if (selectionStart <= start)
+                            {
+                                addedForSelection.AddRange(SolveRightGap(node, selectionStart, selectionEnd, start, end, docToEdit, tag));
+                            }
+                            else if (selectionEnd >= end)
+                            {
+                                addedForSelection.AddRange(SolveLeftGap(node, selectionStart, selectionEnd, start, end, docToEdit, tag));
+                            }
+                            else
+                            {
+                                addedForSelection.AddRange(SolveLeftRightGap(node, selectionStart, selectionEnd, start, end, docToEdit, tag));
+                            }
+                            break;
+                        }
+                    }
                 }
 
-                tagStartPositions.Add(originalNode.OuterStartIndex);
-                tagStartLengths.Add(originalNode.InnerStartIndex - originalNode.OuterStartIndex);
-                currentId = tagStartPositions.Count - 1;
-                toEditNode.Attributes.Add(idAttributeName, currentId.ToString());
-
-                tagClosingPositions.Add(originalNode.InnerStartIndex + originalNode.InnerLength);
-                tagClosingLengths.Add((originalNode.OuterStartIndex + originalNode.OuterLength) - (originalNode.InnerStartIndex + originalNode.InnerLength));
             }
 
             string docToRender = docToEdit.DocumentNode.OuterHtml;
             HtmlSanitizer sanitizer = new HtmlSanitizer();
             sanitizer.AllowedAttributes.Clear();
-            sanitizer.AllowedAttributes.Add(idAttributeName);
+            sanitizer.AllowedAttributes.Add(TAG_ID_ATTRIBUTE_NAME);
+            sanitizer.AllowedAttributes.Add(TAG_INSTANCE_ATTRIBUTE_NAME);
             if (sanitizer.AllowedTags.Contains("script"))
             {
                 sanitizer.AllowedTags.Remove("script");
@@ -191,13 +230,276 @@ namespace Core.Services.AnnotationService
 
             docToRender = sanitizer.Sanitize(docToRender);
 
-            return (docToRender, tagStartPositions.ToArray(), tagStartLengths.ToArray(), tagClosingPositions.ToArray(), tagClosingLengths.ToArray());
+            return docToRender;
+        }
+
+        private HtmlNode SolveFullFill(HtmlNode node, int selectionStart, int selectionEnd, int start, int end, HtmlDocument docToEdit, AnnotationTag tag)
+        {
+            // full fill
+            string textSelected = node.InnerText;
+
+            var parentNode = node.ParentNode;
+            int nodeIndex = parentNode.ChildNodes.IndexOf(node);
+            parentNode.ChildNodes.RemoveAt(nodeIndex);
+
+            EPosition markerPosition = EPosition.MARK_NONE;
+            if (selectionEnd == end && selectionStart == start)
+            {
+                markerPosition = EPosition.MARK_LEFT_RIGHT;
+            }
+
+            HtmlNode spanSelected = CreateSpan(docToEdit, textSelected, TAG_ID_ATTRIBUTE_NAME, TagStartPositions.Count,
+                TAG_INSTANCE_ATTRIBUTE_NAME, tag.Instance, start, markerPosition);
+            parentNode.ChildNodes.Insert(nodeIndex, spanSelected);
+
+            return spanSelected;
+        }
+
+        private List<HtmlNode> SolveRightGap(HtmlNode node, int selectionStart, int selectionEnd, int start, int end, HtmlDocument docToEdit,
+                                             AnnotationTag tag)
+        {
+            // partial fill, end gap
+            string text = node.InnerText;
+            string textAfter = text.Substring(Math.Min(selectionStart - start + tag.Length, text.Length));
+            string textSelected = text.Substring(0, selectionEnd - start);
+
+            var parentNode = node.ParentNode;
+            int nodeIndex = parentNode.ChildNodes.IndexOf(node);
+            parentNode.ChildNodes.RemoveAt(nodeIndex);
+
+            int spanSelectedStart = start;
+            int spanAfterStart = start + textSelected.Length;
+
+            HtmlNode spanSelected = CreateSpan(docToEdit, textSelected, TAG_ID_ATTRIBUTE_NAME, TagStartPositions.Count,
+                TAG_INSTANCE_ATTRIBUTE_NAME, tag.Instance, spanSelectedStart, EPosition.MARK_RIGHT);
+            parentNode.ChildNodes.Insert(nodeIndex, spanSelected);
+
+            HtmlNode spanAfter = CreateSpan(docToEdit, textAfter, TAG_ID_ATTRIBUTE_NAME, TagStartPositions.Count,
+                TAG_INSTANCE_ATTRIBUTE_NAME, null, spanAfterStart);
+            parentNode.ChildNodes.Insert(nodeIndex + 1, spanAfter);
+
+            return new() { spanSelected, spanAfter };
+        }
+
+        private List<HtmlNode> SolveLeftGap(HtmlNode node, int selectionStart, int selectionEnd, int start, int end, HtmlDocument docToEdit,
+                                             AnnotationTag tag)
+        {
+            // partial fill, start gap
+            string text = node.InnerText;
+            string textBefore = text.Substring(0, selectionStart - start);
+            string textSelected = text.Substring(selectionStart - start, Math.Min(tag.Length, text.Length - textBefore.Length));
+
+            var parentNode = node.ParentNode;
+            int nodeIndex = parentNode.ChildNodes.IndexOf(node);
+            parentNode.ChildNodes.RemoveAt(nodeIndex);
+
+            int spanBeforeStart = start;
+            int spanSelectedStart = start + textBefore.Length;
+
+            HtmlNode spanBefore = CreateSpan(docToEdit, textBefore, TAG_ID_ATTRIBUTE_NAME, TagStartPositions.Count,
+                TAG_INSTANCE_ATTRIBUTE_NAME, null, spanBeforeStart);
+            parentNode.ChildNodes.Insert(nodeIndex, spanBefore);
+
+            HtmlNode spanSelected = CreateSpan(docToEdit, textSelected, TAG_ID_ATTRIBUTE_NAME, TagStartPositions.Count,
+                TAG_INSTANCE_ATTRIBUTE_NAME, tag.Instance, spanSelectedStart, EPosition.MARK_LEFT);
+            parentNode.ChildNodes.Insert(nodeIndex + 1, spanSelected);
+
+            return new() { spanSelected, spanBefore };
+        }
+
+        private List<HtmlNode> SolveLeftRightGap(HtmlNode node, int selectionStart, int selectionEnd, int start, int end, HtmlDocument docToEdit,
+                                                 AnnotationTag tag)
+        {
+            // partial fill, start gap end gap
+            string text = node.InnerText;
+            string textBefore = text.Substring(0, selectionStart - start);
+            string textAfter = text.Substring(selectionStart - start + tag.Length);
+            string textSelected = text.Substring(selectionStart - start, tag.Length);
+
+            var parentNode = node.ParentNode;
+            int nodeIndex = parentNode.ChildNodes.IndexOf(node);
+            parentNode.ChildNodes.RemoveAt(nodeIndex);
+
+            int spanBeforeStart = start;
+            int spanSelectedStart = start + textBefore.Length;
+            int spanAfterStart = start + textBefore.Length + textSelected.Length;
+
+            HtmlNode spanBefore = CreateSpan(docToEdit, textBefore, TAG_ID_ATTRIBUTE_NAME, TagStartPositions.Count,
+                TAG_INSTANCE_ATTRIBUTE_NAME, null, spanBeforeStart);
+            parentNode.ChildNodes.Insert(nodeIndex, spanBefore);
+
+            HtmlNode spanSelected = CreateSpan(docToEdit, textSelected, TAG_ID_ATTRIBUTE_NAME, TagStartPositions.Count,
+                TAG_INSTANCE_ATTRIBUTE_NAME, tag.Instance, spanSelectedStart, EPosition.MARK_LEFT_RIGHT);
+            parentNode.ChildNodes.Insert(nodeIndex + 1, spanSelected);
+
+            HtmlNode spanAfter = CreateSpan(docToEdit, textAfter, TAG_ID_ATTRIBUTE_NAME, TagStartPositions.Count,
+                TAG_INSTANCE_ATTRIBUTE_NAME, null, spanAfterStart);
+            parentNode.ChildNodes.Insert(nodeIndex + 2, spanAfter);
+
+            return new() { spanSelected, spanBefore, spanAfter };
+        }
+
+        private HtmlNode CreateSpan(HtmlDocument doc, string text, string tagIdAttributeName, int tagId, string tagInstanceAttributeName,
+                                            Guid? instanceId, int startPosition, EPosition position = EPosition.MARK_NONE)
+        {
+            HtmlNode span = doc.CreateElement("span");
+            span.InnerHtml = text;
+            TagStartPositions.Add(startPosition);
+            TagStartLengths.Add(0);
+            TagClosingPositions.Add(startPosition + text.Length);
+            TagClosingLengths.Add(0);
+            span.Attributes.Add(tagIdAttributeName, tagId.ToString());
+
+            if (instanceId != null)
+            {
+                span.AddClass("annotation");
+                span.Attributes.Add(tagInstanceAttributeName, instanceId.Value.ToString());
+
+                if (position == EPosition.MARK_LEFT || position == EPosition.MARK_LEFT_RIGHT)
+                {
+                    span.Attributes.Add("start", "1");
+                }
+                if (position == EPosition.MARK_RIGHT || position == EPosition.MARK_LEFT_RIGHT)
+                {
+                    span.Attributes.Add("end", "1");
+                }
+            }
+
+            return span;
+        }
+
+        private enum EPosition
+        {
+            MARK_LEFT = 5,
+            MARK_RIGHT = 3,
+            MARK_LEFT_RIGHT = 2,
+            MARK_NONE = 0
+        }
+
+        private void WrapTextInSpan(IEnumerable<HtmlNode> descendantsOriginal, HtmlDocument docToEdit)
+        {
+            foreach (var node in descendantsOriginal)
+            {
+                var originalNode = node;
+                var toEditNode = NodeDict[node];
+
+                if (originalNode.Name.Contains("#"))
+                {
+                    continue;
+                }
+                else
+                {
+                    bool onlyText = true;
+                    bool onlySubtags = true;
+
+                    foreach (var child in node.ChildNodes)
+                    {
+                        if (child.Name.Contains("#"))
+                        {
+                            onlySubtags = false;
+                        }
+                        else
+                        {
+                            onlyText = false;
+                        }
+                    }
+
+                    if (onlyText || onlySubtags)
+                    {
+                        continue;
+                    }
+                    else
+                    {
+
+                        foreach (var child in node.ChildNodes)
+                        {
+                            if (child.Name.Contains("#text"))
+                            {
+                                HtmlNode coveringSpan = docToEdit.CreateElement("span");
+                                coveringSpan.InnerHtml = child.InnerHtml;
+                                TagStartPositions.Add(child.InnerStartIndex);
+                                TagStartLengths.Add(0);
+                                TagClosingPositions.Add(child.InnerStartIndex + child.InnerLength);
+                                TagClosingLengths.Add(0);
+                                coveringSpan.Attributes.Add(TAG_ID_ATTRIBUTE_NAME, (TagStartPositions.Count - 1).ToString());
+
+                                var parent = NodeDict[node];
+                                var index = parent.ChildNodes.IndexOf(NodeDict[child]);
+
+                                parent.ChildNodes.RemoveAt(index);
+                                parent.ChildNodes.Insert(index, coveringSpan);
+                            }
+                        }
+                    }
+                }
+            }
+        }
+
+        private HtmlNode GenerateCSS(HtmlDocument docToEdit, List<AnnotationTag> tags, List<int> paddings)
+        {
+            HtmlNode style = docToEdit.CreateElement("style");
+
+            string inner = "span.annotation {border-bottom: 2px solid;}";
+            inner += "span {line-height: 30px}\n";
+
+            // TODO temporary
+            int lastPadding = 1;
+            foreach (var tag in tags)
+            {
+                inner += $"span[{TAG_INSTANCE_ATTRIBUTE_NAME}=\"{tag.Instance}\"] {{ border-color:{tag.Tag.Color}; padding-bottom: {lastPadding}px }}";
+                lastPadding += 2;
+            }
+
+            inner += "span[end=\"1\"] {border-end-end-radius: 0px; border-right: 1px solid darkgray}\n";
+            inner += "span[start=\"1\"] {border-end-start-radius: 0px; border-left: 1px solid darkgray}\n";
+
+            style.InnerHtml = inner;
+            return style;
+        }
+
+        private void AssignIdsToOriginalDocument(IEnumerable<HtmlNode> descendantsOriginal, ref int currentId)
+        {
+            foreach (var node in descendantsOriginal)
+            {
+                var originalNode = node;
+                var toEditNode = NodeDict[node];
+
+                if (originalNode.Name.Contains("#"))
+                {
+                    continue;
+                }
+                else
+                {
+                    TagStartPositions.Add(originalNode.OuterStartIndex);
+                    TagStartLengths.Add(originalNode.InnerStartIndex - originalNode.OuterStartIndex);
+                    currentId = TagStartPositions.Count - 1;
+                    toEditNode.Attributes.Add(TAG_ID_ATTRIBUTE_NAME, currentId.ToString());
+
+                    TagClosingPositions.Add(originalNode.InnerStartIndex + originalNode.InnerLength);
+                    TagClosingLengths.Add((originalNode.OuterStartIndex + originalNode.OuterLength) - (originalNode.InnerStartIndex + originalNode.InnerLength));
+                }
+            }
+        }
+
+        private void FillNodeDict(IEnumerable<HtmlNode> descendantsOriginal, IEnumerable<HtmlNode> descendantsToEdit)
+        {
+            var zipped = descendantsOriginal.Zip(descendantsToEdit, (orig, toEdit) => new
+            {
+                Original = orig,
+                ToEdit = toEdit
+            });
+            foreach (var node in zipped)
+            {
+                var originalNode = node.Original;
+                var toEditNode = node.ToEdit;
+                NodeDict.Add(originalNode, toEditNode);
+            }
         }
 
         // TODO temporary
         private bool IsHtml(string text)
         {
-            return text.Contains("<!DOCTYPE html>");
+            return text.Contains("<html>");
         }
 
         public void AddAnnotationInstance(Guid annotationId, Guid userId, ERole userRole, AnnotationInstanceAddRequest request)
@@ -279,7 +581,7 @@ namespace Core.Services.AnnotationService
             context.AnnotationTags
                 .Where(at => at.Id == tagInstanceId).ToList()
                 .ForEach(a => context.AnnotationTags.Remove(a));
-            
+
             context.SaveChanges();
         }
     }
