paperlint 5.0.0 → 6.1.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 +2 -1
- package/dist/adapters/latex/index.d.ts.map +1 -1
- package/dist/adapters/latex/index.js +6 -2
- 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/adapters/latex/prose.d.ts +6 -1
- package/dist/adapters/latex/prose.d.ts.map +1 -1
- package/dist/adapters/latex/prose.js +142 -31
- package/dist/adapters/latex/prose.js.map +1 -1
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +35 -18
- 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/register.d.ts +96 -0
- package/dist/domain/register.d.ts.map +1 -0
- package/dist/domain/register.js +136 -0
- package/dist/domain/register.js.map +1 -0
- package/dist/domain/tex-document.d.ts +28 -0
- package/dist/domain/tex-document.d.ts.map +1 -1
- package/dist/domain/tex-document.js +8 -3
- package/dist/domain/tex-document.js.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 +8 -1
- package/dist/ports/latex.d.ts.map +1 -1
- package/dist/presets.d.ts +5 -0
- package/dist/presets.d.ts.map +1 -1
- package/dist/presets.js +8 -1
- package/dist/presets.js.map +1 -1
- package/dist/register-bands.d.ts +37 -0
- package/dist/register-bands.d.ts.map +1 -0
- package/dist/register-bands.js +196 -0
- package/dist/register-bands.js.map +1 -0
- package/dist/register.d.ts +4 -2
- package/dist/register.d.ts.map +1 -1
- package/dist/register.js +6 -45
- package/dist/register.js.map +1 -1
- package/dist/tex-requirements.d.ts +10 -0
- package/dist/tex-requirements.d.ts.map +1 -1
- package/dist/tex-requirements.js +29 -6
- package/dist/tex-requirements.js.map +1 -1
- package/dist/tex-venue-rules.d.ts +8 -2
- 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/tex/claim-emphasis.md +71 -0
- package/docs/rules/tex/contrast-frames.md +100 -0
- package/docs/rules/tex/relation-markers.md +72 -0
- package/docs/rules.md +59 -50
- 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 +66 -17
- package/presets/ieee-conference.jsonc +8 -0
- package/presets/venue-profile.schema.json +57 -1
- package/skills/grade-paper-writing/SKILL.md +59 -27
- package/skills/grade-paper-writing/SKILL.md.spec.ts +58 -26
- package/skills/render-paper/extract-pdf-facts.harness.ts +3 -3
- package/src/adapters/latex/index.test.ts +91 -0
- package/src/adapters/latex/index.ts +6 -2
- 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/adapters/latex/prose.ts +188 -58
- 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 +50 -17
- 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/register.test.ts +153 -0
- package/src/domain/register.ts +261 -0
- package/src/domain/tex-document.ts +47 -3
- 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 +9 -0
- package/src/presets.ts +15 -9
- package/src/register-bands.test.ts +327 -0
- package/src/register-bands.ts +291 -0
- package/src/register.ts +7 -37
- package/src/rule-cases.test.ts +96 -20
- package/src/rule-docs.test.ts +0 -2
- package/src/tex-requirements.ts +56 -8
- package/src/tex-venue-rules.test.ts +110 -5
- package/src/tex-venue-rules.ts +64 -3
- package/src/types.ts +2 -0
- package/src/venue-rules.test.ts +351 -39
- package/src/venue-rules.ts +247 -47
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* THE REGISTER OF A PAPER'S BODY, COUNTED — the measures the register rules judge, and the band a
|
|
3
|
+
* venue's accepted papers set for each. Pure: passages and emphasised phrases in, counts out.
|
|
4
|
+
*
|
|
5
|
+
* Three measures, each per 10,000 words of the body, each read off the same passages and cut into
|
|
6
|
+
* the same sentences as every other rule over the body (`sentences.ts`):
|
|
7
|
+
*
|
|
8
|
+
* contrast_frames a claim set against a rejected alternative: «X, not Y», «not X but Y»,
|
|
9
|
+
* «X rather than Y», «instead of Y», «as opposed to Y»
|
|
10
|
+
* claim_emphasis a phrase in bold INSIDE a sentence that states something: six words or more,
|
|
11
|
+
* or holding a number — not a label opening a paragraph or an item, and not a
|
|
12
|
+
* term of a few words
|
|
13
|
+
* relation_markers a word that names how a sentence relates to the one before: because,
|
|
14
|
+
* therefore, however, in contrast, for example, that is …
|
|
15
|
+
*
|
|
16
|
+
* ── THE BAND IS THE ANCHORS', NOT A CONSTANT ────────────────────────────────────
|
|
17
|
+
* A venue preset records its anchors — accepted papers of the venue (or its parent conference),
|
|
18
|
+
* each with its word count and its count of every measure, as these counters give them. The band
|
|
19
|
+
* of a measure is the anchors' range, widened at each end by two Poisson standard deviations of
|
|
20
|
+
* the anchor standing there: a paper of that anchor's length and register would fall outside it
|
|
21
|
+
* about one time in forty. An anchor with no occurrence is widened as if it had one. Below zero is
|
|
22
|
+
* zero. So every number a message names comes from the preset's anchors, and changing an anchor
|
|
23
|
+
* changes the band.
|
|
24
|
+
*/
|
|
25
|
+
import type { Emphasis, Passage, Span, TextRun } from "./tex-document.ts";
|
|
26
|
+
import { runText, spanIn } from "./tex-document.ts";
|
|
27
|
+
import { CITATION, MATH, REFERENCE, runOf, sentences } from "./sentences.ts";
|
|
28
|
+
|
|
29
|
+
/** The measures a venue's anchors set a band for. */
|
|
30
|
+
export type RegisterMeasureName =
|
|
31
|
+
"contrast_frames" | "claim_emphasis" | "relation_markers";
|
|
32
|
+
|
|
33
|
+
export const REGISTER_MEASURES: readonly RegisterMeasureName[] = [
|
|
34
|
+
"contrast_frames",
|
|
35
|
+
"claim_emphasis",
|
|
36
|
+
"relation_markers",
|
|
37
|
+
];
|
|
38
|
+
|
|
39
|
+
/** One accepted paper of the venue, as the counters measured it. */
|
|
40
|
+
export interface RegisterAnchor {
|
|
41
|
+
/** What the preset calls it: a directory under `fixtures/accepted-papers/`, or a citation. */
|
|
42
|
+
readonly paper: string;
|
|
43
|
+
readonly words: number;
|
|
44
|
+
readonly counts: Readonly<Record<RegisterMeasureName, number>>;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** How far the band reaches past the anchors: this many Poisson standard deviations. */
|
|
48
|
+
export const NOISE_SDS = 2;
|
|
49
|
+
|
|
50
|
+
/** The words a rate is taken over. */
|
|
51
|
+
export const PER = 10_000;
|
|
52
|
+
|
|
53
|
+
/** A rate per `PER` words; 0 for a body with no words. */
|
|
54
|
+
export const rateOf = (count: number, words: number): number =>
|
|
55
|
+
words === 0 ? 0 : (PER * count) / words;
|
|
56
|
+
|
|
57
|
+
/** A measure's band, and the anchors' own range it was widened from. */
|
|
58
|
+
export interface Band {
|
|
59
|
+
readonly min: number;
|
|
60
|
+
readonly max: number;
|
|
61
|
+
readonly anchorMin: number;
|
|
62
|
+
readonly anchorMax: number;
|
|
63
|
+
readonly anchors: number;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** One anchor's rate of a measure, and how far one standard deviation of its count reaches. */
|
|
67
|
+
const anchorRate = (a: RegisterAnchor, m: RegisterMeasureName) => {
|
|
68
|
+
const count = a.counts[m];
|
|
69
|
+
return {
|
|
70
|
+
rate: rateOf(count, a.words),
|
|
71
|
+
sd: rateOf(Math.sqrt(Math.max(count, 1)), a.words),
|
|
72
|
+
};
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
/** The band the anchors set for a measure; null when there are no anchors. */
|
|
76
|
+
export function bandOf(
|
|
77
|
+
anchors: readonly RegisterAnchor[],
|
|
78
|
+
m: RegisterMeasureName,
|
|
79
|
+
): Band | null {
|
|
80
|
+
const rates = anchors.map((a) => anchorRate(a, m));
|
|
81
|
+
const low = rates.reduce<(typeof rates)[number] | null>(
|
|
82
|
+
(lo, r) => (lo === null || r.rate < lo.rate ? r : lo),
|
|
83
|
+
null,
|
|
84
|
+
);
|
|
85
|
+
const high = rates.reduce<(typeof rates)[number] | null>(
|
|
86
|
+
(hi, r) => (hi === null || r.rate > hi.rate ? r : hi),
|
|
87
|
+
null,
|
|
88
|
+
);
|
|
89
|
+
if (low === null || high === null) return null;
|
|
90
|
+
return {
|
|
91
|
+
min: Math.max(0, low.rate - NOISE_SDS * low.sd),
|
|
92
|
+
max: high.rate + NOISE_SDS * high.sd,
|
|
93
|
+
anchorMin: low.rate,
|
|
94
|
+
anchorMax: high.rate,
|
|
95
|
+
anchors: anchors.length,
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// ── sentences, as every register measure reads them ───────────────────────────────
|
|
100
|
+
|
|
101
|
+
/** The characters a mark or math stands as in a sentence's text. */
|
|
102
|
+
const MARK = new RegExp(`[${CITATION}${REFERENCE}${MATH}]`, "gu");
|
|
103
|
+
|
|
104
|
+
const WORDS = new Intl.Segmenter("en", { granularity: "word" });
|
|
105
|
+
|
|
106
|
+
/** A sentence of the body: its text as the reader sees it, its words, its first word, where it stands. */
|
|
107
|
+
export interface BodySentence {
|
|
108
|
+
/** The text with marks and math left out and spaces collapsed, for a message. */
|
|
109
|
+
readonly text: string;
|
|
110
|
+
/** The text with each mark and math as one character, for matching. */
|
|
111
|
+
readonly raw: string;
|
|
112
|
+
/** Its word-like words, and one for each mark or math, as it reads on the page. */
|
|
113
|
+
readonly words: number;
|
|
114
|
+
readonly first: string;
|
|
115
|
+
/** The run it was cut from, and where in that run's text it starts. */
|
|
116
|
+
readonly run: TextRun;
|
|
117
|
+
readonly from: number;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** The body's sentences that hold a word, in order. */
|
|
121
|
+
export function bodySentences(
|
|
122
|
+
passages: readonly Passage[],
|
|
123
|
+
): readonly BodySentence[] {
|
|
124
|
+
return passages.flatMap((p) => {
|
|
125
|
+
const run = runOf(p);
|
|
126
|
+
const text = runText(run);
|
|
127
|
+
return sentences(text).flatMap(([from, to]) => {
|
|
128
|
+
const raw = text.slice(from, to);
|
|
129
|
+
const words = [...WORDS.segment(raw)].filter((w) => w.isWordLike);
|
|
130
|
+
const [first] = words;
|
|
131
|
+
if (first === undefined) return [];
|
|
132
|
+
return [
|
|
133
|
+
{
|
|
134
|
+
text: raw.replace(MARK, "").replace(/\s+/gu, " ").trim(),
|
|
135
|
+
raw,
|
|
136
|
+
words: words.length + (raw.match(MARK)?.length ?? 0),
|
|
137
|
+
first: first.segment,
|
|
138
|
+
run,
|
|
139
|
+
from,
|
|
140
|
+
},
|
|
141
|
+
];
|
|
142
|
+
});
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** The body's words. */
|
|
147
|
+
export const wordsOf = (ss: readonly BodySentence[]): number =>
|
|
148
|
+
ss.reduce((n, s) => n + s.words, 0);
|
|
149
|
+
|
|
150
|
+
// ── the measures ──────────────────────────────────────────────────────────────────
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* One occurrence of a measure: the words around it as the reader sees them, the sentence it stands
|
|
154
|
+
* in, and where it is.
|
|
155
|
+
*/
|
|
156
|
+
export interface Occurrence {
|
|
157
|
+
readonly match: string;
|
|
158
|
+
readonly sentence: string;
|
|
159
|
+
readonly at: Span | null;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** The forms as one pattern: matched left to right, so one phrase is never two occurrences. */
|
|
163
|
+
const anyOf = (forms: readonly RegExp[]): Readonly<RegExp> =>
|
|
164
|
+
new RegExp(forms.map((r) => `(?:${r.source})`).join("|"), "giu");
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* «not» after a comma, a dash (`---` as the source spells it) or a semicolon — «the word, not the
|
|
168
|
+
* operation», «--- not authenticity» —
|
|
169
|
+
* unless it opens a phrase that rejects nothing («not only», «not yet», «not already in the corpus»).
|
|
170
|
+
*/
|
|
171
|
+
const COMMA_NOT =
|
|
172
|
+
/(?:[,;—–]|--)\s*(?:and\s+|but\s+)?not\s+(?!(?:only|least|yet|always|necessarily|all|even|just|merely|surprisingly|already|to)\b)/giu;
|
|
173
|
+
|
|
174
|
+
/** «not X but Y» inside one clause — no comma, colon or semicolon between — but not «not only … but also». */
|
|
175
|
+
const NOT_BUT = /\bnot\b(?!\s+only\b)[^.;:,]{1,60}?\bbut\b(?!\s+also\b)/giu;
|
|
176
|
+
|
|
177
|
+
/** Every form of a contrast frame, as one pattern over one sentence. */
|
|
178
|
+
const CONTRAST_FRAME = anyOf([
|
|
179
|
+
COMMA_NOT,
|
|
180
|
+
NOT_BUT,
|
|
181
|
+
/\brather\s+than\b/u,
|
|
182
|
+
/\binstead\s+of\b/u,
|
|
183
|
+
/\bas\s+opposed\s+to\b/u,
|
|
184
|
+
]);
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Words that name the relation between a sentence and the one before it: cause, consequence,
|
|
188
|
+
* contrast, concession, example, restatement. The list is the one the register diagnosis measured
|
|
189
|
+
* (every marker whose meaning does not depend on context), not tuned to this corpus.
|
|
190
|
+
*/
|
|
191
|
+
const RELATION_MARKERS =
|
|
192
|
+
/(?<![\p{L}])(?:therefore|thus|hence|consequently|as a result|because|since|this means|this implies|which explains|in turn|that is,|i\.e\.|in contrast|by contrast|however|nevertheless|nonetheless|whereas|in other words|for this reason|it follows|accordingly|specifically,|in particular,|for example|for instance|e\.g\.)(?![\p{L}])/giu;
|
|
193
|
+
|
|
194
|
+
/** How many characters of context an excerpt keeps on each side of a match. */
|
|
195
|
+
const CONTEXT = 40;
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* The words around `[from, to)` of a sentence as the reader sees them: the match with up to
|
|
199
|
+
* `CONTEXT` characters on each side, cut at a word, marks and math left out.
|
|
200
|
+
*/
|
|
201
|
+
export function excerpt(raw: string, from: number, to: number): string {
|
|
202
|
+
const start = Math.max(0, from - CONTEXT);
|
|
203
|
+
const end = Math.min(raw.length, to + CONTEXT);
|
|
204
|
+
const words = raw
|
|
205
|
+
.slice(start, end)
|
|
206
|
+
.replace(MARK, "")
|
|
207
|
+
.split(/\s+/u)
|
|
208
|
+
.filter((w) => w !== "");
|
|
209
|
+
const inner = words.slice(
|
|
210
|
+
start > 0 ? 1 : 0,
|
|
211
|
+
end < raw.length ? -1 : words.length,
|
|
212
|
+
);
|
|
213
|
+
return `${start > 0 ? "…" : ""}${inner.join(" ")}${end < raw.length ? "…" : ""}`;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/** Every match of `pattern` in every sentence, in order, with where it stands. */
|
|
217
|
+
function occurrences(
|
|
218
|
+
ss: readonly BodySentence[],
|
|
219
|
+
pattern: Readonly<RegExp>,
|
|
220
|
+
): readonly Occurrence[] {
|
|
221
|
+
return ss.flatMap((s) =>
|
|
222
|
+
[...s.raw.matchAll(pattern)].map((m) => ({
|
|
223
|
+
match: excerpt(s.raw, m.index, m.index + m[0].length),
|
|
224
|
+
sentence: s.text,
|
|
225
|
+
at: spanIn(s.run, s.from + m.index, s.from + m.index + m[0].length),
|
|
226
|
+
})),
|
|
227
|
+
);
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/** The contrast frames of the body's sentences. */
|
|
231
|
+
export const contrastFrames = (
|
|
232
|
+
ss: readonly BodySentence[],
|
|
233
|
+
): readonly Occurrence[] => occurrences(ss, CONTRAST_FRAME);
|
|
234
|
+
|
|
235
|
+
/** The relation markers of the body's sentences. */
|
|
236
|
+
export const relationMarkers = (
|
|
237
|
+
ss: readonly BodySentence[],
|
|
238
|
+
): readonly Occurrence[] => occurrences(ss, RELATION_MARKERS);
|
|
239
|
+
|
|
240
|
+
/** A phrase this long or longer states something rather than names it. */
|
|
241
|
+
export const CLAIM_WORDS = 6;
|
|
242
|
+
|
|
243
|
+
const wordCount = (s: string): number =>
|
|
244
|
+
[...WORDS.segment(s)].filter((w) => w.isWordLike).length;
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* Whether an emphasised phrase is a claim set in bold: bold, inside its sentence (not a label that
|
|
248
|
+
* opens a paragraph or an item), and either `CLAIM_WORDS` words long or holding a number.
|
|
249
|
+
*/
|
|
250
|
+
export const isClaimEmphasis = (e: Emphasis): boolean =>
|
|
251
|
+
e.style === "bold" &&
|
|
252
|
+
e.place === "inline" &&
|
|
253
|
+
(wordCount(e.text) >= CLAIM_WORDS || /\p{N}/u.test(e.text));
|
|
254
|
+
|
|
255
|
+
/** The claims the body sets in bold. */
|
|
256
|
+
export const claimEmphasis = (es: readonly Emphasis[]): readonly Occurrence[] =>
|
|
257
|
+
es.filter(isClaimEmphasis).map((e) => ({
|
|
258
|
+
match: e.text,
|
|
259
|
+
sentence: e.text,
|
|
260
|
+
at: e.span,
|
|
261
|
+
}));
|
|
@@ -110,9 +110,21 @@ export const runText = (run: TextRun): string =>
|
|
|
110
110
|
|
|
111
111
|
/** The source offset of the `i`-th character of the text these segments make, or null outside it. */
|
|
112
112
|
function offsetIn(segments: readonly Segment[], i: number): number | null {
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
113
|
+
if (i < 0) return null;
|
|
114
|
+
// One pass, no copy of the rest per segment: a paragraph's run holds thousands of segments.
|
|
115
|
+
const found = segments.reduce<{
|
|
116
|
+
readonly rest: number;
|
|
117
|
+
readonly at: number | null;
|
|
118
|
+
}>(
|
|
119
|
+
(acc, s) =>
|
|
120
|
+
acc.at !== null
|
|
121
|
+
? acc
|
|
122
|
+
: acc.rest < s.text.length
|
|
123
|
+
? { rest: acc.rest, at: s.at + acc.rest }
|
|
124
|
+
: { rest: acc.rest - s.text.length, at: null },
|
|
125
|
+
{ rest: i, at: null },
|
|
126
|
+
);
|
|
127
|
+
return found.at;
|
|
116
128
|
}
|
|
117
129
|
|
|
118
130
|
/**
|
|
@@ -141,3 +153,35 @@ export type ProsePiece =
|
|
|
141
153
|
export interface Passage {
|
|
142
154
|
readonly pieces: readonly [ProsePiece, ...ProsePiece[]];
|
|
143
155
|
}
|
|
156
|
+
|
|
157
|
+
/** How an emphasised phrase is set: bold, italic (or `\emph`), or underlined. */
|
|
158
|
+
export type EmphasisStyle = "bold" | "italic" | "underline";
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Where an emphasised phrase stands: opening its paragraph or list item — a label, set like a run-in
|
|
162
|
+
* heading even without the full stop (`\item \textbf{A corpus of questions}: …`) — or inside the
|
|
163
|
+
* running text, after other words of its sentence.
|
|
164
|
+
*/
|
|
165
|
+
export type EmphasisPlace = "opening" | "inline";
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* A phrase set in bold, italics or underline inside the body's running prose — not a heading, a
|
|
169
|
+
* run-in heading, a caption, a float or a table, which are never prose (`LatexReader.bodyProse`).
|
|
170
|
+
* `text` is what it typesets, marks and math left out.
|
|
171
|
+
*/
|
|
172
|
+
export interface Emphasis {
|
|
173
|
+
readonly style: EmphasisStyle;
|
|
174
|
+
readonly place: EmphasisPlace;
|
|
175
|
+
readonly text: string;
|
|
176
|
+
readonly span: Span;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* A command that changes the page layout a venue's template sets: the text block, the margins,
|
|
181
|
+
* the line spacing, or space pulled back with a negative skip. `command` is how a message names it
|
|
182
|
+
* (`\setlength{\textheight}`, `\vspace{-2mm}`).
|
|
183
|
+
*/
|
|
184
|
+
export interface LayoutOverride {
|
|
185
|
+
readonly command: string;
|
|
186
|
+
readonly place: Place;
|
|
187
|
+
}
|
package/src/facts-file.test.ts
CHANGED
|
@@ -82,14 +82,24 @@ const doc = (last: PageText, geometry: Geometry = NONE) =>
|
|
|
82
82
|
sha: "a".repeat(64),
|
|
83
83
|
venue: "agenticdev",
|
|
84
84
|
kind: "short",
|
|
85
|
-
read: {
|
|
85
|
+
read: {
|
|
86
|
+
pages: 4,
|
|
87
|
+
fonts: FONTS,
|
|
88
|
+
last,
|
|
89
|
+
layout: [],
|
|
90
|
+
pageTexts: [],
|
|
91
|
+
links: [],
|
|
92
|
+
bibAnchorPage: null,
|
|
93
|
+
appendixAnchorPage: null,
|
|
94
|
+
metadata: {},
|
|
95
|
+
},
|
|
86
96
|
geometry,
|
|
87
97
|
});
|
|
88
98
|
|
|
89
99
|
// ── the document ────────────────────────────────────────────────────────────────────────
|
|
90
|
-
test("schema
|
|
100
|
+
test("schema 3; a measured last page gives both heights, in both fields", () => {
|
|
91
101
|
const d = doc(lastPage(60, 30));
|
|
92
|
-
assert.equal(d.schema,
|
|
102
|
+
assert.equal(d.schema, 3);
|
|
93
103
|
assert.deepEqual(d.last_page, { kind: "measured", columns_pt: [600, 300] });
|
|
94
104
|
assert.deepEqual(d.last_page_cols_pt, [600, 300]);
|
|
95
105
|
assert.equal(d.npages, 4);
|
|
@@ -150,7 +160,17 @@ const PDF_BYTES = "%PDF-pretend";
|
|
|
150
160
|
const good: PdfReader = () =>
|
|
151
161
|
Promise.resolve({
|
|
152
162
|
ok: true,
|
|
153
|
-
facts: {
|
|
163
|
+
facts: {
|
|
164
|
+
pages: 2,
|
|
165
|
+
fonts: FONTS,
|
|
166
|
+
last: lastPage(60, 60),
|
|
167
|
+
layout: [],
|
|
168
|
+
pageTexts: [],
|
|
169
|
+
links: [],
|
|
170
|
+
bibAnchorPage: null,
|
|
171
|
+
appendixAnchorPage: null,
|
|
172
|
+
metadata: {},
|
|
173
|
+
},
|
|
154
174
|
});
|
|
155
175
|
/** The shipped presets at their real paths: `extends` resolves through the same Files port. */
|
|
156
176
|
const SHIPPED_PRESETS = Object.fromEntries(
|
|
@@ -290,6 +310,11 @@ test("a PDF with no text has an empty font list, not a failed document", () => {
|
|
|
290
310
|
fonts: { kind: "no-text" },
|
|
291
311
|
last: lastPage(0, 0),
|
|
292
312
|
layout: [],
|
|
313
|
+
pageTexts: [],
|
|
314
|
+
links: [],
|
|
315
|
+
bibAnchorPage: null,
|
|
316
|
+
appendixAnchorPage: null,
|
|
317
|
+
metadata: {},
|
|
293
318
|
},
|
|
294
319
|
geometry: NONE,
|
|
295
320
|
});
|
|
@@ -387,3 +412,74 @@ test("parseFactsText: a missing schema is reported as null, another schema by it
|
|
|
387
412
|
const other = parse({ ...written(), schema: 1 });
|
|
388
413
|
assert.deepEqual(!other.ok && other.error, { kind: "schema", got: "1" });
|
|
389
414
|
});
|
|
415
|
+
|
|
416
|
+
test("schema 3: what the PDF says is written, and reads back typed", () => {
|
|
417
|
+
const said = {
|
|
418
|
+
...written(),
|
|
419
|
+
pages_text: ["Ada Example\nIntroduction", "References"],
|
|
420
|
+
metadata: { Author: "Ada Example" },
|
|
421
|
+
links: [{ page: 1, uri: "https://github.com/adaexample" }],
|
|
422
|
+
bib_anchor_page: 2,
|
|
423
|
+
appendix_anchor_page: 3,
|
|
424
|
+
};
|
|
425
|
+
const r = parse(said);
|
|
426
|
+
assert.ok(r.ok);
|
|
427
|
+
assert.deepEqual(r.value.text, {
|
|
428
|
+
pages: ["Ada Example\nIntroduction", "References"],
|
|
429
|
+
metadata: { Author: "Ada Example" },
|
|
430
|
+
links: [{ page: 1, uri: "https://github.com/adaexample" }],
|
|
431
|
+
bibAnchorPage: 2,
|
|
432
|
+
appendixAnchorPage: 3,
|
|
433
|
+
});
|
|
434
|
+
const d = factsDocument({
|
|
435
|
+
pdf: "paper.pdf",
|
|
436
|
+
sha: "a".repeat(64),
|
|
437
|
+
venue: null,
|
|
438
|
+
kind: null,
|
|
439
|
+
read: {
|
|
440
|
+
pages: 1,
|
|
441
|
+
fonts: FONTS,
|
|
442
|
+
last: lastPage(1, 0),
|
|
443
|
+
layout: [],
|
|
444
|
+
pageTexts: ["x"],
|
|
445
|
+
links: [{ page: 1, uri: "u" }],
|
|
446
|
+
bibAnchorPage: 1,
|
|
447
|
+
appendixAnchorPage: null,
|
|
448
|
+
metadata: { Title: "t" },
|
|
449
|
+
},
|
|
450
|
+
geometry: MEASURED,
|
|
451
|
+
});
|
|
452
|
+
assert.deepEqual(
|
|
453
|
+
[
|
|
454
|
+
d.pages_text,
|
|
455
|
+
d.links,
|
|
456
|
+
d.bib_anchor_page,
|
|
457
|
+
d.appendix_anchor_page,
|
|
458
|
+
d.metadata,
|
|
459
|
+
],
|
|
460
|
+
[["x"], [{ page: 1, uri: "u" }], 1, null, { Title: "t" }],
|
|
461
|
+
);
|
|
462
|
+
});
|
|
463
|
+
|
|
464
|
+
test("schema 3: a missing or malformed text field is refused, naming it", () => {
|
|
465
|
+
const noText = Object.fromEntries(
|
|
466
|
+
Object.entries(written()).filter(([k]) => k !== "pages_text"),
|
|
467
|
+
);
|
|
468
|
+
assert.equal(why(noText), "`pages_text` is not a list of strings");
|
|
469
|
+
assert.equal(
|
|
470
|
+
why({ ...written(), metadata: { Author: 3 } }),
|
|
471
|
+
"`metadata` is not an object of strings",
|
|
472
|
+
);
|
|
473
|
+
assert.equal(
|
|
474
|
+
why({ ...written(), links: [{ page: 0, uri: "u" }] }),
|
|
475
|
+
"`links` is not a list of { page, uri }",
|
|
476
|
+
);
|
|
477
|
+
assert.equal(
|
|
478
|
+
why({ ...written(), bib_anchor_page: 0 }),
|
|
479
|
+
"`bib_anchor_page` is neither a page number nor null",
|
|
480
|
+
);
|
|
481
|
+
assert.equal(
|
|
482
|
+
why({ ...written(), appendix_anchor_page: "3" }),
|
|
483
|
+
"`appendix_anchor_page` is neither a page number nor null",
|
|
484
|
+
);
|
|
485
|
+
});
|
package/src/facts-file.ts
CHANGED
|
@@ -26,6 +26,14 @@
|
|
|
26
26
|
* fonts are all named `Type3`; `embedded` means a usable program is present, so a corrupted one is
|
|
27
27
|
* false. `pdf` is now relative to the PAPER directory, not to wherever the command ran.
|
|
28
28
|
*
|
|
29
|
+
* ── SCHEMA 3 (2026-09-29) ───────────────────────────────────────────────────────
|
|
30
|
+
* Adds what the PDF SAYS, for the rules that judge text rather than geometry: `pages_text` (each
|
|
31
|
+
* page's text, in content order), `metadata` (the Info dictionary and the XMP packet, flat),
|
|
32
|
+
* `links` (every link annotation's target and page), `bib_anchor_page` and `appendix_anchor_page`
|
|
33
|
+
* (the pages hyperref anchored the bibliography's first entry and the appendix on, or null). Every schema-2 field keeps its name and meaning. The number moves anyway: `anonymity/identity` and `format/page-limit` read these fields,
|
|
34
|
+
* and schema-2 facts without them would be judged as a PDF that says nothing — a clean pass over no
|
|
35
|
+
* input. A schema-2 file is refused by `pdf/fresh` with "rebuild", which is one `paperlint build`.
|
|
36
|
+
*
|
|
29
37
|
* 🔴 STALENESS. The PDF is not committed, so neither are these facts: they live in `_build/` and
|
|
30
38
|
* carry `pdf_sha256`. A rule compares it with the PDF on disk and refuses facts about another build.
|
|
31
39
|
*/
|
|
@@ -52,7 +60,7 @@ import { paperPreset, paperPresetProblem } from "./presets.ts";
|
|
|
52
60
|
import { presetsDir } from "./package-dirs.ts";
|
|
53
61
|
import { messageOf } from "./domain/text.ts";
|
|
54
62
|
|
|
55
|
-
export const FACTS_SCHEMA =
|
|
63
|
+
export const FACTS_SCHEMA = 3;
|
|
56
64
|
export const FACTS_DIR = "_build";
|
|
57
65
|
export const FACTS_FILE = "paper.facts.json";
|
|
58
66
|
|
|
@@ -123,7 +131,17 @@ export type FactsDocument = {
|
|
|
123
131
|
readonly last_page: LastPageEntry;
|
|
124
132
|
/** Schema 1's field, kept: the two heights, or null when the last page is a stub or a review build. */
|
|
125
133
|
readonly last_page_cols_pt: readonly [number, number] | null;
|
|
126
|
-
} &
|
|
134
|
+
} & FactsTextFields &
|
|
135
|
+
FactsGeometryFields;
|
|
136
|
+
|
|
137
|
+
/** Schema 3's fields: what the PDF says (see the module header). */
|
|
138
|
+
export interface FactsTextFields {
|
|
139
|
+
readonly pages_text: readonly string[];
|
|
140
|
+
readonly metadata: Readonly<Record<string, string>>;
|
|
141
|
+
readonly links: readonly { readonly page: number; readonly uri: string }[];
|
|
142
|
+
readonly bib_anchor_page: number | null;
|
|
143
|
+
readonly appendix_anchor_page: number | null;
|
|
144
|
+
}
|
|
127
145
|
|
|
128
146
|
export function fontEntry(f: FontFact): FontEntry {
|
|
129
147
|
return {
|
|
@@ -170,6 +188,11 @@ export function factsDocument(i: FactsInput): FactsDocument {
|
|
|
170
188
|
i.read.fonts.kind === "drawn" ? i.read.fonts.list.map(fontEntry) : [],
|
|
171
189
|
last_page: last,
|
|
172
190
|
last_page_cols_pt: last.kind === "measured" ? last.columns_pt : null,
|
|
191
|
+
pages_text: i.read.pageTexts,
|
|
192
|
+
metadata: i.read.metadata,
|
|
193
|
+
links: i.read.links,
|
|
194
|
+
bib_anchor_page: i.read.bibAnchorPage,
|
|
195
|
+
appendix_anchor_page: i.read.appendixAnchorPage,
|
|
173
196
|
...flatGeometry(i.geometry),
|
|
174
197
|
};
|
|
175
198
|
}
|
|
@@ -249,6 +272,19 @@ export interface ReadFacts {
|
|
|
249
272
|
readonly fonts: readonly FontEntry[];
|
|
250
273
|
/** The geometry columns, or null when no measurer ran (`geometry_source` is null). */
|
|
251
274
|
readonly geometry: FlatGeometry | null;
|
|
275
|
+
/** What the PDF says: schema 3's fields, parsed. */
|
|
276
|
+
readonly text: TextFacts;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/** Schema 3's fields as a rule reads them. */
|
|
280
|
+
export interface TextFacts {
|
|
281
|
+
readonly pages: readonly string[];
|
|
282
|
+
readonly metadata: Readonly<Record<string, string>>;
|
|
283
|
+
readonly links: readonly { readonly page: number; readonly uri: string }[];
|
|
284
|
+
/** The page of the first bibliography entry hyperref anchored, or null. */
|
|
285
|
+
readonly bibAnchorPage: number | null;
|
|
286
|
+
/** The page hyperref anchored the appendix on, or null. */
|
|
287
|
+
readonly appendixAnchorPage: number | null;
|
|
252
288
|
}
|
|
253
289
|
|
|
254
290
|
/** Why a facts file cannot be judged. */
|
|
@@ -331,6 +367,56 @@ function geometryOf(
|
|
|
331
367
|
});
|
|
332
368
|
}
|
|
333
369
|
|
|
370
|
+
const isPage = (v: unknown): v is number =>
|
|
371
|
+
typeof v === "number" && Number.isInteger(v) && v > 0;
|
|
372
|
+
const isPageOrNull = (v: unknown): v is number | null =>
|
|
373
|
+
v === null || isPage(v);
|
|
374
|
+
const isStrings = (v: unknown): v is readonly string[] =>
|
|
375
|
+
Array.isArray(v) && v.every((x) => typeof x === "string");
|
|
376
|
+
const isStringRecord = (v: unknown): v is Readonly<Record<string, string>> =>
|
|
377
|
+
isRecord(v) && Object.values(v).every((x) => typeof x === "string");
|
|
378
|
+
const isLinks = (
|
|
379
|
+
v: unknown,
|
|
380
|
+
): v is readonly { readonly page: number; readonly uri: string }[] =>
|
|
381
|
+
Array.isArray(v) &&
|
|
382
|
+
v.every(
|
|
383
|
+
(l) => isRecord(l) && isPage(l["page"]) && typeof l["uri"] === "string",
|
|
384
|
+
);
|
|
385
|
+
|
|
386
|
+
/** Schema 3's fields, or which one is wrong. */
|
|
387
|
+
function textOf(
|
|
388
|
+
d: Readonly<Record<string, unknown>>,
|
|
389
|
+
): Result<TextFacts, string> {
|
|
390
|
+
const { pages_text, metadata, links } = d;
|
|
391
|
+
const { bib_anchor_page, appendix_anchor_page } = d;
|
|
392
|
+
if (!isStrings(pages_text))
|
|
393
|
+
return err("`pages_text` is not a list of strings");
|
|
394
|
+
if (!isStringRecord(metadata))
|
|
395
|
+
return err("`metadata` is not an object of strings");
|
|
396
|
+
if (!isLinks(links)) return err("`links` is not a list of { page, uri }");
|
|
397
|
+
if (!isPageOrNull(bib_anchor_page))
|
|
398
|
+
return err("`bib_anchor_page` is neither a page number nor null");
|
|
399
|
+
if (!isPageOrNull(appendix_anchor_page))
|
|
400
|
+
return err("`appendix_anchor_page` is neither a page number nor null");
|
|
401
|
+
return ok({
|
|
402
|
+
pages: pages_text,
|
|
403
|
+
metadata,
|
|
404
|
+
links,
|
|
405
|
+
bibAnchorPage: bib_anchor_page,
|
|
406
|
+
appendixAnchorPage: appendix_anchor_page,
|
|
407
|
+
});
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
/** What the PDF measures and what it says: the geometry columns and schema 3's fields. */
|
|
411
|
+
function contentOf(
|
|
412
|
+
d: Readonly<Record<string, unknown>>,
|
|
413
|
+
): Result<Pick<ReadFacts, "geometry" | "text">, string> {
|
|
414
|
+
const geometry = geometryOf(d);
|
|
415
|
+
if (!geometry.ok) return geometry;
|
|
416
|
+
const text = textOf(d);
|
|
417
|
+
return text.ok ? ok({ geometry: geometry.value, text: text.value }) : text;
|
|
418
|
+
}
|
|
419
|
+
|
|
334
420
|
/** The PDF the facts describe: its path and its sha256. */
|
|
335
421
|
function artifactOf(
|
|
336
422
|
d: Readonly<Record<string, unknown>>,
|
|
@@ -368,11 +454,7 @@ export function parseFactsText(text: string): Result<ReadFacts, FactsProblem> {
|
|
|
368
454
|
kind: "broken",
|
|
369
455
|
why: "`fonts` is not a list of { name, type, embedded, program }",
|
|
370
456
|
});
|
|
371
|
-
const
|
|
372
|
-
if (!
|
|
373
|
-
return ok({
|
|
374
|
-
...artifact.value,
|
|
375
|
-
fonts,
|
|
376
|
-
geometry: geometry.value,
|
|
377
|
-
});
|
|
457
|
+
const content = contentOf(d);
|
|
458
|
+
if (!content.ok) return err({ kind: "broken", why: content.error });
|
|
459
|
+
return ok({ ...artifact.value, fonts, ...content.value });
|
|
378
460
|
}
|
package/src/new-paper.test.ts
CHANGED
|
@@ -51,7 +51,13 @@ describe("paperlint new — paperlint.json", () => {
|
|
|
51
51
|
expect(String(s["$comment"])).toMatch(/paperlint:agenticdev/);
|
|
52
52
|
expect(parsePaperSettings(s)).toEqual({
|
|
53
53
|
ok: true,
|
|
54
|
-
value: {
|
|
54
|
+
value: {
|
|
55
|
+
extends: null,
|
|
56
|
+
kind: null,
|
|
57
|
+
pdf: null,
|
|
58
|
+
rules: null,
|
|
59
|
+
identity: null,
|
|
60
|
+
},
|
|
55
61
|
});
|
|
56
62
|
});
|
|
57
63
|
|