paperlint 6.0.0 → 6.2.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 (141) hide show
  1. package/dist/adapters/latex/index.d.ts +1 -1
  2. package/dist/adapters/latex/index.d.ts.map +1 -1
  3. package/dist/adapters/latex/index.js +3 -2
  4. package/dist/adapters/latex/index.js.map +1 -1
  5. package/dist/adapters/latex/prose.d.ts +6 -1
  6. package/dist/adapters/latex/prose.d.ts.map +1 -1
  7. package/dist/adapters/latex/prose.js +142 -31
  8. package/dist/adapters/latex/prose.js.map +1 -1
  9. package/dist/adapters/mp4box/index.d.ts +4 -0
  10. package/dist/adapters/mp4box/index.d.ts.map +1 -0
  11. package/dist/adapters/mp4box/index.js +46 -0
  12. package/dist/adapters/mp4box/index.js.map +1 -0
  13. package/dist/adapters/node/index.d.ts +1 -0
  14. package/dist/adapters/node/index.d.ts.map +1 -1
  15. package/dist/adapters/node/index.js +1 -0
  16. package/dist/adapters/node/index.js.map +1 -1
  17. package/dist/adapters/node/list-dir.io.d.ts +3 -0
  18. package/dist/adapters/node/list-dir.io.d.ts.map +1 -0
  19. package/dist/adapters/node/list-dir.io.js +15 -0
  20. package/dist/adapters/node/list-dir.io.js.map +1 -0
  21. package/dist/adapters/png/index.d.ts +11 -0
  22. package/dist/adapters/png/index.d.ts.map +1 -0
  23. package/dist/adapters/png/index.js +21 -0
  24. package/dist/adapters/png/index.js.map +1 -0
  25. package/dist/adapters/srt/index.d.ts +4 -0
  26. package/dist/adapters/srt/index.d.ts.map +1 -0
  27. package/dist/adapters/srt/index.js +12 -0
  28. package/dist/adapters/srt/index.js.map +1 -0
  29. package/dist/cli.d.ts.map +1 -1
  30. package/dist/cli.js +40 -22
  31. package/dist/cli.js.map +1 -1
  32. package/dist/domain/register.d.ts +96 -0
  33. package/dist/domain/register.d.ts.map +1 -0
  34. package/dist/domain/register.js +136 -0
  35. package/dist/domain/register.js.map +1 -0
  36. package/dist/domain/talk.d.ts +121 -0
  37. package/dist/domain/talk.d.ts.map +1 -0
  38. package/dist/domain/talk.js +121 -0
  39. package/dist/domain/talk.js.map +1 -0
  40. package/dist/domain/tex-document.d.ts +19 -0
  41. package/dist/domain/tex-document.d.ts.map +1 -1
  42. package/dist/domain/tex-document.js +8 -3
  43. package/dist/domain/tex-document.js.map +1 -1
  44. package/dist/lib/paper-config.d.ts +2 -0
  45. package/dist/lib/paper-config.d.ts.map +1 -1
  46. package/dist/lib/paper-config.js +3 -0
  47. package/dist/lib/paper-config.js.map +1 -1
  48. package/dist/paper-settings.d.ts +3 -0
  49. package/dist/paper-settings.d.ts.map +1 -1
  50. package/dist/paper-settings.js +15 -9
  51. package/dist/paper-settings.js.map +1 -1
  52. package/dist/ports/latex.d.ts +6 -1
  53. package/dist/ports/latex.d.ts.map +1 -1
  54. package/dist/ports/talk-media.d.ts +18 -0
  55. package/dist/ports/talk-media.d.ts.map +1 -0
  56. package/dist/ports/talk-media.js +2 -0
  57. package/dist/ports/talk-media.js.map +1 -0
  58. package/dist/presets.d.ts +6 -0
  59. package/dist/presets.d.ts.map +1 -1
  60. package/dist/presets.js +8 -9
  61. package/dist/presets.js.map +1 -1
  62. package/dist/register-bands.d.ts +37 -0
  63. package/dist/register-bands.d.ts.map +1 -0
  64. package/dist/register-bands.js +196 -0
  65. package/dist/register-bands.js.map +1 -0
  66. package/dist/register.d.ts +4 -2
  67. package/dist/register.d.ts.map +1 -1
  68. package/dist/register.js +6 -45
  69. package/dist/register.js.map +1 -1
  70. package/dist/talk-rules.d.ts +65 -0
  71. package/dist/talk-rules.d.ts.map +1 -0
  72. package/dist/talk-rules.js +451 -0
  73. package/dist/talk-rules.js.map +1 -0
  74. package/dist/tex-requirements.d.ts +9 -0
  75. package/dist/tex-requirements.d.ts.map +1 -1
  76. package/dist/tex-requirements.js +28 -6
  77. package/dist/tex-requirements.js.map +1 -1
  78. package/dist/tex-venue-rules.d.ts +1 -1
  79. package/dist/tex-venue-rules.d.ts.map +1 -1
  80. package/dist/tex-venue-rules.js.map +1 -1
  81. package/docs/configuration.md +2 -1
  82. package/docs/rules/talk/captions-cover.md +47 -0
  83. package/docs/rules/talk/duration-floor.md +38 -0
  84. package/docs/rules/talk/duration.md +43 -0
  85. package/docs/rules/talk/one-slide-size.md +38 -0
  86. package/docs/rules/talk/profile.md +64 -0
  87. package/docs/rules/talk/required-files.md +39 -0
  88. package/docs/rules/talk/undeclared.md +35 -0
  89. package/docs/rules/talk/video-format.md +43 -0
  90. package/docs/rules/tex/claim-emphasis.md +71 -0
  91. package/docs/rules/tex/contrast-frames.md +100 -0
  92. package/docs/rules/tex/relation-markers.md +72 -0
  93. package/docs/rules.md +57 -42
  94. package/docs/talk.md +94 -0
  95. package/fixtures/talk/audio-only.mp4 +0 -0
  96. package/fixtures/talk/one-slide.png +0 -0
  97. package/fixtures/talk/short-audio.mp4 +0 -0
  98. package/fixtures/talk/talk.mov +0 -0
  99. package/fixtures/talk/talk.mp4 +0 -0
  100. package/fixtures/talk/talk.srt +7 -0
  101. package/lib/paper-config.harness.mjs +5 -2
  102. package/lib/paper-config.ts +4 -0
  103. package/package.json +3 -1
  104. package/presets/agenticdev.jsonc +29 -0
  105. package/presets/aidc.jsonc +50 -0
  106. package/presets/venue-profile.schema.json +180 -1
  107. package/skills/grade-paper-writing/SKILL.md +59 -27
  108. package/skills/grade-paper-writing/SKILL.md.spec.ts +58 -26
  109. package/src/adapters/latex/index.test.ts +91 -0
  110. package/src/adapters/latex/index.ts +3 -2
  111. package/src/adapters/latex/prose.ts +188 -58
  112. package/src/adapters/mp4box/index.test.ts +63 -0
  113. package/src/adapters/mp4box/index.ts +50 -0
  114. package/src/adapters/node/index.ts +1 -0
  115. package/src/adapters/node/list-dir.io.ts +15 -0
  116. package/src/adapters/node/list-dir.test.ts +30 -0
  117. package/src/adapters/png/index.test.ts +43 -0
  118. package/src/adapters/png/index.ts +29 -0
  119. package/src/adapters/srt/index.test.ts +24 -0
  120. package/src/adapters/srt/index.ts +13 -0
  121. package/src/cli.test.ts +12 -0
  122. package/src/cli.ts +50 -21
  123. package/src/domain/register.test.ts +153 -0
  124. package/src/domain/register.ts +261 -0
  125. package/src/domain/talk.test.ts +125 -0
  126. package/src/domain/talk.ts +274 -0
  127. package/src/domain/tex-document.ts +37 -3
  128. package/src/new-paper.test.ts +1 -0
  129. package/src/paper-settings.test.ts +18 -1
  130. package/src/paper-settings.ts +21 -11
  131. package/src/ports/latex.ts +6 -0
  132. package/src/ports/talk-media.ts +19 -0
  133. package/src/presets.ts +14 -9
  134. package/src/register-bands.test.ts +327 -0
  135. package/src/register-bands.ts +291 -0
  136. package/src/register.ts +7 -37
  137. package/src/rule-cases.test.ts +177 -20
  138. package/src/talk-rules.test.ts +459 -0
  139. package/src/talk-rules.ts +672 -0
  140. package/src/tex-requirements.ts +56 -8
  141. package/src/tex-venue-rules.ts +7 -1
