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

+136
View File
@@ -0,0 +1,136 @@
package translate
import (
"strings"
"unicode"
)
// Lexicon tells which languages know a word, from spelling dictionaries.
// It refines the detection of words that are not function words.
type Lexicon interface {
Knows(word string) (french, english bool)
}
// Function words of one language that are not words of the other: they
// carry most of the signal in a short line.
var frenchWords = set(`le la les un une des du de au aux et est sont ce cette ces cet il ils elle elles je tu nous vous
ne pas que qui quoi dans sur avec pour par mais où donc ou ni sa ses leur leurs mon ma mes ton ta tes notre votre nos vos lui eux
se ça cela ceci très bien aussi alors encore toujours jamais rien tout tous toute toutes quand puis sans sous chez vers entre
être avoir fait faire va vais suis es était étaient ont avait avaient sera oui non merci voilà voici déjà ici là dont
comme comment pourquoi peut peux veux veut faut quelque quelques chose rien personne moi toi soi y en
après avant pendant depuis jusque contre derrière devant près loin enfin ensuite puis peu beaucoup trop assez`)
var englishWords = set(`the and is are was were be been being this that these those he she they we you it his her him their
its my your our of to in at with for from by not no yes do does did have has had will would can could should shall
what who whom where when why how there here then than just into out up down over about back all some any very
only also again never always nothing everything something someone anyone everyone because before after while through
i me us them which been get got go goes going went come came look looks looking says said tell told know think`)
// elisions are French contractions before a vowel: "l'homme", "qu'il".
var elisions = []string{"l'", "d'", "j'", "qu'", "n'", "s'", "c'", "m'", "t'", "jusqu'", "lorsqu'", "puisqu'"}
// contractions end English words: "don't", "we're", "Mary's".
var contractions = []string{"n't", "'re", "'ll", "'ve", "'m", "'d"}
func set(words string) map[string]bool {
m := map[string]bool{}
for _, w := range strings.Fields(words) {
m[w] = true
}
return m
}
const frenchLetters = "éèêëàâçîïôûùüœæ"
// words splits text into lower-case words, apostrophes included
// ("l'homme", "don't").
func words(text string) []string {
var out []string
var w []rune
flush := func() {
if len(w) > 0 {
out = append(out, strings.Trim(string(w), "'"))
w = w[:0]
}
}
for _, r := range text {
switch {
case unicode.IsLetter(r) || unicode.IsMark(r):
w = append(w, unicode.ToLower(r))
case (r == '\'' || r == '’') && len(w) > 0:
w = append(w, '\'')
default:
flush()
}
}
flush()
return out
}
// scores counts the evidence that text is French and that it is English.
// names are words to ignore (the script's characters).
func scores(text string, lex Lexicon, names map[string]bool) (fr, en int) {
for _, w := range words(text) {
if w == "" || names[w] {
continue
}
switch {
case frenchWords[w]:
fr += 2
continue
case englishWords[w]:
en += 2
continue
}
if hasPrefix(w, elisions) {
fr += 2
continue
}
if hasSuffix(w, contractions) {
en += 2
continue
}
if strings.HasSuffix(w, "'s") {
en++
continue
}
if strings.ContainsAny(w, frenchLetters) {
fr++
continue
}
if lex != nil && len([]rune(w)) > 2 {
switch f, e := lex.Knows(w); {
case f && !e:
fr++
case e && !f:
en++
}
}
}
return fr, en
}
func hasPrefix(w string, prefixes []string) bool {
for _, p := range prefixes {
if strings.HasPrefix(w, p) && len(w) > len(p) {
return true
}
}
return false
}
func hasSuffix(w string, suffixes []string) bool {
for _, s := range suffixes {
if strings.HasSuffix(w, s) && len(w) > len(s) {
return true
}
}
return false
}
// notEnglish reports whether text is not clearly English: the parts of a
// line already known to be French (the location of a French slugline).
func (s *session) notEnglish(text string) bool {
fr, en := scores(text, s.lex, s.nameWords)
return en <= fr
}
+160
View File
@@ -0,0 +1,160 @@
package translate
import (
"strings"
"unicode"
)
// The standard terms of French scripts and their Hollywood equivalents.
// Keys are folded (see fold): upper case, no accents, single spaces.
var frenchTimes = table(map[string]string{
"JOUR": "DAY", "NUIT": "NIGHT", "SOIR": "EVENING", "SOIREE": "EVENING", "MATIN": "MORNING",
"MATINEE": "MORNING", "APRES-MIDI": "AFTERNOON", "APRES MIDI": "AFTERNOON", "AUBE": "DAWN",
"AURORE": "DAWN", "CREPUSCULE": "DUSK", "COUCHER DE SOLEIL": "SUNSET", "COUCHER DU SOLEIL": "SUNSET",
"LEVER DE SOLEIL": "SUNRISE", "LEVER DU SOLEIL": "SUNRISE", "MIDI": "NOON", "MINUIT": "MIDNIGHT",
"PLUS TARD": "LATER", "UN PEU PLUS TARD": "A LITTLE LATER", "PEU APRES": "MOMENTS LATER",
"INSTANTS PLUS TARD": "MOMENTS LATER", "QUELQUES INSTANTS PLUS TARD": "MOMENTS LATER",
"QUELQUES MINUTES PLUS TARD": "MINUTES LATER", "CONTINU": "CONTINUOUS", "CONTINUE": "CONTINUOUS",
"CONTINUITE": "CONTINUOUS", "EN CONTINU": "CONTINUOUS", "SUITE": "CONTINUOUS",
"MEME MOMENT": "SAME TIME", "AU MEME MOMENT": "SAME TIME", "MEME TEMPS": "SAME TIME",
"LE LENDEMAIN": "THE NEXT DAY", "LENDEMAIN": "NEXT DAY", "PETIT MATIN": "EARLY MORNING",
"TOT LE MATIN": "EARLY MORNING", "FIN D'APRES-MIDI": "LATE AFTERNOON", "FIN DE JOURNEE": "LATE AFTERNOON",
"DEBUT DE SOIREE": "EARLY EVENING", "TARD LE SOIR": "LATE NIGHT", "TARD DANS LA NUIT": "LATE NIGHT",
"PLUS TOT": "EARLIER", "NUIT AMERICAINE": "DAY FOR NIGHT",
})
var englishTimes = table(map[string]string{
"DAY": "", "NIGHT": "", "MORNING": "", "EVENING": "", "AFTERNOON": "", "DAWN": "", "DUSK": "",
"SUNSET": "", "SUNRISE": "", "NOON": "", "MIDNIGHT": "", "LATER": "", "CONTINUOUS": "",
"MOMENTS LATER": "", "SAME TIME": "", "EARLY MORNING": "", "LATE NIGHT": "", "EARLIER": "",
"MAGIC HOUR": "", "LATE AFTERNOON": "", "EARLY EVENING": "", "SAME": "", "CONT'D": "",
})
// sceneNotes are the parenthetical notes that follow a slugline.
var sceneNotes = table(map[string]string{
"FLASH-BACK": "FLASHBACK", "FLASHBACK": "FLASHBACK", "RETOUR EN ARRIERE": "FLASHBACK",
"FIN DU FLASH-BACK": "END FLASHBACK", "FIN DU FLASHBACK": "END FLASHBACK", "REVE": "DREAM",
"CAUCHEMAR": "NIGHTMARE", "SOUVENIR": "MEMORY", "PRESENT": "PRESENT DAY", "RETOUR AU PRESENT": "PRESENT DAY",
"EN MOUVEMENT": "MOVING", "ROULANT": "MOVING", "A L'ARRET": "STATIONARY", "IMAGINAIRE": "IMAGINED",
"FANTASME": "FANTASY", "STOCK SHOT": "STOCK FOOTAGE", "IMAGES D'ARCHIVE": "STOCK FOOTAGE",
})
// extensions are the character extensions, folded without dots, spaces,
// hyphens or apostrophes: "V.O.", "VOIX OFF" and "voix-off" all give "VOIXOFF".
var extensions = map[string]string{
"VO": "V.O.", "VOIXOFF": "V.O.", "OFF": "V.O.", "ENOFF": "V.O.", "ENPENSEE": "V.O.", "PENSEE": "V.O.",
"PENSEES": "V.O.", "HC": "O.S.", "HORSCHAMP": "O.S.", "HORSCHAMPS": "O.S.", "SUITE": "CONT'D",
"CONT": "CONT'D", "ENSUITE": "CONT'D", "AUTELEPHONE": "ON PHONE", "TELEPHONE": "ON PHONE",
"AUTEL": "ON PHONE", "TEL": "ON PHONE", "ALARADIO": "ON RADIO", "RADIO": "ON RADIO",
"ALATELE": "ON TV", "ALATELEVISION": "ON TV", "TELE": "ON TV", "ALATV": "ON TV",
"SOUSTITRE": "SUBTITLED", "SOUSTITRES": "SUBTITLED", "SOUSTITREE": "SUBTITLED", "PRELAP": "PRE-LAP",
"CHUCHOTE": "WHISPERING", "CHUCHOTANT": "WHISPERING", "ENCHUCHOTANT": "WHISPERING",
"ENREGISTREMENT": "RECORDED", "ENREGISTRE": "RECORDED", "SURECRAN": "ON SCREEN", "ALECRAN": "ON SCREEN",
}
var transitions = table(map[string]string{
"COUPE": "CUT TO:", "COUPE SUR": "CUT TO:", "COUPURE": "CUT TO:", "CUT SUR": "CUT TO:",
"FONDU ENCHAINE": "DISSOLVE TO:", "ENCHAINE": "DISSOLVE TO:", "ENCHAINE SUR": "DISSOLVE TO:",
"FONDU ENCHAINE SUR": "DISSOLVE TO:", "FONDU ENCHAINE A": "DISSOLVE TO:",
"FONDU AU NOIR": "FADE TO BLACK.", "FONDU EN NOIR": "FADE TO BLACK.", "FONDU NOIR": "FADE TO BLACK.",
"FONDU AU BLANC": "FADE TO WHITE.", "FONDU EN FERMETURE": "FADE OUT.", "FERMETURE AU NOIR": "FADE OUT.",
"FERMETURE EN FONDU": "FADE OUT.", "FONDU DE FERMETURE": "FADE OUT.", "FONDU DE SORTIE": "FADE OUT.",
"FONDU EN OUVERTURE": "FADE IN:", "OUVERTURE AU NOIR": "FADE IN:", "OUVERTURE EN FONDU": "FADE IN:",
"FONDU D'OUVERTURE": "FADE IN:", "OUVERTURE EN FONDU AU NOIR": "FADE IN:", "OUVERTURE": "FADE IN:",
"COUPE FRANCHE": "SMASH CUT TO:", "CUT FRANC": "SMASH CUT TO:", "COUPE SECHE": "SMASH CUT TO:",
"CUT SEC": "SMASH CUT TO:", "RACCORD": "MATCH CUT TO:", "RACCORD SUR": "MATCH CUT TO:",
"RACCORD MOUVEMENT": "MATCH CUT TO:", "RACCORD REGARD": "MATCH CUT TO:", "RACCORD DANS L'AXE": "MATCH CUT TO:",
"COUPE SAUTEE": "JUMP CUT TO:", "VOLET": "WIPE TO:", "VOLET SUR": "WIPE TO:", "FONDU": "FADE TO:",
"FONDU SUR": "FADE TO:", "FONDU A": "FADE TO:", "FIN": "THE END",
})
// shots are matched as the first words of an insert / shot element; the
// English forms start like the shots the Fountain format recognises.
var shots = []struct{ french, english string }{
{"TRES GROS PLAN SUR", "EXTREME CLOSE ON"},
{"TRES GROS PLAN", "EXTREME CLOSE-UP"},
{"GROS PLAN SUR", "CLOSE ON"},
{"GROS PLAN", "CLOSE UP"},
{"PLAN RAPPROCHE SUR", "CLOSE ON"},
{"PLAN SERRE SUR", "CLOSE ON"},
{"PLAN LARGE SUR", "WIDE ON"},
{"PLAN LARGE", "WIDE SHOT"},
{"PLAN D'ENSEMBLE", "WIDE SHOT"},
{"PLAN GENERAL", "WIDE SHOT"},
{"PLAN DE SITUATION", "WIDE SHOT"},
{"PLAN AERIEN", "AERIAL SHOT"},
{"VUE AERIENNE", "AERIAL SHOT"},
{"PLAN EN PLONGEE", "OVERHEAD SHOT"},
{"PLONGEE", "OVERHEAD SHOT"},
{"VUE DU DESSUS", "OVERHEAD SHOT"},
{"POINT DE VUE DE", "POV"},
{"POINT DE VUE", "POV"},
{"P.D.V.", "POV"},
{"PDV", "POV"},
{"PLAN SUBJECTIF", "POV"},
{"VUE SUBJECTIVE", "POV"},
{"CONTRECHAMP", "REVERSE ANGLE"},
{"CONTRE-CHAMP", "REVERSE ANGLE"},
{"NOUVEL ANGLE", "NEW ANGLE"},
{"ANGLE SUR", "ANGLE ON"},
{"RETOUR A LA SCENE", "BACK TO SCENE"},
{"RETOUR SUR LA SCENE", "BACK TO SCENE"},
{"INSERT SUR", "INSERT"},
{"TRAVELLING SUR", "TRACKING ON"},
{"TRAVELLING", "TRACKING SHOT"},
{"PLAN MOYEN", "MEDIUM SHOT"},
{"PLAN AMERICAIN", "MEDIUM SHOT"},
{"PLAN TAILLE", "MEDIUM SHOT"},
{"SERIE DE PLANS", "SERIES OF SHOTS"},
{"FIN DU MONTAGE", "END MONTAGE"},
}
// parentheticals are the stock wrylies, folded to lower case.
var parentheticals = map[string]string{
"un temps": "beat", "temps": "beat", "un temps d'arret": "beat", "un silence": "beat",
"suite": "continuing", "continuant": "continuing", "a voix basse": "quietly", "tout bas": "quietly",
"bas": "quietly", "a part": "aside", "ironique": "wryly", "pour lui-meme": "to himself",
"pour elle-meme": "to herself", "pour soi": "to themself", "off": "O.S.", "en off": "V.O.",
"hors champ": "O.S.", "hors-champ": "O.S.", "voix off": "V.O.",
}
// credits are the usual title page credits, folded to lower case.
var credits = map[string]string{
"ecrit par": "Written by", "scenario de": "Screenplay by", "scenario": "Screenplay by",
"scenario et dialogues de": "Screenplay by", "scenario et dialogues": "Screenplay by",
"un film de": "A film by", "realise par": "Directed by", "d'apres une idee de": "Story by",
"histoire originale de": "Story by",
}
func table(m map[string]string) map[string]string {
out := make(map[string]string, len(m))
for k, v := range m {
out[fold(k)] = v
}
return out
}
var accents = strings.NewReplacer(
"À", "A", "Â", "A", "Ä", "A", "Ç", "C", "É", "E", "È", "E", "Ê", "E", "Ë", "E", "Î", "I", "Ï", "I",
"Ô", "O", "Ö", "O", "Ù", "U", "Û", "U", "Ü", "U", "Œ", "OE", "Æ", "AE", "Ÿ", "Y", "’", "'",
)
// fold upper-cases, removes accents and collapses spaces, to look a term
// up whatever its spelling.
func fold(s string) string {
return strings.Join(strings.Fields(accents.Replace(strings.ToUpper(s))), " ")
}
// foldTight also drops dots, spaces, hyphens and apostrophes ("V.O." → "VO").
func foldTight(s string) string {
return strings.Map(func(r rune) rune {
if r == '.' || r == '-' || r == '\'' || unicode.IsSpace(r) {
return -1
}
return r
}, fold(s))
}
// foldLower is fold in lower case, for parentheticals and credits.
func foldLower(s string) string { return strings.ToLower(fold(s)) }
+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])
}
+307
View File
@@ -0,0 +1,307 @@
package translate
import (
"context"
"errors"
"reflect"
"slices"
"sync"
"testing"
"plotzilla/internal/screenplay"
)
// fakeMT translates from a fixed table; a sentence it does not know comes
// back in angle brackets, so the test shows what was asked.
type fakeMT struct {
mu sync.Mutex
dict map[string]string
calls []string
err error
}
func (f *fakeMT) Translate(ctx context.Context, text string) (string, error) {
f.mu.Lock()
defer f.mu.Unlock()
f.calls = append(f.calls, text)
if f.err != nil {
return "", f.err
}
if out, ok := f.dict[text]; ok {
return out, nil
}
return "<" + text + ">", nil
}
type fakeLexicon map[string][2]bool
func (l fakeLexicon) Knows(w string) (bool, bool) { return l[w][0], l[w][1] }
var lexicon = fakeLexicon{
"attends": {true, true}, "cuisine": {true, true}, "laughing": {false, true}, "phone": {false, true},
"rings": {false, true}, "nobody": {false, true}, "moves": {false, true}, "kitchen": {false, true},
"street": {false, true}, "pleut": {true, false}, "entre": {true, false}, "lettre": {true, false}, "octobre": {true, false},
}
var dict = map[string]string{
"Le cuisine de Zorbin": "Zorbin's kitchen",
"Le rue": "The street",
"Zorbin ?": "Zorbin?",
"Tu es là ?": "Are you there?",
"Zorbin décroche.": "Zorbin picks up.",
"Le policier": "The policeman",
"à Zorbin": "To Zorbin",
"Attends !": "Wait!",
"Elle entre.": "She comes in.",
"Il pleut.": "It's raining.",
"Vérifier la date": "Check the date.",
"la lettre.": "the letter.",
"Je l'ai lue.": "I read it.",
"1er octobre 2026": "1 October 2026",
"Il pleut. Elle entre.": "unused",
"Bonjour, Pierre.": "Hello, Pierre.",
"Bonjour, « Zorbin » !": "Hello, « Zorbin » !",
"Ça marche, Zorbin.": "That works, Zorbin.",
"Zorbin et Dravok sortent.": "Zorbin and Dravok go out.",
}
func el(t screenplay.ElementType, text string) screenplay.Element {
return screenplay.NewElement(t, text)
}
func translateDoc(t *testing.T, doc *screenplay.Document, opts Options) (Result, *fakeMT) {
t.Helper()
mt := &fakeMT{dict: dict}
res, err := Document(context.Background(), doc, mt, lexicon, opts)
if err != nil {
t.Fatal(err)
}
return res, mt
}
// texts applies the changes and returns every element's text.
func texts(doc *screenplay.Document, res Result) []string {
out := make([]string, len(doc.Elements))
for i, e := range doc.Elements {
out[i] = e.Text()
}
for _, c := range res.Changes {
out[c.Index] = c.Element.Text()
}
return out
}
func TestScreenplayElements(t *testing.T) {
doc := &screenplay.Document{Elements: []screenplay.Element{
el(screenplay.Scene, "INT. CUISINE DE MARIE - NUIT"),
el(screenplay.Character, "PIERRE (VOIX OFF)"),
el(screenplay.Dialogue, "Marie ? Tu es là ?"),
el(screenplay.Character, "MARIE"),
el(screenplay.Parenthetical, "(un temps)"),
el(screenplay.Dialogue, "Attends !"),
el(screenplay.Parenthetical, "(à Marie)"),
el(screenplay.Character, "LE POLICIER"),
el(screenplay.Character, "MARIE (SUITE)"),
el(screenplay.Transition, "FONDU ENCHAÎNÉ :"),
el(screenplay.Scene, "EXTÉRIEUR RUE - AUBE (FLASH-BACK)"),
el(screenplay.Action, "The phone rings. Marie décroche."),
el(screenplay.Centered, "FIN"),
}}
res, _ := translateDoc(t, doc, Options{})
want := []string{
"INT. MARIE'S KITCHEN - NIGHT",
"PIERRE (V.O.)",
"Marie? Are you there?",
"MARIE",
"(beat)",
"Wait!",
"(to Marie)",
"THE POLICEMAN",
"MARIE (CONT'D)",
"DISSOLVE TO:",
"EXT. STREET - DAWN (FLASHBACK)",
"The phone rings. Marie picks up.",
"THE END",
}
if got := texts(doc, res); !reflect.DeepEqual(got, want) {
for i := range want {
if got[i] != want[i] {
t.Errorf("element %d = %q, want %q", i, got[i], want[i])
}
}
}
if slices.ContainsFunc(res.Changes, func(c Change) bool { return c.Index == 3 }) {
t.Error("a character name was changed")
}
}
func TestEnglishLeftAlone(t *testing.T) {
doc := &screenplay.Document{Elements: []screenplay.Element{
el(screenplay.Scene, "INT. KITCHEN - DAY"),
el(screenplay.Action, "Nobody moves. The phone rings."),
el(screenplay.Character, "PIERRE (O.S.)"),
el(screenplay.Parenthetical, "(laughing)"),
el(screenplay.Dialogue, "I told you, I'm not leaving."),
el(screenplay.Transition, "CUT TO:"),
el(screenplay.Shot, "CLOSE ON THE LETTER"),
el(screenplay.Action, "OK."),
}}
res, mt := translateDoc(t, doc, Options{TitlePage: true})
if len(res.Changes) != 0 || res.TitlePage != nil || len(mt.calls) != 0 {
t.Errorf("English script changed: %+v, model asked %q", res, mt.calls)
}
}
func TestActionRetyped(t *testing.T) {
doc := &screenplay.Document{Elements: []screenplay.Element{
el(screenplay.Action, "FONDU EN OUVERTURE :"),
el(screenplay.Action, "GROS PLAN SUR la lettre."),
el(screenplay.Action, "COUPE SUR :"),
el(screenplay.Action, "Il pleut."),
}}
res, _ := translateDoc(t, doc, Options{})
want := map[int]screenplay.Element{
0: el(screenplay.Transition, "FADE IN:"),
1: el(screenplay.Shot, "CLOSE ON the letter."),
2: el(screenplay.Transition, "CUT TO:"),
3: el(screenplay.Action, "It's raining."),
}
for _, c := range res.Changes {
if w := want[c.Index]; c.Element.Type != w.Type || c.Element.Text() != w.Text() {
t.Errorf("element %d = %s %q, want %s %q", c.Index, c.Element.Type, c.Element.Text(), w.Type, w.Text())
}
}
if len(res.Changes) != len(want) {
t.Errorf("%d changes, want %d", len(res.Changes), len(want))
}
}
func TestNotesStylesAndLocks(t *testing.T) {
doc := &screenplay.Document{Elements: []screenplay.Element{{
Type: screenplay.Action,
Runs: []screenplay.Run{
{Text: "Elle entre. ", Bold: true},
{Text: "Vérifier la date", Note: true},
{Text: " Il pleut.", Rev: 1},
},
Locked: []screenplay.PageLock{{Offset: 0, Page: "2"}, {Offset: 28, Page: "3"}},
}}}
res, _ := translateDoc(t, doc, Options{})
if len(res.Changes) != 1 {
t.Fatalf("changes: %+v", res.Changes)
}
got := res.Changes[0].Element
want := []screenplay.Run{
{Text: "She comes in. ", Bold: true},
// No full stop: the note had none.
{Text: "Check the date", Note: true},
{Text: " It's raining.", Rev: 1},
}
if !reflect.DeepEqual(got.Runs, want) {
t.Errorf("runs = %+v, want %+v", got.Runs, want)
}
// 28 of 38 runes → the same proportion of the 42 translated ones.
if want := []screenplay.PageLock{{Offset: 0, Page: "2"}, {Offset: 31, Page: "3"}}; !reflect.DeepEqual(got.Locked, want) {
t.Errorf("locks = %+v, want %+v", got.Locked, want)
}
}
func TestSelectionAndTitlePage(t *testing.T) {
doc := &screenplay.Document{
TitlePage: screenplay.TitlePage{Title: "La Dernière Bobine", Credit: "Écrit par", DraftDate: "1er octobre 2026"},
Elements: []screenplay.Element{
el(screenplay.Action, "Il pleut."),
el(screenplay.Action, "Elle entre."),
},
}
res, _ := translateDoc(t, doc, Options{Elements: []int{1}})
if len(res.Changes) != 1 || res.Changes[0].Index != 1 || res.TitlePage != nil {
t.Errorf("selection: %+v", res)
}
res, _ = translateDoc(t, doc, Options{TitlePage: true})
want := screenplay.TitlePage{Title: "La Dernière Bobine", Credit: "Written by", DraftDate: "1 October 2026"}
if res.TitlePage == nil || *res.TitlePage != want {
t.Errorf("title page = %+v, want %+v", res.TitlePage, want)
}
}
func TestNamesAndTypography(t *testing.T) {
doc := &screenplay.Document{Elements: []screenplay.Element{
el(screenplay.Character, "MARIE"),
el(screenplay.Character, "PIERRE"),
el(screenplay.Dialogue, "Bonjour, « Marie » !"),
el(screenplay.Action, "Ça marche, PIERRE."),
el(screenplay.Action, "Marie et Pierre sortent."),
}}
res, mt := translateDoc(t, doc, Options{})
got := texts(doc, res)
want := []string{"MARIE", "PIERRE", `Hello, "Marie"!`, "That works, PIERRE.", "Marie and Pierre go out."}
if !reflect.DeepEqual(got, want) {
t.Errorf("got %q, want %q (model asked %q)", got, want, mt.calls)
}
}
func TestIdempotent(t *testing.T) {
doc := &screenplay.Document{Elements: []screenplay.Element{
el(screenplay.Scene, "INT. CUISINE DE MARIE - NUIT"),
el(screenplay.Character, "MARIE (SUITE)"),
el(screenplay.Dialogue, "Je l'ai lue."),
}}
res, _ := translateDoc(t, doc, Options{})
for _, c := range res.Changes {
doc.Elements[c.Index] = c.Element
}
res, mt := translateDoc(t, doc, Options{})
if len(res.Changes) != 0 || len(mt.calls) != 0 {
t.Errorf("second translation changed %+v, asked %q", res.Changes, mt.calls)
}
}
func TestModelError(t *testing.T) {
doc := &screenplay.Document{Elements: []screenplay.Element{el(screenplay.Action, "Il pleut.")}}
boom := errors.New("boom")
if _, err := Document(context.Background(), doc, &fakeMT{err: boom}, nil, Options{Workers: 4}); !errors.Is(err, boom) {
t.Errorf("err = %v, want boom", err)
}
ctx, cancel := context.WithCancel(context.Background())
cancel()
if _, err := Document(ctx, doc, &fakeMT{dict: dict}, nil, Options{}); !errors.Is(err, context.Canceled) {
t.Errorf("err = %v, want context.Canceled", err)
}
}
func TestSentences(t *testing.T) {
tests := map[string][]string{
"Il pleut. Elle entre.": {"Il pleut. ", "Elle entre."},
"Quoi ? Non !": {"Quoi ? ", "Non !"},
"M. Dupont entre. J. Doe aussi.": {"M. Dupont entre. ", "J. Doe aussi."},
"Attends... il revient.": {"Attends... il revient."},
"« Non. » Il sort.": {"« Non. » ", "Il sort."},
}
for in, want := range tests {
if got := sentences(in); !reflect.DeepEqual(got, want) {
t.Errorf("sentences(%q) = %q, want %q", in, got, want)
}
}
}
func TestDetection(t *testing.T) {
tests := []struct {
text string
french bool
}{
{"Il pleut sur la ville.", true},
{"It's raining on the city.", false},
{"Qu'est-ce que tu fais ?", true},
{"Allô ?", true},
{"OK.", false},
{"Don't move.", false},
{"Marie", false},
}
s := &session{lex: lexicon, nameWords: map[string]bool{"marie": true}}
for _, tt := range tests {
if got := s.french(tt.text); got != tt.french {
t.Errorf("french(%q) = %v, want %v", tt.text, got, tt.french)
}
}
}