37 Commits
Author SHA1 Message Date
Kharec d355d93126 chore: release 1.4.1 2026-10-15 18:01:00 +02:00
Kharec 2de0d59641 docs: mention the PDF export warning for undrawable characters 2026-10-15 17:59:28 +02:00
Kharec 093f7ea10d chore: use any instead of []interface{} in the Wails binding 2026-10-15 17:58:55 +02:00
Kharec 764c9d4dc3 fix: recognise Final Draft files by their content
load() chose the parser from the extension alone, so a .txt that is
really FDX was parsed as Fountain. The .fdx extension still wins, and
other names are sniffed for the FinalDraft root element.
2026-10-15 17:58:46 +02:00
Kharec 6622203506 fix: create the file passed on the command line when it is missing
A path that did not exist was silently ignored: plotzilla
new-script.fountain opened an untitled document instead of a new
script saved to that file on the first save.
2026-10-15 17:57:24 +02:00
Kharec b0bcb812b1 fix: bound the spelling checker's word cache
The remembered answers are keyed on user input and never evicted: a
long session could grow the map without bound. Clear it at 10000 words
and let it refill - a script repeats the same words, so re-checks are
cheap.
2026-10-15 17:56:48 +02:00
Kharec 823c9f2d0f fix: share the spelling dictionaries with the translation lexicon
Every model load read the full French and English Hunspell
dictionaries from disk again, even when the spell checker already held
the same dictionary in memory. App now caches one Dictionary per
language; a Dictionary is read-only once loaded, so the checker and
the language detection share it.
2026-10-15 17:55:58 +02:00
Kharec 343836df6b fix: build the stalled-download message from the timeout
The message hard-coded "30 seconds" next to the stallTimeout constant:
change one and the other lied.
2026-10-15 17:54:56 +02:00
Kharec e85d0ba7e9 chore: remove marian's duplicate installation check
marian.Installed and marian.Files were only used by the package's own
test, while the app has its own check in translation.go - with a
different rule (pinned size there, size > 0 here), so the two
definitions of 'installed' could disagree. The app's check is the
canonical one (it owns the pins); Load already fails on an incomplete
model, which the test keeps asserting.
2026-10-15 17:54:44 +02:00
Kharec 1440adc199 docs: record why the translation engine lives in-tree
A from-scratch CPU inference engine is the largest, riskiest part of
the codebase and the least connected to screenwriting, so it deserves a
written decision. It stays: the alternative (a ctranslate2/argos
dependency, a plugin or a separate binary) buys a single menu item at
the cost of cgo and per-distribution packaging, while the in-tree
engine keeps the one-binary, offline, GPL-clean build. The package doc
now says so, with the condition that should reopen the question
(more language pairs).
2026-10-15 17:54:11 +02:00
Kharec 82bfef8cba fix: give each translation worker its share of the CPUs
Translate runs NumCPU/2 sentence workers and each of them started
GOMAXPROCS goroutines for the logit projection of every step: on 16
cores, 128 goroutines fighting for 16 cores. The model now caps the
goroutines of a step (SetThreads, called once after Load) so that
workers x threads stays within the machine.
2026-10-15 17:53:38 +02:00
Kharec d6ee57130f fix: copy decoder caches once per surviving beam, not per candidate
Each beam-search step deep-cloned the keys and values of every layer
for every candidate it kept, so the cost grew quadratically with the
sentence length and produced garbage faster than the unrolled dot()
could win. Survivors are now chosen first; a beam whose parent has a
single survivor takes its slices over (the parent is dead), and only a
shared cache is cloned. The step's other buffers (query/key/value,
hidden, logits) are reused across layers, beams and steps instead of
being allocated afresh each time. TestRealModel confirms the output is
unchanged.
2026-10-15 17:52:37 +02:00
Kharec e80ad634ef feat: warn when a PDF export replaces characters it cannot draw
The hand-written PDF writer uses the standard Courier fonts
(WinAnsiEncoding), so any character outside CP1252 - a Polish, Greek,
Cyrillic or CJK script - was silently written as '?'. Render now
collects the characters it had to replace and returns them, and the
export dialog lists them in a warning once the file is written.

Embedding a TrueType font (e.g. Courier Prime) with a CID/ToUnicode
map would fix the root cause; the warning keeps the zero-dependency
trade-off honest in the meantime.
2026-10-15 17:49:53 +02:00
Kharec 5e7ae97b57 fix: make atomic saves durable and preserve the file
writeAtomic renamed the temporary file over the target without syncing
either: on ext4 with delayed allocation a power cut just after a save
could leave a zero-length script, the exact failure autosave exists to
prevent. Sync the temp file before the rename and the directory after
it. While here, keep an existing file's permissions instead of forcing
0o644, and resolve symlinks first so saving through a link replaces
the file it points to, not the link itself (Dropbox/dotfile setups).
2026-10-15 17:44:56 +02:00
Kharec c0a52a9040 fix: protect the dirty flag with an atomic.Bool
SetDirty runs on a Wails call goroutine while the GTK close handler
reads the flag, with no synchronisation: an edit landing while a save
finished could make the close confirmation unreliable. The spellMu
comment already documents the goroutine-per-call convention; the flag
that guards against losing work deserves the same care.
2026-10-15 17:43:56 +02:00
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
Kharec fd90f9edaf ci: update ignore 2026-09-29 14:25:53 +02:00
Kharec da744d51c6 feat: add pkgbuild 2026-09-29 14:25:50 +02:00
Kharec d8334b1cfd feat: update version 2026-09-29 14:25:46 +02:00
Kharec b652cd88e8 docs: update readme 2026-09-29 14:25:43 +02:00
Kharec 89dafc010e chore: update changelog 2026-09-29 14:25:39 +02:00
Kharec 89a8f0b49b docs: update agent 2026-09-29 14:25:35 +02:00
44 changed files with 4277 additions and 66 deletions

No files matched your search

