paperlint 4.2.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 (251) hide show
  1. package/CLAUDE.md +15 -2
  2. package/CONTRIBUTING.md +3 -0
  3. package/README.md +23 -12
  4. package/action.harness.mjs +54 -36
  5. package/action.yml +12 -27
  6. package/bin/paperlint.mjs +5 -1
  7. package/dist/adapters/latex/includes.d.ts +5 -1
  8. package/dist/adapters/latex/includes.d.ts.map +1 -1
  9. package/dist/adapters/latex/includes.js +8 -2
  10. package/dist/adapters/latex/includes.js.map +1 -1
  11. package/dist/adapters/latex/index.d.ts +1 -0
  12. package/dist/adapters/latex/index.d.ts.map +1 -1
  13. package/dist/adapters/latex/index.js +3 -0
  14. package/dist/adapters/latex/index.js.map +1 -1
  15. package/dist/adapters/latex/layout.d.ts +27 -0
  16. package/dist/adapters/latex/layout.d.ts.map +1 -0
  17. package/dist/adapters/latex/layout.js +62 -0
  18. package/dist/adapters/latex/layout.js.map +1 -0
  19. package/dist/adapters/latex/nodes.d.ts.map +1 -1
  20. package/dist/adapters/latex/nodes.js +11 -1
  21. package/dist/adapters/latex/nodes.js.map +1 -1
  22. package/dist/adapters/latex/parse.d.ts.map +1 -1
  23. package/dist/adapters/latex/parse.js +5 -1
  24. package/dist/adapters/latex/parse.js.map +1 -1
  25. package/dist/build.d.ts.map +1 -1
  26. package/dist/build.js +7 -3
  27. package/dist/build.js.map +1 -1
  28. package/dist/cli.d.ts +2 -8
  29. package/dist/cli.d.ts.map +1 -1
  30. package/dist/cli.js +92 -42
  31. package/dist/cli.js.map +1 -1
  32. package/dist/domain/anonymity.d.ts +68 -0
  33. package/dist/domain/anonymity.d.ts.map +1 -0
  34. package/dist/domain/anonymity.js +94 -0
  35. package/dist/domain/anonymity.js.map +1 -0
  36. package/dist/domain/body-pages.d.ts +69 -0
  37. package/dist/domain/body-pages.d.ts.map +1 -0
  38. package/dist/domain/body-pages.js +141 -0
  39. package/dist/domain/body-pages.js.map +1 -0
  40. package/dist/domain/paper-source.js +2 -2
  41. package/dist/domain/tex-document.d.ts +9 -0
  42. package/dist/domain/tex-document.d.ts.map +1 -1
  43. package/dist/eslint-rules/latex-language.d.ts +5 -1
  44. package/dist/eslint-rules/latex-language.d.ts.map +1 -1
  45. package/dist/eslint-rules/latex-language.js +6 -3
  46. package/dist/eslint-rules/latex-language.js.map +1 -1
  47. package/dist/eslint-rules/paper-context.d.ts +20 -0
  48. package/dist/eslint-rules/paper-context.d.ts.map +1 -0
  49. package/dist/eslint-rules/paper-context.js +44 -0
  50. package/dist/eslint-rules/paper-context.js.map +1 -0
  51. package/dist/eslint-rules/paper-typography.d.ts +1 -1
  52. package/dist/eslint-rules/paper-typography.d.ts.map +1 -1
  53. package/dist/eslint-rules/paper-typography.js +32 -18
  54. package/dist/eslint-rules/paper-typography.js.map +1 -1
  55. package/dist/eslint-rules/pdf-last-page-balance.d.ts +1 -1
  56. package/dist/eslint-rules/pdf-last-page-balance.js +2 -2
  57. package/dist/eslint-rules/tex-build.d.ts.map +1 -1
  58. package/dist/eslint-rules/tex-build.js +7 -1
  59. package/dist/eslint-rules/tex-build.js.map +1 -1
  60. package/dist/facts-file.d.ts +28 -2
  61. package/dist/facts-file.d.ts.map +1 -1
  62. package/dist/facts-file.js +54 -9
  63. package/dist/facts-file.js.map +1 -1
  64. package/dist/init.d.ts +4 -19
  65. package/dist/init.d.ts.map +1 -1
  66. package/dist/init.js +24 -29
  67. package/dist/init.js.map +1 -1
  68. package/dist/lib/paper-config.d.ts.map +1 -1
  69. package/dist/lib/paper-config.js +2 -1
  70. package/dist/lib/paper-config.js.map +1 -1
  71. package/dist/package-dirs.d.ts +2 -0
  72. package/dist/package-dirs.d.ts.map +1 -1
  73. package/dist/package-dirs.js +8 -0
  74. package/dist/package-dirs.js.map +1 -1
  75. package/dist/paper-files.d.ts +2 -2
  76. package/dist/paper-includes.d.ts +17 -0
  77. package/dist/paper-includes.d.ts.map +1 -0
  78. package/dist/paper-includes.js +51 -0
  79. package/dist/paper-includes.js.map +1 -0
  80. package/dist/paper-settings.d.ts +11 -0
  81. package/dist/paper-settings.d.ts.map +1 -1
  82. package/dist/paper-settings.js +38 -4
  83. package/dist/paper-settings.js.map +1 -1
  84. package/dist/pdf-content.d.ts +40 -0
  85. package/dist/pdf-content.d.ts.map +1 -0
  86. package/dist/pdf-content.js +97 -0
  87. package/dist/pdf-content.js.map +1 -0
  88. package/dist/pdf-facts.d.ts +30 -0
  89. package/dist/pdf-facts.d.ts.map +1 -1
  90. package/dist/pdf-facts.js +37 -1
  91. package/dist/pdf-facts.js.map +1 -1
  92. package/dist/ports/latex.d.ts +3 -1
  93. package/dist/ports/latex.d.ts.map +1 -1
  94. package/dist/presets.d.ts +2 -0
  95. package/dist/presets.d.ts.map +1 -1
  96. package/dist/presets.js +4 -0
  97. package/dist/presets.js.map +1 -1
  98. package/dist/rules-config.d.ts +1 -0
  99. package/dist/rules-config.d.ts.map +1 -1
  100. package/dist/rules-config.js.map +1 -1
  101. package/dist/structure.js +1 -1
  102. package/dist/structure.js.map +1 -1
  103. package/dist/tex-paper.d.ts +17 -3
  104. package/dist/tex-paper.d.ts.map +1 -1
  105. package/dist/tex-paper.js +36 -38
  106. package/dist/tex-paper.js.map +1 -1
  107. package/dist/tex-requirements.d.ts +4 -0
  108. package/dist/tex-requirements.d.ts.map +1 -1
  109. package/dist/tex-requirements.js +3 -0
  110. package/dist/tex-requirements.js.map +1 -1
  111. package/dist/tex-venue-rules.d.ts +7 -1
  112. package/dist/tex-venue-rules.d.ts.map +1 -1
  113. package/dist/tex-venue-rules.js +40 -0
  114. package/dist/tex-venue-rules.js.map +1 -1
  115. package/dist/types.d.ts +2 -0
  116. package/dist/types.d.ts.map +1 -1
  117. package/dist/venue-rules.d.ts +38 -8
  118. package/dist/venue-rules.d.ts.map +1 -1
  119. package/dist/venue-rules.js +179 -41
  120. package/dist/venue-rules.js.map +1 -1
  121. package/docs/configuration.md +23 -20
  122. package/docs/e2e.md +32 -11
  123. package/docs/install.md +8 -8
  124. package/docs/prior-art/paper-folder-scaffolding.md +4 -7
  125. package/docs/rules/anonymity/identity.md +111 -0
  126. package/docs/rules/format/layout-override.md +61 -0
  127. package/docs/rules/format/page-limit.md +108 -0
  128. package/docs/rules/pdf/body-size.md +87 -0
  129. package/docs/rules.md +55 -46
  130. package/eslint-rules/latex-language.ts +10 -4
  131. package/eslint-rules/paper-context.test.ts +47 -0
  132. package/eslint-rules/paper-context.ts +57 -0
  133. package/eslint-rules/paper-typography.ts +67 -23
  134. package/eslint-rules/pdf-last-page-balance.harness.mjs +6 -1
  135. package/eslint-rules/pdf-last-page-balance.ts +2 -2
  136. package/eslint-rules/tex-build.ts +8 -1
  137. package/fixtures/build-e2e/aidc/expect.json +1 -1
  138. package/fixtures/build-e2e/aidc/paperlint.json +1 -1
  139. package/fixtures/build-e2e/anon-ack/PIPELINE-STATUS.md +3 -0
  140. package/fixtures/build-e2e/anon-ack/expect.json +19 -0
  141. package/fixtures/build-e2e/anon-ack/paper.tex +31 -0
  142. package/fixtures/build-e2e/anon-ack/paperlint.json +1 -0
  143. package/fixtures/build-e2e/anon-author/PIPELINE-STATUS.md +3 -0
  144. package/fixtures/build-e2e/anon-author/expect.json +19 -0
  145. package/fixtures/build-e2e/anon-author/paper.tex +29 -0
  146. package/fixtures/build-e2e/anon-author/paperlint.json +1 -0
  147. package/fixtures/build-e2e/anon-bib/PIPELINE-STATUS.md +3 -0
  148. package/fixtures/build-e2e/anon-bib/expect.json +20 -0
  149. package/fixtures/build-e2e/anon-bib/paper.tex +35 -0
  150. package/fixtures/build-e2e/anon-bib/paperlint.json +1 -0
  151. package/fixtures/build-e2e/anon-href/PIPELINE-STATUS.md +3 -0
  152. package/fixtures/build-e2e/anon-href/expect.json +19 -0
  153. package/fixtures/build-e2e/anon-href/paper.tex +30 -0
  154. package/fixtures/build-e2e/anon-href/paperlint.json +1 -0
  155. package/fixtures/build-e2e/anon-macro/PIPELINE-STATUS.md +3 -0
  156. package/fixtures/build-e2e/anon-macro/expect.json +19 -0
  157. package/fixtures/build-e2e/anon-macro/paper.tex +30 -0
  158. package/fixtures/build-e2e/anon-macro/paperlint.json +1 -0
  159. package/fixtures/build-e2e/anon-pdfauthor/PIPELINE-STATUS.md +3 -0
  160. package/fixtures/build-e2e/anon-pdfauthor/expect.json +19 -0
  161. package/fixtures/build-e2e/anon-pdfauthor/paper.tex +31 -0
  162. package/fixtures/build-e2e/anon-pdfauthor/paperlint.json +1 -0
  163. package/fixtures/build-e2e/unbalanced/expect.json +2 -2
  164. package/fixtures/real-markdown-paper/baseline.json +1 -2
  165. package/lib/paper-config.ts +3 -1
  166. package/package.json +1 -1
  167. package/presets/acm-sigconf.jsonc +3 -1
  168. package/presets/aidc.jsonc +16 -17
  169. package/presets/ieee-conference.jsonc +8 -0
  170. package/presets/venue-profile.schema.json +9 -0
  171. package/scripts/check.ts +2 -2
  172. package/scripts/eslint-report-guard.mjs +5 -2
  173. package/scripts/harness-api.frozen.json +0 -1
  174. package/skills/paper-pipeline/references/pipeline-status-template.md +2 -16
  175. package/skills/render-paper/extract-pdf-facts.harness.ts +3 -3
  176. package/src/adapters/latex/includes.ts +10 -2
  177. package/src/adapters/latex/index.test.ts +8 -0
  178. package/src/adapters/latex/index.ts +3 -0
  179. package/src/adapters/latex/layout.ts +106 -0
  180. package/src/adapters/latex/nodes.ts +11 -3
  181. package/src/adapters/latex/parse.ts +5 -1
  182. package/src/build.harness.mjs +6 -4
  183. package/src/build.test.ts +23 -1
  184. package/src/build.ts +7 -3
  185. package/src/cli.harness.mjs +22 -54
  186. package/src/cli.test.ts +15 -18
  187. package/src/cli.ts +114 -46
  188. package/src/domain/anonymity.test.ts +163 -0
  189. package/src/domain/anonymity.ts +129 -0
  190. package/src/domain/body-pages.test.ts +300 -0
  191. package/src/domain/body-pages.ts +223 -0
  192. package/src/domain/paper-source.ts +2 -2
  193. package/src/domain/tex-document.ts +10 -0
  194. package/src/facts-file.test.ts +100 -4
  195. package/src/facts-file.ts +91 -9
  196. package/src/init.test.ts +33 -13
  197. package/src/init.ts +25 -48
  198. package/src/linted-set.test.ts +11 -1
  199. package/src/new-paper.harness.mjs +1 -5
  200. package/src/new-paper.test.ts +7 -1
  201. package/src/package-dirs.ts +9 -0
  202. package/src/paper-files.ts +2 -2
  203. package/src/paper-includes.test.ts +161 -0
  204. package/src/paper-includes.ts +77 -0
  205. package/src/paper-settings.test.ts +63 -6
  206. package/src/paper-settings.ts +53 -5
  207. package/src/pdf-content.test.ts +71 -0
  208. package/src/pdf-content.ts +125 -0
  209. package/src/pdf-facts.test.ts +110 -2
  210. package/src/pdf-facts.ts +106 -8
  211. package/src/ports/latex.ts +3 -0
  212. package/src/presets.ts +8 -8
  213. package/src/rule-cases.test.ts +29 -33
  214. package/src/rule-docs.test.ts +0 -3
  215. package/src/rules-config.ts +1 -0
  216. package/src/structure.harness.ts +1 -3
  217. package/src/structure.ts +1 -1
  218. package/src/tex-paper.test.ts +78 -61
  219. package/src/tex-paper.ts +57 -48
  220. package/src/tex-requirements.ts +9 -0
  221. package/src/tex-venue-rules.test.ts +111 -6
  222. package/src/tex-venue-rules.ts +57 -2
  223. package/src/types.ts +2 -0
  224. package/src/venue-rules.test.ts +351 -39
  225. package/src/venue-rules.ts +247 -47
  226. package/templates/paper/PIPELINE-STATUS.md +0 -4
  227. package/dist/action-ref.d.ts +0 -12
  228. package/dist/action-ref.d.ts.map +0 -1
  229. package/dist/action-ref.js +0 -16
  230. package/dist/action-ref.js.map +0 -1
  231. package/dist/eslint-rules/paper-research-question.d.ts +0 -31
  232. package/dist/eslint-rules/paper-research-question.d.ts.map +0 -1
  233. package/dist/eslint-rules/paper-research-question.js +0 -188
  234. package/dist/eslint-rules/paper-research-question.js.map +0 -1
  235. package/docs/rules/tex/missing-input.md +0 -58
  236. package/eslint-rules/paper-research-question.harness.mjs +0 -212
  237. package/eslint-rules/paper-research-question.ts +0 -199
  238. package/fixtures/paper-research-question/comment-only/PIPELINE-STATUS.md +0 -9
  239. package/fixtures/paper-research-question/comment-only/paper.tex +0 -7
  240. package/fixtures/paper-research-question/declared-not-in-paper/PIPELINE-STATUS.md +0 -10
  241. package/fixtures/paper-research-question/declared-not-in-paper/paper.tex +0 -6
  242. package/fixtures/paper-research-question/draft/PIPELINE-STATUS.md +0 -6
  243. package/fixtures/paper-research-question/draft/paper.tex +0 -2
  244. package/fixtures/paper-research-question/markdown-no-rq/PIPELINE-STATUS.md +0 -9
  245. package/fixtures/paper-research-question/markdown-no-rq/paper.md +0 -4
  246. package/fixtures/paper-research-question/shipped-no-rq/PIPELINE-STATUS.md +0 -12
  247. package/fixtures/paper-research-question/shipped-no-rq/paper.tex +0 -3
  248. package/fixtures/paper-research-question/shipped-with-rq/PIPELINE-STATUS.md +0 -10
  249. package/fixtures/paper-research-question/shipped-with-rq/paper.tex +0 -2
  250. package/src/action-ref.test.ts +0 -26
  251. package/src/action-ref.ts +0 -15
