branch: elpa/subed commit d0dfa1a2ee7d09a12763bbb8308ef2cdd875c504 Author: Sacha Chua <sa...@sachachua.com> Commit: Sacha Chua <sa...@sachachua.com>
Preliminary support for .ass files (Advanced SubStation Alpha) --- README.org | 2 +- subed/subed-ass.el | 461 ++++++++++++++++++++++++++++++++++++++++ subed/subed.el | 4 +- tests/test-subed-ass.el | 391 ++++++++++++++++++++++++++++++++++ tests/test-subed-ass.el.license | 3 + tests/test-subed-vtt.el | 2 +- 6 files changed, 860 insertions(+), 3 deletions(-) diff --git a/README.org b/README.org index edd222b..47b40e1 100644 --- a/README.org +++ b/README.org @@ -7,7 +7,7 @@ SPDX-License-Identifier: GPL-3.0-or-later * subed subed is an Emacs major mode for editing subtitles while playing the corresponding video with [[https://mpv.io/][mpv]]. At the moment, the only supported formats are -SubRip ( ~.srt~) and WebVTT ( ~.vtt~ ). +SubRip ( ~.srt~), WebVTT ( ~.vtt~ ), and Advanced SubStation Alpha ( ~.ass~, experimental ). [[file:https://raw.githubusercontent.com/rndusr/subed/master/screenshot.jpg]] diff --git a/subed/subed-ass.el b/subed/subed-ass.el new file mode 100644 index 0000000..b2b0d93 --- /dev/null +++ b/subed/subed-ass.el @@ -0,0 +1,461 @@ +;;; subed-ass.el --- Advanced SubStation Alpha implementation for subed -*- lexical-binding: t; -*- + +;;; License: +;; +;; This file is not part of GNU Emacs. +;; +;; This is free software; you can redistribute it and/or modify +;; it under the terms of the GNU General Public License as published by +;; the Free Software Foundation; either version 3, or (at your option) +;; any later version. +;; +;; This is distributed in the hope that it will be useful, +;; but WITHOUT ANY WARRANTY; without even the implied warranty of +;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +;; GNU General Public License for more details. +;; +;; You should have received a copy of the GNU General Public License +;; along with GNU Emacs; see the file COPYING. If not, write to the +;; Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, +;; Boston, MA 02110-1301, USA. + + +;;; Commentary: + +;; Advanced SubStation Alpha implementation for subed-mode. +;; Since ASS doesn't use IDs, we'll use the starting timestamp. + +;;; Code: + +(require 'subed-config) +(require 'subed-debug) +(require 'subed-common) + +;;; Syntax highlighting + +(defconst subed-ass-font-lock-keywords + (list + '("\\([0-9]+:\\)?[0-9]+:[0-9]+\\.[0-9]+" . 'subed-ass-time-face) + '(",[0-9]+ \\(-->\\) [0-9]+:" 1 'subed-ass-time-separator-face t) + '("^.*$" . 'subed-ass-text-face)) + "Highlighting expressions for `subed-mode'.") + + +;;; Parsing + +(defconst subed-ass--regexp-timestamp "\\(\\([0-9]+\\):\\)?\\([0-9]+\\):\\([0-9]+\\)\\(\\.\\([0-9]+\\)\\)?") +(defconst subed-ass--regexp-start "\\(?:Dialogue\\|Comment\\|Picture\\|Sound\\|Movie\\|Command\\): [0-9]+,") +(defconst subed-ass--regexp-separator "\n") + +(defun subed-ass--timestamp-to-msecs (time-string) + "Find HH:MM:SS.MS pattern in TIME-STRING and convert it to milliseconds. +Return nil if TIME-STRING doesn't match the pattern." + (save-match-data + (when (string-match subed-ass--regexp-timestamp time-string) + (let ((hours (string-to-number (or (match-string 2 time-string) "0"))) + (mins (string-to-number (match-string 3 time-string))) + (secs (string-to-number (match-string 4 time-string))) + (msecs (if (match-string 6 time-string) (string-to-number (subed--right-pad (match-string 6 time-string) 3 ?0)) 0))) + (+ (* (truncate hours) 3600000) + (* (truncate mins) 60000) + (* (truncate secs) 1000) + (truncate msecs)))))) + +(defun subed-ass--msecs-to-timestamp (msecs) + "Convert MSECS to string in the format H:MM:SS.CS." + ;; We need to wrap format-seconds in save-match-data because it does regexp + ;; stuff and we need to preserve our own match-data. + (concat (save-match-data (format-seconds "%h:%02m:%02s" (/ msecs 1000))) + "." (format "%02d" (/ (mod msecs 1000) 10)))) + +(defun subed-ass--subtitle-id () + "Return the ID of the subtitle at point or nil if there is no ID." + (save-excursion + (when (subed-ass--jump-to-subtitle-id) + (when (looking-at subed-ass--regexp-timestamp) + (match-string 0))))) + +(defun subed-ass--subtitle-id-max () + "Return the ID of the last subtitle or nil if there are no subtitles." + (save-excursion + (goto-char (point-max)) + (subed-ass--subtitle-id))) + +(defun subed-ass--subtitle-id-at-msecs (msecs) + "Return the ID of the subtitle at MSECS milliseconds. +Return nil if there is no subtitle at MSECS." + (save-match-data + (save-excursion + (goto-char (point-min)) + (let* ((secs (/ msecs 1000)) + (only-hours (truncate (/ secs 3600))) + (only-mins (truncate (/ (- secs (* only-hours 3600)) 60)))) + ;; Move to first subtitle in the relevant hour + (when (re-search-forward (format "\\(%s\\|\\`\\)%02d:" subed-ass--regexp-separator only-hours) nil t) + (beginning-of-line) + ;; Move to first subtitle in the relevant hour and minute + (re-search-forward (format "\\(\n\n\\|\\`\\)%02d:%02d" only-hours only-mins) nil t))) + ;; Move to first subtitle that starts at or after MSECS + (catch 'subtitle-id + (while (<= (or (subed-ass--subtitle-msecs-start) -1) msecs) + ;; If stop time is >= MSECS, we found a match + (let ((cur-sub-end (subed-ass--subtitle-msecs-stop))) + (when (and cur-sub-end (>= cur-sub-end msecs)) + (throw 'subtitle-id (subed-ass--subtitle-id)))) + (unless (subed-ass--forward-subtitle-id) + (throw 'subtitle-id nil))))))) + +(defun subed-ass--subtitle-msecs-start (&optional sub-id) + "Subtitle start time in milliseconds or nil if it can't be found. +If SUB-ID is not given, use subtitle on point." + (let ((timestamp (save-excursion + (when (subed-ass--jump-to-subtitle-time-start sub-id) + (when (looking-at subed-ass--regexp-timestamp) + (match-string 0)))))) + (when timestamp + (subed-ass--timestamp-to-msecs timestamp)))) + +(defun subed-ass--subtitle-msecs-stop (&optional sub-id) + "Subtitle stop time in milliseconds or nil if it can't be found. +If SUB-ID is not given, use subtitle on point." + (let ((timestamp (save-excursion + (when (subed-ass--jump-to-subtitle-time-stop sub-id) + (when (looking-at subed-ass--regexp-timestamp) + (match-string 0)))))) + (when timestamp + (subed-ass--timestamp-to-msecs timestamp)))) + +(defun subed-ass--subtitle-text (&optional sub-id) + "Return subtitle's text or an empty string. +If SUB-ID is not given, use subtitle on point." + (or (save-excursion + (let ((beg (subed-ass--jump-to-subtitle-text sub-id)) + (end (subed-ass--jump-to-subtitle-end sub-id))) + (when (and beg end) + (buffer-substring beg end)))) + "")) + +(defun subed-ass--subtitle-relative-point () + "Point relative to subtitle's ID or nil if ID can't be found." + (let ((start-point (save-excursion + (when (subed-ass--jump-to-subtitle-id) + (point))))) + (when start-point + (- (point) start-point)))) + +;;; Traversing + +(defun subed-ass--jump-to-subtitle-id (&optional sub-id) + "Move to the ID of a subtitle and return point. +If SUB-ID is not given, focus the current subtitle's ID. +Return point or nil if no subtitle ID could be found. +ASS doesn't use IDs, so we use the starting timestamp instead." + (interactive) + (save-match-data + (if (stringp sub-id) + (let* ((orig-point (point)) + (find-ms (subed-ass--timestamp-to-msecs sub-id)) + (regex (concat "^\\(?:" subed-ass--regexp-start "\\)\\(" subed-ass--regexp-timestamp "\\)")) + done) + (goto-char (point-min)) + (while (not done) + (if (re-search-forward regex nil t) + (when (= (subed-ass--timestamp-to-msecs (match-string 1)) find-ms) + (setq done 'found) + (goto-char (match-beginning 1))) + (setq done 'not-found) + (goto-char orig-point))) + (when (eq done 'found) + (beginning-of-line) + (point))) + (end-of-line) + (let* ((regex (concat "^\\(?:" subed-ass--regexp-start "\\)\\(" subed-ass--regexp-timestamp "\\)")) + (match-found (re-search-backward regex nil t))) + (when (or match-found (re-search-forward regex nil t)) ;; maybe at the beginning? + (goto-char (match-beginning 0)) + (point)))))) + +(defun subed-ass--jump-to-subtitle-id-at-msecs (msecs) + "Move point to the ID of the subtitle that is playing at MSECS. +Return point or nil if point is still on the same subtitle. +See also `subed-ass--subtitle-id-at-msecs'." + (let ((current-sub-id (subed-ass--subtitle-id)) + (target-sub-id (subed-ass--subtitle-id-at-msecs msecs))) + (when (and target-sub-id current-sub-id (not (equal target-sub-id current-sub-id))) + (subed-ass--jump-to-subtitle-id target-sub-id)))) + +(defun subed-ass--jump-to-subtitle-text-at-msecs (msecs) + "Move point to the text of the subtitle that is playing at MSECS. +Return point or nil if point is still on the same subtitle. +See also `subed-ass--subtitle-id-at-msecs'." + (when (subed-ass--jump-to-subtitle-id-at-msecs msecs) + (subed-ass--jump-to-subtitle-text))) + +(defun subed-ass--jump-to-subtitle-time-start (&optional sub-id) + "Move point to subtitle's start time. +If SUB-ID is not given, use subtitle on point. +Return point or nil if no start time could be found." + (interactive) + (save-match-data + (when (subed-ass--jump-to-subtitle-id sub-id) + (when (re-search-forward subed-ass--regexp-timestamp (line-end-position) t) + (goto-char (match-beginning 0)) + (point))))) + +(defun subed-ass--jump-to-subtitle-time-stop (&optional sub-id) + "Move point to subtitle's stop time. +If SUB-ID is not given, use subtitle on point. +Return point or nil if no stop time could be found." + (interactive) + (save-match-data + (when (subed-ass--jump-to-subtitle-id sub-id) + (re-search-forward (concat "\\(?:" subed-ass--regexp-timestamp "\\),") (point-at-eol) t) + (when (looking-at subed-ass--regexp-timestamp) + (point))))) + +(defun subed-ass--jump-to-subtitle-text (&optional sub-id) + "Move point on the first character of subtitle's text. +If SUB-ID is not given, use subtitle on point. +Return point or nil if a the subtitle's text can't be found." + (interactive) + (when (subed-ass--jump-to-subtitle-id sub-id) + (beginning-of-line) + (when (looking-at ".*?,.*?,.*?,.*?,.*?,.*?,.*?,.*?,.*?,") + (goto-char (match-end 0))) + (point))) + +(defun subed-ass--jump-to-subtitle-end (&optional sub-id) + "Move point after the last character of the subtitle's text. +If SUB-ID is not given, use subtitle on point. +Return point or nil if point did not change or if no subtitle end +can be found." + (interactive) + (save-match-data + (let ((orig-point (point))) + (when (subed-ass--jump-to-subtitle-text sub-id) + (end-of-line) + (unless (= orig-point (point)) + (point)))))) + +(defun subed-ass--forward-subtitle-id () + "Move point to next subtitle's ID. +Return point or nil if there is no next subtitle." + (interactive) + (save-match-data + (let ((pos (point))) + (forward-line 1) + (beginning-of-line) + (while (not (or (eobp) (looking-at subed-ass--regexp-start))) + (forward-line 1)) + (if (looking-at subed-ass--regexp-start) + (point) + (goto-char pos) + nil)))) + +(defun subed-ass--backward-subtitle-id () + "Move point to previous subtitle's ID. +Return point or nil if there is no previous subtitle." + (interactive) + (let ((orig-point (point))) + (when (subed-ass--jump-to-subtitle-id) + (forward-line -1) + (while (not (or (bobp) (looking-at subed-ass--regexp-start))) + (forward-line -1)) + (if (looking-at subed-ass--regexp-start) + (point) + (goto-char orig-point) + nil)))) + +(defun subed-ass--forward-subtitle-text () + "Move point to next subtitle's text. +Return point or nil if there is no next subtitle." + (interactive) + (when (subed-ass--forward-subtitle-id) + (subed-ass--jump-to-subtitle-text))) + +(defun subed-ass--backward-subtitle-text () + "Move point to previous subtitle's text. +Return point or nil if there is no previous subtitle." + (interactive) + (when (subed-ass--backward-subtitle-id) + (subed-ass--jump-to-subtitle-text))) + +(defun subed-ass--forward-subtitle-end () + "Move point to end of next subtitle. +Return point or nil if there is no next subtitle." + (interactive) + (when (subed-ass--forward-subtitle-id) + (subed-ass--jump-to-subtitle-end))) + +(defun subed-ass--backward-subtitle-end () + "Move point to end of previous subtitle. +Return point or nil if there is no previous subtitle." + (interactive) + (when (subed-ass--backward-subtitle-id) + (subed-ass--jump-to-subtitle-end))) + +(defun subed-ass--forward-subtitle-time-start () + "Move point to next subtitle's start time." + (interactive) + (when (subed-ass--forward-subtitle-id) + (subed-ass--jump-to-subtitle-time-start))) + +(defun subed-ass--backward-subtitle-time-start () + "Move point to previous subtitle's start time." + (interactive) + (when (subed-ass--backward-subtitle-id) + (subed-ass--jump-to-subtitle-time-start))) + +(defun subed-ass--forward-subtitle-time-stop () + "Move point to next subtitle's stop time." + (interactive) + (when (subed-ass--forward-subtitle-id) + (subed-ass--jump-to-subtitle-time-stop))) + +(defun subed-ass--backward-subtitle-time-stop () + "Move point to previous subtitle's stop time." + (interactive) + (when (subed-ass--backward-subtitle-id) + (subed-ass--jump-to-subtitle-time-stop))) + +;;; Manipulation + +(defun subed-ass--set-subtitle-time-start (msecs &optional sub-id) + "Set subtitle start time to MSECS milliseconds. + +If SUB-ID is not given, set the start of the current subtitle. + +Return the new subtitle start time in milliseconds." + (save-excursion + (when (or (not sub-id) + (and sub-id (subed-ass--jump-to-subtitle-id sub-id))) + (subed-ass--jump-to-subtitle-time-start) + (when (looking-at subed-ass--regexp-timestamp) + (replace-match (subed-ass--msecs-to-timestamp msecs)))))) + +(defun subed-ass--set-subtitle-time-stop (msecs &optional sub-id) + "Set subtitle stop time to MSECS milliseconds. + +If SUB-ID is not given, set the stop of the current subtitle. + +Return the new subtitle stop time in milliseconds." + (save-excursion + (when (or (not sub-id) + (and sub-id (subed-ass--jump-to-subtitle-id sub-id))) + (subed-ass--jump-to-subtitle-time-stop) + (when (looking-at subed-ass--regexp-timestamp) + (replace-match (subed-ass--msecs-to-timestamp msecs)))))) + +(defun subed-ass--make-subtitle (&optional id start stop text) + "Generate new subtitle string. + +ID, START default to 0. +STOP defaults to (+ START `subed-subtitle-spacing') +TEXT defaults to an empty string. + +A newline is appended to TEXT, meaning you'll get two trailing +newlines if TEXT is nil or empty." + (interactive "P") + (format "Dialogue: 0,%s,%s,Default,,0,0,0,,%s\n" + (subed-ass--msecs-to-timestamp (or start 0)) + (subed-ass--msecs-to-timestamp (or stop (+ (or start 0) + subed-default-subtitle-length))) + (replace-regexp-in-string "\n" "\\n" (or text "")))) + +(defun subed-ass--prepend-subtitle (&optional id start stop text) + "Insert new subtitle before the subtitle at point. + +ID and START default to 0. +STOP defaults to (+ START `subed-subtitle-spacing') +TEXT defaults to an empty string. + +Move point to the text of the inserted subtitle. +Return new point." + (interactive "P") + (subed-ass--jump-to-subtitle-id) + (insert (subed-ass--make-subtitle id start stop text)) + (forward-line -1) + (subed-ass--jump-to-subtitle-text)) + +(defun subed-ass--append-subtitle (&optional id start stop text) + "Insert new subtitle after the subtitle at point. + +ID, START default to 0. +STOP defaults to (+ START `subed-subtitle-spacing') +TEXT defaults to an empty string. + +Move point to the text of the inserted subtitle. +Return new point." + (interactive "P") + (unless (subed-ass--forward-subtitle-id) + ;; Point is on last subtitle or buffer is empty + (subed-ass--jump-to-subtitle-end) + (unless (bolp) (insert "\n"))) + (insert (subed-ass--make-subtitle id start stop text)) + (forward-line -1) + (subed-ass--jump-to-subtitle-text)) + +(defun subed-ass--kill-subtitle () + "Remove subtitle at point." + (interactive) + (let ((beg (save-excursion (subed-ass--jump-to-subtitle-id) + (point))) + (end (save-excursion (subed-ass--jump-to-subtitle-id) + (when (subed-ass--forward-subtitle-id) + (point))))) + (if (not end) + ;; Removing the last subtitle because forward-subtitle-id returned nil + (setq beg (save-excursion (goto-char beg) + (subed-ass--backward-subtitle-end) + (1+ (point))) + end (save-excursion (goto-char (point-max))))) + (delete-region beg end))) + +(defun subed-ass--merge-with-next () + "Merge the current subtitle with the next subtitle. +Update the end timestamp accordingly." + (interactive) + (save-excursion + (subed-ass--jump-to-subtitle-end) + (let ((pos (point)) new-end) + (if (subed-ass--forward-subtitle-time-stop) + (progn + (when (looking-at subed-ass--regexp-timestamp) + (setq new-end (subed-ass--timestamp-to-msecs (match-string 0)))) + (subed-ass--jump-to-subtitle-text) + (delete-region pos (point)) + (insert " ") + (subed-ass--set-subtitle-time-stop new-end)) + (error "No subtitle to merge into"))))) + + +;;; Maintenance + +(defun subed-ass--regenerate-ids () + "Not applicable to ASS." + (interactive)) + +(defvar-local subed-ass--regenerate-ids-soon-timer nil) +(defun subed-ass--regenerate-ids-soon () + "Not applicable to ASS." + (interactive)) + +(defun subed-ass--sanitize () + "Not yet implemented." + (interactive)) + +(defun subed-ass--validate () + "Not yet implemented." + (interactive)) + +(defun subed-ass--sort () + "Not yet implemented." + (interactive)) + +(defun subed-ass--init () + "This function is called when subed-mode is entered for a ASS file." + (setq-local subed--subtitle-format "ass") + (setq-local font-lock-defaults '(subed-ass-font-lock-keywords))) + +(provide 'subed-ass) +;;; subed-ass.el ends here diff --git a/subed/subed.el b/subed/subed.el index 6ba7a13..773e7f4 100644 --- a/subed/subed.el +++ b/subed/subed.el @@ -37,6 +37,7 @@ (require 'subed-common) (require 'subed-srt) (require 'subed-vtt) +(require 'subed-ass) (require 'subed-mpv) (defconst subed-mpv-frame-step-map @@ -91,7 +92,8 @@ ;;;###autoload (defvar subed--init-alist '(("srt" . subed-srt--init) - ("vtt" . subed-vtt--init)) + ("vtt" . subed-vtt--init) + ("ass" . subed-ass--init)) "Alist that maps file extensions to format-specific init functions.") ;;; Abstraction hack to support different subtitle formats diff --git a/tests/test-subed-ass.el b/tests/test-subed-ass.el new file mode 100644 index 0000000..8e79522 --- /dev/null +++ b/tests/test-subed-ass.el @@ -0,0 +1,391 @@ +;; -*- eval: (buttercup-minor-mode) -*- + +(add-to-list 'load-path "./subed") +(require 'subed) + +(defvar mock-ass-data + "[Script Info] +; Script generated by FFmpeg/Lavc58.134.100 +ScriptType: v4.00+ +PlayResX: 384 +PlayResY: 288 +ScaledBorderAndShadow: yes + +[V4+ Styles] +Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding +Style: Default,Arial,16,&Hffffff,&Hffffff,&H0,&H0,0,0,0,0,100,100,0,0,1,1,0,2,10,10,10,0 + +[Events] +Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text +Dialogue: 0,0:00:11.12,0:00:14.00,Default,,0,0,0,,Hello, world! +Dialogue: 0,0:00:14.00,0:00:16.80,Default,,0,0,0,,This is a test. +Dialogue: 0,0:00:17.00,0:00:19.80,Default,,0,0,0,,I hope it works. +") + +(defmacro with-temp-ass-buffer (&rest body) + "Call `subed-ass--init' in temporary buffer before running BODY." + `(with-temp-buffer + (subed-ass--init) + (progn ,@body))) + +(describe "ASS" + (describe "Getting" + (describe "the subtitle start/stop time" + (it "returns the time in milliseconds." + (with-temp-ass-buffer + (insert mock-ass-data) + (subed-ass--jump-to-subtitle-id "0:00:14.00") + (expect (subed-ass--subtitle-msecs-start) :to-equal (* 14 1000)) + (expect (subed-ass--subtitle-msecs-stop) :to-equal (+ (* 16 1000) 800)))) + (it "returns nil if time can't be found." + (with-temp-ass-buffer + (expect (subed-ass--subtitle-msecs-start) :to-be nil) + (expect (subed-ass--subtitle-msecs-stop) :to-be nil))) + ) + (describe "the subtitle text" + (describe "when text is empty" + (it "and at the beginning with a trailing newline." + (with-temp-ass-buffer + (insert mock-ass-data) + (subed-ass--jump-to-subtitle-text "0:00:11.12") + (kill-line) + (expect (subed-ass--subtitle-text) :to-equal ""))))) + (describe "when text is not empty" + (it "and has no linebreaks." + (with-temp-ass-buffer + (insert mock-ass-data) + (subed-ass--jump-to-subtitle-text "0:00:14.00") + (expect (subed-ass--subtitle-text) :to-equal "This is a test."))))) + (describe "Jumping" + (describe "to current subtitle timestamp" + (it "can handle different formats of timestamps." + (with-temp-ass-buffer + (insert mock-ass-data) + (expect (subed-ass--jump-to-subtitle-id "00:00:11.120") :to-equal 564) + (expect (subed-ass--subtitle-msecs-start) :to-equal 11120))) + (it "returns timestamp's point when point is already on the timestamp." + (with-temp-ass-buffer + (insert mock-ass-data) + (goto-char (point-min)) + (subed-ass--jump-to-subtitle-id "0:00:11.12") + (expect (subed-ass--jump-to-subtitle-time-start) :to-equal (point)) + (expect (looking-at subed-ass--regexp-timestamp) :to-be t) + (expect (match-string 0) :to-equal "0:00:11.12"))) + (it "returns timestamp's point when point is on the text." + (with-temp-ass-buffer + (insert mock-ass-data) + (search-backward "test") + (expect (thing-at-point 'word) :to-equal "test") + (expect (subed-ass--jump-to-subtitle-time-start) :to-equal 640) + (expect (looking-at subed-ass--regexp-timestamp) :to-be t) + (expect (match-string 0) :to-equal "0:00:14.00"))) + (it "returns nil if buffer is empty." + (with-temp-ass-buffer + (expect (buffer-string) :to-equal "") + (expect (subed-ass--jump-to-subtitle-time-start) :to-equal nil)))) + (describe "to specific subtitle by timestamp" + (it "returns timestamp's point if wanted time exists." + (with-temp-ass-buffer + (insert mock-ass-data) + (goto-char (point-max)) + (expect (subed-ass--jump-to-subtitle-id "0:00:11.12") :to-equal 564) + (expect (looking-at (regexp-quote "Dialogue: 0,0:00:11.12")) :to-be t) + (expect (subed-ass--jump-to-subtitle-id "0:00:17.00") :to-equal 694) + (expect (looking-at (regexp-quote "Dialogue: 0,0:00:17.00")) :to-be t))) + (it "returns nil and does not move if wanted ID does not exists." + (with-temp-ass-buffer + (insert mock-ass-data) + (goto-char (point-min)) + (search-forward "test") + (let ((stored-point (point))) + (expect (subed-ass--jump-to-subtitle-id "0:08:00") :to-equal nil) + (expect stored-point :to-equal (point)))))) + (describe "to subtitle start time" + (it "returns start time's point if movement was successful." + (with-temp-ass-buffer + (insert mock-ass-data) + (re-search-backward "world") + (expect (subed-ass--jump-to-subtitle-time-start) :to-equal 576) + (expect (looking-at subed-ass--regexp-timestamp) :to-be t) + (expect (match-string 0) :to-equal "0:00:11.12"))) + (it "returns nil if movement failed." + (with-temp-ass-buffer + (expect (subed-ass--jump-to-subtitle-time-start) :to-equal nil)))) + (describe "to subtitle stop time" + (it "returns stop time's point if movement was successful." + (with-temp-ass-buffer + (insert mock-ass-data) + (re-search-backward "test") + (expect (subed-ass--jump-to-subtitle-time-stop) :to-equal 651) + (expect (looking-at subed-ass--regexp-timestamp) :to-be t) + (expect (match-string 0) :to-equal "0:00:16.80"))) + (it "returns nil if movement failed." + (with-temp-ass-buffer + (expect (subed-ass--jump-to-subtitle-time-stop) :to-equal nil)))) + (describe "to subtitle text" + (it "returns subtitle text's point if movement was successful." + (with-temp-ass-buffer + (insert mock-ass-data) + (goto-char (point-min)) + (expect (subed-ass--jump-to-subtitle-text) :to-equal 614) + (expect (looking-at "Hello, world!") :to-equal t) + (forward-line 1) + (expect (subed-ass--jump-to-subtitle-text) :to-equal 678) + (expect (looking-at "This is a test.") :to-equal t))) + (it "returns nil if movement failed." + (with-temp-ass-buffer + (expect (subed-ass--jump-to-subtitle-time-stop) :to-equal nil)))) + (describe "to end of subtitle text" + (it "returns point if subtitle end can be found." + (with-temp-ass-buffer + (insert mock-ass-data) + (goto-char (point-min)) + (expect (subed-ass--jump-to-subtitle-end) :to-be 627) + (expect (looking-back "Hello, world!") :to-be t) + (forward-char 2) + (expect (subed-ass--jump-to-subtitle-end) :to-be 693) + (expect (looking-back "This is a test.") :to-be t) + (forward-char 2) + (expect (subed-ass--jump-to-subtitle-end) :to-be 760) + (expect (looking-back "I hope it works.") :to-be t))) + (it "returns nil if subtitle end cannot be found." + (with-temp-ass-buffer + (expect (subed-ass--jump-to-subtitle-end) :to-be nil))) + (it "returns nil if point did not move." + (with-temp-ass-buffer + (insert mock-ass-data) + (subed-ass--jump-to-subtitle-text "0:00:11.12") + (subed-ass--jump-to-subtitle-end) + (expect (subed-ass--jump-to-subtitle-end) :to-be nil))) + (it "works if text is empty." + (with-temp-ass-buffer + (insert mock-ass-data) + (subed-ass--jump-to-subtitle-text "00:00:11.12") + (kill-line) + (backward-char) + (expect (subed-ass--jump-to-subtitle-end) :to-be 614)))) + (describe "to next subtitle ID" + (it "returns point when there is a next subtitle." + (with-temp-ass-buffer + (insert mock-ass-data) + (subed-ass--jump-to-subtitle-id "00:00:11.12") + (expect (subed-ass--forward-subtitle-id) :to-be 628) + (expect (looking-at (regexp-quote "Dialogue: 0,0:00:14.00")) :to-be t))) + (it "returns nil and doesn't move when there is no next subtitle." + (with-temp-ass-buffer + (expect (thing-at-point 'word) :to-equal nil) + (expect (subed-ass--forward-subtitle-id) :to-be nil)) + (with-temp-ass-buffer + (insert mock-ass-data) + (subed-ass--jump-to-subtitle-text "0:00:17.00") + (expect (subed-ass--forward-subtitle-id) :to-be nil)))) + (describe "to previous subtitle ID" + (it "returns point when there is a previous subtitle." + (with-temp-ass-buffer + (insert mock-ass-data) + (subed-ass--jump-to-subtitle-text "00:00:14.00") + (expect (subed-ass--backward-subtitle-id) :to-be 564))) + (it "returns nil and doesn't move when there is no previous subtitle." + (with-temp-ass-buffer + (expect (subed-ass--backward-subtitle-id) :to-be nil)) + (with-temp-ass-buffer + (insert mock-ass-data) + (subed-ass--jump-to-subtitle-id "00:00:11.12") + (expect (subed-ass--backward-subtitle-id) :to-be nil)))) + (describe "to next subtitle text" + (it "returns point when there is a next subtitle." + (with-temp-ass-buffer + (insert mock-ass-data) + (subed-ass--jump-to-subtitle-id "00:00:14.00") + (expect (subed-ass--forward-subtitle-text) :to-be 744) + (expect (thing-at-point 'word) :to-equal "I"))) + (it "returns nil and doesn't move when there is no next subtitle." + (with-temp-ass-buffer + (goto-char (point-max)) + (insert (concat mock-ass-data "\n\n")) + (subed-ass--jump-to-subtitle-id "00:00:17.00") + (expect (subed-ass--forward-subtitle-text) :to-be nil)))) + (describe "to previous subtitle text" + (it "returns point when there is a previous subtitle." + (with-temp-ass-buffer + (insert mock-ass-data) + (subed-ass--jump-to-subtitle-id "00:00:14.00") + (expect (subed-ass--backward-subtitle-text) :to-be 614) + (expect (thing-at-point 'word) :to-equal "Hello"))) + (it "returns nil and doesn't move when there is no previous subtitle." + (with-temp-ass-buffer + (insert mock-ass-data) + (goto-char (point-min)) + (subed-ass--forward-subtitle-time-start) + (expect (looking-at (regexp-quote "0:00:11.12")) :to-be t) + (expect (subed-ass--backward-subtitle-text) :to-be nil) + (expect (looking-at (regexp-quote "0:00:11.12")) :to-be t)))) + (describe "to next subtitle end" + (it "returns point when there is a next subtitle." + (with-temp-ass-buffer + (insert mock-ass-data) + (subed-ass--jump-to-subtitle-text "00:00:14.00") + (expect (thing-at-point 'word) :to-equal "This") + (expect (subed-ass--forward-subtitle-end) :to-be 760))) + (it "returns nil and doesn't move when there is no next subtitle." + (with-temp-ass-buffer + (insert (concat mock-ass-data "\n\n")) + (subed-ass--jump-to-subtitle-text "00:00:17.00") + (expect (subed-ass--forward-subtitle-end) :to-be nil)))) + (describe "to previous subtitle end" + (it "returns point when there is a previous subtitle." + (with-temp-ass-buffer + (insert mock-ass-data) + (subed-ass--jump-to-subtitle-id "00:00:14.00") + (expect (subed-ass--backward-subtitle-end) :to-be 627))) + (it "returns nil and doesn't move when there is no previous subtitle." + (with-temp-ass-buffer + (insert mock-ass-data) + (goto-char (point-min)) + (subed-ass--forward-subtitle-id) + (expect (looking-at (regexp-quote "Dialogue: 0,0:00:11.12")) :to-be t) + (expect (subed-ass--backward-subtitle-text) :to-be nil) + (expect (looking-at (regexp-quote "Dialogue: 0,0:00:11.12")) :to-be t)))) + (describe "to next subtitle start time" + (it "returns point when there is a next subtitle." + (with-temp-ass-buffer + (insert mock-ass-data) + (subed-ass--jump-to-subtitle-id "00:00:14.00") + (expect (subed-ass--forward-subtitle-time-start) :to-be 706))) + (it "returns nil and doesn't move when there is no next subtitle." + (with-temp-ass-buffer + (insert mock-ass-data) + (subed-ass--jump-to-subtitle-id "00:00:17.00") + (let ((pos (point))) + (expect (subed-ass--forward-subtitle-time-start) :to-be nil) + (expect (point) :to-be pos))))) + (describe "to previous subtitle stop" + (it "returns point when there is a previous subtitle." + (with-temp-ass-buffer + (insert mock-ass-data) + (subed-ass--jump-to-subtitle-id "00:00:14.00") + (expect (subed-ass--backward-subtitle-time-stop) :to-be 587))) + (it "returns nil and doesn't move when there is no previous subtitle." + (with-temp-ass-buffer + (insert mock-ass-data) + (goto-char (point-min)) + (subed-ass--forward-subtitle-id) + (expect (subed-ass--backward-subtitle-time-stop) :to-be nil) + (expect (looking-at (regexp-quote "Dialogue: 0,0:00:11.12")) :to-be t)))) + (describe "to next subtitle stop time" + (it "returns point when there is a next subtitle." + (with-temp-ass-buffer + (insert mock-ass-data) + (subed-ass--jump-to-subtitle-id "00:00:14.00") + (expect (subed-ass--forward-subtitle-time-stop) :to-be 717))) + (it "returns nil and doesn't move when there is no next subtitle." + (with-temp-ass-buffer + (insert mock-ass-data) + (subed-ass--jump-to-subtitle-id "00:00:17.00") + (let ((pos (point))) + (expect (subed-ass--forward-subtitle-time-stop) :to-be nil) + (expect (point) :to-be pos)))))) + + (describe "Setting start/stop time" + (it "of subtitle should set it." + (with-temp-ass-buffer + (insert mock-ass-data) + (subed-ass--jump-to-subtitle-id "00:00:14.00") + (subed-ass--set-subtitle-time-start (+ (* 15 1000) 400)) + (expect (subed-ass--subtitle-msecs-start) :to-be (+ (* 15 1000) 400))))) + + (describe "Inserting a subtitle" + (describe "in an empty buffer" + (describe "before the current subtitle" + (it "creates an empty subtitle when passed nothing." + (with-temp-ass-buffer + (subed-ass--prepend-subtitle) + (expect (buffer-string) :to-equal (concat "Dialogue: 0,0:00:00.00,0:00:01.00,Default,,0,0,0,,\n")))) + (it "creates a subtitle with a start time." + (with-temp-ass-buffer + (subed-ass--prepend-subtitle nil 12340) + (expect (buffer-string) :to-equal (concat "Dialogue: 0,0:00:12.34,0:00:13.34,Default,,0,0,0,,\n")))) + (it "creates a subtitle with a start time and stop time." + (with-temp-ass-buffer + (subed-ass--prepend-subtitle nil 60000 65000) + (expect (buffer-string) :to-equal "Dialogue: 0,0:01:00.00,0:01:05.00,Default,,0,0,0,,\n"))) + (it "creates a subtitle with start time, stop time and text." + (with-temp-ass-buffer + (subed-ass--prepend-subtitle nil 60000 65000 "Hello world") + (expect (buffer-string) :to-equal "Dialogue: 0,0:01:00.00,0:01:05.00,Default,,0,0,0,,Hello world\n")))) + (describe "after the current subtitle" + (it "creates an empty subtitle when passed nothing." + (with-temp-ass-buffer + (subed-ass--append-subtitle) + (expect (buffer-string) :to-equal (concat "Dialogue: 0,0:00:00.00,0:00:01.00,Default,,0,0,0,,\n")))) + (it "creates a subtitle with a start time." + (with-temp-ass-buffer + (subed-ass--append-subtitle nil 12340) + (expect (buffer-string) :to-equal (concat "Dialogue: 0,0:00:12.34,0:00:13.34,Default,,0,0,0,,\n")))) + (it "creates a subtitle with a start time and stop time." + (with-temp-ass-buffer + (subed-ass--append-subtitle nil 60000 65000) + (expect (buffer-string) :to-equal "Dialogue: 0,0:01:00.00,0:01:05.00,Default,,0,0,0,,\n"))) + (it "creates a subtitle with start time, stop time and text." + (with-temp-ass-buffer + (subed-ass--append-subtitle nil 60000 65000 "Hello world") + (expect (buffer-string) :to-equal "Dialogue: 0,0:01:00.00,0:01:05.00,Default,,0,0,0,,Hello world\n")))))) + (describe "in a non-empty buffer" + (describe "before the current subtitle" + (describe "with point on the first subtitle" + (it "creates the subtitle before the current one." + (with-temp-ass-buffer + (insert mock-ass-data) + (subed-ass--jump-to-subtitle-time-stop) + (subed-ass--prepend-subtitle) + (expect (buffer-substring (line-beginning-position) (line-end-position)) + :to-equal (concat "Dialogue: 0,0:00:00.00,0:00:01.00,Default,,0,0,0,,"))))) + (describe "with point on a middle subtitle" + (it "creates the subtitle before the current one." + (with-temp-ass-buffer + (insert mock-ass-data) + (subed-ass--jump-to-subtitle-time-stop "0:00:14.00") + (subed-ass--prepend-subtitle) + (expect (buffer-substring (line-beginning-position) (line-end-position)) + :to-equal (concat "Dialogue: 0,0:00:00.00,0:00:01.00,Default,,0,0,0,,")) + (forward-line 1) + (beginning-of-line) + (expect (looking-at "Dialogue: 0,0:00:14.00"))))) + ) + (describe "after the current subtitle" + (describe "with point on a subtitle" + (it "creates the subtitle after the current one." + (with-temp-ass-buffer + (insert mock-ass-data) + (subed-ass--jump-to-subtitle-time-stop "0:00:14.00") + (subed-ass--append-subtitle) + (expect (buffer-substring (line-beginning-position) (line-end-position)) + :to-equal (concat "Dialogue: 0,0:00:00.00,0:00:01.00,Default,,0,0,0,,")) + (forward-line -1) + (expect (subed-ass--subtitle-msecs-start) :to-be 14000)))))) + (describe "Killing a subtitle" + (it "removes the first subtitle." + (with-temp-ass-buffer + (insert mock-ass-data) + (subed-ass--jump-to-subtitle-text "0:00:11.12") + (subed-ass--kill-subtitle) + (expect (subed-ass--subtitle-msecs-start) :to-be 14000) + (forward-line -1) + (beginning-of-line) + (expect (looking-at "Format: Layer"))))) + (it "removes it in between." + (with-temp-ass-buffer + (insert mock-ass-data) + (subed-ass--jump-to-subtitle-text "00:00:14.00") + (subed-ass--kill-subtitle) + (expect (subed-ass--subtitle-msecs-start) :to-be 17000))) + (it "removes the last subtitle." + (with-temp-ass-buffer + (insert mock-ass-data) + (subed-ass--jump-to-subtitle-text "00:00:17.00") + (subed-ass--kill-subtitle) + (expect (subed-ass--subtitle-msecs-start) :to-be 14000))) + (describe "Converting msecs to timestamp" + (it "uses the right format" + (with-temp-ass-buffer + (expect (subed-ass--msecs-to-timestamp 1410) :to-equal "0:00:01.41"))))) diff --git a/tests/test-subed-ass.el.license b/tests/test-subed-ass.el.license new file mode 100644 index 0000000..80098a5 --- /dev/null +++ b/tests/test-subed-ass.el.license @@ -0,0 +1,3 @@ +SPDX-FileCopyrightText: 2020 The subed Authors + +SPDX-License-Identifier: GPL-3.0-or-later diff --git a/tests/test-subed-vtt.el b/tests/test-subed-vtt.el index cfcbc93..7c7f962 100644 --- a/tests/test-subed-vtt.el +++ b/tests/test-subed-vtt.el @@ -35,7 +35,7 @@ Baz. (with-temp-vtt-buffer (insert mock-vtt-data) (subed-vtt--jump-to-subtitle-id "00:03:03.45") - (expect (save-excursion (subed-jump-to-subtitle-time-start) + (expect (save-excursion (subed-vtt--jump-to-subtitle-time-start) (thing-at-point 'line)) :to-equal "00:03:03.45 --> 00:03:15.5\n") (expect (subed-vtt--subtitle-msecs-start) :to-equal (+ (* 3 60 1000) (* 3 1000) 450)) (expect (subed-vtt--subtitle-msecs-stop) :to-equal (+ (* 3 60 1000) (* 15 1000) 500))))