7

Let's say I have a map with two different layers in QGIS:

  • 'LayerA'. It has three attributes (fid, name, quantity) and its geometry is a MultiLine
  • 'LayerB'. It has four attributes (fid, type, location,date) and its geometry is a MultiPolygon

For each layers, I want something like that:

enter image description here

I'm trying to make an atlas/report, with the name of each layers, so that it will generate a page for each layer with these informations, but I'm not able to list the attributes and there type in a table.

Any idea?

In Switzerland, when we transfer DATA to the authority, we should also give a catalogue of the different objects, that explains the content. Here is an example (in French). As you can see, the object 'TLM_Strasse' is a PolyLineZ and have one attribute called "OBJEKTART" as long int. the value of "OBJEKTART" can be 0, 1, 2, 3, 4, 5, 6 or 8.

enter image description here

So I'm trying to automate a bite the process. I need at least the grey part of the table. The list of the possible value for each attribute is not compulsory.

9
  • 1
    So, you want to add information on the layernames, geometry type and a table with field names and types to your layout? I'm a bit slow this morning, hence asking for confirmation I got this right.
    – Erik
    Dec 2, 2022 at 8:51
  • Yes, exactly… you are correct!
    – katagena
    Dec 2, 2022 at 10:31
  • layer_property() gives you access to layername and geometry type... but I can't find a way to add the second part.
    – Erik
    Dec 2, 2022 at 12:16
  • Me too ;-)… as I mentioned, it is the second part that is problematic!
    – katagena
    Dec 2, 2022 at 14:03
  • 1
    What is your datasource? A database? If yes which? An ESRI Shapefile? Geopackage? Other? Dec 4, 2022 at 10:38

4 Answers 4

7

Here is an automated solution that requires no expression and no manual intervention. It makes pages and adds necessary text as HTML table to the related pages automatically. Just change LAYOUT_NAME. You can run it in QGIS Python Editor.

new_layout_name = "LAYOUT_NAME"
project = QgsProject.instance()

# layout settings
layout_manager = project.layoutManager()
layouts = layout_manager.printLayouts()

# remove the same named layout 
for layout in layouts:
    if layout.name() == new_layout_name:
        layout_manager.removeLayout(layout)

# create a newlayout
layout = QgsPrintLayout(project)
layout.setName(new_layout_name)
layout.initializeDefaults()
layout_manager.addLayout(layout)

layers = QgsProject.instance().mapLayers().values()

# add pages with fields and their types
for i, layer in enumerate(layers):
    
    geomtype =  QgsWkbTypes.displayString(layer.wkbType())
    text = f"""<div style='font-size:25'>{layer.name()}</div>
    <div style='font-size:12'>{geomtype}</div>
    <table>
      <tr style='color:white;background-color:black'>
        <th>Attribute</th>
        <th>Type</th>
      </tr>"""
    
    for f in layer.fields():
        text += f"<tr><td>{f.name()}</td><td>{f.typeName()}</td></tr>" 
    
    # label item and settings
    label = QgsLayoutItemLabel(layout)
    label.setText(text)
    label.setMode(QgsLayoutItemLabel.ModeHtml)
    label.attemptMove(QgsLayoutPoint(10, 10, 0), page=i)
    label.attemptResize(QgsLayoutSize(100, 100, 0))
    # add label
    layout.addLayoutItem(label)
    
    text += "</table>"
    
    if not i < len(layers)-1: break
    
    # add new page
    page = QgsLayoutItemPage(layout)
    page.setPageSize('A4', QgsLayoutItemPage.Landscape)
    layout.pageCollection().addPage(page)
    
iface.openLayoutDesigner(layout)

Result:

enter image description here

Demo:

enter image description here

5
+100

A Python solution

You will probably be more flexible by using Python. I personally prefer that.

Here is a code snippet to use as an expression, explanations follow:

from qgis.core import *
from qgis.gui import *

def get_fields_for_layer(layer):
    fields = None
    if isinstance(layer, QgsVectorLayer):
        fields = layer.fields()

    return fields

