Browse Source

Initial implementation of semantic structure (accessability, tagging, bookmarks, etc.)

Marcin Ziąbek 3 months ago
parent
commit
16efedfb14

+ 2 - 0
Source/QuestPDF.UnitTests/TestEngine/MockCanvas.cs

@@ -49,5 +49,7 @@ namespace QuestPDF.UnitTests.TestEngine
         public void DrawHyperlink(string url, Size size) => throw new NotImplementedException();
         public void DrawSectionLink(string sectionName, Size size) => throw new NotImplementedException();
         public void DrawSection(string sectionName) => throw new NotImplementedException();
+        
+        public void SetSemanticNodeId(int nodeId) => throw new NotImplementedException();
     }
 }

+ 2 - 0
Source/QuestPDF.UnitTests/TestEngine/OperationRecordingCanvas.cs

@@ -46,5 +46,7 @@ namespace QuestPDF.UnitTests.TestEngine
         public void DrawHyperlink(string url, Size size) => throw new NotImplementedException();
         public void DrawSectionLink(string sectionName, Size size) => throw new NotImplementedException();
         public void DrawSection(string sectionName) => throw new NotImplementedException();
+        
+        public void SetSemanticNodeId(int nodeId) => throw new NotImplementedException();
     }
 }

+ 7 - 4
Source/QuestPDF/Drawing/DocumentCanvases/PdfDocumentCanvas.cs

@@ -13,12 +13,13 @@ namespace QuestPDF.Drawing.DocumentCanvases
         private SkCanvas? CurrentPageCanvas { get; set; }
         private ProxyDrawingCanvas DrawingCanvas { get; } = new();
         
-        public PdfDocumentCanvas(SkWriteStream stream, DocumentMetadata documentMetadata, DocumentSettings documentSettings)
+        // TODO: is there a better way to pass semantic-related data? Skia requires it BEFORE content generation
+        public PdfDocumentCanvas(SkWriteStream stream, DocumentMetadata documentMetadata, DocumentSettings documentSettings, SkPdfTag? pdfTag)
         {
-            Document = CreatePdf(stream, documentMetadata, documentSettings);
+            Document = CreatePdf(stream, documentMetadata, documentSettings, pdfTag);
         }
 
-        private static SkDocument CreatePdf(SkWriteStream stream, DocumentMetadata documentMetadata, DocumentSettings documentSettings)
+        private static SkDocument CreatePdf(SkWriteStream stream, DocumentMetadata documentMetadata, DocumentSettings documentSettings, SkPdfTag? pdfTag)
         {
             // do not extract to another method, as it will cause the SkText objects
             // to be disposed before the SkPdfDocument is created
@@ -45,7 +46,9 @@ namespace QuestPDF.Drawing.DocumentCanvases
                 
                 RasterDPI = documentSettings.ImageRasterDpi,
                 SupportPDFA = documentSettings.PdfA,
-                CompressDocument = documentSettings.CompressDocument
+                CompressDocument = documentSettings.CompressDocument,
+                
+                SemanticNodeRoot = pdfTag?.Instance ?? IntPtr.Zero
             };
             
             try

+ 64 - 1
Source/QuestPDF/Drawing/DocumentGenerator.cs

@@ -28,8 +28,29 @@ namespace QuestPDF.Drawing
             
             var metadata = document.GetMetadata();
             var settings = document.GetSettings();
-            using var canvas = new PdfDocumentCanvas(stream, metadata, settings);
+            
+            using var semanticTree = GetSemanticTree(document);
+            
+            using var canvas = new PdfDocumentCanvas(stream, metadata, settings, semanticTree);
             RenderDocument(canvas, document, settings);
