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
@@ -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 { Owner, Passage, ProsePiece } from "../../domain/tex-document.ts";
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
- /** What the walk yields: a piece, the end of a passage, the start of the back matter, a footnote. */
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 walkList(t, lastArgument(m));
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 walkList(t, n.content);
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.reduce<{
351
- readonly kept: readonly Node[];
352
- readonly state: ArgumentState;
353
- }>(
354
- (acc, n) => {
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
- /** A formatting macro whose phrase ends in `.` or `:` — a run-in heading, when it opens a paragraph. */
382
- const isRunInHeading = (t: ParsedTex, n: Node): boolean =>
383
- n.type === "macro" &&
384
- RUN_IN_MACROS.has(n.content) &&
385
- /[.:]$/u.test(typeset(walkList(t, lastArgument(n))).trim());
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
- return proseNodes(list).reduce<{
418
- readonly events: readonly Event[];
419
- readonly opening: boolean;
420
- }>(
421
- (acc, n) => ({
422
- events: [
423
- ...acc.events,
424
- ...(acc.opening && isRunInHeading(t, n) ? [BREAK] : nodeEvents(t, n)),
425
- ],
426
- opening: opensAfter(n) || (acc.opening && keepsOpening(n)),
427
- }),
428
- { events: [], opening: opens },
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
- const { done, current } = events.reduce<{
441
- readonly done: readonly (readonly ProsePiece[])[];
442
- readonly current: readonly ProsePiece[];
443
- }>(
444
- (acc, e) =>
445
- e.kind === "piece"
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
- const body = t.root.content.find(isDocument)?.content ?? t.root.content;
480
- return sortBy(
481
- passagesOf(untilEnd(walkList(t, body, true))).filter(hasProse),
482
- startOf,
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
  }
@@ -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");