@qgsfunction(args='auto', group='mygroup', referenced_columns=[])
def generate_fields_repport(layer_name, feature, parent):
    """
    Function to get an HTML table output with field names and types
    on a given layer 'layer_name'.

    Parameters
    ----------
    None
    Returns
    -------
    retval : str
        The HTML string to be displayed in the QGIS print layout.
    """
    retval = ""
    # Warning: make sure you don't have two layers with the same name:
    layer = QgsProject.instance().mapLayersByName(layer_name)[0] # pick first layer in list
    layer_name = layer.name()
    layer_wkb_type = QgsWkbTypes.displayString(layer.wkbType())
    fields = get_fields_for_layer(layer)
    css_snippet = "".join("""
        <style>
          body{font-family: arial; font-size: 12px;}
          h2{font-size: 16pt}
          table{width: 100%; border-collapse: collapse;}
          th,td{
            border: 1px solid black;
            padding: 0px;
            text-align: left;
            font-size: 12px;
          }
          #table0 tr:first-child{
            background:#222222;
            color:#ffffff;
            font-weight: bold;
          }
        </style>
    """).splitlines()
    css_snippet = "".join([line.strip() for line in css_snippet])
    retval += css_snippet
    retval += f"<h2>{layer_name}</h2>"
    retval += f"<h3>{layer_wkb_type}</h3>"
    retval += '<table id="table0">'
    retval += f"<tr><th>Attribute name</th> <td>Attribue type</td></th>"
    for i, field in enumerate(fields):
        strline = f"<tr><th>{field.name()}</th> <td>{field.typeName()}</td></th>"
        retval += strline
    retval += '</table>'

    return retval

In QGIS main menu, go to "Projet > Layout Manager..." and create a new layout, e.g. "printlayout".

Next, go to "Project > layouts > printlayout" to open the layout editor.

Add your map if needed.

Add either an HTML element or a Label element. Here's the HTML element button on the left:

enter image description here

Click the zone where you want to add your element in the layout.

In the right panel, click the radio button for the HTML element to "Source" and then click "Insert or Edit an expression...":

enter image description here

This will open the Expression builder window (screenshot here under). You now have to go the second tab called "Function Editor".

In the "Function Editor" tab, click the little green "+" in the bottom left corner to add a new file. This will be stored in your local QGIS profile, e.g. on Ubuntu ${HOME}/.local/share/QGIS/QGIS3/profiles/default/python/expressions/<your_file_name.py>

Copy/Paste the above Python snippet in the main text area. Then, click "Save and Load Functions".

Back on the first tab ("Expression") in the Expression builder: enter image description here

add this expression: generate_fields_repport('layerA') for your desired layer. Click "OK".

Back in the layout editor, if need be or upon changes, click "Refresh HTML" in the "Item properties" panel (see screenshot above). Also, make sure you ticked "Evaluate QGIS expressions in HTML source" otherwise the code won't be interpreted.

Et voilà, you have your first HTML table in your print layout having the desired output for your first layer: enter image description here

Of course, this is a quite simple building block. Feel free to adapt it to your needs!

Here are some useful resources:
QGIS Python API (aka PyQGIS):

I also highly recommend the PyQGIS cookbook (here in french)

5
  • Thank you both of you (@swiss_knight and @J. Monticolo)... I don't know how to give my bounty now ;-)
    – katagena
    Dec 4, 2022 at 14:34
  • 2
    Give me the bounty I will set a new 50 points bounty on this question for @swiss_knight. Dec 4, 2022 at 14:36
  • I'll do that... I need to wait 18hours left, before giving the bounty! Thank you so much!
    – katagena
    Dec 4, 2022 at 14:41
  • It is done... I let you transfert 50 to swiss_knight
    – katagena
    Dec 5, 2022 at 9:17
  • 1
    @J.Monticolo Thank you, that's very nice! Dec 6, 2022 at 22:48
4
+100

Using PyQGIS to build a QGIS expression function, you can obtain the following result :

function results