@@ -0,0 +1,274 @@
1
+ /**
2
+ * THE TALK — what a venue asks a presenter to send, and what one paper declares about its talk.
3
+ *
4
+ * paperlint does not make a talk. It judges the finished files (the video, the one-slide image, the
5
+ * captions) against the venue's numbers: however the video was made, the venue limits the file.
6
+ *
7
+ * venue preset `talk` the modes the venue allows, what each mode owes, the slot of each kind
8
+ * of paper, the video's format, the one-slide's size
9
+ * paper `talk` the mode this paper presents in, the folder the files are in, and their
10
+ * names when they are not the defaults
11
+ */
12
+ import { err, ok, type Result } from "./result.ts";
13
+ import { fieldOf, isRecord } from "./record.ts";
14
+
15
+ /** How a paper is presented. */
16
+ export type TalkMode = "in-person" | "remote-video" | "remote-live";
17
+ export const TALK_MODES: readonly TalkMode[] = [
18
+ "in-person",
19
+ "remote-video",
20
+ "remote-live",
21
+ ];
22
+
23
+ /** A file a mode can owe. */
24
+ export type TalkArtifact =
25
+ "video" | "one-slide" | "captions" | "slides-pdf" | "poster";
26
+ export const TALK_ARTIFACTS: readonly TalkArtifact[] = [
27
+ "video",
28
+ "one-slide",
29
+ "captions",
30
+ "slides-pdf",
31
+ "poster",
32
+ ];
33
+
34
+ /** What one mode owes. */
35
+ export interface TalkObligation {
36
+ readonly required: readonly TalkArtifact[];
37
+ readonly optional: readonly TalkArtifact[];
38
+ }
39
+
40
+ /** The talk slot of one kind of paper, in seconds. */
41
+ export interface TalkSlot {
42
+ /** The whole slot, talk and questions. Informational. */
43
+ readonly slotS: number;
44
+ /** The shortest talk the venue asks for; null when it sets no floor. */
45
+ readonly talkSMin: number | null;
46
+ /** The longest talk. */
47
+ readonly talkSMax: number;
48
+ /** The questions' share. Informational. */
49
+ readonly qaS: number | null;
50
+ }
51
+
52
+ /** A pixel size. */
53
+ export interface PixelSize {
54
+ readonly width: number;
55
+ readonly height: number;
56
+ }
57
+
58
+ /** The venue's limits on the video file. Every field null when the venue says nothing of it. */
59
+ export interface VideoLimits {
60
+ readonly container: "mp4" | null;
61
+ readonly minHeightPx: number | null;
62
+ readonly maxBytes: number | null;
63
+ readonly captions: "required" | "optional" | null;
64
+ }
65
+
66
+ /** A venue preset's `talk` block, parsed. */
67
+ export interface VenueTalk {
68
+ /** The modes the venue allows; empty when the block names none. */
69
+ readonly modes: readonly TalkMode[];
70
+ readonly artifacts: ReadonlyMap<TalkMode, TalkObligation>;
71
+ /** Slots by paper kind — the names of `format.kinds`. */
72
+ readonly kinds: ReadonlyMap<string, TalkSlot>;
73
+ readonly video: VideoLimits;
74
+ /** The one-slide image's size; null when the venue does not state one. */
75
+ readonly oneSlide: PixelSize | null;
76
+ }
77
+
78
+ /** The artifact → the file name it is looked for under, in the talk folder. */
79
+ export type TalkFiles = Readonly<Record<TalkArtifact, string>>;
80
+
81
+ /** The default talk folder, relative to the paper. */
82
+ export const DEFAULT_TALK_DIR = "talk";
83
+
84
+ /** The file each artifact is looked for under, unless the paper names another. */
85
+ export const DEFAULT_TALK_FILES: TalkFiles = {
86
+ video: "talk.mp4",
87
+ "one-slide": "one-slide.png",
88
+ captions: "talk.srt",
89
+ "slides-pdf": "slides.pdf",
90
+ poster: "poster.pdf",
91
+ };
92
+
93
+ /** A paper's `talk` key, parsed. */
94
+ export interface PaperTalk {
95
+ readonly mode: TalkMode;
96
+ /** The talk folder, relative to the paper. */
97
+ readonly dir: string;
98
+ readonly files: TalkFiles;
99
+ /** The one-slide's size when the paper states it (the venue did not); null otherwise. */
100
+ readonly oneSlide: PixelSize | null;
101
+ }
102
+
103
+ // ── measurements the rules judge ──────────────────────────────────────────────────────────
104
+
105
+ /** What the video file says about itself. */
106
+ export interface VideoProbe {
107
+ /** The container's duration (movie header), in seconds. */
108
+ readonly durationS: number;
109
+ /** The first audio track's duration, in seconds; null when the file has no audio track. */
110
+ readonly audioS: number | null;
111
+ /** The first video track's height in pixels; null when the file has no video track. */
112
+ readonly heightPx: number | null;
113
+ readonly widthPx: number | null;
114
+ /** The file's brands (ftyp): `isom`, `mp41`… for MP4, `qt ` for QuickTime. */
115
+ readonly brands: readonly string[];
116
+ }
117
+
118
+ /** One caption cue, in seconds. */
119
+ export interface Cue {
120
+ readonly startS: number;
121
+ readonly endS: number;
122
+ }
123
+
124
+ // ── the preset's `talk` block, as the schema admits it ───────────────────────────────────
125
+
126
+ /** A preset's `talk` block after `venue-profile.schema.json` accepted it. */
127
+ export interface VenueTalkJson {
128
+ readonly modes?: readonly TalkMode[];
129
+ readonly artifacts?: Readonly<
130
+ Partial<
131
+ Record<
132
+ TalkMode,
133
+ {
134
+ readonly required: readonly TalkArtifact[];
135
+ readonly optional?: readonly TalkArtifact[];
136
+ }
137
+ >
138
+ >
139
+ >;
140
+ readonly kinds?: Readonly<
141
+ Record<
142
+ string,
143
+ {
144
+ readonly slot_s: number;
145
+ readonly talk_s_min?: number;
146
+ readonly talk_s_max: number;
147
+ readonly qa_s?: number;
148
+ }
149
+ >
150
+ >;
151
+ readonly video?: {
152
+ readonly container?: "mp4";
153
+ readonly min_height_px?: number;
154
+ readonly max_bytes?: number;
155
+ readonly captions?: "required" | "optional";
156
+ };
157
+ readonly one_slide?: {
158
+ readonly width_px: number;
159
+ readonly height_px: number;
160
+ };
161
+ }
162
+
163
+ type ArtifactsJson = NonNullable<VenueTalkJson["artifacts"]>;
164
+ type KindsJson = NonNullable<VenueTalkJson["kinds"]>;
165
+
166
+ const artifactsOf = (j: ArtifactsJson): ReadonlyMap<TalkMode, TalkObligation> =>
167
+ new Map(
168
+ TALK_MODES.flatMap((mode) => {
169
+ const o = j[mode];
170
+ return o === undefined
171
+ ? []
172
+ : [[mode, { required: o.required, optional: o.optional ?? [] }]];
173
+ }),
174
+ );
175
+
176
+ const kindsOf = (j: KindsJson): ReadonlyMap<string, TalkSlot> =>
177
+ new Map(
178
+ Object.entries(j).map(([name, k]) => [
179
+ name,
180
+ {
181
+ slotS: k.slot_s,
182
+ talkSMin: k.talk_s_min ?? null,
183
+ talkSMax: k.talk_s_max,
184
+ qaS: k.qa_s ?? null,
185
+ },
186
+ ]),
187
+ );
188
+
189
+ const videoOf = (v: VenueTalkJson["video"] = {}): VideoLimits => ({
190
+ container: v.container ?? null,
191
+ minHeightPx: v.min_height_px ?? null,
192
+ maxBytes: v.max_bytes ?? null,
193
+ captions: v.captions ?? null,
194
+ });
195
+
196
+ /** The schema-checked block → the typed venue talk. Pure. */
197
+ export const venueTalkOf = (j: VenueTalkJson): VenueTalk => ({
198
+ modes: j.modes ?? [],
199
+ artifacts: artifactsOf(j.artifacts ?? {}),
200
+ kinds: kindsOf(j.kinds ?? {}),
201
+ video: videoOf(j.video),
202
+ oneSlide:
203
+ j.one_slide === undefined
204
+ ? null
205
+ : { width: j.one_slide.width_px, height: j.one_slide.height_px },
206
+ });
207
+
208
+ // ── a paper's `talk` key ───────────────────────────────────────────────────────────────────
209
+
210
+ const PAPER_TALK_KEYS = ["mode", "dir", "files", "one_slide"];
211
+
212
+ const isMode = (v: unknown): v is TalkMode => TALK_MODES.some((m) => m === v);
213
+ const isArtifact = (v: string): v is TalkArtifact =>
214
+ TALK_ARTIFACTS.some((a) => a === v);
215
+ const isPlainName = (v: unknown): v is string =>
216
+ typeof v === "string" && v !== "" && !v.includes("/") && !v.includes("\\");
217
+ const isPositiveInt = (v: unknown): v is number =>
218
+ typeof v === "number" && Number.isInteger(v) && v > 0;
219
+
220
+ /** `files`: each key an artifact, each value a file name in the talk folder. */
221
+ function filesOf(v: unknown): Result<TalkFiles, string> {
222
+ if (v === undefined) return ok(DEFAULT_TALK_FILES);
223
+ if (!isRecord(v)) return err(`"talk.files" must be an object`);
224
+ const bad = Object.entries(v).find(
225
+ ([k, name]) => !isArtifact(k) || !isPlainName(name),
226
+ );
227
+ if (bad !== undefined)
228
+ return err(
229
+ `"talk.files": "${bad[0]}" must be one of ${TALK_ARTIFACTS.join(", ")}, naming a file in the talk folder (no slashes)`,
230
+ );
231
+ const named = Object.fromEntries(
232
+ Object.entries(v).filter(
233
+ (e): e is [string, string] => typeof e[1] === "string",
234
+ ),
235
+ );
236
+ return ok({ ...DEFAULT_TALK_FILES, ...named });
237
+ }
238
+
239
+ /** `one_slide`: `{ width_px, height_px }`, both positive integers. */
240
+ function oneSlideOf(v: unknown): Result<PixelSize | null, string> {
241
+ if (v === undefined) return ok(null);
242
+ const w = fieldOf(v, "width_px");
243
+ const h = fieldOf(v, "height_px");
244
+ return isPositiveInt(w) && isPositiveInt(h)
245
+ ? ok({ width: w, height: h })
246
+ : err(
247
+ `"talk.one_slide" must be { "width_px": <integer>, "height_px": <integer> }`,
248
+ );
249
+ }
250
+
251
+ /**
252
+ * A paper's `talk` value → the typed declaration; null when absent. Strict like the rest of the
253
+ * file: an unknown key is refused by name, or a typo would read as "not set".
254
+ */
255
+ export function parsePaperTalk(v: unknown): Result<PaperTalk | null, string> {
256
+ if (v === undefined) return ok(null);
257
+ if (!isRecord(v)) return err(`"talk" must be an object with a "mode"`);
258
+ const unknown = Object.keys(v).find((k) => !PAPER_TALK_KEYS.includes(k));
259
+ if (unknown !== undefined)
260
+ return err(
261
+ `"talk" has an unknown key "${unknown}" — known: ${PAPER_TALK_KEYS.join(", ")}`,
262
+ );
263
+ const mode = v["mode"];
264
+ if (!isMode(mode))
265
+ return err(`"talk.mode" must be one of ${TALK_MODES.join(", ")}`);
266
+ const dir = v["dir"] ?? DEFAULT_TALK_DIR;
267
+ if (typeof dir !== "string" || dir === "")
268
+ return err(`"talk.dir" must be a folder relative to the paper`);
269
+ const files = filesOf(v["files"]);
270
+ if (!files.ok) return files;
271
+ const oneSlide = oneSlideOf(v["one_slide"]);
272
+ if (!oneSlide.ok) return oneSlide;
273
+ return ok({ mode, dir, files: files.value, oneSlide: oneSlide.value });
274
+ }
@@ -110,9 +110,21 @@ export const runText = (run: TextRun): string =>
110
110
 