+4
View File
@@ -37,3 +37,7 @@ frontend/wailsjs/
# Screenshot tool # Screenshot tool
tools/screenshots/node_modules/ tools/screenshots/node_modules/
# makepkg (packaging/arch)
packaging/arch/*
!packaging/arch/PKGBUILD
+53 -1
View File
@@ -46,9 +46,13 @@ behaviour or shortcuts changed.
``` ```
main.go Wails bootstrap: frameless translucent window, LANGUAGE=en, CLI file argument 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 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_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 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 the X11 window icon), plotzilla.desktop, MIME types for .fountain/.fdx
packaging/arch/PKGBUILD Arch package: builds tag $pkgver, installs with `make install`
internal/screenplay the model: Document{TitlePage, Elements, Settings}, Element{Type, Runs, SceneNumber, internal/screenplay the model: Document{TitlePage, Elements, Settings}, Element{Type, Runs, SceneNumber,
Dual, Level, Locked}, Run{Text, b/i/u, Note, Rev}, revision set names Dual, Level, Locked}, Run{Text, b/i/u, Note, Rev}, revision set names
internal/fountain Fountain parser (parse.go) and writer (write.go), incl. Plotzilla's boneyard data internal/fountain Fountain parser (parse.go) and writer (write.go), incl. Plotzilla's boneyard data
@@ -57,6 +61,11 @@ internal/layout line wrapping + pagination (paper sizes, locked pages);
for page geometry for page geometry
internal/pdf hand-written PDF 1.4 writer, standard Courier fonts, zero dependencies 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/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. In-tree on
purpose (one binary, no cgo, offline by design): see the package doc.
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/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/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 frontend/src/schema.ts ProseMirror schema + conversion PM doc ⇄ ScriptDocument JSON
@@ -67,12 +76,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/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/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/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/backend.ts typed wrappers for window.go.main.App and the Wails runtime
frontend/src/icons.ts inline Lucide-style SVG icons 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 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) screenshots/ README images (2880×1800 UI captures + pdf-output.png)
tools/screenshots/ `make screenshots`: sample script, mock server (main.go), Firefox capture (capture.mjs) 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 CHANGELOG.md Keep a Changelog; sections Added, Changed, Fixed only
``` ```
@@ -200,6 +210,19 @@ change `style.css` too** (and vice versa).
itself is unknown (`Dictionary.accepts`): parts need COMPOUNDFLAG or the BEGIN/MIDDLE/LAST 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 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. 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 - 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), character is one document position. Replacements go through `replaceText()` (commands.ts),
which keeps the marks, and each one is its own undo step (`closeHistory`). which keeps the marks, and each one is its own undo step (`closeHistory`).
@@ -230,10 +253,32 @@ change `style.css` too** (and vice versa).
on other goroutines. `query_window_frame` shows how to get an answer back synchronously 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. (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 ## 5. Testing
- Go: unit tests live next to each package (`make test`). Add a test for every parser, - 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. 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 - 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 `pdftoppm -png -r 60 /tmp/x.pdf /tmp/page` and look at the image, or
`pdftotext -layout /tmp/x.pdf -` to check columns. `pdftotext -layout /tmp/x.pdf -` to check columns.
@@ -361,11 +406,18 @@ change `style.css` too** (and vice versa).
reading. Revised text is `<Text RevisionID="n">`, the sets a `<Revisions>` block named 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, `Blue Revision - date`. The exact Final Draft schema for these (ScriptNote placement,
Revisions attributes) was written from memory, not checked against Final Draft itself. 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 parts are bare dictionary words (no affixes), as in Hunspell; the first
COMPOUNDRULE line is the rule count. COMPOUNDRULE line is the rule count.
- A literal BOM or other invisible character in Go source breaks `go vet`; write `""`. - A literal BOM or other invisible character in Go source breaks `go vet`; write `""`.
- `npm` blocks install scripts (esbuild postinstall); the build still works with the bundled - `npm` blocks install scripts (esbuild postinstall); the build still works with the bundled
binary. binary.
- `packaging/arch/PKGBUILD` builds the git tag `$pkgver` (tags have no `v`: `1.1`): on a
release, bump `pkgver` with `VERSION` and push the tag. It installs through
`make install DESTDIR=… PREFIX=/usr`, so a new installed file goes in the Makefile, not in
the PKGBUILD.
- Vite empties `dist/` on each build; placeholders belong in `frontend/public/`. - Vite empties `dist/` on each build; placeholders belong in `frontend/public/`.
- The icon exists three times: `desktop/plotzilla.svg`, its PNG render (`rsvg-convert -w 256 - The icon exists three times: `desktop/plotzilla.svg`, its PNG render (`rsvg-convert -w 256
-h 256 desktop/plotzilla.svg -o desktop/plotzilla.png`) and a simplified copy for small sizes, -h 256 desktop/plotzilla.svg -o desktop/plotzilla.png`) and a simplified copy for small sizes,
+57 -1
View File
@@ -5,7 +5,63 @@ 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 The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). The current
version is in the `VERSION` file. version is in the `VERSION` file.
## [1.1] ## [Unreleased]
## [1.4.1] - 2026-10-15
### Added
- The PDF export warns when the script contains characters the standard PDF fonts
cannot draw: they are written as `?` in the file, and are now listed in a dialog
instead of being silently lost.
### Fixed
- `plotzilla new-script.fountain` opens a new script that will be saved to that file,
instead of an untitled document that forgot the name it was given.
- A Final Draft file named `.txt` (or anything but `.fdx`) is recognised by its
content and opened as FDX, not parsed as Fountain.
- The unsaved-changes marker is now updated atomically: an edit landing while a save
finished could make the close confirmation unreliable.
- Saving is now durable: the file is synced to disk before it replaces the old one (a
power cut just after a save could leave an empty script). An existing file keeps its
permissions, and saving through a symlink replaces the file it points to instead of
replacing the link.
- Translation is faster and uses much less memory: the beam search no longer copies
every beam's decoder state for every candidate at every step, and reuses its
work buffers instead of allocating new ones per layer and step. Each sentence
now uses its share of the processors, so translating a script no longer starts
more workers than the machine has.
## [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`).
### Fixed
- Align VERSION on tag
## [1.1] - 2026-09-29
### Added ### Added
+77 -2
View File
@@ -23,6 +23,7 @@ follows the desktop's window button layout and light/dark preference.
- [Autocomplete](#autocomplete) - [Autocomplete](#autocomplete)
- [Find and replace](#find-and-replace) - [Find and replace](#find-and-replace)
- [Spelling](#spelling) - [Spelling](#spelling)
- [Translating from French](#translating-from-french)
- [Pages and scenes](#pages-and-scenes) - [Pages and scenes](#pages-and-scenes)
- [Revisions and locked pages](#revisions-and-locked-pages) - [Revisions and locked pages](#revisions-and-locked-pages)
- [Title page](#title-page) - [Title page](#title-page)
@@ -50,12 +51,22 @@ MIME types for `.fountain` and `.fdx` files, so file managers offer to open them
Plotzilla. `make install PREFIX=~/.local` installs for your user only (`~/.local/bin` must be in Plotzilla. `make install PREFIX=~/.local` installs for your user only (`~/.local/bin` must be in
your `PATH`); `make uninstall` with the same `PREFIX` removes everything. your `PATH`); `make uninstall` with the same `PREFIX` removes everything.
On Arch Linux, build a package instead, from the tag matching `pkgver`:
```sh
cd packaging/arch && makepkg -si
```
Install the Courier Prime font (`ttf-courier-prime` on the AUR) so the text on screen matches Install the Courier Prime font (`ttf-courier-prime` on the AUR) so the text on screen matches
the PDF exactly. Without it, Plotzilla uses Courier New, Nimbus Mono or Liberation Mono. the PDF exactly. Without it, Plotzilla uses Courier New, Nimbus Mono or Liberation Mono.
Spell checking uses the system's Hunspell dictionaries: install one per language you write in, 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`. 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 editor
The script is shown as a sheet of paper, at the real size and with the real margins of the The script is shown as a sheet of paper, at the real size and with the real margins of the
@@ -259,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 parts to be joined, including the compound patterns some dictionaries describe with Hunspell's
`COMPOUNDRULE`. `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 ## Pages and scenes
Page breaks are computed by the same engine as the PDF export and drawn live in the editor, with Page breaks are computed by the same engine as the PDF export and drawn live in the editor, with
@@ -410,7 +473,8 @@ The title page and first pages of a sample script, as exported:
![PDF output](screenshots/pdf-output.png) ![PDF output](screenshots/pdf-output.png)
The standard Courier fonts cover Western European characters (accents, curly quotes, dashes). The standard Courier fonts cover Western European characters (accents, curly quotes, dashes).
Other characters, such as Greek, Cyrillic or Asian scripts, are printed as `?`. Other characters, such as Greek, Cyrillic or Asian scripts, are printed as `?`: the export then
warns and lists them, so they are not lost silently.
**Final Draft** (`Ctrl+Shift+E`): writes an `.fdx` file that opens in Final Draft and other apps **Final Draft** (`Ctrl+Shift+E`): writes an `.fdx` file that opens in Final Draft and other apps
that read FDX. that read FDX.
@@ -442,6 +506,7 @@ that read FDX.
| `Enter` / `Shift+Enter` in the find field | Next, previous match | | `Enter` / `Shift+Enter` in the find field | Next, previous match |
| `Enter` / `Ctrl+Alt+Enter` in the replace field | Replace, replace all | | `Enter` / `Ctrl+Alt+Enter` in the replace field | Replace, replace all |
| `F7` | Spell checking on or off | | `F7` | Spell checking on or off |
| `Ctrl+Shift+L` | Translate the selection, or the whole script, from French to English |
## Desktop integration ## Desktop integration
@@ -461,6 +526,8 @@ that read FDX.
```sh ```sh
make test # Go tests 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 screenshots # regenerate the images of this README (needs Firefox and pdftoppm)
make dev # live reload, requires the Wails CLI: make dev # live reload, requires the Wails CLI:
# go install github.com/wailsapp/wails/v2/cmd/wails@latest # go install github.com/wailsapp/wails/v2/cmd/wails@latest
@@ -471,16 +538,21 @@ binary embeds `frontend/dist`, so the frontend has to be built first.
``` ```
main.go, app.go Wails application and the methods the frontend calls 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_linux.go desktop integration: light/dark preference, window button layout
desktop/ icon, desktop entry and MIME types installed by `make install` desktop/ icon, desktop entry and MIME types installed by `make install`
packaging/arch PKGBUILD for an Arch Linux package
internal/screenplay document model internal/screenplay document model
internal/fountain Fountain reader and writer internal/fountain Fountain reader and writer
internal/fdx Final Draft reader and writer internal/fdx Final Draft reader and writer
internal/layout line wrapping and pagination, shared by the editor and the PDF internal/layout line wrapping and pagination, shared by the editor and the PDF
internal/pdf PDF generator with no dependencies internal/pdf PDF generator with no dependencies
internal/spell Hunspell dictionary reader and spell checker 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, 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 screenshots/ images used in this README
tools/screenshots the tool behind `make screenshots` tools/screenshots the tool behind `make screenshots`
``` ```
@@ -497,3 +569,6 @@ scripts in the PDF.
Plotzilla is free software, released under the GNU General Public License v3.0. See Plotzilla is free software, released under the GNU General Public License v3.0. See
[LICENSE](LICENSE). [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.0 1.4.1
+106 -24
View File
@@ -10,6 +10,7 @@ import (
"slices" "slices"
"strings" "strings"
"sync" "sync"
"sync/atomic"
"time" "time"
"github.com/wailsapp/wails/v2/pkg/runtime" "github.com/wailsapp/wails/v2/pkg/runtime"
@@ -25,14 +26,21 @@ import (
// App is bound to the frontend: every exported method is callable from JS. // App is bound to the frontend: every exported method is callable from JS.
type App struct { type App struct {
ctx context.Context ctx context.Context
dirty bool // dirty decides whether closing the window asks for confirmation. It is
// written from Wails call goroutines (SetDirty) and read by the GTK
// close handler: atomic, like spellMu protects the checker.
dirty atomic.Bool
initialPath string initialPath string
// Wails runs each call on its own goroutine: spelling calls share one // Wails runs each call on its own goroutine: spelling calls share one
// loaded dictionary. // loaded dictionary. dicts caches them per language so the translation's
// language detection does not read the same files again.
spellMu sync.Mutex spellMu sync.Mutex
checker *spell.Checker checker *spell.Checker
checkerLang string checkerLang string
dicts map[string]*spell.Dictionary
tr translation
} }
type OpenResult struct { type OpenResult struct {
@@ -72,7 +80,7 @@ func (a *App) startup(ctx context.Context) {
// beforeClose keeps the window open while there are unsaved changes and // beforeClose keeps the window open while there are unsaved changes and
// lets the frontend ask; it quits again once the user has decided. // lets the frontend ask; it quits again once the user has decided.
func (a *App) beforeClose(ctx context.Context) bool { func (a *App) beforeClose(ctx context.Context) bool {
if !a.dirty { if !a.dirty.Load() {
return false return false
} }
runtime.EventsEmit(a.ctx, "confirm-close") runtime.EventsEmit(a.ctx, "confirm-close")
@@ -81,13 +89,18 @@ func (a *App) beforeClose(ctx context.Context) bool {
func (a *App) NewDocument() *screenplay.Document { return screenplay.New() } func (a *App) NewDocument() *screenplay.Document { return screenplay.New() }
// InitialFile returns the script passed on the command line, if any. // InitialFile returns the script passed on the command line, if any. A
// path that does not exist yet opens a new script, written there on the
// first save: `plotzilla new-script.fountain` creates the file.
func (a *App) InitialFile() (*OpenResult, error) { func (a *App) InitialFile() (*OpenResult, error) {
if a.initialPath == "" { if a.initialPath == "" {
return nil, nil return nil, nil
} }
path := a.initialPath path := a.initialPath
a.initialPath = "" a.initialPath = ""
if _, err := os.Stat(path); errors.Is(err, os.ErrNotExist) {
return &OpenResult{Path: path, Document: screenplay.New()}, nil
}
return a.load(path) return a.load(path)
} }
@@ -105,7 +118,7 @@ func (a *App) load(path string) (*OpenResult, error) {
return nil, err return nil, err
} }
var doc *screenplay.Document var doc *screenplay.Document
if strings.EqualFold(filepath.Ext(path), ".fdx") { if isFDX(path, data) {
if doc, err = fdx.Read(bytes.NewReader(data)); err != nil { if doc, err = fdx.Read(bytes.NewReader(data)); err != nil {
return nil, err return nil, err
} }
@@ -115,6 +128,16 @@ func (a *App) load(path string) (*OpenResult, error) {
return &OpenResult{Path: path, Document: doc}, nil return &OpenResult{Path: path, Document: doc}, nil
} }
// isFDX reports whether a file is a Final Draft one: the extension says
// so, or its content does — a ".txt" export of Final Draft is still FDX.
func isFDX(path string, data []byte) bool {
if strings.EqualFold(filepath.Ext(path), ".fdx") {
return true
}
head := strings.TrimPrefix(string(data[:min(len(data), 512)]), "\ufeff")
return strings.Contains(head, "<FinalDraft")
}
// Save writes the document to path, asking for one when empty. It returns // Save writes the document to path, asking for one when empty. It returns
// the path written, or "" if the user cancelled. // the path written, or "" if the user cancelled.
func (a *App) Save(doc screenplay.Document, path string) (string, error) { func (a *App) Save(doc screenplay.Document, path string) (string, error) {
@@ -145,11 +168,19 @@ func (a *App) SaveAs(doc screenplay.Document, current string) (string, error) {
return a.Save(doc, path) return a.Save(doc, path)
} }
func (a *App) ExportPDF(doc screenplay.Document, opts ExportOptions, current string) (string, error) { // ExportResult describes a written PDF export.
type ExportResult struct {
Path string `json:"path"` // "" when the user cancelled the dialog
// Replaced are the characters the standard PDF fonts cannot draw:
// each one is written as "?" in the file.
Replaced []string `json:"replaced,omitempty"`
}
func (a *App) ExportPDF(doc screenplay.Document, opts ExportOptions, current string) (ExportResult, error) {
path, err := a.saveDialog("Export PDF", current, ".pdf", path, err := a.saveDialog("Export PDF", current, ".pdf",
[]runtime.FileFilter{{DisplayName: "PDF (*.pdf)", Pattern: "*.pdf"}}) []runtime.FileFilter{{DisplayName: "PDF (*.pdf)", Pattern: "*.pdf"}})
if err != nil || path == "" { if err != nil || path == "" {
return "", err return ExportResult{}, err
} }
var pages []layout.Page var pages []layout.Page
if opts.TitlePage && !doc.TitlePage.IsEmpty() { if opts.TitlePage && !doc.TitlePage.IsEmpty() {
@@ -161,11 +192,18 @@ func (a *App) ExportPDF(doc screenplay.Document, opts ExportOptions, current str
pdfOpts.Revisions = doc.Settings.Revisions pdfOpts.Revisions = doc.Settings.Revisions
} }
var buf bytes.Buffer var buf bytes.Buffer
err = pdf.Render(&buf, pages, pdfOpts) replaced, err := pdf.Render(&buf, pages, pdfOpts)
if err != nil { if err != nil {
return "", err return ExportResult{}, err
} }
return path, writeAtomic(path, buf.Bytes()) if err := writeAtomic(path, buf.Bytes()); err != nil {
return ExportResult{}, err
}
res := ExportResult{Path: path}
for _, r := range replaced {
res.Replaced = append(res.Replaced, string(r))
}
return res, nil
} }
func (a *App) ExportFDX(doc screenplay.Document, current string) (string, error) { func (a *App) ExportFDX(doc screenplay.Document, current string) (string, error) {
@@ -199,7 +237,7 @@ func (a *App) RevisionSet(n int) screenplay.Revision {
return r return r
} }
func (a *App) SetDirty(dirty bool) { a.dirty = dirty } func (a *App) SetDirty(dirty bool) { a.dirty.Store(dirty) }
// dataDir is the XDG data directory (~/.local/share). // dataDir is the XDG data directory (~/.local/share).
func dataDir() string { func dataDir() string {
@@ -273,6 +311,28 @@ func (a *App) SpellLanguages() SpellInfo {
return SpellInfo{Languages: codes, Default: spell.Preferred(codes, locale)} return SpellInfo{Languages: codes, Default: spell.Preferred(codes, locale)}
} }
// dictionaryLocked returns the Hunspell dictionary for a language code
// ("en_US"), loading it the first time. spellMu must be held.
func (a *App) dictionaryLocked(code string) (*spell.Dictionary, error) {
if d, ok := a.dicts[code]; ok {
return d, nil
}
langs := spell.Available(dictionaryDirs())
i := slices.IndexFunc(langs, func(l spell.Language) bool { return l.Code == code })
if i < 0 {
return nil, fmt.Errorf("no %s dictionary installed", code)
}
d, err := spell.Load(langs[i].Aff, langs[i].Dic)
if err != nil {
return nil, fmt.Errorf("loading the %s dictionary: %w", code, err)
}
if a.dicts == nil {
a.dicts = map[string]*spell.Dictionary{}
}
a.dicts[code] = d
return d, nil
}
// spellChecker returns the checker for lang, loading its dictionary the // spellChecker returns the checker for lang, loading its dictionary the
// first time it is asked for. // first time it is asked for.
func (a *App) spellChecker(lang string) (*spell.Checker, error) { func (a *App) spellChecker(lang string) (*spell.Checker, error) {
@@ -281,15 +341,9 @@ func (a *App) spellChecker(lang string) (*spell.Checker, error) {
if a.checker != nil && a.checkerLang == lang { if a.checker != nil && a.checkerLang == lang {
return a.checker, nil return a.checker, nil
} }
langs := spell.Available(dictionaryDirs()) dict, err := a.dictionaryLocked(lang)
i := slices.IndexFunc(langs, func(l spell.Language) bool { return l.Code == lang })
if i < 0 {
return nil, fmt.Errorf("no %s dictionary installed", lang)
}
l := langs[i]
dict, err := spell.Load(l.Aff, l.Dic)
if err != nil { if err != nil {
return nil, fmt.Errorf("loading the %s dictionary: %w", lang, err) return nil, err
} }
var personal []string var personal []string
if data, err := os.ReadFile(personalDictionaryPath()); err == nil { if data, err := os.ReadFile(personalDictionaryPath()); err == nil {
@@ -380,20 +434,48 @@ func (a *App) saveDialog(title, current, ext string, filters []runtime.FileFilte
return path, nil return path, nil
} }
// writeAtomic writes data to path atomically and durably: a temporary file
// in the same directory is written, given the file's permissions and synced
// to disk, then renamed over path and the directory itself synced, so a
// power cut just after a save cannot leave an empty script behind (the very
// failure autosave, which calls this every 1.2 s, exists to prevent). A
// symlinked path is resolved first: the file it points to is replaced, not
// the link. An existing file keeps its permissions; a new one gets 0o644.
func writeAtomic(path string, data []byte) error { func writeAtomic(path string, data []byte) error {
if target, err := filepath.EvalSymlinks(path); err == nil {
path = target
}
mode := os.FileMode(0o644)
if st, err := os.Stat(path); err == nil {
mode = st.Mode().Perm()
}
tmp, err := os.CreateTemp(filepath.Dir(path), "."+filepath.Base(path)+".*") tmp, err := os.CreateTemp(filepath.Dir(path), "."+filepath.Base(path)+".*")
if err != nil { if err != nil {
return err return err
} }
_, werr := tmp.Write(data) if _, err = tmp.Write(data); err == nil {
cerr := tmp.Close() err = tmp.Chmod(mode)
if err := errors.Join(werr, cerr); err != nil { }
if err == nil {
err = tmp.Sync()
}
if cerr := tmp.Close(); err == nil {
err = cerr
}
if err != nil {
os.Remove(tmp.Name()) os.Remove(tmp.Name())
return err return err
} }
if err := os.Chmod(tmp.Name(), 0o644); err != nil { if err := os.Rename(tmp.Name(), path); err != nil {
os.Remove(tmp.Name()) os.Remove(tmp.Name())
return err return err
} }
return os.Rename(tmp.Name(), path) // The rename is a directory entry: sync the directory so it too
// survives a crash.
dir, err := os.Open(filepath.Dir(path))
if err != nil {
return err
}
defer dir.Close()
return dir.Sync()
} }
+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="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="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="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>
<div class="group"> <div class="group">
<button data-action="title" data-icon="title" data-tip="Title page · Ctrl+Shift+T"></button> <button data-action="title" data-icon="title" data-tip="Title page · Ctrl+Shift+T"></button>
@@ -163,6 +164,17 @@
</form> </form>
</dialog> </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> <script type="module" src="/src/main.ts"></script>
</body> </body>
</html> </html>
+3 -3
View File
@@ -1155,9 +1155,9 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/source-map-js": { "node_modules/source-map-js": {
"version": "1.2.1", "version": "1.2.2",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.2.tgz",
"integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", "integrity": "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==",
"dev": true, "dev": true,
"license": "BSD-3-Clause", "license": "BSD-3-Clause",
"engines": { "engines": {
+33 -1
View File
@@ -120,19 +120,45 @@ export interface WindowFrame {
rounded: boolean 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 { export interface ExportOptions {
sceneNumbers: boolean sceneNumbers: boolean
titlePage: boolean titlePage: boolean
revisions: boolean revisions: boolean
} }
/** A written PDF export. */
export interface ExportResult {
/** Empty when the user cancelled the dialog. */
path: string
/** Characters the standard PDF fonts cannot draw, written as "?" in the file. */
replaced?: string[]
}
interface GoApp { interface GoApp {
NewDocument(): Promise<ScriptDocument> NewDocument(): Promise<ScriptDocument>
InitialFile(): Promise<OpenResult | null> InitialFile(): Promise<OpenResult | null>
Open(): Promise<OpenResult | null> Open(): Promise<OpenResult | null>
Save(doc: ScriptDocument, path: string): Promise<string> Save(doc: ScriptDocument, path: string): Promise<string>
SaveAs(doc: ScriptDocument, current: string): Promise<string> SaveAs(doc: ScriptDocument, current: string): Promise<string>
ExportPDF(doc: ScriptDocument, opts: ExportOptions, current: string): Promise<string> ExportPDF(doc: ScriptDocument, opts: ExportOptions, current: string): Promise<ExportResult>
ExportFDX(doc: ScriptDocument, current: string): Promise<string> ExportFDX(doc: ScriptDocument, current: string): Promise<string>
Paginate(doc: ScriptDocument): Promise<LayoutInfo> Paginate(doc: ScriptDocument): Promise<LayoutInfo>
ParseFountain(text: string): Promise<Element[] | null> ParseFountain(text: string): Promise<Element[] | null>
@@ -146,6 +172,12 @@ interface GoApp {
SpellCheck(doc: ScriptDocument, lang: string): Promise<Misspelling[] | null> SpellCheck(doc: ScriptDocument, lang: string): Promise<Misspelling[] | null>
SpellSuggest(word: string, lang: string): Promise<string[] | null> SpellSuggest(word: string, lang: string): Promise<string[] | null>
AddToDictionary(word: string): Promise<void> 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 { 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"/>', 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"/>', 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"/>', 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"/>', check: '<path d="M20 6 9 17l-5-5"/>',
chevron: '<path d="m6 9 6 6 6-6"/>', chevron: '<path d="m6 9 6 6 6-6"/>',
close: '<path d="M17 7 7 17"/><path d="m7 7 10 10"/>', close: '<path d="M17 7 7 17"/><path d="m7 7 10 10"/>',
+116 -1
View File
@@ -36,6 +36,7 @@ import { buildSpellDecorations, misspellingAt, spellKey, spellPlugin, withoutWor
import { closeMenu, menuOpen, openMenu, type MenuItem } from './menu' import { closeMenu, menuOpen, openMenu, type MenuItem } from './menu'
import { activeMatch, replaceAll, replaceMatch, searchMatches, searchPlugin, selectMatch, setSearch } from './search' import { activeMatch, replaceAll, replaceMatch, searchMatches, searchPlugin, selectMatch, setSearch } from './search'
import { clearRevisionMarks, lockPages, lockPlugin, revisionPlugin, unlockPages, type CurrentRevision } from './revisions' import { clearRevisionMarks, lockPages, lockPlugin, revisionPlugin, unlockPages, type CurrentRevision } from './revisions'
import { applyTranslation, selectedElements } from './translate'
import { icon } from './icons' import { icon } from './icons'
const $ = <T extends HTMLElement>(sel: string) => document.querySelector(sel) as T const $ = <T extends HTMLElement>(sel: string) => document.querySelector(sel) as T
@@ -736,6 +737,110 @@ replaceInput.addEventListener('keydown', (e) => {
e.preventDefault() 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 // ----------------------------------------------------------------- files
interface MessageOptions { interface MessageOptions {
@@ -842,7 +947,15 @@ const actions: Record<string, () => unknown> = {
renderPaper() renderPaper()
settingsChanged() settingsChanged()
} }
await api.ExportPDF(script(), chosen, app.path).catch(reportError) const res = await api.ExportPDF(script(), chosen, app.path).catch(reportError)
if (res?.replaced?.length) {
const list = res.replaced.map((c) => `"${c}"`).join(' ')
await messageBox({
title: 'Characters replaced in the PDF',
text: `The standard PDF fonts cannot draw these characters: ${list}. Each one is written as "?" in the exported file.`,
ok: 'OK',
})
}
} }
dialog.showModal() dialog.showModal()
}, },
@@ -877,6 +990,7 @@ const actions: Record<string, () => unknown> = {
underline: () => toggleMark(schema.marks.underline)(view.state, view.dispatch), underline: () => toggleMark(schema.marks.underline)(view.state, view.dispatch),
note: () => toggleMark(schema.marks.note)(view.state, view.dispatch), note: () => toggleMark(schema.marks.note)(view.state, view.dispatch),
revisions: revisionsMenu, revisions: revisionsMenu,
translate: translateScript,
dual: () => toggleDual(view.state, view.dispatch), dual: () => toggleDual(view.state, view.dispatch),
navigator() { navigator() {
document.body.classList.toggle('no-navigator') document.body.classList.toggle('no-navigator')
@@ -931,6 +1045,7 @@ const shortcuts: Record<string, string> = {
f3: 'findNext', f3: 'findNext',
'shift+f3': 'findPrevious', 'shift+f3': 'findPrevious',
f7: 'toggleSpelling', f7: 'toggleSpelling',
'ctrl+shift+l': 'translate',
'ctrl+=': 'zoomIn', 'ctrl+=': 'zoomIn',
'ctrl++': 'zoomIn', 'ctrl++': 'zoomIn',
'ctrl+-': 'zoomOut', '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) 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. */ /** Removes every revision mark; one undo step. */
export function clearRevisionMarks(state: EditorState): Transaction { export function clearRevisionMarks(state: EditorState): Transaction {
return state.tr.removeMark(0, state.doc.content.size, schema.marks.revision).setMeta(INTERNAL, true) 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; 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 { dialog menu button:focus-visible {
outline: 2px solid var(--accent-2); outline: 2px solid var(--accent-2);
outline-offset: 2px; 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)
}
+271
View File
@@ -0,0 +1,271 @@
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. When a
// beam is the only survivor of its parent it takes the parent's slices
// over instead of copying them (the parent is dead from that step on).
keys, values [][]float32
}
type hypothesis struct {
tokens []int
score float64 // length-normalised
}
type candidate struct {
parent, token int
score float64
}
// decoderScratch holds the work buffers of a decoding step, shared by the
// layers, the beams and the steps of a search: allocating them afresh for
// every layer of every step costs more than the dot products they serve.
type decoderScratch struct {
q, k, v []float32
ctxv, tmp []float32
scores []float32
hidden []float32
}
// 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))
ffn := 0
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)
ffn = max(ffn, l.fc1.out)
}
maxLen := min(m.cfg.MaxPositions-1, 2*srcLen+10)
scratch := &decoderScratch{
q: make([]float32, d), k: make([]float32, d), v: make([]float32, d),
ctxv: make([]float32, d), tmp: make([]float32, d),
scores: make([]float32, max(maxLen+1, srcLen)),
hidden: make([]float32, ffn),
}
// One vocabulary of logits per beam, reused at every step: the largest
// allocation of the search.
probs := make([][]float32, numBeams)
for i := range probs {
probs[i] = make([]float32, m.cfg.VocabSize)
}
beams := []beam{{
tokens: []int{m.cfg.DecoderStart},
keys: make([][]float32, len(m.decoder)),
values: make([][]float32, len(m.decoder)),
}}
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, scratch)
}
logProbs := m.logProbs(hidden, probs[:len(beams)])
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
})
// Choose the surviving beams first, then build them: a cache is
// copied at most once per step, and only when several survivors
// share its parent.
children := make([]int, len(beams))
chosen := make([]candidate, 0, numBeams)
for rank, c := range cands {
if c.token == m.cfg.EOSToken {
if rank < numBeams {
parent := beams[c.parent]
done = append(done, hypothesis{tokens: slices.Clone(parent.tokens), score: c.score / float64(len(parent.tokens))})
}
continue
}
chosen = append(chosen, c)
children[c.parent]++
if len(chosen) == numBeams {
break
}
}
next := make([]beam, 0, len(chosen))
for _, c := range chosen {
parent := &beams[c.parent]
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)),
}
takeOver := children[c.parent] == 1
for l := range parent.keys {
if takeOver {
child.keys[l] = parent.keys[l]
child.values[l] = parent.values[l]
} else {
child.keys[l] = slices.Clone(parent.keys[l])
child.values[l] = slices.Clone(parent.values[l])
}
}
next = append(next, child)
}
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, s *decoderScratch) []float32 {
d := m.cfg.DModel
pos := len(b.tokens) - 1
tok := b.tokens[pos]
x := make([]float32, d) // the beam's output vector, which lives past the step
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, k, v, ctxv, tmp, scores := s.q, s.k, s.v, s.ctxv, s.tmp, s.scores
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 := s.hidden[: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 in
// out, one vocabulary per beam, reused from step to step. This is the
// largest product of a step, so it is split across CPUs.
func (m *Model) logProbs(hidden, out [][]float32) [][]float32 {
d, vocab := m.cfg.DModel, m.cfg.VocabSize
workers := runtime.GOMAXPROCS(0)
if m.threads > 0 {
workers = min(workers, m.threads)
}
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
}
+190
View File
@@ -0,0 +1,190 @@
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
}
// 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
}
+411
View File
@@ -0,0 +1,411 @@
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)
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 _, 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")
}
}
+406
View File
@@ -0,0 +1,406 @@
// 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.
//
// The engine lives in-tree on purpose. Plotzilla ships as one binary built
// with only the Go toolchain: a native runtime (ctranslate2, argos) would
// add cgo and per-distribution packaging, and a separate binary or plugin
// an install step, all for a single menu item. Translation is offline by
// design — an unpublished script never leaves the machine — and the model
// is data downloaded at runtime (Apache-2.0), so neither the "no
// third-party dependency" rule nor the GPL is affected. The screenplay
// conventions stay out of this package (internal/translate owns them) and
// the pipeline is covered by a synthetic tiny model and, on demand, the
// real one. If more language pairs are ever added, each with its own
// 300 MB download and quirks, reconsider a separate optional component.
package marian
import (
"context"
"encoding/json"
"fmt"
"math"
"os"
"path/filepath"
)
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 (but
// for SetThreads, right after Load): Translate may be called from several
// goroutines at once.
type Model struct {
cfg config
threads int // goroutines a decoding step may start (0: one per CPU)
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
}
// SetThreads caps how many goroutines each decoding step starts (0: one
// per CPU, the default). When several sentences are translated at once,
// give each its share of the CPUs, or every step of every sentence starts
// a team of goroutines and they oversubscribe the machine. It must be
// called before the first Translate.
func (m *Model) SetThreads(n int) { m.threads = n }
// 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)
ffn := 0 // the hidden layer of every encoder layer, allocated once
for _, l := range m.encoder {
ffn = max(ffn, l.fc1.out)
}
hidden := make([]float32, n*ffn)
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)
h := hidden[:n*l.fc1.out]
l.fc1.rows(h, x, n)
for i := range h {
h[i] = m.activation(h[i])
}
l.fc2.rows(tmp, h, 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, " "))
}
+44 -24
View File
@@ -9,6 +9,7 @@ import (
"fmt" "fmt"
"io" "io"
"math" "math"
"slices"
"strings" "strings"
"unicode/utf16" "unicode/utf16"
@@ -34,8 +35,11 @@ const (
var fontNames = []string{"Courier", "Courier-Bold", "Courier-Oblique", "Courier-BoldOblique"} var fontNames = []string{"Courier", "Courier-Bold", "Courier-Oblique", "Courier-BoldOblique"}
// Render writes pages to w. The title page, if any, must be the first page // Render writes pages to w. The title page, if any, must be the first page
// and have Number 0 so that it is not numbered. // and have Number 0 so that it is not numbered. It returns, sorted, the
func Render(w io.Writer, pages []layout.Page, opt Options) error { // characters the standard Courier fonts cannot draw: each one is written
// as "?" in the output, so the caller can warn instead of corrupting the
// script silently.
func Render(w io.Writer, pages []layout.Page, opt Options) ([]rune, error) {
geo := layout.PaperGeometry(opt.Paper) geo := layout.PaperGeometry(opt.Paper)
var objs [][]byte // objs[i] is object number i+1 var objs [][]byte // objs[i] is object number i+1
add := func(s string) int { add := func(s string) int {
@@ -50,11 +54,12 @@ func Render(w io.Writer, pages []layout.Page, opt Options) error {
} }
add(fmt.Sprintf("<< /Title %s /Author %s /Creator (Plotzilla) /Producer (Plotzilla) >>", textString(opt.Title), textString(opt.Author))) add(fmt.Sprintf("<< /Title %s /Author %s /Creator (Plotzilla) /Producer (Plotzilla) >>", textString(opt.Title), textString(opt.Author)))
missing := map[rune]bool{}
var kids []string var kids []string
for _, pg := range pages { for _, pg := range pages {
stream, err := deflate(pageContent(pg, geo, opt.Revisions)) stream, err := deflate(pageContent(pg, geo, opt.Revisions, missing))
if err != nil { if err != nil {
return err return nil, err
} }
content := add(fmt.Sprintf("<< /Length %d /Filter /FlateDecode >>\nstream\n%s\nendstream", len(stream), stream)) content := add(fmt.Sprintf("<< /Length %d /Filter /FlateDecode >>\nstream\n%s\nendstream", len(stream), stream))
page := add(fmt.Sprintf("<< /Type /Page /Parent 2 0 R /MediaBox [0 0 %g %g] "+ page := add(fmt.Sprintf("<< /Type /Page /Parent 2 0 R /MediaBox [0 0 %g %g] "+
@@ -80,7 +85,16 @@ func Render(w io.Writer, pages []layout.Page, opt Options) error {
} }
fmt.Fprintf(&buf, "trailer\n<< /Size %d /Root 1 0 R /Info 7 0 R >>\nstartxref\n%d\n%%%%EOF\n", len(objs)+1, xref) fmt.Fprintf(&buf, "trailer\n<< /Size %d /Root 1 0 R /Info 7 0 R >>\nstartxref\n%d\n%%%%EOF\n", len(objs)+1, xref)
_, err := w.Write(buf.Bytes()) _, err := w.Write(buf.Bytes())
return err return sortedRunes(missing), err
}
func sortedRunes(set map[rune]bool) []rune {
out := make([]rune, 0, len(set))
for r := range set {
out = append(out, r)
}
slices.Sort(out)
return out
} }
// points converts inches to PDF points, rounded to 1/100 pt. // points converts inches to PDF points, rounded to 1/100 pt.
@@ -92,16 +106,16 @@ func baseline(geo layout.Geometry, row int) float64 {
return top - float64(row)*fontSize - 9 return top - float64(row)*fontSize - 9
} }
func text(b *bytes.Buffer, x, y float64, s string) { func text(b *bytes.Buffer, x, y float64, s string, missing map[rune]bool) {
fmt.Fprintf(b, "BT /F1 12 Tf %.2f %.2f Td %s Tj ET\n", x, y, pdfString(s)) fmt.Fprintf(b, "BT /F1 12 Tf %.2f %.2f Td %s Tj ET\n", x, y, pdfString(s, missing))
} }
func pageContent(pg layout.Page, geo layout.Geometry, revisions []screenplay.Revision) []byte { func pageContent(pg layout.Page, geo layout.Geometry, revisions []screenplay.Revision, missing map[rune]bool) []byte {
var b bytes.Buffer var b bytes.Buffer
header := (geo.Height-0.5)*ptPerIn - 9 header := (geo.Height-0.5)*ptPerIn - 9
if pg.Number > 0 && pg.Label != "" && pg.Label != "1" { if pg.Number > 0 && pg.Label != "" && pg.Label != "1" {
num := pg.Label + "." num := pg.Label + "."
text(&b, layout.RightEdge*ptPerIn-float64(len([]rune(num)))*fontSize*0.6, header, num) text(&b, layout.RightEdge*ptPerIn-float64(len([]rune(num)))*fontSize*0.6, header, num, missing)
} }
// A revised page names its revision; its latest changes are starred. // A revised page names its revision; its latest changes are starred.
rev := pg.Rev() rev := pg.Rev()
@@ -114,17 +128,17 @@ func pageContent(pg layout.Page, geo layout.Geometry, revisions []screenplay.Rev
if r.Date != "" { if r.Date != "" {
label += " - " + r.Date label += " - " + r.Date
} }
text(&b, 1.5*ptPerIn, header, label) text(&b, 1.5*ptPerIn, header, label, missing)
} }
for _, ln := range pg.Lines { for _, ln := range pg.Lines {
y := baseline(geo, ln.Row) y := baseline(geo, ln.Row)
x := ln.X * ptPerIn x := ln.X * ptPerIn
if ln.SceneNumber != "" { if ln.SceneNumber != "" {
text(&b, 0.75*ptPerIn, y, ln.SceneNumber) text(&b, 0.75*ptPerIn, y, ln.SceneNumber, missing)
text(&b, geo.SceneNumber*ptPerIn, y, ln.SceneNumber) text(&b, geo.SceneNumber*ptPerIn, y, ln.SceneNumber, missing)
} }
if rev > 0 && ln.Rev == rev { if rev > 0 && ln.Rev == rev {
text(&b, geo.RevisionMark*ptPerIn, y, "*") text(&b, geo.RevisionMark*ptPerIn, y, "*", missing)
} }
fmt.Fprintf(&b, "BT %.2f %.2f Td ", x, y) fmt.Fprintf(&b, "BT %.2f %.2f Td ", x, y)
cursor := x cursor := x
@@ -142,7 +156,7 @@ func pageContent(pg layout.Page, geo layout.Geometry, revisions []screenplay.Rev
underlines = append(underlines, [2]float64{cursor, cursor + width}) underlines = append(underlines, [2]float64{cursor, cursor + width})
} }
cursor += width cursor += width
fmt.Fprintf(&b, "/F%d 12 Tf %s Tj ", font, pdfString(s.Text)) fmt.Fprintf(&b, "/F%d 12 Tf %s Tj ", font, pdfString(s.Text, missing))
} }
b.WriteString("ET\n") b.WriteString("ET\n")
for _, u := range underlines { for _, u := range underlines {
@@ -164,12 +178,16 @@ func deflate(data []byte) ([]byte, error) {
return b.Bytes(), nil return b.Bytes(), nil
} }
// pdfString encodes s as a WinAnsi literal string for the Courier fonts. // pdfString encodes s as a WinAnsi literal string for the Courier fonts,
func pdfString(s string) string { // recording in missing the characters they cannot draw.
func pdfString(s string, missing map[rune]bool) string {
var b strings.Builder var b strings.Builder
b.WriteByte('(') b.WriteByte('(')
for _, r := range s { for _, r := range s {
c := winAnsi(r) c, ok := winAnsi(r)
if !ok {
missing[r] = true
}
if c == '(' || c == ')' || c == '\\' { if c == '(' || c == ')' || c == '\\' {
b.WriteByte('\\') b.WriteByte('\\')
} }
@@ -189,7 +207,7 @@ func textString(s string) string {
} }
} }
if ascii { if ascii {
return pdfString(s) return pdfString(s, map[rune]bool{}) // ASCII only: nothing to record
} }
var b strings.Builder var b strings.Builder
b.WriteString("<FEFF") b.WriteString("<FEFF")
@@ -207,18 +225,20 @@ var cp1252 = map[rune]byte{
'˜': 0x98, '™': 0x99, 'š': 0x9a, '›': 0x9b, 'œ': 0x9c, 'ž': 0x9e, 'Ÿ': 0x9f, '˜': 0x98, '™': 0x99, 'š': 0x9a, '›': 0x9b, 'œ': 0x9c, 'ž': 0x9e, 'Ÿ': 0x9f,
} }
func winAnsi(r rune) byte { // winAnsi encodes r as a WinAnsi byte; ok is false when the standard
// fonts cannot draw r and '?' is written instead.
func winAnsi(r rune) (c byte, ok bool) {
switch { switch {
case r >= 0x20 && r < 0x7f: case r >= 0x20 && r < 0x7f:
return byte(r) return byte(r), true
case r >= 0xa0 && r <= 0xff: case r >= 0xa0 && r <= 0xff:
return byte(r) return byte(r), true
} }
if c, ok := cp1252[r]; ok { if c, ok := cp1252[r]; ok {
return c return c, true
} }
if r == ' ' || r == ' ' { if r == ' ' || r == ' ' {
return ' ' // narrow spaces used before French punctuation return ' ', true // narrow spaces used before French punctuation
} }
return '?' return '?', false
} }
+36 -3
View File
@@ -5,6 +5,7 @@ import (
"compress/zlib" "compress/zlib"
"io" "io"
"os" "os"
"slices"
"strings" "strings"
"testing" "testing"
@@ -39,9 +40,13 @@ func TestRender(t *testing.T) {
pages := []layout.Page{layout.TitlePage(doc.TitlePage, doc.Settings.Paper)} pages := []layout.Page{layout.TitlePage(doc.TitlePage, doc.Settings.Paper)}
pages = append(pages, layout.Paginate(doc, layout.Options{SceneNumbers: true}).Pages...) pages = append(pages, layout.Paginate(doc, layout.Options{SceneNumbers: true}).Pages...)
var buf bytes.Buffer var buf bytes.Buffer
if err := Render(&buf, pages, Options{Title: doc.TitlePage.Title, Author: doc.TitlePage.Author}); err != nil { replaced, err := Render(&buf, pages, Options{Title: doc.TitlePage.Title, Author: doc.TitlePage.Author})
if err != nil {
t.Fatal(err) t.Fatal(err)
} }
if len(replaced) != 0 {
t.Errorf("the French sample is fully representable, replaced = %q", string(replaced))
}
out := buf.String() out := buf.String()
if !strings.HasPrefix(out, "%PDF-1.4") || !strings.HasSuffix(out, "%%EOF\n") || !strings.Contains(out, "/Count 2") { if !strings.HasPrefix(out, "%PDF-1.4") || !strings.HasSuffix(out, "%%EOF\n") || !strings.Contains(out, "/Count 2") {
t.Fatalf("malformed PDF:\n%.300s", out) t.Fatalf("malformed PDF:\n%.300s", out)
@@ -61,7 +66,7 @@ func TestRevisionsAndA4(t *testing.T) {
doc.Elements[1].Runs[0].Rev = 1 doc.Elements[1].Runs[0].Rev = 1
var buf bytes.Buffer var buf bytes.Buffer
pages := layout.Paginate(doc, layout.Options{}).Pages pages := layout.Paginate(doc, layout.Options{}).Pages
if err := Render(&buf, pages, Options{Paper: doc.Settings.Paper, Revisions: doc.Settings.Revisions}); err != nil { if _, err := Render(&buf, pages, Options{Paper: doc.Settings.Paper, Revisions: doc.Settings.Revisions}); err != nil {
t.Fatal(err) t.Fatal(err)
} }
stream := uncompressed(t, buf.Bytes()) stream := uncompressed(t, buf.Bytes())
@@ -74,7 +79,7 @@ func TestRevisionsAndA4(t *testing.T) {
// Without revisions in the options, nothing is marked. // Without revisions in the options, nothing is marked.
buf.Reset() buf.Reset()
if err := Render(&buf, pages, Options{Paper: doc.Settings.Paper}); err != nil { if _, err := Render(&buf, pages, Options{Paper: doc.Settings.Paper}); err != nil {
t.Fatal(err) t.Fatal(err)
} }
if stream := uncompressed(t, buf.Bytes()); strings.Contains(stream, "Revision") || strings.Contains(stream, "(*)") { if stream := uncompressed(t, buf.Bytes()); strings.Contains(stream, "Revision") || strings.Contains(stream, "(*)") {
@@ -82,6 +87,34 @@ func TestRevisionsAndA4(t *testing.T) {
} }
} }
// TestReplacedChars: characters outside WinAnsi are written as '?' and
// reported, so an export can warn instead of corrupting the text silently.
func TestReplacedChars(t *testing.T) {
src := "INT. ΚΑΦΕΝΕΙΟ - ΜΕΡΑ\n\nΚαλημέρα… Plain ASCII.\n"
doc := fountain.Parse(src)
pages := layout.Paginate(doc, layout.Options{}).Pages
var buf bytes.Buffer
replaced, err := Render(&buf, pages, Options{})
if err != nil {
t.Fatal(err)
}
got := string(replaced)
for _, r := range src { // every Greek letter is reported
if r > 0x7f && r != '…' && !strings.ContainsRune(got, r) {
t.Errorf("%q not reported in %q", r, got)
}
}
if strings.ContainsRune(got, '…') { // WinAnsi has "…"
t.Errorf("representable … reported in %q", got)
}
if !slices.IsSorted(replaced) {
t.Errorf("replaced not sorted: %q", got)
}
if stream := uncompressed(t, buf.Bytes()); !strings.Contains(stream, "????????") {
t.Errorf("Greek not written as '?':\n%s", stream)
}
}
// uncompressed returns the content streams of a PDF written by Render. // uncompressed returns the content streams of a PDF written by Render.
func uncompressed(t *testing.T, pdf []byte) string { func uncompressed(t *testing.T, pdf []byte) string {
var out strings.Builder var out strings.Builder
+9
View File
@@ -90,6 +90,12 @@ func (c *Checker) Add(word string) {
func normalize(w string) string { return strings.ReplaceAll(w, "’", "'") } func normalize(w string) string { return strings.ReplaceAll(w, "’", "'") }
// cacheLimit bounds the remembered answers, which are keyed on user input:
// a long session of typos would grow the map forever otherwise. When full,
// the cache is cleared and refilled; a script repeats the same words, so
// re-checks are cheap.
const cacheLimit = 10000
// Check reports whether word is correct. Words with digits ("3rd", "R2D2") // Check reports whether word is correct. Words with digits ("3rd", "R2D2")
// and single letters are always accepted. // and single letters are always accepted.
func (c *Checker) Check(word string) bool { func (c *Checker) Check(word string) bool {
@@ -104,6 +110,9 @@ func (c *Checker) Check(word string) bool {
} }
ok, found := c.cache[word] ok, found := c.cache[word]
if !found { if !found {
if len(c.cache) >= cacheLimit {
clear(c.cache)
}
ok = c.dict.Check(word) ok = c.dict.Check(word)
c.cache[word] = ok c.cache[word] = ok
} }
+29
View File
@@ -180,6 +180,35 @@ func TestLatin1(t *testing.T) {
} }
} }
func TestCheckerCacheBound(t *testing.T) {
d, err := Parse([]byte("SET UTF-8\n"), []byte("1\nknown\n"))
if err != nil {
t.Fatal(err)
}
c := NewChecker(d, nil)
// letters encodes n in bijective base 26: distinct pure-letter words.
letters := func(n int) string {
var w []rune
for {
w = append([]rune{rune('a' + n%26)}, w...)
if n < 26 {
return string(w)
}
n = n/26 - 1
}
}
for i := range 2 * cacheLimit {
c.Check(letters(i))
}
if len(c.cache) > cacheLimit {
t.Errorf("cache holds %d words, limit %d", len(c.cache), cacheLimit)
}
// The answers are unchanged by a clear.
if !c.Check("known") || c.Check("knownn") {
t.Error("wrong answers after the cache cleared")
}
}
func TestWords(t *testing.T) { func TestWords(t *testing.T) {
got := Words("Élise's well-known R2D2, l’homme… 'quoted' x") got := Words("Élise's well-known R2D2, l’homme… 'quoted' x")
want := []Word{ want := []Word{
+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)
}
}
}
+2 -4
View File
@@ -29,9 +29,7 @@ func main() {
app := NewApp() app := NewApp()
for _, arg := range os.Args[1:] { for _, arg := range os.Args[1:] {
if !strings.HasPrefix(arg, "-") { if !strings.HasPrefix(arg, "-") {
if _, err := os.Stat(arg); err == nil { app.initialPath = arg // may not exist yet: then it is a new script
app.initialPath = arg
}
break break
} }
} }
@@ -49,7 +47,7 @@ func main() {
BackgroundColour: &options.RGBA{R: 0, G: 0, B: 0, A: 0}, BackgroundColour: &options.RGBA{R: 0, G: 0, B: 0, A: 0},
OnStartup: app.startup, OnStartup: app.startup,
OnBeforeClose: app.beforeClose, OnBeforeClose: app.beforeClose,
Bind: []interface{}{app}, Bind: []any{app},
Linux: &linux.Options{ Linux: &linux.Options{
ProgramName: "plotzilla", ProgramName: "plotzilla",
Icon: icon, Icon: icon,
+54
View File
@@ -0,0 +1,54 @@
# Maintainer: Sandro Cazzaniga <sandro@cazzaniga.fr>
pkgname=plotzilla
pkgver=1.4.1
pkgrel=1
pkgdesc='Screenwriting app for Linux: scripts in the Hollywood standard format, Fountain, FDX and PDF'
arch=('x86_64' 'aarch64')
url='https://git.kharec.info/Kharec/plotzilla'
license=('GPL-3.0-only')
depends=('gtk3' 'webkit2gtk-4.1' 'libsoup3' 'gdk-pixbuf2' 'glib2' 'glibc' 'hicolor-icon-theme')
makedepends=('git' 'go' 'nodejs' 'npm')
optdepends=('ttf-courier-prime: the font of the printed page, on screen too'
'hunspell-en_us: English spell checking'
'hunspell-fr: French spell checking')
source=("$pkgname::git+$url.git#tag=$pkgver")
sha256sums=('SKIP')
prepare() {
cd "$pkgname"
export GOPATH="$srcdir/gopath"
go mod download -modcacherw
# Downloads belong in prepare(); the build then runs offline.
npm ci --prefix frontend --no-audit --no-fund
}
build() {
cd "$pkgname"
export GOPATH="$srcdir/gopath"
export CGO_CPPFLAGS="$CPPFLAGS"
export CGO_CFLAGS="$CFLAGS"
export CGO_CXXFLAGS="$CXXFLAGS"
export CGO_LDFLAGS="$LDFLAGS"
export GOFLAGS='-buildmode=pie -trimpath -mod=readonly -modcacherw'
npm run build --prefix frontend
# The binary embeds frontend/dist; webkit2_41 selects WebKitGTK 4.1.
go build -tags desktop,production,webkit2_41 \
-ldflags "-linkmode=external -compressdwarf=false" \
-o build/bin/plotzilla .
}
check() {
cd "$pkgname"
export GOPATH="$srcdir/gopath"
go test ./internal/...
}
package() {
cd "$pkgname"
# DESTDIR skips the cache updates: pacman's hooks refresh the MIME,
# desktop and icon caches.
make install DESTDIR="$pkgdir" PREFIX=/usr
install -Dm644 README.md CHANGELOG.md -t "$pkgdir/usr/share/doc/$pkgname"
}
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

+1 -1
View File
@@ -277,7 +277,7 @@ func pdfOutput(src, tmp, path string) error {
pages := append([]layout.Page{layout.TitlePage(doc.TitlePage, doc.Settings.Paper)}, pages := append([]layout.Page{layout.TitlePage(doc.TitlePage, doc.Settings.Paper)},
layout.Paginate(doc, layout.Options{SceneNumbers: true}).Pages...) layout.Paginate(doc, layout.Options{SceneNumbers: true}).Pages...)
var b bytes.Buffer var b bytes.Buffer
if err := pdf.Render(&b, pages, pdf.Options{Title: doc.TitlePage.Title, Author: doc.TitlePage.Author}); err != nil { if _, err := pdf.Render(&b, pages, pdf.Options{Title: doc.TitlePage.Title, Author: doc.TitlePage.Author}); err != nil {
return err return err
} }
file := filepath.Join(tmp, "sample.pdf") file := filepath.Join(tmp, "sample.pdf")
+348
View File
@@ -0,0 +1,348 @@
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 = fmt.Errorf("the download stalled (no data received for %s)", stallTimeout)
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,
Workers: translateWorkers(),
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
}
// translateWorkers is how many sentences are translated at once: half the
// CPUs, and each one splits its decoding steps across its share of them.
func translateWorkers() int { return max(1, runtime.NumCPU()/2) }
// 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)
}
// Every sentence worker gets an equal share of the CPUs for its
// decoding steps, so workers × threads never oversubscribe the machine.
m.SetThreads(max(1, runtime.NumCPU()/translateWorkers()))
lexicon = a.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. The dictionaries come
// from the same cache as the spell checker's: a Dictionary is read-only
// once loaded, so it can be shared.
func (a *App) loadLexicon() translate.Lexicon {
langs := spell.Available(dictionaryDirs())
pick := func(lang, preferred string) string {
if i := slices.IndexFunc(langs, func(l spell.Language) bool { return l.Code == preferred }); i >= 0 {
return langs[i].Code
}
if i := slices.IndexFunc(langs, func(l spell.Language) bool { return strings.HasPrefix(l.Code, lang+"_") }); i >= 0 {
return langs[i].Code
}
return ""
}
frCode, enCode := pick("fr", "fr_FR"), pick("en", "en_US")
if frCode == "" || enCode == "" {
return nil
}
a.spellMu.Lock()
defer a.spellMu.Unlock()
fr, err := a.dictionaryLocked(frCode)
if err != nil {
return nil
}
en, err := a.dictionaryLocked(enCode)
if err != nil {
return nil
}
return lexicon{fr, en}
}