To obtain this result in a layout, add a label layout label and check ▢ Render as HTML (or HTML frame layout HTML frame). As label content, use the following expression in the QGIS Expression editor :

attribute_tables(
  'the_layer_id',
  'simple',  -- or 'detailled'
  'the, headers, text',  -- comma separated headers
  'excluded field terms for details'  -- comma separated
)

But before this, you must add the following code as a new custom expression function in the Function Editor (here is the documentation process). You can edit colors COLORS to customize your table :

Function editor

from qgis.core import *
from qgis.gui import *
from qgis import QtCore

COLORS = {
    "head_back": "black",
    "head_text": "white",
    "rows_back": "lightgray",
}

def to_str(value) -> str:
    """
    Returns the string version of a value
    :param value: the value to convert into str
    """
    if value is None:
        return ""

    if isinstance(value, QtCore.QDate):
        return value.toString(1)  # Qt.ISODate

    return str(value)


def get_table_info(csh: str, format: str = "simple") -> str:
    """
    Returns HTML headers from text
    :param csh: Comma Separated Headers
    :param format: simple or detailled
    """
    headers = [h.strip() for h in csh.split(",")]
    row = "<td>{0}</td><td>{1}</td>"
    row += "<td>{2}</td>" if format == "detailled" else ""

    if format == "simple" and len(headers) != 2:
        headers = ["Attribute", "Type"]
    if format == "detailled" and len(headers) != 3:
        headers = ["Attribute", "Values", "Type"]

    return {
        "header": f"<th>{'</th><th>'.join(headers)}</th>",
        "main_row": f'<tr id="main_row">{row}</tr>',
        "value_row": f"<tr>{row}</tr>",
    }


def get_fields(lyr: QgsVectorLayer, exclude_field_details: str) -> list:
    """
    Returns layer fields info
    :param lyr: the layer
    """
    excl = [e.strip() for e in exclude_field_details.split(",")]
    return {
        i: (f.name(), f.typeName(), any([e in f.name() for e in excl]))
        for i, f in enumerate(lyr.fields())
    }


def get_geom_type(lyr: QgsVectorLayer) -> str:
    """
    Returns layer geometry type
    :param lyr: the layer
    """
    wkb_types = {
        getattr(QgsWkbTypes, t): t
        for t in dir(QgsWkbTypes)
        if isinstance(getattr(QgsWkbTypes, t), QgsWkbTypes.Type)
    }
    # or QgsWkbTypes.displayString(lyr.wkbType())
    # but with the code above, you can get types of everything in Python
    return wkb_types[lyr.wkbType()]


@qgsfunction(args='auto', group='GIS SE', handlesnull=True)
def attribute_tables(layer_id, format, headers, exclude_field_details , feature, parent):
    """
    Returns a HTML table that describe the layer
    <h4>Syntax</h4>
    <div class="syntax">
    <code><span class="functionname">attribute_tables</span>
    (<span class="argument">@layer_id</span>,
    <span class="argument">format</span>,
    <span class="argument">headers</span>,
    <span class="argument">exclude_field_details</span>)</code>
    <h4>Arguments</h4>
    <div class="arguments">
    <table><tr>
    <td class="argument">@layer_id</td><td>ID of current layer</td></tr><tr>
    <td class="argument">format</td><td>table's format, can be simple or detailled</td></tr><tr>
    <td class="argument">headers</td><td>Comma Separated Headers names</td></tr><tr>
    <td class="argument">exclude_field_details</td><td>Comma Separated part of field names to exclude for details</td></tr>
    </table>
    </div>
    """
    lyr = QgsProject.instance().mapLayer(layer_id)
    if lyr is None:
        return

    if format is None or format not in ["simple", "detailled"]:
        format = "simple"

    if exclude_field_details is None or exclude_field_details == "":
        exclude_field_details = "[!#@]"

    fields = get_fields(lyr, exclude_field_details)
    table_info = get_table_info(headers, format)

    if format == "simple":
        geometry_outside = f"<br><span>{get_geom_type(lyr)}</span>"
        attrs = [
            table_info["main_row"].format(name, type)
            for name, type, _ in fields.values()
        ]

    if format == "detailled":
        geometry_outside = ""
        attrs = [
            table_info["main_row"].format("Shape", "", "Geometry"),
            table_info["value_row"].format("", get_geom_type(lyr), ""),
        ]
        feats = list(lyr.getFeatures())
        for i, (name, type, excluded) in fields.items():
            rows = table_info["main_row"].format(name, "", type)
            if not excluded:
                values = sorted({f[i] for f in feats})
                for v in values:
                    rows += table_info["value_row"].format("", to_str(v), "")

            attrs.append(rows)

    return TEMPLATE.format(**{
        "name": lyr.name(),
        "geometry": geometry_outside,
        "header": table_info["header"],
        "header_back_color": COLORS["head_back"],
        "header_text_color": COLORS["head_text"],
        "main_rows_back_color": COLORS["rows_back"],
        "rows": "".join(attrs),
    })


