@crewhaus/tool-media 0.7.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.
- package/README.md +156 -0
- package/dist/fixtures.d.ts +81 -0
- package/dist/fixtures.js +385 -0
- package/dist/index.d.ts +25 -0
- package/dist/index.js +1132 -0
- package/dist/lib/barcode.d.ts +43 -0
- package/dist/lib/barcode.js +310 -0
- package/dist/lib/bytes.d.ts +45 -0
- package/dist/lib/bytes.js +162 -0
- package/dist/lib/chart.d.ts +32 -0
- package/dist/lib/chart.js +290 -0
- package/dist/lib/color.d.ts +53 -0
- package/dist/lib/color.js +267 -0
- package/dist/lib/diagram.d.ts +48 -0
- package/dist/lib/diagram.js +256 -0
- package/dist/lib/ffprobe.d.ts +36 -0
- package/dist/lib/ffprobe.js +154 -0
- package/dist/lib/identify.d.ts +39 -0
- package/dist/lib/identify.js +519 -0
- package/dist/lib/imageops.d.ts +86 -0
- package/dist/lib/imageops.js +236 -0
- package/dist/lib/jpeg.d.ts +81 -0
- package/dist/lib/jpeg.js +388 -0
- package/dist/lib/png.d.ts +59 -0
- package/dist/lib/png.js +466 -0
- package/dist/lib/qr.d.ts +70 -0
- package/dist/lib/qr.js +544 -0
- package/dist/lib/raster.d.ts +18 -0
- package/dist/lib/raster.js +82 -0
- package/dist/lib/sparkline.d.ts +21 -0
- package/dist/lib/sparkline.js +93 -0
- package/dist/lib/subtitle.d.ts +62 -0
- package/dist/lib/subtitle.js +233 -0
- package/dist/lib/svg.d.ts +76 -0
- package/dist/lib/svg.js +168 -0
- package/dist/paths.d.ts +53 -0
- package/dist/paths.js +161 -0
- package/dist/proc.d.ts +39 -0
- package/dist/proc.js +158 -0
- package/package.json +45 -0
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sparklines — a series reduced to something that fits on one line.
|
|
3
|
+
*
|
|
4
|
+
* Two renderings: an SVG path for a report or a dashboard, and a run of
|
|
5
|
+
* Unicode block characters for a digest line, a commit message or a
|
|
6
|
+
* terminal. Both quantise the same way, so the shapes agree.
|
|
7
|
+
*/
|
|
8
|
+
import { MediaFormatError } from "./bytes";
|
|
9
|
+
import { circle, escapeXml, num, polyline, rect, svgDocument } from "./svg";
|
|
10
|
+
/** Eight levels, from an eighth of a block to a full one. */
|
|
11
|
+
const BLOCKS = ["▁", "▂", "▃", "▄", "▅", "▆", "▇", "█"];
|
|
12
|
+
function resolveRange(values, explicit) {
|
|
13
|
+
const min = explicit.min ?? Math.min(...values);
|
|
14
|
+
const max = explicit.max ?? Math.max(...values);
|
|
15
|
+
if (min > max)
|
|
16
|
+
throw new MediaFormatError(`min ${min} is above max ${max}`);
|
|
17
|
+
if (min === max) {
|
|
18
|
+
// A flat series has no shape; centre it rather than divide by zero.
|
|
19
|
+
return { min: min - 0.5, max: max + 0.5 };
|
|
20
|
+
}
|
|
21
|
+
return { min, max };
|
|
22
|
+
}
|
|
23
|
+
function check(values) {
|
|
24
|
+
if (values.length === 0)
|
|
25
|
+
throw new MediaFormatError("a sparkline needs at least one value");
|
|
26
|
+
for (const value of values) {
|
|
27
|
+
if (!Number.isFinite(value))
|
|
28
|
+
throw new MediaFormatError(`the series contains ${value}`);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* The series as block characters, one per value. Values at or below `min`
|
|
33
|
+
* get the shortest block and values at or above `max` the tallest, so a
|
|
34
|
+
* caller supplying an explicit range gets clipping rather than a surprise.
|
|
35
|
+
*/
|
|
36
|
+
export function sparklineBlocks(values, explicit = {}) {
|
|
37
|
+
check(values);
|
|
38
|
+
const range = resolveRange(values, explicit);
|
|
39
|
+
const span = range.max - range.min;
|
|
40
|
+
let out = "";
|
|
41
|
+
for (const value of values) {
|
|
42
|
+
const fraction = (value - range.min) / span;
|
|
43
|
+
const level = Math.min(7, Math.max(0, Math.round(fraction * 7)));
|
|
44
|
+
out += BLOCKS[level];
|
|
45
|
+
}
|
|
46
|
+
return out;
|
|
47
|
+
}
|
|
48
|
+
/** The series as a small standalone SVG. */
|
|
49
|
+
export function sparklineSvg(values, options = {}, explicit = {}) {
|
|
50
|
+
check(values);
|
|
51
|
+
const width = options.width ?? 120;
|
|
52
|
+
const height = options.height ?? 24;
|
|
53
|
+
if (width < 8 || height < 4) {
|
|
54
|
+
throw new MediaFormatError(`${width}x${height} is too small for a sparkline`);
|
|
55
|
+
}
|
|
56
|
+
const range = resolveRange(values, explicit);
|
|
57
|
+
const span = range.max - range.min;
|
|
58
|
+
const pad = 2;
|
|
59
|
+
const usableWidth = width - pad * 2;
|
|
60
|
+
const usableHeight = height - pad * 2;
|
|
61
|
+
const step = values.length === 1 ? 0 : usableWidth / (values.length - 1);
|
|
62
|
+
const points = values.map((value, i) => {
|
|
63
|
+
const clamped = Math.min(range.max, Math.max(range.min, value));
|
|
64
|
+
return [pad + i * step, pad + usableHeight - ((clamped - range.min) / span) * usableHeight];
|
|
65
|
+
});
|
|
66
|
+
const stroke = options.stroke ?? "#3b6fd4";
|
|
67
|
+
const body = [];
|
|
68
|
+
if (options.fill !== undefined) {
|
|
69
|
+
const first = points[0];
|
|
70
|
+
const last = points[points.length - 1];
|
|
71
|
+
const area = [
|
|
72
|
+
`M ${num(first[0])} ${num(height - pad)}`,
|
|
73
|
+
...points.map(([x, y]) => `L ${num(x)} ${num(y)}`),
|
|
74
|
+
`L ${num(last[0])} ${num(height - pad)}`,
|
|
75
|
+
"Z",
|
|
76
|
+
].join(" ");
|
|
77
|
+
// Escaped like every other caller-supplied colour: a raw `"` here would
|
|
78
|
+
// close the attribute and let the value append elements of its own.
|
|
79
|
+
body.push(`<path d="${area}" fill="${escapeXml(options.fill)}"/>`);
|
|
80
|
+
}
|
|
81
|
+
if (points.length === 1) {
|
|
82
|
+
const only = points[0];
|
|
83
|
+
body.push(rect(pad, only[1] - 1, usableWidth, 2, stroke));
|
|
84
|
+
}
|
|
85
|
+
else {
|
|
86
|
+
body.push(polyline(points, stroke, 1.5));
|
|
87
|
+
}
|
|
88
|
+
if (options.markLast === true) {
|
|
89
|
+
const last = points[points.length - 1];
|
|
90
|
+
body.push(circle(last[0], last[1], 2, stroke));
|
|
91
|
+
}
|
|
92
|
+
return svgDocument(width, height, options.background ?? "none", body);
|
|
93
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
export type SubtitleFormat = "srt" | "vtt";
|
|
2
|
+
export type Cue = {
|
|
3
|
+
/** 1-based position in the file, renumbered on write. */
|
|
4
|
+
readonly index: number;
|
|
5
|
+
readonly startMs: number;
|
|
6
|
+
readonly endMs: number;
|
|
7
|
+
/** Cue text, with its own line breaks kept as `\n`. */
|
|
8
|
+
readonly text: string;
|
|
9
|
+
/** The cue identifier, where the file gave one. */
|
|
10
|
+
readonly id?: string;
|
|
11
|
+
/** WebVTT cue settings, verbatim, where the file gave any. */
|
|
12
|
+
readonly settings?: string;
|
|
13
|
+
};
|
|
14
|
+
export type ParseResult = {
|
|
15
|
+
readonly format: SubtitleFormat;
|
|
16
|
+
readonly cues: ReadonlyArray<Cue>;
|
|
17
|
+
/** Blocks that looked like cues but could not be read, with the reason. */
|
|
18
|
+
readonly skipped: ReadonlyArray<{
|
|
19
|
+
block: number;
|
|
20
|
+
reason: string;
|
|
21
|
+
}>;
|
|
22
|
+
};
|
|
23
|
+
/** `HH:MM:SS,mmm`, `MM:SS.mmm` and the other legal spellings, to millis. */
|
|
24
|
+
export declare function parseTimestamp(text: string): number;
|
|
25
|
+
/** Milliseconds to `HH:MM:SS,mmm` (SRT) or `HH:MM:SS.mmm` (WebVTT). */
|
|
26
|
+
export declare function formatTimestamp(ms: number, format: SubtitleFormat): string;
|
|
27
|
+
/** The format a document is in, from its first meaningful line. */
|
|
28
|
+
export declare function detectSubtitleFormat(text: string): SubtitleFormat;
|
|
29
|
+
/** Parse an SRT or WebVTT document. The format is detected when not given. */
|
|
30
|
+
export declare function parseSubtitles(input: string, format?: SubtitleFormat): ParseResult;
|
|
31
|
+
/**
|
|
32
|
+
* Greedy word wrap at `columns`, preserving the line breaks already in the
|
|
33
|
+
* text. A word longer than `columns` gets its own line rather than being
|
|
34
|
+
* broken mid-word.
|
|
35
|
+
*/
|
|
36
|
+
export declare function wrapCueText(text: string, columns: number): string;
|
|
37
|
+
export type WriteOptions = {
|
|
38
|
+
/** Milliseconds to add to every cue; may be negative. */
|
|
39
|
+
readonly shiftMs?: number;
|
|
40
|
+
/** Re-wrap each cue's text to this many columns. */
|
|
41
|
+
readonly wrapColumns?: number;
|
|
42
|
+
/** WebVTT only: text appended to the `WEBVTT` header line. */
|
|
43
|
+
readonly header?: string;
|
|
44
|
+
};
|
|
45
|
+
export type WriteResult = {
|
|
46
|
+
readonly text: string;
|
|
47
|
+
readonly cueCount: number;
|
|
48
|
+
/** Cues dropped because the shift moved them before zero. */
|
|
49
|
+
readonly dropped: number;
|
|
50
|
+
};
|
|
51
|
+
/**
|
|
52
|
+
* Cues back to a document. A negative shift that would push a cue before
|
|
53
|
+
* zero drops that cue and says so, rather than clamping it to 00:00:00 and
|
|
54
|
+
* silently stacking several cues on top of one another.
|
|
55
|
+
*
|
|
56
|
+
* Caller values cannot change the document's STRUCTURE: cue text is put
|
|
57
|
+
* through `cueBody` so a blank line in it cannot end the cue early, and the
|
|
58
|
+
* one-line fields (a WebVTT id, its settings, the `WEBVTT` header) are
|
|
59
|
+
* refused outright when they carry a line break or a `-->`. Reading the
|
|
60
|
+
* result back therefore always yields the cues that went in, and only those.
|
|
61
|
+
*/
|
|
62
|
+
export declare function writeSubtitles(cues: ReadonlyArray<Cue>, format: SubtitleFormat, options?: WriteOptions): WriteResult;
|
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SubRip (.srt) and WebVTT (.vtt) subtitles: parsing to cues, and writing
|
|
3
|
+
* cues back out with optional time shifting and re-wrapping.
|
|
4
|
+
*
|
|
5
|
+
* ## Read
|
|
6
|
+
*
|
|
7
|
+
* Both formats, with or without a BOM, with CRLF or LF line endings, with
|
|
8
|
+
* or without the cue identifier line. WebVTT `NOTE`, `STYLE` and `REGION`
|
|
9
|
+
* blocks are recognised and skipped rather than mistaken for cues, and cue
|
|
10
|
+
* settings (`align:start position:10%`) are preserved as an opaque string
|
|
11
|
+
* so writing a VTT back does not silently drop the positioning.
|
|
12
|
+
*
|
|
13
|
+
* ## Not read
|
|
14
|
+
*
|
|
15
|
+
* WebVTT chapter and metadata tracks, and SRT's unofficial coordinate
|
|
16
|
+
* extension. Inline markup (`<i>`, `<c.classname>`) is left in the cue text
|
|
17
|
+
* verbatim — this does not parse it and does not strip it.
|
|
18
|
+
*
|
|
19
|
+
* ## Times
|
|
20
|
+
*
|
|
21
|
+
* Every cue carries integer milliseconds. Nothing here reads a clock: a
|
|
22
|
+
* shift is an input, never "now minus something".
|
|
23
|
+
*/
|
|
24
|
+
import { MediaFormatError } from "./bytes";
|
|
25
|
+
const TIMING = /^(.*?)-->(.*?)$/;
|
|
26
|
+
const TIMESTAMP = /^(?:(\d+):)?(\d{1,2}):(\d{1,2})[.,](\d{1,3})$/;
|
|
27
|
+
/** `HH:MM:SS,mmm`, `MM:SS.mmm` and the other legal spellings, to millis. */
|
|
28
|
+
export function parseTimestamp(text) {
|
|
29
|
+
const match = TIMESTAMP.exec(text.trim());
|
|
30
|
+
if (!match)
|
|
31
|
+
throw new MediaFormatError(`"${text}" is not a subtitle timestamp`);
|
|
32
|
+
const hours = match[1] === undefined ? 0 : Number.parseInt(match[1], 10);
|
|
33
|
+
const minutes = Number.parseInt(match[2], 10);
|
|
34
|
+
const seconds = Number.parseInt(match[3], 10);
|
|
35
|
+
const fraction = match[4].padEnd(3, "0");
|
|
36
|
+
if (minutes > 59 || seconds > 59) {
|
|
37
|
+
throw new MediaFormatError(`"${text}" has a minute or second field above 59`);
|
|
38
|
+
}
|
|
39
|
+
return hours * 3_600_000 + minutes * 60_000 + seconds * 1000 + Number.parseInt(fraction, 10);
|
|
40
|
+
}
|
|
41
|
+
/** Milliseconds to `HH:MM:SS,mmm` (SRT) or `HH:MM:SS.mmm` (WebVTT). */
|
|
42
|
+
export function formatTimestamp(ms, format) {
|
|
43
|
+
if (ms < 0)
|
|
44
|
+
throw new MediaFormatError(`cannot format a negative timestamp (${ms}ms)`);
|
|
45
|
+
const total = Math.round(ms);
|
|
46
|
+
const hours = Math.floor(total / 3_600_000);
|
|
47
|
+
const minutes = Math.floor((total % 3_600_000) / 60_000);
|
|
48
|
+
const seconds = Math.floor((total % 60_000) / 1000);
|
|
49
|
+
const millis = total % 1000;
|
|
50
|
+
const pad = (v, width = 2) => String(v).padStart(width, "0");
|
|
51
|
+
return `${pad(hours)}:${pad(minutes)}:${pad(seconds)}${format === "srt" ? "," : "."}${pad(millis, 3)}`;
|
|
52
|
+
}
|
|
53
|
+
/** The format a document is in, from its first meaningful line. */
|
|
54
|
+
export function detectSubtitleFormat(text) {
|
|
55
|
+
const head = text.replace(/^/, "").trimStart();
|
|
56
|
+
return head.startsWith("WEBVTT") ? "vtt" : "srt";
|
|
57
|
+
}
|
|
58
|
+
const VTT_BLOCK_KEYWORDS = ["NOTE", "STYLE", "REGION"];
|
|
59
|
+
/** Parse an SRT or WebVTT document. The format is detected when not given. */
|
|
60
|
+
export function parseSubtitles(input, format) {
|
|
61
|
+
const resolved = format ?? detectSubtitleFormat(input);
|
|
62
|
+
const normalized = input.replace(/^/, "").replace(/\r\n?/g, "\n");
|
|
63
|
+
const body = resolved === "vtt" ? normalized.replace(/^WEBVTT[^\n]*\n?/, "") : normalized;
|
|
64
|
+
const blocks = body.split(/\n{2,}/);
|
|
65
|
+
const cues = [];
|
|
66
|
+
const skipped = [];
|
|
67
|
+
for (let b = 0; b < blocks.length; b++) {
|
|
68
|
+
const block = blocks[b].trim();
|
|
69
|
+
if (block === "")
|
|
70
|
+
continue;
|
|
71
|
+
const lines = block.split("\n");
|
|
72
|
+
const first = lines[0];
|
|
73
|
+
if (resolved === "vtt" &&
|
|
74
|
+
VTT_BLOCK_KEYWORDS.some((k) => first === k || first.startsWith(`${k} `))) {
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
77
|
+
let at = 0;
|
|
78
|
+
let id;
|
|
79
|
+
if (!TIMING.test(first)) {
|
|
80
|
+
id = first.trim();
|
|
81
|
+
at = 1;
|
|
82
|
+
}
|
|
83
|
+
const timingLine = lines[at];
|
|
84
|
+
if (timingLine === undefined || !TIMING.test(timingLine)) {
|
|
85
|
+
skipped.push({ block: b, reason: "no timing line" });
|
|
86
|
+
continue;
|
|
87
|
+
}
|
|
88
|
+
const match = TIMING.exec(timingLine);
|
|
89
|
+
const rightSide = match[2].trim();
|
|
90
|
+
// Cue settings follow the end timestamp, separated by whitespace.
|
|
91
|
+
const spaceAt = rightSide.search(/\s/);
|
|
92
|
+
const endText = spaceAt === -1 ? rightSide : rightSide.slice(0, spaceAt);
|
|
93
|
+
const settings = spaceAt === -1 ? "" : rightSide.slice(spaceAt).trim();
|
|
94
|
+
let startMs;
|
|
95
|
+
let endMs;
|
|
96
|
+
try {
|
|
97
|
+
startMs = parseTimestamp(match[1]);
|
|
98
|
+
endMs = parseTimestamp(endText);
|
|
99
|
+
}
|
|
100
|
+
catch (err) {
|
|
101
|
+
skipped.push({ block: b, reason: err.message });
|
|
102
|
+
continue;
|
|
103
|
+
}
|
|
104
|
+
if (endMs < startMs) {
|
|
105
|
+
skipped.push({ block: b, reason: `end ${endMs}ms is before start ${startMs}ms` });
|
|
106
|
+
continue;
|
|
107
|
+
}
|
|
108
|
+
cues.push({
|
|
109
|
+
index: cues.length + 1,
|
|
110
|
+
startMs,
|
|
111
|
+
endMs,
|
|
112
|
+
text: lines
|
|
113
|
+
.slice(at + 1)
|
|
114
|
+
.join("\n")
|
|
115
|
+
.trim(),
|
|
116
|
+
// An SRT identifier is a sequence number the writer regenerates, so
|
|
117
|
+
// it is not worth carrying; a WebVTT one can be referenced from CSS.
|
|
118
|
+
...(id !== undefined && resolved === "vtt" ? { id } : {}),
|
|
119
|
+
...(settings !== "" && resolved === "vtt" ? { settings } : {}),
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
return { format: resolved, cues, skipped };
|
|
123
|
+
}
|
|
124
|
+
/**
|
|
125
|
+
* Greedy word wrap at `columns`, preserving the line breaks already in the
|
|
126
|
+
* text. A word longer than `columns` gets its own line rather than being
|
|
127
|
+
* broken mid-word.
|
|
128
|
+
*/
|
|
129
|
+
export function wrapCueText(text, columns) {
|
|
130
|
+
if (columns <= 0)
|
|
131
|
+
throw new MediaFormatError(`cannot wrap to ${columns} columns`);
|
|
132
|
+
return text
|
|
133
|
+
.split("\n")
|
|
134
|
+
.map((paragraph) => {
|
|
135
|
+
const words = paragraph.split(/\s+/).filter((w) => w !== "");
|
|
136
|
+
if (words.length === 0)
|
|
137
|
+
return "";
|
|
138
|
+
const lines = [];
|
|
139
|
+
let current = words[0];
|
|
140
|
+
for (const word of words.slice(1)) {
|
|
141
|
+
if (current.length + 1 + word.length <= columns)
|
|
142
|
+
current += ` ${word}`;
|
|
143
|
+
else {
|
|
144
|
+
lines.push(current);
|
|
145
|
+
current = word;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
lines.push(current);
|
|
149
|
+
return lines.join("\n");
|
|
150
|
+
})
|
|
151
|
+
.join("\n");
|
|
152
|
+
}
|
|
153
|
+
/**
|
|
154
|
+
* A cue's text, made safe to put in a block.
|
|
155
|
+
*
|
|
156
|
+
* Both formats separate cues with a BLANK LINE, so a blank line inside a
|
|
157
|
+
* cue's text does not render as a paragraph break — it ENDS the cue, and
|
|
158
|
+
* whatever follows is read back as a new one. Text of
|
|
159
|
+
* `"hello\n\n00:00:09,000 --> 00:00:10,000\nanything"` therefore writes two
|
|
160
|
+
* cues where the caller asked for one, which is structure injection by a
|
|
161
|
+
* value. Runs of blank lines are collapsed, and leading and trailing ones
|
|
162
|
+
* dropped, so the document a caller gets back has exactly the cues they
|
|
163
|
+
* passed in. Line endings are normalised first, because a lone `\r` becomes
|
|
164
|
+
* a line break when the document is read again.
|
|
165
|
+
*/
|
|
166
|
+
function cueBody(text) {
|
|
167
|
+
return text
|
|
168
|
+
.replace(/\r\n?/g, "\n")
|
|
169
|
+
.split("\n")
|
|
170
|
+
.map((line) => line.trimEnd())
|
|
171
|
+
.join("\n")
|
|
172
|
+
.replace(/\n{2,}/g, "\n")
|
|
173
|
+
.replace(/^\n+/, "")
|
|
174
|
+
.replace(/\n+$/, "");
|
|
175
|
+
}
|
|
176
|
+
/**
|
|
177
|
+
* Refuse a single-line field that would not stay on its line. A cue
|
|
178
|
+
* identifier, a cue's settings and the `WEBVTT` header each occupy exactly
|
|
179
|
+
* one line; a break in any of them starts a new block, and a `-->` turns the
|
|
180
|
+
* line into a timing line. Located by cue, so the caller can fix the value.
|
|
181
|
+
*/
|
|
182
|
+
function requireOneLine(value, what) {
|
|
183
|
+
if (/[\n\r]/.test(value)) {
|
|
184
|
+
throw new MediaFormatError(`${what} contains a line break; it has to stay on one line`);
|
|
185
|
+
}
|
|
186
|
+
if (value.includes("-->")) {
|
|
187
|
+
throw new MediaFormatError(`${what} contains "-->", which would read back as a timing line`);
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
/**
|
|
191
|
+
* Cues back to a document. A negative shift that would push a cue before
|
|
192
|
+
* zero drops that cue and says so, rather than clamping it to 00:00:00 and
|
|
193
|
+
* silently stacking several cues on top of one another.
|
|
194
|
+
*
|
|
195
|
+
* Caller values cannot change the document's STRUCTURE: cue text is put
|
|
196
|
+
* through `cueBody` so a blank line in it cannot end the cue early, and the
|
|
197
|
+
* one-line fields (a WebVTT id, its settings, the `WEBVTT` header) are
|
|
198
|
+
* refused outright when they carry a line break or a `-->`. Reading the
|
|
199
|
+
* result back therefore always yields the cues that went in, and only those.
|
|
200
|
+
*/
|
|
201
|
+
export function writeSubtitles(cues, format, options = {}) {
|
|
202
|
+
const shift = options.shiftMs ?? 0;
|
|
203
|
+
if (options.header !== undefined)
|
|
204
|
+
requireOneLine(options.header, "the WEBVTT header");
|
|
205
|
+
const blocks = [];
|
|
206
|
+
let dropped = 0;
|
|
207
|
+
let index = 0;
|
|
208
|
+
for (const cue of cues) {
|
|
209
|
+
const startMs = cue.startMs + shift;
|
|
210
|
+
const endMs = cue.endMs + shift;
|
|
211
|
+
if (startMs < 0 || endMs < 0) {
|
|
212
|
+
dropped++;
|
|
213
|
+
continue;
|
|
214
|
+
}
|
|
215
|
+
index++;
|
|
216
|
+
const body = cueBody(options.wrapColumns === undefined ? cue.text : wrapCueText(cue.text, options.wrapColumns));
|
|
217
|
+
const timing = `${formatTimestamp(startMs, format)} --> ${formatTimestamp(endMs, format)}`;
|
|
218
|
+
if (format === "srt") {
|
|
219
|
+
blocks.push(`${index}\n${timing}\n${body}`);
|
|
220
|
+
}
|
|
221
|
+
else {
|
|
222
|
+
if (cue.id !== undefined)
|
|
223
|
+
requireOneLine(cue.id, `cue ${cue.index}'s id`);
|
|
224
|
+
if (cue.settings !== undefined)
|
|
225
|
+
requireOneLine(cue.settings, `cue ${cue.index}'s settings`);
|
|
226
|
+
const head = cue.id === undefined ? "" : `${cue.id}\n`;
|
|
227
|
+
const settings = cue.settings === undefined ? "" : ` ${cue.settings}`;
|
|
228
|
+
blocks.push(`${head}${timing}${settings}\n${body}`);
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
const prefix = format === "vtt" ? `WEBVTT${options.header === undefined ? "" : ` ${options.header}`}\n\n` : "";
|
|
232
|
+
return { text: `${prefix}${blocks.join("\n\n")}\n`, cueCount: index, dropped };
|
|
233
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SVG construction, shared by the chart, sparkline and diagram renderers.
|
|
3
|
+
*
|
|
4
|
+
* Determinism is the whole contract here: same data in, byte-identical SVG
|
|
5
|
+
* out. That rules out `toLocaleString` (locale-dependent), `Intl` (ICU
|
|
6
|
+
* version-dependent) and real text measurement (font-dependent). Numbers go
|
|
7
|
+
* through `num` below, and label widths are ESTIMATED from a fixed
|
|
8
|
+
* per-character advance — an estimate that is stable everywhere rather than
|
|
9
|
+
* accurate on one machine.
|
|
10
|
+
*/
|
|
11
|
+
/** Escape the five characters that cannot appear raw in XML text or attributes. */
|
|
12
|
+
export declare function escapeXml(text: string): string;
|
|
13
|
+
/**
|
|
14
|
+
* A coordinate, to at most three decimals, with no trailing zeroes and no
|
|
15
|
+
* `-0`. `toFixed` then trim is stable across engines in a way that
|
|
16
|
+
* `toString` on a float is not.
|
|
17
|
+
*/
|
|
18
|
+
export declare function num(value: number): string;
|
|
19
|
+
/**
|
|
20
|
+
* Format a data value for an axis label or legend. Fixed rules, no locale:
|
|
21
|
+
* integers plain, magnitudes at or above 10000 in thousands/millions/
|
|
22
|
+
* billions with one decimal, everything else to at most three significant
|
|
23
|
+
* decimals.
|
|
24
|
+
*/
|
|
25
|
+
export declare function formatValue(value: number): string;
|
|
26
|
+
/**
|
|
27
|
+
* Estimated rendered width of `text` at `fontSize`, in user units.
|
|
28
|
+
*
|
|
29
|
+
* 0.55em per character is close to the average advance of the sans-serif
|
|
30
|
+
* faces this package's `font-family` names, wide enough that a label rarely
|
|
31
|
+
* overflows its reserved space and narrow enough that axes do not look
|
|
32
|
+
* padded. It is an estimate by design: measuring for real would make the
|
|
33
|
+
* output depend on which fonts a machine has.
|
|
34
|
+
*/
|
|
35
|
+
export declare function estimateTextWidth(text: string, fontSize: number): number;
|
|
36
|
+
/** The font stack every renderer here names, ending in a generic family. */
|
|
37
|
+
export declare const FONT_STACK = "ui-sans-serif, -apple-system, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif";
|
|
38
|
+
export type TextAnchor = "start" | "middle" | "end";
|
|
39
|
+
export type TextOptions = {
|
|
40
|
+
readonly fill?: string;
|
|
41
|
+
readonly anchor?: TextAnchor;
|
|
42
|
+
readonly weight?: "normal" | "bold";
|
|
43
|
+
readonly baseline?: "auto" | "middle" | "hanging";
|
|
44
|
+
};
|
|
45
|
+
/** A `<text>` element with escaped content. */
|
|
46
|
+
export declare function text(x: number, y: number, content: string, fontSize: number, options?: TextOptions): string;
|
|
47
|
+
/** A `<rect>`. */
|
|
48
|
+
export declare function rect(x: number, y: number, width: number, height: number, fill: string, extra?: string): string;
|
|
49
|
+
/** A straight `<line>`. */
|
|
50
|
+
export declare function line(x1: number, y1: number, x2: number, y2: number, stroke: string, width?: number): string;
|
|
51
|
+
/** A `<polyline>` with no fill. */
|
|
52
|
+
export declare function polyline(points: ReadonlyArray<[number, number]>, stroke: string, width: number): string;
|
|
53
|
+
/** A `<circle>`. */
|
|
54
|
+
export declare function circle(cx: number, cy: number, r: number, fill: string): string;
|
|
55
|
+
/** Wrap body elements in a complete, standalone SVG document. */
|
|
56
|
+
export declare function svgDocument(width: number, height: number, background: string, body: ReadonlyArray<string>, title?: string): string;
|
|
57
|
+
/**
|
|
58
|
+
* "Nice" axis bounds and tick step for a value range — the 1/2/5 x 10^n
|
|
59
|
+
* ladder every plotting library uses, chosen without looking at pixel
|
|
60
|
+
* widths so the same data always gets the same ticks.
|
|
61
|
+
*/
|
|
62
|
+
export declare function niceScale(min: number, max: number, targetTicks: number): {
|
|
63
|
+
min: number;
|
|
64
|
+
max: number;
|
|
65
|
+
step: number;
|
|
66
|
+
ticks: number[];
|
|
67
|
+
};
|
|
68
|
+
/**
|
|
69
|
+
* A fixed categorical palette. Fixed because "deterministic" has to include
|
|
70
|
+
* the colours: a chart that picks a hue from a hash of the series name
|
|
71
|
+
* still renders the same twice, but a chart that picks at random does not,
|
|
72
|
+
* and neither reads as one system beside its neighbours.
|
|
73
|
+
*/
|
|
74
|
+
export declare const PALETTE: ReadonlyArray<string>;
|
|
75
|
+
/** The palette colour for series `index`, wrapping. */
|
|
76
|
+
export declare function paletteColor(index: number): string;
|
package/dist/lib/svg.js
ADDED
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SVG construction, shared by the chart, sparkline and diagram renderers.
|
|
3
|
+
*
|
|
4
|
+
* Determinism is the whole contract here: same data in, byte-identical SVG
|
|
5
|
+
* out. That rules out `toLocaleString` (locale-dependent), `Intl` (ICU
|
|
6
|
+
* version-dependent) and real text measurement (font-dependent). Numbers go
|
|
7
|
+
* through `num` below, and label widths are ESTIMATED from a fixed
|
|
8
|
+
* per-character advance — an estimate that is stable everywhere rather than
|
|
9
|
+
* accurate on one machine.
|
|
10
|
+
*/
|
|
11
|
+
/** Escape the five characters that cannot appear raw in XML text or attributes. */
|
|
12
|
+
export function escapeXml(text) {
|
|
13
|
+
return text
|
|
14
|
+
.replace(/&/g, "&")
|
|
15
|
+
.replace(/</g, "<")
|
|
16
|
+
.replace(/>/g, ">")
|
|
17
|
+
.replace(/"/g, """)
|
|
18
|
+
.replace(/'/g, "'");
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* A coordinate, to at most three decimals, with no trailing zeroes and no
|
|
22
|
+
* `-0`. `toFixed` then trim is stable across engines in a way that
|
|
23
|
+
* `toString` on a float is not.
|
|
24
|
+
*/
|
|
25
|
+
export function num(value) {
|
|
26
|
+
if (!Number.isFinite(value))
|
|
27
|
+
return "0";
|
|
28
|
+
const fixed = value.toFixed(3);
|
|
29
|
+
const trimmed = fixed.replace(/\.?0+$/, "");
|
|
30
|
+
return trimmed === "-0" || trimmed === "" ? "0" : trimmed;
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Format a data value for an axis label or legend. Fixed rules, no locale:
|
|
34
|
+
* integers plain, magnitudes at or above 10000 in thousands/millions/
|
|
35
|
+
* billions with one decimal, everything else to at most three significant
|
|
36
|
+
* decimals.
|
|
37
|
+
*/
|
|
38
|
+
export function formatValue(value) {
|
|
39
|
+
if (!Number.isFinite(value))
|
|
40
|
+
return String(value);
|
|
41
|
+
const abs = Math.abs(value);
|
|
42
|
+
if (abs >= 1e9)
|
|
43
|
+
return `${num(Math.round(value / 1e8) / 10)}B`;
|
|
44
|
+
if (abs >= 1e6)
|
|
45
|
+
return `${num(Math.round(value / 1e5) / 10)}M`;
|
|
46
|
+
if (abs >= 1e4)
|
|
47
|
+
return `${num(Math.round(value / 1e2) / 10)}k`;
|
|
48
|
+
if (Number.isInteger(value))
|
|
49
|
+
return String(value);
|
|
50
|
+
if (abs >= 100)
|
|
51
|
+
return value.toFixed(1).replace(/\.0$/, "");
|
|
52
|
+
if (abs >= 1)
|
|
53
|
+
return num(Math.round(value * 100) / 100);
|
|
54
|
+
return num(Math.round(value * 1000) / 1000);
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* Estimated rendered width of `text` at `fontSize`, in user units.
|
|
58
|
+
*
|
|
59
|
+
* 0.55em per character is close to the average advance of the sans-serif
|
|
60
|
+
* faces this package's `font-family` names, wide enough that a label rarely
|
|
61
|
+
* overflows its reserved space and narrow enough that axes do not look
|
|
62
|
+
* padded. It is an estimate by design: measuring for real would make the
|
|
63
|
+
* output depend on which fonts a machine has.
|
|
64
|
+
*/
|
|
65
|
+
export function estimateTextWidth(text, fontSize) {
|
|
66
|
+
return Math.round(text.length * fontSize * 0.55 * 100) / 100;
|
|
67
|
+
}
|
|
68
|
+
/** The font stack every renderer here names, ending in a generic family. */
|
|
69
|
+
export const FONT_STACK = "ui-sans-serif, -apple-system, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif";
|
|
70
|
+
/** A `<text>` element with escaped content. */
|
|
71
|
+
export function text(x, y, content, fontSize, options = {}) {
|
|
72
|
+
const parts = [`x="${num(x)}"`, `y="${num(y)}"`, `font-size="${num(fontSize)}"`];
|
|
73
|
+
parts.push(`font-family="${escapeXml(FONT_STACK)}"`);
|
|
74
|
+
if (options.fill !== undefined)
|
|
75
|
+
parts.push(`fill="${escapeXml(options.fill)}"`);
|
|
76
|
+
if (options.anchor !== undefined)
|
|
77
|
+
parts.push(`text-anchor="${options.anchor}"`);
|
|
78
|
+
if (options.weight === "bold")
|
|
79
|
+
parts.push('font-weight="600"');
|
|
80
|
+
if (options.baseline !== undefined && options.baseline !== "auto") {
|
|
81
|
+
parts.push(`dominant-baseline="${options.baseline}"`);
|
|
82
|
+
}
|
|
83
|
+
return `<text ${parts.join(" ")}>${escapeXml(content)}</text>`;
|
|
84
|
+
}
|
|
85
|
+
/** A `<rect>`. */
|
|
86
|
+
export function rect(x, y, width, height, fill, extra = "") {
|
|
87
|
+
return `<rect x="${num(x)}" y="${num(y)}" width="${num(width)}" height="${num(height)}" fill="${escapeXml(fill)}"${extra === "" ? "" : ` ${extra}`}/>`;
|
|
88
|
+
}
|
|
89
|
+
/** A straight `<line>`. */
|
|
90
|
+
export function line(x1, y1, x2, y2, stroke, width = 1) {
|
|
91
|
+
return `<line x1="${num(x1)}" y1="${num(y1)}" x2="${num(x2)}" y2="${num(y2)}" stroke="${escapeXml(stroke)}" stroke-width="${num(width)}"/>`;
|
|
92
|
+
}
|
|
93
|
+
/** A `<polyline>` with no fill. */
|
|
94
|
+
export function polyline(points, stroke, width) {
|
|
95
|
+
const d = points.map(([x, y]) => `${num(x)},${num(y)}`).join(" ");
|
|
96
|
+
return `<polyline points="${d}" fill="none" stroke="${escapeXml(stroke)}" stroke-width="${num(width)}" stroke-linejoin="round" stroke-linecap="round"/>`;
|
|
97
|
+
}
|
|
98
|
+
/** A `<circle>`. */
|
|
99
|
+
export function circle(cx, cy, r, fill) {
|
|
100
|
+
return `<circle cx="${num(cx)}" cy="${num(cy)}" r="${num(r)}" fill="${escapeXml(fill)}"/>`;
|
|
101
|
+
}
|
|
102
|
+
/** Wrap body elements in a complete, standalone SVG document. */
|
|
103
|
+
export function svgDocument(width, height, background, body, title) {
|
|
104
|
+
const head = `<svg xmlns="http://www.w3.org/2000/svg" width="${num(width)}" height="${num(height)}" viewBox="0 0 ${num(width)} ${num(height)}" role="img"${title === undefined ? "" : ' aria-labelledby="t"'}>`;
|
|
105
|
+
const titleElement = title === undefined ? "" : `<title id="t">${escapeXml(title)}</title>`;
|
|
106
|
+
const bg = background === "none" ? "" : rect(0, 0, width, height, background);
|
|
107
|
+
return `${head}${titleElement}${bg}${body.join("")}</svg>\n`;
|
|
108
|
+
}
|
|
109
|
+
/**
|
|
110
|
+
* "Nice" axis bounds and tick step for a value range — the 1/2/5 x 10^n
|
|
111
|
+
* ladder every plotting library uses, chosen without looking at pixel
|
|
112
|
+
* widths so the same data always gets the same ticks.
|
|
113
|
+
*/
|
|
114
|
+
export function niceScale(min, max, targetTicks) {
|
|
115
|
+
let low = min;
|
|
116
|
+
let high = max;
|
|
117
|
+
if (!Number.isFinite(low) || !Number.isFinite(high)) {
|
|
118
|
+
low = 0;
|
|
119
|
+
high = 1;
|
|
120
|
+
}
|
|
121
|
+
if (low === high) {
|
|
122
|
+
// A flat series still needs an axis: open a symmetric window round it.
|
|
123
|
+
const pad = low === 0 ? 1 : Math.abs(low) * 0.1;
|
|
124
|
+
low -= pad;
|
|
125
|
+
high += pad;
|
|
126
|
+
}
|
|
127
|
+
const range = high - low;
|
|
128
|
+
const rawStep = range / Math.max(1, targetTicks);
|
|
129
|
+
const magnitude = 10 ** Math.floor(Math.log10(rawStep));
|
|
130
|
+
const normalized = rawStep / magnitude;
|
|
131
|
+
const step = (normalized <= 1 ? 1 : normalized <= 2 ? 2 : normalized <= 5 ? 5 : 10) * magnitude;
|
|
132
|
+
const niceMin = Math.floor(low / step) * step;
|
|
133
|
+
const niceMax = Math.ceil(high / step) * step;
|
|
134
|
+
const ticks = [];
|
|
135
|
+
// Count the steps rather than accumulating, and snap each tick to the
|
|
136
|
+
// decimal place the step implies — `0 + 3 * 0.1` is 0.30000000000000004,
|
|
137
|
+
// and a tick label must not carry that into the rendered SVG.
|
|
138
|
+
const decimals = Math.min(12, Math.max(0, 1 - Math.floor(Math.log10(step))));
|
|
139
|
+
const snap = (value) => Number.parseFloat(value.toFixed(decimals));
|
|
140
|
+
const count = Math.round((niceMax - niceMin) / step);
|
|
141
|
+
for (let i = 0; i <= count; i++) {
|
|
142
|
+
const value = snap(niceMin + i * step);
|
|
143
|
+
ticks.push(value === 0 ? 0 : value);
|
|
144
|
+
}
|
|
145
|
+
return { min: niceMin, max: niceMax, step, ticks };
|
|
146
|
+
}
|
|
147
|
+
/**
|
|
148
|
+
* A fixed categorical palette. Fixed because "deterministic" has to include
|
|
149
|
+
* the colours: a chart that picks a hue from a hash of the series name
|
|
150
|
+
* still renders the same twice, but a chart that picks at random does not,
|
|
151
|
+
* and neither reads as one system beside its neighbours.
|
|
152
|
+
*/
|
|
153
|
+
export const PALETTE = Object.freeze([
|
|
154
|
+
"#3b6fd4",
|
|
155
|
+
"#e07b39",
|
|
156
|
+
"#4ba36b",
|
|
157
|
+
"#c2504a",
|
|
158
|
+
"#8a63c4",
|
|
159
|
+
"#7d5a4f",
|
|
160
|
+
"#d1719f",
|
|
161
|
+
"#6f7782",
|
|
162
|
+
"#b3a133",
|
|
163
|
+
"#3f9aa8",
|
|
164
|
+
]);
|
|
165
|
+
/** The palette colour for series `index`, wrapping. */
|
|
166
|
+
export function paletteColor(index) {
|
|
167
|
+
return PALETTE[index % PALETTE.length];
|
|
168
|
+
}
|