+
+            static SkPdfTag GetSemanticTree(IDocument document)
+            {
+                // TODO: optimize semantic tree generation
+                // this implementation creates entire document structure only for semantic tree generation,
+                // and then disposes everything, instead of reusing the same structure also for document rendering
+                
+                // TODO: better handle the Lazy element
+                // with this implementation, the entire document is materialized for semantic tree generation, and the Lazy element does not optimize the memory usage
+                // most likely, the Lazy element should be aware of semantic tree generation, or there should be an addition structure, e.g. SemanticLazy or even SemanticLazyProxy
+                
+                var container = new DocumentContainer();
+                document.Compose(container);
+            
+                var content = container.Compose();
+                content.PopulateSemanticTagIds();
+                return content.ExtractStructuralInformation();
+            }
         }
         
         internal static void GenerateXps(SkWriteStream stream, IDocument document)
@@ -150,6 +171,9 @@ namespace QuestPDF.Drawing
 
             var content = ConfigureContent(document, settings, useOriginalImages);
             
+            // TODO: this step may not be required if document structure is used for both: semantic tree generation and document rendering
+            content.PopulateSemanticTagIds();
+            
             if (canvas is CompanionDocumentCanvas)
                 content.VisitChildren(x => x.CreateProxy(y => new LayoutProxy(y)));
             
@@ -543,5 +567,44 @@ namespace QuestPDF.Drawing
                     ApplyInheritedAndGlobalTexStyle(child, documentDefaultTextStyle);
             }
         }
+
+        internal static void PopulateSemanticTagIds(this Element element)
+        {
+            var currentId = 1;
+            Traverse(element);  
+            
+            void Traverse(Element element)
+            {
+                if (element is SemanticTag semanticTag)
+                {
+                    semanticTag.Id = currentId;
+                    currentId++;
+                }
+
+                if (element is ContainerElement container)
+                {
+                    Traverse(container.Child);
+                    return;
+                }
+                
+                foreach (var child in element.GetChildren())
+                    Traverse(child);
+            }
+        }
+
+        internal static SkPdfTag ExtractStructuralInformation(this Element element)
+        {
+            var semanticTags = element.ExtractElementsOfType<SemanticTag>();
+            return GetSkiaTagFor(semanticTags.First());
+
+            static SkPdfTag GetSkiaTagFor(TreeNode<SemanticTag> treeNode)
+            {
+                var tagElement = treeNode.Value;
+                var result = SkPdfTag.Create(tagElement.Id, tagElement.TagType, tagElement.Alt, tagElement.Lang ?? "en-US");
+                var children = treeNode.Children.Select(GetSkiaTagFor).ToArray();
+                result.SetChildren(children);
+                return result;
+            }
+        }
     }
 }

+ 5 - 0
Source/QuestPDF/Drawing/DrawingCanvases/FreeDrawingCanvas.cs

@@ -147,5 +147,10 @@ namespace QuestPDF.Drawing.DrawingCanvases
         {
             
         }
+        
+        public void SetSemanticNodeId(int nodeId)
+        {
+            
+        }
     }
 }

+ 5 - 0
Source/QuestPDF/Drawing/DrawingCanvases/ProxyDrawingCanvas.cs

@@ -162,5 +162,10 @@ internal sealed class ProxyDrawingCanvas : IDrawingCanvas, IDisposable
         Target.DrawSection(sectionName);
     }
     
+    public void SetSemanticNodeId(int nodeId)
+    {
+        Target.SetSemanticNodeId(nodeId);
+    }
+    
     #endregion
 }

+ 5 - 0
Source/QuestPDF/Drawing/DrawingCanvases/SkiaDrawingCanvas.cs

@@ -231,6 +231,11 @@ namespace QuestPDF.Drawing.DrawingCanvases
             CurrentCanvas.AnnotateDestination(sectionName);
         }
         
+        public void SetSemanticNodeId(int nodeId)
+        {
+            CurrentCanvas.SetSemanticNodeId(nodeId);
+        }
+        
         #endregion
     }
 }

+ 19 - 0
Source/QuestPDF/Elements/SemanticTag.cs

@@ -0,0 +1,19 @@
+using System;
+using QuestPDF.Infrastructure;
+
+namespace QuestPDF.Elements;
+
+internal class SemanticTag : ContainerElement
+{
+    public int Id { get; set; }
+    public string TagType { get; set; }
+    public string? Alt { get; set; }
+    public string? Lang { get; set; }
+    
+    internal override void Draw(Size availableSpace)
+    {
+        Console.WriteLine($"{TagType}: {Id}");
+        Canvas.SetSemanticNodeId(Id);
+        Child?.Draw(availableSpace);
+    }
+}

