paperlint 5.0.0 → 6.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CLAUDE.md +13 -0
- package/CONTRIBUTING.md +3 -0
- package/README.md +8 -8
- package/bin/paperlint.mjs +5 -1
- package/dist/adapters/latex/index.d.ts +2 -1
- package/dist/adapters/latex/index.d.ts.map +1 -1
- package/dist/adapters/latex/index.js +6 -2
- package/dist/adapters/latex/index.js.map +1 -1
- package/dist/adapters/latex/layout.d.ts +27 -0
- package/dist/adapters/latex/layout.d.ts.map +1 -0
- package/dist/adapters/latex/layout.js +62 -0
- package/dist/adapters/latex/layout.js.map +1 -0
- package/dist/adapters/latex/nodes.d.ts.map +1 -1
- package/dist/adapters/latex/nodes.js +11 -1
- package/dist/adapters/latex/nodes.js.map +1 -1
- package/dist/adapters/latex/parse.d.ts.map +1 -1
- package/dist/adapters/latex/parse.js +5 -1
- package/dist/adapters/latex/parse.js.map +1 -1
- package/dist/adapters/latex/prose.d.ts +6 -1
- package/dist/adapters/latex/prose.d.ts.map +1 -1
- package/dist/adapters/latex/prose.js +142 -31
- package/dist/adapters/latex/prose.js.map +1 -1
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +35 -18
- package/dist/cli.js.map +1 -1
- package/dist/domain/anonymity.d.ts +68 -0
- package/dist/domain/anonymity.d.ts.map +1 -0
- package/dist/domain/anonymity.js +94 -0
- package/dist/domain/anonymity.js.map +1 -0
- package/dist/domain/body-pages.d.ts +69 -0
- package/dist/domain/body-pages.d.ts.map +1 -0
- package/dist/domain/body-pages.js +141 -0
- package/dist/domain/body-pages.js.map +1 -0
- package/dist/domain/register.d.ts +96 -0
- package/dist/domain/register.d.ts.map +1 -0
- package/dist/domain/register.js +136 -0
- package/dist/domain/register.js.map +1 -0
- package/dist/domain/tex-document.d.ts +28 -0
- package/dist/domain/tex-document.d.ts.map +1 -1
- package/dist/domain/tex-document.js +8 -3
- package/dist/domain/tex-document.js.map +1 -1
- package/dist/eslint-rules/pdf-last-page-balance.d.ts +1 -1
- package/dist/eslint-rules/pdf-last-page-balance.js +2 -2
- package/dist/facts-file.d.ts +28 -2
- package/dist/facts-file.d.ts.map +1 -1
- package/dist/facts-file.js +54 -9
- package/dist/facts-file.js.map +1 -1
- package/dist/lib/paper-config.d.ts.map +1 -1
- package/dist/lib/paper-config.js +2 -1
- package/dist/lib/paper-config.js.map +1 -1
- package/dist/paper-settings.d.ts +11 -0
- package/dist/paper-settings.d.ts.map +1 -1
- package/dist/paper-settings.js +38 -4
- package/dist/paper-settings.js.map +1 -1
- package/dist/pdf-content.d.ts +40 -0
- package/dist/pdf-content.d.ts.map +1 -0
- package/dist/pdf-content.js +97 -0
- package/dist/pdf-content.js.map +1 -0
- package/dist/pdf-facts.d.ts +30 -0
- package/dist/pdf-facts.d.ts.map +1 -1
- package/dist/pdf-facts.js +37 -1
- package/dist/pdf-facts.js.map +1 -1
- package/dist/ports/latex.d.ts +8 -1
- package/dist/ports/latex.d.ts.map +1 -1
- package/dist/presets.d.ts +5 -0
- package/dist/presets.d.ts.map +1 -1
- package/dist/presets.js +8 -1
- package/dist/presets.js.map +1 -1
- package/dist/register-bands.d.ts +37 -0
- package/dist/register-bands.d.ts.map +1 -0
- package/dist/register-bands.js +196 -0
- package/dist/register-bands.js.map +1 -0
- package/dist/register.d.ts +4 -2
- package/dist/register.d.ts.map +1 -1
- package/dist/register.js +6 -45
- package/dist/register.js.map +1 -1
- package/dist/tex-requirements.d.ts +10 -0
- package/dist/tex-requirements.d.ts.map +1 -1
- package/dist/tex-requirements.js +29 -6
- package/dist/tex-requirements.js.map +1 -1
- package/dist/tex-venue-rules.d.ts +8 -2
- package/dist/tex-venue-rules.d.ts.map +1 -1
- package/dist/tex-venue-rules.js +40 -0
- package/dist/tex-venue-rules.js.map +1 -1
- package/dist/types.d.ts +2 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/venue-rules.d.ts +38 -8
- package/dist/venue-rules.d.ts.map +1 -1
- package/dist/venue-rules.js +179 -41
- package/dist/venue-rules.js.map +1 -1
- package/docs/configuration.md +18 -16
- package/docs/e2e.md +32 -11
- package/docs/rules/anonymity/identity.md +111 -0
- package/docs/rules/format/layout-override.md +61 -0
- package/docs/rules/format/page-limit.md +108 -0
- package/docs/rules/pdf/body-size.md +87 -0
- package/docs/rules/tex/claim-emphasis.md +71 -0
- package/docs/rules/tex/contrast-frames.md +100 -0
- package/docs/rules/tex/relation-markers.md +72 -0
- package/docs/rules.md +59 -50
- package/eslint-rules/pdf-last-page-balance.harness.mjs +6 -1
- package/eslint-rules/pdf-last-page-balance.ts +2 -2
- package/fixtures/build-e2e/aidc/expect.json +1 -1
- package/fixtures/build-e2e/aidc/paperlint.json +1 -1
- package/fixtures/build-e2e/anon-ack/PIPELINE-STATUS.md +3 -0
- package/fixtures/build-e2e/anon-ack/expect.json +19 -0
- package/fixtures/build-e2e/anon-ack/paper.tex +31 -0
- package/fixtures/build-e2e/anon-ack/paperlint.json +1 -0
- package/fixtures/build-e2e/anon-author/PIPELINE-STATUS.md +3 -0
- package/fixtures/build-e2e/anon-author/expect.json +19 -0
- package/fixtures/build-e2e/anon-author/paper.tex +29 -0
- package/fixtures/build-e2e/anon-author/paperlint.json +1 -0
- package/fixtures/build-e2e/anon-bib/PIPELINE-STATUS.md +3 -0
- package/fixtures/build-e2e/anon-bib/expect.json +20 -0
- package/fixtures/build-e2e/anon-bib/paper.tex +35 -0
- package/fixtures/build-e2e/anon-bib/paperlint.json +1 -0
- package/fixtures/build-e2e/anon-href/PIPELINE-STATUS.md +3 -0
- package/fixtures/build-e2e/anon-href/expect.json +19 -0
- package/fixtures/build-e2e/anon-href/paper.tex +30 -0
- package/fixtures/build-e2e/anon-href/paperlint.json +1 -0
- package/fixtures/build-e2e/anon-macro/PIPELINE-STATUS.md +3 -0
- package/fixtures/build-e2e/anon-macro/expect.json +19 -0
- package/fixtures/build-e2e/anon-macro/paper.tex +30 -0
- package/fixtures/build-e2e/anon-macro/paperlint.json +1 -0
- package/fixtures/build-e2e/anon-pdfauthor/PIPELINE-STATUS.md +3 -0
- package/fixtures/build-e2e/anon-pdfauthor/expect.json +19 -0
- package/fixtures/build-e2e/anon-pdfauthor/paper.tex +31 -0
- package/fixtures/build-e2e/anon-pdfauthor/paperlint.json +1 -0
- package/fixtures/build-e2e/unbalanced/expect.json +2 -2
- package/lib/paper-config.ts +3 -1
- package/package.json +1 -1
- package/presets/acm-sigconf.jsonc +3 -1
- package/presets/aidc.jsonc +66 -17
- package/presets/ieee-conference.jsonc +8 -0
- package/presets/venue-profile.schema.json +57 -1
- package/skills/grade-paper-writing/SKILL.md +59 -27
- package/skills/grade-paper-writing/SKILL.md.spec.ts +58 -26
- package/skills/render-paper/extract-pdf-facts.harness.ts +3 -3
- package/src/adapters/latex/index.test.ts +91 -0
- package/src/adapters/latex/index.ts +6 -2
- package/src/adapters/latex/layout.ts +106 -0
- package/src/adapters/latex/nodes.ts +11 -3
- package/src/adapters/latex/parse.ts +5 -1
- package/src/adapters/latex/prose.ts +188 -58
- package/src/build.harness.mjs +2 -2
- package/src/build.test.ts +5 -0
- package/src/cli.test.ts +13 -0
- package/src/cli.ts +50 -17
- package/src/domain/anonymity.test.ts +163 -0
- package/src/domain/anonymity.ts +129 -0
- package/src/domain/body-pages.test.ts +300 -0
- package/src/domain/body-pages.ts +223 -0
- package/src/domain/register.test.ts +153 -0
- package/src/domain/register.ts +261 -0
- package/src/domain/tex-document.ts +47 -3
- package/src/facts-file.test.ts +100 -4
- package/src/facts-file.ts +91 -9
- package/src/new-paper.test.ts +7 -1
- package/src/paper-settings.test.ts +63 -6
- package/src/paper-settings.ts +53 -5
- package/src/pdf-content.test.ts +71 -0
- package/src/pdf-content.ts +125 -0
- package/src/pdf-facts.test.ts +110 -2
- package/src/pdf-facts.ts +106 -8
- package/src/ports/latex.ts +9 -0
- package/src/presets.ts +15 -9
- package/src/register-bands.test.ts +327 -0
- package/src/register-bands.ts +291 -0
- package/src/register.ts +7 -37
- package/src/rule-cases.test.ts +96 -20
- package/src/rule-docs.test.ts +0 -2
- package/src/tex-requirements.ts +56 -8
- package/src/tex-venue-rules.test.ts +110 -5
- package/src/tex-venue-rules.ts +64 -3
- package/src/types.ts +2 -0
- package/src/venue-rules.test.ts +351 -39
- package/src/venue-rules.ts +247 -47
|
@@ -8,6 +8,7 @@ import type * as Ast from "@unified-latex/unified-latex-types";
|
|
|
8
8
|
import { describe, expect, it } from "vitest";
|
|
9
9
|
import { runText, spanIn, type ProsePiece } from "../../domain/tex-document.ts";
|
|
10
10
|
import {
|
|
11
|
+
bodyEmphasis,
|
|
11
12
|
bodyProse,
|
|
12
13
|
documentBodyOf,
|
|
13
14
|
documentClassOf,
|
|
@@ -470,6 +471,15 @@ describe("bodyProse — run-in headings and list items", () => {
|
|
|
470
471
|
).toEqual(["It grows.", "It holds."]);
|
|
471
472
|
});
|
|
472
473
|
|
|
474
|
+
it("🔴 braces around a run-in heading change nothing: `{\\textbf{Side-channel eavesdropping:}}` is a heading, left out", () => {
|
|
475
|
+
// barovox-acsac24, Chapters/09_Related_Works.tex, writes its run-in headings this way.
|
|
476
|
+
expect(
|
|
477
|
+
texts(
|
|
478
|
+
"\\begin{document}\n{\\textbf{Side-channel eavesdropping:}} It has been studied.\n\\end{document}",
|
|
479
|
+
),
|
|
480
|
+
).toEqual(["It has been studied."]);
|
|
481
|
+
});
|
|
482
|
+
|
|
473
483
|
it("the same markup inside a sentence, or without the closing mark, is prose", () => {
|
|
474
484
|
expect(
|
|
475
485
|
texts(
|
|
@@ -541,3 +551,84 @@ describe("documentBodyOf — the body of a source that is a document of its own"
|
|
|
541
551
|
);
|
|
542
552
|
});
|
|
543
553
|
});
|
|
554
|
+
|
|
555
|
+
describe("bodyEmphasis — phrases set apart inside the body's prose, and where they stand", () => {
|
|
556
|
+
/** Each phrase as `style/place: text`. */
|
|
557
|
+
const shape = (src: string): readonly string[] =>
|
|
558
|
+
bodyEmphasis(parseLatex(src)).map(
|
|
559
|
+
(e) => `${e.style}/${e.place}: ${e.text}`,
|
|
560
|
+
);
|
|
561
|
+
|
|
562
|
+
it("🔴 a bold phrase inside a sentence is inline; one opening a paragraph or an item is a label (opening)", () => {
|
|
563
|
+
expect(
|
|
564
|
+
shape(
|
|
565
|
+
"\\begin{document}\nWe find that \\textbf{the guards react to the word}.\n\n\\textbf{Varying the volume}. We vary it.\n\\begin{itemize}\n\\item \\textbf{A corpus of questions}: we release it.\n\\end{itemize}\n\\end{document}",
|
|
566
|
+
),
|
|
567
|
+
).toEqual([
|
|
568
|
+
"bold/inline: the guards react to the word",
|
|
569
|
+
"bold/opening: Varying the volume",
|
|
570
|
+
"bold/opening: A corpus of questions",
|
|
571
|
+
]);
|
|
572
|
+
});
|
|
573
|
+
|
|
574
|
+
it("🔴 never a heading, a run-in heading, a caption, a table, or anything after the bibliography", () => {
|
|
575
|
+
expect(
|
|
576
|
+
shape(
|
|
577
|
+
"\\begin{document}\n\\section{\\textbf{Big}}\n\\textbf{Threats.} None \\emph{here}.\n\\begin{table}\\caption{\\textbf{C}}\\begin{tabular}{l}\\textbf{cell}\\end{tabular}\\end{table}\n\\bibliography{refs}\nAfter \\textbf{this}.\n\\end{document}",
|
|
578
|
+
),
|
|
579
|
+
).toEqual(["italic/inline: here"]);
|
|
580
|
+
});
|
|
581
|
+
|
|
582
|
+
it("braces around a label change nothing: `{\\textbf{Filtering}}.` opening a paragraph is a label", () => {
|
|
583
|
+
// barovox-acsac24, Chapters/08_Discussion.tex.
|
|
584
|
+
expect(
|
|
585
|
+
shape(
|
|
586
|
+
"\\begin{document}\n{\\textbf{Increasing the distance}}. The proximity matters.\n\\end{document}",
|
|
587
|
+
),
|
|
588
|
+
).toEqual(["bold/opening: Increasing the distance"]);
|
|
589
|
+
});
|
|
590
|
+
});
|
|
591
|
+
|
|
592
|
+
describe("bodyEmphasis — each style, and edge cases", () => {
|
|
593
|
+
/** Each phrase as `style/place: text`. */
|
|
594
|
+
const shape = (src: string): readonly string[] =>
|
|
595
|
+
bodyEmphasis(parseLatex(src)).map(
|
|
596
|
+
(e) => `${e.style}/${e.place}: ${e.text}`,
|
|
597
|
+
);
|
|
598
|
+
|
|
599
|
+
it("each style: \\emph and \\textit italic, \\underline underline, a `{\\bfseries …}` group bold; a footnote's phrase counts", () => {
|
|
600
|
+
expect(
|
|
601
|
+
shape(
|
|
602
|
+
"\\begin{document}\nA \\emph{b} c \\textit{d} e \\underline{f} g {\\bfseries h i} j.\\footnote{In \\textbf{k}.}\n\\end{document}",
|
|
603
|
+
),
|
|
604
|
+
).toEqual([
|
|
605
|
+
"italic/inline: b",
|
|
606
|
+
"italic/inline: d",
|
|
607
|
+
"underline/inline: f",
|
|
608
|
+
"bold/inline: h i",
|
|
609
|
+
"bold/inline: k",
|
|
610
|
+
]);
|
|
611
|
+
});
|
|
612
|
+
|
|
613
|
+
it("an empty bold macro sets nothing apart, opening a paragraph or not", () => {
|
|
614
|
+
expect(
|
|
615
|
+
shape(
|
|
616
|
+
"\\begin{document}\n\\textbf{} Opens. We \\textbf{ } go.\n\\end{document}",
|
|
617
|
+
),
|
|
618
|
+
).toEqual([]);
|
|
619
|
+
});
|
|
620
|
+
|
|
621
|
+
it("an environment opening a paragraph opens nothing for the phrases inside it", () => {
|
|
622
|
+
expect(
|
|
623
|
+
shape(
|
|
624
|
+
"\\begin{document}\n\\begin{quote}\nWe say \\textbf{this}.\n\\end{quote}\n\\end{document}",
|
|
625
|
+
),
|
|
626
|
+
).toEqual(["bold/inline: this"]);
|
|
627
|
+
});
|
|
628
|
+
|
|
629
|
+
it("the span is the macro with its argument, where the source has it", () => {
|
|
630
|
+
const src = "\\begin{document}\nWe \\textbf{see it}.\n\\end{document}";
|
|
631
|
+
const [e] = bodyEmphasis(parseLatex(src));
|
|
632
|
+
expect(e && src.slice(e.span.start, e.span.end)).toBe("\\textbf{see it}");
|
|
633
|
+
});
|
|
634
|
+
});
|
|
@@ -29,8 +29,9 @@ import {
|
|
|
29
29
|
import { documentBodyOf, includesOf } from "./includes.ts";
|
|
30
30
|
import { outlineOf } from "./outline.ts";
|
|
31
31
|
import { parseLatex } from "./parse.ts";
|
|
32
|
-
import { bodyProse } from "./prose.ts";
|
|
32
|
+
import { bodyEmphasis, bodyProse } from "./prose.ts";
|
|
33
33
|
import { renderedRuns } from "./rendered.ts";
|
|
34
|
+
import { layoutOverridesOf } from "./layout.ts";
|
|
34
35
|
|
|
35
36
|
export {
|
|
36
37
|
documentClassOf,
|
|
@@ -49,8 +50,9 @@ export {
|
|
|
49
50
|
export { documentBodyOf, includesOf } from "./includes.ts";
|
|
50
51
|
export { outlineOf } from "./outline.ts";
|
|
51
52
|
export { parseLatex, type ParsedTex } from "./parse.ts";
|
|
52
|
-
export { bodyProse } from "./prose.ts";
|
|
53
|
+
export { bodyEmphasis, bodyProse } from "./prose.ts";
|
|
53
54
|
export { renderedRuns } from "./rendered.ts";
|
|
55
|
+
export { layoutOverridesOf } from "./layout.ts";
|
|
54
56
|
|
|
55
57
|
/** The `LatexReader` port over unified-latex: each call parses its source once. */
|
|
56
58
|
export const latexReader: LatexReader = {
|
|
@@ -58,8 +60,10 @@ export const latexReader: LatexReader = {
|
|
|
58
60
|
outline: (src) => outlineOf(parseLatex(src)),
|
|
59
61
|
renderedRuns: (src) => renderedRuns(parseLatex(src)),
|
|
60
62
|
bodyProse: (src) => bodyProse(parseLatex(src)),
|
|
63
|
+
bodyEmphasis: (src) => bodyEmphasis(parseLatex(src)),
|
|
61
64
|
includes: (src) => includesOf(parseLatex(src)),
|
|
62
65
|
documentBody: (src) => documentBodyOf(parseLatex(src)),
|
|
63
66
|
template: parseTemplate,
|
|
64
67
|
withDocumentClass: (src, want) => replaceDocumentClass(parseLatex(src), want),
|
|
68
|
+
layoutOverrides: (src) => layoutOverridesOf(parseLatex(src)),
|
|
65
69
|
};
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Where a source changes the page layout its class sets for the whole document — the input of
|
|
3
|
+
* `format/layout-override`. Read from the parse tree, never the text: a command in a comment is not
|
|
4
|
+
* a command.
|
|
5
|
+
*
|
|
6
|
+
* What counts, each by its node:
|
|
7
|
+
*
|
|
8
|
+
* \usepackage{geometry} · \RequirePackage{…,geometry} the package that re-sets the margins
|
|
9
|
+
* \geometry{…} · \newgeometry{…} its commands
|
|
10
|
+
* \setlength / \addtolength on a layout length \textheight \textwidth \topmargin …
|
|
11
|
+
* \linespread{…} · \renewcommand{\baselinestretch} the line spacing
|
|
12
|
+
*
|
|
13
|
+
* Local spacing — a negative `\vspace` or `\vskip` around one float — is not a layout the template
|
|
14
|
+
* sets, and no venue's call names it; it is not read.
|
|
15
|
+
*
|
|
16
|
+
* A comment and a `comment` environment are not read — the parser hands both back as text, not
|
|
17
|
+
* macros. Everything else is — the bibliography, math, floats — and a macro definition too: a
|
|
18
|
+
* `\newcommand` whose body sets a layout length does so wherever it is used. Conditionals are not
|
|
19
|
+
* evaluated.
|
|
20
|
+
*/
|
|
21
|
+
import type { LayoutOverride } from "../../domain/tex-document.ts";
|
|
22
|
+
import {
|
|
23
|
+
collapse,
|
|
24
|
+
isList,
|
|
25
|
+
macroPlace,
|
|
26
|
+
mandatory,
|
|
27
|
+
textOf,
|
|
28
|
+
visited,
|
|
29
|
+
type Macro,
|
|
30
|
+
} from "./nodes.ts";
|
|
31
|
+
import type { ParsedTex } from "./parse.ts";
|
|
32
|
+
|
|
33
|
+
/** The lengths the class sets for the page and the line; changing one changes the venue's layout. */
|
|
34
|
+
export const LAYOUT_LENGTHS: ReadonlySet<string> = new Set([
|
|
35
|
+
"textheight",
|
|
36
|
+
"textwidth",
|
|
37
|
+
"topmargin",
|
|
38
|
+
"oddsidemargin",
|
|
39
|
+
"evensidemargin",
|
|
40
|
+
"columnsep",
|
|
41
|
+
"headsep",
|
|
42
|
+
"headheight",
|
|
43
|
+
"footskip",
|
|
44
|
+
"baselineskip",
|
|
45
|
+
]);
|
|
46
|
+
|
|
47
|
+
/** The macro a mandatory argument holds first, or undefined: `{\textheight}` → `textheight`. */
|
|
48
|
+
function firstMacro(m: Macro, i: number): string | undefined {
|
|
49
|
+
const first = mandatory(m)[i]?.content.find((n) => n.type !== "whitespace");
|
|
50
|
+
return first?.type === "macro" ? first.content : undefined;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const argText = (m: Macro, i: number): string =>
|
|
54
|
+
collapse(textOf(mandatory(m)[i]?.content));
|
|
55
|
+
|
|
56
|
+
/** What names a macro that overrides the layout; null: this use of it does not. */
|
|
57
|
+
type Namer = (m: Macro) => string | null;
|
|
58
|
+
|
|
59
|
+
const usesGeometry: Namer = (m) =>
|
|
60
|
+
argText(m, 0)
|
|
61
|
+
.split(",")
|
|
62
|
+
.map((p) => p.trim())
|
|
63
|
+
.includes("geometry")
|
|
64
|
+
? `\\${m.content}{geometry}`
|
|
65
|
+
: null;
|
|
66
|
+
|
|
67
|
+
const setsLayoutLength: Namer = (m) => {
|
|
68
|
+
const length = firstMacro(m, 0);
|
|
69
|
+
return length !== undefined && LAYOUT_LENGTHS.has(length)
|
|
70
|
+
? `\\${m.content}{\\${length}}`
|
|
71
|
+
: null;
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
/** Each macro that may override the layout, and how to tell whether this use of it does. */
|
|
75
|
+
const OVERRIDES: ReadonlyMap<string, Namer> = new Map<string, Namer>([
|
|
76
|
+
["usepackage", usesGeometry],
|
|
77
|
+
["RequirePackage", usesGeometry],
|
|
78
|
+
["geometry", (m) => `\\${m.content}`],
|
|
79
|
+
["newgeometry", (m) => `\\${m.content}`],
|
|
80
|
+
["linespread", (m) => `\\linespread{${argText(m, 0)}}`],
|
|
81
|
+
["setlength", setsLayoutLength],
|
|
82
|
+
["addtolength", setsLayoutLength],
|
|
83
|
+
[
|
|
84
|
+
"renewcommand",
|
|
85
|
+
(m) =>
|
|
86
|
+
firstMacro(m, 0) === "baselinestretch"
|
|
87
|
+
? "\\renewcommand{\\baselinestretch}"
|
|
88
|
+
: null,
|
|
89
|
+
],
|
|
90
|
+
]);
|
|
91
|
+
|
|
92
|
+
/** How a message names a macro that overrides the layout, or null when it does not. */
|
|
93
|
+
const overrideOf: Namer = (m) => OVERRIDES.get(m.content)?.(m) ?? null;
|
|
94
|
+
|
|
95
|
+
/** Every layout override in the source, in document order. */
|
|
96
|
+
export function layoutOverridesOf(t: ParsedTex): readonly LayoutOverride[] {
|
|
97
|
+
// Nothing is pruned: a comment, and a `comment` environment, reach the tree as leaves of text.
|
|
98
|
+
const lists = visited(t.root, () => false).filter(isList);
|
|
99
|
+
return lists.flatMap((list) =>
|
|
100
|
+
list.flatMap((n) => {
|
|
101
|
+
if (n.type !== "macro") return [];
|
|
102
|
+
const command = overrideOf(n);
|
|
103
|
+
return command === null ? [] : [{ command, place: macroPlace(n) }];
|
|
104
|
+
}),
|
|
105
|
+
);
|
|
106
|
+
}
|
|
@@ -63,9 +63,17 @@ const NONE: Shown = { kind: "none" };
|
|
|
63
63
|
*/
|
|
64
64
|
const SHOWN_ARGUMENT: ReadonlyMap<string, Shown> = new Map<string, Shown>([
|
|
65
65
|
["texorpdfstring", { kind: "argument", at: 0 }],
|
|
66
|
-
...[
|
|
67
|
-
|
|
68
|
-
|
|
66
|
+
...[
|
|
67
|
+
"color",
|
|
68
|
+
"fontsize",
|
|
69
|
+
"vspace",
|
|
70
|
+
"hspace",
|
|
71
|
+
"setlength",
|
|
72
|
+
"addtolength",
|
|
73
|
+
"linespread",
|
|
74
|
+
"geometry",
|
|
75
|
+
"newgeometry",
|
|
76
|
+
].map((m) => [m, NONE] as const),
|
|
69
77
|
]);
|
|
70
78
|
|
|
71
79
|
/** The text a (non-key) macro typesets: see `SHOWN_ARGUMENT`. */
|
|
@@ -82,7 +82,11 @@ const LINK_SIGNATURES = Object.fromEntries(signed(["href"], "m m"));
|
|
|
82
82
|
* Declarations unified-latex has no signature for: signed so their arguments (sizes, not text)
|
|
83
83
|
* attach to them instead of standing beside them as groups a reader would seem to see.
|
|
84
84
|
*/
|
|
85
|
-
const DECLARATION_SIGNATURES = Object.fromEntries(
|
|
85
|
+
const DECLARATION_SIGNATURES = Object.fromEntries([
|
|
86
|
+
...signed(["fontsize"], "m m"),
|
|
87
|
+
// The layout commands `format/layout-override` reads: a spacing factor, geometry's key list.
|
|
88
|
+
...signed(["linespread", "geometry", "newgeometry"], "m"),
|
|
89
|
+
]);
|
|
86
90
|
|
|
87
91
|
/**
|
|
88
92
|
* Macros that define other macros or environments. A heading in a body is no section where it is
|
|
@@ -14,10 +14,18 @@
|
|
|
14
14
|
* own passage; one that cites or links leaves a citation mark in the sentence it hangs from.
|
|
15
15
|
*/
|
|
16
16
|
import type * as Ast from "@unified-latex/unified-latex-types";
|
|
17
|
-
import { hasAtLeast, sortBy } from "remeda";
|
|
18
|
-
import type {
|
|
17
|
+
import { hasAtLeast, mapWithFeedback, only, sortBy, zip } from "remeda";
|
|
18
|
+
import type {
|
|
19
|
+
Emphasis,
|
|
20
|
+
EmphasisStyle,
|
|
21
|
+
Owner,
|
|
22
|
+
Passage,
|
|
23
|
+
Place,
|
|
24
|
+
ProsePiece,
|
|
25
|
+
} from "../../domain/tex-document.ts";
|
|
19
26
|
import {
|
|
20
27
|
argumentsOf,
|
|
28
|
+
collapse,
|
|
21
29
|
inPlace,
|
|
22
30
|
macroPlace,
|
|
23
31
|
mandatory,
|
|
@@ -28,12 +36,16 @@ import {
|
|
|
28
36
|
} from "./nodes.ts";
|
|
29
37
|
import { CITATION_MACROS, DEFINITION_MACROS, type ParsedTex } from "./parse.ts";
|
|
30
38
|
|
|
31
|
-
/**
|
|
39
|
+
/**
|
|
40
|
+
* What the walk yields: a piece, the end of a passage, the start of the back matter, a footnote, and
|
|
41
|
+
* an emphasised phrase — which stands beside the pieces it typesets, and makes no passage of its own.
|
|
42
|
+
*/
|
|
32
43
|
type Event =
|
|
33
44
|
| { readonly kind: "piece"; readonly piece: ProsePiece }
|
|
34
45
|
| { readonly kind: "break" }
|
|
35
46
|
| { readonly kind: "end" }
|
|
36
|
-
| { readonly kind: "aside"; readonly events: readonly Event[] }
|
|
47
|
+
| { readonly kind: "aside"; readonly events: readonly Event[] }
|
|
48
|
+
| { readonly kind: "emphasis"; readonly emphasis: Emphasis };
|
|
37
49
|
|
|
38
50
|
const BREAK: Event = { kind: "break" };
|
|
39
51
|
const END: Event = { kind: "end" };
|
|
@@ -251,6 +263,75 @@ const OWN_EVENTS: ReadonlyMap<
|
|
|
251
263
|
],
|
|
252
264
|
]);
|
|
253
265
|
|
|
266
|
+
/** Formatting macros that set their argument apart from the running text, and how. */
|
|
267
|
+
const EMPHASIS_MACROS: ReadonlyMap<string, EmphasisStyle> = new Map([
|
|
268
|
+
["textbf", "bold"],
|
|
269
|
+
["emph", "italic"],
|
|
270
|
+
["textit", "italic"],
|
|
271
|
+
["textsl", "italic"],
|
|
272
|
+
["underline", "underline"],
|
|
273
|
+
["uline", "underline"],
|
|
274
|
+
]);
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* Declarations that set the rest of their group apart — `{\bfseries …}`, `{\em …}` — and how. The
|
|
278
|
+
* group is the phrase.
|
|
279
|
+
*/
|
|
280
|
+
const EMPHASIS_DECLARATIONS: ReadonlyMap<string, EmphasisStyle> = new Map([
|
|
281
|
+
["bfseries", "bold"],
|
|
282
|
+
["bf", "bold"],
|
|
283
|
+
["em", "italic"],
|
|
284
|
+
["itshape", "italic"],
|
|
285
|
+
["it", "italic"],
|
|
286
|
+
["slshape", "italic"],
|
|
287
|
+
]);
|
|
288
|
+
|
|
289
|
+
/** An emphasised phrase over `place`, typesetting what `inner` typesets; nothing when it has no place or no text. */
|
|
290
|
+
const emphasisOf = (
|
|
291
|
+
style: EmphasisStyle,
|
|
292
|
+
place: Place,
|
|
293
|
+
inner: readonly Event[],
|
|
294
|
+
): readonly Event[] =>
|
|
295
|
+
inPlace(place, (span) => {
|
|
296
|
+
const text = collapse(typeset(inner));
|
|
297
|
+
return text === ""
|
|
298
|
+
? []
|
|
299
|
+
: [
|
|
300
|
+
{
|
|
301
|
+
kind: "emphasis",
|
|
302
|
+
emphasis: { style, place: "inline", text, span },
|
|
303
|
+
},
|
|
304
|
+
];
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
/** A formatting macro's events: what it typesets, and — for an emphasising one — the phrase. */
|
|
308
|
+
function formattingEvents(t: ParsedTex, m: Macro): readonly Event[] {
|
|
309
|
+
const inner = walkList(t, lastArgument(m));
|
|
310
|
+
const style = EMPHASIS_MACROS.get(m.content);
|
|
311
|
+
return style === undefined
|
|
312
|
+
? inner
|
|
313
|
+
: [...emphasisOf(style, macroPlace(m), inner), ...inner];
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/** The style a group's opening declaration sets, when it opens with one (`{\bfseries …}`). */
|
|
317
|
+
function declaredStyle(list: readonly Node[]): EmphasisStyle | undefined {
|
|
318
|
+
const first = list.find(
|
|
319
|
+
(n) => n.type !== "whitespace" && n.type !== "comment",
|
|
320
|
+
);
|
|
321
|
+
return first?.type === "macro"
|
|
322
|
+
? EMPHASIS_DECLARATIONS.get(first.content)
|
|
323
|
+
: undefined;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
/** A group's events: what it typesets, and — when it opens with an emphasis declaration — the phrase. */
|
|
327
|
+
function groupEvents(t: ParsedTex, g: NodeOf<"group">): readonly Event[] {
|
|
328
|
+
const inner = walkList(t, g.content);
|
|
329
|
+
const style = declaredStyle(g.content);
|
|
330
|
+
return style === undefined
|
|
331
|
+
? inner
|
|
332
|
+
: [...emphasisOf(style, placeOf(g), inner), ...inner];
|
|
333
|
+
}
|
|
334
|
+
|
|
254
335
|
function macroEvents(t: ParsedTex, m: Macro): readonly Event[] {
|
|
255
336
|
const name = m.content;
|
|
256
337
|
if (DEFINITION_MACROS.has(name)) return [];
|
|
@@ -260,7 +341,7 @@ function macroEvents(t: ParsedTex, m: Macro): readonly Event[] {
|
|
|
260
341
|
const own = OWN_EVENTS.get(name);
|
|
261
342
|
if (own !== undefined) return own(t, m);
|
|
262
343
|
if (BREAK_MACROS.has(name)) return [BREAK];
|
|
263
|
-
if (INLINE_MACROS.has(name)) return
|
|
344
|
+
if (INLINE_MACROS.has(name)) return formattingEvents(t, m);
|
|
264
345
|
const ch = CHARACTER_MACROS.get(name);
|
|
265
346
|
return ch === undefined ? [] : characterAt(m, ch);
|
|
266
347
|
}
|
|
@@ -298,7 +379,7 @@ function nodeEvents(t: ParsedTex, n: Node): readonly Event[] {
|
|
|
298
379
|
case "whitespace":
|
|
299
380
|
return textAt(n, " ");
|
|
300
381
|
case "group":
|
|
301
|
-
return
|
|
382
|
+
return groupEvents(t, n);
|
|
302
383
|
case "environment":
|
|
303
384
|
case "mathenv":
|
|
304
385
|
return environmentEvents(t, n);
|
|
@@ -346,20 +427,12 @@ function step(
|
|
|
346
427
|
* macro the parser gave no signature — `\institution{…}`, `\ccsdesc[500]{…}`, `\tool{}`,
|
|
347
428
|
* `\if\conference1` — which are its arguments, not text. A formatting macro's group (`\enquote{…}` without csquotes' signature) stays prose.
|
|
348
429
|
*/
|
|
349
|
-
const proseNodes = (list: readonly Node[]): readonly Node[] =>
|
|
350
|
-
list.
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
const next = step(acc.state, n);
|
|
356
|
-
return {
|
|
357
|
-
kept: next.skip ? acc.kept : [...acc.kept, n],
|
|
358
|
-
state: next.state,
|
|
359
|
-
};
|
|
360
|
-
},
|
|
361
|
-
{ kept: [], state: "prose" },
|
|
362
|
-
).kept;
|
|
430
|
+
const proseNodes = (list: readonly Node[]): readonly Node[] => {
|
|
431
|
+
// Each node's step, fed the state the one before left: a scan, linear in the list.
|
|
432
|
+
const start: ReturnType<typeof step> = { skip: false, state: "prose" };
|
|
433
|
+
const steps = mapWithFeedback(list, (prev, n) => step(prev.state, n), start);
|
|
434
|
+
return zip(list, steps).flatMap(([n, st]) => (st.skip ? [] : [n]));
|
|
435
|
+
};
|
|
363
436
|
|
|
364
437
|
/** Formatting macros a run-in heading is set in: `\textbf{Correctness gate.} Each task…`. */
|
|
365
438
|
const RUN_IN_MACROS: ReadonlySet<string> = new Set([
|
|
@@ -378,11 +451,33 @@ const typeset = (events: readonly Event[]): string =>
|
|
|
378
451
|
)
|
|
379
452
|
.join("");
|
|
380
453
|
|
|
381
|
-
/**
|
|
382
|
-
const
|
|
383
|
-
n.type
|
|
384
|
-
|
|
385
|
-
|
|
454
|
+
/** The one node a group holds besides spaces and comments — `{\textbf{…}}` → the `\textbf` — or null. */
|
|
455
|
+
const soleChild = (n: Node): Node | null => {
|
|
456
|
+
if (n.type !== "group") return null;
|
|
457
|
+
const kept = n.content.filter(
|
|
458
|
+
(c) => c.type !== "whitespace" && c.type !== "comment",
|
|
459
|
+
);
|
|
460
|
+
return only(kept) ?? null;
|
|
461
|
+
};
|
|
462
|
+
|
|
463
|
+
/** A node with the groups around it taken off: `{{\textbf{…}}}` → the `\textbf`. */
|
|
464
|
+
const unwrapped = (n: Node): Node => {
|
|
465
|
+
const inner = soleChild(n);
|
|
466
|
+
return inner === null ? n : unwrapped(inner);
|
|
467
|
+
};
|
|
468
|
+
|
|
469
|
+
/**
|
|
470
|
+
* A formatting macro whose phrase ends in `.` or `:` — a run-in heading, when it opens a paragraph.
|
|
471
|
+
* Braces around it change nothing on the page, so `{\textbf{Threats:}}` is one too.
|
|
472
|
+
*/
|
|
473
|
+
const isRunInHeading = (t: ParsedTex, node: Node): boolean => {
|
|
474
|
+
const n = unwrapped(node);
|
|
475
|
+
return (
|
|
476
|
+
n.type === "macro" &&
|
|
477
|
+
RUN_IN_MACROS.has(n.content) &&
|
|
478
|
+
/[.:]$/u.test(typeset(walkList(t, lastArgument(n))).trim())
|
|
479
|
+
);
|
|
480
|
+
};
|
|
386
481
|
|
|
387
482
|
/** Macros that typeset no text, and so leave a paragraph's opening where it was. */
|
|
388
483
|
const SILENT_AT_OPENING: ReadonlySet<string> = new Set([
|
|
@@ -405,28 +500,47 @@ const keepsOpening = (n: Node): boolean =>
|
|
|
405
500
|
const opensAfter = (n: Node): boolean =>
|
|
406
501
|
n.type === "parbreak" || (n.type === "macro" && BREAK_MACROS.has(n.content));
|
|
407
502
|
|
|
503
|
+
/**
|
|
504
|
+
* An emphasising node's events when it opens a paragraph: its own phrase — the first event — is a
|
|
505
|
+
* label, not inline; braces around it change nothing on the page (`{\textbf{…}}.`). Any other node
|
|
506
|
+
* (an environment, a footnote) is left as it is: what it holds opens nothing here.
|
|
507
|
+
*/
|
|
508
|
+
const asOpening = (node: Node, events: readonly Event[]): readonly Event[] => {
|
|
509
|
+
const [own, ...rest] = events;
|
|
510
|
+
const n = unwrapped(node);
|
|
511
|
+
const emphasises =
|
|
512
|
+
(n.type === "macro" && EMPHASIS_MACROS.has(n.content)) ||
|
|
513
|
+
(n.type === "group" && declaredStyle(n.content) !== undefined);
|
|
514
|
+
return emphasises && own?.kind === "emphasis"
|
|
515
|
+
? [
|
|
516
|
+
{ kind: "emphasis", emphasis: { ...own.emphasis, place: "opening" } },
|
|
517
|
+
...rest,
|
|
518
|
+
]
|
|
519
|
+
: events;
|
|
520
|
+
};
|
|
521
|
+
|
|
408
522
|
/**
|
|
409
523
|
* A list's events. `opens` says the list starts a paragraph (the body, an environment's content);
|
|
410
|
-
* a run-in heading that opens a paragraph ends the passage before it and is left out, like a heading
|
|
524
|
+
* a run-in heading that opens a paragraph ends the passage before it and is left out, like a heading,
|
|
525
|
+
* and a phrase emphasised there is a label (`opening`).
|
|
411
526
|
*/
|
|
412
527
|
function walkList(
|
|
413
528
|
t: ParsedTex,
|
|
414
529
|
list: readonly Node[],
|
|
415
530
|
opens = false,
|
|
416
531
|
): readonly Event[] {
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
(
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
).events;
|
|
532
|
+
const nodes = proseNodes(list);
|
|
533
|
+
// Whether a paragraph is open AFTER each node: a scan, so the walk stays linear in the list.
|
|
534
|
+
const after = mapWithFeedback(
|
|
535
|
+
nodes,
|
|
536
|
+
(opening, n) => opensAfter(n) || (opening && keepsOpening(n)),
|
|
537
|
+
opens,
|
|
538
|
+
);
|
|
539
|
+
return nodes.flatMap((n, i) => {
|
|
540
|
+
const opening = i === 0 ? opens : after[i - 1] === true;
|
|
541
|
+
if (!opening) return nodeEvents(t, n);
|
|
542
|
+
return isRunInHeading(t, n) ? [BREAK] : asOpening(n, nodeEvents(t, n));
|
|
543
|
+
});
|
|
430
544
|
}
|
|
431
545
|
|
|
432
546
|
/** The events before the back matter starts. */
|
|
@@ -437,27 +551,35 @@ const untilEnd = (events: readonly Event[]): readonly Event[] => {
|
|
|
437
551
|
|
|
438
552
|
/** Events → passages: pieces between breaks, each footnote a passage of its own. */
|
|
439
553
|
function passagesOf(events: readonly Event[]): readonly Passage[] {
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
? { done: acc.done, current: [...acc.current, e.piece] }
|
|
447
|
-
: e.kind === "break"
|
|
448
|
-
? { done: [...acc.done, acc.current], current: [] }
|
|
449
|
-
: acc,
|
|
450
|
-
{ done: [], current: [] },
|
|
554
|
+
// Where each passage starts and ends: between breaks. Slicing between them keeps this linear in
|
|
555
|
+
// the events — a paragraph of thousands of words is one slice, not a copy per word.
|
|
556
|
+
const breaks = events.flatMap((e, i) => (e.kind === "break" ? [i] : []));
|
|
557
|
+
const bounds = zip(
|
|
558
|
+
[0, ...breaks.map((b) => b + 1)],
|
|
559
|
+
[...breaks, events.length],
|
|
451
560
|
);
|
|
561
|
+
const own = bounds.flatMap(([from, to]) => {
|
|
562
|
+
const pieces = events
|
|
563
|
+
.slice(from, to)
|
|
564
|
+
.flatMap((e) => (e.kind === "piece" ? [e.piece] : []));
|
|
565
|
+
return hasAtLeast(pieces, 1) ? [{ pieces }] : [];
|
|
566
|
+
});
|
|
452
567
|
const asides = events.flatMap((e) =>
|
|
453
568
|
e.kind === "aside" ? passagesOf(untilEnd(e.events)) : [],
|
|
454
569
|
);
|
|
455
|
-
const own = [...done, current].flatMap((pieces) =>
|
|
456
|
-
hasAtLeast(pieces, 1) ? [{ pieces }] : [],
|
|
457
|
-
);
|
|
458
570
|
return [...own, ...asides];
|
|
459
571
|
}
|
|
460
572
|
|
|
573
|
+
/** Every emphasised phrase among events, a footnote's included, up to the back matter. */
|
|
574
|
+
const emphasesOf = (events: readonly Event[]): readonly Emphasis[] =>
|
|
575
|
+
untilEnd(events).flatMap((e) =>
|
|
576
|
+
e.kind === "emphasis"
|
|
577
|
+
? [e.emphasis]
|
|
578
|
+
: e.kind === "aside"
|
|
579
|
+
? emphasesOf(e.events)
|
|
580
|
+
: [],
|
|
581
|
+
);
|
|
582
|
+
|
|
461
583
|
const isDocument = (n: Node): n is Readonly<Ast.Environment> =>
|
|
462
584
|
n.type === "environment" && n.env === "document";
|
|
463
585
|
|
|
@@ -476,9 +598,17 @@ const startOf = (p: Passage): number => {
|
|
|
476
598
|
* source when there is none), up to the first back-matter node.
|
|
477
599
|
*/
|
|
478
600
|
export function bodyProse(t: ParsedTex): readonly Passage[] {
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
601
|
+
return sortBy(passagesOf(untilEnd(bodyEvents(t))).filter(hasProse), startOf);
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
/** The walk of the body: the content of the `document` environment, or the whole source when there is none. */
|
|
605
|
+
const bodyEvents = (t: ParsedTex): readonly Event[] =>
|
|
606
|
+
walkList(t, t.root.content.find(isDocument)?.content ?? t.root.content, true);
|
|
607
|
+
|
|
608
|
+
/**
|
|
609
|
+
* The phrases set in bold, italics or underline inside the body's prose, in document order: what
|
|
610
|
+
* `bodyProse` reads, so never a heading, a run-in heading, a caption, a float or a table.
|
|
611
|
+
*/
|
|
612
|
+
export function bodyEmphasis(t: ParsedTex): readonly Emphasis[] {
|
|
613
|
+
return sortBy(emphasesOf(bodyEvents(t)), (e) => e.span.start);
|
|
484
614
|
}
|
package/src/build.harness.mjs
CHANGED
|
@@ -289,8 +289,8 @@ try {
|
|
|
289
289
|
if (existsSync(factsFile)) {
|
|
290
290
|
const f = JSON.parse(readFileSync(factsFile, "utf8"));
|
|
291
291
|
check(
|
|
292
|
-
"measure: the facts are schema
|
|
293
|
-
f.schema ===
|
|
292
|
+
"measure: the facts are schema 3, about paper.pdf, with the last page's heights",
|
|
293
|
+
f.schema === 3 &&
|
|
294
294
|
f.pdf === "paper.pdf" &&
|
|
295
295
|
JSON.stringify(f.last_page_cols_pt) === "[400,400]",
|
|
296
296
|
);
|
package/src/build.test.ts
CHANGED
package/src/cli.test.ts
CHANGED
|
@@ -578,3 +578,16 @@ test("`paperlint hook` with no name: exit 2, and the usage names an example", as
|
|
|
578
578
|
err: "`hook` needs a name, e.g. `paperlint hook paper-edit-guard`",
|
|
579
579
|
});
|
|
580
580
|
});
|
|
581
|
+
|
|
582
|
+
test("parseSettings refuses a root `identity` that is not a list of words, and takes one that is", () => {
|
|
583
|
+
const bad = parseSettings({ identity: "Ada" }, "paperlint.json", "/r");
|
|
584
|
+
assert.deepEqual(bad, {
|
|
585
|
+
ok: false,
|
|
586
|
+
error:
|
|
587
|
+
'paperlint.json: "identity" must be a list of strings, each with a letter or digit, got "Ada"',
|
|
588
|
+
});
|
|
589
|
+
assert.equal(
|
|
590
|
+
parseSettings({ identity: ["Ada Example"] }, "paperlint.json", "/r").ok,
|
|
591
|
+
true,
|
|
592
|
+
);
|
|
593
|
+
});
|