edfcore 0.2.65 → 0.2.67

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/CHANGELOG.md CHANGED
@@ -6,7 +6,46 @@ alone does not tell you whether you were affected.
6
6
  edfcore is pre-1.0. Patch releases have carried behaviour changes where the old behaviour was a
7
7
  defect; those are called out below.
8
8
 
9
- ## 0.2.64
9
+ ## 0.2.67
10
+
11
+ Three defects in `formatHeader`, found by an adversarial sweep of the modules no earlier pass had
12
+ covered.
13
+
14
+ - **Fixed** the duration line losing a whole second. It computed `recordCount *
15
+ recordDurationSeconds` in float64 and truncated, and a record duration with no exact binary form
16
+ makes that product land just under the true value: 100 records of 0.29 s is exactly 29 s,
17
+ computes as 28.999999999999996, and printed `00:00:28`. It is now computed from
18
+ `recordDurationTicks`, which is exact. A genuine fraction still truncates rather than rounding —
19
+ 7 × 0.7 s is 4.9 and prints `00:00:04`, because rounding would name a time the file never reaches.
20
+ - **Fixed** control characters in a signal label being printed verbatim. EDF pads labels with
21
+ spaces and says nothing about what else may be in them, so a writer can put a newline there and
22
+ the label renders as TWO rows — forging a signal the file does not contain — while a tab shifts
23
+ every column after it. They are replaced with a dot in the rendering only; `signal.raw.label`
24
+ still holds the bytes.
25
+ - **Fixed** the diagnostic severity summary being ordered by arrival, so two files with the same
26
+ diagnostics could summarise them differently. Now error-warning-info, matching
27
+ `formatValidationReport` since 0.2.15 and sharing its counting.
28
+
29
+ ## 0.2.66
30
+
31
+ - **Fixed** the changelog numbering, which had drifted a second time, and **fixed the cause** so it
32
+ cannot drift a third.
33
+ - The mechanism, both times: the entry is written by hand before the release runs, against the
34
+ version the author expects. When a release fails AFTER bumping — a lint error, a flaky test, an
35
+ agent's scratch file in the tree — that number is consumed, the next run produces a different
36
+ one, and every heading from there on inherits the drift. `0.2.29` and `0.2.36` went that way in
37
+ the last round; `0.2.59` went that way in this one, consumed by its own coverage guard when a
38
+ corpus file was added without regenerating its parity golden.
39
+ - `scripts/release.mjs` now refuses to release unless the top `## <version>` heading in
40
+ CHANGELOG.md equals the version being tagged, and its message says what to do — including
41
+ recording a skipped number as never released. One file read, and a silent documentation defect
42
+ becomes a message before anything is committed.
43
+ - Headings `0.2.59` through `0.2.64` are shifted to the releases that actually carried them,
44
+ verified against `git show <tag>:CHANGELOG.md` rather than reasoned about, and `0.2.59` is
45
+ recorded as never released. Prose references in the README, the docs and one test are shifted to
46
+ match.
47
+
48
+ ## 0.2.65
10
49
 
11
50
  - **Added** a migration guide for 0.3.0, published before the release rather than after it:
