-
Notifications
You must be signed in to change notification settings - Fork 17
Example custom report
This example puts it all together and shows how a whole custom report might be created. The example code is interspersed with comments which aim to describe what is going on. The source code for this report - without the detailed comments - can be found here: source code
This example illustrates:
- Reading an instance document
- Filtering the contexts to form the columns
- Getting unique elements from the instance document
- Getting the elements for a context
- Access the taxonomy and from it get the presentation roles
- Assign the instance document value to presentation role nodes
- Prune the presentation roles hierarchy to remove unused nodes
- Generate an HTML view of the report
The report requires an instance document so the first task is to use the static 'XBRL_Instance::FromInstanceDocument' function. The function will return 'true' if the load is successful. In this case the third argument, which is reference argument, will contain a XBRL_Instance instance representing the instance document content.
The first argument to this function is the name of the instance document containing the value to report. The second argument is the name of the file containing the taxonomy. This argument can be used to provide a specific taxonomy file. A null value can be passed in which case the taxonomy will be selected based on the schemaRef attribute of the instance document file. A compiled variant will be used if one is available.
/**
* Create an instance instance from a document
* @var XBRL_Instance $instance
*/
$instance = null;
$result = \XBRL_Instance::FromInstanceDocument(
"...instance.xml", 'taxonomy.zip', $instance
);
The columns of the report will be based on the years represented by the contexts. In this example, those contexts containing segment attributes are not needed to the first thing to do is remove them.
This is done by calling $instance->getContexts(). This returns a ContextsFilter instance. The ContextsFilter class implements function to select contexts in different ways. In this case, the 'NoSegmentContexts' function will be used to restrict the set of contexts to Get a list of the non-segment elements. This way there are no segments to consider in the report layout.
From this contexts selection the list of valid years will retrieved.
$contextsFilter = $instance->getContexts()->NoSegmentContexts();
// Get a list of the years covered by the contexts
$years = $contextsFilter->AllYears();
It is normal for an element to be associated with multiple values - for example one for each unique context. Each associated real value is referred to as an 'entry'. When the source of an instance document is an iXBRL submission an element/context value or entry might appear several times because it appeared several times in the original document.
For example, a company name or retained earnings value might appear in the document multiple times and so be attached to the element multiple times even though the resulting entries are no unique. The 'UniqueElementEntries' function allow you to return just unique entries.
$instance_elements = $instance->getElements()->UniqueElementEntries();
Create a set of columns: one for each year. This OK for an example but in the real world @author Administrator fiscal year could span parts of two calendar years.
$report_columns = array();
foreach ( $years as $year )
{
In XBRL contexts define the temporal characteristics of elements $contextsFilter is already filtered to exclude segments.
$contexts = $contextsFilter->ContextsForYear( $year );
Get the elements for the year contexts
$report_columns[ $year ] = $instance_elements
->ElementByContexts( $contexts )
->getElements();
}
Remove columns for which there are no elements. Presumably this is not a likely occurance.
foreach ( $report_columns as $year => &$column )
{
if ( count( $column ) == 0 ) unset( $report_columns[ $year ] );
}
Grab a reference to the taxonomy. Not strictly necessary but it helps to show some options.
$instance_taxonomy =& $instance->getInstanceTaxonomy();
Now get the presentation hierarchy. The hierarchy is defined by the taxonomy which is associated with the instance document. But a taxonomy set may contain multiple taxonomies each with their own presentation hierarchies. The taxonomies may be different because, for example, the entry point for the instance document may not be the same as the entry point for the whole taxonomy.
Each taxonomy has a unique namespace defined by the author. Using this code, you can get any one taxonomy from any other taxonomy by namespace is you know the namespace:
$tax = $instance_taxonomy->getTaxonomyForNamespace( $namespace );
In this case what's needed is the taxonomy associated with the instance document's schema file. Note that a reference to the taxonomy is being accessed. Taxonomies can be big and there is no point having multiple copies in memory or taking the time to copy the array in memory.
$instance_taxonomy =& $instance_taxonomy
->getTaxonomyForXSD( $instance->getSchemaFilename()
);
The presentation can use 'roles' to represent different aspects of the financial report such as 'income statement' and 'balance sheet'. Roles and their identifiers are unique to taxonomies. Just by way of example, the report will be restricted to three sections.
$roles =& $instance_taxonomy->getPresentationRoleRefs( array(
'http://www.xbrl-uk.org/ExtendedLinkRoles/AE-Balance-Sheet',
'http://www.xbrl-uk.org/ExtendedLinkRoles/AE-Directors-Report',
'http://www.xbrl-uk.org/ExtendedLinkRoles/AE-Income-Statement',
'http://www.xbrl-uk.org/ExtendedLinkRoles/AE-Notes-to-Accounts',
'http://www.xbrl-uk.org/ExtendedLinkRoles/AE-Standard-Information',
) );
Next its time to assign the values from the instance document elements to the appropriate presentation hierarchy nodes. In this way its possible to output a structured report. Things this block of code illustrates:
- Navigating the hierarchy
- Using paths to locate nodes in the hierarchy
- Getting the correct text for node based on the preferred label defined in the taxonomy and the language of the instance document
- That there can be multiple instance document values per element
Process each year in turn
foreach ( $report_columns as $year => $column )
{
Each year has a list of elements to which values exist
foreach ( $column as $elementKey => $element )
{
There could be multiple entries associated with each element
foreach ( $element as $entryKey => $entry )
{
Element values could belong to more than one of the roles. For example, the retained earning value might belong to the income statement and the balance sheet sections.
foreach ( $roles as $uri => &$role )
{
If the element id does not exist in the 'paths' array then the element is not used in the section
if ( ! isset( $role['paths'][ $entry['taxonomy_element']['id'] ] ) )
continue;
Paths describe how to find the node for an element. There may be more than one path in each section. In UK GAAP taxonomy shareholder funds appears under two headings: format 1 and format 2.
$paths = $role['paths'][ $entry['taxonomy_element']['id'] ];
Apply the entry to each path
foreach ( $paths as $path )
{
Get a reference to the hierarchy
$currentNodes =& $role['hierarchy'];
// This is working variable to hold the 'current node'
if ( isset( $pathNode ) ) unset( $pathNode );
$pathsParts = explode( '/', $path );
for ( $i = 0; $i < count( $pathsParts ); $i++ )
{
Work down the hierarchy using the path components to assign values from the $entry variable to nodes in the presentation hierarchy.
// Should do error checking here. There's lots to go wrong.
if ( isset( $pathNode ) )
$currentNodes[ $pathsParts[ $i ] ]['parentNode'] =& $pathNode;
$pathNode =& $currentNodes[ $pathsParts[ $i ] ];
// Get the text for the node if not already retrieved
if ( ! isset( $pathNode['text'] ) )
{
$element_xsd = parse_url( $pathNode['label'], PHP_URL_PATH );
$key = "$element_xsd#{$pathNode['taxonomy_element']['id']}";
$pathNode['text'] = $instance_taxonomy
->getTaxonomyDescriptionForId(
$key,
isset( $pathNode['preferredLabel'] )
? $pathNode['preferredLabel']
: null,
$instance_taxonomy->getDefaultLanguage()
);
if ( ! $pathNode['text'] )
{
$pathNode['text'] = $pathNode['label'];
}
}
$currentNodes =& $pathNode['children'];
}
The variable $pathnode is now the node to which to add the values. If it is valid, mark nodes on the path to the element node as being used. This will be used later to 'prune' unused nodes.
if ( ! $pathNode ) continue; // Something went wrong
$usedNode =& $pathNode;
$usedNode['used'] = 'value';
while ( isset( $usedNode['parentNode'] ) )
{
// get the parent node
$usedNode =& $usedNode['parentNode'];
// If the node has already been used, we're done
if ( isset( $usedNode['used'] ) ) continue;
// Flag the path as used
$usedNode['used'] = 'path';
}
The next step is to assign the instance value (in the $entry variable) to the current node ($pathNode). Tuple elements are recorded differently in the $entry array so need to be handled differently.
if ( \XBRL::isTuple( $pathNode ) )
{
// OK, got a tuple node so record the entry value in the node
if ( ! isset( $pathNode['tuples'] ) )
$pathNode['tuples'] = array();
$pathNode['tuples'] += $entry['tuple_elements'];
}
else
{
// OK, got a node so record the entry value in the node
if ( ! isset( $pathNode['years'] ) )
$pathNode['years'] = array();
In a more complete example, it may be good to allow for multiple entries for the element/year with different contexts. Open/Close balances might be an example.
$pathNode['years'][ $year ] = $entry;
}
}
}
}
}
}
Prune nodes to which no entries have been assigned (probably most of them)
foreach ( $roles as $roleKey => &$role )
{
if ( isset( $role['locators'] ) ) unset( $role['locators'] );
if ( isset( $role['paths'] ) ) unset( $role['paths'] );
foreach ( $role['hierarchy'] as $headingKey => &$heading )
{
// If $heading does not contain an element 'used'
// then its not used and can be pruned
if ( ! isset( $heading['used'] ) )
{
unset( $role['hierarchy'][ $headingKey ] );
continue;
}
This function navigates nodes and prunes and that do not have a 'used' element. It calls itself recursively to process the tree.
$pruneNodes = function( &$nodes ) use( &$pruneNodes )
{
foreach ( $nodes as $nodeKey => &$node )
{
// If not used, prune
if ( ! isset( $node['used'] ) )
{
unset( $nodes[ $nodeKey ] );
}
else if ( isset( $node['children'] ) )
{
$pruneNodes( $node['children'] );
}
}
};
$pruneNodes( $heading['children'] );
}
if ( count( $role['hierarchy'] ) === 0 )
{
unset( $roles[ $roleKey ] );
}
}
Because each presentation role hosts a hierarchy of nodes, the depth of the node equates to the column in the report. The root node of each role hierarchy is in column 1. The childen (in successive report rows) are in column 2. The children's children in column 3 and so on.
The maximum depth across all roles equals the number of description columns needed. In addition there will be the number of value columns (year in this example).
The final report will have this kind of layout.
Heading | Year 1 | Year 2 | |||
---|---|---|---|---|---|
Root | 123.11 | 234.56 | |||
Child 1 | |||||
Child 1-1 | 345.67 | 456.78 | |||
Child 1-2 | 345.67 | 456.78 | |||
Child 2 | |||||
Child 2-1 | 345.67 | 456.78 | |||
Child 2-2 | 345.67 | 456.78 |
/**
* The number of columns will be this number plus the number of years.
* @var int $maxDepth
*/
$maxDepth = 0;
$setDepth = function( &$nodes, $depth = 0 ) use( &$setDepth, &$maxDepth )
{
if ( $depth > $maxDepth ) $maxDepth = $depth;
foreach ( $nodes as $nodeKey => &$node )
{
$node['depth'] = $depth;
if ( ! isset( $node['children'] ) || count( $node['children'] ) === 0 ) continue;
$setDepth( $node['children'], $depth + 1 );
}
};
foreach ( $roles as $uri => $role )
{
$setDepth( $role['hierarchy'] );
}
$maxDepth++;
Finally its time to render the report. There are many ways to do this. In this case its some simple minded HTML. Things to look out for:
- Using the formatting functions. XBRL descendents can implement formatting rules to format specific element types
- Sanitizing text to make sure the correct characters are used
Start by outputting the basic HTML structure including space for styles that will be generated as the report rows and cells are generated. The table columns are predefined because the number is already known.
Note that at each step the code will try to generate HTML that when viewed in a text editor will be correctly indented.
ob_start();
echo "<html>\n" .
" <head>\n" .
" <style>\n" .
" table { width: 100%; }\n" .
" td { border: 1px dotted grey; vertical-align: top; }\n" .
" td.value-column { text-align: center; }\n" .
" tr.heading td { font-weight: bold; padding-top: 10px; }" .
" %styles\n" .
" </style>\n" .
" </head>\n" .
" <body>\n" .
" <table>\n" .
" <colgroup>\n" .
" " . str_repeat( " <col style=\"min-width: 30px;\" />\n", $maxDepth - 1 ) .
" <col width=\"100%\" />\n" .
" " . str_repeat( " <col style=\"min-width: 100px; max-width: 100px; text-align: right; \" />\n", count( $report_columns ) ) .
" </colgroup>\n" .
" <tbody>\n";
$indent = 4; // The number of tabs to prepend to make the HTML easier to read.
$totalColumns = $maxDepth + count( $report_columns );
$classes = array(); // After the report is generated it will contain a list of the classes used to format values
$emptyRow = str_repeat( "\t", $indent ) . "<td colspan=\"" . count( $report_columns ) . "\"></td>\n";
$contexts = $contextsFilter->getContexts();
Each presentation role represents a different section of the report so output a header row for the role and display the 'text' value of the role. Although there are $maxDepth columns, extensive use of the
colspan attribute is made. In this case the text will always be shown in column 1 and all the description columns will fall into one colspan.foreach ( $roles as $uri => &$role )
{
// Output a header for each section
echo str_repeat( "\t", $indent ) . "<tr class=\"heading\">\n";
$indent++;
echo str_repeat( "\t", $indent ) . "<td colspan=\"$maxDepth\">{$role['text']}</td>\n";
// echo str_repeat( "\t", $indent ) . "<td></td>\n";
foreach ( $report_columns as $year => $column )
{
echo str_repeat( "\t", $indent ) . "<td class=\"value-column\">$year</td>\n";
}
$indent--;
echo str_repeat( "\t", $indent ) . "</tr>\n";
Now output the hierarchy. The pattern is to follow that two <td> tags will be output for each node followed by one each for the years. The two node <td> tags will be before and the node. The 'before' <td> will only be output when the node depth is greater than one.
If the 'before' <td> is output then it will be colspan $node['depth'] The 'node' <td> will contain the text and will always be colspan $maxDepth - $node['depth']
The inline function $outputNode contains the code to output rows. It is called once for each role and then recursively calls itself if a node has children. On each invocation it renders the node.
$outputNode = function( &$nodes, $offset )
use( &$outputNode, $indent, $maxDepth,
&$instance, &$instance_taxonomy,
&$report_columns, &$classes, $emptyRow, $contexts
)
{
The node order is defined by the taxonomy. Regardless of their natural order in the 'children' array they should appear in the recommended order. This function does that. The order defaults to zero if not defined.
uasort( $nodes, function( $a, $b ) {
$ordera = isset( $a['order'] ) ? $a['order'] : 0;
$orderb = isset( $b['order'] ) ? $b['order'] : 0;
return $ordera > $orderb ? 1 : ( $ordera == $orderb ? 0 : -1 );
} );
Now it's time to render each node
foreach ( $nodes as $key => &$node )
{
if ( ! $instance_taxonomy->displayNode( $node ) )
{
// Some nodes may not need to be output
$offset += 1;
}
else
{
echo str_repeat( "\t", $indent ) . "<tr>\n";
$indent++;
// Output the description.
// This will be one or two <td> tags depending upon
// the node is at depth zero or not.
$text = $node['text'] .
( isset( $node['taxonomy_element']['balance'] )
? " ({$node['taxonomy_element']['balance']})"
: ""
);
if ( $node['depth'] === 0 )
{
echo str_repeat( "\t", $indent ) .
"<td colspan=\"" . ( $maxDepth ) . "\">" .
$instance_taxonomy->sanitizeText( $text ) .
"</td>\n";
}
else
{
echo str_repeat( "\t", $indent ) .
"<td colspan=\"" .
( $node['depth'] - $offset ) .
"\"></td>\n";
echo str_repeat( "\t", $indent ) .
"<td colspan=\"" .
( $maxDepth - $node['depth'] + $offset ) .
"\" >" .
$instance_taxonomy->sanitizeText( $text ) .
"</td>\n";
}
Why oh why oh why do tuples have to be so awkward? Tuples are a legacy of an earlier iteration of XBRL, a time before the dimensions specification existed. They were an early way to allow taxonomy designers to shoe-horn multiple values into an element. Although not really used any more, they must be accommodated because they could be use.
if ( \XBRL::isTuple( $node ) )
{
if ( ! isset( $node['tuples'] ) )
{
// Invalid: should log
// For now just output an empty row
echo $emptyRow;
}
else
{
$values = array();
$description = null;
// Loop over the tuples valid for the element of this node
foreach ( $node['taxonomy_element']['tuple_elements']
as $taxonomyTupleKey => $taxonomyTuple )
{
// The tuple may not exist in the instance document
if ( ! isset( $node['tuples'][ $taxonomyTuple['name'] ] ) )
continue;
OK a tuple does exist so find each $taxonomyTuple['name'] in $node['tuples']. This is more complicated than it should be. Sometimes each element of a tuple is self-contained: the description is the text label and the value is the value. It might look like this:
Name | Value |
---|---|
Director | Mr Johnson |
However, sometimes the tuples come as a pair of elements, the value of one element being the description while the value of the other is the value of the pair. It might look like this:
Name | Value |
---|---|
Role | Director |
Name | Mr Johnson |
Both alternatives must be accommodated.
foreach ( $node['tuples'][ $taxonomyTuple['name'] ]
as $index => $tuple_element )
{
/**
* @var XBRL $tuple_taxonomy
*/
$tuple_taxonomy = $instance_taxonomy
->getTaxonomyForNamespace(
$taxonomyTuple['namespace']
);
if ( ! $tuple_taxonomy )
{
Cannot find taxonomy instance for namespace $taxonomyTuple['namespace'] so continue
continue;
}
if ( ! isset( $contexts[ $tuple_element['contextRef'] ] ) )
continue;
$treatAsLabel = $tuple_taxonomy->treatAsLabel(
$tuple_element['taxonomy_element']['id']
);
$treatAsText = $tuple_taxonomy->treatAsText(
$tuple_element['taxonomy_element']['id'],
\XBRL_Instance::getElementType( $tuple_element )
);
$type = \XBRL_Instance::getElementType( $tuple_element );
$class = str_replace( ":", "-", $type );
if ( ! isset( $classes[ $class ] ) )
$classes[ $class ] = $instance_taxonomy
->valueAlignment( $type, $instance );
if ( $treatAsLabel )
{
$description = $instance_taxonomy
->sanitizeText( trim(
$tuple_element['value']
), $type );
continue;
}
else if ( $description === null )
{
$description = $tuple_taxonomy
->getTaxonomyDescriptionForId(
$tuple_element['taxonomy_element']['id']
);
}
$values[ $tuple_element['contextRef'] ] = array(
'value' => $instance_taxonomy
->sanitizeText(
( $treatAsText
? trim(
$tuple_element['value']
)
: $instance_taxonomy
->formattedValue(
$tuple_element,$instance
)
), $type
),
'element' => $tuple_element,
);
}
}
Output all the tuple values computed
echo str_repeat( "\t", $indent ) .
"<td class=\"tuple\" colspan=\"" .
count( $report_columns ) .
"\" >" .
"$description<br/>".
implode(
"<br/>",
array_reduce(
$values,
function( $carry, $value ) {
$carry[] = $value['value'] ;
return $carry;
}, array()
)
) .
"</td>\n";
}
}
else
In this section the element is not a tuple. Make sure there is an node element called 'years'. This was set much earlier when the instance document values were being assigned to node.
if ( isset( $node['years'] ) )
{
$type = \XBRL_Instance::getElementType( $node );
$class = str_replace( ":", "-", $type );
if ( ! isset( $classes[ $class ] ) )
$classes[ $class ] = $instance_taxonomy
->valueAlignment( $type, $instance );
$treatAsText = $instance_taxonomy
->treatAsText(
$node['taxonomy_element']['id'],
\XBRL_Instance::getElementType( $node )
);
if ( $treatAsText )
{
$entry = reset( $node['years'] );
echo str_repeat( "\t", $indent ) .
"<td class=\"$class\" colspan=\"" .
count( $report_columns ) .
"\" >" .
$instance_taxonomy
->sanitizeText(
$instance_taxonomy
->formattedValue(
$entry, $instance
), $type
) .
"</td>\n";
}
else
{
foreach ( $report_columns as $year => $column )
{
$entry = $node['years'][ $year ];
echo str_repeat( "\t", $indent ) .
"<td class=\"$class\" >" .
(
isset( $node['years'][ $year ] )
? $instance_taxonomy
->sanitizeText(
$instance_taxonomy
->formattedValue(
$entry, $instance
), $type
)
: ""
) . "</td>\n";
}
}
}
else
{
echo $emptyRow;
}
$indent--;
echo str_repeat( "\t", $indent ) . "</tr>\n";
}
if ( ! isset( $node['children'] ) || count( $node['children'] ) == 0 )
continue;
$outputNode( $node['children'], $offset );
}
};
This is how the $outputNode function defined above is called. The presentation node hierarchy is passed.
$outputNode( $role['hierarchy'], 0 );
}
Finally, the foot of the table is rendered to close our the report
echo " <tbody>\n" .
" </table>\n" .
" <body>\n" .
"<html>\n";
As the rows were generated style classes were created to allow control over the different value types using CSS. Now they can be added to the HTML
.$styles = array();
foreach ( $classes as $class => $alignment )
{
$styles[] = "tr td.$class { text-align: $alignment; }";
}
Now the report is rendered as HTML, the only thing remaining is to save it to file.
$html = ob_get_clean();
file_put_contents(
"example-report.html",
str_replace(
"%styles",
implode( "\n" .str_repeat( "\t", 3 ), $styles ),
$html
)
);
Copyright © 2021 and later years Lyquidity Solutions Limited
- About us
- Purpose
- XBRL support
- Road Map
- Why PHP?
- Contributing
- License
- Reference links
- Case Study
- Digital Financial Reporting
- Digital Financial Reporting examples
Overview
Class and function reference
Compiled taxonomy structure
Common arrays
Compiling
Compiling
Processing linkbases
Additional taxonomy processing
Extension taxonomies
Compiled taxonomy folder
How do I...?
Navigate a node tree
Find a node in a tree
Find elements in a taxonomy
Load an instance document
Find elements in an instance
Create a simple report
Create a comparison report
Example custom report
Work with dimensions
Sign and Verify
Validate
Change the logging
Capture validation information