paperlint 5.0.0 → 6.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CLAUDE.md +13 -0
- package/CONTRIBUTING.md +3 -0
- package/README.md +8 -8
- package/bin/paperlint.mjs +5 -1
- package/dist/adapters/latex/index.d.ts +1 -0
- package/dist/adapters/latex/index.d.ts.map +1 -1
- package/dist/adapters/latex/index.js +3 -0
- package/dist/adapters/latex/index.js.map +1 -1
- package/dist/adapters/latex/layout.d.ts +27 -0
- package/dist/adapters/latex/layout.d.ts.map +1 -0
- package/dist/adapters/latex/layout.js +62 -0
- package/dist/adapters/latex/layout.js.map +1 -0
- package/dist/adapters/latex/nodes.d.ts.map +1 -1
- package/dist/adapters/latex/nodes.js +11 -1
- package/dist/adapters/latex/nodes.js.map +1 -1
- package/dist/adapters/latex/parse.d.ts.map +1 -1
- package/dist/adapters/latex/parse.js +5 -1
- package/dist/adapters/latex/parse.js.map +1 -1
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +25 -5
- package/dist/cli.js.map +1 -1
- package/dist/domain/anonymity.d.ts +68 -0
- package/dist/domain/anonymity.d.ts.map +1 -0
- package/dist/domain/anonymity.js +94 -0
- package/dist/domain/anonymity.js.map +1 -0
- package/dist/domain/body-pages.d.ts +69 -0
- package/dist/domain/body-pages.d.ts.map +1 -0
- package/dist/domain/body-pages.js +141 -0
- package/dist/domain/body-pages.js.map +1 -0
- package/dist/domain/tex-document.d.ts +9 -0
- package/dist/domain/tex-document.d.ts.map +1 -1
- package/dist/eslint-rules/pdf-last-page-balance.d.ts +1 -1
- package/dist/eslint-rules/pdf-last-page-balance.js +2 -2
- package/dist/facts-file.d.ts +28 -2
- package/dist/facts-file.d.ts.map +1 -1
- package/dist/facts-file.js +54 -9
- package/dist/facts-file.js.map +1 -1
- package/dist/lib/paper-config.d.ts.map +1 -1
- package/dist/lib/paper-config.js +2 -1
- package/dist/lib/paper-config.js.map +1 -1
- package/dist/paper-settings.d.ts +11 -0
- package/dist/paper-settings.d.ts.map +1 -1
- package/dist/paper-settings.js +38 -4
- package/dist/paper-settings.js.map +1 -1
- package/dist/pdf-content.d.ts +40 -0
- package/dist/pdf-content.d.ts.map +1 -0
- package/dist/pdf-content.js +97 -0
- package/dist/pdf-content.js.map +1 -0
- package/dist/pdf-facts.d.ts +30 -0
- package/dist/pdf-facts.d.ts.map +1 -1
- package/dist/pdf-facts.js +37 -1
- package/dist/pdf-facts.js.map +1 -1
- package/dist/ports/latex.d.ts +3 -1
- package/dist/ports/latex.d.ts.map +1 -1
- package/dist/presets.d.ts +2 -0
- package/dist/presets.d.ts.map +1 -1
- package/dist/presets.js +4 -0
- package/dist/presets.js.map +1 -1
- package/dist/tex-requirements.d.ts +4 -0
- package/dist/tex-requirements.d.ts.map +1 -1
- package/dist/tex-requirements.js +3 -0
- package/dist/tex-requirements.js.map +1 -1
- package/dist/tex-venue-rules.d.ts +7 -1
- package/dist/tex-venue-rules.d.ts.map +1 -1
- package/dist/tex-venue-rules.js +40 -0
- package/dist/tex-venue-rules.js.map +1 -1
- package/dist/types.d.ts +2 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/venue-rules.d.ts +38 -8
- package/dist/venue-rules.d.ts.map +1 -1
- package/dist/venue-rules.js +179 -41
- package/dist/venue-rules.js.map +1 -1
- package/docs/configuration.md +18 -16
- package/docs/e2e.md +32 -11
- package/docs/rules/anonymity/identity.md +111 -0
- package/docs/rules/format/layout-override.md +61 -0
- package/docs/rules/format/page-limit.md +108 -0
- package/docs/rules/pdf/body-size.md +87 -0
- package/docs/rules.md +44 -39
- package/eslint-rules/pdf-last-page-balance.harness.mjs +6 -1
- package/eslint-rules/pdf-last-page-balance.ts +2 -2
- package/fixtures/build-e2e/aidc/expect.json +1 -1
- package/fixtures/build-e2e/aidc/paperlint.json +1 -1
- package/fixtures/build-e2e/anon-ack/PIPELINE-STATUS.md +3 -0
- package/fixtures/build-e2e/anon-ack/expect.json +19 -0
- package/fixtures/build-e2e/anon-ack/paper.tex +31 -0
- package/fixtures/build-e2e/anon-ack/paperlint.json +1 -0
- package/fixtures/build-e2e/anon-author/PIPELINE-STATUS.md +3 -0
- package/fixtures/build-e2e/anon-author/expect.json +19 -0
- package/fixtures/build-e2e/anon-author/paper.tex +29 -0
- package/fixtures/build-e2e/anon-author/paperlint.json +1 -0
- package/fixtures/build-e2e/anon-bib/PIPELINE-STATUS.md +3 -0
- package/fixtures/build-e2e/anon-bib/expect.json +20 -0
- package/fixtures/build-e2e/anon-bib/paper.tex +35 -0
- package/fixtures/build-e2e/anon-bib/paperlint.json +1 -0
- package/fixtures/build-e2e/anon-href/PIPELINE-STATUS.md +3 -0
- package/fixtures/build-e2e/anon-href/expect.json +19 -0
- package/fixtures/build-e2e/anon-href/paper.tex +30 -0
- package/fixtures/build-e2e/anon-href/paperlint.json +1 -0
- package/fixtures/build-e2e/anon-macro/PIPELINE-STATUS.md +3 -0
- package/fixtures/build-e2e/anon-macro/expect.json +19 -0
- package/fixtures/build-e2e/anon-macro/paper.tex +30 -0
- package/fixtures/build-e2e/anon-macro/paperlint.json +1 -0
- package/fixtures/build-e2e/anon-pdfauthor/PIPELINE-STATUS.md +3 -0
- package/fixtures/build-e2e/anon-pdfauthor/expect.json +19 -0
- package/fixtures/build-e2e/anon-pdfauthor/paper.tex +31 -0
- package/fixtures/build-e2e/anon-pdfauthor/paperlint.json +1 -0
- package/fixtures/build-e2e/unbalanced/expect.json +2 -2
- package/lib/paper-config.ts +3 -1
- package/package.json +1 -1
- package/presets/acm-sigconf.jsonc +3 -1
- package/presets/aidc.jsonc +16 -17
- package/presets/ieee-conference.jsonc +8 -0
- package/presets/venue-profile.schema.json +9 -0
- package/skills/render-paper/extract-pdf-facts.harness.ts +3 -3
- package/src/adapters/latex/index.ts +3 -0
- package/src/adapters/latex/layout.ts +106 -0
- package/src/adapters/latex/nodes.ts +11 -3
- package/src/adapters/latex/parse.ts +5 -1
- package/src/build.harness.mjs +2 -2
- package/src/build.test.ts +5 -0
- package/src/cli.test.ts +13 -0
- package/src/cli.ts +36 -4
- package/src/domain/anonymity.test.ts +163 -0
- package/src/domain/anonymity.ts +129 -0
- package/src/domain/body-pages.test.ts +300 -0
- package/src/domain/body-pages.ts +223 -0
- package/src/domain/tex-document.ts +10 -0
- package/src/facts-file.test.ts +100 -4
- package/src/facts-file.ts +91 -9
- package/src/new-paper.test.ts +7 -1
- package/src/paper-settings.test.ts +63 -6
- package/src/paper-settings.ts +53 -5
- package/src/pdf-content.test.ts +71 -0
- package/src/pdf-content.ts +125 -0
- package/src/pdf-facts.test.ts +110 -2
- package/src/pdf-facts.ts +106 -8
- package/src/ports/latex.ts +3 -0
- package/src/presets.ts +8 -8
- package/src/rule-cases.test.ts +29 -3
- package/src/rule-docs.test.ts +0 -2
- package/src/tex-requirements.ts +9 -0
- package/src/tex-venue-rules.test.ts +110 -5
- package/src/tex-venue-rules.ts +57 -2
- package/src/types.ts +2 -0
- package/src/venue-rules.test.ts +351 -39
- package/src/venue-rules.ts +247 -47
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What a page SAYS, as pdf.js exports it — PURE. The anonymity and page-limit rules judge text, so
|
|
3
|
+
* the facts carry each page's text, the targets of its links, where the bibliography's first entry
|
|
4
|
+
* and the appendix are anchored, and the document's metadata. `pdf-facts.ts` reads the PDF and
|
|
5
|
+
* calls these; nothing here knows what a rule does with the result.
|
|
6
|
+
*/
|
|
7
|
+
import { fieldOf, isRecord } from "./domain/record.ts";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* A page's text: its items in content order, joined WITHOUT a separator — pdf.js emits a space as
|
|
11
|
+
* an item of its own where the glyphs leave a gap, so a word split into two items by kerning comes
|
|
12
|
+
* back whole — with a line break after every item pdf.js marks as ending a line.
|
|
13
|
+
*/
|
|
14
|
+
export const pageTextOf = (items: readonly unknown[]): string =>
|
|
15
|
+
items
|
|
16
|
+
.map((it) => {
|
|
17
|
+
const s = fieldOf(it, "str");
|
|
18
|
+
return `${typeof s === "string" ? s : ""}${fieldOf(it, "hasEOL") === true ? "\n" : ""}`;
|
|
19
|
+
})
|
|
20
|
+
.join("");
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* The target of every link annotation on a page, once each: its `url`, else its `unsafeUrl`. A link
|
|
24
|
+
* whose text breaks across lines is one annotation per line (measured: hyperref's `\\href` gave two
|
|
25
|
+
* for one target), and one target is one fact.
|
|
26
|
+
*/
|
|
27
|
+
export const linkTargetsOf = (
|
|
28
|
+
annotations: readonly unknown[],
|
|
29
|
+
): readonly string[] => {
|
|
30
|
+
const urls = annotations.flatMap((a) => {
|
|
31
|
+
if (fieldOf(a, "subtype") !== "Link") return [];
|
|
32
|
+
const url = fieldOf(a, "url") ?? fieldOf(a, "unsafeUrl");
|
|
33
|
+
return typeof url === "string" && url !== "" ? [url] : [];
|
|
34
|
+
});
|
|
35
|
+
return urls.filter((u, i) => urls.indexOf(u) === i);
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
/** The page reference an explicit destination starts with, or null for anything else. */
|
|
39
|
+
export const refOf = (dest: unknown): object | null => {
|
|
40
|
+
const ref: unknown = Array.isArray(dest) ? dest[0] : undefined;
|
|
41
|
+
return typeof ref === "object" && ref !== null ? ref : null;
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* The page references of the named destinations whose name starts with `prefix`, from pdf.js's
|
|
46
|
+
* `getDestinations()` (name → explicit destination). hyperref names one `cite.<key>` per
|
|
47
|
+
* `\bibitem`, and, after `\appendix` in the standard classes, the appendix's sections
|
|
48
|
+
* `appendix.<letter>`.
|
|
49
|
+
*/
|
|
50
|
+
export const namedRefs = (
|
|
51
|
+
destinations: unknown,
|
|
52
|
+
prefix: string,
|
|
53
|
+
): readonly object[] =>
|
|
54
|
+
isRecord(destinations)
|
|
55
|
+
? Object.entries(destinations).flatMap(([name, dest]) => {
|
|
56
|
+
const ref = refOf(dest);
|
|
57
|
+
return name.startsWith(prefix) && ref !== null ? [ref] : [];
|
|
58
|
+
})
|
|
59
|
+
: [];
|
|
60
|
+
|
|
61
|
+
/** A bookmark title that opens an appendix: «Appendix A: …», «APPENDIX», «Appendices». */
|
|
62
|
+
const APPENDIX_TITLE = /^\s*appendi(?:x|ces)\b/iu;
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* The destinations of the top-level bookmarks that open an appendix, from pdf.js's `getOutline()`:
|
|
66
|
+
* each a name to look up, or an explicit destination. IEEEtran's `\appendices` names its sections
|
|
67
|
+
* `section*.<n>`, so the bookmark's title is what says it is an appendix.
|
|
68
|
+
*/
|
|
69
|
+
export const appendixOutlineDests = (outline: unknown): readonly unknown[] =>
|
|
70
|
+
Array.isArray(outline)
|
|
71
|
+
? outline.flatMap((item: unknown) => {
|
|
72
|
+
const title = fieldOf(item, "title");
|
|
73
|
+
return typeof title === "string" && APPENDIX_TITLE.test(title)
|
|
74
|
+
? [fieldOf(item, "dest")]
|
|
75
|
+
: [];
|
|
76
|
+
})
|
|
77
|
+
: [];
|
|
78
|
+
|
|
79
|
+
/** A value of a metadata field as text: a string, or the strings of a list; nothing else. */
|
|
80
|
+
const textsOf = (v: unknown): readonly string[] =>
|
|
81
|
+
typeof v === "string"
|
|
82
|
+
? [v]
|
|
83
|
+
: Array.isArray(v)
|
|
84
|
+
? v.filter((x): x is string => typeof x === "string")
|
|
85
|
+
: [];
|
|
86
|
+
|
|
87
|
+
const entries = (rec: unknown): readonly (readonly [string, string])[] =>
|
|
88
|
+
isRecord(rec)
|
|
89
|
+
? Object.entries(rec).flatMap(([k, v]) => {
|
|
90
|
+
const text = textsOf(v).join("; ");
|
|
91
|
+
return text === "" ? [] : [[k, text] as const];
|
|
92
|
+
})
|
|
93
|
+
: [];
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* The XMP packet pdf.js parsed, as `name → value`: its `Metadata` iterates `[name, value]` pairs.
|
|
97
|
+
* Null when the PDF carries no packet.
|
|
98
|
+
*/
|
|
99
|
+
export const xmpOf = (
|
|
100
|
+
packet: Iterable<unknown> | null,
|
|
101
|
+
): Readonly<Record<string, unknown>> | null =>
|
|
102
|
+
packet === null
|
|
103
|
+
? null
|
|
104
|
+
: Object.fromEntries(
|
|
105
|
+
[...packet].flatMap((e) =>
|
|
106
|
+
Array.isArray(e) && typeof e[0] === "string" ? [[e[0], e[1]]] : [],
|
|
107
|
+
),
|
|
108
|
+
);
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* The document's metadata as flat `field → text`: every string of the Info dictionary (`Author`,
|
|
112
|
+
* `Title`, …), of its custom entries (`PTEX.Fullbanner`), and of the XMP packet, whose names carry
|
|
113
|
+
* their namespace (`dc:creator`), as pdf.js's `getMetadata` returns them. `xmp` is the packet's
|
|
114
|
+
* `xmpOf`, or null when the PDF has none.
|
|
115
|
+
*/
|
|
116
|
+
export function metadataOf(
|
|
117
|
+
info: unknown,
|
|
118
|
+
xmp: unknown,
|
|
119
|
+
): Readonly<Record<string, string>> {
|
|
120
|
+
return Object.fromEntries([
|
|
121
|
+
...entries(info),
|
|
122
|
+
...entries(fieldOf(info, "Custom")),
|
|
123
|
+
...entries(xmp),
|
|
124
|
+
]);
|
|
125
|
+
}
|
package/src/pdf-facts.test.ts
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
*/
|
|
7
7
|
import assert from "node:assert/strict";
|
|
8
8
|
import { getResolvedPDFJS } from "unpdf";
|
|
9
|
+
import { fieldOf } from "./domain/record.ts";
|
|
9
10
|
import { test } from "vitest";
|
|
10
11
|
import { factsOf, failureOf, rawFontOf } from "./pdf-facts.ts";
|
|
11
12
|
import { present } from "../test/support.ts";
|
|
@@ -21,7 +22,11 @@ interface FakeItem {
|
|
|
21
22
|
height: number;
|
|
22
23
|
}
|
|
23
24
|
/** A page whose text items and loaded fonts are given; its viewport flips y like pdf.js's. */
|
|
24
|
-
function page(
|
|
25
|
+
function page(
|
|
26
|
+
items: FakeItem[],
|
|
27
|
+
fonts: Record<string, unknown>,
|
|
28
|
+
said: { annotations?: unknown[] } = {},
|
|
29
|
+
) {
|
|
25
30
|
const objs = new Map(Object.entries(fonts));
|
|
26
31
|
return {
|
|
27
32
|
getOperatorList: () => Promise.resolve({ fnArray: [], argsArray: [] }),
|
|
@@ -32,6 +37,7 @@ function page(items: FakeItem[], fonts: Record<string, unknown>) {
|
|
|
32
37
|
get: (id: string) => objs.get(id),
|
|
33
38
|
[Symbol.iterator]: () => objs[Symbol.iterator](),
|
|
34
39
|
},
|
|
40
|
+
getAnnotations: () => Promise.resolve(said.annotations ?? []),
|
|
35
41
|
getViewport: () => ({
|
|
36
42
|
width: 612,
|
|
37
43
|
height: H,
|
|
@@ -40,8 +46,23 @@ function page(items: FakeItem[], fonts: Record<string, unknown>) {
|
|
|
40
46
|
}),
|
|
41
47
|
};
|
|
42
48
|
}
|
|
43
|
-
const doc = (
|
|
49
|
+
const doc = (
|
|
50
|
+
pages: ReturnType<typeof page>[],
|
|
51
|
+
info: unknown = {},
|
|
52
|
+
destinations: unknown = {},
|
|
53
|
+
outline: unknown = null,
|
|
54
|
+
) => ({
|
|
44
55
|
numPages: pages.length,
|
|
56
|
+
getMetadata: () => Promise.resolve({ info, metadata: null }),
|
|
57
|
+
getDestinations: () => Promise.resolve(destinations),
|
|
58
|
+
getDestination: (id: string) =>
|
|
59
|
+
Promise.resolve(fieldOf(destinations, id) ?? null),
|
|
60
|
+
getOutline: () => Promise.resolve(outline),
|
|
61
|
+
// A reference is `{ page }` here; one without a page is one pdf.js cannot place.
|
|
62
|
+
getPageIndex: (ref: object) =>
|
|
63
|
+
"page" in ref && typeof ref.page === "number"
|
|
64
|
+
? Promise.resolve(ref.page - 1)
|
|
65
|
+
: Promise.reject(new Error("Invalid page reference")),
|
|
45
66
|
getPage: (i: number) =>
|
|
46
67
|
Promise.resolve(present(pages[i - 1], `page ${String(i)}`)),
|
|
47
68
|
});
|
|
@@ -152,3 +173,90 @@ test("🔴 a rejection with no value at all is a failure too, not a TypeError ou
|
|
|
152
173
|
],
|
|
153
174
|
);
|
|
154
175
|
});
|
|
176
|
+
|
|
177
|
+
test("what the pages say: text, links with their page, metadata", async () => {
|
|
178
|
+
const fonts = { f1: { loadedName: "f1", name: "Named", ascent: 0.8 } };
|
|
179
|
+
const plain = page([item("f1", [10, 0, 0, 10, 50, 700])], fonts);
|
|
180
|
+
const linked = page([item("f1", [10, 0, 0, 10, 50, 700])], fonts, {
|
|
181
|
+
annotations: [{ subtype: "Link", url: "https://github.com/adaexample" }],
|
|
182
|
+
});
|
|
183
|
+
const r = await factsOf(doc([plain, linked], { Author: "Ada Example" }), lib);
|
|
184
|
+
assert.ok(r.ok);
|
|
185
|
+
assert.deepEqual(
|
|
186
|
+
[r.facts.pageTexts, r.facts.links, r.facts.metadata],
|
|
187
|
+
[
|
|
188
|
+
["word", "word"],
|
|
189
|
+
[{ page: 2, uri: "https://github.com/adaexample" }],
|
|
190
|
+
{ Author: "Ada Example" },
|
|
191
|
+
],
|
|
192
|
+
);
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
test("the bibliography anchor: the lowest page of any cite.<key> destination", async () => {
|
|
196
|
+
const fonts = { f1: { loadedName: "f1", name: "Named", ascent: 0.8 } };
|
|
197
|
+
const pages = [1, 2, 3].map(() =>
|
|
198
|
+
page([item("f1", [10, 0, 0, 10, 50, 700])], fonts),
|
|
199
|
+
);
|
|
200
|
+
const facts = async (destinations: unknown) => {
|
|
201
|
+
const r = await factsOf(doc(pages, {}, destinations), lib);
|
|
202
|
+
assert.ok(r.ok);
|
|
203
|
+
return r.facts.bibAnchorPage;
|
|
204
|
+
};
|
|
205
|
+
assert.equal(
|
|
206
|
+
await facts({
|
|
207
|
+
"cite.b": [{ page: 3 }, { name: "XYZ" }],
|
|
208
|
+
"cite.a": [{ page: 2 }, { name: "XYZ" }],
|
|
209
|
+
"section.1": [{ page: 1 }, { name: "XYZ" }],
|
|
210
|
+
// A reference pdf.js cannot place is skipped, not fatal.
|
|
211
|
+
"cite.c": [{ num: 99 }, { name: "XYZ" }],
|
|
212
|
+
}),
|
|
213
|
+
2,
|
|
214
|
+
);
|
|
215
|
+
// No hyperref: no cite destinations, and the value says so.
|
|
216
|
+
assert.equal(await facts({ "section.1": [{ page: 1 }] }), null);
|
|
217
|
+
assert.equal(await facts(null), null);
|
|
218
|
+
// A destination that is not an explicit array is not an anchor.
|
|
219
|
+
assert.equal(await facts({ "cite.a": "named-elsewhere" }), null);
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
test("the appendix anchor: an appendix.<letter> destination, or a bookmark titled «Appendix…»", async () => {
|
|
223
|
+
const fonts = { f1: { loadedName: "f1", name: "Named", ascent: 0.8 } };
|
|
224
|
+
const pages = [1, 2, 3, 4].map(() =>
|
|
225
|
+
page([item("f1", [10, 0, 0, 10, 50, 700])], fonts),
|
|
226
|
+
);
|
|
227
|
+
const facts = async (destinations: unknown, outline: unknown) => {
|
|
228
|
+
const r = await factsOf(doc(pages, {}, destinations, outline), lib);
|
|
229
|
+
assert.ok(r.ok);
|
|
230
|
+
return r.facts.appendixAnchorPage;
|
|
231
|
+
};
|
|
232
|
+
const XYZ = { name: "XYZ" };
|
|
233
|
+
// The standard classes: hyperref names the appendix's sections `appendix.<letter>`.
|
|
234
|
+
assert.equal(
|
|
235
|
+
await facts(
|
|
236
|
+
{ "appendix.B": [{ page: 4 }, XYZ], "appendix.A": [{ page: 3 }, XYZ] },
|
|
237
|
+
null,
|
|
238
|
+
),
|
|
239
|
+
3,
|
|
240
|
+
);
|
|
241
|
+
// IEEEtran's \appendices: the sections are `section*.<n>`; the bookmark's title says appendix,
|
|
242
|
+
// its destination is a name to look up, or an explicit one.
|
|
243
|
+
const named = {
|
|
244
|
+
"section*.5": [{ page: 2 }, XYZ],
|
|
245
|
+
"section*.4": [{ page: 1 }, XYZ],
|
|
246
|
+
};
|
|
247
|
+
assert.equal(
|
|
248
|
+
await facts(named, [
|
|
249
|
+
{ title: "Conclusion", dest: "section*.4", items: [] },
|
|
250
|
+
{ title: "Appendix A: Details", dest: "section*.5", items: [] },
|
|
251
|
+
{ title: "APPENDIX", dest: [{ page: 4 }, XYZ], items: [] },
|
|
252
|
+
]),
|
|
253
|
+
2,
|
|
254
|
+
);
|
|
255
|
+
// No appendix anywhere, a bookmark whose name resolves to nothing, and no outline at all.
|
|
256
|
+
assert.equal(
|
|
257
|
+
await facts(named, [{ title: "Conclusion", dest: "section*.4" }]),
|
|
258
|
+
null,
|
|
259
|
+
);
|
|
260
|
+
assert.equal(await facts({}, [{ title: "Appendices", dest: "gone" }]), null);
|
|
261
|
+
assert.equal(await facts({}, { not: "a list" }), null);
|
|
262
|
+
});
|
package/src/pdf-facts.ts
CHANGED
|
@@ -34,6 +34,15 @@ import {
|
|
|
34
34
|
import type { PageLayout, TextBox } from "./domain/page-layout.ts";
|
|
35
35
|
// eslint-disable-next-line boundaries/dependencies -- legacy layer, moves behind a port in #76
|
|
36
36
|
import { fillsFor, isUpright } from "./adapters/pdfjs/fill.ts";
|
|
37
|
+
import {
|
|
38
|
+
appendixOutlineDests,
|
|
39
|
+
linkTargetsOf,
|
|
40
|
+
metadataOf,
|
|
41
|
+
namedRefs,
|
|
42
|
+
pageTextOf,
|
|
43
|
+
refOf,
|
|
44
|
+
xmpOf,
|
|
45
|
+
} from "./pdf-content.ts";
|
|
37
46
|
import { messageOf } from "./domain/text.ts";
|
|
38
47
|
import { fieldOf, isRecord, numbersOf } from "./domain/record.ts";
|
|
39
48
|
|
|
@@ -83,12 +92,27 @@ interface Page {
|
|
|
83
92
|
[Symbol.iterator](): Iterator<readonly unknown[]>;
|
|
84
93
|
};
|
|
85
94
|
getViewport(params: { scale: number }): Viewport;
|
|
95
|
+
/** pdf.js's annotations; the link ones carry `url`. */
|
|
96
|
+
getAnnotations(): Promise<readonly unknown[]>;
|
|
86
97
|
}
|
|
87
98
|
|
|
88
99
|
/** A document as this module reads it; pdf.js's `PDFDocumentProxy` is one. */
|
|
89
100
|
interface Doc {
|
|
90
101
|
readonly numPages: number;
|
|
91
102
|
getPage(pageNumber: number): Promise<Page>;
|
|
103
|
+
/** The Info dictionary, and the XMP packet when there is one. */
|
|
104
|
+
getMetadata(): Promise<{
|
|
105
|
+
readonly info: unknown;
|
|
106
|
+
readonly metadata: Iterable<unknown> | null;
|
|
107
|
+
}>;
|
|
108
|
+
/** Every named destination: name → explicit destination. */
|
|
109
|
+
getDestinations(): Promise<unknown>;
|
|
110
|
+
/** The 0-based index of the page a destination's reference names. */
|
|
111
|
+
getPageIndex(ref: object): Promise<number>;
|
|
112
|
+
/** One named destination, or null. */
|
|
113
|
+
getDestination(id: string): Promise<unknown>;
|
|
114
|
+
/** The bookmarks: `{ title, dest, items }`, or null. */
|
|
115
|
+
getOutline(): Promise<unknown>;
|
|
92
116
|
}
|
|
93
117
|
|
|
94
118
|
/** A viewport point, which pdf.js types as `any[]`: the two numbers it holds. */
|
|
@@ -106,6 +130,22 @@ export interface PdfFacts {
|
|
|
106
130
|
readonly fonts: Fonts;
|
|
107
131
|
readonly last: PageText;
|
|
108
132
|
readonly layout: readonly PageLayout[];
|
|
133
|
+
/** Each page's text, in content order, a line break after each line (`pageTextOf`). */
|
|
134
|
+
readonly pageTexts: readonly string[];
|
|
135
|
+
/** The target of every link, with its 1-based page. */
|
|
136
|
+
readonly links: readonly PdfLink[];
|
|
137
|
+
/** The 1-based page of the first bibliography entry hyperref anchored, or null without one. */
|
|
138
|
+
readonly bibAnchorPage: number | null;
|
|
139
|
+
/** The 1-based page the appendix starts on, from hyperref's destinations and bookmarks, or null. */
|
|
140
|
+
readonly appendixAnchorPage: number | null;
|
|
141
|
+
/** The Info dictionary and XMP packet, flat (`metadataOf`). */
|
|
142
|
+
readonly metadata: Readonly<Record<string, string>>;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** A link annotation's target, and the page it is on. */
|
|
146
|
+
export interface PdfLink {
|
|
147
|
+
readonly page: number;
|
|
148
|
+
readonly uri: string;
|
|
109
149
|
}
|
|
110
150
|
|
|
111
151
|
export type PdfReadFailure =
|
|
@@ -198,10 +238,17 @@ async function readPage(
|
|
|
198
238
|
page: Page,
|
|
199
239
|
seen: ReadonlySet<string>,
|
|
200
240
|
lib: PdfJs,
|
|
201
|
-
): Promise<{
|
|
241
|
+
): Promise<{
|
|
242
|
+
raw: RawPage;
|
|
243
|
+
items: TextItem[];
|
|
244
|
+
layout: PageLayout;
|
|
245
|
+
text: string;
|
|
246
|
+
links: readonly string[];
|
|
247
|
+
}> {
|
|
202
248
|
// The operator list is what makes pdf.js load a page's fonts into `commonObjs`.
|
|
203
249
|
const ops = await page.getOperatorList();
|
|
204
|
-
const
|
|
250
|
+
const all = (await page.getTextContent()).items;
|
|
251
|
+
const items = all.filter(isText);
|
|
205
252
|
const loaded = loadedFonts(page);
|
|
206
253
|
const named = new Set(items.map((it) => it.fontName));
|
|
207
254
|
const fonts = [...loaded.values()].filter(
|
|
@@ -211,6 +258,8 @@ async function readPage(
|
|
|
211
258
|
raw: { hasText: items.length > 0, fonts },
|
|
212
259
|
items,
|
|
213
260
|
layout: layoutOf(page, items, ops, lib),
|
|
261
|
+
text: pageTextOf(all),
|
|
262
|
+
links: linkTargetsOf(await page.getAnnotations()),
|
|
214
263
|
};
|
|
215
264
|
}
|
|
216
265
|
|
|
@@ -310,12 +359,7 @@ function layoutOf(
|
|
|
310
359
|
* transform, no pages) — `readPdf` below is the one production caller.
|
|
311
360
|
*/
|
|
312
361
|
export async function factsOf(doc: Doc, lib: PdfJs): Promise<PdfRead> {
|
|
313
|
-
const read: {
|
|
314
|
-
page: Page;
|
|
315
|
-
raw: RawPage;
|
|
316
|
-
items: TextItem[];
|
|
317
|
-
layout: PageLayout;
|
|
318
|
-
}[] = [];
|
|
362
|
+
const read: (Awaited<ReturnType<typeof readPage>> & { page: Page })[] = [];
|
|
319
363
|
const seen = new Set<string>();
|
|
320
364
|
for (let i = 1; i <= doc.numPages; i++) {
|
|
321
365
|
const page = await doc.getPage(i);
|
|
@@ -340,10 +384,64 @@ export async function factsOf(doc: Doc, lib: PdfJs): Promise<PdfRead> {
|
|
|
340
384
|
fonts,
|
|
341
385
|
last: pageText(last.page, last.items),
|
|
342
386
|
layout: read.map((r) => r.layout),
|
|
387
|
+
...saidOf(read, await doc.getMetadata()),
|
|
388
|
+
...(await anchorsOf(doc)),
|
|
343
389
|
},
|
|
344
390
|
};
|
|
345
391
|
}
|
|
346
392
|
|
|
393
|
+
/**
|
|
394
|
+
* Where hyperref anchored the bibliography's first entry (the lowest page of a `cite.<key>`
|
|
395
|
+
* destination) and the appendix (the lowest page of an `appendix.<letter>` destination or of a
|
|
396
|
+
* bookmark titled «Appendix…»). A reference pdf.js cannot place is skipped.
|
|
397
|
+
*/
|
|
398
|
+
async function anchorsOf(
|
|
399
|
+
doc: Doc,
|
|
400
|
+
): Promise<Pick<PdfFacts, "bibAnchorPage" | "appendixAnchorPage">> {
|
|
401
|
+
const destinations = await doc.getDestinations();
|
|
402
|
+
const bookmarked = await Promise.all(
|
|
403
|
+
appendixOutlineDests(await doc.getOutline()).map(async (d) =>
|
|
404
|
+
refOf(typeof d === "string" ? await doc.getDestination(d) : d),
|
|
405
|
+
),
|
|
406
|
+
);
|
|
407
|
+
return {
|
|
408
|
+
bibAnchorPage: await lowestPage(doc, namedRefs(destinations, "cite.")),
|
|
409
|
+
appendixAnchorPage: await lowestPage(doc, [
|
|
410
|
+
...namedRefs(destinations, "appendix."),
|
|
411
|
+
...bookmarked.filter((r): r is object => r !== null),
|
|
412
|
+
]),
|
|
413
|
+
};
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
/** The lowest 1-based page any of `refs` names, or null when none can be placed. */
|
|
417
|
+
async function lowestPage(
|
|
418
|
+
doc: Doc,
|
|
419
|
+
refs: readonly object[],
|
|
420
|
+
): Promise<number | null> {
|
|
421
|
+
const pages = await Promise.all(
|
|
422
|
+
refs.map((r) =>
|
|
423
|
+
doc.getPageIndex(r).then(
|
|
424
|
+
(i) => [i + 1],
|
|
425
|
+
() => [],
|
|
426
|
+
),
|
|
427
|
+
),
|
|
428
|
+
);
|
|
429
|
+
const found = pages.flat();
|
|
430
|
+
return found.length === 0 ? null : Math.min(...found);
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
/** What the pages say, from each page's read, and the document's metadata. */
|
|
434
|
+
function saidOf(
|
|
435
|
+
read: readonly Pick<Awaited<ReturnType<typeof readPage>>, "text" | "links">[],
|
|
436
|
+
meta: Awaited<ReturnType<Doc["getMetadata"]>>,
|
|
437
|
+
): Pick<PdfFacts, "pageTexts" | "links" | "metadata"> {
|
|
438
|
+
return {
|
|
439
|
+
pageTexts: read.map((r) => r.text),
|
|
440
|
+
links: read.flatMap((r, i) => r.links.map((uri) => ({ page: i + 1, uri }))),
|
|
441
|
+
metadata: metadataOf(meta.info, xmpOf(meta.metadata)),
|
|
442
|
+
};
|
|
443
|
+
}
|
|
444
|
+
|
|
347
445
|
/** pdf.js's own exception names for the two conditions a caller can act on. */
|
|
348
446
|
export function failureOf(e: unknown): PdfRead {
|
|
349
447
|
const name = fieldOf(e, "name");
|
package/src/ports/latex.ts
CHANGED
|
@@ -6,6 +6,7 @@ import type { Include } from "../domain/paper-source.ts";
|
|
|
6
6
|
import type {
|
|
7
7
|
ClassLine,
|
|
8
8
|
DocumentClass,
|
|
9
|
+
LayoutOverride,
|
|
9
10
|
Outline,
|
|
10
11
|
Passage,
|
|
11
12
|
Span,
|
|
@@ -39,4 +40,6 @@ export interface LatexReader {
|
|
|
39
40
|
* option `want` names (the author's own options stay), or it has none.
|
|
40
41
|
*/
|
|
41
42
|
readonly withDocumentClass: (src: string, want: DocumentClass) => string;
|
|
43
|
+
/** Every command that changes the page layout the class sets: margins, text block, line spacing. */
|
|
44
|
+
readonly layoutOverrides: (src: string) => readonly LayoutOverride[];
|
|
42
45
|
}
|
package/src/presets.ts
CHANGED
|
@@ -83,6 +83,8 @@ export interface Preset {
|
|
|
83
83
|
readonly template: PresetTemplate | null;
|
|
84
84
|
/** Every `name` and `aliases` along the chain: what this venue is called in a paper's text. */
|
|
85
85
|
readonly aliases: readonly string[];
|
|
86
|
+
/** Whether the venue reviews double-blind (`anonymity/identity`); false when no file of the chain says. */
|
|
87
|
+
readonly blind: boolean;
|
|
86
88
|
readonly requiredSections: readonly RequiredSection[];
|
|
87
89
|
readonly tex: TexRequirements;
|
|
88
90
|
readonly format: VenueFormat;
|
|
@@ -196,6 +198,7 @@ export function mergeFormat(
|
|
|
196
198
|
refPtMax: pick("refPtMax"),
|
|
197
199
|
fontsText: pick("fontsText"),
|
|
198
200
|
fontsTitle: pick("fontsTitle"),
|
|
201
|
+
bodyEndsAt: pick("bodyEndsAt"),
|
|
199
202
|
kinds: new Map([...parent.kinds, ...child.kinds]),
|
|
200
203
|
};
|
|
201
204
|
}
|
|
@@ -205,18 +208,13 @@ function merged(
|
|
|
205
208
|
spec: string,
|
|
206
209
|
rootFirst: readonly { readonly preset: PresetFile; readonly file: string }[],
|
|
207
210
|
): Preset {
|
|
208
|
-
const base: {
|
|
209
|
-
name: string | null;
|
|
210
|
-
template: PresetTemplate | null;
|
|
211
|
-
aliases: readonly string[];
|
|
212
|
-
requiredSections: readonly RequiredSection[];
|
|
213
|
-
tex: TexRequirements;
|
|
214
|
-
format: VenueFormat;
|
|
215
|
-
rules: Readonly<Record<string, unknown>>;
|
|
211
|
+
const base: Omit<Preset, "label" | "chain"> & {
|
|
212
|
+
readonly name: string | null;
|
|
216
213
|
} = {
|
|
217
214
|
name: null,
|
|
218
215
|
template: null,
|
|
219
216
|
aliases: [],
|
|
217
|
+
blind: false,
|
|
220
218
|
requiredSections: [],
|
|
221
219
|
tex: NO_REQUIREMENTS,
|
|
222
220
|
format: NO_FORMAT,
|
|
@@ -233,6 +231,7 @@ function merged(
|
|
|
233
231
|
...p.aliases,
|
|
234
232
|
]),
|
|
235
233
|
],
|
|
234
|
+
blind: p.blind ?? acc.blind,
|
|
236
235
|
requiredSections: p.requiredSections ?? acc.requiredSections,
|
|
237
236
|
tex: p.tex ? mergeRequirements(acc.tex, p.tex) : acc.tex,
|
|
238
237
|
format: mergeFormat(acc.format, p.format),
|
|
@@ -245,6 +244,7 @@ function merged(
|
|
|
245
244
|
chain: rootFirst.map((x) => x.file),
|
|
246
245
|
template: m.template,
|
|
247
246
|
aliases: m.aliases,
|
|
247
|
+
blind: m.blind,
|
|
248
248
|
requiredSections: m.requiredSections,
|
|
249
249
|
tex: m.tex,
|
|
250
250
|
format: m.format,
|
package/src/rule-cases.test.ts
CHANGED
|
@@ -43,11 +43,22 @@ const paper = (
|
|
|
43
43
|
|
|
44
44
|
const PDF = "%PDF-1.5 a stand-in for the built PDF";
|
|
45
45
|
const AGENTICDEV = { extends: "paperlint:agenticdev", kind: "short" };
|
|
46
|
+
/** A double-blind venue's paper that declares who wrote it (`anonymity/*`). */
|
|
47
|
+
const BLIND = {
|
|
48
|
+
extends: "paperlint:aidc",
|
|
49
|
+
kind: "regular",
|
|
50
|
+
identity: ["Ada Example", "adaexample"],
|
|
51
|
+
};
|
|
46
52
|
const ACM_TEX = tex("Text.", "\\documentclass[sigconf]{acmart}");
|
|
47
53
|
|
|
48
54
|
/** The facts of a short agenticdev paper that meets every number in its preset. */
|
|
49
55
|
const goodFacts = (): Record<string, unknown> => ({
|
|
50
|
-
schema:
|
|
56
|
+
schema: 3,
|
|
57
|
+
pages_text: [],
|
|
58
|
+
metadata: {},
|
|
59
|
+
links: [],
|
|
60
|
+
bib_anchor_page: null,
|
|
61
|
+
appendix_anchor_page: null,
|
|
51
62
|
pdf: "paper.pdf",
|
|
52
63
|
pdf_sha256: sha256Hex(new TextEncoder().encode(PDF)),
|
|
53
64
|
venue: "agenticdev",
|
|
@@ -381,16 +392,31 @@ const CASES: Readonly<Record<string, RuleCases>> = {
|
|
|
381
392
|
reports: onPdf(built({ page_w_in: 8.264 })),
|
|
382
393
|
silent: built(),
|
|
383
394
|
},
|
|
384
|
-
"
|
|
395
|
+
"format/page-limit": {
|
|
385
396
|
reports: onPdf(
|
|
386
397
|
built({ body_pages: 9, pages_by_type: { body: 9, bib: 2 } }),
|
|
387
398
|
),
|
|
388
399
|
silent: built(),
|
|
389
400
|
},
|
|
390
401
|
"pdf/body-size": {
|
|
391
|
-
reports:
|
|
402
|
+
reports: onPdf(built({ body_pt: 12 })),
|
|
392
403
|
silent: built(),
|
|
393
404
|
},
|
|
405
|
+
"anonymity/identity": {
|
|
406
|
+
reports: onPdf(
|
|
407
|
+
built({ pages_text: ["Ada Example\n", "References\n"] }, BLIND),
|
|
408
|
+
),
|
|
409
|
+
silent: built({ pages_text: ["Anonymous\n", "References\n"] }, BLIND),
|
|
410
|
+
},
|
|
411
|
+
"format/layout-override": {
|
|
412
|
+
reports: {
|
|
413
|
+
tree: aidc("\\linespread{0.9}Text."),
|
|
414
|
+
file: TEX_FILE,
|
|
415
|
+
severity: 2,
|
|
416
|
+
line: 4,
|
|
417
|
+
},
|
|
418
|
+
silent: aidc("\\vspace{-2mm}Text."),
|
|
419
|
+
},
|
|
394
420
|
"pdf/last-page-balance": {
|
|
395
421
|
reports: onPdf(
|
|
396
422
|
built(
|
package/src/rule-docs.test.ts
CHANGED
|
@@ -29,12 +29,10 @@ const AWAITING_PAGE: ReadonlySet<string> = new Set([
|
|
|
29
29
|
"paper/section-word",
|
|
30
30
|
"paper/source",
|
|
31
31
|
"paper/stages",
|
|
32
|
-
"pdf/body-size",
|
|
33
32
|
"pdf/fonts",
|
|
34
33
|
"pdf/fresh",
|
|
35
34
|
"pdf/geometry",
|
|
36
35
|
"pdf/last-page-balance",
|
|
37
|
-
"pdf/limits",
|
|
38
36
|
"pdf/measured",
|
|
39
37
|
"pdf/profile",
|
|
40
38
|
"review/frontmatter",
|
package/src/tex-requirements.ts
CHANGED
|
@@ -131,6 +131,8 @@ export interface VenueFormat {
|
|
|
131
131
|
/** The prefix a font name of the body text starts with (`LinLibertine`). */
|
|
132
132
|
readonly fontsText: string | null;
|
|
133
133
|
readonly fontsTitle: string | null;
|
|
134
|
+
/** How the body is counted: null is banal's count; `references`, the pages before the references. */
|
|
135
|
+
readonly bodyEndsAt: "references" | null;
|
|
134
136
|
readonly kinds: ReadonlyMap<string, KindLimits>;
|
|
135
137
|
}
|
|
136
138
|
|
|
@@ -145,6 +147,7 @@ export const NO_FORMAT: VenueFormat = {
|
|
|
145
147
|
refPtMax: null,
|
|
146
148
|
fontsText: null,
|
|
147
149
|
fontsTitle: null,
|
|
150
|
+
bodyEndsAt: null,
|
|
148
151
|
kinds: new Map(),
|
|
149
152
|
};
|
|
150
153
|
|
|
@@ -168,6 +171,8 @@ export interface PresetFile {
|
|
|
168
171
|
readonly template: string | null;
|
|
169
172
|
/** Other names the venue goes by in a paper's text; empty when the file names none. */
|
|
170
173
|
readonly aliases: readonly string[];
|
|
174
|
+
/** Whether the venue reviews double-blind; null when the file does not say. */
|
|
175
|
+
readonly blind: boolean | null;
|
|
171
176
|
/** Sections the venue requires; null when the file names none (a child's list replaces its parent's). */
|
|
172
177
|
readonly requiredSections: readonly RequiredSection[] | null;
|
|
173
178
|
/** Null when the file declares no `tex` block (allowed only with `extends`). */
|
|
@@ -192,6 +197,7 @@ interface FormatJson {
|
|
|
192
197
|
readonly ref_pt_max?: number;
|
|
193
198
|
readonly fonts_text?: string;
|
|
194
199
|
readonly fonts_title?: string;
|
|
200
|
+
readonly body_ends_at?: "references";
|
|
195
201
|
readonly kinds?: KindsJson;
|
|
196
202
|
}
|
|
197
203
|
|
|
@@ -201,6 +207,7 @@ interface PresetJson {
|
|
|
201
207
|
readonly name?: string;
|
|
202
208
|
readonly template?: string;
|
|
203
209
|
readonly aliases?: readonly string[];
|
|
210
|
+
readonly blind?: boolean;
|
|
204
211
|
readonly required_sections?: readonly {
|
|
205
212
|
readonly title: string;
|
|
206
213
|
readonly position?: "last";
|
|
@@ -238,6 +245,7 @@ function formatOf(j: FormatJson = {}): VenueFormat {
|
|
|
238
245
|
refPtMax: orNull(j.ref_pt_max),
|
|
239
246
|
fontsText: orNull(j.fonts_text),
|
|
240
247
|
fontsTitle: orNull(j.fonts_title),
|
|
248
|
+
bodyEndsAt: orNull(j.body_ends_at),
|
|
241
249
|
kinds: kindsOf(j.kinds),
|
|
242
250
|
};
|
|
243
251
|
}
|
|
@@ -281,6 +289,7 @@ export function parsePreset(
|
|
|
281
289
|
name: orNull(j.name),
|
|
282
290
|
template: orNull(j.template),
|
|
283
291
|
aliases: j.aliases ?? [],
|
|
292
|
+
blind: orNull(j.blind),
|
|
284
293
|
requiredSections: sectionsOf(j.required_sections),
|
|
285
294
|
tex: j.tex ? { packages: j.tex.packages, tools: j.tex.tools ?? {} } : null,
|
|
286
295
|
format: formatOf(j.format),
|