Content Mappings#
DHParser also provides some functionality of standoff markup, like a
“markup anywhere” function (markup())
that allows to add markup anywhere, irrespective of the existing
markup and tree structure. (By appropriately deviding tags the algorithm
ensures that tha tags remain in the nested tree-structure.) This
functionality is concentrated in the module standoff.
Its basic component is the class ContentMapping
which relates the tree-structure to the tree’s concatenated string content.
Basics#
For finding a passage in the text or identifying certain textual features like, for example, matching brackets, traversing the document-tree is not really an option, if only, because a passage may extend over several nodes, possibly even on different levels of the tree hierarchy. For such cases it is possible to generate a content mapping that maps text positions within the pure string-content to the paths of the leaf-nodes to which they belong. This mapping can be thought of as a “string-view” on the tree:
>>> from DHParser.nodetree import *
>>> from DHParser.standoff import *
>>> sentence = parse_sxpr(
... '(sentence (word "This") (blank " ") (word "is") (blank " ")'
... ' (phrase (word "Buckingham") (blank " ") (word "Palace")))')
>>> ctx_mapping = ContentMapping(sentence)
>>> print(ctx_mapping.content)
This is Buckingham Palace
>>> print(ctx_mapping)
0 -> sentence, word "This"
4 -> sentence, blank " "
5 -> sentence, word "is"
7 -> sentence, blank " "
8 -> sentence, phrase, word "Buckingham"
18 -> sentence, phrase, blank " "
19 -> sentence, phrase, word "Palace"
Note that the path in the first line of the output is different from the path in the third line, although the sequence of node-names that appears in the pretty-printed version shown here is the same, i.e. “sentence, word”, because the paths really consist of different Nodes.
Now let’s find all letters that are followed by a whitespace character:
>>> import re
>>> locations = [m.start() for m in re.finditer(r'\w ', ctx_mapping.content)]
>>> targets = [ctx_mapping.get_path_and_offset(loc) for loc in locations]
Tip
Other than the node’s content property, the content mappings content field is not generated on the fly every time it is retrieved, but only when instantiating or rebuilding the mapping. Performance-wise it is advisable to always use the content mapping’s content field.
The target returned by get_path_and_offset()
is a tuple of the target path and the relative position of the location that
falls within this path:
>>> [(pp_path(path), relative_pos) for path, relative_pos in targets]
[('sentence <- word', 3), ('sentence <- word', 1), ('sentence <- phrase <- word', 9)]
Now, the structured text can be manipulated at the precise locations where string search yielded a match. Let’s turn our text into a little riddle by replacing the letters of the leaf-nodes before the match locations with three dots:
>>> for path, pos in targets:
... path[-1].result = '...' + path[-1].content[pos:]
>>> str(sentence)
'...s ...s ...m Palace'
The positions resemble the text positions of the text represented by the
tree at the very moment when the path mapping is generated, not the
source positions captured by the pos-property of the node-objects!
This also means that the mapping becomes outdated, when the tree is
being restructured. Unless you use the methods provided by
ContentMapping itself in order to make changes to
the tree, you need to either call
rebuild_mapping() to update the
content mapping at the affected places or instantiate an entirely new
content mapping.
Note
Content mappings are somwhat similar to and provide some of the
functionality of standoff markup-techniques. Unlike most
standoff-markup-tools, however, they remain connected to the
tree structure. Changes made with methods of
ContentMapping will directly be reflected
in the tree structure (unless the instance-variable auto_cleanup
is set to “False”). Changes made directly to the tree structure
will require a manual update of the content mapping with
rebuild_mapping().
Restricted Mappings#
A very powerful feature of context mappings is that they allow to restrict the string view onto the document tree to selected parts of the tree, which makes it possible to exclude these parts from the search, e.g.:
>>> xml = '''<doc><p>In München<footnote><em>München</em> is the German
... name of the city of Munich</footnote> is a Hofbräuhaus</p></doc>'''
>>> tree = parse_xml(xml)
Now, assume you would like to find all occurrences of “München” in the main text but not in the footnotes, then you can issue a context mapping that ignores all footnotes:
>>> cm = ContentMapping(tree, select=LEAF_PATH, ignore={'footnote'})
>>> list(re.finditer('München', cm.content))
[<re.Match object; span=(3, 10), match='München'>]
In order to restrict the content mapping to certain parts of the tree, the
ContentMapping-class takes a same pair of path selectors similar to the
“criteria” and “skip_subtree” parameters of Node.select_path()
and Node.pick(). However, there is a subtle but important difference:
If the select parameter matches paths that do not end in a leaf-node, all
path-extensions from this path that end in a leaf node will be selected.
(See also leaf_paths().)
In contrast to the restricted content mapping, the search in the string-content of the entire tree yields:
>>> from DHParser.toolkit import printw
>>> printw(list(re.finditer('München', tree.content)))
[<re.Match object; span=(3, 10), match='München'>, <re.Match object; span=(10,
17), match='München'>]
Although, the string locations in a context mappings that has been restricted to certain parts of the tree have shifted with respect to the string locations in the full document tree, there is no need to worry that the mapped locations within the tree have changed:
>>> tree_pos = tree.content.find('Hofbräuhaus')
>>> print(tree_pos)
64
>>> tm = ContentMapping(tree)
>>> tm.content.find('Hofbräuhaus') # should be the same as above
64
>>> cm_pos = cm.content.find('Hofbräuhaus')
>>> print(cm_pos)
16
The string-position is not the same, because the mapping cm omits the
footnote-text. Yet, the path and offset within the tree remain the same.
(Remember that the :Text-nodes are “anonymous” nodes that the XML-parser
inserts for the character data of XML-elements with mixed content.):
>>> cm_path, cm_offset = cm.get_path_and_offset(cm_pos)
>>> print(pp_path(cm_path, delimiter=', '), '->', cm_offset)
doc, p, :Text -> 6
>>> tm_path, tm_offset = tm.get_path_and_offset(tree_pos)
>>> print(pp_path(tm_path, delimiter=', '), '->', tm_offset)
doc, p, :Text -> 6
>>> assert tm_path == cm_path # paths are really the same sequence of nodes
This can easily be confirmed by looking at the complete mappings in direct comparison. First the unrestricted mapping:
>>> print(tm)
0 -> doc, p, :Text "In München"
10 -> doc, p, footnote, em "München"
17 -> doc, p, footnote, :Text " is the German" "name of the city of Munich"
58 -> doc, p, :Text " is a Hofbräuhaus"
Now, the mapping that omits the footnotes:
>>> print(cm)
0 -> doc, p, :Text "In München"
10 -> doc, p, :Text " is a Hofbräuhaus"
Note, that the numbers at the beginning of each line represent the string position which is different for the same path, but this has no bearing on the offsets which count from the content-mapping-specific position of each path in the content mapping.
Conversely, we could also have restricted the content mapping only to the footnote(s):
>>> fm = ContentMapping(tree, select='footnote', ignore=NO_PATH)
>>> print(fm)
0 -> doc, p, footnote, em "München"
7 -> doc, p, footnote, :Text " is the German" "name of the city of Munich"
Here, the parameter ignore=NO_PATH has to be understood as “from the
selected paths do not ignore any paths”. In case the select-parameter matches
for nodes that are not leave-nodes, ContentMapping will map all leave nodes
that are reachable from there. In this case these are all leaf-nodes “above”
a “footnote” node.
Now, let’s look for the string “München” in the footnotes only:
>>> i = fm.content.find('München')
>>> path, offset = fm.get_path_and_offset(i)
>>> pp_path(path, 1)
'doc <- p <- footnote <- em "München"'
>>> print(offset)
0
We can now manipulate the tree through the path and offset. Let’s insert the word “Stadt” in front of “München”. We do so by changing the result of the leaf node of the path to the term at the given offset:
>>> path[-1].result = path[-1].result[:offset] + "Stadt " + \
... path[-1].result[offset:]
In this particular case, because the offset is zero, we could also have
written "Stadt " + path[-1].result, but the formula above also
works for the general case where cannot be sure that the offset will
always be 0.
We expect that the change is reflected in the tree at the right position, i.e. inside the footnote:
>>> printw(tree.as_xml(inline_tags={'doc'}))
<doc><p>In München<footnote><em>Stadt München</em> is the German
name of the city of Munich</footnote> is a Hofbräuhaus</p></doc>
As mentioned earlier, the content mapping should be considered tainted if the
underlying tree has been changed by any other means than the methods of the
ContentMapping-object itself. In order to rebuild the affected path of the
content mapping ContentMapping.rebuild_mapping() must be called for
the affected sections of the content mapping which are defined by the first
and last path index of the content mapping where a change has taken place:
>>> fm.rebuild_mapping(i, i)
>>> print(fm)
0 -> doc, p, footnote, em "Stadt München"
13 -> doc, p, footnote, :Text " is the German" "name of the city of Munich"
Limitations#
A limitation shared with other techniques of standoff-markup consists in the fact that content mappings remain completely agnostic with respect to any textual meaning of the nodes. For example assume that the node-name “pb” signifies a page break, which implies that there is a gap between the two parts separated by the page break. However, because this is considered part of the meaning of “pb” it may not be required by the encoding guide-lines for the document that a gap, say, a blank character or a linefeed is also redundantly encoded in the string content of the document. (It may even be forbidden to do so!) But then a search on the string content may miss phrases separated by a page break:
>>> tree = parse_xml('<doc>xyz New<pb/>York xyz</doc>')
>>> print(tree.content)
xyz NewYork xyz
>>> m = re.search(r'New\s+York', tree.content)
>>> print(m)
None
Currently, the only remedy is to either allow redundant encoding of textual meanings within the string-content or adding specific nodes that carry the redundant textual meanings within their string-content and removing them again, after searches etc. have been finished. In the example above one can implement the first suggestion either by adding “n” before or after the <pb/>-node, or, more elegantly, by turning the empty <pb/> node into a node with content: <pb>n</pb>. The same trick can be applied for practically any kind empty node.
Markup insertion#
Class ContentMapping provides powerful markup-methods
that allows you to add markup at any position you like simply by
passing the start- and end-position in the string-representation of
the document-tree and “automagically” taking care of such perils
as cross-cutting tag-boundaries or overlapping hierarchies.
This solves a common challenge when processing tree structured text-data which consists in adding new nodes that cover certain ranges of the string content that may already have been covered by other elements. The problem is the same as adding further markup to an existing XML or HTML-document. In trivial cases like:
>>> trivial_xml = parse_xml("<trivial>Please mark up Stadt München "
... "in Bavaria in this sentence.</trivial>")
we would hardly need any help by a library to markup a string “Stadt München”. But both to find certain sub-strings and to mark them up can easily become complicated:
>>> hard_xml = parse_xml("<hard>Please mark up Stadt\n<lb/>"
... "<location><em>München</em> in Bavaria</location> in this "
... "sentence.</hard>")
Let’s start with the simple case to see how searching and marking strings works with DHParser:
>>> mapping = ContentMapping(trivial_xml)
>>> match = re.search(r"Stadt\s+München", mapping.content)
>>> _ = mapping.markup(match.start(), match.end(), "foreign",
... attributes={'lang': 'de'})
>>> printw(trivial_xml.as_xml(inline_tags={'trivial'}))
<trivial>Please mark up <foreign lang="de">Stadt München</foreign>
in Bavaria in this sentence.</trivial>
In order to search for the text-string, a regular expression is used rather than a simple search for “Stadt München”, because we cannot assume that it appears in exactly the same form in the text. For example, it could be broken up by a line break, e.g. “Stadt\nMünchen”.
Now, let’s try the more complicated case. Because we will try different configurations, we use copied of the tree “hard_xml”:
>>> import copy
>>> hard_xml_copy = copy.deepcopy(hard_xml)
>>> mapping = ContentMapping(hard_xml_copy)
>>> match = re.search(r"Stadt\s+München", mapping.content)
>>> _ = mapping.markup(match.start(), match.end(), "foreign",
... attributes={'lang': 'de'})
>>> xml_str = hard_xml_copy.as_xml(empty_tags={'lb'})
>>> print(xml_str)
<hard>
Please mark up
<foreign lang="de">
Stadt
<lb/>
</foreign>
<location>
<foreign lang="de">
<em>München</em>
</foreign>
in Bavaria
</location>
in this sentence.
</hard>
As can be seen the <foreign>-tag is split into two parts, because the markup runs across the border of another tag, in this case <location>. Note, that the <lb/>-tag lies inside the <foreign>-tag. But that makes sense, because it would also have been inside the <foreign>-tag, had there been no <location>-tag and no need to split. (Per default, the algorithm behaves somewhat “greedy”, which, however can be configured with a parameter with the same name passed to the constructor of class ContentMapping.)
But what if you don’t want the <foreign>-tag to be split up in two or more parts, as the case may be. Well, in this case, you need to allow those tags, the borders of which the new markup runs across, to be split by that markup:
>>> hard_xml_copy = copy.deepcopy(hard_xml)
>>> divisibility_map = {'foreign': {'location', ':Text'},
... '*': {':Text'}}
>>> mapping = ContentMapping(hard_xml_copy, divisibility=divisibility_map)
>>> match = re.search(r"Stadt\s+München", mapping.content)
>>> _ = mapping.markup(match.start(), match.end(), "foreign",
... attributes={'lang': 'de'})
>>> xml_str = hard_xml_copy.as_xml(empty_tags={'lb'})
>>> print(xml_str)
<hard>
Please mark up
<foreign lang="de">
Stadt
<lb/>
<location>
<em>München</em>
</location>
</foreign>
<location>
in Bavaria
</location>
in this sentence.
</hard>
See the difference? This time the <foreign>-element remains intact,
while the <location>-element has been split. This behavior can be
configures by the divisibility-map that is passed to the parameter
divisibility of the ContentMapping-constructor. It maps elements
(or, rather, their names) to sets of elements that can be cut by them.
The asterisk * is a wildcard and contains those elements that can be
cut by any other element. An element that does not appear in the
value-set anywhere in the mapping cannot be cut by any other element. It
is also possible to pass a simple set of element-names instead of a
dictionary to the divisibility-parameter. In this case any element with
a name in this set can be cut by any other element. Any element the name
of which is not a member of the set cannot be cut when markup is added.
In cases where markup overlaps element-borders, it is unavoidable to decide which element will be divided and which not. It is a general limitation of tree structures that they do not allow overlapping hierarchies. In this particular example, it would most probably be more reasonable to keep the <location>-element intact, because locations should probably be recognizable as units, while this does not really seem to matter for a foreign language annotation.
The case may arise, though, where you cannot avoid splitting elements
that form units. At this point you probably should consider using an
entirely different data-structure, say, a graph. But if this is not an
option, DHParser.nodetree allows you to mark split elements as
belonging to the same “chain” of elements. In order to do so you can
pass a chain_attr_name to the constructor of class ContentMapping.
This is an (arbitrary) name for an attribute which will contain a unique
short string that all elements (of the same name) belonging to one and
the same chain share with each other, but not with any other elements.
Let’s try this on the previous example:
>>> reset_chain_ID() # just to ensure deterministic ID values for doctest, here
>>> hard_xml_copy = copy.deepcopy(hard_xml)
>>> match = re.search(r"Stadt\s+München", hard_xml_copy.content)
>>> divisibility_map = {'foreign': {'location', ':Text'},
... '*': {':Text'}}
>>> mapping = ContentMapping(hard_xml_copy, divisibility=divisibility_map,
... chain_attr_name="chain")
>>> _ = mapping.markup(match.start(), match.end(), "foreign",
... {'lang': 'de'})
>>> xml_str = hard_xml_copy.as_xml(empty_tags={'lb'})
>>> print(xml_str)
<hard>
Please mark up
<foreign lang="de">
Stadt
<lb/>
<location chain="VZT">
<em>München</em>
</location>
</foreign>
<location chain="VZT">
in Bavaria
</location>
in this sentence.
</hard>
Markup plays well together with restricted content mappings as the following example may show:
>>> xml = ("<doc>Please mark up Stadt\n<lb/>"
... "<em>München</em><footnote>'Stadt <em>München</em>'"
... " is German for 'City of Munich'</footnote> in Bavaria"
... " in this sentence.</doc>")
>>> tree = parse_xml(xml)
Let’s assume we’d like to markup locations and text-passages in foreign languages, but only in the main text and not within footnotes and the like. For that purpose, we build a context mapping that is restricted to non-footnote-text:
>>> cm = ContentMapping(tree, select=LEAF_PATH, ignore='footnote',
... chain_attr_name='chain')
>>> print(cm.content)
Please mark up Stadt
München in Bavaria in this sentence.
Now, let’s assume for the sake of the example that we have list of location names to be marked up that contains the phrase “München in Bavaria”. So, we search for this phrase and add the required location markup:
>>> m = re.search(r"München\s+in\s+Bavaria", cm.content)
>>> print(m)
<re.Match object; span=(21, 39), match='München in Bavaria'>
>>> _ = cm.markup(m.start(), m.end(), 'location')
>>> print(tree.as_xml(empty_tags={'lb'}))
<doc>
Please mark up Stadt
<lb/>
<location>
<em>München</em>
<footnote>
'Stadt
<em>München</em>
' is German for 'City of Munich'
</footnote>
in Bavaria
</location>
in this sentence.
</doc>
The <location>-element covers the entire span, including the footnote. This is to be expected as changes are always carried out on the full tree. Only, the mapping is restricted to certain parts of the document. Often, this is indeed the desired behavior. Depending on the use case, however, another behavior (e.g. splitting the <location>-element into one part before the <footnote>-element and one part after that element) might be preferable (see further below).
Because, the <location>-element did not need to be split, it does not need and therefore does not have a “chain”-attribute.
Next, let’s add the <foreign>-element. (We substitute the value of its chain-attribute, so that the doctest does not break, when another random key is picked!):
>>> m = re.search(r'Stadt\s+München', cm.content)
>>> _ = cm.markup(m.start(), m.end(), 'foreign', lang="de")
>>> print(tree.as_xml(empty_tags={'lb'}))
<doc>
Please mark up
<foreign lang="de" chain="RZC">
Stadt
<lb/>
</foreign>
<location>
<foreign lang="de" chain="RZC">
<em>München</em>
</foreign>
<footnote>
'Stadt
<em>München</em>
' is German for 'City of Munich'
</footnote>
in Bavaria
</location>
in this sentence.
</doc>
Here again, one might ask, why the <foreign>-tag contains the <lb>-tag, but the choice makes sense, because if put together again, it should cover the complete stretch including the line-break.
As said earlier, different use cases and different choices are imaginable. What if, you prefer to exclude any footnotes from markup that you add to the main text? For this purpose, you can use the exclude_regions parameter which takes a list of tuples (start, end) as value which represent half-open intervalls of character positions start <= n < end within the string content. Because of this, if you need to exclude particular tags, you first need to determine the start and end of each of theses tags. While this deviates from the practice of defining (sub-)regions of the tree-structured document by path-select functions, it has the advantage that one can easily exclude regions that are not delineated by markup, but merely but textual characteristics like, for examples, bracketed text. Let’s see how this can be done, with out original XML-snippet:
>>> tree = parse_xml(xml)
>>> cm = ContentMapping(tree, ignore='footnote')
>>> m = re.search(r"München\s+in\s+Bavaria", cm.content)
>>> exclude = content_regions(tree, 'footnote')
>>> _ = cm.markup(m.start(), m.end(), 'location', exclude)
>>> print(tree.as_xml(empty_tags={'lb'}))
<doc>
Please mark up Stadt
<lb/>
<em>
<location>München</location>
</em>
<footnote>
'Stadt
<em>München</em>
' is German for 'City of Munich'
</footnote>
<location> in Bavaria</location>
in this sentence.
</doc>
Note
If one prefers to define include-regions instead of exclude-regions for markup,
the exclude-regions that must be passt to the markup()
method can easily be derived from the input-regions by substracting the input regions
from the region covering the full string content (or any superset of it in case you’d
like to avoid a potentially costly strlen() call) with
the function range_difference(). The list of exclude regions passed
to markup() must be sorted and overlapping regions
be merged beforehand. Any list of ranges can be sorted and merged
with sort_and_merge(). Likewise, the range-algebraic functions in
module standoff expect sorted and merged input but are - under this condition -
guaranteed to yield sorted and merged results.
In case empty nodes shall be excluded from the markup, it suffices to pass a zero-length
range at the first character after the empty node (wihtin the content). Say, we have an
xml document <doc>Die Stadt<lb/>München liegt in Bayern</doc>. Here, the letter after
the empty tag <lb/> is the “M” of the word “München”. In the string
content of this document “Die StadtMünchen liegt in Bayern”, the “M” of München is the ninth
letter. In order to avoid it being included in a newly markedup region the range (9, 9)
must be added to the exclude region. The simplest way of obtaining all the regions where
a particular type of tag is located is, again, the function content_regions(),
e.g. exlude = content_regions(tree, “lb”). Here is the full example:
>>> xml = "<doc>Die Stadt<lb/>München liegt in Bayern</doc>"
>>> tree = parse_xml(xml)
>>> cm = ContentMapping(tree)
>>> exclude = content_regions(tree, 'lb')
>>> print(exclude)
[(9, 9)]
>>> _ = cm.markup(0, 16, "X", exclude_regions=exclude) # assignment just to mute output, here
>>> print(tree.as_xml(inline_tags={'doc'}))
<doc><X>Die Stadt</X><lb/><X>München</X> liegt in Bayern</doc>
Classes and Functions-Reference#
The full documentation of all classes and functions can be found in module
DHParser.standoff. The following table of contents lists the most
important of these:
class ContentMapping#
ContentMapping represents a path-mapping of the string-content of all or a specific selection of the leave-nodes of a tree. A content-mapping is an ordered mapping of the first text position of every (selected) leaf-node to the path of this node.
The class provides methods for mapping string positions to paths and offsets (relative to the beginning of the leaf-node of the path)
content_regions(): Returns a list of string-regions,i.e. half-open intervalls. for given path-selectors.
ContentMapping: Mapping the tree to its string-contentget_path_and_offset(): Maps positions in string-content of the ContentMapping to the leaf-path into which they falliterate_paths(): Yields all paths from positionstart_posup to and including positionend_pos.insert_node(): Inserts a node at a particular text-position.markup():Adds markup (i.e. an element) to a particular stretch of text.