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
package/lib/paper-config.ts
CHANGED
|
@@ -136,8 +136,10 @@ export const SETTINGS_KEYS: Readonly<Record<string, string>> = Object.freeze({
|
|
|
136
136
|
"rule overrides: { id: severity } for every paper in scope, or ESLint blocks (files, ignores, rules) — paperlint lint",
|
|
137
137
|
extends:
|
|
138
138
|
"the venue preset: paperlint:<name> or ./path.jsonc — the pdf/ venue rules, paperlint build",
|
|
139
|
-
kind: "the kind of paper, whose page limit applies —
|
|
139
|
+
kind: "the kind of paper, whose page limit applies — format/page-limit",
|
|
140
140
|
pdf: "where the built PDF is, relative to the paper, when it is not paper.pdf — the facts",
|
|
141
|
+
identity:
|
|
142
|
+
"what identifies the authors — names, handles, emails, affiliations, own project names; a blind venue's PDF must not say any of them — anonymity/identity. The root's list and a paper's are joined",
|
|
141
143
|
scripts: "how skill prose names the scripts — consumer.mjs",
|
|
142
144
|
timezone: "the zone deadline anchors are written in — consumer.mjs",
|
|
143
145
|
contactEmail: "the polite-pool address for citation lookups — consumer.mjs",
|
package/package.json
CHANGED
|
@@ -74,7 +74,9 @@
|
|
|
74
74
|
// «font size is 9 pt for all body text»
|
|
75
75
|
"body_pt": 9,
|
|
76
76
|
// banal measures the MODE of the rendered text, not the declared size (measured 08-26: 9.30 at
|
|
77
|
-
// acmart's declared 9). Hence the tolerance
|
|
77
|
+
// acmart's declared 9). Hence the tolerance: banal reads 0.3 pt high on every template measured
|
|
78
|
+
// (fixtures/accepted-papers/body-size.json), and 0.5 absorbs that while a paper one size off is
|
|
79
|
+
// still reported.
|
|
78
80
|
"body_pt_tol": 0.5,
|
|
79
81
|
|
|
80
82
|
// «font size of the references can be from 7 pt to 9 pt»
|
package/presets/aidc.jsonc
CHANGED
|
@@ -22,6 +22,10 @@
|
|
|
22
22
|
{
|
|
23
23
|
"extends": "paperlint:ieee-conference",
|
|
24
24
|
|
|
25
|
+
// «Submissions must be properly anonymized for double-blind review.» (the call above), and
|
|
26
|
+
// `"blind":true` in the portal's data. anonymity/identity then requires the paper's `identity`.
|
|
27
|
+
"blind": true,
|
|
28
|
+
|
|
25
29
|
// What the venue is called in a paper's text (tex/venue-leftover): the workshop, and the
|
|
26
30
|
// conference it is held at.
|
|
27
31
|
"aliases": ["AIDC", "ACSAC"],
|
|
@@ -44,30 +48,25 @@
|
|
|
44
48
|
"textblock_w_in": 7.0,
|
|
45
49
|
"textblock_h_in": 9.02,
|
|
46
50
|
|
|
47
|
-
// 🔴 PAGE
|
|
48
|
-
// and references by what is on them, and a page that holds ANY non-bibliography text
|
|
49
|
-
// body. Measured 2026-09-27 on a [conference,compsoc] paper whose body ends at the
|
|
50
|
-
// page 12:
|
|
51
|
+
// 🔴 THE PAGE LIMIT IS COUNTED BEFORE THE REFERENCES, NOT BY banal. banal splits pages into
|
|
52
|
+
// body and references by what is on them, and a page that holds ANY non-bibliography text
|
|
53
|
+
// counts as body. Measured 2026-09-27 on a [conference,compsoc] paper whose body ends at the
|
|
54
|
+
// foot of page 12:
|
|
51
55
|
// LLM Usage Statement before the references → body 13, references 0
|
|
52
56
|
// an appendix before the references → body 13, references 2
|
|
53
57
|
// both placed after the references → body right, the tail counted as references
|
|
54
58
|
// AIDC counts neither in the limit («excluding references and appendices»; the ACSAC AI policy:
|
|
55
|
-
// the LLM Usage Statement «does not count towards the page limit»)
|
|
56
|
-
//
|
|
57
|
-
//
|
|
58
|
-
//
|
|
59
|
-
"
|
|
60
|
-
"_": "Body pages, excluding references, appendices and the LLM Usage Statement. banal counts a page holding an appendix or the statement before the bibliography as body, so its body count is not this number.",
|
|
61
|
-
"regular": 12,
|
|
62
|
-
"short": 6,
|
|
63
|
-
"references_and_appendices": "unlimited",
|
|
64
|
-
"llm_usage_statement": "not counted (ACSAC AI policy)",
|
|
65
|
-
},
|
|
59
|
+
// the LLM Usage Statement «does not count towards the page limit»). So format/page-limit counts
|
|
60
|
+
// the body up to where the references start (`body_ends_at`): the page they start on counts
|
|
61
|
+
// when body text stands above the heading there, and the limit below is gated. An appendix
|
|
62
|
+
// ends the body too, wherever it sits, when hyperref anchors it (docs/rules/format/page-limit.md).
|
|
63
|
+
"body_ends_at": "references",
|
|
66
64
|
|
|
67
65
|
// «Short position papers or work-in-progress (WIP) papers» share one limit, so one kind.
|
|
66
|
+
// References, appendices and the LLM Usage Statement: unlimited.
|
|
68
67
|
"kinds": {
|
|
69
|
-
"regular": {},
|
|
70
|
-
"short": {},
|
|
68
|
+
"regular": { "body_pages_max": 12 },
|
|
69
|
+
"short": { "body_pages_max": 6 },
|
|
71
70
|
},
|
|
72
71
|
},
|
|
73
72
|
}
|
|
@@ -59,6 +59,14 @@
|
|
|
59
59
|
"body_pt": 10,
|
|
60
60
|
"body_pt_tol": 0.5,
|
|
61
61
|
|
|
62
|
+
// The class sets the bibliography in \footnotesize, 8 pt at 10pt and at 9pt (IEEEtran.cls,
|
|
63
|
+
// \thebibliography). Measured 2026-09-29 on the four built ACSAC papers in
|
|
64
|
+
// fixtures/accepted-papers (body-size.json): pdf.js reads the reference glyphs at 8.00 pt
|
|
65
|
+
// (7.97 without compsoc), banal gives 8.3 on all four. body_pt_tol widens it to 7.5-8.5, so a
|
|
66
|
+
// bibliography shrunk to \scriptsize (7.3) or grown to \small (9.3) is reported.
|
|
67
|
+
"ref_pt_min": 8,
|
|
68
|
+
"ref_pt_max": 8,
|
|
69
|
+
|
|
62
70
|
// The Times clone. Headings and the title are set in the same family, so a `fonts_title` would
|
|
63
71
|
// repeat this check and report a Computer Modern build twice.
|
|
64
72
|
"fonts_text": "NimbusRomNo9L",
|
|
@@ -24,6 +24,10 @@
|
|
|
24
24
|
"type": "string",
|
|
25
25
|
"minLength": 1
|
|
26
26
|
},
|
|
27
|
+
"blind": {
|
|
28
|
+
"description": "The venue reviews double-blind: its call says submissions must be anonymized. Read by `anonymity/identity`, which then requires the paper's `identity` and finds none of it in the PDF. Set only where the venue says so, with the quote. A child's value replaces its parent's.",
|
|
29
|
+
"type": "boolean"
|
|
30
|
+
},
|
|
27
31
|
"aliases": {
|
|
28
32
|
"description": "Other names the venue goes by in a paper's text — its acronym, its parent conference (`[\"AISec\", \"ACM CCS\"]`). Read by `tex/venue-leftover`: a paper that extends another venue and says one of these is flagged. Matched as whole words, case-sensitive. Collected along the `extends` chain, with `name`.",
|
|
29
33
|
"type": "array",
|
|
@@ -105,6 +109,11 @@
|
|
|
105
109
|
"type": "string",
|
|
106
110
|
"minLength": 1
|
|
107
111
|
},
|
|
112
|
+
"body_ends_at": {
|
|
113
|
+
"description": "How `format/page-limit` counts body pages. Absent: banal's body count (every page holding non-bibliography text). `references`: the pages up to where the body ends — at the references or the appendix, whichever comes first, found by hyperref's anchors and then the heading line — with that page counted when body text stands above the heading on it; for a venue whose limit excludes references and appendices (and what follows them, such as statements).",
|
|
114
|
+
"type": "string",
|
|
115
|
+
"enum": ["references"]
|
|
116
|
+
},
|
|
108
117
|
"page_limits_not_gated": {
|
|
109
118
|
"type": "object"
|
|
110
119
|
},
|
|
@@ -119,8 +119,8 @@ try {
|
|
|
119
119
|
const fonts = Fonts.safeParse(facts.fonts);
|
|
120
120
|
const fontList = fonts.success ? fonts.data : [];
|
|
121
121
|
check(
|
|
122
|
-
"the facts are schema
|
|
123
|
-
facts.schema ===
|
|
122
|
+
"the facts are schema 3, about paper.pdf, for the venue paperlint.json declares",
|
|
123
|
+
facts.schema === 3 &&
|
|
124
124
|
facts.pdf === "paper.pdf" &&
|
|
125
125
|
facts.venue === "agenticdev" &&
|
|
126
126
|
facts.kind === "short",
|
|
@@ -236,5 +236,5 @@ try {
|
|
|
236
236
|
}
|
|
237
237
|
|
|
238
238
|
console.log(
|
|
239
|
-
`✓ ${String(check.count)} assertions passed — extract-pdf-facts: the exit-code contract, schema
|
|
239
|
+
`✓ ${String(check.count)} assertions passed — extract-pdf-facts: the exit-code contract, schema 3, and the no-no-yes font`,
|
|
240
240
|
);
|
|
@@ -31,6 +31,7 @@ import { outlineOf } from "./outline.ts";
|
|
|
31
31
|
import { parseLatex } from "./parse.ts";
|
|
32
32
|
import { bodyProse } from "./prose.ts";
|
|
33
33
|
import { renderedRuns } from "./rendered.ts";
|
|
34
|
+
import { layoutOverridesOf } from "./layout.ts";
|
|
34
35
|
|
|
35
36
|
export {
|
|
36
37
|
documentClassOf,
|
|
@@ -51,6 +52,7 @@ export { outlineOf } from "./outline.ts";
|
|
|
51
52
|
export { parseLatex, type ParsedTex } from "./parse.ts";
|
|
52
53
|
export { bodyProse } from "./prose.ts";
|
|
53
54
|
export { renderedRuns } from "./rendered.ts";
|
|
55
|
+
export { layoutOverridesOf } from "./layout.ts";
|
|
54
56
|
|
|
55
57
|
/** The `LatexReader` port over unified-latex: each call parses its source once. */
|
|
56
58
|
export const latexReader: LatexReader = {
|
|
@@ -62,4 +64,5 @@ export const latexReader: LatexReader = {
|
|
|
62
64
|
documentBody: (src) => documentBodyOf(parseLatex(src)),
|
|
63
65
|
template: parseTemplate,
|
|
64
66
|
withDocumentClass: (src, want) => replaceDocumentClass(parseLatex(src), want),
|
|
67
|
+
layoutOverrides: (src) => layoutOverridesOf(parseLatex(src)),
|
|
65
68
|
};
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Where a source changes the page layout its class sets for the whole document — the input of
|
|
3
|
+
* `format/layout-override`. Read from the parse tree, never the text: a command in a comment is not
|
|
4
|
+
* a command.
|
|
5
|
+
*
|
|
6
|
+
* What counts, each by its node:
|
|
7
|
+
*
|
|
8
|
+
* \usepackage{geometry} · \RequirePackage{…,geometry} the package that re-sets the margins
|
|
9
|
+
* \geometry{…} · \newgeometry{…} its commands
|
|
10
|
+
* \setlength / \addtolength on a layout length \textheight \textwidth \topmargin …
|
|
11
|
+
* \linespread{…} · \renewcommand{\baselinestretch} the line spacing
|
|
12
|
+
*
|
|
13
|
+
* Local spacing — a negative `\vspace` or `\vskip` around one float — is not a layout the template
|
|
14
|
+
* sets, and no venue's call names it; it is not read.
|
|
15
|
+
*
|
|
16
|
+
* A comment and a `comment` environment are not read — the parser hands both back as text, not
|
|
17
|
+
* macros. Everything else is — the bibliography, math, floats — and a macro definition too: a
|
|
18
|
+
* `\newcommand` whose body sets a layout length does so wherever it is used. Conditionals are not
|
|
19
|
+
* evaluated.
|
|
20
|
+
*/
|
|
21
|
+
import type { LayoutOverride } from "../../domain/tex-document.ts";
|
|
22
|
+
import {
|
|
23
|
+
collapse,
|
|
24
|
+
isList,
|
|
25
|
+
macroPlace,
|
|
26
|
+
mandatory,
|
|
27
|
+
textOf,
|
|
28
|
+
visited,
|
|
29
|
+
type Macro,
|
|
30
|
+
} from "./nodes.ts";
|
|
31
|
+
import type { ParsedTex } from "./parse.ts";
|
|
32
|
+
|
|
33
|
+
/** The lengths the class sets for the page and the line; changing one changes the venue's layout. */
|
|
34
|
+
export const LAYOUT_LENGTHS: ReadonlySet<string> = new Set([
|
|
35
|
+
"textheight",
|
|
36
|
+
"textwidth",
|
|
37
|
+
"topmargin",
|
|
38
|
+
"oddsidemargin",
|
|
39
|
+
"evensidemargin",
|
|
40
|
+
"columnsep",
|
|
41
|
+
"headsep",
|
|
42
|
+
"headheight",
|
|
43
|
+
"footskip",
|
|
44
|
+
"baselineskip",
|
|
45
|
+
]);
|
|
46
|
+
|
|
47
|
+
/** The macro a mandatory argument holds first, or undefined: `{\textheight}` → `textheight`. */
|
|
48
|
+
function firstMacro(m: Macro, i: number): string | undefined {
|
|
49
|
+
const first = mandatory(m)[i]?.content.find((n) => n.type !== "whitespace");
|
|
50
|
+
return first?.type === "macro" ? first.content : undefined;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const argText = (m: Macro, i: number): string =>
|
|
54
|
+
collapse(textOf(mandatory(m)[i]?.content));
|
|
55
|
+
|
|
56
|
+
/** What names a macro that overrides the layout; null: this use of it does not. */
|
|
57
|
+
type Namer = (m: Macro) => string | null;
|
|
58
|
+
|
|
59
|
+
const usesGeometry: Namer = (m) =>
|
|
60
|
+
argText(m, 0)
|
|
61
|
+
.split(",")
|
|
62
|
+
.map((p) => p.trim())
|
|
63
|
+
.includes("geometry")
|
|
64
|
+
? `\\${m.content}{geometry}`
|
|
65
|
+
: null;
|
|
66
|
+
|
|
67
|
+
const setsLayoutLength: Namer = (m) => {
|
|
68
|
+
const length = firstMacro(m, 0);
|
|
69
|
+
return length !== undefined && LAYOUT_LENGTHS.has(length)
|
|
70
|
+
? `\\${m.content}{\\${length}}`
|
|
71
|
+
: null;
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
/** Each macro that may override the layout, and how to tell whether this use of it does. */
|
|
75
|
+
const OVERRIDES: ReadonlyMap<string, Namer> = new Map<string, Namer>([
|
|
76
|
+
["usepackage", usesGeometry],
|
|
77
|
+
["RequirePackage", usesGeometry],
|
|
78
|
+
["geometry", (m) => `\\${m.content}`],
|
|
79
|
+
["newgeometry", (m) => `\\${m.content}`],
|
|
80
|
+
["linespread", (m) => `\\linespread{${argText(m, 0)}}`],
|
|
81
|
+
["setlength", setsLayoutLength],
|
|
82
|
+
["addtolength", setsLayoutLength],
|
|
83
|
+
[
|
|
84
|
+
"renewcommand",
|
|
85
|
+
(m) =>
|
|
86
|
+
firstMacro(m, 0) === "baselinestretch"
|
|
87
|
+
? "\\renewcommand{\\baselinestretch}"
|
|
88
|
+
: null,
|
|
89
|
+
],
|
|
90
|
+
]);
|
|
91
|
+
|
|
92
|
+
/** How a message names a macro that overrides the layout, or null when it does not. */
|
|
93
|
+
const overrideOf: Namer = (m) => OVERRIDES.get(m.content)?.(m) ?? null;
|
|
94
|
+
|
|
95
|
+
/** Every layout override in the source, in document order. */
|
|
96
|
+
export function layoutOverridesOf(t: ParsedTex): readonly LayoutOverride[] {
|
|
97
|
+
// Nothing is pruned: a comment, and a `comment` environment, reach the tree as leaves of text.
|
|
98
|
+
const lists = visited(t.root, () => false).filter(isList);
|
|
99
|
+
return lists.flatMap((list) =>
|
|
100
|
+
list.flatMap((n) => {
|
|
101
|
+
if (n.type !== "macro") return [];
|
|
102
|
+
const command = overrideOf(n);
|
|
103
|
+
return command === null ? [] : [{ command, place: macroPlace(n) }];
|
|
104
|
+
}),
|
|
105
|
+
);
|
|
106
|
+
}
|
|
@@ -63,9 +63,17 @@ const NONE: Shown = { kind: "none" };
|
|
|
63
63
|
*/
|
|
64
64
|
const SHOWN_ARGUMENT: ReadonlyMap<string, Shown> = new Map<string, Shown>([
|
|
65
65
|
["texorpdfstring", { kind: "argument", at: 0 }],
|
|
66
|
-
...[
|
|
67
|
-
|
|
68
|
-
|
|
66
|
+
...[
|
|
67
|
+
"color",
|
|
68
|
+
"fontsize",
|
|
69
|
+
"vspace",
|
|
70
|
+
"hspace",
|
|
71
|
+
"setlength",
|
|
72
|
+
"addtolength",
|
|
73
|
+
"linespread",
|
|
74
|
+
"geometry",
|
|
75
|
+
"newgeometry",
|
|
76
|
+
].map((m) => [m, NONE] as const),
|
|
69
77
|
]);
|
|
70
78
|
|
|
71
79
|
/** The text a (non-key) macro typesets: see `SHOWN_ARGUMENT`. */
|
|
@@ -82,7 +82,11 @@ const LINK_SIGNATURES = Object.fromEntries(signed(["href"], "m m"));
|
|
|
82
82
|
* Declarations unified-latex has no signature for: signed so their arguments (sizes, not text)
|
|
83
83
|
* attach to them instead of standing beside them as groups a reader would seem to see.
|
|
84
84
|
*/
|
|
85
|
-
const DECLARATION_SIGNATURES = Object.fromEntries(
|
|
85
|
+
const DECLARATION_SIGNATURES = Object.fromEntries([
|
|
86
|
+
...signed(["fontsize"], "m m"),
|
|
87
|
+
// The layout commands `format/layout-override` reads: a spacing factor, geometry's key list.
|
|
88
|
+
...signed(["linespread", "geometry", "newgeometry"], "m"),
|
|
89
|
+
]);
|
|
86
90
|
|
|
87
91
|
/**
|
|
88
92
|
* Macros that define other macros or environments. A heading in a body is no section where it is
|
package/src/build.harness.mjs
CHANGED
|
@@ -289,8 +289,8 @@ try {
|
|
|
289
289
|
if (existsSync(factsFile)) {
|
|
290
290
|
const f = JSON.parse(readFileSync(factsFile, "utf8"));
|
|
291
291
|
check(
|
|
292
|
-
"measure: the facts are schema
|
|
293
|
-
f.schema ===
|
|
292
|
+
"measure: the facts are schema 3, about paper.pdf, with the last page's heights",
|
|
293
|
+
f.schema === 3 &&
|
|
294
294
|
f.pdf === "paper.pdf" &&
|
|
295
295
|
JSON.stringify(f.last_page_cols_pt) === "[400,400]",
|
|
296
296
|
);
|
package/src/build.test.ts
CHANGED
package/src/cli.test.ts
CHANGED
|
@@ -578,3 +578,16 @@ test("`paperlint hook` with no name: exit 2, and the usage names an example", as
|
|
|
578
578
|
err: "`hook` needs a name, e.g. `paperlint hook paper-edit-guard`",
|
|
579
579
|
});
|
|
580
580
|
});
|
|
581
|
+
|
|
582
|
+
test("parseSettings refuses a root `identity` that is not a list of words, and takes one that is", () => {
|
|
583
|
+
const bad = parseSettings({ identity: "Ada" }, "paperlint.json", "/r");
|
|
584
|
+
assert.deepEqual(bad, {
|
|
585
|
+
ok: false,
|
|
586
|
+
error:
|
|
587
|
+
'paperlint.json: "identity" must be a list of strings, each with a letter or digit, got "Ada"',
|
|
588
|
+
});
|
|
589
|
+
assert.equal(
|
|
590
|
+
parseSettings({ identity: ["Ada Example"] }, "paperlint.json", "/r").ok,
|
|
591
|
+
true,
|
|
592
|
+
);
|
|
593
|
+
});
|
package/src/cli.ts
CHANGED
|
@@ -49,12 +49,25 @@ import { banalInstaller, parseBanalSettings } from "./adapters/banal/index.ts";
|
|
|
49
49
|
import { curlDownload } from "./adapters/curl/index.ts";
|
|
50
50
|
import { latexReader } from "./adapters/latex/index.ts";
|
|
51
51
|
import { hostDirs, nodeAdapters, nodeFiles } from "./adapters/node/index.ts";
|
|
52
|
-
import {
|
|
53
|
-
|
|
52
|
+
import {
|
|
53
|
+
ANONYMITY_RULE_LEVELS,
|
|
54
|
+
anonymityRules,
|
|
55
|
+
PAGE_LIMIT_RULE_LEVELS,
|
|
56
|
+
pageLimitRules,
|
|
57
|
+
VENUE_RULE_LEVELS,
|
|
58
|
+
venueRules,
|
|
59
|
+
} from "./venue-rules.ts";
|
|
60
|
+
import {
|
|
61
|
+
FORMAT_RULE_LEVELS,
|
|
62
|
+
formatRules,
|
|
63
|
+
TEX_VENUE_RULE_LEVELS,
|
|
64
|
+
texVenueRules,
|
|
65
|
+
} from "./tex-venue-rules.ts";
|
|
54
66
|
import { claimProvenanceRule } from "./claim-provenance.ts";
|
|
55
67
|
import { registerRule } from "./register.ts";
|
|
56
68
|
import {
|
|
57
69
|
paperRules,
|
|
70
|
+
identityField,
|
|
58
71
|
stringFields,
|
|
59
72
|
type PaperSettings,
|
|
60
73
|
} from "./paper-settings.ts";
|
|
@@ -218,8 +231,9 @@ settings — paperlint.json, at two levels, one schema. Both are optional.
|
|
|
218
231
|
blocks (files, ignores, rules) with globs relative to that file. Order, later wins: paperlint's
|
|
219
232
|
own, the venue preset's, the root file's, the paper's. Optional rules (off unless turned on):
|
|
220
233
|
pdf/last-page-balance. The venue rules (pdf/fresh, pdf/profile, pdf/fonts, pdf/geometry,
|
|
221
|
-
pdf/
|
|
222
|
-
"off" to skip it. An unknown key, in either
|
|
234
|
+
pdf/body-size, pdf/measured, format/page-limit, format/layout-override, anonymity/identity) are
|
|
235
|
+
on for every paper with a venue preset; set one to "off" to skip it. An unknown key, in either
|
|
236
|
+
file, is an error.
|
|
223
237
|
`;
|
|
224
238
|
|
|
225
239
|
/** The config the user would otherwise write by hand. The data comes from `opts`, the mechanism is here. */
|
|
@@ -262,6 +276,9 @@ export function buildConfig(
|
|
|
262
276
|
...venueRules({ files: nodeFiles, venuesDir: presetsDir() }),
|
|
263
277
|
},
|
|
264
278
|
},
|
|
279
|
+
anonymity: {
|
|
280
|
+
rules: anonymityRules({ files: nodeFiles, venuesDir: presetsDir() }),
|
|
281
|
+
},
|
|
265
282
|
},
|
|
266
283
|
},
|
|
267
284
|
{
|
|
@@ -325,6 +342,16 @@ export function buildConfig(
|
|
|
325
342
|
},
|
|
326
343
|
paper: { rules: texPaperRules },
|
|
327
344
|
bib: bibReachable,
|
|
345
|
+
format: {
|
|
346
|
+
rules: {
|
|
347
|
+
...formatRules({
|
|
348
|
+
files: nodeFiles,
|
|
349
|
+
venuesDir: presetsDir(),
|
|
350
|
+
latex: latexReader,
|
|
351
|
+
}),
|
|
352
|
+
...pageLimitRules({ files: nodeFiles, venuesDir: presetsDir() }),
|
|
353
|
+
},
|
|
354
|
+
},
|
|
328
355
|
},
|
|
329
356
|
language: "tex/latex",
|
|
330
357
|
rules: {
|
|
@@ -340,7 +367,10 @@ export function buildConfig(
|
|
|
340
367
|
"tex/acm-frontmatter-override": "error",
|
|
341
368
|
// Silent for a paper whose paperlint.json names no venue (src/venue-rules.ts).
|
|
342
369
|
...VENUE_RULE_LEVELS,
|
|
370
|
+
...ANONYMITY_RULE_LEVELS,
|
|
343
371
|
...TEX_VENUE_RULE_LEVELS,
|
|
372
|
+
...FORMAT_RULE_LEVELS,
|
|
373
|
+
...PAGE_LIMIT_RULE_LEVELS,
|
|
344
374
|
},
|
|
345
375
|
});
|
|
346
376
|
// THE FILES A paper.tex INCLUDES FROM ITS BODY, each read on its own and reported at its own path
|
|
@@ -667,6 +697,8 @@ export function parseSettings(
|
|
|
667
697
|
};
|
|
668
698
|
const defaults = stringFields(raw);
|
|
669
699
|
if (!defaults.ok) return { ok: false, error: `${where}: ${defaults.error}` };
|
|
700
|
+
const identity = identityField(raw);
|
|
701
|
+
if (!identity.ok) return { ok: false, error: `${where}: ${identity.error}` };
|
|
670
702
|
const structure = Structure.safeParse(raw["structure"]);
|
|
671
703
|
if (!structure.success)
|
|
672
704
|
return {
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import {
|
|
3
|
+
findLeaks,
|
|
4
|
+
spellingsOf,
|
|
5
|
+
tokenPattern,
|
|
6
|
+
type Searchable,
|
|
7
|
+
} from "./anonymity.ts";
|
|
8
|
+
|
|
9
|
+
const page = (text: string, n = 1): Searchable => ({
|
|
10
|
+
place: { kind: "page", page: n },
|
|
11
|
+
text,
|
|
12
|
+
});
|
|
13
|
+
const found = (token: string, text: string): string[] =>
|
|
14
|
+
findLeaks([token], [page(text)]).map((l) => l.found);
|
|
15
|
+
|
|
16
|
+
describe("findLeaks — the spellings of one name a PDF produces", () => {
|
|
17
|
+
it("plain, and in any case (the flags fold Unicode, no locale)", () => {
|
|
18
|
+
expect(found("Ada Example", "by Ada Example, 2026")).toEqual([
|
|
19
|
+
"Ada Example",
|
|
20
|
+
]);
|
|
21
|
+
expect(found("Ada Example", "ADA EXAMPLE")).toEqual(["ADA EXAMPLE"]);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it("Cyrillic folds too", () => {
|
|
25
|
+
expect(found("Ада Пример", "АДА ПРИМЕР пишет")).toEqual(["АДА ПРИМЕР"]);
|
|
26
|
+
expect(found("АДА", "ада")).toEqual(["ада"]);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it("items joined without a space where kerning split a word", () => {
|
|
30
|
+
expect(found("Ada Example", "AdaExample")).toEqual(["AdaExample"]);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it("letter-spaced text", () => {
|
|
34
|
+
expect(found("Ada Example", "A d a E x a m p l e")).toEqual([
|
|
35
|
+
"A d a E x a m p l e",
|
|
36
|
+
]);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it("a word hyphenated across a line break, and a name broken across lines", () => {
|
|
40
|
+
expect(found("Ada Example", "Ada Exam-\nple wrote")).toEqual([
|
|
41
|
+
"Ada Exam-\nple",
|
|
42
|
+
]);
|
|
43
|
+
expect(found("Ada Example", "by Ada\nExample")).toEqual(["Ada\nExample"]);
|
|
44
|
+
expect(found("adaexample", "adaexample")).toEqual(["adaexample"]);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it("ligatures (NFKC on both sides)", () => {
|
|
48
|
+
expect(found("Fiona", "fiona")).toEqual(["fiona"]);
|
|
49
|
+
expect(found("fiona", "Fiona")).toEqual(["Fiona"]);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it("handles and addresses, as whole tokens", () => {
|
|
53
|
+
expect(found("adaexample", "https://github.com/adaexample/tool")).toEqual([
|
|
54
|
+
"adaexample",
|
|
55
|
+
]);
|
|
56
|
+
expect(found("ada@example.org", "mail ada@example.org.")).toEqual([
|
|
57
|
+
"ada@example.org",
|
|
58
|
+
]);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it("does not match inside another word", () => {
|
|
62
|
+
expect(found("Ada", "Canada and adamant")).toEqual([]);
|
|
63
|
+
expect(found("MIT", "submitted")).toEqual([]);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it("says nothing about a clean text", () => {
|
|
67
|
+
expect(found("Ada Example", "Anonymous Authors")).toEqual([]);
|
|
68
|
+
});
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
describe("findLeaks — places and tokens", () => {
|
|
72
|
+
it("reports each token once per place, with the place", () => {
|
|
73
|
+
const leaks = findLeaks(
|
|
74
|
+
["Ada Example", "ada@example.org"],
|
|
75
|
+
[
|
|
76
|
+
page("Ada Example and Ada Example", 2),
|
|
77
|
+
{
|
|
78
|
+
place: { kind: "metadata", field: "Author" },
|
|
79
|
+
text: "ada@example.org",
|
|
80
|
+
},
|
|
81
|
+
{
|
|
82
|
+
place: {
|
|
83
|
+
kind: "link",
|
|
84
|
+
page: 3,
|
|
85
|
+
uri: "https://github.com/adaexample",
|
|
86
|
+
},
|
|
87
|
+
text: "https://github.com/adaexample",
|
|
88
|
+
},
|
|
89
|
+
],
|
|
90
|
+
);
|
|
91
|
+
expect(leaks).toEqual([
|
|
92
|
+
{
|
|
93
|
+
token: "Ada Example",
|
|
94
|
+
place: { kind: "page", page: 2 },
|
|
95
|
+
found: "Ada Example",
|
|
96
|
+
},
|
|
97
|
+
{
|
|
98
|
+
token: "ada@example.org",
|
|
99
|
+
place: { kind: "metadata", field: "Author" },
|
|
100
|
+
found: "ada@example.org",
|
|
101
|
+
},
|
|
102
|
+
{
|
|
103
|
+
token: "Ada Example",
|
|
104
|
+
place: { kind: "link", page: 3, uri: "https://github.com/adaexample" },
|
|
105
|
+
found: "adaexample",
|
|
106
|
+
},
|
|
107
|
+
]);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it("a token of separators only has no pattern and finds nothing", () => {
|
|
111
|
+
expect(tokenPattern(" - ")).toBeNull();
|
|
112
|
+
expect(findLeaks([" "], [page("anything at all")])).toEqual([]);
|
|
113
|
+
});
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
describe("findLeaks — one text, two tokens", () => {
|
|
117
|
+
it("is one leak, under the first token declared", () => {
|
|
118
|
+
expect(
|
|
119
|
+
findLeaks(["Ada Example", "adaexample"], [page("by AdaExample")]),
|
|
120
|
+
).toEqual([
|
|
121
|
+
{
|
|
122
|
+
token: "Ada Example",
|
|
123
|
+
place: { kind: "page", page: 1 },
|
|
124
|
+
found: "AdaExample",
|
|
125
|
+
},
|
|
126
|
+
]);
|
|
127
|
+
});
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
describe("spellingsOf — a personal name as a reference list prints it", () => {
|
|
131
|
+
it("adds initials and surname-first forms to a name of two to four words", () => {
|
|
132
|
+
expect(spellingsOf("Ada Example")).toEqual([
|
|
133
|
+
"Ada Example",
|
|
134
|
+
"A. Example",
|
|
135
|
+
"Example, Ada",
|
|
136
|
+
"Example, A.",
|
|
137
|
+
]);
|
|
138
|
+
expect(spellingsOf("Ada Byron Example")).toEqual([
|
|
139
|
+
"Ada Byron Example",
|
|
140
|
+
"A. B. Example",
|
|
141
|
+
"Example, Ada Byron",
|
|
142
|
+
"Example, A. B.",
|
|
143
|
+
]);
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it("leaves a handle, an address, one word, or lowercase words alone", () => {
|
|
147
|
+
expect(spellingsOf("adaexample")).toEqual(["adaexample"]);
|
|
148
|
+
expect(spellingsOf("ada@example.org")).toEqual(["ada@example.org"]);
|
|
149
|
+
expect(spellingsOf("Example")).toEqual(["Example"]);
|
|
150
|
+
expect(spellingsOf("example tool")).toEqual(["example tool"]);
|
|
151
|
+
expect(spellingsOf("A B C D E")).toEqual(["A B C D E"]);
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
it("a self-citation's author list finds the declared name", () => {
|
|
155
|
+
expect(
|
|
156
|
+
found("Ada Example", "[3] A. Example and B. Other, “A tool,” 2025."),
|
|
157
|
+
).toEqual(["A. Example"]);
|
|
158
|
+
expect(found("Ada Example", "Example, A., and Other, B. (2025)")).toEqual([
|
|
159
|
+
"Example, A.",
|
|
160
|
+
]);
|
|
161
|
+
expect(found("Ada Example", "B. Example wrote")).toEqual([]);
|
|
162
|
+
});
|
|
163
|
+
});
|