Use offsets, escaping, and visible labels for annotated text
information visualization
annotated text
named entities
Learn how to draw safe inline annotations from spaCy offsets and why a table of spans must stay beside the highlights.
Sam is reviewing short job-board and flyer sentences. A table of entity rows is accurate, but it is hard to see which words were labeled without reading the same sentence twice.
Annotated text visualization draws labels on spans of text while leaving the source text unchanged. A span is a stretch of text located by character offsets. The display can help readers inspect labels quickly, but the offsets, escaping, and overlap rules have to be honest.
Note
The Riverton Workforce Lab, its job board, and its training flyer are fictional and were created for teaching.
TipWhat you will learn
This lesson shows how to:
use spaCy token offsets instead of searching for entity strings;
convert 0-based, end-exclusive offsets for R’s substr();
draw labels with htmltools tag objects that escape text;
keep annotation layers separate when spans overlap;
provide a captioned table with every span; and
record the spaCy and model versions beside the display.
Start from offsets, not searched text
The spaCy pipeline returns each token’s idx, the 0-based character position where that token starts in the original string. The end of a span is the first character after it. R’s substr() starts counting at 1 and includes its end position, so the slice uses start + 1 and end.
spaCy runtime and English model used for this display
Field
Value
spacyr
1.3.0
name
core_web_sm
version
3.8.0
lang
en
license
MIT
spacy
3.8.7
The model labels HOUSE as ORG. That wrong span stays visible. Lesson 27 checks the named-entity output; this lesson only shows how to draw labels without losing the source positions.
The entity tags use B for the first token in a span and I for later tokens inside the same span. In s001, the three tagged tokens start at character 5, 7, and 8; together they make the span from 5 up to, but not including, 12.
Offset slicing recovers the span when a searched string fails
Searched text
Search found
Offset slice
12 - week
FALSE
12-week
Check code-point units with an accented name
Offsets need a counting rule. spaCy’s Python strings count Unicode code points, and R’s substr() here slices by character positions, not bytes. The sentence below includes accented characters so the byte count differs from the character count.
accent_text <-"Renée Díaz runs the Tools & Dies class at Riverton Skills Centre in October."accent_parsed <-spacy_parse(c(accent = accent_text),pos =TRUE,entity =TRUE,dependency =TRUE,additional_attributes ="idx") |>as_tibble()accent_person <- accent_parsed |>filter(str_detect(entity, "^PERSON")) |>summarise(start =min(idx),end =max(idx +nchar(token, type ="chars")),text =substr(accent_text, start +1L, end),.groups ="drop" )byte_offset <-function(text, position) {if (position ==0L) {return(0L) }nchar(substr(text, 1L, position), type ="bytes")}person_byte_start <-byte_offset(accent_text, accent_person$start)person_byte_end <-byte_offset(accent_text, accent_person$end)wrong_byte_as_character_slice <-substr( accent_text, accent_person$start +1L, person_byte_end)accent_units <-tibble(example ="accented constructed sentence",characters =nchar(accent_text, type ="chars"),bytes =nchar(accent_text, type ="bytes"),person_start = accent_person$start,person_end = accent_person$end,byte_start = person_byte_start,byte_end = person_byte_end,wrong_slice_if_bytes_are_positions = wrong_byte_as_character_slice,sliced_text = accent_person$text)kable( accent_units,format ="html",escape =TRUE,col.names =c("Example","Characters","Bytes","Start","End","Byte start","Byte end","Wrong slice if bytes are used as positions","Sliced text" ),caption ="Character offsets still slice the accented name correctly",row.names =FALSE)
Character offsets still slice the accented name correctly
Example
Characters
Bytes
Start
End
Byte start
Byte end
Wrong slice if bytes are used as positions
Sliced text
accented constructed sentence
76
78
0
10
0
12
Renée Díaz r
Renée Díaz
Do not use byte offsets as character offsets. In this example, the byte offsets would take two extra characters (Renée Díaz r), and every later span in the sentence would slide to the right.
Draw one layer only when spans do not overlap
The renderer below accepts one sentence and one layer of non-overlapping spans. It builds the line with htmltools tags, so source text is escaped by construction. The label appears as visible text inside each mark; the background color is only a backup cue.
spaCy model:
Paid
12-week
[DATE]
training is provided.
spaCy model:
RIVERTON SKILLS OPEN
HOUSE
[ORG]
spaCy model:
Apply by
October 15
[DATE]
The marks are useful because the original words stay in order. The table below is still necessary: it gives the offsets and labels in a form that does not depend on color or inline layout.
Keep a table of every span
Sam also wants to show a reviewer layer for a constructed nested example: Riverton can be a GPE, spaCy’s label for a geopolitical place such as a town, inside Riverton Skills Centre, an organisation name. The two annotations are kept in separate layers so each line can be drawn without overlap.
review_sentence <-tibble(sentence_id ="constructed-01",source_text ="Riverton Skills Centre will run evening classes in October.")review_spans <-tibble(sentence_id =c("constructed-01", "constructed-01"),layer =c("reviewer organisation", "reviewer place"),start =c(0L, 0L),end =c(22L, 8L),text =c("Riverton Skills Centre", "Riverton"),label =c("ORG", "GPE"),reviewed =c("author-written layer", "author-written layer"))all_visible_spans <-bind_rows( model_spans, review_spans)kable( all_visible_spans |>arrange(sentence_id, layer, start, end),format ="html",escape =TRUE,col.names =c("Sentence ID","Layer","Start","End","Text","Label","Review status" ),caption ="Every span used in the inline annotated-text displays",row.names =FALSE)
Every span used in the inline annotated-text displays
Sentence ID
Layer
Start
End
Text
Label
Review status
constructed-01
reviewer organisation
0
22
Riverton Skills Centre
ORG
author-written layer
constructed-01
reviewer place
0
8
Riverton
GPE
author-written layer
s001
spaCy model
5
12
12-week
DATE
model output
s023
spaCy model
21
26
HOUSE
ORG
model output
s028
spaCy model
9
19
October 15
DATE
model output
Now each reviewer layer can be drawn on its own line.
reviewer organisation:
Riverton Skills Centre
[ORG]
will run evening classes in October.
reviewer place:
Riverton
[GPE]
Skills Centre will run evening classes in October.
The two reviewer labels are not a spaCy result. They are an author-written example showing why layers matter.
Show overlap instead of forcing it inline
If those two reviewer spans are put into one layer, they overlap. This lesson does not try to squeeze them into one inline row. It switches to the table view for that layer.
overlap_spans <- review_spans |>mutate(layer ="combined reviewer layer")overlap_report <- overlap_spans |>mutate(inline_display =if_else(has_layer_overlap(overlap_spans),"table view only: spans overlap within the layer","safe for inline display" ) )kable( overlap_report,format ="html",escape =TRUE,col.names =c("Sentence ID","Layer","Start","End","Text","Label","Review status","Display decision" ),caption ="Overlapping spans are kept in a table instead of one inline layer",row.names =FALSE)
Overlapping spans are kept in a table instead of one inline layer
Sentence ID
Layer
Start
End
Text
Label
Review status
Display decision
constructed-01
combined reviewer layer
0
22
Riverton Skills Centre
ORG
author-written layer
table view only: spans overlap within the layer
constructed-01
combined reviewer layer
0
8
Riverton
GPE
author-written layer
table view only: spans overlap within the layer
Nested spans can be drawn by some specialized tools. Crossing spans are harder because one tag cannot contain the other. For a first lesson, the safer rule is simple: draw one non-overlapping layer at a time and keep the complete table.
Escape source text by construction
The renderer treats source text as text. The example below contains <b> and &. They should appear as characters, not as an HTML tag or entity.
escape_text <-"Sam wrote <b> & kept it as text."escape_span <-tibble(sentence_id ="constructed-escape",layer ="escaping check",start =10L,end =13L,text ="<b>",label ="TEXT",reviewed ="constructed hostile text")escape_line <-render_annotation_layer(sentence_id ="constructed-escape",layer_name ="escaping check",spans = escape_span,source_text = escape_text)escape_line$html
escaping check:
Sam wrote
<b>
[TEXT]
& kept it as text.
The same rule applies to tables that show source text. Use an escaping HTML table so text such as <b> remains visible.
The highlights show where the model or reviewer layer placed labels. They do not prove that the labels are correct. The HOUSE example is deliberately left in place because it is a model-output error from the earlier NER lesson.
The page also does not certify accessibility. The visible label text inside each mark keeps color from carrying the meaning alone, and the contrast is designed for the automated scan. Screen-reader behavior and forced-colors behavior still need manual review.
What to remember
An annotated text visualization draws labels on spans while keeping source text unchanged.