111
111
  /** The source offset of the `i`-th character of the text these segments make, or null outside it. */
112
112
  function offsetIn(segments: readonly Segment[], i: number): number | null {
113
- const [s, ...rest] = segments;
114
- if (s === undefined || i < 0) return null;
115
- return i < s.text.length ? s.at + i : offsetIn(rest, i - s.text.length);
113
+ if (i < 0) return null;
114
+ // One pass, no copy of the rest per segment: a paragraph's run holds thousands of segments.
115
+ const found = segments.reduce<{
116
+ readonly rest: number;
117
+ readonly at: number | null;
118
+ }>(
119
+ (acc, s) =>
120
+ acc.at !== null
121
+ ? acc
122
+ : acc.rest < s.text.length
123
+ ? { rest: acc.rest, at: s.at + acc.rest }
124
+ : { rest: acc.rest - s.text.length, at: null },
125
+ { rest: i, at: null },
126
+ );
127
+ return found.at;
116
128
  }
117
129
 
118
130
  /**
@@ -142,6 +154,28 @@ export interface Passage {
142
154
  readonly pieces: readonly [ProsePiece, ...ProsePiece[]];
143
155
  }
144
156
 
157
+ /** How an emphasised phrase is set: bold, italic (or `\emph`), or underlined. */
158
+ export type EmphasisStyle = "bold" | "italic" | "underline";
159
+
160
+ /**
161
+ * Where an emphasised phrase stands: opening its paragraph or list item — a label, set like a run-in
162
+ * heading even without the full stop (`\item \textbf{A corpus of questions}: …`) — or inside the
163
+ * running text, after other words of its sentence.
164
+ */
165
+ export type EmphasisPlace = "opening" | "inline";
166
+
167
+ /**
168
+ * A phrase set in bold, italics or underline inside the body's running prose — not a heading, a
169
+ * run-in heading, a caption, a float or a table, which are never prose (`LatexReader.bodyProse`).
170
+ * `text` is what it typesets, marks and math left out.
171
+ */
172
+ export interface Emphasis {
173
+ readonly style: EmphasisStyle;
174
+ readonly place: EmphasisPlace;
175
+ readonly text: string;
176
+ readonly span: Span;
177
+ }
178
+
145
179
  /**
146
180
  * A command that changes the page layout a venue's template sets: the text block, the margins,
147
181
  * the line spacing, or space pulled back with a negative skip. `command` is how a message names it
@@ -57,6 +57,7 @@ describe("paperlint new — paperlint.json", () => {
57
57
  pdf: null,
58
58
  rules: null,
59
59
  identity: null,
60
+ talk: null,
60
61
  },
61
62
  });
62
63
  });
@@ -31,6 +31,7 @@ describe("parsePaperSettings", () => {
31
31
  pdf: null,
32
32
  rules: null,
33
33
  identity: null,
34
+ talk: null,
34
35
  },
35
36
  });
36
37
  expect(parsePaperSettings({})).toEqual({
@@ -41,6 +42,7 @@ describe("parsePaperSettings", () => {
41
42
  pdf: null,
42
43
  rules: null,
43
44
  identity: null,
45
+ talk: null,
44
46
  },
45
47
  });
46
48
  });
@@ -54,6 +56,7 @@ describe("parsePaperSettings", () => {
54
56
  pdf: null,
55
57
  rules: null,
56
58
  identity: null,
59
+ talk: null,
57
60
  },
58
61
  });
59
62
  });
@@ -152,7 +155,7 @@ describe("readPaperSettings — a root paperlint.json that cannot be used", () =
152
155
  });
153
156
 
154
157
  /** The fields a case does not set. */
