feat: add a translate stack

This commit is contained in:
Kharec committed 2026-10-01 10:15:28 +02:00
1 parent 5cf16588c1
commit cb356ff8c5
10 files changed
+2996

No files matched your search

+957
View File
@@ -0,0 +1,957 @@
// Package translate translates a screenplay from French into English,
// element by element, with the conventions of each element type: a French
// slugline becomes "INT. KITCHEN - NIGHT", "(V.O.)" and "(SUITE)" become
// "(V.O.)" and "(CONT'D)", "FONDU AU NOIR" becomes "FADE TO BLACK.", and so
// on. Free text (action, dialogue, parentheticals, locations) goes through
// a machine translation model, one sentence at a time. Text that does not
// read as French is left untouched, so a script can be translated piece by
// piece, and translating it again changes nothing.
package translate
import (
"context"
"regexp"
"slices"
"strings"
"sync"
"unicode"
"plotzilla/internal/screenplay"
)
// Translator translates one French sentence into English. It must be
// safe for concurrent use.
type Translator interface {
Translate(ctx context.Context, text string) (string, error)
}
// Options says what to translate.
type Options struct {
// Elements are the indexes of the elements to translate (flat order,
// as everywhere else); nil translates every element.
Elements []int
// TitlePage also translates the title page credit, source, draft date
// and notes. The title itself is never translated.
TitlePage bool
// Workers is how many sentences are translated at once (default 1).
Workers int
// Progress is called after each sentence, from any goroutine.
Progress func(done, total int)
}
// Change is a translated element, to put in place of element Index.
type Change struct {
Index int `json:"index"`
Element screenplay.Element `json:"element"`
}
// Result lists what changed. TitlePage is nil when it did not change.
type Result struct {
Changes []Change `json:"changes"`
TitlePage *screenplay.TitlePage `json:"titlePage,omitempty"`
}
// Document translates the French text of doc. It does not modify doc.
func Document(ctx context.Context, doc *screenplay.Document, tr Translator, lex Lexicon, opts Options) (Result, error) {
s := newSession(doc, lex)
indexes := opts.Elements
if indexes == nil {
indexes = make([]int, len(doc.Elements))
for i := range indexes {
indexes[i] = i
}
}
// First pass: collect the sentences to translate. Second pass, once
// they are translated: build the elements from them.
s.collect = true
s.run(doc, indexes, opts.TitlePage)
done, err := s.translateAll(ctx, tr, max(opts.Workers, 1), opts.Progress)
if err != nil {
return Result{}, err
}
s.collect, s.done = false, done
return s.run(doc, indexes, opts.TitlePage), nil
}
type session struct {
lex Lexicon
// names are the script's characters, lower case, longest first: they
// are kept as written instead of being translated ("Marie" would
// become "Mary").
names [][]rune
nameWords map[string]bool
// frenchAt says, for each element, whether it or (when it is
// ambiguous) its nearest unambiguous neighbour is French;
// frenchContext is that value for the element being translated.
frenchAt []bool
frenchContext bool
collect bool
wanted []string
seen map[string]bool
done map[string]string
}
// determiners start a character cue that is a description to translate
// ("LE POLICIER") rather than a name.
var determiners = set("le la les l un une des mon ma mes ton ta tes son sa ses notre votre leur")
func newSession(doc *screenplay.Document, lex Lexicon) *session {
s := &session{lex: lex, nameWords: map[string]bool{}, seen: map[string]bool{}}
seen := map[string]bool{}
for _, el := range doc.Elements {
if el.Type != screenplay.Character {
continue
}
name := strings.ToLower(screenplay.CharacterName(el.PrintedText()))
ws := words(name)
if name == "" || seen[name] || len(ws) == 0 || determiners[strings.TrimSuffix(firstElision(ws[0]), "'")] {
continue
}
seen[name] = true
s.names = append(s.names, []rune(name))
for _, w := range ws {
s.nameWords[w] = true
}
}
s.frenchAt = s.languages(doc)
// Longest first: "Marie-Claire" before "Marie".
for i := 1; i < len(s.names); i++ {
for j := i; j > 0 && len(s.names[j]) > len(s.names[j-1]); j-- {
s.names[j], s.names[j-1] = s.names[j-1], s.names[j]
}
}
return s
}
// languages decides the language of each element: French, English, or,
// for an element without clear evidence ("Attends !", "Oui ?" are short;
// a character cue is a name), the language of its nearest decided
// neighbour.
func (s *session) languages(doc *screenplay.Document) []bool {
n := len(doc.Elements)
lang := make([]int, n) // 1 French, -1 English, 0 undecided
for i, el := range doc.Elements {
fr, en := scores(el.Text(), s.lex, s.nameWords)
switch {
case fr > en:
lang[i] = 1
case en > fr:
lang[i] = -1
}
}
out := make([]bool, n)
for i := range n {
if lang[i] != 0 {
out[i] = lang[i] > 0
continue
}
for d := 1; d < n; d++ {
if i-d >= 0 && lang[i-d] != 0 {
out[i] = lang[i-d] > 0
break
}
if i+d < n && lang[i+d] != 0 {
out[i] = lang[i+d] > 0
break
}
}
}
return out
}
// firstElision returns "l'" for "l'inspecteur", the word itself otherwise.
func firstElision(w string) string {
if i := strings.IndexByte(w, '\''); i > 0 {
return w[:i+1]
}
return w
}
func (s *session) run(doc *screenplay.Document, indexes []int, titlePage bool) Result {
res := Result{Changes: []Change{}}
for _, i := range indexes {
if i < 0 || i >= len(doc.Elements) {
continue
}
s.frenchContext = s.frenchAt[i]
if el, changed := s.element(doc.Elements[i]); changed {
res.Changes = append(res.Changes, Change{Index: i, Element: el})
}
}
if titlePage {
s.frenchContext = false
tp := doc.TitlePage
tp.Credit = s.lines(tp.Credit, s.credit)
tp.Source = s.lines(tp.Source, s.prose)
tp.DraftDate = s.lines(tp.DraftDate, s.prose)
tp.Notes = s.lines(tp.Notes, s.prose)
if tp != doc.TitlePage {
res.TitlePage = &tp
}
}
return res
}
// ------------------------------------------------------------- elements
// element translates el. Notes are translated on their own; the rest of
// the text as a whole, so sentences keep their meaning. Emphasis that
// covers only part of a translated stretch cannot follow the words: the
// translation keeps the styles common to the whole stretch.
func (s *session) element(el screenplay.Element) (screenplay.Element, bool) {
var runs []screenplay.Run
changed := false
if t := s.retype(el); t != el.Type {
el.Type, changed = t, true
}
rule := s.rule(el.Type)
for _, chunk := range chunks(el.Runs) {
var b strings.Builder
for _, r := range chunk {
b.WriteString(r.Text)
}
text := b.String()
var out string
if chunk[0].Note {
out = s.lines(text, s.prose)
} else {
out = s.lines(text, rule)
}
if out == text {
runs = append(runs, chunk...)
continue
}
changed = true
style := commonStyle(chunk)
style.Text = out
runs = append(runs, style)
}
if !changed {
return el, false
}
oldLen := len([]rune(el.Text()))
el.Runs = screenplay.NormalizeRuns(runs)
el.Locked = remapLocks(el.Locked, oldLen, len([]rune(el.Text())))
return el, true
}
// retype recognises a French transition or shot typed as action, as the
// Fountain format reads "FONDU AU NOIR." or "GROS PLAN SUR la lettre" (it
// only knows the English forms): the element becomes a transition or a
// shot, as the English text will be read back.
func (s *session) retype(el screenplay.Element) screenplay.ElementType {
if el.Type != screenplay.Action || len(el.Runs) == 0 || slices.ContainsFunc(el.Runs, func(r screenplay.Run) bool { return r.Note }) {
return el.Type
}
line := strings.TrimSpace(el.Text())
if strings.Contains(line, "\n") {
return el.Type
}
if key := fold(strings.TrimRight(line, ":. \u202f")); transitions[key] != "" && key != "FIN" && isCaps(line) {
return screenplay.Transition
}
fields := strings.Fields(line)
for _, sh := range shots {
n := len(strings.Fields(sh.french))
if len(fields) >= n && isCaps(strings.Join(fields[:n], " ")) && fold(strings.Join(fields[:n], " ")) == sh.french {
return screenplay.Shot
}
}
return el.Type
}
func (s *session) rule(t screenplay.ElementType) func(string) string {
switch t {
case screenplay.Scene:
return s.slugline
case screenplay.Character:
return s.cue
case screenplay.Parenthetical:
return s.parenthetical
case screenplay.Transition:
return s.transition
case screenplay.Shot:
return s.shot
case screenplay.Centered:
return s.centered
}
return s.prose
}
// chunks groups runs into stretches of text: each note alone, the text
// between notes together.
func chunks(runs []screenplay.Run) [][]screenplay.Run {
var out [][]screenplay.Run
for i, r := range runs {
if i == 0 || r.Note || runs[i-1].Note {
out = append(out, nil)
}
out[len(out)-1] = append(out[len(out)-1], r)
}
return out
}
func commonStyle(runs []screenplay.Run) screenplay.Run {
c := runs[0]
c.Text = ""
for _, r := range runs[1:] {
c.Bold = c.Bold && r.Bold
c.Italic = c.Italic && r.Italic
c.Underline = c.Underline && r.Underline
if r.Rev != c.Rev {
c.Rev = 0
}
}
return c
}
// remapLocks keeps the locked pages of a translated element in proportion
// to the text: the page that started halfway through starts halfway
// through the translation.
func remapLocks(locks []screenplay.PageLock, oldLen, newLen int) []screenplay.PageLock {
if len(locks) == 0 {
return locks
}
out := make([]screenplay.PageLock, len(locks))
prev := 0
for i, l := range locks {
off := 0
if oldLen > 0 {
off = (l.Offset*newLen + oldLen/2) / oldLen
}
off = min(max(off, prev), newLen)
out[i] = screenplay.PageLock{Offset: off, Page: l.Page}
prev = off
}
return out
}
// lines applies fn to each line of text, keeping the line breaks and the
// spaces around each line.
func (s *session) lines(text string, fn func(string) string) string {
parts := strings.Split(text, "\n")
for i, line := range parts {
core := strings.TrimSpace(line)
if core == "" {
continue
}
lead := line[:strings.Index(line, core)]
trail := line[len(lead)+len(core):]
parts[i] = lead + fn(core) + trail
}
return strings.Join(parts, "\n")
}
// ---------------------------------------------------------------- rules
// prose translates free text sentence by sentence, leaving the sentences
// that are not French.
//
// A sentence with as much French as English evidence ("Attends !": to
// attend is English too, "Marie ?") is translated when the text around it
// is French: other sentences of the text, or the neighbouring elements.
func (s *session) prose(text string) string {
list := sentences(text)
type verdict struct{ fr, en int }
verdicts := make([]verdict, len(list))
frenchAround := s.frenchContext
for i, sentence := range list {
fr, en := scores(sentence, s.lex, s.nameWords)
verdicts[i] = verdict{fr, en}
frenchAround = frenchAround || fr > en
}
var b strings.Builder
for i, sentence := range list {
core := strings.TrimRightFunc(sentence, unicode.IsSpace)
v := verdicts[i]
if v.fr > v.en || (v.fr == v.en && frenchAround && hasLetter(core)) {
b.WriteString(s.mt(core, modeOf(core)))
} else {
b.WriteString(core)
}
b.WriteString(sentence[len(core):])
}
return b.String()
}
// french reports whether a line reads as French, or could be French and
// sits among French elements.
func (s *session) french(text string) bool {
fr, en := scores(text, s.lex, s.nameWords)
return fr > en || (fr == en && s.frenchContext && hasLetter(text))
}
func hasLetter(text string) bool { return strings.IndexFunc(text, unicode.IsLetter) >= 0 }
var (
slugPrefix = regexp.MustCompile(`(?i)^((?:INT\.?\s*/\s*EXT|EXT\.?\s*/\s*INT|I\s*/\s*E|INT|EXT|EST|INT[ÉE]RIEUR|EXT[ÉE]RIEUR)\.?)\s+(.*)$`)
slugDash = regexp.MustCompile(`\s+[-–—]+\s+`)
trailNote = regexp.MustCompile(`^(.*?)\s*\(([^()]*)\)$`)
)
// slugline translates a French scene heading: "INT. CUISINE DE MARIE -
// NUIT" → "INT. MARIE'S KITCHEN - NIGHT". Times of day and the usual notes
// come from a table, locations from the model.
func (s *session) slugline(line string) string {
prefix, rest := "", line
if m := slugPrefix.FindStringSubmatch(line); m != nil {
prefix, rest = m[1], m[2]
}
note := ""
if m := trailNote.FindStringSubmatch(rest); m != nil && m[1] != "" {
rest, note = m[1], m[2]
}
parts := slugDash.Split(rest, -1)
locations, time := parts, ""
if len(parts) > 1 {
locations, time = parts[:len(parts)-1], parts[len(parts)-1]
}
frTime, isFrTime := frenchTimes[fold(time)]
_, isEnTime := englishTimes[fold(time)]
_, isFrNote := sceneNotes[fold(note)]
fp := fold(prefix)
french := isFrTime || strings.HasPrefix(fp, "INTERIEUR") || strings.HasPrefix(fp, "EXTERIEUR") ||
(!isEnTime && (s.french(strings.Join(locations, " ")) || isFrNote && time == ""))
if !french {
return line
}
switch {
case strings.HasPrefix(fp, "INTERIEUR"):
prefix = "INT."
case strings.HasPrefix(fp, "EXTERIEUR"):
prefix = "EXT."
}
out := make([]string, 0, len(parts))
for _, loc := range locations {
if s.notEnglish(loc) {
loc = s.mt(loc, location)
}
out = append(out, loc)
}
switch {
case isFrTime:
out = append(out, frTime)
case time != "" && !isEnTime && s.notEnglish(time):
out = append(out, s.mt(time, caps))
case time != "":
out = append(out, time)
}
heading := strings.Join(out, " - ")
if prefix != "" {
heading = prefix + " " + heading
}
if note != "" {
if en, ok := sceneNotes[fold(note)]; ok {
note = en
} else if s.french(note) {
note = s.mt(note, caps)
}
heading += " (" + note + ")"
}
return heading
}
var (
parens = regexp.MustCompile(`\(([^()]*)\)`)
shotHead = regexp.MustCompile(`^([A-Z' .]+?)(\s+[-–—:]\s+)(.+)$`)
)
// cue translates a character cue's extensions, "MARIE (VOIX OFF)" →
// "MARIE (V.O.)". The name is kept, unless it is a description starting
// with an article ("LE POLICIER" → "THE POLICEMAN").
func (s *session) cue(line string) string {
name, rest := line, ""
if i := strings.IndexByte(line, '('); i >= 0 {
name, rest = line[:i], line[i:]
}
core := strings.TrimRightFunc(name, unicode.IsSpace)
gap := name[len(core):]
if ws := words(core); len(ws) > 0 && determiners[strings.TrimSuffix(firstElision(ws[0]), "'")] && s.notEnglish(core) {
core = s.mt(core, caps)
}
rest = parens.ReplaceAllStringFunc(rest, func(p string) string {
inner := strings.TrimSpace(p[1 : len(p)-1])
if en, ok := extensions[foldTight(inner)]; ok {
return "(" + en + ")"
}
if s.french(inner) {
return "(" + s.mt(inner, caps) + ")"
}
return p
})
return core + gap + rest
}
// parenthetical translates a wryly, "(un temps)" → "(beat)", "(à Marie)"
// → "(to Marie)", in lower case as is the custom.
func (s *session) parenthetical(line string) string {
open, close := strings.HasPrefix(line, "("), strings.HasSuffix(line, ")")
inner := line
if open {
inner = inner[1:]
}
if close {
inner = inner[:len(inner)-1]
}
inner = strings.TrimSpace(inner)
var out string
if en, ok := parentheticals[foldLower(inner)]; ok {
out = en
} else if s.french(inner) {
out = s.lowerFirst(s.mt(inner, plain))
} else {
return line
}
if open {
out = "(" + out
}
if close {
out += ")"
}
return out
}
// lowerFirst lower-cases the first letter unless it starts a name, an
// acronym or "I".
func (s *session) lowerFirst(text string) string {
r := []rune(text)
if len(r) < 2 || !unicode.IsUpper(r[0]) || !unicode.IsLower(r[1]) {
return text
}
if ws := words(text); len(ws) > 0 && s.nameWords[ws[0]] {
return text
}
r[0] = unicode.ToLower(r[0])
return string(r)
}
// transition translates "FONDU ENCHAÎNÉ :" → "DISSOLVE TO:".
func (s *session) transition(line string) string {
key := fold(strings.TrimRight(line, ":.  "))
if en, ok := transitions[key]; ok {
return en
}
if s.french(line) {
return s.mt(line, caps)
}
return line
}
// shot translates a shot or insert: "GROS PLAN SUR LA LETTRE" → "CLOSE ON
// THE LETTER", "INSERT - UNE PHOTO" → "INSERT - A PHOTO".
func (s *session) shot(line string) string {
fields := strings.Fields(line)
for _, sh := range shots {
n := len(strings.Fields(sh.french))
if len(fields) < n || fold(strings.Join(fields[:n], " ")) != sh.french {
continue
}
rest := afterWords(line, n)
trimmed := strings.TrimLeft(rest, " \t-–—:")
if strings.TrimSpace(trimmed) == "" {
return sh.english
}
sep := " "
if strings.ContainsAny(rest[:len(rest)-len(trimmed)], "-–—:") {
sep = " - "
}
if s.notEnglish(trimmed) {
trimmed = s.mt(trimmed, modeOf(trimmed))
}
return sh.english + sep + trimmed
}
// "INSERT - UNE PHOTO": an English shot with French text after it.
if s.french(line) {
if m := shotHead.FindStringSubmatch(line); m != nil && !s.french(m[1]) {
return m[1] + " - " + s.mt(m[3], caps)
}
return s.mt(line, caps)
}
return line
}
// afterWords returns line after its first n words.
func afterWords(line string, n int) string {
i := 0
for range n {
for i < len(line) && line[i] == ' ' {
i++
}
for i < len(line) && line[i] != ' ' {
i++
}
}
return line[i:]
}
func (s *session) centered(line string) string {
if fold(strings.Trim(line, ". ")) == "FIN" {
return "THE END"
}
return s.prose(line)
}
func (s *session) credit(line string) string {
if en, ok := credits[foldLower(strings.TrimRight(line, ": "))]; ok {
return en
}
return s.prose(line)
}
// isCaps reports whether text is written in capitals.
func isCaps(text string) bool {
letters := 0
for _, r := range text {
if unicode.IsLower(r) {
return false
}
if unicode.IsLetter(r) {
letters++
}
}
return letters > 1
}
// ------------------------------------------------------------ sentences
var abbreviations = set("m mm mme mmes mlle mlles dr pr me mgr st ste etc cf p vol ch av bd no mr mrs ms jr sr vs env")
// sentences splits text into sentences, each with the spaces that follow
// it. A sentence ends with . ! ? or … (and closing quotes or brackets)
// followed by a space and a capital, a digit or an opening quote or dash;
// "M. Dupont" and initials do not end one.
func sentences(text string) []string {
r := []rune(text)
var out []string
start := 0
for i := 0; i < len(r); i++ {
if !strings.ContainsRune(".!?…", r[i]) {
continue
}
j := closers(r, i+1)
if j >= len(r) || !unicode.IsSpace(r[j]) {
i = j - 1
continue
}
k := j
for k < len(r) && unicode.IsSpace(r[k]) {
k++
}
if k == len(r) {
break
}
next := r[k]
if !unicode.IsUpper(next) && !unicode.IsDigit(next) && !strings.ContainsRune("«\"“—–-¿¡([", next) {
i = j - 1
continue
}
if r[i] == '.' && j == i+1 && abbreviated(r[:i]) {
i = j - 1
continue
}
out = append(out, string(r[start:k]))
start, i = k, k-1
}
if start < len(r) {
out = append(out, string(r[start:]))
}
return out
}
// closers skips what may close a sentence after its final punctuation:
// more punctuation, quotes, brackets, and a French closing guillemet after
// its space ("Non. »").
func closers(r []rune, j int) int {
for j < len(r) {
switch {
case strings.ContainsRune(".!?…»\"')]’”", r[j]):
j++
case unicode.IsSpace(r[j]):
k := j
for k < len(r) && unicode.IsSpace(r[k]) && r[k] != '\n' {
k++
}
if k < len(r) && r[k] == '»' {
j = k + 1
continue
}
return j
default:
return j
}
}
return j
}
// abbreviated reports whether text ends with an abbreviation or an
// initial (the word before a full stop).
func abbreviated(text []rune) bool {
i := len(text)
for i > 0 && unicode.IsLetter(text[i-1]) {
i--
}
w := text[i:]
if len(w) == 0 {
return false
}
return (len(w) == 1 && unicode.IsUpper(w[0])) || abbreviations[strings.ToLower(string(w))]
}
// ---------------------------------------------------------- translation
// mode says how a text is put to the model.
type mode byte
const (
plain mode = iota
// caps: text in capitals, translated in lower case (the model reads
// capitals badly) and put back in capitals.
caps
// location: a slugline's location, in capitals. It is given an
// article, which the model needs to read it as a noun phrase
// ("CUISINE DE MARIE" alone gives "MARIE CUISINE", "La cuisine de
// Marie" gives "Marie's kitchen"), removed from the translation.
location
)
func modeOf(text string) mode {
if isCaps(text) {
return caps
}
return plain
}
// mt returns the translation of a French sentence (during the first pass:
// records it and returns it as is).
func (s *session) mt(text string, m mode) string {
key := string(rune('0'+m)) + "\x00" + text
if s.collect {
if !s.seen[key] {
s.seen[key] = true
s.wanted = append(s.wanted, key)
}
return text
}
if out, ok := s.done[key]; ok && out != "" {
return out
}
return text
}
func (s *session) translateAll(ctx context.Context, tr Translator, workers int, progress func(done, total int)) (map[string]string, error) {
done := make(map[string]string, len(s.wanted))
if len(s.wanted) == 0 {
return done, nil
}
ctx, cancel := context.WithCancel(ctx)
defer cancel()
var (
mu sync.Mutex
firstErr error
wg sync.WaitGroup
)
jobs := make(chan string)
for range min(workers, len(s.wanted)) {
wg.Add(1)
go func() {
defer wg.Done()
for key := range jobs {
out, err := s.translateOne(ctx, tr, key)
mu.Lock()
if err != nil && firstErr == nil {
firstErr = err
cancel()
}
done[key] = out
n := len(done)
mu.Unlock()
if err == nil && progress != nil {
progress(n, len(s.wanted))
}
}
}()
}
feed:
for _, key := range s.wanted {
select {
case jobs <- key:
case <-ctx.Done():
break feed
}
}
close(jobs)
wg.Wait()
if firstErr != nil {
return nil, firstErr
}
if err := ctx.Err(); err != nil {
return nil, err
}
return done, nil
}
// placeholders stand for the characters' names while a sentence is
// translated: invented names the model copies as they are (checked with
// OPUS-MT fr-en), so it cannot turn "Marie" into "Mary" or "Pierre" into
// "stone".
var placeholders = []string{
"Zorbin", "Dravok", "Yelmir", "Brontiq", "Quorvel", "Velkor", "Strivan", "Xavek",
"Kwilam", "Tazmik", "Moxtel", "Gorlex", "Pilvam", "Wexol", "Jandrix",
}
func (s *session) translateOne(ctx context.Context, tr Translator, key string) (string, error) {
m, text := mode(key[0]-'0'), key[2:]
upper := m != plain
src, names := s.protect(text, upper)
article := m == location && !startsWithDeterminer(src)
if article {
src = articleFor(src) + src
}
if upper {
src = capitalize(src)
}
out, err := tr.Translate(ctx, src)
if err != nil {
return "", err
}
if article {
out = leadingArticle.ReplaceAllString(out, "")
}
for i, name := range names {
out = placeholderRes[i].ReplaceAllLiteralString(out, name)
}
return polish(text, out, upper), nil
}
var (
leadingArticle = regexp.MustCompile(`(?i)^\s*(the|an?)\s+`)
placeholderRes = func() []*regexp.Regexp {
out := make([]*regexp.Regexp, len(placeholders))
for i, p := range placeholders {
out[i] = regexp.MustCompile(`(?i)\b` + p + `\b`)
}
return out
}()
)
func startsWithDeterminer(text string) bool {
ws := words(text)
return len(ws) > 0 && (determiners[strings.TrimSuffix(firstElision(ws[0]), "'")] || ws[0] == "du" || ws[0] == "au" || ws[0] == "aux")
}
// articleFor picks a French article for a noun phrase: its gender is
// unknown, but any article will do for the model.
func articleFor(text string) string {
first := strings.ToLower(fold(strings.Fields(text)[0]))
switch {
case strings.ContainsAny(first[:1], "aeiouyh"):
return "l'"
case strings.HasSuffix(first, "s") || strings.HasSuffix(first, "x"):
return "les "
}
return "le "
}
// capitalize upper-cases the first letter: a sentence starts with a
// capital, or the model takes it for a fragment.
func capitalize(text string) string {
r := []rune(text)
for i, c := range r {
if unicode.IsLetter(c) {
r[i] = unicode.ToUpper(c)
break
}
}
return string(r)
}
// protect replaces the characters' names written with a capital by
// placeholders and, if lowerCase, lower-cases the rest. It returns the
// names in placeholder order.
func (s *session) protect(text string, lowerCase bool) (string, []string) {
r := []rune(text)
lower := make([]rune, len(r))
for i, c := range r {
lower[i] = unicode.ToLower(c)
}
isWord := func(i int) bool { return i >= 0 && i < len(r) && (unicode.IsLetter(r[i]) || unicode.IsDigit(r[i])) }
var b strings.Builder
var names []string
index := map[string]int{}
for i := 0; i < len(r); {
matched := false
if !isWord(i - 1) {
for _, name := range s.names {
end := i + len(name)
if end > len(r) || isWord(end) || string(lower[i:end]) != string(name) || !hasUpper(r[i:end]) {
continue
}
key := string(name)
n, ok := index[key]
if !ok {
if len(names) == len(placeholders) {
break
}
n = len(names)
index[key] = n
names = append(names, string(r[i:end]))
}
b.WriteString(placeholders[n])
i, matched = end, true
break
}
}
if matched {
continue
}
if lowerCase {
b.WriteRune(lower[i])
} else {
b.WriteRune(r[i])
}
i++
}
return b.String(), names
}
func hasUpper(r []rune) bool {
for _, c := range r {
if unicode.IsUpper(c) {
return true
}
}
return false
}
var (
openQuote = regexp.MustCompile(`«[\s\x{a0}\x{202f}]*`)
closeQuote = regexp.MustCompile(`[\s\x{a0}\x{202f}]*»`)
spaceMark = regexp.MustCompile(`[\s\x{a0}\x{202f}]+([!?;:])`)
)
// polish applies English typography to a translation: no space before
// ! ? ; :, straight quotes instead of guillemets, no full stop the source
// did not have (a location, a wryly), capitals if the source had them.
func polish(src, out string, caps bool) string {
out = openQuote.ReplaceAllString(out, `"`)
out = closeQuote.ReplaceAllString(out, `"`)
out = spaceMark.ReplaceAllString(out, "$1")
out = strings.TrimSpace(out)
if !strings.ContainsAny(lastRune(strings.TrimSpace(src)), ".!?…") &&
strings.HasSuffix(out, ".") && !strings.HasSuffix(out, "..") {
out = strings.TrimSuffix(out, ".")
}
if caps {
out = screenplay.UpperRunes(out)
}
return out
}
func lastRune(s string) string {
r := []rune(s)
if len(r) == 0 {
return ""
}
return string(r[len(r)-1])
}