/** * A header as text. * * Layer 7, and pure. `formatDiagnostics` already turns problems into something a human reads; * this does the same for the header itself — the thing that goes into a bug report, a CLI, or a * log line when a file behaves oddly. * * Two rules keep it useful rather than decorative. It never invents a value: a field edfcore * could not resolve prints as `unknown` rather than as a plausible default, because the whole * point of pasting this somewhere is that the reader can trust it. And it prints no patient * identification unless asked — a header carries a name and a birth date, and a summary that * lands in a chat log or an issue tracker should not carry them by default. */ import { trimEdfField } from './bytes/latin1.js'; import { TICKS_PER_SECOND } from './constants.js'; import { summarizeDiagnostics } from './diagnostics/summary.js'; import { formatCalendarDate, formatClockTime } from './header/dates.js'; import { requireBooleanOption } from './options.js'; import { pluralise } from './text/counted.js'; import { describeValue } from './text/describe.js'; import { printable } from './text/printable.js'; import type { EdfCalendarDate, EdfHeader, FormatHeaderOptions } from './types.js'; /** * `formatCalendarDate`, not a second renderer for the same type. * * The private copy this replaced padded the month and the day but not the year, so any year below * 1000 came out one way here and another way everywhere else. That is reachable from a * conforming-length field: `parseSubfieldDate` requires the EDF+ `dd-MMM-yyyy` Startdate year to be * four characters, not to be >= 1000, so `Startdate 24-APR-0985` resolves to year 985 and one * `edfcore header` run printed `985-04-24` on the start line and `0985-04-24` in a * DATE_FIELDS_DISAGREE diagnostic eight lines below it (fixed in 0.3.110). */ function formatDate(date: EdfCalendarDate | undefined): string { return date === undefined ? 'unknown' : formatCalendarDate(date); } /** * `hh:mm:ss` from an exact tick count. * * Ticks, not `recordCount * recordDurationSeconds`. That product is float64, and a record duration * with no exact binary representation makes it land just under the true value: 100 records of * 0.29 s is exactly 29 s and computes as 28.999999999999996, which floors to 28. The header line * then reports a recording a whole second shorter than it is (fixed in 0.2.67). */ function formatDurationTicks(ticks: bigint): string { if (ticks < 0n) return 'unknown'; const whole = Number(ticks / TICKS_PER_SECOND); const hours = Math.floor(whole / 3600); const minutes = Math.floor((whole % 3600) / 60); const rest = whole % 60; const pad = (n: number): string => String(n).padStart(2, '0'); return `${pad(hours)}:${pad(minutes)}:${pad(rest)}`; } /** `' uV'`, or nothing at all for the blank field a plain EDF file is entitled to write. */ function dimensionSuffix(signal: EdfHeader['signals'][number]): string { const dimension = printable(signal.physicalDimension); return dimension === '' ? '' : ` ${dimension}`; } /** * The rate for the table, rounded when the exact value would not fit in a column. * * `sampleRateHz` is `samplesPerRecord / recordDurationSeconds`, and that division does not have to * come out. A 0.29 s record holding 20 samples is 68.96551724137932 Hz, and interpolating it raw * put seventeen digits into a nine-character column — pushing `range` off its position on that row * alone, which is the alignment `formatHeader` was fixed for once already in 0.3.96, arriving by a * different route. It also stated a derived quotient to the last bit of a float64, in a table whose * other numbers are what the file says. * * `~` rather than a silently rounded number, because this module promises never to invent a value. * The exact rate is on `signal.sampleRateHz`, `edfcore signals` prints it unrounded for scripts, * and `samplesPerRecord` is the authoritative field either way — `cli.md` says to index by that and * never by the rate. * * A rate whose exact spelling already fits is printed exactly, so the ordinary file is unchanged * and the tilde means something when it appears. A rate too small to survive two decimal places * falls back to the exact value rather than printing `~0 Hz`, which would be a claim rather than a * rounding. */ function formatRate(signal: EdfHeader['signals'][number]): string { const rate = signal.sampleRateHz; // undefined is the honest answer for a zero record duration, which is legal EDF. if (rate === undefined) return '—'; const exact = `${rate}`; if (exact.length <= 6) return `${exact} Hz`; const rounded = Number(rate.toFixed(2)); return rounded === 0 ? `${exact} Hz` : `~${rounded} Hz`; } /** * A multi-line summary of a header. * * Patient identification is omitted unless `includePatientId` is set. That is not a privacy * feature — the data is still in `header.patient` for anyone who wants it — it is a default * chosen so that the obvious thing to do with this string is also the safe one. */ export function formatHeader(header: EdfHeader, options?: FormatHeaderOptions): string { // The recording is what a reader has in hand, and this is the report they want printed of it, so // `formatHeader(recording)` is the call the name invites. It read `recording.startTime` and threw // V8's `Cannot read properties of undefined (reading 'startTime')` (fixed in 0.6.110). const signals = (header as { signals?: unknown } | null | undefined)?.signals; if (!Array.isArray(signals)) { /* * A FORGOTTEN AWAIT, which this message's own advice walks a reader past. * * The next step names `parseHeader`, which is synchronous — and the call a reader reaches for * when they have a SOURCE rather than bytes is `readHeader`, which is not. 0.6.217 made this * argument for the three lookups and 0.6.229 for `validateHeader`; this is the same sentence in * the printer, and it named nothing a reader could act on: "it has no signals" is true of a pending * Promise, and true of almost everything else. */ if (typeof (header as { then?: unknown } | null | undefined)?.then === 'function') { throw new RangeError( 'formatHeader(): that is a pending Promise, not a header. Next: await readHeader(source) — it ' + 'resolves to the header this takes.', ); } throw new RangeError( 'formatHeader(): that is not a header — it has no signals. Next: pass recording.header, or ' + 'what parseHeader(bytes, sourceByteLength) returned.', ); } /* * A CHUNK, whose `signals` array satisfies the check above. * * The same slip as the recording, one object along: a chunk is what a reader holds after a read, * and "print what I just read" is what this call's name offers. It reached * `header.startTime.clockSource` and threw V8's `Cannot read properties of undefined` — the exact * failure 0.6.110 added the guard above to remove. * * Being an array of the right signals is the test, as 0.6.183 and 0.6.186 settled for the lookups * and for `trimToWindow`. */ if (typeof (signals[0] as { signalIndex?: unknown } | undefined)?.signalIndex === 'number') { throw new RangeError( 'formatHeader(): that is a chunk, not a header — a chunk has a signals array too, but its ' + 'entries carry samples rather than the declarations this prints. Next: pass ' + 'recording.header.', ); } /* * The OPTIONS, which are two flags and nothing else — so the value a caller means is one of * them, and `formatHeader(header, true)` is what gets written for "include the identification". * * 0.6.130, 0.6.140, 0.6.154, 0.6.163 and 0.6.164 each refused this shape elsewhere. What makes * it worth its own guard here is that the failure is INVISIBLE IN THE OUTPUT. When the lines are * asked for, an empty identification field prints as `unknown`, which is this module's promise: * it never invents a value, "because the whole point of pasting this somewhere is that the * reader can trust it". When the flag is dropped the lines are not printed at all, so the * summary is byte-identical to one nobody asked for — and a reader checking whether a file * carries a name concludes that it does not. * * `undefined` and `null` still mean "no options", which is what they already meant. */ if (options !== undefined && typeof options !== 'object') { throw new RangeError( `formatHeader(): the options are ${describeValue(options)}, not an object — ` + 'includePatientId is a field on one, so the identification lines would have been left ' + 'out and the summary would look exactly like one that never asked for them. ' + 'Next: pass includePatientId on an options object.', ); } /* * The FLAGS, once the options object is there. 0.6.182 closed this for `strict` and 0.6.193 for * `scanSamples`, on the same argument: a flag is compared against a boolean rather than coerced, * so text takes one side of it silently. * * They take opposite sides here, which is why both are named. `includePatientId` is read as * `=== true`, so `'true'` out of a config left the identification out of a report that asked for * it; `diagnosticsHint` is read as `!== false`, so `'false'` printed the hint line the caller was * suppressing. `edfcore header` passes that one `false` precisely because it prints the detail * itself one line below. */ requireBooleanOption( options?.includePatientId, 'includePatientId', 'the identification was left out of a report that asked for it', ); requireBooleanOption( options?.diagnosticsHint, 'diagnosticsHint', 'the hint line was printed under a report that suppressed it', ); const lines: string[] = []; const start = header.startTime; // Through `pluralise`, which is the whole reason that helper is a module rather than a private // function in the formatter next door. This is the first line of `edfcore header`, and on the // one-signal file that most of this suite is built from it read `EDF · 1 signals · 6 records` // while `edfcore validate` on the same file said `scanned 6 records` correctly (fixed in 0.6.4). lines.push( `${header.variant} · ${pluralise(header.signals.length, 'signal')} · ` + `${pluralise(header.recordCount, 'record')}`, ); // `unknown`, not a substituted midnight. The module promise two paragraphs up is that a field // edfcore could not resolve prints as `unknown` rather than as a plausible default, and the date // half has always honoured it. The clock half printed `00:00:00` for a starttime field that // failed its grammar — byte-identical to a file that genuinely started at midnight, which for a // sleep study is the most believable start there is (fixed in 0.3.17). // `formatClockTime`, not a second renderer, for the reason `formatDate` above gives about the // date half: the private copy of THAT one padded the year differently and printed `985-04-24` // here against `0985-04-24` eight lines below. This copy agreed with the shared one on every // input, which is the state the date copy was in until it did not (0.6.6). const clock = start.clockSource === 'none' ? 'unknown' : formatClockTime(start.clock); lines.push(`start ${formatDate(start.resolvedDate)} ${clock} (local, no timezone)`); lines.push( `record ${header.recordDurationSeconds} s · ${header.recordByteLength} bytes · ` + `${header.bytesPerSample} bytes/sample`, ); // "duration" is only honest for a file whose records run end to end. On an EDF+D file this // number is what the records COVER, and the recording reaches further by however much the gaps // add up to — a four-record file with an hour-long hole in it printed `duration 00:00:04` for a // recording that spans 3604 s. Someone pasting that into a bug report says "a 4-second file". // // A header alone cannot know the span: it is the last record's onset minus the first's, and // those live in the timekeeping TALs. What a header does know is that this file claims its // records do not run end to end, so the label says what the number is and the next line says // where the span comes from. // // WHICH WAY they fail to run end to end is the part a header cannot know either, and the two // notes below asserted one of them. `EDF+D` means discontinuous; the records may leave gaps, and // they may also OVERLAP — an overlap is one instant two records both claim, and a file with one // covers MORE time than it spans. "the gaps between them are not in it" told a reader the // recording reaches further than the number, on a file where it reaches less far: 6 s covered // against a span of 3.5 s, printed as though 6 s were a floor. // // 0.3.3 stated the partition — "a gap is time no record covers; an overlap is one instant two // records both claim" — and 0.3.33, 0.3.41 and 0.3.59 applied it to a site each. This is the // fifth, and the first line of `edfcore header`. const discontinuous = header.continuity === 'discontinuous'; const label = discontinuous ? 'covered ' : 'duration '; lines.push( `${label} ${formatDurationTicks(header.recordDurationTicks * BigInt(header.recordCount))} ` + `(${header.recordCount} × ${header.recordDurationSeconds} s)`, ); if (discontinuous) { lines.push(' what the records cover, which on this file is not the span: its'); lines.push(' records may leave gaps between them, and may overlap each other'); lines.push(' buildRecordIndex(recording) reports the span and which it is'); } if (header.recordCountSource === 'sourceByteLength') { // Worth saying out loud: the count came from the file size, not from the header field. lines.push(' record count recovered from the source length'); } if (options?.includePatientId === true) { // Through `printable`, for the reason every other field here is: these are 80 arbitrary bytes // each. 0.3.2 fixed this class in five outputs and missed these two, because the lines are off // by default and no test asked for them. A newline in the patient field opened a row matching // the signal-table shape exactly — ` 0 99 signals · 0 records` — and one in the recording // field forged a `record 9 s` line at the left margin, contradicting the real geometry // three lines above it (fixed in 0.3.16). // `trimEdfField`, not `String.prototype.trim`. These are the untrimmed 80 bytes, and `.trim()` // strips whitespace but NOT U+0000 — so on the NUL-padded identification fields a large share // of real writers emit, the padding survived and `printable` turned every NUL into a `.`. An // empty patient field printed as eighty dots, which made the `|| 'unknown'` below unreachable // and read as redaction; a populated one trailed dots that read as truncation. It is the same // gap `redactDiagnostic` names in diagnostics/format.ts, and every other consumer of these // bytes — `parsePatientId`, `validateRecording`, `redactDiagnostic` — already used // `trimEdfField` (fixed in 0.3.48). lines.push(`patient ${printable(trimEdfField(header.patient.raw)) || 'unknown'}`); lines.push(`recording ${printable(trimEdfField(header.recording.raw)) || 'unknown'}`); } lines.push(''); /* * Wide enough for the largest index in THIS file, never narrower than the three the heading has * always used. EDF's signal-count field is four characters, so 9999 signals is a legal file and * a thousand-channel one is an ordinary high-density recording — and a fixed width of three put * signal 1000 one column to the right of signal 999, in the middle of the same table. Rows * either side of that boundary had their last three columns in different places, which is worse * than a table that is uniformly wrong: nothing about it looks like a formatting decision. * * A file with fewer than a thousand signals prints exactly what it printed before (0.6.24). */ const indexWidth = Math.max(3, `${Math.max(0, header.signals.length - 1)}`.length); // Built from the SAME widths as the data rows below, not spaced by hand. The hand-spaced literal // had one space too many after `label` and one after `kind`, so `kind` sat at column 27 over data // at 26 and `rate` and `range` were two out — on every file, in the output whose whole purpose is // being read in a terminal (fixed in 0.3.96). lines.push( `${'#'.padStart(indexWidth)} ${'label'.padEnd(21)}${'kind'.padEnd(12)}${'rate'.padEnd(9)} range`, ); for (const signal of header.signals) { const index = String(signal.index).padStart(indexWidth); // Control characters are replaced, not printed. A label holding a newline would otherwise // render as two rows and forge a signal the file does not contain; a tab would shift every // column after it. EDF pads labels with spaces and says nothing about what else may be in // them, so a writer can put anything there and a reader must not be steered by it. const label = printable(signal.label).slice(0, 20).padEnd(21); const kind = signal.kind.padEnd(12); const rate = formatRate(signal).padEnd(9); const range = signal.kind === 'annotations' ? '—' : signal.scale === undefined ? 'no usable scale' : // Through `printable` for the same reason the label is, and it is the same row: the // dimension is 8 arbitrary header bytes, `trimEdfField` strips only 0x20 and 0x00, and // this is the LAST thing on the line — so a newline in it puts everything after it at // column 0, where it reads as another signal. `edfcore signals` already sanitised this // field; `edfcore header` did not (fixed in 0.3.47). // // The separator belongs to the dimension, not to the range. A blank physical dimension // is ordinary — two of the seven files in the corpus have one, and EDF requires nothing // of the field — and printing the space anyway left the row ending in whitespace, on // the last thing on the line, where nothing shows it (fixed in 0.6.10). `${signal.physicalMinimum}..${signal.physicalMaximum}` + dimensionSuffix(signal); lines.push(`${index} ${label}${kind}${rate} ${range}`); } if (header.diagnostics.length > 0) { lines.push(''); // Fixed error-warning-info order, matching `formatValidationReport` since 0.2.15. Ordering by // arrival meant two files with the same diagnostics could summarise them differently. const counted = summarizeDiagnostics(header.diagnostics); const summary = ( [ [counted.errors, 'error'], [counted.warnings, 'warning'], [counted.infos, 'info'], ] as const ) .filter(([count]) => count > 0) // The 0.4.421 defect verbatim, in the other formatter: `2 warning`, under a heading that // had already counted them. `formatValidationReport` was fixed then and this was not. .map(([count, severity]) => pluralise(count, severity)) .join(', '); lines.push(`${pluralise(header.diagnostics.length, 'diagnostic')}: ${summary}`); if (options?.diagnosticsHint !== false) { lines.push('Call formatDiagnostics(header.diagnostics) for the detail.'); } } return lines.join('\n'); }