155
- const UNSET = { pdf: null, rules: null, identity: null };
158
+ const UNSET = { pdf: null, rules: null, identity: null, talk: null };
156
159
 
157
160
  describe("readPaperSettings — the root paperlint.json's defaults, the paper's file over them", () => {
158
161
  const ROOT = "/work";
@@ -206,6 +209,7 @@ describe("readPaperSettings — the root paperlint.json's defaults, the paper's
206
209
  // The root's rules are the project's blocks (cli.ts), not this paper's.
207
210
  rules: { "pdf/fonts": "off" },
208
211
  identity: null,
212
+ talk: null,
209
213
  });
210
214
  });
211
215
 
@@ -248,6 +252,7 @@ describe("paperRules — `rules` in paperlint.json", () => {
248
252
  pdf: null,
249
253
  rules,
250
254
  identity: null,
255
+ talk: null,
251
256
  });
252
257
 
253
258
  it("no rules: none", () => {
@@ -324,3 +329,15 @@ describe("readPaperSettings — identity, at both levels", () => {
324
329
  );
325
330
  });
326
331
  });
332
+
333
+ describe("talk in a paper's paperlint.json", () => {
334
+ it("is parsed into the paper's settings", () => {
335
+ const r = parsePaperSettings({ talk: { mode: "remote-video" } });
336
+ expect(r.ok && r.value.talk?.mode).toBe("remote-video");
337
+ });
338
+
339
+ it("refuses a talk it cannot parse, naming the key", () => {
340
+ const r = parsePaperSettings({ talk: { mode: "zoom" } });
341
+ expect(r.ok ? "" : r.error).toMatch(/"talk.mode"/);
342
+ });
343
+ });
@@ -31,6 +31,7 @@ import {
31
31
  type RuleEntry,
32
32
  } from "./rules-config.ts";
