Skip to content

Example custom report

bseddon edited this page Nov 11, 2016 · 17 revisions

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

Initializing 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 
);

Defining columns

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();

Selecting elements

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 report columns

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 ] );
}

Access the taxonomy

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()
);

Work with presentation roles

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',
) );

Assign instance document values to presentation nodes

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

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 ] );
	}
}

How many columns does the table need to have?

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++;

Rendering the report

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
	)
);
Clone this wiki locally