14 Commits
Author SHA1 Message Date
funkpower 90fb977a10 colophon: six more things the front matter of a book is not
Read out of the review queue a real run produced, where each of these was
sitting as a proposed title waiting for a person to say no:

  1. General Remarks            a numbered heading
  2   James W. McAllister       a numbered heading
  5222 What Is This             a folio that ran into the line
  827 North Fourth Street       an address
  Page xxxvi / Chapter I        a marker
  title                         a form label, printed literally
  Sidebottom, Harry. Ancient    a bibliography entry
  To Mackenzie,                 a dedication

The numbers are the delicate part. A leading number that could be a year
is left alone, because 1984 Revisited is a book and 5222 What Is This is
a folio, and nothing else distinguishes them. A dedication has to end in
a comma, so To Kill a Mockingbird survives.

The copyright notices in the same pile are already fixed: those
proposals were written before 0.2.2 and are stale rather than wrong.
2026-09-21 15:47:24 -04:00
funkpower 05b712d084 0.2.2 — a subtitle begins with a capital
title_case counted small words from the start of the whole string, so
every subtitle that opened with one was set in lower case: 'Aesthetic in
Kant: a Critique', 'Born Fighting: the Scots-Irish'. A colon starts a
title of its own and the first word of one is capitalised however small
it is.

Found by book-pipeline, which had been proposing a title page's capitals
verbatim into a catalogue and needed this to stop.
2026-09-21 11:30:26 -04:00
funkpower 60943420b4 0.2.1 — the holder goes between the symbol and the year
'(c) Alfredo Ferrarin 2004' was read as a title. The notice pattern
wanted the year to follow the symbol directly, and a copyright line puts
the holder's name in between, which is the ordinary way to set one.
2026-09-21 11:19:54 -04:00
funkpower 3b1fbb73ce 0.2.0
The Python surface gained enough this week that three different builds
were installed on two machines all calling themselves 0.1.0: moria had
read_colophon through merge_colophon, mirkwood had a partial from the
middle of the afternoon, and PyPI had neither. A version that does not
move is a version nobody can depend on, which is how book-pipeline ended
up running a wheel scp'd into place by hand.

New since 0.1.0: colophon --shelf and catena.shelve, which file a book's
printed Library of Congress headings onto a closed list; catena
.read_colophon; catena.fetch_identifier, the registers in process; and
catena.colophon_prompt / merge_colophon, the question about what the
pages did not settle and the reply folded back in.
2026-09-21 11:10:13 -04:00
funkpower 8f048efd69 bindings: let go of the interpreter while reading a book
read_colophon is several milliseconds of computation over a few hundred
kilobytes, and it held the GIL for all of them. A caller reading a
library through asyncio.to_thread therefore got no overlap whatsoever:
six books in flight, one book's worth of work at a time.

Measured on this library, that was most of why a concurrent pass still
paced at four and a half hours.
2026-09-21 10:57:38 -04:00
funkpower 0416359c65 bindings: colophon's own question, and the reply folded back in
colophon has composed the question about what its pages did not settle
since it was written, and merged a reply back into the record it read.
Both were reachable only from a terminal, so a pipeline wanting the same
fallback would have had to write its own prompt — a second one, drifting
from the first, asking for a shape merge does not accept.