33
33
  import { messageOf } from "./domain/text.ts";
34
+ import { parsePaperTalk, type PaperTalk } from "./domain/talk.ts";
34
35
 
35
36
  /** What one paper declares. Every absent field is null. */
36
37
  export interface PaperSettings {
@@ -51,6 +52,8 @@ export interface PaperSettings {
51
52
  * and cannot drop them by accident. Null when neither declares one.
52
53
  */
53
54
  readonly identity: readonly string[] | null;
55
+ /** How this paper is presented (`talk/*`); null when it declares no talk. Never the root's. */
56
+ readonly talk: PaperTalk | null;
54
57
  }
55
58
 
56
59
  /** Why a paper's settings cannot be read. */
@@ -109,6 +112,19 @@ export function identityField(
109
112
  );
110
113
  }
111
114
 
115
+ /** `rules` (checked for shape only) and `talk`, each null when absent. */
116
+ function rulesAndTalk(
117
+ json: Readonly<Record<string, unknown>>,
118
+ ): Result<Pick<PaperSettings, "rules" | "talk">, string> {
119
+ const rules = json["rules"];
120
+ if (rules !== undefined && !isObject(rules) && !Array.isArray(rules))
121
+ return err(
122
+ `"rules" must be an object of rule id → severity, or a list of ESLint blocks`,
123
+ );
124
+ const talk = parsePaperTalk(json["talk"]);
125
+ return talk.ok ? ok({ rules: rules ?? null, talk: talk.value }) : talk;
126
+ }
127
+
112
128
  /** The parsed JSON of `paperlint.json` → the settings, or one line saying what is wrong. Pure. */
