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); + } } });