paperlint 5.0.0 → 6.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CLAUDE.md +13 -0
- package/CONTRIBUTING.md +3 -0
- package/README.md +8 -8
- package/bin/paperlint.mjs +5 -1
- package/dist/adapters/latex/index.d.ts +1 -0
- package/dist/adapters/latex/index.d.ts.map +1 -1
- package/dist/adapters/latex/index.js +3 -0
- package/dist/adapters/latex/index.js.map +1 -1
- package/dist/adapters/latex/layout.d.ts +27 -0
- package/dist/adapters/latex/layout.d.ts.map +1 -0
- package/dist/adapters/latex/layout.js +62 -0
- package/dist/adapters/latex/layout.js.map +1 -0
- package/dist/adapters/latex/nodes.d.ts.map +1 -1
- package/dist/adapters/latex/nodes.js +11 -1
- package/dist/adapters/latex/nodes.js.map +1 -1
- package/dist/adapters/latex/parse.d.ts.map +1 -1
- package/dist/adapters/latex/parse.js +5 -1
- package/dist/adapters/latex/parse.js.map +1 -1
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +25 -5
- package/dist/cli.js.map +1 -1
- package/dist/domain/anonymity.d.ts +68 -0
- package/dist/domain/anonymity.d.ts.map +1 -0
- package/dist/domain/anonymity.js +94 -0
- package/dist/domain/anonymity.js.map +1 -0
- package/dist/domain/body-pages.d.ts +69 -0
- package/dist/domain/body-pages.d.ts.map +1 -0
- package/dist/domain/body-pages.js +141 -0
- package/dist/domain/body-pages.js.map +1 -0
- package/dist/domain/tex-document.d.ts +9 -0
- package/dist/domain/tex-document.d.ts.map +1 -1
- package/dist/eslint-rules/pdf-last-page-balance.d.ts +1 -1
- package/dist/eslint-rules/pdf-last-page-balance.js +2 -2
- package/dist/facts-file.d.ts +28 -2
- package/dist/facts-file.d.ts.map +1 -1
- package/dist/facts-file.js +54 -9
- package/dist/facts-file.js.map +1 -1
- package/dist/lib/paper-config.d.ts.map +1 -1
- package/dist/lib/paper-config.js +2 -1
- package/dist/lib/paper-config.js.map +1 -1
- package/dist/paper-settings.d.ts +11 -0
- package/dist/paper-settings.d.ts.map +1 -1
- package/dist/paper-settings.js +38 -4
- package/dist/paper-settings.js.map +1 -1
- package/dist/pdf-content.d.ts +40 -0
- package/dist/pdf-content.d.ts.map +1 -0
- package/dist/pdf-content.js +97 -0
- package/dist/pdf-content.js.map +1 -0
- package/dist/pdf-facts.d.ts +30 -0
- package/dist/pdf-facts.d.ts.map +1 -1
- package/dist/pdf-facts.js +37 -1
- package/dist/pdf-facts.js.map +1 -1
- package/dist/ports/latex.d.ts +3 -1
- package/dist/ports/latex.d.ts.map +1 -1
- package/dist/presets.d.ts +2 -0
- package/dist/presets.d.ts.map +1 -1
- package/dist/presets.js +4 -0
- package/dist/presets.js.map +1 -1
- package/dist/tex-requirements.d.ts +4 -0
- package/dist/tex-requirements.d.ts.map +1 -1
- package/dist/tex-requirements.js +3 -0
- package/dist/tex-requirements.js.map +1 -1
- package/dist/tex-venue-rules.d.ts +7 -1
- package/dist/tex-venue-rules.d.ts.map +1 -1
- package/dist/tex-venue-rules.js +40 -0
- package/dist/tex-venue-rules.js.map +1 -1
- package/dist/types.d.ts +2 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/venue-rules.d.ts +38 -8
- package/dist/venue-rules.d.ts.map +1 -1
- package/dist/venue-rules.js +179 -41
- package/dist/venue-rules.js.map +1 -1
- package/docs/configuration.md +18 -16
- package/docs/e2e.md +32 -11
- package/docs/rules/anonymity/identity.md +111 -0
- package/docs/rules/format/layout-override.md +61 -0
- package/docs/rules/format/page-limit.md +108 -0
- package/docs/rules/pdf/body-size.md +87 -0
- package/docs/rules.md +44 -39
- package/eslint-rules/pdf-last-page-balance.harness.mjs +6 -1
- package/eslint-rules/pdf-last-page-balance.ts +2 -2
- package/fixtures/build-e2e/aidc/expect.json +1 -1
- package/fixtures/build-e2e/aidc/paperlint.json +1 -1
- package/fixtures/build-e2e/anon-ack/PIPELINE-STATUS.md +3 -0
- package/fixtures/build-e2e/anon-ack/expect.json +19 -0
- package/fixtures/build-e2e/anon-ack/paper.tex +31 -0
- package/fixtures/build-e2e/anon-ack/paperlint.json +1 -0
- package/fixtures/build-e2e/anon-author/PIPELINE-STATUS.md +3 -0
- package/fixtures/build-e2e/anon-author/expect.json +19 -0
- package/fixtures/build-e2e/anon-author/paper.tex +29 -0
- package/fixtures/build-e2e/anon-author/paperlint.json +1 -0
- package/fixtures/build-e2e/anon-bib/PIPELINE-STATUS.md +3 -0
- package/fixtures/build-e2e/anon-bib/expect.json +20 -0
- package/fixtures/build-e2e/anon-bib/paper.tex +35 -0
- package/fixtures/build-e2e/anon-bib/paperlint.json +1 -0
- package/fixtures/build-e2e/anon-href/PIPELINE-STATUS.md +3 -0
- package/fixtures/build-e2e/anon-href/expect.json +19 -0
- package/fixtures/build-e2e/anon-href/paper.tex +30 -0
- package/fixtures/build-e2e/anon-href/paperlint.json +1 -0
- package/fixtures/build-e2e/anon-macro/PIPELINE-STATUS.md +3 -0
- package/fixtures/build-e2e/anon-macro/expect.json +19 -0
- package/fixtures/build-e2e/anon-macro/paper.tex +30 -0
- package/fixtures/build-e2e/anon-macro/paperlint.json +1 -0
- package/fixtures/build-e2e/anon-pdfauthor/PIPELINE-STATUS.md +3 -0
- package/fixtures/build-e2e/anon-pdfauthor/expect.json +19 -0
- package/fixtures/build-e2e/anon-pdfauthor/paper.tex +31 -0
- package/fixtures/build-e2e/anon-pdfauthor/paperlint.json +1 -0
- package/fixtures/build-e2e/unbalanced/expect.json +2 -2
- package/lib/paper-config.ts +3 -1
- package/package.json +1 -1
- package/presets/acm-sigconf.jsonc +3 -1
- package/presets/aidc.jsonc +16 -17
- package/presets/ieee-conference.jsonc +8 -0
- package/presets/venue-profile.schema.json +9 -0
- package/skills/render-paper/extract-pdf-facts.harness.ts +3 -3
- package/src/adapters/latex/index.ts +3 -0
- package/src/adapters/latex/layout.ts +106 -0
- package/src/adapters/latex/nodes.ts +11 -3
- package/src/adapters/latex/parse.ts +5 -1
- package/src/build.harness.mjs +2 -2
- package/src/build.test.ts +5 -0
- package/src/cli.test.ts +13 -0
- package/src/cli.ts +36 -4
- package/src/domain/anonymity.test.ts +163 -0
- package/src/domain/anonymity.ts +129 -0
- package/src/domain/body-pages.test.ts +300 -0
- package/src/domain/body-pages.ts +223 -0
- package/src/domain/tex-document.ts +10 -0
- package/src/facts-file.test.ts +100 -4
- package/src/facts-file.ts +91 -9
- package/src/new-paper.test.ts +7 -1
- package/src/paper-settings.test.ts +63 -6
- package/src/paper-settings.ts +53 -5
- package/src/pdf-content.test.ts +71 -0
- package/src/pdf-content.ts +125 -0
- package/src/pdf-facts.test.ts +110 -2
- package/src/pdf-facts.ts +106 -8
- package/src/ports/latex.ts +3 -0
- package/src/presets.ts +8 -8
- package/src/rule-cases.test.ts +29 -3
- package/src/rule-docs.test.ts +0 -2
- package/src/tex-requirements.ts +9 -0
- package/src/tex-venue-rules.test.ts +110 -5
- package/src/tex-venue-rules.ts +57 -2
- package/src/types.ts +2 -0
- package/src/venue-rules.test.ts +351 -39
- package/src/venue-rules.ts +247 -47
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WHO WROTE THIS PAPER, FOUND IN THE PAPER — the matching `anonymity/identity` judges a blind submission
|
|
3
|
+
* by. A rule cannot know who the author is, so the author DECLARES it (`identity` in
|
|
4
|
+
* `paperlint.json`); given that list, finding it is mechanical, and this module is the mechanism.
|
|
5
|
+
*
|
|
6
|
+
* ── WHAT A MATCH TOLERATES, AND WHY EACH ─────────────────────────────────────────
|
|
7
|
+
* PDF text is not the source text. pdf.js hands back runs of glyphs, and the same name comes out
|
|
8
|
+
* in several spellings of the same letters:
|
|
9
|
+
*
|
|
10
|
+
* ligatures `fi` for `fi` → both sides NFKC-normalized
|
|
11
|
+
* case `ADA EXAMPLE` in small caps → Unicode case folding (the `iu` flags: no
|
|
12
|
+
* locale is consulted, Cyrillic folds too)
|
|
13
|
+
* kerning splits `Ex` `ample` as two items → the page text joins items without a space
|
|
14
|
+
* letter-spacing `A d a E x a m p l e` → whitespace may stand between any two letters
|
|
15
|
+
* line breaks `Ada` ⏎ `Example`, `Exam-` ⏎ `ple` → so may a hyphen and a line break
|
|
16
|
+
* soft hyphens U+00AD inside a word → treated like a hyphen
|
|
17
|
+
* a reference list `A. Example`, `Example, A.` → a personal name's bibliographic forms
|
|
18
|
+
* are searched too (`spellingsOf`)
|
|
19
|
+
*
|
|
20
|
+
* Only the ENDS of a token are anchored: the letter or digit before its first character and after
|
|
21
|
+
* its last must be absent, so `Ada` does not match inside `Canada`. Between its characters anything
|
|
22
|
+
* of the above may stand. The token's own spaces and hyphens are separators too, so
|
|
23
|
+
* `Ada Example` also finds `Ada-Example` and `AdaExample` — the same person.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
/** Where a declared string was found: a page's text, a metadata field, or a link's target. */
|
|
27
|
+
export type LeakPlace =
|
|
28
|
+
| { readonly kind: "page"; readonly page: number }
|
|
29
|
+
| { readonly kind: "metadata"; readonly field: string }
|
|
30
|
+
| { readonly kind: "link"; readonly page: number; readonly uri: string };
|
|
31
|
+
|
|
32
|
+
/** One text to search, and where it came from. */
|
|
33
|
+
export interface Searchable {
|
|
34
|
+
readonly place: LeakPlace;
|
|
35
|
+
readonly text: string;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** One declared string found in one place, with the text it matched as it stands there. */
|
|
39
|
+
export interface Leak {
|
|
40
|
+
readonly token: string;
|
|
41
|
+
readonly place: LeakPlace;
|
|
42
|
+
readonly found: string;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** What may stand between two characters of a token: whitespace, hyphens, soft hyphens. */
|
|
46
|
+
const SEPARATOR = "[\\s\\u00AD-]*";
|
|
47
|
+
const BEFORE = "(?<![\\p{L}\\p{N}])";
|
|
48
|
+
const AFTER = "(?![\\p{L}\\p{N}])";
|
|
49
|
+
|
|
50
|
+
/** `c` as a pattern that matches it literally. */
|
|
51
|
+
const literal = (c: string): string =>
|
|
52
|
+
c.replace(/[.*+?^${}()|[\]\\/]/gu, "\\$&");
|
|
53
|
+
|
|
54
|
+
/** The characters of a token that must appear: NFKC, without its own separators. */
|
|
55
|
+
const lettersOf = (token: string): readonly string[] =>
|
|
56
|
+
token
|
|
57
|
+
.normalize("NFKC")
|
|
58
|
+
.replace(/[\s\u00AD-]/gu, "")
|
|
59
|
+
.match(/./gsu) ?? [];
|
|
60
|
+
|
|
61
|
+
/** A word of a personal name: letters, with an apostrophe or a hyphen inside. */
|
|
62
|
+
const NAME_WORD = /^\p{Lu}[\p{L}'’-]*$/u;
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* The spellings a bibliography gives a personal name, besides the name itself. A token of two to
|
|
66
|
+
* four capitalized words (`Ada Example`, `Ada B. Example` is not one: an initial is not a word)
|
|
67
|
+
* is read as given names and a surname, and the reference list's forms are added: initials
|
|
68
|
+
* (`A. Example`), surname first (`Example, Ada`, `Example, A.`). A token that is not a name — a
|
|
69
|
+
* handle, an address, one word — has only itself.
|
|
70
|
+
*/
|
|
71
|
+
export function spellingsOf(token: string): readonly string[] {
|
|
72
|
+
const words = token.trim().split(/\s+/u);
|
|
73
|
+
if (
|
|
74
|
+
words.length < 2 ||
|
|
75
|
+
words.length > 4 ||
|
|
76
|
+
!words.every((w) => NAME_WORD.test(w))
|
|
77
|
+
)
|
|
78
|
+
return [token];
|
|
79
|
+
const surname = words.slice(-1).join("");
|
|
80
|
+
const given = words.slice(0, -1);
|
|
81
|
+
const initials = given.map((w) => `${w.slice(0, 1)}.`).join(" ");
|
|
82
|
+
return [
|
|
83
|
+
token,
|
|
84
|
+
`${initials} ${surname}`,
|
|
85
|
+
`${surname}, ${given.join(" ")}`,
|
|
86
|
+
`${surname}, ${initials}`,
|
|
87
|
+
];
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** One spelling as a pattern body: its characters, separators allowed between any two. */
|
|
91
|
+
const bodyOf = (spelling: string): string =>
|
|
92
|
+
lettersOf(spelling).map(literal).join(SEPARATOR);
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* A declared token as the pattern that finds it and its bibliographic spellings (see the module
|
|
96
|
+
* header), or null for a token with nothing but separators in it — which would match everywhere,
|
|
97
|
+
* and is refused where it is read.
|
|
98
|
+
*/
|
|
99
|
+
export function tokenPattern(token: string): Readonly<RegExp> | null {
|
|
100
|
+
if (lettersOf(token).length === 0) return null;
|
|
101
|
+
const bodies = spellingsOf(token).map(bodyOf).join("|");
|
|
102
|
+
return new RegExp(`${BEFORE}(?:${bodies})${AFTER}`, "iu");
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Every token found in the sources, once per place: its first match there. Two tokens that match
|
|
107
|
+
* the same text in the same place (`Ada Example` and `adaexample` both finding `AdaExample`) are one
|
|
108
|
+
* leak, reported under the first token declared. Pure.
|
|
109
|
+
*/
|
|
110
|
+
export function findLeaks(
|
|
111
|
+
tokens: readonly string[],
|
|
112
|
+
sources: readonly Searchable[],
|
|
113
|
+
): readonly Leak[] {
|
|
114
|
+
const patterns = tokens.flatMap((token) => {
|
|
115
|
+
const re = tokenPattern(token);
|
|
116
|
+
return re === null ? [] : [{ token, re }];
|
|
117
|
+
});
|
|
118
|
+
return sources.flatMap((s) => {
|
|
119
|
+
const text = s.text.normalize("NFKC");
|
|
120
|
+
const leaks = patterns.flatMap(({ token, re }) => {
|
|
121
|
+
const m = re.exec(text);
|
|
122
|
+
return m === null ? [] : [{ token, place: s.place, found: m[0] }];
|
|
123
|
+
});
|
|
124
|
+
const key = (l: Leak): string => l.found.toLowerCase();
|
|
125
|
+
return leaks.filter(
|
|
126
|
+
(l, i) => leaks.findIndex((x) => key(x) === key(l)) === i,
|
|
127
|
+
);
|
|
128
|
+
});
|
|
129
|
+
}
|
|
@@ -0,0 +1,300 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import {
|
|
3
|
+
bodyEnd,
|
|
4
|
+
isAppendixHeading,
|
|
5
|
+
isReferencesHeading,
|
|
6
|
+
} from "./body-pages.ts";
|
|
7
|
+
|
|
8
|
+
describe("isReferencesHeading", () => {
|
|
9
|
+
it.each([
|
|
10
|
+
"References",
|
|
11
|
+
"REFERENCES",
|
|
12
|
+
"R EFERENCES",
|
|
13
|
+
"Bibliography",
|
|
14
|
+
"7 References",
|
|
15
|
+
"VII. REFERENCES",
|
|
16
|
+
" References ",
|
|
17
|
+
])("%j is a heading", (line) => {
|
|
18
|
+
expect(isReferencesHeading(line)).toBe(true);
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it.each(["See the references below", "References to prior work", "Refs", ""])(
|
|
22
|
+
"%j is not",
|
|
23
|
+
(line) => {
|
|
24
|
+
expect(isReferencesHeading(line)).toBe(false);
|
|
25
|
+
},
|
|
26
|
+
);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
describe("bodyEnd", () => {
|
|
30
|
+
it("a heading below body text on its page: that page is a body page", () => {
|
|
31
|
+
expect(
|
|
32
|
+
bodyEnd([
|
|
33
|
+
"Intro\nbody",
|
|
34
|
+
"more body",
|
|
35
|
+
"end of the body\nReferences\n[1] A.",
|
|
36
|
+
"Appendix",
|
|
37
|
+
]),
|
|
38
|
+
).toEqual({ kind: "found", by: "references", page: 3, bodyPages: 3 });
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it("a heading that is the first text of its page: the body ends on the page before", () => {
|
|
42
|
+
expect(bodyEnd(["Intro\nbody", "more body", "References\n[1] A."])).toEqual(
|
|
43
|
+
{ kind: "found", by: "references", page: 3, bodyPages: 2 },
|
|
44
|
+
);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it("empty lines and a bare page number above the heading are not body", () => {
|
|
48
|
+
expect(bodyEnd(["body", "\n \n7\nR EFERENCES\n[1] x"])).toEqual({
|
|
49
|
+
kind: "found",
|
|
50
|
+
by: "references",
|
|
51
|
+
page: 2,
|
|
52
|
+
bodyPages: 1,
|
|
53
|
+
});
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it("a statement or appendix after the references is not counted", () => {
|
|
57
|
+
expect(
|
|
58
|
+
bodyEnd([
|
|
59
|
+
"body",
|
|
60
|
+
"R EFERENCES\n[1] x\nLLM Usage Statement",
|
|
61
|
+
"Appendix A",
|
|
62
|
+
]),
|
|
63
|
+
).toEqual({ kind: "found", by: "references", page: 2, bodyPages: 1 });
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it("no heading on any page is said, not guessed", () => {
|
|
67
|
+
expect(bodyEnd(["body", "more"])).toEqual({ kind: "missing" });
|
|
68
|
+
expect(bodyEnd([])).toEqual({ kind: "missing" });
|
|
69
|
+
});
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
describe("bodyEnd: a «References» line that is not the heading", () => {
|
|
73
|
+
const BODY = [
|
|
74
|
+
"Intro\nbody",
|
|
75
|
+
"Related work\nReferences\nto prior work are many.",
|
|
76
|
+
];
|
|
77
|
+
|
|
78
|
+
it("🔴 a bare «References» line in the body loses to a heading followed by [1]", () => {
|
|
79
|
+
expect(
|
|
80
|
+
bodyEnd([...BODY, "more body", "last body line\nReferences\n[1] A."]),
|
|
81
|
+
).toEqual({ kind: "found", by: "references", page: 4, bodyPages: 4 });
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it("🔴 with hyperref's anchor, a bare «References» on an earlier page is ignored", () => {
|
|
85
|
+
expect(
|
|
86
|
+
bodyEnd([...BODY, "References\n[1] A.", "Appendix"], {
|
|
87
|
+
bib: 3,
|
|
88
|
+
appendix: null,
|
|
89
|
+
}),
|
|
90
|
+
).toEqual({ kind: "found", by: "references", page: 3, bodyPages: 2 });
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it("🔴 a table header «References» above the heading on the anchor's page: the heading is the confirmed one", () => {
|
|
94
|
+
expect(
|
|
95
|
+
bodyEnd(["body", "TABLE 2\nTool\nReferences\nx\nReferences\n[1] A."], {
|
|
96
|
+
bib: 2,
|
|
97
|
+
appendix: null,
|
|
98
|
+
}),
|
|
99
|
+
).toEqual({ kind: "found", by: "references", page: 2, bodyPages: 2 });
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it("with an anchor and no [1] after the heading, the last heading line on the anchor's page is taken", () => {
|
|
103
|
+
expect(
|
|
104
|
+
bodyEnd(["body", "Tool\nReferences\nx\nReferences\nAbel, A."], {
|
|
105
|
+
bib: 2,
|
|
106
|
+
appendix: null,
|
|
107
|
+
}),
|
|
108
|
+
).toEqual({ kind: "found", by: "references", page: 2, bodyPages: 2 });
|
|
109
|
+
expect(
|
|
110
|
+
bodyEnd(["body", "References\nAbel, A. (2020)."], {
|
|
111
|
+
bib: 2,
|
|
112
|
+
appendix: null,
|
|
113
|
+
}),
|
|
114
|
+
).toEqual({ kind: "found", by: "references", page: 2, bodyPages: 1 });
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it("without an anchor or a confirmed heading, the first bare heading is the fallback", () => {
|
|
118
|
+
expect(bodyEnd(["body", "References\nAbel, A. (2020)."])).toEqual({
|
|
119
|
+
kind: "found",
|
|
120
|
+
by: "references",
|
|
121
|
+
page: 2,
|
|
122
|
+
bodyPages: 1,
|
|
123
|
+
});
|
|
124
|
+
});
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
describe("bodyEnd: the two signals disagree", () => {
|
|
128
|
+
it("the anchor's page has no heading line: said, with the anchor", () => {
|
|
129
|
+
expect(
|
|
130
|
+
bodyEnd(["body", "References\n[1] A.", "[2] B."], {
|
|
131
|
+
bib: 3,
|
|
132
|
+
appendix: null,
|
|
133
|
+
}),
|
|
134
|
+
).toEqual({
|
|
135
|
+
kind: "disagree",
|
|
136
|
+
what: "references",
|
|
137
|
+
heading: null,
|
|
138
|
+
anchor: 3,
|
|
139
|
+
});
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it("a confirmed heading on another page than the anchor's: said, with both", () => {
|
|
143
|
+
expect(
|
|
144
|
+
bodyEnd(["body\nReferences\n[1] quoted", "References\n[1] A."], {
|
|
145
|
+
bib: 2,
|
|
146
|
+
appendix: null,
|
|
147
|
+
}),
|
|
148
|
+
).toEqual({ kind: "disagree", what: "references", heading: 1, anchor: 2 });
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it("an anchor past the last page is a disagreement, not a crash", () => {
|
|
152
|
+
expect(bodyEnd(["References\n[1] A."], { bib: 5, appendix: null })).toEqual(
|
|
153
|
+
{
|
|
154
|
+
kind: "disagree",
|
|
155
|
+
what: "references",
|
|
156
|
+
heading: null,
|
|
157
|
+
anchor: 5,
|
|
158
|
+
},
|
|
159
|
+
);
|
|
160
|
+
});
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
describe("bodyEnd: running headers and footers are not body", () => {
|
|
164
|
+
/** A page of an ACM-style paper: a running header, then `text`. */
|
|
165
|
+
const page = (n: number, text: string) =>
|
|
166
|
+
`Conf '26, June ${String(n)}, Place\nAuthors\n${text}`;
|
|
167
|
+
|
|
168
|
+
it("🔴 a heading at the top of its page, under a running header: the page is not body", () => {
|
|
169
|
+
const pages = [1, 2, 3].map((n) => page(n, `body ${String(n)}`));
|
|
170
|
+
expect(bodyEnd([...pages, page(4, "References\n[1] A.")])).toEqual({
|
|
171
|
+
kind: "found",
|
|
172
|
+
by: "references",
|
|
173
|
+
page: 4,
|
|
174
|
+
bodyPages: 3,
|
|
175
|
+
});
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
it("body text under the header and above the heading still makes the page body", () => {
|
|
179
|
+
const pages = [1, 2, 3].map((n) => page(n, `body ${String(n)}`));
|
|
180
|
+
expect(
|
|
181
|
+
bodyEnd([...pages, page(4, "the end of the body\nReferences\n[1] A.")]),
|
|
182
|
+
).toEqual({ kind: "found", by: "references", page: 4, bodyPages: 4 });
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
it("a line on only two pages is not a running header: it counts as body", () => {
|
|
186
|
+
expect(bodyEnd(["Setup\nbody", "x", "Setup\nReferences\n[1] A."])).toEqual({
|
|
187
|
+
kind: "found",
|
|
188
|
+
by: "references",
|
|
189
|
+
page: 3,
|
|
190
|
+
bodyPages: 3,
|
|
191
|
+
});
|
|
192
|
+
});
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
describe("isAppendixHeading", () => {
|
|
196
|
+
it.each([
|
|
197
|
+
"Appendix A.",
|
|
198
|
+
"APPENDIX",
|
|
199
|
+
"A PPENDIX B",
|
|
200
|
+
"Appendices",
|
|
201
|
+
"Appendix 1:",
|
|
202
|
+
])("%j is a heading", (line) => {
|
|
203
|
+
expect(isAppendixHeading(line)).toBe(true);
|
|
204
|
+
});
|
|
205
|
+
it.each([
|
|
206
|
+
"Appendix A shows the rest.",
|
|
207
|
+
"See the appendix",
|
|
208
|
+
"Appendix A Proofs",
|
|
209
|
+
"",
|
|
210
|
+
])("%j is not", (line) => {
|
|
211
|
+
expect(isAppendixHeading(line)).toBe(false);
|
|
212
|
+
});
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
describe("bodyEnd: an appendix before the references ends the body", () => {
|
|
216
|
+
const PAGES = [
|
|
217
|
+
"body",
|
|
218
|
+
"more body",
|
|
219
|
+
"the conclusion ends here",
|
|
220
|
+
"Appendix A.\nDetailed results\nTABLE 3",
|
|
221
|
+
"References\n[1] A.",
|
|
222
|
+
];
|
|
223
|
+
|
|
224
|
+
it("🔴 the appendix at the top of its page, the references after it: the body ends on the page before", () => {
|
|
225
|
+
expect(bodyEnd(PAGES, { bib: 5, appendix: 4 })).toEqual({
|
|
226
|
+
kind: "found",
|
|
227
|
+
by: "appendix",
|
|
228
|
+
page: 4,
|
|
229
|
+
bodyPages: 3,
|
|
230
|
+
});
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
it("an appendix that starts below the conclusion on its page: that page is body", () => {
|
|
234
|
+
const pages = [...PAGES];
|
|
235
|
+
pages[3] = "the conclusion ends here\nAppendix A.\nDetails";
|
|
236
|
+
expect(bodyEnd(pages, { bib: 5, appendix: 4 })).toEqual({
|
|
237
|
+
kind: "found",
|
|
238
|
+
by: "appendix",
|
|
239
|
+
page: 4,
|
|
240
|
+
bodyPages: 4,
|
|
241
|
+
});
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
it("an appendix after the references changes nothing", () => {
|
|
245
|
+
expect(
|
|
246
|
+
bodyEnd(["body", "end\nReferences\n[1] A.", "Appendix A.\nx"], {
|
|
247
|
+
bib: 2,
|
|
248
|
+
appendix: 3,
|
|
249
|
+
}),
|
|
250
|
+
).toEqual({ kind: "found", by: "references", page: 2, bodyPages: 2 });
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
it("the appendix and the references on one page: the heading that comes first ends the body", () => {
|
|
254
|
+
expect(
|
|
255
|
+
bodyEnd(["body", "end\nAppendix A.\nx\nReferences\n[1] A."], {
|
|
256
|
+
bib: 2,
|
|
257
|
+
appendix: 2,
|
|
258
|
+
}),
|
|
259
|
+
).toEqual({ kind: "found", by: "appendix", page: 2, bodyPages: 2 });
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
it("an appendix and no references heading at all: the appendix ends the body", () => {
|
|
263
|
+
expect(
|
|
264
|
+
bodyEnd(["body", "Appendix\nx"], { bib: null, appendix: 2 }),
|
|
265
|
+
).toEqual({
|
|
266
|
+
kind: "found",
|
|
267
|
+
by: "appendix",
|
|
268
|
+
page: 2,
|
|
269
|
+
bodyPages: 1,
|
|
270
|
+
});
|
|
271
|
+
});
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
describe("bodyEnd: where the appendix is not taken, or not found", () => {
|
|
275
|
+
it("without hyperref's anchor the appendix is not looked for: a sentence can end on «Appendix A.»", () => {
|
|
276
|
+
expect(
|
|
277
|
+
bodyEnd(["body, detailed in\nAppendix A.", "end\nReferences\n[1] A."]),
|
|
278
|
+
).toEqual({ kind: "found", by: "references", page: 2, bodyPages: 2 });
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
it("hyperref anchors the appendix on a page with no line reading «Appendix»: said", () => {
|
|
282
|
+
expect(
|
|
283
|
+
bodyEnd(["body", "A Proofs\nx"], { bib: null, appendix: 2 }),
|
|
284
|
+
).toEqual({
|
|
285
|
+
kind: "disagree",
|
|
286
|
+
what: "appendix",
|
|
287
|
+
heading: null,
|
|
288
|
+
anchor: 2,
|
|
289
|
+
});
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
it("an appendix anchor past the last page is a disagreement, not a crash", () => {
|
|
293
|
+
expect(bodyEnd(["body"], { bib: null, appendix: 4 })).toEqual({
|
|
294
|
+
kind: "disagree",
|
|
295
|
+
what: "appendix",
|
|
296
|
+
heading: null,
|
|
297
|
+
anchor: 4,
|
|
298
|
+
});
|
|
299
|
+
});
|
|
300
|
+
});
|
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* HOW MANY PAGES THE BODY TAKES, counted from what the PDF says rather than from how banal
|
|
3
|
+
* classifies its pages. A venue that limits the body «excluding references and appendices» (AIDC)
|
|
4
|
+
* cannot be judged on banal's body count: banal calls any page holding non-bibliography text body,
|
|
5
|
+
* so an LLM Usage Statement or an appendix placed after the bibliography is counted as body, and a
|
|
6
|
+
* correct paper fails (measured, `presets/aidc.jsonc`).
|
|
7
|
+
*
|
|
8
|
+
* ── WHERE THE BODY ENDS: AT THE REFERENCES, OR AT THE APPENDIX, WHICHEVER COMES FIRST ──────
|
|
9
|
+
* «Excluding references and appendices» excludes an appendix wherever it sits, so the body ends at
|
|
10
|
+
* the first of the two. Each is found by a structural signal first:
|
|
11
|
+
*
|
|
12
|
+
* references the page of the first bibliography entry, from the destination hyperref writes for
|
|
13
|
+
* every `\bibitem` (`cite.<key>`); then, on that page, the heading line: a line that
|
|
14
|
+
* reads «References» or «Bibliography» (any case, optionally numbered), the one
|
|
15
|
+
* CONFIRMED by a next line opening the first entry, `[1]`, else the last one there.
|
|
16
|
+
* Without hyperref: the first confirmed heading, else the first bare one — a table
|
|
17
|
+
* header or a subsection titled «References» reads the same, so it is a fallback.
|
|
18
|
+
* appendix the page hyperref anchored the appendix on (an `appendix.<letter>` destination, or a
|
|
19
|
+
* bookmark titled «Appendix…»); then, on that page, the line reading «Appendix»,
|
|
20
|
+
* «Appendix A.», «APPENDIX» or «Appendices». Without hyperref the appendix is not
|
|
21
|
+
* looked for: a sentence can end on a line reading «Appendix A.», and nothing tells
|
|
22
|
+
* it from the heading.
|
|
23
|
+
*
|
|
24
|
+
* A structural signal whose page has no heading line, or a confirmed references heading on another
|
|
25
|
+
* page than hyperref's first entry, is a disagreement, and it is said rather than resolved by
|
|
26
|
+
* picking one.
|
|
27
|
+
*
|
|
28
|
+
* ── THE PAGE THE BODY ENDS ON ──────────────────────────────────────────────────────
|
|
29
|
+
* Body text above the heading on that page makes it a body page: a body that runs half a page past
|
|
30
|
+
* the limit is over the limit, and the count must flag it rather than let a desk reject through.
|
|
31
|
+
* Only when the heading is the first text of its page does the body end on the page before. A line
|
|
32
|
+
* with no letter (a page number) is not body text, and neither is a running header or footer — a
|
|
33
|
+
* line that repeats, digits aside, on at least three pages and a third of them.
|
|
34
|
+
*/
|
|
35
|
+
|
|
36
|
+
/** What ends the body. */
|
|
37
|
+
export type BodyEndsAt = "references" | "appendix";
|
|
38
|
+
|
|
39
|
+
/** Where hyperref anchored the bibliography's first entry and the appendix: 1-based pages, or null. */
|
|
40
|
+
export interface Anchors {
|
|
41
|
+
readonly bib: number | null;
|
|
42
|
+
readonly appendix: number | null;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Where the body ends, that no page says, or that a structural signal and the text disagree. */
|
|
46
|
+
export type BodyEnd =
|
|
47
|
+
| {
|
|
48
|
+
readonly kind: "found";
|
|
49
|
+
readonly by: BodyEndsAt;
|
|
50
|
+
/** 1-based page holding the heading that ends the body. */
|
|
51
|
+
readonly page: number;
|
|
52
|
+
/** The body, as the venue counts it: the heading's page too, when body text stands above it. */
|
|
53
|
+
readonly bodyPages: number;
|
|
54
|
+
}
|
|
55
|
+
| { readonly kind: "missing" }
|
|
56
|
+
| {
|
|
57
|
+
readonly kind: "disagree";
|
|
58
|
+
readonly what: BodyEndsAt;
|
|
59
|
+
/** The page of the first confirmed references heading, or null when the anchor's page has no heading. */
|
|
60
|
+
readonly heading: number | null;
|
|
61
|
+
/** The page hyperref anchored it on. */
|
|
62
|
+
readonly anchor: number;
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* A line that is a references heading: `References` or `Bibliography`, in any case (IEEEtran sets
|
|
67
|
+
* `REFERENCES` in small caps, which pdf.js may split as `R EFERENCES`), optionally numbered
|
|
68
|
+
* (`7 References`, `VII. REFERENCES`). The line is compared with every space removed.
|
|
69
|
+
*/
|
|
70
|
+
const HEADING = /^(?:(?:\d+|[ivxlcdm]+)\.?)?(?:references|bibliography)$/iu;
|
|
71
|
+
|
|
72
|
+
/** Whether one line of a page's text is the references heading. */
|
|
73
|
+
export const isReferencesHeading = (line: string): boolean =>
|
|
74
|
+
HEADING.test(line.normalize("NFKC").replace(/\s+/gu, ""));
|
|
75
|
+
|
|
76
|
+
/** A line that is an appendix heading: `Appendix A.`, `APPENDIX`, `Appendices`, spaces removed. */
|
|
77
|
+
const APPENDIX_HEADING = /^appendi(?:x|ces)(?:[a-z]|\d+)?[.:]?$/iu;
|
|
78
|
+
|
|
79
|
+
/** Whether one line of a page's text is an appendix heading. */
|
|
80
|
+
export const isAppendixHeading = (line: string): boolean =>
|
|
81
|
+
APPENDIX_HEADING.test(line.normalize("NFKC").replace(/\s+/gu, ""));
|
|
82
|
+
|
|
83
|
+
/** Whether a line opens the first entry of a numbered bibliography: `[1]`. */
|
|
84
|
+
const isFirstEntry = (line: string): boolean => /^\s*\[\s*1\s*\]/u.test(line);
|
|
85
|
+
|
|
86
|
+
/** A page's lines, and where its headings are: the confirmed one, and every one. */
|
|
87
|
+
interface PageReading {
|
|
88
|
+
readonly lines: readonly string[];
|
|
89
|
+
/** Line index of a heading whose next non-empty line is `[1]`, or -1. */
|
|
90
|
+
readonly confirmed: number;
|
|
91
|
+
/** Line indices of every heading line. */
|
|
92
|
+
readonly headings: readonly number[];
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function readPage(text: string): PageReading {
|
|
96
|
+
const lines = text.split("\n");
|
|
97
|
+
const headings = lines.flatMap((l, i) => (isReferencesHeading(l) ? [i] : []));
|
|
98
|
+
const confirmed =
|
|
99
|
+
headings.find((i) =>
|
|
100
|
+
isFirstEntry(lines.slice(i + 1).find((l) => l.trim() !== "") ?? ""),
|
|
101
|
+
) ?? -1;
|
|
102
|
+
return { lines, confirmed, headings };
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** A line with digits blanked and spaces collapsed: what a running header keeps from page to page. */
|
|
106
|
+
const shape = (line: string): string =>
|
|
107
|
+
line.replace(/\d+/gu, "#").replace(/\s+/gu, " ").trim();
|
|
108
|
+
|
|
109
|
+
/** The shapes of the lines that repeat on at least three pages and a third of them. */
|
|
110
|
+
function runningLines(pages: readonly PageReading[]): ReadonlySet<string> {
|
|
111
|
+
const onPages = pages.flatMap((p) => [...new Set(p.lines.map(shape))]);
|
|
112
|
+
const sorted = onPages.toSorted();
|
|
113
|
+
const floor = Math.max(3, pages.length / 3);
|
|
114
|
+
// In sorted order, a shape on `floor` pages is the same string `floor - 1` places further on.
|
|
115
|
+
return new Set(
|
|
116
|
+
sorted.filter((s, i) => sorted[i + Math.ceil(floor) - 1] === s),
|
|
117
|
+
);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** The heading line the references start at on a page: the confirmed one, else the last one. */
|
|
121
|
+
const startLine = (p: PageReading): number =>
|
|
122
|
+
p.confirmed >= 0 ? p.confirmed : (p.headings.at(-1) ?? -1);
|
|
123
|
+
|
|
124
|
+
/** A heading that ends the body: its 0-based page and its line there. */
|
|
125
|
+
interface At {
|
|
126
|
+
readonly kind: "at";
|
|
127
|
+
readonly by: BodyEndsAt;
|
|
128
|
+
readonly page: number;
|
|
129
|
+
readonly line: number;
|
|
130
|
+
/** The lines of that page above the heading. */
|
|
131
|
+
readonly above: readonly string[];
|
|
132
|
+
}
|
|
133
|
+
type Start = At | Extract<BodyEnd, { readonly kind: "disagree" | "missing" }>;
|
|
134
|
+
|
|
135
|
+
/** The references heading at line `line` of 0-based page `page`. */
|
|
136
|
+
const at = (page: number, p: PageReading, line: number): At => ({
|
|
137
|
+
kind: "at",
|
|
138
|
+
by: "references",
|
|
139
|
+
page,
|
|
140
|
+
line,
|
|
141
|
+
above: p.lines.slice(0, line),
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
/** Where the references start: at hyperref's first entry's page, else at the heading in the text. */
|
|
145
|
+
function referencesAt(
|
|
146
|
+
pages: readonly PageReading[],
|
|
147
|
+
anchor: number | null,
|
|
148
|
+
): Start {
|
|
149
|
+
const confirmed = pages.findIndex((p) => p.confirmed >= 0);
|
|
150
|
+
if (anchor !== null) {
|
|
151
|
+
const p = pages[anchor - 1];
|
|
152
|
+
const line = p === undefined ? -1 : startLine(p);
|
|
153
|
+
if (p === undefined || line < 0)
|
|
154
|
+
return { kind: "disagree", what: "references", heading: null, anchor };
|
|
155
|
+
if (confirmed >= 0 && confirmed !== anchor - 1)
|
|
156
|
+
return {
|
|
157
|
+
kind: "disagree",
|
|
158
|
+
what: "references",
|
|
159
|
+
heading: confirmed + 1,
|
|
160
|
+
anchor,
|
|
161
|
+
};
|
|
162
|
+
return at(anchor - 1, p, line);
|
|
163
|
+
}
|
|
164
|
+
const i =
|
|
165
|
+
confirmed >= 0 ? confirmed : pages.findIndex((p) => p.headings.length > 0);
|
|
166
|
+
const p = pages[i];
|
|
167
|
+
return p === undefined ? { kind: "missing" } : at(i, p, startLine(p));
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** Where the appendix starts: on hyperref's page for it, at the line reading «Appendix». */
|
|
171
|
+
function appendixAt(
|
|
172
|
+
pages: readonly PageReading[],
|
|
173
|
+
anchor: number | null,
|
|
174
|
+
): Start {
|
|
175
|
+
if (anchor === null) return { kind: "missing" };
|
|
176
|
+
const lines = pages[anchor - 1]?.lines ?? [];
|
|
177
|
+
const line = lines.findIndex(isAppendixHeading);
|
|
178
|
+
return line < 0
|
|
179
|
+
? { kind: "disagree", what: "appendix", heading: null, anchor }
|
|
180
|
+
: {
|
|
181
|
+
kind: "at",
|
|
182
|
+
by: "appendix",
|
|
183
|
+
page: anchor - 1,
|
|
184
|
+
line,
|
|
185
|
+
above: lines.slice(0, line),
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/** The body's end at a heading: the heading's page counts when body text stands above it. */
|
|
190
|
+
function endAt(pages: readonly PageReading[], at: At): BodyEnd {
|
|
191
|
+
const running = runningLines(pages);
|
|
192
|
+
const body = at.above.some((l) => /\p{L}/u.test(l) && !running.has(shape(l)));
|
|
193
|
+
return {
|
|
194
|
+
kind: "found",
|
|
195
|
+
by: at.by,
|
|
196
|
+
page: at.page + 1,
|
|
197
|
+
bodyPages: body ? at.page + 1 : at.page,
|
|
198
|
+
};
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Where the body ends — at the references or the appendix, whichever comes first — from each
|
|
203
|
+
* page's text and where hyperref anchored the two. Pure.
|
|
204
|
+
*/
|
|
205
|
+
export function bodyEnd(
|
|
206
|
+
texts: readonly string[],
|
|
207
|
+
anchors: Anchors = { bib: null, appendix: null },
|
|
208
|
+
): BodyEnd {
|
|
209
|
+
const pages = texts.map(readPage);
|
|
210
|
+
const starts = [
|
|
211
|
+
referencesAt(pages, anchors.bib),
|
|
212
|
+
appendixAt(pages, anchors.appendix),
|
|
213
|
+
];
|
|
214
|
+
const disagree = starts.find(
|
|
215
|
+
(s): s is Extract<BodyEnd, { readonly kind: "disagree" }> =>
|
|
216
|
+
s.kind === "disagree",
|
|
217
|
+
);
|
|
218
|
+
if (disagree !== undefined) return disagree;
|
|
219
|
+
const first = starts
|
|
220
|
+
.filter((s): s is At => s.kind === "at")
|
|
221
|
+
.toSorted((a, b) => a.page - b.page || a.line - b.line)[0];
|
|
222
|
+
return first === undefined ? { kind: "missing" } : endAt(pages, first);
|
|
223
|
+
}
|
|
@@ -141,3 +141,13 @@ export type ProsePiece =
|
|
|
141
141
|
export interface Passage {
|
|
142
142
|
readonly pieces: readonly [ProsePiece, ...ProsePiece[]];
|
|
143
143
|
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* A command that changes the page layout a venue's template sets: the text block, the margins,
|
|
147
|
+
* the line spacing, or space pulled back with a negative skip. `command` is how a message names it
|
|
148
|
+
* (`\setlength{\textheight}`, `\vspace{-2mm}`).
|
|
149
|
+
*/
|
|
150
|
+
export interface LayoutOverride {
|
|
151
|
+
readonly command: string;
|
|
152
|
+
readonly place: Place;
|
|
153
|
+
}
|