" + Encode(l.Title) + "
"); + sb.AppendLine("" + Encode(summary) + "
"); + sb.AppendLine(" "); + sb.AppendLine("` | Force the language for FCE language-gated rules (code or English name, e.g. `es` / `Spanish`); default: auto-detect from content |
| `--fix-rtl-via-unicode-chars` | Fix RTL via Unicode characters |
@@ -532,7 +632,7 @@ In most batch pipelines `--apply-min-gap` is the better choice; reach for the FC
### FixCommonErrors rule selection
-`--fix-common-errors` (no value) runs all 39 rules. Pass `--fix-common-errors-rules:` to pick a subset — supplying that option implies `--fix-common-errors`.
+`--fix-common-errors` (no value) runs all 40 rules (39 fixes plus the `FixCommonOcrErrors` pass). Pass `--fix-common-errors-rules:` to pick a subset — supplying that option implies `--fix-common-errors`.
```bash
seconv movie.srt subrip --fix-common-errors # all rules
@@ -645,18 +745,19 @@ This mirrors the desktop app, where batch convert's *Remove formatting* function
| `unipac`, `pacunicode` | PAC Unicode |
| `ebu`, `ebustl`, `stl` | EBU STL — binary |
| `cavena`, `cavena890` | Cavena 890 — binary |
-| `cheetahcaption` | CheetahCaption — binary |
-| `capmakerplus` | CapMakerPlus — binary |
+| `cheetah`, `cheetahcaption` | CheetahCaption — binary |
+| `capmaker`, `capmakerplus` | CapMakerPlus — binary |
| `ayato` | Ayato — binary |
-| `bluraysup`, `sup` | Blu-Ray sup — image |
+| `bluraysup`, `blurayup`, `sup` | Blu-Ray sup — image |
| `vobsub` | VobSub — image |
| `bdnxml`, `bdn-xml` | BDN-XML — image (folder of PNGs + index.xml) |
| `bdnxml8bit`, `bdn-xml8-bit` | BDN-XML with 8-bit palette-indexed PNGs — image |
| `dost`, `dostimage` | DOST/image |
| `fcpimage`, `fcp` | FCP/image |
-| `dcinemainterop` | D-Cinema interop/png |
-| `dcinemasmpte2014` | D-Cinema SMPTE 2014/png |
-| `imageswithtimecode` | Images with time codes in file name |
+| `dcinemainterop`, `dcinema-interop` | D-Cinema interop/png |
+| `dcinemasmpte2014`, `dcinema-smpte` | D-Cinema SMPTE 2014/png |
+| `imageswithtimecode`, `imagesintc` | Images with time codes in file name |
+| `webvttthumbnail`, `webvtt-thumbnail`, `vttthumb` | WebVTT Thumbnail — image (sprite sheet + `.vtt`) |
| `plaintext`, `text`, `txt` | Plain text (HTML stripped) |
| `customtext`, `customtextformat` | Custom-templated text (requires `--custom-format`) |
diff --git a/docs/reference/keyboard-shortcuts.md b/docs/reference/keyboard-shortcuts.md
index 9452061b34d..e8345faaf89 100644
--- a/docs/reference/keyboard-shortcuts.md
+++ b/docs/reference/keyboard-shortcuts.md
@@ -80,6 +80,8 @@ Inside the [source view](../features/source-view.md) window (F2), which edits th
| Shortcut | Action |
|----------|--------|
+| Ctrl+Z | Undo |
+| Ctrl+Shift+Z / Ctrl+Y | Redo |
| Ctrl+F | Find in the source |
| Ctrl+H | Replace in the source (Cmd+Alt+F also works on macOS) |
| F3 / Shift+F3 | Find next / previous |
diff --git a/docs/reference/mouse-controls.md b/docs/reference/mouse-controls.md
index bc2a2c131f6..5aac4e09155 100644
--- a/docs/reference/mouse-controls.md
+++ b/docs/reference/mouse-controls.md
@@ -9,7 +9,9 @@ See also: [Audio Visualizer](../features/audio-visualizer.md) | [Video Player](.
| Action | Effect |
|--------|--------|
| Click on video | Play/Pause toggle |
-| Double-click on video | Toggle fullscreen |
+| Scroll wheel over video | Seek ±0.5 s per notch (direction follows the waveform's "Invert mouse-wheel" setting) |
+
+Full screen is toggled with Alt+Enter — see [Keyboard Shortcuts](keyboard-shortcuts.md).
## Audio Visualizer / Waveform
@@ -18,19 +20,23 @@ See also: [Audio Visualizer](../features/audio-visualizer.md) | [Video Player](.
| Left-click | Set current position |
| Left-click + drag on subtitle edge | Adjust start/end time of subtitle |
| Left-click + drag in empty area | Create a new selection |
-| Right-click | Open context menu |
-| Scroll wheel | Scroll waveform horizontally |
+| Shift + Click | Set the selected subtitle's start time to the clicked position |
+| Ctrl (or Cmd) + Click | Set the selected subtitle's end time to the clicked position |
+| Ctrl + Shift + Click | Set the selected subtitle's start time and offset all following subtitles by the same amount |
+| Alt + Click | Move the selected subtitle's start to the clicked position, keeping its duration |
+| Right-click | Select the subtitle under the cursor ("Select subtitle on right click", on by default), move the video position there, and open the context menu. On macOS, Ctrl + left-click opens the same menu |
+| Scroll wheel | Scroll waveform horizontally — or seek the video by one step per notch when "Mouse-wheel sets video position" is on; "Invert mouse-wheel" flips the direction (Options → Settings → Waveform/spectrogram) |
| Alt + Scroll wheel | Horizontal zoom in/out |
| Shift + Scroll wheel | Vertical zoom in/out |
| Ctrl (or Cmd) + Scroll wheel | Scroll waveform and seek video to cursor |
-| Double-click on subtitle | Select the subtitle in the grid |
+| Double-click | Nothing by default — set "Waveform double-click action" in Options → Settings → Waveform/spectrogram to select the subtitle, center on it, pause, or play |
## Subtitle Grid
| Action | Effect |
|--------|--------|
| Click on row | Select subtitle line |
-| Double-click on row | Edit subtitle line in text editor |
+| Double-click on row | Go to the subtitle in the video and pause (default; "Subtitle grid double-click action" in Options → Settings → General offers other actions, e.g. play, or also focus the text box) |
| Ctrl + Click | Toggle selection of individual rows |
| Shift + Click | Select range of rows |
| Right-click | Open context menu |
diff --git a/docs/reference/supported-formats.md b/docs/reference/supported-formats.md
index 36756692360..e852529d90d 100644
--- a/docs/reference/supported-formats.md
+++ b/docs/reference/supported-formats.md
@@ -17,11 +17,12 @@ Subtitle Edit supports a wide range of subtitle formats for reading and writing.
| EBU STL | .stl | |
| Spruce STL | .stl | |
| Scenarist Closed Captions (SCC) | .scc | |
-| DVD Studio Pro | .txt | |
+| DVD Studio Pro | .stl | |
| Cavena 890 | .890 | |
| PAC | .pac | |
| Cheetah | .cap | |
-| Avid DS | .txt | |
+| Avid DVD, Avid Caption, Avid Caption Drop Frame, Avid Loc Markers | .txt | |
+| Avid STL | .stl | |
| JSON (various) | .json | |
| LRC (Lyrics) | .lrc | |
| and many more... | | |
@@ -47,6 +48,16 @@ Formats used to move captions and markers between Subtitle Edit and video editor
| Adobe Premiere PrProj Xml | .xml |
| Adobe Encore (tabs) | .txt |
+## Spreadsheets
+
+Import only — a spreadsheet with a header row naming start/end/text columns is read directly, and any layout can be mapped by hand in *File → Import → CSV/XLSX/ODS with custom columns*. See [Import Spreadsheets](../features/import-csv-xlsx.md).
+
+| Format | Extension(s) |
+|--------|--------------|
+| Excel workbook (Office Open XML) | .xlsx |
+| OpenDocument spreadsheet | .ods |
+| Delimited text (comma, semicolon, tab, pipe) | .csv, .tsv, .txt |
+
## Image-Based Formats
| Format | Extension(s) |
@@ -55,16 +66,25 @@ Formats used to move captions and markers between Subtitle Edit and video editor
| VobSub (DVD) | .sub/.idx |
| BDN XML | .xml |
| Timed Text Base64 Image (SMPTE-TT bitmap) | .xml |
+| Timed Text Image | .xml |
+| BDN XML 8-bit (palette-indexed PNGs, export) | .xml |
+| DOST | .dost |
+| Final Cut Pro Image (FCP/image) | .xml |
+| SPU Image | .xml |
+| D-Cinema interop/png, D-Cinema SMPTE 2014/png (export) | .xml |
+| Images with time codes in file name (export) | image files |
+| WebVTT Thumbnail (sprite sheet) | .vtt |
## Container Formats (with embedded subtitles)
| Format | Extension(s) |
|--------|--------------|
-| Matroska (MKV/MKS) | .mkv, .mks |
-| MP4 / MOV (text tracks, including fragmented MP4/DASH — wvtt, stpp, tx3g) | .mp4, .m4v, .m4s, .3gp |
+| Matroska (MKV/MKS/WebM) | .mkv, .mks, .webm |
+| MP4 / MOV (text tracks, including fragmented MP4/DASH — wvtt, stpp, tx3g) | .mp4, .m4v, .m4s, .3gp, .mov |
| Transport Stream (teletext, DVB-sub) | .ts, .m2ts, .mts |
-| AVI (XSUB) | .avi |
+| AVI (XSUB) | .avi, .divx |
| MacCaption | .mcc |
+| MXF (timed-text essences) | .mxf |
## Video Formats (for loading video)
diff --git a/docs/screenshots/README.md b/docs/screenshots/README.md
index b016846a4e1..0a3a2d47bae 100644
--- a/docs/screenshots/README.md
+++ b/docs/screenshots/README.md
@@ -37,6 +37,7 @@ All listed files exist on disk. Filenames are stable and may be linked from `../
- `multiple-replace.png` — Multiple replace window
- `modify-selection.png` — Modify selection window
- `show-history.png` — Show history window
+- `ai-assistant.png` — AI assistant window
## Tools
@@ -87,6 +88,8 @@ All listed files exist on disk. Filenames are stable and may be linked from `../
- `blank-video.png` — Blank video window
- `cut-video.png` — Cut video window
- `re-encode-video.png` — Re-encode video window
+- `video-ocr.png` — Video OCR window
+- `chapters.png` — Chapters window
## Translation
@@ -157,6 +160,10 @@ Used by `../reference/assa-override-tags.md` to illustrate individual override t
## Counts
-- Top-level screenshots: **92**
+- Top-level screenshots: **95**
- ASSA override-tag examples (`assa/`): **35**
-- Total: **127**
+- Total: **130**
+
+## How the dialog screenshots are made
+
+Most dialog screenshots are headless renders of the real windows (dark theme, English, sample subtitle loaded), captured with Avalonia's headless platform and real Skia drawing — the same setup the UI tests use (`UseHeadless(new AvaloniaHeadlessPlatformOptions { UseHeadlessDrawing = false }).UseSkia()`, see `tests/UI/Logic/Accessibility/EditBoxAccessibilityNameTests.cs`). A temporary `[AvaloniaFact]` resolves the view model from `AddSubtitleEditServices()`, calls its `Initialize(...)` with sample lines, constructs the window, runs `Dispatcher.UIThread.RunJobs()` and saves `window.CaptureRenderedFrame()`. Such renders have no OS title bar, and video/waveform areas stay black because libmpv does not run headlessly, so windows that are mostly video (main window, video player) are still real screenshots. PNGs are quantized to a 256-color palette before committing.
diff --git a/docs/screenshots/ai-assistant.png b/docs/screenshots/ai-assistant.png
new file mode 100644
index 00000000000..b9acd45e219
Binary files /dev/null and b/docs/screenshots/ai-assistant.png differ
diff --git a/docs/screenshots/ai-review.png b/docs/screenshots/ai-review.png
index f5d126baa74..a9f19a58f8d 100644
Binary files a/docs/screenshots/ai-review.png and b/docs/screenshots/ai-review.png differ
diff --git a/docs/screenshots/apply-duration-limits.png b/docs/screenshots/apply-duration-limits.png
index dc448aca621..567011795b3 100644
Binary files a/docs/screenshots/apply-duration-limits.png and b/docs/screenshots/apply-duration-limits.png differ
diff --git a/docs/screenshots/assa-attachments.png b/docs/screenshots/assa-attachments.png
index fbc65d9cce8..d0411b15a61 100644
Binary files a/docs/screenshots/assa-attachments.png and b/docs/screenshots/assa-attachments.png differ
diff --git a/docs/screenshots/assa-set-position.png b/docs/screenshots/assa-set-position.png
index 43db8d2d039..98e40406a37 100644
Binary files a/docs/screenshots/assa-set-position.png and b/docs/screenshots/assa-set-position.png differ
diff --git a/docs/screenshots/assa-styles.png b/docs/screenshots/assa-styles.png
index a7fb4b9d2ea..e26295eaa93 100644
Binary files a/docs/screenshots/assa-styles.png and b/docs/screenshots/assa-styles.png differ
diff --git a/docs/screenshots/auto-translate.png b/docs/screenshots/auto-translate.png
index f6fe3b32841..d1aff685676 100644
Binary files a/docs/screenshots/auto-translate.png and b/docs/screenshots/auto-translate.png differ
diff --git a/docs/screenshots/batch-convert.png b/docs/screenshots/batch-convert.png
index 48a7c466578..d5fab211087 100644
Binary files a/docs/screenshots/batch-convert.png and b/docs/screenshots/batch-convert.png differ
diff --git a/docs/screenshots/beautify-time-codes.png b/docs/screenshots/beautify-time-codes.png
index 1fd6717eaf0..19e9fa50de0 100644
Binary files a/docs/screenshots/beautify-time-codes.png and b/docs/screenshots/beautify-time-codes.png differ
diff --git a/docs/screenshots/burn-in.png b/docs/screenshots/burn-in.png
index 1fc9171ffa9..0ebe4fc9f7d 100644
Binary files a/docs/screenshots/burn-in.png and b/docs/screenshots/burn-in.png differ
diff --git a/docs/screenshots/change-speed.png b/docs/screenshots/change-speed.png
index 46ed6845ae8..bf4a9fb47c4 100644
Binary files a/docs/screenshots/change-speed.png and b/docs/screenshots/change-speed.png differ
diff --git a/docs/screenshots/chapters.png b/docs/screenshots/chapters.png
new file mode 100644
index 00000000000..86a162bd279
Binary files /dev/null and b/docs/screenshots/chapters.png differ
diff --git a/docs/screenshots/compare.png b/docs/screenshots/compare.png
index bb4409cb799..2e76701d64b 100644
Binary files a/docs/screenshots/compare.png and b/docs/screenshots/compare.png differ
diff --git a/docs/screenshots/convert-actors.png b/docs/screenshots/convert-actors.png
index 519f776ebe2..665bb3a580c 100644
Binary files a/docs/screenshots/convert-actors.png and b/docs/screenshots/convert-actors.png differ
diff --git a/docs/screenshots/export-image-based.png b/docs/screenshots/export-image-based.png
index f8cbd94301b..fe3c1d45126 100644
Binary files a/docs/screenshots/export-image-based.png and b/docs/screenshots/export-image-based.png differ
diff --git a/docs/screenshots/find.png b/docs/screenshots/find.png
index f0c229fc8e6..6ba737dfee6 100644
Binary files a/docs/screenshots/find.png and b/docs/screenshots/find.png differ
diff --git a/docs/screenshots/fix-names.png b/docs/screenshots/fix-names.png
index a592999727b..46044600b7c 100644
Binary files a/docs/screenshots/fix-names.png and b/docs/screenshots/fix-names.png differ
diff --git a/docs/screenshots/import-plain-text.png b/docs/screenshots/import-plain-text.png
index 150b6cfff41..ac4ac847728 100644
Binary files a/docs/screenshots/import-plain-text.png and b/docs/screenshots/import-plain-text.png differ
diff --git a/docs/screenshots/modify-selection.png b/docs/screenshots/modify-selection.png
index 84822cc4e3d..9720b558b29 100644
Binary files a/docs/screenshots/modify-selection.png and b/docs/screenshots/modify-selection.png differ
diff --git a/docs/screenshots/netflix-errors.png b/docs/screenshots/netflix-errors.png
index e742b48a92a..a2e6b76953e 100644
Binary files a/docs/screenshots/netflix-errors.png and b/docs/screenshots/netflix-errors.png differ
diff --git a/docs/screenshots/point-sync-via-other.png b/docs/screenshots/point-sync-via-other.png
index 5e69ee9ec1c..1676429e289 100644
Binary files a/docs/screenshots/point-sync-via-other.png and b/docs/screenshots/point-sync-via-other.png differ
diff --git a/docs/screenshots/point-sync.png b/docs/screenshots/point-sync.png
index 4b0f48496cf..e785c62fdc3 100644
Binary files a/docs/screenshots/point-sync.png and b/docs/screenshots/point-sync.png differ
diff --git a/docs/screenshots/remove-text-hi.png b/docs/screenshots/remove-text-hi.png
index 2bcb11230b4..9b88cafb781 100644
Binary files a/docs/screenshots/remove-text-hi.png and b/docs/screenshots/remove-text-hi.png differ
diff --git a/docs/screenshots/settings.png b/docs/screenshots/settings.png
index 174484b27b8..ab5f842e90f 100644
Binary files a/docs/screenshots/settings.png and b/docs/screenshots/settings.png differ
diff --git a/docs/screenshots/speech-to-text.png b/docs/screenshots/speech-to-text.png
index d804a7185eb..692fe42ed11 100644
Binary files a/docs/screenshots/speech-to-text.png and b/docs/screenshots/speech-to-text.png differ
diff --git a/docs/screenshots/spell-check.png b/docs/screenshots/spell-check.png
index 411339325bc..88102532a84 100644
Binary files a/docs/screenshots/spell-check.png and b/docs/screenshots/spell-check.png differ
diff --git a/docs/screenshots/split-break-long-lines.png b/docs/screenshots/split-break-long-lines.png
index a2bb918b0ee..31dbe667710 100644
Binary files a/docs/screenshots/split-break-long-lines.png and b/docs/screenshots/split-break-long-lines.png differ
diff --git a/docs/screenshots/statistics.png b/docs/screenshots/statistics.png
index 9040f8b1ce1..c069e9db424 100644
Binary files a/docs/screenshots/statistics.png and b/docs/screenshots/statistics.png differ
diff --git a/docs/screenshots/text-to-speech.png b/docs/screenshots/text-to-speech.png
index 235c4ebae66..fec652a383c 100644
Binary files a/docs/screenshots/text-to-speech.png and b/docs/screenshots/text-to-speech.png differ
diff --git a/docs/screenshots/transparent-subtitles.png b/docs/screenshots/transparent-subtitles.png
index fb87bc76bda..e1d85b6c575 100644
Binary files a/docs/screenshots/transparent-subtitles.png and b/docs/screenshots/transparent-subtitles.png differ
diff --git a/docs/screenshots/video-ocr.png b/docs/screenshots/video-ocr.png
new file mode 100644
index 00000000000..bd9dd6d2f5d
Binary files /dev/null and b/docs/screenshots/video-ocr.png differ
diff --git a/docs/screenshots/visual-sync.png b/docs/screenshots/visual-sync.png
index c4c06c8ea8c..aef6d1395d9 100644
Binary files a/docs/screenshots/visual-sync.png and b/docs/screenshots/visual-sync.png differ
diff --git a/docs/screenshots/word-lists.png b/docs/screenshots/word-lists.png
index 02feda4e648..15e844367b3 100644
Binary files a/docs/screenshots/word-lists.png and b/docs/screenshots/word-lists.png differ
diff --git a/docs/third-party-components.md b/docs/third-party-components.md
index ce0d8d5b564..4bf6c739421 100644
--- a/docs/third-party-components.md
+++ b/docs/third-party-components.md
@@ -36,8 +36,7 @@ Subtitle Edit stores these components in its **Data Folder**.
| **Purfview Faster-Whisper XXL** | `faster-whisper-xxl.exe`, `_models/` folder | `[Data Folder]/SpeechToText/Purfview-Faster-Whisper-XXL` |
| **Crisp ASR** | `crispasr.exe`, `models/` folder | `[Data Folder]/CrispASR` |
| **Qwen3 ASR CPP** | `qwen3-asr-cli.exe`, `models/` folder | `[Data Folder]/Qwen3ASR` |
-| **Parakeet.cpp** | `parakeet.exe`, model folders | `[Data Folder]/parakeet.cpp` |
-| **PaddleOCR** | `paddleocr.exe`, `models/` folder | `[Data Folder]/OCR/PaddleOCR3-4` |
+| **PaddleOCR** | `paddleocr.exe`, `models/` folder | `[Data Folder]/OCR/PaddleOCR3-7` |
| **Qwen3 TTS (CrispASR)** | shares `crispasr.exe` + `models/` from `[Data Folder]/CrispASR`; reference voices in `voices/` | `[Data Folder]/TextToSpeech/Qwen3TtsCrispAsr` (voices only) |
| **Chatterbox TTS (CrispASR)** | shares `crispasr.exe` + `models/` from `[Data Folder]/CrispASR`; reference voices in `voices/` | `[Data Folder]/TextToSpeech/Chatterbox` (voices only) |
| **OmniVoice TTS** | `omnivoice-tts.exe`, `omnivoice-codec.exe`, `models/`, `voices/` | `[Data Folder]/TextToSpeech/OmniVoice` |
@@ -57,7 +56,9 @@ Used for reading media info, extracting audio, and generating waveforms.
* **Alternative: Custom Path**
* You can point to an existing FFmpeg installation in `Settings.json` (located in the Data Folder):
```json
- "FfmpegPath": "C:\\path\\to\\your\\ffmpeg.exe"
+ "General": {
+ "FfmpegPath": "C:\\path\\to\\your\\ffmpeg.exe"
+ }
```
* Use double backslashes (`\\`) for Windows paths in JSON.
@@ -110,30 +111,33 @@ Used for GPU-accelerated AI-based speech recognition.
### SE5 Speech-to-Text Engines
Subtitle Edit 5 can download additional ASR engines directly from the **Speech to text** window.
-* **Crisp ASR:** Stored in `[Data Folder]/CrispASR`. Models go into its `models` folder. Crisp ASR backends include Parakeet, Canary, Cohere, Fire Red, GLM, Granite, Qwen3, Mega, Omni, and Kyutai.
+* **Crisp ASR:** Stored in `[Data Folder]/CrispASR`. Models go into its `models` folder. Crisp ASR backends include Parakeet, Canary, Cohere, Fire Red, Fun-ASR Nano, GigaAM, GLM, Granite, Qwen3, Mega, MOSS Diarize, Omni, Kyutai, SenseVoice, ARK, and Voxtral.
* The speech-to-text dialog also offers a **Forced aligner** combo for word-level timestamps. Built-in (where the backend supports it), Canary CTC, Qwen3, and 12 language-specific wav2vec2 aligners (the WhisperX aligner zoo): `en`, `de`, `fr`, `es`, `it`, `ja`, `zh`, `nl`, `pt`, `ar`, `uk`, `cs`. The default is the built-in aligner when the backend supports it, otherwise Qwen3 or Canary CTC depending on the backend; pick a wav2vec2 entry manually to use one of those.
-* **Qwen3 ASR CPP:** Stored in `[Data Folder]/Qwen3ASR`. Models go into `[Data Folder]/Qwen3ASR/models`.
-* **Parakeet.cpp:** Stored in `[Data Folder]/parakeet.cpp`. Each model has its own folder because the model weights and `vocab.txt` must stay together.
+* **Qwen3 ASR CPP:** Stored in `[Data Folder]/Qwen3ASR`. Models go into `[Data Folder]/Qwen3ASR/models`. (Parakeet is no longer a standalone engine; it is a Crisp ASR backend.)
Use [Speech to Text](features/speech-to-text.md) for the current engine list and workflow.
### PaddleOCR
Used for OCR of image-based subtitles.
-* **Destination:** `[Data Folder]/OCR/PaddleOCR3-4`
-* **Models:** `[Data Folder]/OCR/PaddleOCR3-4/models`
+* **Destination:** `[Data Folder]/OCR/PaddleOCR3-7`
+* **Models:** `[Data Folder]/OCR/PaddleOCR3-7/models`
* **Builds:** Subtitle Edit can download CPU, CUDA 11.8, or CUDA 12.9 builds, on both Windows and Linux (Linux x64 only; the Linux builds need glibc 2.35 or newer).
-* **Version:** The folder name follows the PaddleOCR release the standalone engine is built from, so upgrading installs into a new folder instead of mixing files. The old `[Data Folder]/OCR/PaddleOCR3-1` folder is deleted automatically after the new one is installed.
+* **Recognition models:** PP-OCRv6 (PaddleOCR 3.7) recognizes Chinese, English, Japanese and the Latin languages with one unified model; Arabic, Cyrillic, E-Slavic, Devanagari, Korean, Greek, Tamil, Telugu, Thai and Pali still use their PP-OCRv5 models, and Georgian its PP-OCRv3 one. All of them ship in the same bundle.
+* **Version:** The folder name follows the PaddleOCR release the standalone engine is built from, so upgrading installs into a new folder instead of mixing files. The old `[Data Folder]/OCR/PaddleOCR3-1` and `[Data Folder]/OCR/PaddleOCR3-4` folders are deleted automatically after the new one is installed.
+* **Paddle OCR Python:** The pip-installed engine uses the same downloaded models, so it needs `paddleocr` 3.7 or newer - older versions do not know the PP-OCRv6 model names.
### Local Text-to-Speech Engines
Subtitle Edit 5 can download local TTS servers and models from the **Text to speech** window.
-* **Qwen3 TTS (CrispASR):** Reference voices are stored in `[Data Folder]/TextToSpeech/Qwen3TtsCrispAsr/voices`. The talker GGUFs (VoiceDesign 1.7B or CustomVoice 1.7B) and the 12 Hz codec are downloaded into the shared `[Data Folder]/CrispASR/models` cache alongside the Crisp ASR speech-to-text models, not under `TextToSpeech/Qwen3TtsCrispAsr/models` — installing Crisp ASR first is therefore recommended. Older installs that still have model files under the legacy `TextToSpeech/Qwen3TtsCrispAsr/models` folder are migrated automatically the first time the engine is used.
+* **Qwen3 TTS (CrispASR):** Reference voices are stored in `[Data Folder]/TextToSpeech/Qwen3TtsCrispAsr/voices`. The talker GGUFs (VoiceDesign 1.7B, CustomVoice 1.7B or Voice clone Base 1.7B) and the 12 Hz codec are downloaded into the shared `[Data Folder]/CrispASR/models` cache alongside the Crisp ASR speech-to-text models, not under `TextToSpeech/Qwen3TtsCrispAsr/models` — installing Crisp ASR first is therefore recommended. Older installs that still have model files under the legacy `TextToSpeech/Qwen3TtsCrispAsr/models` folder are migrated automatically the first time the engine is used.
* **Chatterbox TTS (CrispASR):** Reference voices are stored in `[Data Folder]/TextToSpeech/Chatterbox/voices`. The Base / Turbo model GGUFs (T3 + S3Gen) are downloaded into the shared `[Data Folder]/CrispASR/models` cache alongside the Crisp ASR speech-to-text models, not under `TextToSpeech/Chatterbox/models` — installing Crisp ASR first is therefore recommended. Older installs that still have model files under the legacy `TextToSpeech/Chatterbox/models` folder are migrated automatically the first time the engine is used.
* **OmniVoice TTS:** Stored in `[Data Folder]/TextToSpeech/OmniVoice`. Brings its own `omnivoice-tts` and `omnivoice-codec` binaries. Supports 646 languages and voice cloning on CPU. `models/` and `voices/` subfolders.
* **Kokoro TTS:** Stored in `[Data Folder]/TextToSpeech/KokoroTtsCpp`. Models go into the `models` folder.
-Use [Text to Speech](features/text-to-speech.md) for engine-specific options.
+These are examples, not the full set — many more local engines are downloadable from the Text to speech window (IndexTTS, CosyVoice3, dots.tts, VoxCPM2, MOSS-TTS, Zonos, VibeVoice, Confucius4-TTS, Pocket TTS, Higgs Audio, Fish Audio, and more), following the same layout: CrispASR-based engines share the `[Data Folder]/CrispASR` cache, and the rest live under `[Data Folder]/TextToSpeech/`.
+
+Use [Text to Speech](features/text-to-speech.md) for the full engine list and engine-specific options.
---
@@ -147,7 +151,9 @@ Used for reading media info, extracting audio, and generating waveforms.
* **Alternative:** Place the `ffmpeg` binary in `[Data Folder]/ffmpeg`.
* **Custom Path:** You can specify a custom path in `Settings.json` (located in the Data Folder):
```json
- "FfmpegPath": "/path/to/your/ffmpeg"
+ "General": {
+ "FfmpegPath": "/path/to/your/ffmpeg"
+ }
```
### MPV Media Player (libmpv)
@@ -190,7 +196,7 @@ Used for GPU-accelerated AI-based speech recognition.
### SE5 Speech-to-Text, OCR, and TTS Engines
-The same data-folder layout is used on Linux. Prefer the in-app downloaders for Crisp ASR, Qwen3 ASR, Parakeet.cpp, PaddleOCR, Qwen3 TTS (CrispASR), Chatterbox TTS (CrispASR), OmniVoice TTS, and Kokoro TTS because the required files differ by build and model.
+The same data-folder layout is used on Linux. Prefer the in-app downloaders for Crisp ASR, Qwen3 ASR, PaddleOCR, and the local TTS engines because the required files differ by build and model.
---
@@ -204,7 +210,9 @@ Used for reading media info, extracting audio, and generating waveforms.
* **Alternative:** Place the `ffmpeg` binary in `[Data Folder]/ffmpeg`.
* **Custom Path:** You can specify a custom path in `Settings.json` (located in the Data Folder):
```json
- "FfmpegPath": "/path/to/your/ffmpeg"
+ "General": {
+ "FfmpegPath": "/path/to/your/ffmpeg"
+ }
```
### MPV Media Player (libmpv)
@@ -237,15 +245,6 @@ Used for AI-based speech recognition.
* **Files:** Download or build the binary and ensure it is named `whisper-cli`.
* **Models:** Models (`.bin` files) go into a `Models` subfolder: `[Data Folder]/SpeechToText/Cpp/Models`.
-### MLX Whisper (Speech-to-Text, Apple Silicon)
-Runs Apple's `mlx-whisper` Python package on the GPU / Neural Engine. **Subtitle Edit does not download this engine** — you install the Python package yourself, and there is no file in the Data Folder.
-
-* **Requirements:** An Apple Silicon Mac (M1 or newer) and Python 3.
-* **Install:** `pip3 install mlx-whisper` — or, for an isolated install, `pipx install mlx-whisper`.
-* **Models:** MLX-format Whisper weights (`tiny` … `large-v3-turbo`) are downloaded from Hugging Face (the `mlx-community` org) into the Hugging Face cache on first use.
-* **How detection works:** Subtitle Edit does not look for a binary; it looks for a Python interpreter that can `import mlx_whisper`. It probes Homebrew (`/opt/homebrew/bin`, `/usr/local/bin`), python.org framework builds, pyenv, and the system Python. Because **pipx, virtual environments, and conda install the package into an isolated environment** that those shared interpreters cannot import, Subtitle Edit also reads the shebang of the installed `mlx_whisper` command — found on your `PATH` or at `~/.local/bin/mlx_whisper` (where pipx places it) — to locate the exact matching interpreter.
-* **If it reports "not found" after installing:** confirm the command resolves with `which mlx_whisper`. If it does not, add its directory to your `PATH`, or symlink the command into `~/.local/bin`. (Symlinking or copying only the *model files* does nothing — detection is by the Python package, not a binary.)
-
### SE5 Speech-to-Text, OCR, and TTS Engines
Some newer local engines are platform-specific or model-specific. Use the in-app downloaders where available, and check [Speech to Text](features/speech-to-text.md), [Text to Speech](features/text-to-speech.md), and [OCR](features/ocr.md) for current engine notes.
diff --git a/docs/translating.md b/docs/translating.md
index d82ecda0e1d..9d9a47cdcbc 100644
--- a/docs/translating.md
+++ b/docs/translating.md
@@ -58,7 +58,7 @@ Near the top of the JSON file, update these fields:
```json
{
"title": "Subtitle Edit",
- "version": null,
+ "version": "v5.2.0-beta32",
"translatedBy": "Your Name (or email / homepage)",
"cultureName": "de-DE",
...
@@ -76,7 +76,7 @@ The `cultureName` value **must** be a valid .NET culture identifier.
## Step 4 — Save and Install the File
-Save your translated file as **`{CultureName}.json`** (e.g., `de-DE.json`) and place it in the **`Languages`** subfolder inside Subtitle Edit's data folder:
+Save your translated file as **`{English language name}.json`** (e.g., `German.json` — the file name is what the language list shows, and the shipped translations are named the same way) and place it in the **`Languages`** subfolder inside Subtitle Edit's data folder:
| Platform | Languages folder path |
|----------|-----------------------|
diff --git a/installer/WindowsInno/Subtitle_Edit_Localization.iss b/installer/WindowsInno/Subtitle_Edit_Localization.iss
index 55be25bca4d..74d5da390a0 100644
--- a/installer/WindowsInno/Subtitle_Edit_Localization.iss
+++ b/installer/WindowsInno/Subtitle_Edit_Localization.iss
@@ -1,4 +1,4 @@
-;* Subtitle Edit - installer localization script
+;* Subtitle Edit - installer localization script
; English
[CustomMessages]
@@ -242,7 +242,7 @@ hu.types_default=Default installation
; Italian
[CustomMessages]
it.sm_com_Changelog=Elenco novità di Subtitle Edit
-it.run_ViewChangelog=Visualizza le novità
+it.run_ViewChangelog=Visualizza elenco novità di Subtitle Edit
it.msg_DeleteSettings=Vuoi eliminare le impostazioni personali di Subtitle Edit?
it.tsk_AllUsers=Per tutti gli utenti
it.tsk_CurrentUser=Solo per l'utente attuale
@@ -254,6 +254,9 @@ it.types_custom=Installazione personalizzata
it.types_default=Installazione predefinita
it.NameAndVersion=%1 %2
it.LaunchProgram=Esegui %1
+it.AdditionalIcons=Collegamenti:
+it.CreateDesktopIcon=Crea collegamento programma sul &desktop
+it.CreateQuickLaunchIcon=Crea collegamento programma nella &barra 'Avvio veloce'
; Japanese
[CustomMessages]
diff --git a/installer/macBundle/SubtitleEdit.app/Contents/Info.plist b/installer/macBundle/SubtitleEdit.app/Contents/Info.plist
index 120286a6157..5152ef06876 100644
--- a/installer/macBundle/SubtitleEdit.app/Contents/Info.plist
+++ b/installer/macBundle/SubtitleEdit.app/Contents/Info.plist
@@ -20,8 +20,19 @@
SBED
CFBundleIconFile
SE.icns
+
LSMinimumSystemVersion
- 10.15
+ 12.0
NSHighResolutionCapable
NSSupportsAutomaticGraphicsSwitching
@@ -41,7 +52,16 @@
vtt
sbv
stl
- xml
+ ttml
+ dfxp
+ itt
+ smi
+ scc
+ mcc
+ cap
+ pac
+ lrc
+ idx
CFBundleTypeName
Subtitle Files
@@ -50,6 +70,22 @@
LSHandlerRank
Owner
+
+
+ CFBundleTypeExtensions
+
+ xml
+
+ CFBundleTypeName
+ XML Subtitle Files
+ CFBundleTypeRole
+ Editor
+ LSHandlerRank
+ Alternate
+
CFBundleTypeExtensions
diff --git a/src/libse/BluRaySup/BluRaySupFadeStep.cs b/src/libse/BluRaySup/BluRaySupFadeStep.cs
new file mode 100644
index 00000000000..aa4eb60a0d5
--- /dev/null
+++ b/src/libse/BluRaySup/BluRaySupFadeStep.cs
@@ -0,0 +1,38 @@
+using System;
+
+namespace Nikse.SubtitleEdit.Core.BluRaySup
+{
+ ///
+ /// One alpha level of a Blu-ray fade. The object is sent once, at the start of the epoch, and
+ /// each step after that is written as a "palette update display set" - a PCS with
+ /// palette_update_flag set plus a PDS holding the same palette with every entry's alpha
+ /// scaled by . That is how retail discs fade: re-sending the whole
+ /// object per step instead would cost kilobytes each and run into the decoder's pixel
+ /// transfer budget, while a palette is ~1.3 KB and needs no decoding at all.
+ ///
+ public class BluRaySupFadeStep
+ {
+ ///
+ /// When the alpha level takes effect, in milliseconds - the same time base as
+ /// .
+ ///
+ public long TimeMs { get; set; }
+
+ ///
+ /// Alpha of the palette at this step, 0 (invisible) to 100 (fully opaque).
+ ///
+ public int AlphaPercent { get; set; }
+
+ public long TimeForWrite => (long)Math.Round(TimeMs * 90.0, MidpointRounding.AwayFromZero);
+
+ public BluRaySupFadeStep()
+ {
+ }
+
+ public BluRaySupFadeStep(long timeMs, int alphaPercent)
+ {
+ TimeMs = timeMs;
+ AlphaPercent = alphaPercent;
+ }
+ }
+}
diff --git a/src/libse/BluRaySup/BluRaySupPalette.cs b/src/libse/BluRaySup/BluRaySupPalette.cs
index 3fcadd61bb5..1b5b00b92ff 100644
--- a/src/libse/BluRaySup/BluRaySupPalette.cs
+++ b/src/libse/BluRaySup/BluRaySupPalette.cs
@@ -50,6 +50,17 @@ public class BluRaySupPalette
* @return Integer array with red, blue, green component (in this order)
*/
public static int[] YCbCr2Rgb(int y, int cb, int cr, bool useBt601)
+ {
+ YCbCr2Rgb(y, cb, cr, useBt601, out var ir, out var ig, out var ib);
+ return new[] { ir, ig, ib };
+ }
+
+ ///
+ /// Same conversion as without the int[3]
+ /// allocation - callers that decode a whole palette (up to 256 entries) call this once
+ /// per entry, so the array was pure per-call garbage.
+ ///
+ public static void YCbCr2Rgb(int y, int cb, int cr, bool useBt601, out int r8, out int g8, out int b8)
{
// Studio range → center/offset removal
y -= 16;
@@ -82,7 +93,7 @@ public static int[] YCbCr2Rgb(int y, int cb, int cr, bool useBt601)
if (r > 254.0) r += 0.35;
if (g > 254.0) g += 0.35;
if (b > 254.0) b += 0.35;
-
+
int ir = (int)Math.Round(r, MidpointRounding.AwayFromZero);
int ig = (int)Math.Round(g, MidpointRounding.AwayFromZero);
int ib = (int)Math.Round(b, MidpointRounding.AwayFromZero);
@@ -91,7 +102,9 @@ public static int[] YCbCr2Rgb(int y, int cb, int cr, bool useBt601)
if (ig < 0) ig = 0; else if (ig > 255) ig = 255;
if (ib < 0) ib = 0; else if (ib > 255) ib = 255;
- return new[] { ir, ig, ib };
+ r8 = ir;
+ g8 = ig;
+ b8 = ib;
}
/**
@@ -300,10 +313,10 @@ public void SetYCbCr(int index, int yn, int cbn, int crn)
_cb[index] = (byte)cbn;
_cr[index] = (byte)crn;
// create RGB
- var rgb = YCbCr2Rgb(yn, cbn, crn, _useBt601);
- _r[index] = (byte)rgb[0];
- _g[index] = (byte)rgb[1];
- _b[index] = (byte)rgb[2];
+ YCbCr2Rgb(yn, cbn, crn, _useBt601, out var r8, out var g8, out var b8);
+ _r[index] = (byte)r8;
+ _g[index] = (byte)g8;
+ _b[index] = (byte)b8;
}
/**
diff --git a/src/libse/BluRaySup/BluRaySupParser.cs b/src/libse/BluRaySup/BluRaySupParser.cs
index 7ca7677c1c4..8258e5e87e0 100644
--- a/src/libse/BluRaySup/BluRaySupParser.cs
+++ b/src/libse/BluRaySup/BluRaySupParser.cs
@@ -557,7 +557,10 @@ public static List ParseBluRaySupFromMatroska(MatroskaTrackInfo matrosk
subtitles.Add(sup);
// fix overlapping
- if (subtitles.Count > 1 && sub[subtitles.Count - 2].End > sub[subtitles.Count - 1].Start)
+ // Index "subtitles", not "sub": the two lists are not parallel (one
+ // container block can yield several display sets), so this compared
+ // unrelated blocks and threw once subtitles outgrew sub.
+ if (subtitles.Count > 1 && subtitles[subtitles.Count - 2].EndTime > subtitles[subtitles.Count - 1].StartTime)
{
subtitles[subtitles.Count - 2].EndTime = subtitles[subtitles.Count - 1].StartTime - 1;
}
diff --git a/src/libse/BluRaySup/BluRaySupPicture.cs b/src/libse/BluRaySup/BluRaySupPicture.cs
index 00ddd320639..498f3d90c0d 100644
--- a/src/libse/BluRaySup/BluRaySupPicture.cs
+++ b/src/libse/BluRaySup/BluRaySupPicture.cs
@@ -103,6 +103,13 @@ public class BluRaySupPicture
///
public List> Palettes { get; set; } = new List>();
+ ///
+ /// Alpha levels for a fade in/out, in presentation order. A step at
+ /// sets the alpha the caption appears with; every later step becomes a palette update
+ /// display set. Empty (the default) writes the caption fully opaque, as before.
+ ///
+ public List FadeSteps { get; set; } = new List();
+
///
/// Create RLE buffer from bitmap
///
@@ -121,6 +128,11 @@ private static byte[] EncodeImage(SKBitmap bm, List palette)
}
}
+ // Cap on the approximate-match memo below. A caption repeats its anti-aliasing
+ // colours on every row, but a photographic source can hold millions of distinct
+ // ones - past the cap the linear scan simply runs again, as it always did.
+ const int maxCachedColors = 1 << 16;
+
var transparentColor = (byte)palette[palette.Count - 1];
var bytes = new List(bm.Width * 2);
var reader = new BitmapRowReader(bm);
@@ -145,7 +157,15 @@ private static byte[] EncodeImage(SKBitmap bm, List palette)
}
else
{
+ // FindBestMatch is a linear scan over the (up to 255 entry) palette and
+ // the palette does not change while encoding, so the answer for a color
+ // is fixed. Anti-aliased edges hit this for the same handful of colors
+ // on every row of the caption; remember them in the same lookup.
color = FindBestMatch(c, palette);
+ if (lookup.Count < maxCachedColors)
+ {
+ lookup[c] = color;
+ }
}
for (len = 1; x + len < bm.Width; len++)
@@ -361,6 +381,15 @@ private static List GetBitmapPalette(SKBitmap bitmap, SKColor fontColor
lookup = new HashSet();
pal.Add(fontColor);
lookup.Add(fontColor);
+
+ // Colors already known to have a close palette entry. HasCloseColor is a linear scan
+ // over the palette, and this pass reached it for every pixel of every anti-aliased
+ // edge - a 1920x200 caption ran it hundreds of thousands of times over a palette
+ // growing towards 254 entries. A rejection can never be undone: the palette only
+ // grows, and the tolerance only widens as it does (1 -> 5 -> 25), so a color that
+ // had a close entry once still has one later. Rejections take no palette slot, so
+ // caching one can never skip an insertion.
+ var rejected = new HashSet();
for (var y = 0; y < bitmap.Height; y++)
{
reader.ReadRow(y, row);
@@ -373,6 +402,10 @@ private static List GetBitmapPalette(SKBitmap bitmap, SKColor fontColor
{
// exact color already exists
}
+ else if (rejected.Contains(c))
+ {
+ // a close enough color already exists
+ }
else if (pal.Count < 100)
{
if (!HasCloseColor(c, pal, 1))
@@ -380,6 +413,10 @@ private static List GetBitmapPalette(SKBitmap bitmap, SKColor fontColor
pal.Add(c);
lookup.Add(c);
}
+ else
+ {
+ rejected.Add(c);
+ }
}
else if (pal.Count < 240)
{
@@ -388,14 +425,32 @@ private static List GetBitmapPalette(SKBitmap bitmap, SKColor fontColor
pal.Add(c);
lookup.Add(c);
}
+ else
+ {
+ rejected.Add(c);
+ }
}
- else if (pal.Count < 254 && !HasCloseColor(c, pal, 25))
+ else if (pal.Count < 254)
{
- pal.Add(c);
- lookup.Add(c);
+ if (!HasCloseColor(c, pal, 25))
+ {
+ pal.Add(c);
+ lookup.Add(c);
+ }
+ else
+ {
+ rejected.Add(c);
+ }
}
}
}
+
+ // Every branch above requires pal.Count < 254, so a full palette freezes the
+ // result - the rest of the image only cost scans that could not change it.
+ if (pal.Count >= 254)
+ {
+ break;
+ }
}
pal.Add(SKColors.Transparent); // last entry must be transparent
@@ -444,6 +499,78 @@ private static int GetFpsId(double fps)
private static long _lastEndTimeForWrite = -1000;
+ ///
+ /// Splits into the alpha the caption appears with (part of the
+ /// epoch's own palette) and the steps that follow it as palette update display sets.
+ /// Steps outside the caption, and steps that do not change the alpha, are dropped - each
+ /// one would cost a display set for nothing.
+ ///
+ private static List GetFadeSteps(BluRaySupPicture pic, out int startAlphaPercent)
+ {
+ startAlphaPercent = 100;
+ var updates = new List();
+ if (pic.FadeSteps == null || pic.FadeSteps.Count == 0)
+ {
+ return updates;
+ }
+
+ var sorted = new List(pic.FadeSteps);
+ sorted.Sort((a, b) => a.TimeMs.CompareTo(b.TimeMs));
+
+ var lastAlpha = 100;
+ var lastTime = long.MinValue;
+ foreach (var step in sorted)
+ {
+ var alpha = Math.Min(100, Math.Max(0, step.AlphaPercent));
+ if (step.TimeMs <= pic.StartTime)
+ {
+ startAlphaPercent = alpha;
+ lastAlpha = alpha;
+ continue;
+ }
+
+ if (step.TimeMs >= pic.EndTime || alpha == lastAlpha || step.TimeMs == lastTime)
+ {
+ continue;
+ }
+
+ updates.Add(new BluRaySupFadeStep(step.TimeMs, alpha));
+ lastAlpha = alpha;
+ lastTime = step.TimeMs;
+ }
+
+ return updates;
+ }
+
+ ///
+ /// Writes a Palette Definition Segment with every alpha scaled by
+ /// - the whole of a Blu-ray fade is this one number
+ /// changing between display sets.
+ ///
+ private static int WritePds(byte[] buf, int index, byte[] packetHeader, BluRaySupPalette pal, int palSize, int paletteVersion, int alphaPercent)
+ {
+ packetHeader[10] = 0x14; // ID (keep PTS & DTS)
+ ToolBox.SetWord(packetHeader, 11, 2 + palSize * 5); // size
+ for (var i = 0; i < packetHeader.Length; i++)
+ {
+ buf[index++] = packetHeader[i];
+ }
+
+ buf[index++] = 0; // palette_id
+ buf[index++] = (byte)paletteVersion; // palette_version_number
+ var alpha = pal.GetAlpha();
+ for (var i = 0; i < palSize; i++)
+ {
+ buf[index++] = (byte)i; // index
+ buf[index++] = pal.GetY()[i]; // Y
+ buf[index++] = pal.GetCr()[i]; // Cr
+ buf[index++] = pal.GetCb()[i]; // Cb
+ buf[index++] = (byte)(alpha[i] * alphaPercent / 100); // Alpha
+ }
+
+ return index;
+ }
+
///
/// Create the binary stream representation of one caption
///
@@ -482,7 +609,8 @@ public static byte[] CreateSupFrame(BluRaySupPicture pic, SKBitmap bmp, SKColor
}
else
{
- numAddPackets = 1 + (rleBuf.Length - 0xffe4) / 0xffeb;
+ // round up, but without an extra empty packet when the rest divides evenly
+ numAddPackets = (rleBuf.Length - 0xffe4 + 0xffeb - 1) / 0xffeb;
}
// a typical frame consists of 8 packets. It can be elongated by additional object frames
@@ -542,14 +670,20 @@ public static byte[] CreateSupFrame(BluRaySupPicture pic, SKBitmap bmp, SKColor
{
0x00, 0x00, // 0: object_id
0x00, // 2: object_version_number
- 0x40 // 3: first_in_sequence (0x80), last_in_sequence (0x40), 6bits reserved
+ 0x00 // 3: first_in_sequence (0x80), last_in_sequence (0x40), 6bits reserved
+ // set per packet below - only the final one is last_in_sequence
};
- var size = packetHeader.Length * (8 + numAddPackets);
+ // Fade steps ride along as palette update display sets (PCS + PDS + END) between the
+ // caption and the screen clear - same object, only the palette alpha changes.
+ var fadeSteps = GetFadeSteps(pic, out var startAlphaPercent);
+
+ var size = packetHeader.Length * (8 + numAddPackets + fadeSteps.Count * 3);
size += headerPcsStart.Length + headerPcsEnd.Length;
size += 2 * headerWds.Length + headerOdsFirst.Length;
size += numAddPackets * headerOdsNext.Length;
size += (2 + palSize * 5) /* PDS */;
+ size += fadeSteps.Count * (headerPcsStart.Length + 2 + palSize * 5);
size += rleBuf.Length;
switch (alignment)
@@ -679,23 +813,7 @@ public static byte[] CreateSupFrame(BluRaySupPicture pic, SKBitmap bmp, SKColor
}
// write PDS - Palette Definition Segment
- packetHeader[10] = 0x14; // ID (keep PTS & DTS)
- ToolBox.SetWord(packetHeader, 11, 2 + palSize * 5); // size
- for (var i = 0; i < packetHeader.Length; i++)
- {
- buf[index++] = packetHeader[i];
- }
-
- buf[index++] = 0;
- buf[index++] = 0;
- for (var i = 0; i < palSize; i++)
- {
- buf[index++] = (byte)i; // index
- buf[index++] = pal.GetY()[i]; // Y
- buf[index++] = pal.GetCr()[i]; // Cr
- buf[index++] = pal.GetCb()[i]; // Cb
- buf[index++] = pal.GetAlpha()[i]; // Alpha
- }
+ index = WritePds(buf, index, packetHeader, pal, palSize, 0, startAlphaPercent);
// write first OBJ
var bufSize = rleBuf.Length;
@@ -742,6 +860,8 @@ public static byte[] CreateSupFrame(BluRaySupPicture pic, SKBitmap bmp, SKColor
buf[index++] = packetHeader[i];
}
+ // only the final fragment carries last_in_sequence - middle ones must be 0x00
+ headerOdsNext[3] = (byte)(p == numAddPackets - 1 ? 0x40 : 0x00);
for (var i = 0; i < headerOdsNext.Length; i++)
{
buf[index++] = headerOdsNext[i];
@@ -763,6 +883,47 @@ public static byte[] CreateSupFrame(BluRaySupPicture pic, SKBitmap bmp, SKColor
buf[index++] = packetHeader[i];
}
+ // write the fade steps - one palette update display set each (PCS + PDS + END). The
+ // PCS repeats the composition of the epoch start with palette_update_flag set, which
+ // tells the decoder to keep the object it already has and only take the new palette.
+ var endPts = pic.EndTimeForWrite;
+ var compositionNumber = pic.CompositionNumber;
+ headerPcsStart[7] = 0x00; // composition_state: normal case
+ headerPcsStart[8] = 0x80; // palette_update_flag
+ for (var step = 0; step < fadeSteps.Count; step++)
+ {
+ // A step may only be scheduled after the caption is up and before it is taken
+ // down; the start PTS can have been nudged by the small gap removal above.
+ var stepPts = Math.Min(Math.Max(fadeSteps[step].TimeForWrite, pts + 1), endPts - 1);
+
+ compositionNumber++;
+ packetHeader[10] = 0x16; // ID
+ ToolBox.SetDWord(packetHeader, 2, (uint)stepPts); // PTS
+ ToolBox.SetDWord(packetHeader, 6, 0); // DTS (0 = unset)
+ ToolBox.SetWord(packetHeader, 11, headerPcsStart.Length); // size
+ for (var i = 0; i < packetHeader.Length; i++)
+ {
+ buf[index++] = packetHeader[i];
+ }
+
+ ToolBox.SetWord(headerPcsStart, 5, compositionNumber);
+ for (var i = 0; i < headerPcsStart.Length; i++)
+ {
+ buf[index++] = headerPcsStart[i];
+ }
+
+ // The palette version has to move for the decoder to take the update; it is a
+ // byte, so it wraps on captions with more than 255 steps.
+ index = WritePds(buf, index, packetHeader, pal, palSize, (step + 1) & 0xff, fadeSteps[step].AlphaPercent);
+
+ packetHeader[10] = 0x80; // END (keep PTS & DTS)
+ ToolBox.SetWord(packetHeader, 11, 0); // size
+ for (var i = 0; i < packetHeader.Length; i++)
+ {
+ buf[index++] = packetHeader[i];
+ }
+ }
+
// write PCS end
packetHeader[10] = 0x16; // ID
ToolBox.SetDWord(packetHeader, 2, (uint)pic.EndTimeForWrite); // PTS
@@ -776,7 +937,7 @@ public static byte[] CreateSupFrame(BluRaySupPicture pic, SKBitmap bmp, SKColor
ToolBox.SetWord(headerPcsEnd, 0, pic.Width);
ToolBox.SetWord(headerPcsEnd, 2, h); // cropped height
ToolBox.SetByte(headerPcsEnd, 4, fpsId);
- ToolBox.SetWord(headerPcsEnd, 5, pic.CompositionNumber + 1);
+ ToolBox.SetWord(headerPcsEnd, 5, compositionNumber + 1);
for (var i = 0; i < headerPcsEnd.Length; i++)
{
buf[index++] = headerPcsEnd[i];
diff --git a/src/libse/BluRaySup/SkiaExt.cs b/src/libse/BluRaySup/SkiaExt.cs
index 9825c38f9ce..504965c1e95 100644
--- a/src/libse/BluRaySup/SkiaExt.cs
+++ b/src/libse/BluRaySup/SkiaExt.cs
@@ -111,13 +111,23 @@ private static bool ByteArraysEqual(ReadOnlySpan a1, ReadOnlySpan a2
return a1.SequenceEqual(a2);
}
- public class TrimResult
+ ///
+ /// is always a new bitmap (a crop or a copy), never the source,
+ /// so callers own it - hence .
+ ///
+ public class TrimResult : IDisposable
{
public SKBitmap TrimmedBitmap { get; set; }
public int Top { get; set; }
public int Left { get; set; }
public int Right { get; set; }
public int Bottom { get; set; }
+
+ public void Dispose()
+ {
+ TrimmedBitmap?.Dispose();
+ TrimmedBitmap = null;
+ }
}
///
diff --git a/src/libse/BluRaySup/ToolBox.cs b/src/libse/BluRaySup/ToolBox.cs
index 1ffa4a1bea8..9d3970d70a1 100644
--- a/src/libse/BluRaySup/ToolBox.cs
+++ b/src/libse/BluRaySup/ToolBox.cs
@@ -29,15 +29,17 @@ public static class ToolBox
*/
public static long[] MillisecondsToTime(double ms)
{
+ // Each component must be truncated, not rounded: rounding one up makes the remainder
+ // negative and cascades, so 3500 ms came out as 0:0:4:-500 and 40 minutes as 1:-20:0:0.
var time = new long[4];
// time[0] = hours
- time[0] = (long)Math.Round(ms / (60 * 60 * 1000), MidpointRounding.AwayFromZero);
+ time[0] = (long)(ms / (60.0 * 60.0 * 1000.0));
ms -= time[0] * 60.0 * 60.0 * 1000.0;
// time[1] = minutes
- time[1] = (long)Math.Round(ms / (60.0 * 1000.0), MidpointRounding.AwayFromZero);
+ time[1] = (long)(ms / (60.0 * 1000.0));
ms -= time[1] * 60 * 1000;
// time[2] = seconds
- time[2] = (long)Math.Round(ms / 1000.0, MidpointRounding.AwayFromZero);
+ time[2] = (long)(ms / 1000.0);
ms -= time[2] * 1000.0;
time[3] = (long)Math.Round(ms, MidpointRounding.AwayFromZero);
return time;
diff --git a/src/libse/CDG/CdgGraphics.cs b/src/libse/CDG/CdgGraphics.cs
index c60cde3c67d..ebe438b87cc 100644
--- a/src/libse/CDG/CdgGraphics.cs
+++ b/src/libse/CDG/CdgGraphics.cs
@@ -79,7 +79,10 @@ public SKBitmap ToBitmap(int packetNumber) // long timeInMilliseconds)
}
var graphicData = GetGraphicData();
- var bitmap = new SKBitmap(FullWidth, FullHeight, SKColorType.Rgba8888, SKAlphaType.Premul);
+ // Bgra8888, not Rgba8888: the loop below packs (a<<24)|(r<<16)|(g<<8)|b, which on
+ // a little-endian machine lands in memory as B,G,R,A. Declaring Rgba8888 made
+ // Skia read that back as R,G,B,A - i.e. red and blue swapped in every frame.
+ var bitmap = new SKBitmap(FullWidth, FullHeight, SKColorType.Bgra8888, SKAlphaType.Premul);
// Copy pixel data into the bitmap
var pixelData = bitmap.GetPixels();
diff --git a/src/libse/Cea608/CaptionScreen.cs b/src/libse/Cea608/CaptionScreen.cs
index ce0a7514e04..a4a0550358c 100644
--- a/src/libse/Cea608/CaptionScreen.cs
+++ b/src/libse/Cea608/CaptionScreen.cs
@@ -184,8 +184,19 @@ public void RollUp()
return;
}
+ // Reuse the row that scrolled off the top, cleared - a plain `new CcRow()` is NOT
+ // empty: its chars start with a null foreground/background pen state, so IsEmpty()
+ // (and with it CaptionScreen.IsEmpty and Serialize) reported a blank row as content
+ // for the rest of the file once a roll-up had happened.
+ var topRow = rows[removeIndex];
rows.RemoveAt(removeIndex);
- rows.Add(new CcRow());
+ topRow.Clear();
+
+ // It goes back at the roll-up base row, not at the bottom of the screen. Appending
+ // only happens to be right when the base row IS the bottom row - with a roll-up
+ // window placed higher up (a PAC can put it anywhere) appending scrolled every row
+ // below the window along with it.
+ rows.Insert(Math.Min(CurrentRow, rows.Count), topRow);
Rows = rows.ToArray();
}
}
diff --git a/src/libse/Cea708/VancDataWriter.cs b/src/libse/Cea708/VancDataWriter.cs
index 24bddaed78f..b31cbdcaf9b 100644
--- a/src/libse/Cea708/VancDataWriter.cs
+++ b/src/libse/Cea708/VancDataWriter.cs
@@ -76,12 +76,49 @@ public static string[] GenerateLinesFromText(string input, int counter)
}
bytes.AddRange(c1.GetBytes());
- var c2 = new SetText(line);
- if (c2.GetBytes().Length + bytes.Count > 32)
+ // Split the line across packets instead of appending it whole: a cc_data
+ // section holds at most 16 byte-pairs, so a single SetText longer than the
+ // remaining room made CcDataSection throw "Too many bytes for CCData!" and the
+ // whole save failed on any line of ~34 characters or more.
+ var remaining = line;
+ while (remaining.Length > 0)
{
- counter = FlushCommands(counter, bytes, results);
+ var room = 32 - bytes.Count;
+ if (room <= 0)
+ {
+ counter = FlushCommands(counter, bytes, results);
+ room = 32 - bytes.Count;
+ }
+
+ // Count whole characters: a G2 character encodes as EXT1 + code, and
+ // splitting that pair apart would emit a stray escape.
+ var take = 0;
+ var used = 0;
+ while (take < remaining.Length)
+ {
+ var charLength = new SetText(remaining[take].ToString()).GetBytes().Length;
+ if (used + charLength > room)
+ {
+ break;
+ }
+
+ used += charLength;
+ take++;
+ }
+
+ if (take == 0)
+ {
+ counter = FlushCommands(counter, bytes, results);
+ continue;
+ }
+
+ bytes.AddRange(new SetText(remaining.Substring(0, take)).GetBytes());
+ remaining = remaining.Substring(take);
+ if (remaining.Length > 0)
+ {
+ counter = FlushCommands(counter, bytes, results);
+ }
}
- bytes.AddRange(c2.GetBytes());
}
FlushCommands(counter, bytes, results);
diff --git a/src/libse/Common/ActorConverter.cs b/src/libse/Common/ActorConverter.cs
index 1fb76b36c7f..861bbe7c888 100644
--- a/src/libse/Common/ActorConverter.cs
+++ b/src/libse/Common/ActorConverter.cs
@@ -4,7 +4,6 @@
using System;
using System.Collections.Generic;
using System.Linq;
-using System.Text;
namespace Nikse.SubtitleEdit.Core.Common
{
@@ -35,8 +34,19 @@ public ActorConverter(SubtitleFormat subtitleFormat, string languageCode)
_nameListInclMulti = _namesList.GetAllNames();
}
- public string FixActorsFromActor(Paragraph p, int? changeCasing, SKColor? color)
+ ///
+ /// Moves the actor column into the text. The converted paragraph is returned in the result -
+ /// the actor column is cleared, as the name now lives in the text and would otherwise be
+ /// written twice (#14077).
+ ///
+ public ActorConverterResult FixActorsFromActor(Paragraph paragraph, int? changeCasing, SKColor? color)
{
+ var p = new Paragraph(paragraph, false);
+ if (ToActor)
+ {
+ return new ActorConverterResult { Paragraph = p, Selected = true };
+ }
+
var actor = p.Actor;
if (changeCasing.HasValue)
{
@@ -55,85 +65,161 @@ public string FixActorsFromActor(Paragraph p, int? changeCasing, SKColor? color)
{
actor = actor + ":";
}
- else if (ToActor)
- {
- return p.Text;
- }
- if (color.HasValue && !ToActor)
+ if (color.HasValue)
{
actor = SetColor(_subtitleFormat, color.Value, actor);
}
p.Text = actor + " " + p.Text.TrimStart(' ');
+ p.Actor = string.Empty;
- return p.Text;
+ return new ActorConverterResult { Paragraph = p, Selected = true };
}
- public string FixActorsFromBeforeColon(Paragraph p, char ch, int? changeCasing, SKColor? color)
+ ///
+ /// Converts "Actor: text" lines. The converted paragraph is returned in the result: converting
+ /// to the actor column writes from whichever line carries the
+ /// name, and a second speaker in the same paragraph becomes
+ /// - the same shape
+ /// returns for the bracket formats (#14077).
+ ///
+ public ActorConverterResult FixActorsFromBeforeColon(Paragraph paragraph, char ch, int? changeCasing, SKColor? color)
{
- var sb = new StringBuilder();
- foreach (var line in p.Text.SplitToLines())
+ var p = new Paragraph(paragraph, false);
+ var lines = p.Text.SplitToLines();
+
+ // Only one extra paragraph can be split off, so a third speaker would be lost.
+ if (ToActor && lines.Count(line => HasActor(line, ch)) > 2)
{
+ return new ActorConverterResult { Paragraph = paragraph, Skip = true };
+ }
+
+ Paragraph nextParagraph = null;
+ var selectFix = true;
+ var actorAssigned = false;
+ var textLines = new List();
+ foreach (var line in lines)
+ {
+ // index into the trimmed line - leading whitespace goes with the actor it precedes
var s = line.Trim();
- var startIdx = line.IndexOf(ch);
- if (startIdx > 0)
+ var startIdx = s.IndexOf(ch);
+ if (startIdx <= 0)
{
- var actor = s.Substring(0, startIdx).Trim(' ', '-', '"');
- if (changeCasing.HasValue)
- {
- actor = SetCasing(_subtitleFormat, changeCasing, actor);
- }
-
- if (ToSquare)
- {
- actor = "[" + actor + "]";
- }
- else if (ToParentheses)
- {
- actor = "(" + actor + ")";
- }
- else if (ToColon)
+ // A line without an actor belongs to the paragraph the previous line went to.
+ if (nextParagraph != null)
{
- actor = actor + ":";
+ nextParagraph.Text = (nextParagraph.Text + Environment.NewLine + s).Trim();
}
- else if (ToActor)
+ else
{
+ textLines.Add(s);
}
- if (color.HasValue && !ToActor)
- {
- SetColor(_subtitleFormat, color.Value, actor);
- }
+ continue;
+ }
- if (ToSquare)
- {
- s = actor + " " + s.Substring(startIdx + 1).TrimStart(' ');
- }
- else if (ToParentheses)
- {
- s = actor + " " + s.Substring(startIdx + 1).TrimStart(' ');
- }
- else if (ToColon)
- {
- s = actor + " " + s.Substring(startIdx + 1).TrimStart(' ');
- }
- else if (ToActor)
- {
- s = s.Substring(startIdx + 1);
- }
+ var actor = s.Substring(0, startIdx).Trim(' ', '-', '"');
+ // AND, not assignment: selectFix is declared outside the per-line loop, so a later line's valid speaker used to mask an earlier line whose "actor" was really a clock ("It's 12:30 now.") - pre-checking a fix that destroys text.
+ selectFix &= IsActor(actor);
+ if (changeCasing.HasValue)
+ {
+ actor = SetCasing(_subtitleFormat, changeCasing, actor);
}
- sb.AppendLine(s);
+ if (ToSquare)
+ {
+ actor = "[" + actor + "]";
+ }
+ else if (ToParentheses)
+ {
+ actor = "(" + actor + ")";
+ }
+ else if (ToColon)
+ {
+ actor = actor + ":";
+ }
+
+ if (color.HasValue && !ToActor)
+ {
+ actor = SetColor(_subtitleFormat, color.Value, actor);
+ }
+
+ var text = s.Substring(startIdx + 1).TrimStart(' ');
+ if (!ToActor)
+ {
+ textLines.Add(actor + " " + text);
+ }
+ else if (!actorAssigned)
+ {
+ // The first name found goes in the actor column...
+ p.Actor = actor;
+ actorAssigned = true;
+ textLines.Add(text);
+ }
+ else
+ {
+ // ...a second one needs a paragraph of its own, as the column holds one name.
+ nextParagraph = new Paragraph(p) { Text = text, Actor = actor };
+ }
}
- return sb.ToString().Trim();
+ p.Text = string.Join(Environment.NewLine, textLines).Trim();
+
+ return new ActorConverterResult
+ {
+ Paragraph = p,
+ NextParagraph = nextParagraph,
+ Selected = selectFix,
+ };
+ }
+
+ private static bool HasActor(string line, char ch)
+ {
+ return line.Trim().IndexOf(ch) > 0;
+ }
+
+ ///
+ /// A line splitter can break a tag in half - "Princess Peach on (Speaker\n2) Super Smash
+ /// Bros." - and the per-line scan in then finds no complete pair on
+ /// either line, so the paragraph was silently left alone. Line breaks inside a bracket pair
+ /// are folded to a space, pulling the two half-lines together. Capped at name length: a
+ /// bracket pair spanning that much text is a parenthetical remark, not a speaker tag.
+ ///
+ private static string JoinTagBrokenOverLineBreak(string text, char start, char end)
+ {
+ const int maxTagLength = 50;
+
+ var startIdx = text.IndexOf(start);
+ while (startIdx >= 0)
+ {
+ var endIdx = text.IndexOf(end, startIdx + 1);
+ if (endIdx < 0)
+ {
+ break;
+ }
+
+ var inner = text.Substring(startIdx + 1, endIdx - startIdx - 1);
+ if (inner.Length <= maxTagLength &&
+ inner.IndexOf(start) < 0 &&
+ (inner.Contains('\n') || inner.Contains('\r')))
+ {
+ var joined = string.Join(" ", inner.Split((char[])null, StringSplitOptions.RemoveEmptyEntries));
+ text = text.Substring(0, startIdx + 1) + joined + text.Substring(endIdx);
+ endIdx = startIdx + 1 + joined.Length;
+ }
+
+ startIdx = text.IndexOf(start, endIdx + 1);
+ }
+
+ return text;
}
public ActorConverterResult FixActors(Paragraph paragraph, char start, char end, int? changeCasing, SKColor? color)
{
var p = new Paragraph(paragraph, false);
Paragraph nextParagraph = null;
+ p.Text = JoinTagBrokenOverLineBreak(p.Text, start, end);
var lines = p.Text.SplitToLines();
if (lines.Count > 2)
{
@@ -148,15 +234,15 @@ public ActorConverterResult FixActors(Paragraph paragraph, char start, char end,
var s = line;
var startIdx = s.IndexOf(start);
var endIdx = s.IndexOf(end);
- if (startIdx != -1 && endIdx != -1)
- {
- if (endIdx < startIdx)
- {
- break;
- }
+ // A closing bracket before the opening one is not an actor - the line is kept as it
+ // is. Giving up on the whole paragraph here dropped this line and every line after
+ // it from the text.
+ if (startIdx != -1 && endIdx > startIdx)
+ {
var actor = s.Substring(startIdx + 1, endIdx - startIdx - 1).Trim(' ', '-', '"');
- selectFix = IsActor(actor);
+ // AND, not assignment: selectFix is declared outside the per-line loop, so a later line's valid speaker used to mask an earlier line whose "actor" was really a clock ("It's 12:30 now.") - pre-checking a fix that destroys text.
+ selectFix &= IsActor(actor);
if (changeCasing.HasValue)
{
actor = SetCasing(_subtitleFormat, changeCasing, actor);
@@ -208,9 +294,20 @@ public ActorConverterResult FixActors(Paragraph paragraph, char start, char end,
}
else if (lineIdx == 1 && ToActor)
{
- nextParagraph = new Paragraph(p);
- nextParagraph.Text = s.Trim();
- nextParagraph.Actor = actor;
+ if (string.IsNullOrEmpty(p.Actor))
+ {
+ // Only the second line names a speaker, so it belongs to this paragraph -
+ // splitting off a paragraph with no actor at all would leave the name
+ // nowhere (#14077).
+ p.Actor = actor;
+ p.Text = (p.Text + Environment.NewLine + s.Trim()).Trim();
+ }
+ else
+ {
+ nextParagraph = new Paragraph(p);
+ nextParagraph.Text = s.Trim();
+ nextParagraph.Actor = actor;
+ }
}
else if (lineIdx == 1)
{
@@ -269,6 +366,8 @@ private static string SetColor(SubtitleFormat format, SKColor color, string acto
return actor;
}
+ private static readonly string[] CommonTitles = { "Mr.", "Mrs.", "Dr." };
+
private bool IsActor(string s)
{
if (string.IsNullOrWhiteSpace(s))
@@ -294,15 +393,14 @@ private bool IsActor(string s)
return false;
}
- if (word.Any(c => char.IsDigit(c) || (!char.IsLetter(c) && c != '-' && c != '\'')))
+ if (CommonTitles.Contains(word))
{
- return false;
+ continue;
}
- var commonTitles = new[] { "Mr.", "Mrs.", "Dr.", };
- if (commonTitles.Contains(word))
+ if (word.Any(c => char.IsDigit(c) || (!char.IsLetter(c) && c != '-' && c != '\'')))
{
- continue;
+ return false;
}
if (!_nameListInclMulti.Contains(word, StringComparer.OrdinalIgnoreCase))
diff --git a/src/libse/Common/AssaResampler.cs b/src/libse/Common/AssaResampler.cs
index 92b5c869930..84c3d969f02 100644
--- a/src/libse/Common/AssaResampler.cs
+++ b/src/libse/Common/AssaResampler.cs
@@ -183,6 +183,20 @@ private static string FixDrawing(decimal sourceWidth, decimal targetWidth, decim
return sb.ToString().TrimEnd() + s;
}
+ // The tag regexes below allow whitespace ("\pos (10,11)"), so the parameters cannot be cut
+ // at a fixed offset from the tag name - take everything between the parentheses instead.
+ private static string GetTagParameters(string matchValue)
+ {
+ var open = matchValue.IndexOf('(');
+ var close = matchValue.LastIndexOf(')');
+ if (open < 0 || close <= open)
+ {
+ return string.Empty;
+ }
+
+ return matchValue.Substring(open + 1, close - open - 1).RemoveChar(' ');
+ }
+
private static string FixMethodFourParameters(decimal sourceWidth, decimal targetWidth, decimal sourceHeight, decimal targetHeight, string input, string tag)
{
var regex = GetCachedRegex("\\\\" + tag + "\\s*\\(\\s*[-+]?\\d+[\\.\\d+]*\\s*,\\s*[-+]?\\d+[\\.\\d+]*\\s*,\\s*[-+]?\\d+[\\.\\d+]*\\s*,\\s*[-+]?\\d+[\\.\\d+]*\\s*\\)");
@@ -190,7 +204,7 @@ private static string FixMethodFourParameters(decimal sourceWidth, decimal targe
var match = regex.Match(s);
while (match.Success)
{
- var value = match.Value.Substring(tag.Length + 2, match.Value.Length - tag.Length - 3).RemoveChar(' ');
+ var value = GetTagParameters(match.Value);
var arr = value.Split(',');
if (arr.Length == 4 &&
decimal.TryParse(arr[0], NumberStyles.AllowDecimalPoint | NumberStyles.AllowLeadingSign, CultureInfo.InvariantCulture, out var x1) &&
@@ -212,7 +226,8 @@ private static string FixMethodFourParameters(decimal sourceWidth, decimal targe
}
else
{
- break;
+ // Skip this tag rather than abandoning every remaining tag on the line.
+ match = regex.Match(s, match.Index + match.Value.Length);
}
}
@@ -226,7 +241,7 @@ private static string FixMethodSixParametersFourActive(decimal sourceWidth, deci
var match = regex.Match(s);
while (match.Success)
{
- var value = match.Value.Substring(tag.Length + 2, match.Value.Length - tag.Length - 3).RemoveChar(' ');
+ var value = GetTagParameters(match.Value);
var arr = value.Split(',');
if (arr.Length == 6 &&
decimal.TryParse(arr[0], NumberStyles.AllowDecimalPoint | NumberStyles.AllowLeadingSign, CultureInfo.InvariantCulture, out var x1) &&
@@ -252,7 +267,8 @@ private static string FixMethodSixParametersFourActive(decimal sourceWidth, deci
}
else
{
- break;
+ // Skip this tag rather than abandoning every remaining tag on the line.
+ match = regex.Match(s, match.Index + match.Value.Length);
}
}
@@ -266,7 +282,7 @@ private static string FixMethodTwoParameters(decimal sourceWidth, decimal target
var match = regex.Match(s);
while (match.Success)
{
- var value = match.Value.Substring(tag.Length + 2, match.Value.Length - tag.Length - 3).RemoveChar(' ');
+ var value = GetTagParameters(match.Value);
var arr = value.Split(',');
if (arr.Length == 2 &&
decimal.TryParse(arr[0], NumberStyles.AllowDecimalPoint | NumberStyles.AllowLeadingSign, CultureInfo.InvariantCulture, out var x) &&
@@ -282,7 +298,8 @@ private static string FixMethodTwoParameters(decimal sourceWidth, decimal target
}
else
{
- break;
+ // Skip this tag rather than abandoning every remaining tag on the line.
+ match = regex.Match(s, match.Index + match.Value.Length);
}
}
@@ -306,7 +323,8 @@ private static string FixTagWithNumber(decimal sourceHeight, decimal targetHeigh
}
else
{
- break;
+ // Skip this tag rather than abandoning every remaining tag on the line.
+ match = regex.Match(s, match.Index + match.Value.Length);
}
}
diff --git a/src/libse/Common/CharUtils.cs b/src/libse/Common/CharUtils.cs
index 6ea30179028..a9b071e4035 100644
--- a/src/libse/Common/CharUtils.cs
+++ b/src/libse/Common/CharUtils.cs
@@ -6,7 +6,7 @@ public static class CharUtils
/// Checks if character matches [0-9]
///
///
- public static bool IsDigit(char ch) => ch >= '0' && ch <= '9';
+ public static bool IsAsciiDigit(char ch) => ch >= '0' && ch <= '9';
///
/// Checks if given character is hexadecimal
@@ -17,6 +17,7 @@ public static class CharUtils
///
/// Checks if character is between A-Z or a-z
///
- public static bool IsEnglishAlphabet(char ch) => ch >= 'A' && ch <= 'z' && (ch <= 'Z' || ch >= 'a');
+ /// char.IsAsciiLetter (.NET 7+) is not available in netstandard2.1, which this library also targets.
+ public static bool IsAsciiLetter(char ch) => ch >= 'A' && ch <= 'z' && (ch <= 'Z' || ch >= 'a');
}
}
\ No newline at end of file
diff --git a/src/libse/Common/ContinuationUtilities.cs b/src/libse/Common/ContinuationUtilities.cs
index d2321434bb5..ccc26e51cd5 100644
--- a/src/libse/Common/ContinuationUtilities.cs
+++ b/src/libse/Common/ContinuationUtilities.cs
@@ -708,7 +708,9 @@ public static string RemovePrefix(string originalText, ContinuationProfile profi
foreach (var prefix in Prefixes)
{
- if (newFirstWord.StartsWith(prefix, StringComparison.Ordinal) && !newFirstWord.EndsWith(prefix + Environment.NewLine, StringComparison.Ordinal))
+ // EndsWith cannot express "the prefix is followed by a line break" - the prefix was
+ // just matched at the START. HasPrefix below is the correct twin of this test.
+ if (newFirstWord.StartsWith(prefix, StringComparison.Ordinal) && !newFirstWord.StartsWith(prefix + Environment.NewLine, StringComparison.Ordinal))
{
newFirstWord = newFirstWord.Substring(prefix.Length);
}
@@ -911,7 +913,9 @@ public static bool IsBold(string input)
while (input.IndexOf("", StringComparison.Ordinal) >= 0)
{
var startIndex = input.IndexOf("", StringComparison.Ordinal);
- var endIndex = input.IndexOf("", StringComparison.Ordinal);
+ // From startIndex, as IsItalic does: searching from 0 found a stray leading
+ // "" and produced a negative Substring length, throwing on ordinary text.
+ var endIndex = input.IndexOf("", startIndex, StringComparison.Ordinal);
var textToRemove = endIndex >= 0 ? input.Substring(startIndex, (endIndex + 4) - startIndex) : input.Substring(startIndex);
input = input.Replace(textToRemove, string.Empty);
}
@@ -960,15 +964,15 @@ public static bool IsFullLineTag(string input, int position)
var endIndex = input.LastIndexOf("", StringComparison.Ordinal);
if (endIndex >= 0)
{
+ // A truncated closing tag ("
" + Encode(summary) + "
"); + sb.AppendLine(" "); + sb.AppendLine("