12
51
  [Migrating to 0.3](https://edfcore.vercel.app/docs/migrating-to-0-3). Three functions are
@@ -20,7 +59,7 @@ defect; those are called out below.
20
59
  - Includes the find-and-replace, with word boundaries, and a note on why: `sampleStartTicks` and
21
60
  `sampleStartTicksOf` are distinct names and a substring replace would damage the second.
22
61
 
23
- ## 0.2.63
62
+ ## 0.2.64
24
63
 
25
64
  - **Added** a test that drives essentially the whole public barrel over every corpus file — six
26
65
  files written by five pieces of software across twenty-one years — and asserts the results are
@@ -34,7 +73,7 @@ defect; those are called out below.
34
73
  nowhere written down, and the sleep-edfx hypnogram is a real file of exactly that shape. Now
35
74
  stated in the source, in the docs, and pinned by the test that found it.
36
75
 
37
- ## 0.2.62
76
+ ## 0.2.63
38
77
 
39
78
  - **Added** `formatAnnotations`, the third formatter beside `formatHeader` and
40
79
  `formatDiagnostics`, and the one a hypnogram or an event list actually needs.
@@ -49,7 +88,7 @@ defect; those are called out below.
49
88
  the header start time, a recording may begin after its first annotation, and clamping to zero
50
89
  would silently move it.
51
90
 
52
- ## 0.2.61
91
+ ## 0.2.62
53
92
 
54
93
  - **Deprecated** `sampleIndexAt`, `sampleStartTicks` and `sampleStartSeconds`, which are renamed
55
94
  to `gridSampleIndexAt`, `gridSampleStartTicks` and `gridSampleStartSeconds` in **0.3.0**. The
@@ -62,7 +101,7 @@ defect; those are called out below.
62
101
  - Nothing is removed here. An editor will point at the replacement a release before the rename
63
102
  lands, and for a contiguous file the rename is the only thing that affects a caller.
64
103
 
65
- ## 0.2.60
104
+ ## 0.2.61
66
105
 
67
106
  - **Added** `sampleAt`, `sampleStartTicksOf` and `sampleStartSecondsOf` — the recording-aware
68
107
  counterpart to the sample-grid family, and the groundwork for 0.3.0.
@@ -79,7 +118,7 @@ defect; those are called out below.
79
118
  follows. A round-trip test pins the pair together across the gap: the sample at a sample's start
80
119
  is that sample, for every sample in the file.
81
120
 
82
- ## 0.2.59
121
+ ## 0.2.60
83
122
 
84
123
  - **Added** `calib.rec` from edfplus.info — the last corpus the README named. It was written by
85
124
  **Bob Kemp, who wrote the EDF specification**, expressly to check that a reader gets amplitude
@@ -99,6 +138,12 @@ defect; those are called out below.
99
138
  the new corpus file had no parity golden yet. That is exactly the drift it exists to catch, and
100
139
  it caught it before the release rather than after.
101
140
 
141
+ ## 0.2.59
142
+
143
+ Never released. The 0.2.59 release run failed its own coverage guard — a corpus file had been added
144
+ without regenerating its parity golden — which consumed the number before a tag was cut. The
145
+ calibration-file work that carried this heading while it was being written shipped in `0.2.60`.
146
+
102
147
  ## 0.2.58
103
148
 
104
149
  - **Added** CHB-MIT to the corpus, closing a gap the README has named since 0.1. It is chosen to be
package/README.md CHANGED
@@ -341,7 +341,7 @@ also supplies something no synthetic fixture here had — a montage that names o
341
341
  which is what `EdfAmbiguousChannelError` exists for and had until now only ever been raised against
342
342
  a fixture written to raise it.
343
343
 
344
- **The format author's own calibration file, as of 0.2.59.** `calib.rec` was written by Bob Kemp —
344
+ **The format author's own calibration file, as of 0.2.60.** `calib.rec` was written by Bob Kemp —
345
345
  who wrote the EDF specification — expressly to check that a reader gets amplitude *and polarity*
346
346
  right. It is the only fixture here whose expected values come from neither edfcore nor another
347
347
  library but from the file's own design: its gain is 25/1024, exactly representable in binary, so
@@ -109,5 +109,5 @@ export declare const SIGNAL_FIELD_BLOCK_OFFSETS: {
109
109
  readonly reserved: 224;
110
110
  };
111
111
  /** Published package version. Kept in sync with package.json by a test. */
112
- export declare const VERSION = "0.2.65";
112
+ export declare const VERSION = "0.2.67";
113
113
  //# sourceMappingURL=constants.d.ts.map
package/dist/constants.js CHANGED
@@ -79,5 +79,5 @@ export const SIGNAL_FIELD_BLOCK_OFFSETS = {
79
79
  reserved: 224,
80
80
  };
81
81
  /** Published package version. Kept in sync with package.json by a test. */
82
- export const VERSION = '0.2.65';
82
+ export const VERSION = '0.2.67';
83
83
  //# sourceMappingURL=constants.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"format-header.d.ts","sourceRoot":"","sources":["../src/format-header.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AAEH,OAAO,KAAK,EAAmB,SAAS,EAAE,mBAAmB,EAAE,MAAM,YAAY,CAAC;AAwBlF;;;;;;GAMG;AACH,wBAAgB,YAAY,CAAC,MAAM,EAAE,SAAS,EAAE,OAAO,CAAC,EAAE,mBAAmB,GAAG,MAAM,CA0DrF"}
1
+ {"version":3,"file":"format-header.d.ts","sourceRoot":"","sources":["../src/format-header.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AAIH,OAAO,KAAK,EAAmB,SAAS,EAAE,mBAAmB,EAAE,MAAM,YAAY,CAAC;AA0ClF;;;;;;GAMG;AACH,wBAAgB,YAAY,CAAC,MAAM,EAAE,SAAS,EAAE,OAAO,CAAC,EAAE,mBAAmB,GAAG,MAAM,CAsErF"}
@@ -11,6 +11,8 @@
11
11
  * identification unless asked — a header carries a name and a birth date, and a summary that
12
12
  * lands in a chat log or an issue tracker should not carry them by default.
13
13
  */
14
+ import { TICKS_PER_SECOND } from './constants.js';
15
+ import { summarizeDiagnostics } from './diagnostics/summary.js';
14
16
  function formatDate(date) {
15
17
  if (date === undefined)
16
18
  return 'unknown';
@@ -18,16 +20,33 @@ function formatDate(date) {
18
20
  const day = String(date.day).padStart(2, '0');
19
21
  return `${date.year}-${month}-${day}`;
20
22
  }
21
- function formatDuration(seconds) {
22
- if (!Number.isFinite(seconds) || seconds < 0)
23
+ /**
24
+ * `hh:mm:ss` from an exact tick count.
25
+ *
26
+ * Ticks, not `recordCount * recordDurationSeconds`. That product is float64, and a record duration
27
+ * with no exact binary representation makes it land just under the true value: 100 records of
28
+ * 0.29 s is exactly 29 s and computes as 28.999999999999996, which floors to 28. The header line
29
+ * then reports a recording a whole second shorter than it is (fixed in 0.2.67).
30
+ */
31
+ function formatDurationTicks(ticks) {
32
+ if (ticks < 0n)
23
33
  return 'unknown';
24
- const whole = Math.floor(seconds);
34
+ const whole = Number(ticks / TICKS_PER_SECOND);
25
35
  const hours = Math.floor(whole / 3600);
26
36
  const minutes = Math.floor((whole % 3600) / 60);
27
37
  const rest = whole % 60;
28
38
  const pad = (n) => String(n).padStart(2, '0');
29
39
  return `${pad(hours)}:${pad(minutes)}:${pad(rest)}`;
30
40
  }
41
+ /** Control characters become a dot, so one field can never become two rows or shift a column. */
42
+ function printable(text) {
43
+ let out = '';
44
+ for (const character of text) {
45
+ const code = character.codePointAt(0) ?? 0;
46
+ out += code < 0x20 || code === 0x7f ? '.' : character;
47
+ }
48
+ return out;
49
+ }
31
50
  function formatRate(signal) {
32
51
  // undefined is the honest answer for a zero record duration, which is legal EDF.
33
52
  return signal.sampleRateHz === undefined ? '—' : `${signal.sampleRateHz} Hz`;
@@ -48,7 +67,7 @@ export function formatHeader(header, options) {
48
67
  ` (local, no timezone)`);
49
68
  lines.push(`record ${header.recordDurationSeconds} s · ${header.recordByteLength} bytes · ` +
50
69
  `${header.bytesPerSample} bytes/sample`);
51
- lines.push(`duration ${formatDuration(header.recordCount * header.recordDurationSeconds)} ` +
70
+ lines.push(`duration ${formatDurationTicks(header.recordDurationTicks * BigInt(header.recordCount))} ` +
52
71
  `(${header.recordCount} × ${header.recordDurationSeconds} s)`);
53
72
  if (header.recordCountSource === 'sourceByteLength') {
54
73
  // Worth saying out loud: the count came from the file size, not from the header field.
@@ -62,7 +81,11 @@ export function formatHeader(header, options) {
62
81
  lines.push(' # label kind rate range');
63
82
  for (const signal of header.signals) {
64
83
  const index = String(signal.index).padStart(3);
65
- const label = signal.label.slice(0, 20).padEnd(21);
84
+ // Control characters are replaced, not printed. A label holding a newline would otherwise
85
+ // render as two rows and forge a signal the file does not contain; a tab would shift every
86
+ // column after it. EDF pads labels with spaces and says nothing about what else may be in
87
+ // them, so a writer can put anything there and a reader must not be steered by it.
88
+ const label = printable(signal.label).slice(0, 20).padEnd(21);
66
89
  const kind = signal.kind.padEnd(12);
67
90
  const rate = formatRate(signal).padEnd(9);
68
91
  const range = signal.kind === 'annotations'
@@ -74,11 +97,17 @@ export function formatHeader(header, options) {
74
97
  }
75
98
  if (header.diagnostics.length > 0) {
76
99
  lines.push('');
77
- const counts = new Map();
78
- for (const diagnostic of header.diagnostics) {
79
- counts.set(diagnostic.severity, (counts.get(diagnostic.severity) ?? 0) + 1);
80
- }
81
- const summary = [...counts].map(([severity, count]) => `${count} ${severity}`).join(', ');
100
+ // Fixed error-warning-info order, matching `formatValidationReport` since 0.2.15. Ordering by
101
+ // arrival meant two files with the same diagnostics could summarise them differently.
102
+ const counted = summarizeDiagnostics(header.diagnostics);
103
+ const summary = [
104
+ [counted.errors, 'error'],
105
+ [counted.warnings, 'warning'],
106
+ [counted.infos, 'info'],
107
+ ]
108
+ .filter(([count]) => count > 0)
109
+ .map(([count, severity]) => `${count} ${severity}`)
110
+ .join(', ');
82
111
  lines.push(`${header.diagnostics.length} diagnostic(s): ${summary}`);
83
112
  lines.push('Call formatDiagnostics(header.diagnostics) for the detail.');
84
113
  }
@@ -1 +1 @@
1
- {"version":3,"file":"format-header.js","sourceRoot":"","sources":["../src/format-header.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AAIH,SAAS,UAAU,CAAC,IAAiC;IACnD,IAAI,IAAI,KAAK,SAAS;QAAE,OAAO,SAAS,CAAC;IACzC,MAAM,KAAK,GAAG,MAAM,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC,QAAQ,CAAC,CAAC,EAAE,GAAG,CAAC,CAAC;IAClD,MAAM,GAAG,GAAG,MAAM,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC,QAAQ,CAAC,CAAC,EAAE,GAAG,CAAC,CAAC;IAC9C,OAAO,GAAG,IAAI,CAAC,IAAI,IAAI,KAAK,IAAI,GAAG,EAAE,CAAC;AACxC,CAAC;AAED,SAAS,cAAc,CAAC,OAAe;IACrC,IAAI,CAAC,MAAM,CAAC,QAAQ,CAAC,OAAO,CAAC,IAAI,OAAO,GAAG,CAAC;QAAE,OAAO,SAAS,CAAC;IAC/D,MAAM,KAAK,GAAG,IAAI,CAAC,KAAK,CAAC,OAAO,CAAC,CAAC;IAClC,MAAM,KAAK,GAAG,IAAI,CAAC,KAAK,CAAC,KAAK,GAAG,IAAI,CAAC,CAAC;IACvC,MAAM,OAAO,GAAG,IAAI,CAAC,KAAK,CAAC,CAAC,KAAK,GAAG,IAAI,CAAC,GAAG,EAAE,CAAC,CAAC;IAChD,MAAM,IAAI,GAAG,KAAK,GAAG,EAAE,CAAC;IACxB,MAAM,GAAG,GAAG,CAAC,CAAS,EAAU,EAAE,CAAC,MAAM,CAAC,CAAC,CAAC,CAAC,QAAQ,CAAC,CAAC,EAAE,GAAG,CAAC,CAAC;IAC9D,OAAO,GAAG,GAAG,CAAC,KAAK,CAAC,IAAI,GAAG,CAAC,OAAO,CAAC,IAAI,GAAG,CAAC,IAAI,CAAC,EAAE,CAAC;AACtD,CAAC;AAED,SAAS,UAAU,CAAC,MAAoC;IACtD,iFAAiF;IACjF,OAAO,MAAM,CAAC,YAAY,KAAK,SAAS,CAAC,CAAC,CAAC,GAAG,CAAC,CAAC,CAAC,GAAG,MAAM,CAAC,YAAY,KAAK,CAAC;AAC/E,CAAC;AAED;;;;;;GAMG;AACH,MAAM,UAAU,YAAY,CAAC,MAAiB,EAAE,OAA6B;IAC3E,MAAM,KAAK,GAAa,EAAE,CAAC;IAC3B,MAAM,KAAK,GAAG,MAAM,CAAC,SAAS,CAAC;IAE/B,KAAK,CAAC,IAAI,CACR,GAAG,MAAM,CAAC,OAAO,MAAM,MAAM,CAAC,OAAO,CAAC,MAAM,cAAc,MAAM,CAAC,WAAW,UAAU,CACvF,CAAC;IACF,KAAK,CAAC,IAAI,CACR,gBAAgB,UAAU,CAAC,KAAK,CAAC,YAAY,CAAC,IAAI,MAAM,CAAC,KAAK,CAAC,KAAK,CAAC,IAAI,CAAC,CAAC,QAAQ,CAAC,CAAC,EAAE,GAAG,CAAC,GAAG;QAC5F,GAAG,MAAM,CAAC,KAAK,CAAC,KAAK,CAAC,MAAM,CAAC,CAAC,QAAQ,CAAC,CAAC,EAAE,GAAG,CAAC,IAAI,MAAM,CAAC,KAAK,CAAC,KAAK,CAAC,MAAM,CAAC,CAAC,QAAQ,CAAC,CAAC,EAAE,GAAG,CAAC,EAAE;QAC/F,uBAAuB,CAC1B,CAAC;IACF,KAAK,CAAC,IAAI,CACR,gBAAgB,MAAM,CAAC,qBAAqB,QAAQ,MAAM,CAAC,gBAAgB,WAAW;QACpF,GAAG,MAAM,CAAC,cAAc,eAAe,CAC1C,CAAC;IACF,KAAK,CAAC,IAAI,CACR,gBAAgB,cAAc,CAAC,MAAM,CAAC,WAAW,GAAG,MAAM,CAAC,qBAAqB,CAAC,GAAG;QAClF,IAAI,MAAM,CAAC,WAAW,MAAM,MAAM,CAAC,qBAAqB,KAAK,CAChE,CAAC;IACF,IAAI,MAAM,CAAC,iBAAiB,KAAK,kBAAkB,EAAE,CAAC;QACpD,uFAAuF;QACvF,KAAK,CAAC,IAAI,CAAC,4DAA4D,CAAC,CAAC;IAC3E,CAAC;IAED,IAAI,OAAO,EAAE,gBAAgB,KAAK,IAAI,EAAE,CAAC;QACvC,KAAK,CAAC,IAAI,CAAC,gBAAgB,MAAM,CAAC,OAAO,CAAC,GAAG,CAAC,IAAI,EAAE,IAAI,SAAS,EAAE,CAAC,CAAC;QACrE,KAAK,CAAC,IAAI,CAAC,gBAAgB,MAAM,CAAC,SAAS,CAAC,GAAG,CAAC,IAAI,EAAE,IAAI,SAAS,EAAE,CAAC,CAAC;IACzE,CAAC;IAED,KAAK,CAAC,IAAI,CAAC,EAAE,CAAC,CAAC;IACf,KAAK,CAAC,IAAI,CAAC,yDAAyD,CAAC,CAAC;IACtE,KAAK,MAAM,MAAM,IAAI,MAAM,CAAC,OAAO,EAAE,CAAC;QACpC,MAAM,KAAK,GAAG,MAAM,CAAC,MAAM,CAAC,KAAK,CAAC,CAAC,QAAQ,CAAC,CAAC,CAAC,CAAC;QAC/C,MAAM,KAAK,GAAG,MAAM,CAAC,KAAK,CAAC,KAAK,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,MAAM,CAAC,EAAE,CAAC,CAAC;QACnD,MAAM,IAAI,GAAG,MAAM,CAAC,IAAI,CAAC,MAAM,CAAC,EAAE,CAAC,CAAC;QACpC,MAAM,IAAI,GAAG,UAAU,CAAC,MAAM,CAAC,CAAC,MAAM,CAAC,CAAC,CAAC,CAAC;QAC1C,MAAM,KAAK,GACT,MAAM,CAAC,IAAI,KAAK,aAAa;YAC3B,CAAC,CAAC,GAAG;YACL,CAAC,CAAC,MAAM,CAAC,KAAK,KAAK,SAAS;gBAC1B,CAAC,CAAC,iBAAiB;gBACnB,CAAC,CAAC,GAAG,MAAM,CAAC,eAAe,KAAK,MAAM,CAAC,eAAe,IAAI,MAAM,CAAC,iBAAiB,EAAE,CAAC;QAC3F,KAAK,CAAC,IAAI,CAAC,GAAG,KAAK,KAAK,KAAK,GAAG,IAAI,GAAG,IAAI,IAAI,KAAK,EAAE,CAAC,CAAC;IAC1D,CAAC;IAED,IAAI,MAAM,CAAC,WAAW,CAAC,MAAM,GAAG,CAAC,EAAE,CAAC;QAClC,KAAK,CAAC,IAAI,CAAC,EAAE,CAAC,CAAC;QACf,MAAM,MAAM,GAAG,IAAI,GAAG,EAAkB,CAAC;QACzC,KAAK,MAAM,UAAU,IAAI,MAAM,CAAC,WAAW,EAAE,CAAC;YAC5C,MAAM,CAAC,GAAG,CAAC,UAAU,CAAC,QAAQ,EAAE,CAAC,MAAM,CAAC,GAAG,CAAC,UAAU,CAAC,QAAQ,CAAC,IAAI,CAAC,CAAC,GAAG,CAAC,CAAC,CAAC;QAC9E,CAAC;QACD,MAAM,OAAO,GAAG,CAAC,GAAG,MAAM,CAAC,CAAC,GAAG,CAAC,CAAC,CAAC,QAAQ,EAAE,KAAK,CAAC,EAAE,EAAE,CAAC,GAAG,KAAK,IAAI,QAAQ,EAAE,CAAC,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;QAC1F,KAAK,CAAC,IAAI,CAAC,GAAG,MAAM,CAAC,WAAW,CAAC,MAAM,mBAAmB,OAAO,EAAE,CAAC,CAAC;QACrE,KAAK,CAAC,IAAI,CAAC,4DAA4D,CAAC,CAAC;IAC3E,CAAC;IAED,OAAO,KAAK,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;AAC1B,CAAC"}
1
+ {"version":3,"file":"format-header.js","sourceRoot":"","sources":["../src/format-header.ts"],"names":[],"mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "edfcore",
3
- "version": "0.2.65",
3
+ "version": "0.2.67",
4
4
  "description": "Modern, typed, zero-dependency reader for EDF, EDF+, BDF and BDF+ biosignal files. Works in browsers and Node with true random access.",
5
5
  "keywords": [
6
6
  "edf",
package/src/constants.ts CHANGED
@@ -93,4 +93,4 @@ export const SIGNAL_FIELD_BLOCK_OFFSETS = {
93
93
  } as const;
94
94
 
95
95
  /** Published package version. Kept in sync with package.json by a test. */
96
- export const VERSION = '0.2.65';
96
+ export const VERSION = '0.2.67';
@@ -12,6 +12,8 @@
12
12
  * lands in a chat log or an issue tracker should not carry them by default.
13
13
  */
14
14
 
15
+ import { TICKS_PER_SECOND } from './constants.js';
16
+ import { summarizeDiagnostics } from './diagnostics/summary.js';
15
17
  import type { EdfCalendarDate, EdfHeader, FormatHeaderOptions } from './types.js';
16
18
 
17
19
  function formatDate(date: EdfCalendarDate | undefined): string {
@@ -21,9 +23,17 @@ function formatDate(date: EdfCalendarDate | undefined): string {
21
23
  return `${date.year}-${month}-${day}`;
22
24
  }
23
25
 
24
- function formatDuration(seconds: number): string {
25
- if (!Number.isFinite(seconds) || seconds < 0) return 'unknown';
26
- const whole = Math.floor(seconds);
26
+ /**
27
+ * `hh:mm:ss` from an exact tick count.
28
+ *
29
+ * Ticks, not `recordCount * recordDurationSeconds`. That product is float64, and a record duration
30
+ * with no exact binary representation makes it land just under the true value: 100 records of
31
+ * 0.29 s is exactly 29 s and computes as 28.999999999999996, which floors to 28. The header line
32
+ * then reports a recording a whole second shorter than it is (fixed in 0.2.67).
33
+ */
34
+ function formatDurationTicks(ticks: bigint): string {
35
+ if (ticks < 0n) return 'unknown';
36
+ const whole = Number(ticks / TICKS_PER_SECOND);
27
37
  const hours = Math.floor(whole / 3600);
28
38
  const minutes = Math.floor((whole % 3600) / 60);
29
39
  const rest = whole % 60;
@@ -31,6 +41,16 @@ function formatDuration(seconds: number): string {
31
41
  return `${pad(hours)}:${pad(minutes)}:${pad(rest)}`;
32
42
  }
33
43
 
44
+ /** Control characters become a dot, so one field can never become two rows or shift a column. */
45
+ function printable(text: string): string {
46
+ let out = '';
47
+ for (const character of text) {
48
+ const code = character.codePointAt(0) ?? 0;
49
+ out += code < 0x20 || code === 0x7f ? '.' : character;
50
+ }
51
+ return out;
52
+ }
53
+
34
54
  function formatRate(signal: EdfHeader['signals'][number]): string {
35
55
  // undefined is the honest answer for a zero record duration, which is legal EDF.
36
56
  return signal.sampleRateHz === undefined ? '—' : `${signal.sampleRateHz} Hz`;
@@ -60,7 +80,7 @@ export function formatHeader(header: EdfHeader, options?: FormatHeaderOptions):
60
80
  `${header.bytesPerSample} bytes/sample`,
61
81
  );
62
82
  lines.push(
63
- `duration ${formatDuration(header.recordCount * header.recordDurationSeconds)} ` +
83
+ `duration ${formatDurationTicks(header.recordDurationTicks * BigInt(header.recordCount))} ` +
64
84
  `(${header.recordCount} × ${header.recordDurationSeconds} s)`,
65
85
  );
66
86
  if (header.recordCountSource === 'sourceByteLength') {
@@ -77,7 +97,11 @@ export function formatHeader(header: EdfHeader, options?: FormatHeaderOptions):
77
97
  lines.push(' # label kind rate range');
78
98
  for (const signal of header.signals) {
79
99
  const index = String(signal.index).padStart(3);
80
- const label = signal.label.slice(0, 20).padEnd(21);
100
+ // Control characters are replaced, not printed. A label holding a newline would otherwise
101
+ // render as two rows and forge a signal the file does not contain; a tab would shift every
102
+ // column after it. EDF pads labels with spaces and says nothing about what else may be in
103
+ // them, so a writer can put anything there and a reader must not be steered by it.
104
+ const label = printable(signal.label).slice(0, 20).padEnd(21);
81
105
  const kind = signal.kind.padEnd(12);
82
106
  const rate = formatRate(signal).padEnd(9);
83
107
  const range =
@@ -91,11 +115,19 @@ export function formatHeader(header: EdfHeader, options?: FormatHeaderOptions):
91
115
 
92
116
  if (header.diagnostics.length > 0) {
93
117
  lines.push('');
94
- const counts = new Map<string, number>();
95
- for (const diagnostic of header.diagnostics) {
96
- counts.set(diagnostic.severity, (counts.get(diagnostic.severity) ?? 0) + 1);
97
- }
98
- const summary = [...counts].map(([severity, count]) => `${count} ${severity}`).join(', ');
118
+ // Fixed error-warning-info order, matching `formatValidationReport` since 0.2.15. Ordering by
119
+ // arrival meant two files with the same diagnostics could summarise them differently.
120
+ const counted = summarizeDiagnostics(header.diagnostics);
121
+ const summary = (
122
+ [
123
+ [counted.errors, 'error'],
124
+ [counted.warnings, 'warning'],
125
+ [counted.infos, 'info'],
126
+ ] as const
127
+ )
128
+ .filter(([count]) => count > 0)
129
+ .map(([count, severity]) => `${count} ${severity}`)
130
+ .join(', ');
99
131
  lines.push(`${header.diagnostics.length} diagnostic(s): ${summary}`);
100
132
  lines.push('Call formatDiagnostics(header.diagnostics) for the detail.');
101
133
  }