1package cc23import (4 "cmp"5 "math"6 "slices"7 "strings"8 "time"9 "unicode"10)1112// Captions on a CEA-608 screen hold two lines of 32 characters, so a long13// cue is split into several shown in turn. They split where a reader would14// pause, at the end of a sentence or clause before anywhere else, with15// lines of about equal length, and each shows long enough to read.1617const (18 // readingRate is how many characters a second a caption is shown for,19 // and minShow the least time any is.20 readingRate = 1821 minShow = time.Second22)2324// showFor is how long a caption of n characters needs to be read.25func showFor(n int) time.Duration {26 return max(minShow, time.Duration(n)*time.Second/readingRate)27}2829// captions608 lays cues out for CEA-608's screen, as captions of at most30// two lines of 32 characters each. A cue too short to read runs on into31// the gap after it, never past the next cue's start.32func captions608(cues []Cue) []Cue {33 cues = merge(cues)34 var out []Cue35 for i, c := range cues {36 limit := time.Duration(math.MaxInt64)37 if i+1 < len(cues) {38 limit = cues[i+1].Start39 }40 out = append(out, pace(c.Start, c.End, limit, chunk(c.Text))...)41 }42 return out43}4445// merge sorts cues and joins those starting together, which a single46// pop-on caption must show at once.47func merge(cues []Cue) []Cue {48 cues = slices.Clone(cues)49 slices.SortStableFunc(cues, func(a, b Cue) int { return cmp.Compare(a.Start, b.Start) })50 var out []Cue51 for _, c := range cues {52 if n := len(out); n > 0 && c.Start-out[n-1].Start < 20*time.Millisecond {53 out[n-1].Text += "\n" + c.Text54 out[n-1].End = max(out[n-1].End, c.End)55 continue56 }57 out = append(out, c)58 }59 return out60}6162// pace times a cue's captions: from start, sharing its time out by63// length, running past end to give each long enough to read, but not past64// limit.65func pace(start, end, limit time.Duration, chunks []string) []Cue {66 var need time.Duration67 weights, total := make([]int, len(chunks)), 068 for i, ch := range chunks {69 n := len([]rune(ch))70 need += showFor(n)71 weights[i] = n + 8 // so short captions get a fair share72 total += weights[i]73 }74 stop := min(max(end, start+need), limit)75 if len(chunks) == 0 || stop <= start {76 return nil77 }78 out := make([]Cue, 0, len(chunks))79 at, done := start, 080 for i, ch := range chunks {81 done += weights[i]82 next := start + (stop-start)*time.Duration(done)/time.Duration(total)83 out = append(out, Cue{Start: at, End: next, Text: ch})84 at = next85 }86 return out87}8889// token is a word of a caption on its way to 608.90type token struct {91 text string92 n int // length in characters93 dialogue bool // begins a line of dialogue ("- Who's there?")94}9596// chunk splits a cue's text into captions of at most Rows lines of97// Columns characters, in characters 608 has. Lines that already fit are98// kept as the source broke them.99func chunk(text string) []string {100 var lines []string101 for l := range strings.SplitSeq(text, "\n") {102 if l = strings.Join(strings.Fields(fold(l)), " "); l != "" {103 lines = append(lines, l)104 }105 }106 if len(lines) == 0 {107 return nil108 }109 if len(lines) <= Rows && !slices.ContainsFunc(lines, func(l string) bool { return len([]rune(l)) > Columns }) {110 return []string{strings.Join(lines, "\n")}111 }112 var toks []token113 for _, l := range lines {114 for i, w := range strings.Fields(l) {115 r := []rune(w)116 for first := true; len(r) > 0; first = false { // a word longer than a line goes in pieces117 piece := r[:min(len(r), Columns)]118 r = r[len(piece):]119 toks = append(toks, token{text: string(piece), n: len(piece), dialogue: first && i == 0 && speaker(w)})120 }121 }122 }123 // The cheapest way to split the words into captions that fit, where124 // each caption costs, and more so if it ends mid-phrase or is short.125 n := len(toks)126 cost, from := make([]int, n+1), make([]int, n+1)127 for i := 1; i <= n; i++ {128 cost[i] = math.MaxInt129 chars := -1130 for j := i - 1; j >= 0; j-- {131 chars += toks[j].n + 1132 if chars > Rows*Columns+1 {133 break134 }135 if cost[j] == math.MaxInt || layout(toks[j:i]) == nil {136 continue137 }138 c := cost[j] + 10139 if i < n {140 c += breakCost(toks[i-1], toks[i])141 }142 if chars < 24 && (j > 0 || i < n) {143 c += 4144 }145 if c < cost[i] {146 cost[i], from[i] = c, j147 }148 }149 }150 var out []string151 for i := n; i > 0; i = from[i] {152 out = append(out, strings.Join(layout(toks[from[i]:i]), "\n"))153 }154 slices.Reverse(out)155 return out156}157158// layout puts words on one line, or two of about equal length broken159// where a reader would pause, or at a change of speaker. It returns nil160// when they don't fit.161func layout(toks []token) []string {162 join := func(ts []token) string {163 var b strings.Builder164 for i, t := range ts {165 if i > 0 {166 b.WriteByte(' ')167 }168 b.WriteString(t.text)169 }170 return b.String()171 }172 width := func(ts []token) int {173 n := len(ts) - 1174 for _, t := range ts {175 n += t.n176 }177 return n178 }179 speakers := 0180 for _, t := range toks[1:] {181 if t.dialogue {182 speakers++183 }184 }185 best, at := math.MaxInt, -1186 for k := 1; k < len(toks); k++ {187 a, b := width(toks[:k]), width(toks[k:])188 if a > Columns || b > Columns || (speakers > 0 && !toks[k].dialogue) {189 continue190 }191 c := abs(a-b) + breakCost(toks[k-1], toks[k])/2192 if a > b {193 c += 2 // the longer line below reads better194 }195 if c < best {196 best, at = c, k197 }198 }199 switch {200 case speakers > 1:201 return nil202 case speakers == 0 && width(toks) <= Columns:203 return []string{join(toks)}204 case at < 0:205 return nil206 }207 return []string{join(toks[:at]), join(toks[at:])}208}209210func abs(n int) int { return max(n, -n) }211212// conjunctions begin clauses, so are good places to break before.213var conjunctions = map[string]bool{214 "and": true, "but": true, "or": true, "so": true, "because": true, "if": true, "when": true,215 "while": true, "that": true, "which": true, "who": true, "where": true, "then": true,216 "though": true, "although": true, "until": true, "unless": true, "since": true,217 "after": true, "before": true, "as": true, "than": true,218}219220// breakCost is how badly breaking between two words interrupts a reader:221// not at all at the end of a sentence or a change of speaker, a little222// after a comma or before a conjunction, and most mid-phrase.223func breakCost(a, b token) int {224 end := strings.TrimRightFunc(a.text, func(r rune) bool { return strings.ContainsRune("\"')]}»", r) })225 last, _ := lastRune(end)226 switch {227 case b.dialogue || strings.ContainsRune(".!?♪", last):228 return 0229 case strings.ContainsRune(",;:—", last) || isDash(b.text):230 return 3231 case conjunctions[strings.ToLower(strings.TrimFunc(b.text, func(r rune) bool { return !unicode.IsLetter(r) }))]:232 return 6233 }234 return 12235}236237func lastRune(s string) (rune, bool) {238 r := []rune(s)239 if len(r) == 0 {240 return 0, false241 }242 return r[len(r)-1], true243}244245// isDash reports whether a word is a dash: "-", "--" or "—".246func isDash(w string) bool { return w == "-" || w == "--" || w == "—" }247248// speaker reports whether a line's first word marks a change of speaker:249// "- Hi" or "-Hi".250func speaker(w string) bool {251 r := []rune(w)252 return isDash(w) || len(r) > 1 && r[0] == '-' && unicode.IsLetter(r[1])253}254255// fold maps text to characters CEA-608 can show: lookalikes stand in for256// the rest, such as "..." for an ellipsis and plain letters for accented257// ones it lacks, and anything else is dropped.258func fold(s string) string {259 var b strings.Builder260 for _, r := range s {261 _, ok := charset[r]262 switch {263 case ok:264 b.WriteRune(r)265 case substitutes[r] != "":266 b.WriteString(substitutes[r])267 case unicode.IsSpace(r):268 b.WriteByte(' ')269 }270 }271 return b.String()272}273274// substitutes stand in for characters CEA-608 lacks.275var substitutes = map[rune]string{276 '`': "'", '‘': "'", '’': "'", '‚': "'", '‛': "'", '′': "'", '´': "'",277 '„': "\"", '‟': "\"", '″': "\"",278 '…': "...", '‐': "-", '‑': "-", '‒': "-", '–': "-", '―': "—", '−': "-",279 '♫': "♪", '♩': "♪", '♬': "♪",280 '‹': "<", '›': ">",281 'Æ': "AE", 'æ': "ae", 'Œ': "OE", 'œ': "oe", 'Þ': "Th", 'þ': "th",282 'Ð': "D", 'ð': "d", 'Ý': "Y", 'ý': "y", 'ÿ': "y", 'Ÿ': "Y",283 '¹': "1", '²': "2", '³': "3", '¼': "1/4", '¾': "3/4",284 '×': "x", '¬': "-", 'ª': "a", 'º': "o",285 'Ā': "A", 'ā': "a", 'Ă': "A", 'ă': "a", 'Ą': "A", 'ą': "a",286 'Ć': "C", 'ć': "c", 'Č': "C", 'č': "c", 'Ď': "D", 'ď': "d", 'Đ': "D", 'đ': "d",287 'Ē': "E", 'ē': "e", 'Ė': "E", 'ė': "e", 'Ę': "E", 'ę': "e", 'Ě': "E", 'ě': "e",288 'Ğ': "G", 'ğ': "g", 'İ': "I", 'ı': "i", 'Ī': "I", 'ī': "i",289 'Ł': "L", 'ł': "l", 'Ń': "N", 'ń': "n", 'Ň': "N", 'ň': "n",290 'Ō': "O", 'ō': "o", 'Ő': "O", 'ő': "o", 'Ř': "R", 'ř': "r",291 'Ś': "S", 'ś': "s", 'Ş': "S", 'ş': "s", 'Š': "S", 'š': "s",292 'Ţ': "T", 'ţ': "t", 'Ť': "T", 'ť': "t",293 'Ū': "U", 'ū': "u", 'Ů': "U", 'ů': "u", 'Ű': "U", 'ű': "u",294 'Ź': "Z", 'ź': "z", 'Ż': "Z", 'ż': "z", 'Ž': "Z", 'ž': "z",295}