Răsfoiți Sursa

Improve semantic handling of complex tables (containing cells that span multiple columns or rows)

Marcin Ziąbek 3 luni în urmă
părinte
comite
f01ac8c98d

+ 85 - 6
Source/QuestPDF/Elements/Table/Table.cs

@@ -357,11 +357,20 @@ namespace QuestPDF.Elements.Table
         #endregion
         
         #region Semantic
+
+        internal enum TablePartType
+        {
+            Header,
+            Body,
+            Footer
+        }
         
         internal bool EnableAutomatedSemanticTagging { get; set; }
         internal bool IsSemanticTaggingApplied { get; set; }
         internal SemanticTreeManager SemanticTreeManager { get; set; } = new();
-        internal bool IsTableHeader { get; set; }
+
+        internal TablePartType PartType { get; set; }
+        public List<TableCell> HeaderCells { get; set; } = []; 
 
         private void RegisterSemanticTree()
         {
@@ -398,17 +407,21 @@ namespace QuestPDF.Elements.Table
                     if (tableCell.Child is not SemanticTag semanticTag)
                         continue;
                     
-                    if (IsTableHeader || tableCell.IsSemanticHorizontalHeader)
+                    if (PartType is TablePartType.Header || tableCell.IsSemanticHorizontalHeader)
                         semanticTag.TagType = "TH";
                     
                     semanticTag.RegisterCurrentSemanticNode();
-                    AssignCellAttributes(tableCell, semanticTag);
+                    tableCell.SemanticNodeId = semanticTag.SemanticTreeNode!.NodeId;
+                    
+                    AssignCellAttributesForColumnAndRowSpans(tableCell, semanticTag);
                 }
                 
                 SemanticTreeManager.PopStack();
             }
 
-            void AssignCellAttributes(TableCell tableCell, SemanticTag semanticTag)
+            AssignCellAttributesForHeaderCellRoles();
+            
+            static void AssignCellAttributesForColumnAndRowSpans(TableCell tableCell, SemanticTag semanticTag)
             {
                 if (tableCell.ColumnSpan > 1)
                 {
@@ -429,10 +442,43 @@ namespace QuestPDF.Elements.Table
                         Value = tableCell.RowSpan
                     });
                 }
+            }
 
-                if (semanticTag.TagType == "TH")
+            void AssignCellAttributesForHeaderCellRoles()
+            {
+                if (PartType is TablePartType.Footer)
+                    return;
+
+                if (DoesTableBodyRequireExtendedHeaderTagging())
+                {
+                    if (PartType is TablePartType.Body)
+                        AssignCellAttributesForHeaderCellRolesOfComplexTables();
+                }
+                else
+                {
+                    AssignCellAttributesForHeaderCellRolesOfSimpleTables();
+                }
+            }
+            
+            bool DoesTableBodyRequireExtendedHeaderTagging()
+            {
+                return ContainsSpanningCells(HeaderCells) || ContainsSpanningCells(Cells);
+                
+                static bool ContainsSpanningCells(IEnumerable<TableCell> cells) =>
+                    cells.Any(x => x.RowSpan > 1 || x.ColumnSpan > 1);
+            }
+            
+            void AssignCellAttributesForHeaderCellRolesOfSimpleTables()
+            {
+                foreach (var tableCell in Cells)
                 {
-                    var scopeValue = (IsTableHeader, tableCell.IsSemanticHorizontalHeader) switch
+                    if (tableCell.Child is not SemanticTag semanticTag)
+                        continue;
+
+                    if (semanticTag.TagType != "TH") 
+                        continue;
+                    
+                    var scopeValue = (PartType is TablePartType.Header, tableCell.IsSemanticHorizontalHeader) switch
                     {
                         (true, true) => "Both",
                         (true, false) => "Column",
@@ -451,6 +497,39 @@ namespace QuestPDF.Elements.Table
                     }
                 }
             }
+            
+            void AssignCellAttributesForHeaderCellRolesOfComplexTables()
+            {
+                var semanticHorizontalHeaders = Cells
+                    .Where(x => x.IsSemanticHorizontalHeader)
+                    .ToList();
+                
+                foreach (var tableCell in Cells)
+                {
+                    if (tableCell.Child is not SemanticTag semanticTag)
+                        continue;
+                    
+                    var relatedVerticalHeaders = HeaderCells
+                        .Where(x => x.Column <= tableCell.Column && tableCell.Column < x.Column + x.ColumnSpan)
+                        .Select(x => x.SemanticNodeId);
+                    
+                    // TODO: this lookup may cause performance issues
+                    var relatedHorizontalHeaders = semanticHorizontalHeaders
+                        .Where(x => x.Row <= tableCell.Row && tableCell.Row < x.Row + x.RowSpan)
+                        .Select(x => x.SemanticNodeId);
+                    
+                    semanticTag.SemanticTreeNode!.Attributes.Add(new SemanticTreeNode.Attribute
+                    {
+                        Owner = "Table",
+                        Name = "Headers",
+                        Value = relatedVerticalHeaders
+                            .Concat(relatedHorizontalHeaders)
+                            .Distinct()
+                            .OrderBy(x => x)
+                            .ToArray()
+                    });
+                }
+            }
         }
         
         #endregion

+ 1 - 0
Source/QuestPDF/Elements/Table/TableCell.cs

@@ -13,6 +13,7 @@ namespace QuestPDF.Elements.Table
         public int ZIndex { get; set; }
         
         public bool IsSemanticHorizontalHeader { get; set; }
+        public int SemanticNodeId { get; set; }
         
         public bool IsRendered { get; set; }
     }

+ 7 - 5
Source/QuestPDF/Fluent/TableExtensions.cs

@@ -162,9 +162,11 @@ namespace QuestPDF.Fluent
             var hasHeader = HeaderTable.Cells.Any();
             var hasFooter = FooterTable.Cells.Any();
             
-            ConfigureTable(HeaderTable, isHeader: true);
-            ConfigureTable(ContentTable);
-            ConfigureTable(FooterTable);
+            ConfigureTable(HeaderTable, Table.TablePartType.Header);
+            ConfigureTable(ContentTable, Table.TablePartType.Body);
+            ConfigureTable(FooterTable, Table.TablePartType.Footer);
+            
+            ContentTable.HeaderCells = HeaderTable.Cells;
             
             container
                 .Element(x => EnableAutomatedSemanticTagging ? x.SemanticTable() : x)
@@ -191,7 +193,7 @@ namespace QuestPDF.Fluent
 
             return container;
             
-            void ConfigureTable(Table table, bool isHeader = false)
+            void ConfigureTable(Table table, Table.TablePartType tablePartType)
             {
                 if (!table.Columns.Any())
                     throw new DocumentComposeException($"Table should have at least one column. Please call the '{nameof(ColumnsDefinition)}' method to define columns.");
@@ -200,7 +202,7 @@ namespace QuestPDF.Fluent
                 table.ValidateCellPositions();
                 
                 table.EnableAutomatedSemanticTagging = EnableAutomatedSemanticTagging;
-                table.IsTableHeader = isHeader;
+                table.PartType = tablePartType;
             }
         }
     }