+ 311 - 0
Source/QuestPDF/Fluent/SemanticExtensions.cs

@@ -0,0 +1,311 @@
+using System;
+using QuestPDF.Infrastructure;
+
+namespace QuestPDF.Fluent;
+
+public static class SemanticExtensions
+{
+    private static IContainer SemanticTag(this IContainer container, string type, string? alt = null)
+    {
+        return container.Element(new Elements.SemanticTag
+        {
+            TagType = type, 
+            Alt = alt
+        });
+    }
+    
+    /// <summary>
+    /// A complete document.
+    /// This is the root element of any structure tree containing multiple parts or multiple articles.
+    /// </summary>
+    public static IContainer SemanticDocument(this IContainer container)
+    {
+        return container.SemanticTag("Document");
+    }
+    
+    /// <summary>
+    /// A large-scale division of a document.
+    /// This type of element is appropriate for grouping articles or sections.
+    /// </summary>
+    public static IContainer SemanticPart(this IContainer container)
+    {
+        return container.SemanticTag("Part");
+    }
+    
+    /// <summary>
+    /// A relatively self-contained body of text constituting a single narrative or exposition.
+    /// Articles should be disjoint; that is, they should not contain other articles as constituent elements.
+    /// </summary>
+    public static IContainer SemanticArticle(this IContainer container)
+    {
+        return container.SemanticTag("Art");
+    }
+    
+    /// <summary>
+    /// A container for grouping related content elements.
+    /// For example, a section might contain a heading, several introductory paragraphs, and two or more other sections nested within it as subsections.
+    /// </summary>
+    public static IContainer SemanticSection(this IContainer container)
+    {
+        return container.SemanticTag("Sect");
+    }
+    
+    /// <summary>
+    /// A generic block-level element or group of elements.
+    /// </summary>
+    public static IContainer SemanticDivision(this IContainer container)
+    {
+        return container.SemanticTag("Div");
+    }
+    
+    /// <summary>
+    /// A portion of text consisting of one or more paragraphs attributed to someone other than the author of the surrounding text.
+    /// </summary>
+    public static IContainer SemanticBlockQuotation(this IContainer container)
+    {
+        return container.SemanticTag("BlockQuote");
+    }
+    
+    /// <summary>
+    /// A brief portion of text describing a table or figure.
+    /// </summary>
+    public static IContainer SemanticCaption(this IContainer container)
+    {
+        return container.SemanticTag("Caption");
+    }
+    
+    /// <summary>
+    /// 
+    /// </summary>
+    public static IContainer SemanticLanguage(this IContainer container, string lang)
+    {
+        return container.SemanticTag("Caption");
+    }
+    
+    #region Table of Contents
+    
+    /// <summary>
+    /// <para>A list made up of table of contents item entries (SemanticTableOfContentsItem) and/or other nested table of contents entries (SemanticTableOfContents).</para>
+    /// <para>A SemanticTableOfContents entry that includes only SemanticTableOfContentsItem entries represents a flat hierarchy.</para>
+    /// <para>A SemanticTableOfContents entry that includes other nested SemanticTableOfContents entries (and possibly SemanticTableOfContentsItem entries) represents a more complex hierarchy.</para>
+    /// <para>Ideally, the hierarchy of a top-level SemanticTableOfContents entry reflects the structure of the main body of the document.</para>
+    /// <para>Lists of figures and tables, as well as bibliographies, can be treated as  tables of contents for purposes of the standard structure types.</para>
+    /// </summary>
+    public static IContainer SemanticTableOfContents(this IContainer container)
+    {
+        return container.SemanticTag("TOC");
+    }
+    
+    /// <summary>
+    /// An individual member of a table of contents.
+    /// </summary>
+    public static IContainer SemanticTableOfContentsItem(this IContainer container)
+    {
+        return container.SemanticTag("TOCI");
+    }
+    
+    #endregion
+    
+    // and more
+    
+    #region Headers
+    
+    /// <summary>
+    /// (Heading) A label for a subdivision of a document's content. It should be the first child of the division that it heads.
+    /// </summary>
+    public static IContainer SemanticHeader(this IContainer container, string title)
+    {
+        if (string.IsNullOrWhiteSpace(title))
+            throw new ArgumentException("Title cannot be null or empty.", nameof(title));
+
+        return container.SemanticTag($"H1", title);
+    }
+    
+    #endregion
+    
+    /// <summary>
+    /// A low-level division of text.
+    /// </summary>
+    public static IContainer SemanticParagraph(this IContainer container)
+    {
+        return container.SemanticTag("P");
+    }
+    
+    #region Lists
+    
+    /// <summary>
+    /// A sequence of items of like meaning and importance.
+    /// Its immediate children should be an optional SemanticCaption followed by one or more list items SemanticListItem.
+    /// </summary>
+    public static IContainer SemanticList(this IContainer container)
+    {
+        return container.SemanticTag("L");
+    }
+
+    /// <summary>
+    /// An individual member of a list.
+    /// Its children may be one or more SemanticListLabel, SemanticListItemBody, or both.
+    /// </summary>
+    public static IContainer SemanticListItem(this IContainer container)
+    {
+        return container.SemanticTag("LI");
+    }
+    
+    /// <summary>
+    /// A name or number that distinguishes a given item from others in the same list or other group of like items.
+    /// 
+    /// <remarks>
+    /// In a dictionary list, for example, it contains the term being defined; in a bulleted or numbered list, it contains the bullet character or the number of the list item and associated punctuation.
+    /// </remarks>
+    /// </summary>
+    public static IContainer SemanticListLabel(this IContainer container)
+    {
+        return container.SemanticTag("Lbl");
+    }
+    
+    /// <summary>
+    /// The descriptive content of a list item.
+    /// 
+    /// <remarks>
+    /// In a dictionary list, for example, it contains the definition of the term. It may contain more sophisticated content.
+    /// </remarks>
+    /// </summary>
+    public static IContainer SemanticListItemBody(this IContainer container)
+    {
+        return container.SemanticTag("LBody");
+    }
+    
+    #endregion
+    
+    #region Table
+    
+    /// <summary>
+    /// A two-dimensional layout of rectangular data cells, possibly having a complex substructure.
+    /// It contains either one or more table rows SemanticTableRow as children; or an optional table head SemanticTableHeader followed by one or more table body elements SemanticTableBody and an optional table footer SemanticTableFooter.
+    /// In addition, a table may have a SemanticCaption as its first or last child.
+    /// </summary>
+    public static IContainer SemanticTable(this IContainer container)
+    {
+        return container.SemanticTag("Table");
+    }
+    
+    /// <summary>
+    ///  A row of headings or data in a table. It may contain table header cells and table data cells (structure types TH and TD).
+    /// </summary>
+    public static IContainer SemanticTableRow(this IContainer container)
+    {
+        return container.SemanticTag("TR");
+    }
+    
+    /// <summary>
+    /// A table cell containing header text describing one or more rows or columns of the table.
+    /// </summary>
+    public static IContainer SemanticTableHeaderCell(this IContainer container)
+    {
+        return container.SemanticTag("TH");
+    }
+    
+    /// <summary>
+    /// A table cell containing data that is part of the table's content.
+    /// </summary>
+    public static IContainer SemanticTableDataCell(this IContainer container)
+    {
+        return container.SemanticTag("TD");
+    }
+    
+    /// <summary>
+    /// A group of rows that constitute the header of a table.
+    /// If the table is split across multiple pages, these rows may be redrawn at the top of each table fragment (although there is only one SemanticTableHeader element).
+    /// </summary>
+    public static IContainer SemanticTableHeader(this IContainer container)
+    {
+        return container.SemanticTag("THead");
+    }
+    
+    /// <summary>
+    /// A group of rows that constitute the main body portion of a table.
+    /// If the table is split across multiple pages, the body area may be broken apart on a row boundary.
+    /// A table may have multiple SemanticTableBody elements to allow for the drawing of a border or background for a set of rows.
+    /// </summary>
+    public static IContainer SemanticTableBody(this IContainer container)
+    {
+        return container.SemanticTag("TBody");
+    }
+
+    /// <summary>
+    /// A group of rows that constitute the footer of a table.
+    /// If the table is split across multiple pages, these rows may be redrawn at the bottom of each table fragment (although there is only one SemanticTableFooter element).
+    /// </summary>
+    public static IContainer SemanticTableFooter(this IContainer container)
+    {
+        return container.SemanticTag("TFood");
+    }
+    
+    #endregion
+    
+    #region Inline Elements
+    
+    /// <summary>
+    /// A generic inline portion of text having no particular inherent characteristics.
+    /// It can be used, for example, to delimit a range of text with a given set of styling attributes.
+    /// </summary>
+    public static IContainer SemanticSpan(this IContainer container)
+    {
+        return container.SemanticTag("Span");
+    }
+    
+    /// <summary>
+    /// An inline portion of text attributed to someone other than the author of the surrounding text.
+    /// The quoted text should be contained inline within a single paragraph.
+    /// This differs from the block-level element SemanticBlockQuotation, which consists of one or more complete paragraphs (or other elements presented as if they were complete paragraphs).
+    /// </summary>
+    public static IContainer SemanticQuote(this IContainer container)
+    {
+        return container.SemanticTag("Quote");
+    }
+
+    /// <summary>
+    /// A fragment of computer program text.
+    /// </summary>
+    public static IContainer SemanticCode(this IContainer container)
+    {
+        return container.SemanticTag("Code");
+    }
+    
+    #endregion
+    
+    #region Illustration Elements
+    
+    /// <summary>
+    /// An item of graphical content.
+    /// </summary>
+    public static IContainer SemanticFigure(this IContainer container)
+    {
+        return container.SemanticTag("Figure");
+    }
+    
+    /// <summary>
+    /// An alias for a SemanticFigure.
+    /// </summary>
+    public static IContainer SemanticImage(this IContainer container)
+    {
+        return container.SemanticFigure();
+    }
+    
+    /// <summary>
+    /// A mathematical formula.
+    ///
+    /// This structure type is useful only for identifying an entire content element as a formula.
+    /// No standard structure types are defined for identifying individual components within the formula.
+    /// From a formatting standpoint, the formula shall be treated similarly to a figure.
+    /// </summary>
+    public static IContainer SemanticFormula(this IContainer container)
+    {
+        return container.SemanticTag("Formula");
+    }
+    
+    #endregion
+    
+    // TODO: links?
+    // TODO: ActualText?
+}

