16 Commits
Author SHA1 Message Date
KharecandClaude Opus 5.5 643adffb12 chore: release 1.4
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 16:41:35 +02:00
KharecandClaude Opus 5.5 b6b58c7aca chore: update source-map-js to fix a high severity advisory
GHSA-68fv-2mgg-jv7q: event-loop denial of service through indexed
source-map section offsets. Build-time dependency only.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 16:41:27 +02:00
Kharec 839e9291d6 feat: resumable download and cancellable load of the translation model
Downloading the 300 MB model restarted from zero after any failure and
hung forever on a stalled connection. Download into a .partial file
that survives interruptions, resume it with a Range request (falling
back to a fresh download when the server cannot), skip files already
verified, and abort when nothing arrives for 30 s. A model directory
only counts as installed when every file has its pinned size, so a
truncated model is offered for download again instead of failing to
load.

The model load held the translation mutex, so Cancel did nothing for
the seconds it takes to read 300 MB. Load without holding the lock,
make marian.Load context-aware, and let CancelTranslation wait for the
task to wind down so a new one can start at once. The idle release
timer re-arms when it fires while a download holds the task slot,
instead of never freeing the model.
2026-10-13 18:17:53 +02:00
Kharec 8181c6e885 docs: update readme 2026-10-13 18:15:14 +02:00
Kharec 082152fc31 chore: update changelog 2026-10-13 18:15:10 +02:00
Kharec b84284b60c docs: update agent 2026-10-13 18:15:05 +02:00
Kharec 7872403e11 docs: update readme 2026-10-13 16:52:13 +02:00
Kharec e9eb2ff36a docs: update agent 2026-10-01 10:17:54 +02:00
Kharec 60032ce490 chore: update chanelchore: update changelog 2026-10-01 10:17:50 +02:00
Kharec 5e1dc1cace docs: update readme 2026-10-01 10:16:05 +02:00
Kharec ed6704b97e feat: implement translation 2026-10-01 10:16:01 +02:00
Kharec 803dc3328a feat: main translation script 2026-10-01 10:15:53 +02:00
Kharec 9987d7e1ab feat: update frontend to integrate translation 2026-10-01 10:15:47 +02:00
Kharec 49df7f36a1 feat: refresh screenshots 2026-10-01 10:15:34 +02:00
Kharec cb356ff8c5 feat: add a translate stack 2026-10-01 10:15:28 +02:00
Kharec 5cf16588c1 chore: update changelog 2026-09-29 14:26:51 +02:00
37 changed files with 3875 additions and 9 deletions

No files matched your search

