diff --git a/README.md b/README.md
index f09b783..512233e 100644
--- a/README.md
+++ b/README.md
@@ -391,6 +391,18 @@ let cap1 = caption();
cap1.init("hypertranscript", "hyperplayer", '37' , '21'); // transcript Id, player Id, max chars, min chars for caption line
```
+`init()` takes an optional eighth argument of sentence and paragraph rules. With none given, captions are generated exactly as before.
+
+```javascript
+cap1.init("hypertranscript", "hyperplayer", '37', '21', undefined, undefined, null, {
+ detectAbbreviations: true, // "e.g.", "U.S.", "p.m." end a sentence only before a capital; a lone initial never does
+ abbreviations: ["Dr.", "Prof."], // words that never end a sentence — language-specific, so you supply them
+ joinSentences: true, // a short sentence shares the caption before it when the whole sentence fits
+ maxJoinGap: 1, // ...unless more than this many seconds of silence separate them
+ paragraphBreaks: true, // a new paragraph always starts a new caption
+});
+```
+
## :money_with_wings: Web Monetization Support :money_with_wings:
[Web Monetization](https://webmonetization.org/) is a browser API, stewarded by the [Interledger Foundation](https://interledger.org/), that lets visitors stream micropayments to the sites they're reading. There is currently no native browser support — visitors need a [Web Monetization agent](https://webmonetization.org/supporters/get-started/) (browser extension) installed to actually pay.
diff --git a/__TEST__/caption.test.js b/__TEST__/caption.test.js
index fa5c2c2..e95b6ee 100644
--- a/__TEST__/caption.test.js
+++ b/__TEST__/caption.test.js
@@ -244,3 +244,148 @@ test("an inverted or zero-length cue is repaired to a readable length, not skipp
// repaired: extended to the 1s minimum on-screen time
expect(cues[1].stop).toBe("00:00:06.000");
});
+
+// ---- options: abbreviations, sentence joining, paragraph breaks ------------
+
+// One word per 400ms; "|" starts a new paragraph, a ["speaker", name] tuple
+// (given as the string "@Name") a speaker label.
+function buildFromText(text, stepMs = 400, durMs = 350) {
+ let t = 0;
+ const paragraphs = text.split("|").map((para) =>
+ "
" + para.trim().split(/\s+/).map((w) => {
+ if (w.startsWith("@")) return `[${w.slice(1)}] `;
+ const span = `${w} `;
+ t += stepMs;
+ return span;
+ }).join("") + "
");
+ document.body.innerHTML = `${paragraphs.join("")}
`;
+}
+const cueTexts = (result) => parseVtt(result.vtt).map((c) => c.text);
+const run = (options, max = 32, min = 21) =>
+ caption().init("transcript", null, max, min, undefined, undefined, null, options);
+
+test("with no options an abbreviation still ends the caption, as it always has", () => {
+ buildFromText("We spoke to Dr. Smith about it.");
+ expect(cueTexts(run())).toEqual(["We spoke to Dr.", "Smith about it."]);
+});
+
+test("a listed title never ends a sentence", () => {
+ buildFromText("We spoke to Dr. Smith about it.");
+ expect(cueTexts(run({ abbreviations: ["Dr."] }))).toEqual(["We spoke to Dr. Smith about it."]);
+ // case and the trailing full stop are ignored, in the list and in the text
+ buildFromText("We spoke to (dr. Smith) about it.");
+ expect(cueTexts(run({ abbreviations: new Set(["DR"]) }))).toEqual(["We spoke to (dr. Smith) about it."]);
+});
+
+test("a title in another language's list is just a word", () => {
+ buildFromText("Wir trafen Hr. Schmidt.");
+ expect(cueTexts(run({ detectAbbreviations: true, abbreviations: ["Dr."] })))
+ .toEqual(["Wir trafen Hr.", "Schmidt."]);
+ expect(cueTexts(run({ detectAbbreviations: true, abbreviations: ["Hr.", "Fr."] })))
+ .toEqual(["Wir trafen Hr. Schmidt."]);
+});
+
+test("a dotted abbreviation ends a sentence only before a capital", () => {
+ buildFromText("It costs 3.5 million, e.g. for a small firm.");
+ expect(cueTexts(run({ detectAbbreviations: true })).join(" "))
+ .toBe("It costs 3.5 million, e.g. for a small firm.");
+ expect(cueTexts(run({ detectAbbreviations: true })).some((t) => t.endsWith("e.g."))).toBe(false);
+
+ buildFromText("We left at 5 p.m. Then it rained.");
+ expect(cueTexts(run({ detectAbbreviations: true }))).toEqual(["We left at 5 p.m.", "Then it rained."]);
+
+ buildFromText("We left at 5 p.m. on Friday.");
+ expect(cueTexts(run({ detectAbbreviations: true }))).toEqual(["We left at 5 p.m. on Friday."]);
+});
+
+test("a lone initial never ends a sentence, but the word I does", () => {
+ buildFromText("It was J. Smith again.");
+ expect(cueTexts(run({ detectAbbreviations: true }))).toEqual(["It was J. Smith again."]);
+ buildFromText("So did I. Then we left.");
+ expect(cueTexts(run({ detectAbbreviations: true }))).toEqual(["So did I.", "Then we left."]);
+});
+
+test("question marks, exclamation marks and an ellipsis are untouched by the rule", () => {
+ buildFromText("Really? Yes! She paused... then went on.");
+ expect(cueTexts(run({ detectAbbreviations: true, abbreviations: ["Dr."] })))
+ .toEqual(["Really?", "Yes!", "She paused...", "then went on."]);
+});
+
+test("short sentences share a caption when joinSentences is on", () => {
+ buildFromText("Yes. No. Maybe. I see. Go on. Fine. That is all. Thanks.");
+ expect(cueTexts(run())).toHaveLength(8); // one per sentence, as before
+ const joined = run({ joinSentences: true });
+ const cues = parseVtt(joined.vtt);
+ expect(cues.map((c) => c.text)).toEqual(["Yes. No. Maybe. I see. Go on. Fine. That is all. Thanks."]);
+ // two lines, neither over the limit, and the times cover every sentence
+ const lines = joined.data[0].text.replace(/\n+$/, "").split("\n");
+ expect(lines.map((l) => l.trim())).toEqual(["Yes. No. Maybe. I see. Go on.", "Fine. That is all. Thanks."]);
+ lines.forEach((line) => expect(line.length).toBeLessThanOrEqual(32));
+ expect(cues[0].start).toBe("00:00:00.000");
+ expect(cues[0].stop).toBe("00:00:04.750"); // "Thanks." starts at 4.4s, lasts 350ms
+});
+
+test("a caption holds two lines at most, so a third short sentence starts a new one", () => {
+ buildFromText("That really is the whole of it. And nothing else at all here. Thanks.");
+ expect(cueTexts(run({ joinSentences: true }))).toEqual([
+ "That really is the whole of it. And nothing else at all here.",
+ "Thanks.",
+ ]);
+});
+
+test("a sentence is never split in order to fill a caption", () => {
+ // the second sentence fits on neither the first line nor a line of its own
+ buildFromText("Yes. This sentence is far too long to sit on one line of a caption.");
+ const texts = cueTexts(run({ joinSentences: true }));
+ expect(texts[0]).toBe("Yes.");
+ expect(texts.slice(1).join(" ")).toBe("This sentence is far too long to sit on one line of a caption.");
+});
+
+test("a short sentence joins the last caption of a long one", () => {
+ buildFromText("This sentence is far too long to sit on one line. Yes.");
+ expect(cueTexts(run())).toEqual(["This sentence is far too long to sit on one line.", "Yes."]);
+ expect(cueTexts(run({ joinSentences: true }))).toEqual(["This sentence is far too long to sit on one line. Yes."]);
+ // ...but not when the caption's second line has no room left for it
+ buildFromText("This sentence is far too long to sit on one line of a caption. Yes.");
+ const texts = cueTexts(run({ joinSentences: true }));
+ expect(texts[texts.length - 1]).toBe("Yes.");
+});
+
+test("nothing is joined across a speaker label", () => {
+ buildFromText("@Ann Yes. No. @Bob Maybe. Fine.");
+ expect(cueTexts(run({ joinSentences: true }))).toEqual(["Yes. No.", "Maybe. Fine."]);
+});
+
+test("nothing is joined across a pause longer than maxJoinGap", () => {
+ buildTranscript([
+ [0, 300, "Yes."],
+ [400, 300, "No."], // 100ms after "Yes." ends
+ [3000, 300, "Maybe."], // 2.3s of silence
+ [3400, 300, "Fine."],
+ ]);
+ expect(cueTexts(run({ joinSentences: true }))).toEqual(["Yes. No.", "Maybe. Fine."]);
+ expect(cueTexts(run({ joinSentences: true, maxJoinGap: 5 }))).toEqual(["Yes. No. Maybe. Fine."]);
+ expect(cueTexts(run({ joinSentences: true, maxJoinGap: 0 }))).toEqual(["Yes.", "No.", "Maybe.", "Fine."]);
+});
+
+test("paragraphBreaks: a new paragraph always starts a new caption", () => {
+ buildFromText("Yes. No. | Maybe. Fine.");
+ expect(cueTexts(run({ joinSentences: true }))).toEqual(["Yes. No. Maybe. Fine."]);
+ expect(cueTexts(run({ joinSentences: true, paragraphBreaks: true }))).toEqual(["Yes. No.", "Maybe. Fine."]);
+});
+
+test("paragraphBreaks also breaks where a paragraph ends without punctuation", () => {
+ buildFromText("the first thought | the second thought");
+ expect(cueTexts(run())).toEqual(["the first thought the second thought"]);
+ expect(cueTexts(run({ paragraphBreaks: true }))).toEqual(["the first thought", "the second thought"]);
+});
+
+test("joined captions never overlap and keep the cue count honest", () => {
+ buildFromText("Yes. No. Maybe. | @Ann I see. Go on. This one is a good deal longer than a line allows. Fine.");
+ const cues = parseVtt(run({ joinSentences: true, paragraphBreaks: true, detectAbbreviations: true }).vtt);
+ for (let i = 1; i < cues.length; i += 1) {
+ expect(cues[i].start >= cues[i - 1].stop).toBe(true);
+ }
+ expect(cues.map((c) => c.text).join(" "))
+ .toBe("Yes. No. Maybe. I see. Go on. This one is a good deal longer than a line allows. Fine.");
+});
diff --git a/js/caption.d.ts b/js/caption.d.ts
index a537dfa..99cfc10 100644
--- a/js/caption.d.ts
+++ b/js/caption.d.ts
@@ -18,6 +18,28 @@ export interface CaptionsResult {
data: CaptionCue[];
}
+export interface CaptionOptions {
+ /**
+ * Dotted abbreviations ("e.g.", "U.S.", "p.m.") end a sentence only when
+ * the next word starts with a capital; a lone initial ("J.") never does.
+ */
+ detectAbbreviations?: boolean;
+ /**
+ * Words that never end a sentence, e.g. titles such as "Dr." or "Prof.".
+ * Case and the trailing full stop are ignored. Language-specific.
+ */
+ abbreviations?: Iterable;
+ /**
+ * A short sentence shares the caption before it when the whole sentence
+ * fits there. Never across a speaker label or a pause over maxJoinGap.
+ */
+ joinSentences?: boolean;
+ /** Seconds of silence that still allow a join (default 1). */
+ maxJoinGap?: number;
+ /** A new paragraph always starts a new caption. */
+ paragraphBreaks?: boolean;
+}
+
export interface CaptionInstance {
/**
* Generate captions from a hypertranscript's [data-m] word spans.
@@ -31,6 +53,8 @@ export interface CaptionInstance {
* @param srclang value for the text track's srclang attribute
* @param parent optional element whose innerHTML is parsed instead of
* the live document (e.g. a detached editor state)
+ * @param options sentence and paragraph rules; with none given the
+ * output is unchanged from earlier versions
*/
init(
transcriptId: string,
@@ -39,7 +63,8 @@ export interface CaptionInstance {
minLength?: number,
label?: string,
srclang?: string,
- parent?: HTMLElement
+ parent?: HTMLElement | null,
+ options?: CaptionOptions
): CaptionsResult;
}
diff --git a/js/caption.js b/js/caption.js
index ce3804c..e884281 100644
--- a/js/caption.js
+++ b/js/caption.js
@@ -1,5 +1,5 @@
/*! (C) The Hyperaudio Project. MIT @license: en.wikipedia.org/wiki/MIT_License. */
-/*! Version 2.1.7 */
+/*! Version 2.2.0 */
'use strict';
const caption = function () {
@@ -32,7 +32,32 @@ const caption = function () {
return (parseInt(parts[0], 10) * 3600) + (parseInt(parts[1], 10) * 60) + parseFloat(parts[2]);
}
- cap.init = function (transcriptId, playerId, maxLength, minLength, label, srclang, parent) {
+ // options (all optional; with none given the output is what it always was):
+ // detectAbbreviations true: a dotted abbreviation ("e.g.", "U.S.", "p.m.")
+ // ends a sentence only when the next word starts with
+ // a capital, and a lone initial ("J.") never does
+ // abbreviations words that never end a sentence however they are
+ // followed - titles such as "Dr." or "Prof.", which
+ // no rule can tell from a sentence end. An array or
+ // Set; case and the trailing full stop are ignored.
+ // Language-specific, so the caller supplies them.
+ // joinSentences true: a short sentence shares the caption before it
+ // when the WHOLE sentence fits there. A sentence is
+ // never split in order to fill a caption, and never
+ // joined across a speaker label or a long pause.
+ // maxJoinGap seconds of silence that still allow a join (default 1)
+ // paragraphBreaks true: a new paragraph always starts a new caption
+ cap.init = function (transcriptId, playerId, maxLength, minLength, label, srclang, parent, options) {
+
+ const opts = options || {};
+ const detectAbbreviations = opts.detectAbbreviations === true;
+ const normaliseAbbreviation = (text) => String(text).toLowerCase().replace(/\.$/, '');
+ const abbreviationSet = opts.abbreviations
+ ? new Set(Array.from(opts.abbreviations, normaliseAbbreviation))
+ : null;
+ const joinSentences = opts.joinSentences === true;
+ const paragraphBreaks = opts.paragraphBreaks === true;
+ const maxJoinGap = typeof opts.maxJoinGap === 'number' && opts.maxJoinGap >= 0 ? opts.maxJoinGap : 1;
let transcript = document.getElementById(transcriptId);
@@ -93,6 +118,39 @@ const caption = function () {
const endSentenceDelimiter = /[\.。?؟!]/g;
const midSentenceDelimiter = /[,、–,،و:,…‥]/g;
+ // A full stop is not always the end of a sentence. Only "." is ambiguous:
+ // ? ! 。 ؟ close the sentence whatever the word is.
+ const startsWithCapital = (text) => /^[^\p{L}\p{N}]*\p{Lu}/u.test(text || '');
+ function endsSentence(text, nextText) {
+ const token = text.replace(/\s/g, '');
+ if (!token.slice(-1).match(endSentenceDelimiter)) return false;
+ if (token.slice(-1) !== '.') return true;
+ const core = token.replace(/^[^\p{L}\p{N}]+/u, ''); // opening quotes and brackets
+ if (abbreviationSet !== null && abbreviationSet.has(normaliseAbbreviation(core))) {
+ return false; // a listed title: never a sentence end
+ }
+ if (detectAbbreviations) {
+ // a lone initial, "J. Smith" - but "I." is a word, and ends sentences
+ if (/^\p{Lu}\.$/u.test(core) && core !== 'I.') return false;
+ // "e.g.", "U.S.", "p.m.", "Ph.D.": these can legitimately close a
+ // sentence ("We left at 5 p.m. Then it rained."), and the capital
+ // that follows is the only sign of it
+ if (/^(?:\p{L}{1,2}\.){2,}$/u.test(core)) return nextText === null || startsWithCapital(nextText);
+ }
+ return true;
+ }
+
+ // the text of the next spoken word, or null when a speaker label or the
+ // end of the transcript comes first (either closes the sentence anyway)
+ function nextSpokenText(index) {
+ const next = words[index + 1];
+ if (next === undefined || next.classList.contains('speaker')) return null;
+ return next.textContent;
+ }
+
+ const paragraphOf = (word) => (typeof word.closest === 'function' ? word.closest('p') : null);
+ let lastParagraph = null;
+
if (!isNaN(maxLength) && maxLength != null) {
maxLineLength = maxLength;
}
@@ -118,6 +176,20 @@ const caption = function () {
// doesn't force a layout pass, and works in jsdom for tests.
thisSegmentMeta.speaker = word.textContent;
} else {
+ // A new paragraph (paragraphBreaks): close the sentence in hand, so no
+ // caption runs across the break even where the paragraph ended without
+ // punctuation, and mark the segment so nothing is joined onto it.
+ const paragraph = paragraphOf(word);
+ const newParagraph = paragraphBreaks && lastParagraph !== null && paragraph !== lastParagraph;
+ lastParagraph = paragraph;
+ if (newParagraph) {
+ if (thisSegmentMeta.start !== null) {
+ data.segments.push(thisSegmentMeta);
+ thisSegmentMeta = new segmentMeta('', null, 0, 0, 0);
+ }
+ thisSegmentMeta.paragraphStart = true;
+ }
+
let thisStart = parseInt(word.getAttribute('data-m'), 10) / 1000;
let thisDuration = parseInt(word.getAttribute('data-d'), 10) / 1000;
@@ -161,9 +233,7 @@ const caption = function () {
thisSegmentMeta.words.push(thisWordMeta);
- // remove spaces first just in case
- const lastChar = thisText.replace(/\s/g, '').slice(-1);
- if (lastChar.match(endSentenceDelimiter)) {
+ if (endsSentence(thisText, nextSpokenText(i))) {
data.segments.push(thisSegmentMeta);
thisSegmentMeta = null;
}
@@ -187,6 +257,38 @@ const caption = function () {
const captions = [];
let thisCaption = null;
+ // Where each caption's speech really ends, in seconds - the pause before
+ // the next sentence is measured from here, not from a stop time that the
+ // timing safeguards may later extend.
+ const speechEnd = new Map();
+
+ // joinSentences: put a whole short sentence into the caption before it.
+ // On the same line when it fits there, else as the second line of a
+ // one-line caption. Never across a speaker label, a paragraph that must
+ // break, or a pause longer than maxJoinGap.
+ function joinToPrevious(segment, text, segmentStop) {
+ const prev = captions[captions.length - 1];
+ if (!joinSentences || prev === undefined) return false;
+ if (segment.speaker !== '' || segment.paragraphStart === true) return false;
+ const prevEnd = speechEnd.get(prev);
+ if (prevEnd === undefined || segment.start - prevEnd > maxJoinGap) return false;
+
+ const lines = prev.text.replace(/\n+$/, '').split('\n');
+ const last = lines[lines.length - 1];
+ if ((last + text).length <= maxLineLength) {
+ lines[lines.length - 1] = last + text;
+ } else if (lines.length === 1) {
+ lines.push(text);
+ } else {
+ return false;
+ }
+ // a one-line caption ends in a newline, a two-line one does not
+ prev.text = lines.length === 1 ? lines[0] + '\n' : lines.join('\n');
+ prev.stop = formatSeconds(segmentStop);
+ speechEnd.set(prev, segmentStop);
+ return true;
+ }
+
data.segments.forEach((segment, i, arr) => {
// Captions pushed from here on belong to this segment (one sentence,
// one speaker) - used to fold a trailing orphan word back safely.
@@ -210,20 +312,22 @@ const caption = function () {
segmentStop = segment.start + 5;
}
+ const segmentText = segment.words.map((word) => word.text).join('');
+ if (joinToPrevious(segment, segmentText, segmentStop)) {
+ return; // the sentence went into the caption before it
+ }
+
thisCaption = new captionMeta(
formatSeconds(segment.start),
formatSeconds(segmentStop),
'',
);
- segment.words.forEach((word) => {
- thisCaption.text += word.text;
- });
-
- thisCaption.text += '\n';
+ thisCaption.text = segmentText + '\n';
//console.log("0. pushing because the whole segment fits on a line!");
//console.log(thisCaption);
captions.push(thisCaption);
+ speechEnd.set(thisCaption, segmentStop);
thisCaption = null;
} else {
// The number of chars in this segment is longer than our single line maximum
@@ -371,6 +475,11 @@ const caption = function () {
}
}
}
+
+ // the sentence's last caption ends where its last word does
+ if (captions.length > segmentStartCount && lastOutTime !== undefined) {
+ speechEnd.set(captions[captions.length - 1], lastOutTime);
+ }
}
});