@@ -0,0 +1,300 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import {
3
+ bodyEnd,
4
+ isAppendixHeading,
5
+ isReferencesHeading,
6
+ } from "./body-pages.ts";
7
+
8
+ describe("isReferencesHeading", () => {
9
+ it.each([
10
+ "References",
11
+ "REFERENCES",
12
+ "R EFERENCES",
13
+ "Bibliography",
14
+ "7 References",
15
+ "VII. REFERENCES",
16
+ " References ",
17
+ ])("%j is a heading", (line) => {
18
+ expect(isReferencesHeading(line)).toBe(true);
19
+ });
20
+
21
+ it.each(["See the references below", "References to prior work", "Refs", ""])(
22
+ "%j is not",
23
+ (line) => {
24
+ expect(isReferencesHeading(line)).toBe(false);
25
+ },
26
+ );
27
+ });
28
+
29
+ describe("bodyEnd", () => {
30
+ it("a heading below body text on its page: that page is a body page", () => {
31
+ expect(
32
+ bodyEnd([
33
+ "Intro\nbody",
34
+ "more body",
35
+ "end of the body\nReferences\n[1] A.",
36
+ "Appendix",
37
+ ]),
38
+ ).toEqual({ kind: "found", by: "references", page: 3, bodyPages: 3 });
39
+ });
40
+
41
+ it("a heading that is the first text of its page: the body ends on the page before", () => {
42
+ expect(bodyEnd(["Intro\nbody", "more body", "References\n[1] A."])).toEqual(
43
+ { kind: "found", by: "references", page: 3, bodyPages: 2 },
44
+ );
45
+ });
46
+
47
+ it("empty lines and a bare page number above the heading are not body", () => {
48
+ expect(bodyEnd(["body", "\n \n7\nR EFERENCES\n[1] x"])).toEqual({
49
+ kind: "found",
50
+ by: "references",
51
+ page: 2,
52
+ bodyPages: 1,
53
+ });
54
+ });
55
+
56
+ it("a statement or appendix after the references is not counted", () => {
57
+ expect(
58
+ bodyEnd([
59
+ "body",
60
+ "R EFERENCES\n[1] x\nLLM Usage Statement",
61
+ "Appendix A",
62
+ ]),
63
+ ).toEqual({ kind: "found", by: "references", page: 2, bodyPages: 1 });
64
+ });
65
+
66
+ it("no heading on any page is said, not guessed", () => {
67
+ expect(bodyEnd(["body", "more"])).toEqual({ kind: "missing" });
68
+ expect(bodyEnd([])).toEqual({ kind: "missing" });
69
+ });
70
+ });
71
+
72
+ describe("bodyEnd: a «References» line that is not the heading", () => {
73
+ const BODY = [
74
+ "Intro\nbody",
75
+ "Related work\nReferences\nto prior work are many.",
76
+ ];
77
+
78
+ it("🔴 a bare «References» line in the body loses to a heading followed by [1]", () => {
79
+ expect(
80
+ bodyEnd([...BODY, "more body", "last body line\nReferences\n[1] A."]),
81
+ ).toEqual({ kind: "found", by: "references", page: 4, bodyPages: 4 });
82
+ });
83
+
84
+ it("🔴 with hyperref's anchor, a bare «References» on an earlier page is ignored", () => {
85
+ expect(
86
+ bodyEnd([...BODY, "References\n[1] A.", "Appendix"], {
87
+ bib: 3,
88
+ appendix: null,
89
+ }),
90
+ ).toEqual({ kind: "found", by: "references", page: 3, bodyPages: 2 });
91
+ });
92
+
93
+ it("🔴 a table header «References» above the heading on the anchor's page: the heading is the confirmed one", () => {
94
+ expect(
95
+ bodyEnd(["body", "TABLE 2\nTool\nReferences\nx\nReferences\n[1] A."], {
96
+ bib: 2,
97
+ appendix: null,
98
+ }),
99
+ ).toEqual({ kind: "found", by: "references", page: 2, bodyPages: 2 });
100
+ });
101
+
102
+ it("with an anchor and no [1] after the heading, the last heading line on the anchor's page is taken", () => {
103
+ expect(
104
+ bodyEnd(["body", "Tool\nReferences\nx\nReferences\nAbel, A."], {
105
+ bib: 2,
106
+ appendix: null,
107
+ }),
108
+ ).toEqual({ kind: "found", by: "references", page: 2, bodyPages: 2 });
109
+ expect(
110
+ bodyEnd(["body", "References\nAbel, A. (2020)."], {
111
+ bib: 2,
112
+ appendix: null,
113
+ }),
114
+ ).toEqual({ kind: "found", by: "references", page: 2, bodyPages: 1 });
115
+ });
116
+
117
+ it("without an anchor or a confirmed heading, the first bare heading is the fallback", () => {
118
+ expect(bodyEnd(["body", "References\nAbel, A. (2020)."])).toEqual({
119
+ kind: "found",
120
+ by: "references",
121
+ page: 2,
122
+ bodyPages: 1,
123
+ });
124
+ });
125
+ });
126
+
127
+ describe("bodyEnd: the two signals disagree", () => {
128
+ it("the anchor's page has no heading line: said, with the anchor", () => {
129
+ expect(
130
+ bodyEnd(["body", "References\n[1] A.", "[2] B."], {
131
+ bib: 3,
132
+ appendix: null,
133
+ }),
134
+ ).toEqual({
135
+ kind: "disagree",
136
+ what: "references",
137
+ heading: null,
138
+ anchor: 3,
139
+ });
140
+ });
141
+
142
+ it("a confirmed heading on another page than the anchor's: said, with both", () => {
143
+ expect(
144
+ bodyEnd(["body\nReferences\n[1] quoted", "References\n[1] A."], {
145
+ bib: 2,
146
+ appendix: null,
147
+ }),
148
+ ).toEqual({ kind: "disagree", what: "references", heading: 1, anchor: 2 });
149
+ });
150
+
151
+ it("an anchor past the last page is a disagreement, not a crash", () => {
152
+ expect(bodyEnd(["References\n[1] A."], { bib: 5, appendix: null })).toEqual(
153
+ {
154
+ kind: "disagree",
155
+ what: "references",
156
+ heading: null,
157
+ anchor: 5,
158
+ },
159
+ );
160
+ });
161
+ });
162
+
163
+ describe("bodyEnd: running headers and footers are not body", () => {
164
+ /** A page of an ACM-style paper: a running header, then `text`. */
165
+ const page = (n: number, text: string) =>
166
+ `Conf '26, June ${String(n)}, Place\nAuthors\n${text}`;
167
+
168
+ it("🔴 a heading at the top of its page, under a running header: the page is not body", () => {
169
+ const pages = [1, 2, 3].map((n) => page(n, `body ${String(n)}`));
170
+ expect(bodyEnd([...pages, page(4, "References\n[1] A.")])).toEqual({
171
+ kind: "found",
172
+ by: "references",
173
+ page: 4,
174
+ bodyPages: 3,
175
+ });
176
+ });
177
+
178
+ it("body text under the header and above the heading still makes the page body", () => {
179
+ const pages = [1, 2, 3].map((n) => page(n, `body ${String(n)}`));
180
+ expect(
181
+ bodyEnd([...pages, page(4, "the end of the body\nReferences\n[1] A.")]),
182
+ ).toEqual({ kind: "found", by: "references", page: 4, bodyPages: 4 });
183
+ });
184
+
185
+ it("a line on only two pages is not a running header: it counts as body", () => {
186
+ expect(bodyEnd(["Setup\nbody", "x", "Setup\nReferences\n[1] A."])).toEqual({
187
+ kind: "found",
188
+ by: "references",
189
+ page: 3,
190
+ bodyPages: 3,
191
+ });
192
+ });
193
+ });
194
+
195
+ describe("isAppendixHeading", () => {
196
+ it.each([
197
+ "Appendix A.",
198
+ "APPENDIX",
199
+ "A PPENDIX B",
200
+ "Appendices",
201
+ "Appendix 1:",
202
+ ])("%j is a heading", (line) => {
203
+ expect(isAppendixHeading(line)).toBe(true);
204
+ });
205
+ it.each([
206
+ "Appendix A shows the rest.",
207
+ "See the appendix",
208
+ "Appendix A Proofs",
209
+ "",
210
+ ])("%j is not", (line) => {
211
+ expect(isAppendixHeading(line)).toBe(false);
212
+ });
213
+ });
214
+
215
+ describe("bodyEnd: an appendix before the references ends the body", () => {
216
+ const PAGES = [
217
+ "body",
218
+ "more body",
219
+ "the conclusion ends here",
220
+ "Appendix A.\nDetailed results\nTABLE 3",
221
+ "References\n[1] A.",
222
+ ];
223
+
224
+ it("🔴 the appendix at the top of its page, the references after it: the body ends on the page before", () => {
225
+ expect(bodyEnd(PAGES, { bib: 5, appendix: 4 })).toEqual({
226
+ kind: "found",
227
+ by: "appendix",
228
+ page: 4,
229
+ bodyPages: 3,
230
+ });
231
+ });
232
+
233
+ it("an appendix that starts below the conclusion on its page: that page is body", () => {
234
+ const pages = [...PAGES];
235
+ pages[3] = "the conclusion ends here\nAppendix A.\nDetails";
236
+ expect(bodyEnd(pages, { bib: 5, appendix: 4 })).toEqual({
237
+ kind: "found",
238
+ by: "appendix",
239
+ page: 4,
240
+ bodyPages: 4,
241
+ });
242
+ });
243
+
244
+ it("an appendix after the references changes nothing", () => {
245
+ expect(
246
+ bodyEnd(["body", "end\nReferences\n[1] A.", "Appendix A.\nx"], {
247
+ bib: 2,
248
+ appendix: 3,
249
+ }),
250
+ ).toEqual({ kind: "found", by: "references", page: 2, bodyPages: 2 });
251
+ });
252
+
253
+ it("the appendix and the references on one page: the heading that comes first ends the body", () => {
254
+ expect(
255
+ bodyEnd(["body", "end\nAppendix A.\nx\nReferences\n[1] A."], {
256
+ bib: 2,
257
+ appendix: 2,
258
+ }),
259
+ ).toEqual({ kind: "found", by: "appendix", page: 2, bodyPages: 2 });
260
+ });
261
+
262
+ it("an appendix and no references heading at all: the appendix ends the body", () => {
263
+ expect(
264
+ bodyEnd(["body", "Appendix\nx"], { bib: null, appendix: 2 }),
265
+ ).toEqual({
266
+ kind: "found",
267
+ by: "appendix",
268
+ page: 2,
269
+ bodyPages: 1,
270
+ });
271
+ });
272
+ });
273
+
274
+ describe("bodyEnd: where the appendix is not taken, or not found", () => {
275
+ it("without hyperref's anchor the appendix is not looked for: a sentence can end on «Appendix A.»", () => {
276
+ expect(
277
+ bodyEnd(["body, detailed in\nAppendix A.", "end\nReferences\n[1] A."]),
278
+ ).toEqual({ kind: "found", by: "references", page: 2, bodyPages: 2 });
279
+ });
280
+
281
+ it("hyperref anchors the appendix on a page with no line reading «Appendix»: said", () => {
282
+ expect(
283
+ bodyEnd(["body", "A Proofs\nx"], { bib: null, appendix: 2 }),
284
+ ).toEqual({
285
+ kind: "disagree",
286
+ what: "appendix",
287
+ heading: null,
288
+ anchor: 2,
289
+ });
290
+ });
291
+
292
+ it("an appendix anchor past the last page is a disagreement, not a crash", () => {
293
+ expect(bodyEnd(["body"], { bib: null, appendix: 4 })).toEqual({
294
+ kind: "disagree",
295
+ what: "appendix",
296
+ heading: null,
297
+ anchor: 4,
298
+ });
299
+ });
300
+ });
@@ -0,0 +1,223 @@
1
+ /**
2
+ * HOW MANY PAGES THE BODY TAKES, counted from what the PDF says rather than from how banal
3
+ * classifies its pages. A venue that limits the body «excluding references and appendices» (AIDC)
4
+ * cannot be judged on banal's body count: banal calls any page holding non-bibliography text body,
5
+ * so an LLM Usage Statement or an appendix placed after the bibliography is counted as body, and a
6
+ * correct paper fails (measured, `presets/aidc.jsonc`).
7
+ *
8
+ * ── WHERE THE BODY ENDS: AT THE REFERENCES, OR AT THE APPENDIX, WHICHEVER COMES FIRST ──────
9
+ * «Excluding references and appendices» excludes an appendix wherever it sits, so the body ends at
10
+ * the first of the two. Each is found by a structural signal first:
11
+ *
12
+ * references the page of the first bibliography entry, from the destination hyperref writes for
13
+ * every `\bibitem` (`cite.<key>`); then, on that page, the heading line: a line that
14
+ * reads «References» or «Bibliography» (any case, optionally numbered), the one
15
+ * CONFIRMED by a next line opening the first entry, `[1]`, else the last one there.
16
+ * Without hyperref: the first confirmed heading, else the first bare one — a table
17
+ * header or a subsection titled «References» reads the same, so it is a fallback.
18
+ * appendix the page hyperref anchored the appendix on (an `appendix.<letter>` destination, or a
19
+ * bookmark titled «Appendix…»); then, on that page, the line reading «Appendix»,
20
+ * «Appendix A.», «APPENDIX» or «Appendices». Without hyperref the appendix is not
21
+ * looked for: a sentence can end on a line reading «Appendix A.», and nothing tells
22
+ * it from the heading.
23
+ *
24
+ * A structural signal whose page has no heading line, or a confirmed references heading on another
25
+ * page than hyperref's first entry, is a disagreement, and it is said rather than resolved by
26
+ * picking one.
27
+ *
28
+ * ── THE PAGE THE BODY ENDS ON ──────────────────────────────────────────────────────
29
+ * Body text above the heading on that page makes it a body page: a body that runs half a page past
30
+ * the limit is over the limit, and the count must flag it rather than let a desk reject through.
31
+ * Only when the heading is the first text of its page does the body end on the page before. A line
32
+ * with no letter (a page number) is not body text, and neither is a running header or footer — a
33
+ * line that repeats, digits aside, on at least three pages and a third of them.
34
+ */
35
+
36
+ /** What ends the body. */
37
+ export type BodyEndsAt = "references" | "appendix";
38
+
39
+ /** Where hyperref anchored the bibliography's first entry and the appendix: 1-based pages, or null. */
40
+ export interface Anchors {
41
+ readonly bib: number | null;
42
+ readonly appendix: number | null;
43
+ }
44
+
45
+ /** Where the body ends, that no page says, or that a structural signal and the text disagree. */
46
+ export type BodyEnd =
47
+ | {
48
+ readonly kind: "found";
49
+ readonly by: BodyEndsAt;
50
+ /** 1-based page holding the heading that ends the body. */
51
+ readonly page: number;
52
+ /** The body, as the venue counts it: the heading's page too, when body text stands above it. */
53
+ readonly bodyPages: number;
54
+ }
55
+ | { readonly kind: "missing" }
56
+ | {
57
+ readonly kind: "disagree";
58
+ readonly what: BodyEndsAt;
59
+ /** The page of the first confirmed references heading, or null when the anchor's page has no heading. */
60
+ readonly heading: number | null;
61
+ /** The page hyperref anchored it on. */
62
+ readonly anchor: number;
63
+ };
64
+
65
+ /**
66
+ * A line that is a references heading: `References` or `Bibliography`, in any case (IEEEtran sets
67
+ * `REFERENCES` in small caps, which pdf.js may split as `R EFERENCES`), optionally numbered
68
+ * (`7 References`, `VII. REFERENCES`). The line is compared with every space removed.
69
+ */
70
+ const HEADING = /^(?:(?:\d+|[ivxlcdm]+)\.?)?(?:references|bibliography)$/iu;
71
+
72
+ /** Whether one line of a page's text is the references heading. */
73
+ export const isReferencesHeading = (line: string): boolean =>
74
+ HEADING.test(line.normalize("NFKC").replace(/\s+/gu, ""));
75
+
76
+ /** A line that is an appendix heading: `Appendix A.`, `APPENDIX`, `Appendices`, spaces removed. */
77
+ const APPENDIX_HEADING = /^appendi(?:x|ces)(?:[a-z]|\d+)?[.:]?$/iu;
78
+
79
+ /** Whether one line of a page's text is an appendix heading. */
80
+ export const isAppendixHeading = (line: string): boolean =>
81
+ APPENDIX_HEADING.test(line.normalize("NFKC").replace(/\s+/gu, ""));
82
+
83
+ /** Whether a line opens the first entry of a numbered bibliography: `[1]`. */
84
+ const isFirstEntry = (line: string): boolean => /^\s*\[\s*1\s*\]/u.test(line);
85
+
86
+ /** A page's lines, and where its headings are: the confirmed one, and every one. */
87
+ interface PageReading {
88
+ readonly lines: readonly string[];
89
+ /** Line index of a heading whose next non-empty line is `[1]`, or -1. */
90
+ readonly confirmed: number;
91
+ /** Line indices of every heading line. */
92
+ readonly headings: readonly number[];
93
+ }
94
+
95
+ function readPage(text: string): PageReading {
96
+ const lines = text.split("\n");
97
+ const headings = lines.flatMap((l, i) => (isReferencesHeading(l) ? [i] : []));
98
+ const confirmed =
99
+ headings.find((i) =>
100
+ isFirstEntry(lines.slice(i + 1).find((l) => l.trim() !== "") ?? ""),
101
+ ) ?? -1;
102
+ return { lines, confirmed, headings };
103
+ }
104
+
105
+ /** A line with digits blanked and spaces collapsed: what a running header keeps from page to page. */
106
+ const shape = (line: string): string =>
107
+ line.replace(/\d+/gu, "#").replace(/\s+/gu, " ").trim();
108
+
109
+ /** The shapes of the lines that repeat on at least three pages and a third of them. */
110
+ function runningLines(pages: readonly PageReading[]): ReadonlySet<string> {
111
+ const onPages = pages.flatMap((p) => [...new Set(p.lines.map(shape))]);
112
+ const sorted = onPages.toSorted();
113
+ const floor = Math.max(3, pages.length / 3);
114
+ // In sorted order, a shape on `floor` pages is the same string `floor - 1` places further on.
115
+ return new Set(
116
+ sorted.filter((s, i) => sorted[i + Math.ceil(floor) - 1] === s),
117
+ );
118
+ }
119
+
120
+ /** The heading line the references start at on a page: the confirmed one, else the last one. */
121
+ const startLine = (p: PageReading): number =>
122
+ p.confirmed >= 0 ? p.confirmed : (p.headings.at(-1) ?? -1);
123
+
124
+ /** A heading that ends the body: its 0-based page and its line there. */
125
+ interface At {
126
+ readonly kind: "at";
127
+ readonly by: BodyEndsAt;
128
+ readonly page: number;
129
+ readonly line: number;
130
+ /** The lines of that page above the heading. */
131
+ readonly above: readonly string[];
132
+ }
133
+ type Start = At | Extract<BodyEnd, { readonly kind: "disagree" | "missing" }>;
134
+
135
+ /** The references heading at line `line` of 0-based page `page`. */
136
+ const at = (page: number, p: PageReading, line: number): At => ({
137
+ kind: "at",
138
+ by: "references",
139
+ page,
140
+ line,
141
+ above: p.lines.slice(0, line),
142
+ });
143
+
144
+ /** Where the references start: at hyperref's first entry's page, else at the heading in the text. */
145
+ function referencesAt(
146
+ pages: readonly PageReading[],
147
+ anchor: number | null,
148
+ ): Start {
149
+ const confirmed = pages.findIndex((p) => p.confirmed >= 0);
150
+ if (anchor !== null) {
151
+ const p = pages[anchor - 1];
152
+ const line = p === undefined ? -1 : startLine(p);
153
+ if (p === undefined || line < 0)
154
+ return { kind: "disagree", what: "references", heading: null, anchor };
155
+ if (confirmed >= 0 && confirmed !== anchor - 1)
156
+ return {
157
+ kind: "disagree",
158
+ what: "references",
159
+ heading: confirmed + 1,
160
+ anchor,
161
+ };
162
+ return at(anchor - 1, p, line);
163
+ }
164
+ const i =
165
+ confirmed >= 0 ? confirmed : pages.findIndex((p) => p.headings.length > 0);
166
+ const p = pages[i];
167
+ return p === undefined ? { kind: "missing" } : at(i, p, startLine(p));
168
+ }
169
+
170
+ /** Where the appendix starts: on hyperref's page for it, at the line reading «Appendix». */
171
+ function appendixAt(
172
+ pages: readonly PageReading[],
173
+ anchor: number | null,
174
+ ): Start {
175
+ if (anchor === null) return { kind: "missing" };
176
+ const lines = pages[anchor - 1]?.lines ?? [];
177
+ const line = lines.findIndex(isAppendixHeading);
178
+ return line < 0
179
+ ? { kind: "disagree", what: "appendix", heading: null, anchor }
180
+ : {
181
+ kind: "at",
182
+ by: "appendix",
183
+ page: anchor - 1,
184
+ line,
185
+ above: lines.slice(0, line),
186
+ };
187
+ }
188
+
189
+ /** The body's end at a heading: the heading's page counts when body text stands above it. */
190
+ function endAt(pages: readonly PageReading[], at: At): BodyEnd {
191
+ const running = runningLines(pages);
192
+ const body = at.above.some((l) => /\p{L}/u.test(l) && !running.has(shape(l)));
193
+ return {
194
+ kind: "found",
195
+ by: at.by,
196
+ page: at.page + 1,
197
+ bodyPages: body ? at.page + 1 : at.page,
198
+ };
199
+ }
200
+
201
+ /**
202
+ * Where the body ends — at the references or the appendix, whichever comes first — from each
203
+ * page's text and where hyperref anchored the two. Pure.
204
+ */
205
+ export function bodyEnd(
206
+ texts: readonly string[],
207
+ anchors: Anchors = { bib: null, appendix: null },
208
+ ): BodyEnd {
209
+ const pages = texts.map(readPage);
210
+ const starts = [
211
+ referencesAt(pages, anchors.bib),
212
+ appendixAt(pages, anchors.appendix),
213
+ ];
214
+ const disagree = starts.find(
215
+ (s): s is Extract<BodyEnd, { readonly kind: "disagree" }> =>
216
+ s.kind === "disagree",
217
+ );
218
+ if (disagree !== undefined) return disagree;
219
+ const first = starts
220
+ .filter((s): s is At => s.kind === "at")
221
+ .toSorted((a, b) => a.page - b.page || a.line - b.line)[0];
222
+ return first === undefined ? { kind: "missing" } : endAt(pages, first);
223
+ }
@@ -11,8 +11,8 @@
11
11
  *