+47 -1
View File
@@ -46,6 +46,9 @@ behaviour or shortcuts changed.
```
main.go Wails bootstrap: frameless translucent window, LANGUAGE=en, CLI file argument
app.go App struct bound to JS: file I/O, dialogs, pagination, autosave recovery
translation.go App methods for translation: model status/download (pinned files + SHA-256,
resumable), Translate, CancelTranslation, model loaded lazily and freed
after 5 min idle
desktop_linux.go cgo: desktop light/dark preference for GTK dialogs, window button layout, compositing
desktop/ app icon (plotzilla.svg, source of truth; plotzilla.png = 256 px render embedded as
the X11 window icon), plotzilla.desktop, MIME types for .fountain/.fdx
@@ -58,6 +61,10 @@ internal/layout line wrapping + pagination (paper sizes, locked pages);
for page geometry
internal/pdf hand-written PDF 1.4 writer, standard Courier fonts, zero dependencies
internal/spell Hunspell .aff/.dic reader, checker (compounds too), suggestions, word scan
internal/marian MarianMT (OPUS-MT) inference in pure Go: safetensors, SentencePiece unigram,
transformer encoder-decoder, beam search, verified download
internal/translate French → English script translation: language detection, screenplay term
tables, per-element rules, name protection, parallel sentence translation
frontend/index.html app shell: header bar, navigator, paper, HUD, <dialog>s
frontend/src/main.ts app controller: state, save/autosave, pagination loop, navigator, menus, shortcuts, window frame
frontend/src/schema.ts ProseMirror schema + conversion PM doc ⇄ ScriptDocument JSON
@@ -68,12 +75,13 @@ frontend/src/decorations.ts live page-break widgets + empty-element placeholder
frontend/src/search.ts find & replace: query, match highlights, next/previous, replace (all)
frontend/src/spellcheck.ts misspelling underlines (from Go), word under the cursor left alone
frontend/src/menu.ts popup menus with keyboard navigation (spelling corrections, languages)
frontend/src/translate.ts elements covered by the selection, the transaction applying a translation
frontend/src/backend.ts typed wrappers for window.go.main.App and the Wails runtime
frontend/src/icons.ts inline Lucide-style SVG icons
frontend/src/style.css the whole UI theme (dark, blue) and the on-screen page geometry
screenshots/ README images (2880×1800 UI captures + pdf-output.png)
tools/screenshots/ `make screenshots`: sample script, mock server (main.go), Firefox capture (capture.mjs)
VERSION current version (1.0)
VERSION current version
CHANGELOG.md Keep a Changelog; sections Added, Changed, Fixed only
```
@@ -201,6 +209,19 @@ change `style.css` too** (and vice versa).
itself is unknown (`Dictionary.accepts`): parts need COMPOUNDFLAG or the BEGIN/MIDDLE/LAST
flag of their position; the first part may take a prefix, the last a suffix, any other affix
needs COMPOUNDPERMITFLAG in its continuation classes. Suggestions do not build compounds.
- Translation (`Ctrl+Shift+L`) runs in Go, offline: `internal/marian` runs the OPUS-MT fr-en
model (Hugging Face form: `config.json`, `model.safetensors`, `source.spm`, `target.spm`,
`vocab.json`), downloaded on demand into `~/.local/share/plotzilla/models/opus-mt-fr-en`
(also looked up in `$XDG_DATA_DIRS/plotzilla/models/`). The download resumes the `.partial`
files an interruption left, skips files already downloaded, and aborts when nothing arrives
for 30 s; a directory only counts as installed when every file has its pinned size.
`translate.Document` returns the
changed elements (flat indexes) and the title page; the frontend replaces them in one
transaction (`applyTranslation`), only if the doc is unchanged. Both tasks report progress
with the `translation-progress` event (done, total; `0, 0` while loading) and stop on
`CancelTranslation`, returning `nil`/`false` (not an error) when cancelled. The model loads
without holding the translation lock (and `marian.Load` takes the context) so Cancel stays
responsive during the seconds-long load; `CancelTranslation` waits for the task to wind down.
- Find & replace is frontend-only (`search.ts`): matches are computed on `blockText`, where one
character is one document position. Replacements go through `replaceText()` (commands.ts),
which keeps the marks, and each one is its own undo step (`closeHistory`).
@@ -231,10 +252,32 @@ change `style.css` too** (and vice versa).
on other goroutines. `query_window_frame` shows how to get an answer back synchronously
(refcounted request + `GCond` with a timeout); never call it from the main thread.
### Translation rules (`internal/translate`)
- Text is translated only when it reads as French (`scores`: function words, elisions,
accents, then the Hunspell lexicon when both fr and en dictionaries exist). Ties follow the
other sentences of the text, then the nearest decided element (`languages`). Translating
twice must change nothing (`TestIdempotent`).
- Two passes: the rules run once collecting the sentences to translate (`mt` records them),
the model translates the unique sentences in parallel, then the rules run again with the
results. Keep the rules deterministic and free of side effects.
- Fixed terms (times of day, extensions, transitions, shots, wrylies, credits) are tables in
`rules.go`, keyed by `fold` (capitals, no accents). The English shots must start with a
prefix the Fountain parser knows (`shotRe` in `fountain/parse.go`).
- Character names (cues without an article) are swapped for invented `placeholders` before the
model sees them and put back after: OPUS-MT translates names (`Marie` → `Mary`). A new
placeholder must be checked against the model (some get altered: `Nuvrak` → `Nukrak`).
- Capitals are translated in lower case (`caps` mode) and slugline locations with a French
article added and the English one removed (`location` mode): the model mistranslates both
otherwise (`CUISINE DE MARIE` → `MARIE CUISINE`).
## 5. Testing
- Go: unit tests live next to each package (`make test`). Add a test for every parser,
writer or pagination change; round-trip tests (`Parse(Write(doc)) == doc`) protect formats.
- Translation: `internal/translate` tests use a fake translator and lexicon. The real model runs
in `TestRealModel` when `PLOTZILLA_MT_MODEL` names its directory; `TestTinyModel` covers the
whole Marian pipeline on a random 8-dimension model written by the test.
- PDF visual check: `PLOTZILLA_PDF=/tmp/x.pdf go test ./internal/pdf`, then
`pdftoppm -png -r 60 /tmp/x.pdf /tmp/page` and look at the image, or
`pdftotext -layout /tmp/x.pdf -` to check columns.
@@ -362,6 +405,9 @@ change `style.css` too** (and vice versa).
reading. Revised text is `<Text RevisionID="n">`, the sets a `<Revisions>` block named
`Blue Revision - date`. The exact Final Draft schema for these (ScriptNote placement,
Revisions attributes) was written from memory, not checked against Final Draft itself.
- The translation transaction carries the `ownLocks` meta (revisions.ts): replacing a whole
element would make `lockPlugin` move its locked pages to the element's start, so Go remaps
them (`remapLocks`, proportionally) and the transaction adds the revision mark itself.
- COMPOUNDRULE parts are bare dictionary words (no affixes), as in Hunspell; the first
COMPOUNDRULE line is the rule count.
- A literal BOM or other invisible character in Go source breaks `go vet`; write `""`.
+26 -2
View File
@@ -5,13 +5,37 @@ All notable changes to Plotzilla are documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). The current
version is in the `VERSION` file.
## [1.2]
## [Unreleased]
## [1.4] - 2026-10-15
### Fixed
- The Cancel button responds while the translation model is loading.
- The translation model download resumes where it stopped after an interruption, skips files
already downloaded, and reports a stalled connection instead of hanging. A model directory
with missing or truncated files is offered for download again instead of failing to load.
## [1.3] - 2026-10-01
### Added
- Translation of a script, or of the selection, from French to English (`Ctrl+Shift+L`),
offline: the OPUS-MT model runs in Go, downloaded once on demand. Sluglines, character
extensions, parentheticals, transitions and shots follow English script conventions;
character names and text already in English are left as they are.
## [1.2] - 2026-09-29
### Added
- Arch Linux PKGBUILD (`packaging/arch`).
## [1.1]
### Fixed
- Align VERSION on tag
## [1.1] - 2026-09-29
### Added
+68 -1
View File
@@ -23,6 +23,7 @@ follows the desktop's window button layout and light/dark preference.
- [Autocomplete](#autocomplete)
- [Find and replace](#find-and-replace)
- [Spelling](#spelling)
- [Translating from French](#translating-from-french)
- [Pages and scenes](#pages-and-scenes)
- [Revisions and locked pages](#revisions-and-locked-pages)
- [Title page](#title-page)
@@ -62,6 +63,10 @@ the PDF exactly. Without it, Plotzilla uses Courier New, Nimbus Mono or Liberati
Spell checking uses the system's Hunspell dictionaries: install one per language you write in,
for example `hunspell-en_us`, `hunspell-en_gb` or `hunspell-fr`.
Translation from French needs a model of about 300 MB, which Plotzilla downloads the first time
it is used (see [Translating from French](#translating-from-french)). A package can install it
for every user instead, in `/usr/share/plotzilla/models/opus-mt-fr-en`.
## The editor
The script is shown as a sheet of paper, at the real size and with the real margins of the
@@ -265,6 +270,58 @@ missing from the dictionary (`Haustürschlüssel`) is accepted when the dictiona
parts to be joined, including the compound patterns some dictionaries describe with Hunspell's
`COMPOUNDRULE`.
## Translating from French
The translate button in the header bar (`Ctrl+Shift+L`) translates a French script into
English. It runs on your computer, without an internet connection, so a script never leaves it.
- With text selected, the elements the selection touches are translated; otherwise the whole
script and its title page are (the title itself is kept).
- Text that is already in English is left as it is, so you can translate a script a scene at
a time, revise the English, and translate the rest later. A translation is undone in one step
with `Ctrl+Z`.
- A progress window shows the sentences translated so far; **Cancel** stops without changing
the script.
Each element is translated the way an English script writes it:
| Element | French | English |
|---|---|---|
| Scene heading | `INT. CUISINE DE MARIE - NUIT` | `INT. MARIE'S KITCHEN - NIGHT` |
| | `EXTÉRIEUR RUE - AUBE (FLASH-BACK)` | `EXT. STREET - DAWN (FLASHBACK)` |
| Character | `PIERRE (VOIX OFF)`, `(H.C.)`, `(SUITE)` | `PIERRE (V.O.)`, `(O.S.)`, `(CONT'D)` |
| | `LE POLICIER` | `THE POLICEMAN` |
| Parenthetical | `(un temps)`, `(à voix basse)`, `(à Marie)` | `(beat)`, `(quietly)`, `(to Marie)` |
| Transition | `FONDU ENCHAÎNÉ :`, `COUPE SUR :`, `FONDU AU NOIR.` | `DISSOLVE TO:`, `CUT TO:`, `FADE TO BLACK.` |
| Insert / shot | `GROS PLAN SUR LA LETTRE`, `CONTRECHAMP` | `CLOSE ON THE LETTER`, `REVERSE ANGLE` |
| Action, dialogue | `Marie ? Tu es là ?` | `Marie? Are you there?` |
- Times of day, character extensions, transitions, shots and the usual wrylies come from a
table of screenplay terms. Locations, action, dialogue, notes, sections and synopses are
translated by the model, one sentence at a time.
- Character names are kept as they are: `Marie` does not become `Mary`, nor `Pierre` `stone`.
A cue is translated only when it is a description starting with an article (`LE POLICIER`).
- A French transition or shot typed as action, as in a Fountain file (`FONDU AU NOIR.`,
`GROS PLAN SUR…`), becomes a transition or an insert.
- English typography replaces French typography: no space before `?`, `!`, `:` or `;`, and
straight quotes instead of guillemets.
- A sentence is French when its words say so: French words such as `le`, `qui` or `avec`,
elisions (`l'homme`), accents, and, when the French and English Hunspell dictionaries are
installed, which of the two languages knows its other words. A line too short to tell
(`Attends !`, `Oui ?`) follows the elements around it.
- Notes are translated on their own. Bold, italic or underline that covers only part of a
translated sentence is lost, since the words move; emphasis on a whole sentence is kept.
Locked pages stay in the same proportion of the translated element, and while revisions are
marked, translated text is marked with the current revision set.
The model is [OPUS-MT French–English](https://huggingface.co/Helsinki-NLP/opus-mt-fr-en) by the
University of Helsinki (Apache 2.0 license). Plotzilla runs it itself, in Go, with nothing else
to install. The first translation asks to download it (about 300 MB) from Hugging Face into
`~/.local/share/plotzilla/models/opus-mt-fr-en`; every file is checked against its known
checksum, and an interrupted download resumes where it stopped. The model takes about 300 MB of
memory while it is loaded, and is unloaded after five minutes without translating. A machine
translation is a first draft: read it over, especially the dialogue.
## Pages and scenes
Page breaks are computed by the same engine as the PDF export and drawn live in the editor, with
@@ -448,6 +505,7 @@ that read FDX.
| `Enter` / `Shift+Enter` in the find field | Next, previous match |
| `Enter` / `Ctrl+Alt+Enter` in the replace field | Replace, replace all |
| `F7` | Spell checking on or off |
| `Ctrl+Shift+L` | Translate the selection, or the whole script, from French to English |
## Desktop integration
@@ -467,6 +525,8 @@ that read FDX.
```sh
make test # Go tests
PLOTZILLA_MT_MODEL=~/.local/share/plotzilla/models/opus-mt-fr-en go test ./internal/marian
# also test the real translation model, once downloaded
make screenshots # regenerate the images of this README (needs Firefox and pdftoppm)
make dev # live reload, requires the Wails CLI:
# go install github.com/wailsapp/wails/v2/cmd/wails@latest
@@ -477,6 +537,7 @@ binary embeds `frontend/dist`, so the frontend has to be built first.
```
main.go, app.go Wails application and the methods the frontend calls
translation.go translation: model download, loading and the Translate method
desktop_linux.go desktop integration: light/dark preference, window button layout
desktop/ icon, desktop entry and MIME types installed by `make install`
packaging/arch PKGBUILD for an Arch Linux package
@@ -486,8 +547,11 @@ internal/fdx Final Draft reader and writer
internal/layout line wrapping and pagination, shared by the editor and the PDF
internal/pdf PDF generator with no dependencies
internal/spell Hunspell dictionary reader and spell checker
internal/marian MarianMT translation models in pure Go: SentencePiece, transformer,
beam search, verified download
internal/translate French to English script translation, element by element
frontend/src editor: schema, commands, autocomplete, page breaks, find, spelling,
revisions and locked pages, interface
revisions and locked pages, translation, interface
screenshots/ images used in this README
tools/screenshots the tool behind `make screenshots`
```
@@ -504,3 +568,6 @@ scripts in the PDF.
Plotzilla is free software, released under the GNU General Public License v3.0. See
[LICENSE](LICENSE).
The model downloaded for translation is separate: [OPUS-MT French–English](https://huggingface.co/Helsinki-NLP/opus-mt-fr-en),
by the University of Helsinki, is released under the Apache 2.0 license.
+1 -1
View File
@@ -1 +1 @@
1.2
1.4
+2
View File
@@ -33,6 +33,8 @@ type App struct {
spellMu sync.Mutex
checker *spell.Checker
checkerLang string
tr translation
}
type OpenResult struct {
+12
View File
@@ -38,6 +38,7 @@
<button data-action="find" data-icon="search" data-tip="Find and replace · Ctrl+F"></button>
<button data-action="spelling" data-icon="spell" data-tip="Spelling · F7"></button>
<button data-action="revisions" data-icon="revisions" data-tip="Revisions and locked pages"></button>
<button data-action="translate" data-icon="translate" data-tip="Translate French to English · Ctrl+Shift+L"></button>
</div>
<div class="group">
<button data-action="title" data-icon="title" data-tip="Title page · Ctrl+Shift+T"></button>
@@ -163,6 +164,17 @@
</form>
</dialog>
<dialog id="progress-dialog" class="message">
<form method="dialog">
<h2 id="progress-title"></h2>
<p id="progress-text"></p>
<div class="progress" id="progress"><div class="progress-bar" id="progress-bar"></div></div>
<menu>
<button value="cancel" id="progress-cancel">Cancel</button>
</menu>
</form>
</dialog>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
+3 -3
View File
@@ -1155,9 +1155,9 @@
"license": "MIT"
},
"node_modules/source-map-js": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
"integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.2.tgz",
"integrity": "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==",
"dev": true,
"license": "BSD-3-Clause",
"engines": {
+24
View File
@@ -120,6 +120,24 @@ export interface WindowFrame {
rounded: boolean
}
/** Whether the translation model is installed, and its download size in bytes. */
export interface TranslationStatus {
installed: boolean
size: number
}
/** A translated element, to put in place of element `index` (flat order). */
export interface TranslatedElement {
index: number
element: Element
}
export interface TranslationResult {
changes: TranslatedElement[] | null
/** Set when the title page changed. */
titlePage?: TitlePage
}
export interface ExportOptions {
sceneNumbers: boolean
titlePage: boolean
@@ -146,6 +164,12 @@ interface GoApp {
SpellCheck(doc: ScriptDocument, lang: string): Promise<Misspelling[] | null>
SpellSuggest(word: string, lang: string): Promise<string[] | null>
AddToDictionary(word: string): Promise<void>
TranslationStatus(): Promise<TranslationStatus>
/** False if cancelled. Progress: "translation-progress" (bytes done, total). */
DownloadTranslationModel(): Promise<boolean>
/** Null if cancelled. Progress: "translation-progress" (sentences done, total; 0, 0 while loading). */
Translate(doc: ScriptDocument, elements: number[] | null, titlePage: boolean): Promise<TranslationResult | null>
CancelTranslation(): Promise<void>
}
interface WailsRuntime {
+1
View File
@@ -22,6 +22,7 @@ const paths: Record<string, string> = {
note: '<path d="M15.5 3H5a2 2 0 0 0-2 2v14c0 1.1.9 2 2 2h14a2 2 0 0 0 2-2V8.5L15.5 3Z"/><path d="M15 3v6h6"/><path d="M7 13h6"/><path d="M7 17h4"/>',
revisions: '<path d="M12 20h9"/><path d="M16.38 3.62a1 1 0 0 1 3 3L7.37 18.64a2 2 0 0 1-.86.5l-2.87.84a.5.5 0 0 1-.62-.62l.84-2.87a2 2 0 0 1 .5-.86z"/><path d="m15 5 3 3"/>',
spell: '<path d="m6 16 6-12 6 12"/><path d="M8 12h8"/><path d="m16 20 2 2 4-4"/>',
translate: '<path d="m5 8 6 6"/><path d="m4 14 6-6 2-3"/><path d="M2 5h12"/><path d="M7 2h1"/><path d="m22 22-5-10-5 10"/><path d="M14 18h6"/>',
check: '<path d="M20 6 9 17l-5-5"/>',
chevron: '<path d="m6 9 6 6 6-6"/>',
close: '<path d="M17 7 7 17"/><path d="m7 7 10 10"/>',
+107
View File
@@ -36,6 +36,7 @@ import { buildSpellDecorations, misspellingAt, spellKey, spellPlugin, withoutWor
import { closeMenu, menuOpen, openMenu, type MenuItem } from './menu'
import { activeMatch, replaceAll, replaceMatch, searchMatches, searchPlugin, selectMatch, setSearch } from './search'
import { clearRevisionMarks, lockPages, lockPlugin, revisionPlugin, unlockPages, type CurrentRevision } from './revisions'
import { applyTranslation, selectedElements } from './translate'
import { icon } from './icons'
const $ = <T extends HTMLElement>(sel: string) => document.querySelector(sel) as T
@@ -736,6 +737,110 @@ replaceInput.addEventListener('keydown', (e) => {
e.preventDefault()
})
// ----------------------------------------------------------- translation
interface ProgressOptions {
title: string
/** The line under the bar, from Go's progress (a total of 0: not started yet). */
describe: (done: number, total: number) => string
}
/**
* Runs a Go task that reports "translation-progress" behind a progress
* dialog; closing the dialog (Cancel, Esc) cancels the task.
*/
async function withProgress<T>(opts: ProgressOptions, task: () => Promise<T>): Promise<T> {
const dialog = $<HTMLDialogElement>('#progress-dialog')
const track = $('#progress')
const bar = $('#progress-bar')
const show = (done: number, total: number) => {
track.dataset.state = total ? '' : 'busy'
bar.style.transform = total ? `scaleX(${Math.min(1, done / total)})` : ''
$('#progress-text').textContent = opts.describe(done, total)
}
$('#progress-title').textContent = opts.title
show(0, 0)
const off = runtime.EventsOn('translation-progress', (done, total) => show(Number(done), Number(total)))
let running = true
dialog.onclose = () => {
if (running) api.CancelTranslation().catch(console.error)
}
typeMenu.hidden = true
dialog.showModal()
try {
return await task()
} finally {
running = false
off()
if (dialog.open) dialog.close()
view.focus()
}
}
const megabytes = (bytes: number) => Math.round(bytes / 1e6).toLocaleString('en-US')
/**
* Translates the French text of the selection, or of the whole script and
* its title page, into English, offline. Text already in English is left
* as it is, so a script can be translated a part at a time.
*/
async function translateScript() {
const status = await api.TranslationStatus()
if (!status.installed) {
const download = await messageBox({
title: 'Download the translation model?',
text: `Plotzilla translates French into English on this computer, without an internet connection. It needs the OPUS-MT French–English model (${megabytes(status.size)} MB), downloaded once from Hugging Face.`,
ok: 'Download',
cancel: 'Cancel',
})
if (!download) return
const installed = await withProgress(
{
title: 'Downloading the translation model',
describe: (done, total) => (total ? `${megabytes(done)} of ${megabytes(total)} MB` : 'Connecting…'),
},
() => api.DownloadTranslationModel(),
)
if (!installed) return
}
const doc = view.state.doc
const elements = selectedElements(view.state)
const whole = elements === null
const result = await withProgress(
{
title: whole ? 'Translating the script' : 'Translating the selection',
describe: (done, total) =>
total ? `${done.toLocaleString('en-US')} of ${total.toLocaleString('en-US')} sentences` : 'Loading the translation model…',
},
() => api.Translate(toScript(doc, app.titlePage, app.settings), elements, whole),
)
if (!result) return // cancelled
if (view.state.doc !== doc) {
await messageBox({
title: 'Translation not applied',
text: 'The script changed while it was being translated. Translate it again to include the changes.',
ok: 'OK',
})
return
}
const changes = result.changes ?? []
if (changes.length) {
view.dispatch(applyTranslation(view.state, changes, app.settings.revisions ?? [], currentRevision()))
}
if (result.titlePage) {
app.titlePage = { ...result.titlePage }
markChanged()
}
if (!changes.length && !result.titlePage) {
await messageBox({
title: 'Nothing to translate',
text: `No French text was found in the ${whole ? 'script' : 'selection'}. Text already in English is left as it is.`,
ok: 'OK',
})
}
}
// ----------------------------------------------------------------- files
interface MessageOptions {
@@ -877,6 +982,7 @@ const actions: Record<string, () => unknown> = {
underline: () => toggleMark(schema.marks.underline)(view.state, view.dispatch),
note: () => toggleMark(schema.marks.note)(view.state, view.dispatch),
revisions: revisionsMenu,
translate: translateScript,
dual: () => toggleDual(view.state, view.dispatch),
navigator() {
document.body.classList.toggle('no-navigator')
@@ -931,6 +1037,7 @@ const shortcuts: Record<string, string> = {
f3: 'findNext',
'shift+f3': 'findPrevious',
f7: 'toggleSpelling',
'ctrl+shift+l': 'translate',
'ctrl+=': 'zoomIn',
'ctrl++': 'zoomIn',
'ctrl+-': 'zoomOut',
+9
View File
@@ -131,6 +131,15 @@ export function unlockPages(state: EditorState): Transaction {
return setLocks(state.tr, new Map()).setMeta(INTERNAL, true).setMeta('addToHistory', false)
}
/**
* Lets a transaction set the locked pages itself (in the elements' `locked`
* attribute) instead of the plugin mapping them. Revision marks are then
* the transaction's job too.
*/
export function ownLocks(tr: Transaction): Transaction {
return tr.setMeta(INTERNAL, true)
}
/** Removes every revision mark; one undo step. */
export function clearRevisionMarks(state: EditorState): Transaction {
return state.tr.removeMark(0, state.doc.content.size, schema.marks.revision).setMeta(INTERNAL, true)
+34
View File
@@ -1539,6 +1539,40 @@ dialog.message p {
user-select: text;
}
/* Progress of a download or a translation. The bar is scaled, not resized,
so only transform is animated; without a total it sweeps back and forth. */
.progress {
height: 6px;
margin: -4px 0 18px;
border-radius: 3px;
background: var(--accent-soft);
overflow: hidden;
}
.progress-bar {
height: 100%;
border-radius: 3px;
background: var(--accent);
transform: scaleX(0);
transform-origin: left;
transition: transform 0.3s var(--ease);
}
.progress[data-state='busy'] .progress-bar {
width: 30%;
transform: none;
animation: sweep 1.2s var(--ease) infinite alternate;
}
@keyframes sweep {
from {
translate: 0;
}
to {
translate: 233%;
}
}
dialog menu button:focus-visible {
outline: 2px solid var(--accent-2);
outline-offset: 2px;
+44
View File
@@ -0,0 +1,44 @@
// Applying a French → English translation computed in Go: which elements
// the selection covers, and the transaction that puts the translated
// elements in place.
import type { EditorState, Transaction } from 'prosemirror-state'
import type { Revision, TranslatedElement } from './backend'
import { flatElements, fromElement, schema } from './schema'
import { ownLocks, type CurrentRevision } from './revisions'
/** Flat indexes of the elements the selection touches; null for the whole script. */
export function selectedElements(state: EditorState): number[] | null {
const { from, to, empty } = state.selection
if (empty) return null
const out: number[] = []
flatElements(state.doc).forEach(({ node, pos }, i) => {
if (pos + node.nodeSize > from && pos < to) out.push(i)
})
return out
}
/**
* Replaces each translated element, in one undoable step. Go has already
* placed the element's locked pages in the translated text, so the lock
* plugin is told to leave them; translated text is marked with the current
* revision set when revisions are being marked.
*/
export function applyTranslation(
state: EditorState,
changes: TranslatedElement[],
revisions: Revision[],
current: CurrentRevision | null,
): Transaction {
const flat = flatElements(state.doc)
const tr = state.tr
const mark = current && schema.marks.revision.create(current)
// From the end, so the positions of the elements before stay valid.
for (const change of [...changes].sort((a, b) => b.index - a.index)) {
const at = flat[change.index]
if (!at) continue
const node = fromElement(change.element, revisions)
tr.replaceWith(at.pos, at.pos + at.node.nodeSize, node)
if (mark && node.content.size) tr.addMark(at.pos + 1, at.pos + 1 + node.content.size, mark)
}
return ownLocks(tr)
}
+234
View File
@@ -0,0 +1,234 @@
package marian
import (
"context"
"math"
"runtime"
"slices"
"sync"
)
// beam is a partial translation being extended by beam search.
type beam struct {
tokens []int // starts with the decoder start token
score float64
// Keys and values of the decoder's self-attention, one slice per layer
// holding len(tokens) rows: each step only computes its own row.
keys, values [][]float32
}
type hypothesis struct {
tokens []int
score float64 // length-normalised
}
type candidate struct {
parent, token int
score float64
}
// beamSearch generates the translation, as Hugging Face's generate() does
// with the model's defaults: num_beams beams, length penalty 1, the
// padding token never produced.
func (m *Model) beamSearch(ctx context.Context, enc []float32, srcLen int) ([]int, error) {
d := m.cfg.DModel
numBeams := max(m.cfg.NumBeams, 1)
crossK := make([][]float32, len(m.decoder))
crossV := make([][]float32, len(m.decoder))
for i, l := range m.decoder {
crossK[i] = make([]float32, srcLen*d)
crossV[i] = make([]float32, srcLen*d)
l.cross.k.rows(crossK[i], enc, srcLen)
l.cross.v.rows(crossV[i], enc, srcLen)
}
beams := []beam{{
tokens: []int{m.cfg.DecoderStart},
keys: make([][]float32, len(m.decoder)),
values: make([][]float32, len(m.decoder)),
}}
maxLen := min(m.cfg.MaxPositions-1, 2*srcLen+10)
var done []hypothesis
for len(beams[0].tokens) <= maxLen {
if err := ctx.Err(); err != nil {
return nil, err
}
hidden := make([][]float32, len(beams))
for i := range beams {
hidden[i] = m.decodeStep(&beams[i], crossK, crossV, srcLen)
}
logProbs := m.logProbs(hidden)
var cands []candidate
for i, lp := range logProbs {
for _, t := range topK(lp, 2*numBeams) {
cands = append(cands, candidate{parent: i, token: t, score: beams[i].score + float64(lp[t])})
}
}
slices.SortStableFunc(cands, func(a, b candidate) int {
switch {
case a.score > b.score:
return -1
case a.score < b.score:
return 1
}
return 0
})
var next []beam
for rank, c := range cands {
parent := beams[c.parent]
if c.token == m.cfg.EOSToken {
if rank < numBeams {
done = append(done, hypothesis{tokens: slices.Clone(parent.tokens), score: c.score / float64(len(parent.tokens))})
}
continue
}
child := beam{
tokens: append(slices.Clone(parent.tokens), c.token),
score: c.score,
keys: make([][]float32, len(parent.keys)),
values: make([][]float32, len(parent.values)),
}
for l := range parent.keys {
child.keys[l] = slices.Clone(parent.keys[l])
child.values[l] = slices.Clone(parent.values[l])
}
next = append(next, child)
if len(next) == numBeams {
break
}
}
slices.SortStableFunc(done, func(a, b hypothesis) int {
switch {
case a.score > b.score:
return -1
case a.score < b.score:
return 1
}
return 0
})
if len(done) > numBeams {
done = done[:numBeams]
}
if len(next) == 0 {
break
}
// Finished: no running beam can still beat the worst kept
// hypothesis (scores only decrease as tokens are added).
if len(done) == numBeams && next[0].score/float64(len(next[0].tokens)) <= done[numBeams-1].score {
break
}
beams = next
}
if len(done) == 0 {
return beams[0].tokens[1:], nil
}
return done[0].tokens[1:], nil
}
// decodeStep runs the decoder for the beam's last token and returns its
// output vector.
func (m *Model) decodeStep(b *beam, crossK, crossV [][]float32, srcLen int) []float32 {
d := m.cfg.DModel
pos := len(b.tokens) - 1
tok := b.tokens[pos]
x := make([]float32, d)
emb := m.embed[tok*d : (tok+1)*d]
p := m.decPos[pos*d : (pos+1)*d]
for j := range x {
x[j] = emb[j]*m.embedScale + p[j]
}
q := make([]float32, d)
k := make([]float32, d)
v := make([]float32, d)
ctxv := make([]float32, d)
tmp := make([]float32, d)
scores := make([]float32, max(pos+1, srcLen))
for i, l := range m.decoder {
l.self.q.apply(q, x)
l.self.k.apply(k, x)
l.self.v.apply(v, x)
b.keys[i] = append(b.keys[i], k...)
b.values[i] = append(b.values[i], v...)
attend(ctxv, q, b.keys[i], b.values[i], pos+1, l.self.heads, scores)
l.self.o.apply(tmp, ctxv)
addNorm(x, tmp, l.selfNorm, 1)
l.cross.q.apply(q, x)
attend(ctxv, q, crossK[i], crossV[i], srcLen, l.cross.heads, scores)
l.cross.o.apply(tmp, ctxv)
addNorm(x, tmp, l.crossNorm, 1)
hidden := make([]float32, l.fc1.out)
l.fc1.apply(hidden, x)
for j := range hidden {
hidden[j] = m.activation(hidden[j])
}
l.fc2.apply(tmp, hidden)
addNorm(x, tmp, l.finalLayerNorm, 1)
}
return x
}
// logProbs projects each beam's vector on the vocabulary (the embedding
// matrix, shared with the output layer) and returns log-softmax scores.
// This is the largest product of a step, so it is split across CPUs.
func (m *Model) logProbs(hidden [][]float32) [][]float32 {
d, vocab := m.cfg.DModel, m.cfg.VocabSize
out := make([][]float32, len(hidden))
for i := range out {
out[i] = make([]float32, vocab)
}
workers := runtime.GOMAXPROCS(0)
chunk := (vocab + workers - 1) / workers
var wg sync.WaitGroup
for lo := 0; lo < vocab; lo += chunk {
hi := min(lo+chunk, vocab)
wg.Add(1)
go func() {
defer wg.Done()
for t := lo; t < hi; t++ {
row := m.embed[t*d : (t+1)*d]
for i, h := range hidden {
out[i][t] = dot(h, row) + m.logitsBias[t]
}
}
}()
}
wg.Wait()
for _, logits := range out {
if m.cfg.PadToken >= 0 && m.cfg.PadToken < vocab {
logits[m.cfg.PadToken] = float32(math.Inf(-1))
}
maxLogit := float32(math.Inf(-1))
for _, l := range logits {
maxLogit = max(maxLogit, l)
}
var sum float64
for _, l := range logits {
sum += math.Exp(float64(l - maxLogit))
}
norm := maxLogit + float32(math.Log(sum))
for t := range logits {
logits[t] -= norm
}
}
return out
}
// topK returns the indexes of the k largest values, largest first.
func topK(values []float32, k int) []int {
idx := make([]int, 0, k+1)
for i, v := range values {
if len(idx) == k && v <= values[idx[k-1]] {
continue
}
at := len(idx)
for at > 0 && values[idx[at-1]] < v {
at--
}
idx = slices.Insert(idx, at, i)
if len(idx) > k {
idx = idx[:k]
}
}
return idx
}
+200
View File
@@ -0,0 +1,200 @@
package marian
import (
"context"
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
)
// RemoteFile is a model file to download, checked against its size and
// SHA-256 digest.
type RemoteFile struct {
Name string
URL string
Size int64
SHA256 string
}
// Installed reports whether dir holds every file of a model.
func Installed(dir string) bool {
for _, f := range Files {
if st, err := os.Stat(filepath.Join(dir, f)); err != nil || st.Size() == 0 {
return false
}
}
return true
}
// Download fetches files into dir. A file already there with the right
// size and digest is skipped. A file is downloaded into a ".partial" file
// that is renamed only once its digest checks out, so an interrupted
// download never leaves a half-written model behind; the partial file is
// kept and the next Download resumes it where it stopped. progress
// receives the bytes downloaded so far and the total.
func Download(ctx context.Context, client *http.Client, dir string, files []RemoteFile, progress func(done, total int64)) error {
if err := os.MkdirAll(dir, 0o755); err != nil {
return err
}
var total, done int64
for _, f := range files {
total += f.Size
}
report := func(n int64) {
if progress != nil {
progress(done+n, total)
}
}
for _, f := range files {
report(0)
n, err := download(ctx, client, dir, f, report)
if err != nil {
return fmt.Errorf("downloading %s: %w", f.Name, err)
}
done += n
report(0)
}
return nil
}
// download fetches one file, resuming the partial download left by an
// interrupted run when the server supports it.
func download(ctx context.Context, client *http.Client, dir string, f RemoteFile, progress func(int64)) (int64, error) {
final := filepath.Join(dir, f.Name)
if ok, err := verify(final, f); err != nil {
return 0, err
} else if ok {
return f.Size, nil // downloaded by a previous run
}
partial := filepath.Join(dir, "."+f.Name+".partial")
var off int64
if st, err := os.Stat(partial); err == nil && st.Size() > 0 && st.Size() <= f.Size {
off = st.Size()
}
if off == f.Size {
// A previous run was interrupted between the digest check and
// the rename.
if ok, _ := verify(partial, f); !ok {
off = 0 // corrupt: start over
} else if err := os.Rename(partial, final); err != nil {
return 0, err
} else {
return f.Size, nil
}
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, f.URL, nil)
if err != nil {
return 0, err
}
if off > 0 {
req.Header.Set("Range", fmt.Sprintf("bytes=%d-", off))
}
resp, err := client.Do(req)
if err != nil {
return 0, err
}
defer resp.Body.Close()
if off > 0 && resp.StatusCode == http.StatusOK {
off = 0 // the server cannot resume: start over
}
want := http.StatusOK
if off > 0 {
want = http.StatusPartialContent
}
if resp.StatusCode != want {
if resp.StatusCode == http.StatusRequestedRangeNotSatisfiable {
os.Remove(partial) // the partial file cannot be resumed
}
return 0, fmt.Errorf("server answered %s", resp.Status)
}
tmp, err := os.OpenFile(partial, os.O_CREATE|os.O_RDWR, 0o644)
if err != nil {
return 0, err
}
hash := sha256.New()
if off > 0 {
// The digest covers the whole file: seed it with the bytes
// already on disk.
if _, err := io.CopyN(hash, tmp, off); err != nil {
tmp.Close()
return 0, fmt.Errorf("reading the partial download: %w", err)
}
} else if err := tmp.Truncate(0); err != nil {
tmp.Close()
return 0, err
}
if _, err := tmp.Seek(off, io.SeekStart); err != nil {
tmp.Close()
return 0, err
}
w := &countingWriter{w: io.MultiWriter(tmp, hash), progress: func(n int64) { progress(off + n) }}
_, err = io.Copy(w, io.LimitReader(resp.Body, f.Size-off+1))
if cerr := tmp.Close(); err == nil {
err = cerr
}
if err != nil {
return 0, err // the partial file stays, for the next run
}
if got := off + w.n; got != f.Size {
return 0, fmt.Errorf("got %d bytes, expected %d", got, f.Size)
}
if sum := hex.EncodeToString(hash.Sum(nil)); sum != f.SHA256 {
os.Remove(partial) // corrupt: resuming it would fail again
return 0, fmt.Errorf("checksum mismatch")
}
if err := os.Chmod(partial, 0o644); err != nil {
return 0, err
}
return f.Size, os.Rename(partial, final)
}
// verify reports whether path holds the file, with its expected size and
// digest. A missing file is not an error.
func verify(path string, f RemoteFile) (bool, error) {
st, err := os.Stat(path)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return false, nil
}
return false, err
}
if st.Size() != f.Size {
return false, nil
}
sum, err := hashFile(path)
if err != nil {
return false, err
}
return sum == f.SHA256, nil
}
func hashFile(path string) (string, error) {
r, err := os.Open(path)
if err != nil {
return "", err
}
defer r.Close()
h := sha256.New()
if _, err := io.Copy(h, r); err != nil {
return "", err
}
return hex.EncodeToString(h.Sum(nil)), nil
}
type countingWriter struct {
w io.Writer
n int64
progress func(int64)
}
func (c *countingWriter) Write(p []byte) (int, error) {
n, err := c.w.Write(p)
c.n += int64(n)
c.progress(c.n)
return n, err
}
+417
View File
@@ -0,0 +1,417 @@
package marian
import (
"context"
"crypto/sha256"
"encoding/binary"
"encoding/hex"
"encoding/json"
"fmt"
"math"
"math/rand"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"reflect"
"strings"
"testing"
)
// ---------------------------------------------------------- test helpers
func protoKey(field, wire int) []byte { return binary.AppendUvarint(nil, uint64(field<<3|wire)) }
func protoBytes(field int, b []byte) []byte {
out := append(protoKey(field, 2), binary.AppendUvarint(nil, uint64(len(b)))...)
return append(out, b...)
}
func protoVarint(field int, v uint64) []byte {
return append(protoKey(field, 0), binary.AppendUvarint(nil, v)...)
}
func protoFloat(field int, f float32) []byte {
return binary.LittleEndian.AppendUint32(protoKey(field, 5), math.Float32bits(f))
}
type testPiece struct {
piece string
score float32
typ int
}
// spmModel serialises a SentencePiece ModelProto of the given type.
func spmModel(modelType int, pieces []testPiece) []byte {
var out []byte
for _, p := range pieces {
var msg []byte
msg = append(msg, protoBytes(1, []byte(p.piece))...)
msg = append(msg, protoFloat(2, p.score)...)
msg = append(msg, protoVarint(3, uint64(p.typ))...)
out = append(out, protoBytes(1, msg)...)
}
out = append(out, protoBytes(2, protoVarint(3, uint64(modelType)))...)
return out
}
var pieces = []testPiece{
{"<unk>", 0, pieceUnknown}, {"</s>", 0, pieceControl},
{"▁hello", -1, pieceNormal}, {"▁he", -2, pieceNormal}, {"llo", -2, pieceNormal}, {"▁", -3, pieceNormal},
{"h", -5, pieceNormal}, {"e", -5, pieceNormal}, {"l", -5, pieceNormal}, {"o", -5, pieceNormal},
}
func writeFile(t *testing.T, path string, data []byte) {
t.Helper()
if err := os.WriteFile(path, data, 0o644); err != nil {
t.Fatal(err)
}
}
type testTensor struct {
name string
shape []int
data []float32
}
func writeSafetensors(t *testing.T, path string, tensors []testTensor) {
t.Helper()
header := map[string]any{"__metadata__": map[string]string{"format": "pt"}}
var data []byte
for _, x := range tensors {
start := len(data)
for _, f := range x.data {
data = binary.LittleEndian.AppendUint32(data, math.Float32bits(f))
}
header[x.name] = map[string]any{"dtype": "F32", "shape": x.shape, "data_offsets": []int{start, len(data)}}
}
h, _ := json.Marshal(header)
out := binary.LittleEndian.AppendUint64(nil, uint64(len(h)))
writeFile(t, path, append(append(out, h...), data...))
}
// ----------------------------------------------------------------- tests
func TestSentencePiece(t *testing.T) {
path := filepath.Join(t.TempDir(), "m.spm")
writeFile(t, path, spmModel(1, pieces))
sp, err := loadSentencePiece(path)
if err != nil {
t.Fatal(err)
}
tests := map[string][]string{
"hello": {"▁hello"},
" hello hel ": {"▁hello", "▁he", "l"},
"helloz": {"▁hello", "z"},
"": nil,
}
for in, want := range tests {
if got := sp.encode(in); !reflect.DeepEqual(got, want) {
t.Errorf("encode(%q) = %q, want %q", in, got, want)
}
}
if got := decodePieces([]string{"▁Hello", ",", "▁world"}); got != "Hello, world" {
t.Errorf("decodePieces = %q", got)
}
writeFile(t, path, spmModel(2, pieces))
if _, err := loadSentencePiece(path); err == nil || !strings.Contains(err.Error(), "unigram") {
t.Errorf("BPE model: err = %v", err)
}
writeFile(t, path, []byte{0x0a, 0xff})
if _, err := loadSentencePiece(path); err == nil {
t.Error("malformed model accepted")
}
}
func TestSafetensors(t *testing.T) {
path := filepath.Join(t.TempDir(), "m.safetensors")
writeSafetensors(t, path, []testTensor{{"w", []int{2, 2}, []float32{1, -2, 3.5, 0}}})
got, err := readSafetensors(context.Background(), path)
if err != nil {
t.Fatal(err)
}
if w := got["w"]; !reflect.DeepEqual(w.shape, []int{2, 2}) || !reflect.DeepEqual(w.data, []float32{1, -2, 3.5, 0}) {
t.Errorf("w = %+v", w)
}
// F16 and BF16 tensors are widened.
h := []byte(`{"a":{"dtype":"F16","shape":[2],"data_offsets":[0,4]},"b":{"dtype":"BF16","shape":[1],"data_offsets":[4,6]}}`)
data := binary.LittleEndian.AppendUint64(nil, uint64(len(h)))
data = append(data, h...)
data = append(data, 0x00, 0x3c, 0x00, 0xc0, 0x80, 0x3f) // 1, -2 (F16), 1 (BF16)
writeFile(t, path, data)
got, err = readSafetensors(context.Background(), path)
if err != nil {
t.Fatal(err)
}
if !reflect.DeepEqual(got["a"].data, []float32{1, -2}) || !reflect.DeepEqual(got["b"].data, []float32{1}) {
t.Errorf("a = %v, b = %v", got["a"].data, got["b"].data)
}
h = []byte(`{"a":{"dtype":"F32","shape":[3],"data_offsets":[0,4]}}`)
writeFile(t, path, append(binary.LittleEndian.AppendUint64(nil, uint64(len(h))), append(h, 0, 0, 0, 0)...))
if _, err := readSafetensors(context.Background(), path); err == nil {
t.Error("shape mismatch accepted")
}
}
func TestHalf(t *testing.T) {
tests := map[uint16]float32{0x3c00: 1, 0xc000: -2, 0x7bff: 65504, 0x0001: 1.0 / (1 << 24), 0x8000: float32(math.Copysign(0, -1))}
for h, want := range tests {
if got := half(h); got != want {
t.Errorf("half(%#04x) = %v, want %v", h, got, want)
}
}
if !math.IsInf(float64(half(0x7c00)), 1) {
t.Error("half(0x7c00) is not +Inf")
}
}
func TestTopK(t *testing.T) {
if got := topK([]float32{0.1, 0.9, 0.3, 0.7, 0.9}, 3); !reflect.DeepEqual(got, []int{1, 4, 3}) {
t.Errorf("topK = %v", got)
}
if got := topK([]float32{1}, 3); !reflect.DeepEqual(got, []int{0}) {
t.Errorf("topK of one = %v", got)
}
}
// tinyModel writes a random model of the real architecture, small enough
// to run in a test.
func tinyModel(t *testing.T) string {
t.Helper()
dir := t.TempDir()
const d, ffn, vocab, positions = 8, 16, 12, 32
cfg := fmt.Sprintf(`{"d_model":%d,"encoder_layers":1,"decoder_layers":1,"encoder_attention_heads":2,
"decoder_attention_heads":2,"activation_function":"swish","scale_embedding":true,"normalize_before":false,
"max_position_embeddings":%d,"vocab_size":%d,"pad_token_id":%d,"eos_token_id":0,"decoder_start_token_id":%d,"num_beams":2}`,
d, positions, vocab, vocab-1, vocab-1)
writeFile(t, filepath.Join(dir, "config.json"), []byte(cfg))
writeFile(t, filepath.Join(dir, "source.spm"), spmModel(1, pieces))
writeFile(t, filepath.Join(dir, "target.spm"), spmModel(1, pieces))
ids := map[string]int{"</s>": 0, "<unk>": 1, "<pad>": vocab - 1}
for i, p := range pieces[2:] {
ids[p.piece] = i + 2
}
v, _ := json.Marshal(ids)
writeFile(t, filepath.Join(dir, "vocab.json"), v)
rng := rand.New(rand.NewSource(1))
random := func(n int) []float32 {
out := make([]float32, n)
for i := range out {
out[i] = float32(rng.NormFloat64() * 0.5)
}
return out
}
ones := func(n int) []float32 {
out := make([]float32, n)
for i := range out {
out[i] = 1
}
return out
}
var tensors []testTensor
add := func(name string, data []float32, shape ...int) {
tensors = append(tensors, testTensor{name, shape, data})
}
lin := func(p string, in, out int) {
add(p+".weight", random(in*out), out, in)
add(p+".bias", random(out), out)
}
norm := func(p string) {
add(p+".weight", ones(d), d)
add(p+".bias", make([]float32, d), d)
}
attn := func(p string) {
for _, x := range []string{"q_proj", "k_proj", "v_proj", "out_proj"} {
lin(p+"."+x, d, d)
}
}
add("model.shared.weight", random(vocab*d), vocab, d)
add("model.encoder.embed_positions.weight", random(positions*d), positions, d)
add("model.decoder.embed_positions.weight", random(positions*d), positions, d)
add("final_logits_bias", make([]float32, vocab), 1, vocab)
attn("model.encoder.layers.0.self_attn")
norm("model.encoder.layers.0.self_attn_layer_norm")
lin("model.encoder.layers.0.fc1", d, ffn)
lin("model.encoder.layers.0.fc2", ffn, d)
norm("model.encoder.layers.0.final_layer_norm")
attn("model.decoder.layers.0.self_attn")
norm("model.decoder.layers.0.self_attn_layer_norm")
attn("model.decoder.layers.0.encoder_attn")
norm("model.decoder.layers.0.encoder_attn_layer_norm")
lin("model.decoder.layers.0.fc1", d, ffn)
lin("model.decoder.layers.0.fc2", ffn, d)
norm("model.decoder.layers.0.final_layer_norm")
writeSafetensors(t, filepath.Join(dir, "model.safetensors"), tensors)
return dir
}
func TestTinyModel(t *testing.T) {
dir := tinyModel(t)
if !Installed(dir) {
t.Fatal("tiny model not installed")
}
m, err := Load(context.Background(), dir)
if err != nil {
t.Fatal(err)
}
first, err := m.Translate(context.Background(), "hello hello")
if err != nil {
t.Fatal(err)
}
// Random weights: the output means nothing, but it is deterministic
// and made of target pieces.
again, _ := m.Translate(context.Background(), "hello hello")
if first != again {
t.Errorf("translations differ: %q, %q", first, again)
}
if strings.ContainsAny(first, "▁<>") {
t.Errorf("translation %q contains special pieces", first)
}
if out, _ := m.Translate(context.Background(), " "); out != "" {
t.Errorf("blank text translated to %q", out)
}
ctx, cancel := context.WithCancel(context.Background())
cancel()
if _, err := m.Translate(ctx, "hello"); err == nil {
t.Error("cancelled translation succeeded")
}
os.Remove(filepath.Join(dir, "vocab.json"))
if Installed(dir) {
t.Error("incomplete model reported installed")
}
if _, err := Load(context.Background(), dir); err == nil {
t.Error("incomplete model loaded")
}
}
// TestRealModel runs the OPUS-MT model when PLOTZILLA_MT_MODEL names its
// directory (~/.local/share/plotzilla/models/opus-mt-fr-en once
// downloaded by the app).
func TestRealModel(t *testing.T) {
dir := os.Getenv("PLOTZILLA_MT_MODEL")
if dir == "" {
t.Skip("PLOTZILLA_MT_MODEL not set")
}
m, err := Load(context.Background(), dir)
if err != nil {
t.Fatal(err)
}
tests := map[string]string{
"Bonjour, comment allez-vous ?": "Hello, how are you?",
"Je t'avais dit de ne jamais revenir ici.": "I told you never to come back here.",
}
for in, want := range tests {
if got, err := m.Translate(context.Background(), in); err != nil || got != want {
t.Errorf("Translate(%q) = %q, %v, want %q", in, got, err, want)
}
}
}
func TestDownload(t *testing.T) {
content := []byte("model weights")
sum := sha256.Sum256(content)
var requests int
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
requests++
switch r.URL.Path {
case "/missing":
http.NotFound(w, r)
case "/full": // a server without resume support
w.Write(content)
default:
var off int
if _, err := fmt.Sscanf(r.Header.Get("Range"), "bytes=%d-", &off); err == nil && off > 0 && off < len(content) {
w.WriteHeader(http.StatusPartialContent)
w.Write(content[off:])
return
}
w.Write(content)
}
}))
defer srv.Close()
dir := filepath.Join(t.TempDir(), "model")
file := RemoteFile{Name: "f.bin", URL: srv.URL + "/f", Size: int64(len(content)), SHA256: hex.EncodeToString(sum[:])}
var last int64
if err := Download(context.Background(), srv.Client(), dir, []RemoteFile{file}, func(done, total int64) { last = done }); err != nil {
t.Fatal(err)
}
if got, _ := os.ReadFile(filepath.Join(dir, "f.bin")); string(got) != string(content) || last != file.Size {
t.Errorf("downloaded %q, progress %d", got, last)
}
// A file already downloaded and verified is skipped.
if err := Download(context.Background(), srv.Client(), dir, []RemoteFile{file}, nil); err != nil {
t.Fatal(err)
}
if requests != 1 {
t.Errorf("a complete file was downloaded again (%d requests)", requests)
}
// An interrupted download resumes its partial file.
resumed := file
resumed.Name, resumed.URL = "r.bin", srv.URL+"/r"
writeFile(t, filepath.Join(dir, ".r.bin.partial"), content[:6])
if err := Download(context.Background(), srv.Client(), dir, []RemoteFile{resumed}, nil); err != nil {
t.Fatal(err)
}
if got, _ := os.ReadFile(filepath.Join(dir, "r.bin")); string(got) != string(content) {
t.Errorf("resumed download = %q", got)
}
// A server that cannot resume answers the whole file: the partial
// download is restarted.
full := file
full.Name, full.URL = "full.bin", srv.URL+"/full"
writeFile(t, filepath.Join(dir, ".full.bin.partial"), content[:6])
if err := Download(context.Background(), srv.Client(), dir, []RemoteFile{full}, nil); err != nil {
t.Fatal(err)
}
if got, _ := os.ReadFile(filepath.Join(dir, "full.bin")); string(got) != string(content) {
t.Errorf("restarted download = %q", got)
}
// A corrupt partial file fails the digest check and is removed: the
// next download starts from scratch.
corrupt := file
corrupt.Name, corrupt.URL = "c.bin", srv.URL+"/c"
writeFile(t, filepath.Join(dir, ".c.bin.partial"), []byte("garbag"))
if err := Download(context.Background(), srv.Client(), dir, []RemoteFile{corrupt}, nil); err == nil {
t.Error("corrupt partial file accepted")
}
if _, err := os.Stat(filepath.Join(dir, ".c.bin.partial")); err == nil {
t.Error("corrupt partial file kept")
}
if err := Download(context.Background(), srv.Client(), dir, []RemoteFile{corrupt}, nil); err != nil {
t.Fatal(err)
}
bad := file
bad.Name, bad.SHA256 = "bad.bin", strings.Repeat("0", 64)
missing := file
missing.Name, missing.URL = "missing.bin", srv.URL+"/missing"
short := file
short.Name, short.Size = "short.bin", file.Size+5
for _, f := range []RemoteFile{bad, missing, short} {
if err := Download(context.Background(), srv.Client(), dir, []RemoteFile{f}, nil); err == nil {
t.Errorf("%s: no error", f.Name)
}
if _, err := os.Stat(filepath.Join(dir, f.Name)); err == nil {
t.Errorf("%s: a failed download left the file", f.Name)
}
}
// Only the interrupted download keeps a partial file, to be resumed.
for _, f := range []RemoteFile{bad, missing} {
if _, err := os.Stat(filepath.Join(dir, "."+f.Name+".partial")); err == nil {
t.Errorf("%s: a failed download left a partial file", f.Name)
}
}
if _, err := os.Stat(filepath.Join(dir, ".short.bin.partial")); err != nil {
t.Error("an interrupted download left no partial file to resume")
}
}
+383
View File
@@ -0,0 +1,383 @@
// Package marian runs MarianMT translation models (the OPUS-MT models of
// the University of Helsinki, in their Hugging Face form) on the CPU, in
// plain Go: a SentencePiece tokenizer, the transformer encoder-decoder and
// beam search. A model directory holds config.json, model.safetensors,
// source.spm, target.spm and vocab.json.
package marian
import (
"context"
"encoding/json"
"fmt"
"math"
"os"
"path/filepath"
)
// Files are the files a model directory must contain.
var Files = []string{"config.json", "model.safetensors", "source.spm", "target.spm", "vocab.json"}
type config struct {
DModel int `json:"d_model"`
EncoderLayers int `json:"encoder_layers"`
DecoderLayers int `json:"decoder_layers"`
EncoderHeads int `json:"encoder_attention_heads"`
DecoderHeads int `json:"decoder_attention_heads"`
Activation string `json:"activation_function"`
ScaleEmbedding bool `json:"scale_embedding"`
NormalizeBefore bool `json:"normalize_before"`
MaxPositions int `json:"max_position_embeddings"`
VocabSize int `json:"vocab_size"`
PadToken int `json:"pad_token_id"`
EOSToken int `json:"eos_token_id"`
DecoderStart int `json:"decoder_start_token_id"`
NumBeams int `json:"num_beams"`
}
type linear struct {
w, b []float32
in, out int
}
type layerNorm struct{ g, b []float32 }
type attention struct {
q, k, v, o linear
heads int
}
type encoderLayer struct {
self attention
selfNorm layerNorm
fc1, fc2 linear
finalLayerNorm layerNorm
}
type decoderLayer struct {
self, cross attention
selfNorm, crossNorm layerNorm
fc1, fc2 linear
finalLayerNorm layerNorm
}
// Model is a loaded translation model. It is read-only once loaded:
// Translate may be called from several goroutines at once.
type Model struct {
cfg config
embed []float32 // vocab × d, shared by encoder, decoder and output
encPos []float32 // positions × d
decPos []float32
logitsBias []float32
encoder []encoderLayer
decoder []decoderLayer
source *sentencePiece
target *sentencePiece
ids map[string]int
pieces []string
unk int
embedScale float32
activation func(float32) float32
}
// Load reads the model in dir. The load can take seconds for a large
// model: it stops early when ctx is cancelled.
func Load(ctx context.Context, dir string) (*Model, error) {
if err := ctx.Err(); err != nil {
return nil, err
}
var cfg config
data, err := os.ReadFile(filepath.Join(dir, "config.json"))
if err != nil {
return nil, err
}
if err := json.Unmarshal(data, &cfg); err != nil {
return nil, fmt.Errorf("reading config.json: %w", err)
}
if cfg.DModel == 0 || cfg.EncoderHeads == 0 || cfg.DecoderHeads == 0 || cfg.DModel%cfg.EncoderHeads != 0 || cfg.DModel%cfg.DecoderHeads != 0 {
return nil, fmt.Errorf("unsupported model dimensions in config.json")
}
if cfg.NormalizeBefore {
return nil, fmt.Errorf("unsupported model: pre-norm layers")
}
m := &Model{cfg: cfg, embedScale: 1}
if cfg.ScaleEmbedding {
m.embedScale = float32(math.Sqrt(float64(cfg.DModel)))
}
switch cfg.Activation {
case "swish", "silu":
m.activation = func(x float32) float32 { return x / (1 + float32(math.Exp(float64(-x)))) }
case "relu":
m.activation = func(x float32) float32 { return max(x, 0) }
case "gelu":
m.activation = func(x float32) float32 { return 0.5 * x * (1 + float32(math.Erf(float64(x)/math.Sqrt2))) }
default:
return nil, fmt.Errorf("unsupported activation %q", cfg.Activation)
}
if m.source, err = loadSentencePiece(filepath.Join(dir, "source.spm")); err != nil {
return nil, err
}
if m.target, err = loadSentencePiece(filepath.Join(dir, "target.spm")); err != nil {
return nil, err
}
if data, err = os.ReadFile(filepath.Join(dir, "vocab.json")); err != nil {
return nil, err
}
if err := json.Unmarshal(data, &m.ids); err != nil {
return nil, fmt.Errorf("reading vocab.json: %w", err)
}
m.pieces = make([]string, cfg.VocabSize)
for p, id := range m.ids {
if id >= 0 && id < len(m.pieces) {
m.pieces[id] = p
}
}
m.unk = m.ids["<unk>"]
if err := ctx.Err(); err != nil {
return nil, err
}
tensors, err := readSafetensors(ctx, filepath.Join(dir, "model.safetensors"))
if err != nil {
return nil, fmt.Errorf("reading model.safetensors: %w", err)
}
if err := m.bind(tensors); err != nil {
return nil, fmt.Errorf("reading model.safetensors: %w", err)
}
return m, nil
}
// bind takes the weights the model needs from the tensors, checking their
// shapes.
func (m *Model) bind(t map[string]tensor) error {
d := m.cfg.DModel
var missing error
get := func(name string, shape ...int) []float32 {
x, ok := t[name]
if !ok {
if missing == nil {
missing = fmt.Errorf("missing tensor %s", name)
}
return nil
}
n := 1
for _, s := range shape {
n *= s
}
if len(x.data) != n && missing == nil {
missing = fmt.Errorf("tensor %s has shape %v", name, x.shape)
}
return x.data
}
first := func(names ...string) string {
for _, n := range names {
if _, ok := t[n]; ok {
return n
}
}
return names[0]
}
lin := func(prefix string, in, out int) linear {
return linear{w: get(prefix+".weight", out, in), b: get(prefix+".bias", out), in: in, out: out}
}
norm := func(prefix string) layerNorm {
return layerNorm{g: get(prefix+".weight", d), b: get(prefix+".bias", d)}
}
attn := func(prefix string, heads int) attention {
return attention{
q: lin(prefix+".q_proj", d, d), k: lin(prefix+".k_proj", d, d),
v: lin(prefix+".v_proj", d, d), o: lin(prefix+".out_proj", d, d), heads: heads,
}
}
ffn := func(prefix string) int {
if x, ok := t[prefix+".fc1.weight"]; ok && len(x.shape) == 2 {
return x.shape[0]
}
return 4 * d
}
v := m.cfg.VocabSize
m.embed = get(first("model.shared.weight", "model.decoder.embed_tokens.weight", "model.encoder.embed_tokens.weight"), v, d)
m.encPos = get("model.encoder.embed_positions.weight", m.cfg.MaxPositions, d)
m.decPos = get("model.decoder.embed_positions.weight", m.cfg.MaxPositions, d)
m.logitsBias = get("final_logits_bias", 1, v)
for i := range m.cfg.EncoderLayers {
p := fmt.Sprintf("model.encoder.layers.%d", i)
f := ffn(p)
m.encoder = append(m.encoder, encoderLayer{
self: attn(p+".self_attn", m.cfg.EncoderHeads), selfNorm: norm(p + ".self_attn_layer_norm"),
fc1: lin(p+".fc1", d, f), fc2: lin(p+".fc2", f, d), finalLayerNorm: norm(p + ".final_layer_norm"),
})
}
for i := range m.cfg.DecoderLayers {
p := fmt.Sprintf("model.decoder.layers.%d", i)
f := ffn(p)
m.decoder = append(m.decoder, decoderLayer{
self: attn(p+".self_attn", m.cfg.DecoderHeads), selfNorm: norm(p + ".self_attn_layer_norm"),
cross: attn(p+".encoder_attn", m.cfg.DecoderHeads), crossNorm: norm(p + ".encoder_attn_layer_norm"),
fc1: lin(p+".fc1", d, f), fc2: lin(p+".fc2", f, d), finalLayerNorm: norm(p + ".final_layer_norm"),
})
}
return missing
}
// maxSourceTokens leaves room for the end-of-sentence token.
func (m *Model) maxSourceTokens() int { return m.cfg.MaxPositions - 1 }
// Translate translates one text. Long texts are better split into
// sentences first: the models are trained on sentences.
func (m *Model) Translate(ctx context.Context, text string) (string, error) {
pieces := m.source.encode(text)
if len(pieces) == 0 {
return "", nil
}
ids := make([]int, 0, len(pieces)+1)
for _, p := range pieces {
id, ok := m.ids[p]
if !ok {
id = m.unk
}
ids = append(ids, id)
}
if len(ids) > m.maxSourceTokens() {
ids = ids[:m.maxSourceTokens()]
}
ids = append(ids, m.cfg.EOSToken)
out, err := m.beamSearch(ctx, m.encode(ids), len(ids))
if err != nil {
return "", err
}
words := make([]string, 0, len(out))
for _, id := range out {
if id == m.cfg.EOSToken || id == m.cfg.PadToken || id == m.unk || id < 0 || id >= len(m.pieces) {
continue
}
words = append(words, m.pieces[id])
}
return decodePieces(words), nil
}
// encode runs the encoder: one d-vector per source token.
func (m *Model) encode(ids []int) []float32 {
d := m.cfg.DModel
n := len(ids)
x := make([]float32, n*d)
for i, id := range ids {
row := x[i*d : (i+1)*d]
emb := m.embed[id*d : (id+1)*d]
pos := m.encPos[i*d : (i+1)*d]
for j := range row {
row[j] = emb[j]*m.embedScale + pos[j]
}
}
q := make([]float32, n*d)
k := make([]float32, n*d)
v := make([]float32, n*d)
ctxv := make([]float32, n*d)
tmp := make([]float32, n*d)
scores := make([]float32, n)
for _, l := range m.encoder {
l.self.q.rows(q, x, n)
l.self.k.rows(k, x, n)
l.self.v.rows(v, x, n)
for i := range n {
attend(ctxv[i*d:(i+1)*d], q[i*d:(i+1)*d], k, v, n, l.self.heads, scores)
}
l.self.o.rows(tmp, ctxv, n)
addNorm(x, tmp, l.selfNorm, n)
hidden := make([]float32, n*l.fc1.out)
l.fc1.rows(hidden, x, n)
for i := range hidden {
hidden[i] = m.activation(hidden[i])
}
l.fc2.rows(tmp, hidden, n)
addNorm(x, tmp, l.finalLayerNorm, n)
}
return x
}
// rows applies the layer to n row vectors: dst = x·Wᵀ + b.
func (l linear) rows(dst, x []float32, n int) {
for i := range n {
l.apply(dst[i*l.out:(i+1)*l.out], x[i*l.in:(i+1)*l.in])
}
}
func (l linear) apply(dst, x []float32) {
for o := range l.out {
dst[o] = dot(x, l.w[o*l.in:(o+1)*l.in]) + l.b[o]
}
}
// attend computes multi-head scaled dot-product attention of one query
// over n keys and values (n × d each).
func attend(dst, q, k, v []float32, n, heads int, scores []float32) {
d := len(q)
hd := d / heads
scale := float32(1 / math.Sqrt(float64(hd)))
clear(dst)
for h := range heads {
lo, hi := h*hd, (h+1)*hd
maxScore := float32(math.Inf(-1))
for j := range n {
s := dot(q[lo:hi], k[j*d+lo:j*d+hi]) * scale
scores[j] = s
maxScore = max(maxScore, s)
}
var sum float32
for j := range n {
scores[j] = float32(math.Exp(float64(scores[j] - maxScore)))
sum += scores[j]
}
out := dst[lo:hi]
for j := range n {
p := scores[j] / sum
row := v[j*d+lo : j*d+hi]
for c := range out {
out[c] += p * row[c]
}
}
}
}
// addNorm sets x = LayerNorm(x + y) for n rows.
func addNorm(x, y []float32, ln layerNorm, n int) {
d := len(ln.g)
for i := range n {
row := x[i*d : (i+1)*d]
add := y[i*d : (i+1)*d]
var mean float64
for j := range row {
row[j] += add[j]
mean += float64(row[j])
}
mean /= float64(d)
var variance float64
for _, val := range row {
diff := float64(val) - mean
variance += diff * diff
}
inv := 1 / math.Sqrt(variance/float64(d)+1e-5)
for j := range row {
row[j] = float32((float64(row[j])-mean)*inv)*ln.g[j] + ln.b[j]
}
}
}
// dot is the hot loop of the whole model: unrolled, with independent
// accumulators so the CPU can overlap the additions.
func dot(a, b []float32) float32 {
b = b[:len(a)]
var s0, s1, s2, s3 float32
i := 0
for ; i+8 <= len(a); i += 8 {
s0 += a[i]*b[i] + a[i+4]*b[i+4]
s1 += a[i+1]*b[i+1] + a[i+5]*b[i+5]
s2 += a[i+2]*b[i+2] + a[i+6]*b[i+6]
s3 += a[i+3]*b[i+3] + a[i+7]*b[i+7]
}
for ; i < len(a); i++ {
s0 += a[i] * b[i]
}
return s0 + s1 + s2 + s3
}
+141
View File
@@ -0,0 +1,141 @@
package marian
import (
"context"
"encoding/binary"
"encoding/json"
"fmt"
"io"
"math"
"os"
"unsafe"
)
// tensor is a weight matrix or vector read from a safetensors file, in
// row-major order.
type tensor struct {
shape []int
data []float32
}
type tensorInfo struct {
DType string `json:"dtype"`
Shape []int `json:"shape"`
Offsets [2]int64 `json:"data_offsets"`
}
// readSafetensors loads every tensor of a safetensors file (an 8-byte
// little-endian header length, a JSON header, then the raw data) as
// float32. F32 tensors are read straight into their slice: the file is
// large and a second copy would double the memory needed to load it.
func readSafetensors(ctx context.Context, path string) (map[string]tensor, error) {
f, err := os.Open(path)
if err != nil {
return nil, err
}
defer f.Close()
var size uint64
if err := binary.Read(f, binary.LittleEndian, &size); err != nil {
return nil, fmt.Errorf("reading header size: %w", err)
}
if size > 100<<20 {
return nil, fmt.Errorf("header too large (%d bytes)", size)
}
header := make([]byte, size)
if _, err := io.ReadFull(f, header); err != nil {
return nil, fmt.Errorf("reading header: %w", err)
}
var infos map[string]json.RawMessage
if err := json.Unmarshal(header, &infos); err != nil {
return nil, fmt.Errorf("parsing header: %w", err)
}
base := int64(8 + size)
out := make(map[string]tensor, len(infos))
for name, raw := range infos {
if name == "__metadata__" {
continue
}
if err := ctx.Err(); err != nil {
return nil, err
}
var info tensorInfo
if err := json.Unmarshal(raw, &info); err != nil {
return nil, fmt.Errorf("tensor %s: %w", name, err)
}
n := 1
for _, d := range info.Shape {
n *= d
}
t, err := readTensor(f, base, info, n)
if err != nil {
return nil, fmt.Errorf("tensor %s: %w", name, err)
}
out[name] = tensor{shape: info.Shape, data: t}
}
return out, nil
}
func readTensor(f *os.File, base int64, info tensorInfo, n int) ([]float32, error) {
start, end := info.Offsets[0], info.Offsets[1]
width := map[string]int64{"F32": 4, "F16": 2, "BF16": 2}[info.DType]
if width == 0 {
return nil, fmt.Errorf("unsupported type %s", info.DType)
}
if end-start != int64(n)*width {
return nil, fmt.Errorf("size %d does not match shape %v", end-start, info.Shape)
}
data := make([]float32, n)
if n == 0 {
return data, nil
}
if info.DType == "F32" {
buf := unsafe.Slice((*byte)(unsafe.Pointer(&data[0])), n*4)
if _, err := f.ReadAt(buf, base+start); err != nil {
return nil, err
}
if !littleEndian {
for i := range data {
data[i] = math.Float32frombits(binary.LittleEndian.Uint32(buf[i*4:]))
}
}
return data, nil
}
buf := make([]byte, n*2)
if _, err := f.ReadAt(buf, base+start); err != nil {
return nil, err
}
for i := range data {
h := binary.LittleEndian.Uint16(buf[i*2:])
if info.DType == "BF16" {
data[i] = math.Float32frombits(uint32(h) << 16)
} else {
data[i] = half(h)
}
}
return data, nil
}
var littleEndian = func() bool {
x := uint16(1)
return *(*byte)(unsafe.Pointer(&x)) == 1
}()
// half converts an IEEE 754 half-precision float.
func half(h uint16) float32 {
sign := uint32(h>>15) << 31
exp := uint32(h>>10) & 0x1f
frac := uint32(h) & 0x3ff
switch {
case exp == 0 && frac == 0:
return math.Float32frombits(sign)
case exp == 0: // subnormal
v := float32(frac) / 1024 / 16384
if sign != 0 {
return -v
}
return v
case exp == 0x1f:
return math.Float32frombits(sign | 0xff<<23 | frac<<13)
}
return math.Float32frombits(sign | (exp+112)<<23 | frac<<13)
}
+225
View File
@@ -0,0 +1,225 @@
package marian
import (
"encoding/binary"
"errors"
"fmt"
"math"
"os"
"strings"
"unicode"
)
// space is SentencePiece's visible word boundary ("▁").
const space = "▁"
// sentencePiece is a SentencePiece unigram model: it splits text into the
// pieces of the vocabulary that maximise the summed piece scores.
type sentencePiece struct {
scores map[string]float32
maxRunes int
unkScore float32
// addDummyPrefix starts the text with "▁", as if preceded by a space.
addDummyPrefix bool
}
// SentencePiece piece types (sentencepiece_model.proto).
const (
pieceNormal = 1
pieceUnknown = 2
pieceControl = 3
pieceUserDefined = 4
pieceUnused = 5
pieceByte = 6
)
// loadSentencePiece reads a serialized ModelProto. Only the fields needed
// to encode are decoded: the pieces (field 1), the model type (trainer
// spec, field 2) and the dummy-prefix flag (normalizer spec, field 3).
func loadSentencePiece(path string) (*sentencePiece, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, err
}
sp := &sentencePiece{scores: map[string]float32{}, addDummyPrefix: true}
minScore := float32(0)
err = protoFields(data, func(field int, wire int, v uint64, b []byte) error {
switch field {
case 1:
var piece string
var score float32
typ := pieceNormal
err := protoFields(b, func(field, wire int, v uint64, b []byte) error {
switch field {
case 1:
piece = string(b)
case 2:
score = math.Float32frombits(uint32(v))
case 3:
typ = int(v)
}
return nil
})
if err != nil {
return err
}
if typ == pieceNormal || typ == pieceUserDefined {
sp.scores[piece] = score
sp.maxRunes = max(sp.maxRunes, len([]rune(piece)))
minScore = min(minScore, score)
}
case 2:
return protoFields(b, func(field, wire int, v uint64, b []byte) error {
if field == 3 && v != 1 {
return fmt.Errorf("unsupported SentencePiece model type %d (only unigram)", v)
}
return nil
})
case 3:
return protoFields(b, func(field, wire int, v uint64, b []byte) error {
if field == 3 {
sp.addDummyPrefix = v != 0
}
return nil
})
}
return nil
})
if err != nil {
return nil, fmt.Errorf("reading %s: %w", path, err)
}
if len(sp.scores) == 0 {
return nil, fmt.Errorf("reading %s: no pieces", path)
}
// SentencePiece gives unknown characters a score below every piece.
sp.unkScore = minScore - 10
return sp, nil
}
// protoFields calls fn for each field of a protobuf message: v holds
// varints and fixed-width values, b length-delimited ones.
func protoFields(data []byte, fn func(field, wire int, v uint64, b []byte) error) error {
for len(data) > 0 {
key, n := binary.Uvarint(data)
if n <= 0 {
return errors.New("malformed protobuf key")
}
data = data[n:]
field, wire := int(key>>3), int(key&7)
var v uint64
var b []byte
switch wire {
case 0:
v, n = binary.Uvarint(data)
if n <= 0 {
return errors.New("malformed protobuf varint")
}
data = data[n:]
case 1:
if len(data) < 8 {
return errors.New("truncated protobuf")
}
v, data = binary.LittleEndian.Uint64(data), data[8:]
case 2:
l, n := binary.Uvarint(data)
if n <= 0 || uint64(len(data)-n) < l {
return errors.New("truncated protobuf")
}
b, data = data[n:n+int(l)], data[n+int(l):]
case 5:
if len(data) < 4 {
return errors.New("truncated protobuf")
}
v, data = uint64(binary.LittleEndian.Uint32(data)), data[4:]
default:
return fmt.Errorf("unsupported protobuf wire type %d", wire)
}
if err := fn(field, wire, v, b); err != nil {
return err
}
}
return nil
}
// normalize approximates SentencePiece's nmt_nfkc normalisation for the
// characters scripts actually contain: every kind of space becomes a
// plain one, runs of spaces collapse, control characters go away and a
// few compatibility characters are decomposed.
func normalize(text string) string {
var b strings.Builder
pendingSpace := false
for _, r := range text {
switch {
case unicode.IsSpace(r):
pendingSpace = b.Len() > 0
continue
case unicode.IsControl(r) || r == '\u200b' || r == '\ufeff':
continue
}
if pendingSpace {
b.WriteByte(' ')
pendingSpace = false
}
switch r {
case '…':
b.WriteString("...")
case 'fi':
b.WriteString("fi")
case 'fl':
b.WriteString("fl")
case 'ff':
b.WriteString("ff")
default:
b.WriteRune(r)
}
}
return b.String()
}
// encode splits text into pieces (Viterbi search over the unigram scores).
func (sp *sentencePiece) encode(text string) []string {
text = normalize(text)
if text == "" {
return nil
}
if sp.addDummyPrefix {
text = " " + text
}
runes := []rune(strings.ReplaceAll(text, " ", space))
n := len(runes)
best := make([]float64, n+1)
from := make([]int, n+1)
for i := 1; i <= n; i++ {
best[i] = math.Inf(-1)
}
for end := 1; end <= n; end++ {
for start := max(0, end-sp.maxRunes); start < end; start++ {
if math.IsInf(best[start], -1) {
continue
}
score, ok := sp.scores[string(runes[start:end])]
if !ok {
if end-start != 1 {
continue
}
score = sp.unkScore
}
if s := best[start] + float64(score); s > best[end] {
best[end], from[end] = s, start
}
}
}
var pieces []string
for end := n; end > 0; end = from[end] {
pieces = append(pieces, string(runes[from[end]:end]))
}
for i, j := 0, len(pieces)-1; i < j; i, j = i+1, j-1 {
pieces[i], pieces[j] = pieces[j], pieces[i]
}
return pieces
}
// decodePieces joins target pieces back into text.
func decodePieces(pieces []string) string {
return strings.TrimSpace(strings.ReplaceAll(strings.Join(pieces, ""), space, " "))
}
+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)
}
}
}
+1 -1
View File
@@ -1,7 +1,7 @@
# Maintainer: Sandro Cazzaniga <sandro@cazzaniga.fr>
pkgname=plotzilla
pkgver=1.2
pkgver=1.4
pkgrel=1
pkgdesc='Screenwriting app for Linux: scripts in the Hollywood standard format, Fountain, FDX and PDF'
arch=('x86_64' 'aarch64')
Binary file not shown.

Before

Width:  |  Height:  |  Size: 362 KiB

After

Width:  |  Height:  |  Size: 363 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 351 KiB

After

Width:  |  Height:  |  Size: 352 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 423 KiB

After

Width:  |  Height:  |  Size: 420 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 294 KiB

After

Width:  |  Height:  |  Size: 296 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 372 KiB

After

Width:  |  Height:  |  Size: 373 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 378 KiB

After

Width:  |  Height:  |  Size: 379 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 357 KiB

After

Width:  |  Height:  |  Size: 358 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 412 KiB

After

Width:  |  Height:  |  Size: 414 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 268 KiB

After

Width:  |  Height:  |  Size: 270 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 374 KiB

After

Width:  |  Height:  |  Size: 375 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 276 KiB

After

Width:  |  Height:  |  Size: 277 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 320 KiB

After

Width:  |  Height:  |  Size: 321 KiB

+336
View File
@@ -0,0 +1,336 @@
package main
import (
"context"
"errors"
"fmt"
"net/http"
"os"
"path/filepath"
"runtime"
"slices"
"strings"
"sync"
"time"
wails "github.com/wailsapp/wails/v2/pkg/runtime"
"plotzilla/internal/marian"
"plotzilla/internal/screenplay"
"plotzilla/internal/spell"
"plotzilla/internal/translate"
)
// The French → English model is OPUS-MT fr-en (University of Helsinki,
// Apache-2.0), pinned to one revision so that every file can be checked.
const (
modelName = "opus-mt-fr-en"
modelURL = "https://huggingface.co/Helsinki-NLP/opus-mt-fr-en/resolve/c4aed37b318c763fd177aa449b44e3b783cc6c02/"
// modelIdle is how long the model (300 MB of memory) stays loaded
// after a translation, ready for the next one.
modelIdle = 5 * time.Minute
// stallTimeout is how long a download may go without receiving a byte
// (or its response headers) before it is aborted as stalled.
stallTimeout = 30 * time.Second
)
// errStalled aborts a download that receives nothing for stallTimeout.
var errStalled = errors.New("the download stalled (no data received for 30 seconds)")
var modelFiles = []marian.RemoteFile{
{Name: "config.json", Size: 1416, SHA256: "b3be13d046d9899d7aab8cf4ed624d9a79f5776038ba793f6b4d2ce3e02192f7"},
{Name: "source.spm", Size: 802397, SHA256: "78d0e717c77053f1c4b856d8661d9cb87c64f083a35418c087b9146300e4f585"},
{Name: "target.spm", Size: 778395, SHA256: "173e9f493a668fe396d599e28d414a201193094e6ffd7a4678e5aab0f6d3d838"},
{Name: "vocab.json", Size: 1339166, SHA256: "945c604346ce15ce4aff9001001e7f925e336d942c4087017f191871162cbdc4"},
{Name: "model.safetensors", Size: 300803608, SHA256: "6e3837f34b903802c3d0d670362b997cee6e87584a1108eb3fa89e4625e4424a"},
}
// translation is the state of the translation feature: the loaded model,
// and the running task (one at a time), which the user can cancel.
type translation struct {
mu sync.Mutex
model *marian.Model
lexicon translate.Lexicon
release *time.Timer
cancel context.CancelFunc
done chan struct{} // closed when the running task is over
}
// TranslationStatus says whether the model is installed, and how much a
// download weighs.
type TranslationStatus struct {
Installed bool `json:"installed"`
Size int64 `json:"size"`
}
// modelDirs lists where the model is looked for: the user's data
// directory (where it is downloaded), then the system data directories,
// where a distribution package may install it.
func modelDirs() []string {
dirs := []string{filepath.Join(dataDir(), "plotzilla", "models", modelName)}
system := os.Getenv("XDG_DATA_DIRS")
if system == "" {
system = "/usr/local/share:/usr/share"
}
for _, d := range filepath.SplitList(system) {
if d != "" {
dirs = append(dirs, filepath.Join(d, "plotzilla", "models", modelName))
}
}
return dirs
}
// installedModel returns the first model directory holding every file at
// its pinned size. Checking the digests would mean reading 300 MB on
// every call; the sizes catch truncated or stale files, and the digests
// were checked when the files were downloaded.
func installedModel() string {
for _, d := range modelDirs() {
if modelComplete(d) {
return d
}
}
return ""
}
func modelComplete(dir string) bool {
for _, f := range modelFiles {
st, err := os.Stat(filepath.Join(dir, f.Name))
if err != nil || st.Size() != f.Size {
return false
}
}
return true
}
func (a *App) TranslationStatus() TranslationStatus {
var size int64
for _, f := range modelFiles {
size += f.Size
}
return TranslationStatus{Installed: installedModel() != "", Size: size}
}
// startTask registers a cancellable task; only one runs at a time.
func (a *App) startTask() (context.Context, error) {
a.tr.mu.Lock()
defer a.tr.mu.Unlock()
if a.tr.cancel != nil {
return nil, errors.New("a translation task is already running")
}
ctx, cancel := context.WithCancel(a.ctx)
a.tr.cancel = cancel
a.tr.done = make(chan struct{})
return ctx, nil
}
func (a *App) endTask() {
a.tr.mu.Lock()
defer a.tr.mu.Unlock()
if a.tr.cancel != nil {
a.tr.cancel()
a.tr.cancel = nil
close(a.tr.done)
a.tr.done = nil
}
}
// CancelTranslation stops the running download or translation, waiting
// for it to wind down so that a new task can start right away.
func (a *App) CancelTranslation() {
a.tr.mu.Lock()
cancel, done := a.tr.cancel, a.tr.done
a.tr.mu.Unlock()
if cancel == nil {
return
}
cancel()
<-done
}
// progressEmitter sends "translation-progress" events, at most every
// 100 ms: a download reports every 32 KB.
func (a *App) progressEmitter() func(done, total int64) {
var mu sync.Mutex
var last time.Time
return func(done, total int64) {
mu.Lock()
defer mu.Unlock()
if done < total && time.Since(last) < 100*time.Millisecond {
return
}
last = time.Now()
wails.EventsEmit(a.ctx, "translation-progress", done, total)
}
}
// DownloadTranslationModel downloads the model into the user's data
// directory. An interrupted download resumes where it stopped. It returns
// false if the user cancelled.
func (a *App) DownloadTranslationModel() (bool, error) {
if installedModel() != "" {
return true, nil
}
ctx, err := a.startTask()
if err != nil {
return false, err
}
defer a.endTask()
files := make([]marian.RemoteFile, len(modelFiles))
for i, f := range modelFiles {
f.URL = modelURL + f.Name
files[i] = f
}
// No total timeout (300 MB can take a while) but abort a stalled
// transfer: the watchdog is reset by every progress report.
ctx, stop := context.WithCancelCause(ctx)
defer stop(nil)
emit := a.progressEmitter()
stalled := time.AfterFunc(stallTimeout, func() { stop(errStalled) })
defer stalled.Stop()
transport := http.DefaultTransport.(*http.Transport).Clone()
transport.ResponseHeaderTimeout = stallTimeout
client := &http.Client{Transport: transport}
err = marian.Download(ctx, client, modelDirs()[0], files, func(done, total int64) {
stalled.Reset(stallTimeout)
emit(done, total)
})
if err != nil {
if errors.Is(context.Cause(ctx), errStalled) {
return false, errStalled
}
if errors.Is(err, context.Canceled) {
return false, nil
}
return false, err
}
return true, nil
}
// Translate translates the French text of the elements (flat indexes; all
// of them when nil) and, if titlePage, of the title page. It returns nil
// if the user cancelled.
func (a *App) Translate(doc screenplay.Document, elements []int, titlePage bool) (*translate.Result, error) {
ctx, err := a.startTask()
if err != nil {
return nil, err
}
defer a.endTask()
if installedModel() == "" {
return nil, errors.New("the translation model is not installed")
}
wails.EventsEmit(a.ctx, "translation-progress", 0, 0) // loading
model, lexicon, err := a.translator(ctx)
if errors.Is(err, context.Canceled) {
return nil, nil
}
if err != nil {
return nil, err
}
defer a.releaseLater()
emit := a.progressEmitter()
res, err := translate.Document(ctx, &doc, model, lexicon, translate.Options{
Elements: elements,
TitlePage: titlePage,
// The model splits its largest product across every CPU already:
// half as many sentences at once keeps the machine responsive.
Workers: max(1, runtime.NumCPU()/2),
Progress: func(done, total int) { emit(int64(done), int64(total)) },
})
if errors.Is(err, context.Canceled) {
return nil, nil
}
if err != nil {
return nil, err
}
return &res, nil
}
// translator returns the model and the dictionaries that help tell French
// from English, loading them the first time. The load happens without
// a.tr.mu: reading 300 MB takes seconds, and CancelTranslation must stay
// responsive.
func (a *App) translator(ctx context.Context) (*marian.Model, translate.Lexicon, error) {
a.tr.mu.Lock()
if a.tr.release != nil {
a.tr.release.Stop()
}
model, lexicon := a.tr.model, a.tr.lexicon
a.tr.mu.Unlock()
if model != nil {
return model, lexicon, nil
}
dir := installedModel()
if dir == "" {
return nil, nil, errors.New("the translation model is not installed")
}
m, err := marian.Load(ctx, dir)
if err != nil {
if ctx.Err() != nil {
return nil, nil, ctx.Err()
}
return nil, nil, fmt.Errorf("loading the translation model: %w", err)
}
lexicon = loadLexicon()
a.tr.mu.Lock()
if ctx.Err() == nil { // cancelled while loading: cache nothing
a.tr.model, a.tr.lexicon = m, lexicon
}
a.tr.mu.Unlock()
if err := ctx.Err(); err != nil {
return nil, nil, err
}
return m, lexicon, nil
}
// releaseLater frees the model once it has been unused for modelIdle.
func (a *App) releaseLater() {
a.tr.mu.Lock()
defer a.tr.mu.Unlock()
a.tr.release = time.AfterFunc(modelIdle, a.releaseModel)
}
// releaseModel frees the model when no task is running. When one is (a
// download loads nothing but holds the task slot), it tries again after
// another modelIdle.
func (a *App) releaseModel() {
a.tr.mu.Lock()
defer a.tr.mu.Unlock()
if a.tr.cancel != nil {
a.tr.release = time.AfterFunc(modelIdle, a.releaseModel)
return
}
a.tr.model, a.tr.lexicon = nil, nil
}
// lexicon tells French words from English ones with the installed
// Hunspell dictionaries.
type lexicon struct{ fr, en *spell.Dictionary }
func (l lexicon) Knows(word string) (french, english bool) { return l.fr.Check(word), l.en.Check(word) }
// loadLexicon needs both a French and an English dictionary: with one
// only, every word it knows would count for its language. Without them,
// detection relies on function words and accents.
func loadLexicon() translate.Lexicon {
langs := spell.Available(dictionaryDirs())
load := func(lang, preferred string) *spell.Dictionary {
i := slices.IndexFunc(langs, func(l spell.Language) bool { return l.Code == preferred })
if i < 0 {
i = slices.IndexFunc(langs, func(l spell.Language) bool { return strings.HasPrefix(l.Code, lang+"_") })
}
if i < 0 {
return nil
}
d, err := spell.Load(langs[i].Aff, langs[i].Dic)
if err != nil {
return nil
}
return d
}
fr, en := load("fr", "fr_FR"), load("en", "en_US")
if fr == nil || en == nil {
return nil
}
return lexicon{fr, en}
}