Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d355d93126 | ||
|
|
2de0d59641 | ||
|
|
093f7ea10d | ||
|
|
764c9d4dc3 | ||
|
|
6622203506 | ||
|
|
b0bcb812b1 | ||
|
|
823c9f2d0f | ||
|
|
343836df6b | ||
|
|
e85d0ba7e9 | ||
|
|
1440adc199 | ||
|
|
82bfef8cba | ||
|
|
d6ee57130f | ||
|
|
e80ad634ef | ||
|
|
5e7ae97b57 | ||
|
|
c0a52a9040 | ||
|
|
643adffb12 | ||
|
|
b6b58c7aca | ||
|
|
839e9291d6 | ||
|
|
8181c6e885 | ||
|
|
082152fc31 | ||
|
|
b84284b60c | ||
|
|
7872403e11 | ||
|
|
e9eb2ff36a | ||
|
|
60032ce490 | ||
|
|
5e1dc1cace | ||
|
|
ed6704b97e | ||
|
|
803dc3328a | ||
|
|
9987d7e1ab | ||
|
|
49df7f36a1 | ||
|
|
cb356ff8c5 | ||
|
|
5cf16588c1 |
No files matched your search
@@ -46,6 +46,9 @@ 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
|
||||||
@@ -58,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
|
||||||
@@ -68,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
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -201,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`).
|
||||||
@@ -231,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.
|
||||||
@@ -362,6 +406,9 @@ 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 `""`.
|
||||||
|
|||||||
@@ -5,13 +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.2]
|
## [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
|
### Added
|
||||||
|
|
||||||
- Arch Linux PKGBUILD (`packaging/arch`).
|
- Arch Linux PKGBUILD (`packaging/arch`).
|
||||||
|
|
||||||
## [1.1]
|
### Fixed
|
||||||
|
|
||||||
|
- Align VERSION on tag
|
||||||
|
|
||||||
|
## [1.1] - 2026-09-29
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -62,6 +63,10 @@ the PDF exactly. Without it, Plotzilla uses Courier New, Nimbus Mono or Liberati
|
|||||||
Spell checking uses the system's Hunspell dictionaries: install one per language you write in,
|
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
|
||||||
@@ -265,6 +270,58 @@ missing from the dictionary (`Haustürschlüssel`) is accepted when the dictiona
|
|||||||
parts to be joined, including the compound patterns some dictionaries describe with Hunspell's
|
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
|
||||||
@@ -416,7 +473,8 @@ The title page and first pages of a sample script, as exported:
|
|||||||

|

|
||||||
|
|
||||||
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.
|
||||||
@@ -448,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
|
||||||
|
|
||||||
@@ -467,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
|
||||||
@@ -477,6 +538,7 @@ binary embeds `frontend/dist`, so the frontend has to be built first.
|
|||||||
|
|
||||||
```
|
```
|
||||||
main.go, app.go Wails application and the methods the frontend calls
|
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
|
packaging/arch PKGBUILD for an Arch Linux package
|
||||||
@@ -486,8 +548,11 @@ 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`
|
||||||
```
|
```
|
||||||
@@ -504,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.
|
||||||
@@ -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"
|
||||||
@@ -24,15 +25,22 @@ 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()
|
||||||
}
|
}
|
||||||
@@ -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>
|
||||||
@@ -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": {
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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"/>',
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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, " "))
|
||||||
|
}
|
||||||
@@ -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
|
||||||
}
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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{
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)) }
|
||||||
@@ -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])
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
# Maintainer: Sandro Cazzaniga <sandro@cazzaniga.fr>
|
# Maintainer: Sandro Cazzaniga <sandro@cazzaniga.fr>
|
||||||
|
|
||||||
pkgname=plotzilla
|
pkgname=plotzilla
|
||||||
pkgver=1.2
|
pkgver=1.4.1
|
||||||
pkgrel=1
|
pkgrel=1
|
||||||
pkgdesc='Screenwriting app for Linux: scripts in the Hollywood standard format, Fountain, FDX and PDF'
|
pkgdesc='Screenwriting app for Linux: scripts in the Hollywood standard format, Fountain, FDX and PDF'
|
||||||
arch=('x86_64' 'aarch64')
|
arch=('x86_64' 'aarch64')
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 362 KiB After Width: | Height: | Size: 363 KiB |
|
Before Width: | Height: | Size: 351 KiB After Width: | Height: | Size: 352 KiB |
|
Before Width: | Height: | Size: 423 KiB After Width: | Height: | Size: 420 KiB |
|
Before Width: | Height: | Size: 294 KiB After Width: | Height: | Size: 296 KiB |
|
Before Width: | Height: | Size: 372 KiB After Width: | Height: | Size: 373 KiB |
|
Before Width: | Height: | Size: 378 KiB After Width: | Height: | Size: 379 KiB |
|
Before Width: | Height: | Size: 357 KiB After Width: | Height: | Size: 358 KiB |
|
Before Width: | Height: | Size: 412 KiB After Width: | Height: | Size: 414 KiB |
|
Before Width: | Height: | Size: 268 KiB After Width: | Height: | Size: 270 KiB |
|
Before Width: | Height: | Size: 374 KiB After Width: | Height: | Size: 375 KiB |
|
Before Width: | Height: | Size: 276 KiB After Width: | Height: | Size: 277 KiB |
|
Before Width: | Height: | Size: 320 KiB After Width: | Height: | Size: 321 KiB |
@@ -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")
|
||||||
|
|||||||
@@ -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}
|
||||||
|
}
|
||||||