TEMPLATE = """
<!DOCTYPE html>
<html>
<head>
<style>
#layer {{
  font-family: Arial, Helvetica, sans-serif;
  color: black;
  font-weight: bold;
  font-size: 24px;
}}
#main_row {{
  background-color: {main_rows_back_color};
}}
#attributes {{
  font-family: Arial, Helvetica, sans-serif;
  border-collapse: collapse;
  width: 100%;
  margin-top: 5px;
}}
#attributes td, #attributes th {{
  padding: 5px;
}}
#attributes th {{
  font-size: 18px;
  padding-top: 5px;
  padding-bottom: 5px;
  text-align: left;
  background-color: {header_back_color};
  color: {header_text_color};
}}
</style>
</head>
<body>
<span id="layer">{name}</span>
{geometry}
<table id="attributes">
  <tr>{header}</tr>
  {rows}
</table>
</body>
</html>
"""
4
  • Where can I add a condition for listing the list of unique value? For exemple, if the field contains the word 'surface', I don't want to generate the list! Or just the first one, as exemple.
    – katagena
    Dec 5, 2022 at 9:19
  • It's not yet possible. I design a new function with parameters with colors, fields with attributes lists, geometry above or inside the table. Dec 5, 2022 at 9:27
  • @katagena: done, see the new code, hope it's clear and useful. Dec 6, 2022 at 19:33
  • 1
    It's perfect, thank you so much!
    – katagena
    Dec 7, 2022 at 13:52
4

The attribute fieldtype seems not to be accessible using default QGIS expressions except for layers with Geometry type No Geometry (attributes only). The option is hidden in the source layer property. You can isolate this information using Regular Expressions and than format the resulting string. Use this expression to extract field names, followed by field type:

with_variable(
    'prop',
    layer_property('layerB','source'),  -- change layer name here
    regexp_replace( 
        substr(
            @prop,
            regexp_match( 
                @prop,
                '(&field=)'
            )
        ),
        '(&field=)','\n'
    )
)

Result looks like (here for your layerB):

fid:integer(10,0)
type:string(10,0)
location:string(10,0)
date:date(0,0)&uid={8fa7fa4f-0371-4d6b-8325-989fac7ffe35}

enter image description here

To keep only fieldname and fieldtype and introduce a tab between both, use further regular expressions. Thus the final expression looks like:

regexp_replace( 
    with_variable(
        'prop',
        layer_property('layerB','source'),  -- change layer name here
        regexp_replace( 
            substr(
                @prop,
                regexp_match( 
                    @prop,
                    '(&field=)'
                )
            ),
            '(&field=)','\n'
        )
    ),
    '\\(.*',
    ''
)

This results in:

fid:integer
type:string
location:string
date:date
2
  • Strange... with 'source', I only have the path to the layer: '/Users/ant/Desktop/test_stack.gpkg|layername=stack'
    – katagena
    Dec 4, 2022 at 14:38
  • Seems to work only with No geometry type layers. Chaged the answer accordingly.
    – Babel
    Dec 4, 2022 at 19:03

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Not the answer you're looking for? Browse other questions tagged or ask your own question.