paperlint 6.1.0 → 6.2.1
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/dist/adapters/mp4box/index.d.ts +4 -0
- package/dist/adapters/mp4box/index.d.ts.map +1 -0
- package/dist/adapters/mp4box/index.js +46 -0
- package/dist/adapters/mp4box/index.js.map +1 -0
- package/dist/adapters/node/index.d.ts +1 -0
- package/dist/adapters/node/index.d.ts.map +1 -1
- package/dist/adapters/node/index.js +1 -0
- package/dist/adapters/node/index.js.map +1 -1
- package/dist/adapters/node/list-dir.io.d.ts +3 -0
- package/dist/adapters/node/list-dir.io.d.ts.map +1 -0
- package/dist/adapters/node/list-dir.io.js +15 -0
- package/dist/adapters/node/list-dir.io.js.map +1 -0
- package/dist/adapters/png/index.d.ts +11 -0
- package/dist/adapters/png/index.d.ts.map +1 -0
- package/dist/adapters/png/index.js +21 -0
- package/dist/adapters/png/index.js.map +1 -0
- package/dist/adapters/srt/index.d.ts +4 -0
- package/dist/adapters/srt/index.d.ts.map +1 -0
- package/dist/adapters/srt/index.js +12 -0
- package/dist/adapters/srt/index.js.map +1 -0
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +25 -4
- package/dist/cli.js.map +1 -1
- package/dist/domain/talk.d.ts +121 -0
- package/dist/domain/talk.d.ts.map +1 -0
- package/dist/domain/talk.js +121 -0
- package/dist/domain/talk.js.map +1 -0
- package/dist/lib/paper-config.d.ts +2 -0
- package/dist/lib/paper-config.d.ts.map +1 -1
- package/dist/lib/paper-config.js +3 -0
- package/dist/lib/paper-config.js.map +1 -1
- package/dist/paper-settings.d.ts +3 -0
- package/dist/paper-settings.d.ts.map +1 -1
- package/dist/paper-settings.js +15 -9
- package/dist/paper-settings.js.map +1 -1
- package/dist/ports/talk-media.d.ts +18 -0
- package/dist/ports/talk-media.d.ts.map +1 -0
- package/dist/ports/talk-media.js +2 -0
- package/dist/ports/talk-media.js.map +1 -0
- package/dist/presets.d.ts +3 -0
- package/dist/presets.d.ts.map +1 -1
- package/dist/presets.js +6 -10
- package/dist/presets.js.map +1 -1
- package/dist/talk-rules.d.ts +65 -0
- package/dist/talk-rules.d.ts.map +1 -0
- package/dist/talk-rules.js +451 -0
- package/dist/talk-rules.js.map +1 -0
- package/dist/tex-requirements.d.ts +3 -0
- package/dist/tex-requirements.d.ts.map +1 -1
- package/dist/tex-requirements.js +2 -0
- package/dist/tex-requirements.js.map +1 -1
- package/docs/configuration.md +2 -1
- package/docs/rules/talk/captions-cover.md +47 -0
- package/docs/rules/talk/duration-floor.md +38 -0
- package/docs/rules/talk/duration.md +43 -0
- package/docs/rules/talk/one-slide-size.md +38 -0
- package/docs/rules/talk/profile.md +64 -0
- package/docs/rules/talk/required-files.md +39 -0
- package/docs/rules/talk/undeclared.md +35 -0
- package/docs/rules/talk/video-format.md +43 -0
- package/docs/rules.md +11 -0
- package/docs/talk.md +94 -0
- package/fixtures/talk/audio-only.mp4 +0 -0
- package/fixtures/talk/one-slide.png +0 -0
- package/fixtures/talk/short-audio.mp4 +0 -0
- package/fixtures/talk/talk.mov +0 -0
- package/fixtures/talk/talk.mp4 +0 -0
- package/fixtures/talk/talk.srt +7 -0
- package/lib/paper-config.harness.mjs +5 -2
- package/lib/paper-config.ts +4 -0
- package/package.json +3 -1
- package/presets/agenticdev.jsonc +30 -0
- package/presets/venue-profile.schema.json +132 -0
- package/skills/submit-paper/references/venues/agenticdev.md +36 -9
- package/src/adapters/mp4box/index.test.ts +63 -0
- package/src/adapters/mp4box/index.ts +50 -0
- package/src/adapters/node/index.ts +1 -0
- package/src/adapters/node/list-dir.io.ts +15 -0
- package/src/adapters/node/list-dir.test.ts +30 -0
- package/src/adapters/png/index.test.ts +43 -0
- package/src/adapters/png/index.ts +29 -0
- package/src/adapters/srt/index.test.ts +24 -0
- package/src/adapters/srt/index.ts +13 -0
- package/src/cli.test.ts +12 -0
- package/src/cli.ts +31 -3
- package/src/domain/talk.test.ts +125 -0
- package/src/domain/talk.ts +274 -0
- package/src/new-paper.test.ts +1 -0
- package/src/paper-settings.test.ts +18 -1
- package/src/paper-settings.ts +21 -11
- package/src/ports/talk-media.ts +19 -0
- package/src/presets.ts +9 -10
- package/src/rule-cases.test.ts +110 -3
- package/src/talk-rules.test.ts +459 -0
- package/src/talk-rules.ts +672 -0
- package/src/tex-requirements.ts +9 -0
|
@@ -186,6 +186,138 @@
|
|
|
186
186
|
}
|
|
187
187
|
}
|
|
188
188
|
}
|
|
189
|
+
},
|
|
190
|
+
"talk": {
|
|
191
|
+
"description": "What the venue asks a presenter to send, judged by the talk/* rules on the finished files (src/talk-rules.ts). Read only for a paper whose paperlint.json declares `talk`. A child preset's block replaces its parent's whole. Durations in seconds.",
|
|
192
|
+
"type": "object",
|
|
193
|
+
"additionalProperties": false,
|
|
194
|
+
"properties": {
|
|
195
|
+
"modes": {
|
|
196
|
+
"description": "The presentation modes the venue allows.",
|
|
197
|
+
"type": "array",
|
|
198
|
+
"items": {
|
|
199
|
+
"type": "string",
|
|
200
|
+
"enum": ["in-person", "remote-video", "remote-live"]
|
|
201
|
+
},
|
|
202
|
+
"uniqueItems": true,
|
|
203
|
+
"minItems": 1
|
|
204
|
+
},
|
|
205
|
+
"artifacts": {
|
|
206
|
+
"description": "What each mode owes: the files required, and those that may be sent.",
|
|
207
|
+
"type": "object",
|
|
208
|
+
"propertyNames": {
|
|
209
|
+
"type": "string",
|
|
210
|
+
"enum": ["in-person", "remote-video", "remote-live"]
|
|
211
|
+
},
|
|
212
|
+
"additionalProperties": {
|
|
213
|
+
"type": "object",
|
|
214
|
+
"additionalProperties": false,
|
|
215
|
+
"required": ["required"],
|
|
216
|
+
"properties": {
|
|
217
|
+
"required": {
|
|
218
|
+
"type": "array",
|
|
219
|
+
"items": {
|
|
220
|
+
"type": "string",
|
|
221
|
+
"enum": [
|
|
222
|
+
"video",
|
|
223
|
+
"one-slide",
|
|
224
|
+
"captions",
|
|
225
|
+
"slides-pdf",
|
|
226
|
+
"poster"
|
|
227
|
+
]
|
|
228
|
+
},
|
|
229
|
+
"uniqueItems": true
|
|
230
|
+
},
|
|
231
|
+
"optional": {
|
|
232
|
+
"type": "array",
|
|
233
|
+
"items": {
|
|
234
|
+
"type": "string",
|
|
235
|
+
"enum": [
|
|
236
|
+
"video",
|
|
237
|
+
"one-slide",
|
|
238
|
+
"captions",
|
|
239
|
+
"slides-pdf",
|
|
240
|
+
"poster"
|
|
241
|
+
]
|
|
242
|
+
},
|
|
243
|
+
"uniqueItems": true
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
},
|
|
248
|
+
"kinds": {
|
|
249
|
+
"description": "The talk slot per paper kind (the names of format.kinds; the paper's `kind` selects one).",
|
|
250
|
+
"type": "object",
|
|
251
|
+
"additionalProperties": {
|
|
252
|
+
"type": "object",
|
|
253
|
+
"additionalProperties": false,
|
|
254
|
+
"required": ["slot_s", "talk_s_max"],
|
|
255
|
+
"properties": {
|
|
256
|
+
"slot_s": {
|
|
257
|
+
"description": "The whole slot, talk and questions. Informational.",
|
|
258
|
+
"type": "number",
|
|
259
|
+
"exclusiveMinimum": 0
|
|
260
|
+
},
|
|
261
|
+
"talk_s_min": {
|
|
262
|
+
"description": "The shortest talk the venue asks for (talk/duration-floor).",
|
|
263
|
+
"type": "number",
|
|
264
|
+
"minimum": 0
|
|
265
|
+
},
|
|
266
|
+
"talk_s_max": {
|
|
267
|
+
"description": "The longest talk (talk/duration).",
|
|
268
|
+
"type": "number",
|
|
269
|
+
"exclusiveMinimum": 0
|
|
270
|
+
},
|
|
271
|
+
"qa_s": {
|
|
272
|
+
"description": "The questions' share. Informational.",
|
|
273
|
+
"type": "number",
|
|
274
|
+
"minimum": 0
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
},
|
|
279
|
+
"video": {
|
|
280
|
+
"description": "The venue's limits on the video file (talk/video-format).",
|
|
281
|
+
"type": "object",
|
|
282
|
+
"additionalProperties": false,
|
|
283
|
+
"properties": {
|
|
284
|
+
"container": {
|
|
285
|
+
"type": "string",
|
|
286
|
+
"enum": ["mp4"]
|
|
287
|
+
},
|
|
288
|
+
"min_height_px": {
|
|
289
|
+
"description": "The lowest vertical resolution; HD is 720.",
|
|
290
|
+
"type": "integer",
|
|
291
|
+
"minimum": 1
|
|
292
|
+
},
|
|
293
|
+
"max_bytes": {
|
|
294
|
+
"type": "integer",
|
|
295
|
+
"minimum": 1
|
|
296
|
+
},
|
|
297
|
+
"captions": {
|
|
298
|
+
"description": "`required`: a captions file is owed with the video (talk/required-files).",
|
|
299
|
+
"type": "string",
|
|
300
|
+
"enum": ["required", "optional"]
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
},
|
|
304
|
+
"one_slide": {
|
|
305
|
+
"description": "The one-slide image's size in pixels (talk/one-slide-size), when the venue states one.",
|
|
306
|
+
"type": "object",
|
|
307
|
+
"additionalProperties": false,
|
|
308
|
+
"required": ["width_px", "height_px"],
|
|
309
|
+
"properties": {
|
|
310
|
+
"width_px": {
|
|
311
|
+
"type": "integer",
|
|
312
|
+
"minimum": 1
|
|
313
|
+
},
|
|
314
|
+
"height_px": {
|
|
315
|
+
"type": "integer",
|
|
316
|
+
"minimum": 1
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
}
|
|
189
321
|
}
|
|
190
322
|
},
|
|
191
323
|
"allOf": [
|
|
@@ -116,17 +116,44 @@ the preamble, the copyright block, CCS, source files). Here — only what's spec
|
|
|
116
116
|
- **Title changes are allowed** — the portal explicitly asks for it "exactly as it should appear
|
|
117
117
|
in the ACM DL." But decide before eRights (see the publisher card).
|
|
118
118
|
|
|
119
|
-
## Attendance
|
|
119
|
+
## Attendance and the talk (2026 edition)
|
|
120
120
|
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
121
|
+
**Remote is allowed, but there is no live slot.** On 2026-08-27 the chairs told accepted authors
|
|
122
|
+
who cannot travel (by email) that the workshop adopts ASE 2026's
|
|
123
|
+
[Not-in-Person Presentations](https://conf.researchr.org/track/ase-2026/ase-2026-not-in-person-presentations)
|
|
124
|
+
policy. That page, verbatim:
|
|
125
125
|
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
126
|
+
> ASE 2026 does not provide presentation slots for papers that are not presented in-person by one
|
|
127
|
+
> of the authors.
|
|
128
|
+
|
|
129
|
+
Instead the author sends:
|
|
130
|
+
|
|
131
|
+
| artifact | what the page says | deadline |
|
|
132
|
+
| --------- | --------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------- |
|
|
133
|
+
| video | "made available via a dedicated page on the conference website (recommendation: 10 min duration, mp4 file format, HD resolution)" | ASE page: 2026-10-08; the workshop chairs asked by email for **2026-10-01** |
|
|
134
|
+
| one-slide | "shown during one of the conference sessions"; no size or format stated | same as the video |
|
|
135
|
+
| poster | optional for the workshop; ASE prints it | ASE page: 2026-09-21 |
|
|
136
|
+
|
|
137
|
+
**Where to upload.** The page says: "find the submission link on the submission page for your paper
|
|
138
|
+
(see author-kit e-mail from Conference Publishing Consulting)". That is the same personal page used
|
|
139
|
+
for the camera-ready. On it, the block "Optional Archive/Appendix/Video/Picture Submissions … Submit
|
|
140
|
+
Material to ACM" uploads straight into the ACM system, once per file. "All submitted material will be
|
|
141
|
+
published in the ACM DL if the publishing-rights agreement gives permission for this", so tick
|
|
142
|
+
permission for auxiliary material when filling in eRights. The chairs never named a channel in
|
|
143
|
+
email; sending the files to them as attachments as well covers both readings.
|
|
144
|
+
|
|
145
|
+
**The slot.** Per the chairs' email to all authors (2026-09-29), a short paper gets 10 minutes in
|
|
146
|
+
total: approximately 6–7 for the presentation and 3–4 for questions. It is stated as approximate,
|
|
147
|
+
with no minimum.
|
|
148
|
+
|
|
149
|
+
**Q&A for a remote author.** No source requires live attendance or a live answer; none confirms
|
|
150
|
+
email Q&A either. Put a contact address on the video's last slide and on the one-slide.
|
|
151
|
+
|
|
152
|
+
**Registration is still required** and still a **publication gate**, not a travel expense:
|
|
153
|
+
workshop-day registration (non-member) €350 through 08-31, €410 through 09-20, €460 on-site.
|
|
154
|
+
If entry requires a visa, ask the chairs right after acceptance.
|
|
155
|
+
|
|
156
|
+
The `talk` rules check the finished files against these numbers: `docs/talk.md`.
|
|
130
157
|
|
|
131
158
|
## 🤖 Machine-readable format profile
|
|
132
159
|
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/** `probeVideo` on the committed synthetic media (`fixtures/talk/`): 3 s, 1280×720, no voice. */
|
|
2
|
+
import { readFileSync } from "node:fs";
|
|
3
|
+
import { dirname, join } from "node:path";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
import { describe, expect, it } from "vitest";
|
|
6
|
+
import { probeVideo } from "./index.ts";
|
|
7
|
+
|
|
8
|
+
const FIXTURES = join(
|
|
9
|
+
dirname(fileURLToPath(import.meta.url)),
|
|
10
|
+
"../../../fixtures/talk",
|
|
11
|
+
);
|
|
12
|
+
const bytes = (name: string): Uint8Array =>
|
|
13
|
+
new Uint8Array(readFileSync(join(FIXTURES, name)));
|
|
14
|
+
|
|
15
|
+
describe("probeVideo", () => {
|
|
16
|
+
it("reads the movie header's duration, the audio track's, the size and the brands", () => {
|
|
17
|
+
expect(probeVideo(bytes("talk.mp4"))).toEqual({
|
|
18
|
+
durationS: 3,
|
|
19
|
+
audioS: 3.064,
|
|
20
|
+
heightPx: 720,
|
|
21
|
+
widthPx: 1280,
|
|
22
|
+
brands: ["isom", "iso2", "avc1", "mp41"],
|
|
23
|
+
});
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it("reads an audio track that stops before the video", () => {
|
|
27
|
+
expect(probeVideo(bytes("short-audio.mp4"))?.audioS).toBe(1.064);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it("reads a QuickTime file, with its `qt ` brand and no audio track", () => {
|
|
31
|
+
expect(probeVideo(bytes("talk.mov"))).toMatchObject({
|
|
32
|
+
durationS: 3,
|
|
33
|
+
audioS: null,
|
|
34
|
+
brands: ["qt "],
|
|
35
|
+
});
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it("is null for bytes that do not open with an ftyp box", () => {
|
|
39
|
+
expect(probeVideo(bytes("one-slide.png"))).toBeNull();
|
|
40
|
+
expect(probeVideo(new Uint8Array(3))).toBeNull();
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it("reads a Node Buffer that is a view into a larger buffer, as readFileSync returns for small files", () => {
|
|
44
|
+
const mov = bytes("talk.mov");
|
|
45
|
+
const big = new Uint8Array(mov.length + 100);
|
|
46
|
+
big.set(mov, 50);
|
|
47
|
+
expect(probeVideo(Buffer.from(big.buffer, 50, mov.length))?.brands).toEqual(
|
|
48
|
+
["qt "],
|
|
49
|
+
);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it("is null for an ftyp box with no moov after it", () => {
|
|
53
|
+
const ftyp = bytes("talk.mp4").slice(0, 32);
|
|
54
|
+
expect(probeVideo(ftyp)).toBeNull();
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it("has no size for a file without a video track", () => {
|
|
58
|
+
expect(probeVideo(bytes("audio-only.mp4"))).toMatchObject({
|
|
59
|
+
heightPx: null,
|
|
60
|
+
widthPx: null,
|
|
61
|
+
});
|
|
62
|
+
});
|
|
63
|
+
});
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MP4Box.js (GPAC's ISO-BMFF parser, pure JavaScript, no dependencies) as the video half of the
|
|
3
|
+
* `TalkMedia` port: the movie header's duration, the first audio and video tracks, the brands.
|
|
4
|
+
* No ffmpeg: the boxes that hold these numbers (`ftyp`, `moov/mvhd`, `trak/tkhd`, `mdhd`) are read
|
|
5
|
+
* directly, in milliseconds even for a ten-megabyte file.
|
|
6
|
+
*/
|
|
7
|
+
import { createFile, MP4BoxBuffer, type Movie, type Track } from "mp4box";
|
|
8
|
+
import type { VideoProbe } from "../../domain/talk.ts";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Whether the bytes open with an `ftyp` box, as every MP4 and QuickTime file written by a current
|
|
12
|
+
* tool does. Anything else is refused here, before MP4Box.js — which would log its parse errors to
|
|
13
|
+
* the console, in the middle of the lint output.
|
|
14
|
+
*/
|
|
15
|
+
const opensWithFtyp = (bytes: Uint8Array): boolean =>
|
|
16
|
+
bytes.length >= 8 &&
|
|
17
|
+
new TextDecoder("latin1").decode(bytes.subarray(4, 8)) === "ftyp";
|
|
18
|
+
|
|
19
|
+
/** A track's duration in seconds, from its own media timescale. */
|
|
20
|
+
const seconds = (t: Readonly<Pick<Track, "duration" | "timescale">>): number =>
|
|
21
|
+
t.duration / t.timescale;
|
|
22
|
+
|
|
23
|
+
/** The movie's info, or null when the bytes hold no complete `moov` box. */
|
|
24
|
+
function movieOf(bytes: Uint8Array): Readonly<Movie> | null {
|
|
25
|
+
const file = createFile();
|
|
26
|
+
// A copy into a buffer of its own: a Node Buffer's `.buffer` is a shared pool for small files,
|
|
27
|
+
// and `Buffer#slice` does not copy, so `bytes.slice().buffer` would hand MP4Box.js the whole pool.
|
|
28
|
+
const copy = new Uint8Array(bytes).buffer;
|
|
29
|
+
file.appendBuffer(MP4BoxBuffer.fromArrayBuffer(copy, 0));
|
|
30
|
+
file.flush();
|
|
31
|
+
// `moov` is typed as always present and is undefined until the box has been parsed.
|
|
32
|
+
const parsed: unknown = file.moov;
|
|
33
|
+
return parsed === undefined ? null : file.getInfo();
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** The bytes of a video file → its durations, size and brands; null when not an ISO media file. */
|
|
37
|
+
export function probeVideo(bytes: Uint8Array): VideoProbe | null {
|
|
38
|
+
if (!opensWithFtyp(bytes)) return null;
|
|
39
|
+
const movie = movieOf(bytes);
|
|
40
|
+
if (movie === null) return null;
|
|
41
|
+
const audio = movie.audioTracks[0];
|
|
42
|
+
const video = movie.videoTracks[0];
|
|
43
|
+
return {
|
|
44
|
+
durationS: movie.duration / movie.timescale,
|
|
45
|
+
audioS: audio === undefined ? null : seconds(audio),
|
|
46
|
+
heightPx: video?.video?.height ?? null,
|
|
47
|
+
widthPx: video?.video?.width ?? null,
|
|
48
|
+
brands: [...new Set(movie.brands)],
|
|
49
|
+
};
|
|
50
|
+
}
|
|
@@ -34,3 +34,4 @@ export function nodeAdapters(o: NodeAdapterOptions): NodePorts {
|
|
|
34
34
|
export { nodeFiles } from "./files.io.ts";
|
|
35
35
|
export { spawnProcess, type SpawnSync } from "./process.io.ts";
|
|
36
36
|
export { hostDirs } from "./host.io.ts";
|
|
37
|
+
export { nodeListDir } from "./list-dir.io.ts";
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/** `ListDir` over `node:fs`: the names in a directory, or none when it does not exist. */
|
|
2
|
+
import { readdirSync } from "node:fs";
|
|
3
|
+
import type { AbsolutePath } from "../../domain/paths.ts";
|
|
4
|
+
import type { ListDir } from "../../ports/talk-media.ts";
|
|
5
|
+
import { codeOf } from "../../domain/text.ts";
|
|
6
|
+
|
|
7
|
+
export const nodeListDir: ListDir = (dir: AbsolutePath): readonly string[] => {
|
|
8
|
+
try {
|
|
9
|
+
return readdirSync(dir);
|
|
10
|
+
} catch (e) {
|
|
11
|
+
const code = codeOf(e);
|
|
12
|
+
if (code === "ENOENT" || code === "ENOTDIR") return [];
|
|
13
|
+
throw e;
|
|
14
|
+
}
|
|
15
|
+
};
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/** `nodeListDir`: a directory's names; none for a missing directory or a file; other errors thrown. */
|
|
2
|
+
import { mkdirSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { describe, expect, it } from "vitest";
|
|
5
|
+
import { absolutePath } from "../../domain/paths.ts";
|
|
6
|
+
import { useTempDir } from "../../../test/support.ts";
|
|
7
|
+
import { nodeListDir } from "./list-dir.io.ts";
|
|
8
|
+
|
|
9
|
+
const root = useTempDir("paperlint-list-dir-");
|
|
10
|
+
|
|
11
|
+
describe("nodeListDir", () => {
|
|
12
|
+
it("lists a directory's names", () => {
|
|
13
|
+
mkdirSync(join(root, "talk"));
|
|
14
|
+
writeFileSync(join(root, "talk", "a.mp4"), "");
|
|
15
|
+
expect(nodeListDir(absolutePath(join(root, "talk")))).toEqual(["a.mp4"]);
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it("is empty for a directory that does not exist", () => {
|
|
19
|
+
expect(nodeListDir(absolutePath(join(root, "none")))).toEqual([]);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it("is empty for a path that is a file", () => {
|
|
23
|
+
writeFileSync(join(root, "f"), "");
|
|
24
|
+
expect(nodeListDir(absolutePath(join(root, "f")))).toEqual([]);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it("throws any other error", () => {
|
|
28
|
+
expect(() => nodeListDir(absolutePath(`${root}/a\0b`))).toThrow();
|
|
29
|
+
});
|
|
30
|
+
});
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/** `imageSize`: the PNG header's width and height, or null for anything that is not a PNG. */
|
|
2
|
+
import { readFileSync } from "node:fs";
|
|
3
|
+
import { dirname, join } from "node:path";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
import { describe, expect, it } from "vitest";
|
|
6
|
+
import { imageSize } from "./index.ts";
|
|
7
|
+
|
|
8
|
+
const png = new Uint8Array(
|
|
9
|
+
readFileSync(
|
|
10
|
+
join(
|
|
11
|
+
dirname(fileURLToPath(import.meta.url)),
|
|
12
|
+
"../../../fixtures/talk/one-slide.png",
|
|
13
|
+
),
|
|
14
|
+
),
|
|
15
|
+
);
|
|
16
|
+
|
|
17
|
+
describe("imageSize", () => {
|
|
18
|
+
it("reads the IHDR width and height", () => {
|
|
19
|
+
expect(imageSize(png)).toEqual({ width: 16, height: 9 });
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it("reads a size above 65 535 (four bytes, big-endian)", () => {
|
|
23
|
+
const big = png.slice();
|
|
24
|
+
big.set([0, 1, 0, 0], 16);
|
|
25
|
+
expect(imageSize(big)).toEqual({ width: 65_536, height: 9 });
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it("is null for bytes too short to hold the header", () => {
|
|
29
|
+
expect(imageSize(png.slice(0, 20))).toBeNull();
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it("is null without the PNG signature", () => {
|
|
33
|
+
const jpeg = png.slice();
|
|
34
|
+
jpeg.set([0xff, 0xd8], 0);
|
|
35
|
+
expect(imageSize(jpeg)).toBeNull();
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it("is null when the first chunk is not IHDR", () => {
|
|
39
|
+
const odd = png.slice();
|
|
40
|
+
odd.set(new TextEncoder().encode("IDAT"), 12);
|
|
41
|
+
expect(imageSize(odd)).toBeNull();
|
|
42
|
+
});
|
|
43
|
+
});
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The PNG format as the image half of the `TalkMedia` port: an image's size, from its header.
|
|
3
|
+
*
|
|
4
|
+
* A PNG opens with an eight-byte signature and then the `IHDR` chunk, whose first eight data bytes
|
|
5
|
+
* are the width and the height as big-endian unsigned integers (PNG specification, 11.2.2). That is
|
|
6
|
+
* the whole of what is read; the pixels are never decoded.
|
|
7
|
+
*/
|
|
8
|
+
import type { PixelSize } from "../../domain/talk.ts";
|
|
9
|
+
|
|
10
|
+
const SIGNATURE = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a];
|
|
11
|
+
/** Where the `IHDR` chunk's type and its width and height sit: after the signature and the length. */
|
|
12
|
+
const IHDR_TYPE_AT = 12;
|
|
13
|
+
const WIDTH_AT = 16;
|
|
14
|
+
const HEIGHT_AT = 20;
|
|
15
|
+
|
|
16
|
+
/** The bytes of an image → its size; null when they are not a PNG. */
|
|
17
|
+
export function imageSize(bytes: Uint8Array): PixelSize | null {
|
|
18
|
+
if (bytes.length < HEIGHT_AT + 4) return null;
|
|
19
|
+
if (!SIGNATURE.every((b, i) => bytes[i] === b)) return null;
|
|
20
|
+
const type = new TextDecoder("latin1").decode(
|
|
21
|
+
bytes.subarray(IHDR_TYPE_AT, IHDR_TYPE_AT + 4),
|
|
22
|
+
);
|
|
23
|
+
if (type !== "IHDR") return null;
|
|
24
|
+
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
|
25
|
+
return {
|
|
26
|
+
width: view.getUint32(WIDTH_AT),
|
|
27
|
+
height: view.getUint32(HEIGHT_AT),
|
|
28
|
+
};
|
|
29
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/** `captions`: the cues of an `.srt` file, their times in seconds. */
|
|
2
|
+
import { describe, expect, it } from "vitest";
|
|
3
|
+
import { captions } from "./index.ts";
|
|
4
|
+
|
|
5
|
+
describe("captions", () => {
|
|
6
|
+
it("reads every cue's start and end", () => {
|
|
7
|
+
const srt =
|
|
8
|
+
"1\n00:00:00,000 --> 00:00:01,200\nA line.\n\n2\n00:01:01,400 --> 00:01:02,900\nAnother.\n";
|
|
9
|
+
expect(captions(srt)).toEqual([
|
|
10
|
+
{ startS: 0, endS: 1.2 },
|
|
11
|
+
{ startS: 61.4, endS: 62.9 },
|
|
12
|
+
]);
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
it("reads Windows line endings", () => {
|
|
16
|
+
expect(captions("1\r\n00:00:03,000 --> 00:00:04,000\r\nx\r\n")).toEqual([
|
|
17
|
+
{ startS: 3, endS: 4 },
|
|
18
|
+
]);
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it("is empty for text that holds no cue", () => {
|
|
22
|
+
expect(captions("not captions")).toEqual([]);
|
|
23
|
+
});
|
|
24
|
+
});
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SubRip (`.srt`) captions as the captions half of the `TalkMedia` port, through `srt-parser-2`
|
|
3
|
+
* (MIT, no dependencies). Only the timing of each cue is kept: the rules judge coverage, not words.
|
|
4
|
+
*/
|
|
5
|
+
import Parser from "srt-parser-2";
|
|
6
|
+
import type { Cue } from "../../domain/talk.ts";
|
|
7
|
+
|
|
8
|
+
/** The text of an `.srt` file → its cues in file order; empty when it holds none. */
|
|
9
|
+
export function captions(text: string): readonly Cue[] {
|
|
10
|
+
return new Parser()
|
|
11
|
+
.fromSrt(text)
|
|
12
|
+
.map((line) => ({ startS: line.startSeconds, endS: line.endSeconds }));
|
|
13
|
+
}
|
package/src/cli.test.ts
CHANGED
|
@@ -70,6 +70,18 @@ test("🔴 parseSettings refuses a mistyped `structure` instead of letting `lint
|
|
|
70
70
|
assert.equal(off.ok && off.value.structure, false);
|
|
71
71
|
});
|
|
72
72
|
|
|
73
|
+
test("parseSettings refuses `talk` in the root file: it is one paper's setting", () => {
|
|
74
|
+
const r = parseSettings(
|
|
75
|
+
{ talk: { mode: "remote-video" } },
|
|
76
|
+
"paperlint.json",
|
|
77
|
+
"/r",
|
|
78
|
+
);
|
|
79
|
+
assert.equal(
|
|
80
|
+
r.ok ? null : r.error,
|
|
81
|
+
"paperlint.json: \"talk\" is a paper's setting — set it in that paper's paperlint.json",
|
|
82
|
+
);
|
|
83
|
+
});
|
|
84
|
+
|
|
73
85
|
test("parseSettings refuses a non-object, names one unknown key or several, and a mistyped default", () => {
|
|
74
86
|
const error = (json: unknown) => {
|
|
75
87
|
const r = parseSettings(json, "paperlint.json", "/r");
|
package/src/cli.ts
CHANGED
|
@@ -48,7 +48,16 @@ import { cacheRoot, cachedTree, runToolchain } from "./toolchain.ts";
|
|
|
48
48
|
import { banalInstaller, parseBanalSettings } from "./adapters/banal/index.ts";
|
|
49
49
|
import { curlDownload } from "./adapters/curl/index.ts";
|
|
50
50
|
import { latexReader } from "./adapters/latex/index.ts";
|
|
51
|
-
import {
|
|
51
|
+
import {
|
|
52
|
+
hostDirs,
|
|
53
|
+
nodeAdapters,
|
|
54
|
+
nodeFiles,
|
|
55
|
+
nodeListDir,
|
|
56
|
+
} from "./adapters/node/index.ts";
|
|
57
|
+
import { probeVideo } from "./adapters/mp4box/index.ts";
|
|
58
|
+
import { imageSize } from "./adapters/png/index.ts";
|
|
59
|
+
import { captions } from "./adapters/srt/index.ts";
|
|
60
|
+
import { TALK_RULE_LEVELS, talkRules } from "./talk-rules.ts";
|
|
52
61
|
import {
|
|
53
62
|
ANONYMITY_RULE_LEVELS,
|
|
54
63
|
anonymityRules,
|
|
@@ -125,6 +134,7 @@ import {
|
|
|
125
134
|
CONFIG_FILE,
|
|
126
135
|
DEFAULT_PAPERS_ROOT,
|
|
127
136
|
PAPERS_DIR_FIELD,
|
|
137
|
+
PAPER_ONLY_KEYS,
|
|
128
138
|
SETTINGS_KEYS,
|
|
129
139
|
findProjectRoot,
|
|
130
140
|
} from "#lib/paper-config";
|
|
@@ -236,8 +246,9 @@ settings — paperlint.json, at two levels, one schema. Both are optional.
|
|
|
236
246
|
own, the venue preset's, the root file's, the paper's. Optional rules (off unless turned on):
|
|
237
247
|
pdf/last-page-balance. The venue rules (pdf/fresh, pdf/profile, pdf/fonts, pdf/geometry,
|
|
238
248
|
pdf/body-size, pdf/measured, format/page-limit, format/layout-override, anonymity/identity) are
|
|
239
|
-
on for every paper with a venue preset; set one to "off" to skip it.
|
|
240
|
-
|
|
249
|
+
on for every paper with a venue preset; set one to "off" to skip it. The talk rules (talk/*) judge
|
|
250
|
+
the finished talk files of a paper whose paperlint.json declares "talk" (docs/talk.md). An
|
|
251
|
+
unknown key, in either file, is an error.
|
|
241
252
|
`;
|
|
242
253
|
|
|
243
254
|
/** What the rules over a paper.tex read with: the disk, the shipped presets, the LaTeX reader. */
|
|
@@ -247,6 +258,14 @@ const TEX_RULE_DEPS = {
|
|
|
247
258
|
latex: latexReader,
|
|
248
259
|
};
|
|
249
260
|
|
|
261
|
+
/** What the talk rules read with: the disk, the shipped presets, and the three media readers. */
|
|
262
|
+
const TALK_RULE_DEPS = {
|
|
263
|
+
files: nodeFiles,
|
|
264
|
+
venuesDir: presetsDir(),
|
|
265
|
+
media: { probeVideo, imageSize, captions },
|
|
266
|
+
listDir: nodeListDir,
|
|
267
|
+
};
|
|
268
|
+
|
|
250
269
|
/** The config the user would otherwise write by hand. The data comes from `opts`, the mechanism is here. */
|
|
251
270
|
export function buildConfig(
|
|
252
271
|
opts: PaperlintConfig = {},
|
|
@@ -344,6 +363,7 @@ export function buildConfig(
|
|
|
344
363
|
},
|
|
345
364
|
paper: { rules: texPaperRules },
|
|
346
365
|
bib: bibReachable,
|
|
366
|
+
talk: { rules: talkRules(TALK_RULE_DEPS) },
|
|
347
367
|
format: {
|
|
348
368
|
rules: {
|
|
349
369
|
...formatRules(TEX_RULE_DEPS),
|
|
@@ -372,6 +392,8 @@ export function buildConfig(
|
|
|
372
392
|
...TEX_VENUE_RULE_LEVELS,
|
|
373
393
|
...FORMAT_RULE_LEVELS,
|
|
374
394
|
...PAGE_LIMIT_RULE_LEVELS,
|
|
395
|
+
// Silent for a paper that declares no `talk` (src/talk-rules.ts), but talk/undeclared.
|
|
396
|
+
...TALK_RULE_LEVELS,
|
|
375
397
|
},
|
|
376
398
|
});
|
|
377
399
|
// THE FILES A paper.tex INCLUDES FROM ITS BODY, each read on its own and reported at its own path
|
|
@@ -696,6 +718,12 @@ export function parseSettings(
|
|
|
696
718
|
`${where}: unknown key${unknown.length > 1 ? "s" : ""} ${unknown.map((k) => `"${k}"`).join(", ")} — ` +
|
|
697
719
|
`a typo would otherwise read as "not set". Known keys: ${Object.keys(SETTINGS_KEYS).join(", ")}`,
|
|
698
720
|
};
|
|
721
|
+
const paperOnly = PAPER_ONLY_KEYS.find((k) => k in raw);
|
|
722
|
+
if (paperOnly !== undefined)
|
|
723
|
+
return {
|
|
724
|
+
ok: false,
|
|
725
|
+
error: `${where}: "${paperOnly}" is a paper's setting — set it in that paper's ${CONFIG_FILE}`,
|
|
726
|
+
};
|
|
699
727
|
const defaults = stringFields(raw);
|
|
700
728
|
if (!defaults.ok) return { ok: false, error: `${where}: ${defaults.error}` };
|
|
701
729
|
const identity = identityField(raw);
|