113
129
  export function parsePaperSettings(
114
130
  json: unknown,
@@ -128,16 +144,9 @@ export function parsePaperSettings(
128
144
  if (!fields.ok) return fields;
129
145
  const identity = identityField(json);
130
146
  if (!identity.ok) return identity;
131
- const rules = json["rules"];
132
- if (rules !== undefined && !isObject(rules) && !Array.isArray(rules))
133
- return err(
134
- `"rules" must be an object of rule id → severity, or a list of ESLint blocks`,
135
- );
136
- return ok({
137
- ...fields.value,
138
- rules: rules ?? null,
139
- identity: identity.value,
140
- });
147
+ const rest = rulesAndTalk(json);
148
+ if (!rest.ok) return rest;
149
+ return ok({ ...fields.value, ...rest.value, identity: identity.value });
141
150
  }
142
151
 
143
152
  /** A JSON file through `files`: undefined when absent, the problem when it does not parse. */
@@ -223,13 +232,14 @@ function merge(
223
232
  paper: PaperSettings | null,
224
233
  ): PaperSettings | null {
225
234
  const r = root ?? NO_DEFAULTS;
226
- const p = paper ?? { ...NO_DEFAULTS, rules: null };
235
+ const p = paper ?? { ...NO_DEFAULTS, rules: null, talk: null };
227
236
  const merged: PaperSettings = {
228
237
  extends: p.extends ?? r.extends,
229
238
  kind: p.kind ?? r.kind,
230
239
  pdf: p.pdf ?? r.pdf,
231
240
  rules: p.rules,
232
241
  identity: joined(r.identity, p.identity),
242
+ talk: p.talk,
233
243
  };
234
244
  return paper === null && Object.values(merged).every((v) => v === null)
235
245
  ? null
@@ -6,6 +6,7 @@ import type { Include } from "../domain/paper-source.ts";
6
6
  import type {
7
7
  ClassLine,
8
8
  DocumentClass,
9
+ Emphasis,
9
10
  LayoutOverride,
10
11
  Outline,
11
12
  Passage,
@@ -26,6 +27,11 @@ export interface LatexReader {
26
27
  * code or the title block. Citations, links and cross-references stay in place as marks.
27
28
  */
28
29
  readonly bodyProse: (src: string) => readonly Passage[];
30
+ /**
31
+ * The phrases set in bold, italics or underline inside that same prose, in document order — never
32
+ * a heading, a run-in heading (`\textbf{Threats.} …`), a caption, a float or a table.
33
+ */
34
+ readonly bodyEmphasis: (src: string) => readonly Emphasis[];
29
35
  /** Every `\input`, `\include` and `\subfile`, in source order: the path as written, where it stands. */
30
36
  readonly includes: (src: string) => readonly Include[];
31
37
  /** The span of the `document` environment's body, or null when the source has none. */
@@ -0,0 +1,19 @@
1
+ /**
2
+ * Reading a talk's files: what the talk rules need to know about a video, an image and captions.
3
+ * Shaped by the questions the rules ask, not by any tool: the adapters behind it are MP4Box.js,
4
+ * a PNG header reader and an SRT parser (`src/adapters/`).
5
+ */
6
+ import type { AbsolutePath } from "../domain/paths.ts";
7
+ import type { Cue, PixelSize, VideoProbe } from "../domain/talk.ts";
8
+
9
+ export interface TalkMedia {
10
+ /** The video's durations, size and brands; null when the bytes are not an ISO media file. */
11
+ probeVideo(bytes: Uint8Array): VideoProbe | null;
12
+ /** The image's size; null when the bytes are not a PNG. */
13
+ imageSize(bytes: Uint8Array): PixelSize | null;
14
+ /** The cues of a captions file, in file order; empty when it holds none. */
15
+ captions(text: string): readonly Cue[];
16
+ }
17
+
18
+ /** The names in a directory; empty when there is no such directory. */
19
+ export type ListDir = (dir: AbsolutePath) => readonly string[];
package/src/presets.ts CHANGED
@@ -19,7 +19,7 @@
19
19
  * tex union — a child never removes a package its parent needs
20
20
  * format per key, the child wins; `kinds` by kind name, a child's kind replaces that kind
21
21
  * rules per rule id, the child wins
22
- * template the child wins; so does `name`, and `required_sections` (the whole list)
22
+ * template the child wins; so does `name`, and `required_sections`, `register` and `talk` (each whole)
23
23
  * aliases union, with every `name` — what the venue is called along the chain
24
24
  *
25
25
  * ── THE LABEL ─────────────────────────────────────────────────────────────────────
@@ -51,6 +51,8 @@ import {
51
51
  } from "./paper-settings.ts";
52
52
  import { CONFIG_FILE } from "#lib/paper-config";
53
53
  import { messageOf } from "./domain/text.ts";
54
+ import type { RegisterAnchor } from "./domain/register.ts";
55
+ import type { VenueTalk } from "./domain/talk.ts";
54
56
 
55
57
  /** The prefix of a shipped preset's spec. */
56
58
  export const SHIPPED_PREFIX = "paperlint:";
@@ -89,6 +91,10 @@ export interface Preset {
89
91
  readonly tex: TexRequirements;
90
92
  readonly format: VenueFormat;
91
93
  readonly rules: Readonly<Record<string, unknown>>;
94
+ /** The accepted papers the register rules measure a body against; empty when no file names any. */
95
+ readonly registerAnchors: readonly RegisterAnchor[];
96
+ /** What the venue asks a presenter to send (`talk/*`); null when no file of the chain says. */
97
+ readonly talk: VenueTalk | null;
92
98
  }
93
99
 
94
100
  /** Why a spec does not resolve. */
@@ -219,6 +225,8 @@ function merged(
219
225
  tex: NO_REQUIREMENTS,
220
226
  format: NO_FORMAT,
221
227
  rules: {},
228
+ registerAnchors: [],
229
+ talk: null,
222
230
  };
223
231
  const m = rootFirst.reduce(
224
232
  (acc, { preset: p, file }) => ({
@@ -236,19 +244,16 @@ function merged(
236
244
  tex: p.tex ? mergeRequirements(acc.tex, p.tex) : acc.tex,
237
245
  format: mergeFormat(acc.format, p.format),
238
246
  rules: { ...acc.rules, ...p.rules },
247
+ registerAnchors: p.registerAnchors ?? acc.registerAnchors,
248
+ talk: p.talk ?? acc.talk,
239
249
  }),
240
250
  base,
241
251
  );
252
+ const { name, ...rest } = m;
242
253
  return {
243
- label: m.name ?? labelOf(spec),
254
+ label: name ?? labelOf(spec),
244
255
  chain: rootFirst.map((x) => x.file),
245
- template: m.template,
246
- aliases: m.aliases,
247
- blind: m.blind,
248
- requiredSections: m.requiredSections,
249
- tex: m.tex,
250
- format: m.format,
251
- rules: m.rules,
256
+ ...rest,
252
257
  };
253
258
  }
254
259