12
12
  * ── WHAT IS NOT DONE ─────────────────────────────────────────────────────────────
13
13
  * No macro is expanded, so a path built from a macro (`\input{\dir/intro}`) is read as written. A
14
- * file that cannot be found is reported and left out; a file that includes itself, directly or
15
- * through others, is spliced once.
14
+ * file that cannot be found is left out and named in `missing`; a file that includes itself,
15
+ * directly or through others, is spliced once.
16
16
  */
17
17
  import { extname, normalize } from "node:path";
18
18
  import type { Span } from "./tex-document.ts";
@@ -141,3 +141,13 @@ export type ProsePiece =
141
141
  export interface Passage {
142
142
  readonly pieces: readonly [ProsePiece, ...ProsePiece[]];
143
143
  }
144
+
145
+ /**
146
+ * A command that changes the page layout a venue's template sets: the text block, the margins,
147
+ * the line spacing, or space pulled back with a negative skip. `command` is how a message names it
148
+ * (`\setlength{\textheight}`, `\vspace{-2mm}`).
149
+ */
150
+ export interface LayoutOverride {
151
+ readonly command: string;
152
+ readonly place: Place;
153
+ }
@@ -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: { pages: 4, fonts: FONTS, last, layout: [] },
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 2; a measured last page gives both heights, in both fields", () => {
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, 2);
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: { pages: 2, fonts: FONTS, last: lastPage(60, 60), layout: [] },
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
+ });