merge fills only the unsettled fields, so a model cannot overwrite what a
book printed about itself. That is the property that makes asking one
safe, and it belongs to colophon rather than to any caller.
2026-09-21 10:49:51 -04:00
funkpower f4497b3f0a colophon: the front matter of a book nobody typeset carefully
Read against five thousand real books, the title page is often not the
first thing in the file, and what is there instead has shape. Six kinds
of it were being read as titles:

  vi   CONTENTS                                  a folioed heading
  Copyright (c) 2013 by Barry Davies, BEM        a notice
  Translated from the French by Willard R. Trask a credit
  Vol. 11. HENRY JAMES, JR.                      a collected edition's list
  [PART ONE:                                     a part heading
  Piper Verlag GmbH, Munich<span style=...       a converter's leavings

Rejecting one line only moves the misread down one, because a page that
converted badly converted badly throughout, so the whole shape goes:
markdown links, stylesheet declarations, HTML entities, epub filenames,
and the sentences a copyright page sets.

The notice pattern wants the symbol or the year, never the word alone.
The first draft rejected 'Copyright and Its Discontents', which its own
test caught.

On sixty books this drops five claimed titles, all of them wrong, and
fixes six that were reading one homily's heading for every other
homily's title.
2026-09-21 10:47:10 -04:00
funkpower d76cee8abe fmt
check.sh runs cargo fmt --check and three of my commits did not.
2026-09-21 10:26:15 -04:00
funkpower f4d936e620 recipes, examples: shelving a library by what its books printed
The suite could read a cataloguing block and could ask a register, and
nothing showed the two together — which is the only way either is worth
acting on. A title page alone gives up a running head when the scan
starts late; an ISBN alone returns a confidently wrong book when the
catalogue has been carrying the wrong one for years.

The example's title comparison folds to letters and digits rather than
comparing strings. A register and a title page differ over case, over a
curly apostrophe against a straight one, and over whether the subtitle
counts as part of the title. An example that called those disagreements
would teach the wrong lesson, and did until it was fixed.

It runs on this library and finds both of the cases worth finding: a file
called Document5 by -ducati996 that both sources agree is Russell's
Problems of Philosophy, and a Pelikan whose stored ISBN belongs to a book
about sculpture.
2026-09-21 10:21:52 -04:00
funkpower 18cc217c0d bindings: what a book says about itself, and what the register says back
book-pipeline reaches for colophon and bibfetch on every book it repairs.
Shelling out would mean a process per book per tool across five thousand
books, and parsing back the JSON it is about to parse anyway.

bibfetch's fetch loop lived in main.rs, so binding it meant either
lifting it or writing a second one. A second implementation of 'which
register answers about this kind of identifier, and in what order' would
drift from the first, and the copy nobody runs from a terminal would
drift silently. It is now bibfetch::ask, and the command line calls it
too — verified by running the CLI before and after and diffing.

fetch_identifier releases the GIL around the request. It is the only
function here that opens a socket, and holding the interpreter for the
length of an HTTP call would stop every other thread in it.

bibfetch is optional and only the python feature turns it on: its HTTP
client does not build for wasm32, and an unconditional dependency would
have taken the JavaScript package down with it.
2026-09-21 10:20:24 -04:00
funkpower 6199fce251 colophon: file a book's printed subjects onto a shelf someone has
A cataloguing block states subjects in Library of Congress form, which
is precise, deep and no use for browsing. Real blocks from this library
read 'Political science – Early works to 1800' and 'English
literature—Middle English, 1100–1500—History and criticism'. Nobody has
a shelf called that.

--shelf takes the closed list the library is actually browsed by and
files the printed headings onto it, longest term first so a period of
thought does not collapse into its parent. Matching is on whole words
after folding punctuation away, because a cataloguing block separates
with em dashes, en dashes, full stops and plain hyphens, often three in
one heading, and OCR turns any of those into any other.

What matches nothing comes back under unshelved rather than being
dropped: a subject silently discarded is indistinguishable from a book
that printed none, and the difference is the whole value of reading the
block.
2026-09-21 10:14:52 -04:00
funkpower 4ea28c03b6 bibfetch: ask every register that answers about a book, in turn
An ISBN is not a DOI. The body that assigns a DOI is the only one that has
it, so one register is the whole answer. A book's identifier is assigned by
its publisher and recorded by whoever gets round to it, and the record that
comes back is as often as not missing the thing the caller wanted: asked
about 9780199205592, OpenLibrary's edition record names *The First World
War* and the press that printed it, and nobody at all as its author, because
an edition record names its authors by key.

So an ISBN now has a chain rather than a register. The edition record first,
then OpenLibrary's own index — which answers with `author_name` filled in
and costs nothing — and Google Books last, because it answers anonymous
callers only until a daily quota runs out, after which it replies 429 to
everyone behind the same address, as it is doing here today.

The first complete answer wins, an incomplete one is kept in case nothing
better comes, and a register being down is a failure only when every
register is. `source` on the record says which one answered.

colophon comes with it: the imprint reader was taking `Oxford is a
registered trade mark of Oxford University Press` for a publisher and
`Oxford N`, off `Oxford New York`, for a place. A legal notice mentioning a
press is not the press, and a city is the city and not the first letter of
the next one.
2026-09-21 09:28:27 -04:00
funkpower ba8ec81735 colophon: the record a book prints in its own front matter
A catalogue's worst records are the ones whose file says nothing true about
itself: a PDF titled `Untitled` by an author called `Oxford`, which is a
publisher, from a filename that was never anything but a filename. What
those books do have is pages. The title page carries the title as the
publisher set it; the copyright page carries the imprint, the year, the
ISBN, and in most English books since the 1970s the cataloguing block, which
is a catalogue record a librarian already made, subject headings and all.

So this reads the pages. Every field names the page it came from, and a book
that printed nothing comes back empty with its fields in `unsettled` rather
than filled with something plausible — an extraction that starts at chapter
one has no front matter in it, and saying so is the answer.

Two pages look like a title page and are not, and both are how a catalogue
comes to name the wrong book. A series advertisement lists other titles in
the shape a title page uses, over as many as four pages with only the first
announcing itself. A translation's cataloguing block prints the original
title in brackets above the real one. Both are found and refused.

Three things the pages give that no file does: the subject headings a
librarian assigned, an ISBN that exists only in print — which turns a book
with no identifier into one `bibfetch` can look up — and a title in the case
the publisher meant, because a half title prints unbroken what the title
page breaks across two lines and sets in capitals.

`--prompt` and `--merge` are citefind's: a question about exactly the fields
the pages left unsettled, composed here and answered wherever the caller
keeps a model, with the advertising taken out of what the model is shown.
A field a model answered is marked `model`, so a record always says which of
it was read and which was inferred.

Measured against 600 books of the home corpus: the cataloguing block
answered for 21% of them and agreed with the catalogue 89% of the time, most
of the residue being the catalogue's error rather than this one's. Title
pages answered for another 41%. The remaining 38% print nothing readable,
and are what the prompt is for.
2026-09-21 09:28:26 -04:00
funkpower d55486977d release: staging cannot create a package that has never existed
The registry answers POST /-/stage/package with a 404 for a name it has not
seen, so the first release of a new name cannot be staged at all — no token
scope changes that. The step now says so and lets the release stand rather
than failing after PyPI has already published.

The first version goes out by hand with 2FA; every one after it stages from
here.
2026-09-21 08:45:50 -04:00
29 changed files with 3545 additions and 132 deletions
+10
View File
@@ -147,5 +147,15 @@ jobs:
echo "::notice::catena-corpus@$version is already published" echo "::notice::catena-corpus@$version is already published"
exit 0 exit 0
fi fi
# Staging offers a new version of a package that exists. It cannot
# bring one into being: the registry answers `POST /-/stage/package`
# with a 404 for a name it has never seen. So the first release of a
# new name is published by hand, with 2FA, and every release after
# it goes through here. Not a failure — PyPI is already out.
if ! npm view catena-corpus version >/dev/null 2>&1; then
echo "::warning::catena-corpus does not exist on npm yet, and staging cannot create it."
echo "::warning::Publish the first version by hand: npm publish crates/bindings/npm/dist --access public"
exit 0
fi
cd crates/bindings/npm/dist && npm stage publish --access public cd crates/bindings/npm/dist && npm stage publish --access public
echo "::notice::staged catena-corpus@$version; approve it with 2FA to publish" echo "::notice::staged catena-corpus@$version; approve it with 2FA to publish"
Generated
+41 -25
View File
@@ -101,7 +101,7 @@ checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
[[package]] [[package]]
name = "avplan" name = "avplan"
version = "0.1.0" version = "0.2.3"
dependencies = [ dependencies = [
"catena-cli", "catena-cli",
"clap", "clap",
@@ -126,7 +126,7 @@ checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5"
[[package]] [[package]]
name = "bibclean" name = "bibclean"
version = "0.1.0" version = "0.2.3"
dependencies = [ dependencies = [
"catena-cli", "catena-cli",
"clap", "clap",
@@ -143,7 +143,7 @@ dependencies = [
[[package]] [[package]]
name = "bibfetch" name = "bibfetch"
version = "0.1.0" version = "0.2.3"
dependencies = [ dependencies = [
"catena-cli", "catena-cli",
"clap", "clap",
@@ -196,7 +196,7 @@ dependencies = [
[[package]] [[package]]
name = "bookmeta" name = "bookmeta"
version = "0.1.0" version = "0.2.3"
dependencies = [ dependencies = [
"catena-cli", "catena-cli",
"clap", "clap",
@@ -210,7 +210,7 @@ dependencies = [
[[package]] [[package]]
name = "booksniff" name = "booksniff"
version = "0.1.0" version = "0.2.3"
dependencies = [ dependencies = [
"catena-cli", "catena-cli",
"clap", "clap",
@@ -250,12 +250,14 @@ checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04"
[[package]] [[package]]
name = "catena" name = "catena"
version = "0.1.0" version = "0.2.3"
dependencies = [ dependencies = [
"bibclean", "bibclean",
"bibfetch",
"booksniff", "booksniff",
"chunkmd", "chunkmd",
"citefind", "citefind",
"colophon",
"corpus-core", "corpus-core",
"cuekit", "cuekit",
"cuepack", "cuepack",
@@ -275,7 +277,7 @@ dependencies = [
[[package]] [[package]]
name = "catena-cli" name = "catena-cli"
version = "0.1.0" version = "0.2.3"
dependencies = [ dependencies = [
"clap", "clap",
"clap_mangen", "clap_mangen",
@@ -310,7 +312,7 @@ checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600"
[[package]] [[package]]
name = "chunkmd" name = "chunkmd"
version = "0.1.0" version = "0.2.3"
dependencies = [ dependencies = [
"catena-cli", "catena-cli",
"clap", "clap",
@@ -332,7 +334,7 @@ dependencies = [
[[package]] [[package]]
name = "citefind" name = "citefind"
version = "0.1.0" version = "0.2.3"
dependencies = [ dependencies = [
"catena-cli", "catena-cli",
"chunkmd", "chunkmd",
@@ -395,6 +397,20 @@ dependencies = [
"roff", "roff",
] ]
[[package]]
name = "colophon"
version = "0.2.3"
dependencies = [
"bibclean",
"catena-cli",
"clap",
"corpus-core",
"regex",
"schemars",
"serde",
"serde_json",
]
[[package]] [[package]]
name = "colorchoice" name = "colorchoice"
version = "1.0.5" version = "1.0.5"
@@ -432,7 +448,7 @@ dependencies = [
[[package]] [[package]]
name = "corpus-core" name = "corpus-core"
version = "0.1.0" version = "0.2.3"
dependencies = [ dependencies = [
"schemars", "schemars",
"serde", "serde",
@@ -476,7 +492,7 @@ dependencies = [
[[package]] [[package]]
name = "cuekit" name = "cuekit"
version = "0.1.0" version = "0.2.3"
dependencies = [ dependencies = [
"corpus-core", "corpus-core",
"regex", "regex",
@@ -488,7 +504,7 @@ dependencies = [
[[package]] [[package]]
name = "cuenorm" name = "cuenorm"
version = "0.1.0" version = "0.2.3"
dependencies = [ dependencies = [
"catena-cli", "catena-cli",
"clap", "clap",
@@ -498,7 +514,7 @@ dependencies = [
[[package]] [[package]]
name = "cuepack" name = "cuepack"
version = "0.1.0" version = "0.2.3"
dependencies = [ dependencies = [
"catena-cli", "catena-cli",
"clap", "clap",
@@ -509,7 +525,7 @@ dependencies = [
[[package]] [[package]]
name = "cueseg" name = "cueseg"
version = "0.1.0" version = "0.2.3"
dependencies = [ dependencies = [
"catena-cli", "catena-cli",
"clap", "clap",
@@ -596,7 +612,7 @@ dependencies = [
[[package]] [[package]]
name = "emb" name = "emb"
version = "0.1.0" version = "0.2.3"
dependencies = [ dependencies = [
"catena-cli", "catena-cli",
"clap", "clap",
@@ -860,7 +876,7 @@ dependencies = [
[[package]] [[package]]
name = "idnorm" name = "idnorm"
version = "0.1.0" version = "0.2.3"
dependencies = [ dependencies = [
"bibclean", "bibclean",
"catena-cli", "catena-cli",
@@ -926,7 +942,7 @@ dependencies = [
[[package]] [[package]]
name = "langid" name = "langid"
version = "0.1.0" version = "0.2.3"
dependencies = [ dependencies = [
"catena-cli", "catena-cli",
"clap", "clap",
@@ -1006,7 +1022,7 @@ dependencies = [
[[package]] [[package]]
name = "mdscrub" name = "mdscrub"
version = "0.1.0" version = "0.2.3"
dependencies = [ dependencies = [
"catena-cli", "catena-cli",
"clap", "clap",
@@ -1018,7 +1034,7 @@ dependencies = [
[[package]] [[package]]
name = "mdsplit" name = "mdsplit"
version = "0.1.0" version = "0.2.3"
dependencies = [ dependencies = [
"catena-cli", "catena-cli",
"clap", "clap",
@@ -1097,7 +1113,7 @@ checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
[[package]] [[package]]
name = "pdfgutter" name = "pdfgutter"
version = "0.1.0" version = "0.2.3"
dependencies = [ dependencies = [
"catena-cli", "catena-cli",
"clap", "clap",
@@ -1110,7 +1126,7 @@ dependencies = [
[[package]] [[package]]
name = "pdftriage" name = "pdftriage"
version = "0.1.0" version = "0.2.3"
dependencies = [ dependencies = [
"catena-cli", "catena-cli",
"clap", "clap",
@@ -1354,7 +1370,7 @@ checksum = "323c417e1d9665a65b263ec744ba09030cfb277e9daa0b018a4ab62e57bc8189"
[[package]] [[package]]
name = "rrf" name = "rrf"
version = "0.1.0" version = "0.2.3"
dependencies = [ dependencies = [
"catena-cli", "catena-cli",
"clap", "clap",
@@ -1501,7 +1517,7 @@ dependencies = [
[[package]] [[package]]
name = "seriesof" name = "seriesof"
version = "0.1.0" version = "0.2.3"
dependencies = [ dependencies = [
"catena-cli", "catena-cli",
"clap", "clap",
@@ -1618,7 +1634,7 @@ checksum = "adb6935a6f5c20170eeceb1a3835a49e12e19d792f6dd344ccc76a985ca5a6ca"
[[package]] [[package]]
name = "textkit" name = "textkit"
version = "0.1.0" version = "0.2.3"
dependencies = [ dependencies = [
"regex", "regex",
"tiktoken-rs", "tiktoken-rs",
@@ -1708,7 +1724,7 @@ checksum = "fd3ca314f692efd6c868f8408f53fe444634a845f96c028b97d35f6a1f79f0ee"
[[package]] [[package]]
name = "tocfind" name = "tocfind"
version = "0.1.0" version = "0.2.3"
dependencies = [ dependencies = [
"catena-cli", "catena-cli",
"clap", "clap",
+27 -26
View File
@@ -3,38 +3,39 @@ resolver = "3"
members = ["crates/*"] members = ["crates/*"]
[workspace.package] [workspace.package]
version = "0.1.0" version = "0.2.3"
edition = "2024" edition = "2024"
rust-version = "1.85" rust-version = "1.85"
license = "MPL-2.0" license = "MPL-2.0"
repository = "https://git.pumpin.online/funkpower/catena" repository = "https://git.pumpin.online/funkpower/catena"
[workspace.dependencies] [workspace.dependencies]
catena-cli = { path = "crates/catena-cli", version = "0.1.0" } catena-cli = { path = "crates/catena-cli", version = "0.2.3" }
corpus-core = { path = "crates/corpus-core", version = "0.1.0" } corpus-core = { path = "crates/corpus-core", version = "0.2.3" }
cuekit = { path = "crates/cuekit", version = "0.1.0" } cuekit = { path = "crates/cuekit", version = "0.2.3" }
textkit = { path = "crates/textkit", version = "0.1.0" } textkit = { path = "crates/textkit", version = "0.2.3" }
chunkmd = { path = "crates/chunkmd", version = "0.1.0" } chunkmd = { path = "crates/chunkmd", version = "0.2.3" }
mdscrub = { path = "crates/mdscrub", version = "0.1.0" } mdscrub = { path = "crates/mdscrub", version = "0.2.3" }
mdsplit = { path = "crates/mdsplit", version = "0.1.0" } mdsplit = { path = "crates/mdsplit", version = "0.2.3" }
pdftriage = { path = "crates/pdftriage", version = "0.1.0" } pdftriage = { path = "crates/pdftriage", version = "0.2.3" }
cuenorm = { path = "crates/cuenorm", version = "0.1.0" } cuenorm = { path = "crates/cuenorm", version = "0.2.3" }
cueseg = { path = "crates/cueseg", version = "0.1.0" } cueseg = { path = "crates/cueseg", version = "0.2.3" }
rrf = { path = "crates/rrf", version = "0.1.0" } rrf = { path = "crates/rrf", version = "0.2.3" }
cuepack = { path = "crates/cuepack", version = "0.1.0" } cuepack = { path = "crates/cuepack", version = "0.2.3" }
bibclean = { path = "crates/bibclean", version = "0.1.0" } bibclean = { path = "crates/bibclean", version = "0.2.3" }
avplan = { path = "crates/avplan", version = "0.1.0" } avplan = { path = "crates/avplan", version = "0.2.3" }
pdfgutter = { path = "crates/pdfgutter", version = "0.1.0" } pdfgutter = { path = "crates/pdfgutter", version = "0.2.3" }
catena = { path = "crates/bindings", version = "0.1.0" } catena = { path = "crates/bindings", version = "0.2.3" }
seriesof = { path = "crates/seriesof", version = "0.1.0" } seriesof = { path = "crates/seriesof", version = "0.2.3" }
idnorm = { path = "crates/idnorm", version = "0.1.0" } idnorm = { path = "crates/idnorm", version = "0.2.3" }
bibfetch = { path = "crates/bibfetch", version = "0.1.0" } bibfetch = { path = "crates/bibfetch", version = "0.2.3" }
bookmeta = { path = "crates/bookmeta", version = "0.1.0" } bookmeta = { path = "crates/bookmeta", version = "0.2.3" }
langid = { path = "crates/langid", version = "0.1.0" } langid = { path = "crates/langid", version = "0.2.3" }
tocfind = { path = "crates/tocfind", version = "0.1.0" } tocfind = { path = "crates/tocfind", version = "0.2.3" }
booksniff = { path = "crates/booksniff", version = "0.1.0" } booksniff = { path = "crates/booksniff", version = "0.2.3" }
citefind = { path = "crates/citefind", version = "0.1.0" } citefind = { path = "crates/citefind", version = "0.2.3" }
emb = { path = "crates/emb", version = "0.1.0" } colophon = { path = "crates/colophon", version = "0.2.3" }
emb = { path = "crates/emb", version = "0.2.3" }
clap = { version = "4", features = ["derive"] } clap = { version = "4", features = ["derive"] }
clap_mangen = "0.3" clap_mangen = "0.3"
+1
View File
@@ -25,6 +25,7 @@ Each tool installs by name, so you can take one without taking the rest.
|---|---| |---|---|
| [`booksniff`](crates/booksniff/README.md) | What a file actually is, whatever its name claims | | [`booksniff`](crates/booksniff/README.md) | What a file actually is, whatever its name claims |
| [`bookmeta`](crates/bookmeta/README.md) | The metadata a file already carries | | [`bookmeta`](crates/bookmeta/README.md) | The metadata a file already carries |
| [`colophon`](crates/colophon/README.md) | The record a book prints in its own front matter |
| [`tocfind`](crates/tocfind/README.md) | A book's own table of contents | | [`tocfind`](crates/tocfind/README.md) | A book's own table of contents |
| [`langid`](crates/langid/README.md) | What language a document is in | | [`langid`](crates/langid/README.md) | What language a document is in |
| [`pdfgutter`](crates/pdfgutter/README.md) | Books scanned two pages to a sheet, split back into pages | | [`pdfgutter`](crates/pdfgutter/README.md) | Books scanned two pages to a sheet, split back into pages |
+45 -1
View File
@@ -67,7 +67,19 @@ pub fn title_case(title: &str) -> String {
.to_lowercase() .to_lowercase()
.trim_end_matches(['.', ',', ':', ';', '!', '?', ')', ']', '"', '\'']) .trim_end_matches(['.', ',', ':', ';', '!', '?', ')', ']', '"', '\''])
.to_string(); .to_string();
if SMALL_WORDS.binary_search(&core.as_str()).is_ok() && at > 0 && at < last { // A small word stays small in the middle of a title, but not
// where a title actually starts. A colon starts a subtitle, and
// "Aesthetic in Kant: a Critique" is the giveaway that the rule
// was only ever counting from the beginning of the whole string.
let opens_a_subtitle = at > 0
&& words[at - 1]
.trim_end_matches(['"', '\'', ')', ']'])
.ends_with([':', ';', '?', '!', '.']);
if SMALL_WORDS.binary_search(&core.as_str()).is_ok()
&& at > 0
&& at < last
&& !opens_a_subtitle
{
return word.to_lowercase(); return word.to_lowercase();
} }
let lowered = word.to_lowercase(); let lowered = word.to_lowercase();
@@ -218,3 +230,35 @@ mod tests {
); );
} }
} }
#[cfg(test)]
mod subtitle_case_tests {
use super::title_case;
#[test]
fn a_subtitle_starts_with_a_capital() {
// The rule counted small words from the start of the string, so every
// subtitle beginning "A", "The" or "Of" was set lower case.
assert_eq!(
title_case("AESTHETIC IN KANT: A CRITIQUE"),
"Aesthetic in Kant: A Critique"
);
assert_eq!(
title_case("BORN FIGHTING: THE SCOTS-IRISH"),
"Born Fighting: The Scots-Irish"
);
assert_eq!(
title_case("A HISTORY: AN INTRODUCTION TO IT"),
"A History: An Introduction to It"
);
}
#[test]
fn a_small_word_in_the_middle_is_still_small() {
assert_eq!(
title_case("THE RISE AND FALL OF THE THIRD REICH"),
"The Rise and Fall of the Third Reich"
);
assert_eq!(title_case("WAR OF THE WORLDS"), "War of the Worlds");
}
}
+6 -1
View File
@@ -260,9 +260,14 @@ mod tests {
#[test] #[test]
fn an_underscored_title_is_cleaned_before_it_is_cased() { fn an_underscored_title_is_cleaned_before_it_is_cased() {
let cleaned = clean(&book("THE FALL_ AND THE RISE_txt", "Arendt, Hannah")); let cleaned = clean(&book("THE FALL_ AND THE RISE_txt", "Arendt, Hannah"));
// "And" is capitalised because the colon starts a subtitle, and the
// first word of one is capitalised however small it is. This asserted
// "and" until the rule was found to be counting small words from the
// start of the whole string, which set every subtitle opening "A",
// "The" or "An" in lower case.
assert_eq!( assert_eq!(
cleaned.book.title.as_deref(), cleaned.book.title.as_deref(),
Some("The Fall: and the Rise") Some("The Fall: And the Rise")
); );
assert_eq!(cleaned.changes.len(), 2); assert_eq!(cleaned.changes.len(), 2);
} }
+14 -1
View File
@@ -84,7 +84,20 @@ is a failure — nobody replied, and the identifier may be perfectly good.
|---|---|---| |---|---|---|
| `doi` | Crossref | It assigns most scholarly DOIs | | `doi` | Crossref | It assigns most scholarly DOIs |
| `arxiv` | the arXiv | Its own register of preprints | | `arxiv` | the arXiv | Its own register of preprints |
| `isbn` | OpenLibrary | Books, without a key | | `isbn` | OpenLibrary's edition record, then its index, then Google Books | Books, without a key |
One register is enough for a DOI or a preprint: the body that assigns the
identifier is the only one that has it. A book is different. An ISBN is
assigned by a publisher and recorded by whoever gets round to it, so an
answer that names the book but not who wrote it is common — OpenLibrary's
edition record names its authors by key rather than by name, and its index
names them. So the registers are asked in turn and the first complete answer
wins. An incomplete one is kept in case nothing better comes, and a register
being down is a failure only when every register is.
Google Books is asked last because it answers anonymous callers only until a
daily quota runs out, after which it replies `429` to everyone sharing the
address.
An **ISSN** has no register that answers without a key, so it is not looked up An **ISSN** has no register that answers without a key, so it is not looked up
on its own. A journal's ISSN comes back on the works published in it instead. on its own. A journal's ISSN comes back on the works published in it instead.
+143
View File
@@ -0,0 +1,143 @@
//! Asking the registers, which is the part that touches the network.
//!
//! Lifted out of the command line so that the Python bindings ask in exactly
//! the same order, with the same retries and the same backoff. Two
//! implementations of "which register answers about this identifier" would
//! drift, and the one nobody runs from a terminal would drift silently.
use std::time::Duration;
use crate::{Error, Record, Source};
/// How to ask: who to say you are, how long to wait, how often to retry.
#[derive(Debug, Clone)]
pub struct Ask {
/// An address the registers ask for, and answer faster when they get.
pub polite: Option<String>,
/// How long to wait on any one register.
pub timeout: Duration,
/// How many times to try a register that is unreachable.
pub attempts: u32,
}
impl Default for Ask {
fn default() -> Self {
Self {
polite: None,
timeout: crate::TIMEOUT,
attempts: crate::ATTEMPTS,
}
}
}
impl Ask {
/// An agent configured to ask this way.
pub fn agent(&self) -> ureq::Agent {
ureq::Agent::config_builder()
.timeout_global(Some(self.timeout))
.user_agent(user_agent(self.polite.as_deref()))
.build()
.into()
}
}
/// Ask every register that answers about this kind of identifier, in turn,
/// and stop at the first complete answer.
///
/// An incomplete answer is kept in case nothing better comes: a title with no
/// authors is still more than nothing, and a register being down is only a
/// failure when every register is. `Ok(None)` means no register here answers
/// about that kind at all.
pub fn lookup(
agent: &ureq::Agent,
id: &str,
kind: &str,
attempts: u32,
) -> Result<Option<Record>, Error> {
let chain = Source::chain_for_kind(kind);
if chain.is_empty() {
return Ok(None);
}
let mut best: Option<Record> = None;
let mut last_error = None;
for source in chain {
match ask(agent, *source, id, kind, attempts) {
Ok(record) => {
let complete = record.found && record.title.is_some() && !record.authors.is_empty();
let better = best.as_ref().is_none_or(|kept| record.found && !kept.found);
if complete {
return Ok(Some(record));
}
if better {
best = Some(record);
}
}
Err(why) => last_error = Some(why),
}
}
match best {
Some(record) => Ok(Some(record)),
None => {
Err(last_error.unwrap_or_else(|| Error::Unreachable("no register answered".into())))
}
}
}
/// Ask a register, retrying a failure that might not be one.
///
/// Waits a second before the second attempt and two before the third: long
/// enough for a register that is rate-limiting, short enough that one which
/// is simply down does not hold up a pipeline.
fn ask(
agent: &ureq::Agent,
source: Source,
id: &str,
kind: &str,
attempts: u32,
) -> Result<crate::Record, Error> {
let mut last = Error::Unreachable("no attempt was made".into());
for attempt in 0..attempts.max(1) {
match fetch(agent, source, id, kind) {
Ok(record) => return Ok(record),
// A shape this cannot read will not read any better next time.
Err(e @ Error::Shape(_)) => return Err(e),
Err(e) => last = e,
}
std::thread::sleep(Duration::from_secs(1 << attempt));
}
Err(last)
}
fn fetch(
agent: &ureq::Agent,
source: Source,
id: &str,
kind: &str,
) -> Result<crate::Record, Error> {
let response = agent.get(source.url(id)).call();
let mut response = match response {
Ok(response) => response,
// A register that does not have it says so with a status, and that is
// an answer rather than a failure.
Err(ureq::Error::StatusCode(404)) => return Ok(crate::missing(id, kind, source)),
Err(e) => return Err(Error::Unreachable(e.to_string())),
};
let body = response
.body_mut()
.read_to_string()
.map_err(|e| Error::Unreachable(e.to_string()))?;
source.read(id, kind, &body)
}
/// Who is asking.
///
/// The registers ask for an address and answer faster when they get one; a
/// tool that does not say who it is gets the slow pool.
fn user_agent(polite: Option<&str>) -> String {
let version = env!("CARGO_PKG_VERSION");
match polite {
Some(address) => format!("bibfetch/{version} (mailto:{address})"),
None => format!("bibfetch/{version}"),
}
}
+1
View File
@@ -13,6 +13,7 @@
//! book. There is no register for an ISSN that answers without a key, so an //! book. There is no register for an ISSN that answers without a key, so an
//! ISSN is looked up as the journal of a work rather than on its own. //! ISSN is looked up as the journal of a work rather than on its own.
pub mod ask;
pub mod source; pub mod source;
use std::time::Duration; use std::time::Duration;
+10 -71
View File
@@ -4,7 +4,6 @@ use std::path::PathBuf;
use std::process::ExitCode; use std::process::ExitCode;
use std::time::Duration; use std::time::Duration;
use bibfetch::{Error, Source};
use clap::Parser; use clap::Parser;
use corpus_core::exit; use corpus_core::exit;
@@ -65,11 +64,12 @@ fn main() -> ExitCode {
} }
}; };
let agent: ureq::Agent = ureq::Agent::config_builder() let agent = bibfetch::ask::Ask {
.timeout_global(Some(Duration::from_secs(args.timeout))) polite: args.polite.clone(),
.user_agent(user_agent(args.polite.as_deref())) timeout: Duration::from_secs(args.timeout),
.build() attempts: args.attempts,
.into(); }
.agent();
let mut records = Vec::new(); let mut records = Vec::new();
let mut missing = 0usize; let mut missing = 0usize;
@@ -86,14 +86,12 @@ fn main() -> ExitCode {
} }
}; };
let Some(source) = Source::for_kind(&kind) else { match bibfetch::ask::lookup(&agent, &id, &kind, args.attempts) {
Ok(None) => {
eprintln!("bibfetch: {id}: no register here answers about {kind} identifiers"); eprintln!("bibfetch: {id}: no register here answers about {kind} identifiers");
missing += 1; missing += 1;
continue; }
}; Ok(Some(record)) => {
match ask(&agent, source, &id, &kind, args.attempts) {
Ok(record) => {
if !record.found { if !record.found {
missing += 1; missing += 1;
} }
@@ -116,65 +114,6 @@ fn main() -> ExitCode {
}) })
} }
/// Ask a register, retrying a failure that might not be one.
///
/// Waits a second before the second attempt and two before the third: long
/// enough for a register that is rate-limiting, short enough that one which
/// is simply down does not hold up a pipeline.
fn ask(
agent: &ureq::Agent,
source: Source,
id: &str,
kind: &str,
attempts: u32,
) -> Result<bibfetch::Record, Error> {
let mut last = Error::Unreachable("no attempt was made".into());
for attempt in 0..attempts.max(1) {
match fetch(agent, source, id, kind) {
Ok(record) => return Ok(record),
// A shape this cannot read will not read any better next time.
Err(e @ Error::Shape(_)) => return Err(e),
Err(e) => last = e,
}
std::thread::sleep(Duration::from_secs(1 << attempt));
}
Err(last)
}
fn fetch(
agent: &ureq::Agent,
source: Source,
id: &str,
kind: &str,
) -> Result<bibfetch::Record, Error> {
let response = agent.get(source.url(id)).call();
let mut response = match response {
Ok(response) => response,
// A register that does not have it says so with a status, and that is
// an answer rather than a failure.
Err(ureq::Error::StatusCode(404)) => return Ok(bibfetch::missing(id, kind, source)),
Err(e) => return Err(Error::Unreachable(e.to_string())),
};
let body = response
.body_mut()
.read_to_string()
.map_err(|e| Error::Unreachable(e.to_string()))?;
source.read(id, kind, &body)
}
/// Who is asking.
///
/// The registers ask for an address and answer faster when they get one; a
/// tool that does not say who it is gets the slow pool.
fn user_agent(polite: Option<&str>) -> String {
let version = env!("CARGO_PKG_VERSION");
match polite {
Some(address) => format!("bibfetch/{version} (mailto:{address})"),
None => format!("bibfetch/{version}"),
}
}
/// The identifier and its kind out of one record. /// The identifier and its kind out of one record.
fn read_identifier(line: &str) -> Result<(String, String), String> { fn read_identifier(line: &str) -> Result<(String, String), String> {
let value: serde_json::Value = let value: serde_json::Value =
+128 -4
View File
@@ -17,6 +17,13 @@ pub enum Source {
Arxiv, Arxiv,
/// OpenLibrary, for books by ISBN. /// OpenLibrary, for books by ISBN.
OpenLibrary, OpenLibrary,
/// OpenLibrary's index, rather than its edition record. Asked when the
/// edition record has the book but not who wrote it, which is often: an
/// edition names its authors by key, and the index names them.
OpenLibrarySearch,
/// Google Books, for books by ISBN. Asked last, because it answers
/// anonymous callers only until a daily quota runs out.
GoogleBooks,
} }
impl Source { impl Source {
@@ -26,16 +33,34 @@ impl Source {
Source::Crossref => "crossref", Source::Crossref => "crossref",
Source::Arxiv => "arxiv", Source::Arxiv => "arxiv",
Source::OpenLibrary => "openlibrary", Source::OpenLibrary => "openlibrary",
Source::OpenLibrarySearch => "openlibrary",
Source::GoogleBooks => "googlebooks",
} }
} }
/// Which register answers about a kind of identifier. /// Which register answers about a kind of identifier.
pub fn for_kind(kind: &str) -> Option<Self> { pub fn for_kind(kind: &str) -> Option<Self> {
Self::chain_for_kind(kind).first().copied()
}
/// Every register that answers about a kind, in the order to ask them.
///
/// One register is enough for a DOI or a preprint: the body that assigns
/// the identifier is the only one that has it. A book is different. An
/// ISBN is assigned by a publisher and recorded by whoever gets round to
/// it, so coverage is patchy in different places for different books,
/// and an answer missing the authors is common enough to be worth asking
/// a second register about.
pub fn chain_for_kind(kind: &str) -> &'static [Self] {
match kind { match kind {
"doi" => Some(Source::Crossref), "doi" => &[Source::Crossref],
"arxiv" => Some(Source::Arxiv), "arxiv" => &[Source::Arxiv],
"isbn" => Some(Source::OpenLibrary), "isbn" => &[
_ => None, Source::OpenLibrary,
Source::OpenLibrarySearch,
Source::GoogleBooks,
],
_ => &[],
} }
} }
@@ -50,6 +75,15 @@ impl Source {
Source::OpenLibrary => { Source::OpenLibrary => {
format!("https://openlibrary.org/isbn/{}.json", encode(id)) format!("https://openlibrary.org/isbn/{}.json", encode(id))
} }
Source::OpenLibrarySearch => format!(
"https://openlibrary.org/search.json?q=isbn:{}\
&fields=title,subtitle,author_name,first_publish_year,publisher&limit=1",
encode(id)
),
Source::GoogleBooks => format!(
"https://www.googleapis.com/books/v1/volumes?q=isbn:{}",
encode(id)
),
} }
} }
@@ -59,6 +93,8 @@ impl Source {
Source::Crossref => crossref(id, kind, body), Source::Crossref => crossref(id, kind, body),
Source::Arxiv => arxiv(id, kind, body), Source::Arxiv => arxiv(id, kind, body),
Source::OpenLibrary => open_library(id, kind, body), Source::OpenLibrary => open_library(id, kind, body),
Source::OpenLibrarySearch => open_library_search(id, kind, body),
Source::GoogleBooks => google_books(id, kind, body),
} }
} }
} }
@@ -203,6 +239,94 @@ fn open_library(id: &str, kind: &str, body: &str) -> Result<Record, Error> {
Ok(record) Ok(record)
} }
#[derive(Deserialize)]
struct SearchReply {
#[serde(default)]
docs: Vec<SearchDoc>,
}
#[derive(Deserialize)]
struct SearchDoc {
#[serde(default)]
title: Option<String>,
#[serde(default)]
subtitle: Option<String>,
#[serde(default)]
author_name: Vec<String>,
#[serde(default)]
first_publish_year: Option<i64>,
#[serde(default)]
publisher: Vec<String>,
}
/// A search that matched nothing answers with no documents rather than a
/// status, so an empty reply is the absence and not a failure.
fn open_library_search(id: &str, kind: &str, body: &str) -> Result<Record, Error> {
let reply: SearchReply = serde_json::from_str(body).map_err(|e| Error::Shape(e.to_string()))?;
let Some(doc) = reply.docs.into_iter().next() else {
return Ok(crate::missing(id, kind, Source::OpenLibrarySearch));
};
let mut record = blank(id, kind, Source::OpenLibrarySearch);
record.title = match (doc.title, doc.subtitle) {
(Some(title), Some(subtitle)) => Some(format!("{title}: {subtitle}")),
(title, _) => title,
};
record.authors = doc.author_name;
record.year = doc.first_publish_year;
record.container = doc.publisher.into_iter().next();
Ok(record)
}
#[derive(Deserialize)]
struct GoogleReply {
#[serde(default)]
items: Vec<GoogleItem>,
}
#[derive(Deserialize)]
struct GoogleItem {
#[serde(rename = "volumeInfo", default)]
volume_info: GoogleVolume,
}
#[derive(Deserialize, Default)]
struct GoogleVolume {
#[serde(default)]
title: Option<String>,
#[serde(default)]
subtitle: Option<String>,
#[serde(default)]
authors: Vec<String>,
#[serde(rename = "publishedDate", default)]
published_date: Option<String>,
#[serde(default)]
publisher: Option<String>,
#[serde(default)]
description: Option<String>,
}
/// A search that matched nothing answers with no items rather than a status,
/// so an empty reply is the absence and not a failure.
fn google_books(id: &str, kind: &str, body: &str) -> Result<Record, Error> {
let reply: GoogleReply = serde_json::from_str(body).map_err(|e| Error::Shape(e.to_string()))?;
let Some(item) = reply.items.into_iter().next() else {
return Ok(crate::missing(id, kind, Source::GoogleBooks));
};
let volume = item.volume_info;
let mut record = blank(id, kind, Source::GoogleBooks);
record.title = match (volume.title, volume.subtitle) {
(Some(title), Some(subtitle)) => Some(format!("{title}: {subtitle}")),
(title, _) => title,
};
record.authors = volume.authors;
record.year = volume.published_date.as_deref().and_then(year_in);
record.container = volume.publisher;
record.summary = volume.description.map(|text| collapse(&text));
Ok(record)
}
/// The first four-digit year in a string. /// The first four-digit year in a string.
fn year_in(text: &str) -> Option<i64> { fn year_in(text: &str) -> Option<i64> {
let bytes: Vec<char> = text.chars().collect(); let bytes: Vec<char> = text.chars().collect();
+7 -1
View File
@@ -30,6 +30,7 @@ crate-type = ["cdylib", "rlib"]
[dependencies] [dependencies]
booksniff.workspace = true booksniff.workspace = true
bibclean.workspace = true bibclean.workspace = true
colophon.workspace = true
chunkmd.workspace = true chunkmd.workspace = true
citefind.workspace = true citefind.workspace = true
corpus-core.workspace = true corpus-core.workspace = true
@@ -47,6 +48,11 @@ textkit.workspace = true
pyo3 = { workspace = true, optional = true } pyo3 = { workspace = true, optional = true }
# Only the Python build gets this. bibfetch is the one tool here that opens a
# socket, and its HTTP client does not build for wasm32 — so a dependency that
# is unconditional would take the JavaScript package down with it.
bibfetch = { workspace = true, optional = true }
# The JavaScript build. Only ever compiled for a wasm32 target; on any other # The JavaScript build. Only ever compiled for a wasm32 target; on any other
# the module is compiled out and these sit unused. # the module is compiled out and these sit unused.
wasm-bindgen = { workspace = true, optional = true } wasm-bindgen = { workspace = true, optional = true }
@@ -56,7 +62,7 @@ serde-wasm-bindgen = { workspace = true, optional = true }
# Only maturin turns this on. pyo3's extension-module feature leaves the # Only maturin turns this on. pyo3's extension-module feature leaves the
# Python symbols to be resolved by the interpreter at load time, so an # Python symbols to be resolved by the interpreter at load time, so an
# ordinary `cargo build` would fail to link if it were on by default. # ordinary `cargo build` would fail to link if it were on by default.
python = ["dep:pyo3"] python = ["dep:pyo3", "dep:bibfetch"]
# The C ABI. Off by default so an ordinary build does not also export a # The C ABI. Off by default so an ordinary build does not also export a
# hundred symbols nobody asked for. # hundred symbols nobody asked for.
capi = [] capi = []
+10
View File
@@ -16,10 +16,12 @@ from ._catena import (
chunk_markdown, chunk_markdown,
citation_prompt, citation_prompt,
clean_record, clean_record,
colophon_prompt,
clean_underscores, clean_underscores,
compose_subjects, compose_subjects,
count_tokens, count_tokens,
cue_windows, cue_windows,
fetch_identifier,
find_citations, find_citations,
fuse_hits, fuse_hits,
identify, identify,
@@ -29,16 +31,19 @@ from ._catena import (
looks_like_footnote_citations, looks_like_footnote_citations,
looks_like_person, looks_like_person,
merge_citation_reply, merge_citation_reply,
merge_colophon,
needs_subject, needs_subject,
pack_cues, pack_cues,
parse_identifier, parse_identifier,
parse_subtitles, parse_subtitles,
read_colophon,
record_problems, record_problems,
record_severity, record_severity,
same_work, same_work,
scrub, scrub,
segment_cues, segment_cues,
series_of, series_of,
shelve,
split_collection, split_collection,
suggest_author, suggest_author,
title_case, title_case,
@@ -52,10 +57,12 @@ __all__ = [
"chunk_markdown", "chunk_markdown",
"citation_prompt", "citation_prompt",
"clean_record", "clean_record",
"colophon_prompt",
"clean_underscores", "clean_underscores",
"compose_subjects", "compose_subjects",
"count_tokens", "count_tokens",
"cue_windows", "cue_windows",
"fetch_identifier",
"find_citations", "find_citations",
"fuse_hits", "fuse_hits",
"identify", "identify",
@@ -65,16 +72,19 @@ __all__ = [
"looks_like_footnote_citations", "looks_like_footnote_citations",
"looks_like_person", "looks_like_person",
"merge_citation_reply", "merge_citation_reply",
"merge_colophon",
"needs_subject", "needs_subject",
"pack_cues", "pack_cues",
"parse_identifier", "parse_identifier",
"parse_subtitles", "parse_subtitles",
"read_colophon",
"record_problems", "record_problems",
"record_severity", "record_severity",
"same_work", "same_work",
"scrub", "scrub",
"segment_cues", "segment_cues",
"series_of", "series_of",
"shelve",
"split_collection", "split_collection",
"suggest_author", "suggest_author",
"title_case", "title_case",
@@ -75,3 +75,49 @@ def valid_isbn(text: str) -> bool: ...
def isbn_from_text(text: str) -> str | None: ... def isbn_from_text(text: str) -> str | None: ...
def isbn_from_filename(name: str) -> str | None: ... def isbn_from_filename(name: str) -> str | None: ...
def same_work(ours: str, theirs: str) -> bool: ... def same_work(ours: str, theirs: str) -> bool: ...
def read_colophon(
text: str, pages: int = ..., shelf: str | None = ...
) -> dict[str, Any]:
"""What a book prints about itself in its opening pages.
`shelf` is a closed list of headings to file the printed subjects onto,
one per line, optionally `Heading = term, term`.
"""
...
def shelve(shelf: str, subjects: list[str]) -> tuple[list[str], list[str]]:
"""File subject headings onto a shelf. Returns (shelved, unshelved)."""
...
def fetch_identifier(
id: str,
kind: str = ...,
polite: str | None = ...,
timeout: int = ...,
attempts: int = ...,
) -> dict[str, Any] | None:
"""Ask the register that assigns an identifier what it knows about one.
Blocks on the network. `None` when no register answers about that kind.
"""
...
def colophon_prompt(
record: dict[str, Any], text: str, pages: int = ...
) -> str | None:
"""The question to put to a model about what the pages did not settle.
None when the pages settled everything. Nothing is sent anywhere.
"""
...
def merge_colophon(
record: dict[str, Any], reply: str
) -> tuple[dict[str, Any], int]:
"""A model's reply folded in. Returns (record, fields_settled).
Only fills what the pages left unsettled; a model never overwrites what a
book printed about itself.
"""
...
+120
View File
@@ -431,5 +431,125 @@ fn _catena(module: &Bound<'_, PyModule>) -> PyResult<()> {
module.add_function(wrap_pyfunction!(merge_citation_reply, module)?)?; module.add_function(wrap_pyfunction!(merge_citation_reply, module)?)?;
module.add_function(wrap_pyfunction!(series_of, module)?)?; module.add_function(wrap_pyfunction!(series_of, module)?)?;
module.add_function(wrap_pyfunction!(parse_identifier, module)?)?; module.add_function(wrap_pyfunction!(parse_identifier, module)?)?;
module.add_function(wrap_pyfunction!(read_colophon, module)?)?;
module.add_function(wrap_pyfunction!(shelve, module)?)?;
module.add_function(wrap_pyfunction!(fetch_identifier, module)?)?;
module.add_function(wrap_pyfunction!(colophon_prompt, module)?)?;
module.add_function(wrap_pyfunction!(merge_colophon, module)?)?;
Ok(()) Ok(())
} }
// ───────────────────── what a book says about itself ──────────────────────
/// Read what a book prints about itself in its opening pages.
///
/// `shelf` is the closed list of headings to file the printed subjects onto,
/// in the format `colophon --shelf` takes: one heading per line, optionally
/// `Heading = term, term`. Without it the subjects come back as the book
/// printed them and nothing is filed.
#[pyfunction]
#[pyo3(signature = (text, pages = colophon::PAGES, shelf = None))]
fn read_colophon<'py>(
py: Python<'py>,
text: String,
pages: usize,
shelf: Option<String>,
) -> PyResult<Bound<'py, PyAny>> {
if pages == 0 {
return Err(PyValueError::new_err("pages = 0 would read nothing"));
}
// Reading a book's front matter is several milliseconds of pure
// computation on a few hundred kilobytes, and holding the interpreter for
// it makes `asyncio.to_thread` a lie: a caller reading a library several
// books at a time got no overlap at all until this let go.
let printed = py.detach(move || {
let shelf = shelf.as_deref().map(colophon::shelf::Shelf::parse);
colophon::read_onto(&text, pages, shelf.as_ref())
});
to_py(py, &printed)
}
/// File subject headings onto a shelf without reading a book.
///
/// For the headings a catalogue already holds: the same matching the printed
/// ones get, so a record's existing subjects and its book's agree about what
/// counts as a heading. Returns `(shelved, unshelved)`.
#[pyfunction]
fn shelve(shelf: &str, subjects: Vec<String>) -> (Vec<String>, Vec<String>) {
let filed = colophon::shelf::Shelf::parse(shelf).file(&subjects);
(filed.on, filed.unfiled)
}
// ──────────────────────────── the registers ───────────────────────────────
/// Ask the body that assigns an identifier what it knows about one.
///
/// Crossref for a DOI, the arXiv for a preprint, OpenLibrary and Google Books
/// for an ISBN, asked in turn until one gives a complete answer. This is the
/// only function here that touches the network, and it blocks: call it from a
/// thread, or with `asyncio.to_thread`.
///
/// Returns the record, or `None` when no register here answers about that kind
/// of identifier. Raises when every register that should answer was
/// unreachable, because that is a different thing from the book not existing.
#[pyfunction]
#[pyo3(signature = (id, kind = "isbn", polite = None, timeout = 20, attempts = 3))]
fn fetch_identifier<'py>(
py: Python<'py>,
id: &str,
kind: &str,
polite: Option<String>,
timeout: u64,
attempts: u32,
) -> PyResult<Option<Bound<'py, PyAny>>> {
let ask = bibfetch::ask::Ask {
polite,
timeout: std::time::Duration::from_secs(timeout.max(1)),
attempts: attempts.max(1),
};
let agent = ask.agent();
// The GIL is not needed while waiting on a socket, and holding it there
// would stop every other thread in the interpreter for the whole request.
let found = py
.detach(|| bibfetch::ask::lookup(&agent, id, kind, ask.attempts))
.map_err(|e| PyValueError::new_err(e.to_string()))?;
match found {
Some(record) => Ok(Some(to_py(py, &record)?)),
None => Ok(None),
}
}
/// The question to put to a model about the fields a book's pages did not
/// settle, or `None` when they settled everything.
///
/// Nothing is sent anywhere: this composes the text and hands it back, so the
/// caller decides which model, on whose hardware, at what cost. The reply goes
/// to `merge_colophon`.
#[pyfunction]
#[pyo3(signature = (record, text, pages = colophon::PAGES))]
fn colophon_prompt(
record: &Bound<'_, PyAny>,
text: &str,
pages: usize,
) -> PyResult<Option<String>> {
let printed: colophon::Printed = from_py(record)?;
Ok(colophon::prompt::compose(&printed, text, pages))
}
/// A model's reply folded into the record the pages gave.
///
/// Only the fields the pages left unsettled are filled: a model cannot
/// overwrite something a book printed about itself, which is the whole reason
/// the pages are read first. Returns the completed record and how many fields
/// the reply actually settled.
#[pyfunction]
fn merge_colophon<'py>(
py: Python<'py>,
record: &Bound<'py, PyAny>,
reply: &str,
) -> PyResult<(Bound<'py, PyAny>, usize)> {
let mut printed: colophon::Printed = from_py(record)?;
let filled = colophon::prompt::merge(&mut printed, reply)
.map_err(|e| PyValueError::new_err(e.to_string()))?;
Ok((to_py(py, &printed)?, filled))
}
+21
View File
@@ -0,0 +1,21 @@
[package]
name = "colophon"
description = "The record a book prints in its own front matter."
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
repository.workspace = true
[dependencies]
catena-cli.workspace = true
clap.workspace = true
corpus-core.workspace = true
bibclean.workspace = true
regex.workspace = true
schemars.workspace = true
serde.workspace = true
serde_json.workspace = true
[lints]
workspace = true
+151
View File
@@ -0,0 +1,151 @@
# colophon
The record a book prints in its own front matter.
Part of [catena](../../README.md).
## What it does
A book states what it is twice before the text starts. The title page carries
the title as the publisher set it and the name of whoever wrote it. The
copyright page carries the imprint, the year, the ISBN, and — in most
English-language books printed since the 1970s — the cataloguing block, which
is a catalogue record a librarian made from the manuscript, subject headings
and all.
So this reads the pages rather than the file. A PDF whose embedded title is
`Untitled` and whose filename says `Oxford` opens on *The First World War: A
Very Short Introduction*, by Michael Howard, and that is what comes back.
Every field names the page it was read off. Nothing is inferred: a scan that
begins at the preface printed no title page, and the record of that is an
empty one with every field listed in `unsettled`.
## A shelf of your own
A cataloguing block states subjects the way a national library does:
```text
Political science – Early works to 1800
English literature—Middle English, 1100–1500—History and criticism
```
Nobody browses by those. `--shelf` takes the closed list you actually file by
and returns the headings from it that the book's own subjects hit:
```sh
colophon --shelf shelf.txt book.md
```
```text
# one heading per line; `= terms` gives the words that match it,
# and a heading with no terms matches its own name.
Byzantine History = byzantium, byzantine, constantinople
Political Science = political science, politics
Ethics
```
The longest term wins, so `Ancient Philosophy` beats `Philosophy` and a period
of thought does not collapse into its parent. Matching is on whole words after
punctuation is folded away, because a block separates with em dashes, en
dashes, full stops and hyphens, and OCR turns any of those into any other.
Headings that match nothing come back under `unshelved`, kept as printed. A
subject quietly dropped reads exactly like a book that printed none.
## When you would use it
- **Cataloguing scans and secondhand files**, where the embedded metadata is
whatever the last tool to touch the file happened to write.
- **Checking a catalogue against the books themselves.** A record that
disagrees with the book's own title page is a record worth looking at.
- **Recovering an ISBN** that only exists in print, which turns a book with
no identifier into one [`bibfetch`](../bibfetch/README.md) can look up.
- **Getting subject headings** that nobody had to invent, from the
cataloguing block of the books that print one.
## Install
```sh
cargo install --git https://git.pumpin.online/funkpower/catena colophon
```
## Example
```
$ colophon first-world-war.md
{"v":1,"path":"first-world-war.md","title":"The First World War","subtitle":"A Very Short Introduction","authors":["Michael Howard"],"editors":[],"isbns":["9780192803535"],"publisher":"Oxford University Press","place":"Oxford","year":2002,"subjects":[],"read_from":{"authors":"title_page","isbns":"copyright_page","publisher":"copyright_page","subtitle":"title_page","title":"title_page","year":"copyright_page"},"unsettled":["subjects"],"pages_read":16}
```
A library at once, and the books that say nothing about themselves:
```sh
colophon library/*.md | jq -r 'select(.title == null) | .path'
```
An ISBN that only exists in print, looked up:
```sh
colophon book.md | jq -r '.isbns[]' | idnorm | bibfetch --polite you@example.org
```
## What it will not read
Two pages in a book's front matter look like a title page and are not, and
both of them are how a catalogue ends up naming the wrong book. They are
found and refused.
The **series advertisement** lists other titles in the same series in the
same shape a title page uses — a work in capitals, a person beside it —
sometimes for four pages, with only the first saying what it is. The
**uniform title** in a translation's cataloguing block is the original
title, printed in brackets above the real one as a filing heading.
## Asking a model about the rest
Roughly a third of books print nothing this can read: an extraction that
starts at chapter one has no front matter in it at all. `--prompt` writes a
question about exactly the fields left `unsettled`, and `--merge` folds the
answer back in.
```sh
colophon --prompt book.md | ollama run qwen3:8b > reply.json
colophon --merge reply.json book.md
```
Nothing is sent from here: the question goes to stdout, and the model is
yours. What a model answers is marked `model` in `read_from`, so a record
always says which of its fields were read off a page and which were
answered.
The pages the question shows have had the advertising taken out of them
first. Handing a model a series list is how a book becomes catalogued as
whichever of two hundred other titles the model's eye fell on.
## Where this stops
It reports what the book printed, not what is true. `THE HUMAN CONDITION` is
reported in the capitals it was set in, and straightening that is
[`bibclean`](../bibclean/README.md)'s job. What a file's own metadata claims
is [`bookmeta`](../bookmeta/README.md)'s. What a register says about the
ISBN is [`bibfetch`](../bibfetch/README.md)'s. Which series a book belongs to
is [`seriesof`](../seriesof/README.md)'s.
## Options
| Option | Effect |
|---|---|
| `--pages <COUNT>` | How many pages in from the front to read. Default `16` |
| `--prompt` | Write the question to put to a model about what the pages left unsettled |
| `--merge <FILE>` | Fill in from a model's reply and write the completed record |
| `--quiet` | Say nothing; the exit code is the answer |
| `--man` | Write this page as a manual page, in roff |
## Exit codes
| Code | Meaning |
|---|---|
| `0` | The book printed something about itself |
| `1` | It printed nothing this could read |
| `65` | The input was not readable as text |
| `74` | The file could not be read |
+274
View File
@@ -0,0 +1,274 @@
//! The cataloguing-in-publication block, which is a catalogue record the
//! publisher printed inside the book.
//!
//! Since the 1970s most English-language publishers have printed the record
//! the Library of Congress made from the manuscript on the copyright page,
//! and since 2013 they have printed it in labelled fields. Either way it
//! names the author as a library files him, the title with its subtitle, the
//! ISBN, and the subject headings the book was classified under — which is
//! the one place in a book where its subjects are stated rather than
//! inferred.
//!
//! It is a record, so it is read as one. Nothing here guesses.
use std::sync::LazyLock;
use regex::Regex;
/// What the block said.
#[derive(Debug, Default, Clone, PartialEq)]
pub struct Cip {
/// Title, without its subtitle.
pub title: Option<String>,
/// Subtitle, where the title statement had one.
pub subtitle: Option<String>,
/// Authors, in reading order rather than filing order.
pub authors: Vec<String>,
/// Editors, where the work is an edited one.
pub editors: Vec<String>,
/// ISBNs printed inside the block.
pub isbns: Vec<String>,
/// Subject headings the book was classified under.
pub subjects: Vec<String>,
}
impl Cip {
fn is_empty(&self) -> bool {
self.title.is_none() && self.authors.is_empty() && self.subjects.is_empty()
}
}
static HEAD: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(r"(?i)catalog(?:u)?ing[-\s]in[-\s]publication(?:\s+data)?").expect("a valid pattern")
});
/// `B105.C477D46 1991` / `126—dc20` — the call number, which ends the block.
static CALL_NUMBER: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(r"(?i)^([A-Z]{1,3}\d{1,4}(\.\w+)*\s+\d{4}|\d{3}(\.\d+)?\s*[—–-]{1,2}\s*dc\d{2})")
.expect("a valid pattern")
});
/// A name as a library files it: `Howard, Michael, 1922- author.`
static FILED_NAME: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(
r"^(?P<last>[A-Z][\p{L}'’\-]+(?:\s+[A-Z][\p{L}'’\-]+)?),\s+(?P<first>[A-Z][\p{L}'’\-\.]*(?:\s+[A-Z][\p{L}'’\-\.]*){0,3})\
(?:,\s*(?:\d{4}\s*[-–]\s*\d{0,4}|b\.\s*\d{4}|d\.\s*\d{4}))?\
(?:,?\s*(?:author|editor|translator|compiler)s?\.?)?\.?$",
)
.expect("a valid pattern")
});
/// `[Sur la traduction. English]` — the uniform title of a translation,
/// printed above the title it was translated into.
static UNIFORM_TITLE: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"^\\?\[.*\\?\]\.?$").expect("a valid pattern"));
static LABEL: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(r"(?i)^(names|title|other titles|description|identifiers|subjects|classification|uniform title)\s*:\s*(.*)$")
.expect("a valid pattern")
});
/// `1. Consciousness. 2. Philosophy of mind. I. Title.`
static NUMBERED_SUBJECTS: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"^\s*1\.\s+\S").expect("a valid pattern"));
static SUBJECT_SPLIT: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"\s*\b\d{1,2}\.\s+").expect("a valid pattern"));
/// The added entries a subject list ends with: `I. Title. II. Series.`
static ADDED_ENTRY: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"\s+[IVX]+\.\s+.*$").expect("a valid pattern"));
static ISBN_LINE: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(r"(?i)isbn[:\s]*([0-9][0-9\s\-–]{8,20}[0-9Xx])").expect("a valid pattern")
});
/// The responsibility statement's separator, and what OCR makes of it.
///
/// A slash between the title and who wrote it is a solidus in a serif face,
/// and page scanners read it as a capital I or a lowercase l about as often
/// as they read it correctly.
static RESPONSIBILITY: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"\s+[/Il\\|]\s+").expect("a valid pattern"));
static EDITION_TAIL: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(r"(?i)\s*[.,]?\s*[—–-]{1,2}\s*(\d+(st|nd|rd|th)|first|second|third|new|rev\.?|revised)\b.*$")
.expect("a valid pattern")
});
fn tidy(s: &str) -> String {
s.trim()
.trim_matches(|c: char| c == '\\' || c == '"')
.trim()
.trim_end_matches(['.', ',', ';', ':', '/'])
.trim()
.to_string()
}
/// Split a statement of responsibility into who wrote and who edited.
fn people(statement: &str) -> (Vec<String>, Vec<String>) {
let mut authors = Vec::new();
let mut editors = Vec::new();
for part in statement.split(';') {
let part = part.trim();
let lower = part.to_lowercase();
let (target, rest) = if let Some(rest) = lower.strip_prefix("edited by ") {
(&mut editors, &part[part.len() - rest.len()..])
} else if lower.starts_with("translated by ")
|| lower.starts_with("with ")
|| lower.starts_with("foreword by ")
|| lower.starts_with("introduction by ")
{
continue;
} else {
(&mut authors, part)
};
for name in rest.split(" and ").flat_map(|n| n.split(',')) {
let name = tidy(name);
if name.len() >= 3 && name.split_whitespace().count() <= 5 {
target.push(name);
}
}
}
(authors, editors)
}
fn filed_to_reading(line: &str) -> Option<String> {
let caps = FILED_NAME.captures(line)?;
Some(format!(
"{} {}",
tidy(caps.name("first")?.as_str()),
tidy(caps.name("last")?.as_str())
))
}
fn split_title(statement: &str) -> (Option<String>, Option<String>, Vec<String>, Vec<String>) {
let mut pieces = RESPONSIBILITY.splitn(statement, 2);
let title_part = pieces.next().unwrap_or_default();
let (authors, editors) = pieces.next().map(people).unwrap_or_default();
let title_part = EDITION_TAIL.replace(title_part, "");
let mut halves = title_part.splitn(2, " : ");
let title = tidy(halves.next().unwrap_or_default());
let subtitle = halves.next().map(tidy).filter(|s| !s.is_empty());
(
Some(title).filter(|t| t.len() >= 2),
subtitle,
authors,
editors,
)
}
fn subjects_from(line: &str) -> Vec<String> {
let trimmed = ADDED_ENTRY.replace(line, "");
SUBJECT_SPLIT
.split(&trimmed)
.map(tidy)
.filter(|s| s.len() >= 3 && s.split_whitespace().count() <= 12)
.collect()
}
fn isbns_from(line: &str) -> Vec<String> {
ISBN_LINE
.captures_iter(line)
.filter_map(|c| {
let digits = bibclean::isbn::digits(&c[1]);
bibclean::isbn::valid(&digits).then_some(digits)
})
.collect()
}
/// Read the CIP block out of a book's front matter, if it printed one.
pub fn read(front: &str) -> Option<Cip> {
let start = HEAD.find(front)?.end();
let mut block: Vec<&str> = Vec::new();
for line in front[start..].lines().skip(1) {
let line = line.trim();
if line.is_empty() {
if block.len() > 2 {
break;
}
continue;
}
if CALL_NUMBER.is_match(line) {
break;
}
block.push(line);
if block.len() >= 24 {
break;
}
}
if block.is_empty() {
return None;
}
let mut cip = Cip::default();
let labelled = block.iter().any(|l| {
LABEL
.captures(l)
.is_some_and(|c| matches!(c[1].to_lowercase().as_str(), "names" | "title" | "subjects"))
});
if labelled {
for line in &block {
let Some(caps) = LABEL.captures(line) else {
continue;
};
let value = caps[2].trim();
match caps[1].to_lowercase().as_str() {
"names" => {
for name in value.split('|') {
if let Some(read) = filed_to_reading(tidy(name).as_str()) {
cip.authors.push(read);
}
}
}
"title" => {
let (title, subtitle, authors, editors) = split_title(value);
cip.title = title;
cip.subtitle = subtitle;
if cip.authors.is_empty() {
cip.authors = authors;
}
cip.editors = editors;
}
"identifiers" => cip.isbns.extend(isbns_from(value)),
"subjects" => {
let value = value.trim_start_matches("LCSH:").trim();
for subject in value.split('|') {
let subject = tidy(subject);
if subject.len() >= 3 {
cip.subjects.push(subject);
}
}
}
_ => {}
}
}
} else {
let mut seen_title = false;
for line in &block {
if UNIFORM_TITLE.is_match(line) {
continue;
}
if !seen_title {
if let Some(name) = filed_to_reading(line) {
cip.authors.push(name);
continue;
}
if ISBN_LINE.is_match(line) || line.to_lowercase().starts_with("isbn") {
cip.isbns.extend(isbns_from(line));
continue;
}
if RESPONSIBILITY.is_match(line) || line.contains(" : ") {
let (title, subtitle, authors, editors) = split_title(line);
cip.title = title;
cip.subtitle = subtitle;
if cip.authors.is_empty() {
cip.authors = authors;
}
cip.editors = editors;
seen_title = true;
continue;
}
}
if NUMBERED_SUBJECTS.is_match(line) {
cip.subjects = subjects_from(line);
}
cip.isbns.extend(isbns_from(line));
}
}
cip.isbns.sort();
cip.isbns.dedup();
if cip.is_empty() { None } else { Some(cip) }
}
+113
View File
@@ -0,0 +1,113 @@
//! The copyright page: who published it, where, when, and under what ISBN.
//!
//! Everything on this page is formula. The same dozen sentences appear in
//! every book printed in English, which is what makes them worth matching
//! exactly rather than interpreting.
use std::sync::LazyLock;
use regex::Regex;
/// What the copyright page stated.
#[derive(Debug, Default, Clone, PartialEq)]
pub struct Imprint {
/// The press.
pub publisher: Option<String>,
/// Where it publishes from, where the page named one place.
pub place: Option<String>,
/// The year of this edition, preferring first publication.
pub year: Option<u16>,
/// Every checksum-valid ISBN printed on the page, in the order printed.
pub isbns: Vec<String>,
}
static PUBLISHED_BY: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(r"(?im)^\s*(?:first\s+)?published(?:\s+(?:in\s+)?(?:1[5-9]|20)\d{2})?\s+by\s+(?P<who>[^,.\n]{3,60})")
.expect("a valid pattern")
});
static PRESS_LINE: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(r"(?im)^\s*(?P<who>[A-Z][^\n]{2,48}?(?:University Press|Press|Publishers|Publishing|Books|Verlag))\s*[,.]?\s*$")
.expect("a valid pattern")
});
/// A sentence that merely mentions a press is not the press.
///
/// `Oxford is a registered trade mark of Oxford University Press` ends in a
/// publisher's name and is a legal notice.
static NOT_AN_IMPRINT: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(r"(?i)( is | are | was |registered|trade mark|trademark|imprint of|division of|©|\(c\)|@|;|\d{4})")
.expect("a valid pattern")
});
static FIRST_PUBLISHED: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(r"(?i)first published[^.\n]{0,40}?((?:1[5-9]|20)\d{2})").expect("a valid pattern")
});
static COPYRIGHT_YEAR: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(r"(?i)(?:©|\(c\)|copyright)\s*(?:by\s+)?((?:1[5-9]|20)\d{2})")
.expect("a valid pattern")
});
static ISBN: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(r"(?i)isbn(?:[-\s]?1[03])?[:\s]*([0-9][0-9\s\-–—]{8,24}[0-9Xx])")
.expect("a valid pattern")
});
/// `Oxford : Oxford University Press, 2007` — the labelled CIP's description,
/// which states all three at once.
static DESCRIPTION: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(r"(?im)^\s*Description\s*:\s*(?P<place>[^:|\n]{2,40}?)\s*:\s*(?P<who>[^,|\n]{3,60}?)\s*,\s*(?P<year>(?:1[5-9]|20)\d{2})")
.expect("a valid pattern")
});
fn tidy(s: &str) -> String {
s.trim()
.trim_end_matches([',', '.', ';'])
.trim()
.to_string()
}
/// Every checksum-valid ISBN announced in a piece of text.
pub fn isbns(text: &str) -> Vec<String> {
let mut out: Vec<String> = Vec::new();
for caps in ISBN.captures_iter(text) {
let digits = bibclean::isbn::digits(&caps[1]);
if matches!(digits.len(), 10 | 13)
&& bibclean::isbn::valid(&digits)
&& !out.contains(&digits)
{
out.push(digits);
}
}
out
}
/// Read the copyright page.
pub fn read(text: &str) -> Imprint {
let mut out = Imprint {
isbns: isbns(text),
..Imprint::default()
};
if let Some(caps) = DESCRIPTION.captures(text) {
out.place = Some(tidy(&caps["place"]));
out.publisher = Some(tidy(&caps["who"]));
out.year = caps["year"].parse().ok();
return out;
}
out.publisher = PUBLISHED_BY
.captures_iter(text)
.chain(PRESS_LINE.captures_iter(text))
.map(|caps| tidy(&caps["who"]))
.find(|who| !NOT_AN_IMPRINT.is_match(who) && who.split_whitespace().count() <= 8);
out.year = FIRST_PUBLISHED
.captures(text)
.or_else(|| COPYRIGHT_YEAR.captures(text))
.and_then(|caps| caps[1].parse().ok());
out.place = text
.lines()
.map(str::trim)
.find(|line| crate::title::PLACE.is_match(line) && line.split_whitespace().count() <= 4)
.and_then(|line| {
crate::title::PLACE
.captures(line)
.map(|caps| tidy(&caps["city"]))
});
out
}
+362
View File
@@ -0,0 +1,362 @@
//! The record a book prints in its own front matter.
//!
//! A book states its own title twice before the text starts, and states it
//! properly: on the title page, set by the publisher, and again in the
//! cataloguing block on the copyright page, where a librarian has already
//! decided what the title is, who the author is, and what the book is about.
//! A file's embedded metadata may say `Untitled` and a filename may say
//! `Oxford`, but the printed page says *The First World War: A Very Short
//! Introduction*, by Michael Howard.
//!
//! So this reads the pages, not the file. What comes back is what the book
//! printed, with the page it was printed on named for every field, and
//! nothing at all where the book printed nothing — a scan that starts at the
//! preface has no title page, and the honest record of that is an empty one.
//!
//! Two traps are worth naming, because both of them are how a reader that
//! does not know better ends up cataloguing the wrong book:
//!
//! - **The series advertisement.** A paperback's front matter lists two
//! hundred other books in the same series, in the same shape a title page
//! uses: a work in capitals, a person beside it. A title taken off that
//! page belongs to somebody else. Those pages are found and refused,
//! including the ones that continue the list without repeating its heading.
//! - **The uniform title.** A translation's cataloguing block prints the
//! original title in brackets above the real one. It is a heading, not the
//! title of the book in your hand.
//!
//! What this does not do is decide whether what the book printed is any
//! good. `THE HUMAN CONDITION` is reported in the capitals it was set in;
//! straightening that is [`bibclean`]'s job. Nor does it look anything up:
//! an ISBN read off the copyright page is a key, and [`bibfetch`] is what
//! turns a key into a catalogue record.
//!
//! [`bibclean`]: https://git.pumpin.online/funkpower/catena
//! [`bibfetch`]: https://git.pumpin.online/funkpower/catena
pub mod cip;
pub mod imprint;
pub mod page;
pub mod prompt;
pub mod shelf;
pub mod title;
use std::collections::BTreeMap;
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
/// How many pages in from the front to read.
///
/// Front matter that has not finished by here is not front matter. Reading
/// further costs nothing but finds chapter headings, which look enough like
/// titles to be believed.
pub const PAGES: usize = 16;
/// Which page a field was read off.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "snake_case")]
pub enum Source {
/// The cataloguing-in-publication block: a catalogue record, printed.
Cip,
/// The title page, as the publisher set it.
TitlePage,
/// The copyright page, outside the cataloguing block.
CopyrightPage,
/// A model, answering about the pages this could not read. Never
/// written by a read: only [`prompt::merge`] puts it on a record.
Model,
}
impl Source {
/// What it is called.
pub fn name(self) -> &'static str {
match self {
Source::Cip => "cip",
Source::TitlePage => "title_page",
Source::CopyrightPage => "copyright_page",
Source::Model => "model",
}
}
}
/// What one book printed about itself.
///
/// Every field is written on every record, empty where the book said
/// nothing, and every field that was read names the page it came from in
/// `read_from`. The fields a catalogue needs and this could not find are
/// listed in `unsettled`, so an absence is legible rather than inferred from
/// a missing key.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
pub struct Printed {
/// Record version.
#[serde(default = "version")]
pub v: u32,
/// The file this was read from, where it was read from one.
pub path: Option<String>,
/// The title, without its subtitle.
pub title: Option<String>,
/// The subtitle, where the page set one.
pub subtitle: Option<String>,
/// Whoever the book says wrote it, in reading order.
pub authors: Vec<String>,
/// Whoever the book says edited it.
pub editors: Vec<String>,
/// Checksum-valid ISBNs printed in the front matter, as printed.
pub isbns: Vec<String>,
/// The press.
pub publisher: Option<String>,
/// Where it publishes from.
pub place: Option<String>,
/// The year of publication, preferring first publication.
pub year: Option<u16>,
/// The subject headings the cataloguing block filed it under, as printed.
pub subjects: Vec<String>,
/// Those headings filed onto a caller's own shelf, when one was given.
///
/// Empty without `--shelf`, and empty too when the book's subjects match
/// nothing on it — which is an answer, and the reason `unshelved` exists.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub shelved: Vec<String>,
/// Printed headings that matched nothing on the shelf, kept verbatim so a
/// subject is never silently dropped.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub unshelved: Vec<String>,
/// Which page each field was read off.
pub read_from: BTreeMap<String, Source>,
/// The fields a catalogue wants that this book did not print.
pub unsettled: Vec<String>,
/// How many pages were read.
pub pages_read: usize,
}
fn version() -> u32 {
corpus_core::V
}
impl corpus_core::Record for Printed {
fn version(&self) -> u32 {
self.v
}
}
impl Printed {
/// Did the book print anything at all?
pub fn is_empty(&self) -> bool {
self.title.is_none()
&& self.authors.is_empty()
&& self.isbns.is_empty()
&& self.subjects.is_empty()
}
fn note(&mut self, field: &str, source: Source) {
self.read_from.insert(field.to_string(), source);
}
}
/// Which page holds the title, given where the copyright page is.
///
/// The title page is the last page before the copyright page that is a page
/// of set lines rather than a list or a contents — publishers have put it
/// there for two centuries. Where there is no copyright page to count back
/// from, the first such page before the text starts is all there is to go on.
fn title_page(pages: &[page::Page], copyright: Option<usize>) -> Option<usize> {
let usable = |i: usize| pages.get(i).is_some_and(|p| p.kind == page::Kind::Title);
if let Some(c) = copyright {
if let Some(found) = (0..c).rev().find(|i| usable(*i)) {
return Some(found);
}
}
pages
.iter()
.position(|p| p.kind == page::Kind::Text)
.map_or_else(
|| (0..pages.len()).find(|i| usable(*i)),
|end| (0..end).find(|i| usable(*i)),
)
}
/// The longest printing of the same title anywhere in the front matter.
///
/// A title page breaks its title across lines and sets it in capitals; the
/// half title two leaves earlier prints the whole of it on one line, with
/// its subtitle, in the case the publisher meant. They are the same title,
/// and the unbroken one is the one to keep.
fn longest_printing<'a>(pages: &'a [page::Page], title: &str) -> Option<&'a str> {
let wanted = squash(title);
if wanted.len() < 4 {
return None;
}
pages
.iter()
.filter(|p| matches!(p.kind, page::Kind::Title | page::Kind::Copyright))
.flat_map(|p| p.lines.iter())
.filter(|line| {
let candidate = squash(line);
candidate.starts_with(&wanted) && candidate.len() > wanted.len()
})
.min_by_key(|line| line.len())
.map(String::as_str)
}
/// A string reduced to the letters and digits in it, for comparing two
/// printings of one title across a change of case and punctuation.
fn squash(s: &str) -> String {
s.chars()
.filter(|c| c.is_alphanumeric())
.flat_map(char::to_lowercase)
.collect()
}
/// Read a book's front matter, and file its subjects onto a shelf.
///
/// `budget` is how many pages in to read; [`PAGES`] is the sensible default.
/// Passing a [`Shelf`](shelf::Shelf) fills `shelved` and `unshelved`; without
/// one, `subjects` is still read and both stay empty.
pub fn read_onto(text: &str, budget: usize, shelf: Option<&shelf::Shelf>) -> Printed {
let mut out = read(text, budget);
if let Some(shelf) = shelf.filter(|s| !s.is_empty()) {
let filed = shelf.file(&out.subjects);
if !filed.on.is_empty() {
out.note("shelved", Source::Cip);
}
out.shelved = filed.on;
out.unshelved = filed.unfiled;
}
out
}
/// Read a book's front matter.
///
/// `budget` is how many pages in to read; [`PAGES`] is the sensible default.
pub fn read(text: &str, budget: usize) -> Printed {
let pages = page::split(text, budget);
let mut out = Printed {
v: version(),
path: None,
title: None,
subtitle: None,
authors: Vec::new(),
editors: Vec::new(),
isbns: Vec::new(),
publisher: None,
place: None,
year: None,
subjects: Vec::new(),
shelved: Vec::new(),
unshelved: Vec::new(),
read_from: BTreeMap::new(),
unsettled: Vec::new(),
pages_read: pages.len(),
};
let front: String = pages
.iter()
.map(|p| p.text.as_str())
.collect::<Vec<_>>()
.join("\n");
let copyright = pages.iter().position(|p| p.kind == page::Kind::Copyright);
// The cataloguing block is a record somebody else already made, so it
// outranks the title page wherever the two disagree.
if let Some(found) = cip::read(&front) {
if let Some(t) = found.title {
out.title = Some(t);
out.note("title", Source::Cip);
}
if let Some(s) = found.subtitle {
out.subtitle = Some(s);
out.note("subtitle", Source::Cip);
}
if !found.authors.is_empty() {
out.authors = found.authors;
out.note("authors", Source::Cip);
}
if !found.editors.is_empty() {
out.editors = found.editors;
out.note("editors", Source::Cip);
}
if !found.subjects.is_empty() {
out.subjects = found.subjects;
out.note("subjects", Source::Cip);
}
out.isbns = found.isbns;
if !out.isbns.is_empty() {
out.note("isbns", Source::Cip);
}
}
if let Some(page) = title_page(&pages, copyright).and_then(|i| title::read(&pages[i].lines)) {
if out.title.is_none() {
out.title = match page
.title
.as_deref()
.and_then(|t| longest_printing(&pages, t))
{
Some(longer) => {
let (title, subtitle) = longer
.split_once(" : ")
.or_else(|| longer.split_once(": "))
.map_or((longer.to_string(), None), |(head, tail)| {
(head.trim().to_string(), Some(tail.trim().to_string()))
});
out.subtitle = subtitle;
Some(title)
}
None => page.title,
};
if out.title.is_some() {
out.note("title", Source::TitlePage);
}
if out.subtitle.is_none() && page.subtitle.is_some() {
out.subtitle = page.subtitle;
out.note("subtitle", Source::TitlePage);
}
}
if out.authors.is_empty() && !page.authors.is_empty() {
out.authors = page.authors;
out.note("authors", Source::TitlePage);
}
if out.editors.is_empty() && !page.editors.is_empty() {
out.editors = page.editors;
out.note("editors", Source::TitlePage);
}
}
let imprint_text = copyright.map_or(front.as_str(), |i| pages[i].text.as_str());
let stated = imprint::read(imprint_text);
for isbn in stated.isbns {
if !out.isbns.contains(&isbn) {
out.isbns.push(isbn);
out.read_from
.entry("isbns".into())
.or_insert(Source::CopyrightPage);
}
}
if let Some(publisher) = stated.publisher {
out.publisher = Some(publisher);
out.note("publisher", Source::CopyrightPage);
}
if let Some(place) = stated.place {
out.place = Some(place);
out.note("place", Source::CopyrightPage);
}
if let Some(year) = stated.year {
out.year = Some(year);
out.note("year", Source::CopyrightPage);
}
for (field, missing) in [
("title", out.title.is_none()),
("authors", out.authors.is_empty() && out.editors.is_empty()),
("isbns", out.isbns.is_empty()),
("publisher", out.publisher.is_none()),
("year", out.year.is_none()),
("subjects", out.subjects.is_empty()),
] {
if missing {
out.unsettled.push(field.to_string());
}
}
out
}
+167
View File
@@ -0,0 +1,167 @@
//! The record a book prints in its own front matter.
use std::path::PathBuf;
use std::process::ExitCode;
use clap::Parser;
use corpus_core::exit;
/// Read what a book prints about itself in its opening pages.
///
/// The input is the book as markdown — the whole of it, or as much of the
/// front as you care to pass. The title page and the cataloguing block on
/// the copyright page are read, and what they state comes back as one
/// record, with the page each field was read off named in `read_from`.
///
/// Nothing is inferred. A scan that starts at chapter one prints no title
/// page, and what comes back then is a record with nothing in it and every
/// field named in `unsettled` — which is the answer, and a usable one.
#[derive(Parser)]
#[command(
version,
about,
after_help = "\
Exit codes: 0 the book printed something about itself, 1 it printed nothing
this could read, 65 the input was not readable as text, 74 the file could not
be read.
Example:
colophon book.md | jq -r '[.title, (.authors|join(\", \"))] | @tsv'
colophon *.md | jq -r 'select(.unsettled | index(\"title\")) | .path'
colophon book.md | jq -r '.isbns[]' | idnorm | bibfetch"
)]
struct Args {
/// The books to read. Standard input when none are given.
paths: Vec<PathBuf>,
/// Say nothing; the exit code is the answer.
#[arg(long, short)]
quiet: bool,
/// Write the question to put to a model about the fields these pages
/// did not settle, instead of the record. Nothing is sent: it goes to
/// stdout. One book at a time, because the pages differ.
#[arg(long)]
prompt: bool,
/// Fill in what the pages did not settle from a model's reply, written
/// earlier by `--prompt`, and write the completed record.
#[arg(long, value_name = "FILE")]
merge: Option<PathBuf>,
/// How many pages in from the front to read.
#[arg(long, value_name = "COUNT", default_value_t = colophon::PAGES)]
pages: usize,
/// A closed list of headings to file the book's printed subjects under.
///
/// One heading per line, optionally `Heading = term, term` to give the
/// words that should match it; a heading with no terms matches its own
/// name. A cataloguing block states subjects in Library of Congress form,
/// which is no use as a shelf, so this says what the shelf is. What
/// matches nothing comes back under `unshelved` rather than being dropped.
#[arg(long, value_name = "FILE")]
shelf: Option<PathBuf>,
}
fn main() -> ExitCode {
let args: Args = match catena_cli::parse() {
Ok(args) => args,
Err(code) => return code,
};
if args.pages == 0 {
eprintln!("colophon: --pages 0 would read nothing");
return ExitCode::from(exit::USAGE);
}
if args.prompt && args.merge.is_some() {
eprintln!("colophon: --prompt composes a question and --merge answers one");
return ExitCode::from(exit::USAGE);
}
if (args.prompt || args.merge.is_some()) && args.paths.len() > 1 {
eprintln!("colophon: one book at a time for --prompt and --merge");
return ExitCode::from(exit::USAGE);
}
// Read the shelf before any book, so a bad path fails at once rather than
// after the whole library has been read with no shelf applied.
let shelf = match args.shelf.as_deref() {
None => None,
Some(path) => match std::fs::read_to_string(path) {
Ok(text) => {
let s = colophon::shelf::Shelf::parse(&text);
if s.is_empty() {
eprintln!("colophon: {} lists no headings", path.display());
return ExitCode::from(exit::USAGE);
}
Some(s)
}
Err(e) => {
eprintln!("colophon: {}: {e}", path.display());
return ExitCode::from(exit::USAGE);
}
},
};
let inputs: Vec<Option<PathBuf>> = if args.paths.is_empty() {
vec![None]
} else {
args.paths.iter().cloned().map(Some).collect()
};
let mut records = Vec::with_capacity(inputs.len());
let mut last_text = String::new();
for path in inputs {
let text = match catena_cli::read_text(path.as_deref()) {
Ok(text) => text,
Err((code, why)) => {
eprintln!("colophon: {why}");
return ExitCode::from(code);
}
};
let mut record = colophon::read_onto(&text, args.pages, shelf.as_ref());
record.path = path.as_deref().map(|p| p.display().to_string());
records.push(record);
last_text = text;
}
if args.prompt {
let record = &records[0];
return match colophon::prompt::compose(record, &last_text, args.pages) {
// Nothing left to ask is not a failure, and a question nobody
// needs would waste a model's time.
None => ExitCode::from(exit::NO),
Some(question) => catena_cli::text("colophon", &question),
};
}
if let Some(path) = args.merge.as_deref() {
let reply = match std::fs::read_to_string(path) {
Ok(reply) => reply,
Err(e) => {
eprintln!("colophon: {}: {e}", path.display());
return ExitCode::from(exit::IO);
}
};
match colophon::prompt::merge(&mut records[0], &reply) {
Err(why) => {
eprintln!("colophon: {why}");
return ExitCode::from(exit::DATA);
}
Ok(0) => eprintln!("colophon: the reply filled nothing in"),
Ok(filled) => eprintln!("colophon: {filled} field(s) filled in from the reply"),
}
}
let anything = records.iter().any(|r| !r.is_empty());
if args.quiet {
return ExitCode::from(if anything { exit::OK } else { exit::NO });
}
if !anything {
eprintln!("colophon: nothing in these pages says what the book is");
for record in &records {
let _ = catena_cli::stream("colophon", std::slice::from_ref(record));
}
return ExitCode::from(exit::NO);
}
catena_cli::stream("colophon", &records)
}
+278
View File
@@ -0,0 +1,278 @@
//! The opening of a book, cut into pages and each page named for what it is.
//!
//! Front matter is not prose and does not read like it. A title page is four
//! lines and no sentence; a copyright page is a dense block of formulae that
//! recur from publisher to publisher; a series advertisement is a list of
//! other people's books. Telling them apart is most of the work, because the
//! title that matters is on one of them and the titles that will mislead you
//! are on another.
use std::sync::LazyLock;
use regex::Regex;
/// What a page of front matter is.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Kind {
/// Nothing legible on it.
Blank,
/// Other books, advertised. The trap: it names two hundred works that
/// are not this one, in the same shapes a title page uses.
SeriesList,
/// Publication facts: the imprint, the copyright, the ISBN, the CIP block.
Copyright,
/// The book's own table of contents.
Contents,
/// Few lines, no sentences — a title page, a half title, or a dedication.
Title,
/// Running text. The front matter is over.
Text,
}
/// One page, cleaned of what the converter added.
#[derive(Debug, Clone)]
pub struct Page {
/// Page number as the marker gave it, or position when there was none.
pub number: u32,
/// Legible lines, in order, with markup and typesetting slugs removed.
pub lines: Vec<String>,
/// The page's text as it arrived, for the block readers.
pub text: String,
/// What the page is.
pub kind: Kind,
}
static PAGE_MARKER: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(r"(?im)^\s*<!--\s*page\s+(\d+)\s*-->\s*$").expect("a valid pattern")
});
static IMAGE: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"!\[[^\]]*\]\([^)]*\)").expect("a valid pattern"));
static HEADING: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"^#{1,6}\s+").expect("a valid pattern"));
/// `DESA01 19/9/06 6:02 PM Page i` — the typesetter's slug, printed by
/// accident and carried by every page of some books.
static SLUG: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(r"(?i)^[a-z]{2,10}[-_ ]?\d{1,6}\s+\d{1,2}/\d{1,2}/\d{2,4}.*page\s+[ivxlcdm\d]+\s*$")
.expect("a valid pattern")
});
/// A converted EPUB's navigation: `[Begin Reading](dedication.xhtml#ded)`,
/// and the page links some converters leave behind.
static NAVIGATION: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(r"(?i)^(<|\[[^\]]*\]\([^)]*\)|https?://|www\.|previous page|next page|page_\w+)")
.expect("a valid pattern")
});
static FOLIO: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"^[\d\s.\-–—ivxlcdmIVXLCDM|]+$").expect("a valid pattern"));
static BLANK_NOTE: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(r"(?i)^(this page (has been )?intentionally left blank|blank page)\.?$")
.expect("a valid pattern")
});
static LIST_CUE: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(
r"(?i)(available now|available soon|also by|also in this series|also available|\
other titles|other books|forthcoming|in the same series|praise for|advance praise|\
very short introductions|series editors?|titles in this series)",
)
.expect("a valid pattern")
});
static COPYRIGHT_CUE: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(
r"(?i)(all rights reserved|first published|first edition|copyright\s*[©®(]?|[©®]\s*\d{4}|\(c\)\s*\d{4}|\
library of congress|british library|catalog(u)?ing[- ]in[- ]publication|\
printed (in|and bound)|isbn|no part of this (book|publication)|\
typeset (in|by)|a cip (record|catalogue))",
)
.expect("a valid pattern")
});
static CONTENTS_CUE: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(r"(?i)^(table of )?contents$|\.{4,}\s*\d+\s*$").expect("a valid pattern")
});
/// A line of a series list: a work, then whoever wrote it, on one line.
static LISTED_WORK: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(r"^[A-Z][A-Za-z'’\-]*(?:[ ,][A-Z][A-Za-z'’\-]*){0,7}\s+[A-Z][a-z'’\-]+\s+[A-Z][A-Za-z'’\-]+\.?$")
.expect("a valid pattern")
});
/// A person's name alone on a line.
pub static PERSON: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(r"^(?:[A-Z][A-Za-z'’\-\.]{0,20}\s+){1,3}[A-Z][A-Za-z'’\-]{1,25}\.?$")
.expect("a valid pattern")
});
/// Letters spread across a line by a PDF's glyph positions, put back together.
///
/// `A N E W T R A N SL AT ION` is one word to a reader and eleven tokens to a
/// parser, and the line it ruins is usually the title.
pub fn despread(line: &str) -> String {
let tokens: Vec<&str> = line.split_whitespace().collect();
if tokens.len() < 4 {
return line.to_string();
}
let singles = tokens.iter().filter(|t| t.chars().count() == 1).count();
if singles * 2 < tokens.len() {
return line.to_string();
}
let mut out: Vec<String> = Vec::new();
let mut run = String::new();
for token in tokens {
if token.chars().count() <= 3 && token.chars().all(char::is_alphabetic) {
run.push_str(token);
} else {
if !run.is_empty() {
out.push(std::mem::take(&mut run));
}
out.push(token.to_string());
}
}
if !run.is_empty() {
out.push(run);
}
out.join(" ")
}
/// Is the line so damaged that reading it would invent something?
///
/// A font whose glyphs map into the private use area comes out of extraction
/// as a line of unassigned code points. It looks like text to a length check
/// and is not text.
fn illegible(line: &str) -> bool {
let total = line.chars().filter(|c| !c.is_whitespace()).count();
if total == 0 {
return true;
}
let bad = line
.chars()
.filter(|c| {
let n = *c as u32;
(0xE000..=0xF8FF).contains(&n) || *c == '\u{FFFD}'
})
.count();
bad * 5 > total
}
fn clean_line(raw: &str) -> Option<String> {
let without_images = IMAGE.replace_all(raw, " ");
let stripped = HEADING.replace(without_images.trim(), "");
let line = despread(
stripped
.trim()
.trim_matches(|c| c == '*' || c == '_' || c == '|')
.trim(),
);
if line.is_empty()
|| illegible(&line)
|| SLUG.is_match(&line)
|| NAVIGATION.is_match(&line)
|| FOLIO.is_match(&line)
|| BLANK_NOTE.is_match(&line)
{
return None;
}
Some(line)
}
fn looks_like_prose(line: &str) -> bool {
let words = line.split_whitespace().count();
words > 14 || (words > 8 && line.trim_end().ends_with(['.', ';', ',']))
}
fn classify(lines: &[String], text: &str) -> Kind {
if lines.is_empty() {
return Kind::Blank;
}
if COPYRIGHT_CUE.find_iter(text).count() >= 2 {
return Kind::Copyright;
}
if LIST_CUE.is_match(text) && lines.len() >= 5 {
return Kind::SeriesList;
}
let listed = lines.iter().filter(|l| LISTED_WORK.is_match(l)).count();
if lines.len() >= 6 && listed * 5 >= lines.len() * 2 {
return Kind::SeriesList;
}
if lines.iter().take(3).any(|l| CONTENTS_CUE.is_match(l))
|| lines.iter().filter(|l| CONTENTS_CUE.is_match(l)).count() >= 4
{
return Kind::Contents;
}
if lines.len() <= 12 && !lines.iter().any(|l| looks_like_prose(l)) {
return Kind::Title;
}
Kind::Text
}
/// Cut the opening into pages and name each one.
///
/// Page markers are used where the converter left them. Where it did not —
/// an EPUB has no pages to mark — the blank lines are the only boundary
/// there is, and blocks stand in for pages. That is weaker, which is why a
/// book converted from EPUB usually comes back with nothing but its ISBN:
/// the metadata is in its OPF, and [`bookmeta`] reads that.
///
/// [`bookmeta`]: https://git.pumpin.online/funkpower/catena
pub fn split(text: &str, budget: usize) -> Vec<Page> {
let mut pages = Vec::new();
if PAGE_MARKER.is_match(text) {
let mut last_end = 0;
let mut number = 0u32;
for m in PAGE_MARKER.captures_iter(text) {
let whole = m.get(0).expect("group zero is always there");
if number > 0 || whole.start() > 0 {
push(&mut pages, number, &text[last_end..whole.start()]);
}
number = m[1].parse().unwrap_or(number + 1);
last_end = whole.end();
if pages.len() > budget {
break;
}
}
push(&mut pages, number, &text[last_end..]);
} else {
for (i, block) in text.split("\n\n").enumerate() {
push(&mut pages, i as u32 + 1, block);
if pages.len() > budget {
break;
}
}
}
pages.truncate(budget);
continuations(&mut pages);
pages
}
fn push(pages: &mut Vec<Page>, number: u32, text: &str) {
let lines: Vec<String> = text.lines().filter_map(clean_line).collect();
let kind = classify(&lines, text);
pages.push(Page {
number,
lines,
text: text.to_string(),
kind,
});
}
/// A series list runs over several pages and only the first one announces
/// itself. The pages after it are the same list and have to be refused too,
/// or the title taken off one of them is another book's.
fn continuations(pages: &mut [Page]) {
for i in 1..pages.len() {
if pages[i - 1].kind != Kind::SeriesList {
continue;
}
let page = &pages[i];
if page.kind == Kind::Blank || page.lines.len() < 6 {
continue;
}
// Only a page that is itself a list continues one. Counting bare
// names here would take a title page for advertising, because a
// title set in capitals and broken across lines is, line by line,
// the same shape as a person's name.
let listed = page
.lines
.iter()
.filter(|l| LISTED_WORK.is_match(l))
.count();
if page.lines.len() >= 6 && listed * 2 >= page.lines.len() {
pages[i].kind = Kind::SeriesList;
}
}
}
+194
View File
@@ -0,0 +1,194 @@
//! A question to put to a model, for what the pages did not settle.
//!
//! Nothing here sends anything. The prompt is composed and printed, and what
//! happens to it is the caller's business — their model, their machine,
//! their GPU. The tool stays what every tool here is: the same output for
//! the same input, on every machine, with no network and nothing installed.
//!
//! What the model is shown is not the front matter as it arrived. The pages
//! that advertise other books are taken out first, because handing them to a
//! model is how a book ends up catalogued as whichever of two hundred other
//! titles the model's eye fell on. It is shown the pages that describe this
//! book, told what was already read off them, and asked only for the rest.
use crate::{Printed, Source, page};
/// The version of the prompt below.
///
/// Bumped when the wording changes in a way that could change an answer, so
/// a caller keeping results can tell which prompt produced them.
pub const PROMPT_VERSION: u32 = 1;
/// How much of the front matter to show. Enough for a title page, a
/// copyright page and a contents; not enough to wander into the text.
const SHOWN: usize = 6000;
/// The fields a model may be asked for.
const ASKABLE: &[&str] = &["title", "authors", "publisher", "year", "subjects"];
fn known(record: &Printed) -> String {
let mut out = String::new();
if let Some(title) = &record.title {
out.push_str(&format!("title already read: {title}\n"));
}
if !record.authors.is_empty() {
out.push_str(&format!(
"authors already read: {}\n",
record.authors.join("; ")
));
}
if !record.isbns.is_empty() {
out.push_str(&format!(
"isbn printed in the book: {}\n",
record.isbns.join(", ")
));
}
if let Some(publisher) = &record.publisher {
out.push_str(&format!("publisher already read: {publisher}\n"));
}
out
}
/// The front matter with the advertising taken out.
pub fn readable_pages(text: &str, budget: usize) -> String {
let mut out = String::new();
for page in page::split(text, budget) {
if matches!(page.kind, page::Kind::SeriesList | page::Kind::Blank) || page.lines.is_empty()
{
continue;
}
out.push_str(&format!("--- page {}\n", page.number));
for line in &page.lines {
out.push_str(line);
out.push('\n');
}
out.push('\n');
if out.len() >= SHOWN {
break;
}
}
out.truncate(SHOWN);
out
}
/// Compose the question, or decline to.
///
/// `None` when the pages settled everything a model could help with, because
/// there is nothing to ask and a call to a model would be waste.
pub fn compose(record: &Printed, text: &str, budget: usize) -> Option<String> {
let wanted: Vec<&str> = ASKABLE
.iter()
.copied()
.filter(|field| record.unsettled.iter().any(|f| f == field))
.collect();
if wanted.is_empty() {
return None;
}
let pages = readable_pages(text, budget);
if pages.trim().is_empty() {
return None;
}
let mut out = String::from(
"Below are the opening pages of one book, as text. Pages advertising \
other books have already been removed.\n\n",
);
out.push_str(&pages);
out.push('\n');
let already = known(record);
if !already.is_empty() {
out.push_str("Read off these pages already:\n");
out.push_str(&already);
out.push('\n');
}
out.push_str(&format!("wanted: {}\n\n", wanted.join(", ")));
out.push_str(
"Reply with one JSON object and nothing else, using only the keys \
listed as wanted. `title` is the book's own title, without its \
subtitle. `authors` is a list of the people who wrote it, as the \
book writes their names. `publisher` is the press. `year` is a \
four-digit number. `subjects` is a list of at most five subject \
headings for what the book is about.\n\n\
Answer only from the pages above. Use null for anything they do not \
settle: a guess is worse than an absence, because nobody downstream \
can tell them apart. A title listed beside other titles is another \
book's, not this one's.\n",
);
Some(out)
}
/// Fill in what the pages did not settle from a model's reply.
///
/// Only the fields that were unsettled are filled, and each one is marked as
/// having come from a model — a record that cannot say which of its fields
/// were read and which were answered is a record nobody can apply a policy
/// to. Returns how many fields were filled.
pub fn merge(record: &mut Printed, reply: &str) -> Result<usize, String> {
let start = reply.find('{').ok_or("the reply holds no JSON object")?;
let end = reply.rfind('}').ok_or("the reply holds no JSON object")?;
let parsed: serde_json::Value = serde_json::from_str(&reply[start..=end])
.map_err(|e| format!("the reply is not JSON: {e}"))?;
let object = parsed.as_object().ok_or("the reply is not a JSON object")?;
let mut filled = 0;
let wanted = |record: &Printed, field: &str| record.unsettled.iter().any(|f| f == field);
if wanted(record, "title") {
if let Some(title) = object.get("title").and_then(|v| v.as_str()) {
let title = title.trim();
if !title.is_empty() {
record.title = Some(title.to_string());
record.read_from.insert("title".into(), Source::Model);
filled += 1;
}
}
}
if wanted(record, "publisher") {
if let Some(publisher) = object.get("publisher").and_then(|v| v.as_str()) {
let publisher = publisher.trim();
if !publisher.is_empty() {
record.publisher = Some(publisher.to_string());
record.read_from.insert("publisher".into(), Source::Model);
filled += 1;
}
}
}
if wanted(record, "year") {
if let Some(year) = object.get("year").and_then(serde_json::Value::as_u64) {
if (1400..=2200).contains(&year) {
record.year = Some(year as u16);
record.read_from.insert("year".into(), Source::Model);
filled += 1;
}
}
}
for (field, into) in [("authors", true), ("subjects", false)] {
if !wanted(record, field) {
continue;
}
let Some(list) = object.get(field).and_then(|v| v.as_array()) else {
continue;
};
let values: Vec<String> = list
.iter()
.filter_map(|v| v.as_str())
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.collect();
if values.is_empty() {
continue;
}
if into {
record.authors = values;
} else {
record.subjects = values;
}
record.read_from.insert(field.into(), Source::Model);
filled += 1;
}
record
.unsettled
.retain(|field| !record.read_from.contains_key(field));
Ok(filled)
}
+264
View File
@@ -0,0 +1,264 @@
//! Filing a book's printed subject headings onto a shelf someone actually has.
//!
//! A cataloguing block states subjects in Library of Congress form, which is
//! precise, deep, and no use as a shelf. Real blocks from real books read:
//!
//! ```text
//! Political science – Early works to 1800
//! English literature—Middle English, 1100–1500—History and criticism
//! Methodology. 2.Anthropic principle
//! Hobbes, Thomas, 1588–1679. Leviathan
//! ```
//!
//! Nobody browses by those. A library is browsed by a short closed list its
//! owner chose, and the job here is to get from the first to the second.
//!
//! The mapping is deliberately dumb: a heading is matched against the terms of
//! the shelf, longest first, and the first hit wins. It infers nothing, invents
//! nothing, and returns nothing at all rather than guess — a heading that
//! matches no term is reported unfiled, with its raw text kept, because a
//! subject silently dropped is indistinguishable from a book with no subjects.
use std::collections::BTreeSet;
/// A closed list of headings, with the words that file a book under each.
///
/// Parsed from a plain text file: one heading per line, optionally followed by
/// `=` and the comma-separated terms that should match it. A heading with no
/// terms matches its own name.
///
/// ```text
/// Byzantine History = byzantium, byzantine, constantinople, eastern roman
/// Patristics = church fathers, patristic, patrology
/// Ethics
/// ```
#[derive(Debug, Clone, Default)]
pub struct Shelf {
/// (term, heading), longest term first so the most specific wins.
terms: Vec<(String, String)>,
headings: BTreeSet<String>,
}
impl Shelf {
/// Read a shelf from the list format above. Blank lines and `#` comments
/// are skipped.
pub fn parse(text: &str) -> Self {
let mut terms: Vec<(String, String)> = Vec::new();
let mut headings = BTreeSet::new();
for line in text.lines() {
let line = line.trim();
if line.is_empty() || line.starts_with('#') {
continue;
}
let (heading, rest) = match line.split_once('=') {
Some((h, r)) => (h.trim(), r.trim()),
None => (line, ""),
};
if heading.is_empty() {
continue;
}
headings.insert(heading.to_string());
terms.push((fold(heading), heading.to_string()));
for term in rest.split(',') {
let term = fold(term);
if !term.is_empty() {
terms.push((term, heading.to_string()));
}
}
}
// Longest first: "ancient philosophy" must beat "philosophy", or every
// period of thought files under the parent and the shelf does no work.
terms.sort_by(|a, b| b.0.len().cmp(&a.0.len()).then_with(|| a.0.cmp(&b.0)));
terms.dedup_by(|a, b| a.0 == b.0);
Self { terms, headings }
}
/// Whether the shelf lists no headings, in which case it files nothing.
pub fn is_empty(&self) -> bool {
self.terms.is_empty()
}
/// Whether a name is one of the shelf's own headings.
pub fn has(&self, heading: &str) -> bool {
self.headings.contains(heading)
}
/// The heading a single raw subject files under, if any.
pub fn file_one(&self, raw: &str) -> Option<&str> {
let hay = fold(raw);
if hay.is_empty() {
return None;
}
self.terms
.iter()
.find(|(term, _)| contains_word(&hay, term))
.map(|(_, heading)| heading.as_str())
}
/// File a book's raw headings, in the shelf's own order, without repeats.
///
/// Returns the headings that matched and the raw subjects that did not, so
/// a caller can tell "this book is not about anything on the shelf" from
/// "this book printed no subjects".
pub fn file(&self, raw: &[String]) -> Filed {
let mut on = Vec::new();
let mut unfiled = Vec::new();
for subject in raw {
match self.file_one(subject) {
Some(h) => {
if !on.iter().any(|x: &String| x == h) {
on.push(h.to_string());
}
}
None => unfiled.push(subject.clone()),
}
}
Filed { on, unfiled }
}
}
/// What a shelf made of a book's printed subjects.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct Filed {
/// Headings from the shelf, in the order the book's subjects hit them.
pub on: Vec<String>,
/// Subjects that matched no term, kept verbatim.
pub unfiled: Vec<String>,
}
/// Lowercase, and reduce every run of non-alphanumerics to one space.
///
/// Cataloguing blocks separate with em dashes, en dashes, full stops and plain
/// hyphens, sometimes three of them in one heading, and OCR turns any of those
/// into any other. None of that should decide whether a book gets shelved.
fn fold(s: &str) -> String {
let mut out = String::with_capacity(s.len());
let mut space = true; // no leading space
for c in s.chars() {
if c.is_alphanumeric() {
for l in c.to_lowercase() {
out.push(l);
}
space = false;
} else if !space {
out.push(' ');
space = true;
}
}
while out.ends_with(' ') {
out.pop();
}
out
}
/// Whether `needle` appears in `hay` on whole-word boundaries.
///
/// Substring matching would file "Islam" under any book mentioning "Islamic"
/// — which is right — but would also file "Art" under "Cartography", which is
/// not. Both strings are already folded, so the boundary is a space.
fn contains_word(hay: &str, needle: &str) -> bool {
if needle.is_empty() || needle.len() > hay.len() {
return false;
}
hay.match_indices(needle).any(|(i, _)| {
let before = i == 0 || hay.as_bytes()[i - 1] == b' ';
let end = i + needle.len();
let after = end == hay.len() || hay.as_bytes()[end] == b' ';
before && after
})
}
#[cfg(test)]
mod tests {
use super::*;
fn shelf() -> Shelf {
Shelf::parse(
"# a shelf\n\
Philosophy = philosophy, philosophical\n\
Ancient Philosophy = ancient philosophy, plato, aristotle\n\
Political Science = political science, politics\n\
Theology = theology, christianity\n\
Byzantine History = byzantium, byzantine, constantinople\n\
Ethics\n",
)
}
#[test]
fn a_heading_with_no_terms_matches_its_own_name() {
assert_eq!(shelf().file_one("Ethics — early works"), Some("Ethics"));
}
#[test]
fn the_most_specific_term_wins() {
// Both "ancient philosophy" and "philosophy" match; the longer one has
// to win or every period files under the parent.
assert_eq!(
shelf().file_one("Ancient philosophy—History"),
Some("Ancient Philosophy")
);
}
#[test]
fn real_cataloguing_headings_file() {
let s = shelf();
for (raw, want) in [
(
"Political science – Early works to 1800",
"Political Science",
),
("Political science — Philosophy", "Political Science"),
("Byzantine Empire—History—527-1081", "Byzantine History"),
("Christianity and culture", "Theology"),
] {
assert_eq!(s.file_one(raw), Some(want), "{raw}");
}
}
#[test]
fn a_dash_is_a_dash_however_it_was_printed() {
// em, en, hyphen, and the double hyphen OCR leaves behind.
let s = shelf();
for raw in [
"Philosophy—Introductions",
"Philosophy – Introductions",
"Philosophy - Introductions",
"Philosophy -- Introductions",
"Philosophy. Introductions",
] {
assert_eq!(s.file_one(raw), Some("Philosophy"), "{raw}");
}
}
#[test]
fn a_term_must_match_a_whole_word() {
// "politics" must not file a book on "geopolitics", and "art" must not
// file one on cartography.
let s = Shelf::parse("Art\nPolitical Science = politics\n");
assert_eq!(s.file_one("Cartography—History"), None);
assert_eq!(s.file_one("Art—Byzantine"), Some("Art"));
}
#[test]
fn what_matches_nothing_is_reported_rather_than_dropped() {
let filed = shelf().file(&[
"Political science – Early works to 1800".into(),
"Selectivity (Psychology)".into(),
]);
assert_eq!(filed.on, ["Political Science"]);
assert_eq!(filed.unfiled, ["Selectivity (Psychology)"]);
}
#[test]
fn a_heading_is_not_repeated() {
let filed = shelf().file(&["Philosophy—Ancient".into(), "Philosophy—Modern".into()]);
assert_eq!(filed.on, ["Philosophy"]);
}
#[test]
fn an_empty_shelf_files_nothing_and_says_so() {
let s = Shelf::parse(" \n# nothing but a comment\n");
assert!(s.is_empty());
assert_eq!(s.file(&["Philosophy".into()]).unfiled, ["Philosophy"]);
}
}
+594
View File
@@ -0,0 +1,594 @@
//! The title page, read as the block it is.
//!
//! A title page is laid out rather than written: the title, sometimes broken
//! across lines by nothing but the width of the page, then whoever wrote it,
//! then the publisher at the foot. Reading it is a matter of knowing where
//! the title stops, which is at the first line that is a person or a press.
use std::sync::LazyLock;
use regex::Regex;
use crate::page::PERSON;
/// What a title page printed.
#[derive(Debug, Default, Clone, PartialEq)]
pub struct TitlePage {
/// The title, with lines the typesetter broke put back together.
pub title: Option<String>,
/// What followed it, where the page gave a subtitle.
pub subtitle: Option<String>,
/// Whoever it says wrote the book.
pub authors: Vec<String>,
/// Whoever it says edited it.
pub editors: Vec<String>,
}
/// `(1714)`, `, 1922-2019` — when a title page dates its author.
static LIFESPAN: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(
r"\s*[(,]\s*(?:1[0-9]|20)\d{2}\s*(?:[-\u{2013}]\s*(?:(?:1[0-9]|20)\d{2})?)?\s*\)?\s*$",
)
.expect("a valid pattern")
});
/// `350 BC`, `1971` — a date alone on a line, which is not a title.
static BARE_DATE: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"(?i)^\d{1,4}\s*(bc|ad|bce|ce)?\.?$").expect("a valid pattern"));
/// `Copyright © 2013 by`, `\u{a9} 2013 Richard Swinburne`, `Copyright 1971 by`.
/// `1. General Remarks`, `2 James W. McAllister` — a numbered heading.
static NUMBERED_HEADING: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"^\s*\d+\s*[.)]\s+\S|^\s*\d+\s{2,}\S").expect("a valid pattern"));
/// `5222 What Is This`, `827 North Fourth Street` — a number that is not a
/// year, leading a line.
static LEADING_NUMBER: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"^\s*(\d{1,5})\s+\S").expect("a valid pattern"));
/// `Page xxxvi`, `Chapter I`, `Fig. 3` — a marker, not a name.
static MARKER: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(
r"(?i)^\s*(page|pages|p\.|pp\.|fig\.|figure|plate|chapter|ch\.|section|part)\s+[\divxlcm]",
)
.expect("a valid pattern")
});
/// `Sidebottom, Harry. Ancient warfare` — a bibliography entry, which front
/// matter prints in its cataloguing block and in its further reading.
static CATALOGUE_ENTRY: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(r"^[A-Z][A-Za-z'\-]+,\s+[A-Z][A-Za-z'.\-]*\.\s+\S").expect("a valid pattern")
});
/// `To Mackenzie,` — a dedication, set alone on its own page.
static DEDICATION: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(r"(?i)^\s*(to|for|in memory of)\s+\S.*,\s*$").expect("a valid pattern")
});
static COPYRIGHT_NOTICE: LazyLock<Regex> = LazyLock::new(|| {
// The year need not follow the symbol directly: "(c) Alfredo Ferrarin
// 2004" puts the holder between them, and was read as a title.
Regex::new(r"(?i)(\u{a9}|\(c\)|copyright)[^\n]{0,60}?(1[0-9]|20)\d{2}")
.expect("a valid pattern")
});
/// `Edited by`, with whoever it was set on the line beneath.
static ROLE_ONLY: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(r"(?i)^(edited|translated|written|selected|compiled)\s+by:?$")
.expect("a valid pattern")
});
static BYLINE: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(r"(?i)^(?P<role>by|edited by|written by|translated by|selected by|compiled by)\s+(?P<who>.{3,90})$")
.expect("a valid pattern")
});
/// A press, an imprint, or the line that announces one. The foot of the page.
pub static IMPRINT: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(
r"(?i)\b(university press|[a-z]+ press\b|publish(?:er|ers|ing)\b|publications\b|\
verlag\b|éditions\b|editions\b|books? (?:ltd|inc|group)\b|\
routledge|blackwell|wiley|springer|palgrave|macmillan|penguin|\
random house|harpercollins|w\.? ?w\.? norton|doubleday|bloomsbury|eerdmans|\
brill\b|sage\b|vintage\b|knopf|faber|crossway|zondervan|clarendon|\
foundation\b|institute\b|society\b|trust\b|distributed by|\
cover (design|art|photograph)|jacket design|thank you for buying|\
typeset|designed by|illustrated by|printed by|\
copyright|all rights reserved|printed in)\b",
)
.expect("a valid pattern")
});
/// A city a publisher puts on a title page, alone on its line.
pub static PLACE: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(
r"(?i)^(?P<city>oxford|cambridge|london|new york|new haven|chicago|boston|princeton|\
berlin|paris|munich|rome|madrid|grand rapids|edinburgh|dublin|toronto|sydney|\
melbourne|minneapolis|philadelphia|san francisco|los angeles|leiden|amsterdam|\
copenhagen|stockholm|moscow|beijing|tokyo|delhi|nairobi)\b[\s,·•|and]*",
)
.expect("a valid pattern")
});
/// Words that name a part of a book rather than the book.
static STRUCTURAL: &[&str] = &[
"abbreviations",
"about the author",
"abstract",
"acknowledgements",
"acknowledgments",
"afterword",
"appendix",
"author",
"authors",
"bibliography",
"contents",
"dedication",
"epigraph",
"epilogue",
"foreword",
"frontispiece",
"glossary",
"half title",
"illustrations",
"index",
"introduction",
"keywords",
"list of contributors",
"list of illustrations",
"maps",
"notes",
"postscript",
"preface",
"prologue",
"subtitle",
"table of contents",
"title",
"title page",
"translator's note",
];
fn is_structural(line: &str) -> bool {
let norm: String = line
.chars()
.filter(|c| c.is_alphanumeric() || c.is_whitespace())
.collect::<String>()
.trim()
.to_lowercase();
let norm = strip_folio(&norm);
STRUCTURAL.binary_search(&norm).is_ok()
}
/// A heading with its page number taken off either end.
///
/// Front matter is folioed in roman, and the folio sits on the same line as
/// the heading often enough to matter: `vi CONTENTS` and `viii CONTENTS`
/// are contents pages, and were being read as titles because they are not the
/// bare word.
fn strip_folio(norm: &str) -> &str {
let is_folio = |w: &str| {
!w.is_empty()
&& (w.chars().all(|c| c.is_ascii_digit())
|| w.chars()
.all(|c| matches!(c, 'i' | 'v' | 'x' | 'l' | 'c' | 'd' | 'm')))
};
let mut out = norm.trim();
loop {
let trimmed = match out.split_once(' ') {
Some((head, rest)) if is_folio(head) => rest.trim_start(),
_ => match out.rsplit_once(' ') {
Some((rest, tail)) if is_folio(tail) => rest.trim_end(),
_ => return out,
},
};
if trimmed == out {
return out;
}
out = trimmed;
}
}
/// Lines that are printed matter rather than anything a book is called.
///
/// Every one of these was read as a title off a real book in a real library:
/// a copyright notice, a translator's credit, a part heading in brackets, a
/// span of HTML a converter left behind, and a volume entry from a collected
/// edition's own list of volumes.
fn is_furniture(line: &str) -> bool {
let t = line.trim();
if t.starts_with('[') || t.starts_with('\\') {
return true;
}
// A converter that left its markup in. Nothing printed on a title page is
// a tag, a stylesheet declaration, a markdown link or a filename, and a
// title that appears to be one is a fragment of the conversion rather than
// of the book. Rejecting only the first such line is no use: the next line
// of a mangled page is mangled too, so the whole shape has to go.
if t.contains("<span")
|| t.contains("<div")
|| t.contains("<p ")
|| t.contains("</")
|| t.contains("](") // a markdown link the converter split
|| t.contains("&quot;")
|| t.contains("&amp;")
|| t.contains("mso-") // Word's stylesheet, via HTML export
|| t.contains("font-family")
|| t.contains("font-size")
|| t.contains("style=")
|| t.contains("_epub_")
|| t.contains(".html")
|| t.contains(".xhtml")
{
return true;
}
// Sentences a copyright page sets, which are not notices and so not caught
// by the notice pattern.
if NUMBERED_HEADING.is_match(t) || MARKER.is_match(t) || CATALOGUE_ENTRY.is_match(t) {
return true;
}
// A line opening with a number that is not a year. "5222 What Is This" and
// "827 North Fourth Street" are a stray folio and an address; "1984
// Revisited" is a book, and the difference is whether the number could be
// the year of one.
if let Some(c) = LEADING_NUMBER.captures(t) {
let n: u32 = c[1].parse().unwrap_or(0);
if !(1000..=2099).contains(&n) {
return true;
}
}
if DEDICATION.is_match(t) {
return true;
}
let lower = t.to_lowercase();
for phrase in [
"moral rights of the author",
"no part of this publication",
"british library cataloguing",
"library of congress cataloging",
"printed in the united",
"typeset by",
"a cip catalogue record",
] {
if lower.contains(phrase) {
return true;
}
}
let low = t.to_lowercase();
// The notice, not the word. "Copyright and Its Discontents" is a book;
// "Copyright © 2013 by" is the page it would be printed on. What tells
// them apart is the symbol or the year, never the word alone.
if COPYRIGHT_NOTICE.is_match(t)
|| low.contains("all rights reserved")
|| low.starts_with("first published")
|| low.starts_with("reprinted")
{
return true;
}
// "Translated from the French by Willard R. Trask" — a credit, not a name
// and not a title. ROLE_ONLY catches the bare "Translated by"; this is the
// whole sentence, which a title page sets on one line.
if low.starts_with("translated from") || low.starts_with("translated by") {
return true;
}
// "Vol. 11. HENRY JAMES, JR." — an entry in a collected edition's list of
// its own volumes, printed in the front matter of every volume.
if low.starts_with("vol.") || low.starts_with("volume ") && low.contains('.') {
return true;
}
false
}
fn people(who: &str) -> Vec<String> {
who.split(&[';', '&'][..])
.flat_map(|part| part.split(" and "))
.map(|name| name.trim().trim_end_matches(['.', ',']).trim().to_string())
.filter(|name| name.len() >= 3 && name.split_whitespace().count() <= 5)
.collect()
}
/// Words a line ends on when the typesetter broke a title across it.
///
/// `Esotericism in` / `Western Culture` is one title set on two lines, and
/// nothing but the dangling preposition says so.
const CONTINUES: &[&str] = &[
"a", "an", "and", "at", "between", "by", "for", "from", "in", "into", "of", "on", "or", "the",
"to", "under", "with", "without",
];
/// Endings no surname has and every abstract noun does.
///
/// Two capitalised words look exactly like a name — `Martin Cohen` and
/// `Political Philosophy` are the same shape — and on a title page the
/// second one is the top half of the title. English morphology is what
/// tells them apart without a dictionary of names to consult.
const NOT_A_NAME: &[&str] = &[
"ance", "ence", "ics", "ism", "ity", "ment", "ness", "ography", "ology", "osophy", "sion",
"tion", "ture",
];
fn could_be_a_person(line: &str) -> bool {
let line = LIFESPAN.replace(line, "");
let line = line.trim().trim_end_matches('.');
if !PERSON.is_match(line) {
return false;
}
let tokens: Vec<&str> = line.split_whitespace().collect();
if tokens.len() > 4 {
return false;
}
!tokens.iter().any(|token| {
let word = token.trim_end_matches('.').to_lowercase();
word.len() > 4 && NOT_A_NAME.iter().any(|end| word.ends_with(end))
})
}
fn wraps(line: &str) -> bool {
if line.ends_with('-') {
return true;
}
line.rsplit_once(char::is_whitespace)
.is_some_and(|(_, last)| CONTINUES.contains(&last.to_lowercase().as_str()))
}
/// Put a title the page broke across lines back together.
fn unwrap_lines(block: &[&str]) -> Vec<String> {
let mut out: Vec<String> = Vec::new();
for line in block {
match out.last_mut() {
Some(previous) if wraps(previous) => {
if previous.ends_with('-') {
previous.pop();
previous.push_str(line);
} else {
previous.push(' ');
previous.push_str(line);
}
}
_ => out.push((*line).to_string()),
}
}
out
}
/// Read a title page, or decline to.
///
/// The page is taken as a block that ends at the imprint — a press, a place,
/// or the first line of running prose. What is left is the title, whoever
/// wrote it, and nothing else, because that is all a title page holds.
///
/// Where the byline sits is the publisher's choice and both choices are
/// common, so it is looked for at the top of the block and at the bottom and
/// nowhere between: a name in the middle of a title page is a title that
/// happens to be two capitalised words.
///
/// Declines when the block is empty or runs past five lines, at which point
/// this is a page of something else and a title taken off it would be a
/// guess with a page number attached.
pub fn read(lines: &[String]) -> Option<TitlePage> {
let mut out = TitlePage::default();
let mut block: Vec<&str> = Vec::new();
let mut waiting: Option<String> = None;
for line in lines.iter().take(14) {
// `Edited by` on its own line, with the names set under it.
if let Some(role) = waiting.take() {
let who = people(line);
if !who.is_empty() {
if role.starts_with("edited") {
out.editors.extend(who);
} else if !role.starts_with("translated") {
out.authors.extend(who);
}
continue;
}
}
if is_structural(line) || is_furniture(line) || BARE_DATE.is_match(line) {
if block.is_empty() {
continue;
}
break;
}
if let Some(caps) = BYLINE.captures(line) {
let role = caps["role"].to_lowercase();
let who = people(&caps["who"]);
if who.is_empty() {
waiting = Some(role);
continue;
}
if role.starts_with("edited") {
out.editors.extend(who);
} else if !role.starts_with("translated") {
out.authors.extend(who);
}
continue;
}
if ROLE_ONLY.is_match(line) {
waiting = Some(line.to_lowercase());
continue;
}
if IMPRINT.is_match(line) || PLACE.is_match(line) {
if block.is_empty() {
continue;
}
break;
}
block.push(line);
if block.len() > 5 {
return None;
}
}
if block.is_empty() {
return None;
}
// The byline is above the title or below it, never inside it.
if out.authors.is_empty() && out.editors.is_empty() && block.len() > 1 {
if could_be_a_person(block[0]) {
out.authors = people(block[0]);
block.remove(0);
} else if could_be_a_person(block[block.len() - 1]) {
out.authors = people(block[block.len() - 1]);
block.pop();
}
}
let units = unwrap_lines(&block);
let mut title = units.first()?.clone();
let mut subtitle = units.get(1).cloned();
let split = title
.split_once(" : ")
.or_else(|| title.split_once(": "))
.map(|(head, tail)| (head.trim().to_string(), tail.trim().to_string()));
if let Some((head, tail)) = split {
title = head;
subtitle = Some(tail);
}
// A fragment is not a title. Two letters of one, left behind by a font
// the extractor could not read, is worse than nothing: it is a title
// page's authority attached to noise.
out.title = Some(title).filter(|t| {
t.chars().filter(|c| c.is_alphabetic()).count() >= 4
&& t.split_whitespace()
.any(|w| w.chars().filter(|c| c.is_alphabetic()).count() >= 3)
});
out.subtitle = subtitle.filter(|s| s.len() >= 2);
out.title.as_ref()?;
Some(out)
}
#[cfg(test)]
mod furniture_tests {
use super::*;
/// Every string here was read as a title off a book in a real library.
#[test]
fn printed_matter_is_not_a_title() {
for line in [
"vi CONTENTS",
"viii CONTENTS",
"xiv ACKNOWLEDGEMENTS",
"12 INDEX",
"Translated from the French by Willard R. Trask",
"Copyright © 2013 by Barry Davies, BEM",
"© Alfredo Ferrarin 2004",
"\u{a9} Cambridge University Press 2011",
"[PART ONE:",
"Piper Verlag GmbH, Munich<span style=\"font-size\">",
"Vol. 11. HENRY JAMES, JR.",
"All rights reserved",
"First published 1971",
] {
assert!(
is_structural(line) || is_furniture(line),
"read as a title: {line:?}"
);
}
}
#[test]
fn a_real_title_survives_all_of_that() {
for line in [
"The First World War",
"A Very Short Introduction",
"Christianity and Classical Culture",
"The Problems of Philosophy",
"Volume and Void",
"Contents of Consciousness",
"August 1914",
"Copyright and Its Discontents",
] {
assert!(
!(is_structural(line) || is_furniture(line)),
"rejected a real title: {line:?}"
);
}
}
#[test]
fn a_folio_comes_off_either_end() {
assert_eq!(strip_folio("vi contents"), "contents");
assert_eq!(strip_folio("contents vi"), "contents");
assert_eq!(strip_folio("contents"), "contents");
// A title that happens to be roman numerals throughout is not a folio
// with nothing left; it keeps whatever it had.
assert_eq!(strip_folio("i"), "i");
}
}
#[cfg(test)]
mod conversion_tests {
use super::*;
/// A mangled page is mangled on every line, so rejecting the first
/// leftover only moves the misread down one. Each of these is the line
/// colophon fell through to after the line above it was rejected.
#[test]
fn what_a_converter_left_behind_is_never_a_title() {
for line in [
"[PART ONE:",
"RULERS AND REDNECKS](Webb_9780767922951_epub_p01_r1.html)",
"[AFRICAN SLAVERY IN AMERICA - \\[1775\\]](index_split_004.html)",
"Piper Verlag GmbH, Munich<span style=\"font-family:&quot;Times\"",
"mso-bidi-font-family:&quot;Times New Roman&quot;;mso-ansi-language",
"The moral rights of the author have been asserted",
"No part of this publication may be reproduced",
"British Library Cataloguing in Publication Data",
"Typeset by SPI Publisher Services",
] {
assert!(is_furniture(line), "read as a title: {line:?}");
}
}
#[test]
fn a_title_that_merely_sounds_like_the_copyright_page_survives() {
for line in [
"Copyright and Its Discontents",
"The Moral Rights of Man",
"Printed Matter",
"A Publication of the Institute",
] {
assert!(!is_furniture(line), "rejected a real title: {line:?}");
}
}
}
#[cfg(test)]
mod review_pile_tests {
use super::*;
/// Everything here was proposed as a title by a real run over this
/// library, and sat in the review queue waiting for a person to say no.
#[test]
fn what_the_review_pile_was_full_of() {
for line in [
"1. General Remarks",
"2 James W. McAllister",
"5222 What Is This",
"827 North Fourth Street",
"Page xxxvi",
"Page xxxviii",
"Chapter I",
"title",
"Sidebottom, Harry. Ancient warfare",
"To Mackenzie,",
] {
assert!(
is_structural(line) || is_furniture(line),
"still read as a title: {line:?}"
);
}
}
/// And none of it catches a book. The numbers are the delicate part: a
/// year can open a title, and a folio cannot.
#[test]
fn a_book_is_still_a_book() {
for line in [
"1984 Revisited",
"1914: The Year the World Ended",
"Chapter and Verse",
"Part of the Furniture",
"To Kill a Mockingbird",
"For Whom the Bell Tolls",
"The Title of This Book",
"Pages from a Cold Island",
"Sections of a Broken Whole",
"In Memory of Bread",
] {
assert!(
!(is_structural(line) || is_furniture(line)),
"rejected a real title: {line:?}"
);
}
}
}
+299
View File
@@ -0,0 +1,299 @@
//! What real front matter looks like, and what has to come off it.
//!
//! Every case here is a shape taken from a book that was catalogued wrongly
//! without it.
use colophon::{PAGES, Source, read};
/// A paperback series prints two hundred other titles before its own, in the
/// same shape a title page uses. Catalogued off that page, this book is by
/// whichever author the reader's eye landed on.
#[test]
fn a_series_advertisement_is_not_the_title_page() {
let front = "\
<!-- Page 2 -->
The First World War: A Very Short Introduction
<!-- Page 3 -->
Very Short Introductions available now:
ANARCHISM Colin Ward
ANCIENT EGYPT Ian Shaw
ARISTOTLE Jonathan Barnes
THE ELEMENTS Philip Ball
CONSCIOUSNESS Susan Blackmore
COSMOLOGY Peter Coles
<!-- Page 4 -->
CLASSICS Mary Beard
DADA AND SURREALISM David Hopkins
DARWIN Jonathan Howard
THE DEAD SEA SCROLLS Timothy Lim
RUSSIAN LITERATURE Catriona Kelly
<!-- Page 6 -->
Michael Howard
THE FIRST
WORLD WAR
A Very Short Introduction
<!-- Page 7 -->
Great Clarendon Street, Oxford ox2 6dp
Oxford University Press is a department of the University of Oxford.
First published 2002
All rights reserved
ISBN 978-0-19-280353-5
";
let found = read(front, PAGES);
// The title page breaks the title across two lines and sets it in
// capitals; the half title prints the whole of it, cased as the
// publisher meant. Both are this book, and the unbroken one is kept.
assert_eq!(found.title.as_deref(), Some("The First World War"));
assert_eq!(found.subtitle.as_deref(), Some("A Very Short Introduction"));
assert_eq!(found.authors, vec!["Michael Howard"]);
assert_eq!(found.isbns, vec!["9780192803535"]);
assert_eq!(found.year, Some(2002));
assert_eq!(found.read_from.get("title"), Some(&Source::TitlePage));
}
/// The cataloguing block is a catalogue record somebody else already made.
/// The solidus between the title and who wrote it is read as a capital I by
/// every scanner ever built.
#[test]
fn a_cataloguing_block_outranks_the_page_it_is_printed_on() {
let front = "\
<!-- Page 4 -->
Consciousness Explained
Daniel C. Dennett
<!-- Page 5 -->
Copyright © 1991 by Daniel C. Dennett
All rights reserved
First published 1991
Library of Congress Cataloging-in-Publication Data
Dennett, Daniel C.
Consciousness explained I Daniel C. Dennett. — 1st ed.
p. cm.
Includes bibliographical references and index.
ISBN 0-316-18065-3
1. Consciousness. 2. Philosophy of mind. I. Title.
B105.C477D46 1991
126—dc20
";
let found = read(front, PAGES);
assert_eq!(found.title.as_deref(), Some("Consciousness explained"));
assert_eq!(found.authors, vec!["Daniel C. Dennett"]);
assert_eq!(found.read_from.get("title"), Some(&Source::Cip));
assert_eq!(found.subjects, vec!["Consciousness", "Philosophy of mind"]);
assert_eq!(found.isbns, vec!["0316180653"]);
}
/// Since 2013 the block comes in labelled fields, and the subject headings
/// with it — the one place a book states what it is about.
#[test]
fn the_labelled_block_gives_its_subjects() {
let front = "\
<!-- Page 4 -->
Library of Congress Cataloging-in-Publication Data
Names: Howard, Michael, 1922- author.
Title: The First World War : a very short introduction / Michael Howard.
Description: Oxford : Oxford University Press, 2007.
Identifiers: ISBN 9780192803535
Subjects: LCSH: World War, 1914-1918. | Europe--History--1871-1918.
All rights reserved
";
let found = read(front, PAGES);
assert_eq!(found.title.as_deref(), Some("The First World War"));
assert_eq!(found.subtitle.as_deref(), Some("a very short introduction"));
assert_eq!(found.authors, vec!["Michael Howard"]);
assert_eq!(found.publisher.as_deref(), Some("Oxford University Press"));
assert_eq!(found.place.as_deref(), Some("Oxford"));
assert_eq!(found.year, Some(2007));
assert_eq!(found.subjects.len(), 2);
}
/// A translation's block prints the original title in brackets above the
/// real one. It is a heading, not the book in your hand.
#[test]
fn a_uniform_title_is_not_the_title() {
let front = "\
<!-- Page 4 -->
All rights reserved
Library of Congress Cataloging-in-Publication Data
Ricoeur, Paul.
[Sur la traduction. English]
On translation / Paul Ricoeur ; translated by Eileen Brennan.
ISBN 0-415-35777-X
";
let found = read(front, PAGES);
assert_eq!(found.title.as_deref(), Some("On translation"));
assert_eq!(found.authors, vec!["Paul Ricoeur"]);
}
/// A title broken across two lines by the width of the page, with the second
/// half looking exactly like a person's name.
#[test]
fn a_wrapped_title_is_one_title() {
let front = "\
<!-- Page 4 -->
Esotericism in
Western Culture
Counter-Normativity and
Rejected Knowledge
Wouter J. Hanegraaff
<!-- Page 5 -->
BLOOMSBURY ACADEMIC
First published 2012
All rights reserved
";
let found = read(front, PAGES);
assert_eq!(
found.title.as_deref(),
Some("Esotericism in Western Culture")
);
assert_eq!(
found.subtitle.as_deref(),
Some("Counter-Normativity and Rejected Knowledge")
);
assert_eq!(found.authors, vec!["Wouter J. Hanegraaff"]);
}
/// Two capitalised words are a name or they are half a title, and English
/// morphology is the only thing on the page that says which.
#[test]
fn an_abstract_noun_is_not_a_person() {
let front = "\
<!-- Page 1 -->
Political Philosophy
From Plato to Mao
Martin Cohen
Pluto Press
LONDON
<!-- Page 2 -->
First published 2001 by Pluto Press
All rights reserved
";
let found = read(front, PAGES);
assert_eq!(found.title.as_deref(), Some("Political Philosophy"));
assert_eq!(found.subtitle.as_deref(), Some("From Plato to Mao"));
assert_eq!(found.authors, vec!["Martin Cohen"]);
assert_eq!(found.publisher.as_deref(), Some("Pluto Press"));
}
/// A scan that starts at the preface prints no title page, and the honest
/// record of that is an empty one.
#[test]
fn a_book_that_says_nothing_about_itself_is_reported_as_saying_nothing() {
let front = "\
<!-- Page 2 -->
Preface
Some mental states have qualitative phenomenal characters, and since the
dawn of Behaviorism some philosophers have doubted the ability of any
physicalist account of the mental to accommodate this fact.
";
let found = read(front, PAGES);
assert!(found.is_empty());
assert!(found.title.is_none());
for field in ["title", "authors", "isbns", "publisher", "year", "subjects"] {
assert!(
found.unsettled.iter().any(|f| f == field),
"{field} should be unsettled"
);
}
}
/// An ISBN whose check digit disagrees is a mistyped one, and reporting it
/// would hand the next stage a key that opens nothing.
#[test]
fn an_isbn_that_fails_its_check_digit_is_not_reported() {
let front = "\
<!-- Page 3 -->
All rights reserved
First published 1999
ISBN 978-0-19-280353-9
";
let found = read(front, PAGES);
assert!(found.isbns.is_empty());
}
/// What a model answers is filled in only where the pages said nothing, and
/// is marked as having come from a model.
#[test]
fn a_reply_fills_only_what_the_pages_left() {
let front = "\
<!-- Page 4 -->
Esotericism in
Western Culture
Wouter J. Hanegraaff
<!-- Page 5 -->
All rights reserved
First published 2012
";
let mut found = read(front, PAGES);
assert!(found.subjects.is_empty());
let filled = colophon::prompt::merge(
&mut found,
"{\"title\": \"Something Else\", \"subjects\": [\"Occultism\", \"Europe\"]}",
)
.expect("a readable reply");
assert_eq!(filled, 1);
assert_eq!(
found.title.as_deref(),
Some("Esotericism in Western Culture")
);
assert_eq!(found.subjects, vec!["Occultism", "Europe"]);
assert_eq!(found.read_from.get("subjects"), Some(&Source::Model));
assert!(!found.unsettled.iter().any(|f| f == "subjects"));
}
/// The question a model is asked never includes the pages that would
/// mislead it.
#[test]
fn the_question_leaves_out_the_advertising() {
let front = "\
<!-- Page 3 -->
Very Short Introductions available now:
ANARCHISM Colin Ward
THE ELEMENTS Philip Ball
CONSCIOUSNESS Susan Blackmore
ARISTOTLE Jonathan Barnes
COSMOLOGY Peter Coles
<!-- Page 6 -->
Some pages of a preface that names no title at all, running on for long
enough that the classifier takes it for text rather than a title page.
";
let found = read(front, PAGES);
let question = colophon::prompt::compose(&found, front, PAGES).expect("a question");
assert!(!question.contains("THE ELEMENTS"));
assert!(!question.contains("Philip Ball"));
assert!(question.contains("wanted: title"));
}
+92
View File
@@ -6,6 +6,7 @@ this suite, because a real pipeline has a database at the end of it.
- [A shelf of files, sorted before anything else touches it](#a-shelf-of-files-sorted-before-anything-else-touches-it) - [A shelf of files, sorted before anything else touches it](#a-shelf-of-files-sorted-before-anything-else-touches-it)
- [A catalogue out of what the files already say](#a-catalogue-out-of-what-the-files-already-say) - [A catalogue out of what the files already say](#a-catalogue-out-of-what-the-files-already-say)
- [Shelving a library by what its books printed](#shelving-a-library-by-what-its-books-printed)
- [Asking a model, without this suite calling one](#asking-a-model-without-this-suite-calling-one) - [Asking a model, without this suite calling one](#asking-a-model-without-this-suite-calling-one)
- [A scanned book into searchable passages](#a-scanned-book-into-searchable-passages) - [A scanned book into searchable passages](#a-scanned-book-into-searchable-passages)
- [A collected edition into one record per work](#a-collected-edition-into-one-record-per-work) - [A collected edition into one record per work](#a-collected-edition-into-one-record-per-work)
@@ -98,6 +99,97 @@ with:
langid converted/*.md | jq -r 'select(.language != "en") | [.path, .name] | @tsv' langid converted/*.md | jq -r 'select(.language != "en") | [.path, .name] | @tsv'
``` ```
## Shelving a library by what its books printed
A catalogue's subjects come from wherever the file came from, which is to say
from nowhere in particular. A book's own cataloguing block is the one place its
subjects are stated rather than guessed — and stated in Library of Congress
form, which is precise and useless for browsing:
```
$ colophon converted/hobbes.md | jq -r '.subjects[]'
Hobbes, Thomas, 1588–1679. Leviathan
Political science – Early works to 1800
```
Nobody has a shelf called that. Give `colophon` the list you actually file by
and it files them for you:
```
$ cat shelf.txt
Political Science = political science, politics
Byzantine History = byzantium, byzantine, constantinople
Theology = theology, christianity
Ethics
$ colophon --shelf shelf.txt converted/*.md | jq -c '{title, shelved, unshelved}'
{"title":"Leviathan","shelved":["Political Science"],"unshelved":["Hobbes, Thomas, 1588–1679. Leviathan"]}
{"title":"Christianity and Classical Culture","shelved":["Theology"],"unshelved":[]}
```
`unshelved` is the point of the exercise. A subject quietly dropped reads
exactly like a book that printed none, and the difference decides whether you
go looking for a heading your shelf is missing.
**The title page is not evidence on its own.** A scan that begins mid-frontmatter
gives up a running head, a copyright line, or the author's name where the title
should be. What makes a printed record trustworthy is a second one agreeing,
and the ISBN in the same block is how you get one:
```sh
colophon --shelf shelf.txt converted/*.md > printed.ndjson
jq -r 'select(.isbns != null) | .isbns[0]' printed.ndjson \
| idnorm --quiet \
| bibfetch --polite you@example.org > register.ndjson
```
Now two sources that cannot have copied each other have spoken, and you can
say which books are settled and which need a person:
```sh
jq -s -r '
(.[1] | map({key: .id, value: .}) | from_entries) as $reg
| .[0][]
| . as $printed
| ($reg[$printed.isbns[0] // ""] // {}) as $found
| [ $printed.title,
$found.title,
(if $printed.title and $found.title then "corroborated" else "one source" end)
] | @tsv
' printed.ndjson register.ndjson
```
```
The First World War The First World War: A Very Short Introduction corroborated
Document5 The Problems of Philosophy corroborated
Born Fighting — one source
```
The second row is the case worth the whole recipe: a file named `Document5`
whose author reads `-ducati996`, which the book itself and OpenLibrary agree
is Russell's *The Problems of Philosophy*. The third is the honest failure —
one source said something, nothing confirmed it, and it goes to a human.
In process, where a pipeline actually lives:
```python
import catena
shelf = open("shelf.txt").read()
printed = catena.read_colophon(markdown, shelf=shelf)
found = None
if printed["isbns"]:
found = catena.fetch_identifier(printed["isbns"][0], polite="you@example.org")
settled = bool(printed.get("title") and found and found.get("title"))
```
`catena.shelve(shelf, subjects)` files headings a catalogue already holds, by
the same rules, so a record's existing subjects and its book's printed ones are
judged alike.
## Asking a model, without this suite calling one ## Asking a model, without this suite calling one
A tool here may compose a prompt. It does not send one. That keeps every A tool here may compose a prompt. It does not send one. That keeps every
+15
View File
@@ -15,6 +15,7 @@ NASA broadcast, which is a work of the US government.
| [`shell/03-search-and-rerank.sh`](shell/03-search-and-rerank.sh) | Passages into pgvector, then a search that asks twice and reconciles | | [`shell/03-search-and-rerank.sh`](shell/03-search-and-rerank.sh) | Passages into pgvector, then a search that asks twice and reconciles |
| [`rust/`](rust/src/main.rs) | The same book walked through in one process, with no pipe anywhere | | [`rust/`](rust/src/main.rs) | The same book walked through in one process, with no pipe anywhere |
| [`python/book_to_citations.py`](python/book_to_citations.py) | A book to rows a database can take | | [`python/book_to_citations.py`](python/book_to_citations.py) | A book to rows a database can take |
| [`python/shelve_a_library.py`](python/shelve_a_library.py) | What a book says about itself, shelved and checked against a register |
| [`python/search_and_rerank.py`](python/search_and_rerank.py) | The pgvector search, in the language a pipeline is usually written in | | [`python/search_and_rerank.py`](python/search_and_rerank.py) | The pgvector search, in the language a pipeline is usually written in |
| [`typescript/src/episode-to-chapters.ts`](typescript/src/episode-to-chapters.ts) | Captions into chapter marks and deep links for a player | | [`typescript/src/episode-to-chapters.ts`](typescript/src/episode-to-chapters.ts) | Captions into chapter marks and deep links for a player |
| [`typescript/src/search-and-rerank.ts`](typescript/src/search-and-rerank.ts) | Fusing two rankings app-side, and what re-ranking means | | [`typescript/src/search-and-rerank.ts`](typescript/src/search-and-rerank.ts) | Fusing two rankings app-side, and what re-ranking means |
@@ -83,6 +84,20 @@ python examples/python/book_to_citations.py
pip install catena-corpus psycopg pip install catena-corpus psycopg
DATABASE_URL=postgresql://user@host/db \ DATABASE_URL=postgresql://user@host/db \
python examples/python/search_and_rerank.py "why did the empire decline" python examples/python/search_and_rerank.py "why did the empire decline"
`shelve_a_library.py` is the metadata half: it reads the cataloguing block a
book prints in its own front matter, files those subjects onto a closed list
you supply, and asks the register that assigned the ISBN whether it agrees.
```sh
python examples/python/shelve_a_library.py converted/*.md
```
Neither source is worth acting on alone — a scan that starts mid-frontmatter
gives up a running head where the title should be, and an ISBN a catalogue has
been carrying wrongly returns a confidently wrong book. The example prints
`corroborated` only when both said the same thing, and the disagreements are
the interesting output.
``` ```
## TypeScript ## TypeScript
+110
View File
@@ -0,0 +1,110 @@
"""What a book says about itself, checked against what a register says back.
A catalogue's subjects and titles come from wherever its files came from. Two
things can correct that without a model: the cataloguing block the book prints
in its own front matter, and the register that assigned its ISBN. Neither is
trustworthy alone — a scan that starts mid-frontmatter yields a running head
where the title should be, and an ISBN a catalogue has been carrying wrongly
returns a confidently wrong book. Together they are worth acting on.
python examples/python/shelve_a_library.py converted/*.md
With no arguments it reads a book from Project Gutenberg, which prints no
cataloguing block at all — the honest common case, and worth seeing.
"""
import pathlib
import re
import sys
import urllib.request
import catena
# The closed list this library is browsed by. `Heading = term, term` gives the
# words that file a book under it; a heading on its own matches its own name.
SHELF = """
Philosophy = philosophy, philosophical
Ancient Philosophy = ancient philosophy, plato, aristotle
Political Science = political science, politics, government
Theology = theology, christianity, religion
Church History = church history, ecclesiastical history
Byzantine History = byzantium, byzantine, constantinople
History = history, historiography
Literature = literature, fiction, poetry
Psychology = psychology
Ethics
Logic
"""
GUTENBERG = "https://www.gutenberg.org/cache/epub/5827/pg5827.txt"
def sample(into: pathlib.Path) -> str:
"""Locke's *Essay*, which Gutenberg strips the front matter from."""
book = into / "locke.txt"
if not book.exists():
into.mkdir(parents=True, exist_ok=True)
print(f"fetching {GUTENBERG}", file=sys.stderr)
with urllib.request.urlopen(GUTENBERG, timeout=120) as reply: # noqa: S310
book.write_bytes(reply.read())
return book.read_text(encoding="utf-8", errors="replace")
def same(a: str, b: str) -> bool:
"""Whether two printings of one title are the same title.
Compared on letters and digits alone. A register and a title page differ
over case, over a curly apostrophe against a straight one, and over
whether the subtitle is part of the title — none of which is a
disagreement, and all of which a plain string comparison calls one.
"""
fold = lambda s: re.sub(r"[^a-z0-9]+", "", (s or "").lower())
a, b = fold(a), fold(b)
return bool(a) and bool(b) and (a in b or b in a)
def look(text: str, name: str) -> None:
printed = catena.read_colophon(text, shelf=SHELF)
title = printed.get("title")
isbns = printed.get("isbns") or []
print(f"\n{name}")
print(f" printed title {title or '— (no title page in these pages)'}")
if printed.get("authors"):
print(f" printed authors {', '.join(printed['authors'])}")
if printed.get("shelved"):
print(f" shelved under {', '.join(printed['shelved'])}")
for raw in printed.get("unshelved", []):
# Not a failure: a heading the shelf has no place for is how you find
# out the shelf is missing one.
print(f" no shelf for {raw}")
if not printed.get("subjects"):
print(" subjects — (no cataloguing block printed)")
if not isbns:
print(" register — (nothing to look up; one source only)")
return
# The only call here that opens a socket, and it blocks.
found = catena.fetch_identifier(isbns[0], polite="you@example.org")
if not found or not found.get("found"):
print(f" register {isbns[0]} is unknown to every register asked")
return
print(f" register says {found['title']} / {', '.join(found.get('authors') or [])}")
agrees = bool(title) and same(title, found["title"])
print(f" verdict {'corroborated' if agrees else 'two sources disagree — ask a person'}")
def main() -> int:
paths = [pathlib.Path(p) for p in sys.argv[1:]]
if not paths:
look(sample(pathlib.Path("/tmp/catena-example")), "Locke, from Gutenberg")
return 0
for path in paths:
look(path.read_text(encoding="utf-8", errors="replace"), path.name)
return 0
if __name__ == "__main__":
raise SystemExit(main())