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/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
|
|
|
@@ -30,18 +30,31 @@ describe("parsePaperSettings", () => {
|
|
|
30
30
|
kind: "research",
|
|
31
31
|
pdf: null,
|
|
32
32
|
rules: null,
|
|
33
|
+
identity: null,
|
|
33
34
|
},
|
|
34
35
|
});
|
|
35
36
|
expect(parsePaperSettings({})).toEqual({
|
|
36
37
|
ok: true,
|
|
37
|
-
value: {
|
|
38
|
+
value: {
|
|
39
|
+
extends: null,
|
|
40
|
+
kind: null,
|
|
41
|
+
pdf: null,
|
|
42
|
+
rules: null,
|
|
43
|
+
identity: null,
|
|
44
|
+
},
|
|
38
45
|
});
|
|
39
46
|
});
|
|
40
47
|
|
|
41
48
|
it("extends: null is valid and means no venue chosen yet — what `paperlint new` writes", () => {
|
|
42
49
|
expect(parsePaperSettings({ extends: null })).toEqual({
|
|
43
50
|
ok: true,
|
|
44
|
-
value: {
|
|
51
|
+
value: {
|
|
52
|
+
extends: null,
|
|
53
|
+
kind: null,
|
|
54
|
+
pdf: null,
|
|
55
|
+
rules: null,
|
|
56
|
+
identity: null,
|
|
57
|
+
},
|
|
45
58
|
});
|
|
46
59
|
});
|
|
47
60
|
});
|
|
@@ -138,6 +151,9 @@ describe("readPaperSettings — a root paperlint.json that cannot be used", () =
|
|
|
138
151
|
});
|
|
139
152
|
});
|
|
140
153
|
|
|
154
|
+
/** The fields a case does not set. */
|
|
155
|
+
const UNSET = { pdf: null, rules: null, identity: null };
|
|
156
|
+
|
|
141
157
|
describe("readPaperSettings — the root paperlint.json's defaults, the paper's file over them", () => {
|
|
142
158
|
const ROOT = "/work";
|
|
143
159
|
const withFiles = (f: Record<string, string>) =>
|
|
@@ -161,8 +177,7 @@ describe("readPaperSettings — the root paperlint.json's defaults, the paper's
|
|
|
161
177
|
).toEqual({
|
|
162
178
|
extends: "paperlint:agenticdev",
|
|
163
179
|
kind: "short",
|
|
164
|
-
|
|
165
|
-
rules: null,
|
|
180
|
+
...UNSET,
|
|
166
181
|
});
|
|
167
182
|
});
|
|
168
183
|
|
|
@@ -172,8 +187,7 @@ describe("readPaperSettings — the root paperlint.json's defaults, the paper's
|
|
|
172
187
|
).toEqual({
|
|
173
188
|
extends: "paperlint:aisec",
|
|
174
189
|
kind: null,
|
|
175
|
-
|
|
176
|
-
rules: null,
|
|
190
|
+
...UNSET,
|
|
177
191
|
});
|
|
178
192
|
});
|
|
179
193
|
|
|
@@ -191,6 +205,7 @@ describe("readPaperSettings — the root paperlint.json's defaults, the paper's
|
|
|
191
205
|
pdf: null,
|
|
192
206
|
// The root's rules are the project's blocks (cli.ts), not this paper's.
|
|
193
207
|
rules: { "pdf/fonts": "off" },
|
|
208
|
+
identity: null,
|
|
194
209
|
});
|
|
195
210
|
});
|
|
196
211
|
|
|
@@ -232,6 +247,7 @@ describe("paperRules — `rules` in paperlint.json", () => {
|
|
|
232
247
|
kind: null,
|
|
233
248
|
pdf: null,
|
|
234
249
|
rules,
|
|
250
|
+
identity: null,
|
|
235
251
|
});
|
|
236
252
|
|
|
237
253
|
it("no rules: none", () => {
|
|
@@ -267,3 +283,44 @@ describe("paperRules — `rules` in paperlint.json", () => {
|
|
|
267
283
|
}
|
|
268
284
|
});
|
|
269
285
|
});
|
|
286
|
+
|
|
287
|
+
describe("readPaperSettings — identity, at both levels", () => {
|
|
288
|
+
const ROOT = "/work";
|
|
289
|
+
const withFiles = (f: Record<string, string>) =>
|
|
290
|
+
memoryFiles({ [`${ROOT}/package.json`]: "{}", ...f });
|
|
291
|
+
const read = (f: Record<string, string>) => {
|
|
292
|
+
const r = readPaperSettings(withFiles(f), PAPER);
|
|
293
|
+
if (!r.ok) throw new Error(r.error.why);
|
|
294
|
+
return r.value;
|
|
295
|
+
};
|
|
296
|
+
|
|
297
|
+
it("identity: the root's list and the paper's, joined without repeats", () => {
|
|
298
|
+
expect(
|
|
299
|
+
read({
|
|
300
|
+
[`${ROOT}/paperlint.json`]: '{"identity":["Ada Example","adaexample"]}',
|
|
301
|
+
[`${PAPER}/paperlint.json`]:
|
|
302
|
+
'{"identity":["adaexample","Bob Coauthor"]}',
|
|
303
|
+
})?.identity,
|
|
304
|
+
).toEqual(["Ada Example", "adaexample", "Bob Coauthor"]);
|
|
305
|
+
expect(
|
|
306
|
+
read({ [`${ROOT}/paperlint.json`]: '{"identity":["Ada Example"]}' })
|
|
307
|
+
?.identity,
|
|
308
|
+
).toEqual(["Ada Example"]);
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
it("identity of the wrong shape, at either level, is refused naming it", () => {
|
|
312
|
+
const why = (f: Record<string, string>) => {
|
|
313
|
+
const r = readPaperSettings(withFiles(f), PAPER);
|
|
314
|
+
return r.ok ? "" : r.error.why;
|
|
315
|
+
};
|
|
316
|
+
expect(why({ [`${PAPER}/paperlint.json`]: '{"identity":"Ada"}' })).toMatch(
|
|
317
|
+
/"identity" must be a list of strings/,
|
|
318
|
+
);
|
|
319
|
+
expect(why({ [`${PAPER}/paperlint.json`]: '{"identity":[" "]}' })).toMatch(
|
|
320
|
+
/"identity" must be a list of strings, each with a letter or digit/,
|
|
321
|
+
);
|
|
322
|
+
expect(why({ [`${ROOT}/paperlint.json`]: '{"identity":[3]}' })).toMatch(
|
|
323
|
+
/^the root paperlint\.json: "identity" must be/,
|
|
324
|
+
);
|
|
325
|
+
});
|
|
326
|
+
});
|
package/src/paper-settings.ts
CHANGED
|
@@ -45,6 +45,12 @@ export interface PaperSettings {
|
|
|
45
45
|
* against the shipped rules.
|
|
46
46
|
*/
|
|
47
47
|
readonly rules: Readonly<Record<string, unknown>> | readonly unknown[] | null;
|
|
48
|
+
/**
|
|
49
|
+
* What identifies the authors — `anonymity/identity` requires a blind venue's PDF to say none of it.
|
|
50
|
+
* The root's list and the paper's own, joined: a paper adds its co-authors to the project's author
|
|
51
|
+
* and cannot drop them by accident. Null when neither declares one.
|
|
52
|
+
*/
|
|
53
|
+
readonly identity: readonly string[] | null;
|
|
48
54
|
}
|
|
49
55
|
|
|
50
56
|
/** Why a paper's settings cannot be read. */
|
|
@@ -86,6 +92,23 @@ export function stringFields(
|
|
|
86
92
|
});
|
|
87
93
|
}
|
|
88
94
|
|
|
95
|
+
/**
|
|
96
|
+
* `identity`: absent is null; otherwise a list of strings, each with a letter or digit in it — a
|
|
97
|
+
* blank entry would match nothing and read as a declared, clean identity.
|
|
98
|
+
*/
|
|
99
|
+
export function identityField(
|
|
100
|
+
d: Readonly<Record<string, unknown>>,
|
|
101
|
+
): Result<readonly string[] | null, string> {
|
|
102
|
+
const v = d["identity"];
|
|
103
|
+
if (v === undefined) return ok(null);
|
|
104
|
+
return Array.isArray(v) &&
|
|
105
|
+
v.every((x) => typeof x === "string" && /[\p{L}\p{N}]/u.test(x))
|
|
106
|
+
? ok(v.filter((x): x is string => typeof x === "string"))
|
|
107
|
+
: err(
|
|
108
|
+
`"identity" must be a list of strings, each with a letter or digit, got ${JSON.stringify(v)}`,
|
|
109
|
+
);
|
|
110
|
+
}
|
|
111
|
+
|
|
89
112
|
/** The parsed JSON of `paperlint.json` → the settings, or one line saying what is wrong. Pure. */
|
|
90
113
|
export function parsePaperSettings(
|
|
91
114
|
json: unknown,
|
|
@@ -103,6 +126,8 @@ export function parsePaperSettings(
|
|
|
103
126
|
);
|
|
104
127
|
const fields = stringFields(json);
|
|
105
128
|
if (!fields.ok) return fields;
|
|
129
|
+
const identity = identityField(json);
|
|
130
|
+
if (!identity.ok) return identity;
|
|
106
131
|
const rules = json["rules"];
|
|
107
132
|
if (rules !== undefined && !isObject(rules) && !Array.isArray(rules))
|
|
108
133
|
return err(
|
|
@@ -111,6 +136,7 @@ export function parsePaperSettings(
|
|
|
111
136
|
return ok({
|
|
112
137
|
...fields.value,
|
|
113
138
|
rules: rules ?? null,
|
|
139
|
+
identity: identity.value,
|
|
114
140
|
});
|
|
115
141
|
}
|
|
116
142
|
|
|
@@ -144,9 +170,18 @@ function rootDefaults(
|
|
|
144
170
|
if (!json.ok) return json;
|
|
145
171
|
if (!isObject(json.value)) return ok(null);
|
|
146
172
|
const fields = stringFields(json.value);
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
173
|
+
const identity = identityField(json.value);
|
|
174
|
+
if (!fields.ok)
|
|
175
|
+
return err({
|
|
176
|
+
kind: "broken",
|
|
177
|
+
why: `the root ${CONFIG_FILE}: ${fields.error}`,
|
|
178
|
+
});
|
|
179
|
+
return identity.ok
|
|
180
|
+
? ok({ ...fields.value, identity: identity.value })
|
|
181
|
+
: err({
|
|
182
|
+
kind: "broken",
|
|
183
|
+
why: `the root ${CONFIG_FILE}: ${identity.error}`,
|
|
184
|
+
});
|
|
150
185
|
}
|
|
151
186
|
|
|
152
187
|
/**
|
|
@@ -167,8 +202,20 @@ export function readPaperSettings(
|
|
|
167
202
|
return ok(merge(defaults.value, parsed.value));
|
|
168
203
|
}
|
|
169
204
|
|
|
170
|
-
type Defaults = Pick<PaperSettings, "extends" | "kind" | "pdf">;
|
|
171
|
-
const NO_DEFAULTS: Defaults = {
|
|
205
|
+
type Defaults = Pick<PaperSettings, "extends" | "kind" | "pdf" | "identity">;
|
|
206
|
+
const NO_DEFAULTS: Defaults = {
|
|
207
|
+
extends: null,
|
|
208
|
+
kind: null,
|
|
209
|
+
pdf: null,
|
|
210
|
+
identity: null,
|
|
211
|
+
};
|
|
212
|
+
|
|
213
|
+
/** Both lists, joined without repeats; null when neither side has one. */
|
|
214
|
+
const joined = (
|
|
215
|
+
a: readonly string[] | null,
|
|
216
|
+
b: readonly string[] | null,
|
|
217
|
+
): readonly string[] | null =>
|
|
218
|
+
a === null && b === null ? null : [...new Set([...(a ?? []), ...(b ?? [])])];
|
|
172
219
|
|
|
173
220
|
/** The paper's own values win; each absent one falls back to the root's. */
|
|
174
221
|
function merge(
|
|
@@ -182,6 +229,7 @@ function merge(
|
|
|
182
229
|
kind: p.kind ?? r.kind,
|
|
183
230
|
pdf: p.pdf ?? r.pdf,
|
|
184
231
|
rules: p.rules,
|
|
232
|
+
identity: joined(r.identity, p.identity),
|
|
185
233
|
};
|
|
186
234
|
return paper === null && Object.values(merged).every((v) => v === null)
|
|
187
235
|
? null
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/** What a page says, read from pdf.js's shapes: text, links, metadata. */
|
|
2
|
+
import { describe, expect, it } from "vitest";
|
|
3
|
+
import { linkTargetsOf, metadataOf, pageTextOf, xmpOf } from "./pdf-content.ts";
|
|
4
|
+
|
|
5
|
+
describe("pageTextOf", () => {
|
|
6
|
+
it("joins items without a space, and breaks the line where pdf.js marks one", () => {
|
|
7
|
+
expect(
|
|
8
|
+
pageTextOf([
|
|
9
|
+
{ str: "Ada Ex", hasEOL: false },
|
|
10
|
+
{ str: "ample", hasEOL: true },
|
|
11
|
+
{ type: "beginMarkedContent" },
|
|
12
|
+
{ str: "", hasEOL: true },
|
|
13
|
+
{ str: "Intro" },
|
|
14
|
+
]),
|
|
15
|
+
).toBe("Ada Example\n\nIntro");
|
|
16
|
+
});
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
describe("linkTargetsOf", () => {
|
|
20
|
+
it("each link's target once: url, else unsafeUrl; not other annotations, not empty ones", () => {
|
|
21
|
+
expect(
|
|
22
|
+
linkTargetsOf([
|
|
23
|
+
{ subtype: "Link", url: "https://a.example" },
|
|
24
|
+
{ subtype: "Link", url: "https://a.example" },
|
|
25
|
+
{ subtype: "Link", unsafeUrl: "mailto:x@example.org" },
|
|
26
|
+
{ subtype: "Link", dest: "sec:1" },
|
|
27
|
+
{ subtype: "Link", url: "" },
|
|
28
|
+
{ subtype: "Widget", url: "https://b.example" },
|
|
29
|
+
]),
|
|
30
|
+
).toEqual(["https://a.example", "mailto:x@example.org"]);
|
|
31
|
+
});
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
describe("metadataOf and xmpOf", () => {
|
|
35
|
+
it("every string of the Info dictionary, its custom entries and the XMP packet, flat", () => {
|
|
36
|
+
const xmp = xmpOf(
|
|
37
|
+
new Map<unknown, unknown>([
|
|
38
|
+
["dc:creator", ["Ada Example", "Bob Other"]],
|
|
39
|
+
["dc:title", "A Title"],
|
|
40
|
+
["xmp:rating", 3],
|
|
41
|
+
[7, "not a name"],
|
|
42
|
+
]),
|
|
43
|
+
);
|
|
44
|
+
expect(xmp).toEqual({
|
|
45
|
+
"dc:creator": ["Ada Example", "Bob Other"],
|
|
46
|
+
"dc:title": "A Title",
|
|
47
|
+
"xmp:rating": 3,
|
|
48
|
+
});
|
|
49
|
+
expect(
|
|
50
|
+
metadataOf(
|
|
51
|
+
{
|
|
52
|
+
Author: "Ada Example",
|
|
53
|
+
Subject: "",
|
|
54
|
+
Trapped: { name: "False" },
|
|
55
|
+
Custom: { "PTEX.Fullbanner": "This is pdfTeX" },
|
|
56
|
+
},
|
|
57
|
+
xmp,
|
|
58
|
+
),
|
|
59
|
+
).toEqual({
|
|
60
|
+
Author: "Ada Example",
|
|
61
|
+
"PTEX.Fullbanner": "This is pdfTeX",
|
|
62
|
+
"dc:creator": "Ada Example; Bob Other",
|
|
63
|
+
"dc:title": "A Title",
|
|
64
|
+
});
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it("no Info and no packet: nothing", () => {
|
|
68
|
+
expect(xmpOf(null)).toBeNull();
|
|
69
|
+
expect(metadataOf(null, null)).toEqual({});
|
|
70
|
+
});
|
|
71
|
+
});
|