From 3d8fafa62573003b3533c35106962d6a36fac836 Mon Sep 17 00:00:00 2001 From: erklu Date: Sun, 18 Oct 2026 07:20:42 +0000 Subject: [PATCH 1/8] Add word highlights to the transcript editor, with optional RTF export (#131) Select text and press Ctrl+Shift+H, or use the Highlight button in the toolbar, to highlight it; do it again to take the highlight off. A note to self made while reviewing against the recording -- it never changes the text. - Anchored to whole words (SRTCaption.highlighted_words), kept apart from the "my edits" marks. carry_highlights() moves them with the words through typing, split, merge, delete across captions, word moves and Find & Replace; a word typed between two highlighted words joins the highlight, any other new word does not earn one. - Saved with the transcription ("highlighted" next to "edited"). Transcriptions only for now: a set of subtitles is plain SRT and has nowhere to keep them. - Drawn yellow with a line above and below so colour is not the only cue (1.4.1, 1.4.11), role="mark", distinguishable from flagged words, my edits and the word being played, in both themes. - RTF export can carry highlights and changed words; the options are unchecked by default and only shown when there is something to carry (a clean export is unchanged). - Shortcuts dialog gets a "Highlighting" group for transcriptions. Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01LCjpD3ehigLiUGH1oVQQAW --- AGENTS.md | 11 + pages/srt.py | 24 + tests/test_highlights.py | 1027 +++++++++++++++++++++++++++++++ tests/test_my_edits.py | 5 +- tests/test_transcript_editor.py | 9 + utils/caption.py | 13 + utils/srt.py | 124 +++- utils/srt_export.py | 96 ++- utils/srt_render.py | 11 + utils/srt_review.py | 228 ++++++- utils/srt_search.py | 10 +- utils/styles.py | 65 ++ utils/transcript_editor.js | 128 +++- utils/transcript_editor.py | 78 +++ 14 files changed, 1800 insertions(+), 29 deletions(-) create mode 100644 tests/test_highlights.py diff --git a/AGENTS.md b/AGENTS.md index 3e22e04..9c02fe9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -124,6 +124,17 @@ There was a separate "Uncertain words" switch alongside a Low/Medium/High select - **The marking must not read as an error.** No red, and no wavy underline (that means spellcheck). It uses `--color-review-*`, a violet reserved for this and used nowhere else. - The flagged counter comes from `flagged_word_count()` over the whole word list, and reports 0 while the switch is off. Anything that changes the switch or the sensitivity must call `update_flagged_count()`. +### Highlights (SUNET/scribe-ui#131) + +The reader's own highlighter: a note to self made while reviewing against the recording, never a change to the text. Stored as `SRTCaption.highlighted_words`, word positions counted exactly as `edited_words` is, and **kept apart from it** -- the two mean different things and move differently when the text does. Anchored to whole words, not characters: a selection that takes any of a word takes all of it (`words_in_selection()`; nothing selected means the word the caret is in or against), so a highlight survives every edit the way an edit mark does, and cannot end up cutting a word. + +- **They follow the words.** `carry_highlights(previous, current, marked)` is the highlight's own `retag_edits`: words that stay move with their marks, words replaced one for one keep the mark, a longer or shorter replacement is highlighted when everything it replaced was, words typed *between two highlighted words* join the highlight and words typed anywhere else do **not** earn one (unlike an edit mark: typing a word is not highlighting it), removed words take theirs with them. `carry_marks(caption, new_text)` does both kinds in one call and is what every site that replaces a caption's text goes through (typing, split with unsent text, Find & Replace). Split/merge/`delete_range` reuse `split_edits`/`joined_edits` on the second set, and the one-word moves carry it across (they still do not carry edit marks). Any new place that rewrites `caption.text` has to carry both sets. +- **Toggling.** Ctrl+Shift+H (Ctrl only, by `event.code === "KeyH"`: Cmd+Shift+H is the browser's Home on a Mac; no Alt) reports `togglehighlight` with one `{id, start, end}` per block the selection reaches into (`highlightSpans()`, offsets in each block's own text), ahead of the block-scoped lists because those see only the caret's block. `SRTEditor.toggle_highlight()` takes everything touched: all already highlighted -> remove, otherwise add (a mixed selection becomes fully highlighted, a second press clears it). One undo step. `TranscriptEditor.toggle_highlight()` redraws and **puts the selection back** (`selectRange()`, offsets are unchanged because the text is), so the same key undoes what it did. Nothing selected and no word at the caret says so. **The toolbar's Highlight button** (`pages/srt.py`, where Validate sits in the subtitle editor, so transcriptions only) does the same for whoever does not know the key: it calls `TranscriptEditor.highlight_selection()` -> the component's `highlightSelection()`, which reads the selection as it is and emits the same `togglehighlight` (an empty list when nothing is selected, answered with the notice). It prevents the default on `mousedown`, as the caption row's note icon does, so pressing it does not take focus and the selection with it; `aria-keyshortcuts` names the key, the tooltip too. +- **Saved with the transcription -- and for now only there.** `to_dict()` writes `"highlighted"` (only when non-empty) next to `"edited"` and `parse_txt()` reads it back, ignoring anything that is not a list of ints. A set of subtitles is saved as plain SRT, which has no room for it, so **highlights are a transcription feature for now**: the key is not bound in `subtitleMode`, `SRTEditor.toggle_highlight()` refuses for `data_format == "srt"`, the shortcuts dialog has no "Highlighting" group there, and neither export option is offered or applied (`export_rtf()` ignores both for subtitles). The way to extend it is a place to keep marks beside an SRT result (positions plus a digest of each caption's text, restored only onto a caption with the same number and text) -- a backend endpoint; a working draft exists on the local branch `backup/highlight-words-with-subtitle-marks`, never pushed. +- **Drawing.** `review_runs()` gives a highlighted word `hl: true` (and makes every word a run of its own whenever a block has any highlight, since it is drawn with no switch on); the space between two highlighted words is a highlighted run too, so a passage reads as one stretch, except across a line break. The component draws `runClass(run)` (the review/edit class **and** `highlight-word`) with `role="mark"`. Yellow, and a 2px line above and below: yellow on white is 1.14:1 so colour cannot carry it (1.4.1/1.4.11) -- the lines are 4.9:1 against the page in light mode and the text on the yellow 13.6:1; dark mode uses a dark gold (8.0:1 text, 12.6:1 frame). No horizontal padding, so highlighting does not move the text. On a flagged or changed word the highlight takes the wash and the word keeps its own bar underneath; the playing word's tint wins the wash and the lines stay. +- **RTF export** (`export_rtf(..., highlights=False, edits=False)`, the two checkboxes in the export dialog, transcriptions only, **off by default** and **hidden when there is no mark of that kind** to carry, `rtf_marks_available()`, across every transcription of a bulk export): highlights as `\highlight1` (pure yellow, Word's own marker colour), changed words teal and underlined (`\cf2\ul`, underlined because a printout may have no colour). `marked_spans()` splits a caption into whole-word pieces; the same bridging rule as the editor. The colour table is only written when used, so a clean export is byte-for-byte what it was. No other format carries either. +- Tests: `tests/test_highlights.py`. + ## Undo history (`utils/undo_redo.py`) `save_state()` snapshots an `EditorState`: the captions **and** the speaker set, copied so a later edit to the live editor cannot reach back into a state already on a stack. Both halves have to travel together — the captions carry which speaker each block belongs to, the list carries which names exist at all, and the list cannot simply be recomputed from the captions on the way back, since a speaker can legitimately exist with no block using it ("Add new", or every block reassigned away). Restoring only the captions left a block naming a speaker the list no longer held: the menu highlighted nothing as current, `prompt_rename` refused to act on that name (it checks membership), and the name introduced by the undone edit lingered as unused forever. diff --git a/pages/srt.py b/pages/srt.py index 39ce6ba..5a3c87c 100644 --- a/pages/srt.py +++ b/pages/srt.py @@ -256,6 +256,30 @@ async def look_up_original() -> None: "click", lambda: editor.validate_captions(), ) + else: + # Where Validate is in the subtitle editor: the one thing + # about the text that is not history or finding. The same + # as Ctrl+Shift+H. Transcriptions only, for now. + # + # A mouse press must not take focus, or the selection it + # is meant to act on is the first thing it would lose -- + # the same reason the caption row's music-note icon + # prevents the default on mousedown. + with ui.button("Highlight", icon="highlight").props( + "flat aria-keyshortcuts=Control+Shift+H" + ).classes( + "editor-btn editor-toolbar-btn editor-quiet" + ) as highlight_button: + highlight_button.tooltip( + "Highlight the selection, or remove the highlight " + "(Ctrl + Shift + H)" + ) + highlight_button.on( + "mousedown", js_handler="(event) => event.preventDefault()" + ) + highlight_button.on( + "click", lambda: transcript.highlight_selection() + ) with ui.row().classes("editor-toolbar-group"): editor.show_keyboard_shortcuts() diff --git a/tests/test_highlights.py b/tests/test_highlights.py new file mode 100644 index 0000000..2029835 --- /dev/null +++ b/tests/test_highlights.py @@ -0,0 +1,1027 @@ +# Copyright (c) 2025-2026 Sunet. +# Contributor: Kristofer Hallin +# +# This file is part of Sunet Scribe. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +""" +The reader's own highlights: kept per word, carried through every edit that +moves words, saved with a transcription, drawn, and optionally exported. +""" + +import json +import pathlib +import re + +import pytest + +from utils.caption import SRTCaption +from utils.srt import SRTEditor +from utils.transcript_editor import TranscriptEditor + + +WORDS = { + "version": 1, + "words": [ + {"t": "ett", "s": 0.0, "e": 0.5}, + {"t": "två", "s": 0.6, "e": 1.0}, + {"t": "tre", "s": 1.1, "e": 1.5}, + {"t": "fyra", "s": 2.0, "e": 2.4}, + {"t": "fem", "s": 2.5, "e": 2.9}, + {"t": "sex", "s": 3.0, "e": 3.5}, + ], +} + + +def make_editor(data_format="txt"): + editor = SRTEditor("job-uuid", data_format, "file") + + editor.changed = 0 + editor.snapshots = 0 + + def mark(): + editor.changed += 1 + + def snapshot(): + editor.snapshots += 1 + + editor.refresh_display = lambda *a, **k: None + editor.update_words_per_minute = lambda *a, **k: None + editor.update_flagged_count = lambda *a, **k: None + editor.mark_as_changed = mark + editor.save_state_for_undo = snapshot + + editor.load_words(WORDS) + editor.captions = [ + SRTCaption(1, "00:00:00,000", "00:00:01,500", "ett två tre", speaker="A"), + SRTCaption(2, "00:00:02,000", "00:00:03,500", "fyra fem sex", speaker="A"), + ] + editor.speakers = {"A"} + + return editor + + +@pytest.fixture +def editor(monkeypatch): + messages = [] + monkeypatch.setattr( + "utils.srt.ui.notify", lambda message, **k: messages.append(message) + ) + built = make_editor() + built.messages = messages + + return built + + +class TestWordsInSelection: + def test_a_selection_takes_the_words_it_touches(self, editor): + assert editor.words_in_selection("ett två tre", 4, 7) == [1] + + def test_a_selection_ending_inside_a_word_takes_all_of_it(self, editor): + assert editor.words_in_selection("ett två tre", 1, 5) == [0, 1] + + def test_a_selection_only_brushing_an_edge_does_not_take_that_word(self, editor): + # Ends where "två" begins; starts where "ett" ends. + assert editor.words_in_selection("ett två tre", 0, 4) == [0] + assert editor.words_in_selection("ett två tre", 3, 8) == [1] + + def test_the_whole_text(self, editor): + assert editor.words_in_selection("ett två tre", 0, 11) == [0, 1, 2] + + def test_a_backwards_selection_is_the_same(self, editor): + assert editor.words_in_selection("ett två tre", 7, 4) == [1] + + def test_a_caret_in_a_word_takes_that_word(self, editor): + assert editor.words_in_selection("ett två tre", 5, 5) == [1] + + def test_a_caret_against_either_edge_of_a_word_takes_that_word(self, editor): + assert editor.words_in_selection("ett två tre", 3, 3) == [0] + assert editor.words_in_selection("ett två tre", 4, 4) == [1] + + def test_a_caret_in_the_space_between_words_takes_none(self, editor): + assert editor.words_in_selection("ett två", 4, 4) == [] + + def test_line_breaks_separate_words_too(self, editor): + assert editor.words_in_selection("ett\ntvå", 4, 6) == [1] + + def test_empty_text(self, editor): + assert editor.words_in_selection("", 0, 0) == [] + + +class TestCarryHighlights: + def carry(self, editor, before, after, marked): + return editor.carry_highlights(before, after, set(marked)) + + def test_words_that_stay_move_with_their_marks(self, editor): + assert self.carry(editor, "b c", "a b c", {0}) == {1} + + def test_words_removed_take_their_marks_with_them(self, editor): + assert self.carry(editor, "a b c", "a c", {1}) == set() + assert self.carry(editor, "a b c", "a c", {0, 1, 2}) == {0, 1} + + def test_a_word_corrected_inside_a_highlight_stays_highlighted(self, editor): + assert self.carry(editor, "teh cat sat", "the cat sat", {0, 1}) == {0, 1} + + def test_a_word_corrected_elsewhere_does_not_gain_one(self, editor): + assert self.carry(editor, "teh cat sat", "the cat sat", {1}) == {1} + + def test_a_new_word_does_not_earn_a_highlight(self, editor): + assert self.carry(editor, "a b", "a new b", set()) == set() + assert self.carry(editor, "a b", "a b new", {0, 1}) == {0, 1} + + def test_a_word_typed_inside_a_highlight_joins_it(self, editor): + assert self.carry(editor, "a b", "a new b", {0, 1}) == {0, 1, 2} + + def test_a_word_typed_next_to_a_highlight_does_not(self, editor): + assert self.carry(editor, "a b c", "a b new c", {0, 1}) == {0, 1} + assert self.carry(editor, "a b c", "new a b c", {0, 1}) == {1, 2} + + def test_a_longer_replacement_of_highlighted_words_is_highlighted(self, editor): + assert self.carry(editor, "a x y d", "a p q r d", {1, 2}) == {1, 2, 3} + + def test_a_replacement_of_part_of_a_highlight_is_not(self, editor): + assert self.carry(editor, "a x y d", "a p q r d", {1}) == set() + + def test_a_shorter_replacement_of_highlighted_words_is_highlighted(self, editor): + assert self.carry(editor, "a x y d", "a p d", {1, 2}) == {1} + + def test_the_input_is_not_changed(self, editor): + marked = {0} + editor.carry_highlights("a b", "x a b", marked) + + assert marked == {0} + + def test_no_marks_stay_none(self, editor): + assert self.carry(editor, "a b c", "a x c d", set()) == set() + + +class TestToggling: + def test_it_highlights_the_words_selected(self, editor): + first = editor.captions[0] + + assert editor.toggle_highlight([(first, 4, 7)]) == "added" + + assert first.highlighted_words == {1} + + def test_the_same_selection_again_takes_it_off(self, editor): + first = editor.captions[0] + editor.toggle_highlight([(first, 4, 7)]) + + assert editor.toggle_highlight([(first, 4, 7)]) == "removed" + + assert first.highlighted_words == set() + + def test_a_mixed_selection_is_highlighted_throughout(self, editor): + first = editor.captions[0] + first.highlighted_words = {0} + + assert editor.toggle_highlight([(first, 0, 11)]) == "added" + + assert first.highlighted_words == {0, 1, 2} + + def test_a_selection_inside_a_highlight_takes_off_only_what_it_holds(self, editor): + first = editor.captions[0] + first.highlighted_words = {0, 1, 2} + + assert editor.toggle_highlight([(first, 4, 7)]) == "removed" + + assert first.highlighted_words == {0, 2} + + def test_a_selection_across_captions_is_one_gesture(self, editor): + first, second = editor.captions + + editor.toggle_highlight([(first, 4, 11), (second, 0, 4)]) + + assert first.highlighted_words == {1, 2} + assert second.highlighted_words == {0} + assert editor.snapshots == 1 + + def test_across_captions_it_comes_off_only_when_all_of_it_is_highlighted( + self, editor + ): + first, second = editor.captions + first.highlighted_words = {1, 2} + + # Not all highlighted yet: the second caption's word is not. + editor.toggle_highlight([(first, 4, 11), (second, 0, 4)]) + assert second.highlighted_words == {0} + + editor.toggle_highlight([(first, 4, 11), (second, 0, 4)]) + assert first.highlighted_words == set() + assert second.highlighted_words == set() + + def test_nothing_selected_says_so_and_changes_nothing(self, editor): + first = editor.captions[0] + + assert editor.toggle_highlight([(first, 3, 4)]) == "" + assert editor.toggle_highlight([]) == "" + + assert first.highlighted_words == set() + assert editor.snapshots == 0 + assert editor.changed == 0 + assert editor.messages == ["Select the words to highlight first"] * 2 + + def test_it_is_one_undo_step(self, editor): + editor.toggle_highlight([(editor.captions[0], 0, 11)]) + + assert editor.snapshots == 1 + + def test_the_text_is_untouched(self, editor): + editor.toggle_highlight([(editor.captions[0], 0, 11)]) + + assert [caption.text for caption in editor.captions] == [ + "ett två tre", + "fyra fem sex", + ] + assert editor.captions[0].edited_words == set() + + def test_a_transcription_counts_it_as_a_change_to_save(self, editor): + editor.toggle_highlight([(editor.captions[0], 0, 11)]) + + assert editor.changed == 1 + + def test_subtitles_get_none_for_now(self): + """ + Plain SRT has nowhere to keep a highlight, so a set of subtitles is + refused outright -- no change, no undo step, no notice. + """ + + editor = make_editor("srt") + + assert editor.toggle_highlight([(editor.captions[0], 0, 11)]) == "" + + assert editor.captions[0].highlighted_words == set() + assert editor.snapshots == 0 + assert editor.changed == 0 + + +class TestSavedWithTheTranscription: + def reopen(self, editor): + saved = json.dumps(editor.export_json()) + + reopened = make_editor() + reopened.captions = [] + reopened.speakers = set() + reopened.parse_txt(saved) + + return reopened + + def test_a_copy_does_not_share_the_marks(self): + caption = SRTCaption(1, "00:00:00,000", "00:00:01,000", "a b") + caption.highlighted_words = {0} + + copy = caption.copy() + copy.highlighted_words.add(1) + + assert caption.highlighted_words == {0} + + def test_they_are_in_the_saved_data_only_when_there_are_any(self): + caption = SRTCaption(1, "00:00:00,000", "00:00:01,000", "a b") + + assert "highlighted" not in caption.to_dict() + + caption.highlighted_words = {1, 0} + + assert caption.to_dict()["highlighted"] == [0, 1] + + def test_they_come_back_when_the_file_is_opened_again(self, editor): + editor.captions[0].highlighted_words = {0, 2} + editor.captions[1].highlighted_words = {1} + + reopened = self.reopen(editor) + + assert [caption.highlighted_words for caption in reopened.captions] == [ + {0, 2}, + {1}, + ] + + def test_edit_marks_and_highlights_are_kept_apart(self, editor): + editor.captions[0].edited_words = {0} + editor.captions[0].highlighted_words = {2} + + reopened = self.reopen(editor) + + assert reopened.captions[0].edited_words == {0} + assert reopened.captions[0].highlighted_words == {2} + + def test_a_file_saved_before_this_existed_opens_without_any(self, editor): + reopened = self.reopen(editor) + + assert all(not caption.highlighted_words for caption in reopened.captions) + + def test_junk_in_the_file_is_ignored(self, editor): + data = editor.export_json() + data["segments"][0]["highlighted"] = [0, "x", None, True, 2.5, 1] + data["segments"][1]["highlighted"] = "all of it" + + reopened = make_editor() + reopened.captions = [] + reopened.parse_txt(json.dumps(data)) + + assert reopened.captions[0].highlighted_words == {0, 1} + assert reopened.captions[1].highlighted_words == set() + + +class TestFollowingTheText: + """ + A highlight is on words, so it has to stay on them whatever the reader does + to the text around them. + """ + + def test_typing_before_it_moves_it_along(self, editor): + first = editor.captions[0] + first.highlighted_words = {1} + + editor.update_caption_text(first, "nytt ett två tre") + + assert first.highlighted_words == {2} + + def test_correcting_a_word_inside_it_keeps_it(self, editor): + first = editor.captions[0] + first.highlighted_words = {0, 1, 2} + + editor.update_caption_text(first, "ett tvo tre") + + assert first.highlighted_words == {0, 1, 2} + + def test_typing_a_word_gives_it_no_highlight_of_its_own(self, editor): + first = editor.captions[0] + + editor.update_caption_text(first, "ett två tre fyra") + + assert first.highlighted_words == set() + + def test_splitting_divides_them_between_the_halves(self, editor): + first = editor.captions[0] + first.highlighted_words = {0, 2} + + editor.split_caption(first, cursor_position=7) + + assert editor.captions[0].text == "ett två" + assert editor.captions[0].highlighted_words == {0} + assert editor.captions[1].text == "tre" + assert editor.captions[1].highlighted_words == {0} + + def test_splitting_with_unsent_text_keeps_them(self, editor): + first = editor.captions[0] + first.highlighted_words = {2} + + editor.split_caption(first, cursor_position=8, text="nytt ett två tre") + + assert editor.captions[0].text == "nytt ett" + assert editor.captions[0].highlighted_words == set() + assert editor.captions[1].text == "två tre" + assert editor.captions[1].highlighted_words == {1} + + def test_merging_with_the_next_joins_them(self, editor): + first, second = editor.captions + first.highlighted_words = {2} + second.highlighted_words = {0} + + editor.merge_with_next(first) + + assert editor.captions[0].highlighted_words == {2, 3} + + def test_merging_with_the_previous_joins_them(self, editor): + first, second = editor.captions + first.highlighted_words = {2} + second.highlighted_words = {0} + + editor.merge_with_previous(second) + + assert editor.captions[0].highlighted_words == {2, 3} + + def test_a_word_moved_to_the_previous_caption_takes_its_highlight(self, editor): + first, second = editor.captions + second.highlighted_words = {0, 2} + + editor.move_first_word_to_previous(second) + + assert first.highlighted_words == {3} + assert second.highlighted_words == {1} + + def test_a_word_moved_to_the_next_caption_takes_its_highlight(self, editor): + first, second = editor.captions + first.highlighted_words = {0, 2} + second.highlighted_words = {1} + + editor.move_last_word_to_next(first) + + assert first.highlighted_words == {0} + assert second.highlighted_words == {0, 2} + + def test_a_word_moved_without_one_leaves_the_rest_alone(self, editor): + first, second = editor.captions + second.highlighted_words = {2} + + editor.move_first_word_to_previous(second) + + assert first.highlighted_words == set() + assert second.highlighted_words == {1} + + def test_deleting_across_captions_keeps_what_is_left(self, editor): + first, second = editor.captions + first.highlighted_words = {0, 2} + second.highlighted_words = {0, 2} + + # From the end of "två" in the first to just after "fyra" in the + # second: "ett två" and " fem sex" are what is left. + editor.delete_range(first, second, 7, 4, "") + + assert editor.captions[0].text == "ett två fem sex" + # "ett" and "sex" were highlighted; "två" and "fem" were not. + assert editor.captions[0].highlighted_words == {0, 3} + + def test_replacing_text_keeps_them_following_the_words(self, editor): + first = editor.captions[0] + first.highlighted_words = {2} + editor.search_term = "ett" + editor.selected_caption = first + editor.case_sensitive = True + + editor.replace_in_current_caption("ett nytt") + + assert first.text == "ett nytt två tre" + assert first.highlighted_words == {3} + + +class TestDrawing: + def runs(self, editor, caption): + return editor.review_runs(caption) + + def test_nothing_is_drawn_when_nothing_is_highlighted(self, editor): + assert self.runs(editor, editor.captions[0]) == [ + {"t": "ett två tre", "flag": False} + ] + + def test_a_highlight_is_drawn_without_any_switch_on(self, editor): + first = editor.captions[0] + first.highlighted_words = {1} + + runs = self.runs(editor, first) + + assert [run["t"] for run in runs if run.get("hl")] == ["två"] + assert "".join(run["t"] for run in runs) == "ett två tre" + + def test_the_space_between_highlighted_words_is_highlighted_too(self, editor): + first = editor.captions[0] + first.highlighted_words = {0, 1} + + runs = self.runs(editor, first) + + assert [run["t"] for run in runs if run.get("hl")] == ["ett", " ", "två"] + + def test_the_space_around_a_lone_highlighted_word_is_not(self, editor): + first = editor.captions[0] + first.highlighted_words = {1} + + runs = self.runs(editor, first) + + assert [run["t"] for run in runs if run.get("hl")] == ["två"] + + def test_a_line_break_between_highlighted_words_is_left_out(self, editor): + first = editor.captions[0] + first.text = "ett\ntvå" + first.highlighted_words = {0, 1} + + runs = self.runs(editor, first) + + assert [run["t"] for run in runs if run.get("hl")] == ["ett", "två"] + assert "".join(run["t"] for run in runs) == "ett\ntvå" + + def test_highlights_are_not_runs_with_a_flag(self, editor): + first = editor.captions[0] + first.highlighted_words = {0} + + assert all(run["flag"] is False for run in self.runs(editor, first)) + + def test_a_changed_word_can_be_highlighted_too(self, editor): + editor.show_my_edits = True + first = editor.captions[0] + first.highlighted_words = {1} + first.edited_words = {1} + + run = [run for run in self.runs(editor, first) if run["t"] == "två"][0] + + assert run["hl"] is True + assert run["edit"] is True + + def test_it_carries_the_word_timing_when_following_the_audio(self, editor): + first = editor.captions[0] + first.highlighted_words = {1} + + runs = editor.review_runs(first, per_word=True) + run = [run for run in runs if run["t"] == "två"][0] + + assert run["hl"] is True + assert (run["s"], run["e"]) == (0.6, 1.0) + + def test_other_words_read_as_they_always_did(self, editor): + editor.show_my_edits = True + first = editor.captions[0] + first.highlighted_words = {1} + + runs = self.runs(editor, first) + + assert "hl" not in [run for run in runs if run["t"] == "ett"][0] + + +class TestRtfExport: + def export(self, editor, **kwargs): + return editor.export_rtf(False, False, False, **kwargs) + + def test_a_clean_export_has_no_marking_and_no_colour_table(self, editor): + editor.captions[0].highlighted_words = {1} + editor.captions[0].edited_words = {0} + + clean = self.export(editor) + + assert "highlight" not in clean + assert "colortbl" not in clean + assert "\\ul" not in clean + + def test_a_clean_export_is_the_same_as_before_highlights_existed(self, editor): + before = self.export(editor) + + editor.captions[0].highlighted_words = {0, 1} + editor.captions[1].edited_words = {2} + + assert self.export(editor) == before + + def test_highlights_are_a_yellow_marker_when_asked_for(self, editor): + editor.captions[0].highlighted_words = {1} + + rtf = self.export(editor, highlights=True) + + assert r"{\colortbl;\red255\green255\blue0;" in rtf + assert r"ett {\highlight1 tv\u229?} tre" in rtf + + def test_a_passage_is_one_marked_stretch(self, editor): + editor.captions[0].highlighted_words = {0, 1} + + rtf = self.export(editor, highlights=True) + + assert rtf.count(r"\highlight1") == 1 + assert r"{\highlight1 ett tv\u229?} tre" in rtf + + def test_a_piece_starting_with_a_space_keeps_it(self, editor): + # The one space after a control word is its delimiter; any more are + # the text. Two highlighted words with two spaces between them are + # bridged, so the piece starts at the first word; the piece after a + # lone highlighted word starts with the spaces. + editor.captions[0].text = "ett två" + editor.captions[0].highlighted_words = {0} + + rtf = self.export(editor, highlights=True) + + assert r"{\highlight1 ett} tv\u229?" in rtf + + def test_changed_words_are_underlined_and_teal_when_asked_for(self, editor): + editor.captions[0].edited_words = {2} + + rtf = self.export(editor, edits=True) + + assert r"\red18\green121\blue106;" in rtf + assert r"{\cf2\ul tre}" in rtf + assert "highlight1" not in rtf + + def test_both_on_the_same_word(self, editor): + editor.captions[0].edited_words = {2} + editor.captions[0].highlighted_words = {2} + + rtf = self.export(editor, highlights=True, edits=True) + + assert r"{\highlight1\cf2\ul tre}" in rtf + + def test_each_is_independent_of_the_other(self, editor): + editor.captions[0].edited_words = {2} + editor.captions[0].highlighted_words = {0} + + highlights_only = self.export(editor, highlights=True) + edits_only = self.export(editor, edits=True) + + assert r"\ul" not in highlights_only + assert "highlight1" not in edits_only + + def test_the_text_is_the_same_with_or_without(self, editor): + editor.captions[0].highlighted_words = {0, 1, 2} + editor.captions[0].edited_words = {1} + editor.captions[1].highlighted_words = {1} + + def body(rtf): + return rtf.split(r"\fs20 ", 1)[1] + + marked = body(self.export(editor, highlights=True, edits=True)) + clean = body(self.export(editor)) + + # Take the groups back out: what is left must be the clean text. + stripped = re.sub( + r"\{(?:\\highlight1)?(?:\\cf2\\ul)? ", "", marked + ) + # Every group's closing brace goes, but the document's own stays. + stripped = stripped[:-1].replace("}", "") + "}" + + assert stripped == clean + + def test_subtitles_never_carry_them_for_now(self): + subtitles = make_editor("srt") + subtitles.captions[0].edited_words = {0} + subtitles.captions[0].highlighted_words = {1} + + rtf = self.export(subtitles, highlights=True, edits=True) + + assert "highlight" not in rtf + assert "colortbl" not in rtf + assert "\\ul" not in rtf + + def test_a_line_break_is_still_a_line_break(self, editor): + editor.captions[0].text = "ett\ntvå" + editor.captions[0].highlighted_words = {0, 1} + + rtf = self.export(editor, highlights=True) + + assert r"{\highlight1 ett}\line {\highlight1 tv\u229?}" in rtf + + def test_special_characters_in_a_marked_word_are_escaped(self, editor): + editor.captions[0].text = "a {b} c" + editor.captions[0].highlighted_words = {1} + + rtf = self.export(editor, highlights=True) + + assert r"{\highlight1 \{b\}}" in rtf + + def test_the_braces_balance(self, editor): + editor.captions[0].highlighted_words = {0, 2} + editor.captions[0].edited_words = {1, 2} + editor.captions[1].highlighted_words = {0, 1, 2} + + rtf = self.export(editor, highlights=True, edits=True) + unescaped = rtf.replace("\\{", "").replace("\\}", "") + + assert unescaped.count("{") == unescaped.count("}") + + +class TestOfferingTheMarks: + """ + The export dialog offers a kind of mark only when there is one to carry, + and only from a transcription, for now. + """ + + def test_nothing_marked_offers_nothing(self, editor): + assert editor.rtf_marks_available() == (False, False) + + def test_each_kind_is_offered_on_its_own(self, editor): + editor.captions[1].highlighted_words = {0} + assert editor.rtf_marks_available() == (True, False) + + editor.captions[1].highlighted_words = set() + editor.captions[0].edited_words = {2} + assert editor.rtf_marks_available() == (False, True) + + def test_both(self, editor): + editor.captions[0].highlighted_words = {0} + editor.captions[1].edited_words = {0} + + assert editor.rtf_marks_available() == (True, True) + + def test_a_bulk_export_offers_a_kind_if_any_file_has_it(self, editor): + other = make_editor() + other.captions[0].highlighted_words = {1} + + assert editor.rtf_marks_available([other]) == (True, False) + + def test_subtitles_offer_nothing_for_now(self): + subtitles = make_editor("srt") + subtitles.captions[0].edited_words = {0} + + assert subtitles.rtf_marks_available() == (False, False) + + def test_subtitles_in_a_bulk_export_are_not_counted(self, editor): + subtitles = make_editor("srt") + subtitles.captions[0].edited_words = {0} + + assert editor.rtf_marks_available([subtitles]) == (False, False) + + def test_the_checkboxes_follow_it_and_start_unchecked(self): + source = pathlib.Path("utils/srt_export.py").read_text() + + assert "rtf_hl_incl.set_visibility(has_highlights)" in source + assert "rtf_edits_incl.set_visibility(has_edits)" in source + assert '"Include highlights", value=False' in source + assert '"Include my edits (underlined)", value=False' in source + + +class TestMarkedSpans: + def test_the_pieces_are_the_text_again(self, editor): + caption = editor.captions[0] + caption.text = "ett två\ntre" + caption.highlighted_words = {0, 2} + caption.edited_words = {1} + + spans = editor.marked_spans(caption, True, True) + + assert "".join(text for text, _, _ in spans) == "ett två\ntre" + + def test_neighbouring_words_with_the_same_marks_are_one_piece(self, editor): + caption = editor.captions[0] + caption.highlighted_words = {0, 1} + + assert editor.marked_spans(caption, True, False) == [ + ("ett två", True, False), + (" tre", False, False), + ] + + def test_with_both_off_it_is_one_unmarked_piece(self, editor): + caption = editor.captions[0] + caption.highlighted_words = {0, 1} + caption.edited_words = {2} + + assert editor.marked_spans(caption, False, False) == [ + ("ett två tre", False, False) + ] + + def test_no_text_no_pieces(self, editor): + caption = editor.captions[0] + caption.text = "" + + assert editor.marked_spans(caption, True, True) == [] + + +class FakeBody: + def __init__(self): + self.selected = [] + self.asked = 0 + + def highlight_selection(self): + self.asked += 1 + + def select_range(self, *args): + self.selected.append(args) + + +@pytest.fixture +def view(editor, monkeypatch): + view = TranscriptEditor(editor) + view.body = FakeBody() + view.refreshed = 0 + view.refresh = lambda *a, **k: setattr(view, "refreshed", view.refreshed + 1) + view.changed = lambda *a, **k: None + + return view + + +class TestTheShortcutsDialog: + def groups(self, data_format): + return dict(make_editor(data_format).keyboard_shortcut_groups()) + + def test_a_transcription_lists_it(self): + assert self.groups("txt")["Highlighting"] == [ + ("Highlight selection, or remove the highlight", "Ctrl + Shift + H") + ] + + def test_subtitles_do_not_for_now(self): + assert "Highlighting" not in self.groups("srt") + + +class TestTheHandler: + def test_the_event_reaches_the_view(self): + source = pathlib.Path("utils/transcript_editor.py").read_text() + + assert ( + 'self.body.on(\n "togglehighlight", ' + "lambda event: self.toggle_highlight(event.args)\n )" + ) in source + + def test_it_highlights_what_the_component_measured(self, view, editor): + view.toggle_highlight({"spans": [{"id": 1, "start": 4, "end": 7}]}) + + assert editor.captions[0].highlighted_words == {1} + assert view.refreshed == 1 + + def test_the_selection_is_put_back_after_the_redraw(self, view, editor): + view.toggle_highlight( + { + "spans": [ + {"id": 1, "start": 4, "end": 11}, + {"id": 2, "start": 0, "end": 4}, + ] + } + ) + + assert view.body.selected == [(1, 4, 2, 4)] + + def test_nothing_selected_changes_nothing_and_redraws_nothing(self, view, editor): + view.toggle_highlight({"spans": [{"id": 1, "start": 3, "end": 4}]}) + + assert view.refreshed == 0 + assert view.body.selected == [] + + @pytest.mark.parametrize( + "args", + [ + None, + "x", + {}, + {"spans": None}, + {"spans": "all"}, + {"spans": []}, + {"spans": [None, 3, "x"]}, + {"spans": [{"id": 99, "start": 0, "end": 3}]}, + {"spans": [{"id": 1, "start": "0", "end": 3}]}, + {"spans": [{"id": 1, "start": True, "end": 3}]}, + {"spans": [{"id": 1, "start": 0}]}, + ], + ) + def test_bad_input_is_harmless(self, view, editor, args): + view.toggle_highlight(args) + + assert all(not caption.highlighted_words for caption in editor.captions) + assert view.refreshed == 0 + + def test_a_bad_span_does_not_stop_the_good_ones(self, view, editor): + view.toggle_highlight( + { + "spans": [ + {"id": 99, "start": 0, "end": 3}, + {"id": 2, "start": 0, "end": 4}, + ] + } + ) + + assert editor.captions[1].highlighted_words == {0} + + +class TestTheButton: + """ + The toolbar's Highlight button, where Validate is in the subtitle editor: + the same as the key, for whoever does not know the key. + """ + + def page(self): + return pathlib.Path("pages/srt.py").read_text() + + def test_it_asks_the_component_for_the_selection(self, view): + view.highlight_selection() + + assert view.body.asked == 1 + + def test_it_is_harmless_before_the_editor_is_built(self, editor): + TranscriptEditor(editor).highlight_selection() + + def test_nothing_selected_says_so_and_changes_nothing(self, view, editor): + view.toggle_highlight({"spans": []}) + + assert editor.messages == ["Select the words to highlight first"] + assert view.refreshed == 0 + assert all(not caption.highlighted_words for caption in editor.captions) + + def test_the_component_reports_the_selection_as_the_key_does(self): + source = pathlib.Path("utils/transcript_editor.js").read_text() + body = source[source.index("highlightSelection() {") :] + body = body[: body.index("\n },")] + + assert "this.flush();" in body + assert 'this.$emit("togglehighlight", { spans: this.highlightSpans() });' in body + + def test_it_is_in_the_toolbar_for_transcriptions_only(self): + page = self.page() + validate = page.index('with ui.button("Validate", icon="check")') + highlight = page.index('with ui.button("Highlight", icon="highlight")') + + # After Validate's own branch, in the else of the same test. + between = page[validate:highlight] + + assert between.count("\n else:\n") == 1 + + def test_it_is_a_labelled_quiet_toolbar_button_naming_its_key(self): + page = self.page() + start = page.index('with ui.button("Highlight", icon="highlight")') + button = page[start : start + 1400] + + assert "aria-keyshortcuts=Control+Shift+H" in button + assert "editor-btn editor-toolbar-btn editor-quiet" in button + assert "(Ctrl + Shift + H)" in button + + def test_a_mouse_press_does_not_take_the_selection_with_it(self): + page = self.page() + start = page.index('with ui.button("Highlight", icon="highlight")') + button = page[start : start + 1400] + + assert '"mousedown", js_handler="(event) => event.preventDefault()"' in button + + def test_a_click_is_the_same_as_the_key(self): + page = self.page() + start = page.index('with ui.button("Highlight", icon="highlight")') + button = page[start : start + 1400] + + assert '"click", lambda: transcript.highlight_selection()' in button + + +class TestTheComponent: + def source(self): + return pathlib.Path("utils/transcript_editor.js").read_text() + + def test_the_key_is_ctrl_shift_h_by_physical_key(self): + source = self.source() + start = source.index("event.ctrlKey &&\n event.shiftKey") + block = source[start : start + 260] + + assert "!event.altKey" in block + assert "!event.metaKey" in block + assert 'event.code === "KeyH"' in block + + assert '"togglehighlight"' in source + + def test_it_is_for_transcriptions_only_for_now(self): + source = self.source() + start = source.index("!this.subtitleMode &&\n event.ctrlKey &&") + + assert 'event.code === "KeyH"' in source[start : start + 260] + + def test_it_is_ahead_of_the_block_scoped_lists(self): + """ + A selection can reach across blocks, which the lists -- working from + the caret's own block -- cannot see. + """ + + source = self.source() + + assert source.index('"togglehighlight"') < source.index( + "this.subtitleShortcut(event, at)" + ) + + def test_both_marking_classes_come_from_one_function(self): + source = self.source() + + assert source.count(':class="runClass(run)"') == 2 + assert 'run.hl ? "highlight-word" : null' in source + + def test_a_highlight_is_exposed_as_marked_text(self): + assert self.source().count(""":role="run.hl ? 'mark' : null\"""") == 2 + + def test_the_selection_is_sent_one_range_per_block(self): + source = self.source() + body = source[source.index("highlightSpans() {") :] + body = body[: body.index("\n },")] + + assert "first === last" in body + assert "this.plainText(block.textContent).length" in body + + def test_the_selection_can_be_put_back(self): + source = self.source() + + assert "selectRange(startId, startOffset, endId, endOffset) {" in source + assert "pointAt(block, offset) {" in source + + +class TestTheStyles: + def css(self): + return pathlib.Path("utils/styles.py").read_text() + + def rule(self, selector): + css = self.css() + rule = css[css.index(selector) :] + + return rule[: rule.index("}")] + + def test_it_has_its_own_colours_in_both_themes(self): + css = self.css() + + assert css.count("--color-highlight-bg:") == 2 + assert css.count("--color-highlight-frame:") == 2 + assert css.count("--color-highlight-text:") == 2 + + def test_it_does_not_pad_the_text_sideways(self): + assert "padding: 1px 0;" in self.rule(" .highlight-word {") + + def test_it_has_a_line_above_and_below_that_does_not_depend_on_colour(self): + rule = self.rule(" .highlight-word {") + + assert "inset 0 2px 0 var(--color-highlight-frame)" in rule + assert "inset 0 -2px 0 var(--color-highlight-frame)" in rule + + def test_a_flagged_or_changed_word_keeps_its_own_bar(self): + review = self.rule(" .highlight-word.review-word {") + edit = self.rule(" .highlight-word.edit-word {") + + assert "inset 0 2px 0 var(--color-highlight-frame)" in review + assert "inset 0 -2px 0 var(--color-review-accent)" in review + assert "inset 0 2px 0 var(--color-highlight-frame)" in edit + assert "inset 0 -2px 0 var(--color-edit-accent)" in edit + + def test_the_word_being_played_keeps_the_lines(self): + rule = self.rule(" .transcript-text .highlight-word[data-current] {") + + assert "background-color: var(--color-playing-bg);" in rule + assert "inset 0 2px 0 var(--color-highlight-frame)" in rule diff --git a/tests/test_my_edits.py b/tests/test_my_edits.py index 7505b78..40b9417 100644 --- a/tests/test_my_edits.py +++ b/tests/test_my_edits.py @@ -716,7 +716,10 @@ def test_a_space_already_gone_just_clears_the_reference(self): def test_the_template_renders_the_edit_marking(self): source = self.source() - assert "'edit-word'" in source + # Chosen in runClass, which the template calls for every run, so the + # highlight can be added to the same list without a second binding. + assert '"edit-word"' in source + assert ':class="runClass(run)"' in source # Bound on every word, not only the ones the server marked, so the # stylesheet can reach the message on a word the browser marked too. assert ':data-edit="editLabel"' in source diff --git a/tests/test_transcript_editor.py b/tests/test_transcript_editor.py index 6890104..084ded8 100644 --- a/tests/test_transcript_editor.py +++ b/tests/test_transcript_editor.py @@ -2819,6 +2819,15 @@ def test_the_transcription_list_is_exactly_these_and_nothing_else(self): ("Delete paragraph", "Ctrl + D"), ], ), + ( + "Highlighting", + [ + ( + "Highlight selection, or remove the highlight", + "Ctrl + Shift + H", + ), + ], + ), ( "File operations", [ diff --git a/utils/caption.py b/utils/caption.py index 7c621ae..0f610b4 100644 --- a/utils/caption.py +++ b/utils/caption.py @@ -52,6 +52,13 @@ def __init__( # and marked untouched words as edited. self.edited_words: Set[int] = set() + # Word positions the reader has highlighted for themselves, counted the + # same way. A note to self made while reviewing against the recording: + # it never changes the text, and it is kept apart from edited_words + # because the two mean different things and move differently when the + # text changes (see carry_highlights). + self.highlighted_words: Set[int] = set() + def copy(self) -> "SRTCaption": """ Create a deep copy of the caption. @@ -71,6 +78,7 @@ def copy(self) -> "SRTCaption": # Copied rather than shared: the undo stack holds these snapshots, so # undoing a change has to take its marks back with it. new_caption.edited_words = set(self.edited_words) + new_caption.highlighted_words = set(self.highlighted_words) return new_caption def to_dict(self) -> Dict[str, Any]: @@ -83,6 +91,11 @@ def to_dict(self) -> Dict[str, Any]: # Only when there are any, so a transcription nobody has touched # exports exactly as it did before this was recorded. **({"edited": sorted(self.edited_words)} if self.edited_words else {}), + **( + {"highlighted": sorted(self.highlighted_words)} + if self.highlighted_words + else {} + ), } def to_srt_format(self) -> str: diff --git a/utils/srt.py b/utils/srt.py index 7f2a08f..63ed358 100644 --- a/utils/srt.py +++ b/utils/srt.py @@ -683,6 +683,16 @@ def parse_txt(self, data: dict) -> None: position for position in marks if isinstance(position, int) } + # The same for the reader's highlights. + highlights = segment.get("highlighted") + + if isinstance(highlights, list): + restored.highlighted_words = { + position + for position in highlights + if isinstance(position, int) and not isinstance(position, bool) + } + self.captions.append(restored) self.speakers.add(segment.get("speaker", "")) @@ -956,9 +966,7 @@ def split_caption( if text is not None and text != caption.text: # The text area holds edits that have not been committed yet; # splitting must not discard them, nor the marks they earned. - caption.edited_words = self.retag_edits( - caption.text, text, caption.edited_words - ) + self.carry_marks(caption, text) caption.text = text self.mark_as_changed() @@ -983,10 +991,14 @@ def split_caption( # Marks follow their words across the break. kept, moved = self.split_edits(caption.edited_words, len(first_part.split())) + kept_highlights, moved_highlights = self.split_edits( + caption.highlighted_words, len(first_part.split()) + ) # Update first caption caption.text = first_part caption.edited_words = kept + caption.highlighted_words = kept_highlights caption.end_time = self.seconds_to_timestamp(mid_seconds) # Create second caption @@ -998,6 +1010,7 @@ def split_caption( speaker=caption.speaker, ) new_caption.edited_words = moved + new_caption.highlighted_words = moved_highlights # Insert new caption caption_index = self.captions.index(caption) @@ -1079,9 +1092,21 @@ def move_first_word_to_previous(self, caption: SRTCaption) -> None: self.save_state_for_undo() previous = self.captions[position - 1] + + # A highlight on the word follows it across. Read before either text + # changes, since both positions are counted against the old words. + first_was_highlighted = 0 in caption.highlighted_words + previous_count = len(previous.text.split()) + previous.text = f"{previous.text.rstrip()} {moved_word}" caption.text = remaining + caption.highlighted_words = { + index - 1 for index in caption.highlighted_words if index > 0 + } + if first_was_highlighted: + previous.highlighted_words.add(previous_count) + if self.word_is_timed(moved_timing) and self.word_is_timed(next_timing): previous.end_time = self.seconds_to_timestamp(moved_timing["e"]) caption.start_time = self.seconds_to_timestamp(next_timing["s"]) @@ -1136,9 +1161,21 @@ def move_last_word_to_next(self, caption: SRTCaption) -> None: self.save_state_for_undo() following = self.captions[position + 1] + + # See move_first_word_to_previous: the highlight goes with the word. + last_index = len(caption.text.split()) - 1 + last_was_highlighted = last_index in caption.highlighted_words + following.text = f"{moved_word} {following.text.lstrip()}" caption.text = remaining + caption.highlighted_words.discard(last_index) + following.highlighted_words = { + index + 1 for index in following.highlighted_words + } + if last_was_highlighted: + following.highlighted_words.add(0) + if self.word_is_timed(moved_timing) and self.word_is_timed(previous_timing): following.start_time = self.seconds_to_timestamp(moved_timing["s"]) caption.end_time = self.seconds_to_timestamp(previous_timing["e"]) @@ -1161,6 +1198,66 @@ def finish_word_move(self) -> None: self.update_words_per_minute() self.refresh_display(force_full_refresh=True) + def toggle_highlight(self, selections: list) -> str: + """ + Highlight the words a selection touches, or take the highlight off. + + `selections` is a list of (caption, start, end) with the offsets in the + caption's text, one entry per caption the selection reaches into. A + selection that reaches across blocks is one gesture and one undo step. + + One key does both, so which it is depends on what is there: when every + word taken is already highlighted the highlight comes off, otherwise + everything taken gets one. A mixed selection therefore becomes fully + highlighted first -- the reader can press again to clear it. + + Returns "added", "removed", or "" when nothing was selected (a notice + says so). + + Transcriptions only, for now. A set of subtitles is saved as plain + SRT, which has nowhere to keep a highlight, so it gets none: nothing + in the editor asks for one there, and this refuses if something does. + """ + + if self.data_format == "srt": + return "" + + touched = [] + + for caption, start, end in selections: + positions = self.words_in_selection(caption.text, start, end) + + if positions: + touched.append((caption, positions)) + + if not touched: + ui.notify( + "Select the words to highlight first", + type="warning", + timeout=None, + close_button="Close", + ) + return "" + + remove = all( + position in caption.highlighted_words + for caption, positions in touched + for position in positions + ) + + self.save_state_for_undo() + + for caption, positions in touched: + if remove: + caption.highlighted_words.difference_update(positions) + else: + caption.highlighted_words.update(positions) + + # Kept with the transcription's own data, so it is a change to save. + self.mark_as_changed() + + return "removed" if remove else "added" + def add_caption_after(self, caption: SRTCaption) -> None: """ Add a new caption after the selected one. @@ -1270,9 +1367,7 @@ def update_caption_text( self.save_state_for_undo() # Worked out before the text is replaced, while there is still # something to compare it against. - caption.edited_words = self.retag_edits( - caption.text, new_text, caption.edited_words - ) + self.carry_marks(caption, new_text) caption.text = new_text # Editing a word can take it off the count, or put one on it. self.update_flagged_count() @@ -1327,14 +1422,21 @@ def delete_range( # retag_edits does with words a change brought in. old_text = first.text old_marks = set(first.edited_words) + old_highlights = set(first.highlighted_words) for caption in self.captions[start + 1 : end + 1]: old_marks = self.joined_edits( old_marks, caption.edited_words, len(old_text.split()) ) + old_highlights = self.joined_edits( + old_highlights, caption.highlighted_words, len(old_text.split()) + ) old_text = "\n".join(part for part in (old_text, caption.text) if part) first.edited_words = self.retag_edits(old_text, merged, old_marks) + first.highlighted_words = self.carry_highlights( + old_text, merged, old_highlights + ) first.text = merged first.end_time = last.end_time @@ -1378,6 +1480,11 @@ def merge_with_next(self, caption: SRTCaption) -> None: next_caption.edited_words, len(caption.text.split()), ) + caption.highlighted_words = self.joined_edits( + caption.highlighted_words, + next_caption.highlighted_words, + len(caption.text.split()), + ) # Only actually joined with a newline when both sides have text -- # an empty caption (freshly added, then merged straight back away # with Backspace) has nothing to separate from the other, and a @@ -1426,6 +1533,11 @@ def merge_with_previous(self, caption: SRTCaption) -> None: caption.edited_words, len(previous_caption.text.split()), ) + previous_caption.highlighted_words = self.joined_edits( + previous_caption.highlighted_words, + caption.highlighted_words, + len(previous_caption.text.split()), + ) # See the equivalent join in merge_with_next for why this is not # always a bare "\n" join. previous_caption.text = "\n".join( diff --git a/utils/srt_export.py b/utils/srt_export.py index d995c8b..8bf590d 100644 --- a/utils/srt_export.py +++ b/utils/srt_export.py @@ -160,6 +160,29 @@ class ExportMixin: Export formats and the export dialog. """ + def rtf_marks_available(self, others: list | None = None) -> tuple: + """ + Whether there is anything for the RTF export to carry: (highlights, + changed words). Looked at across this editor and any others exported + along with it, so a bulk export offers a choice if any file has one. + + Transcriptions only, for now: a set of subtitles does not carry its + marks into an export. + """ + + editors = [self] + list(others or []) + captions = [ + caption + for editor in editors + if editor.data_format != "srt" + for caption in editor.captions + ] + + return ( + any(caption.highlighted_words for caption in captions), + any(caption.edited_words for caption in captions), + ) + def export_rtf( self, speakers: bool, @@ -167,11 +190,24 @@ def export_rtf( block_nr: bool, ts_which: str = "both", ts_fmt: str = "srt", + highlights: bool = False, + edits: bool = False, ) -> str: """ Export captions to RTF format with proper Unicode handling. + + `highlights` carries the reader's highlights as a yellow marker and + `edits` carries the words they changed, underlined and in the teal of + the editor's own marking -- underlined as well because a printout + may have no colour. Both are off by default, which is the clean + transcript: nothing about the text itself changes either way. + + Transcriptions only, for now: subtitles never carry them. """ + if self.data_format == "srt": + highlights = edits = False + def fmt_ts(ts: str, f: str) -> str: """ Format timestamp according to selected format. @@ -210,8 +246,20 @@ def to_rtf_unicode(text: str) -> str: ) return "".join(result) + # Colour 1 is the marker (pure yellow: it is what Word's own highlighter + # palette holds, so it is shown as drawn and not matched to the nearest + # colour), colour 2 the editor's teal for changed words. The table is + # only written when it is used, so a clean export is the document it + # always was. + colours = ( + r"{\colortbl;\red255\green255\blue0;\red18\green121\blue106;}" + if highlights or edits + else "" + ) rtf_content = ( - r"{\rtf1\ansi\deff0{\fonttbl{\f0 Arial;}}" r"\viewkind4\uc1\pard\f0\fs20 " + r"{\rtf1\ansi\deff0{\fonttbl{\f0 Arial;}}" + + colours + + r"\viewkind4\uc1\pard\f0\fs20 " ) parts = [] @@ -238,9 +286,24 @@ def to_rtf_unicode(text: str) -> str: if header_parts: rtf_data = r"\b " + " ".join(header_parts) + r"\b0\line " - rtf_data += ( - to_rtf_unicode(caption.text).replace("\n", r"\line ") + r"\line\line " - ) + for text, highlighted, edited in self.marked_spans( + caption, highlights, edits + ): + body = to_rtf_unicode(text).replace("\n", r"\line ") + + if highlighted or edited: + # A group, so the formatting ends with the piece. The one + # space after a control word is its delimiter and is not + # part of the text, so a piece that starts with a space + # keeps it. + style = (r"\highlight1" if highlighted else "") + ( + r"\cf2\ul" if edited else "" + ) + body = "{" + style + " " + body + "}" + + rtf_data += body + + rtf_data += r"\line\line " parts.append(rtf_data) @@ -542,6 +605,23 @@ def _close_and_delete() -> None: rtf_idx_incl = ui.checkbox( "Include paragraph numbers", value=False ) + # Off by default: an export is clean unless + # it is asked to carry the marks. And only + # offered for a kind of mark that exists in + # what is being exported -- a choice about + # nothing is just clutter. Transcriptions + # only, for now (see rtf_marks_available). + has_highlights, has_edits = self.rtf_marks_available( + [editor for _, editor in bulk_editors or []] + ) + rtf_hl_incl = ui.checkbox( + "Include highlights", value=False + ) + rtf_hl_incl.set_visibility(has_highlights) + rtf_edits_incl = ui.checkbox( + "Include my edits (underlined)", value=False + ) + rtf_edits_incl.set_visibility(has_edits) ui.separator() # CSV options (for csv only) @@ -1007,6 +1087,14 @@ def export_one(editor): rtf_idx_incl.value, ts_which.value, ts_fmt.value, + highlights=bool( + rtf_hl_incl.visible + and rtf_hl_incl.value + ), + edits=bool( + rtf_edits_incl.visible + and rtf_edits_incl.value + ), ) elif fmt.value == "txt": parts = [] diff --git a/utils/srt_render.py b/utils/srt_render.py index 34afae6..a91cf4a 100644 --- a/utils/srt_render.py +++ b/utils/srt_render.py @@ -712,8 +712,19 @@ def keyboard_shortcut_groups(self) -> list: ] ) + # The selection's own highlight. A transcription's alone, for now: a + # set of subtitles is plain SRT, which has nowhere to keep it. + highlighting = ( + [] + if subtitles + else [ + ("Highlight selection, or remove the highlight", "Ctrl + Shift + H"), + ] + ) + shortcut_groups = [ ("Editing", editing), + *([("Highlighting", highlighting)] if highlighting else []), ( "File operations", [ diff --git a/utils/srt_review.py b/utils/srt_review.py index e224be6..1e0600d 100644 --- a/utils/srt_review.py +++ b/utils/srt_review.py @@ -347,6 +347,178 @@ def joined_edits(first: set, second: set, offset: int) -> set: return set(first) | {index + offset for index in second} + @staticmethod + def carry_highlights(previous: str, current: str, marked: set) -> set: + """ + Carry a caption's highlights across a change to its text. + + Unlike an edit mark, a highlight is the reader's note about a passage + and not about who wrote it, so a change does not earn one: typing a + new word somewhere does not highlight it. The reader's marks do have + to survive the corrections made inside a highlighted passage, though, + or fixing a typo would quietly take the highlight away. + + - Words that stay move with their marks, however far they shift. + - Words replaced one for one keep the mark of the word they replace. + - A longer or shorter replacement is highlighted when everything it + replaced was. + - Words inserted between two highlighted words are inside the + highlight and take it; anywhere else they do not. + - Words that are gone take their highlight with them. + """ + + before = previous.split() + after = current.split() + carried: set = set() + + matcher = SequenceMatcher(None, before, after, autojunk=False) + + for tag, before_start, before_end, after_start, after_end in ( + matcher.get_opcodes() + ): + if tag == "equal": + for offset in range(before_end - before_start): + if before_start + offset in marked: + carried.add(after_start + offset) + elif tag == "replace": + old = range(before_start, before_end) + new = range(after_start, after_end) + + if len(old) == len(new): + carried.update( + after_start + offset + for offset, index in enumerate(old) + if index in marked + ) + elif all(index in marked for index in old): + carried.update(new) + elif tag == "insert": + if before_start - 1 in marked and before_start in marked: + carried.update(range(after_start, after_end)) + + return carried + + def marked_spans( + self, caption: SRTCaption, highlights: bool, edits: bool + ) -> list: + """ + A caption's text as (text, highlighted, edited) pieces, for a format + that draws the marks itself -- the RTF export. + + Not the editor's own runs (review_runs): those follow the switches + under the video, while an export is told what to carry. A piece's + text is whole words and the space between them, so concatenating the + pieces gives the caption's text back exactly. + + The space between two marked words is marked with them, so a passage + is one stretch and not a row of words, as it is drawn in the editor. + A line break is left outside the marking, for the same reason as + there. + """ + + tokens = [token for token in re.split(r"(\s+)", caption.text) if token] + position = -1 + positions: list = [] + + for token in tokens: + if token.strip(): + position += 1 + positions.append(position) + else: + positions.append(None) + + def flags(at: int) -> tuple: + word = positions[at] + + if word is not None: + return ( + highlights and self.token_is_highlight(caption, word), + edits and self.token_is_edit(caption, word), + ) + + before = positions[at - 1] if at > 0 else None + after = positions[at + 1] if at + 1 < len(tokens) else None + + if before is None or after is None or "\n" in tokens[at]: + return (False, False) + + return ( + highlights + and self.token_is_highlight(caption, before) + and self.token_is_highlight(caption, after), + edits + and self.token_is_edit(caption, before) + and self.token_is_edit(caption, after), + ) + + pieces: list = [] + + for at, token in enumerate(tokens): + highlighted, edited = flags(at) + + if pieces and pieces[-1][1:] == (highlighted, edited): + pieces[-1] = (pieces[-1][0] + token, highlighted, edited) + else: + pieces.append((token, highlighted, edited)) + + return pieces + + def carry_marks(self, caption: SRTCaption, new_text: str) -> None: + """ + Carry both kinds of mark on a caption across a change to its text. + + Called before the text itself is replaced, while there is still + something to compare the new text against. + """ + + caption.edited_words = self.retag_edits( + caption.text, new_text, caption.edited_words + ) + caption.highlighted_words = self.carry_highlights( + caption.text, new_text, caption.highlighted_words + ) + + @staticmethod + def words_in_selection(text: str, start: int, end: int) -> list: + """ + The positions of the words a selection touches, counted in + whitespace separated words from the start of the text. + + A word is touched when the selection takes any of it, so a selection + that ends part way through a word takes the whole word: marking a + passage never cuts a word in two. A selection that only brushes a + word's edge, ending where it begins or starting where it ends, does + not touch it. + + Nothing selected means the word the caret is in or against: either + edge counts, since the caret is as often just after a word as in it, + and no two words share an edge because whitespace separates them. A + caret in the space between words touches neither. + """ + + if end < start: + start, end = end, start + + touched = [] + + for position, match in enumerate(re.finditer(r"\S+", text)): + if start == end: + if match.start() <= start <= match.end(): + touched.append(position) + elif match.start() < end and match.end() > start: + touched.append(position) + + return touched + + @staticmethod + def token_is_highlight(caption: SRTCaption, index: int) -> bool: + """ + Whether the reader has highlighted the word at this position. + """ + + return index in caption.highlighted_words + + def speech_runs(self, gap: float = 0.3) -> List[list]: """ Where speech actually is, as [start, end] pairs. @@ -668,17 +840,21 @@ def review_runs( if not source: return [] + highlighted = caption.highlighted_words + if ( not self.show_uncertain_words and not self.show_my_edits and not per_word + and not highlighted ): return [{"t": source, "flag": False}] # A word has to be a run of its own before it can be marked at all, so # marking the reader's words needs the same split that following the - # audio does. - one_run_per_word = per_word or self.show_my_edits + # audio does. So does a highlight, which is always drawn: it is not one + # of the switches under the video. + one_run_per_word = per_word or self.show_my_edits or bool(highlighted) words = self.aligned_words(caption, source) runs: list = [] @@ -690,12 +866,42 @@ def flush() -> None: runs.append({"t": "".join(plain), "flag": False}) plain.clear() - for token in re.split(r"(\s+)", source): - if not token: - continue + tokens = [token for token in re.split(r"(\s+)", source) if token] + + def is_highlighted(position: int) -> bool: + return self.token_is_highlight(caption, position) + + # Which word each token is, so that the space between two highlighted + # words can be drawn highlighted too: a passage reads as one marked + # stretch, not a row of separate words. + word_position = -1 + positions: list = [] + for token in tokens: + if token.strip(): + word_position += 1 + positions.append(word_position) + else: + positions.append(None) + + for at, token in enumerate(tokens): if not token.strip(): - plain.append(token) + before = positions[at - 1] if at > 0 else None + after = positions[at + 1] if at + 1 < len(tokens) else None + + # A line break is left unmarked: a highlight running out to the + # edge of the block would draw a stub at the end of the line. + if ( + "\n" not in token + and before is not None + and after is not None + and is_highlighted(before) + and is_highlighted(after) + ): + flush() + runs.append({"t": token, "flag": False, "hl": True}) + else: + plain.append(token) continue word = words[index] if index < len(words) else None @@ -704,14 +910,19 @@ def flush() -> None: # be honoured here too or words stay marked with them switched off. flagged = self.show_uncertain_words and self.word_needs_review(word) edited = self.show_my_edits and self.token_is_edit(caption, index - 1) + marked = is_highlighted(index - 1) - if not one_run_per_word and not flagged and not edited: + if not one_run_per_word and not flagged and not edited and not marked: plain.append(token) continue flush() run = {"t": token, "flag": flagged} + # Only carried when true, like "edit". + if marked: + run["hl"] = True + # Only carried when true, so a run reads the same as it always did # wherever nothing has been edited. if edited: @@ -719,7 +930,8 @@ def flush() -> None: # Only a word still matching what the model transcribed has a # timing we can attribute to it; an edited word has none, and nor - # has a word transcribed without one. Neither is ever highlighted. + # has a word transcribed without one. Neither is ever lit up as the + # word being played. if per_word and self.word_is_timed(word): run["s"] = word["s"] run["e"] = word["e"] diff --git a/utils/srt_search.py b/utils/srt_search.py index 95a27b7..f029ba0 100644 --- a/utils/srt_search.py +++ b/utils/srt_search.py @@ -127,11 +127,7 @@ def replace_in_current_caption(self, replacement: str) -> None: # every mark after it along. Worked out here rather than through # update_caption_text, which would take a second undo snapshot on # top of the one already taken above. - self.selected_caption.edited_words = self.retag_edits( - self.selected_caption.text, - new_text, - self.selected_caption.edited_words, - ) + self.carry_marks(self.selected_caption, new_text) self.selected_caption.text = new_text self.update_flagged_count() self.refresh_display() @@ -171,9 +167,7 @@ def replace_all(self, replacement: str) -> None: # of the reader's, and a replacement of a different length # moves every mark after it. One undo snapshot covers the whole # Replace All, so this cannot go through update_caption_text. - caption.edited_words = self.retag_edits( - caption.text, new_text, caption.edited_words - ) + self.carry_marks(caption, new_text) caption.text = new_text count += 1 diff --git a/utils/styles.py b/utils/styles.py index 04fadfe..857a83c 100644 --- a/utils/styles.py +++ b/utils/styles.py @@ -127,6 +127,15 @@ --color-edit-accent: #12796a; --color-edit-text: #10453d; + /* Words the reader has highlighted for themselves. The one marking + that is yellow, as a highlighter is. Yellow on white is barely + brighter than the page (1.14:1) so the colour cannot carry it + alone: the frame lines carry it, 4.9:1 against the page (1.4.1, + 1.4.11), and the text on the yellow measures 13.6:1. */ + --color-highlight-bg: #fff1a3; + --color-highlight-frame: #8a6d00; + --color-highlight-text: #2b2400; + /* The word currently being spoken. A pale tint of the brand navy, so it reads as "you are here" rather than as a status. */ --color-playing-bg: #dbe4f0; @@ -252,6 +261,12 @@ --color-edit-accent: #6fd3c1; --color-edit-text: #dff5f0; + /* Dark mode keeps the yellow as a dark gold. Text on it 8.0:1, the + frame 12.6:1 against the surface. */ + --color-highlight-bg: #5a4a00; + --color-highlight-frame: #ffd54f; + --color-highlight-text: #fff6cc; + --color-playing-bg: #1c3252; --color-header-bg: #16181d; @@ -1372,6 +1387,56 @@ box-shadow: inset 0 -2px 0 var(--color-edit-accent); } + /* Words the reader has highlighted. Yellow, like a highlighter, with a + line above and below so it does not depend on the colour: it is a + different shape from the other two markings, which are a wash with a + bar underneath. No horizontal padding, unlike them, so highlighting + a passage does not move the text -- and a run of highlighted words + with the spaces between them reads as one stretch. + + On a flagged or changed word the highlight takes the wash and the text + colour, and the word keeps its own bar underneath, so both can be seen. + While the word is being played the playing tint wins the wash but the + lines stay, so the passage can still be seen under the playback. */ + .highlight-word { + padding: 1px 0; + color: var(--color-highlight-text); + background-color: var(--color-highlight-bg); + box-shadow: + inset 0 2px 0 var(--color-highlight-frame), + inset 0 -2px 0 var(--color-highlight-frame); + box-decoration-break: clone; + -webkit-box-decoration-break: clone; + } + .highlight-word.review-word { + box-shadow: + inset 0 2px 0 var(--color-highlight-frame), + inset 0 -2px 0 var(--color-review-accent); + } + .highlight-word.edit-word { + box-shadow: + inset 0 2px 0 var(--color-highlight-frame), + inset 0 -2px 0 var(--color-edit-accent); + } + .transcript-text .highlight-word[data-current] { + background-color: var(--color-playing-bg); + color: inherit; + border-radius: 0; + box-shadow: + inset 0 2px 0 var(--color-highlight-frame), + inset 0 -2px 0 var(--color-highlight-frame); + } + .transcript-text .highlight-word.review-word[data-current] { + box-shadow: + inset 0 2px 0 var(--color-highlight-frame), + inset 0 -2px 0 var(--color-review-accent); + } + .transcript-text .highlight-word.edit-word[data-current] { + box-shadow: + inset 0 2px 0 var(--color-highlight-frame), + inset 0 -2px 0 var(--color-edit-accent); + } + /* Shared hover message. A CSS box rather than title=, which the browser draws itself and stylesheets cannot reach. diff --git a/utils/transcript_editor.js b/utils/transcript_editor.js index 814e30f..6e4a8e9 100644 --- a/utils/transcript_editor.js +++ b/utils/transcript_editor.js @@ -205,11 +205,11 @@ export default { >Delete caption
{{ run.t }}
{{ run.t }}
{{ run.t }}
+ >{{ run.t }}