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.
Files changed (147) hide show
  1. package/CLAUDE.md +13 -0
  2. package/CONTRIBUTING.md +3 -0
  3. package/README.md +8 -8
  4. package/bin/paperlint.mjs +5 -1
  5. package/dist/adapters/latex/index.d.ts +1 -0
  6. package/dist/adapters/latex/index.d.ts.map +1 -1
  7. package/dist/adapters/latex/index.js +3 -0
  8. package/dist/adapters/latex/index.js.map +1 -1
  9. package/dist/adapters/latex/layout.d.ts +27 -0
  10. package/dist/adapters/latex/layout.d.ts.map +1 -0
  11. package/dist/adapters/latex/layout.js +62 -0
  12. package/dist/adapters/latex/layout.js.map +1 -0
  13. package/dist/adapters/latex/nodes.d.ts.map +1 -1
  14. package/dist/adapters/latex/nodes.js +11 -1
  15. package/dist/adapters/latex/nodes.js.map +1 -1
  16. package/dist/adapters/latex/parse.d.ts.map +1 -1
  17. package/dist/adapters/latex/parse.js +5 -1
  18. package/dist/adapters/latex/parse.js.map +1 -1
  19. package/dist/cli.d.ts.map +1 -1
  20. package/dist/cli.js +25 -5
  21. package/dist/cli.js.map +1 -1
  22. package/dist/domain/anonymity.d.ts +68 -0
  23. package/dist/domain/anonymity.d.ts.map +1 -0
  24. package/dist/domain/anonymity.js +94 -0
  25. package/dist/domain/anonymity.js.map +1 -0
  26. package/dist/domain/body-pages.d.ts +69 -0
  27. package/dist/domain/body-pages.d.ts.map +1 -0
  28. package/dist/domain/body-pages.js +141 -0
  29. package/dist/domain/body-pages.js.map +1 -0
  30. package/dist/domain/tex-document.d.ts +9 -0
  31. package/dist/domain/tex-document.d.ts.map +1 -1
  32. package/dist/eslint-rules/pdf-last-page-balance.d.ts +1 -1
  33. package/dist/eslint-rules/pdf-last-page-balance.js +2 -2
  34. package/dist/facts-file.d.ts +28 -2
  35. package/dist/facts-file.d.ts.map +1 -1
  36. package/dist/facts-file.js +54 -9
  37. package/dist/facts-file.js.map +1 -1
  38. package/dist/lib/paper-config.d.ts.map +1 -1
  39. package/dist/lib/paper-config.js +2 -1
  40. package/dist/lib/paper-config.js.map +1 -1
  41. package/dist/paper-settings.d.ts +11 -0
  42. package/dist/paper-settings.d.ts.map +1 -1
  43. package/dist/paper-settings.js +38 -4
  44. package/dist/paper-settings.js.map +1 -1
  45. package/dist/pdf-content.d.ts +40 -0
  46. package/dist/pdf-content.d.ts.map +1 -0
  47. package/dist/pdf-content.js +97 -0
  48. package/dist/pdf-content.js.map +1 -0
  49. package/dist/pdf-facts.d.ts +30 -0
  50. package/dist/pdf-facts.d.ts.map +1 -1
  51. package/dist/pdf-facts.js +37 -1
  52. package/dist/pdf-facts.js.map +1 -1
  53. package/dist/ports/latex.d.ts +3 -1
  54. package/dist/ports/latex.d.ts.map +1 -1
  55. package/dist/presets.d.ts +2 -0
  56. package/dist/presets.d.ts.map +1 -1
  57. package/dist/presets.js +4 -0
  58. package/dist/presets.js.map +1 -1
  59. package/dist/tex-requirements.d.ts +4 -0
  60. package/dist/tex-requirements.d.ts.map +1 -1
  61. package/dist/tex-requirements.js +3 -0
  62. package/dist/tex-requirements.js.map +1 -1
  63. package/dist/tex-venue-rules.d.ts +7 -1
  64. package/dist/tex-venue-rules.d.ts.map +1 -1
  65. package/dist/tex-venue-rules.js +40 -0
  66. package/dist/tex-venue-rules.js.map +1 -1
  67. package/dist/types.d.ts +2 -0
  68. package/dist/types.d.ts.map +1 -1
  69. package/dist/venue-rules.d.ts +38 -8
  70. package/dist/venue-rules.d.ts.map +1 -1
  71. package/dist/venue-rules.js +179 -41
  72. package/dist/venue-rules.js.map +1 -1
  73. package/docs/configuration.md +18 -16
  74. package/docs/e2e.md +32 -11
  75. package/docs/rules/anonymity/identity.md +111 -0
  76. package/docs/rules/format/layout-override.md +61 -0
  77. package/docs/rules/format/page-limit.md +108 -0
  78. package/docs/rules/pdf/body-size.md +87 -0
  79. package/docs/rules.md +44 -39
  80. package/eslint-rules/pdf-last-page-balance.harness.mjs +6 -1
  81. package/eslint-rules/pdf-last-page-balance.ts +2 -2
  82. package/fixtures/build-e2e/aidc/expect.json +1 -1
  83. package/fixtures/build-e2e/aidc/paperlint.json +1 -1
  84. package/fixtures/build-e2e/anon-ack/PIPELINE-STATUS.md +3 -0
  85. package/fixtures/build-e2e/anon-ack/expect.json +19 -0
  86. package/fixtures/build-e2e/anon-ack/paper.tex +31 -0
  87. package/fixtures/build-e2e/anon-ack/paperlint.json +1 -0
  88. package/fixtures/build-e2e/anon-author/PIPELINE-STATUS.md +3 -0
  89. package/fixtures/build-e2e/anon-author/expect.json +19 -0
  90. package/fixtures/build-e2e/anon-author/paper.tex +29 -0
  91. package/fixtures/build-e2e/anon-author/paperlint.json +1 -0
  92. package/fixtures/build-e2e/anon-bib/PIPELINE-STATUS.md +3 -0
  93. package/fixtures/build-e2e/anon-bib/expect.json +20 -0
  94. package/fixtures/build-e2e/anon-bib/paper.tex +35 -0
  95. package/fixtures/build-e2e/anon-bib/paperlint.json +1 -0
  96. package/fixtures/build-e2e/anon-href/PIPELINE-STATUS.md +3 -0
  97. package/fixtures/build-e2e/anon-href/expect.json +19 -0
  98. package/fixtures/build-e2e/anon-href/paper.tex +30 -0
  99. package/fixtures/build-e2e/anon-href/paperlint.json +1 -0
  100. package/fixtures/build-e2e/anon-macro/PIPELINE-STATUS.md +3 -0
  101. package/fixtures/build-e2e/anon-macro/expect.json +19 -0
  102. package/fixtures/build-e2e/anon-macro/paper.tex +30 -0
  103. package/fixtures/build-e2e/anon-macro/paperlint.json +1 -0
  104. package/fixtures/build-e2e/anon-pdfauthor/PIPELINE-STATUS.md +3 -0
  105. package/fixtures/build-e2e/anon-pdfauthor/expect.json +19 -0
  106. package/fixtures/build-e2e/anon-pdfauthor/paper.tex +31 -0
  107. package/fixtures/build-e2e/anon-pdfauthor/paperlint.json +1 -0
  108. package/fixtures/build-e2e/unbalanced/expect.json +2 -2
  109. package/lib/paper-config.ts +3 -1
  110. package/package.json +1 -1
  111. package/presets/acm-sigconf.jsonc +3 -1
  112. package/presets/aidc.jsonc +16 -17
  113. package/presets/ieee-conference.jsonc +8 -0
  114. package/presets/venue-profile.schema.json +9 -0
  115. package/skills/render-paper/extract-pdf-facts.harness.ts +3 -3
  116. package/src/adapters/latex/index.ts +3 -0
  117. package/src/adapters/latex/layout.ts +106 -0
  118. package/src/adapters/latex/nodes.ts +11 -3
  119. package/src/adapters/latex/parse.ts +5 -1
  120. package/src/build.harness.mjs +2 -2
  121. package/src/build.test.ts +5 -0
  122. package/src/cli.test.ts +13 -0
  123. package/src/cli.ts +36 -4
  124. package/src/domain/anonymity.test.ts +163 -0
  125. package/src/domain/anonymity.ts +129 -0
  126. package/src/domain/body-pages.test.ts +300 -0
  127. package/src/domain/body-pages.ts +223 -0
  128. package/src/domain/tex-document.ts +10 -0
  129. package/src/facts-file.test.ts +100 -4
  130. package/src/facts-file.ts +91 -9
  131. package/src/new-paper.test.ts +7 -1
  132. package/src/paper-settings.test.ts +63 -6
  133. package/src/paper-settings.ts +53 -5
  134. package/src/pdf-content.test.ts +71 -0
  135. package/src/pdf-content.ts +125 -0
  136. package/src/pdf-facts.test.ts +110 -2
  137. package/src/pdf-facts.ts +106 -8
  138. package/src/ports/latex.ts +3 -0
  139. package/src/presets.ts +8 -8
  140. package/src/rule-cases.test.ts +29 -3
  141. package/src/rule-docs.test.ts +0 -2
  142. package/src/tex-requirements.ts +9 -0
  143. package/src/tex-venue-rules.test.ts +110 -5
  144. package/src/tex-venue-rules.ts +57 -2
  145. package/src/types.ts +2 -0
  146. package/src/venue-rules.test.ts +351 -39
  147. 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
+ }
@@ -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(items: FakeItem[], fonts: Record<string, unknown>) {
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 = (pages: ReturnType<typeof page>[]) => ({
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<{ raw: RawPage; items: TextItem[]; layout: PageLayout }> {
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 items = (await page.getTextContent()).items.filter(isText);
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");
@@ -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,
@@ -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: 2,
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
- "pdf/limits": {
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: { ...onPdf(built({ body_pt: 12 })), severity: 1 },
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(
@@ -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",
@@ -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),