diff --git a/scribble-doc/scriblib/scribblings/autobib.scrbl b/scribble-doc/scriblib/scribblings/autobib.scrbl index ea8def2425..721ac8a95d 100644 --- a/scribble-doc/scriblib/scribblings/autobib.scrbl +++ b/scribble-doc/scriblib/scribblings/autobib.scrbl @@ -50,6 +50,11 @@ includes a citation to section 8 of the Racket reference. @history[#:changed "1.61" @elem{Added fields and location types for better bibtex support.}] +@history[#:changed "1.68" + @elem{Improved bibliography layout, added support for + multi-paragraph notes and extended support for + structured content in bibliography fields.}] + @defform/subs[(define-cite ~cite-id citet-id generate-bibliography-id option ...) @@ -158,11 +163,44 @@ optionally given @racket[render-date-expr] functions. Styles for use with @racket[define-cite]. +With @racket[number-style], bibliography entries use hanging +indentation in HTML and LaTeX output, with citation numbers +aligned in a separate label column. Text output instead +separates each number from its entry with a non-breaking space. + The @racket[author+date-square-bracket-style] definition is the same as @racket[author+date-style], except that references to citations are enclosed in @litchar["[]"] instead of @litchar["()"]. -} +In LaTeX output, Scribble tries to keep short bibliography +entries together, reserving at least five lines before starting +an entry. This approximates the previous behavior, which +prevented page breaks within individual entries altogether. +Longer entries may now span pages. + +The @tt{\AutobibNeedlines} counter controls the minimum number of lines, +defaulting to 5. Set it to 0 to disable this constraint. +The optional @tt{needspace} package is required +for the constraint to take effect; +this behavior is disabled if the package is unavailable, +as if the counter were 0. + +The @tt{\AutobibEntrySetup} command, empty by default, +allows additional LaTeX settings to be applied locally +to each bibliography entry. + +To require four lines before each entry and relax line breaking +for long annotations, configure these settings using @tt{\AtBeginDocument} +from e.g. a @racket[tex-addition] that you add to your document's style: + +@racketblock[ +(tex-addition + (bytes-append + #"\\AtBeginDocument{%\n" + #" \\AutobibNeedlines=4\\relax\n" + #" \\renewcommand{\\AutobibEntrySetup}{%\n" + #" \\emergencystretch=2em\n" + #" \\tolerance=1000}}%\n"))]} @defproc[(bib? [v any/c]) boolean?]{ @@ -176,6 +214,7 @@ Returns @racket[#t] if @racket[v] is a value produced by [#:location location any/c #f] [#:date date (or/c #f date? exact-nonnegative-integer? string?) #f] [#:url url (or/c #f string?) #f] + [#:accessed accessed any/c #f] [#:doi doi (or/c #f string?) #f] [#:note note any/c #f]) bib?]{ @@ -187,6 +226,23 @@ supplied. Functions like @racket[proceedings-location], @racket[author-name], and @racket[authors] help produce elements in a standard format. +The @racket[#:note] argument may contain multiple paragraphs, +separated by blank lines. The first paragraph follows the +bibliographic information; subsequent paragraphs remain within +the same bibliography entry. + +When both @racket[#:doi] and @racket[#:url] are supplied, +the DOI takes precedence. +A period is inserted after a DOI when followed by a non-empty note. +No period is appended directly to a URL. + +@racket[#:accessed] gives the date a @racket[#:url] (in CSL terms, the +date it was accessed), and is only used when @racket[#:url] is displayed, +i.e. when no @racket[#:doi] is supplied. It is rendered right after the +URL, separated from it by a space, as @tt{(accessed ...)}. A period is +inserted after it when followed by a non-empty note; a naked URL (no +@racket[#:accessed]) never gets that period. + Dates are internally represented as @racket[date] values, so a @racket[date] may be given, or a number or string that represent the year. @@ -197,13 +253,42 @@ name, the last non-empty sequence of alphabetic characters or @litchar["-"] after a space is treated as the author name, and the rest is treated as the first name. -@history[#:changed "1.49" @elem{Added @racket[#:doi].}]} +@history[#:changed "1.49" @elem{Added @racket[#:doi].}] +@history[#:changed "1.68" + @elem{Added @racket[#:accessed], which replaces the accessed-date + support formerly provided by the now-removed @tt{webpage-location} + function: the accessed date is now attached directly to the + bib entry instead of being embedded in its @racket[#:location], + so it renders next to the URL rather than before the date.}]} @defproc[(in-bib [orig bib?] [where string?]) bib?]{ Extends a bib value so that the rendered citation is suffixed with @racket[where], which might be a page or chapter number.} +Each of the following @tt{*-location} functions combines its arguments +into a single @racket[element?]. @racket[proceedings-location], +@racket[book-location], @racket[booklet-location], @racket[misc-location], +and @racket[manual-location] may legitimately be called by +@racketmodname[scriblib/bibtex] with no useful information at all (e.g. +a BibTeX entry that supplies none of the corresponding optional fields): +each returns @racket[#f] when every one of its arguments is either +omitted (@racket[#f]) or supplied as empty content. The rest each have +at least one genuinely required argument, so they always have something +to report and never return @racket[#f]. + +For a handful of fields across these functions -- an edition, an editor, +a location, a page range, a series number -- @racket[#f] alone isn't +enough to tell ``omitted'' from ``supplied but empty,'' because the +field gets wrapped in surrounding text (e.g. an editor's name becomes +``NAME (Ed.)''; an edition becomes ``EDITION edition''). Omitting the +field (@racket[#f], the default) still quietly contributes nothing, but +explicitly supplying empty or otherwise trivial content for one of +these particular fields (e.g. @racket[""]) raises a contract violation +instead of silently producing an orphaned fragment like ``(Ed.)'' with +no name attached. Each function's entry below says which of its fields +this applies to. + @defproc[(proceedings-location [#:editor editor_ any/c #f] [location any/c] [#:series series any/c #f] @@ -213,14 +298,20 @@ Extends a bib value so that the rendered citation is suffixed with [#:organization organization any/c #f] [#:publisher publisher #f] [#:address address #f]) - element?]{ + (or/c element? #f)]{ -Combines elements to generate an element that is suitable for +Combines the supplied information to produce content suitable for describing a paper's location within a conference or workshop -proceedings. +proceedings. Returns @racket[#f] when every argument is omitted or +empty, except that explicitly-supplied-but-trivial content raises for +@racket[location], @racket[#:editor], @racket[#:number], and +@racket[#:pages] specifically, since each of those is wrapped in +surrounding text (see above). - @history[#:changed "1.61" - @elem{Added fields for bibtex support: editor number organization publisher address.}] +@history[#:changed "1.61" + @elem{Added fields for bibtex support: editor number organization publisher address.}] +@history[#:changed "1.68" + @elem{Added @racket[#f] as a possible result.}] } @defproc[(journal-location [title any/c] @@ -229,8 +320,9 @@ proceedings. [#:pages pages (or (list/c any/c any/c) #f) #f]) element?]{ -Combines elements to generate an element that is suitable for -describing a paper's location within a journal.} +Combines the supplied information to produce content suitable for +describing a paper's location within a journal. +} @defproc[(book-location [#:edition edition any/c #f] @@ -242,9 +334,16 @@ describing a paper's location within a journal.} [#:pages pages any/c #f] [#:publisher publisher any/c #f] [#:address address any/c #f]) - element?]{ -Combines elements to generate an element that is suitable for -describing a book's location. + (or/c element? #f)]{ +Combines the supplied information to produce content suitable for +describing a book's location. Returns @racket[#f] when every argument +is omitted or empty, except that explicitly-supplied-but-trivial +content raises for @racket[#:edition], @racket[#:editor], +@racket[#:number], and @racket[#:pages] specifically (see above). + +A numeric @racket[chapter], supplied as a number or a string +of decimal digits, is prefixed with ``chapter''. +Other chapter content is used unchanged. @history[#:changed "1.61" @elem{Added fields for bibtex support: editor chapter series volume number pages address. @@ -254,28 +353,34 @@ describing a book's location. @defproc[(booklet-location [#:howpublished howpublished any/c #f] [#:address address any/c #f]) - element?]{ -Combines elements to generate an element that is suitable for -describing a booklet's location. + (or/c element? #f)]{ +Combines the supplied information to produce content suitable for +describing a booklet's location. Neither argument is wrapped in +surrounding text, so returns @racket[#f] when both are omitted or +empty, with no exceptions. @history[#:added "1.61"] } @defproc[(misc-location [#:howpublished howpublished any/c #f]) - element?]{ -Combines elements to generate an element that is suitable for -describing a bibtex misc entry's location. - + (or/c element? #f)]{ +Combines the supplied information to produce content suitable for +describing a bibtex misc entry's location. Its one argument isn't +wrapped in surrounding text, so returns @racket[#f] when it's omitted +or empty, with no exception. @history[#:added "1.61"] } @defproc[(manual-location [#:organization organization any/c #f] [#:edition edition any/c #f]) - element?]{ -Combines elements to generate an element that is suitable for -describing a manual's location. + (or/c element? #f)]{ +Combines the supplied information to produce content suitable for +describing a manual's location. Returns @racket[#f] when both +arguments are omitted or empty, except that explicitly-supplied-but- +trivial content raises for @racket[#:edition] specifically (see +above). @history[#:added "1.61"] } @@ -286,8 +391,7 @@ describing a manual's location. [#:number number any/c #f] [#:address address any/c #f]) element?]{ - -Combines elements to generate an element that is suitable for +Combines the supplied information to produce content suitable for describing a technical report's location. @history[#:changed "1.61" @elem{Added fields for bibtex support: type address.}] @@ -298,24 +402,13 @@ describing a technical report's location. [#:type type any/c #f] [#:address address any/c #f]) element?]{ - -Combines elements to generate an element that is suitable for +Combines the supplied information to produce content suitable for describing a dissertation. @history[#:changed "1.61" @elem{Added fields for bibtex support: type address.}] } -@defproc[(webpage-location [url string? #f] - [#:accessed accessed any/c #f]) - element?]{ - Combines elements to generate an element that is suitable for - describing a web page. - - @history[#:changed "1.61" - @elem{Made field url optional now that any autobib entry may have a url.}] -} - @defproc[(book-chapter-location [title any/c] [#:edition edition any/c #f] @@ -328,10 +421,11 @@ describing a dissertation. [#:publisher publisher any/c #f] [#:address address any/c #f]) element?]{ - -Combines elements to generate an element that is suitable for +Combines the supplied information to produce content suitable for describing a paper's location within a chapter or part of a book or collection of books. +The @racket[chapter] argument is formatted as by @racket[book-location]. + @history[#:changed "1.61" @elem{Added fields for bibtex support: editor chapter number address.}] } @@ -369,7 +463,15 @@ one created by @racket[other-authors] renders as ``et al.''} Takes an author-name element and create one that represents the editor of a collection. If a @racket[name] is a string, it is parsed in the -same way as by @racket[make-bib].} +same way as by @racket[make-bib]. + +Raises a contract violation if @racket[name] is empty or otherwise +trivial content (e.g. @racket[""]), rather than silently producing a +name-less ``(Ed.)'' credit. + +@history[#:changed "1.68" + @elem{Raises on empty or trivial content instead of silently + producing a bogus, name-less credit.}]} @defparam[abbreviate-given-names abbreviate? any/c]{ Shortens given names in calls to @racket[author] and @racket[make-bib] diff --git a/scribble-doc/scriblib/scribblings/bibtex.scrbl b/scribble-doc/scriblib/scribblings/bibtex.scrbl index 52e34bdab8..3b3669aad6 100644 --- a/scribble-doc/scriblib/scribblings/bibtex.scrbl +++ b/scribble-doc/scriblib/scribblings/bibtex.scrbl @@ -11,25 +11,42 @@ This library supports parsing BibTeX @litchar{.bib} files. -We support the 14 BibTeX entry types documented -@hyperlink["https://www.openoffice.org/bibliographic/bibtex-defs.html"]{in the LaTeX book} -(1986) Appendix B.2: +We support the 14 standard BibTeX entry types documented in Oren +Patashnik's +@hyperlink["https://ctan.org/pkg/bibtex"]{@italic{BIBTEXing}} +(February 8, 1988), §3.1 "Entry Types" — the definitive reference for +classic BibTeX, which updates Appendix B.2 of Leslie Lamport's +@italic{LaTeX: A Document Preparation System} (1986): @litchar{article}, @litchar{book}, @litchar{booklet}, @litchar{conference}, @litchar{inbook}, @litchar{incollection}, @litchar{inproceedings}, @litchar{manual}, @litchar{mastersthesis}, @litchar{misc}, @litchar{phdthesis}, @litchar{proceedings}, @litchar{techreport}, and @litchar{unpublished}. -We support all the required and optional fields documented in the LaTeX book, -with the following known limitations so far: +Human-readable BibTeX fields are converted from a subset of +LaTeX syntax into Scribble content. This includes grouping +braces, common accent and special-letter commands, +@tt{\emph}, @tt{\texttt}, @tt{\textit}, @tt{\textbf}, +@tt{\textsc} and @tt{\url}. + +Grouping braces are preserved internally where relevant +to BibTeX name parsing. Unknown LaTeX commands and their +attached arguments are retained rather than discarded. +Inline and display mathematics are preserved in LaTeX +syntax; other backends do not translate them into native +mathematical expressions. + +Blank lines in @litchar{note} fields separate paragraphs. + +The @litchar{url} and @litchar{doi} fields are interpreted +as scalar strings rather than general LaTeX content. +Enclosing brace groups are removed and conventional +LaTeX escapes for special URL characters, such as +@litchar{\_}, @litchar{\%} and @litchar{\&}, are unescaped. +Other LaTeX commands are not interpreted in these fields. + +We support all the required and optional fields documented in +@italic{BIBTEXing}, with the following known limitations so far: @itemize[ - @item{We enclose the string parsed from field @litchar{title} in - @racket["(elem #:style (make-style #f '(exact-chars)) title)"], - which will directly include its text in the output. - This will presumably do the Right Thing™ when using the LaTeX backend, - but the wrong thing in the HTML backend.} - @item{Other fields are just parsed as strings, - and may appear as source code rather than as formatted code - in both the LaTeX and HTML backends.} @item{We fail to process @litchar{month}.} @item{We only support @litchar{pages} fields that have decimal numbers separated by one or more dashes.} @@ -38,14 +55,64 @@ with the following known limitations so far: @litchar{isbn}, @litchar{issn}, nor any other non-standard field except those described below.}] -In addition to the old standard entries, we support the often seen +For each standard entry type, the fields that +@hyperlink["https://ctan.org/pkg/bibtex"]{@italic{BIBTEXing}} §3.1 marks +required are enforced at parse time: a missing one raises an error, +rather than being silently treated as absent the way every other field +is. This is: +@itemize[ + @item{@litchar{article}: @litchar{author}, @litchar{title}, + @litchar{journal}, @litchar{year}.} + @item{@litchar{book}: @litchar{author} or @litchar{editor}, + @litchar{title}, @litchar{publisher}, @litchar{year}.} + @item{@litchar{booklet}: @litchar{title}.} + @item{@litchar{conference}: same as @litchar{inproceedings}.} + @item{@litchar{inbook}: @litchar{author} or @litchar{editor}, + @litchar{title}, @litchar{chapter} and/or @litchar{pages}, + @litchar{publisher}, @litchar{year}.} + @item{@litchar{incollection}: @litchar{author}, @litchar{title}, + @litchar{booktitle}, @litchar{publisher}, @litchar{year}.} + @item{@litchar{inproceedings}: @litchar{author}, @litchar{title}, + @litchar{booktitle}, @litchar{year}.} + @item{@litchar{manual}: @litchar{title}.} + @item{@litchar{mastersthesis}: @litchar{author}, @litchar{title}, + @litchar{school}, @litchar{year}.} + @item{@litchar{misc}: none.} + @item{@litchar{phdthesis}: @litchar{author}, @litchar{title}, + @litchar{school}, @litchar{year}.} + @item{@litchar{proceedings}: @litchar{title}, @litchar{year}.} + @item{@litchar{techreport}: @litchar{author}, @litchar{title}, + @litchar{institution}, @litchar{year}.} + @item{@litchar{unpublished}: @litchar{author}, @litchar{title}, + @litchar{note} (no @litchar{year}).}] +An @litchar{author}-or-@litchar{editor} or +@litchar{chapter}-and/or-@litchar{pages} requirement above is enforced +as: an error unless at least one of the two fields is present. + +Note that this is stricter than classic BibTeX itself: per +@italic{BIBTEXing}, "required" is a property of the standard +bibliography styles, not a constraint on the @tt{.bib} file format +itself — a real BibTeX run only warns about a missing required field +and still processes the entry, however poorly formatted the result. +We chose to make it a hard error here instead, on the theory that a +citation silently missing its journal or its year is worse than one +that fails to build at all. + +In addition to the 14 standard entries, we support the often seen @litchar{online} and @litchar{webpage} entry types, for which we support the fields @litchar{url}, @litchar{title}, @litchar{author}. Additionally @litchar{online} has field @litchar{urldate} for the day the site was visited, whereas @litchar{webpage} instead has field @litchar{lastchecked}. - -Also, for every entry type, we support the extra optional fields -@litchar{note}, @litchar{url}, @litchar{doi}. +Neither is among @italic{BIBTEXing}'s standard entry types, so there is +no spec to draw a required-fields list from; but by our own choice, +@litchar{title} and @litchar{url} are required for both (a webpage +citation with neither isn't a citation), while @litchar{author} stays +optional, since most web pages don't have a clean byline. + +Also, for every entry type, we support the extra fields +@litchar{note}, @litchar{url}, @litchar{doi} — all optional, except that +@litchar{url} is required (see above) for @litchar{online} and +@litchar{webpage}, the two entry types it's actually about. But mind that the @litchar{doi} field currently overrides the @litchar{url} in @racketmodname[scriblib/autobib]. @@ -56,6 +123,20 @@ We do support the @litchar["@string"] feature defined in @elem{Support all standard entry types plus @litchar{online} and @litchar{webpage}, all fields but @litchar{month} (or @litchar{type} for @litchar{incollection}), and support @litchar{note}, @litchar{url}, @litchar{doi} on all entry types.}] +@history[#:changed "1.68" + @elem{Added structured LaTeX content parsing, improved + author-name handling, URL and DOI unescaping, + and support for multi-paragraph notes. Started enforcing, + as hard parse-time errors, the fields + @italic{BIBTEXing} marks required for each standard entry + type (see above), including its author-or-editor and + chapter-and/or-pages disjunctions; also require + @litchar{title} and @litchar{url} on @litchar{online} and + @litchar{webpage} entries, by our own choice rather than any + spec. Fixed a latent bug where an @litchar{editor}-only + @litchar{book} or @litchar{inbook} entry (valid, since author + is not required when editor is given) would silently render + a bogus @tt{"#f"} in place of the missing author's name.}] @defform[(define-bibtex-cite bib-pth ~cite-id citet-id generate-bibliography-id option ...)]{ diff --git a/scribble-lib/info.rkt b/scribble-lib/info.rkt index 776b296746..02f092ad2e 100644 --- a/scribble-lib/info.rkt +++ b/scribble-lib/info.rkt @@ -21,7 +21,7 @@ (define pkg-authors '(mflatt eli)) -(define version "1.67") +(define version "1.68") (define license '((Apache-2.0 OR MIT) diff --git a/scribble-lib/scriblib/autobib.css b/scribble-lib/scriblib/autobib.css index 8a0830295c..2252007c84 100644 --- a/scribble-lib/scriblib/autobib.css +++ b/scribble-lib/scriblib/autobib.css @@ -1,9 +1,59 @@ - -.AutoBibliography p { +/* Author/date bibliography entries. + Continuation lines are indented 1em relative to the first line. */ +.AutoBibliography .Autobibentry { padding-left: 1em; +} + +.AutoBibliography .Autobibentry > .SIntrapara:first-child { text-indent: -1em; } +/* Numbered bibliography entries. + The number box and hanging indent have the same width. */ +.AutoBibliography .Autocolbibentry { + padding-left: 7ex; +} + +.AutoBibliography .Autocolbibentry > .SIntrapara:first-child { + text-indent: -7ex; +} + +.AutoBibliography .Autocolbibnumber { + display: inline-block; + box-sizing: border-box; + width: 7ex; + padding-right: 0.15em; + text-indent: 0; + text-align: right; + white-space: nowrap; +} + +/* No extra vertical gap between paragraphs in the same entry: + first-line indentation already distinguishes them. */ +.AutoBibliography .Autobibentry > .SIntrapara, +.AutoBibliography .Autocolbibentry > .SIntrapara { + margin: 0; +} + +.AutoBibliography .Autobibentry > .SIntrapara:not(:first-child), +.AutoBibliography .Autocolbibentry > .SIntrapara:not(:first-child) { + text-indent: 1.5em; +} + +/* More space between entries than between their paragraphs. */ .AutoBibliography td { - vertical-align: text-top; + padding-bottom: 0.5em; +} + +.BibtexSmallCaps { + font-variant: small-caps; +} +.BibtexInlineMath, +.BibtexDisplayMath { + font-family: monospace; + white-space: pre-wrap; +} +.BibtexDisplayMath { + display: block; + text-align: center; } diff --git a/scribble-lib/scriblib/autobib.rkt b/scribble-lib/scriblib/autobib.rkt index 92674a2315..ea1976269c 100644 --- a/scribble-lib/scriblib/autobib.rkt +++ b/scribble-lib/scriblib/autobib.rkt @@ -21,13 +21,19 @@ author-name org-author-name (contract-out [authors (->* (content?) #:rest (listof content?) element?)] + ;; proceedings-location, book-location, booklet-location, misc-location, and + ;; manual-location may legitimately be called by bibtex.rkt with no useful + ;; content at all (e.g. an entry missing every optional bibtex field), so they + ;; keep returning #f in that case. The others all have a genuinely required + ;; argument and so always have something to say: they always return an element. [proceedings-location (->* [any/c] [#:pages (or/c (list/c any/c any/c) #f) #:series any/c #:volume any/c #:number any/c #:editor any/c #:address any/c #:publisher any/c #:organization any/c] - element?)] + (or/c element? #f))] [journal-location - (->* [any/c] [#:pages (or/c (list/c any/c any/c) #f) #:volume any/c #:number any/c] element?)] + (->* [any/c] [#:pages (or/c (list/c any/c any/c) #f) #:volume any/c #:number any/c] + element?)] [book-location (->* [] [#:edition any/c #:chapter any/c #:editor any/c @@ -38,7 +44,8 @@ [misc-location (->* [] [#:howpublished any/c] (or/c element? #f))] [techrpt-location - (->* [#:institution any/c] [#:number any/c #:type any/c #:address any/c] element?)] + (->* [#:institution any/c] [#:number any/c #:type any/c #:address any/c] + element?)] [dissertation-location (->* [#:institution any/c] [#:degree any/c #:type any/c #:address any/c] element?)] @@ -46,9 +53,8 @@ (->* [any/c] [#:edition any/c #:editor any/c #:chapter any/c #:series any/c #:volume any/c #:number any/c #:pages (or/c (list/c any/c any/c) #f) - #:publisher any/c #:address any/c] element?)] - [webpage-location - (->* [] [string? #:accessed any/c] (or/c element? #f))] + #:publisher any/c #:address any/c] + element?)] [manual-location (->* [] [#:organization any/c #:edition any/c] (or/c element? #f))]) other-authors @@ -79,14 +85,17 @@ (make-typ-addition (abs "autobib.typ"))))) (define bib-single-style (make-style "AutoBibliography" autobib-style-extras)) -(define bib-columns-style (make-style #f autobib-style-extras)) -(define bibentry-style (make-style "Autobibentry" autobib-style-extras)) +(define bibentry-style + (make-style "Autobibentry" + (cons (alt-tag "div") autobib-style-extras))) +(define colbibentry-style + (make-style "Autocolbibentry" + (cons (alt-tag "div") autobib-style-extras))) (define bibentrytarget-style (make-style "Autobibtarget" autobib-style-extras)) (define colbibnumber-style (make-style "Autocolbibnumber" autobib-style-extras)) -(define colbibentry-style (make-style "Autocolbibentry" autobib-style-extras)) -(define-struct auto-bib (author date title location url note is-book? doi key specific)) +(define-struct auto-bib (author date title location url accessed note is-book? doi key specific)) (define-struct bib-group (ht)) (define-struct (author-element element) (names cite)) ;; NB: names should always be a string @@ -246,6 +255,7 @@ (define/public (render-citation date-cite i) date-cite) (define/public (render-author+dates author dates) (list* author " " dates)) (define/public (bibliography-line i e) (list e)) + (define/public (bibliography-prefix i) null) (super-new))) (define author+date-style (new author+date-style%)) @@ -260,7 +270,7 @@ (define number-style (new (class object% - (define/public (bibliography-table-style) bib-columns-style) + (define/public (bibliography-table-style) bib-single-style) (define/public (entry-style) colbibentry-style) (define/public (disambiguate-date?) #f) (define/public (collapse-for-date?) #f) @@ -270,10 +280,12 @@ (define/public (get-item-sep) ", ") (define/public (render-citation date-cite i) (number->string i)) (define/public (render-author+dates author dates) dates) + (define/public (bibliography-prefix i) + (make-element + colbibnumber-style + (list "[" (number->string i) "]" 'nbsp))) (define/public (bibliography-line i e) - (list (make-paragraph plain - (make-element colbibnumber-style (list "[" (number->string i) "]"))) - e)) + (list e)) (super-new)))) (define (gen-bib tag group sec-title @@ -310,11 +322,6 @@ (define disambiguated (let () (define (bib->para bib disambiguation i) - (define collect-target - (list (make-target-element - #f - (bib->entry bib style disambiguation render-date-bib i) - `(autobib ,(auto-bib-key bib))))) ;; Communicate to scribble's resolve step. (define (collect ci) ;; store the author @@ -336,11 +343,29 @@ (collect-put! ci `(autobib-disambiguation ,(auto-bib-key bib)) (or disambiguation 'unambiguous))) + (define entry + (bib->entry bib style disambiguation render-date-bib i)) + (define blocks (compound-paragraph-blocks entry)) + (define first-block (car blocks)) + (define marked-first + (make-paragraph + (paragraph-style first-block) + (list + (send style bibliography-prefix i) + (make-collect-element + bibentrytarget-style + (list + (make-target-element + #f + (paragraph-content first-block) + `(autobib ,(auto-bib-key bib)))) + collect)))) (send style bibliography-line i - (make-paragraph plain - (list (make-collect-element bibentrytarget-style collect-target collect))))) + (make-compound-paragraph + (compound-paragraph-style entry) + (cons marked-first (cdr blocks))))) ;; create the bibliography with disambiguations added. (define-values (last num-ambiguous rev-disambiguated*) (for/fold ([last #f] [num-ambiguous 0] [rev-disambiguated '()]) ([bib (in-list bibs)] @@ -385,44 +410,91 @@ null) table)) +;; Build a potentially multi-paragraph entry base on the note field. (define (bib->entry bib style disambiguation render-date-bib i) - (define-values (author date title location url note is-book? doi) + (define-values (author date title location url accessed note is-book? doi) (values (auto-bib-author bib) (auto-bib-date bib) (auto-bib-title bib) (auto-bib-location bib) (auto-bib-url bib) + (auto-bib-accessed bib) (auto-bib-note bib) (auto-bib-is-book? bib) (auto-bib-doi bib))) - (make-element (send style entry-style) - (append - (if author - `(,author - ,@(if (ends-in-punc? author) - '(" ") - '(". "))) - null) - ;; (if is-book? null '(ldquo)) - (if is-book? - (list (italic title)) - (decode-content (list title))) - (if (ends-in-punc? title) - null - '(".")) - ;; (if is-book? null '(rdquo)) - (if location - `(" " ,@(decode-content (list location)) ,(if date "," ".")) - null) - (if date `(" " - ,@(if disambiguation - `(,@(decode-content (list (render-date-bib date))) ,disambiguation) - (decode-content (list (render-date-bib date)))) - ".") - null) - (if (and (not doi) url) `(" " ,[(url-rendering) url]) null) - (if doi `(" " ,[(doi-rendering) doi]) null) - (if note `(" " ,note) null)))) + (define note-blocks + (if (and note + (not (string=? "" (string-trim (content->string note))))) + (note->flow note) + null)) + (define header + (append + (if author + `(,author + ,@(if (ends-in-punc? author) + '(" ") + '(". "))) + null) + ;; (if is-book? null '(ldquo)) + (if title + (append + (if is-book? + (list (italic title)) + (decode-content (list title))) + (if (ends-in-punc? title) + null + '("."))) + null) + ;; (if is-book? null '(rdquo)) + (if location + `(" " ,@(decode-content (list location)) ,(if date "," ".")) + null) + (if date `(" " + ,@(if disambiguation + `(,@(decode-content (list (render-date-bib date))) ,disambiguation) + (decode-content (list (render-date-bib date)))) + ".") + null) + (cond + (doi + `(" " ,[(doi-rendering) doi] + ,@(if (pair? note-blocks) '(".") null))) + (url + ;; do NOT include a . immediately after a naked URL (no accessed date), it's confusing + `(" " ,[(url-rendering) url] + ,@(if accessed + `(" (accessed " ,@(decode-content (list (contentify accessed))) ")" + ,@(if (pair? note-blocks) '(".") null)) + null))) + (else + null)))) + (define first-content + (append header + (if (pair? note-blocks) + (cons " " (paragraph-content (car note-blocks))) + null))) + (make-compound-paragraph + (send style entry-style) + (cons + (make-paragraph plain first-content) + (for/list ([p (in-list (if (pair? note-blocks) + (cdr note-blocks) + null))]) + (make-paragraph + (make-style #f '(never-indents)) + (paragraph-content p)))))) + +(define (note->flow note) + (define (split-lines c) + (cond + [(string? c) + (add-between + (string-split c "\n" #:trim? #f #:repeat? #f) + "\n")] + [(list? c) + (append-map split-lines c)] + [else (list c)])) + (decode-flow (split-lines note))) (define-syntax (define-cite stx) (syntax-parse stx @@ -482,6 +554,7 @@ #:location [location #f] #:date [date #f] #:url [url #f] + #:accessed [accessed #f] #:doi [doi #f] #:note [note #f]) ;; TODO what to do with type?? @@ -490,15 +563,16 @@ [(author-element? author) author] [else (parse-author author)])) (define parsed-date (understand-date date)) - (make-auto-bib author* parsed-date title location url note is-book? doi + (make-auto-bib author* parsed-date title location url accessed note is-book? doi (content->string (make-element #f (append (if author* (list author*) null) - (list title) + (if title (list title) null) (if location (decode-content (list location)) null) (if date (decode-content (list (default-render-date-bib parsed-date))) null) (if (and (not doi) url) (list [(url-rendering) url]) null) + (if (and (not doi) url accessed) (decode-content (list (contentify accessed))) null) (if doi (list [(doi-rendering) doi]) null) (if note (list note) null)))) "")) @@ -510,6 +584,7 @@ (auto-bib-title bib) (auto-bib-location bib) (auto-bib-url bib) + (auto-bib-accessed bib) (auto-bib-note bib) (auto-bib-is-book? bib) (auto-bib-doi bib) @@ -540,11 +615,194 @@ (define (given-names->initials str) (regexp-replace* #rx"(.)[^ ]*( |$)" str "\\1. ")) +;; return content for v, preserve false +(define (contentify v) + (if (or (not v) (content? v)) + v + (format "~a" v))) +;; return string for v, preserve false +(define (stringify v) + (and v (content->string (contentify v)))) + +;; wrap non-#f content as an element, for backward compatibility with +;; the *-location functions' historical result type. Flattens c first, so +;; content that's merely empty (e.g. "") normalizes to #f like an omitted +;; argument does, rather than becoming a visibly-empty but non-#f element. +;; Only call this on results that aren't already elements. +(define (elemify c) + (define fc (flatten-content c)) + (and fc (elem fc))) + +;; Flattens c and returns the (non-#f) flattened content; raises an error +;; if c flattens to #f, i.e. is empty or otherwise trivial content -- as +;; opposed to c simply being #f, which flatten-content also treats as +;; trivial, but which callers use throughout this file to mean "omitted" +;; rather than "supplied but empty." Distinguishing the two matters: an +;; omitted, optional argument should quietly contribute nothing, but an +;; argument that's explicitly supplied yet trivial (e.g. #:editor "") is +;; almost always a caller mistake, and silently swallowing it just produces +;; a bogus fragment (e.g. a stray "(Ed.)" credit with no name) instead of +;; either the intended content or a clear error. +;; what, if given, names the argument/field/function that was expected to +;; be non-trivial, for a more specific error message. +(define (ensure-nontrivial-content c [what "content"]) + (or (flatten-content c) + (raise-argument-error 'autobib (format "non-trivial (non-empty) ~a" what) c))) + (module+ test (require rackunit) (check-equal? (given-names->initials "Matthew") "M. ") (check-equal? (given-names->initials "Matthew R.") "M. R. ") - (check-equal? (given-names->initials "Matthew Raymond") "M. R. ")) + (check-equal? (given-names->initials "Matthew Raymond") "M. R. ") + (check-equal? (content->string (journal-location (bold "Journal of Things"))) "Journal of Things") + (check-equal? (content->string (author-name (italic "Ada") "Lovelace")) "Ada Lovelace") + + (check-false (contentify #f)) + (check-false (stringify #f)) + (check-equal? (contentify 42) "42") + (check-equal? (stringify 42) "42") + (define emphasized (italic "foo")) + (check-eq? (contentify emphasized) emphasized) + (check-equal? (stringify emphasized) "foo") + + (check-false (flatten-content '(#f "" () #f))) + (check-equal? (flatten-content '(("a") #f ("b" "c"))) + '("a" "b" "c")) + (check-equal? + (concatenate-content #:separator ", " + '("foo" "bar") #f "baz") + '("foo" "bar" ", " "baz")) + + (check-equal? (flatten-content 'foo) "foo") + (check-equal? (flatten-content 42) "42") + (check-equal? (content->string (book-location #:edition 'second)) + "Second edition") + + (check-equal? (capitalize-content "second") "Second") + (check-equal? (capitalize-content '("" "second")) "Second") + (define emphasized-second (emph "second")) + (check-eq? (capitalize-content emphasized-second) + emphasized-second) + (check-equal? + (capitalize-content (list emphasized-second " edition")) + (list emphasized-second " edition")) + (check-equal? + (content->string (book-location #:edition "second")) + "Second edition") + + (define no-note + (make-bib #:title "Title" #:doi "10.1234/foo")) + (define with-note + (make-bib #:title "Title" + #:doi "10.1234/foo" + #:note "A note")) + (define (entry-first-text bib) + (content->string + (paragraph-content + (car (compound-paragraph-blocks + (bib->entry bib author+date-style #f + default-render-date-bib 1)))))) + (check-equal? (entry-first-text no-note) + "Title. doi:10.1234/foo") + ;; #:title is a mandatory keyword, but its value may still be #f -- e.g. a + ;; bibtex misc entry has no required fields at all. make-bib must be able + ;; to construct such a bib, and bib->entry must be able to render it, + ;; without a title. + (check-not-exn (λ () (make-bib #:title #f #:doi "10.1234/foo"))) + (check-equal? (entry-first-text (make-bib #:title #f #:doi "10.1234/foo")) + " doi:10.1234/foo") + (check-equal? (entry-first-text with-note) + "Title. doi:10.1234/foo. A note") + (check-equal? + (entry-first-text (make-bib #:title "Title" #:doi "10.1/x" #:note "")) + "Title. doi:10.1/x") + (check-equal? + (entry-first-text (make-bib #:title "Title" #:doi "10.1/x" #:note " \n ")) + "Title. doi:10.1/x") + (check-equal? + (entry-first-text (make-bib #:title "Title" + #:url "https://example.org" + #:note "A note")) + "Title. https://example.org A note") + (check-equal? + (entry-first-text (make-bib #:title "Title" + #:url "https://example.org" + #:accessed "January 2024")) + "Title. https://example.org (accessed January 2024)") + (check-equal? + (entry-first-text (make-bib #:title "Title" + #:url "https://example.org" + #:accessed "January 2024" + #:note "A note")) + "Title. https://example.org (accessed January 2024). A note") + (check-equal? + (entry-first-text (make-bib #:title "Title" + #:doi "10.1234/foo" + #:url "https://example.org" + #:accessed "January 2024" + #:note "A note")) + "Title. doi:10.1234/foo. A note") + ;; journal-location, techrpt-location, and book-chapter-location are contracted to + ;; always return an element when given #f for their required argument (i.e. + ;; supplying no real information), via ensure-nontrivial-content; see + ;; scribble-test/tests/scriblib/autobib.rkt for the check-exn versions of this + ;; going through the exported, contracted bindings instead. + ;; proceedings-location, book-location, booklet-location, misc-location, and + ;; manual-location may gracefully return #f when every argument is #f or + ;; empty content -- but only for plain (undecorated) fields: an explicitly + ;; empty value for a decorated one (edition, editor, location) is a caller + ;; mistake and raises via ensure-nontrivial-content instead, since silently + ;; swallowing it would otherwise leave orphaned decoration (e.g. a stray + ;; "(Ed.)" credit with no name) in the output. + (check-false (proceedings-location #f)) + (check-exn exn:fail:contract? (λ () (proceedings-location ""))) + (check-equal? (content->string (proceedings-location #f #:publisher "ACM")) + "ACM") + (check-false (book-location)) + (check-exn exn:fail:contract? (λ () (book-location #:edition ""))) + (check-false (booklet-location)) + (check-false (booklet-location #:howpublished "" #:address "")) + (check-false (misc-location)) + (check-false (misc-location #:howpublished "")) + (check-false (manual-location)) + (check-false (manual-location #:organization "")) + (check-exn exn:fail:contract? (λ () (manual-location #:edition ""))) + (check-equal? + (content->string + (book-location + #:edition "second" + #:chapter 3 + #:series "LNCS" + #:volume 42 + #:number 7 + #:pages '(10 20) + #:publisher "Springer" + #:address "Berlin")) + "Second edition, chapter 3, LNCS, 42(7), pp. 10--20. Springer, Berlin") + (check-equal? + (content->string (book-location #:chapter 3)) + "Chapter 3") + (check-equal? + (content->string + (book-location #:edition "second" #:chapter 3)) + "Second edition, chapter 3") + + (define multi-note + (make-bib + #:title "Title" + #:note "First paragraph.\n\nSecond paragraph.")) + (define entry + (bib->entry multi-note author+date-style #f + default-render-date-bib 1)) + (check-true (compound-paragraph? entry)) + (define paragraphs (compound-paragraph-blocks entry)) + (check-equal? (length paragraphs) 2) + (check-equal? + (content->string (paragraph-content (first paragraphs))) + "Title. First paragraph.") + (check-equal? + (content->string (paragraph-content (second paragraphs))) + "Second paragraph.")) (define (proceedings-location #:editor [editor_ #f] @@ -556,46 +814,69 @@ #:organization [organization #f] #:publisher [publisher #f] #:address [address #f]) - (concatenate-elements - (concatenate-elements - @elem{In } - (concatenate-elements + (define location-content* + (and location (italic (ensure-nontrivial-content location "proceedings-location's location argument")))) + (define details + (concatenate-content #:separator ", " (and editor_ (editor editor_)) - @italic{@elem{Proc. @to-string[location]}} - (series-volume-number-pages-element series volume number pages))) - #:separator ". " - (organization-publisher-address-element organization publisher address))) + (and location-content* @list{Proc. @location-content*}) + (series-volume-number-pages-content series volume number pages))) + (elemify + (concatenate-content + (and details + (if location-content* + (concatenate-content "In " details) + details)) + #:separator ". " + (organization-publisher-address-content organization publisher address)))) (define (journal-location location #:volume [volume #f] #:number [number #f] #:pages [pages #f]) - (concatenate-elements - @italic{@to-string[location]} - #:separator " " - (series-volume-number-pages-element #f volume number pages))) - -;; The URL is now redundant with the URL in make-bib, so we now (2025-12) make it optional -(define (webpage-location (url #f) #:accessed [accessed #f]) - (concatenate-elements - (and url ((url-rendering) url)) - #:separator " " - (and accessed @elem{(accessed @to-string[accessed])}))) - -(define (string-capitalize str) - (if (non-empty-string? str) - (let ([chars (string->list str)]) - (list->string (cons (char-upcase (car chars)) (cdr chars)))) - str)) - -(define (concatenate-elements #:separator (separator "") . elements) - (let loop ((l (filter (lambda (x) x) elements))) ;; remove #f from the list - (match l - ['() #f] - [(list a) (elem a)] - [(cons a b) (elem a separator (loop (cdr l)))]))) + (elemify + (concatenate-content + @italic[(ensure-nontrivial-content location "journal-location's title argument")] + #:separator " " + (series-volume-number-pages-content #f volume number pages)))) + +(define (capitalize-string s) + (string-append + (string (char-upcase (string-ref s 0))) + (substring s 1))) + +(define (capitalize-content content) + (match (flatten-content content) + [(? non-empty-string? s) (capitalize-string s)] + [(cons (? non-empty-string? s) r) (cons (capitalize-string s) r)] + [x x])) + +(define (flatten-content content) + (define a '()) + (let loop ((c content)) + (cond + [(pair? c) (loop (car c)) (loop (cdr c))] + [(or (null? c) (not c) (void? c) (equal? c "")) (void)] + [else (set! a (cons (contentify c) a))])) + (match a + ['() #f] + [(list x) x] + [else (reverse a)])) + +(define (concatenate-content #:separator (separator #f) . content) + (define l (filter values (map flatten-content content))) + (and (pair? l) (flatten-content (if separator (add-between l separator) l)))) + +(define (chapter-content chapter) + (cond + [(number? chapter) + (list "chapter " (number->string chapter))] + [(and (string? chapter) + (regexp-match? #px"^[0-9]+$" chapter)) + (list "chapter " chapter)] + [else chapter])) (define (book-location #:edition [edition #f] @@ -607,53 +888,63 @@ #:pages [pages #f] #:publisher [publisher #f] #:address [address #f]) - (concatenate-elements - (concatenate-elements - #:separator ", " - (edition-element edition) - (to-string* chapter) - (and editor_ (editor editor_)) - (series-volume-number-pages-element series volume number pages)) - #:separator ". " - (organization-publisher-address-element #f publisher address))) + (define edition-content* (edition-content edition)) + (define chapter-content* (chapter-content chapter)) + (elemify + (concatenate-content + (concatenate-content + #:separator ", " + edition-content* + (if edition-content* + chapter-content* + (capitalize-content chapter-content*)) + (and editor_ (editor editor_)) + (series-volume-number-pages-content series volume number pages)) + #:separator ". " + (organization-publisher-address-content #f publisher address)))) (define (booklet-location #:howpublished [howpublished #f] #:address [address #f]) - (concatenate-elements #:separator ". " - (to-string* howpublished) - (to-string* address))) + (elemify + (concatenate-content #:separator ". " + howpublished + address))) (define (misc-location #:howpublished [howpublished #f]) - (and howpublished (elem (to-string howpublished)))) + (elemify (and howpublished (contentify howpublished)))) (define (manual-location #:organization [organization #f] #:edition [edition #f]) - (concatenate-elements - (edition-element edition) - #:separator ", " - (to-string* organization))) + (elemify + (concatenate-content + (edition-content edition) + #:separator ", " + organization))) (define (techrpt-location #:institution institution #:type [type #f] #:number [number #f] #:address [address #f]) - (concatenate-elements #:separator ", " - (to-string* institution) (to-string* type) (to-string* number) (to-string* address))) + (elemify + (concatenate-content #:separator ", " + (ensure-nontrivial-content institution "techrpt-location's institution argument") + type number address))) (define (dissertation-location #:institution institution #:degree [degree "PhD"] #:type [type #f] #:address [address #f]) - (concatenate-elements #:separator ", " - @elem{@to-string[degree] dissertation} - (to-string institution) - (to-string* type) - (to-string* address))) + (elemify + (concatenate-content #:separator ", " + @list{@(ensure-nontrivial-content degree "degree argument") dissertation} + (ensure-nontrivial-content institution "dissertation-location's institution argument") + type + address))) (define (book-chapter-location location @@ -666,30 +957,35 @@ #:pages [pages #f] #:publisher [publisher #f] #:address [address #f]) - (concatenate-elements #:separator " " - @elem{In @italic{@elem{@to-string[location]}}} - (book-location #:edition edition #:chapter chapter #:editor editor_ - #:series series #:volume volume #:number number #:pages pages - #:publisher publisher #:address address))) + (elemify + (concatenate-content #:separator " " + @list{In @italic[(ensure-nontrivial-content location "book-chapter-location's location argument")]} + (book-location #:edition edition #:chapter chapter #:editor editor_ + #:series series #:volume volume #:number number #:pages pages + #:publisher publisher #:address address)))) ;; ---------------------------------------- (define (author-name first last #:suffix [suffix #f]) + (define first* (contentify first)) + (define last* (contentify last)) + (define suffix* (contentify suffix)) + + ;; Plain-text projections are needed for sorting. + (define first-string (stringify first*)) + (define last-string (stringify last*)) + (define suffix-string (stringify suffix*)) (make-author-element #f - (list - (format "~a ~a~a" - (if (abbreviate-given-names) - (given-names->initials first) - first) - last - (if suffix - (format " ~a" suffix) - ""))) - (format "~a ~a~a" last first (if suffix - (format " ~a" suffix) - "")) - last)) + (concatenate-content #:separator " " + (if (abbreviate-given-names) + (given-names->initials first-string) + first*) + last* + suffix*) + (format "~a ~a~a" last-string first-string + (if suffix-string (format " ~a" suffix-string) "")) + last*)) (define (org-author-name org) (make-author-element @@ -744,30 +1040,29 @@ (let ([name (parse-author name)]) (make-author-element #f - (append (element-content name) - '(" (Ed.)")) + (list (ensure-nontrivial-content (element-content name) "editor name") + " (Ed.)") (author-element-names name) (author-element-cite name)))) -(define (to-string v) (format "~a" v)) -(define (to-string* v) (and v (to-string v))) -(define (edition-element edition) - (and edition @elem{@(string-capitalize (to-string edition)) edition})) -(define (pages-element pages) - (and pages @elem{pp. @(to-string (car pages))--@(to-string (cadr pages))})) -(define (series-volume-number-pages-element series volume number pages) - (concatenate-elements - (to-string* series) +(define (edition-content edition) + (and edition + @list{@(ensure-nontrivial-content (capitalize-content edition) "edition argument") edition})) +(define (pages-content pages) + (and pages @elem{pp. @(ensure-nontrivial-content (car pages) "pages start")--@(ensure-nontrivial-content (cadr pages) "pages end")})) +(define (series-volume-number-pages-content series volume number pages) + (concatenate-content + series #:separator ", " - (concatenate-elements - (to-string* volume) - (and number @elem{(@to-string[number])})) - (pages-element pages))) -(define (organization-publisher-address-element organization publisher address) - (concatenate-elements - (to-string* organization) + (concatenate-content + volume + (and number @list{(@(ensure-nontrivial-content number "number argument"))})) + (pages-content pages))) +(define (organization-publisher-address-content organization publisher address) + (concatenate-content + organization #:separator ". " - (concatenate-elements - (to-string* publisher) + (concatenate-content + publisher #:separator ", " - (to-string* address)))) + address))) diff --git a/scribble-lib/scriblib/autobib.tex b/scribble-lib/scriblib/autobib.tex index 8e95ce9711..b3342922b3 100644 --- a/scribble-lib/scriblib/autobib.tex +++ b/scribble-lib/scriblib/autobib.tex @@ -1,15 +1,81 @@ +\IfFileExists{needspace.sty}{% + \usepackage{needspace}% +}{% + \providecommand{\Needspace}[1]{}% +} -\newenvironment{AutoBibliography}{\begin{small}}{\end{small}} -\newcommand{\Autobibentry}[1]{\hspace{0.05\linewidth}\parbox[t]{0.95\linewidth}{\parindent=-0.05\linewidth#1\vspace{1.0ex}}} -\newcommand{\Autobibtarget}[1]{\phantomsection#1} +\newenvironment{AutoBibliography} + {\begin{small}} + {\end{small}} -\usepackage{calc} -\newlength{\ABcollength} -\newcommand{\Autocolbibnumber}[1]{\parbox[t]{5ex}{\hfill#1~~\vspace{1.0ex}}} -\newcommand{\Autocolbibentry}[1]{\setlength{\ABcollength}{\linewidth-5ex}\parbox[t]{\ABcollength}{#1\vspace{1.0ex}}} +% Minimum number of lines that must remain before starting an entry. +\newcount\AutobibNeedlines +\AutobibNeedlines=5 -\newcommand{\Autobibref}[1]{#1} +% Optional per-entry LaTeX layout adjustments. +\newcommand{\AutobibEntrySetup}{} + +% Hanging indentation of ordinary bibliography entries. +\newlength{\ABindent} +\setlength{\ABindent}{0.05\linewidth} + +% Additional indentation of subsequent paragraph first lines. +\newlength{\AutobibParindent} +\setlength{\AutobibParindent}{1.5em} + +% Width reserved for the number in a numbered bibliography. +\newlength{\AutobibNumberWidth} +\setlength{\AutobibNumberWidth}{5ex} +\newenvironment{Autobibentry}{% + \par + \ifnum\AutobibNeedlines>0 + \Needspace{\AutobibNeedlines\baselineskip}% + \fi + \begingroup + \AutobibEntrySetup + \leftskip=\ABindent + \parindent=\AutobibParindent + \parskip=0pt + \noindent + \hspace*{-\ABindent}% + \ignorespaces +}{% + \par + \endgroup + \addvspace{1ex}% +} + +\newenvironment{Autocolbibentry}{% + \par + \ifnum\AutobibNeedlines>0 + \Needspace{\AutobibNeedlines\baselineskip}% + \fi + \begingroup + \AutobibEntrySetup + \leftskip=\AutobibNumberWidth + \parindent=\AutobibParindent + \parskip=0pt + \noindent + \hspace*{-\AutobibNumberWidth}% + \ignorespaces +}{% + \par + \endgroup + \addvspace{1ex}% +} + +\newcommand{\Autocolbibnumber}[1]{% + \makebox[\AutobibNumberWidth][r]{#1\hspace{0.25em}}% + \ignorespaces +} +\newcommand{\Autobibtarget}[1]{\phantomsection#1} +\newcommand{\Autobibref}[1]{#1} \providecommand{\AutobibLink}[1]{#1} \newcommand{\pseudodoi}[1]{#1} + +\providecommand{\BibtexGroup}[1]{{#1}} +\providecommand{\BibtexSmallCaps}[1]{\textsc{#1}} +\providecommand{\BibtexInlineMath}[1]{#1} +\providecommand{\BibtexDisplayMath}[1]{#1} diff --git a/scribble-lib/scriblib/autobib.typ b/scribble-lib/scriblib/autobib.typ index e7a87d7c24..054f6e61a9 100644 --- a/scribble-lib/scriblib/autobib.typ +++ b/scribble-lib/scriblib/autobib.typ @@ -6,3 +6,7 @@ #let Autobibref(body) = body #let AutobibLink(body) = body #let pseudodoi(body) = body +#let BibtexGroup(body) = body +#let BibtexSmallCaps(body) = smallcaps(body) +#let BibtexInlineMath(body) = body +#let BibtexDisplayMath(body) = body diff --git a/scribble-lib/scriblib/bibtex.rkt b/scribble-lib/scriblib/bibtex.rkt index 7bedc96f69..d3ff07380e 100644 --- a/scribble-lib/scriblib/bibtex.rkt +++ b/scribble-lib/scriblib/bibtex.rkt @@ -2,9 +2,18 @@ (require racket/function racket/match racket/list - racket/string) + racket/string + scriblib/autobib + scribble/core + scribble/manual + "private/read-latex.rkt") -;; Spec but not official: https://www.openoffice.org/bibliographic/bibtex-defs.html +;; The Spec we are following is Oren Patashnik, BIBTEXing (1988), §3.1: https://mirrors.mit.edu/CTAN/biblio/bibtex/base/btxdoc.pdf +;; Same in HTML: https://www.openoffice.org/bibliographic/btxdoc.html +;; Source in LaTeX: https://mirrors.mit.edu/CTAN/biblio/bibtex/base/btxdoc.tex +;; Spec excerpt in HTML: https://www.openoffice.org/bibliographic/bibtex-defs.html +;; More: https://ctan.org/tex-archive/biblio/bibtex/base +;; Examples: https://mirrors.mit.edu/CTAN/biblio/bibtex/base/xampl.bib ;; Informal spec: https://www.bibtex.com/g/bibtex-format/ ;; More incomplete spec: https://www.bibtex.org/Format/ https://www.bibtex.org/SpecialSymbols/ ;; Examples for test suite: https://www.bibtex.com/e/entry-types/ @@ -35,7 +44,7 @@ (read-while (negate pred) ip)) (define (slurp-whitespace ip) - (read-while (λ (c) (and (char? c) (char-whitespace? c))) ip)) + (read-while char-whitespace? ip)) (define (read-entries ip) (slurp-whitespace ip) @@ -131,17 +140,40 @@ (char=? c #\}))) ip))) - (define (read-braced-value ip) - (read-char ip) - (let loop () - (define first-part (read-until (λ (c) (or (char=? c #\{) (char=? c #\}))) - ip)) - (match (peek-char ip) + (define (read-delimited-value ip terminator) + (define out (open-output-string)) + (let loop ([depth 0]) + (match (read-char ip) + [(? eof-object?) + (perror ip 'read-value "Unexpected EOF in delimited value")] + [#\\ + (write-char #\\ out) + (define next (read-char ip)) + (when (eof-object? next) + (perror ip 'read-value "Unexpected EOF after backslash")) + (write-char next out) + (loop depth)] [#\{ - (string-append first-part (read-braced-value ip) (loop))] + (write-char #\{ out) + (loop (add1 depth))] [#\} - (read-char ip) - first-part]))) + (cond + [(positive? depth) + (write-char #\} out) + (loop (sub1 depth))] + [(eqv? terminator #\}) (get-output-string out)] + [else (perror ip 'read-value "Unexpected closing brace")])] + [#\" + (if (and (zero? depth) (eqv? terminator #\")) + (get-output-string out) + (begin (write-char #\" out) (loop depth)))] + [c + (write-char c out) + (loop depth)]))) + + (define (read-braced-value ip) + (read-char ip) + (read-delimited-value ip #\})) (define (read-value ip) (slurp-whitespace ip) @@ -161,20 +193,12 @@ (read-braced-value ip)] [#\" (read-char ip) - (let loop () - (define first-part (read-until (λ (c) (or (char=? c #\{) (char=? c #\"))) - ip)) - (match (peek-char ip) - [#\{ - (string-append first-part (read-braced-value ip) (loop))] - [#\" - (read-char ip) - first-part]))] + (read-delimited-value ip #\")] [(? char-numeric?) (read-while char-numeric? ip)] [(? char-alphabetic?) (define string-tag (read-until (λ (c) (or (char-whitespace? c) - (char=? c #\,))) + (memv c '(#\, #\} #\#)))) ip)) (hash-ref STRING-DB string-tag (λ () string-tag))] @@ -194,9 +218,6 @@ (bibtex-parse (current-input-port))))) bibdb) -(require scriblib/autobib - scribble/manual) - (define-syntax-rule (define-bibtex-cite bib-pth ~cite-id citet-id generate-bibliography-id . options) @@ -224,53 +245,504 @@ (define ~cite-id (make-citer bibtex-db autobib-cite)) (define citet-id (make-citer bibtex-db autobib-citet)))) -;; Seems a little redundant to convert latex escapes into unicode only to -;; convert them back into latex, but we need to sort authors so we can't -;; leave them as literal-chars. -(define (latex-to-unicode str) - ; This is probably defined somewhere... - ; NOTE: Incomplete. Please file PR if you need more. - (define converts - '(("\\'\\i" . "ı́") - ("\\\"u" . "ü") - ("\\\"o" . "ö") - ("\\\"i" . "ï") - ("\\'i" . "í") - ("\\i" . "ı") - ("\\'a" . "á") - ("\\'A" . "Á") - ("\\~a" . "ã") - ("\\`a" . "À") - ("\\~A" . "Ã"))) - (for/fold ([str str]) - ([p converts]) - (string-replace str (car p) (cdr p)))) +(struct name-word (content) #:transparent) + +(define (name-tokens content) + (define tokens null) + (define word-parts null) + (define text (open-output-string)) + + (define (flush-text!) + (define s (get-output-string text)) + (unless (string=? s "") + (set! word-parts (cons s word-parts))) + (set! text (open-output-string))) + + (define (flush-word!) + (flush-text!) + (when (pair? word-parts) + (define parts (reverse word-parts)) + (set! tokens + (cons (name-word (if (null? (cdr parts)) (car parts) parts)) + tokens)) + (set! word-parts null))) + + (define (delimiter! token) + (flush-word!) + (unless (and (eq? token 'space) + (pair? tokens) + (eq? (car tokens) 'space)) + (set! tokens (cons token tokens)))) + + (for ([part (in-list (if (list? content) content (list content)))]) + (cond + [(string? part) + (for ([c (in-string part)]) + (cond [(char-whitespace? c) (delimiter! 'space)] + [(char=? c #\,) (delimiter! 'comma)] + [else (write-char c text)]))] + [else + (flush-text!) + (set! word-parts (cons part word-parts))])) + (flush-word!) + (reverse tokens)) + +(define (trim-name-tokens tokens) + (define (space? token) (eq? token 'space)) + (reverse (dropf (reverse (dropf tokens space?)) space?))) + +(define (split-name-tokens tokens delimiter?) + (let loop ([tokens tokens] [part null] [parts null]) + (cond + [(null? tokens) + (reverse (cons (trim-name-tokens (reverse part)) parts))] + [(delimiter? (car tokens)) + (loop (cdr tokens) null + (cons (trim-name-tokens (reverse part)) parts))] + [else (loop (cdr tokens) (cons (car tokens) part) parts)]))) + +(define (and-word? token) + (and (name-word? token) + (string? (name-word-content token)) + (string=? (name-word-content token) "and"))) + +(define (split-authors tokens) + (let loop ([tokens tokens] [part null] [parts null]) + (match tokens + ['() (reverse (cons (trim-name-tokens (reverse part)) parts))] + [(list* 'space (? and-word?) 'space rest) + (loop rest null (cons (trim-name-tokens (reverse part)) parts))] + [(cons first rest) (loop rest (cons first part) parts)]))) + +(define (join-name-words words) + (match words + ['() ""] + [(list one) one] + [_ (if (andmap string? words) + (string-join words " ") + (add-between words " "))])) + +(define (lowercase-initial? word) + (define s (content->string word)) + (for/first ([c (in-string s)] #:when (char-alphabetic? c)) + (char-lower-case? c))) + +(define (parse-one-author tokens) + (define parts + (for/list ([part (in-list (split-name-tokens tokens + (λ (token) (eq? token 'comma))))]) + (for/list ([token (in-list part)] #:when (name-word? token)) + (name-word-content token)))) + (match parts + [(list (list name)) (org-author-name name)] + [(list (list)) (error 'parse-author "empty BibTeX author")] + [(list words) + (define von-index + (for/first ([word (in-list (drop-right words 1))] + [i (in-naturals)] + #:when (lowercase-initial? word)) + i)) + (if von-index + (author-name (join-name-words (take words von-index)) + (join-name-words (drop words von-index))) + (author-name (join-name-words (drop-right words 1)) + (last words)))] + [(list last first) + (author-name (join-name-words first) (join-name-words last))] + [(list last suffix first) + (author-name (join-name-words first) + (join-name-words last) + #:suffix (join-name-words suffix))] + [_ (error 'parse-author "invalid BibTeX name ~e" parts)])) (define (parse-author as) (and as - (apply authors - (for/list ([a (in-list (regexp-split #px"\\s+and\\s+" as))]) - (define (trim s) - (string-trim (regexp-replace #px"\\s+" s " "))) - (match (latex-to-unicode a) - [(pregexp #px"^(.*),(.*),(.*)$" (list _ two suffix one)) - (author-name (trim one) (trim two) #:suffix (trim suffix))] - [(pregexp #px"^(.*),(.*)$" (list _ two one)) - (author-name (string-trim one) (string-trim two))] - [(pregexp #px"^(.*?)\\s+(\\p{Ll}[^\\s]*(\\s+\\p{Ll}[^\\s]*)*)\\s+(.*)$" (list _ one von-like _ two)) - (author-name (string-trim one) - (string-append (string-trim von-like) " " (string-trim two)))] - [space-separated - (match (regexp-split #px"\\s+" space-separated) - [(list one) (org-author-name one)] - [(list one two) (author-name one two)] - [(list-rest first rest) - (author-name (apply string-append (add-between (cons first (drop-right rest 1)) - " ")) - (last rest))])]))))) + (apply authors + (for/list ([tokens (in-list + (split-authors (name-tokens (latex->content as))))]) + (parse-one-author tokens))))) + +(define (parse-pages ps) + (match ps + [(regexp #rx"^([0-9]+)[-—–]+([0-9]+)$" (list _ f l)) ;; NB: mind the Unicode dashes + (list f l)] + [#f + #f] + [_ + (error 'parse-pages "Invalid page format ~e" ps)])) + +(define (ungroup-scalar value) + ;; Remove only balanced brace groups enclosing the *entire* value. + ;; Respect backslash-escaped braces and retain embedded brace groups. + (define (outer-group? s) + (define n (string-length s)) + (and (>= n 2) + (char=? (string-ref s 0) #\{) + (let loop ([i 0] [depth 0]) + (cond + [(= i n) #f] + [(char=? (string-ref s i) #\\) + (and (< (add1 i) n) + (loop (+ i 2) depth))] + [(char=? (string-ref s i) #\{) + (loop (add1 i) (add1 depth))] + [(char=? (string-ref s i) #\}) + (and (positive? depth) + (if (= depth 1) + (= i (sub1 n)) + (loop (add1 i) (sub1 depth))))] + [else (loop (add1 i) depth)])))) + (if (string? value) + (let loop ([s value]) + (if (outer-group? s) + (loop (substring s 1 (sub1 (string-length s)))) + s)) + value)) + +(define (generate-bib db key) + (match-define (bibdb raw bibs) db) + (hash-ref! bibs (string-foldcase key) + (λ () + (define the-raw (hash-ref raw (string-foldcase key) (λ () (error 'bibtex "Unknown citation ~e" key)))) + (define (raw-attr a [def #f]) + (hash-ref the-raw a def)) + (define (raw-attr* a) + (hash-ref the-raw a + (λ () (error 'bibtex "Key ~a is missing attribute ~a, has ~a" + key a the-raw)))) + (define (scalar-attr a [def #f]) + (ungroup-scalar (raw-attr a def))) + (define (scalar-attr* a) + (ungroup-scalar (raw-attr* a))) + (define (url-attr a [def #f]) + (define raw (raw-attr a def)) + (and raw (unescape-url (ungroup-scalar raw)))) + (define (url-attr* a) + (unescape-url (ungroup-scalar (raw-attr* a)))) + ;; #f (not a parse of #f!) when the attribute is absent, like + ;; content-attr/url-attr/scalar-attr. This matters wherever an + ;; author-ish field is legitimately optional -- e.g. "editor" + ;; wherever it's not required, or "author" on book/inbook where + ;; "editor" may stand in for it (see require-one-of below): + ;; parse-author on #f doesn't error, it silently produces a + ;; bogus one-word "#f" author/editor credit in the output. + (define (author-attr a) + (define raw (raw-attr a)) + (and raw (parse-author raw))) + (define (author-attr* a) + (parse-author (raw-attr* a))) + (define (pages-attr a) + (parse-pages (scalar-attr a))) + (define (content-attr a [def #f]) + (latex->content (hash-ref the-raw a def))) + (define (content-attr* a) + (latex->content + (hash-ref the-raw a + (λ () (error 'bibtex "Key ~a is missing attribute ~a, has ~a" + key a the-raw))))) + ;; Per the LaTeX book's disjunctive requirements (e.g. author-or-editor, + ;; chapter-and/or-pages), raise unless at least one of the given + ;; attributes is present. + (define (require-one-of . as) + (unless (ormap (λ (a) (hash-has-key? the-raw a)) as) + (error 'bibtex "Key ~a is missing at least one of attributes ~a, has ~a" + key as the-raw))) + (match (raw-attr 'type) + ;; TODO: eid replaces pages for online journals + ;; TODO: add isbn for books (inbooks, proceedings, inproceedings?) + ;; TODO: add issn for periodicals (?) + ;; TODO: add optional urldate everywhere? + ;; TODO: add keywords everywhere as in biblatex? + ["article" ;; An article from a journal or magazine. + (make-bib + #:type 'article + ;; required: + #:author (author-attr* "author") + #:title (content-attr* "title") + #:date (scalar-attr* "year") ;; TODO: optional month + #:location (journal-location + (content-attr* "journal") + ;; optional: + #:pages (pages-attr "pages") + #:number (content-attr "number") + #:volume (content-attr "volume")) + ;; optional: + #:note (content-attr "note") + ;; extra: (WHERE IS THAT SPECIFIED?) + #:url (url-attr "url") + #:doi (url-attr "doi"))] + ["book" ;; A book with an explicit publisher. + (require-one-of "author" "editor") + (make-bib + #:type 'book + #:is-book? #t + ;; required: + #:author (author-attr "author") ;; author OR editor is required, checked above + #:title (content-attr* "title") + #:date (scalar-attr* "year") ;; TODO: optional month + #:location (book-location + #:publisher (content-attr* "publisher") + ;; optional: + #:editor (author-attr "editor") ;; see above + #:volume (content-attr "volume") ;; volume OR number + #:number (content-attr "number") + #:series (content-attr "series") + #:address (content-attr "address") + #:edition (content-attr "edition")) + ;; optional: + #:note (content-attr "note") + ;; extra: (WHERE IS THAT SPECIFIED?) + #:url (url-attr "url") + #:doi (url-attr "doi"))] + ["booklet" ;; A work that is printed and bound, but without a named publisher or sponsoring institution. + (make-bib + #:type 'booklet + #:is-book? #t ;; TODO or #f??? or have make-bib accept a #:type ??? + ;; required: + #:title (content-attr* "title") + ;; optional: + #:author (author-attr "author") ;; TODO: make it optional + #:date (scalar-attr "year") ;; TODO: month + #:location (booklet-location + #:howpublished (content-attr "howpublished") + #:address (content-attr "address")) + #:note (content-attr "note") + ;; extra: (WHERE IS THAT SPECIFIED?) + #:url (url-attr "url") + #:doi (url-attr "doi"))] + [(or "conference" ;; The same as INPROCEEDINGS, included for Scribe compatibility. + "inproceedings") ;; An article in a conference proceedings. + (make-bib + #:type 'inproceedings + ;; required: + #:author (author-attr* "author") + #:title (content-attr* "title") + #:date (scalar-attr* "year") ;; TODO: optional month + #:location (proceedings-location + (content-attr* "booktitle") + ;; optional: + #:editor (author-attr "editor") + #:series (content-attr "series") + #:volume (content-attr "volume") ;; volume OR number + #:number (content-attr "number") + #:pages (pages-attr "pages") + #:address (content-attr "address") + #:organization (content-attr "organization") + #:publisher (content-attr "publisher")) + ;; optional: + #:note (content-attr "note") + ;; extra: (WHERE IS THAT SPECIFIED?) + #:url (url-attr "url") + #:doi (url-attr "doi"))] + ["inbook" ;; A part of a book, which may be a chapter (or section or whatever) and/or a range of pages. + (require-one-of "author" "editor") + (require-one-of "chapter" "pages") + (make-bib + #:type 'inbook + #:is-book? #t ;; TODO or #f ??? + ;; required: + #:author (author-attr "author") ;; author OR editor is required, checked above + #:title (content-attr* "title") + #:date (scalar-attr* "year") ;; TODO: optional month + #:location (book-location + #:editor (author-attr "editor") ;; see above + #:chapter (content-attr "chapter") ;; chapter OR pages is required, checked above + #:pages (pages-attr "pages") + #:publisher (content-attr* "publisher") + ;; optional: + #:volume (content-attr "volume") ;; volume OR number + #:number (content-attr "number") + #:series (content-attr "series") + #:address (content-attr "address") + #:edition (content-attr "edition")) + ;; optional: + #:note (content-attr "note") + ;; extra: (WHERE IS THAT SPECIFIED?) + #:url (url-attr "url") + #:doi (url-attr "doi"))] + ["incollection" ;; A part of a book having its own title. + ;; TODO: figure out why https://www.openoffice.org/bibliographic/bibtex-defs.html + ;; talks about a "type" kind of label, what it does, who uses it for what... + ;; or whether it's a bug in that page. + (make-bib + #:type 'incollection + ;; required: + #:author (author-attr* "author") + #:title (content-attr* "title") + #:date (scalar-attr* "year") ;; TODO: optional month + #:location (book-chapter-location + (content-attr* "booktitle") + #:publisher (content-attr* "publisher") + ;; optional: + #:editor (author-attr "editor") + #:volume (content-attr "volume") ;; volume OR number + #:number (content-attr "number") + #:series (content-attr "series") + #:chapter (content-attr "chapter") + #:pages (pages-attr "pages") + #:address (content-attr "address") + #:edition (content-attr "edition")) + ;; optional: + #:note (content-attr "note") + ;; extra: (WHERE IS THAT SPECIFIED?) + #:url (url-attr "url") + #:doi (url-attr "doi"))] + ["manual" ;; Technical documentation + (make-bib + #:type 'manual + ;; required: + #:title (content-attr* "title") + ;; optional: + #:author (author-attr "author") + #:date (scalar-attr "year") ;; TODO: optional month + #:location (manual-location + ;; optional: + #:organization (content-attr "organization") + #:edition (content-attr "edition")) + #:note (content-attr "note") + ;; extra: (WHERE IS THAT SPECIFIED?) + #:url (url-attr "url") + #:doi (url-attr "doi"))] + ["mastersthesis" ;; A Master's thesis. + (make-bib + #:type 'mastersthesis + ;; required: + #:author (author-attr* "author") + #:title (content-attr* "title") + #:date (scalar-attr* "year") ;; TODO: optional month + #:location (dissertation-location + #:institution (content-attr* "school") + #:degree "Master’s" + ;; optional: + #:type (content-attr "type") + #:address (content-attr "address")) + ;; optional: + #:note (content-attr "note") + ;; extra: (WHERE IS THAT SPECIFIED?) + #:url (url-attr "url") + #:doi (url-attr "doi"))] + ["misc" ;; Use this type when nothing else fits. + (make-bib + #:type 'misc + ;; optional: all (no required field) + #:author (author-attr "author") + #:title (content-attr "title") + #:date (scalar-attr "year") ;; TODO: month + #:location (misc-location + #:howpublished (content-attr "howpublished")) + #:note (content-attr "note") + ;; extra: (WHERE IS THAT SPECIFIED?) + #:url (url-attr "url") + #:doi (url-attr "doi"))] + ["phdthesis" ;; A PhD thesis. + (make-bib + #:type 'phdthesis + ;; required: + #:author (author-attr* "author") + #:title (content-attr* "title") + #:date (scalar-attr* "year") ;; TODO: optional month + #:location (dissertation-location + #:institution (content-attr* "school") + #:degree "PhD" + ;; optional: + #:type (content-attr "type") + #:address (content-attr "address")) + ;; optional: + #:note (content-attr "note") + ;; extra: (WHERE IS THAT SPECIFIED?) + #:url (url-attr "url") + #:doi (url-attr "doi"))] + ["proceedings" ;; The proceedings of a conference. + (make-bib + #:type 'proceedings + ;; required: + #:title (content-attr* "title") + #:date (scalar-attr* "year") ;; TODO: optional month + ;; optional: + #:location (proceedings-location + #f + ;; optional: + #:editor (author-attr "editor") + #:volume (content-attr "volume") ;; volume OR number + #:number (content-attr "number") + #:series (content-attr "series") + #:address (content-attr "address") + #:organization (content-attr "organization") + #:publisher (content-attr "publisher")) + #:note (content-attr "note") + ;; extra: (WHERE IS THAT SPECIFIED?) + #:url (url-attr "url") + #:doi (url-attr "doi"))] + ["techreport" ;; A report published by a school or other institution, usually numbered within a series. + ;; Required fields: author, title, institution, year. Optional fields: type, number, address, month, note. + (make-bib + #:type 'techreport + ;; required: + #:author (author-attr* "author") + #:title (content-attr* "title") + #:date (scalar-attr* "year") ;; TODO: optional month + #:location (techrpt-location + #:institution (content-attr* "institution") + ;; optional: + #:type (content-attr "type") + #:number (content-attr "number") + #:address (content-attr "address")) + ;; optional: + #:note (content-attr "note") + ;; extra: (WHERE IS THAT SPECIFIED?) + #:url (url-attr "url") + #:doi (url-attr "doi"))] + ["unpublished" ;; A document having an author and title, but not formally published. + (make-bib + #:type 'unpublished + ;; required: + #:author (author-attr* "author") + #:title (content-attr* "title") + #:note (content-attr* "note") + ;; optional: + #:date (scalar-attr "year") ;; TODO: month + ;; extra: (WHERE IS THAT SPECIFIED?) + #:url (url-attr "url") + #:doi (url-attr "doi"))] + ;; SEEN IN THE WILD, BUT WHERE ARE THESE SPECIFIED??? + ["online" + ;; Not a standard BIBTEXing entry type, so nothing here is + ;; "required" by any spec; but a webpage citation without a + ;; URL or a title to show for it isn't a citation at all. + (make-bib + #:type 'webpage + ;; required, by our own choice (see above): + #:title (content-attr* "title") + #:url (url-attr* "url") + ;; optional: + #:accessed (content-attr "urldate") ;; when visited + #:author (author-attr "author") + #:note (content-attr "note") + #:date (scalar-attr "year") ;; TODO: month ;; presumably when written + #:doi (url-attr "doi"))] + ["webpage" + ;; Not a standard BIBTEXing entry type; see "online" above. + (make-bib + #:type 'webpage + ;; required, by our own choice (see "online" above): + #:title (content-attr* "title") + #:url (url-attr* "url") + ;; optional: + #:accessed (content-attr "lastchecked") + #:author (author-attr "author") + #:note (content-attr "note") + #:date (scalar-attr "year") ;; TODO: month ;; presumably when written + #:doi (url-attr "doi"))] + [_ + (make-bib #:title (format "~v" the-raw))])))) (module+ test - (require rackunit) + (require rackunit + racket/file + racket/path + scribble/render + (prefix-in html: scribble/html-render) + (prefix-in latex: scribble/latex-render)) ;; use this as a predicate to hack around lack of ;; ability to use equal? on author element structs; @@ -443,314 +915,472 @@ print-as-equal-string? (parse-author "Foo anderson") ;; Should not be parsed as the two authors "Foo" & "erson" (authors - (author-name "Foo" "anderson")))) + (author-name "Foo" "anderson"))) -(define (parse-pages ps) - (match ps - [(regexp #rx"^([0-9]+)[-—–]+([0-9]+)$" (list _ f l)) ;; NB: mind the Unicode dashes - (list f l)] - [#f - #f] - [_ - (error 'parse-pages "Invalid page format ~e" ps)])) + (define grouping-db + (bibtex-parse + (open-input-string + "@misc{x, author={Guy L. {Steele Jr.}}, title={The {ACM} Paper}}"))) + (check-equal? + (hash-ref (hash-ref (bibdb-raw grouping-db) "x") "author") + "Guy L. {Steele Jr.}") + (check-equal? + (hash-ref (hash-ref (bibdb-raw grouping-db) "x") "title") + "The {ACM} Paper") + (check-equal? (content->string (parse-author "Guy L. {Steele Jr.}")) + "Guy L. Steele Jr.") + (check-equal? (content->string (parse-author "{Steele Jr.}, Guy L.")) + "Guy L. Steele Jr.") + (check-equal? (content->string (parse-author "Steele, Jr., Guy L.")) + "Guy L. Steele Jr.") + (check + print-as-equal-string? + (parse-author "Guy L. {Steele Jr.}") + (authors + (author-name "Guy L." + (latex->content "{Steele Jr.}")))) + (check + print-as-equal-string? + (parse-author "Steele, Jr., Guy L.") + (authors + (author-name "Guy L." "Steele" #:suffix "Jr."))) + (check-equal? + (content->string + (parse-author "{Barnes and Noble, Inc.} and Guy L. {Steele Jr.}")) + "Barnes and Noble, Inc. and Guy L. Steele Jr.") + (check-true + (bibtex-group? + (name-word-content + (last (filter name-word? + (name-tokens (latex->content "Guy L. {Steele Jr.}"))))))) + (check-equal? (content->string (latex->content "\\emph{a \\texttt{b}}")) + "a b") + (define required-db + (bibtex-parse + (open-input-string + #<string html-path)) + + ;; Required fields must retain LaTeX formatting. + (check-true + (regexp-match? #px"]*>JournalSentinel" + rendered)) + (check-true + (regexp-match? #px"]*class=\"stt\"[^>]*>ConferenceSentinel" + rendered)) + (check-true + (regexp-match? #px"]*>CollectionSentinel" + rendered)) + + (delete-file html-path) + + (define compat-db + (bibtex-parse + (open-input-string + (string-append + "@misc{compat-test,\n" + " author={S{\\o}ren Kierkegaard},\n" + " title={The $\\lambda$-calculus and {\\em old-style} emphasis},\n" + " note={J.~of Things}\n" + "}\n")))) + (define-cite compat-cite compat-citet compat-bibliography) + (void (compat-cite (generate-bib compat-db "compat-test"))) + (define tex-path (make-temporary-file "bibtex-compat~a.tex")) + (render (list (compat-bibliography)) + (list tex-path) + #:dest-dir (path-only tex-path) + #:render-mixin latex:render-mixin) + (define compat-tex (file->string tex-path)) + (check-not-false (string-contains? compat-tex "$\\lambda$")) + (check-not-false (string-contains? compat-tex "\\BibtexGroup{\\em ")) + (check-not-false (string-contains? compat-tex "J.~of Things")) + (delete-file tex-path) + + ;; Required fields must still produce useful errors when absent. + ;; Each fixture below supplies every other required field, so the + ;; error is unambiguously about the one field under test. + (check-exn + #rx"missing attribute journal" + (λ () + (generate-bib + (bibtex-parse + (open-input-string + "@article{x, author={A}, title={X}, year={2026}}")) + "x"))) + + (check-exn + #rx"missing attribute booktitle" + (λ () + (generate-bib + (bibtex-parse + (open-input-string + "@inproceedings{x, author={A}, title={X}, year={2026}}")) + "x"))) + + ;; Missing author, title, or year is now caught too. + (check-exn + #rx"missing attribute author" + (λ () + (generate-bib + (bibtex-parse + (open-input-string + "@article{x, title={X}, journal={J}, year={2026}}")) + "x"))) + (check-exn + #rx"missing attribute title" + (λ () + (generate-bib + (bibtex-parse + (open-input-string + "@article{x, author={A}, journal={J}, year={2026}}")) + "x"))) + (check-exn + #rx"missing attribute year" + (λ () + (generate-bib + (bibtex-parse + (open-input-string + "@article{x, author={A}, title={X}, journal={J}}")) + "x"))) + + ;; book/inbook accept either author or editor, but need at least one. + (check-exn + #rx"missing at least one of attributes \\(author editor\\)" + (λ () + (generate-bib + (bibtex-parse + (open-input-string + "@book{x, title={X}, publisher={P}, year={2026}}")) + "x"))) + ;; An editor-only book must not render a bogus "#f" as its author: that + ;; was the actual failure mode of calling author-attr on a missing field + ;; (it doesn't error, it silently stringifies #f into a fake author name). + (define editor-only-db + (bibtex-parse + (open-input-string + "@book{editor-only, editor={Eve Editor}, title={Edited Book}, publisher={P}, year={2026}}"))) + (define-cite editor-only-cite editor-only-citet editor-only-bibliography) + (void (editor-only-cite (generate-bib editor-only-db "editor-only"))) + (define editor-only-html-path (make-temporary-file "bibtex-editor-only~a.html")) + (render (list (editor-only-bibliography)) + (list editor-only-html-path) + #:dest-dir (path-only editor-only-html-path) + #:render-mixin html:render-mixin) + (define editor-only-html (file->string editor-only-html-path)) + (check-false (string-contains? editor-only-html "#f")) + (check-not-false (string-contains? editor-only-html "Eve Editor")) + (delete-file editor-only-html-path) + + ;; inbook additionally needs chapter and/or pages. + (check-exn + #rx"missing at least one of attributes \\(chapter pages\\)" + (λ () + (generate-bib + (bibtex-parse + (open-input-string + "@inbook{x, author={A}, title={X}, publisher={P}, year={2026}}")) + "x"))) + (check-not-exn + (λ () + (generate-bib + (bibtex-parse + (open-input-string + "@inbook{x, author={A}, title={X}, publisher={P}, year={2026}, pages={1--2}}")) + "x"))) + + ;; unpublished requires author, title, and note, but no year. + (check-not-exn + (λ () + (generate-bib + (bibtex-parse + (open-input-string + "@unpublished{x, author={A}, title={X}, note={N}}")) + "x"))) + (check-exn + #rx"missing attribute note" + (λ () + (generate-bib + (bibtex-parse + (open-input-string + "@unpublished{x, author={A}, title={X}}")) + "x"))) + + ;; booklet and manual only require title. + (check-not-exn + (λ () (generate-bib (bibtex-parse (open-input-string "@booklet{x, title={X}}")) "x"))) + (check-not-exn + (λ () (generate-bib (bibtex-parse (open-input-string "@manual{x, title={X}}")) "x"))) + + ;; proceedings requires title and year, but no author. + (check-not-exn + (λ () (generate-bib (bibtex-parse (open-input-string "@proceedings{x, title={X}, year={2026}}")) "x"))) + + ;; misc requires nothing at all. + (check-not-exn + (λ () (generate-bib (bibtex-parse (open-input-string "@misc{x,}")) "x"))) + + ;; online/webpage aren't standard BIBTEXing types, but we still require + ;; title and url (just not author) as our own policy. + (check-not-exn + (λ () (generate-bib (bibtex-parse (open-input-string "@online{x, title={X}, url={https://example.org}}")) "x"))) + (check-exn + #rx"missing attribute url" + (λ () (generate-bib (bibtex-parse (open-input-string "@online{x, title={X}}")) "x"))) + (check-exn + #rx"missing attribute title" + (λ () (generate-bib (bibtex-parse (open-input-string "@online{x, url={https://example.org}}")) "x"))) + (check-not-exn + (λ () (generate-bib (bibtex-parse (open-input-string "@webpage{x, title={X}, url={https://example.org}}")) "x"))) + (check-exn + #rx"missing attribute url" + (λ () (generate-bib (bibtex-parse (open-input-string "@webpage{x, title={X}}")) "x"))) + (check-exn + #rx"missing attribute title" + (λ () (generate-bib (bibtex-parse (open-input-string "@webpage{x, url={https://example.org}}")) "x"))) + + (define standard-types-db + (bibtex-parse + (open-input-string + "@inbook{chapter-test, author={Alice Example}, title={Whole Book}, chapter={3}, publisher={ACM}, year={2026}}\n@proceedings{volume-test, title={Conference Papers}, year={2026}, publisher={ACM}}"))) + (check-not-exn (lambda () (generate-bib standard-types-db "chapter-test"))) + (check-not-exn (lambda () (generate-bib standard-types-db "volume-test"))) + + (check-equal? + (content->string (parse-author "Fran\\c{c}ois Rideau and V\\'{\\i}ctor Braberman and Erd\\H{o}s, Paul")) + "François Rideau, Víctor Braberman, and Paul Erdős") + + (define formatting-db + (bibtex-parse + (open-input-string + (string-append + "@misc{format-test,\n" + " author={Alice Example},\n" + " title={A \\textit{fine} \\textbf{bold} \\textsc{test}},\n" + " note={See \\url{https://example.org/~alice/a_b?x=1&y=2}},\n" + " year={2026}\n" + "}\n")))) + (define-cite format-cite format-citet format-bibliography) + (void (format-cite (generate-bib formatting-db "format-test"))) + + (define format-tex-path (make-temporary-file "bibtex-format~a.tex")) + (render (list (format-bibliography)) + (list format-tex-path) + #:dest-dir (path-only format-tex-path) + #:render-mixin latex:render-mixin) + (define format-tex (file->string format-tex-path)) + (check-not-false (string-contains? format-tex "\\BibtexSmallCaps{test}")) + (check-not-false (string-contains? format-tex "https://example.org/")) + (delete-file format-tex-path) + + (define format-html-path (make-temporary-file "bibtex-format~a.html")) + (render (list (format-bibliography)) + (list format-html-path) + #:dest-dir (path-only format-html-path) + #:render-mixin html:render-mixin) + (define format-html (file->string format-html-path)) + (check-not-false (string-contains? format-html "BibtexSmallCaps")) + (check-not-false (regexp-match? #rx"href=\"https://example\\.org/" + format-html)) + (delete-file format-html-path) + + (check-equal? (ungroup-scalar "{2000}") "2000") + (check-equal? (ungroup-scalar "{{2000}}") "2000") + (check-equal? (ungroup-scalar "{a}{b}") "{a}{b}") + (check-equal? (ungroup-scalar "\\{2000\\}") "\\{2000\\}") + (check-equal? (ungroup-scalar "https://example.org/a_{b}") + "https://example.org/a_{b}") + (check-false (ungroup-scalar #f)) + + (define scalars-db + (bibtex-parse + (open-input-string + #<string scalars-html-path)) + (check-not-false (string-contains? scalars-html "2000")) + (check-false (string-contains? scalars-html "{2000}")) + (check-not-false (string-contains? scalars-html + "https://doi.org/10.1000/example")) + (check-not-false (string-contains? scalars-html + "https://example.org/a_b")) + ;; Test link targets, not just displayed text. + (check-not-false + (regexp-match? + #rx"href=\"https://example\\.org/escaped_a_b\"" + scalars-html)) + (check-not-false + (regexp-match? + #rx"href=\"https://doi\\.org/10\\.1000/escaped_a_b\"" + scalars-html)) + (delete-file scalars-html-path) + + (define scalars-tex-path + (make-temporary-file "bibtex-scalars~a.tex")) + (render (list (scalars-bibliography)) + (list scalars-tex-path) + #:dest-dir (path-only scalars-tex-path) + #:render-mixin latex:render-mixin) + (define scalars-tex (file->string scalars-tex-path)) + (check-not-false + (string-contains? + scalars-tex + "\\href{https://example.org/escaped_a_b}{")) + (check-not-false + (string-contains? + scalars-tex + "\\href{https://doi.org/10.1000/escaped_a_b}{")) + (delete-file scalars-tex-path) + + ;; Unbraced string references must not consume their delimiters. + (define string-reference-db + (bibtex-parse + (open-input-string + (string-append + "@string{base={Ada}}\n" + "@string{alias=base}\n" + "@misc{direct,title=base}\n" + "@misc{indirect,title=alias}\n" + "@misc{concat,title=base#\" Lovelace\"}\n" + "@misc{comma,title=base,year=2026}\n")))) + (define (raw-title key) + (hash-ref + (hash-ref (bibdb-raw string-reference-db) key) + "title")) + (check-equal? (raw-title "direct") "Ada") + (check-equal? (raw-title "indirect") "Ada") + (check-equal? (raw-title "concat") "Ada Lovelace") + (check-equal? (raw-title "comma") "Ada") + + (define math-db + (bibtex-parse + (open-input-string + (string-append + "@misc{math-compat,\n" + " author={Alice Example},\n" + " title={The $\\lambda$-calculus and $$x^2$$},\n" + " note={Price: \\$5},\n" + " year={2026}\n" + "}\n")))) + (define-cite math-cite math-citet math-bibliography) + (void (math-cite (generate-bib math-db "math-compat"))) + + (define math-html-path + (make-temporary-file "bibtex-math~a.html")) + (render (list (math-bibliography)) + (list math-html-path) + #:dest-dir (path-only math-html-path) + #:render-mixin html:render-mixin) + (define math-html (file->string math-html-path)) + (check-not-false (string-contains? math-html "BibtexInlineMath")) + (check-not-false (string-contains? math-html "BibtexDisplayMath")) + (check-not-false (string-contains? math-html "$\\lambda$")) + (check-not-false (string-contains? math-html "$$x^2$$")) + (check-not-false (string-contains? math-html "Price: $5")) + (delete-file math-html-path) + + (define math-tex-path + (make-temporary-file "bibtex-math~a.tex")) + (render (list (math-bibliography)) + (list math-tex-path) + #:dest-dir (path-only math-tex-path) + #:render-mixin latex:render-mixin) + (define math-tex (file->string math-tex-path)) + (check-not-false + (string-contains? math-tex "\\BibtexInlineMath{$\\lambda$}")) + (check-not-false + (string-contains? math-tex "\\BibtexDisplayMath{$$x^2$$}")) + (delete-file math-tex-path)) (provide (struct-out bibdb) path->bibdb diff --git a/scribble-lib/scriblib/private/read-latex.rkt b/scribble-lib/scriblib/private/read-latex.rkt new file mode 100644 index 0000000000..ff6c24cfe5 --- /dev/null +++ b/scribble-lib/scriblib/private/read-latex.rkt @@ -0,0 +1,387 @@ +#lang racket/base + +;; A deliberately small LaTeX reader for BibTeX's human-readable fields. +;; Grouping braces become visually transparent Scribble elements; their +;; private style property lets the BibTeX name parser recognize protected +;; words without teaching Autobib anything about BibTeX or LaTeX. +;; Supported: grouping, \emph{...}, \texttt{...}, common control symbols, +;; dotless i/j, and common accents. Declaration-style font switches +;; (\em, \tt, \bf) are intentionally not interpreted. + +(require racket/match + racket/list + racket/string + scribble/core + scribble/base) + +(provide latex->content bibtex-group? unescape-url) + +(define url-escaped-chars + '(#\% #\& #\# #\_ #\~ #\{ #\} #\\)) + +(define (unescape-url s) + (define out (open-output-string)) + (let loop ([i 0]) + (when (< i (string-length s)) + (define c (string-ref s i)) + (cond + [(and (char=? c #\\) + (< (add1 i) (string-length s)) + (memv (string-ref s (add1 i)) + url-escaped-chars)) + (write-char (string-ref s (add1 i)) out) + (loop (+ i 2))] + [else + (write-char c out) + (loop (add1 i))]))) + (get-output-string out)) + +(define bibtex-group-style (make-style "BibtexGroup" '(bibtex-group))) +(define raw-tex-style (make-style #f '(exact-chars))) +(define bibtex-smallcaps-style (make-style "BibtexSmallCaps" null)) +(define bibtex-inline-math-style + (make-style "BibtexInlineMath" '(exact-chars))) +(define bibtex-display-math-style + (make-style "BibtexDisplayMath" '(exact-chars))) + +(define (bibtex-group? value) + (and (element? value) + (and (memq 'bibtex-group + (style-properties (element-style value))) + #t))) + +(define accents + (hash #\' "\u0301" #\` "\u0300" #\" "\u0308" + #\~ "\u0303" #\^ "\u0302" #\= "\u0304" + #\c "\u0327" #\v "\u030c" #\H "\u030b" + #\u "\u0306" #\k "\u0328" #\. "\u0307" + #\r "\u030a")) + +(define letter-commands + (hash "o" "ø" "O" "Ø" + "l" "ł" "L" "Ł" + "ss" "ß" + "aa" "å" "AA" "Å" + "ae" "æ" "AE" "Æ" + "oe" "œ" "OE" "Œ" + "dh" "ð" "DH" "Ð" + "th" "þ" "TH" "Þ")) + +(define (latex->content source) + (cond + [source + (define ip (open-input-string source)) + (define (read-word) + (list->string (let loop () + (define c (peek-char ip)) + (if (and (char? c) (char-alphabetic? c)) + (cons (read-char ip) (loop)) + null)))) + + (define (read-control-whitespace) + (list->string (let spaces () + (define c (peek-char ip)) + (if (and (char? c) (char-whitespace? c)) + (cons (read-char ip) (spaces)) + null)))) + + (define (read-url-argument) + ;; The opening brace is still at the input port. + (read-char ip) + (define out (open-output-string)) + (let loop ([depth 1]) + (match (read-char ip) + [(? eof-object?) (error 'latex->content "unclosed URL in ~e" source)] + [#\\ + (define next (read-char ip)) + (when (eof-object? next) + (error 'latex->content "trailing backslash in URL in ~e" source)) + (write-char #\\ out) + (write-char next out) + (loop depth)] + [#\{ + (write-char #\{ out) + (loop (add1 depth))] + [#\} + (cond + [(= depth 1) (unescape-url (get-output-string out))] + [else + (write-char #\} out) + (loop (sub1 depth))])] + [c + (write-char c out) + (loop depth)]))) + + (define (normalize pieces) + (match pieces + ['() ""] + [(list one) one] + [_ pieces])) + + ;; For unknown commands, retain immediately attached braced + ;; arguments literally, including nested braces and escapes. + (define (read-raw-arguments) + (define out (open-output-string)) + (let arguments () + (when (eqv? (peek-char ip) #\{) + (define depth 0) + (let copy () + (match (read-char ip) + [(? eof-object?) (error 'latex->content "unclosed argument in ~e" source)] + [#\\ + (write-char #\\ out) + (define next (read-char ip)) + (when (eof-object? next) + (error 'latex->content "trailing backslash in ~e" source)) + (write-char next out) + (copy)] + [#\{ + (set! depth (add1 depth)) + (write-char #\{ out) + (copy)] + [#\} + (set! depth (sub1 depth)) + (write-char #\} out) + (unless (zero? depth) + (copy))] + [c + (write-char c out) + (copy)])) + (arguments))) + (get-output-string out)) + + (define (read-math) + ;; The opening $ was already consumed by read-group. + (define display? (eqv? (peek-char ip) #\$)) + (when display? + (read-char ip)) + (define out (open-output-string)) + (display (if display? "$$" "$") out) + (let loop () + (match (read-char ip) + [(? eof-object?) (error 'latex->content "unclosed math in ~e" source)] + [#\\ + (write-char #\\ out) + (define next (read-char ip)) + (when (eof-object? next) + (error 'latex->content "trailing backslash in math in ~e" source)) + (write-char next out) + (loop)] + [#\$ + (write-char #\$ out) + (cond + [(and display? (not (eqv? (peek-char ip) #\$))) (loop)] + [else + (when display? + (write-char (read-char ip) out)) + (get-output-string out)])] + [c + (write-char c out) + (loop)]))) + + (define (math-element raw) + (make-element (if (string-prefix? raw "$$") bibtex-display-math-style bibtex-inline-math-style) + (list raw))) + + (define (read-group in-group?) + (define pieces null) + (define out (open-output-string)) + + (define (flush!) + (define s (get-output-string out)) + (unless (string=? s "") + (set! pieces (cons s pieces))) + (set! out (open-output-string))) + + (define (emit! value) + (flush!) + (set! pieces (cons value pieces))) + + (define (finish) + (flush!) + (reverse pieces)) + + (define (read-accent-argument) + (read-control-whitespace) + (match (peek-char ip) + [#\{ + (read-char ip) + (content->string (read-group #t))] + [#\\ + (read-char ip) + (define name (read-word)) + (unless (string=? name "") + (read-control-whitespace)) + (cond + [(string=? name "i") "ı"] + [(string=? name "j") "ȷ"] + [else name])] + [(? char? c) (string (read-char ip))] + [_ (error 'latex->content "missing accent argument in ~e" source)])) + + + (define (emit-accent! accent) + (define argument (read-accent-argument)) + (when (string=? argument "") + (error 'latex->content "empty accent argument in ~e" source)) + (emit! (let ([first-char (substring argument 0 1)]) + (string-normalize-nfc (string-append (if (string=? first-char "ı") "i" first-char) + (hash-ref accents accent) + (substring argument 1)))))) + + (let loop () + (match (read-char ip) + [(? eof-object?) + (when in-group? + (error 'latex->content "unclosed brace in ~e" source)) + (finish)] + [#\{ + (emit! (make-element bibtex-group-style (read-group #t))) + (loop)] + [#\} + (unless in-group? + (error 'latex->content "unexpected closing brace in ~e" source)) + (finish)] + [#\$ + (emit! (math-element (read-math))) + (loop)] + [#\~ + (emit! 'nbsp) + (loop)] + [#\\ + (define next (peek-char ip)) + (cond + [(eof-object? next) (error 'latex->content "trailing backslash in ~e" source)] + [(char-alphabetic? next) + (define word (read-word)) + (cond + [(member word '("emph" "texttt" "textit" "textbf" "textsc")) + (define whitespace (read-control-whitespace)) + (cond + [(eqv? (peek-char ip) #\{) + (read-char ip) + (define body (read-group #t)) + (emit! (cond + [(string=? word "emph") (apply emph body)] + [(string=? word "texttt") (apply tt body)] + [(string=? word "textit") (apply italic body)] + [(string=? word "textbf") (apply bold body)] + [else (make-element bibtex-smallcaps-style body)]))] + [else + (emit! (make-element raw-tex-style + (list (string-append "\\" word whitespace))))])] + [(string=? word "url") + (define whitespace (read-control-whitespace)) + (if (eqv? (peek-char ip) #\{) + (emit! (url (read-url-argument))) + (emit! (make-element raw-tex-style (list (string-append "\\url" whitespace)))))] + [(and (= (string-length word) 1) (hash-has-key? accents (string-ref word 0))) + (emit-accent! (string-ref word 0))] + [(hash-has-key? letter-commands word) + (read-control-whitespace) + (display (hash-ref letter-commands word) out)] + [(string=? word "i") + (read-control-whitespace) + (display "ı" out)] + [(string=? word "j") + (read-control-whitespace) + (display "ȷ" out)] + [else + (emit! (make-element raw-tex-style + (list (string-append "\\" word (read-raw-arguments)))))]) + (loop)] + [else + (define symbol (read-char ip)) + (cond + [(hash-has-key? accents symbol) (emit-accent! symbol)] + [(memv symbol '(#\{ #\} #\% #\& #\$ #\# #\_)) (write-char symbol out)] + [(char=? symbol #\space) (write-char #\space out)] + ;; Unknown control symbol, including \\, remains + ;; literal in LaTeX rather than silently vanishing. + [else (emit! (make-element raw-tex-style (list (string #\\ symbol))))]) + (loop)])] + [c + (write-char c out) + (loop)]))) + + (normalize (read-group #f))] + [else #f])) + +(module+ test + (require rackunit) + (check-equal? (content->string (latex->content "Guy L. {Steele Jr.}")) + "Guy L. Steele Jr.") + (check-true (bibtex-group? (latex->content "{Steele Jr.}"))) + (check-equal? (content->string (latex->content "\\emph{A \\texttt{B}}")) + "A B") + (check-equal? (content->string (latex->content "\\{x\\} \\% \\&")) + "{x} % &") + (check-equal? (content->string (latex->content "J.~of Things")) + "J.\u00a0of Things") + (check-equal? (content->string (latex->content "The $\\lambda$-calculus")) + "The $\\lambda$-calculus") + (check-equal? (content->string (latex->content "The $$x^2$$ formula")) + "The $$x^2$$ formula") + (check-exn #rx"unclosed math" + (lambda () (latex->content "The $x+y"))) + (check-equal? + (content->string + (latex->content "Fran\\c{c}ois Erd\\H{o}s \\v{S}ediv \\u{g} \\k{a} \\.{z} \\r{a}")) + "François Erdős Šediv ğ ą ż å") + (check-equal? + (content->string + (latex->content "S{\\o}ren {\\L}ukasz D{\\ae}dalus Fu{\\ss}, \\O{} \\AA{} \\oe{}")) + "Søren Łukasz Dædalus Fuß, Ø Å œ") + (check-equal? + (content->string (latex->content "V\\'{\\i}ctor and V\\'ictor")) + "Víctor and Víctor") + (check-equal? + (content->string (latex->content "\\i \\j")) + "ıȷ") + (check-equal? + (content->string + (latex->content + "Ren\\' e; Fran\\c cois; Mart\\'\\i n; \\o ren")) + "René; François; Martín; øren") + + (check-equal? + (content->string + (latex->content "\\textit{A \\textbf{B}} \\textsc{C} \\emph{D}")) + "A B C D") + (check-equal? + (style-name (element-style (latex->content "\\textsc{SmallCaps}"))) + "BibtexSmallCaps") + (check-equal? + (content->string + (latex->content "\\url{https://example.org/~alice/a_b?x=1&y=2}")) + "https://example.org/~alice/a_b?x=1&y=2") + (check-equal? + (content->string + (latex->content "\\url{https://example.org/a\\_b\\%20c}")) + "https://example.org/a_b%20c") + (check-exn #rx"unclosed URL" + (lambda () (latex->content "\\url{https://example.org"))) + + (check-equal? + (style-name (element-style (latex->content "$\\lambda$"))) + "BibtexInlineMath") + (check-equal? + (style-name (element-style (latex->content "$$x^2$$"))) + "BibtexDisplayMath") + (check-equal? + (content->string (latex->content "Price \\$5; $\\lambda$")) + "Price $5; $\\lambda$") + + (check-equal? + (content->string + (latex->content "Fran\\c cois; Mart\\'\\i n; \\o ren")) + "François; Martín; øren") + (check-equal? + (content->string (latex->content "\\c {c}")) + "ç") + (check-equal? + (unescape-url "https://example.org/a\\_b\\%20c?x=1\\&y=2") + "https://example.org/a_b%20c?x=1&y=2") + (check-equal? + (unescape-url "https://example.org/\\unknown") + "https://example.org/\\unknown")) diff --git a/scribble-test/tests/scribble/docs/autobib-numbered.txt b/scribble-test/tests/scribble/docs/autobib-numbered.txt index 123f238437..d8a0056e96 100644 --- a/scribble-test/tests/scribble/docs/autobib-numbered.txt +++ b/scribble-test/tests/scribble/docs/autobib-numbered.txt @@ -14,7 +14,7 @@ B&B [1, 4]. Bibliography -[1]A. One. There, 2012. -[2]A. Two. Here, 2012. -[3]A. Three. Where?, 2013. -[4]B. Uno. Ici, 2012. +[1] A. One. There, 2012. +[2] A. Two. Here, 2012. +[3] A. Three. Where?, 2013. +[4] B. Uno. Ici, 2012. diff --git a/scribble-test/tests/scriblib/autobib.rkt b/scribble-test/tests/scriblib/autobib.rkt index 2552a49eaa..045eb33a37 100644 --- a/scribble-test/tests/scriblib/autobib.rkt +++ b/scribble-test/tests/scriblib/autobib.rkt @@ -2,6 +2,8 @@ (require rackunit scriblib/autobib scribble/base scribble/core) +(error-print-width 2000) + (test-case "define-cite" ;; Check that `define-cite` binds the expected identifiers @@ -29,7 +31,12 @@ (check-not-exn (λ () (journal-location 'JFP))) (check-exn exn:fail:contract? - (λ () (journal-location "Journal of Chromatography" #:pages 30)))) + (λ () (journal-location "Journal of Chromatography" #:pages 30))) + ;; title is a genuinely required argument: passing #f (i.e. no real + ;; information) raises via ensure-nontrivial-content instead of quietly + ;; producing #f. + (check-exn exn:fail:contract? + (λ () (journal-location #f)))) (test-case "book-location" (check-not-exn @@ -39,17 +46,8 @@ (check-not-exn (λ () (book-location)))) -(test-case "webpage-location" - (check-not-exn - (λ () (webpage-location "https://www.racket-lang.org"))) - (check-not-exn - (λ () (webpage-location "https://www.racket-lang.org" #:accessed "January 2024"))) - (check-not-exn - (λ () (webpage-location)))) - (define (mk-bookloc-elem/ed ed) - (define (wrap v) (element (style #f '()) (if (list? v) v (list v)))) - (wrap (wrap (wrap (list ed " edition"))))) + (elem ed " edition")) (test-case "book-location-edition-capitalization" (check-equal? (book-location #:edition 'a) @@ -69,7 +67,21 @@ (check-not-exn (λ () (techrpt-location #:institution "MIT" #:number 'AIM-353))) (check-not-exn - (λ () (techrpt-location #:institution 'UCB)))) + (λ () (techrpt-location #:institution 'UCB))) + ;; institution is a genuinely required argument, so techrpt-location is + ;; contracted to always return an element: passing #f violates that + ;; contract instead of quietly producing #f. + (check-exn exn:fail:contract? + (λ () (techrpt-location #:institution #f)))) + +(test-case "book-chapter-location" + (check-not-exn + (λ () (book-chapter-location "Handbook of X" #:publisher "Springer"))) + ;; title (the leading location argument) is a genuinely required argument, + ;; so book-chapter-location is contracted to always return an element: + ;; passing #f violates that contract instead of quietly producing #f. + (check-exn exn:fail:contract? + (λ () (book-chapter-location #f)))) (test-case "dissertation-location" (check-not-exn @@ -108,3 +120,22 @@ (other-authors)))))) (gen-bib)))) +(test-case "number-style bibliography is single-column" + (let () + (define-cite cite citet gen-bib #:style number-style) + + (define b + (make-bib #:author "Alice Alpha" + #:title "First Paper" + #:date "2020")) + + ;; Make the entry reachable by the bibliography generator. + (cite b) + + (define bib (gen-bib #:sec-title #f)) + + (check-pred table? bib) + + ;; Every bibliography row should now contain exactly one cell. + (for ([row (in-list (table-blockss bib))]) + (check-equal? (length row) 1)))) diff --git a/scribble-test/tests/scriblib/bibtex.latex-escapes.txt b/scribble-test/tests/scriblib/bibtex.latex-escapes.txt index 838181d2c0..378bc82fe5 100644 --- a/scribble-test/tests/scriblib/bibtex.latex-escapes.txt +++ b/scribble-test/tests/scriblib/bibtex.latex-escapes.txt @@ -1,7 +1,7 @@ Bibliography -[1]Vı́ctor Braberman, Federico Fernández, Diego Garbervetsky, and Sergio - Yovine. Parametric prediction of heap memory requirements. In Proc. - Proceedings of the 7th international symposium on Memory management, - ISMM ’08, pp. 141–150. ACM, New York, NY, USA, 2008. - doi:10.1145/1375634.1375655 +[1] Víctor Braberman, Federico Fernández, Diego Garbervetsky, and Sergio +Yovine. Parametric prediction of heap memory requirements. In Proc. +Proceedings of the 7th international symposium on Memory management, +ISMM ’08, pp. 141–150. ACM, New York, NY, USA, 2008. +doi:10.1145/1375634.1375655 diff --git a/scribble-test/tests/scriblib/bibtex.number.txt b/scribble-test/tests/scriblib/bibtex.number.txt index d352930997..fdd96afc51 100644 --- a/scribble-test/tests/scriblib/bibtex.number.txt +++ b/scribble-test/tests/scriblib/bibtex.number.txt @@ -1,11 +1,12 @@ Bibliography -[1]Ran Canetti. Universally Composable Security: A New Paradigm for - Cryptographic Protocols. Cryptology ePrint Archive, Report 2000/067, - 2000. -[2]Michael Salib. Starkiller: A Static Type Inferencer and Compiler for - Python. Master’s dissertation, Massachusetts Institute of Technology, - 2004. -[3]Sam Tobin-Hochstadt, Vincent St-Amour, Ryan Culpepper, Matthew Flatt, - and Matthias Felleisen. Languages as Libraries. In Proc. PLDI, 2011. -[4]ZA ZAuThOr. StrIngS ArE TerriblE. 2000. +[1] Ran Canetti. Universally Composable Security: A New Paradigm for +Cryptographic Protocols. Cryptology ePrint Archive, Report 2000/067, +2000. +[2] Michael Salib. Starkiller: A Static Type Inferencer and Compiler for +Python. Master’s dissertation, Massachusetts Institute of Technology, +2004. +[3] Sam Tobin-Hochstadt, Vincent St-Amour, Ryan Culpepper, Matthew +Flatt, and Matthias Felleisen. Languages as Libraries. In Proc. PLDI, +2011. +[4] ZA ZAuThOr. StrIngS ArE TerriblE. 2000. diff --git a/scribble-test/tests/scriblib/bibtex.rkt b/scribble-test/tests/scriblib/bibtex.rkt index 04cc5ee1e7..76f0fc518f 100644 --- a/scribble-test/tests/scriblib/bibtex.rkt +++ b/scribble-test/tests/scriblib/bibtex.rkt @@ -6,6 +6,8 @@ scribble/render (prefix-in text: scribble/text-render)) +(error-print-width 2000) + (define-runtime-path example.bib "example.bib") (define-runtime-path normal-expected-path "bibtex.normal.txt") diff --git a/scribble-test/tests/scriblib/test-braced-space.rkt b/scribble-test/tests/scriblib/test-braced-space.rkt index 77a5fb97f9..32b6e47e75 100644 --- a/scribble-test/tests/scriblib/test-braced-space.rkt +++ b/scribble-test/tests/scriblib/test-braced-space.rkt @@ -14,8 +14,8 @@ (define test2 (hash-ref raw "test-braced2")) (define test3 (hash-ref raw "test-braced3")) - (check-equal? (hash-ref test1 "title") "Advances in ACM Technology") - (check-equal? (hash-ref test1 "journal") "Proceedings of the IEEE Conference") - (check-equal? (hash-ref test2 "title") "The ACM Framework") - (check-equal? (hash-ref test3 "title") "Testing SIGPLAN Methods") - (check-equal? (hash-ref test3 "booktitle") "International ACM Conference")) + (check-equal? (hash-ref test1 "title") "Advances in {ACM} Technology") + (check-equal? (hash-ref test1 "journal") "Proceedings of the {IEEE} Conference") + (check-equal? (hash-ref test2 "title") "The {ACM} Framework") + (check-equal? (hash-ref test3 "title") "Testing {SIGPLAN} Methods") + (check-equal? (hash-ref test3 "booktitle") "International {ACM} Conference"))