+ 2 - 0
Source/QuestPDF/Infrastructure/IDrawingCanvas.cs

@@ -40,5 +40,7 @@ namespace QuestPDF.Infrastructure
         void DrawHyperlink(string url, Size size);
         void DrawSectionLink(string sectionName, Size size);
         void DrawSection(string sectionName);
+        
+        void SetSemanticNodeId(int nodeId);
     }
 }

BIN
Source/QuestPDF/Runtimes/osx-arm64/native/libQuestPdfSkia.dylib


+ 8 - 0
Source/QuestPDF/Skia/SkCanvas.cs

@@ -141,6 +141,11 @@ internal sealed class SkCanvas : IDisposable
         API.canvas_set_matrix9(Instance, matrix);
     }
     
+    public void SetSemanticNodeId(int nodeId)
+    {
+        API.canvas_set_semantic_node_id(Instance, nodeId);
+    }
+    
     ~SkCanvas()
     {
         this.WarnThatFinalizerIsReached();
@@ -235,5 +240,8 @@ internal sealed class SkCanvas : IDisposable
         
         [DllImport(SkiaAPI.LibraryName, CallingConvention = CallingConvention.Cdecl)]
         public static extern void canvas_set_matrix9(IntPtr canvas, SkCanvasMatrix matrix);
+
+        [DllImport(SkiaAPI.LibraryName, CallingConvention = CallingConvention.Cdecl)]
+        public static extern void canvas_set_semantic_node_id(IntPtr canvas, int nodeId);
     }
 }

