Four formats a library of ebooks holds and this had no name for, so each came back as text, as HTML or as unknown. RTF is its opening group, after any byte-order mark and blank space. Microsoft Reader is its signature at the front. A FictionBook is XML whose root element is FictionBook, found by stepping over the declaration, comments and doctype in the first four kilobytes. That walk is over bytes rather than text, because a great many FictionBooks are windows-1251 and would otherwise be called binary; other XML is still HTML, as before. A zip whose only real member is one FictionBook is zip-wrapped-fb2, the same way a zipped PDF is zip-wrapped-pdf: handed to a converter as FictionBook it is still a zip. A zip whose every real member is a JPEG, PNG, GIF or WebP is cbz, once directories, ComicInfo.xml and desktop clutter are passed over. One PDF or one readme among the pages and it is not a comic. Only the names in the archive's directory are read, as for every other zip. The kind vocabulary grows by five: lit, fb2, rtf, cbz and zip-wrapped-fb2.
corpus-core
The record types and the stream plumbing everything shares.
Part of catena.
What it is
A library, not a command — though it carries a small one for printing schemas. It holds the three record types, the NDJSON reader and writer, and the exit-code conventions.
Everything else in the workspace depends on it, which is what makes the tools composable: they agree about the records before they agree about anything else.
When you would use it
- Reading or writing these records in your own program, without shelling out to any of the tools.
- Generating bindings in another language, from the JSON Schema.
- Writing a new filter that joins the pipeline.
The records
Doc,
Chunk and
Hit. Each derives Serialize,
Deserialize and JsonSchema, and implements Record, which is how a reader
knows what version it is looking at.
use corpus_core::{Chunk, Kind, Doc};
let mut chunk = Chunk::new(0, "the mill stood at the northern end", 8);
chunk.page = Some(41);
chunk.chapter = Some("III. The Second Estate".into());
chunk.flag(corpus_core::chunk::flags::FOOTNOTE_CITATIONS);
let doc = Doc::new("scan.pdf", Kind::Pdf, 9_261_184);
Every field is emitted, including the empty ones, so a consumer can rely on the shape rather than on what a particular producer happened to fill in.
Reading and writing a stream
use corpus_core::ndjson;
for record in ndjson::read::<Chunk, _>(std::io::stdin().lock()) {
let chunk = record?;
println!("{} tokens", chunk.tokens);
}
ndjson::write_line(&mut std::io::stdout(), &chunk)?;
The reader is strict, and each strictness is there because the alternative is a silent failure:
- A record from a version it does not know is refused. A reader that parses a newer record into whatever lines up will process truncated records rather than failing.
- It stops at the first bad line. A bad line means the producer and the consumer disagree about the format, and reading on buries that.
- Lines are bounded. A filter pointed at a binary file should fail, not
allocate the file. The limit is sixteen megabytes and can be changed with
read_with_limit. - Blank lines are skipped and a byte-order mark is ignored, so a stream that was concatenated or written on Windows still reads.
- Every record is flushed as it is written, so a slow stage downstream sees a record as soon as it exists.
An error knows whether it was a closed pipe:
match ndjson::write_line(&mut out, &chunk) {
Err(e) if e.is_broken_pipe() => return Ok(()), // `| head` is normal
other => other?,
}
Floats JSON cannot carry
JSON has no spelling for NaN or infinity, and the usual behaviour is to write
them as null — which is a silent corruption in both directions: a score
written as null fails to read back and kills the stream at the consumer, and
a timestamp written as null reads back as "no timestamp" with nothing to say
it was ever anything else.
So a non-finite number is refused where it is produced, which is the only place anyone can still fix it. Use the same guard on your own records:
#[derive(Serialize, Deserialize)]
struct Mine {
#[serde(with = "corpus_core::finite")]
score: f64,
#[serde(default, with = "corpus_core::finite::option")]
when: Option<f64>,
}
Exit codes
use corpus_core::exit;
std::process::ExitCode::from(exit::OK) // 0
std::process::ExitCode::from(exit::NO) // 1
std::process::ExitCode::from(exit::INDETERMINATE) // 2
std::process::ExitCode::from(exit::USAGE) // 64
Verdict is the three-answer form for a predicate:
use corpus_core::exit::Verdict;
let answer = Verdict::from(needs_ocr);
std::process::ExitCode::from(answer)
See the contract for the whole table.
The schemas
$ cargo run -p corpus-core -- schema chunk > chunk.schema.json
$ cargo run -p corpus-core -- schema all
doc, chunk, hit or all. The output is byte-identical across runs, so
it can be committed and diffed.
See also
- The records — the shapes, described.
catena-cli— the rest of what a filter needs.textkit— counting and splitting text.