Small examples make a parser look easy. This one uses a real document: Jon Bosak’s XML edition of Hamlet, part of the Shakespeare collection that has been a standard XML test corpus since 1998. It is 280 KB and about 20,000 nodes — small enough to fetch in a second, structured enough that navigating it is a real question rather than a demonstration.
library(zuxml)
url <- "https://www.ibiblio.org/xml/examples/shakespeare/hamlet.xml"
path <- file.path(tempdir(), "hamlet.xml")
download.file(url, path, quiet = TRUE)
file.size(path)
#> [1] 279663It does not parse, and that is the point
xml_read(path)
#> Error : document type declaration is not allowed (at line 2, column 32)The file opens with
<!DOCTYPE PLAY SYSTEM "play.dtd">. zuxml
rejects document type declarations by default, because a DTD is the
entry point for entity-expansion attacks and for pulling in external
files, and a parser pointed at an untrusted body should not accept one
on the reader’s behalf.
Hamlet is not untrusted, so say so:
doc <- xml_read(path, allow_doctype = TRUE)
doc
#> <zuxml_document>
#> root: PLAY
#> nodes: 19840 attributes: 0
#> memory: 1.5 Mb
#> encoding: not declaredWorth being precise about what that flag does and does not do. It
allows the declaration to be present. It does not enable entity
expansion or external entity resolution — neither is compiled into the
build at all, so play.dtd is never fetched and no
&entity; in the document body would be substituted. The
flag moves one specific check, not the security model.
Walking the structure
The play is five acts of scenes of speeches of lines, which is what the markup says:
root <- xml_root(doc)
table(xml_name(xml_elements(root)))
#> ACT FM PERSONAE PLAYSUBT SCNDESCR TITLE
#> 5 1 1 1 1 1
c(acts = length(xml_elements(root, "ACT")),
scenes = length(xml_find(doc, "SCENE")),
speeches = length(xml_find(doc, "SPEECH")),
lines = length(xml_find(doc, "LINE")))
#> acts scenes speeches lines
#> 5 20 1138 4014xml_elements() looks only at direct element children, so
it answers “what is immediately inside the play”;
xml_find() searches all descendants in document order, so
it answers “how many speeches are there anywhere”. The distinction is
the whole navigation API in one line.
Every accessor is vectorized
There is no loop here. xml_find() returns a nodeset of
1,138 speakers and xml_text() turns all of them into a
character vector at once:
speakers <- xml_text(xml_find(doc, "SPEAKER"))
length(unique(speakers))
#> [1] 35
head(sort(table(speakers), decreasing = TRUE), 8)
#> speakers
#> HAMLET HORATIO KING CLAUDIUS LORD POLONIUS QUEEN GERTRUDE
#> 359 112 102 86 69
#> LAERTES OPHELIA ROSENCRANTZ
#> 62 58 49Hamlet has 359 of the 1,138 speeches — very nearly a third of the play, which is the sort of thing the shape of the data tells you once it is in R.
Nodesets compose
A nodeset indexes like a vector, so per-act figures are the same two calls applied to a subset:
acts <- xml_elements(root, "ACT")
data.frame(
act = xml_text(xml_elements(acts, "TITLE")),
scenes = vapply(seq_along(acts),
function(i) length(xml_elements(acts[i], "SCENE")), 1L),
speeches = vapply(seq_along(acts),
function(i) length(xml_find(acts[i], "SPEECH")), 1L)
)
#> act scenes speeches
#> 1 ACT I 5 251
#> 2 ACT II 2 201
#> 3 ACT III 4 250
#> 4 ACT IV 7 179
#> 5 ACT V 2 257Note xml_elements(acts, "TITLE") — one call across all
five acts, giving five titles. That is the vectorization again: a
nodeset in, a nodeset out.
Finding one thing
speeches <- xml_find(doc, "SPEECH")
first_line <- vapply(seq_along(speeches),
function(i) xml_text(xml_elements(speeches[i], "LINE"))[1],
"")
i <- grep("^To be, or not to be", first_line)
i
#> [1] 471
xml_text(xml_elements(speeches[i], "SPEAKER"))
#> [1] "HAMLET"
head(xml_text(xml_elements(speeches[i], "LINE")), 3)
#> [1] "To be, or not to be: that is the question:"
#> [2] "Whether 'tis nobler in the mind to suffer"
#> [3] "The slings and arrows of outrageous fortune,"And the longest speech in the play, by line count:
n_lines <- vapply(seq_along(speeches),
function(i) length(xml_elements(speeches[i], "LINE")), 1L)
xml_text(xml_elements(speeches[which.max(n_lines)], "SPEAKER"))
#> [1] "HAMLET"
max(n_lines)
#> [1] 60What this cost
Parsing 280 KB into a 19,840-node tree took about 7 milliseconds and 1.5 MB. The tree is immutable and the accessors are vectorized over it, so the analysis above is a handful of passes over a structure that was built once.
For a document you did not write, keep the limits in mind rather than
the timings: max_nodes, max_depth,
max_attrs, max_text and
max_memory all have defaults, each raises its own catchable
condition, and zuxml_info() reports what your build
actually enforces.