+ 3 - 1
Source/QuestPDF/Skia/SkPdfDocument.cs

@@ -19,7 +19,9 @@ internal struct SkPdfDocumentMetadata
 
     [MarshalAs(UnmanagedType.I1)] public bool SupportPDFA;
     [MarshalAs(UnmanagedType.I1)] public bool CompressDocument;
-    public float RasterDPI;    
+    public float RasterDPI;
+
+    public IntPtr SemanticNodeRoot;
 }
 
 internal static class SkPdfDocument

+ 76 - 0
Source/QuestPDF/Skia/SkPdfTag.cs

@@ -0,0 +1,76 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Runtime.InteropServices;
+
+namespace QuestPDF.Skia;
+
+internal sealed class SkPdfTag : IDisposable
+{
+    public IntPtr Instance { get; private set; }
+    public int NodeId { get; set; }
+    public string Type { get; set; } = "";
+    public string? Alt { get; set; }
+    public string? Lang { get; set; }
+    private ICollection<SkPdfTag>? Children { get; set; }
+    
+    private SkPdfTag(IntPtr instance)
+    {
+        Instance = instance;
+        SkiaAPI.EnsureNotNull(Instance);
+    }
+    
+    public static SkPdfTag Create(int nodeId, string? type, string? alt, string? lang)
+    {
+        var instance = API.pdf_structure_element_create(nodeId, type, alt, lang);
+        return new SkPdfTag(instance) { NodeId = nodeId, Type = type ?? "", Alt = alt, Lang = lang };
+    }
+    
+    public void SetChildren(ICollection<SkPdfTag> children)
+    {
+        Children = children;
+        
+        var childrenArray = children.ToArray();
+        var childrenPointers = childrenArray.Select(c => c.Instance).ToArray();
+        var unmanagedArray = Marshal.AllocHGlobal(IntPtr.Size * childrenPointers.Length);
+        Marshal.Copy(childrenPointers, 0, unmanagedArray, childrenPointers.Length);
+        
+        API.pdf_structure_element_set_children(Instance, unmanagedArray, childrenPointers.Length);
+        Marshal.FreeHGlobal(unmanagedArray);
+    }
+
+    ~SkPdfTag()
+    {
+        this.WarnThatFinalizerIsReached();
+        Dispose();
+    }
+    
+    public void Dispose()
+    {
+        if (Instance == IntPtr.Zero)
+            return;
+        
+        foreach (var child in Children ?? [])
+            child.Instance = IntPtr.Zero;
+        
+        API.pdf_structure_element_delete(Instance);
+        Instance = IntPtr.Zero;
+        GC.SuppressFinalize(this);
+    }
+    
+    private static class API
+    {
+        [DllImport(SkiaAPI.LibraryName, CallingConvention = CallingConvention.Cdecl)]
+        public static extern IntPtr pdf_structure_element_create(
+            int nodeId,
+            [MarshalAs(UnmanagedType.CustomMarshaler, MarshalTypeRef = typeof(Utf8StringMarshaller))] string type,
+            [MarshalAs(UnmanagedType.CustomMarshaler, MarshalTypeRef = typeof(Utf8StringMarshaller))] string alt,
+            [MarshalAs(UnmanagedType.CustomMarshaler, MarshalTypeRef = typeof(Utf8StringMarshaller))] string lang);
+        
+        [DllImport(SkiaAPI.LibraryName, CallingConvention = CallingConvention.Cdecl)]
+        public static extern void pdf_structure_element_set_children(IntPtr element, IntPtr children, int count);
+        
+        [DllImport(SkiaAPI.LibraryName, CallingConvention = CallingConvention.Cdecl)]
+        public static extern void pdf_structure_element_delete(IntPtr element);
+    }
+}